@blumintinc/eslint-plugin-blumint 1.20.103 → 1.20.104
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-assert-safe-object-key.js +164 -35
- package/lib/rules/enforce-firestore-set-merge.js +233 -3
- package/lib/rules/enforce-querykey-ts.js +34 -12
- package/lib/rules/use-latest-callback.js +130 -1
- package/package.json +1 -1
- package/release-manifest.json +39 -0
package/lib/index.js
CHANGED
|
@@ -118,6 +118,36 @@ function isInsideMockFactory(node) {
|
|
|
118
118
|
}
|
|
119
119
|
return false;
|
|
120
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Strips the wrappers that stand between a computed key and the value that
|
|
123
|
+
* actually names the property. `k as string`, `k satisfies string`, `<string>k`
|
|
124
|
+
* and `k!` erase at compile time, and `await k` resolves to the very same key,
|
|
125
|
+
* so every one of them leaves the run-time lookup untouched — including a lookup
|
|
126
|
+
* of `__proto__` or `constructor`. Reading the wrapper instead of what it holds
|
|
127
|
+
* classifies nothing and turns appending `as string` into a silent bypass of the
|
|
128
|
+
* guard, so the wrappers are peeled off before the key is judged.
|
|
129
|
+
*
|
|
130
|
+
* The peel repeats because the wrappers nest: `(x as any)!` is a non-null
|
|
131
|
+
* assertion over a type assertion.
|
|
132
|
+
*/
|
|
133
|
+
function unwrapKeyExpression(node) {
|
|
134
|
+
let current = node;
|
|
135
|
+
for (;;) {
|
|
136
|
+
switch (current.type) {
|
|
137
|
+
case utils_1.AST_NODE_TYPES.TSAsExpression:
|
|
138
|
+
case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
|
|
139
|
+
case utils_1.AST_NODE_TYPES.TSNonNullExpression:
|
|
140
|
+
case utils_1.AST_NODE_TYPES.TSTypeAssertion:
|
|
141
|
+
current = current.expression;
|
|
142
|
+
break;
|
|
143
|
+
case utils_1.AST_NODE_TYPES.AwaitExpression:
|
|
144
|
+
current = current.argument;
|
|
145
|
+
break;
|
|
146
|
+
default:
|
|
147
|
+
return current;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
121
151
|
/** Names that read as a positional sequence rather than a keyed record. */
|
|
122
152
|
const ARRAY_LIKE_NAME = /^(array|arr|items|elements|list|collection|data)s?$/i;
|
|
123
153
|
/**
|
|
@@ -186,18 +216,90 @@ function isNumericCall(node) {
|
|
|
186
216
|
callee.object.name === 'Math');
|
|
187
217
|
}
|
|
188
218
|
/**
|
|
189
|
-
* A `: number` annotation on a binding name.
|
|
190
|
-
* declarators carry one, and
|
|
191
|
-
*
|
|
219
|
+
* A `: number` annotation on a binding name. Parameters and variable
|
|
220
|
+
* declarators are the bindings that carry one, and TypeScript checks every
|
|
221
|
+
* value that reaches such a binding against it.
|
|
192
222
|
*/
|
|
193
223
|
function isNumberAnnotated(node) {
|
|
194
224
|
return (node.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
195
225
|
node.typeAnnotation?.typeAnnotation.type === utils_1.AST_NODE_TYPES.TSNumberKeyword);
|
|
196
226
|
}
|
|
227
|
+
/** The types an assertion can launder any value through without complaint. */
|
|
228
|
+
const LAUNDERING_ASSERTION_TYPES = new Set([
|
|
229
|
+
utils_1.AST_NODE_TYPES.TSAnyKeyword,
|
|
230
|
+
utils_1.AST_NODE_TYPES.TSUnknownKeyword,
|
|
231
|
+
]);
|
|
232
|
+
/**
|
|
233
|
+
* An assertion naming `number` over the value it wraps — `f() as number`,
|
|
234
|
+
* `f() satisfies number`, `<number>f()`. An assertion to anything other than
|
|
235
|
+
* the `number` keyword — `as any`, `as unknown`, `as string`, `as const`, a
|
|
236
|
+
* union, a generic — is not this claim at all.
|
|
237
|
+
*
|
|
238
|
+
* The claim is only worth trusting because TypeScript checks it: `f() as number`
|
|
239
|
+
* is rejected unless the operand's type overlaps `number`. A step through `any`
|
|
240
|
+
* or `unknown` removes exactly that check, which is what makes
|
|
241
|
+
* `userInput as unknown as number` the idiom for asserting anything at all — so
|
|
242
|
+
* a chain carrying one proves nothing, and a string laundered through it would
|
|
243
|
+
* re-open the `__proto__` key this rule exists to stop.
|
|
244
|
+
*/
|
|
245
|
+
function assertsNumberType(node) {
|
|
246
|
+
if ((node.type !== utils_1.AST_NODE_TYPES.TSAsExpression &&
|
|
247
|
+
node.type !== utils_1.AST_NODE_TYPES.TSSatisfiesExpression &&
|
|
248
|
+
node.type !== utils_1.AST_NODE_TYPES.TSTypeAssertion) ||
|
|
249
|
+
node.typeAnnotation.type !== utils_1.AST_NODE_TYPES.TSNumberKeyword) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
for (let inner = node.expression; inner.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
|
|
253
|
+
inner.type === utils_1.AST_NODE_TYPES.TSSatisfiesExpression ||
|
|
254
|
+
inner.type === utils_1.AST_NODE_TYPES.TSTypeAssertion; inner = inner.expression) {
|
|
255
|
+
if (LAUNDERING_ASSERTION_TYPES.has(inner.typeAnnotation.type)) {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Whether the write is the initializer of a declaration that declares itself
|
|
263
|
+
* numeric — either by annotating the binding name (`const k: number =
|
|
264
|
+
* rankOf(id)`, `(index: number = rankOf(id)) =>`) or by asserting the
|
|
265
|
+
* initializing value (`const k = rankOf(id) as number`). TypeScript rejects a
|
|
266
|
+
* non-numeric value under either spelling, so on a TypeScript source both are
|
|
267
|
+
* syntactic proof that the value is a number — the same trust a
|
|
268
|
+
* `(index: number) =>` parameter already earns. Without them an author whose
|
|
269
|
+
* index comes from a call has no compliant spelling at all, because the shape
|
|
270
|
+
* of a call proves nothing on its own.
|
|
271
|
+
*
|
|
272
|
+
* The proof covers the initializer alone. A later assignment is a separate
|
|
273
|
+
* statement and is where a value out of a `catch` binding or an `any`-typed
|
|
274
|
+
* source enters the binding, so every other write still has to prove itself by
|
|
275
|
+
* its own shape — including a `for (k of xs)` binding, whose write expression
|
|
276
|
+
* is the iterated value rather than an initializer.
|
|
277
|
+
*/
|
|
278
|
+
function initializesNumericDeclaration(writeExpr) {
|
|
279
|
+
const site = writeExpr.parent;
|
|
280
|
+
switch (site?.type) {
|
|
281
|
+
case utils_1.AST_NODE_TYPES.VariableDeclarator:
|
|
282
|
+
// A destructuring pattern takes the initializer apart before binding, so
|
|
283
|
+
// an assertion over the whole initializer describes the container rather
|
|
284
|
+
// than the element bound out of it: `const { a } = f() as number` says
|
|
285
|
+
// nothing about `a`.
|
|
286
|
+
return (site.id.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
287
|
+
(isNumberAnnotated(site.id) || assertsNumberType(writeExpr)));
|
|
288
|
+
// A parameter default is checked against the parameter's own annotation the
|
|
289
|
+
// same way a declarator's initializer is checked against its own. That
|
|
290
|
+
// annotation is also what admits the parameter as a numeric binding at all,
|
|
291
|
+
// so an assertion on the default decides nothing here.
|
|
292
|
+
case utils_1.AST_NODE_TYPES.AssignmentPattern:
|
|
293
|
+
return isNumberAnnotated(site.left);
|
|
294
|
+
default:
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
197
298
|
/**
|
|
198
299
|
* Whether the definition can hold a number: a declarator (whose value is proven
|
|
199
|
-
* by its writes) or a parameter annotated `: number`.
|
|
200
|
-
* import, a function or class name, a catch binding — is
|
|
300
|
+
* by its declaration site and its writes) or a parameter annotated `: number`.
|
|
301
|
+
* Anything else — an import, a function or class name, a catch binding — is
|
|
302
|
+
* not.
|
|
201
303
|
*/
|
|
202
304
|
function definesNumericBinding(def) {
|
|
203
305
|
return (def.node.type === utils_1.AST_NODE_TYPES.VariableDeclarator ||
|
|
@@ -431,6 +533,22 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
|
|
|
431
533
|
return createFixes(fixer, node, expressionText);
|
|
432
534
|
},
|
|
433
535
|
});
|
|
536
|
+
/**
|
|
537
|
+
* Reports a key whose written form may carry assertion or await wrappers.
|
|
538
|
+
*
|
|
539
|
+
* The report and the fix sit on the outermost written node, so the wrapper
|
|
540
|
+
* the author put there survives the rewrite: `m[assertSafe(k as string)]`
|
|
541
|
+
* rather than `m[assertSafe(k)]`, which would delete text the fixer does not
|
|
542
|
+
* own. `assertSafe` is identity-typed (`<T extends PropertyKey>(key: T): T`),
|
|
543
|
+
* so wrapping the asserted expression preserves the key's type, and wrapping
|
|
544
|
+
* an `await` keeps the validation on the resolved key rather than moving it
|
|
545
|
+
* onto the promise.
|
|
546
|
+
*
|
|
547
|
+
* A key written without a wrapper keeps the narrower argument the fix has
|
|
548
|
+
* always emitted: `String(id)` and `` `${id}` `` collapse to `id`, whose
|
|
549
|
+
* conversion assertSafe subsumes.
|
|
550
|
+
*/
|
|
551
|
+
const reportWrittenKey = (written, unwrapped, innerText) => reportUseAssertSafe(written, written === unwrapped ? innerText : context.sourceCode.getText(written));
|
|
434
552
|
/**
|
|
435
553
|
* Returns true when the identifier was initialized directly from an
|
|
436
554
|
* assertSafe(...) call, e.g. `const safeKey = assertSafe(rawKey)`.
|
|
@@ -463,34 +581,36 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
|
|
|
463
581
|
* does not prove numeric keeps being reported.
|
|
464
582
|
*/
|
|
465
583
|
const isStaticallyNumeric = (node, seen = new Set()) => {
|
|
466
|
-
|
|
584
|
+
// An assertion or an await around an operand leaves its run-time value
|
|
585
|
+
// alone, so the proof reads through to what the wrapper holds. The
|
|
586
|
+
// annotation on the binding underneath is what proves the key numeric —
|
|
587
|
+
// an assertion asserts and proves nothing on its own.
|
|
588
|
+
const target = unwrapKeyExpression(node);
|
|
589
|
+
switch (target.type) {
|
|
467
590
|
case utils_1.AST_NODE_TYPES.Literal:
|
|
468
|
-
return typeof
|
|
591
|
+
return typeof target.value === 'number';
|
|
469
592
|
case utils_1.AST_NODE_TYPES.UpdateExpression:
|
|
470
593
|
return true;
|
|
471
594
|
case utils_1.AST_NODE_TYPES.UnaryExpression:
|
|
472
|
-
return (
|
|
473
|
-
|
|
474
|
-
|
|
595
|
+
return (target.operator === '-' ||
|
|
596
|
+
target.operator === '+' ||
|
|
597
|
+
target.operator === '~');
|
|
475
598
|
case utils_1.AST_NODE_TYPES.BinaryExpression:
|
|
476
|
-
if (NUMERIC_BINARY_OPERATORS.has(
|
|
599
|
+
if (NUMERIC_BINARY_OPERATORS.has(target.operator)) {
|
|
477
600
|
return true;
|
|
478
601
|
}
|
|
479
|
-
return (
|
|
480
|
-
isStaticallyNumeric(
|
|
481
|
-
isStaticallyNumeric(
|
|
602
|
+
return (target.operator === '+' &&
|
|
603
|
+
isStaticallyNumeric(target.left, seen) &&
|
|
604
|
+
isStaticallyNumeric(target.right, seen));
|
|
482
605
|
case utils_1.AST_NODE_TYPES.CallExpression:
|
|
483
|
-
return isNumericCall(
|
|
606
|
+
return isNumericCall(target);
|
|
484
607
|
case utils_1.AST_NODE_TYPES.MemberExpression:
|
|
485
608
|
// `.length` is a number on arrays, typed arrays and strings alike.
|
|
486
|
-
return (!
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
case utils_1.AST_NODE_TYPES.TSAsExpression:
|
|
490
|
-
case utils_1.AST_NODE_TYPES.TSNonNullExpression:
|
|
491
|
-
return isStaticallyNumeric(node.expression, seen);
|
|
609
|
+
return (!target.computed &&
|
|
610
|
+
target.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
611
|
+
target.property.name === 'length');
|
|
492
612
|
case utils_1.AST_NODE_TYPES.Identifier:
|
|
493
|
-
return isNumericIdentifier(
|
|
613
|
+
return isNumericIdentifier(target, seen);
|
|
494
614
|
default:
|
|
495
615
|
return false;
|
|
496
616
|
}
|
|
@@ -523,6 +643,9 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
|
|
|
523
643
|
NUMERIC_ASSIGNMENT_OPERATORS.has(assignment.operator)) {
|
|
524
644
|
return true;
|
|
525
645
|
}
|
|
646
|
+
if (initializesNumericDeclaration(writeExpr)) {
|
|
647
|
+
return true;
|
|
648
|
+
}
|
|
526
649
|
return isStaticallyNumeric(writeExpr, nextSeen);
|
|
527
650
|
});
|
|
528
651
|
if (!staysNumeric) {
|
|
@@ -538,14 +661,15 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
|
|
|
538
661
|
// Handle computed property in object destructuring
|
|
539
662
|
Property(node) {
|
|
540
663
|
if (node.computed && node.key) {
|
|
541
|
-
const
|
|
664
|
+
const written = node.key;
|
|
665
|
+
const key = unwrapKeyExpression(written);
|
|
542
666
|
// Check for String(id) pattern
|
|
543
667
|
if (key.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
544
668
|
key.callee.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
545
669
|
key.callee.name === 'String') {
|
|
546
670
|
const arg = key.arguments[0];
|
|
547
671
|
const argText = context.sourceCode.getText(arg);
|
|
548
|
-
|
|
672
|
+
reportWrittenKey(written, key, argText);
|
|
549
673
|
}
|
|
550
674
|
// Check for template literals like `${id}`
|
|
551
675
|
if (key.type === utils_1.AST_NODE_TYPES.TemplateLiteral &&
|
|
@@ -555,21 +679,22 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
|
|
|
555
679
|
key.quasis[1].value.raw === '') {
|
|
556
680
|
const expr = key.expressions[0];
|
|
557
681
|
const exprText = context.sourceCode.getText(expr);
|
|
558
|
-
|
|
682
|
+
reportWrittenKey(written, key, exprText);
|
|
559
683
|
}
|
|
560
684
|
}
|
|
561
685
|
},
|
|
562
686
|
// Handle binary expressions like 'key' in obj
|
|
563
687
|
BinaryExpression(node) {
|
|
564
688
|
if (node.operator === 'in') {
|
|
565
|
-
const
|
|
689
|
+
const written = node.left;
|
|
690
|
+
const left = unwrapKeyExpression(written);
|
|
566
691
|
// Check for String(id) pattern
|
|
567
692
|
if (left.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
568
693
|
left.callee.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
569
694
|
left.callee.name === 'String') {
|
|
570
695
|
const arg = left.arguments[0];
|
|
571
696
|
const argText = context.sourceCode.getText(arg);
|
|
572
|
-
|
|
697
|
+
reportWrittenKey(written, left, argText);
|
|
573
698
|
}
|
|
574
699
|
// Check for template literals like `${id}`
|
|
575
700
|
if (left.type === utils_1.AST_NODE_TYPES.TemplateLiteral &&
|
|
@@ -579,13 +704,17 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
|
|
|
579
704
|
left.quasis[1].value.raw === '') {
|
|
580
705
|
const expr = left.expressions[0];
|
|
581
706
|
const exprText = context.sourceCode.getText(expr);
|
|
582
|
-
|
|
707
|
+
reportWrittenKey(written, left, exprText);
|
|
583
708
|
}
|
|
584
709
|
}
|
|
585
710
|
},
|
|
586
711
|
MemberExpression(node) {
|
|
587
712
|
if (node.computed) {
|
|
588
|
-
const
|
|
713
|
+
const written = node.property;
|
|
714
|
+
// The written key may sit under assertion or await wrappers that erase
|
|
715
|
+
// at run time; what they hold is what names the property, so that is
|
|
716
|
+
// what the branches below classify.
|
|
717
|
+
const property = unwrapKeyExpression(written);
|
|
589
718
|
// Skip if already using assertSafe
|
|
590
719
|
if (property.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
591
720
|
property.callee.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
@@ -619,7 +748,7 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
|
|
|
619
748
|
property.callee.name === 'String') {
|
|
620
749
|
const arg = property.arguments[0];
|
|
621
750
|
const argText = context.sourceCode.getText(arg);
|
|
622
|
-
|
|
751
|
+
reportWrittenKey(written, property, argText);
|
|
623
752
|
return;
|
|
624
753
|
}
|
|
625
754
|
// Check for template literals
|
|
@@ -640,7 +769,7 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
|
|
|
640
769
|
}
|
|
641
770
|
const expr = property.expressions[0];
|
|
642
771
|
const exprText = context.sourceCode.getText(expr);
|
|
643
|
-
|
|
772
|
+
reportWrittenKey(written, property, exprText);
|
|
644
773
|
return;
|
|
645
774
|
}
|
|
646
775
|
// Check for direct variable usage (identifiers)
|
|
@@ -659,7 +788,7 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
|
|
|
659
788
|
return;
|
|
660
789
|
}
|
|
661
790
|
const propText = context.sourceCode.getText(property);
|
|
662
|
-
|
|
791
|
+
reportWrittenKey(written, property, propText);
|
|
663
792
|
return;
|
|
664
793
|
}
|
|
665
794
|
// Check for binary expressions (like index + 1)
|
|
@@ -669,7 +798,7 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
|
|
|
669
798
|
return;
|
|
670
799
|
}
|
|
671
800
|
const propText = context.sourceCode.getText(property);
|
|
672
|
-
|
|
801
|
+
reportWrittenKey(written, property, propText);
|
|
673
802
|
return;
|
|
674
803
|
}
|
|
675
804
|
// Check for boolean expressions and other literals
|
|
@@ -681,7 +810,7 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
|
|
|
681
810
|
return;
|
|
682
811
|
}
|
|
683
812
|
const propText = context.sourceCode.getText(property);
|
|
684
|
-
|
|
813
|
+
reportWrittenKey(written, property, propText);
|
|
685
814
|
return;
|
|
686
815
|
}
|
|
687
816
|
// Check for function calls (anything that isn't handled above)
|
|
@@ -694,7 +823,7 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
|
|
|
694
823
|
return;
|
|
695
824
|
}
|
|
696
825
|
const propText = context.sourceCode.getText(property);
|
|
697
|
-
|
|
826
|
+
reportWrittenKey(written, property, propText);
|
|
698
827
|
return;
|
|
699
828
|
}
|
|
700
829
|
}
|
|
@@ -9,6 +9,16 @@ const FIRESTORE_MODULES = new Set(['firebase/firestore', 'firebase-admin']);
|
|
|
9
9
|
const UPDATE_DOC = 'updateDoc';
|
|
10
10
|
const SET_DOC = 'setDoc';
|
|
11
11
|
const MERGE_ARGUMENT = ', { merge: true }';
|
|
12
|
+
const BATCH_MANAGER = 'batchManager';
|
|
13
|
+
/**
|
|
14
|
+
* Realtime Database's batch manager is held under the same `batchManager` field
|
|
15
|
+
* name as the Firestore one, yet it exposes no `set` method at all — its
|
|
16
|
+
* positional `update(path, data)` is the only write path it has, and RTDB's
|
|
17
|
+
* update already merges shallowly. Rewriting one of its calls emits a method
|
|
18
|
+
* that does not exist (TS2339), so a receiver proven to be this class is out of
|
|
19
|
+
* the rule's scope entirely.
|
|
20
|
+
*/
|
|
21
|
+
const REALTIME_BATCH_MANAGER = 'RealtimeBatchManager';
|
|
12
22
|
function isFirestoreDynamicImport(node) {
|
|
13
23
|
if (node?.type !== utils_1.AST_NODE_TYPES.AwaitExpression) {
|
|
14
24
|
return false;
|
|
@@ -67,6 +77,121 @@ function bindsFirestoreExport(variable, imported) {
|
|
|
67
77
|
return (variable.defs.length > 0 &&
|
|
68
78
|
variable.defs.every((def) => firestoreBindingOf(def)?.imported === imported));
|
|
69
79
|
}
|
|
80
|
+
/** The rightmost segment of a type name, so `realtimeDb.X` reads like a bare `X`. */
|
|
81
|
+
function typeNameOf(node) {
|
|
82
|
+
if (node.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
83
|
+
return node.name;
|
|
84
|
+
}
|
|
85
|
+
if (node.type === utils_1.AST_NODE_TYPES.TSQualifiedName) {
|
|
86
|
+
return typeNameOf(node.right);
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Whether a type annotation names the Realtime Database batch manager. Wrappers
|
|
92
|
+
* that preserve the instance type — `Readonly<…>`, a union, an intersection —
|
|
93
|
+
* are looked through, because the field they annotate still holds the class.
|
|
94
|
+
*/
|
|
95
|
+
function isRealtimeType(node) {
|
|
96
|
+
if (!node) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
switch (node.type) {
|
|
100
|
+
case utils_1.AST_NODE_TYPES.TSTypeReference:
|
|
101
|
+
return (typeNameOf(node.typeName) === REALTIME_BATCH_MANAGER ||
|
|
102
|
+
(node.typeParameters?.params ?? []).some(isRealtimeType));
|
|
103
|
+
case utils_1.AST_NODE_TYPES.TSUnionType:
|
|
104
|
+
case utils_1.AST_NODE_TYPES.TSIntersectionType:
|
|
105
|
+
return node.types.some(isRealtimeType);
|
|
106
|
+
default:
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function isRealtimeAnnotation(annotation) {
|
|
111
|
+
return isRealtimeType(annotation?.typeAnnotation);
|
|
112
|
+
}
|
|
113
|
+
/** Whether an initializer constructs the Realtime Database batch manager. */
|
|
114
|
+
function isRealtimeInstance(node) {
|
|
115
|
+
if (node?.type !== utils_1.AST_NODE_TYPES.NewExpression) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
const { callee } = node;
|
|
119
|
+
if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
120
|
+
return callee.name === REALTIME_BATCH_MANAGER;
|
|
121
|
+
}
|
|
122
|
+
return (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
123
|
+
!callee.computed &&
|
|
124
|
+
callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
125
|
+
callee.property.name === REALTIME_BATCH_MANAGER);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Whether a parameter binds `name` to the Realtime batch manager. A parameter
|
|
129
|
+
* property declares the field outright; a plain constructor parameter of the
|
|
130
|
+
* same name is the evidence a subclass carries when it forwards the manager to a
|
|
131
|
+
* `super()` that stores it.
|
|
132
|
+
*/
|
|
133
|
+
function parameterBindsRealtime(param, name) {
|
|
134
|
+
const declared = param.type === utils_1.AST_NODE_TYPES.TSParameterProperty ? param.parameter : param;
|
|
135
|
+
const identifier = declared.type === utils_1.AST_NODE_TYPES.AssignmentPattern
|
|
136
|
+
? declared.left
|
|
137
|
+
: declared;
|
|
138
|
+
const initializer = declared.type === utils_1.AST_NODE_TYPES.AssignmentPattern ? declared.right : null;
|
|
139
|
+
return (identifier.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
140
|
+
identifier.name === name &&
|
|
141
|
+
(isRealtimeAnnotation(identifier.typeAnnotation) ||
|
|
142
|
+
isRealtimeInstance(initializer)));
|
|
143
|
+
}
|
|
144
|
+
/** Whether a class member identifies `name` as the Realtime batch manager. */
|
|
145
|
+
function memberBindsRealtime(member, name) {
|
|
146
|
+
if (member.type === utils_1.AST_NODE_TYPES.PropertyDefinition) {
|
|
147
|
+
return (!member.computed &&
|
|
148
|
+
member.key.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
149
|
+
member.key.name === name &&
|
|
150
|
+
(isRealtimeInstance(member.value) ||
|
|
151
|
+
isRealtimeAnnotation(member.typeAnnotation)));
|
|
152
|
+
}
|
|
153
|
+
return (member.type === utils_1.AST_NODE_TYPES.MethodDefinition &&
|
|
154
|
+
member.kind === 'constructor' &&
|
|
155
|
+
member.value.params.some((param) => parameterBindsRealtime(param, name)));
|
|
156
|
+
}
|
|
157
|
+
/** Strips assertions, which change a literal's type but not its value. */
|
|
158
|
+
function unwrapAssertions(node) {
|
|
159
|
+
if (node.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
|
|
160
|
+
node.type === utils_1.AST_NODE_TYPES.TSSatisfiesExpression ||
|
|
161
|
+
node.type === utils_1.AST_NODE_TYPES.TSTypeAssertion ||
|
|
162
|
+
node.type === utils_1.AST_NODE_TYPES.TSNonNullExpression) {
|
|
163
|
+
return unwrapAssertions(node.expression);
|
|
164
|
+
}
|
|
165
|
+
return node;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Whether an expression evaluates to a primitive value on its face. Firestore's
|
|
169
|
+
* update data is an object of field updates, so a primitive in the data
|
|
170
|
+
* position proves the call is not Firestore's — the only signal available where
|
|
171
|
+
* the receiver is inherited from another module.
|
|
172
|
+
*/
|
|
173
|
+
function isPrimitiveLiteral(node) {
|
|
174
|
+
if (!node) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
const expression = unwrapAssertions(node);
|
|
178
|
+
// A template literal evaluates to a string however it interpolates.
|
|
179
|
+
if (expression.type === utils_1.AST_NODE_TYPES.TemplateLiteral) {
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
if (expression.type === utils_1.AST_NODE_TYPES.UnaryExpression &&
|
|
183
|
+
(expression.operator === '-' || expression.operator === '+')) {
|
|
184
|
+
return isPrimitiveLiteral(expression.argument);
|
|
185
|
+
}
|
|
186
|
+
if (expression.type !== utils_1.AST_NODE_TYPES.Literal) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
const { value } = expression;
|
|
190
|
+
return (typeof value === 'string' ||
|
|
191
|
+
typeof value === 'number' ||
|
|
192
|
+
typeof value === 'boolean' ||
|
|
193
|
+
typeof value === 'bigint');
|
|
194
|
+
}
|
|
70
195
|
exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
|
|
71
196
|
name: 'enforce-firestore-set-merge',
|
|
72
197
|
meta: {
|
|
@@ -97,6 +222,109 @@ exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
|
|
|
97
222
|
*/
|
|
98
223
|
const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
|
|
99
224
|
let plannedSetDocBinding = false;
|
|
225
|
+
/**
|
|
226
|
+
* Top-level classes by name, so a field inherited from a superclass declared
|
|
227
|
+
* in the same file resolves to the declaration that carries its evidence.
|
|
228
|
+
*/
|
|
229
|
+
let topLevelClasses = null;
|
|
230
|
+
function classBodiesByName() {
|
|
231
|
+
if (topLevelClasses) {
|
|
232
|
+
return topLevelClasses;
|
|
233
|
+
}
|
|
234
|
+
topLevelClasses = new Map();
|
|
235
|
+
for (const statement of sourceCode.ast.body) {
|
|
236
|
+
const declaration = statement.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration ||
|
|
237
|
+
statement.type === utils_1.AST_NODE_TYPES.ExportDefaultDeclaration
|
|
238
|
+
? statement.declaration
|
|
239
|
+
: statement;
|
|
240
|
+
if (declaration?.type === utils_1.AST_NODE_TYPES.ClassDeclaration &&
|
|
241
|
+
declaration.id) {
|
|
242
|
+
topLevelClasses.set(declaration.id.name, declaration.body);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (declaration?.type === utils_1.AST_NODE_TYPES.VariableDeclaration) {
|
|
246
|
+
for (const declarator of declaration.declarations) {
|
|
247
|
+
if (declarator.id.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
248
|
+
declarator.init?.type === utils_1.AST_NODE_TYPES.ClassExpression) {
|
|
249
|
+
topLevelClasses.set(declarator.id.name, declarator.init.body);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return topLevelClasses;
|
|
255
|
+
}
|
|
256
|
+
function enclosingClassBody(node) {
|
|
257
|
+
let current = node.parent;
|
|
258
|
+
while (current) {
|
|
259
|
+
if (current.type === utils_1.AST_NODE_TYPES.ClassBody) {
|
|
260
|
+
return current;
|
|
261
|
+
}
|
|
262
|
+
current = current.parent;
|
|
263
|
+
}
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
/** Follows `extends` in-file, since a subclass inherits its field's type. */
|
|
267
|
+
function classBindsRealtime(body, name, seen) {
|
|
268
|
+
if (seen.has(body)) {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
seen.add(body);
|
|
272
|
+
if (body.body.some((member) => memberBindsRealtime(member, name))) {
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
const declaration = body.parent;
|
|
276
|
+
const superClass = declaration?.type === utils_1.AST_NODE_TYPES.ClassDeclaration ||
|
|
277
|
+
declaration?.type === utils_1.AST_NODE_TYPES.ClassExpression
|
|
278
|
+
? declaration.superClass
|
|
279
|
+
: null;
|
|
280
|
+
if (superClass?.type !== utils_1.AST_NODE_TYPES.Identifier) {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
const superBody = classBodiesByName().get(superClass.name);
|
|
284
|
+
return superBody ? classBindsRealtime(superBody, name, seen) : false;
|
|
285
|
+
}
|
|
286
|
+
function identifierBindsRealtime(identifier) {
|
|
287
|
+
const scope = ASTHelpers_1.ASTHelpers.getScope(context, identifier);
|
|
288
|
+
const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(scope, identifier.name);
|
|
289
|
+
return (variable?.defs ?? []).some((def) => {
|
|
290
|
+
const declaredName = def.name;
|
|
291
|
+
return ((declaredName.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
292
|
+
isRealtimeAnnotation(declaredName.typeAnnotation)) ||
|
|
293
|
+
(def.node.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
|
|
294
|
+
isRealtimeInstance(def.node.init)));
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Whether the file itself proves the receiver holds a RealtimeBatchManager:
|
|
299
|
+
* `this.batchManager` against the class (or an in-file superclass) that
|
|
300
|
+
* declares the field, and a plain identifier against its binding.
|
|
301
|
+
*/
|
|
302
|
+
function receiverBindsRealtime(node, receiver) {
|
|
303
|
+
if (receiver.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
304
|
+
return identifierBindsRealtime(receiver);
|
|
305
|
+
}
|
|
306
|
+
if (receiver.type !== utils_1.AST_NODE_TYPES.MemberExpression ||
|
|
307
|
+
receiver.computed ||
|
|
308
|
+
receiver.property.type !== utils_1.AST_NODE_TYPES.Identifier ||
|
|
309
|
+
receiver.object.type !== utils_1.AST_NODE_TYPES.ThisExpression) {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
const body = enclosingClassBody(node);
|
|
313
|
+
return body
|
|
314
|
+
? classBindsRealtime(body, receiver.property.name, new Set())
|
|
315
|
+
: false;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Either syntactic signal puts a `batchManager.update(…)` call outside the
|
|
319
|
+
* rule: the receiver resolves in-file to the Realtime Database manager, or
|
|
320
|
+
* the data argument is a primitive literal, which Firestore's object of
|
|
321
|
+
* field updates can never be. A call with no data argument has nothing in
|
|
322
|
+
* that position, so only the receiver can answer for it.
|
|
323
|
+
*/
|
|
324
|
+
function isRealtimeBatchUpdate(node, receiver) {
|
|
325
|
+
return (isPrimitiveLiteral(node.arguments[1]) ||
|
|
326
|
+
receiverBindsRealtime(node, receiver));
|
|
327
|
+
}
|
|
100
328
|
function isFirestoreUpdateCall(node) {
|
|
101
329
|
// Check if it's a set() call with merge: true
|
|
102
330
|
if (node.callee.type === utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
@@ -119,11 +347,13 @@ exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
|
|
|
119
347
|
// Only flag update() calls that are Firestore operations
|
|
120
348
|
if (property.name === 'update') {
|
|
121
349
|
const object = node.callee.object;
|
|
122
|
-
// Check for BatchManager update calls
|
|
350
|
+
// Check for BatchManager update calls. The Realtime Database
|
|
351
|
+
// manager answers to the same field name without a `set` method, so
|
|
352
|
+
// its calls are not Firestore operations at all.
|
|
123
353
|
if (object.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
124
354
|
object.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
125
|
-
object.property.name ===
|
|
126
|
-
return
|
|
355
|
+
object.property.name === BATCH_MANAGER) {
|
|
356
|
+
return !isRealtimeBatchUpdate(node, object);
|
|
127
357
|
}
|
|
128
358
|
if (object.type === utils_1.AST_NODE_TYPES.CallExpression) {
|
|
129
359
|
// Check if it's a createHash().update() call
|
|
@@ -21,6 +21,24 @@ const SRC_TIER_SEGMENT = '/src/';
|
|
|
21
21
|
* the alias must still have its existing imports understood.
|
|
22
22
|
*/
|
|
23
23
|
const ALIASED_QUERY_KEYS_MODULE = '@/util/routing/queryKeys';
|
|
24
|
+
/**
|
|
25
|
+
* queryKeys.ts is also reachable through the constants barrel, which
|
|
26
|
+
* `prefer-global-router-state-key` accepts as an approved re-export
|
|
27
|
+
* (prefer-global-router-state-key.ts:139-143) and whose messages advertise it.
|
|
28
|
+
* Both rules police the same `useRouterState` key and both ship as `error` in
|
|
29
|
+
* the recommended config, so a source one of them blesses must not be the
|
|
30
|
+
* other's violation (#1714).
|
|
31
|
+
*/
|
|
32
|
+
const APPROVED_REEXPORT_SOURCES = new Set(['constants', 'constants/index']);
|
|
33
|
+
/**
|
|
34
|
+
* Reduce a specifier to the module it names, dropping the roots that are all
|
|
35
|
+
* spellings of the same location: the `@/` and `src/` aliases, and any run of
|
|
36
|
+
* relative steps. Mirrors the sibling's normalization
|
|
37
|
+
* (prefer-global-router-state-key.ts:149-151) so `src/constants` and
|
|
38
|
+
* `../constants` are recognized as the same approved re-export that `constants`
|
|
39
|
+
* is.
|
|
40
|
+
*/
|
|
41
|
+
const normalizeSpecifier = (source) => source.replace(/^@\/|^src\//, '').replace(/^(\.\/|\.\.\/)+/, '');
|
|
24
42
|
const toPosixPath = (filePath) => filePath.replace(/\\/g, '/');
|
|
25
43
|
const ensureRelativeSpecifier = (specifier) => specifier.startsWith('.') ? specifier : `./${specifier}`;
|
|
26
44
|
const isWindowsDrivePath = (filePath) => /^[A-Za-z]:[\\/]/.test(filePath);
|
|
@@ -91,7 +109,6 @@ exports.enforceQueryKeyTs = (0, createRule_1.createRule)({
|
|
|
91
109
|
ALIASED_QUERY_KEYS_MODULE,
|
|
92
110
|
QUERY_KEYS_MODULE,
|
|
93
111
|
]);
|
|
94
|
-
const allowedQueryKeyFactories = new Set(['makeQueryKey', 'getQueryKey']);
|
|
95
112
|
const sourceCode = context.getSourceCode();
|
|
96
113
|
/**
|
|
97
114
|
* Reports are buffered until the whole file has been walked so the fixer can
|
|
@@ -128,6 +145,15 @@ exports.enforceQueryKeyTs = (0, createRule_1.createRule)({
|
|
|
128
145
|
source.endsWith(`/${QUERY_KEYS_SUFFIX}`)) {
|
|
129
146
|
return true;
|
|
130
147
|
}
|
|
148
|
+
// The approved re-export and the root-relative spelling of the module
|
|
149
|
+
// itself are recognized under every alias of their root, which is what the
|
|
150
|
+
// sibling rule accepts; recognizing less makes its advertised remedy this
|
|
151
|
+
// rule's violation (#1714).
|
|
152
|
+
const normalized = normalizeSpecifier(source);
|
|
153
|
+
if (APPROVED_REEXPORT_SOURCES.has(normalized) ||
|
|
154
|
+
normalized === QUERY_KEYS_SUFFIX) {
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
131
157
|
// A relative specifier can name the module without spelling out
|
|
132
158
|
// `util/routing`: a sibling reaches it as `./queryKeys`, and a file two
|
|
133
159
|
// directories below `src/util` as `../../routing/queryKeys`. Resolving
|
|
@@ -401,18 +427,14 @@ exports.enforceQueryKeyTs = (0, createRule_1.createRule)({
|
|
|
401
427
|
return (isValidQueryKeyUsage(node.consequent) &&
|
|
402
428
|
isValidQueryKeyUsage(node.alternate));
|
|
403
429
|
}
|
|
404
|
-
//
|
|
430
|
+
// A call's return value is opaque to a syntactic check, so every call is
|
|
431
|
+
// allowed rather than guessed at — the position
|
|
432
|
+
// `prefer-global-router-state-key` takes and documents. Enumerating
|
|
433
|
+
// factory names instead reported whichever spelling the enumeration
|
|
434
|
+
// missed, including the `buildQueryKey` of the sibling's own documented
|
|
435
|
+
// remedy (#1714).
|
|
405
436
|
if (node.type === utils_1.AST_NODE_TYPES.CallExpression) {
|
|
406
|
-
|
|
407
|
-
if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
408
|
-
return allowedQueryKeyFactories.has(callee.name);
|
|
409
|
-
}
|
|
410
|
-
if (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
411
|
-
!callee.computed &&
|
|
412
|
-
callee.property.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
413
|
-
return allowedQueryKeyFactories.has(callee.property.name);
|
|
414
|
-
}
|
|
415
|
-
return false;
|
|
437
|
+
return true;
|
|
416
438
|
}
|
|
417
439
|
return false;
|
|
418
440
|
}
|
|
@@ -312,6 +312,132 @@ const orphansLocalBinding = (root, deletedRanges) => [...variablesReferencedIn(r
|
|
|
312
312
|
.filter((variable) => variable.defs.length > 0 && isFunctionLocal(variable))
|
|
313
313
|
.some((variable) => variable.references.every((reference) => !reference.isRead() ||
|
|
314
314
|
fallsInside(deletedRanges, reference.identifier.range)));
|
|
315
|
+
/**
|
|
316
|
+
* A hook, by React's naming convention. The convention is the only general
|
|
317
|
+
* signal available in one file: a custom hook's body may live anywhere, so no
|
|
318
|
+
* list of hook names (`useFirestore`, `useDocSnapshot`, ...) can be complete.
|
|
319
|
+
*/
|
|
320
|
+
const HOOK_NAME = /^use[A-Z]/;
|
|
321
|
+
/**
|
|
322
|
+
* The name the naming convention applies to. A member callee
|
|
323
|
+
* (`hooks.useThing(...)`) carries it on the property, which is what the author
|
|
324
|
+
* reads as the hook's name.
|
|
325
|
+
*/
|
|
326
|
+
const calleeNameOf = (call) => {
|
|
327
|
+
const { callee } = call;
|
|
328
|
+
if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
329
|
+
return callee.name;
|
|
330
|
+
}
|
|
331
|
+
if (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
332
|
+
!callee.computed &&
|
|
333
|
+
callee.property.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
334
|
+
return callee.property.name;
|
|
335
|
+
}
|
|
336
|
+
return null;
|
|
337
|
+
};
|
|
338
|
+
const isHookCall = (call) => {
|
|
339
|
+
const name = calleeNameOf(call);
|
|
340
|
+
return !!name && HOOK_NAME.test(name);
|
|
341
|
+
};
|
|
342
|
+
/** Wrappers a value passes through without its identity changing. */
|
|
343
|
+
const TRANSPARENT_VALUE_WRAPPERS = new Set([
|
|
344
|
+
utils_1.AST_NODE_TYPES.TSAsExpression,
|
|
345
|
+
utils_1.AST_NODE_TYPES.TSSatisfiesExpression,
|
|
346
|
+
utils_1.AST_NODE_TYPES.TSNonNullExpression,
|
|
347
|
+
utils_1.AST_NODE_TYPES.TSTypeAssertion,
|
|
348
|
+
]);
|
|
349
|
+
/**
|
|
350
|
+
* Whether the value written at `node` reaches a hook in a position where the
|
|
351
|
+
* hook can compare it between renders: an element of a dependency array handed
|
|
352
|
+
* to a hook (`useEffect(effect, [handler])`), or a direct argument of one — a
|
|
353
|
+
* custom hook is free to list an argument in a dependency array of its own, and
|
|
354
|
+
* `useFirestore(handler, initial)` does exactly that.
|
|
355
|
+
*
|
|
356
|
+
* Positions that use the value without keying anything on its identity do not
|
|
357
|
+
* qualify: calling it, spelling it as a JSX prop, or handing it to a plain
|
|
358
|
+
* function all read through to the latest closure a stable wrapper holds, so
|
|
359
|
+
* nothing observes the frozen reference.
|
|
360
|
+
*/
|
|
361
|
+
const feedsHookDependency = (node) => {
|
|
362
|
+
let value = node;
|
|
363
|
+
let parent = value.parent;
|
|
364
|
+
while (parent) {
|
|
365
|
+
if (parent.type === utils_1.AST_NODE_TYPES.CallExpression) {
|
|
366
|
+
return (parent.arguments.some((argument) => argument === value) &&
|
|
367
|
+
isHookCall(parent));
|
|
368
|
+
}
|
|
369
|
+
// An array is followed out to whatever holds it, which is how an element of
|
|
370
|
+
// a dependency array is reached. Everything else — a JSX container, a
|
|
371
|
+
// property, a return, a function body — ends the walk, because the identity
|
|
372
|
+
// stops being an argument the hook itself receives.
|
|
373
|
+
if (parent.type !== utils_1.AST_NODE_TYPES.ArrayExpression &&
|
|
374
|
+
!TRANSPARENT_VALUE_WRAPPERS.has(parent.type)) {
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
value = parent;
|
|
378
|
+
parent = value.parent;
|
|
379
|
+
}
|
|
380
|
+
return false;
|
|
381
|
+
};
|
|
382
|
+
/**
|
|
383
|
+
* Whether the call's result changes identity between renders. An empty
|
|
384
|
+
* dependency array already pins it for the component's lifetime, so converting
|
|
385
|
+
* the call cannot change what any consumer observes. A missing array, or one
|
|
386
|
+
* spelled as a value this file cannot read, leaves a reference the author may
|
|
387
|
+
* be keying on.
|
|
388
|
+
*/
|
|
389
|
+
const identityCanChange = (call) => {
|
|
390
|
+
const dependencies = call.arguments[1];
|
|
391
|
+
if (!dependencies) {
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
394
|
+
if (dependencies.type === utils_1.AST_NODE_TYPES.ArrayExpression) {
|
|
395
|
+
return dependencies.elements.length > 0;
|
|
396
|
+
}
|
|
397
|
+
return true;
|
|
398
|
+
};
|
|
399
|
+
/** The declarator binding the call's result, seen through TS wrappers. */
|
|
400
|
+
const declaratorOf = (call) => {
|
|
401
|
+
let value = call;
|
|
402
|
+
let parent = value.parent;
|
|
403
|
+
while (parent && TRANSPARENT_VALUE_WRAPPERS.has(parent.type)) {
|
|
404
|
+
value = parent;
|
|
405
|
+
parent = value.parent;
|
|
406
|
+
}
|
|
407
|
+
return parent &&
|
|
408
|
+
parent.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
|
|
409
|
+
parent.init === value
|
|
410
|
+
? parent
|
|
411
|
+
: null;
|
|
412
|
+
};
|
|
413
|
+
/**
|
|
414
|
+
* Whether the callback's identity is load-bearing: it changes between renders
|
|
415
|
+
* AND something in this file keys a hook on it.
|
|
416
|
+
*
|
|
417
|
+
* `useLatestCallback` returns a permanently stable reference, so a hook that
|
|
418
|
+
* compares the callback across renders stops seeing it change. An effect keyed
|
|
419
|
+
* on the callback then fires once, ever — the callers that deliberately rebuild
|
|
420
|
+
* a handler so a fetch re-runs lose their refresh (issue #1711). No compliant
|
|
421
|
+
* remedy exists for such a site: rewriting it as `useMemo` is converted back to
|
|
422
|
+
* `useCallback` by `prefer-usecallback-over-usememo-for-functions`, so the
|
|
423
|
+
* violation is not reported at all rather than reported without a fix.
|
|
424
|
+
*
|
|
425
|
+
* A callback whose identity is exposed some other way — returned from a custom
|
|
426
|
+
* hook, stored on an object — keeps reporting: the consumer is out of this
|
|
427
|
+
* file's sight, and reporting is the direction that preserves the rule.
|
|
428
|
+
*/
|
|
429
|
+
const identityIsLoadBearing = (context, call) => {
|
|
430
|
+
if (!identityCanChange(call)) {
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
const declarator = declaratorOf(call);
|
|
434
|
+
// An unbound call is consumed where it is written, so its own position in the
|
|
435
|
+
// tree answers the question the references would.
|
|
436
|
+
if (!declarator) {
|
|
437
|
+
return feedsHookDependency(call);
|
|
438
|
+
}
|
|
439
|
+
return ASTHelpers_1.ASTHelpers.getDeclaredVariables(context, declarator).some((variable) => variable.references.some((reference) => reference.isRead() && feedsHookDependency(reference.identifier)));
|
|
440
|
+
};
|
|
315
441
|
exports.useLatestCallback = (0, createRule_1.createRule)({
|
|
316
442
|
name: 'use-latest-callback',
|
|
317
443
|
meta: {
|
|
@@ -454,7 +580,10 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
|
|
|
454
580
|
}
|
|
455
581
|
}
|
|
456
582
|
}
|
|
457
|
-
|
|
583
|
+
// A callback another hook keys on is left alone entirely — see
|
|
584
|
+
// identityIsLoadBearing for why the report itself is withheld rather
|
|
585
|
+
// than just its fix.
|
|
586
|
+
if (!isJsxReturning && !identityIsLoadBearing(context, node)) {
|
|
458
587
|
const currentCallbackName = node.callee.type === utils_1.AST_NODE_TYPES.Identifier
|
|
459
588
|
? node.callee.name
|
|
460
589
|
: node.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,43 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.104",
|
|
4
|
+
"date": "2026-08-04T20:46:47.102Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "enforce-assert-safe-object-key",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1712,
|
|
11
|
+
1713
|
|
12
|
+
],
|
|
13
|
+
"summary": "honour the declaration site as a numeric proof (closes #1713); read computed keys through assertion and await wrappers (closes #1712)"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"name": "enforce-firestore-set-merge",
|
|
17
|
+
"changeType": "fix",
|
|
18
|
+
"issues": [
|
|
19
|
+
1710
|
|
20
|
+
],
|
|
21
|
+
"summary": "exempt Realtime Database receivers from the batchManager name match (closes #1710)"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"name": "enforce-querykey-ts",
|
|
25
|
+
"changeType": "fix",
|
|
26
|
+
"issues": [
|
|
27
|
+
1714
|
|
28
|
+
],
|
|
29
|
+
"summary": "accept the key shapes prefer-global-router-state-key blesses (closes #1714)"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"name": "use-latest-callback",
|
|
33
|
+
"changeType": "fix",
|
|
34
|
+
"issues": [
|
|
35
|
+
1711
|
|
36
|
+
],
|
|
37
|
+
"summary": "exempt callbacks whose identity another hook keys on (closes #1711)"
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
},
|
|
2
41
|
{
|
|
3
42
|
"version": "1.20.103",
|
|
4
43
|
"date": "2026-08-04T18:19:46.054Z",
|