@unito/integration-sdk
Version:
Integration SDK
115 lines (104 loc) • 4.24 kB
text/typescript
/**
* 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<string>(['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;