Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eslint-factory/eslint.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ module.exports = [
"gh-aw-custom/no-unsafe-catch-error-property": "warn",
"gh-aw-custom/no-unsafe-promise-catch-error-property": "warn",
"gh-aw-custom/prefer-get-error-message": "warn",
"gh-aw-custom/prefer-get-error-message-over-string": "warn",
"gh-aw-custom/prefer-number-isnan": "warn",
"gh-aw-custom/require-async-entrypoint-catch": "warn",
"gh-aw-custom/require-await-core-summary-write": "warn",
Expand Down
2 changes: 2 additions & 0 deletions eslint-factory/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { noJsonStringifyErrorRule } from "./rules/no-json-stringify-error";
import { noUnsafeCatchErrorPropertyRule } from "./rules/no-unsafe-catch-error-property";
import { noUnsafePromiseCatchErrorPropertyRule } from "./rules/no-unsafe-promise-catch-error-property";
import { preferGetErrorMessageRule } from "./rules/prefer-get-error-message";
import { preferGetErrorMessageOverStringRule } from "./rules/prefer-get-error-message-over-string";
import { preferNumberIsNanRule } from "./rules/prefer-number-isnan";
import { requireAsyncEntrypointCatchRule } from "./rules/require-async-entrypoint-catch";
import { requireAwaitCoreSummaryWriteRule } from "./rules/require-await-core-summary-write";
Expand Down Expand Up @@ -45,6 +46,7 @@ const plugin = {
"no-unsafe-catch-error-property": noUnsafeCatchErrorPropertyRule,
"no-unsafe-promise-catch-error-property": noUnsafePromiseCatchErrorPropertyRule,
"prefer-get-error-message": preferGetErrorMessageRule,
"prefer-get-error-message-over-string": preferGetErrorMessageOverStringRule,
"prefer-number-isnan": preferNumberIsNanRule,
"require-async-entrypoint-catch": requireAsyncEntrypointCatchRule,
"require-await-core-summary-write": requireAwaitCoreSummaryWriteRule,
Expand Down
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: [],
});
});
});

Copy link
Copy Markdown
Contributor Author

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 in isInlineRejectionHandler but has no invalid test case — only .catch() is tested as invalid.

💡 Suggested invalid test case
it("invalid: String(err) flagged in .then(_, 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.then(() => {}, err => log(\`failed: \${String(err)}\`));`,
        errors: [{ messageId: "preferGetErrorMessage", suggestions: [{ messageId: "replaceWithGetErrorMessage" }] }],
      },
    ],
  });
});

Without this, the prop.name === "then" && parent.arguments[1] === node branch in isInlineRejectionHandler is untested on the invalid path.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test suite has zero coverage for the .then(undefined, err => ...) rejection-handler path, even though the implementation explicitly special-cases arguments[1] for .then.

💡 Untested branch in scope-resolution logic

isInlineRejectionHandler (rule file, line 17) checks prop.name === "then" && parent.arguments[1] === node, but no test in this file ever exercises a .then(onFulfilled, onRejected) call. Since this is the same kind of positional-argument matching that is easy to get backwards (arguments[0] vs arguments[1]), a regression here would silently ship with all 6 existing tests still green.

Suggested addition covering the .then(_, err) path and a .catch(function(err) {...}) FunctionExpression case, since isCaughtErrorVariableDef explicitly allows FunctionExpression alongside ArrowFunctionExpression but no test uses that form.

Comment on lines +1 to +91

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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.

165 changes: 165 additions & 0 deletions eslint-factory/src/rules/prefer-get-error-message-over-string.ts
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;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] getStringCallArgName accepts TSESTree.CallExpressionArgument but is called with expr which is typed as TSESTree.Expression | TSESTree.SpreadElement (the element type of TemplateLiteral.expressions). These are not the same union — TSESTree.SpreadElement is not a valid CallExpressionArgument. This likely only works at runtime because spread elements cannot appear in template literal expressions, but TypeScript may flag it.

💡 Suggested fix

Change the parameter type to TSESTree.Expression | TSESTree.SpreadElement (matching TemplateLiteral.expressions) or simply TSESTree.Node:

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 {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rule only checks that an identifier named getErrorMessage is resolvable in scope — it never verifies it actually came from ./error_helpers.cjs, so any unrelated local function/variable/parameter with that name would trigger a broken autofix.

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 },

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The guards on lines 126–129 are redundant — getStringCallArgName already verifies that expr is a CallExpression and that expr.arguments[0] is an Identifier before returning non-null. If errorVar is non-null, these conditions are already guaranteed true.

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) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] After getStringCallArgName(expr) returns a non-null name, the next two guards (expr.type !== CallExpression and argNode.type !== Identifier) are always true by construction — getStringCallArgName already validates both. This dead code creates a misleading impression that expr might not be a CallExpression here.

💡 Suggested simplification
for (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 { errorVar, argNode } | null so the type narrowing is co-located with the check.

@copilot please address this.

return fixer.replaceText(expr, `getErrorMessage(${errorVar})`);
},
},
],
});
}
},
};
},
});
Loading