@blumintinc/eslint-plugin-blumint 1.20.0 → 1.20.1
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
CHANGED
|
@@ -296,7 +296,7 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
|
|
|
296
296
|
if (lowerCallee.startsWith('assert')) {
|
|
297
297
|
return identifierReturnsBoolean(calleeName);
|
|
298
298
|
}
|
|
299
|
-
return
|
|
299
|
+
return calleeNameImpliesBoolean(calleeName, approvedPrefixesWithoutAsserts);
|
|
300
300
|
}
|
|
301
301
|
// Default to false for other cases with || to avoid false positives
|
|
302
302
|
return false;
|
|
@@ -316,7 +316,7 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
|
|
|
316
316
|
return identifierReturnsBoolean(calleeName);
|
|
317
317
|
}
|
|
318
318
|
// Check if the function name suggests it returns a boolean
|
|
319
|
-
return
|
|
319
|
+
return calleeNameImpliesBoolean(calleeName, approvedPrefixesWithoutAsserts);
|
|
320
320
|
}
|
|
321
321
|
}
|
|
322
322
|
return false;
|
|
@@ -418,7 +418,12 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
|
|
|
418
418
|
];
|
|
419
419
|
const matchesPrefix = isPrefixedByBooleanKeyword(calleeName, calleeBooleanPrefixes);
|
|
420
420
|
if (matchesPrefix) {
|
|
421
|
-
|
|
421
|
+
// The callee's declaration outranks its name: a resolvable predicate
|
|
422
|
+
// that demonstrably returns a verdict object, a string or a Promise
|
|
423
|
+
// yields a non-boolean value no matter how it is named.
|
|
424
|
+
return calleeReturnEvaluation(calleeName) === 'nonBoolean'
|
|
425
|
+
? 'nonBoolean'
|
|
426
|
+
: 'boolean';
|
|
422
427
|
}
|
|
423
428
|
if (lowerCallee.startsWith('get') ||
|
|
424
429
|
lowerCallee.startsWith('fetch') ||
|
|
@@ -565,6 +570,238 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
|
|
|
565
570
|
}
|
|
566
571
|
return false;
|
|
567
572
|
}
|
|
573
|
+
// Predicates that call themselves (or each other) would otherwise recurse
|
|
574
|
+
// forever while their returns are classified.
|
|
575
|
+
const calleesUnderEvaluation = new Set();
|
|
576
|
+
const NON_BOOLEAN_TYPE_KEYWORDS = new Set([
|
|
577
|
+
utils_1.AST_NODE_TYPES.TSStringKeyword,
|
|
578
|
+
utils_1.AST_NODE_TYPES.TSNumberKeyword,
|
|
579
|
+
utils_1.AST_NODE_TYPES.TSBigIntKeyword,
|
|
580
|
+
utils_1.AST_NODE_TYPES.TSSymbolKeyword,
|
|
581
|
+
utils_1.AST_NODE_TYPES.TSObjectKeyword,
|
|
582
|
+
utils_1.AST_NODE_TYPES.TSVoidKeyword,
|
|
583
|
+
utils_1.AST_NODE_TYPES.TSNullKeyword,
|
|
584
|
+
utils_1.AST_NODE_TYPES.TSUndefinedKeyword,
|
|
585
|
+
utils_1.AST_NODE_TYPES.TSTypeLiteral,
|
|
586
|
+
utils_1.AST_NODE_TYPES.TSArrayType,
|
|
587
|
+
utils_1.AST_NODE_TYPES.TSTupleType,
|
|
588
|
+
utils_1.AST_NODE_TYPES.TSFunctionType,
|
|
589
|
+
utils_1.AST_NODE_TYPES.TSConstructorType,
|
|
590
|
+
utils_1.AST_NODE_TYPES.TSIntersectionType,
|
|
591
|
+
utils_1.AST_NODE_TYPES.TSTemplateLiteralType,
|
|
592
|
+
utils_1.AST_NODE_TYPES.TSMappedType,
|
|
593
|
+
]);
|
|
594
|
+
const NON_BOOLEAN_EXPRESSION_TYPES = new Set([
|
|
595
|
+
utils_1.AST_NODE_TYPES.ObjectExpression,
|
|
596
|
+
utils_1.AST_NODE_TYPES.ArrayExpression,
|
|
597
|
+
utils_1.AST_NODE_TYPES.TemplateLiteral,
|
|
598
|
+
utils_1.AST_NODE_TYPES.TaggedTemplateExpression,
|
|
599
|
+
utils_1.AST_NODE_TYPES.ArrowFunctionExpression,
|
|
600
|
+
utils_1.AST_NODE_TYPES.FunctionExpression,
|
|
601
|
+
utils_1.AST_NODE_TYPES.ClassExpression,
|
|
602
|
+
utils_1.AST_NODE_TYPES.NewExpression,
|
|
603
|
+
utils_1.AST_NODE_TYPES.UpdateExpression,
|
|
604
|
+
utils_1.AST_NODE_TYPES.JSXElement,
|
|
605
|
+
utils_1.AST_NODE_TYPES.JSXFragment,
|
|
606
|
+
]);
|
|
607
|
+
// `as const` preserves the operand's shape, so the assertion says nothing
|
|
608
|
+
// about booleanness and the operand must be classified instead.
|
|
609
|
+
function isConstAssertion(typeNode) {
|
|
610
|
+
return (typeNode.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
|
|
611
|
+
typeNode.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
612
|
+
typeNode.typeName.name === 'const');
|
|
613
|
+
}
|
|
614
|
+
function classifyTypeAnnotation(typeNode) {
|
|
615
|
+
if (typeNode.type === utils_1.AST_NODE_TYPES.TSBooleanKeyword ||
|
|
616
|
+
typeNode.type === utils_1.AST_NODE_TYPES.TSTypePredicate) {
|
|
617
|
+
return 'boolean';
|
|
618
|
+
}
|
|
619
|
+
if (typeNode.type === utils_1.AST_NODE_TYPES.TSLiteralType) {
|
|
620
|
+
const literal = typeNode.literal;
|
|
621
|
+
return literal.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
622
|
+
typeof literal.value === 'boolean'
|
|
623
|
+
? 'boolean'
|
|
624
|
+
: 'nonBoolean';
|
|
625
|
+
}
|
|
626
|
+
if (typeNode.type === utils_1.AST_NODE_TYPES.TSUnionType) {
|
|
627
|
+
const members = typeNode.types.map(classifyTypeAnnotation);
|
|
628
|
+
if (members.every((member) => member === 'boolean')) {
|
|
629
|
+
return 'boolean';
|
|
630
|
+
}
|
|
631
|
+
// A union that mixes boolean with anything else cannot be trusted to
|
|
632
|
+
// hold a boolean; the repository prefers false negatives here.
|
|
633
|
+
return members.some((member) => member === 'nonBoolean')
|
|
634
|
+
? 'nonBoolean'
|
|
635
|
+
: 'indeterminate';
|
|
636
|
+
}
|
|
637
|
+
if (typeNode.type === utils_1.AST_NODE_TYPES.TSTypeReference) {
|
|
638
|
+
if (typeNode.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
639
|
+
typeNode.typeName.name === 'Boolean') {
|
|
640
|
+
return 'boolean';
|
|
641
|
+
}
|
|
642
|
+
// Named types (including `Promise<boolean>`, whose call site yields a
|
|
643
|
+
// promise rather than a boolean) are treated as non-boolean values.
|
|
644
|
+
return 'nonBoolean';
|
|
645
|
+
}
|
|
646
|
+
if (NON_BOOLEAN_TYPE_KEYWORDS.has(typeNode.type)) {
|
|
647
|
+
return 'nonBoolean';
|
|
648
|
+
}
|
|
649
|
+
// `any`, `unknown`, generics, conditional and indexed-access types carry
|
|
650
|
+
// no reliable syntactic verdict.
|
|
651
|
+
return 'indeterminate';
|
|
652
|
+
}
|
|
653
|
+
function classifyReturnExpression(expression) {
|
|
654
|
+
// A bare `return;` (or a fall-through) produces undefined.
|
|
655
|
+
if (!expression)
|
|
656
|
+
return 'nonBoolean';
|
|
657
|
+
if (expression.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
|
|
658
|
+
expression.type === utils_1.AST_NODE_TYPES.TSSatisfiesExpression ||
|
|
659
|
+
expression.type === utils_1.AST_NODE_TYPES.TSTypeAssertion) {
|
|
660
|
+
const annotation = expression.typeAnnotation;
|
|
661
|
+
if (annotation && !isConstAssertion(annotation)) {
|
|
662
|
+
const classified = classifyTypeAnnotation(annotation);
|
|
663
|
+
if (classified !== 'indeterminate') {
|
|
664
|
+
return classified;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return classifyReturnExpression(expression.expression);
|
|
668
|
+
}
|
|
669
|
+
if (expression.type === utils_1.AST_NODE_TYPES.ChainExpression ||
|
|
670
|
+
expression.type === utils_1.AST_NODE_TYPES.TSNonNullExpression) {
|
|
671
|
+
return classifyReturnExpression(expression.expression);
|
|
672
|
+
}
|
|
673
|
+
if (expression.type === utils_1.AST_NODE_TYPES.SequenceExpression) {
|
|
674
|
+
return classifyReturnExpression(expression.expressions[expression.expressions.length - 1]);
|
|
675
|
+
}
|
|
676
|
+
if (NON_BOOLEAN_EXPRESSION_TYPES.has(expression.type)) {
|
|
677
|
+
return 'nonBoolean';
|
|
678
|
+
}
|
|
679
|
+
if (expression.type === utils_1.AST_NODE_TYPES.Literal) {
|
|
680
|
+
return typeof expression.value === 'boolean' ? 'boolean' : 'nonBoolean';
|
|
681
|
+
}
|
|
682
|
+
if (expression.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
683
|
+
expression.name === 'undefined') {
|
|
684
|
+
return 'nonBoolean';
|
|
685
|
+
}
|
|
686
|
+
if (expression.type === utils_1.AST_NODE_TYPES.UnaryExpression) {
|
|
687
|
+
if (expression.operator === '!' || expression.operator === 'delete') {
|
|
688
|
+
return 'boolean';
|
|
689
|
+
}
|
|
690
|
+
return 'nonBoolean';
|
|
691
|
+
}
|
|
692
|
+
if (expression.type === utils_1.AST_NODE_TYPES.BinaryExpression &&
|
|
693
|
+
!BOOLEANISH_BINARY_OPERATORS.has(expression.operator)) {
|
|
694
|
+
return 'nonBoolean';
|
|
695
|
+
}
|
|
696
|
+
// Branching expressions are classified from their branches so a mix of a
|
|
697
|
+
// boolean and an object (or two objects) is recognized as non-boolean.
|
|
698
|
+
if (expression.type === utils_1.AST_NODE_TYPES.ConditionalExpression) {
|
|
699
|
+
return combineClassifications([
|
|
700
|
+
classifyReturnExpression(expression.consequent),
|
|
701
|
+
classifyReturnExpression(expression.alternate),
|
|
702
|
+
]);
|
|
703
|
+
}
|
|
704
|
+
if (expression.type === utils_1.AST_NODE_TYPES.LogicalExpression) {
|
|
705
|
+
return combineClassifications([
|
|
706
|
+
classifyReturnExpression(expression.left),
|
|
707
|
+
classifyReturnExpression(expression.right),
|
|
708
|
+
]);
|
|
709
|
+
}
|
|
710
|
+
const evaluation = evaluateBooleanishExpression(expression);
|
|
711
|
+
if (evaluation === 'boolean')
|
|
712
|
+
return 'boolean';
|
|
713
|
+
if (evaluation === 'nonBoolean')
|
|
714
|
+
return 'nonBoolean';
|
|
715
|
+
return 'indeterminate';
|
|
716
|
+
}
|
|
717
|
+
function combineClassifications(classifications) {
|
|
718
|
+
if (classifications.length === 0)
|
|
719
|
+
return 'indeterminate';
|
|
720
|
+
if (classifications.every((entry) => entry === 'boolean')) {
|
|
721
|
+
return 'boolean';
|
|
722
|
+
}
|
|
723
|
+
return classifications.some((entry) => entry === 'nonBoolean')
|
|
724
|
+
? 'nonBoolean'
|
|
725
|
+
: 'indeterminate';
|
|
726
|
+
}
|
|
727
|
+
function classifyFunctionReturn(functionLike) {
|
|
728
|
+
const annotation = functionLike.returnType?.typeAnnotation;
|
|
729
|
+
if (annotation) {
|
|
730
|
+
const classified = classifyTypeAnnotation(annotation);
|
|
731
|
+
if (classified !== 'indeterminate') {
|
|
732
|
+
return classified;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
// Calling an async or generator function hands back a promise or an
|
|
736
|
+
// iterator, never the boolean produced inside the body.
|
|
737
|
+
if (functionLike.async || functionLike.generator) {
|
|
738
|
+
return 'nonBoolean';
|
|
739
|
+
}
|
|
740
|
+
if (functionLike.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression &&
|
|
741
|
+
functionLike.expression) {
|
|
742
|
+
return classifyReturnExpression(functionLike.body);
|
|
743
|
+
}
|
|
744
|
+
// Overload signatures and ambient declarations expose no body to inspect.
|
|
745
|
+
if (functionLike.body?.type !== utils_1.AST_NODE_TYPES.BlockStatement) {
|
|
746
|
+
return 'indeterminate';
|
|
747
|
+
}
|
|
748
|
+
const returnArguments = collectReturnArguments(functionLike.body);
|
|
749
|
+
if (returnArguments.length === 0) {
|
|
750
|
+
return 'nonBoolean';
|
|
751
|
+
}
|
|
752
|
+
return combineClassifications(returnArguments.map(classifyReturnExpression));
|
|
753
|
+
}
|
|
754
|
+
function functionOfDefinition(definition) {
|
|
755
|
+
if (definition.type === 'FunctionName') {
|
|
756
|
+
return definition.node;
|
|
757
|
+
}
|
|
758
|
+
if (definition.type === 'Variable') {
|
|
759
|
+
const init = definition.node
|
|
760
|
+
.init;
|
|
761
|
+
if (init?.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
762
|
+
init?.type === utils_1.AST_NODE_TYPES.FunctionExpression) {
|
|
763
|
+
return init;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return undefined;
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Resolve a callee identifier through the scope chain and classify the value
|
|
770
|
+
* its calls produce, without a type checker.
|
|
771
|
+
*/
|
|
772
|
+
function calleeReturnEvaluation(name) {
|
|
773
|
+
if (calleesUnderEvaluation.has(name))
|
|
774
|
+
return 'indeterminate';
|
|
775
|
+
const variable = findVariableInScopes(name);
|
|
776
|
+
if (!variable || variable.defs.length === 0)
|
|
777
|
+
return 'indeterminate';
|
|
778
|
+
calleesUnderEvaluation.add(name);
|
|
779
|
+
try {
|
|
780
|
+
const classifications = [];
|
|
781
|
+
for (const definition of variable.defs) {
|
|
782
|
+
const functionLike = functionOfDefinition(definition);
|
|
783
|
+
// Imports, parameters and aliases hide the implementation, so the
|
|
784
|
+
// name heuristic must keep its reach across module boundaries.
|
|
785
|
+
if (!functionLike)
|
|
786
|
+
return 'indeterminate';
|
|
787
|
+
classifications.push(classifyFunctionReturn(functionLike));
|
|
788
|
+
}
|
|
789
|
+
return combineClassifications(classifications);
|
|
790
|
+
}
|
|
791
|
+
finally {
|
|
792
|
+
calleesUnderEvaluation.delete(name);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Decide whether a call to `calleeName` yields a boolean, giving the callee's
|
|
797
|
+
* resolvable declaration the final say over its name.
|
|
798
|
+
*/
|
|
799
|
+
function calleeNameImpliesBoolean(calleeName, prefixes) {
|
|
800
|
+
if (!isPrefixedByBooleanKeyword(calleeName, prefixes)) {
|
|
801
|
+
return false;
|
|
802
|
+
}
|
|
803
|
+
return calleeReturnEvaluation(calleeName) !== 'nonBoolean';
|
|
804
|
+
}
|
|
568
805
|
/**
|
|
569
806
|
* Check if a variable is used in a while loop condition and is likely a DOM element or tree node
|
|
570
807
|
* This helps identify variables like 'parent', 'element', 'node', etc. that are used
|
|
@@ -6,6 +6,23 @@ const createRule_1 = require("../utils/createRule");
|
|
|
6
6
|
const FIRESTORE_METHODS = new Set(['get', 'set', 'update', 'delete']);
|
|
7
7
|
const COLLECTION_CONSTRUCTORS = new Set(['Set', 'Map', 'WeakSet', 'WeakMap']);
|
|
8
8
|
const KNOWN_FIRESTORE_ROOTS = new Set(['db', 'firestore']);
|
|
9
|
+
// The modular client SDK. DocSetter/DocSetterTransaction wrap
|
|
10
|
+
// firebase-admin/firestore and cannot be imported from frontend code, so a
|
|
11
|
+
// client-SDK batch or transaction has no facade to route through and must not
|
|
12
|
+
// be reported.
|
|
13
|
+
const CLIENT_SDK_MODULES = new Set([
|
|
14
|
+
'firebase/firestore',
|
|
15
|
+
'@firebase/firestore',
|
|
16
|
+
'firebase/firestore/lite',
|
|
17
|
+
]);
|
|
18
|
+
// firebase-admin/firestore exposes no `writeBatch` export at all (the admin
|
|
19
|
+
// API is `db.batch()`), so this spelling identifies the client SDK with
|
|
20
|
+
// certainty even when the binding's origin cannot be traced.
|
|
21
|
+
const CLIENT_BATCH_FACTORY = 'writeBatch';
|
|
22
|
+
// Shared spelling: the client SDK exports a free `runTransaction(db, cb)`
|
|
23
|
+
// while the admin SDK only exposes the `db.runTransaction(cb)` method, so the
|
|
24
|
+
// callee shape, not the name, decides which SDK a transaction belongs to.
|
|
25
|
+
const TRANSACTION_RUNNER = 'runTransaction';
|
|
9
26
|
const isMemberExpression = (node) => node.type === utils_1.AST_NODE_TYPES.MemberExpression;
|
|
10
27
|
const isCallExpression = (node) => node.type === utils_1.AST_NODE_TYPES.CallExpression;
|
|
11
28
|
const isIdentifier = (node) => node.type === utils_1.AST_NODE_TYPES.Identifier;
|
|
@@ -94,6 +111,16 @@ exports.enforceFirestoreFacade = (0, createRule_1.createRule)({
|
|
|
94
111
|
const firestoreTransactionVariables = new Set();
|
|
95
112
|
const docSetterVariables = new Set();
|
|
96
113
|
const batchManagerVariables = new Set();
|
|
114
|
+
// local name -> imported name, for bindings that come from the modular
|
|
115
|
+
// client SDK (covers `import { writeBatch as wb }` aliases).
|
|
116
|
+
const clientSdkImportedLocals = new Map();
|
|
117
|
+
// Namespace bindings for the client SDK (`import * as fs`).
|
|
118
|
+
const clientSdkNamespaceLocals = new Set();
|
|
119
|
+
// Bindings that demonstrably come from some other module, so a local
|
|
120
|
+
// helper named `writeBatch` is not mistaken for the SDK export.
|
|
121
|
+
const nonClientImportedLocals = new Set();
|
|
122
|
+
// Batches/transactions proven to originate from the client SDK.
|
|
123
|
+
const clientFirestoreVariables = new Set();
|
|
97
124
|
const sourceCode = context.sourceCode;
|
|
98
125
|
const clearFirestoreTrackingFor = (name) => {
|
|
99
126
|
firestoreDocRefVariables.delete(name);
|
|
@@ -102,7 +129,36 @@ exports.enforceFirestoreFacade = (0, createRule_1.createRule)({
|
|
|
102
129
|
firestoreTransactionVariables.delete(name);
|
|
103
130
|
docSetterVariables.delete(name);
|
|
104
131
|
batchManagerVariables.delete(name);
|
|
132
|
+
clientFirestoreVariables.delete(name);
|
|
105
133
|
};
|
|
134
|
+
const isClientSdkExportReference = (node, exportName, allowUntracedSpelling) => {
|
|
135
|
+
const callee = unwrapTSAsExpression(node);
|
|
136
|
+
if (isIdentifier(callee)) {
|
|
137
|
+
if (clientSdkImportedLocals.get(callee.name) === exportName) {
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
return (allowUntracedSpelling &&
|
|
141
|
+
callee.name === exportName &&
|
|
142
|
+
!nonClientImportedLocals.has(callee.name));
|
|
143
|
+
}
|
|
144
|
+
if (isMemberExpression(callee) &&
|
|
145
|
+
isIdentifier(callee.property) &&
|
|
146
|
+
callee.property.name === exportName) {
|
|
147
|
+
const base = getLeftmostIdentifier(callee.object);
|
|
148
|
+
return !!base && clientSdkNamespaceLocals.has(base.name);
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
};
|
|
152
|
+
// `writeBatch(firestore)` / `fs.writeBatch(firestore)` — client SDK only.
|
|
153
|
+
const isClientBatchFactoryCall = (node) => {
|
|
154
|
+
const candidate = unwrapTSAsExpression(node);
|
|
155
|
+
return (isCallExpression(candidate) &&
|
|
156
|
+
isClientSdkExportReference(candidate.callee, CLIENT_BATCH_FACTORY, true));
|
|
157
|
+
};
|
|
158
|
+
// `runTransaction(firestore, cb)` — the free-function form only exists in
|
|
159
|
+
// the client SDK. The name alone is not enough, because a project helper
|
|
160
|
+
// could share it, so the binding must be traced back to the SDK.
|
|
161
|
+
const isClientTransactionRunnerCall = (node) => isClientSdkExportReference(node.callee, TRANSACTION_RUNNER, false);
|
|
106
162
|
const recordFirestoreVariable = (varName, expression) => {
|
|
107
163
|
const target = unwrapTSAsExpression(expression);
|
|
108
164
|
if (target.type === utils_1.AST_NODE_TYPES.ConditionalExpression) {
|
|
@@ -131,6 +187,10 @@ exports.enforceFirestoreFacade = (0, createRule_1.createRule)({
|
|
|
131
187
|
batchManagerVariables.add(varName);
|
|
132
188
|
return true;
|
|
133
189
|
}
|
|
190
|
+
if (isClientBatchFactoryCall(target)) {
|
|
191
|
+
clientFirestoreVariables.add(varName);
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
134
194
|
if (isFirestoreDocumentReference(target)) {
|
|
135
195
|
firestoreDocRefVariables.add(varName);
|
|
136
196
|
return true;
|
|
@@ -143,8 +203,7 @@ exports.enforceFirestoreFacade = (0, createRule_1.createRule)({
|
|
|
143
203
|
isMemberExpression(target.callee) &&
|
|
144
204
|
isIdentifier(target.callee.property) &&
|
|
145
205
|
target.callee.property.name === 'batch' &&
|
|
146
|
-
|
|
147
|
-
target.callee.object.name === 'db') {
|
|
206
|
+
isFirestoreRoot(target.callee.object, firestoreCollectionVariables, firestoreDocRefVariables)) {
|
|
148
207
|
firestoreBatchVariables.add(varName);
|
|
149
208
|
return true;
|
|
150
209
|
}
|
|
@@ -214,6 +273,110 @@ exports.enforceFirestoreFacade = (0, createRule_1.createRule)({
|
|
|
214
273
|
clearFirestoreTrackingFor(varName);
|
|
215
274
|
recordFirestoreVariable(varName, right);
|
|
216
275
|
};
|
|
276
|
+
const recordImportBinding = (localName, importedName, isClientSource) => {
|
|
277
|
+
if (!isClientSource) {
|
|
278
|
+
nonClientImportedLocals.add(localName);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (importedName === null) {
|
|
282
|
+
clientSdkNamespaceLocals.add(localName);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
clientSdkImportedLocals.set(localName, importedName);
|
|
286
|
+
};
|
|
287
|
+
const recordImportPattern = (pattern, source) => {
|
|
288
|
+
const isClientSource = CLIENT_SDK_MODULES.has(source);
|
|
289
|
+
if (pattern.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
|
|
290
|
+
for (const property of pattern.properties) {
|
|
291
|
+
if (property.type !== utils_1.AST_NODE_TYPES.Property)
|
|
292
|
+
continue;
|
|
293
|
+
if (!isIdentifier(property.key) || !isIdentifier(property.value)) {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
recordImportBinding(property.value.name, property.key.name, isClientSource);
|
|
297
|
+
}
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (isIdentifier(pattern)) {
|
|
301
|
+
recordImportBinding(pattern.name, null, isClientSource);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
const getImportExpressionSource = (node) => {
|
|
305
|
+
const expression = unwrapTSAsExpression(node);
|
|
306
|
+
if (expression.type !== utils_1.AST_NODE_TYPES.ImportExpression)
|
|
307
|
+
return null;
|
|
308
|
+
const source = expression.source;
|
|
309
|
+
return source.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
310
|
+
typeof source.value === 'string'
|
|
311
|
+
? source.value
|
|
312
|
+
: null;
|
|
313
|
+
};
|
|
314
|
+
const isPromiseAllCall = (node) => isCallExpression(node) &&
|
|
315
|
+
isMemberExpression(node.callee) &&
|
|
316
|
+
isIdentifier(node.callee.object) &&
|
|
317
|
+
node.callee.object.name === 'Promise' &&
|
|
318
|
+
isIdentifier(node.callee.property) &&
|
|
319
|
+
node.callee.property.name === 'all';
|
|
320
|
+
// Frontend Firebase access is required to be dynamically imported, so
|
|
321
|
+
// `await import(...)` — including the correlated `Promise.all` array form —
|
|
322
|
+
// is the ordinary client-SDK call site rather than an edge case.
|
|
323
|
+
const recordDynamicImportBindings = (node) => {
|
|
324
|
+
const init = node.init;
|
|
325
|
+
if (!init || init.type !== utils_1.AST_NODE_TYPES.AwaitExpression)
|
|
326
|
+
return;
|
|
327
|
+
const awaited = unwrapTSAsExpression(init.argument);
|
|
328
|
+
const directSource = getImportExpressionSource(awaited);
|
|
329
|
+
if (directSource !== null) {
|
|
330
|
+
recordImportPattern(node.id, directSource);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (!isPromiseAllCall(awaited) ||
|
|
334
|
+
node.id.type !== utils_1.AST_NODE_TYPES.ArrayPattern) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const promises = awaited.arguments[0];
|
|
338
|
+
if (!promises || promises.type !== utils_1.AST_NODE_TYPES.ArrayExpression)
|
|
339
|
+
return;
|
|
340
|
+
node.id.elements.forEach((element, index) => {
|
|
341
|
+
const promise = promises.elements[index];
|
|
342
|
+
if (!element || !promise)
|
|
343
|
+
return;
|
|
344
|
+
const source = getImportExpressionSource(promise);
|
|
345
|
+
if (source === null)
|
|
346
|
+
return;
|
|
347
|
+
recordImportPattern(element, source);
|
|
348
|
+
});
|
|
349
|
+
};
|
|
350
|
+
const getCallbackFirstParam = (node) => {
|
|
351
|
+
const callback = node.arguments.find((argument) => argument.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
352
|
+
argument.type === utils_1.AST_NODE_TYPES.FunctionExpression);
|
|
353
|
+
const param = callback?.params[0];
|
|
354
|
+
return param && isIdentifier(param) ? param : null;
|
|
355
|
+
};
|
|
356
|
+
// Binds the transaction callback parameter to the SDK that produced it, so
|
|
357
|
+
// classification rests on origin rather than on the parameter being
|
|
358
|
+
// spelled `transaction`.
|
|
359
|
+
const recordTransactionCallback = (node) => {
|
|
360
|
+
if (isClientTransactionRunnerCall(node)) {
|
|
361
|
+
const param = getCallbackFirstParam(node);
|
|
362
|
+
if (param) {
|
|
363
|
+
clearFirestoreTrackingFor(param.name);
|
|
364
|
+
clientFirestoreVariables.add(param.name);
|
|
365
|
+
}
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const callee = node.callee;
|
|
369
|
+
if (!isMemberExpression(callee) ||
|
|
370
|
+
!isIdentifier(callee.property) ||
|
|
371
|
+
callee.property.name !== TRANSACTION_RUNNER ||
|
|
372
|
+
!isFirestoreRoot(callee.object, firestoreCollectionVariables, firestoreDocRefVariables)) {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const param = getCallbackFirstParam(node);
|
|
376
|
+
if (param && !clientFirestoreVariables.has(param.name)) {
|
|
377
|
+
firestoreTransactionVariables.add(param.name);
|
|
378
|
+
}
|
|
379
|
+
};
|
|
217
380
|
const isRealtimeDbRefAssignment = (node) => {
|
|
218
381
|
if (node.type !== utils_1.AST_NODE_TYPES.VariableDeclarator)
|
|
219
382
|
return false;
|
|
@@ -282,7 +445,8 @@ exports.enforceFirestoreFacade = (0, createRule_1.createRule)({
|
|
|
282
445
|
const object = node.callee.object;
|
|
283
446
|
if (isIdentifier(object)) {
|
|
284
447
|
const name = object.name;
|
|
285
|
-
if (
|
|
448
|
+
if (clientFirestoreVariables.has(name) ||
|
|
449
|
+
docSetterVariables.has(name) ||
|
|
286
450
|
batchManagerVariables.has(name) ||
|
|
287
451
|
realtimeDbRefVariables.has(name) ||
|
|
288
452
|
realtimeDbChildVariables.has(name) ||
|
|
@@ -304,7 +468,7 @@ exports.enforceFirestoreFacade = (0, createRule_1.createRule)({
|
|
|
304
468
|
}
|
|
305
469
|
return false;
|
|
306
470
|
}
|
|
307
|
-
if (isRealtimeDbReference(object)) {
|
|
471
|
+
if (isRealtimeDbReference(object) || isClientBatchFactoryCall(object)) {
|
|
308
472
|
return false;
|
|
309
473
|
}
|
|
310
474
|
if (isFirestoreDocumentReference(object)) {
|
|
@@ -374,7 +538,19 @@ exports.enforceFirestoreFacade = (0, createRule_1.createRule)({
|
|
|
374
538
|
});
|
|
375
539
|
};
|
|
376
540
|
return {
|
|
541
|
+
ImportDeclaration(node) {
|
|
542
|
+
const source = node.source.value;
|
|
543
|
+
const isClientSource = typeof source === 'string' && CLIENT_SDK_MODULES.has(source);
|
|
544
|
+
for (const specifier of node.specifiers) {
|
|
545
|
+
if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier) {
|
|
546
|
+
recordImportBinding(specifier.local.name, specifier.imported.name, isClientSource);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
recordImportBinding(specifier.local.name, null, isClientSource);
|
|
550
|
+
}
|
|
551
|
+
},
|
|
377
552
|
VariableDeclarator(node) {
|
|
553
|
+
recordDynamicImportBindings(node);
|
|
378
554
|
isRealtimeDbRefAssignment(node);
|
|
379
555
|
isCollectionObjectAssignment(node);
|
|
380
556
|
isFirestoreAssignment(node);
|
|
@@ -383,6 +559,7 @@ exports.enforceFirestoreFacade = (0, createRule_1.createRule)({
|
|
|
383
559
|
handleAssignmentExpression(node);
|
|
384
560
|
},
|
|
385
561
|
CallExpression(node) {
|
|
562
|
+
recordTransactionCallback(node);
|
|
386
563
|
if (!isFirestoreMethodCall(node))
|
|
387
564
|
return;
|
|
388
565
|
const callee = node.callee;
|
|
@@ -265,14 +265,95 @@ function getFunctionName(fn) {
|
|
|
265
265
|
}
|
|
266
266
|
return findNameInAncestors(parent);
|
|
267
267
|
}
|
|
268
|
+
/**
|
|
269
|
+
* Locates the ancestor that supplies a function's name, mirroring the walk
|
|
270
|
+
* `getFunctionName` performs through `findNameInAncestors` but yielding the
|
|
271
|
+
* NODE instead of the string, so callers can tell an object-property key apart
|
|
272
|
+
* from a variable or assignment binding.
|
|
273
|
+
*/
|
|
274
|
+
function findNamingNode(fn) {
|
|
275
|
+
let current = fn.parent;
|
|
276
|
+
while (current) {
|
|
277
|
+
if (getNameFromNode(current)) {
|
|
278
|
+
return current;
|
|
279
|
+
}
|
|
280
|
+
if (!isTransparentNode(current)) {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
current = current.parent;
|
|
284
|
+
}
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* True when a function expression is passed directly as an argument to a call
|
|
289
|
+
* (after unwrapping assertion/parenthesis wrappers). Function declarations are
|
|
290
|
+
* excluded because a declaration is a statement, never an argument.
|
|
291
|
+
*/
|
|
292
|
+
function isCallbackArgument(fn) {
|
|
293
|
+
if (fn.type === utils_1.AST_NODE_TYPES.FunctionDeclaration) {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
let parent = fn.parent;
|
|
297
|
+
while (parent && isExpressionWrapper(parent)) {
|
|
298
|
+
parent = parent.parent;
|
|
299
|
+
}
|
|
300
|
+
return (parent?.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
301
|
+
parent.arguments.some((arg) => unwrapNestedExpressions(arg) === fn));
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* True when a function's only claim to component/hook status is an object
|
|
305
|
+
* property key, and that object is assembled inside an anonymous callback which
|
|
306
|
+
* is itself neither a component nor a hook — the shape of a module factory
|
|
307
|
+
* (`jest.mock('m', () => ({ useThing: … }))`,
|
|
308
|
+
* `registerModule('m', () => ({ useThing: … }))`). Such a factory builds a
|
|
309
|
+
* value; React never renders it, so a `use*`/PascalCase key inside it names a
|
|
310
|
+
* member of that value rather than a render body. This is the same reasoning
|
|
311
|
+
* that makes `isComponentName` reject SCREAMING_SNAKE_CASE and that makes
|
|
312
|
+
* `isIterationMethodCallback` exempt discarded callbacks: a name match in a
|
|
313
|
+
* non-render context is not a render body.
|
|
314
|
+
*
|
|
315
|
+
* Requiring the enclosing function to be an anonymous CALL ARGUMENT is the
|
|
316
|
+
* deliberate narrowing that preserves genuine hook factories
|
|
317
|
+
* (`export function createApi(client) { return { useUser: … }; }`), whose
|
|
318
|
+
* returned hook really is consumed by React and really does need referential
|
|
319
|
+
* stability.
|
|
320
|
+
*/
|
|
321
|
+
function isFactoryObjectMember(fn) {
|
|
322
|
+
const namingNode = findNamingNode(fn);
|
|
323
|
+
if (!namingNode || namingNode.type !== utils_1.AST_NODE_TYPES.Property) {
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
const objectLiteral = namingNode.parent;
|
|
327
|
+
if (!objectLiteral ||
|
|
328
|
+
objectLiteral.type !== utils_1.AST_NODE_TYPES.ObjectExpression) {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
let current = objectLiteral.parent;
|
|
332
|
+
while (current) {
|
|
333
|
+
if (isFunctionNode(current)) {
|
|
334
|
+
return isCallbackArgument(current) && !isComponentOrHookFunction(current);
|
|
335
|
+
}
|
|
336
|
+
current = current.parent;
|
|
337
|
+
}
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
268
340
|
/**
|
|
269
341
|
* Checks whether a function should be treated as a React component or hook
|
|
270
342
|
* based on naming conventions. Enables the rule to limit reports to user-facing
|
|
271
343
|
* components and hooks rather than arbitrary functions.
|
|
344
|
+
*
|
|
345
|
+
* The name match is disqualified for members of a factory-built object (see
|
|
346
|
+
* `isFactoryObjectMember`). Disqualifying at the classifier — rather than
|
|
347
|
+
* skipping the report — keeps `findEnclosingComponentOrHook` walking outward,
|
|
348
|
+
* so a literal inside a factory nested in a real component is still attributed
|
|
349
|
+
* to that component.
|
|
272
350
|
*/
|
|
273
351
|
function isComponentOrHookFunction(fn) {
|
|
274
352
|
const name = getFunctionName(fn);
|
|
275
|
-
|
|
353
|
+
if (!isComponentName(name) && !isHookName(name)) {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
return !isFactoryObjectMember(fn);
|
|
276
357
|
}
|
|
277
358
|
/**
|
|
278
359
|
* Checks whether a function node represents a hook by name.
|
|
@@ -329,6 +410,54 @@ function getLiteralDescriptor(node) {
|
|
|
329
410
|
const descriptor = LITERAL_DESCRIPTOR_BY_TYPE[node.type] ?? null;
|
|
330
411
|
return descriptor;
|
|
331
412
|
}
|
|
413
|
+
/**
|
|
414
|
+
* Jest APIs whose second argument is a module factory: a function executed to
|
|
415
|
+
* BUILD a replacement module, hoisted above imports by `babel-plugin-jest-hoist`.
|
|
416
|
+
*/
|
|
417
|
+
const JEST_MOCK_FACTORY_METHODS = new Set(['mock', 'doMock', 'setMock']);
|
|
418
|
+
/**
|
|
419
|
+
* True when `node` sits anywhere inside a jest module factory
|
|
420
|
+
* (`jest.mock('m', () => ({ … }))`). A factory is executed to build a
|
|
421
|
+
* replacement module, never rendered by React, so the rule's "on each render"
|
|
422
|
+
* premise does not hold for the test doubles it defines.
|
|
423
|
+
*
|
|
424
|
+
* Beyond the premise, neither remediation the rule advises is legal here:
|
|
425
|
+
* `babel-plugin-jest-hoist` rejects every out-of-scope reference inside a
|
|
426
|
+
* factory except `mock`-prefixed names, so `useMemo` cannot be imported or
|
|
427
|
+
* called, and hoisting to a module constant changes behaviour because the
|
|
428
|
+
* double must rebuild per call for tests to vary its result. This mirrors
|
|
429
|
+
* `isInsideIterationMethodCallback`, which exempts literals whose remediation
|
|
430
|
+
* is unfollowable at the literal.
|
|
431
|
+
*
|
|
432
|
+
* The walk is lexical, so a hook declared outside the factory in the same file
|
|
433
|
+
* stays analyzed.
|
|
434
|
+
*/
|
|
435
|
+
function isInsideJestMockFactory(node) {
|
|
436
|
+
let current = node;
|
|
437
|
+
while (current) {
|
|
438
|
+
if (isFunctionNode(current) &&
|
|
439
|
+
current.type !== utils_1.AST_NODE_TYPES.FunctionDeclaration) {
|
|
440
|
+
let parent = current.parent;
|
|
441
|
+
while (parent && isExpressionWrapper(parent)) {
|
|
442
|
+
parent = parent.parent;
|
|
443
|
+
}
|
|
444
|
+
if (parent?.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
445
|
+
parent.arguments.some((arg) => unwrapNestedExpressions(arg) === current)) {
|
|
446
|
+
const { callee } = parent;
|
|
447
|
+
if (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
448
|
+
!callee.computed &&
|
|
449
|
+
callee.object.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
450
|
+
callee.object.name === 'jest' &&
|
|
451
|
+
callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
452
|
+
JEST_MOCK_FACTORY_METHODS.has(callee.property.name)) {
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
current = current.parent;
|
|
458
|
+
}
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
332
461
|
/**
|
|
333
462
|
* Finds the nearest ancestor function considered a React component or hook.
|
|
334
463
|
* @param node Starting node for the search.
|
|
@@ -862,6 +991,329 @@ exports.reactMemoizeLiterals = (0, createRule_1.createRule)({
|
|
|
862
991
|
}
|
|
863
992
|
return usages.every((ref) => isStyleJSXAttributeValue(ref.identifier));
|
|
864
993
|
}
|
|
994
|
+
const PRIMITIVE_TYPE_KEYWORDS = new Set([
|
|
995
|
+
utils_1.AST_NODE_TYPES.TSBooleanKeyword,
|
|
996
|
+
utils_1.AST_NODE_TYPES.TSStringKeyword,
|
|
997
|
+
utils_1.AST_NODE_TYPES.TSNumberKeyword,
|
|
998
|
+
utils_1.AST_NODE_TYPES.TSBigIntKeyword,
|
|
999
|
+
utils_1.AST_NODE_TYPES.TSSymbolKeyword,
|
|
1000
|
+
utils_1.AST_NODE_TYPES.TSVoidKeyword,
|
|
1001
|
+
utils_1.AST_NODE_TYPES.TSNullKeyword,
|
|
1002
|
+
utils_1.AST_NODE_TYPES.TSUndefinedKeyword,
|
|
1003
|
+
utils_1.AST_NODE_TYPES.TSNeverKeyword,
|
|
1004
|
+
utils_1.AST_NODE_TYPES.TSTypePredicate,
|
|
1005
|
+
utils_1.AST_NODE_TYPES.TSTemplateLiteralType,
|
|
1006
|
+
]);
|
|
1007
|
+
const NON_PRIMITIVE_EXPRESSION_TYPES = new Set([
|
|
1008
|
+
utils_1.AST_NODE_TYPES.ObjectExpression,
|
|
1009
|
+
utils_1.AST_NODE_TYPES.ArrayExpression,
|
|
1010
|
+
utils_1.AST_NODE_TYPES.ArrowFunctionExpression,
|
|
1011
|
+
utils_1.AST_NODE_TYPES.FunctionExpression,
|
|
1012
|
+
utils_1.AST_NODE_TYPES.ClassExpression,
|
|
1013
|
+
utils_1.AST_NODE_TYPES.NewExpression,
|
|
1014
|
+
utils_1.AST_NODE_TYPES.JSXElement,
|
|
1015
|
+
utils_1.AST_NODE_TYPES.JSXFragment,
|
|
1016
|
+
]);
|
|
1017
|
+
const COMPARISONISH_BINARY_OPERATORS = new Set([
|
|
1018
|
+
'===',
|
|
1019
|
+
'!==',
|
|
1020
|
+
'==',
|
|
1021
|
+
'!=',
|
|
1022
|
+
'<',
|
|
1023
|
+
'>',
|
|
1024
|
+
'<=',
|
|
1025
|
+
'>=',
|
|
1026
|
+
'in',
|
|
1027
|
+
'instanceof',
|
|
1028
|
+
]);
|
|
1029
|
+
/**
|
|
1030
|
+
* Innermost scope containing `node`. Resolved from the source code rather
|
|
1031
|
+
* than `context.getScope()` because the latter is deprecated in ESLint 9
|
|
1032
|
+
* and reports the traversal position instead of the node's own scope.
|
|
1033
|
+
*/
|
|
1034
|
+
function scopeOf(node) {
|
|
1035
|
+
const sourceCode = context.getSourceCode();
|
|
1036
|
+
return sourceCode.getScope ? sourceCode.getScope(node) : null;
|
|
1037
|
+
}
|
|
1038
|
+
function findVariableInScopes(name, startScope) {
|
|
1039
|
+
let currentScope = startScope;
|
|
1040
|
+
while (currentScope) {
|
|
1041
|
+
const variable = currentScope.variables.find((v) => v.name === name);
|
|
1042
|
+
if (variable)
|
|
1043
|
+
return variable;
|
|
1044
|
+
currentScope = currentScope.upper;
|
|
1045
|
+
}
|
|
1046
|
+
return undefined;
|
|
1047
|
+
}
|
|
1048
|
+
function isConstAssertion(typeNode) {
|
|
1049
|
+
return (typeNode.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
|
|
1050
|
+
typeNode.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
1051
|
+
typeNode.typeName.name === 'const');
|
|
1052
|
+
}
|
|
1053
|
+
function classifyTypeAnnotation(typeNode) {
|
|
1054
|
+
if (PRIMITIVE_TYPE_KEYWORDS.has(typeNode.type)) {
|
|
1055
|
+
return 'primitive';
|
|
1056
|
+
}
|
|
1057
|
+
if (typeNode.type === utils_1.AST_NODE_TYPES.TSLiteralType) {
|
|
1058
|
+
return 'primitive';
|
|
1059
|
+
}
|
|
1060
|
+
if (typeNode.type === utils_1.AST_NODE_TYPES.TSUnionType) {
|
|
1061
|
+
const members = typeNode.types.map(classifyTypeAnnotation);
|
|
1062
|
+
if (members.every((m) => m === 'primitive'))
|
|
1063
|
+
return 'primitive';
|
|
1064
|
+
return members.some((m) => m === 'nonPrimitive')
|
|
1065
|
+
? 'nonPrimitive'
|
|
1066
|
+
: 'indeterminate';
|
|
1067
|
+
}
|
|
1068
|
+
if (typeNode.type === utils_1.AST_NODE_TYPES.TSTypeLiteral ||
|
|
1069
|
+
typeNode.type === utils_1.AST_NODE_TYPES.TSArrayType ||
|
|
1070
|
+
typeNode.type === utils_1.AST_NODE_TYPES.TSTupleType ||
|
|
1071
|
+
typeNode.type === utils_1.AST_NODE_TYPES.TSFunctionType ||
|
|
1072
|
+
typeNode.type === utils_1.AST_NODE_TYPES.TSConstructorType ||
|
|
1073
|
+
typeNode.type === utils_1.AST_NODE_TYPES.TSObjectKeyword ||
|
|
1074
|
+
typeNode.type === utils_1.AST_NODE_TYPES.TSIntersectionType ||
|
|
1075
|
+
typeNode.type === utils_1.AST_NODE_TYPES.TSMappedType ||
|
|
1076
|
+
typeNode.type === utils_1.AST_NODE_TYPES.TSTypeReference) {
|
|
1077
|
+
return 'nonPrimitive';
|
|
1078
|
+
}
|
|
1079
|
+
return 'indeterminate';
|
|
1080
|
+
}
|
|
1081
|
+
function combineReturnEvaluations(entries) {
|
|
1082
|
+
if (entries.length === 0)
|
|
1083
|
+
return 'indeterminate';
|
|
1084
|
+
if (entries.every((e) => e === 'primitive'))
|
|
1085
|
+
return 'primitive';
|
|
1086
|
+
return entries.some((e) => e === 'nonPrimitive')
|
|
1087
|
+
? 'nonPrimitive'
|
|
1088
|
+
: 'indeterminate';
|
|
1089
|
+
}
|
|
1090
|
+
function classifyReturnExpression(expression) {
|
|
1091
|
+
if (!expression)
|
|
1092
|
+
return 'primitive';
|
|
1093
|
+
if (expression.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
|
|
1094
|
+
expression.type === utils_1.AST_NODE_TYPES.TSSatisfiesExpression ||
|
|
1095
|
+
expression.type === utils_1.AST_NODE_TYPES.TSTypeAssertion) {
|
|
1096
|
+
const annotation = expression.typeAnnotation;
|
|
1097
|
+
if (annotation && !isConstAssertion(annotation)) {
|
|
1098
|
+
const classified = classifyTypeAnnotation(annotation);
|
|
1099
|
+
if (classified !== 'indeterminate')
|
|
1100
|
+
return classified;
|
|
1101
|
+
}
|
|
1102
|
+
return classifyReturnExpression(expression.expression);
|
|
1103
|
+
}
|
|
1104
|
+
if (expression.type === utils_1.AST_NODE_TYPES.ChainExpression ||
|
|
1105
|
+
expression.type === utils_1.AST_NODE_TYPES.TSNonNullExpression) {
|
|
1106
|
+
return classifyReturnExpression(expression.expression);
|
|
1107
|
+
}
|
|
1108
|
+
if (expression.type === PARENTHESIZED_EXPRESSION_TYPE) {
|
|
1109
|
+
return classifyReturnExpression(expression
|
|
1110
|
+
.expression);
|
|
1111
|
+
}
|
|
1112
|
+
if (expression.type === utils_1.AST_NODE_TYPES.SequenceExpression) {
|
|
1113
|
+
return classifyReturnExpression(expression.expressions[expression.expressions.length - 1]);
|
|
1114
|
+
}
|
|
1115
|
+
if (NON_PRIMITIVE_EXPRESSION_TYPES.has(expression.type)) {
|
|
1116
|
+
return 'nonPrimitive';
|
|
1117
|
+
}
|
|
1118
|
+
// Literals, template literals and update expressions are primitives by
|
|
1119
|
+
// construction.
|
|
1120
|
+
if (expression.type === utils_1.AST_NODE_TYPES.Literal ||
|
|
1121
|
+
expression.type === utils_1.AST_NODE_TYPES.TemplateLiteral ||
|
|
1122
|
+
expression.type === utils_1.AST_NODE_TYPES.UpdateExpression) {
|
|
1123
|
+
return 'primitive';
|
|
1124
|
+
}
|
|
1125
|
+
if (expression.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
1126
|
+
expression.name === 'undefined') {
|
|
1127
|
+
return 'primitive';
|
|
1128
|
+
}
|
|
1129
|
+
if (expression.type === utils_1.AST_NODE_TYPES.UnaryExpression) {
|
|
1130
|
+
// `!`, `typeof`, `void`, `-`, `+`, `~`, `delete` all yield primitives.
|
|
1131
|
+
return 'primitive';
|
|
1132
|
+
}
|
|
1133
|
+
if (expression.type === utils_1.AST_NODE_TYPES.BinaryExpression) {
|
|
1134
|
+
return COMPARISONISH_BINARY_OPERATORS.has(expression.operator)
|
|
1135
|
+
? 'primitive'
|
|
1136
|
+
: 'indeterminate';
|
|
1137
|
+
}
|
|
1138
|
+
if (expression.type === utils_1.AST_NODE_TYPES.ConditionalExpression) {
|
|
1139
|
+
return combineReturnEvaluations([
|
|
1140
|
+
classifyReturnExpression(expression.consequent),
|
|
1141
|
+
classifyReturnExpression(expression.alternate),
|
|
1142
|
+
]);
|
|
1143
|
+
}
|
|
1144
|
+
if (expression.type === utils_1.AST_NODE_TYPES.LogicalExpression) {
|
|
1145
|
+
return combineReturnEvaluations([
|
|
1146
|
+
classifyReturnExpression(expression.left),
|
|
1147
|
+
classifyReturnExpression(expression.right),
|
|
1148
|
+
]);
|
|
1149
|
+
}
|
|
1150
|
+
return 'indeterminate';
|
|
1151
|
+
}
|
|
1152
|
+
function collectReturnArgumentsOf(body) {
|
|
1153
|
+
const found = [];
|
|
1154
|
+
const walk = (node) => {
|
|
1155
|
+
if (node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
|
|
1156
|
+
node.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
|
|
1157
|
+
node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression) {
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
if (node.type === utils_1.AST_NODE_TYPES.ReturnStatement) {
|
|
1161
|
+
found.push(node.argument ?? null);
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
for (const key of Object.keys(node)) {
|
|
1165
|
+
if (key === 'parent')
|
|
1166
|
+
continue;
|
|
1167
|
+
const value = node[key];
|
|
1168
|
+
if (Array.isArray(value)) {
|
|
1169
|
+
for (const entry of value) {
|
|
1170
|
+
if (ASTHelpers_1.ASTHelpers.isNode(entry))
|
|
1171
|
+
walk(entry);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
else if (ASTHelpers_1.ASTHelpers.isNode(value)) {
|
|
1175
|
+
walk(value);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
};
|
|
1179
|
+
for (const statement of body.body) {
|
|
1180
|
+
walk(statement);
|
|
1181
|
+
}
|
|
1182
|
+
return found;
|
|
1183
|
+
}
|
|
1184
|
+
function classifyFunctionReturn(functionLike) {
|
|
1185
|
+
const annotation = functionLike.returnType?.typeAnnotation;
|
|
1186
|
+
if (annotation) {
|
|
1187
|
+
const classified = classifyTypeAnnotation(annotation);
|
|
1188
|
+
if (classified !== 'indeterminate')
|
|
1189
|
+
return classified;
|
|
1190
|
+
}
|
|
1191
|
+
// Async/generator calls hand back a promise or an iterator object.
|
|
1192
|
+
if (functionLike.async || functionLike.generator) {
|
|
1193
|
+
return 'nonPrimitive';
|
|
1194
|
+
}
|
|
1195
|
+
if (functionLike.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression &&
|
|
1196
|
+
functionLike.expression) {
|
|
1197
|
+
return classifyReturnExpression(functionLike.body);
|
|
1198
|
+
}
|
|
1199
|
+
if (functionLike.body?.type !== utils_1.AST_NODE_TYPES.BlockStatement) {
|
|
1200
|
+
return 'indeterminate';
|
|
1201
|
+
}
|
|
1202
|
+
const returnArguments = collectReturnArgumentsOf(functionLike.body);
|
|
1203
|
+
if (returnArguments.length === 0) {
|
|
1204
|
+
// Falls off the end: yields undefined.
|
|
1205
|
+
return 'primitive';
|
|
1206
|
+
}
|
|
1207
|
+
const combined = combineReturnEvaluations(returnArguments.map((arg) => classifyReturnExpression(arg)));
|
|
1208
|
+
// An unclassifiable return is still safe when no return expression can
|
|
1209
|
+
// syntactically reach a whole-parameter binding: the argument's reference
|
|
1210
|
+
// then has no path out through the result.
|
|
1211
|
+
if (combined === 'indeterminate' &&
|
|
1212
|
+
!returnArguments.some((arg) => expressionReachesParameters(arg, functionLike))) {
|
|
1213
|
+
return 'primitive';
|
|
1214
|
+
}
|
|
1215
|
+
return combined;
|
|
1216
|
+
}
|
|
1217
|
+
function parameterNamesOf(functionLike) {
|
|
1218
|
+
// Only a whole-parameter binding can carry the argument object's identity
|
|
1219
|
+
// out. A destructured binding holds a PROPERTY of the argument, so
|
|
1220
|
+
// returning it never exposes the literal's own identity.
|
|
1221
|
+
const names = new Set();
|
|
1222
|
+
const unwrap = (node) => {
|
|
1223
|
+
if (node.type === utils_1.AST_NODE_TYPES.AssignmentPattern) {
|
|
1224
|
+
return unwrap(node.left);
|
|
1225
|
+
}
|
|
1226
|
+
if (node.type === utils_1.AST_NODE_TYPES.RestElement) {
|
|
1227
|
+
return unwrap(node.argument);
|
|
1228
|
+
}
|
|
1229
|
+
if (node.type === utils_1.AST_NODE_TYPES.TSParameterProperty) {
|
|
1230
|
+
return unwrap(node.parameter);
|
|
1231
|
+
}
|
|
1232
|
+
return node;
|
|
1233
|
+
};
|
|
1234
|
+
for (const param of functionLike.params) {
|
|
1235
|
+
const bare = unwrap(param);
|
|
1236
|
+
if (bare.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
1237
|
+
names.add(bare.name);
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
return names;
|
|
1241
|
+
}
|
|
1242
|
+
function expressionReachesParameters(expression, functionLike) {
|
|
1243
|
+
if (!expression)
|
|
1244
|
+
return false;
|
|
1245
|
+
const names = parameterNamesOf(functionLike);
|
|
1246
|
+
let reaches = false;
|
|
1247
|
+
const walk = (node) => {
|
|
1248
|
+
if (reaches)
|
|
1249
|
+
return;
|
|
1250
|
+
if (node.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
1251
|
+
// `arguments` exposes the raw argument list, so it can carry the
|
|
1252
|
+
// literal's identity out even when every parameter is destructured.
|
|
1253
|
+
(names.has(node.name) || node.name === 'arguments')) {
|
|
1254
|
+
reaches = true;
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
for (const key of Object.keys(node)) {
|
|
1258
|
+
if (key === 'parent')
|
|
1259
|
+
continue;
|
|
1260
|
+
const value = node[key];
|
|
1261
|
+
if (Array.isArray(value)) {
|
|
1262
|
+
for (const entry of value) {
|
|
1263
|
+
if (ASTHelpers_1.ASTHelpers.isNode(entry))
|
|
1264
|
+
walk(entry);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
else if (ASTHelpers_1.ASTHelpers.isNode(value)) {
|
|
1268
|
+
walk(value);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
};
|
|
1272
|
+
walk(expression);
|
|
1273
|
+
return reaches;
|
|
1274
|
+
}
|
|
1275
|
+
function functionOfDefinition(definition) {
|
|
1276
|
+
if (definition.type === 'FunctionName') {
|
|
1277
|
+
return definition.node;
|
|
1278
|
+
}
|
|
1279
|
+
if (definition.type === 'Variable') {
|
|
1280
|
+
const init = definition.node
|
|
1281
|
+
.init;
|
|
1282
|
+
if (init?.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
1283
|
+
init?.type === utils_1.AST_NODE_TYPES.FunctionExpression) {
|
|
1284
|
+
return init;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
return undefined;
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* True when every declaration bound to `calleeName` provably returns a
|
|
1291
|
+
* primitive, so invoking it cannot hand an argument's reference back.
|
|
1292
|
+
*
|
|
1293
|
+
* The analysis never follows a call inside the callee's body — a returned
|
|
1294
|
+
* CallExpression classifies as indeterminate — so it terminates on
|
|
1295
|
+
* self-recursive and mutually recursive callees without a re-entrancy guard.
|
|
1296
|
+
*/
|
|
1297
|
+
function calleeReturnIsPrimitive(calleeName, startScope) {
|
|
1298
|
+
const variable = findVariableInScopes(calleeName, startScope);
|
|
1299
|
+
if (!variable || variable.defs.length === 0)
|
|
1300
|
+
return false;
|
|
1301
|
+
// A binding written again after initialization may hold a different
|
|
1302
|
+
// function by the time the call runs, so the declaration on record no
|
|
1303
|
+
// longer proves anything about the value actually invoked.
|
|
1304
|
+
if (variable.references.some((ref) => ref.isWrite() && !ref.init)) {
|
|
1305
|
+
return false;
|
|
1306
|
+
}
|
|
1307
|
+
const classifications = [];
|
|
1308
|
+
for (const definition of variable.defs) {
|
|
1309
|
+
const functionLike = functionOfDefinition(definition);
|
|
1310
|
+
// Imports, parameters and aliases hide the implementation.
|
|
1311
|
+
if (!functionLike)
|
|
1312
|
+
return false;
|
|
1313
|
+
classifications.push(classifyFunctionReturn(functionLike));
|
|
1314
|
+
}
|
|
1315
|
+
return combineReturnEvaluations(classifications) === 'primitive';
|
|
1316
|
+
}
|
|
865
1317
|
/**
|
|
866
1318
|
* True when `expr` — a plain function call's result, or a reference to the
|
|
867
1319
|
* variable holding that result — is consumed only for its primitive value,
|
|
@@ -931,14 +1383,22 @@ exports.reactMemoizeLiterals = (0, createRule_1.createRule)({
|
|
|
931
1383
|
}
|
|
932
1384
|
}
|
|
933
1385
|
/**
|
|
934
|
-
* True when the literal is a direct argument of a plain function call
|
|
935
|
-
*
|
|
936
|
-
*
|
|
937
|
-
*
|
|
938
|
-
*
|
|
939
|
-
*
|
|
940
|
-
* (
|
|
941
|
-
*
|
|
1386
|
+
* True when the literal is a direct argument of a plain function call and
|
|
1387
|
+
* its identity provably cannot reach a memoization boundary — it is then
|
|
1388
|
+
* neither a JSX prop, nor a hook dependency, nor captured by an effect, so
|
|
1389
|
+
* re-creating it each render costs nothing and memoizing it buys nothing.
|
|
1390
|
+
*
|
|
1391
|
+
* Two independent conditions each suffice. The result is only ever consumed
|
|
1392
|
+
* primitively (see isPrimitivelyConsumed), which reasons from the CALL SITE.
|
|
1393
|
+
* Or the callee is locally declared and cannot hand the reference back (see
|
|
1394
|
+
* calleeReturnIsPrimitive), which reasons from the CALLEE. The call-site
|
|
1395
|
+
* condition covers callees that are imported or otherwise opaque; the callee
|
|
1396
|
+
* condition covers results consumed in positions no whitelist can prove
|
|
1397
|
+
* primitive, such as an object member or a hook dependency array.
|
|
1398
|
+
*
|
|
1399
|
+
* The callee must be a plain Identifier: member calls (`obj.method({...})`)
|
|
1400
|
+
* are excluded because the receiver could retain the reference, and this
|
|
1401
|
+
* keeps the guard to plain, non-hook synchronous calls.
|
|
942
1402
|
*/
|
|
943
1403
|
function isPrimitiveConsumedCallArgument(node) {
|
|
944
1404
|
// Walk up through transparent wrappers to the position the literal
|
|
@@ -962,7 +1422,12 @@ exports.reactMemoizeLiterals = (0, createRule_1.createRule)({
|
|
|
962
1422
|
if (parent.callee.type !== utils_1.AST_NODE_TYPES.Identifier) {
|
|
963
1423
|
return false;
|
|
964
1424
|
}
|
|
965
|
-
|
|
1425
|
+
// Either sufficient condition exempts: a provably primitive consumption
|
|
1426
|
+
// site, or a callee that cannot hand the reference back.
|
|
1427
|
+
if (isPrimitivelyConsumed(parent)) {
|
|
1428
|
+
return true;
|
|
1429
|
+
}
|
|
1430
|
+
return calleeReturnIsPrimitive(parent.callee.name, scopeOf(node));
|
|
966
1431
|
}
|
|
967
1432
|
function reportLiteral(node) {
|
|
968
1433
|
const descriptor = getLiteralDescriptor(node);
|
|
@@ -977,6 +1442,12 @@ exports.reactMemoizeLiterals = (0, createRule_1.createRule)({
|
|
|
977
1442
|
if (isTerminalUsage(node)) {
|
|
978
1443
|
return;
|
|
979
1444
|
}
|
|
1445
|
+
// A jest module factory builds a replacement module; React never renders
|
|
1446
|
+
// it, and jest's out-of-scope-variable restriction makes the rule's own
|
|
1447
|
+
// remediations illegal inside it.
|
|
1448
|
+
if (isInsideJestMockFactory(node)) {
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
980
1451
|
// A function literal passed directly as the callback to an Array
|
|
981
1452
|
// iteration method (.map/.filter/.reduce/.forEach/...) is invoked
|
|
982
1453
|
// synchronously during render and then discarded, so its identity is
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,35 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.1",
|
|
4
|
+
"date": "2026-07-24T22:02:37.444Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "enforce-boolean-naming-prefixes",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1346
|
|
11
|
+
],
|
|
12
|
+
"summary": "skip boolean-prefixed callees whose return is demonstrably non-boolean (closes #1346)"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "enforce-firestore-facade",
|
|
16
|
+
"changeType": "fix",
|
|
17
|
+
"issues": [
|
|
18
|
+
1348
|
|
19
|
+
],
|
|
20
|
+
"summary": "classify batches by import origin, not variable name (closes #1348)"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "react-memoize-literals",
|
|
24
|
+
"changeType": "fix",
|
|
25
|
+
"issues": [
|
|
26
|
+
1347,
|
|
27
|
+
1349
|
|
28
|
+
],
|
|
29
|
+
"summary": "exempt argument literals whose callee cannot return their reference (closes #1349); skip hook names resolved inside jest.mock and factory callbacks (closes #1347)"
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
},
|
|
2
33
|
{
|
|
3
34
|
"version": "1.20.0",
|
|
4
35
|
"date": "2026-07-24T15:36:40.953Z",
|