@blumintinc/eslint-plugin-blumint 1.20.103 → 1.20.105
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-dynamic-firebase-imports.d.ts +2 -2
- package/lib/rules/enforce-dynamic-firebase-imports.js +193 -54
- package/lib/rules/enforce-empty-object-check.js +14 -0
- package/lib/rules/enforce-firestore-set-merge.js +233 -3
- package/lib/rules/enforce-querykey-ts.js +34 -12
- package/lib/rules/memoize-root-level-hocs.d.ts +2 -1
- package/lib/rules/memoize-root-level-hocs.js +167 -11
- package/lib/rules/use-latest-callback.js +130 -1
- package/package.json +1 -1
- package/release-manifest.json +70 -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
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { TSESTree } from '@typescript-eslint/utils';
|
|
2
|
-
declare const enforceFirebaseImports:
|
|
1
|
+
import { TSESLint, TSESTree } from '@typescript-eslint/utils';
|
|
2
|
+
declare const enforceFirebaseImports: TSESLint.RuleModule<"noDynamicImport", never[], {} | {
|
|
3
3
|
ImportDeclaration(node: TSESTree.ImportDeclaration): void;
|
|
4
4
|
}>;
|
|
5
5
|
export default enforceFirebaseImports;
|
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const utils_1 = require("@typescript-eslint/utils");
|
|
3
4
|
const createRule_1 = require("../utils/createRule");
|
|
5
|
+
const isFunctionNode = (node) => node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
6
|
+
node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
|
|
7
|
+
node.type === utils_1.AST_NODE_TYPES.FunctionExpression;
|
|
8
|
+
/**
|
|
9
|
+
* Walks outward from a reference to the innermost `async` function whose block
|
|
10
|
+
* body contains it.
|
|
11
|
+
*
|
|
12
|
+
* A reference sitting in a *synchronous* callback nested inside an async
|
|
13
|
+
* function still resolves once the declaration heads the async body, because
|
|
14
|
+
* the callback cannot run before the first statement of the body it is created
|
|
15
|
+
* in — so the walk continues past non-async functions rather than giving up.
|
|
16
|
+
*
|
|
17
|
+
* The containment check is against the body rather than the function: a
|
|
18
|
+
* reference in a parameter default or a signature type annotation is evaluated
|
|
19
|
+
* before the body runs, so a declaration at the top of the body would come too
|
|
20
|
+
* late for it.
|
|
21
|
+
*/
|
|
22
|
+
const enclosingAsyncBodyOf = (identifier) => {
|
|
23
|
+
let current = identifier.parent;
|
|
24
|
+
while (current) {
|
|
25
|
+
if (isFunctionNode(current) &&
|
|
26
|
+
current.async &&
|
|
27
|
+
current.body.type === utils_1.AST_NODE_TYPES.BlockStatement &&
|
|
28
|
+
identifier.range[0] >= current.body.range[0] &&
|
|
29
|
+
identifier.range[1] <= current.body.range[1]) {
|
|
30
|
+
return current;
|
|
31
|
+
}
|
|
32
|
+
current = current.parent;
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
};
|
|
36
|
+
const THIRD_PARTY_DIRECTORY = /(^|\/)node_modules(\/|$)/;
|
|
37
|
+
// Anchored at the end of the path so multi-part suffixes such as
|
|
38
|
+
// `useStartMatch.integration.test.ts` are recognized while production modules
|
|
39
|
+
// that merely contain the word (`latest.tsx`, `contest.ts`, `testHelpers.ts`)
|
|
40
|
+
// keep their enforcement.
|
|
41
|
+
const TEST_FILE_SUFFIX = /\.(test|spec)\.[cm]?[jt]sx?$/;
|
|
42
|
+
// Jest convention directories hold test-only modules regardless of file name.
|
|
43
|
+
const TEST_FILE_DIRECTORY = /(^|\/)(__tests__|__mocks__)\//;
|
|
44
|
+
/**
|
|
45
|
+
* The rule's rationale is bundle weight: a static import pulls Firebase into the
|
|
46
|
+
* initial client chunk. A suite, a Jest manual mock and a declaration file are
|
|
47
|
+
* never part of that chunk, so there is nothing to inflate and the rule has
|
|
48
|
+
* nothing to enforce there.
|
|
49
|
+
*
|
|
50
|
+
* The exemption is load-bearing rather than cosmetic because the rule is
|
|
51
|
+
* fixable: a suite's static binding is exactly what `jest.mock()` hoisting
|
|
52
|
+
* intercepts, and rewriting it emits a module-scope `await import(...)` that a
|
|
53
|
+
* CommonJS test transform cannot even parse (issue #1715).
|
|
54
|
+
*/
|
|
55
|
+
const isNeverBundled = (filename) => filename.endsWith('.d.ts') ||
|
|
56
|
+
TEST_FILE_SUFFIX.test(filename) ||
|
|
57
|
+
TEST_FILE_DIRECTORY.test(filename);
|
|
4
58
|
const enforceFirebaseImports = (0, createRule_1.createRule)({
|
|
5
59
|
name: 'enforce-dynamic-firebase-imports',
|
|
6
60
|
meta: {
|
|
@@ -13,18 +67,26 @@ const enforceFirebaseImports = (0, createRule_1.createRule)({
|
|
|
13
67
|
hasSuggestions: true,
|
|
14
68
|
schema: [],
|
|
15
69
|
messages: {
|
|
16
|
-
noDynamicImport: 'Static import from firebaseCloud path "{{importPath}}" eagerly bundles Firebase code into the initial client chunk, which inflates startup time and prevents lazy loading.
|
|
70
|
+
noDynamicImport: 'Static import from firebaseCloud path "{{importPath}}" eagerly bundles Firebase code into the initial client chunk, which inflates startup time and prevents lazy loading. Load it at the call site instead, inside an async function body (e.g., `const { export } = await import(\'{{importPath}}\')`). Keep it out of module scope: a top-level `await import(...)` defers nothing and does not parse once the module is compiled to CommonJS.',
|
|
17
71
|
},
|
|
18
72
|
},
|
|
19
73
|
defaultOptions: [],
|
|
20
74
|
create(context) {
|
|
75
|
+
const sourceCode = context.getSourceCode();
|
|
76
|
+
// Normalize Windows backslash separators so the forward-slash directory
|
|
77
|
+
// checks match on every platform. Without this, `getFilename()` returns
|
|
78
|
+
// `C:\repo\src\hooks\__tests__\Foo.ts` on Windows and the exemption
|
|
79
|
+
// silently fails there.
|
|
80
|
+
const filename = (context.getFilename?.() ?? '').replace(/\\/g, '/');
|
|
81
|
+
// `<input>`/`<text>` are the synthetic names RuleTester uses when a case
|
|
82
|
+
// declares no filename. They match none of the exemptions below, so a
|
|
83
|
+
// snippet keeps its enforcement — unlike a path-gated rule, this one has no
|
|
84
|
+
// include list to fall outside of.
|
|
85
|
+
if (THIRD_PARTY_DIRECTORY.test(filename) || isNeverBundled(filename)) {
|
|
86
|
+
return {};
|
|
87
|
+
}
|
|
21
88
|
return {
|
|
22
89
|
ImportDeclaration(node) {
|
|
23
|
-
// Skip third-party files
|
|
24
|
-
const filename = context.getFilename?.();
|
|
25
|
-
if (filename && /(^|[\\/])node_modules([\\/]|$)/.test(filename)) {
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
90
|
// Skip type-only import declarations
|
|
29
91
|
if (node.importKind === 'type') {
|
|
30
92
|
return;
|
|
@@ -51,75 +113,152 @@ const enforceFirebaseImports = (0, createRule_1.createRule)({
|
|
|
51
113
|
? spec.imported.name
|
|
52
114
|
: `${spec.imported.name} as ${spec.local.name}`)
|
|
53
115
|
.join(', ');
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
116
|
+
const destructureEntry = (spec) => spec.imported.name === spec.local.name
|
|
117
|
+
? spec.local.name
|
|
118
|
+
: `${spec.imported.name}: ${spec.local.name}`;
|
|
119
|
+
const buildValueStatements = () => {
|
|
59
120
|
if (namespaceSpecifier) {
|
|
60
121
|
const nsLocal = namespaceSpecifier.local.name;
|
|
61
|
-
|
|
122
|
+
const statements = [
|
|
123
|
+
`const ${nsLocal} = await import('${importPath}');`,
|
|
124
|
+
];
|
|
62
125
|
if (defaultSpecifier) {
|
|
63
|
-
const
|
|
64
|
-
statements.push(`const ${defLocal} = ${nsLocal}.default;`);
|
|
126
|
+
statements.push(`const ${defaultSpecifier.local.name} = ${nsLocal}.default;`);
|
|
65
127
|
}
|
|
66
|
-
const destructureFromNamespace = [];
|
|
67
128
|
if (namedSpecifiers.length > 0) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
return imported === local ? imported : `${imported}: ${local}`;
|
|
72
|
-
});
|
|
73
|
-
destructureFromNamespace.push(...destructureParts);
|
|
129
|
+
statements.push(`const { ${namedSpecifiers
|
|
130
|
+
.map(destructureEntry)
|
|
131
|
+
.join(', ')} } = ${nsLocal};`);
|
|
74
132
|
}
|
|
75
|
-
|
|
76
|
-
statements.push(`const { ${destructureFromNamespace.join(', ')} } = ${nsLocal};`);
|
|
77
|
-
}
|
|
78
|
-
return statements.join(' ');
|
|
133
|
+
return statements;
|
|
79
134
|
}
|
|
80
|
-
const destructureParts = [
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
135
|
+
const destructureParts = [
|
|
136
|
+
...(defaultSpecifier
|
|
137
|
+
? [`default: ${defaultSpecifier.local.name}`]
|
|
138
|
+
: []),
|
|
139
|
+
...namedSpecifiers.map(destructureEntry),
|
|
140
|
+
];
|
|
141
|
+
// A side-effect import binds nothing, so there is no declaration to
|
|
142
|
+
// relocate — the awaited call would have to stay at module scope.
|
|
143
|
+
return destructureParts.length > 0
|
|
144
|
+
? [
|
|
145
|
+
`const { ${destructureParts.join(', ')} } = await import('${importPath}');`,
|
|
146
|
+
]
|
|
147
|
+
: [];
|
|
148
|
+
};
|
|
149
|
+
/**
|
|
150
|
+
* An `ImportDeclaration` only ever sits at module scope, so rewriting
|
|
151
|
+
* it in place can only ever produce a module-scope `await import(...)`
|
|
152
|
+
* — which defers nothing (the module still awaits it during
|
|
153
|
+
* evaluation) and does not even parse once the file is compiled to
|
|
154
|
+
* CommonJS, where top-level await does not exist (issue #1716).
|
|
155
|
+
*
|
|
156
|
+
* The rewrite is therefore only expressible when every value reference
|
|
157
|
+
* lives in one async function body: the declaration can then head that
|
|
158
|
+
* body, exactly the shape the codebase writes by hand. Anything else
|
|
159
|
+
* is a per-call-site refactor the fixer declines rather than corrupts.
|
|
160
|
+
*/
|
|
161
|
+
const findRelocationTarget = () => {
|
|
162
|
+
const valueLocalNames = new Set([
|
|
163
|
+
defaultSpecifier?.local.name,
|
|
164
|
+
namespaceSpecifier?.local.name,
|
|
165
|
+
...namedSpecifiers.map((spec) => spec.local.name),
|
|
166
|
+
].filter((name) => name !== undefined));
|
|
167
|
+
const references = context
|
|
168
|
+
.getDeclaredVariables(node)
|
|
169
|
+
.filter((variable) => valueLocalNames.has(variable.name))
|
|
170
|
+
.flatMap((variable) => variable.references);
|
|
171
|
+
// Nothing reads the binding, so there is no call site to defer to.
|
|
172
|
+
if (references.length === 0) {
|
|
173
|
+
return undefined;
|
|
84
174
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
175
|
+
let target;
|
|
176
|
+
for (const reference of references) {
|
|
177
|
+
const enclosing = enclosingAsyncBodyOf(reference.identifier);
|
|
178
|
+
if (!enclosing || (target && target !== enclosing)) {
|
|
179
|
+
return undefined;
|
|
90
180
|
}
|
|
181
|
+
target = enclosing;
|
|
91
182
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
183
|
+
return target;
|
|
184
|
+
};
|
|
185
|
+
const indentationAt = (line) => /^[ \t]*/.exec(sourceCode.lines[line - 1] ?? '')?.[0] ?? '';
|
|
186
|
+
/**
|
|
187
|
+
* Consumes the import's own trailing whitespace, and its line break
|
|
188
|
+
* when the import owns the line, so the removal strands neither a blank
|
|
189
|
+
* line nor the indentation of whatever shared the line with it.
|
|
190
|
+
* Anything that is not whitespace — a trailing comment, a statement —
|
|
191
|
+
* is left untouched.
|
|
192
|
+
*/
|
|
193
|
+
const removalEnd = () => {
|
|
194
|
+
const text = sourceCode.getText();
|
|
195
|
+
let cursor = node.range[1];
|
|
196
|
+
while (cursor < text.length &&
|
|
197
|
+
(text[cursor] === ' ' || text[cursor] === '\t')) {
|
|
198
|
+
cursor += 1;
|
|
199
|
+
}
|
|
200
|
+
if (text[cursor] === '\n') {
|
|
201
|
+
return cursor + 1;
|
|
95
202
|
}
|
|
96
|
-
if (
|
|
97
|
-
return
|
|
98
|
-
|
|
99
|
-
|
|
203
|
+
if (text[cursor] === '\r' && text[cursor + 1] === '\n') {
|
|
204
|
+
return cursor + 2;
|
|
205
|
+
}
|
|
206
|
+
return cursor;
|
|
207
|
+
};
|
|
208
|
+
const buildFix = (fixer) => {
|
|
209
|
+
const target = findRelocationTarget();
|
|
210
|
+
const statements = buildValueStatements();
|
|
211
|
+
if (!target || statements.length === 0) {
|
|
212
|
+
return null;
|
|
100
213
|
}
|
|
101
|
-
|
|
214
|
+
const body = target.body;
|
|
215
|
+
// A directive stops being a directive the moment a declaration
|
|
216
|
+
// precedes it, so `'use server'` on a server action would silently
|
|
217
|
+
// become a discarded string expression. The declaration goes after
|
|
218
|
+
// the whole prologue instead.
|
|
219
|
+
const prologueLength = body.body.findIndex((statement) => statement.type !== utils_1.AST_NODE_TYPES.ExpressionStatement ||
|
|
220
|
+
statement.expression.type !== utils_1.AST_NODE_TYPES.Literal ||
|
|
221
|
+
typeof statement.expression.value !== 'string');
|
|
222
|
+
const directives = body.body.slice(0, prologueLength === -1 ? body.body.length : prologueLength);
|
|
223
|
+
const lastDirective = directives[directives.length - 1];
|
|
224
|
+
const following = body.body[directives.length];
|
|
225
|
+
const anchorLine = lastDirective
|
|
226
|
+
? lastDirective.loc.end.line
|
|
227
|
+
: body.loc.start.line;
|
|
228
|
+
const neighbour = following ?? lastDirective;
|
|
229
|
+
// A body written on one line keeps its shape; a multi-line body gets
|
|
230
|
+
// the declaration on its own line at the body's own indentation.
|
|
231
|
+
const insertion = following && following.loc.start.line === anchorLine
|
|
232
|
+
? ` ${statements.join(' ')}`
|
|
233
|
+
: statements
|
|
234
|
+
.map((statement) => {
|
|
235
|
+
const indent = neighbour
|
|
236
|
+
? indentationAt(neighbour.loc.start.line)
|
|
237
|
+
: `${indentationAt(target.loc.start.line)} `;
|
|
238
|
+
return `\n${indent}${statement}`;
|
|
239
|
+
})
|
|
240
|
+
.join('');
|
|
241
|
+
return [
|
|
242
|
+
// Type-only specifiers are erased at compile time, so they stay
|
|
243
|
+
// where they are instead of riding along into the function body.
|
|
244
|
+
typeOnlySpecifiers.length > 0
|
|
245
|
+
? fixer.replaceText(node, `import type { ${buildTypeNames()} } from '${importPath}';`)
|
|
246
|
+
: fixer.removeRange([node.range[0], removalEnd()]),
|
|
247
|
+
lastDirective
|
|
248
|
+
? fixer.insertTextAfter(lastDirective, insertion)
|
|
249
|
+
: fixer.insertTextAfterRange([body.range[0], body.range[0] + 1], insertion),
|
|
250
|
+
];
|
|
102
251
|
};
|
|
103
252
|
context.report({
|
|
104
253
|
node,
|
|
105
254
|
messageId: 'noDynamicImport',
|
|
106
255
|
data: { importPath },
|
|
107
|
-
fix
|
|
108
|
-
const replacement = buildReplacement();
|
|
109
|
-
return replacement ? fixer.replaceText(node, replacement) : null;
|
|
110
|
-
},
|
|
256
|
+
fix: buildFix,
|
|
111
257
|
suggest: [
|
|
112
258
|
{
|
|
113
259
|
messageId: 'noDynamicImport',
|
|
114
260
|
data: { importPath },
|
|
115
|
-
fix
|
|
116
|
-
const replacement = buildReplacement({
|
|
117
|
-
allowSideEffectFix: true,
|
|
118
|
-
});
|
|
119
|
-
return replacement
|
|
120
|
-
? fixer.replaceText(node, replacement)
|
|
121
|
-
: null;
|
|
122
|
-
},
|
|
261
|
+
fix: buildFix,
|
|
123
262
|
},
|
|
124
263
|
],
|
|
125
264
|
});
|