/** * Forbids `throw new Error(...)` and related untyped builtins inside connector * source. Connectors must throw typed errors from `@unito/integration-sdk` * (e.g. `HttpErrors.UnprocessableEntityError`, `HttpErrors.NotFoundError`, * domain-specific subclasses). * * Bad: * throw new Error('Item not found'); * throw Error(`Unsupported item type "${type}"`); * throw new TypeError('Invalid input'); * * Good: * throw new HttpErrors.NotFoundError('Item not found'); * throw new HttpErrors.UnprocessableEntityError(`Unsupported item type "${type}"`); * * Why: * 1. Typed errors carry HTTP status codes — the SDK maps them to the correct * response. Raw `Error` becomes a generic 500. * 2. Typed errors carry structured fields (`category`, `code`, `subCategory`, * `additionalErrorData`) that the structured logger extracts via its * whitelist. Raw `Error` only logs the message. * 3. Downstream code can branch on `instanceof` — raw `Error` forces string * matching on the message, which is brittle. * * Allowlisted files (legitimate raw `Error` use): * - `**\/cache.ts` — internal hash-helper guards * - `**\/script(s)/**` — one-off CLI tooling, not request-path * - `**\/errors.ts` — defines the typed error classes themselves * - `**\/helpers/stringHelper.ts` — pure utility, not request-path * * Allowlisted message substrings: * - `/only supported in tests/i` — dev-mode-only guard * - `/assertNever/` — exhaustiveness check helper */ import type { Rule } from 'eslint'; import type * as ESTree from 'estree'; const RAW_ERROR_NAMES = new Set(['Error', 'TypeError', 'RangeError', 'SyntaxError', 'ReferenceError']); const ALLOWLISTED_FILE_PATTERNS = [/\/cache\.ts$/, /\/scripts?\//, /\/errors\.ts$/, /\/helpers\/stringHelper\.ts$/]; const ALLOWLISTED_MESSAGE_PATTERNS = [/only supported in tests/i, /assertNever/]; function isFileAllowlisted(filename: string): boolean { return ALLOWLISTED_FILE_PATTERNS.some(re => re.test(filename)); } function isMessageAllowlisted(arg: ESTree.Node | undefined, sourceCode: Rule.RuleContext['sourceCode']): boolean { if (!arg) { return false; } const text = sourceCode.getText(arg); return ALLOWLISTED_MESSAGE_PATTERNS.some(re => re.test(text)); } function getThrownConstructorName(arg: ESTree.Node | null | undefined): string | null { if (!arg) { return null; } const isConstructorLike = arg.type === 'NewExpression' || arg.type === 'CallExpression'; if (!isConstructorLike) { return null; } const callExpr = arg as ESTree.NewExpression | ESTree.CallExpression; if (!callExpr.callee || callExpr.callee.type !== 'Identifier') { return null; } return callExpr.callee.name; } const rule: Rule.RuleModule = { meta: { type: 'problem', docs: { description: 'Disallow `throw new Error(...)` and related raw builtins; require typed errors from `@unito/integration-sdk`', recommended: true, }, schema: [], messages: { rawThrow: 'Do not `throw new {{name}}(...)`. Throw a typed error from `@unito/integration-sdk` instead (e.g. `HttpErrors.UnprocessableEntityError`, `HttpErrors.NotFoundError`). Typed errors carry the right HTTP status code, expose structured metadata to the logger, and let callers branch on `instanceof`.', }, }, create(context) { const filename = context.filename ?? ''; if (isFileAllowlisted(filename)) { return {}; } const sourceCode = context.sourceCode; return { ThrowStatement(node) { const constructorName = getThrownConstructorName(node.argument); if (!constructorName || !RAW_ERROR_NAMES.has(constructorName)) { return; } const callExpr = node.argument as ESTree.NewExpression | ESTree.CallExpression; const firstArg = callExpr.arguments[0] as ESTree.Node | undefined; if (isMessageAllowlisted(firstArg, sourceCode)) { return; } context.report({ node, messageId: 'rawThrow', data: { name: constructorName }, }); }, }; }, }; export default rule;