@blumintinc/eslint-plugin-blumint 1.20.136 → 1.20.138
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-centralized-mock-firestore.js +46 -3
- package/lib/rules/enforce-firestore-set-merge.js +278 -66
- package/lib/rules/enforce-microdiff.js +87 -20
- package/lib/rules/fast-deep-equal-over-microdiff.js +220 -71
- package/lib/rules/key-only-outermost-element.js +159 -21
- package/lib/rules/logical-top-to-bottom-grouping.js +104 -33
- package/lib/rules/no-empty-dependency-use-callbacks.js +116 -43
- package/lib/rules/no-explicit-return-type.js +164 -21
- package/lib/rules/no-mock-firebase-admin.js +52 -8
- package/lib/rules/no-redundant-usecallback-wrapper.d.ts +2 -1
- package/lib/rules/no-redundant-usecallback-wrapper.js +126 -70
- package/lib/rules/no-unused-props.js +69 -57
- package/lib/rules/no-useless-usememo-primitives.d.ts +2 -1
- package/lib/rules/no-useless-usememo-primitives.js +156 -47
- package/lib/rules/no-usememo-for-pass-by-value.js +165 -220
- package/lib/rules/prefer-params-over-parent-id.d.ts +2 -1
- package/lib/rules/prefer-params-over-parent-id.js +122 -39
- package/lib/rules/use-latest-callback.js +109 -11
- package/lib/rules/vertically-group-related-functions.js +41 -10
- package/lib/utils/importRemoval.d.ts +88 -0
- package/lib/utils/importRemoval.js +127 -46
- package/lib/utils/patternBindingRemoval.d.ts +28 -0
- package/lib/utils/patternBindingRemoval.js +199 -0
- package/lib/utils/typeDeclarationRemoval.d.ts +32 -0
- package/lib/utils/typeDeclarationRemoval.js +105 -0
- package/package.json +1 -1
- package/release-manifest.json +140 -0
package/lib/index.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.enforceCentralizedMockFirestore = void 0;
|
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
6
|
const importInsertion_1 = require("../utils/importInsertion");
|
|
7
|
+
const importRemoval_1 = require("../utils/importRemoval");
|
|
7
8
|
/**
|
|
8
9
|
* The centralized module's identity: the path segments that name it, without
|
|
9
10
|
* the relative prefix a specifier needs to reach it. Keeping the identity
|
|
@@ -166,6 +167,32 @@ function retiredSpan(node) {
|
|
|
166
167
|
}
|
|
167
168
|
return undefined;
|
|
168
169
|
}
|
|
170
|
+
function isWithinAny(range, ranges) {
|
|
171
|
+
return ranges.some(([start, end]) => range[0] >= start && range[1] <= end);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The orphans the retirement carries away with it, as opposed to the ones it
|
|
175
|
+
* strands.
|
|
176
|
+
*
|
|
177
|
+
* A binding whose own declaration sits inside a retired span disappears with
|
|
178
|
+
* that span — retiring the local mock is the whole point of the fix, so the
|
|
179
|
+
* binding losing its last reference is not a defect. Every other orphan has a
|
|
180
|
+
* declaration that SURVIVES: `const myMockFirestore = jest.fn();` under
|
|
181
|
+
* `const mockFirestore = myMockFirestore;` is left bound to nothing the moment
|
|
182
|
+
* the alias goes, turning a file that lints clean into one that fails
|
|
183
|
+
* `no-unused-vars` and `noUnusedLocals` (#1900).
|
|
184
|
+
*
|
|
185
|
+
* Deleting the survivor instead is not on offer: its initializer is an
|
|
186
|
+
* arbitrary expression — `jest.fn()`, a factory call, a `require` — whose effect
|
|
187
|
+
* this fixer cannot prove absent, and dropping it would delete working code to
|
|
188
|
+
* settle a lint warning. The fix is therefore withheld and the report stands, so
|
|
189
|
+
* the local mock is still surfaced for a human to retire. An orphaned IMPORT
|
|
190
|
+
* never reaches here; the shared planner retires it in the same fix.
|
|
191
|
+
*/
|
|
192
|
+
const retirementCarriesOrphan = (variables, removed) => variables.every((variable) => variable.identifiers.length > 0 &&
|
|
193
|
+
variable.identifiers.every((identifier) => isWithinAny(identifier.range, removed)))
|
|
194
|
+
? []
|
|
195
|
+
: null;
|
|
169
196
|
/**
|
|
170
197
|
* Collapses edits that touch, so an overlap can never rewrite a range twice.
|
|
171
198
|
*/
|
|
@@ -392,6 +419,18 @@ exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
|
|
|
392
419
|
? retirementEdit(originalText, span.start, span.end)
|
|
393
420
|
: { start: span.start, end: span.end, text: '' });
|
|
394
421
|
}
|
|
422
|
+
// Whatever the retired declarations were the last readers of has
|
|
423
|
+
// to go with them, or the fix trades this report for an unused
|
|
424
|
+
// binding the consumer's build fails on (#1900). Only the
|
|
425
|
+
// retirements are handed over: the reference rewrites below swap
|
|
426
|
+
// one name for another rather than deleting a declaration, and a
|
|
427
|
+
// rewritten call to a RENAMED centralized import would otherwise
|
|
428
|
+
// read as unbinding the very import the file is told to keep.
|
|
429
|
+
const orphaned = (0, importRemoval_1.planOrphanedBindingRemoval)(sourceCode, removals.map(({ start, end }) => [start, end]), retirementCarriesOrphan);
|
|
430
|
+
if (!orphaned) {
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
const orphanRemovals = orphaned.map(([start, end]) => ({ start, end, text: '' }));
|
|
395
434
|
// Replace custom mockFirestore references with the standard one
|
|
396
435
|
const replacements = [];
|
|
397
436
|
// Add replacements for custom mockFirestore names
|
|
@@ -412,11 +451,15 @@ exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
|
|
|
412
451
|
text: 'mockFirestore',
|
|
413
452
|
});
|
|
414
453
|
});
|
|
415
|
-
// A reference inside a
|
|
454
|
+
// A reference inside a deleted range goes away with it, so
|
|
416
455
|
// rewriting it would only fight the removal for the same range.
|
|
417
|
-
const
|
|
456
|
+
const deletions = [...removals, ...orphanRemovals];
|
|
457
|
+
const survivingReplacements = replacements.filter((replacement) => !deletions.some((removal) => replacement.start >= removal.start &&
|
|
418
458
|
replacement.end <= removal.end));
|
|
419
|
-
const edits = mergeEdits([
|
|
459
|
+
const edits = mergeEdits([
|
|
460
|
+
...deletions,
|
|
461
|
+
...survivingReplacements,
|
|
462
|
+
]);
|
|
420
463
|
// Every edit is bounded to the characters it owns. Rebuilding
|
|
421
464
|
// the file and writing it over the `Program` node instead would
|
|
422
465
|
// emit whatever precedes `Program.range[0]` — a header comment,
|
|
@@ -5,6 +5,9 @@ const utils_1 = require("@typescript-eslint/utils");
|
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
6
|
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
7
7
|
const disableDirectives_1 = require("../utils/disableDirectives");
|
|
8
|
+
const importRemoval_1 = require("../utils/importRemoval");
|
|
9
|
+
const patternBindingRemoval_1 = require("../utils/patternBindingRemoval");
|
|
10
|
+
const replacementSegments_1 = require("../utils/replacementSegments");
|
|
8
11
|
const lexicalScope_1 = require("../utils/lexicalScope");
|
|
9
12
|
const FIRESTORE_MODULES = new Set(['firebase/firestore', 'firebase-admin']);
|
|
10
13
|
const UPDATE_DOC = 'updateDoc';
|
|
@@ -20,15 +23,22 @@ const BATCH_MANAGER = 'batchManager';
|
|
|
20
23
|
* the rule's scope entirely.
|
|
21
24
|
*/
|
|
22
25
|
const REALTIME_BATCH_MANAGER = 'RealtimeBatchManager';
|
|
23
|
-
|
|
26
|
+
/** The firestore module a dynamic `await import(…)` reads, if it is one. */
|
|
27
|
+
function firestoreDynamicImportModule(node) {
|
|
24
28
|
if (node?.type !== utils_1.AST_NODE_TYPES.AwaitExpression) {
|
|
25
|
-
return
|
|
29
|
+
return null;
|
|
26
30
|
}
|
|
27
31
|
const imported = node.argument;
|
|
28
|
-
|
|
29
|
-
imported.source.type
|
|
30
|
-
typeof imported.source.value
|
|
31
|
-
FIRESTORE_MODULES.has(imported.source.value))
|
|
32
|
+
if (imported.type !== utils_1.AST_NODE_TYPES.ImportExpression ||
|
|
33
|
+
imported.source.type !== utils_1.AST_NODE_TYPES.Literal ||
|
|
34
|
+
typeof imported.source.value !== 'string' ||
|
|
35
|
+
!FIRESTORE_MODULES.has(imported.source.value)) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return imported.source.value;
|
|
39
|
+
}
|
|
40
|
+
function isFirestoreDynamicImport(node) {
|
|
41
|
+
return firestoreDynamicImportModule(node) !== null;
|
|
32
42
|
}
|
|
33
43
|
/**
|
|
34
44
|
* Reads a binding's origin off the AST rather than off a traversal flag, so the
|
|
@@ -47,13 +57,16 @@ function firestoreBindingOf(def) {
|
|
|
47
57
|
}
|
|
48
58
|
return {
|
|
49
59
|
imported: node.imported.name,
|
|
60
|
+
module: declaration.source.value,
|
|
50
61
|
node,
|
|
51
|
-
entries: declaration.specifiers,
|
|
52
62
|
};
|
|
53
63
|
}
|
|
54
64
|
if (node.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
|
|
55
|
-
node.id.type === utils_1.AST_NODE_TYPES.ObjectPattern
|
|
56
|
-
|
|
65
|
+
node.id.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
|
|
66
|
+
const module = firestoreDynamicImportModule(node.init);
|
|
67
|
+
if (module === null) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
57
70
|
const property = def.name.parent;
|
|
58
71
|
if (property?.type !== utils_1.AST_NODE_TYPES.Property ||
|
|
59
72
|
property.parent !== node.id ||
|
|
@@ -64,19 +77,41 @@ function firestoreBindingOf(def) {
|
|
|
64
77
|
}
|
|
65
78
|
return {
|
|
66
79
|
imported: property.key.name,
|
|
80
|
+
module,
|
|
67
81
|
node: property,
|
|
68
|
-
entries: node.id.properties,
|
|
69
82
|
};
|
|
70
83
|
}
|
|
71
84
|
return null;
|
|
72
85
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Whether every declaration of a visible binding is the given firestore export,
|
|
88
|
+
* read from the given module.
|
|
89
|
+
*
|
|
90
|
+
* The module half is what makes the answer usable as "this name already means
|
|
91
|
+
* what the rewrite is about to emit". Matching on the exported name alone let a
|
|
92
|
+
* `setDoc` imported from `firebase-admin` stand in for the modular SDK's, so the
|
|
93
|
+
* fix emitted a call to the wrong function and left the `firebase/firestore`
|
|
94
|
+
* `updateDoc` import bound to nothing (#1901).
|
|
95
|
+
*/
|
|
96
|
+
function bindsFirestoreExport(variable, imported, module) {
|
|
78
97
|
return (variable.defs.length > 0 &&
|
|
79
|
-
variable.defs.every((def) =>
|
|
98
|
+
variable.defs.every((def) => {
|
|
99
|
+
const binding = firestoreBindingOf(def);
|
|
100
|
+
return binding?.imported === imported && binding.module === module;
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* A comment whose meaning is tied to where it sits. Re-emitting one somewhere
|
|
105
|
+
* else retargets it — a disable directive lands on an unrelated line and a
|
|
106
|
+
* `@ts-expect-error` becomes an error of its own — so a removal that would move
|
|
107
|
+
* one is withheld instead.
|
|
108
|
+
*/
|
|
109
|
+
function isPositionalDirective(comment) {
|
|
110
|
+
if ((0, disableDirectives_1.parseDisableDirectives)([comment]).length > 0) {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
const value = comment.value.trim();
|
|
114
|
+
return value.startsWith('@ts-expect-error') || value.startsWith('@ts-ignore');
|
|
80
115
|
}
|
|
81
116
|
/** The rightmost segment of a type name, so `realtimeDb.X` reads like a bare `X`. */
|
|
82
117
|
function typeNameOf(node) {
|
|
@@ -290,6 +325,13 @@ exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
|
|
|
290
325
|
*/
|
|
291
326
|
const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
|
|
292
327
|
let plannedSetDocBinding = false;
|
|
328
|
+
/**
|
|
329
|
+
* Calls a previous report's fix already rewrote. A batch that retires the
|
|
330
|
+
* `updateDoc` binding has to own every call that reads it — see
|
|
331
|
+
* {@link retiringRewrites} — so the reports for those calls stand without a
|
|
332
|
+
* fix of their own rather than fighting the carrier for the same ranges.
|
|
333
|
+
*/
|
|
334
|
+
const batchedCalls = new Set();
|
|
293
335
|
/**
|
|
294
336
|
* Classes declared directly in one statement container, by name — both the
|
|
295
337
|
* `class X {}` spelling and the `const X = class {}` one, each looked
|
|
@@ -560,51 +602,198 @@ exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
|
|
|
560
602
|
];
|
|
561
603
|
}
|
|
562
604
|
/**
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
605
|
+
* The source the removal planner reads, with the comments inside a
|
|
606
|
+
* declaration hidden from it.
|
|
607
|
+
*
|
|
608
|
+
* `planImportBindingRemoval` declines outright on any comment inside the
|
|
609
|
+
* declaration it is asked to edit, because the ranges it computes span
|
|
610
|
+
* separators and a comment nested among the entries would be swallowed. That
|
|
611
|
+
* decline is not available here. By the time a removal is planned the
|
|
612
|
+
* rewrite has already stripped the binding's last reference, so declining
|
|
613
|
+
* the removal alone leaves `updateDoc` bound to nothing — and declining the
|
|
614
|
+
* WHOLE fix lets a comment decide whether the rewrite fires at all, which is
|
|
615
|
+
* a comment changing the transform just the same (#1877).
|
|
616
|
+
*
|
|
617
|
+
* The planner is therefore asked for the ranges as if the declaration
|
|
618
|
+
* carried no comments, and {@link carriedText} re-emits every comment those
|
|
619
|
+
* ranges cover in place of the text they delete. Only `getCommentsInside` is
|
|
620
|
+
* blinded: `getCommentsBefore` still answers for the directive that binds a
|
|
621
|
+
* whole statement, which is the one shape no re-emission can save.
|
|
570
622
|
*/
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
623
|
+
const removalSource = {
|
|
624
|
+
text: sourceCode.text,
|
|
625
|
+
ast: sourceCode.ast,
|
|
626
|
+
scopeManager: sourceCode.scopeManager,
|
|
627
|
+
getTokenBefore: sourceCode.getTokenBefore.bind(sourceCode),
|
|
628
|
+
getTokenAfter: sourceCode.getTokenAfter.bind(sourceCode),
|
|
629
|
+
getCommentsBefore: sourceCode.getCommentsBefore.bind(sourceCode),
|
|
630
|
+
getCommentsInside: () => [],
|
|
631
|
+
};
|
|
632
|
+
/** The indentation of the line `offset` sits on, for a carried line break. */
|
|
633
|
+
function indentAt(offset) {
|
|
634
|
+
const lineStart = sourceCode.text.lastIndexOf('\n', offset - 1) + 1;
|
|
635
|
+
const prefix = sourceCode.text.slice(lineStart, offset);
|
|
636
|
+
const [indent] = /^[ \t]*/.exec(prefix) ?? [''];
|
|
637
|
+
return indent;
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* What a removal range is replaced with: nothing, or the comments it covers
|
|
641
|
+
* re-emitted so the deletion carries them instead of dropping them. `null`
|
|
642
|
+
* withholds the fix, for a comment whose meaning is its position.
|
|
643
|
+
*
|
|
644
|
+
* The separators around the carried run are chosen from the text on either
|
|
645
|
+
* side rather than added unconditionally, so a range that already sits
|
|
646
|
+
* between whitespace does not gain any. A trailing line break is mandatory
|
|
647
|
+
* where the last comment is a line comment or the range consumed its own
|
|
648
|
+
* newline: without one the surviving code moves onto the comment's line and
|
|
649
|
+
* is commented out.
|
|
650
|
+
*/
|
|
651
|
+
function carriedText(range) {
|
|
652
|
+
const comments = sourceCode
|
|
653
|
+
.getAllComments()
|
|
654
|
+
.filter((comment) => comment.range[0] >= range[0] && comment.range[1] <= range[1]);
|
|
655
|
+
if (comments.length === 0) {
|
|
656
|
+
return '';
|
|
574
657
|
}
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
});
|
|
578
|
-
if (isComma(before)) {
|
|
579
|
-
return [fixer.removeRange([before.range[0], binding.node.range[1]])];
|
|
658
|
+
if (comments.some(isPositionalDirective)) {
|
|
659
|
+
return null;
|
|
580
660
|
}
|
|
581
|
-
const
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
661
|
+
const indent = indentAt(range[0]);
|
|
662
|
+
const segments = comments.map((comment) => ({
|
|
663
|
+
text: sourceCode.text.slice(comment.range[0], comment.range[1]),
|
|
664
|
+
breakAfter: (0, replacementSegments_1.requiresLineBreakAfter)(comment),
|
|
665
|
+
}));
|
|
666
|
+
const body = (0, replacementSegments_1.joinSegmentBody)(segments, indent);
|
|
667
|
+
const before = range[0] > 0 ? sourceCode.text[range[0] - 1] : '';
|
|
668
|
+
const after = sourceCode.text[range[1]] ?? '';
|
|
669
|
+
const lead = before === '' || /\s/.test(before) ? '' : ' ';
|
|
670
|
+
const trail = segments[segments.length - 1].breakAfter ||
|
|
671
|
+
sourceCode.text.slice(range[0], range[1]).endsWith('\n')
|
|
672
|
+
? `\n${indent}`
|
|
673
|
+
: after === '' || /\s/.test(after)
|
|
674
|
+
? ''
|
|
675
|
+
: ' ';
|
|
676
|
+
return `${lead}${body}${trail}`;
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* The edits that unbind whatever `removed` leaves referenced by nothing, or
|
|
680
|
+
* `null` when no such edit is provably safe — in which case the caller drops
|
|
681
|
+
* its whole fix, since a rewrite that strips a binding's last use while
|
|
682
|
+
* leaving the binding behind trades this report for an unused-variable one
|
|
683
|
+
* the consumer's build fails on (#1901).
|
|
684
|
+
*
|
|
685
|
+
* `removed` is the set of reference ranges ONE fix rewrites. Everything else
|
|
686
|
+
* — which bindings that strands, whether a specifier or a whole declaration
|
|
687
|
+
* has to go, whether the name still occurs where scope analysis says it
|
|
688
|
+
* should not — is the shared planner's answer, including the destructured
|
|
689
|
+
* property that binds a `await import('firebase/firestore')` entry.
|
|
690
|
+
*/
|
|
691
|
+
function planRetirement(fixer, removed) {
|
|
692
|
+
const ranges = (0, importRemoval_1.planOrphanedBindingRemoval)(removalSource, removed, (variables, planned) => (0, patternBindingRemoval_1.planPatternBindingRemoval)(removalSource, variables, planned));
|
|
693
|
+
if (!ranges || ranges.length === 0) {
|
|
694
|
+
return null;
|
|
586
695
|
}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
696
|
+
const fixes = [];
|
|
697
|
+
for (const range of ranges) {
|
|
698
|
+
const carried = carriedText(range);
|
|
699
|
+
if (carried === null) {
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
fixes.push(fixer.replaceTextRange([range[0], range[1]], carried));
|
|
703
|
+
}
|
|
704
|
+
return fixes;
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Every call this pass would rewrite, when together they account for the
|
|
708
|
+
* WHOLE of `variable` — otherwise `null`, because the binding survives and
|
|
709
|
+
* nothing may be removed.
|
|
710
|
+
*
|
|
711
|
+
* The batch exists because retirement is not a per-report question. Two
|
|
712
|
+
* violations sharing one import each strip one reference, and only after
|
|
713
|
+
* both land is the binding unreferenced; a report that removed the specifier
|
|
714
|
+
* on its own would strand whichever sibling fix a multi-rule `--fix` drops.
|
|
715
|
+
* Collecting the calls lets ONE report own the import edit and every rewrite
|
|
716
|
+
* that justifies it, which ESLint applies whole or not at all.
|
|
717
|
+
*
|
|
718
|
+
* A reference the rule would not rewrite — read as a value, suppressed by an
|
|
719
|
+
* inline directive, spread arguments, a `setDoc` meaning something else at
|
|
720
|
+
* that site — keeps the binding alive and is answered `null` rather than
|
|
721
|
+
* quietly excluded.
|
|
722
|
+
*/
|
|
723
|
+
function retiringRewrites(variable, callee, setDocVariable) {
|
|
724
|
+
const rewrites = [];
|
|
725
|
+
for (const reference of variable.references) {
|
|
726
|
+
const identifier = reference.identifier;
|
|
727
|
+
// A destructured `const { updateDoc } = await import(…)` records the
|
|
728
|
+
// declaration writing to its own binding. That write is not a use, and
|
|
729
|
+
// counting it as one would make orphanhood depend on how the binding was
|
|
730
|
+
// SPELLED — `orphanedBindings` discounts it for the same reason.
|
|
731
|
+
if (reference.init === true &&
|
|
732
|
+
variable.identifiers.some((declared) => declared === identifier)) {
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
const call = identifier.parent;
|
|
736
|
+
if (!reference.isRead() ||
|
|
737
|
+
call?.type !== utils_1.AST_NODE_TYPES.CallExpression ||
|
|
738
|
+
call.callee !== identifier) {
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
const lastArgument = call.arguments[call.arguments.length - 1];
|
|
742
|
+
if (!lastArgument || hasSpreadArgument(call)) {
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
if (isReportSuppressed(call)) {
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
// `setDoc` has to mean the same thing at every site the batch rewrites,
|
|
749
|
+
// and the batch is planned from one site's resolution.
|
|
750
|
+
if (ASTHelpers_1.ASTHelpers.findVariableInScope(reference.from, SET_DOC) !==
|
|
751
|
+
setDocVariable) {
|
|
752
|
+
return null;
|
|
753
|
+
}
|
|
754
|
+
rewrites.push({ identifier, call, lastArgument });
|
|
755
|
+
}
|
|
756
|
+
// The reporting call has to be among them, or scope analysis did not link
|
|
757
|
+
// the reference this fix is about to rewrite — in which case its ranges
|
|
758
|
+
// account for nothing and the binding must be left alone.
|
|
759
|
+
if (!rewrites.some((rewrite) => rewrite.identifier === callee)) {
|
|
760
|
+
return null;
|
|
761
|
+
}
|
|
762
|
+
// ESLint merges one report's fixes into a single span and refuses
|
|
763
|
+
// overlapping edits within it, so a call nested inside another cannot ride
|
|
764
|
+
// in the same batch.
|
|
765
|
+
const ordered = [...rewrites].sort((left, right) => left.call.range[0] - right.call.range[0]);
|
|
766
|
+
const overlaps = ordered.some((rewrite, index) => index > 0 && rewrite.call.range[0] < ordered[index - 1].call.range[1]);
|
|
767
|
+
return overlaps ? null : rewrites;
|
|
768
|
+
}
|
|
769
|
+
/** `updateDoc(ref, data)` → `setDoc(ref, data, { merge: true })`. */
|
|
770
|
+
function rewriteCall(fixer, rewrite) {
|
|
771
|
+
batchedCalls.add(rewrite.call);
|
|
590
772
|
return [
|
|
591
|
-
fixer.
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
773
|
+
fixer.replaceText(rewrite.identifier, SET_DOC),
|
|
774
|
+
// `setDoc` takes the document data between the reference and the
|
|
775
|
+
// options, so a call that passed no data gets an empty object to merge.
|
|
776
|
+
fixer.insertTextAfter(rewrite.lastArgument, rewrite.call.arguments.length > 1
|
|
777
|
+
? MERGE_ARGUMENT
|
|
778
|
+
: `, {}${MERGE_ARGUMENT}`),
|
|
595
779
|
];
|
|
596
780
|
}
|
|
597
781
|
/**
|
|
598
782
|
* `updateDoc(ref, data)` becomes `setDoc(ref, data, { merge: true })`, which
|
|
599
|
-
* only works if `setDoc` is bound. The import edit and the call
|
|
783
|
+
* only works if `setDoc` is bound. The import edit and the call rewrites ship
|
|
600
784
|
* as one fix array: they sit in disjoint ranges, and a multi-rule `--fix`
|
|
601
785
|
* that applied one without the other would leave the file with an unbound
|
|
602
|
-
* name.
|
|
786
|
+
* name, or with a binding nothing reads.
|
|
603
787
|
*/
|
|
604
788
|
function fixUpdateDocCall(fixer, node, callee) {
|
|
605
789
|
if (isReportSuppressed(node)) {
|
|
606
790
|
return null;
|
|
607
791
|
}
|
|
792
|
+
// An earlier report's fix already rewrites this call, and ESLint refuses
|
|
793
|
+
// two overlapping edits.
|
|
794
|
+
if (batchedCalls.has(node)) {
|
|
795
|
+
return null;
|
|
796
|
+
}
|
|
608
797
|
const lastArgument = node.arguments[node.arguments.length - 1];
|
|
609
798
|
if (!lastArgument || hasSpreadArgument(node)) {
|
|
610
799
|
return null;
|
|
@@ -625,33 +814,56 @@ exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
|
|
|
625
814
|
// catches both, and declining before the binding is scheduled leaves the
|
|
626
815
|
// carrier slot to a violation whose scope is safe.
|
|
627
816
|
const setDocVariable = ASTHelpers_1.ASTHelpers.findVariableInScope(scope, SET_DOC);
|
|
628
|
-
if (setDocVariable &&
|
|
817
|
+
if (setDocVariable &&
|
|
818
|
+
!bindsFirestoreExport(setDocVariable, SET_DOC, updateBinding.module)) {
|
|
629
819
|
return null;
|
|
630
820
|
}
|
|
631
|
-
// Rewriting
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
if (!plannedSetDocBinding) {
|
|
642
|
-
fixes.push(
|
|
643
|
-
? fixer.replaceText(updateBinding.node, SET_DOC)
|
|
644
|
-
: fixer.insertTextAfter(updateBinding.node, `, ${SET_DOC}`));
|
|
821
|
+
// Rewriting every reference to `updateDoc` frees its binding site, and the
|
|
822
|
+
// binding then has to go in the SAME fix: leaving it behind turns a file
|
|
823
|
+
// that lints clean into one failing `no-unused-vars` and `noUnusedLocals`,
|
|
824
|
+
// with this report resolved so nothing re-reports the debt (#1901).
|
|
825
|
+
const rewrites = retiringRewrites(updateVariable, callee, setDocVariable);
|
|
826
|
+
if (!rewrites) {
|
|
827
|
+
// A reference survives the pass, so the name stays bound and `setDoc` is
|
|
828
|
+
// added alongside it. Only the first surviving violation carries the
|
|
829
|
+
// binding; the rest emit the call against it.
|
|
830
|
+
const fixes = [];
|
|
831
|
+
if (!setDocVariable && !plannedSetDocBinding) {
|
|
832
|
+
fixes.push(fixer.insertTextAfter(updateBinding.node, `, ${SET_DOC}`));
|
|
645
833
|
plannedSetDocBinding = true;
|
|
646
834
|
}
|
|
835
|
+
fixes.push(...rewriteCall(fixer, {
|
|
836
|
+
identifier: callee,
|
|
837
|
+
call: node,
|
|
838
|
+
lastArgument,
|
|
839
|
+
}));
|
|
840
|
+
return fixes;
|
|
841
|
+
}
|
|
842
|
+
const fixes = [];
|
|
843
|
+
if (setDocVariable) {
|
|
844
|
+
// The name is already bound to firestore's own `setDoc`, so the entry
|
|
845
|
+
// that becomes redundant is removed rather than renamed.
|
|
846
|
+
const retirement = planRetirement(fixer, rewrites.map((rewrite) => rewrite.identifier.range));
|
|
847
|
+
if (!retirement) {
|
|
848
|
+
return null;
|
|
849
|
+
}
|
|
850
|
+
fixes.push(...retirement);
|
|
851
|
+
}
|
|
852
|
+
else {
|
|
853
|
+
// A second `updateDoc` binding under another local name would already
|
|
854
|
+
// have claimed the `setDoc` entry; emitting a second one collides with
|
|
855
|
+
// it (TS2300).
|
|
856
|
+
if (plannedSetDocBinding) {
|
|
857
|
+
return null;
|
|
858
|
+
}
|
|
859
|
+
// The entry is renamed in place, so an alias disappears together with
|
|
860
|
+
// the references that used it.
|
|
861
|
+
fixes.push(fixer.replaceText(updateBinding.node, SET_DOC));
|
|
862
|
+
plannedSetDocBinding = true;
|
|
647
863
|
}
|
|
648
|
-
|
|
649
|
-
fixes.push(...
|
|
864
|
+
for (const rewrite of rewrites) {
|
|
865
|
+
fixes.push(...rewriteCall(fixer, rewrite));
|
|
650
866
|
}
|
|
651
|
-
fixes.push(fixer.replaceText(callee, SET_DOC));
|
|
652
|
-
// `setDoc` takes the document data between the reference and the options,
|
|
653
|
-
// so a call that passed no data gets an empty object to merge.
|
|
654
|
-
fixes.push(fixer.insertTextAfter(lastArgument, node.arguments.length > 1 ? MERGE_ARGUMENT : `, {}${MERGE_ARGUMENT}`));
|
|
655
867
|
return fixes;
|
|
656
868
|
}
|
|
657
869
|
return {
|