diff --git a/eslint-factory/src/rules/no-caught-error-interpolation.test.ts b/eslint-factory/src/rules/no-caught-error-interpolation.test.ts index 71b3e538089..5fdbd7b6676 100644 --- a/eslint-factory/src/rules/no-caught-error-interpolation.test.ts +++ b/eslint-factory/src/rules/no-caught-error-interpolation.test.ts @@ -329,4 +329,110 @@ describe("no-caught-error-interpolation", () => { ], }); }); + + it("valid: non-'error' EventEmitter event name is not flagged", () => { + cjsRuleTester.run("no-caught-error-interpolation", noCaughtErrorInterpolationRule, { + valid: [`emitter.on('data', chunk => { log(\`got: \${chunk}\`); });`, `emitter.once('close', code => { log(\`exited: \${code}\`); });`, `emitter.addListener('message', msg => { log(\`msg: \${msg}\`); });`], + invalid: [], + }); + }); + + it("valid: named/hoisted listener function passed to .on('error', ...) is not flagged (inline-only restriction)", () => { + cjsRuleTester.run("no-caught-error-interpolation", noCaughtErrorInterpolationRule, { + valid: [ + // Named function reference — not an inline callback, so out of scope per the inline restriction + `function onError(err) { log(\`\${err}\`); } emitter.on('error', onError);`, + ], + invalid: [], + }); + }); + + it("invalid: bare .on('error', ...) listener variable is flagged", () => { + cjsRuleTester.run("no-caught-error-interpolation", noCaughtErrorInterpolationRule, { + valid: [], + invalid: [ + { + code: `emitter.on('error', err => { log(\`event error: \${err}\`); });`, + errors: [ + { + messageId: "bareErrorInterpolation", + data: { errorVar: "err" }, + suggestions: [ + { + messageId: "useStringFallback", + data: { errorVar: "err" }, + output: `emitter.on('error', err => { log(\`event error: \${String(err)}\`); });`, + }, + ], + }, + ], + }, + { + // Mirrors the grounded live occurrence from mcp_server_core.cjs:1052 + code: `process.stdin.on("error", err => server.debug(\`stdin error: \${err}\`));`, + errors: [ + { + messageId: "bareErrorInterpolation", + data: { errorVar: "err" }, + suggestions: [ + { + messageId: "useStringFallback", + data: { errorVar: "err" }, + output: `process.stdin.on("error", err => server.debug(\`stdin error: \${String(err)}\`));`, + }, + ], + }, + ], + }, + ], + }); + }); + + it("invalid: bare .once('error', ...) listener variable is flagged", () => { + cjsRuleTester.run("no-caught-error-interpolation", noCaughtErrorInterpolationRule, { + valid: [], + invalid: [ + { + code: `emitter.once('error', err => { log(\`once error: \${err}\`); });`, + errors: [ + { + messageId: "bareErrorInterpolation", + data: { errorVar: "err" }, + suggestions: [ + { + messageId: "useStringFallback", + data: { errorVar: "err" }, + output: `emitter.once('error', err => { log(\`once error: \${String(err)}\`); });`, + }, + ], + }, + ], + }, + ], + }); + }); + + it("invalid: bare .addListener('error', ...) listener variable is flagged", () => { + cjsRuleTester.run("no-caught-error-interpolation", noCaughtErrorInterpolationRule, { + valid: [], + invalid: [ + { + code: `emitter.addListener('error', err => { log(\`add error: \${err}\`); });`, + errors: [ + { + messageId: "bareErrorInterpolation", + data: { errorVar: "err" }, + suggestions: [ + { + messageId: "useStringFallback", + data: { errorVar: "err" }, + output: `emitter.addListener('error', err => { log(\`add error: \${String(err)}\`); });`, + }, + ], + }, + ], + }, + ], + }); + }); }); diff --git a/eslint-factory/src/rules/no-caught-error-interpolation.ts b/eslint-factory/src/rules/no-caught-error-interpolation.ts index 9d7c6c3a6b7..ca053685229 100644 --- a/eslint-factory/src/rules/no-caught-error-interpolation.ts +++ b/eslint-factory/src/rules/no-caught-error-interpolation.ts @@ -18,6 +18,24 @@ function isInlineRejectionHandler(node: TSESTree.ArrowFunctionExpression | TSEST return false; } +/** + * Returns true when the function node is an inline listener passed as the + * second argument to an EventEmitter-style `.on('error', fn)`, + * `.once('error', fn)`, or `.addListener('error', fn)` call. + */ +function isInlineEventErrorHandler(node: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression): boolean { + const parent = node.parent; + if (!parent || parent.type !== AST_NODE_TYPES.CallExpression) return false; + const callee = parent.callee; + if (callee.type !== AST_NODE_TYPES.MemberExpression || callee.computed) return false; + const prop = callee.property; + if (prop.type !== AST_NODE_TYPES.Identifier) return false; + if (prop.name !== "on" && prop.name !== "once" && prop.name !== "addListener") return false; + if (parent.arguments[1] !== node) return false; + const eventArg = parent.arguments[0]; + return eventArg?.type === AST_NODE_TYPES.Literal && eventArg.value === "error"; +} + /** * Returns true when `node` is a bare `Identifier` expression — no member * access, no call, no unary/binary operation, no nullish coercion. Used to @@ -44,13 +62,20 @@ function isCaughtErrorVariableDef(def: TSESLint.Scope.Definition): boolean { } // Inline rejection handler parameter (.catch(err => ...) / .then(_, err => ...)) + // or inline EventEmitter 'error' event listener (.on('error', err => ...) etc.) // def.node is the function node for Parameter definitions if (def.type === "Parameter") { const fn = def.node as TSESTree.Node; if (fn.type !== AST_NODE_TYPES.ArrowFunctionExpression && fn.type !== AST_NODE_TYPES.FunctionExpression) { return false; } - return isInlineRejectionHandler(fn as TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression); + const fnNode = fn as TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression; + if (isInlineRejectionHandler(fnNode)) return true; + if (isInlineEventErrorHandler(fnNode)) { + // Only the first parameter receives the error object from an 'error' event + return fnNode.params[0] === def.name; + } + return false; } return false; @@ -67,7 +92,8 @@ export const noCaughtErrorInterpolationRule = createRule({ "For Error objects this produces the redundant 'Error: message' prefix; for non-Error throws (plain objects, strings, etc.) " + "it silently produces '[object Object]' or another useless string. " + "Use getErrorMessage(err) for consistent, safe formatting, or String(err) when getErrorMessage is unavailable. " + - "Detected scopes: try/catch bindings, .catch(fn) inline callbacks, and .then(onFulfilled, onRejected) inline rejection handlers.", + "Detected scopes: try/catch bindings, .catch(fn) inline callbacks, .then(onFulfilled, onRejected) inline rejection handlers, " + + "and inline EventEmitter 'error' event listeners (.on('error', fn) / .once('error', fn) / .addListener('error', fn)).", }, schema: [], messages: {