From 03bfcb835a62366ff9e2bcac1fc638cc9f996ee4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Aug 2026 11:41:42 +0000 Subject: [PATCH 1/2] Initial plan From 4fe34345d84f4ec9d07a01c6b3c27bfa03ae0b7a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Aug 2026 11:55:19 +0000 Subject: [PATCH 2/2] fix(eslint-factory): extend no-caught-error-interpolation to cover EventEmitter .on('error') handlers - Add isInlineEventErrorHandler() helper that matches .on/'once'/.addListener with 'error' event - Update isCaughtErrorVariableDef() to recognize first param of inline EventEmitter error listeners - Update rule description to mention EventEmitter scope - Add 5 new tests: valid (non-error events, named listener), invalid (.on/.once/.addListener + mcp_server_core.cjs repro) Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../no-caught-error-interpolation.test.ts | 106 ++++++++++++++++++ .../rules/no-caught-error-interpolation.ts | 30 ++++- 2 files changed, 134 insertions(+), 2 deletions(-) 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: {