1
+ import {
2
+ AST_NODE_TYPES ,
3
+ ESLintUtils ,
4
+ TSESTree ,
5
+ } from "@typescript-eslint/utils" ;
6
+ import { RuleListener , RuleModule } from "@typescript-eslint/utils/ts-eslint" ;
7
+ import createRule from "../utils/createRule.js" ;
8
+
9
+ // Define the type for the rule options
10
+ type NoRepeatedMemberAccessOptions = [
11
+ {
12
+ minOccurrences ?: number ;
13
+ } ?
14
+ ] ;
15
+
16
+ const noRepeatedMemberAccess : ESLintUtils . RuleModule <
17
+ "repeatedAccess" , // Message ID type
18
+ NoRepeatedMemberAccessOptions , // Options type
19
+ unknown , // This parameter is often unused or unknown
20
+ ESLintUtils . RuleListener // Listener type
21
+ > = createRule ( {
22
+ name : "no-repeated-member-access" ,
23
+ defaultOptions : [ { minOccurrences : 2 } ] , // Provide a default object matching the options structure
24
+ meta : {
25
+ type : "suggestion" ,
26
+ docs : {
27
+ description :
28
+ "Avoid getting member variable multiple-times in the same context" ,
29
+ } ,
30
+ fixable : "code" ,
31
+ messages : {
32
+ repeatedAccess :
33
+ "Try refactor member access to a variable (e.g. 'const temp = {{ path }};') to avoid possible performance loss" ,
34
+ } ,
35
+ schema : [
36
+ {
37
+ type : "object" ,
38
+ properties : {
39
+ minOccurrences : { type : "number" , minimum : 2 } ,
40
+ } ,
41
+ } ,
42
+ ] ,
43
+ } ,
44
+ create ( context ) {
45
+ function getObjectChain ( node : TSESTree . Node ) {
46
+ // node is the outermost MemberExpression, e.g. ctx.data.v1
47
+ let current = node ;
48
+ const path : string [ ] = [ ] ;
49
+
50
+ // Helper function to skip TSNonNullExpression nodes
51
+ function skipTSNonNullExpression (
52
+ node : TSESTree . Node
53
+ ) : TSESTree . Expression {
54
+ if ( node . type === "TSNonNullExpression" ) {
55
+ return skipTSNonNullExpression ( node . expression ) ;
56
+ }
57
+ return node as TSESTree . Expression ;
58
+ }
59
+
60
+ // First check if this is part of a switch-case statement
61
+ let parent = node ;
62
+ while ( parent && parent . parent ) {
63
+ parent = parent . parent ;
64
+ if (
65
+ parent . type === AST_NODE_TYPES . SwitchCase &&
66
+ parent . test &&
67
+ ( node === parent . test || isDescendant ( node , parent . test ) )
68
+ ) {
69
+ return null ; // Skip members used in switch-case statements
70
+ }
71
+ }
72
+
73
+ // Helper function to check if a node is a descendant of another node
74
+ function isDescendant (
75
+ node : TSESTree . Node ,
76
+ possibleAncestor : TSESTree . Node
77
+ ) : boolean {
78
+ let current = node . parent ;
79
+ while ( current ) {
80
+ if ( current === possibleAncestor ) return true ;
81
+ current = current . parent ;
82
+ }
83
+ return false ;
84
+ }
85
+
86
+ // Traverse up to the second last member (object chain)
87
+ while (
88
+ ( current && current . type === "MemberExpression" ) ||
89
+ current . type === "TSNonNullExpression"
90
+ ) {
91
+ // Skip non-null assertions
92
+ if ( current . type === "TSNonNullExpression" ) {
93
+ current = current . expression ;
94
+ continue ;
95
+ }
96
+
97
+ // Only handle static property or static index
98
+ if ( current . computed ) {
99
+ // true means access with "[]"
100
+ // false means access with "."
101
+ if ( current . property . type === "Literal" ) {
102
+ // e.g. obj[1], obj["name"]
103
+ path . unshift ( `[${ current . property . value } ]` ) ;
104
+ } else {
105
+ // Ignore dynamic property access
106
+ // e.g. obj[var], obj[func()]
107
+ return null ;
108
+ }
109
+ } else {
110
+ // e.g. obj.prop
111
+ path . unshift ( `.${ current . property . name } ` ) ;
112
+ }
113
+
114
+ // Check if we've reached the base object
115
+ let objExpr = current . object ;
116
+ if ( objExpr ?. type === "TSNonNullExpression" ) {
117
+ objExpr = skipTSNonNullExpression ( objExpr ) ;
118
+ }
119
+
120
+ // If object is not MemberExpression, we've reached the base object
121
+ if ( ! objExpr || objExpr . type !== "MemberExpression" ) {
122
+ // Handle "this" expressions
123
+ if ( objExpr && objExpr . type === "ThisExpression" ) {
124
+ path . unshift ( "this" ) ;
125
+
126
+ // Skip reporting if the chain is just 'this.property'
127
+ if ( path . length <= 2 ) {
128
+ return null ;
129
+ }
130
+
131
+ path . pop ( ) ; // Remove the last property
132
+ return path . join ( "" ) . replace ( / ^ \. / , "" ) ;
133
+ }
134
+
135
+ // If object is Identifier, add it to the path
136
+ if ( objExpr && objExpr . type === "Identifier" ) {
137
+ const baseName = objExpr . name ;
138
+
139
+ // Skip if the base looks like an enum/constant (starts with capital letter)
140
+ if (
141
+ baseName . length > 0 &&
142
+ baseName [ 0 ] === baseName [ 0 ] . toUpperCase ( )
143
+ ) {
144
+ return null ; // Likely an enum or static class
145
+ }
146
+
147
+ path . unshift ( baseName ) ;
148
+ // Remove the last property (keep only the object chain)
149
+ path . pop ( ) ;
150
+ return path . join ( "" ) . replace ( / ^ \. / , "" ) ;
151
+ }
152
+ return null ;
153
+ }
154
+ current = objExpr ;
155
+ }
156
+ return null ;
157
+ }
158
+
159
+ // Store nodes for each object chain in each scope for auto-fixing
160
+ const chainNodesMap = new Map < string , TSESTree . MemberExpression [ ] > ( ) ;
161
+
162
+ const occurrences = new Map ( ) ;
163
+ const minOccurrences = context . options [ 0 ] ?. minOccurrences || 2 ;
164
+
165
+ return {
166
+ MemberExpression ( node ) {
167
+ // Only check the outermost member expression
168
+ if ( node . parent && node . parent . type === "MemberExpression" ) return ;
169
+
170
+ const objectChain = getObjectChain ( node ) ;
171
+ if ( ! objectChain ) return ;
172
+
173
+ const baseObjectName = objectChain . split ( / [ . [ ] / ) [ 0 ] ;
174
+ // no need to continue if what we extract is the same as the base object
175
+ if ( objectChain === baseObjectName ) return ;
176
+
177
+ // Use scope range as part of the key
178
+ const scope = context . sourceCode . getScope ( node ) ;
179
+ if ( ! scope || ! scope . block || ! scope . block . range ) return ;
180
+
181
+ const key = `${ scope . block . range . join ( "-" ) } -${ objectChain } ` ;
182
+
183
+ // Store node for auto-fixing
184
+ if ( ! chainNodesMap . has ( key ) ) {
185
+ chainNodesMap . set ( key , [ ] ) ;
186
+ }
187
+ chainNodesMap . get ( key ) ?. push ( node as TSESTree . MemberExpression ) ;
188
+
189
+ const count = ( occurrences . get ( key ) || 0 ) + 1 ;
190
+ occurrences . set ( key , count ) ;
191
+
192
+ if ( count >= minOccurrences ) {
193
+ context . report ( {
194
+ node,
195
+ messageId : "repeatedAccess" ,
196
+ data : {
197
+ path : objectChain ,
198
+ count : count ,
199
+ } ,
200
+ * fix ( fixer ) {
201
+ const nodes = chainNodesMap . get ( key ) ;
202
+ if ( ! nodes || nodes . length < minOccurrences ) return ;
203
+
204
+ // Create a safe variable name based on the object chain
205
+ const safeVarName = `_${ objectChain . replace (
206
+ / [ ^ a - z A - Z 0 - 9 _ ] / g,
207
+ "_"
208
+ ) } `;
209
+
210
+ // Find the first statement containing the first instance
211
+ let statement : TSESTree . Node = nodes [ 0 ] ;
212
+ while (
213
+ statement . parent &&
214
+ ! [
215
+ "Program" ,
216
+ "BlockStatement" ,
217
+ "StaticBlock" ,
218
+ "SwitchCase" ,
219
+ ] . includes ( statement . parent . type )
220
+ ) {
221
+ statement = statement . parent ;
222
+ }
223
+
224
+ // Check if the variable already exists in this scope
225
+ const scope = context . sourceCode . getScope ( nodes [ 0 ] ) ;
226
+ const variableExists = scope . variables . some (
227
+ ( v ) => v . name === safeVarName
228
+ ) ;
229
+
230
+ // Only insert declaration if variable doesn't exist
231
+ if ( ! variableExists ) {
232
+ yield fixer . insertTextBefore (
233
+ statement ,
234
+ `const ${ safeVarName } = ${ objectChain } ;\n`
235
+ ) ;
236
+ }
237
+
238
+ // Replace ALL occurrences, not just the current node
239
+ for ( const memberNode of nodes ) {
240
+ const objText = context . sourceCode . getText ( memberNode . object ) ;
241
+ if ( objText === objectChain ) {
242
+ yield fixer . replaceText ( memberNode . object , safeVarName ) ;
243
+ }
244
+ }
245
+ } ,
246
+ } ) ;
247
+ }
248
+ } ,
249
+ } ;
250
+ } ,
251
+ } ) ;
0 commit comments