From b0ae20cc5c732d35929fffc3463c326c5836f7a0 Mon Sep 17 00:00:00 2001 From: Lewis Marshall Date: Thu, 30 Jul 2026 14:23:10 +0100 Subject: [PATCH] Generate TypeScript error-code declarations from the registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JS SDKs each name error codes themselves, and the names have drifted from this registry: 15 of ably-chat-js's 20 disagree with the code's `identifier`, and ably-js uses bare numeric literals at ~149 call sites. Generating the names instead makes `identifier` the single spelling every SDK uses, and makes registering a code here the only step needed before referring to it. errors/scripts/generate-ts.js emits one of two shapes. `--format=type` gives a bare `ErrorCode` union of numeric literals, for a consumer that wants compile-time checking at no bundle-size cost — the type erases, and because registry codes are 5-6 digits while HTTP statuses are 3, a `code` and `statusCode` transposed at a call site fails to compile. `--format=const` gives one documented `export const` per code plus the union, for consumers that need the values at runtime; individual consts rather than an aggregate object or a TS `enum` so that unused codes tree-shake out of the browser bundles those SDKs ship. Nothing generated is committed here. Each consuming repository generates from the submodule it already vendors, commits the output into its own src/, and regenerates in CI at the pinned commit to fail on a diff. That mirrors how ably/docs publishes the error pages, and means an SDK cannot merge a reference to a code that is not merged here first. The generator refuses to emit rather than produce an unusable file, on a duplicate `identifier`, two identifiers colliding on one PascalCase name, or a name that is not a valid JavaScript identifier. None of the three occurs in the registry today. Those failures, a bad argument, and an unwritable `--out` all print as a plain message and exit 1; a stack trace there would only be noise, so one now indicates a bug in the generator rather than a problem with the input. Output is sorted by numeric code and byte-identical for a given registry state, as the drift check requires, and depends only on Node's standard library and the local frontmatter parser, so it runs from a superproject with no `npm install` inside the submodule. Co-Authored-By: Claude Opus 5 (1M context) --- .eslintrc.js | 10 ++ errors/README.md | 15 +- errors/scripts/generate-ts.js | 319 ++++++++++++++++++++++++++++++++++ package.json | 1 + test/generate-ts.test.js | 237 +++++++++++++++++++++++++ 5 files changed, 581 insertions(+), 1 deletion(-) create mode 100644 errors/scripts/generate-ts.js create mode 100644 test/generate-ts.test.js diff --git a/.eslintrc.js b/.eslintrc.js index fb5f3599..be0e49c4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -22,4 +22,14 @@ module.exports = { 'no-use-before-define': ['off'], 'max-classes-per-file': ['off'], }, + overrides: [ + { + // A test may assert through a local `expectSomething` helper rather than + // calling `expect` directly; count those as assertions. + files: ['test/**/*.js'], + rules: { + 'jest/expect-expect': ['warn', { assertFunctionNames: ['expect', 'expect*'] }], + }, + }, + ], }; diff --git a/errors/README.md b/errors/README.md index 4f53435b..e76d296e 100644 --- a/errors/README.md +++ b/errors/README.md @@ -24,6 +24,19 @@ You can add an optional Markdown body beneath the frontmatter for detail-page co See [`guidelines.md`](./guidelines.md) for the full rules on title, summary, body, tone, and terminology, and run `npm run validate:errors` to check your entry (CI runs both, and fails if `errors.json` is out of date). +## Generating SDK constants + +The registry also drives the error-code constants used by the JavaScript SDKs, so that every SDK refers to a code by the same name and adding a code is a single step: register it here, then use it wherever you're working. `identifier` is the canonical basis for each generated name — `room_is_in_an_invalid_state` becomes `RoomIsInAnInvalidState` — which is why it's a frozen contract rather than something to churn. + +```sh +npm run generate:errorcodes-ts -- --format=type --out path/to/errorcodes.ts # union of numeric literals +npm run generate:errorcodes-ts -- --format=const --out path/to/errorcodes.ts # one export const per code +``` + +Use `--format=type` where you only want compile-time checking (the type erases, so it costs no bundle size) and `--format=const` where you need the values at runtime. Omit `--out` to write to stdout. The output is deterministic and has no dependencies beyond Node's standard library, so a consuming repository can generate from its vendored submodule without running `npm install` inside it. + +Generated output is not committed here. Each consuming repository generates it, commits the result into its own `src/`, and has a CI step that regenerates at the pinned submodule commit and fails on a diff — the same arrangement as [publishing to the docs site](#publishing-to-the-docs-site) below. Because the check runs at the *pinned* commit, an SDK can't merge a reference to a code that hasn't been merged here first. + ## Publishing to the docs site Changes here don't reach [ably.com/docs](https://ably.com/docs/platform/errors/codes) automatically. The docs site vendors this registry as a git submodule and generates its public error pages from it, so once your change is merged to `main` a follow-up PR against [`ably/docs`](https://github.com/ably/docs) is needed to publish it: @@ -39,6 +52,6 @@ CI in `ably/docs` (`check-error-docs`) regenerates and diffs, so a PR whose comm - [`codes/`](./codes) — the registry: one `.md` per valid code. - [`guidelines.md`](./guidelines.md) — how to write entries: rules on title, summary, body, tone, and terminology. - [`CLAUDE.md`](./CLAUDE.md) — guidance for agents adding, editing, or reviewing entries. -- [`scripts/`](./scripts) — the validator run in CI. +- [`scripts/`](./scripts) — the validator run in CI, and the generators for `protocol/errors.json` and the SDK TypeScript constants. `protocol/errors.json` is generated from this registry — a machine-readable map of each code to its `identifier`, `title`, and `summary`. It must not be edited by hand; run `npm run generate:errors` to regenerate it, and CI fails if the committed file is out of date. diff --git a/errors/scripts/generate-ts.js b/errors/scripts/generate-ts.js new file mode 100644 index 00000000..a480f526 --- /dev/null +++ b/errors/scripts/generate-ts.js @@ -0,0 +1,319 @@ +#!/usr/bin/env node + +/* + * Generates TypeScript error-code declarations from the registry in + * `errors/codes/`. + * + * The generator lives here; each SDK runs it against its vendored copy of this + * repository, commits the output into its own `src/`, and its CI regenerates at + * the pinned submodule commit and fails on a diff. Two output shapes: + * + * --format=type a bare `ErrorCode` union of numeric literals, for consumers + * that want compile-time checking at zero runtime cost. + * --format=const one `export const` per code plus an `ErrorCode` union, for + * consumers that need the values at runtime. Individual + * consts rather than an object or a TS `enum` so that unused + * codes tree-shake out of browser bundles. + * + * Output is deterministic — sorted by numeric code, byte-identical for a given + * registry state — because the consumers' drift check diffs it. + * + * No dependencies beyond `fs`, `path`, and the local `frontmatter.js`, so this + * runs from a superproject without an `npm install` inside the submodule. + */ + +const fs = require('fs'); +const path = require('path'); +const { parseFrontmatter } = require('./frontmatter'); + +const CODES_DIR = path.resolve(__dirname, '..', 'codes'); + +const REQUIRED = ['code', 'identifier', 'title', 'summary']; +const FORMATS = ['type', 'const']; + +const HEADER = [ + '// GENERATED FROM ably-common/errors/codes — DO NOT EDIT.', + '// Regenerate with: npm run generate:errorcodes-ts', +]; + +const USAGE = 'Usage: node errors/scripts/generate-ts.js --format=type|const [--out ]'; + +/** Width available for JSDoc prose, after the leading ` * `. */ +const DOC_WIDTH = 76; + +/** + * A failure whose message is meant for whoever ran the generator: a bad + * argument, or a registry that can't be turned into valid TypeScript. + * + * `main` prints these as a plain message and exits 1. Anything else keeps its + * stack, because it's a bug in the generator rather than a problem with the + * input. + */ +class GeneratorError extends Error {} + +/** + * Convert a registry `identifier` to the PascalCase name used for its constant. + * + * @param {string} identifier - A `snake_case` registry identifier. + * @returns {string} The PascalCase equivalent. + */ +function pascalCase(identifier) { + return identifier + .split('_') + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); +} + +/** + * Read every `.md` in the registry, in ascending numeric code order. + * + * @param {string} [dir] - The directory to read; defaults to `errors/codes`. + * @returns {Array} One `{ code, identifier, title, summary }` per entry. + */ +function loadEntries(dir = CODES_DIR) { + if (!fs.existsSync(dir)) { + throw new GeneratorError(`no error registry at ${dir}: if this is a vendored copy of ably-common, the submodule may be uninitialised or pinned to a commit predating errors/codes/`); + } + return fs.readdirSync(dir) + .filter((f) => f.endsWith('.md')) + .map((f) => f.replace(/\.md$/, '')) + .sort((a, b) => Number(a) - Number(b)) + .map((code) => { + let content; + try { + content = fs.readFileSync(path.join(dir, `${code}.md`), 'utf8'); + } catch (err) { + throw new GeneratorError(`could not read ${code}.md: ${err.message}`); + } + const parsed = parseFrontmatter(content); + if (parsed.error) { + throw new GeneratorError(`${code}.md: ${parsed.error}`); + } + const { fields } = parsed; + const missing = REQUIRED.filter((k) => !fields[k]); + if (missing.length) { + throw new GeneratorError(`${code}.md: missing frontmatter field(s): ${missing.join(', ')}`); + } + return { + code: Number(fields.code), + identifier: fields.identifier, + title: fields.title, + summary: fields.summary, + }; + }); +} + +/** + * Attach the generated constant name to each entry. + * + * Fails rather than emitting a duplicate or unusable declaration on: a + * duplicate `identifier`, two identifiers colliding on one PascalCase name, or + * a name that isn't a valid JavaScript identifier. None of the three occurs in + * the registry today; the assertions are here to keep it that way. + * + * @param {Array} entries - Entries from `loadEntries`. + * @returns {Array} The same entries, each with a `name` property. + */ +function nameEntries(entries) { + const byIdentifier = new Map(); + const byName = new Map(); + + return entries.map((entry) => { + const { code, identifier } = entry; + + if (byIdentifier.has(identifier)) { + throw new GeneratorError(`duplicate identifier "${identifier}": used by both ${byIdentifier.get(identifier)} and ${code}`); + } + byIdentifier.set(identifier, code); + + const name = pascalCase(identifier); + if (!/^[A-Z][A-Za-z0-9]*$/.test(name)) { + throw new GeneratorError(`identifier "${identifier}" (${code}) generates "${name}", which is not a valid JavaScript identifier`); + } + if (byName.has(name)) { + const other = byName.get(name); + throw new GeneratorError(`identifier "${identifier}" (${code}) collides with "${other.identifier}" (${other.code}): both generate "${name}"`); + } + byName.set(name, entry); + + return { ...entry, name }; + }); +} + +/** + * Hard-wrap prose to the JSDoc content width. + * + * @param {string} text - The text to wrap. + * @returns {Array} One string per output line. + */ +function wrap(text) { + const lines = []; + let line = ''; + text.split(/\s+/).filter(Boolean).forEach((word) => { + if (line === '') { + line = word; + } else if (`${line} ${word}`.length <= DOC_WIDTH) { + line += ` ${word}`; + } else { + lines.push(line); + line = word; + } + }); + if (line !== '') lines.push(line); + return lines; +} + +/** + * Neutralise anything in registry prose that would close a JSDoc comment. + * + * @param {string} text - The text to escape. + * @returns {string} The text, safe to embed in a block comment. + */ +function escapeDoc(text) { + return text.replace(/\*\//g, '*\\/'); +} + +/** + * Render the JSDoc block documenting one code. + * + * @param {object} entry - A named entry. + * @returns {string} The comment block, without a trailing newline. + */ +function docBlock(entry) { + const title = escapeDoc(entry.title).replace(/\.$/, ''); + return [ + '/**', + ` * ${title}.`, + ' *', + ...wrap(escapeDoc(entry.summary)).map((l) => ` * ${l}`), + ` * @see https://help.ably.io/error/${entry.code}`, + ' */', + ].join('\n'); +} + +/** + * Render the `--format=type` output: the union of numeric literals alone. + * + * @param {Array} entries - Named entries in output order. + * @returns {string} The file contents. + */ +function renderType(entries) { + return [ + ...HEADER, + '', + '/** A registered Ably error code. */', + 'export type ErrorCode =', + ...entries.map((e, i) => ` | ${e.code}${i === entries.length - 1 ? ';' : ''}`), + '', + ].join('\n'); +} + +/** + * Render the `--format=const` output: one const per code, then the union. + * + * @param {Array} entries - Named entries in output order. + * @returns {string} The file contents. + */ +function renderConst(entries) { + return [ + ...HEADER, + '', + ...entries.flatMap((e) => [docBlock(e), `export const ${e.name} = ${e.code};`, '']), + '/** A registered Ably error code. */', + 'export type ErrorCode =', + ...entries.map((e, i) => ` | typeof ${e.name}${i === entries.length - 1 ? ';' : ''}`), + '', + ].join('\n'); +} + +/** + * Generate the TypeScript source for a set of registry entries. + * + * @param {string} format - Either `type` or `const`. + * @param {Array} [entries] - Entries to render; defaults to the registry. + * @returns {string} The file contents. + */ +function generate(format, entries = loadEntries()) { + if (!FORMATS.includes(format)) { + throw new GeneratorError(`unknown --format "${format}" (expected ${FORMATS.join(' or ')})`); + } + const named = nameEntries(entries); + if (named.length === 0) { + throw new GeneratorError('the registry is empty: nothing to generate'); + } + return format === 'type' ? renderType(named) : renderConst(named); +} + +/** + * Parse `--format` and `--out` from the command line. + * + * @param {Array} argv - Arguments after the script name. + * @returns {{ format: string, out: string | null }} The parsed options; `out` + * is null when the output goes to stdout. + */ +function parseArgs(argv) { + let format = null; + let out = null; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + const flag = arg.replace(/=.*$/, ''); + let value = arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : null; + if (value === null && (flag === '--format' || flag === '--out')) { + i += 1; + value = i < argv.length ? argv[i] : null; + if (value === null) throw new GeneratorError(`${flag} requires a value\n${USAGE}`); + } + if (flag === '--format') { + format = value; + } else if (flag === '--out') { + out = value; + } else { + throw new GeneratorError(`unexpected argument "${arg}"\n${USAGE}`); + } + } + + if (!format) throw new GeneratorError(`--format is required\n${USAGE}`); + return { format, out: out === '-' ? null : out }; +} + +/** + * Run as a CLI: generate and write to `--out`, or to stdout if it is omitted. + * + * @returns {void} + */ +function main() { + try { + const opts = parseArgs(process.argv.slice(2)); + const source = generate(opts.format); + if (opts.out === null) { + process.stdout.write(source); + return; + } + const target = path.resolve(process.cwd(), opts.out); + try { + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, source); + } catch (err) { + throw new GeneratorError(`could not write ${target}: ${err.message}`); + } + const shown = path.relative(process.cwd(), target); + console.error(`Wrote ${shown.startsWith('..') ? target : shown}`); + } catch (err) { + // A bad argument or an unusable registry is the caller's problem, so report + // it as a message. A stack here would only ever be noise. Anything else is + // a bug in the generator, and rethrowing keeps the stack that locates it. + if (!(err instanceof GeneratorError)) throw err; + console.error(err.message); + process.exit(1); + } +} + +if (require.main === module) { + main(); +} + +module.exports = { + pascalCase, loadEntries, nameEntries, generate, +}; diff --git a/package.json b/package.json index ee5ea37c..e479f8d3 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "validate:errors": "node errors/scripts/validate-errors.js", "validate:errors-json": "node scripts/validate-json-schema.js protocol/errors.json", "generate:errors": "node errors/scripts/generate-errors-json.js && prettier --write protocol/errors.json", + "generate:errorcodes-ts": "node errors/scripts/generate-ts.js", "validate:json-schema": "node scripts/validate-json-schema.js", "fetch:agent-releases": "node scripts/fetch-agent-releases.js", "export:agents": "node scripts/export-agents-csv.js", diff --git a/test/generate-ts.test.js b/test/generate-ts.test.js new file mode 100644 index 00000000..2946fda1 --- /dev/null +++ b/test/generate-ts.test.js @@ -0,0 +1,237 @@ +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { + pascalCase, loadEntries, nameEntries, generate, +} = require('../errors/scripts/generate-ts'); + +const SCRIPT = path.resolve(__dirname, '..', 'errors', 'scripts', 'generate-ts.js'); + +/** + * Run the generator as a CLI, the way a consuming repository does. + * + * @param {...string} argv - Arguments to pass. + * @returns {{ status: number, stdout: string, stderr: string }} The result. + */ +const run = (...argv) => spawnSync(process.execPath, [SCRIPT, ...argv], { encoding: 'utf8' }); + +/** + * Build a registry entry, overriding any field. + * + * @param {object} [overrides] - Fields to override on the default entry. + * @returns {object} An entry as `loadEntries` would return it. + */ +const entry = (overrides = {}) => ({ + code: 40000, + identifier: 'bad_request', + title: 'Bad request', + summary: 'The request was rejected because it was invalid and could not be processed.', + ...overrides, +}); + +/** + * Write a throwaway `codes/` directory containing the given entries. + * + * @param {Array} entries - Entries to write, one file each. + * @returns {string} The directory path. + */ +function writeRegistry(entries) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ably-codes-')); + entries.forEach((e) => { + const frontmatter = ['code', 'identifier', 'title', 'summary'] + .filter((k) => e[k] !== undefined) + .map((k) => `${k}: ${e[k]}`) + .join('\n'); + fs.writeFileSync(path.join(dir, `${e.code}.md`), `---\n${frontmatter}\n---\n`); + }); + return dir; +} + +describe('pascalCase', () => { + it('converts snake_case identifiers', () => { + expect(pascalCase('bad_request')).toBe('BadRequest'); + expect(pascalCase('room_is_in_an_invalid_state')).toBe('RoomIsInAnInvalidState'); + expect(pascalCase('unable_to_automatically_re_enter_presence')) + .toBe('UnableToAutomaticallyReEnterPresence'); + }); + + it('keeps single-word and digit-bearing identifiers intact', () => { + expect(pascalCase('disconnected')).toBe('Disconnected'); + expect(pascalCase('http2_error')).toBe('Http2Error'); + }); +}); + +describe('loadEntries', () => { + it('orders entries by numeric code, not lexically', () => { + const dir = writeRegistry([ + entry({ code: 102202, identifier: 'c' }), + entry({ code: 9999, identifier: 'a' }), + entry({ code: 40000, identifier: 'b' }), + ]); + expect(loadEntries(dir).map((e) => e.code)).toEqual([9999, 40000, 102202]); + }); + + it('rejects an entry missing a required field', () => { + const dir = writeRegistry([entry({ summary: undefined })]); + expect(() => loadEntries(dir)).toThrow(/40000\.md: missing frontmatter field\(s\): summary/); + }); + + it('names the file it could not read', () => { + const dir = writeRegistry([]); + fs.mkdirSync(path.join(dir, '40000.md')); + expect(() => loadEntries(dir)).toThrow(/could not read 40000\.md/); + }); + + it('explains an absent registry rather than surfacing a bare ENOENT', () => { + const dir = path.join(os.tmpdir(), 'ably-codes-does-not-exist'); + expect(() => loadEntries(dir)).toThrow(/no error registry at .*submodule may be uninitialised/s); + }); +}); + +describe('nameEntries', () => { + it('attaches the generated name to each entry', () => { + expect(nameEntries([entry()])[0].name).toBe('BadRequest'); + }); + + it('fails on a duplicate identifier', () => { + const entries = [entry({ code: 40000 }), entry({ code: 40001 })]; + expect(() => nameEntries(entries)).toThrow(/duplicate identifier "bad_request"/); + }); + + it('fails on a PascalCase collision between distinct identifiers', () => { + const entries = [ + entry({ code: 40000, identifier: 'bad_request' }), + entry({ code: 40001, identifier: 'bad__request' }), + ]; + expect(() => nameEntries(entries)).toThrow(/collides with "bad_request".*both generate "BadRequest"/); + }); + + it('fails on a name that is not a valid JavaScript identifier', () => { + const entries = [entry({ identifier: 'bad-request' })]; + expect(() => nameEntries(entries)).toThrow(/not a valid JavaScript identifier/); + }); +}); + +describe('generate', () => { + it('rejects an unknown format', () => { + expect(() => generate('enum', [entry()])).toThrow(/unknown --format "enum"/); + }); + + it('rejects an empty registry', () => { + expect(() => generate('type', [])).toThrow(/registry is empty/); + }); + + it('emits a union of numeric literals for --format=type', () => { + const out = generate('type', [entry({ code: 40000 }), entry({ code: 40001, identifier: 'x' })]); + expect(out).toBe([ + '// GENERATED FROM ably-common/errors/codes — DO NOT EDIT.', + '// Regenerate with: npm run generate:errorcodes-ts', + '', + '/** A registered Ably error code. */', + 'export type ErrorCode =', + ' | 40000', + ' | 40001;', + '', + ].join('\n')); + }); + + it('emits one documented const per code for --format=const', () => { + expect(generate('const', [entry()])).toBe([ + '// GENERATED FROM ably-common/errors/codes — DO NOT EDIT.', + '// Regenerate with: npm run generate:errorcodes-ts', + '', + '/**', + ' * Bad request.', + ' *', + ' * The request was rejected because it was invalid and could not be processed.', + ' * @see https://help.ably.io/error/40000', + ' */', + 'export const BadRequest = 40000;', + '', + '/** A registered Ably error code. */', + 'export type ErrorCode =', + ' | typeof BadRequest;', + '', + ].join('\n')); + }); + + it('emits individual consts rather than an object or a TS enum, so codes tree-shake', () => { + const out = generate('const', [entry()]); + expect(out).not.toMatch(/\benum\b/); + expect(out).not.toMatch(/as const/); + }); + + it('wraps long summaries and escapes anything that would close the comment', () => { + const out = generate('const', [entry({ + summary: `An overlong summary ${'padding '.repeat(12)}ends here with a */ sequence.`, + })]); + out.split('\n').forEach((line) => expect(line.length).toBeLessThanOrEqual(80)); + // The only `*/` in the doc block is its terminator: the one in the summary + // was escaped, so the comment can't be closed early. + const doc = out.slice(out.indexOf('/**'), out.indexOf('export const')); + expect(doc).toContain('*\\/'); + expect(doc.match(/\*\//g)).toHaveLength(1); + }); +}); + +describe('the CLI', () => { + /** + * Assert a run failed the way a CLI should: a message, no stack, exit 1. + * + * @param {object} result - A result from `run`. + * @param {RegExp} expected - A pattern the message must match. + * @returns {void} + */ + const expectCleanFailure = (result, expected) => { + expect(result.stderr).toMatch(expected); + expect(result.stderr).not.toMatch(/^\s+at /m); + expect(result.stderr).not.toContain('GeneratorError'); + expect(result.stdout).toBe(''); + expect(result.status).toBe(1); + }; + + it('writes to stdout when --out is omitted', () => { + const result = run('--format=type'); + expect(result.status).toBe(0); + expect(result.stdout).toMatch(/^export type ErrorCode =$/m); + }); + + it('reports an unusable registry as a message, not a stack trace', () => { + // The format is validated during generation rather than argument parsing, + // so this exercises the same path a duplicate identifier would. + expectCleanFailure(run('--format=enum'), /unknown --format "enum"/); + }); + + it('reports a bad argument as a message, not a stack trace', () => { + expectCleanFailure(run('--format=type', '--bogus'), /unexpected argument "--bogus"/); + expectCleanFailure(run('--out=x.ts'), /--format is required/); + expectCleanFailure(run('--format'), /--format requires a value/); + }); + + it('reports an unwritable --out as a message, not a stack trace', () => { + const notADirectory = path.join(SCRIPT, 'nested', 'errorcodes.ts'); + expectCleanFailure(run('--format=type', '--out', notADirectory), /could not write .*errorcodes\.ts/); + }); +}); + +describe('the committed registry', () => { + it('generates both formats without tripping an assertion', () => { + expect(() => generate('type')).not.toThrow(); + expect(() => generate('const')).not.toThrow(); + }); + + it('is byte-identical across runs', () => { + expect(generate('type')).toBe(generate('type')); + expect(generate('const')).toBe(generate('const')); + }); + + it('emits one declaration and one union member per registry file', () => { + const count = fs.readdirSync(path.resolve(__dirname, '..', 'errors', 'codes')) + .filter((f) => f.endsWith('.md')).length; + const out = generate('const'); + expect(out.match(/^export const /gm)).toHaveLength(count); + expect(out.match(/^ {2}\| typeof /gm)).toHaveLength(count); + expect(generate('type').match(/^ {2}\| \d+/gm)).toHaveLength(count); + }); +});