@pobammer-ts/eslint-cease-nonsense-rules 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +317 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/rules/enforce-ianitor-check-type.js +7 -10
- package/dist/rules/enforce-ianitor-check-type.js.map +1 -1
- package/dist/rules/no-color3-constructor.js +2 -5
- package/dist/rules/no-color3-constructor.js.map +1 -1
- package/dist/rules/no-shorthand-names.js +1 -2
- package/dist/rules/no-shorthand-names.js.map +1 -1
- package/dist/rules/require-react-component-keys.js +22 -16
- package/dist/rules/require-react-component-keys.js.map +1 -1
- package/dist/rules/use-exhaustive-dependencies.d.ts +3 -0
- package/dist/rules/use-exhaustive-dependencies.js +776 -0
- package/dist/rules/use-exhaustive-dependencies.js.map +1 -0
- package/dist/rules/use-hook-at-top-level.d.ts +3 -0
- package/dist/rules/use-hook-at-top-level.js +392 -0
- package/dist/rules/use-hook-at-top-level.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */
|
|
2
|
+
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
|
3
|
+
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
|
4
|
+
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
|
5
|
+
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
|
6
|
+
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
|
|
7
|
+
/**
|
|
8
|
+
* Default hooks to check for exhaustive dependencies.
|
|
9
|
+
*/
|
|
10
|
+
const DEFAULT_HOOKS = new Map([
|
|
11
|
+
["useEffect", { closureIndex: 0, dependenciesIndex: 1 }],
|
|
12
|
+
["useLayoutEffect", { closureIndex: 0, dependenciesIndex: 1 }],
|
|
13
|
+
["useInsertionEffect", { closureIndex: 0, dependenciesIndex: 1 }],
|
|
14
|
+
["useCallback", { closureIndex: 0, dependenciesIndex: 1 }],
|
|
15
|
+
["useMemo", { closureIndex: 0, dependenciesIndex: 1 }],
|
|
16
|
+
["useImperativeHandle", { closureIndex: 1, dependenciesIndex: 2 }],
|
|
17
|
+
]);
|
|
18
|
+
/**
|
|
19
|
+
* Hooks with stable results that don't need to be in dependencies.
|
|
20
|
+
*/
|
|
21
|
+
const STABLE_HOOKS = new Map([
|
|
22
|
+
["useState", new Set([1])], // setter at index 1
|
|
23
|
+
["useReducer", new Set([1])], // dispatch at index 1
|
|
24
|
+
["useTransition", new Set([1])], // startTransition at index 1
|
|
25
|
+
["useRef", true], // entire result is stable
|
|
26
|
+
["useBinding", true], // React Lua: both binding and setter are stable
|
|
27
|
+
]);
|
|
28
|
+
/**
|
|
29
|
+
* Values that don't need to be in dependencies (imported, constants, etc.).
|
|
30
|
+
*/
|
|
31
|
+
const STABLE_VALUE_TYPES = new Set(["ImportBinding", "FunctionDeclaration", "ClassDeclaration", "FunctionName"]);
|
|
32
|
+
/**
|
|
33
|
+
* Global built-in identifiers that are always stable and should never be dependencies.
|
|
34
|
+
* Includes JavaScript/TypeScript globals, constructors, and type-only names.
|
|
35
|
+
*/
|
|
36
|
+
const GLOBAL_BUILTINS = new Set([
|
|
37
|
+
// Primitive values
|
|
38
|
+
"undefined",
|
|
39
|
+
"null",
|
|
40
|
+
"Infinity",
|
|
41
|
+
"NaN",
|
|
42
|
+
// Constructors
|
|
43
|
+
"Array",
|
|
44
|
+
"Object",
|
|
45
|
+
"String",
|
|
46
|
+
"Number",
|
|
47
|
+
"Boolean",
|
|
48
|
+
"Symbol",
|
|
49
|
+
"BigInt",
|
|
50
|
+
"Function",
|
|
51
|
+
// Collections
|
|
52
|
+
"Map",
|
|
53
|
+
"Set",
|
|
54
|
+
"WeakMap",
|
|
55
|
+
"WeakSet",
|
|
56
|
+
// Promises and async
|
|
57
|
+
"Promise",
|
|
58
|
+
// Utility
|
|
59
|
+
"Date",
|
|
60
|
+
"RegExp",
|
|
61
|
+
"Error",
|
|
62
|
+
"Math",
|
|
63
|
+
"JSON",
|
|
64
|
+
// Global functions
|
|
65
|
+
"parseInt",
|
|
66
|
+
"parseFloat",
|
|
67
|
+
"isNaN",
|
|
68
|
+
"isFinite",
|
|
69
|
+
"encodeURI",
|
|
70
|
+
"encodeURIComponent",
|
|
71
|
+
"decodeURI",
|
|
72
|
+
"decodeURIComponent",
|
|
73
|
+
// TypeScript utility types (appear in type annotations but shouldn't be dependencies)
|
|
74
|
+
"ReadonlyArray",
|
|
75
|
+
"ReadonlyMap",
|
|
76
|
+
"ReadonlySet",
|
|
77
|
+
"Partial",
|
|
78
|
+
"Required",
|
|
79
|
+
"Readonly",
|
|
80
|
+
"Pick",
|
|
81
|
+
"Omit",
|
|
82
|
+
"Exclude",
|
|
83
|
+
"Extract",
|
|
84
|
+
"Record",
|
|
85
|
+
"NonNullable",
|
|
86
|
+
"ReturnType",
|
|
87
|
+
"InstanceType",
|
|
88
|
+
"Parameters",
|
|
89
|
+
// Web/Node globals commonly seen
|
|
90
|
+
"console",
|
|
91
|
+
"setTimeout",
|
|
92
|
+
"setInterval",
|
|
93
|
+
"clearTimeout",
|
|
94
|
+
"clearInterval",
|
|
95
|
+
// Common DOM/Web types
|
|
96
|
+
"Element",
|
|
97
|
+
"Node",
|
|
98
|
+
"Document",
|
|
99
|
+
"Window",
|
|
100
|
+
"Event",
|
|
101
|
+
]);
|
|
102
|
+
/**
|
|
103
|
+
* Gets the hook name from a call expression.
|
|
104
|
+
*
|
|
105
|
+
* @param node - The call expression node.
|
|
106
|
+
* @returns The hook name or undefined.
|
|
107
|
+
*/
|
|
108
|
+
function getHookName(node) {
|
|
109
|
+
const { callee } = node;
|
|
110
|
+
// Direct call: useEffect(...)
|
|
111
|
+
if (callee.type === "Identifier") {
|
|
112
|
+
return callee.name;
|
|
113
|
+
}
|
|
114
|
+
// Member expression: React.useEffect(...)
|
|
115
|
+
if (callee.type === "MemberExpression" && callee.property.type === "Identifier") {
|
|
116
|
+
return callee.property.name;
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Gets the member expression depth (number of property accesses).
|
|
122
|
+
*
|
|
123
|
+
* @param node - The node to analyze.
|
|
124
|
+
* @returns The depth count.
|
|
125
|
+
*/
|
|
126
|
+
function getMemberExpressionDepth(node) {
|
|
127
|
+
let depth = 0;
|
|
128
|
+
let current = node;
|
|
129
|
+
while (current.type === "MemberExpression") {
|
|
130
|
+
depth++;
|
|
131
|
+
current = current.object;
|
|
132
|
+
}
|
|
133
|
+
return depth;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Gets the root identifier from a member expression.
|
|
137
|
+
*
|
|
138
|
+
* @param node - The node to analyze.
|
|
139
|
+
* @returns The root identifier or undefined.
|
|
140
|
+
*/
|
|
141
|
+
function getRootIdentifier(node) {
|
|
142
|
+
let current = node;
|
|
143
|
+
while (current.type === "MemberExpression") {
|
|
144
|
+
current = current.object;
|
|
145
|
+
}
|
|
146
|
+
return current.type === "Identifier" ? current : undefined;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Converts a node to a dependency string representation.
|
|
150
|
+
*
|
|
151
|
+
* @param node - The node to convert.
|
|
152
|
+
* @param sourceCode - The source code instance.
|
|
153
|
+
* @returns The dependency string.
|
|
154
|
+
*/
|
|
155
|
+
function nodeToDependencyString(node, sourceCode) {
|
|
156
|
+
return sourceCode.getText(node);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Checks if a stable array index is being accessed.
|
|
160
|
+
*
|
|
161
|
+
* @param stableResult - The stable result set.
|
|
162
|
+
* @param node - The variable declarator node.
|
|
163
|
+
* @param identifierName - The identifier name being accessed.
|
|
164
|
+
* @returns True if accessing a stable array index.
|
|
165
|
+
*/
|
|
166
|
+
function isStableArrayIndex(stableResult, node, identifierName) {
|
|
167
|
+
if (!stableResult)
|
|
168
|
+
return false;
|
|
169
|
+
if (!(stableResult instanceof Set) || node.type !== "VariableDeclarator" || node.id.type !== "ArrayPattern") {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
const elements = node.id.elements;
|
|
173
|
+
for (let i = 0; i < elements.length; i++) {
|
|
174
|
+
const element = elements[i];
|
|
175
|
+
if (element && element.type === "Identifier" && element.name === identifierName) {
|
|
176
|
+
return stableResult.has(i);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Checks if a value is from a stable hook.
|
|
183
|
+
*
|
|
184
|
+
* @param init - The initializer expression.
|
|
185
|
+
* @param node - The variable declarator node.
|
|
186
|
+
* @param identifierName - The identifier name being accessed.
|
|
187
|
+
* @param stableHooks - Map of stable hooks.
|
|
188
|
+
* @returns True if the value is from a stable hook.
|
|
189
|
+
*/
|
|
190
|
+
function isStableHookValue(init, node, identifierName, stableHooks) {
|
|
191
|
+
const initNode = init;
|
|
192
|
+
if (initNode.type !== "CallExpression")
|
|
193
|
+
return false;
|
|
194
|
+
const hookName = getHookName(initNode);
|
|
195
|
+
if (!hookName)
|
|
196
|
+
return false;
|
|
197
|
+
const stableResult = stableHooks.get(hookName);
|
|
198
|
+
if (stableResult === true)
|
|
199
|
+
return true;
|
|
200
|
+
return isStableArrayIndex(stableResult, node, identifierName);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Checks if a value is stable (doesn't need to be in dependencies).
|
|
204
|
+
*
|
|
205
|
+
* @param variable - The variable to check.
|
|
206
|
+
* @param identifierName - The identifier name being accessed.
|
|
207
|
+
* @param stableHooks - Map of stable hooks.
|
|
208
|
+
* @returns True if the value is stable.
|
|
209
|
+
*/
|
|
210
|
+
/* eslint-disable jsdoc/require-param, jsdoc/require-returns */
|
|
211
|
+
function isStableValue(variable, identifierName, stableHooks) {
|
|
212
|
+
if (!variable)
|
|
213
|
+
return false;
|
|
214
|
+
const { defs } = variable;
|
|
215
|
+
if (defs.length === 0)
|
|
216
|
+
return false;
|
|
217
|
+
for (const def of defs) {
|
|
218
|
+
const { node, type } = def;
|
|
219
|
+
// Imports, functions, classes are stable
|
|
220
|
+
if (STABLE_VALUE_TYPES.has(type))
|
|
221
|
+
return true;
|
|
222
|
+
// Check for const declarations with constant initializers
|
|
223
|
+
if (type === "Variable" && node.type === "VariableDeclarator") {
|
|
224
|
+
const parent = node.parent;
|
|
225
|
+
if (!parent || parent.type !== "VariableDeclaration" || parent.kind !== "const") {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const { init } = node;
|
|
229
|
+
// Check if it's from a stable hook first
|
|
230
|
+
// @ts-expect-error - Type mismatch between ESLint and TypeScript AST types
|
|
231
|
+
if (init && isStableHookValue(init, node, identifierName, stableHooks)) {
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
// Check for React Lua bindings - bindings are always stable
|
|
235
|
+
if (init?.type === "CallExpression") {
|
|
236
|
+
const { callee } = init;
|
|
237
|
+
// React.joinBindings() returns a stable binding
|
|
238
|
+
if (callee.type === "MemberExpression" &&
|
|
239
|
+
callee.object.type === "Identifier" &&
|
|
240
|
+
callee.object.name === "React" &&
|
|
241
|
+
callee.property.type === "Identifier" &&
|
|
242
|
+
callee.property.name === "joinBindings") {
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
// .map() on bindings returns a stable binding
|
|
246
|
+
// This covers: binding.map(...), React.joinBindings(...).map(...), etc.
|
|
247
|
+
if (callee.type === "MemberExpression" &&
|
|
248
|
+
callee.property.type === "Identifier" &&
|
|
249
|
+
callee.property.name === "map") {
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// Check for literal values FIRST (stable regardless of scope)
|
|
254
|
+
if (init) {
|
|
255
|
+
if (init.type === "Literal" || init.type === "TemplateLiteral") {
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
if (init.type === "UnaryExpression" && init.argument.type === "Literal") {
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// For non-literal constants, only module-level is stable
|
|
263
|
+
// Component-scoped non-literal constants are recreated on every render
|
|
264
|
+
const varDef = variable.defs.find((d) => d.node === node);
|
|
265
|
+
if (varDef && varDef.node.type === "VariableDeclarator") {
|
|
266
|
+
const declParent = varDef.node.parent?.parent;
|
|
267
|
+
// Module-level (Program or ExportNamedDeclaration)
|
|
268
|
+
if (declParent && (declParent.type === "Program" || declParent.type === "ExportNamedDeclaration")) {
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Finds the topmost member expression in a chain when an identifier is the object.
|
|
278
|
+
*
|
|
279
|
+
* @param node - The identifier node.
|
|
280
|
+
* @returns The topmost member expression or the node itself.
|
|
281
|
+
*/
|
|
282
|
+
function findTopmostMemberExpression(node) {
|
|
283
|
+
let current = node;
|
|
284
|
+
let parent = node.parent;
|
|
285
|
+
// Walk up the tree while we're the object of a member expression
|
|
286
|
+
while (parent?.type === "MemberExpression" && parent.object === current) {
|
|
287
|
+
current = parent;
|
|
288
|
+
parent = parent.parent;
|
|
289
|
+
}
|
|
290
|
+
return current;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Checks if an identifier is in a TypeScript type-only position.
|
|
294
|
+
* Type parameters and type annotations are compile-time only and should not be dependencies.
|
|
295
|
+
*
|
|
296
|
+
* @param identifier - The identifier node to check.
|
|
297
|
+
* @returns True if the identifier is in a type-only position.
|
|
298
|
+
*/
|
|
299
|
+
function isInTypePosition(identifier) {
|
|
300
|
+
let parent = identifier.parent;
|
|
301
|
+
while (parent) {
|
|
302
|
+
// Any TypeScript-specific node indicates a type-only position
|
|
303
|
+
if (parent.type.startsWith("TS")) {
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
// Stop searching at certain boundaries
|
|
307
|
+
if (parent.type === "FunctionDeclaration" ||
|
|
308
|
+
parent.type === "FunctionExpression" ||
|
|
309
|
+
parent.type === "ArrowFunctionExpression" ||
|
|
310
|
+
parent.type === "VariableDeclarator") {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
parent = parent.parent;
|
|
314
|
+
}
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Checks if a variable is declared directly in the component/hook body OR is a prop (parameter).
|
|
319
|
+
* Per React rules, only variables "declared directly inside the component body" are reactive.
|
|
320
|
+
* This includes props (function parameters).
|
|
321
|
+
* Variables from outer scopes (module-level, parent functions) are non-reactive.
|
|
322
|
+
*
|
|
323
|
+
* @param variable - The variable to check.
|
|
324
|
+
* @param closureNode - The closure node (useEffect callback, etc.).
|
|
325
|
+
* @returns True if the variable is declared in the component body or is a prop.
|
|
326
|
+
*/
|
|
327
|
+
function isDeclaredInComponentBody(variable, closureNode) {
|
|
328
|
+
// Find the parent component/hook function
|
|
329
|
+
let parent = closureNode.parent;
|
|
330
|
+
while (parent) {
|
|
331
|
+
const isFunction = parent.type === "FunctionDeclaration" ||
|
|
332
|
+
parent.type === "FunctionExpression" ||
|
|
333
|
+
parent.type === "ArrowFunctionExpression";
|
|
334
|
+
if (isFunction) {
|
|
335
|
+
// Capture parent in a const so TypeScript understands it's stable in closures
|
|
336
|
+
const functionParent = parent;
|
|
337
|
+
// Check if variable is a parameter of this function (props)
|
|
338
|
+
const isParameter = variable.defs.some((def) => {
|
|
339
|
+
if (def.type !== "Parameter")
|
|
340
|
+
return false;
|
|
341
|
+
// For parameters, the def.node is the function itself
|
|
342
|
+
// Just check if the definition's node is the current function parent
|
|
343
|
+
return def.node === functionParent;
|
|
344
|
+
});
|
|
345
|
+
if (isParameter) {
|
|
346
|
+
return true; // Props are reactive
|
|
347
|
+
}
|
|
348
|
+
// Check if variable is defined inside this function
|
|
349
|
+
return variable.defs.some((def) => {
|
|
350
|
+
let node = def.node.parent;
|
|
351
|
+
while (node && node !== functionParent) {
|
|
352
|
+
node = node.parent;
|
|
353
|
+
}
|
|
354
|
+
return node === functionParent;
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
parent = parent.parent;
|
|
358
|
+
}
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Collects all captured identifiers from a closure.
|
|
363
|
+
*
|
|
364
|
+
* @param node - The closure node (function/arrow function).
|
|
365
|
+
* @param scope - The scope of the closure.
|
|
366
|
+
* @param sourceCode - The source code instance.
|
|
367
|
+
* @returns Array of captured identifiers.
|
|
368
|
+
*/
|
|
369
|
+
function collectCaptures(node, scope, sourceCode) {
|
|
370
|
+
const captures = [];
|
|
371
|
+
const captureSet = new Set();
|
|
372
|
+
/**
|
|
373
|
+
* Recursively visits nodes to find identifier references.
|
|
374
|
+
*
|
|
375
|
+
* @param current - The current node.
|
|
376
|
+
*/
|
|
377
|
+
function visit(current) {
|
|
378
|
+
if (current.type === "Identifier") {
|
|
379
|
+
const { name } = current;
|
|
380
|
+
// Skip if already captured
|
|
381
|
+
if (captureSet.has(name))
|
|
382
|
+
return;
|
|
383
|
+
// Skip global built-ins (always stable, never need to be in dependencies)
|
|
384
|
+
if (GLOBAL_BUILTINS.has(name))
|
|
385
|
+
return;
|
|
386
|
+
// Skip TypeScript type-only positions (type parameters, annotations, etc.)
|
|
387
|
+
if (isInTypePosition(current))
|
|
388
|
+
return;
|
|
389
|
+
// Look up the variable in the scope chain
|
|
390
|
+
let variable;
|
|
391
|
+
let currentScope = scope;
|
|
392
|
+
while (currentScope) {
|
|
393
|
+
variable = currentScope.set.get(name);
|
|
394
|
+
if (variable)
|
|
395
|
+
break;
|
|
396
|
+
currentScope = currentScope.upper;
|
|
397
|
+
}
|
|
398
|
+
// Only capture if variable is defined outside the closure
|
|
399
|
+
if (variable) {
|
|
400
|
+
const isDefinedInClosure = variable.defs.some((def) => {
|
|
401
|
+
let defNode = def.node;
|
|
402
|
+
while (defNode) {
|
|
403
|
+
if (defNode === node)
|
|
404
|
+
return true;
|
|
405
|
+
defNode = defNode.parent;
|
|
406
|
+
}
|
|
407
|
+
return false;
|
|
408
|
+
});
|
|
409
|
+
if (!isDefinedInClosure) {
|
|
410
|
+
// Only capture variables declared in the component body
|
|
411
|
+
// Per React rules, only "variables declared directly inside the component body" are reactive
|
|
412
|
+
// Variables from outer scopes (module-level, parent functions) are non-reactive and stable
|
|
413
|
+
if (!isDeclaredInComponentBody(variable, node)) {
|
|
414
|
+
return; // From outer scope - skip
|
|
415
|
+
}
|
|
416
|
+
captureSet.add(name);
|
|
417
|
+
const depthNode = findTopmostMemberExpression(current);
|
|
418
|
+
const usagePath = sourceCode.getText(depthNode);
|
|
419
|
+
captures.push({
|
|
420
|
+
depth: getMemberExpressionDepth(depthNode),
|
|
421
|
+
name,
|
|
422
|
+
node: depthNode,
|
|
423
|
+
usagePath,
|
|
424
|
+
variable,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
// Traverse member expressions
|
|
430
|
+
if (current.type === "MemberExpression") {
|
|
431
|
+
visit(current.object);
|
|
432
|
+
if (current.computed) {
|
|
433
|
+
visit(current.property);
|
|
434
|
+
}
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
// Visit children
|
|
438
|
+
const keys = sourceCode.visitorKeys?.[current.type] || [];
|
|
439
|
+
for (const key of keys) {
|
|
440
|
+
const value = current[key];
|
|
441
|
+
if (Array.isArray(value)) {
|
|
442
|
+
for (const item of value) {
|
|
443
|
+
if (item && typeof item === "object" && "type" in item) {
|
|
444
|
+
visit(item);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
else if (value && typeof value === "object" && "type" in value) {
|
|
449
|
+
visit(value);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
visit(node);
|
|
454
|
+
return captures;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Parses dependencies from a dependency array expression.
|
|
458
|
+
*
|
|
459
|
+
* @param node - The dependency array node.
|
|
460
|
+
* @param sourceCode - The source code instance.
|
|
461
|
+
* @returns Array of dependency information.
|
|
462
|
+
*/
|
|
463
|
+
function parseDependencies(node, sourceCode) {
|
|
464
|
+
const dependencies = [];
|
|
465
|
+
for (const element of node.elements) {
|
|
466
|
+
if (!element || element.type === "SpreadElement")
|
|
467
|
+
continue;
|
|
468
|
+
const name = nodeToDependencyString(element, sourceCode);
|
|
469
|
+
const depth = getMemberExpressionDepth(element);
|
|
470
|
+
dependencies.push({
|
|
471
|
+
depth,
|
|
472
|
+
name,
|
|
473
|
+
node: element,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
return dependencies;
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Checks if a dependency or capture is an inline function or object (unstable).
|
|
480
|
+
*
|
|
481
|
+
* @param node - The node to check.
|
|
482
|
+
* @returns True if the node is an unstable value.
|
|
483
|
+
*/
|
|
484
|
+
function isUnstableValue(node) {
|
|
485
|
+
if (!node)
|
|
486
|
+
return false;
|
|
487
|
+
// Inline functions
|
|
488
|
+
if (node.type === "FunctionExpression" ||
|
|
489
|
+
node.type === "ArrowFunctionExpression" ||
|
|
490
|
+
node.type === "FunctionDeclaration") {
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
// Object literals
|
|
494
|
+
if (node.type === "ObjectExpression") {
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
// Array literals
|
|
498
|
+
if (node.type === "ArrayExpression") {
|
|
499
|
+
return true;
|
|
500
|
+
}
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Converts stableResult configuration to internal format.
|
|
505
|
+
*
|
|
506
|
+
* @param stableResult - The stable result configuration.
|
|
507
|
+
* @returns The internal stable result format.
|
|
508
|
+
*/
|
|
509
|
+
function convertStableResult(stableResult) {
|
|
510
|
+
if (typeof stableResult === "boolean")
|
|
511
|
+
return stableResult;
|
|
512
|
+
if (typeof stableResult === "number")
|
|
513
|
+
return new Set([stableResult]);
|
|
514
|
+
if (Array.isArray(stableResult) && stableResult.length > 0) {
|
|
515
|
+
if (typeof stableResult[0] === "number") {
|
|
516
|
+
return new Set(stableResult);
|
|
517
|
+
}
|
|
518
|
+
return new Set(stableResult);
|
|
519
|
+
}
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
const useExhaustiveDependencies = {
|
|
523
|
+
create(context) {
|
|
524
|
+
const options = {
|
|
525
|
+
hooks: [],
|
|
526
|
+
reportMissingDependenciesArray: true,
|
|
527
|
+
reportUnnecessaryDependencies: true,
|
|
528
|
+
...context.options[0],
|
|
529
|
+
};
|
|
530
|
+
// Build hook configuration map
|
|
531
|
+
const hookConfigs = new Map(DEFAULT_HOOKS);
|
|
532
|
+
for (const customHook of options.hooks) {
|
|
533
|
+
if (customHook.closureIndex !== undefined && customHook.dependenciesIndex !== undefined) {
|
|
534
|
+
hookConfigs.set(customHook.name, {
|
|
535
|
+
closureIndex: customHook.closureIndex,
|
|
536
|
+
dependenciesIndex: customHook.dependenciesIndex,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
// Build stable hooks map
|
|
541
|
+
const stableHooks = new Map(STABLE_HOOKS);
|
|
542
|
+
for (const customHook of options.hooks) {
|
|
543
|
+
if (customHook.stableResult !== undefined) {
|
|
544
|
+
stableHooks.set(customHook.name, convertStableResult(customHook.stableResult));
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
// Performance: cache scope lookups
|
|
548
|
+
const scopeCache = new WeakMap();
|
|
549
|
+
/**
|
|
550
|
+
* Gets the scope for a node with caching.
|
|
551
|
+
*
|
|
552
|
+
* @param node - The node to get scope for.
|
|
553
|
+
* @returns The scope.
|
|
554
|
+
*/
|
|
555
|
+
function getScope(node) {
|
|
556
|
+
const cached = scopeCache.get(node);
|
|
557
|
+
if (cached)
|
|
558
|
+
return cached;
|
|
559
|
+
const scope = context.sourceCode.getScope(node);
|
|
560
|
+
scopeCache.set(node, scope);
|
|
561
|
+
return scope;
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
CallExpression(node) {
|
|
565
|
+
const callNode = node;
|
|
566
|
+
// Early exit: get hook name
|
|
567
|
+
const hookName = getHookName(callNode);
|
|
568
|
+
if (!hookName)
|
|
569
|
+
return;
|
|
570
|
+
// Early exit: check if this hook needs dependency checking
|
|
571
|
+
const hookConfig = hookConfigs.get(hookName);
|
|
572
|
+
if (!hookConfig)
|
|
573
|
+
return;
|
|
574
|
+
const { closureIndex, dependenciesIndex } = hookConfig;
|
|
575
|
+
const { arguments: args } = callNode;
|
|
576
|
+
// Early exit: check if closure argument exists
|
|
577
|
+
const closureArg = args[closureIndex];
|
|
578
|
+
if (!closureArg)
|
|
579
|
+
return;
|
|
580
|
+
// Early exit: check if closure is a function
|
|
581
|
+
if (closureArg.type !== "ArrowFunctionExpression" && closureArg.type !== "FunctionExpression") {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
// Get dependencies argument
|
|
585
|
+
const depsArg = args[dependenciesIndex];
|
|
586
|
+
// Report missing dependencies array if configured
|
|
587
|
+
if (!depsArg && options.reportMissingDependenciesArray) {
|
|
588
|
+
// Collect captures to see if any are needed
|
|
589
|
+
const scope = getScope(closureArg);
|
|
590
|
+
const captures = collectCaptures(closureArg, scope, context.sourceCode);
|
|
591
|
+
// Filter out stable values
|
|
592
|
+
const requiredCaptures = captures.filter((capture) => !isStableValue(capture.variable, capture.name, stableHooks));
|
|
593
|
+
if (requiredCaptures.length > 0) {
|
|
594
|
+
const missingNames = Array.from(new Set(requiredCaptures.map((c) => c.name))).join(", ");
|
|
595
|
+
context.report({
|
|
596
|
+
data: { deps: missingNames },
|
|
597
|
+
messageId: "missingDependenciesArray",
|
|
598
|
+
node: callNode,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
// Early exit: no dependencies array
|
|
604
|
+
if (!depsArg)
|
|
605
|
+
return;
|
|
606
|
+
// Dependencies must be an array
|
|
607
|
+
if (depsArg.type !== "ArrayExpression")
|
|
608
|
+
return;
|
|
609
|
+
const depsArray = depsArg;
|
|
610
|
+
// Collect captures from closure
|
|
611
|
+
const scope = getScope(closureArg);
|
|
612
|
+
const captures = collectCaptures(closureArg, scope, context.sourceCode);
|
|
613
|
+
// Parse dependencies array
|
|
614
|
+
const dependencies = parseDependencies(depsArray, context.sourceCode);
|
|
615
|
+
// Check for unnecessary dependencies first (for consistent error ordering)
|
|
616
|
+
for (const dep of dependencies) {
|
|
617
|
+
const depRootIdent = getRootIdentifier(dep.node);
|
|
618
|
+
if (!depRootIdent)
|
|
619
|
+
continue;
|
|
620
|
+
const depName = depRootIdent.name;
|
|
621
|
+
// Find all captures with the same root identifier
|
|
622
|
+
const matchingCaptures = captures.filter((c) => getRootIdentifier(c.node)?.name === depName);
|
|
623
|
+
// If no captures use this identifier at all, it's unnecessary
|
|
624
|
+
if (matchingCaptures.length === 0) {
|
|
625
|
+
if (options.reportUnnecessaryDependencies) {
|
|
626
|
+
context.report({
|
|
627
|
+
data: { name: dep.name },
|
|
628
|
+
messageId: "unnecessaryDependency",
|
|
629
|
+
node: dep.node,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
// Check if dependency is more specific than any usage
|
|
635
|
+
// dep.depth > all capture depths means the dep is too specific
|
|
636
|
+
const maxCaptureDepth = Math.max(...matchingCaptures.map((c) => c.depth));
|
|
637
|
+
if (dep.depth > maxCaptureDepth && options.reportUnnecessaryDependencies) {
|
|
638
|
+
context.report({
|
|
639
|
+
data: { name: dep.name },
|
|
640
|
+
messageId: "unnecessaryDependency",
|
|
641
|
+
node: dep.node,
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
// Check for missing dependencies
|
|
646
|
+
for (const capture of captures) {
|
|
647
|
+
// Skip stable values
|
|
648
|
+
if (isStableValue(capture.variable, capture.name, stableHooks))
|
|
649
|
+
continue;
|
|
650
|
+
// Check if the capture is in the dependencies
|
|
651
|
+
const rootIdent = getRootIdentifier(capture.node);
|
|
652
|
+
if (!rootIdent)
|
|
653
|
+
continue;
|
|
654
|
+
const captureName = rootIdent.name;
|
|
655
|
+
let isInDeps = false;
|
|
656
|
+
// Check if capture is covered by dependencies
|
|
657
|
+
for (const dep of dependencies) {
|
|
658
|
+
const depRootIdent = getRootIdentifier(dep.node);
|
|
659
|
+
// Check name match and depth: dependency should not be more specific than capture
|
|
660
|
+
if (depRootIdent?.name === captureName && dep.depth <= capture.depth) {
|
|
661
|
+
isInDeps = true;
|
|
662
|
+
break;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
if (!isInDeps) {
|
|
666
|
+
// Report on the last dependency in the array for better error positioning
|
|
667
|
+
const lastDep = dependencies[dependencies.length - 1];
|
|
668
|
+
context.report({
|
|
669
|
+
data: { name: capture.usagePath },
|
|
670
|
+
messageId: "missingDependency",
|
|
671
|
+
node: lastDep?.node || depsArray,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
// Check for unstable dependencies in the array
|
|
676
|
+
for (const capture of captures) {
|
|
677
|
+
// Skip stable values
|
|
678
|
+
if (isStableValue(capture.variable, capture.name, stableHooks))
|
|
679
|
+
continue;
|
|
680
|
+
// Check if this capture has a corresponding dependency
|
|
681
|
+
const rootIdent = getRootIdentifier(capture.node);
|
|
682
|
+
if (!rootIdent)
|
|
683
|
+
continue;
|
|
684
|
+
const captureName = rootIdent.name;
|
|
685
|
+
// Find if there's a matching dependency
|
|
686
|
+
for (const dep of dependencies) {
|
|
687
|
+
const depRootIdent = getRootIdentifier(dep.node);
|
|
688
|
+
const isMatch = depRootIdent?.name === captureName && dep.depth === capture.depth;
|
|
689
|
+
const isDirectIdentifier = dep.depth === 0;
|
|
690
|
+
if (isMatch && isDirectIdentifier) {
|
|
691
|
+
const def = capture.variable?.defs[0];
|
|
692
|
+
const initNode = def?.node.type === "VariableDeclarator" ? def.node.init : undefined;
|
|
693
|
+
if (isUnstableValue(initNode)) {
|
|
694
|
+
context.report({
|
|
695
|
+
data: { name: capture.usagePath },
|
|
696
|
+
messageId: "unstableDependency",
|
|
697
|
+
node: dep.node,
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
break;
|
|
701
|
+
}
|
|
702
|
+
if (isMatch)
|
|
703
|
+
break;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
},
|
|
707
|
+
};
|
|
708
|
+
},
|
|
709
|
+
meta: {
|
|
710
|
+
docs: {
|
|
711
|
+
description: "Enforce exhaustive and correct dependency specification in React hooks to prevent stale closures and unnecessary re-renders",
|
|
712
|
+
recommended: true,
|
|
713
|
+
url: "https://biomejs.dev/linter/rules/use-exhaustive-dependencies/",
|
|
714
|
+
},
|
|
715
|
+
fixable: "code",
|
|
716
|
+
messages: {
|
|
717
|
+
missingDependenciesArray: "This hook does not specify its dependencies array. Missing: {{deps}}",
|
|
718
|
+
missingDependency: "This hook does not specify its dependency on {{name}}.",
|
|
719
|
+
unnecessaryDependency: "This dependency {{name}} can be removed from the list.",
|
|
720
|
+
unstableDependency: "{{name}} changes on every re-render. Wrap the definition in useCallback() or useMemo() to stabilize it.",
|
|
721
|
+
},
|
|
722
|
+
schema: [
|
|
723
|
+
{
|
|
724
|
+
additionalProperties: false,
|
|
725
|
+
properties: {
|
|
726
|
+
hooks: {
|
|
727
|
+
description: "Array of custom hook entries to check for exhaustive dependencies",
|
|
728
|
+
items: {
|
|
729
|
+
additionalProperties: false,
|
|
730
|
+
properties: {
|
|
731
|
+
closureIndex: {
|
|
732
|
+
description: "Index of the closure argument for dependency validation",
|
|
733
|
+
type: "number",
|
|
734
|
+
},
|
|
735
|
+
dependenciesIndex: {
|
|
736
|
+
description: "Index of the dependencies array for validation",
|
|
737
|
+
type: "number",
|
|
738
|
+
},
|
|
739
|
+
name: {
|
|
740
|
+
description: "The name of the hook",
|
|
741
|
+
type: "string",
|
|
742
|
+
},
|
|
743
|
+
stableResult: {
|
|
744
|
+
description: "Specify stable results: true (whole result), number (array index), number[] (multiple indices), or string[] (object properties)",
|
|
745
|
+
oneOf: [
|
|
746
|
+
{ type: "boolean" },
|
|
747
|
+
{ type: "number" },
|
|
748
|
+
{ items: { type: "number" }, type: "array" },
|
|
749
|
+
{ items: { type: "string" }, type: "array" },
|
|
750
|
+
],
|
|
751
|
+
},
|
|
752
|
+
},
|
|
753
|
+
required: ["name"],
|
|
754
|
+
type: "object",
|
|
755
|
+
},
|
|
756
|
+
type: "array",
|
|
757
|
+
},
|
|
758
|
+
reportMissingDependenciesArray: {
|
|
759
|
+
default: true,
|
|
760
|
+
description: "Report when the dependencies array is completely missing",
|
|
761
|
+
type: "boolean",
|
|
762
|
+
},
|
|
763
|
+
reportUnnecessaryDependencies: {
|
|
764
|
+
default: true,
|
|
765
|
+
description: "Report when unnecessary dependencies are specified",
|
|
766
|
+
type: "boolean",
|
|
767
|
+
},
|
|
768
|
+
},
|
|
769
|
+
type: "object",
|
|
770
|
+
},
|
|
771
|
+
],
|
|
772
|
+
type: "problem",
|
|
773
|
+
},
|
|
774
|
+
};
|
|
775
|
+
export default useExhaustiveDependencies;
|
|
776
|
+
//# sourceMappingURL=use-exhaustive-dependencies.js.map
|