1- import { AST_NODE_TYPES , ESLintUtils , TSESTree } from "@typescript-eslint/utils" ;
1+ import { AST_NODE_TYPES , ESLintUtils , TSESLint , TSESTree } from "@typescript-eslint/utils" ;
22
33const createRule = ESLintUtils . RuleCreator ( name => `https://github.com/github/gh-aw/tree/main/eslint-factory#${ name } ` ) ;
44
55interface 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