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); + }); +});