@blumintinc/eslint-plugin-blumint 1.20.145 → 1.20.147
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/lib/index.js +1 -1
- package/lib/rules/enforce-memoize-async.js +311 -0
- package/lib/rules/no-entire-object-hook-deps.js +266 -3
- package/lib/rules/prefer-use-deep-compare-memo.js +307 -19
- package/lib/rules/prevent-children-clobber.js +297 -35
- package/lib/rules/vertically-group-related-functions.js +154 -0
- package/package.json +1 -1
- package/release-manifest.json +52 -0
|
@@ -84,31 +84,315 @@ function isNonPrimitiveWithoutTypes(expr) {
|
|
|
84
84
|
return false;
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Layer A: what the type checker proves about a dependency expression.
|
|
89
|
+
*
|
|
90
|
+
* Reading the type at the *reference* rather than at the declaration is
|
|
91
|
+
* deliberate: control-flow narrowing is what makes an optional `a?: string`
|
|
92
|
+
* answer `string` at the site React actually compares.
|
|
93
|
+
*/
|
|
94
|
+
function primitivenessByType(context, expr) {
|
|
89
95
|
const services = context.parserServices;
|
|
90
96
|
if (!services?.program || !services?.esTreeNodeToTSNodeMap) {
|
|
91
|
-
return
|
|
97
|
+
return 'unproven';
|
|
92
98
|
}
|
|
93
99
|
try {
|
|
100
|
+
// Dereferenced inside the function rather than at module scope: reading
|
|
101
|
+
// `ts.TypeFlags` while the module is being loaded takes the whole plugin
|
|
102
|
+
// down wherever `typescript` is not yet resolvable (#1354).
|
|
94
103
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
95
104
|
const ts = require('typescript');
|
|
96
105
|
const checker = services.program.getTypeChecker();
|
|
97
106
|
const tsNode = services.esTreeNodeToTSNodeMap.get(expr);
|
|
98
107
|
if (!tsNode)
|
|
99
|
-
return
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
+
return 'unproven';
|
|
109
|
+
const primitiveFlags = ts.TypeFlags.StringLike |
|
|
110
|
+
ts.TypeFlags.NumberLike |
|
|
111
|
+
ts.TypeFlags.BooleanLike |
|
|
112
|
+
ts.TypeFlags.BigIntLike |
|
|
113
|
+
ts.TypeFlags.ESSymbolLike |
|
|
114
|
+
ts.TypeFlags.Null |
|
|
115
|
+
ts.TypeFlags.Undefined |
|
|
116
|
+
ts.TypeFlags.Void;
|
|
117
|
+
// `any` and `unknown` carry no shape, and an unresolved type parameter is
|
|
118
|
+
// whatever its caller supplies, so none of the three is an answer.
|
|
119
|
+
const uninformativeFlags = ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.TypeParameter;
|
|
120
|
+
const classify = (candidate) => {
|
|
121
|
+
if (candidate.flags & uninformativeFlags)
|
|
122
|
+
return 'unproven';
|
|
123
|
+
if (candidate.isUnion()) {
|
|
124
|
+
const verdicts = candidate.types.map(classify);
|
|
125
|
+
if (verdicts.includes('unproven'))
|
|
126
|
+
return 'unproven';
|
|
127
|
+
return verdicts.every((verdict) => verdict === 'primitive')
|
|
128
|
+
? 'primitive'
|
|
129
|
+
: 'nonPrimitive';
|
|
130
|
+
}
|
|
131
|
+
return candidate.flags & primitiveFlags ? 'primitive' : 'nonPrimitive';
|
|
132
|
+
};
|
|
133
|
+
return classify(checker.getTypeAtLocation(tsNode));
|
|
108
134
|
}
|
|
109
135
|
catch {
|
|
136
|
+
return 'unproven';
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Type nodes naming a primitive outright. A `TSTypeReference` is absent by
|
|
141
|
+
* design: an alias resolves only through the checker, which is Layer A's job.
|
|
142
|
+
*/
|
|
143
|
+
const PRIMITIVE_TYPE_KEYWORDS = new Set([
|
|
144
|
+
utils_1.AST_NODE_TYPES.TSStringKeyword,
|
|
145
|
+
utils_1.AST_NODE_TYPES.TSNumberKeyword,
|
|
146
|
+
utils_1.AST_NODE_TYPES.TSBooleanKeyword,
|
|
147
|
+
utils_1.AST_NODE_TYPES.TSBigIntKeyword,
|
|
148
|
+
utils_1.AST_NODE_TYPES.TSSymbolKeyword,
|
|
149
|
+
utils_1.AST_NODE_TYPES.TSNullKeyword,
|
|
150
|
+
utils_1.AST_NODE_TYPES.TSUndefinedKeyword,
|
|
151
|
+
utils_1.AST_NODE_TYPES.TSVoidKeyword,
|
|
152
|
+
]);
|
|
153
|
+
function isPrimitiveLiteralNode(node) {
|
|
154
|
+
if (node.type === utils_1.AST_NODE_TYPES.TemplateLiteral)
|
|
155
|
+
return true;
|
|
156
|
+
if (node.type === utils_1.AST_NODE_TYPES.UnaryExpression) {
|
|
157
|
+
// `-1` and `+1` are literal types spelled across two nodes.
|
|
158
|
+
return ((node.operator === '-' || node.operator === '+') &&
|
|
159
|
+
isPrimitiveLiteralNode(node.argument));
|
|
160
|
+
}
|
|
161
|
+
if (node.type !== utils_1.AST_NODE_TYPES.Literal)
|
|
162
|
+
return false;
|
|
163
|
+
// A regular expression literal is an object, and its `value` is null in
|
|
164
|
+
// hosts that cannot construct it — the same shape a `null` literal has.
|
|
165
|
+
if ('regex' in node && node.regex)
|
|
166
|
+
return false;
|
|
167
|
+
const { value } = node;
|
|
168
|
+
return (value === null ||
|
|
169
|
+
typeof value === 'string' ||
|
|
170
|
+
typeof value === 'number' ||
|
|
171
|
+
typeof value === 'boolean' ||
|
|
172
|
+
typeof value === 'bigint');
|
|
173
|
+
}
|
|
174
|
+
function isPrimitiveTypeNode(node) {
|
|
175
|
+
if (PRIMITIVE_TYPE_KEYWORDS.has(node.type))
|
|
176
|
+
return true;
|
|
177
|
+
if (node.type === utils_1.AST_NODE_TYPES.TSLiteralType) {
|
|
178
|
+
return isPrimitiveLiteralNode(node.literal);
|
|
179
|
+
}
|
|
180
|
+
// A union is primitive only if nothing in it can carry identity, which is
|
|
181
|
+
// what makes `string | undefined` — the type an optional parameter has —
|
|
182
|
+
// answer the same as `string`.
|
|
183
|
+
if (node.type === utils_1.AST_NODE_TYPES.TSUnionType) {
|
|
184
|
+
return node.types.length > 0 && node.types.every(isPrimitiveTypeNode);
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
function isUseStateCallee(callee) {
|
|
189
|
+
if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
190
|
+
return callee.name === 'useState';
|
|
191
|
+
}
|
|
192
|
+
return (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
193
|
+
!callee.computed &&
|
|
194
|
+
callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
195
|
+
callee.property.name === 'useState');
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Whether a `const [x] = useState(<primitive>)` binding holds a primitive.
|
|
199
|
+
*
|
|
200
|
+
* Only element 0 qualifies: element 1 is the setter, a function. The setter is
|
|
201
|
+
* also the only other writer of element 0, and the initial value types what it
|
|
202
|
+
* accepts, so the state stays whatever kind the initializer made it.
|
|
203
|
+
*/
|
|
204
|
+
function isPrimitiveUseStateElement(declarator, bound) {
|
|
205
|
+
const { id, init } = declarator;
|
|
206
|
+
if (id.type !== utils_1.AST_NODE_TYPES.ArrayPattern)
|
|
110
207
|
return false;
|
|
208
|
+
if (id.elements[0] !== bound)
|
|
209
|
+
return false;
|
|
210
|
+
if (!init || init.type !== utils_1.AST_NODE_TYPES.CallExpression)
|
|
211
|
+
return false;
|
|
212
|
+
if (!isUseStateCallee(init.callee))
|
|
213
|
+
return false;
|
|
214
|
+
// An explicit type argument overrides whatever the initial value would have
|
|
215
|
+
// inferred, so it answers instead of the initializer.
|
|
216
|
+
const typeArguments = init.typeParameters?.params;
|
|
217
|
+
if (typeArguments && typeArguments.length > 0) {
|
|
218
|
+
return typeArguments.every(isPrimitiveTypeNode);
|
|
111
219
|
}
|
|
220
|
+
const [initial] = init.arguments;
|
|
221
|
+
return initial !== undefined && isPrimitiveLiteralNode(initial);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Layer B: primitiveness the parser alone can see.
|
|
225
|
+
*
|
|
226
|
+
* This layer exists because Layer A goes blind exactly where the shared rule
|
|
227
|
+
* testers run it — with no `project`, `useState` imported from react resolves
|
|
228
|
+
* to `any`, and so does every `lib` type. Syntax the file spells out does not
|
|
229
|
+
* degrade that way.
|
|
230
|
+
*/
|
|
231
|
+
function isProvablyPrimitiveBinding(definition) {
|
|
232
|
+
const bound = definition.name;
|
|
233
|
+
if (bound.type !== utils_1.AST_NODE_TYPES.Identifier)
|
|
234
|
+
return false;
|
|
235
|
+
// An annotation constrains every assignment to the binding, so it answers for
|
|
236
|
+
// a `let` and a reassigned parameter just as it does for a `const`.
|
|
237
|
+
const annotation = bound.typeAnnotation?.typeAnnotation;
|
|
238
|
+
if (annotation)
|
|
239
|
+
return isPrimitiveTypeNode(annotation);
|
|
240
|
+
const declarator = definition.node;
|
|
241
|
+
const declaration = definition.parent;
|
|
242
|
+
if (declarator.type !== utils_1.AST_NODE_TYPES.VariableDeclarator)
|
|
243
|
+
return false;
|
|
244
|
+
// Without an annotation the initializer is the only evidence, and it only
|
|
245
|
+
// describes the binding for as long as nothing rebinds it.
|
|
246
|
+
if (!declaration ||
|
|
247
|
+
declaration.type !== utils_1.AST_NODE_TYPES.VariableDeclaration ||
|
|
248
|
+
declaration.kind !== 'const') {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
if (declarator.id === bound) {
|
|
252
|
+
return declarator.init !== null && isPrimitiveLiteralNode(declarator.init);
|
|
253
|
+
}
|
|
254
|
+
return isPrimitiveUseStateElement(declarator, bound);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Members that exist on primitives and on nothing else in ordinary code.
|
|
258
|
+
*
|
|
259
|
+
* `length`, `slice`, `includes`, `indexOf`, `concat`, `at`, `toString` and
|
|
260
|
+
* `valueOf` are deliberately absent: arrays and objects carry them too, so
|
|
261
|
+
* vetoing on one would stop the rule seeing the array dependencies it exists
|
|
262
|
+
* to catch.
|
|
263
|
+
*/
|
|
264
|
+
const PRIMITIVE_ONLY_MEMBERS = new Set([
|
|
265
|
+
'toUpperCase',
|
|
266
|
+
'toLowerCase',
|
|
267
|
+
'toFixed',
|
|
268
|
+
'toPrecision',
|
|
269
|
+
'trim',
|
|
270
|
+
'trimStart',
|
|
271
|
+
'trimEnd',
|
|
272
|
+
'padStart',
|
|
273
|
+
'padEnd',
|
|
274
|
+
'charAt',
|
|
275
|
+
'charCodeAt',
|
|
276
|
+
'codePointAt',
|
|
277
|
+
'normalize',
|
|
278
|
+
'localeCompare',
|
|
279
|
+
'startsWith',
|
|
280
|
+
'endsWith',
|
|
281
|
+
'repeat',
|
|
282
|
+
'toExponential',
|
|
283
|
+
]);
|
|
284
|
+
/**
|
|
285
|
+
* The children of a node an identifier can be *referenced* from.
|
|
286
|
+
*
|
|
287
|
+
* A non-computed member's property and a non-computed key spell a name rather
|
|
288
|
+
* than read a binding, so counting them as occurrences would let an unrelated
|
|
289
|
+
* `other.trim` decide what `trim` denotes.
|
|
290
|
+
*/
|
|
291
|
+
function referencedChildren(node) {
|
|
292
|
+
const children = [];
|
|
293
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
294
|
+
const record = node;
|
|
295
|
+
for (const key of Object.keys(record)) {
|
|
296
|
+
if (key === 'parent')
|
|
297
|
+
continue;
|
|
298
|
+
if (key === 'property' &&
|
|
299
|
+
node.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
300
|
+
!node.computed) {
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (key === 'key' &&
|
|
304
|
+
(node.type === utils_1.AST_NODE_TYPES.Property ||
|
|
305
|
+
node.type === utils_1.AST_NODE_TYPES.MethodDefinition ||
|
|
306
|
+
node.type === utils_1.AST_NODE_TYPES.PropertyDefinition) &&
|
|
307
|
+
!node.computed) {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const child = record[key];
|
|
311
|
+
if (!child)
|
|
312
|
+
continue;
|
|
313
|
+
if (Array.isArray(child)) {
|
|
314
|
+
for (const element of child) {
|
|
315
|
+
if (element && typeof element === 'object' && 'type' in element) {
|
|
316
|
+
children.push(element);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
else if (typeof child === 'object' && 'type' in child) {
|
|
321
|
+
children.push(child);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return children;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Layer C: every read of the name inside the callback is a member that only a
|
|
328
|
+
* primitive has.
|
|
329
|
+
*
|
|
330
|
+
* The weakest of the three layers, and the only one that reaches a binding the
|
|
331
|
+
* file gives no annotation and no initializer for — `const { asPath } =
|
|
332
|
+
* useRouter()` is the shape the consumer's hooks are written in. It answers
|
|
333
|
+
* only when *every* occurrence agrees: a computed access, a spread, or the bare
|
|
334
|
+
* name passed along says nothing about the value's shape, and one such
|
|
335
|
+
* occurrence withdraws the guess.
|
|
336
|
+
*/
|
|
337
|
+
function readsOnlyPrimitiveMembers(callback, name) {
|
|
338
|
+
let accesses = 0;
|
|
339
|
+
let proven = true;
|
|
340
|
+
const visit = (node) => {
|
|
341
|
+
if (!proven)
|
|
342
|
+
return;
|
|
343
|
+
if (node.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
344
|
+
node.object.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
345
|
+
node.object.name === name) {
|
|
346
|
+
if (node.computed ||
|
|
347
|
+
node.property.type !== utils_1.AST_NODE_TYPES.Identifier ||
|
|
348
|
+
!PRIMITIVE_ONLY_MEMBERS.has(node.property.name)) {
|
|
349
|
+
proven = false;
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
accesses += 1;
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (node.type === utils_1.AST_NODE_TYPES.Identifier && node.name === name) {
|
|
356
|
+
proven = false;
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
for (const child of referencedChildren(node)) {
|
|
360
|
+
visit(child);
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
visit(callback.body);
|
|
364
|
+
return proven && accesses > 0;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* The one binding a name denotes at a call site, or null when the answer is not
|
|
368
|
+
* a single declaration. A name with several definitions — a redeclared `var`, a
|
|
369
|
+
* merged declaration — describes more than one thing, and no one of them speaks
|
|
370
|
+
* for the reference.
|
|
371
|
+
*/
|
|
372
|
+
function soleDefinitionOf(context, node, name) {
|
|
373
|
+
const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, node), name);
|
|
374
|
+
if (!variable || variable.defs.length !== 1)
|
|
375
|
+
return null;
|
|
376
|
+
return variable.defs[0];
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Whether a dependency is *provably* a primitive, across all three layers.
|
|
380
|
+
*
|
|
381
|
+
* The direction is asymmetric on purpose. A missed deep comparison costs a
|
|
382
|
+
* recomputation; a deep comparison wrapped around a `string` costs an injected
|
|
383
|
+
* dependency, a new import and a hook that cannot help, so nothing short of a
|
|
384
|
+
* proof promotes a dependency here (#1979).
|
|
385
|
+
*/
|
|
386
|
+
function isProvablyPrimitiveDependency(context, call, callback, dep) {
|
|
387
|
+
const verdict = primitivenessByType(context, dep);
|
|
388
|
+
if (verdict === 'primitive')
|
|
389
|
+
return true;
|
|
390
|
+
const definition = soleDefinitionOf(context, call, dep.name);
|
|
391
|
+
if (definition && isProvablyPrimitiveBinding(definition))
|
|
392
|
+
return true;
|
|
393
|
+
// Method names are evidence only where nothing better exists: a checker that
|
|
394
|
+
// resolved the type has already answered, and answered from more than a name.
|
|
395
|
+
return (verdict === 'unproven' && readsOnlyPrimitiveMembers(callback, dep.name));
|
|
112
396
|
}
|
|
113
397
|
function collectMemoizedIdentifiers(context) {
|
|
114
398
|
const memoized = new Set();
|
|
@@ -401,12 +685,8 @@ exports.preferUseDeepCompareMemo = (0, createRule_1.createRule)({
|
|
|
401
685
|
if (el.type === utils_1.AST_NODE_TYPES.SpreadElement)
|
|
402
686
|
continue;
|
|
403
687
|
const expr = el;
|
|
404
|
-
//
|
|
405
|
-
let isNonPrimitive =
|
|
406
|
-
// Fallback heuristic without type info for literals/arrays/objects/functions
|
|
407
|
-
if (!isNonPrimitive) {
|
|
408
|
-
isNonPrimitive = isNonPrimitiveWithoutTypes(expr);
|
|
409
|
-
}
|
|
688
|
+
// Syntactic classification for literals/arrays/objects/functions
|
|
689
|
+
let isNonPrimitive = isNonPrimitiveWithoutTypes(expr);
|
|
410
690
|
// Identifier-specific heuristic: consider non-primitive only if used as object or function in callback
|
|
411
691
|
if (!isNonPrimitive && expr.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
412
692
|
// Imported identifiers are treated as stable
|
|
@@ -414,7 +694,15 @@ exports.preferUseDeepCompareMemo = (0, createRule_1.createRule)({
|
|
|
414
694
|
isNonPrimitive = false;
|
|
415
695
|
}
|
|
416
696
|
else if (identifierUsedAsObjectOrArray(callback, expr.name) &&
|
|
417
|
-
!isIdentifierMemoizedAbove(expr.name, memoizedIds)
|
|
697
|
+
!isIdentifierMemoizedAbove(expr.name, memoizedIds) &&
|
|
698
|
+
// Reading a member off a name proves the receiver has members,
|
|
699
|
+
// which every primitive also has: `slug.toUpperCase()` and
|
|
700
|
+
// `cfg.a` are the same shape. The promotion therefore stands only
|
|
701
|
+
// while the receiver is not provably a primitive — a bare
|
|
702
|
+
// Identifier reaches this branch through no other signal, so the
|
|
703
|
+
// veto can withhold nothing the rule established some other way
|
|
704
|
+
// (#1979).
|
|
705
|
+
!isProvablyPrimitiveDependency(context, node, callback, expr)) {
|
|
418
706
|
isNonPrimitive = true;
|
|
419
707
|
}
|
|
420
708
|
}
|