Skip to content

Commit 0121fe6

Browse files
authored
eslint-factory: detect catch-variable aliases in require-error-cause-in-rethrow (#43966)
1 parent 56d9eb4 commit 0121fe6

2 files changed

Lines changed: 125 additions & 33 deletions

File tree

eslint-factory/src/rules/require-error-cause-in-rethrow.test.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@ describe("require-error-cause-in-rethrow", () => {
4141
`try { doSomething(); } catch (err) { const e = new Error(\`msg: \${getErrorMessage(err)}\`); log(e); }`,
4242
// Existing cause property with wrapped expression should not be flagged.
4343
`try { doSomething(); } catch (err) { throw new Error("Failed: " + getErrorMessage(err), { cause: new Error(err.message), code: 500 }); }`,
44+
// Alias initialized from unrelated value should not be treated as catch alias.
45+
`try { doSomething(); } catch (deleteError) { const err = getFallbackError(); throw new Error(\`Failed to delete: \${String(err)}\`); }`,
46+
// Reassigned alias is intentionally not tracked (too complex to follow safely).
47+
`try { doSomething(); } catch (deleteError) { let err = deleteError; err = normalizeError(err); throw new Error(\`Failed to delete: \${String(err)}\`); }`,
48+
// Alias + cause should pass.
49+
`try { doSomething(); } catch (deleteError) { const err = deleteError; throw new Error(\`Failed to delete existing remote branch: \${message || String(err)}\`, { cause: err }); }`,
50+
// Alias name shadowed in nested block — inner `err` is unrelated, should not flag.
51+
`try { doSomething(); } catch (deleteError) { const err = deleteError; { const err = getFallbackError(); throw new Error(String(err)); } }`,
4452
],
4553
invalid: [],
4654
});
@@ -55,7 +63,7 @@ describe("require-error-cause-in-rethrow", () => {
5563
errors: [
5664
{
5765
messageId: "missingCause",
58-
data: { catchVar: "err" },
66+
data: { catchParam: "err", refName: "err" },
5967
suggestions: [
6068
{
6169
messageId: "addCause",
@@ -70,7 +78,7 @@ describe("require-error-cause-in-rethrow", () => {
7078
errors: [
7179
{
7280
messageId: "missingCause",
73-
data: { catchVar: "err" },
81+
data: { catchParam: "err", refName: "err" },
7482
suggestions: [
7583
{
7684
messageId: "addCause",
@@ -85,7 +93,7 @@ describe("require-error-cause-in-rethrow", () => {
8593
errors: [
8694
{
8795
messageId: "missingCause",
88-
data: { catchVar: "error" },
96+
data: { catchParam: "error", refName: "error" },
8997
suggestions: [
9098
{
9199
messageId: "addCause",
@@ -101,7 +109,7 @@ describe("require-error-cause-in-rethrow", () => {
101109
errors: [
102110
{
103111
messageId: "missingCause",
104-
data: { catchVar: "err" },
112+
data: { catchParam: "err", refName: "err" },
105113
suggestions: [
106114
{
107115
messageId: "addCause",
@@ -117,7 +125,7 @@ describe("require-error-cause-in-rethrow", () => {
117125
errors: [
118126
{
119127
messageId: "missingCause",
120-
data: { catchVar: "err" },
128+
data: { catchParam: "err", refName: "err" },
121129
suggestions: [
122130
{
123131
messageId: "addCause",
@@ -133,7 +141,7 @@ describe("require-error-cause-in-rethrow", () => {
133141
errors: [
134142
{
135143
messageId: "missingCause",
136-
data: { catchVar: "err" },
144+
data: { catchParam: "err", refName: "err" },
137145
suggestions: [
138146
{
139147
messageId: "addCause",
@@ -143,6 +151,22 @@ describe("require-error-cause-in-rethrow", () => {
143151
},
144152
],
145153
},
154+
{
155+
// Aliased catch var + logical expression branch should be detected.
156+
code: `try { doSomething(); } catch (deleteError) { const err = deleteError; throw new Error(\`Failed to delete existing remote branch: \${message || String(err)}\`); }`,
157+
errors: [
158+
{
159+
messageId: "missingCause",
160+
data: { catchParam: "deleteError", refName: "err" },
161+
suggestions: [
162+
{
163+
messageId: "addCause",
164+
output: `try { doSomething(); } catch (deleteError) { const err = deleteError; throw new Error(` + `\`Failed to delete existing remote branch: \${message || String(err)}\`, { cause: err }); }`,
165+
},
166+
],
167+
},
168+
],
169+
},
146170
],
147171
});
148172
});

eslint-factory/src/rules/require-error-cause-in-rethrow.ts

Lines changed: 95 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import { AST_NODE_TYPES, ESLintUtils, TSESTree } from "@typescript-eslint/utils";
1+
import { AST_NODE_TYPES, ESLintUtils, TSESLint, TSESTree } from "@typescript-eslint/utils";
22

33
const createRule = ESLintUtils.RuleCreator(name => `https://github.com/github/gh-aw/tree/main/eslint-factory#${name}`);
44

55
interface CatchFrame {
66
varName: string;
7+
catchNode: TSESTree.CatchClause | null;
8+
aliases: Map<string, TSESTree.VariableDeclarator>;
79
}
810

911
/**
@@ -20,39 +22,83 @@ function getErrorMessageArg(node: TSESTree.CallExpression): string | null {
2022
}
2123

2224
/**
23-
* Returns true when the expression tree references the given identifier name.
24-
* Used to detect whether the catch variable appears somewhere in the Error
25-
* message (directly or via getErrorMessage(catchVar)).
25+
* Returns the first identifier name from `trackedNames` referenced in `node`,
26+
* or null if none are found. Used to detect whether the Error message
27+
* references the caught variable (or a direct alias of it).
28+
* `verifyIdentifier` is called on each name-matched identifier node to confirm
29+
* the binding resolves to the expected catch variable or alias (scope safety).
2630
*/
27-
function expressionReferencesCatchVar(node: TSESTree.Expression, varName: string): boolean {
28-
if (node.type === AST_NODE_TYPES.Identifier && node.name === varName) return true;
31+
function findTrackedIdentifierReference(node: TSESTree.Expression, trackedNames: ReadonlySet<string>, verifyIdentifier: (name: string, identNode: TSESTree.Identifier) => boolean): string | null {
32+
if (node.type === AST_NODE_TYPES.Identifier && trackedNames.has(node.name)) {
33+
return verifyIdentifier(node.name, node) ? node.name : null;
34+
}
2935
if (node.type === AST_NODE_TYPES.CallExpression) {
3036
const gem = getErrorMessageArg(node);
31-
if (gem === varName) return true;
37+
if (gem && trackedNames.has(gem)) {
38+
// getErrorMessageArg returns the name; verify via the actual argument identifier node
39+
const firstArg = node.arguments[0];
40+
if (firstArg && firstArg.type === AST_NODE_TYPES.Identifier && verifyIdentifier(gem, firstArg)) return gem;
41+
}
3242
// Recurse into all arguments
3343
for (const arg of node.arguments) {
34-
if (arg.type !== "SpreadElement" && expressionReferencesCatchVar(arg, varName)) return true;
44+
if (arg.type === "SpreadElement") continue;
45+
const match = findTrackedIdentifierReference(arg, trackedNames, verifyIdentifier);
46+
if (match) return match;
3547
}
3648
}
3749
if (node.type === AST_NODE_TYPES.TemplateLiteral) {
3850
for (const expr of node.expressions) {
39-
if (expressionReferencesCatchVar(expr, varName)) return true;
51+
const match = findTrackedIdentifierReference(expr, trackedNames, verifyIdentifier);
52+
if (match) return match;
4053
}
4154
}
4255
if (node.type === AST_NODE_TYPES.BinaryExpression) {
4356
const left = node.left;
4457
const right = node.right;
45-
const leftResult = left.type !== AST_NODE_TYPES.PrivateIdentifier && expressionReferencesCatchVar(left, varName);
46-
return leftResult || expressionReferencesCatchVar(right, varName);
58+
if (left.type !== AST_NODE_TYPES.PrivateIdentifier) {
59+
const leftMatch = findTrackedIdentifierReference(left, trackedNames, verifyIdentifier);
60+
if (leftMatch) return leftMatch;
61+
}
62+
return findTrackedIdentifierReference(right, trackedNames, verifyIdentifier);
63+
}
64+
if (node.type === AST_NODE_TYPES.LogicalExpression) {
65+
const leftMatch = findTrackedIdentifierReference(node.left, trackedNames, verifyIdentifier);
66+
if (leftMatch) return leftMatch;
67+
return findTrackedIdentifierReference(node.right, trackedNames, verifyIdentifier);
68+
}
69+
if (node.type === AST_NODE_TYPES.ConditionalExpression) {
70+
const testMatch = findTrackedIdentifierReference(node.test, trackedNames, verifyIdentifier);
71+
if (testMatch) return testMatch;
72+
const consequentMatch = findTrackedIdentifierReference(node.consequent, trackedNames, verifyIdentifier);
73+
if (consequentMatch) return consequentMatch;
74+
return findTrackedIdentifierReference(node.alternate, trackedNames, verifyIdentifier);
4775
}
4876
if (node.type === AST_NODE_TYPES.MemberExpression) {
49-
if (node.object.type !== AST_NODE_TYPES.Super && expressionReferencesCatchVar(node.object, varName)) return true;
77+
if (node.object.type !== AST_NODE_TYPES.Super) {
78+
const objectMatch = findTrackedIdentifierReference(node.object, trackedNames, verifyIdentifier);
79+
if (objectMatch) return objectMatch;
80+
}
5081
if (node.computed) {
51-
return expressionReferencesCatchVar(node.property as TSESTree.Expression, varName);
82+
return findTrackedIdentifierReference(node.property as TSESTree.Expression, trackedNames, verifyIdentifier);
5283
}
53-
return false;
84+
return null;
5485
}
55-
return false;
86+
return null;
87+
}
88+
89+
function collectCatchAliases(catchBody: TSESTree.BlockStatement, catchVarName: string): Map<string, TSESTree.VariableDeclarator> {
90+
const aliases = new Map<string, TSESTree.VariableDeclarator>();
91+
for (const statement of catchBody.body) {
92+
if (statement.type !== AST_NODE_TYPES.VariableDeclaration || statement.kind !== "const") continue;
93+
for (const decl of statement.declarations) {
94+
if (decl.id.type !== AST_NODE_TYPES.Identifier) continue;
95+
if (!decl.init || decl.init.type !== AST_NODE_TYPES.Identifier) continue;
96+
if (decl.init.name !== catchVarName) continue;
97+
if (decl.id.name === catchVarName) continue;
98+
aliases.set(decl.id.name, decl);
99+
}
100+
}
101+
return aliases;
56102
}
57103

58104
/**
@@ -86,8 +132,8 @@ export const requireErrorCauseInRethrowRule = createRule({
86132
},
87133
schema: [],
88134
messages: {
89-
missingCause: "`new Error(...)` inside catch ({{catchVar}}) references {{catchVar}} but omits `{ cause: {{catchVar}} }` — the original stack trace will be lost. Add `{ cause: {{catchVar}} }` as the second argument.",
90-
addCause: "Add `{ cause: {{catchVar}} }` as the second argument to preserve the original error chain.",
135+
missingCause: "`new Error(...)` inside catch ({{catchParam}}) references {{refName}} but omits `{ cause: {{refName}} }` — the original stack trace will be lost. Add `{ cause: {{refName}} }` as the second argument.",
136+
addCause: "Add `{ cause: {{refName}} }` as the second argument to preserve the original error chain.",
91137
},
92138
},
93139
defaultOptions: [],
@@ -131,10 +177,10 @@ export const requireErrorCauseInRethrowRule = createRule({
131177
const param = node.param;
132178
if (!param || param.type !== AST_NODE_TYPES.Identifier) {
133179
// Bare catch {} or destructured — push empty sentinel so CatchClause:exit still pops
134-
catchStack.push({ varName: "" });
180+
catchStack.push({ varName: "", catchNode: null, aliases: new Map() });
135181
return;
136182
}
137-
catchStack.push({ varName: param.name });
183+
catchStack.push({ varName: param.name, catchNode: node, aliases: collectCatchAliases(node.body, param.name) });
138184
},
139185

140186
"CatchClause:exit"() {
@@ -149,18 +195,40 @@ export const requireErrorCauseInRethrowRule = createRule({
149195
if (callee.type !== AST_NODE_TYPES.Identifier || callee.name !== "Error") return;
150196

151197
const frame = innermostCatch();
152-
if (!frame || !frame.varName) return;
198+
if (!frame || !frame.varName || !frame.catchNode) return;
153199
if (!isInsideCatchBody(node)) return;
154200

155201
const catchVarName = frame.varName;
202+
const catchNode = frame.catchNode;
203+
const trackedNames = new Set<string>([catchVarName, ...frame.aliases.keys()]);
156204
const args = node.arguments;
157205

206+
// Build a scope-aware verifier: confirm the identifier actually resolves to the
207+
// catch parameter or a recorded alias, not a shadowed variable in a nested block.
208+
const verifyIdentifier = (name: string, identNode: TSESTree.Identifier): boolean => {
209+
let currentScope: TSESLint.Scope.Scope | null = sourceCode.getScope(identNode);
210+
while (currentScope !== null) {
211+
const variable = currentScope.set.get(name);
212+
if (variable !== undefined) {
213+
if (name === catchVarName) {
214+
return variable.defs.some(def => def.type === TSESLint.Scope.DefinitionType.CatchClause && def.node === catchNode);
215+
}
216+
const aliasDeclarator = frame.aliases.get(name);
217+
if (aliasDeclarator === undefined) return false;
218+
return variable.defs.some(def => def.type === TSESLint.Scope.DefinitionType.Variable && def.node === aliasDeclarator);
219+
}
220+
currentScope = currentScope.upper;
221+
}
222+
return false;
223+
};
224+
158225
// Must have at least a message argument that references the catch variable.
159226
if (args.length === 0) return;
160227
const msgArg = args[0];
161228
if (msgArg.type === "SpreadElement") return;
162229

163-
if (!expressionReferencesCatchVar(msgArg, catchVarName)) return;
230+
const causeIdentifier = findTrackedIdentifierReference(msgArg, trackedNames, verifyIdentifier);
231+
if (!causeIdentifier) return;
164232

165233
// If a second argument exists, check that it contains { cause: catchVar }.
166234
if (args.length >= 2) {
@@ -173,11 +241,11 @@ export const requireErrorCauseInRethrowRule = createRule({
173241
context.report({
174242
node,
175243
messageId: "missingCause",
176-
data: { catchVar: catchVarName },
244+
data: { catchParam: catchVarName, refName: causeIdentifier },
177245
suggest: [
178246
{
179247
messageId: "addCause" as const,
180-
data: { catchVar: catchVarName },
248+
data: { refName: causeIdentifier },
181249
fix(fixer) {
182250
// If there's already a second arg, replace it with an object that adds cause.
183251
if (args.length >= 2) {
@@ -186,17 +254,17 @@ export const requireErrorCauseInRethrowRule = createRule({
186254
if (secondArg.type === AST_NODE_TYPES.ObjectExpression) {
187255
// Add cause property to the existing object
188256
if (secondArg.properties.length === 0) {
189-
return fixer.replaceText(secondArg, `{ cause: ${catchVarName} }`);
257+
return fixer.replaceText(secondArg, `{ cause: ${causeIdentifier} }`);
190258
}
191259
const firstProp = secondArg.properties[0];
192-
return fixer.insertTextBefore(firstProp, `cause: ${catchVarName}, `);
260+
return fixer.insertTextBefore(firstProp, `cause: ${causeIdentifier}, `);
193261
}
194262
return null;
195263
}
196-
// No second argument — append `, { cause: catchVar }` before closing paren
264+
// No second argument — append `, { cause: causeIdentifier }` before closing paren
197265
const lastArg = args[args.length - 1];
198266
if (!lastArg || lastArg.type === "SpreadElement") return null;
199-
return fixer.insertTextAfter(lastArg, `, { cause: ${catchVarName} }`);
267+
return fixer.insertTextAfter(lastArg, `, { cause: ${causeIdentifier} }`);
200268
},
201269
},
202270
],

0 commit comments

Comments
 (0)