@blumintinc/eslint-plugin-blumint 1.20.145 → 1.20.146
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/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 +38 -0
package/lib/index.js
CHANGED
|
@@ -196,6 +196,246 @@ function declaresFunctionParameter(params) {
|
|
|
196
196
|
}
|
|
197
197
|
return parameterAnnotation(params[0])?.type === utils_1.AST_NODE_TYPES.TSFunctionType;
|
|
198
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* A database transaction handle is valid only for the attempt that created it,
|
|
201
|
+
* and a transaction body is re-run whenever the driver retries — Firestore
|
|
202
|
+
* retries an attempt whose reads a concurrent write invalidated. A memoized
|
|
203
|
+
* body hands the retry the first attempt's cached promise, so the retry queues
|
|
204
|
+
* no writes on its own handle, commits empty, and the caller reads the first
|
|
205
|
+
* attempt's return value and reports success. Memoizing the method that OWNS
|
|
206
|
+
* the transaction is the same defect one level up: the whole transaction, writes
|
|
207
|
+
* included, then runs once per instance.
|
|
208
|
+
*/
|
|
209
|
+
const TRANSACTION_TYPE_NAME = 'Transaction';
|
|
210
|
+
const RUN_TRANSACTION_NAME = 'runTransaction';
|
|
211
|
+
/**
|
|
212
|
+
* Keys that hold source positions or the tree's only back-edge rather than
|
|
213
|
+
* child nodes; `parent` would make a subtree walk non-terminating.
|
|
214
|
+
*/
|
|
215
|
+
const NON_TRAVERSABLE_KEYS = new Set(['parent', 'range', 'loc', 'type']);
|
|
216
|
+
/** Every node of `root`'s subtree, `root` included. */
|
|
217
|
+
function* subtreeOf(root) {
|
|
218
|
+
yield root;
|
|
219
|
+
for (const [key, value] of Object.entries(root)) {
|
|
220
|
+
if (NON_TRAVERSABLE_KEYS.has(key)) {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (Array.isArray(value)) {
|
|
224
|
+
for (const element of value) {
|
|
225
|
+
if (ASTHelpers_1.ASTHelpers.isNode(element)) {
|
|
226
|
+
yield* subtreeOf(element);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
else if (ASTHelpers_1.ASTHelpers.isNode(value)) {
|
|
231
|
+
yield* subtreeOf(value);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** The name a call invokes, for `f()` and for `o.f()` alike. */
|
|
236
|
+
function calleeName(node) {
|
|
237
|
+
const callee = withoutChain(node.callee);
|
|
238
|
+
if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
239
|
+
return callee.name;
|
|
240
|
+
}
|
|
241
|
+
if (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
242
|
+
!callee.computed &&
|
|
243
|
+
callee.property.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
244
|
+
return callee.property.name;
|
|
245
|
+
}
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Whether the node is a `runTransaction(…)` call, under any receiver:
|
|
250
|
+
* `db.runTransaction`, `firestore.runTransaction` and a bare imported
|
|
251
|
+
* `runTransaction` all open a retryable transaction.
|
|
252
|
+
*/
|
|
253
|
+
function isRunTransactionCall(node) {
|
|
254
|
+
return (node.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
255
|
+
calleeName(node) === RUN_TRANSACTION_NAME);
|
|
256
|
+
}
|
|
257
|
+
/** Whether the method opens a transaction anywhere in its own body. */
|
|
258
|
+
function ownsTransaction(fn) {
|
|
259
|
+
for (const node of subtreeOf(fn)) {
|
|
260
|
+
if (isRunTransactionCall(node)) {
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Whether a type name denotes the transaction handle. The rightmost segment is
|
|
268
|
+
* the type's own name, so the qualified spellings — `FirebaseFirestore.
|
|
269
|
+
* Transaction`, `admin.firestore.Transaction` — answer alongside the bare one;
|
|
270
|
+
* a locally aliased import (`import { Transaction as Txn }`) answers through
|
|
271
|
+
* the alias set the file's imports define.
|
|
272
|
+
*/
|
|
273
|
+
function namesTransaction(typeName, aliases) {
|
|
274
|
+
if (typeName.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
275
|
+
return (typeName.name === TRANSACTION_TYPE_NAME || aliases.has(typeName.name));
|
|
276
|
+
}
|
|
277
|
+
if (typeName.type === utils_1.AST_NODE_TYPES.TSQualifiedName) {
|
|
278
|
+
return typeName.right.name === TRANSACTION_TYPE_NAME;
|
|
279
|
+
}
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Whether a declared type hands the method a transaction handle: written
|
|
284
|
+
* directly, as one arm of a union or intersection, or as a property of an
|
|
285
|
+
* object type — the shape a destructured `{ transaction }: { transaction:
|
|
286
|
+
* Transaction }` parameter carries.
|
|
287
|
+
*
|
|
288
|
+
* Type ARGUMENTS are deliberately not entered: `Map<string, Transaction>` or
|
|
289
|
+
* `Promise<Transaction>` describes a collection of handles or a handle yet to
|
|
290
|
+
* exist, neither of which is the attempt-scoped handle this carve-out is about.
|
|
291
|
+
*/
|
|
292
|
+
function declaresTransactionType(annotation, aliases) {
|
|
293
|
+
if (!annotation) {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
if (annotation.type === utils_1.AST_NODE_TYPES.TSTypeReference) {
|
|
297
|
+
return namesTransaction(annotation.typeName, aliases);
|
|
298
|
+
}
|
|
299
|
+
if (annotation.type === utils_1.AST_NODE_TYPES.TSUnionType ||
|
|
300
|
+
annotation.type === utils_1.AST_NODE_TYPES.TSIntersectionType) {
|
|
301
|
+
return annotation.types.some((member) => declaresTransactionType(member, aliases));
|
|
302
|
+
}
|
|
303
|
+
if (annotation.type === utils_1.AST_NODE_TYPES.TSTypeLiteral) {
|
|
304
|
+
return annotation.members.some((member) => member.type === utils_1.AST_NODE_TYPES.TSPropertySignature &&
|
|
305
|
+
declaresTransactionType(member.typeAnnotation?.typeAnnotation, aliases));
|
|
306
|
+
}
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* The type annotation a parameter declares, including the destructuring shapes
|
|
311
|
+
* `parameterAnnotation` does not reach: an object or array pattern carries its
|
|
312
|
+
* annotation on the pattern itself.
|
|
313
|
+
*/
|
|
314
|
+
function bindingAnnotation(param) {
|
|
315
|
+
if (param.type === utils_1.AST_NODE_TYPES.ObjectPattern ||
|
|
316
|
+
param.type === utils_1.AST_NODE_TYPES.ArrayPattern) {
|
|
317
|
+
return param.typeAnnotation?.typeAnnotation;
|
|
318
|
+
}
|
|
319
|
+
return parameterAnnotation(param);
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Whether the method declares a parameter typed as a transaction handle.
|
|
323
|
+
*
|
|
324
|
+
* The test reads the ANNOTATION, not the parameter's name: a parameter merely
|
|
325
|
+
* named `transaction` is as likely to hold a payment or a ledger entry, and the
|
|
326
|
+
* rule's other carve-outs (void result, callback parameter) are annotation-driven
|
|
327
|
+
* for the same reason. A bare `async apply(transaction)` therefore keeps
|
|
328
|
+
* reporting — it declares nothing to honour, and under the `noImplicitAny` its
|
|
329
|
+
* consumers compile with it does not type-check anyway. Where the handle arrives
|
|
330
|
+
* through an unresolvable alias (`args: MembershipArgs`), the call-site test
|
|
331
|
+
* below is what recognises it.
|
|
332
|
+
*/
|
|
333
|
+
function declaresTransactionParameter(params, aliases) {
|
|
334
|
+
return params.some((param) => declaresTransactionType(bindingAnnotation(param), aliases));
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* The expression itself, with any `ChainExpression` wrapper removed. ESTree
|
|
338
|
+
* wraps a whole optional chain in that node, so `this?.body` and
|
|
339
|
+
* `this.body.bind?.(this)` reach a bare member/call test as something else
|
|
340
|
+
* entirely. Nullish spellings carry the transaction handle exactly as the plain
|
|
341
|
+
* ones do, and reading through the wrapper is what keeps the carve-out from
|
|
342
|
+
* lapsing on them — a lapse that would restore the empty-commit autofix.
|
|
343
|
+
*/
|
|
344
|
+
function withoutChain(node) {
|
|
345
|
+
return node.type === utils_1.AST_NODE_TYPES.ChainExpression
|
|
346
|
+
? withoutChain(node.expression)
|
|
347
|
+
: node;
|
|
348
|
+
}
|
|
349
|
+
/** The own-method name a `this.foo` reference reads, if it reads one. */
|
|
350
|
+
function thisMemberName(node) {
|
|
351
|
+
const expression = withoutChain(node);
|
|
352
|
+
if (expression.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
353
|
+
!expression.computed &&
|
|
354
|
+
withoutChain(expression.object).type === utils_1.AST_NODE_TYPES.ThisExpression &&
|
|
355
|
+
expression.property.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
356
|
+
return expression.property.name;
|
|
357
|
+
}
|
|
358
|
+
// `this.body.bind(this)` passes the same method, one wrapper out.
|
|
359
|
+
if (expression.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
360
|
+
expression.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
361
|
+
!expression.callee.computed &&
|
|
362
|
+
expression.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
363
|
+
expression.callee.property.name === 'bind') {
|
|
364
|
+
return thisMemberName(expression.callee.object);
|
|
365
|
+
}
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
/** Whether the subtree mentions the binding, under any nesting. */
|
|
369
|
+
function mentionsBinding(node, name) {
|
|
370
|
+
for (const descendant of subtreeOf(node)) {
|
|
371
|
+
if (descendant.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
372
|
+
descendant.name === name) {
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Own methods a `runTransaction` argument hands the attempt to: the method
|
|
380
|
+
* passed as the callback itself, and every `this.method(…)` the callback body
|
|
381
|
+
* invokes with the attempt's handle among its arguments.
|
|
382
|
+
*
|
|
383
|
+
* Passing the handle on is what makes a method part of the attempt, so a
|
|
384
|
+
* helper the callback calls WITHOUT it — a config read, a lookup that takes no
|
|
385
|
+
* transaction — is untouched and keeps reporting.
|
|
386
|
+
*/
|
|
387
|
+
function collectTransactionParticipants(argument, participants) {
|
|
388
|
+
const passed = thisMemberName(argument);
|
|
389
|
+
if (passed) {
|
|
390
|
+
participants.add(passed);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (argument.type !== utils_1.AST_NODE_TYPES.ArrowFunctionExpression &&
|
|
394
|
+
argument.type !== utils_1.AST_NODE_TYPES.FunctionExpression) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const handle = argument.params[0];
|
|
398
|
+
if (handle?.type !== utils_1.AST_NODE_TYPES.Identifier) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
for (const node of subtreeOf(argument.body)) {
|
|
402
|
+
if (node.type !== utils_1.AST_NODE_TYPES.CallExpression) {
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
const method = thisMemberName(node.callee);
|
|
406
|
+
if (method &&
|
|
407
|
+
node.arguments.some((arg) => mentionsBinding(arg, handle.name))) {
|
|
408
|
+
participants.add(method);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
/** Every own method the class hands a transaction handle to. */
|
|
413
|
+
function transactionParticipantsOf(body) {
|
|
414
|
+
const participants = new Set();
|
|
415
|
+
for (const node of subtreeOf(body)) {
|
|
416
|
+
if (!isRunTransactionCall(node)) {
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
for (const argument of node.arguments) {
|
|
420
|
+
collectTransactionParticipants(argument, participants);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return participants;
|
|
424
|
+
}
|
|
425
|
+
/** The statically known name of a method, for matching call sites against it. */
|
|
426
|
+
function methodName(node) {
|
|
427
|
+
if (node.computed) {
|
|
428
|
+
return undefined;
|
|
429
|
+
}
|
|
430
|
+
const { key } = node;
|
|
431
|
+
if (key.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
432
|
+
return key.name;
|
|
433
|
+
}
|
|
434
|
+
if (key.type === utils_1.AST_NODE_TYPES.Literal && typeof key.value === 'string') {
|
|
435
|
+
return key.value;
|
|
436
|
+
}
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
199
439
|
/**
|
|
200
440
|
* Matches a memoize decorator in supported syntaxes:
|
|
201
441
|
* - @Alias()
|
|
@@ -290,6 +530,66 @@ exports.enforceMemoizeAsync = (0, createRule_1.createRule)({
|
|
|
290
530
|
}
|
|
291
531
|
return memoizeImportCache;
|
|
292
532
|
};
|
|
533
|
+
/**
|
|
534
|
+
* Local names bound to an imported `Transaction` type, so that an aliased
|
|
535
|
+
* import (`import { Transaction as Txn } from 'firebase-admin/firestore'`)
|
|
536
|
+
* is read as the handle it is. The module is not constrained: a handle is
|
|
537
|
+
* re-exported through as many paths as a codebase has layers, and the
|
|
538
|
+
* imported NAME already carries the signal.
|
|
539
|
+
*/
|
|
540
|
+
let transactionAliasCache = null;
|
|
541
|
+
const transactionAliases = () => {
|
|
542
|
+
if (!transactionAliasCache) {
|
|
543
|
+
transactionAliasCache = new Set();
|
|
544
|
+
for (const statement of context.sourceCode.ast.body) {
|
|
545
|
+
if (statement.type !== utils_1.AST_NODE_TYPES.ImportDeclaration) {
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
for (const spec of statement.specifiers) {
|
|
549
|
+
if (spec.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
550
|
+
spec.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
551
|
+
spec.imported.name === TRANSACTION_TYPE_NAME) {
|
|
552
|
+
transactionAliasCache.add(spec.local.name);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return transactionAliasCache;
|
|
558
|
+
};
|
|
559
|
+
/**
|
|
560
|
+
* The class-level scan is shared by every method of the class, so it runs
|
|
561
|
+
* once per class body rather than once per candidate method.
|
|
562
|
+
*/
|
|
563
|
+
const participantCache = new WeakMap();
|
|
564
|
+
const transactionParticipants = (body) => {
|
|
565
|
+
let participants = participantCache.get(body);
|
|
566
|
+
if (!participants) {
|
|
567
|
+
participants = transactionParticipantsOf(body);
|
|
568
|
+
participantCache.set(body, participants);
|
|
569
|
+
}
|
|
570
|
+
return participants;
|
|
571
|
+
};
|
|
572
|
+
/**
|
|
573
|
+
* Whether the method takes part in a database transaction attempt, either
|
|
574
|
+
* by opening one or by being handed the attempt's handle. Caching such a
|
|
575
|
+
* method is never an optimisation: a retried attempt would replay the first
|
|
576
|
+
* attempt's promise, writing nothing on its own handle while the caller
|
|
577
|
+
* reads a success it did not get.
|
|
578
|
+
*/
|
|
579
|
+
const participatesInTransaction = (node, fn) => {
|
|
580
|
+
if (declaresTransactionParameter(fn.params, transactionAliases())) {
|
|
581
|
+
return true;
|
|
582
|
+
}
|
|
583
|
+
if (ownsTransaction(fn)) {
|
|
584
|
+
return true;
|
|
585
|
+
}
|
|
586
|
+
const body = node.parent;
|
|
587
|
+
if (body?.type !== utils_1.AST_NODE_TYPES.ClassBody) {
|
|
588
|
+
return false;
|
|
589
|
+
}
|
|
590
|
+
const name = methodName(node);
|
|
591
|
+
return name !== undefined && transactionParticipants(body).has(name);
|
|
592
|
+
};
|
|
293
593
|
return {
|
|
294
594
|
MethodDefinition(node) {
|
|
295
595
|
// Only process async instance methods (skip static methods)
|
|
@@ -341,6 +641,17 @@ exports.enforceMemoizeAsync = (0, createRule_1.createRule)({
|
|
|
341
641
|
if (declaresVoidResult(node.value.returnType)) {
|
|
342
642
|
return;
|
|
343
643
|
}
|
|
644
|
+
// A transaction handle is valid only for the attempt that created it,
|
|
645
|
+
// so a result derived from one must not outlive that attempt. Caching
|
|
646
|
+
// the body of a `runTransaction` callback — or the method that owns the
|
|
647
|
+
// call — turns the retry a concurrent write provokes into a silent
|
|
648
|
+
// no-op: the retry replays the first attempt's promise, queues nothing
|
|
649
|
+
// on its own handle, commits empty, and reports the first attempt's
|
|
650
|
+
// value as success. The fixer would apply that unattended under
|
|
651
|
+
// `--fix`, so both report and fix are withheld.
|
|
652
|
+
if (participatesInTransaction(node, node.value)) {
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
344
655
|
const { aliases: memoizeAliases, namespaces: memoizeNamespaces } = memoizeImports();
|
|
345
656
|
const hasMemoizeImport = memoizeAliases.size > 0 || memoizeNamespaces.size > 0;
|
|
346
657
|
// Check if method already has @Memoize or @Memoize() decorator
|
|
@@ -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
|
}
|
|
@@ -103,33 +103,205 @@ function aliasDeclarationNamed(statement, name) {
|
|
|
103
103
|
: undefined;
|
|
104
104
|
}
|
|
105
105
|
/**
|
|
106
|
-
*
|
|
107
|
-
* an
|
|
106
|
+
* The string members of `const NAME = ['a', 'b'] as const`, or `undefined` when
|
|
107
|
+
* the statement declares something else or an element is not a string literal.
|
|
108
|
+
*
|
|
109
|
+
* `prefer-union-from-const-array` rewrites a string-literal union alias into
|
|
110
|
+
* exactly this pair of declarations plus `(typeof NAME)[number]`, so a keep-list
|
|
111
|
+
* that reads decidable before that transform has to stay decidable after it.
|
|
112
|
+
*/
|
|
113
|
+
function constArrayMembersNamed(statement, name) {
|
|
114
|
+
const declared = (0, lexicalScope_1.declarationOf)(statement);
|
|
115
|
+
if (declared.type !== utils_1.AST_NODE_TYPES.VariableDeclaration ||
|
|
116
|
+
declared.kind !== 'const') {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
for (const declarator of declared.declarations) {
|
|
120
|
+
if (declarator.id.type !== utils_1.AST_NODE_TYPES.Identifier ||
|
|
121
|
+
declarator.id.name !== name ||
|
|
122
|
+
declarator.init?.type !== utils_1.AST_NODE_TYPES.TSAsExpression) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const { expression, typeAnnotation } = declarator.init;
|
|
126
|
+
// Without `as const` the elements widen to `string`, and the indexed access
|
|
127
|
+
// yields `string` rather than a union of the literals.
|
|
128
|
+
const isConstAssertion = typeAnnotation.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
|
|
129
|
+
typeAnnotation.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
130
|
+
typeAnnotation.typeName.name === 'const';
|
|
131
|
+
if (!isConstAssertion || expression.type !== utils_1.AST_NODE_TYPES.ArrayExpression)
|
|
132
|
+
return undefined;
|
|
133
|
+
const members = [];
|
|
134
|
+
for (const element of expression.elements) {
|
|
135
|
+
if (element?.type !== utils_1.AST_NODE_TYPES.Literal ||
|
|
136
|
+
typeof element.value !== 'string') {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
members.push(element.value);
|
|
140
|
+
}
|
|
141
|
+
return members;
|
|
142
|
+
}
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Lexical lookup over pre-collected statement lists, innermost first, so an
|
|
147
|
+
* inner declaration shadows a same-named outer one.
|
|
108
148
|
*
|
|
109
149
|
* Resolution is by scope rather than by a map built during traversal because
|
|
110
150
|
* type aliases hoist: a component declared above its own props alias must still
|
|
111
151
|
* resolve it.
|
|
112
152
|
*/
|
|
113
153
|
function aliasResolverFrom(lists, startIndex = 0) {
|
|
114
|
-
return
|
|
115
|
-
|
|
116
|
-
for (
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
154
|
+
return {
|
|
155
|
+
typeAlias(name) {
|
|
156
|
+
for (let index = startIndex; index < lists.length; index += 1) {
|
|
157
|
+
for (const statement of lists[index]) {
|
|
158
|
+
const declaration = aliasDeclarationNamed(statement, name);
|
|
159
|
+
if (declaration) {
|
|
160
|
+
return {
|
|
161
|
+
typeNode: declaration.typeAnnotation,
|
|
162
|
+
resolve: aliasResolverFrom(lists, index),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
123
165
|
}
|
|
124
166
|
}
|
|
125
|
-
|
|
126
|
-
|
|
167
|
+
return undefined;
|
|
168
|
+
},
|
|
169
|
+
constArrayKeys(name) {
|
|
170
|
+
for (let index = startIndex; index < lists.length; index += 1) {
|
|
171
|
+
for (const statement of lists[index]) {
|
|
172
|
+
const members = constArrayMembersNamed(statement, name);
|
|
173
|
+
if (members)
|
|
174
|
+
return members;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return undefined;
|
|
178
|
+
},
|
|
127
179
|
};
|
|
128
180
|
}
|
|
129
181
|
function aliasResolverAt(from) {
|
|
130
182
|
return aliasResolverFrom((0, lexicalScope_1.enclosingStatementLists)(from));
|
|
131
183
|
}
|
|
132
|
-
|
|
184
|
+
/**
|
|
185
|
+
* The property names a `Pick<T, K>` keep-list names, or `null` when the list is
|
|
186
|
+
* not decidable from syntax alone.
|
|
187
|
+
*
|
|
188
|
+
* `null` means "prove nothing", never "keeps nothing": a keep-list spelled as a
|
|
189
|
+
* type parameter, `keyof T`, `string`, or a template-literal type can include
|
|
190
|
+
* `children`, and treating an undecidable list as an empty one would exempt
|
|
191
|
+
* exactly those.
|
|
192
|
+
*/
|
|
193
|
+
function stringLiteralKeySet(node, resolveAlias, seen = new Set()) {
|
|
194
|
+
if (node.type === utils_1.AST_NODE_TYPES.TSLiteralType) {
|
|
195
|
+
return node.literal.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
196
|
+
typeof node.literal.value === 'string'
|
|
197
|
+
? new Set([node.literal.value])
|
|
198
|
+
: null;
|
|
199
|
+
}
|
|
200
|
+
if (node.type === utils_1.AST_NODE_TYPES.TSUnionType) {
|
|
201
|
+
const keys = new Set();
|
|
202
|
+
for (const member of node.types) {
|
|
203
|
+
const memberKeys = stringLiteralKeySet(member, resolveAlias, seen);
|
|
204
|
+
if (!memberKeys)
|
|
205
|
+
return null;
|
|
206
|
+
for (const key of memberKeys) {
|
|
207
|
+
keys.add(key);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return keys;
|
|
211
|
+
}
|
|
212
|
+
// `(typeof KEYS)[number]` over a `const KEYS = [...] as const` names exactly
|
|
213
|
+
// the array's members — the spelling `prefer-union-from-const-array` rewrites
|
|
214
|
+
// a literal union alias into.
|
|
215
|
+
if (node.type === utils_1.AST_NODE_TYPES.TSIndexedAccessType &&
|
|
216
|
+
node.indexType.type === utils_1.AST_NODE_TYPES.TSNumberKeyword &&
|
|
217
|
+
node.objectType.type === utils_1.AST_NODE_TYPES.TSTypeQuery &&
|
|
218
|
+
node.objectType.exprName.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
219
|
+
const members = resolveAlias?.constArrayKeys(node.objectType.exprName.name);
|
|
220
|
+
return members ? new Set(members) : null;
|
|
221
|
+
}
|
|
222
|
+
// A bare alias standing for a literal union (`type Keys = 'sx' | 'size'`) is
|
|
223
|
+
// as decidable as the union written inline. A parameterized reference is not:
|
|
224
|
+
// it is a computed key list whose members this rule cannot enumerate.
|
|
225
|
+
if (node.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
|
|
226
|
+
node.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
227
|
+
!node.typeParameters) {
|
|
228
|
+
const name = node.typeName.name;
|
|
229
|
+
if (!resolveAlias || seen.has(name))
|
|
230
|
+
return null;
|
|
231
|
+
const alias = resolveAlias.typeAlias(name);
|
|
232
|
+
if (!alias)
|
|
233
|
+
return null;
|
|
234
|
+
// Guarding before the recursion terminates a self-referential alias.
|
|
235
|
+
seen.add(name);
|
|
236
|
+
return stringLiteralKeySet(alias.typeNode, alias.resolve, seen);
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Whether a closed object type provably declares no `propertyName` member.
|
|
242
|
+
*
|
|
243
|
+
* An index signature reopens the type — `{ [key: string]: unknown }` admits
|
|
244
|
+
* `children` — and so does a computed key the rule cannot evaluate, since the
|
|
245
|
+
* constant behind `[KEY]` may well be `'children'`. Both surrender the proof.
|
|
246
|
+
*/
|
|
247
|
+
function typeLiteralExcludesProperty(node, propertyName) {
|
|
248
|
+
return node.members.every((member) => {
|
|
249
|
+
if (member.type === utils_1.AST_NODE_TYPES.TSIndexSignature)
|
|
250
|
+
return false;
|
|
251
|
+
if (member.type !== utils_1.AST_NODE_TYPES.TSPropertySignature &&
|
|
252
|
+
member.type !== utils_1.AST_NODE_TYPES.TSMethodSignature) {
|
|
253
|
+
// Call and construct signatures declare no named member.
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
const key = member.key;
|
|
257
|
+
if (member.computed) {
|
|
258
|
+
return (key.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
259
|
+
String(key.value) !== propertyName);
|
|
260
|
+
}
|
|
261
|
+
if (key.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
262
|
+
return key.name !== propertyName;
|
|
263
|
+
}
|
|
264
|
+
if (key.type === utils_1.AST_NODE_TYPES.Literal) {
|
|
265
|
+
return String(key.value) !== propertyName;
|
|
266
|
+
}
|
|
267
|
+
return false;
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Generic wrappers that re-map a type's own members without contributing any of
|
|
272
|
+
* their own, so their argument still stands for the whole props type.
|
|
273
|
+
*
|
|
274
|
+
* `PropsWithChildren` is the counter-example the list exists for: it ADDS
|
|
275
|
+
* `children`, so a proof about its argument says nothing about the wrapper.
|
|
276
|
+
*/
|
|
277
|
+
const PROPERTY_PRESERVING_WRAPPERS = new Set([
|
|
278
|
+
'Readonly',
|
|
279
|
+
'Required',
|
|
280
|
+
'Partial',
|
|
281
|
+
'NonNullable',
|
|
282
|
+
]);
|
|
283
|
+
function wrapperNameOf(typeName) {
|
|
284
|
+
if (typeName.type === utils_1.AST_NODE_TYPES.Identifier)
|
|
285
|
+
return typeName.name;
|
|
286
|
+
if (typeName.type === utils_1.AST_NODE_TYPES.TSQualifiedName) {
|
|
287
|
+
return typeName.right.type === utils_1.AST_NODE_TYPES.Identifier
|
|
288
|
+
? typeName.right.name
|
|
289
|
+
: null;
|
|
290
|
+
}
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Whether a type node provably lacks `propertyName`.
|
|
295
|
+
*
|
|
296
|
+
* `closedLiteralCounts` records whether this position IS the props type rather
|
|
297
|
+
* than merely a part of it. Two of the proofs below — a `Pick<>` keep-list and
|
|
298
|
+
* a closed object literal — describe the type they sit on, so they only carry
|
|
299
|
+
* to the binding when nothing can add members on the way back out. The blanket
|
|
300
|
+
* recursion through a type reference's arguments is where that distinction
|
|
301
|
+
* bites: `PropsWithChildren<{ sx?: string }>` and `Record<string, { a: number }>`
|
|
302
|
+
* both wrap a children-free argument in something that admits `children`.
|
|
303
|
+
*/
|
|
304
|
+
function typeNodeExcludesProperty(node, propertyName, resolveAlias, seen = new Set(), closedLiteralCounts = false) {
|
|
133
305
|
if (node.type === utils_1.AST_NODE_TYPES.TSTypeReference) {
|
|
134
306
|
const typeName = node.typeName.type === utils_1.AST_NODE_TYPES.Identifier
|
|
135
307
|
? node.typeName.name
|
|
@@ -141,34 +313,62 @@ function typeNodeExcludesProperty(node, propertyName, resolveAlias, seen = new S
|
|
|
141
313
|
return true;
|
|
142
314
|
}
|
|
143
315
|
}
|
|
316
|
+
// A keep-list is a stronger guarantee than an omit-list: `Pick` drops every
|
|
317
|
+
// member it does not name, so a fully decidable list without `propertyName`
|
|
318
|
+
// excludes it outright.
|
|
319
|
+
if (closedLiteralCounts &&
|
|
320
|
+
typeName === 'Pick' &&
|
|
321
|
+
node.typeParameters?.params?.[1]) {
|
|
322
|
+
const kept = stringLiteralKeySet(node.typeParameters.params[1], resolveAlias);
|
|
323
|
+
if (kept && !kept.has(propertyName)) {
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
144
327
|
if (node.typeParameters?.params) {
|
|
145
|
-
|
|
328
|
+
const argumentsAreTheType = closedLiteralCounts &&
|
|
329
|
+
PROPERTY_PRESERVING_WRAPPERS.has(wrapperNameOf(node.typeName) ?? '');
|
|
330
|
+
return node.typeParameters.params.some((param) => typeNodeExcludesProperty(param, propertyName, resolveAlias, seen, argumentsAreTheType));
|
|
146
331
|
}
|
|
147
|
-
|
|
148
|
-
|
|
332
|
+
// Keyed by position as well as by name: the same alias proves different
|
|
333
|
+
// things in a props position than inside an arbitrary generic, so a visit
|
|
334
|
+
// in one position must not suppress the visit in the other.
|
|
335
|
+
const seenKey = `${closedLiteralCounts ? 'props' : 'part'}:${typeName}`;
|
|
336
|
+
if (typeName && resolveAlias && !seen.has(seenKey)) {
|
|
337
|
+
const alias = resolveAlias.typeAlias(typeName);
|
|
149
338
|
if (alias) {
|
|
150
339
|
// Guarding before the recursion terminates a self-referential alias.
|
|
151
|
-
seen.add(
|
|
152
|
-
if (typeNodeExcludesProperty(alias.typeNode, propertyName, alias.resolve, seen)) {
|
|
340
|
+
seen.add(seenKey);
|
|
341
|
+
if (typeNodeExcludesProperty(alias.typeNode, propertyName, alias.resolve, seen, closedLiteralCounts)) {
|
|
153
342
|
return true;
|
|
154
343
|
}
|
|
155
344
|
}
|
|
156
345
|
}
|
|
157
346
|
}
|
|
347
|
+
if (node.type === utils_1.AST_NODE_TYPES.TSTypeLiteral) {
|
|
348
|
+
return (closedLiteralCounts && typeLiteralExcludesProperty(node, propertyName));
|
|
349
|
+
}
|
|
158
350
|
if (node.type === utils_1.AST_NODE_TYPES.TSUnionType) {
|
|
159
|
-
return node.types.every((typeNode) => typeNodeExcludesProperty(typeNode, propertyName, resolveAlias, seen));
|
|
351
|
+
return node.types.every((typeNode) => typeNodeExcludesProperty(typeNode, propertyName, resolveAlias, seen, closedLiteralCounts));
|
|
160
352
|
}
|
|
161
353
|
if (node.type === utils_1.AST_NODE_TYPES.TSIntersectionType) {
|
|
162
|
-
return node.types.every((typeNode) => typeNodeExcludesProperty(typeNode, propertyName, resolveAlias, seen));
|
|
354
|
+
return node.types.every((typeNode) => typeNodeExcludesProperty(typeNode, propertyName, resolveAlias, seen, closedLiteralCounts));
|
|
163
355
|
}
|
|
164
356
|
return false;
|
|
165
357
|
}
|
|
166
|
-
|
|
167
|
-
|
|
358
|
+
/**
|
|
359
|
+
* Entry point for a type node that stands for a whole props type, whether it
|
|
360
|
+
* was written as a parameter annotation or supplied as a `forwardRef` type
|
|
361
|
+
* argument.
|
|
362
|
+
*/
|
|
363
|
+
function propsTypeNodeExcludesProperty(typeNode, propertyName, resolveAlias) {
|
|
364
|
+
if (!typeNode)
|
|
168
365
|
return false;
|
|
169
|
-
return typeNodeExcludesProperty(
|
|
366
|
+
return typeNodeExcludesProperty(typeNode, propertyName, resolveAlias, new Set(), true);
|
|
367
|
+
}
|
|
368
|
+
function typeAnnotationExcludesProperty(annotation, propertyName, resolveAlias) {
|
|
369
|
+
return propsTypeNodeExcludesProperty(annotation?.typeAnnotation, propertyName, resolveAlias);
|
|
170
370
|
}
|
|
171
|
-
function collectRestBindingsFromPattern(pattern, ctx,
|
|
371
|
+
function collectRestBindingsFromPattern(pattern, ctx, typeNode, resolveAlias, sourceChildrenSourceId) {
|
|
172
372
|
const childrenPresent = patternHasChildrenProperty(pattern);
|
|
173
373
|
for (const prop of pattern.properties) {
|
|
174
374
|
if (prop.type === utils_1.AST_NODE_TYPES.RestElement &&
|
|
@@ -177,7 +377,7 @@ function collectRestBindingsFromPattern(pattern, ctx, annotation, resolveAlias,
|
|
|
177
377
|
ctx.bindings.set(prop.argument.name, {
|
|
178
378
|
identifier: prop.argument,
|
|
179
379
|
childrenExcluded: childrenPresent,
|
|
180
|
-
typeAnnotationExcludesProperty:
|
|
380
|
+
typeAnnotationExcludesProperty: propsTypeNodeExcludesProperty(typeNode, 'children', resolveAlias),
|
|
181
381
|
childrenSourceId: sourceChildrenSourceId ?? prop.argument.name,
|
|
182
382
|
});
|
|
183
383
|
}
|
|
@@ -212,13 +412,31 @@ function recordChildrenValueBindingsFromPattern(pattern, ctx, sourceChildrenSour
|
|
|
212
412
|
}
|
|
213
413
|
}
|
|
214
414
|
}
|
|
215
|
-
|
|
415
|
+
/**
|
|
416
|
+
* The props type a parameter carries: its own annotation when it has one, and
|
|
417
|
+
* otherwise the type the call site supplies contextually.
|
|
418
|
+
*
|
|
419
|
+
* An explicit annotation always wins. It is the type the body is checked
|
|
420
|
+
* against, so a `forwardRef<E, Clean>((props: MenuProps, ref) => …)` still
|
|
421
|
+
* carries whatever `MenuProps` declares.
|
|
422
|
+
*/
|
|
423
|
+
function propsTypeNodeOf(param, contextualTypeNode) {
|
|
424
|
+
// A `TSParameterProperty` (a constructor's `private x: T`) carries its
|
|
425
|
+
// annotation on the parameter it wraps and never takes part in a component's
|
|
426
|
+
// props, so it simply has no props type of its own.
|
|
427
|
+
const own = param.type === utils_1.AST_NODE_TYPES.TSParameterProperty
|
|
428
|
+
? undefined
|
|
429
|
+
: param.typeAnnotation?.typeAnnotation;
|
|
430
|
+
return own ?? contextualTypeNode;
|
|
431
|
+
}
|
|
432
|
+
function recordParamBindings(param, ctx, resolveAlias, contextualTypeNode) {
|
|
433
|
+
const typeNode = propsTypeNodeOf(param, contextualTypeNode);
|
|
216
434
|
if (param.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
217
435
|
ctx.propsLikeIdentifiers.add(param.name);
|
|
218
436
|
ctx.bindings.set(param.name, {
|
|
219
437
|
identifier: param,
|
|
220
438
|
childrenExcluded: false,
|
|
221
|
-
typeAnnotationExcludesProperty:
|
|
439
|
+
typeAnnotationExcludesProperty: propsTypeNodeExcludesProperty(typeNode, 'children', resolveAlias),
|
|
222
440
|
childrenSourceId: param.name,
|
|
223
441
|
});
|
|
224
442
|
return;
|
|
@@ -229,20 +447,61 @@ function recordParamBindings(param, ctx, resolveAlias) {
|
|
|
229
447
|
ctx.bindings.set(param.left.name, {
|
|
230
448
|
identifier: param.left,
|
|
231
449
|
childrenExcluded: false,
|
|
232
|
-
typeAnnotationExcludesProperty:
|
|
450
|
+
typeAnnotationExcludesProperty: propsTypeNodeExcludesProperty(typeNode, 'children', resolveAlias),
|
|
233
451
|
childrenSourceId: param.left.name,
|
|
234
452
|
});
|
|
235
453
|
return;
|
|
236
454
|
}
|
|
237
455
|
if (param.type === utils_1.AST_NODE_TYPES.AssignmentPattern &&
|
|
238
456
|
param.left.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
|
|
239
|
-
collectRestBindingsFromPattern(param.left, ctx,
|
|
457
|
+
collectRestBindingsFromPattern(param.left, ctx, typeNode, resolveAlias);
|
|
240
458
|
return;
|
|
241
459
|
}
|
|
242
460
|
if (param.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
|
|
243
|
-
collectRestBindingsFromPattern(param, ctx,
|
|
461
|
+
collectRestBindingsFromPattern(param, ctx, typeNode, resolveAlias);
|
|
244
462
|
}
|
|
245
463
|
}
|
|
464
|
+
/**
|
|
465
|
+
* The props type a `forwardRef<Element, Props>(…)` call supplies to its render
|
|
466
|
+
* callback, whose parameters carry no annotation of their own.
|
|
467
|
+
*
|
|
468
|
+
* Without this the props type is invisible in the `forwardRef` spelling, so
|
|
469
|
+
* even the documented `Omit<…, 'children'>` remedy could not be seen and the
|
|
470
|
+
* rule reported on props it could prove children-free in every other spelling.
|
|
471
|
+
*/
|
|
472
|
+
function forwardRefPropsTypeNode(node) {
|
|
473
|
+
const call = node.parent;
|
|
474
|
+
if (call?.type !== utils_1.AST_NODE_TYPES.CallExpression)
|
|
475
|
+
return undefined;
|
|
476
|
+
if (call.arguments[0] !== node)
|
|
477
|
+
return undefined;
|
|
478
|
+
// An optional call detaches the type arguments onto a
|
|
479
|
+
// `TSInstantiationExpression` — `forwardRef<E, P>?.(…)` parses as
|
|
480
|
+
// instantiate-then-call — so both the callee and the type arguments are read
|
|
481
|
+
// through it. The node predates the `LeftHandSideExpression` union this
|
|
482
|
+
// version declares, hence the hand-written narrowing.
|
|
483
|
+
const rawCallee = call.callee;
|
|
484
|
+
const instantiation = rawCallee.type === utils_1.AST_NODE_TYPES.TSInstantiationExpression
|
|
485
|
+
? rawCallee
|
|
486
|
+
: undefined;
|
|
487
|
+
const callee = instantiation?.expression ?? call.callee;
|
|
488
|
+
const calleeName = callee.type === utils_1.AST_NODE_TYPES.Identifier
|
|
489
|
+
? callee.name
|
|
490
|
+
: callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
491
|
+
!callee.computed &&
|
|
492
|
+
callee.property.type === utils_1.AST_NODE_TYPES.Identifier
|
|
493
|
+
? callee.property.name
|
|
494
|
+
: null;
|
|
495
|
+
if (calleeName !== 'forwardRef')
|
|
496
|
+
return undefined;
|
|
497
|
+
// `typeParameters` is where @typescript-eslint/utils 5 puts a call's type
|
|
498
|
+
// arguments; `typeArguments` is the name later majors use. Reading both keeps
|
|
499
|
+
// this working across the rename.
|
|
500
|
+
const instantiated = (instantiation ?? call);
|
|
501
|
+
const typeArguments = instantiated.typeParameters ?? instantiated.typeArguments;
|
|
502
|
+
// The first type argument is the element type; the props type is the second.
|
|
503
|
+
return typeArguments?.params?.[1];
|
|
504
|
+
}
|
|
246
505
|
function findNearestComponentContext(stack) {
|
|
247
506
|
for (let i = stack.length - 1; i >= 0; i -= 1) {
|
|
248
507
|
if (stack[i].isComponent)
|
|
@@ -460,9 +719,12 @@ exports.preventChildrenClobber = (0, createRule_1.createRule)({
|
|
|
460
719
|
// ESLint has assigned `parent` up the chain by the time this visitor
|
|
461
720
|
// runs, but not yet onto the parameter's own type annotation.
|
|
462
721
|
const resolveAlias = aliasResolverAt(node);
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
722
|
+
// Only the first parameter receives the contextual props type; the
|
|
723
|
+
// second is the forwarded ref.
|
|
724
|
+
const contextualPropsType = forwardRefPropsTypeNode(node);
|
|
725
|
+
node.params.forEach((param, index) => {
|
|
726
|
+
recordParamBindings(param, ctx, resolveAlias, index === 0 ? contextualPropsType : undefined);
|
|
727
|
+
});
|
|
466
728
|
}
|
|
467
729
|
functionStack.push(ctx);
|
|
468
730
|
},
|
|
@@ -528,7 +790,7 @@ exports.preventChildrenClobber = (0, createRule_1.createRule)({
|
|
|
528
790
|
const initBinding = findBinding(init.name, functionStack);
|
|
529
791
|
const sourceChildrenSourceId = initBinding?.childrenSourceId ?? init.name;
|
|
530
792
|
recordChildrenValueBindingsFromPattern(id, componentCtx, sourceChildrenSourceId);
|
|
531
|
-
collectRestBindingsFromPattern(id, componentCtx, id.typeAnnotation ?? null, aliasResolverAt(node), sourceChildrenSourceId);
|
|
793
|
+
collectRestBindingsFromPattern(id, componentCtx, id.typeAnnotation?.typeAnnotation ?? null, aliasResolverAt(node), sourceChildrenSourceId);
|
|
532
794
|
}
|
|
533
795
|
},
|
|
534
796
|
JSXElement(node) {
|
|
@@ -422,6 +422,109 @@ function collectReferencedNames(node) {
|
|
|
422
422
|
visit(node, null);
|
|
423
423
|
return names;
|
|
424
424
|
}
|
|
425
|
+
// Type-space shells whose contents are erased at runtime. A reference inside
|
|
426
|
+
// one (`const x: ReturnType<typeof buildHit> = …`) never reads the binding at
|
|
427
|
+
// module evaluation, so it must not count as an eager reference — counting it
|
|
428
|
+
// would withhold autofixes that are perfectly safe to apply.
|
|
429
|
+
const TYPE_SPACE_NODE_TYPES = new Set([
|
|
430
|
+
'TSTypeAnnotation',
|
|
431
|
+
'TSTypeParameterInstantiation',
|
|
432
|
+
'TSTypeParameterDeclaration',
|
|
433
|
+
'TSTypeAliasDeclaration',
|
|
434
|
+
'TSInterfaceDeclaration',
|
|
435
|
+
'TSTypeQuery',
|
|
436
|
+
'TSDeclareFunction',
|
|
437
|
+
]);
|
|
438
|
+
// Names a statement reads at MODULE EVALUATION time. A read inside a function
|
|
439
|
+
// body is deferred to call time and constrains nothing — except when that
|
|
440
|
+
// function is an immediately invoked callee, which runs during evaluation. The
|
|
441
|
+
// distinction is the whole point of this rule: callers referencing helpers
|
|
442
|
+
// inside their bodies is the safe pattern being enforced, while an initializer
|
|
443
|
+
// calling a helper runs the moment the module loads.
|
|
444
|
+
function collectEagerlyReferencedNames(node) {
|
|
445
|
+
const names = new Set();
|
|
446
|
+
const visit = (current, parent) => {
|
|
447
|
+
if (!current || !ASTHelpers_1.ASTHelpers.isNode(current)) {
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (TYPE_SPACE_NODE_TYPES.has(current.type)) {
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
// An `as`/`satisfies` wrapper evaluates only its expression; the type side
|
|
454
|
+
// is erased.
|
|
455
|
+
if (current.type === 'TSAsExpression' ||
|
|
456
|
+
current.type === 'TSSatisfiesExpression') {
|
|
457
|
+
visit(current.expression, current);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (current.type === 'FunctionDeclaration' ||
|
|
461
|
+
current.type === 'FunctionExpression' ||
|
|
462
|
+
current.type === 'ArrowFunctionExpression') {
|
|
463
|
+
// Deferred to call time — unless this function is the callee of the call
|
|
464
|
+
// being visited, which the CallExpression case enters directly.
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (current.type === 'CallExpression' &&
|
|
468
|
+
(current.callee.type === 'ArrowFunctionExpression' ||
|
|
469
|
+
current.callee.type === 'FunctionExpression')) {
|
|
470
|
+
// An IIFE's body runs during module evaluation, so its reads are eager.
|
|
471
|
+
visit(current.callee.body, current.callee);
|
|
472
|
+
current.callee.params.forEach((param) => visit(param, current.callee));
|
|
473
|
+
current.arguments.forEach((argument) => visit(argument, current));
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (current.type === 'ClassDeclaration' ||
|
|
477
|
+
current.type === 'ClassExpression') {
|
|
478
|
+
// A class's heritage clause, computed member keys, static initializers,
|
|
479
|
+
// and static blocks all run at evaluation; method bodies and instance
|
|
480
|
+
// property values wait for construction.
|
|
481
|
+
visit(current.superClass, current);
|
|
482
|
+
current.body.body.forEach((member) => {
|
|
483
|
+
if (member.type === 'StaticBlock') {
|
|
484
|
+
member.body.forEach((statement) => visit(statement, member));
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if ('computed' in member && member.computed && 'key' in member) {
|
|
488
|
+
visit(member.key, member);
|
|
489
|
+
}
|
|
490
|
+
if (member.type === 'PropertyDefinition' &&
|
|
491
|
+
member.static &&
|
|
492
|
+
member.value) {
|
|
493
|
+
visit(member.value, member);
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (current.type === 'Identifier') {
|
|
499
|
+
const isMemberProperty = parent?.type === 'MemberExpression' &&
|
|
500
|
+
parent.property === current &&
|
|
501
|
+
!parent.computed;
|
|
502
|
+
const isObjectKey = parent?.type === 'Property' &&
|
|
503
|
+
parent.key === current &&
|
|
504
|
+
!parent.computed;
|
|
505
|
+
if (!isMemberProperty && !isObjectKey) {
|
|
506
|
+
names.add(current.name);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
Object.values(current).forEach((value) => {
|
|
510
|
+
if (!value || value === current || current.parent === value) {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (Array.isArray(value)) {
|
|
514
|
+
value.forEach((child) => {
|
|
515
|
+
if (ASTHelpers_1.ASTHelpers.isNode(child)) {
|
|
516
|
+
visit(child, current);
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
else if (ASTHelpers_1.ASTHelpers.isNode(value)) {
|
|
521
|
+
visit(value, current);
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
};
|
|
525
|
+
visit(node, null);
|
|
526
|
+
return names;
|
|
527
|
+
}
|
|
425
528
|
// Names bound by an interleaved VALUE declaration (const/let/var) — but not by a
|
|
426
529
|
// reorderable function. Hoisting a function above a value binding it references
|
|
427
530
|
// is a genuine runtime declare-before-use. Type aliases are tracked separately
|
|
@@ -548,6 +651,50 @@ function reorderHoistsFunctionAboveDependency(regionStatements, functionStatemen
|
|
|
548
651
|
}
|
|
549
652
|
return false;
|
|
550
653
|
}
|
|
654
|
+
// Decline (return true) any reorder that would place a const/let/var-declared
|
|
655
|
+
// function BELOW an interleaved statement that reads it at module evaluation
|
|
656
|
+
// time. The fixer pins interleaved statements and swaps functions among their
|
|
657
|
+
// own slots, so a helper `const` can be carried past its own module-scope
|
|
658
|
+
// caller (`const CHAMPION = buildHit(…)`) — the emitted file parses and
|
|
659
|
+
// type-checks but throws `ReferenceError: Cannot access '…' before
|
|
660
|
+
// initialization` the moment anything imports it, and `--fix` reports nothing
|
|
661
|
+
// about the file it just broke. Function declarations hoist, so demoting one
|
|
662
|
+
// past an eager reference stays loadable and is not declined. This is the
|
|
663
|
+
// mirror of reorderHoistsFunctionAboveDependency: that guard protects a moved
|
|
664
|
+
// function from losing a dependency ABOVE it; this one protects a pinned
|
|
665
|
+
// statement from losing its dependency BELOW it.
|
|
666
|
+
function reorderDemotesDeclarationBelowEagerReference(regionStatements, functionStatements, expectedOrderInfos) {
|
|
667
|
+
const variableDeclaredNames = new Set();
|
|
668
|
+
expectedOrderInfos.forEach((info) => {
|
|
669
|
+
const declaration = info.statementNode.type === 'ExportNamedDeclaration'
|
|
670
|
+
? info.statementNode.declaration
|
|
671
|
+
: info.statementNode;
|
|
672
|
+
if (declaration?.type === 'VariableDeclaration') {
|
|
673
|
+
variableDeclaredNames.add(info.name);
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
if (variableDeclaredNames.size === 0) {
|
|
677
|
+
return false;
|
|
678
|
+
}
|
|
679
|
+
const placedNames = new Set();
|
|
680
|
+
let slotCursor = 0;
|
|
681
|
+
for (const statement of regionStatements) {
|
|
682
|
+
if (functionStatements.has(statement)) {
|
|
683
|
+
const occupant = expectedOrderInfos[slotCursor];
|
|
684
|
+
slotCursor += 1;
|
|
685
|
+
if (occupant) {
|
|
686
|
+
placedNames.add(occupant.name);
|
|
687
|
+
}
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
for (const name of collectEagerlyReferencedNames(statement)) {
|
|
691
|
+
if (variableDeclaredNames.has(name) && !placedNames.has(name)) {
|
|
692
|
+
return true;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return false;
|
|
697
|
+
}
|
|
551
698
|
exports.verticallyGroupRelatedFunctions = (0, createRule_1.createRule)({
|
|
552
699
|
name: 'vertically-group-related-functions',
|
|
553
700
|
meta: {
|
|
@@ -729,6 +876,13 @@ exports.verticallyGroupRelatedFunctions = (0, createRule_1.createRule)({
|
|
|
729
876
|
if (reorderHoistsFunctionAboveDependency(slice, functionStatements, expectedOrderInfos)) {
|
|
730
877
|
return null;
|
|
731
878
|
}
|
|
879
|
+
// The mirror hazard: a const-declared helper carried below a pinned
|
|
880
|
+
// statement that calls it at module evaluation. The emitted file
|
|
881
|
+
// would throw at import time, so the rewrite is withheld; the
|
|
882
|
+
// misorderedFunction report still fires.
|
|
883
|
+
if (reorderDemotesDeclarationBelowEagerReference(slice, functionStatements, expectedOrderInfos)) {
|
|
884
|
+
return null;
|
|
885
|
+
}
|
|
732
886
|
if (!blockContainsOnlyFunctions) {
|
|
733
887
|
// Real modules interleave type aliases, consts, and top-level
|
|
734
888
|
// calls (e.g. `void autoRunIfMain();`) between functions. Rather
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,42 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.146",
|
|
4
|
+
"date": "2026-08-13T00:14:52.842Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "enforce-memoize-async",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1975
|
|
11
|
+
],
|
|
12
|
+
"summary": "exempt methods taking part in a transaction (closes #1975)"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "prefer-use-deep-compare-memo",
|
|
16
|
+
"changeType": "fix",
|
|
17
|
+
"issues": [
|
|
18
|
+
1979
|
|
19
|
+
],
|
|
20
|
+
"summary": "stop promoting primitive deps via member access (closes #1979)"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "prevent-children-clobber",
|
|
24
|
+
"changeType": "fix",
|
|
25
|
+
"issues": [
|
|
26
|
+
1980
|
|
27
|
+
],
|
|
28
|
+
"summary": "widen the syntactic exemption beyond Omit<> (closes #1980)"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "vertically-group-related-functions",
|
|
32
|
+
"changeType": "fix",
|
|
33
|
+
"issues": [
|
|
34
|
+
1983
|
|
35
|
+
],
|
|
36
|
+
"summary": "decline reorders that demote a helper below its module-scope caller (closes #1983)"
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
},
|
|
2
40
|
{
|
|
3
41
|
"version": "1.20.145",
|
|
4
42
|
"date": "2026-08-12T16:05:12.174Z",
|