@blumintinc/eslint-plugin-blumint 1.19.32 → 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/README.md +1 -0
- package/lib/index.js +4 -1
- package/lib/rules/enforce-boolean-naming-prefixes.js +240 -3
- package/lib/rules/enforce-firestore-facade.js +181 -4
- package/lib/rules/no-render-function-components.d.ts +8 -0
- package/lib/rules/no-render-function-components.js +269 -0
- package/lib/rules/react-memoize-literals.js +481 -10
- package/package.json +1 -1
- package/release-manifest.json +45 -0
package/README.md
CHANGED
|
@@ -196,6 +196,7 @@ full closed loop is documented in agora's `.claude/skills/eslint-autonomy/SKILL.
|
|
|
196
196
|
| [no-redundant-param-types](docs/rules/no-redundant-param-types.md) | Disallow redundant parameter type annotations | ✅ | | | 🔧 | | |
|
|
197
197
|
| [no-redundant-this-params](docs/rules/no-redundant-this-params.md) | Disallow passing class instance members (this.foo) into class instance methods; access the member from this inside the method instead. | ✅ | | | | | |
|
|
198
198
|
| [no-redundant-usecallback-wrapper](docs/rules/no-redundant-usecallback-wrapper.md) | Prevent wrapping already memoized/stable callbacks from hooks/contexts in an extra useCallback() | ✅ | | | 🔧 | | |
|
|
199
|
+
| [no-render-function-components](docs/rules/no-render-function-components.md) | Disallow declaring a local `render*` function that returns JSX and consuming it as a plain function (direct call or array-iteration callback); author it as a real component rendered with JSX instead | ✅ | | | | | |
|
|
199
200
|
| [no-res-error-status-in-onrequest](docs/rules/no-res-error-status-in-onrequest.md) | Forbid sending 4xx/5xx Express responses inside onRequest handlers; throw structured HttpsError instances instead so the wrapper can format, log, and map errors consistently. | ✅ | | | | | |
|
|
200
201
|
| [no-restricted-properties-fix](docs/rules/no-restricted-properties-fix.md) | Disallow certain properties on certain objects, with special handling for Object.keys() and Object.values() | | | | 🔧 | | |
|
|
201
202
|
| [no-satisfies-in-frontend-bundle](docs/rules/no-satisfies-in-frontend-bundle.md) | Disallow the `satisfies` operator in files that reach the frontend webpack bundle (Next.js 12 SWC cannot parse it) | ✅ | | | | | |
|
package/lib/index.js
CHANGED
|
@@ -196,6 +196,7 @@ const no_single_dismiss_dialog_button_1 = require("./rules/no-single-dismiss-dia
|
|
|
196
196
|
const no_portal_inside_tooltip_1 = require("./rules/no-portal-inside-tooltip");
|
|
197
197
|
const no_satisfies_in_frontend_bundle_1 = require("./rules/no-satisfies-in-frontend-bundle");
|
|
198
198
|
const prefer_utility_function_own_file_1 = require("./rules/prefer-utility-function-own-file");
|
|
199
|
+
const no_render_function_components_1 = require("./rules/no-render-function-components");
|
|
199
200
|
const NO_FRONTEND_IMPORTS_FROM_FUNCTIONS_MESSAGE = 'Backend Cloud Functions (.f.ts under functions/) must not import frontend modules from the repo root src/**. Frontend code can depend on browser-only APIs and bundling it into Cloud Functions breaks server execution; move shared logic into functions/src or a shared package.';
|
|
200
201
|
function noFrontendImportsFromFunctionsPatterns(pattern) {
|
|
201
202
|
return [
|
|
@@ -222,7 +223,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
|
|
|
222
223
|
module.exports = {
|
|
223
224
|
meta: {
|
|
224
225
|
name: '@blumintinc/eslint-plugin-blumint',
|
|
225
|
-
version: '1.
|
|
226
|
+
version: '1.20.1',
|
|
226
227
|
},
|
|
227
228
|
parseOptions: {
|
|
228
229
|
ecmaVersion: 2020,
|
|
@@ -424,6 +425,7 @@ module.exports = {
|
|
|
424
425
|
'@blumintinc/blumint/no-portal-inside-tooltip': 'error',
|
|
425
426
|
'@blumintinc/blumint/no-satisfies-in-frontend-bundle': 'error',
|
|
426
427
|
'@blumintinc/blumint/prefer-utility-function-own-file': 'error',
|
|
428
|
+
'@blumintinc/blumint/no-render-function-components': 'error',
|
|
427
429
|
},
|
|
428
430
|
/**
|
|
429
431
|
* Depth-specific overrides block only import strings that traverse to the
|
|
@@ -700,6 +702,7 @@ module.exports = {
|
|
|
700
702
|
'no-portal-inside-tooltip': no_portal_inside_tooltip_1.noPortalInsideTooltip,
|
|
701
703
|
'no-satisfies-in-frontend-bundle': no_satisfies_in_frontend_bundle_1.noSatisfiesInFrontendBundle,
|
|
702
704
|
'prefer-utility-function-own-file': prefer_utility_function_own_file_1.preferUtilityFunctionOwnFile,
|
|
705
|
+
'no-render-function-components': no_render_function_components_1.noRenderFunctionComponents,
|
|
703
706
|
},
|
|
704
707
|
};
|
|
705
708
|
//# sourceMappingURL=index.js.map
|
|
@@ -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;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { TSESLint } from '@typescript-eslint/utils';
|
|
2
|
+
type RuleOptions = {
|
|
3
|
+
renderPropNames?: string[];
|
|
4
|
+
allowNames?: string[];
|
|
5
|
+
};
|
|
6
|
+
type Options = [RuleOptions?];
|
|
7
|
+
export declare const noRenderFunctionComponents: TSESLint.RuleModule<"renderFunctionComponent", Options, TSESLint.RuleListener>;
|
|
8
|
+
export {};
|