/** * Flags catch blocks whose only action is to return a falsy/empty value * with no logging. Hides bugs and produces silent data loss. * * Example violation: * try { ... } catch (e) { return null; } * * Acceptable: * try { ... } catch (e) { logger.warn('msg', { error: e }); return null; } * try { ... } catch (e) { throw new HttpErrors.X('...'); } */ import type { Rule } from 'eslint'; import type * as ESTree from 'estree'; const FALSY_LITERAL_VALUES = new Set([null, '']); const LOGGER_METHOD_REGEX = /^(log|warn|error|info|debug)$/i; const LOGGER_NAME_REGEX = /\b(logger|console|log)\b/i; const RETURNED_VALUE_LABELS: Record = { ObjectExpression: '{}', ArrayExpression: '[]', }; function isFalsyLiteral(node: ESTree.Expression | null | undefined): boolean { if (!node) { return true; } if (node.type === 'Literal' && FALSY_LITERAL_VALUES.has(node.value)) { return true; } if (node.type === 'Identifier' && node.name === 'undefined') { return true; } if (node.type === 'ObjectExpression' && node.properties.length === 0) { return true; } if (node.type === 'ArrayExpression' && node.elements.length === 0) { return true; } return false; } function walkMemberChain(node: ESTree.Expression): { root: string | null; props: string[] } { const props: string[] = []; let current: ESTree.Expression | ESTree.Super = node; while (current.type === 'MemberExpression') { if (current.property.type === 'Identifier') { props.push(current.property.name); } current = current.object; } const root = current.type === 'Identifier' ? current.name : null; return { root, props }; } function isLoggerCallExpression(callExpr: ESTree.CallExpression): boolean { const callee = callExpr.callee; if (callee.type === 'Identifier') { return LOGGER_METHOD_REGEX.test(callee.name); } if (callee.type !== 'MemberExpression') { return false; } const { root, props } = walkMemberChain(callee as ESTree.Expression); return [root, ...props].some(p => p !== null && LOGGER_NAME_REGEX.test(p)); } function isLoggerCall(node: ESTree.Statement): boolean { if (node.type !== 'ExpressionStatement') { return false; } const expr = node.expression; if (expr.type === 'AwaitExpression' && expr.argument && expr.argument.type === 'CallExpression') { return isLoggerCallExpression(expr.argument); } if (expr.type !== 'CallExpression') { return false; } return isLoggerCallExpression(expr); } function describeReturnedValue(node: ESTree.Expression | null | undefined): string { if (!node) { return 'undefined'; } const label = RETURNED_VALUE_LABELS[node.type]; if (label) { return label; } if (node.type === 'Literal') { return String(node.value); } if (node.type === 'Identifier') { return node.name; } return 'value'; } const rule: Rule.RuleModule = { meta: { type: 'problem', docs: { description: 'Disallow silent catch blocks that return falsy/empty values without logging. ' + 'Detected: null, undefined, bare return, empty string, empty array, empty object. ' + 'Boolean false and numeric 0 are NOT flagged — those are usually intentional semantic results.', recommended: true, }, schema: [], messages: { silentReturn: 'Catch block silently returns {{value}} without logging. Add a logger call (e.g. `logger.warn(...)`) or rethrow.', }, }, create(context) { return { CatchClause(node: ESTree.CatchClause) { const body = node.body.body; if (body.length === 0) { return; } const lastStatement = body[body.length - 1]; if (!lastStatement || lastStatement.type !== 'ReturnStatement') { return; } if (!isFalsyLiteral(lastStatement.argument)) { return; } const earlierStatements = body.slice(0, -1); if (earlierStatements.some(isLoggerCall)) { return; } context.report({ node: lastStatement, messageId: 'silentReturn', data: { value: describeReturnedValue(lastStatement.argument) }, }); }, } as Rule.RuleListener; }, }; export default rule;