-
Notifications
You must be signed in to change notification settings - Fork 475
[eslint-miner] feat(eslint): add prefer-get-error-message-over-string rule #48575
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e94348f
e931839
7c91563
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| import { RuleTester } from "eslint"; | ||
| import { describe, it } from "vitest"; | ||
| import { preferGetErrorMessageOverStringRule } from "./prefer-get-error-message-over-string"; | ||
|
|
||
| const cjsRuleTester = new RuleTester({ | ||
| languageOptions: { | ||
| ecmaVersion: 2022, | ||
| sourceType: "commonjs", | ||
| }, | ||
| }); | ||
|
|
||
| describe("prefer-get-error-message-over-string", () => { | ||
| it("valid: String(err) not flagged when getErrorMessage is unavailable", () => { | ||
| cjsRuleTester.run("prefer-get-error-message-over-string", preferGetErrorMessageOverStringRule, { | ||
| valid: [`try { f(); } catch (err) { console.log(\`failed: \${String(err)}\`); }`, `p.catch(err => \`error: \${String(err)}\`);`], | ||
| invalid: [], | ||
| }); | ||
| }); | ||
|
|
||
| it("valid: non-caught variable String() interpolation is not flagged", () => { | ||
| cjsRuleTester.run("prefer-get-error-message-over-string", preferGetErrorMessageOverStringRule, { | ||
| valid: [`const { getErrorMessage } = require("./error_helpers.cjs"); const name = "world"; const s = \`Hello \${String(name)}\`;`], | ||
| invalid: [], | ||
| }); | ||
| }); | ||
|
|
||
| it("valid: getErrorMessage(err) usage is not flagged", () => { | ||
| cjsRuleTester.run("prefer-get-error-message-over-string", preferGetErrorMessageOverStringRule, { | ||
| valid: [`const { getErrorMessage } = require("./error_helpers.cjs"); try { f(); } catch (err) { console.log(\`failed: \${getErrorMessage(err)}\`); }`], | ||
| invalid: [], | ||
| }); | ||
| }); | ||
|
|
||
| it("invalid: String(err) flagged in catch block when getErrorMessage is imported", () => { | ||
| cjsRuleTester.run("prefer-get-error-message-over-string", preferGetErrorMessageOverStringRule, { | ||
| valid: [], | ||
| invalid: [ | ||
| { | ||
| code: `const { getErrorMessage } = require("./error_helpers.cjs"); try { f(); } catch (err) { throw new Error(\`Failed: \${String(err)}\`, { cause: err }); }`, | ||
| errors: [ | ||
| { | ||
| messageId: "preferGetErrorMessage", | ||
| suggestions: [ | ||
| { | ||
| messageId: "replaceWithGetErrorMessage", | ||
| output: `const { getErrorMessage } = require("./error_helpers.cjs"); try { f(); } catch (err) { throw new Error(\`Failed: \${getErrorMessage(err)}\`, { cause: err }); }`, | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }); | ||
| }); | ||
|
|
||
| it("invalid: String(err) flagged in .catch(fn) rejection handler when getErrorMessage is imported", () => { | ||
| cjsRuleTester.run("prefer-get-error-message-over-string", preferGetErrorMessageOverStringRule, { | ||
| valid: [], | ||
| invalid: [ | ||
| { | ||
| code: `const { getErrorMessage } = require("./error_helpers.cjs"); p.catch(err => log(\`failed: \${String(err)}\`));`, | ||
| errors: [ | ||
| { | ||
| messageId: "preferGetErrorMessage", | ||
| suggestions: [ | ||
| { | ||
| messageId: "replaceWithGetErrorMessage", | ||
| output: `const { getErrorMessage } = require("./error_helpers.cjs"); p.catch(err => log(\`failed: \${getErrorMessage(err)}\`));`, | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }); | ||
| }); | ||
|
|
||
| it("valid: String(err) in a tagged template is not flagged", () => { | ||
| cjsRuleTester.run("prefer-get-error-message-over-string", preferGetErrorMessageOverStringRule, { | ||
| valid: [`const { getErrorMessage } = require("./error_helpers.cjs"); try { f(); } catch (err) { tag\`failed: \${String(err)}\`; }`], | ||
| invalid: [], | ||
| }); | ||
| }); | ||
|
|
||
| it("valid: String(metadata) for a non-first rejection handler parameter is not flagged", () => { | ||
| cjsRuleTester.run("prefer-get-error-message-over-string", preferGetErrorMessageOverStringRule, { | ||
| valid: [`const { getErrorMessage } = require("./error_helpers.cjs"); p.catch((err, metadata) => log(\`info: \${String(metadata)}\`));`], | ||
| invalid: [], | ||
| }); | ||
| }); | ||
| }); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test suite has zero coverage for the 💡 Untested branch in scope-resolution logic
Suggested addition covering the
Comment on lines
+1
to
+91
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No test covers a shadowed error variable — a false positive here could produce a broken autofix on a non-error value. |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| import { AST_NODE_TYPES, ESLintUtils, TSESLint, TSESTree } from "@typescript-eslint/utils"; | ||
|
|
||
| const createRule = ESLintUtils.RuleCreator(name => `https://github.com/github/gh-aw/tree/main/eslint-factory#${name}`); | ||
|
|
||
| /** | ||
| * Returns true when the function node is an inline rejection handler passed to | ||
| * a promise method (.catch(fn) or .then(onFulfilled, onRejected)). | ||
| */ | ||
| function isInlineRejectionHandler(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 === "catch" && parent.arguments[0] === node) return true; | ||
| if (prop.name === "then" && parent.arguments[1] === node) return true; | ||
| return false; | ||
| } | ||
|
|
||
| /** | ||
| * Returns true when the given identifier is the first formal parameter of fn, | ||
| * or is nested within it (e.g. the bound name of an assignment pattern or rest | ||
| * element at the first parameter position). | ||
| * Promise rejection callbacks forward the rejection reason only as their first | ||
| * argument; additional parameters are unrelated values and must not be flagged. | ||
| */ | ||
| function isFirstParameterOf(fn: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression, paramNameNode: TSESTree.Node): boolean { | ||
| if (fn.params.length === 0) return false; | ||
| const firstParam = fn.params[0]; | ||
| // Fast path: simple identifier param — the nodes are the same object. | ||
| if (firstParam === paramNameNode) return true; | ||
| // For assignment patterns (err = default) or rest params (...err) the | ||
| // identifier is nested inside the first param node; use range containment. | ||
| if (!paramNameNode.range || !firstParam.range) return false; | ||
| return paramNameNode.range[0] >= firstParam.range[0] && paramNameNode.range[1] <= firstParam.range[1]; | ||
| } | ||
|
|
||
| /** | ||
| * Returns true when the variable definition represents a caught error binding: | ||
| * - A try/catch clause with a simple identifier param (not destructured). | ||
| * - The *first* parameter of an inline promise rejection handler (.catch(fn) / | ||
| * .then(_, fn)). Only the first argument receives the rejection reason; | ||
| * additional parameters (e.g. metadata) are not error values. | ||
| */ | ||
| function isCaughtErrorVariableDef(def: TSESLint.Scope.Definition): boolean { | ||
| if (def.type === "CatchClause") { | ||
| const catchNode = def.node as TSESTree.CatchClause; | ||
| return catchNode.param?.type === AST_NODE_TYPES.Identifier; | ||
| } | ||
|
|
||
| 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; | ||
| } | ||
| const fnNode = fn as TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression; | ||
| if (!isInlineRejectionHandler(fnNode)) return false; | ||
| return isFirstParameterOf(fnNode, def.name); | ||
| } | ||
|
|
||
| return false; | ||
| } | ||
|
|
||
| /** | ||
| * Returns the argument name when `node` is a `String(<identifier>)` call | ||
| * expression, or null otherwise. | ||
| */ | ||
| function getStringCallArgName(node: TSESTree.CallExpressionArgument): string | null { | ||
| if (node.type !== AST_NODE_TYPES.CallExpression) return null; | ||
| if (node.callee.type !== AST_NODE_TYPES.Identifier || node.callee.name !== "String") return null; | ||
| if (node.arguments.length !== 1) return null; | ||
| const arg = node.arguments[0]; | ||
| return arg.type === AST_NODE_TYPES.Identifier ? arg.name : null; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/codebase-design] 💡 Suggested fixChange the parameter type to function getStringCallArgName(node: TSESTree.Expression | TSESTree.SpreadElement): string | null {@copilot please address this. |
||
| } | ||
|
|
||
| export const preferGetErrorMessageOverStringRule = createRule({ | ||
| name: "prefer-get-error-message-over-string", | ||
| meta: { | ||
| type: "suggestion", | ||
| hasSuggestions: true, | ||
| docs: { | ||
| description: | ||
| "Prefer getErrorMessage(err) over String(err) when interpolating a caught error into a template literal, when getErrorMessage is already resolvable in scope. " + | ||
| "String(err) on an Error produces the redundant 'Error: message' prefix and does not sanitize GitHub's HTML error-page responses, " + | ||
| "while getErrorMessage(err) handles both correctly. Several actions/setup/js files already import getErrorMessage " + | ||
| "elsewhere yet still call String(err) at other call sites in the same file — this rule catches that inconsistency.", | ||
| }, | ||
| schema: [], | ||
| messages: { | ||
| preferGetErrorMessage: "Use getErrorMessage({{errorVar}}) instead of String({{errorVar}}) — getErrorMessage is already available in this scope and produces a cleaner, sanitized message for caught errors.", | ||
| replaceWithGetErrorMessage: "Replace String({{errorVar}}) with getErrorMessage({{errorVar}}).", | ||
| }, | ||
| }, | ||
| defaultOptions: [], | ||
| create(context) { | ||
| const sourceCode = context.sourceCode; | ||
| type Scope = ReturnType<typeof sourceCode.getScope>; | ||
|
|
||
| function isDefinitionAvailableAtNode(definition: TSESLint.Scope.Definition, node: TSESTree.Node): boolean { | ||
| if (definition.type === "ImportBinding" || definition.type === "FunctionName") { | ||
| return true; | ||
| } | ||
| const definitionNode = definition.name ?? definition.node; | ||
| if (!definitionNode?.range || !node.range) return false; | ||
| return definitionNode.range[0] < node.range[0]; | ||
| } | ||
|
|
||
| function hasResolvableLocalBinding(node: TSESTree.Node, name: string): boolean { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The rule only checks that an identifier named |
||
| let scope: Scope | null = sourceCode.getScope(node); | ||
| while (scope) { | ||
| const variable = scope.set.get(name); | ||
| if (variable && variable.defs.some(def => isDefinitionAvailableAtNode(def, node))) { | ||
| return true; | ||
| } | ||
| scope = scope.upper; | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| function resolvesToCaughtErrorVariable(expr: TSESTree.Identifier): boolean { | ||
| let scope: Scope | null = sourceCode.getScope(expr); | ||
| while (scope) { | ||
| const v = scope.set.get(expr.name); | ||
| if (v) { | ||
| return v.defs.some(isCaughtErrorVariableDef); | ||
| } | ||
| scope = scope.upper; | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| return { | ||
| TemplateLiteral(node) { | ||
| // Tagged templates pass values to the tag function as-is; not string-coerced. | ||
| if (node.parent?.type === AST_NODE_TYPES.TaggedTemplateExpression) return; | ||
|
|
||
| for (const expr of node.expressions) { | ||
| const errorVar = getStringCallArgName(expr); | ||
| if (!errorVar) continue; | ||
| if (expr.type !== AST_NODE_TYPES.CallExpression) continue; | ||
| const argNode = expr.arguments[0]; | ||
| if (argNode.type !== AST_NODE_TYPES.Identifier) continue; | ||
| if (!resolvesToCaughtErrorVariable(argNode)) continue; | ||
| if (!hasResolvableLocalBinding(node, "getErrorMessage")) continue; | ||
|
|
||
| context.report({ | ||
| node: expr, | ||
| messageId: "preferGetErrorMessage", | ||
| data: { errorVar }, | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The guards on lines 126–129 are redundant — Consider removing the dead-code checks: // These two guards can be removed:
if (expr.type !== AST_NODE_TYPES.CallExpression) continue;
const argNode = expr.arguments[0];
if (argNode.type !== AST_NODE_TYPES.Identifier) continue;Not a bug, but dead code reduces clarity. @copilot please address this. |
||
| suggest: [ | ||
| { | ||
| messageId: "replaceWithGetErrorMessage", | ||
| data: { errorVar }, | ||
| fix(fixer) { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/codebase-design] After 💡 Suggested simplificationfor (const expr of node.expressions) {
const errorVar = getStringCallArgName(expr);
if (!errorVar) continue;
// expr is guaranteed to be CallExpression with one Identifier argument here
const argNode = (expr as TSESTree.CallExpression).arguments[0] as TSESTree.Identifier;
if (!resolvesToCaughtErrorVariable(argNode)) continue;
if (!hasResolvableLocalBinding(node, "getErrorMessage")) continue;
// ...
}Or extract a helper that returns @copilot please address this. |
||
| return fixer.replaceText(expr, `getErrorMessage(${errorVar})`); | ||
| }, | ||
| }, | ||
| ], | ||
| }); | ||
| } | ||
| }, | ||
| }; | ||
| }, | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[/tdd] The
.then(onFulfilled, onRejected)rejection path is implemented inisInlineRejectionHandlerbut has noinvalidtest case — only.catch()is tested as invalid.💡 Suggested invalid test case
Without this, the
prop.name === "then" && parent.arguments[1] === nodebranch inisInlineRejectionHandleris untested on the invalid path.@copilot please address this.