@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.
Files changed (28) hide show
  1. package/lib/index.js +1 -1
  2. package/lib/rules/enforce-centralized-mock-firestore.js +46 -3
  3. package/lib/rules/enforce-firestore-set-merge.js +278 -66
  4. package/lib/rules/enforce-microdiff.js +87 -20
  5. package/lib/rules/fast-deep-equal-over-microdiff.js +220 -71
  6. package/lib/rules/key-only-outermost-element.js +159 -21
  7. package/lib/rules/logical-top-to-bottom-grouping.js +104 -33
  8. package/lib/rules/no-empty-dependency-use-callbacks.js +116 -43
  9. package/lib/rules/no-explicit-return-type.js +164 -21
  10. package/lib/rules/no-mock-firebase-admin.js +52 -8
  11. package/lib/rules/no-redundant-usecallback-wrapper.d.ts +2 -1
  12. package/lib/rules/no-redundant-usecallback-wrapper.js +126 -70
  13. package/lib/rules/no-unused-props.js +69 -57
  14. package/lib/rules/no-useless-usememo-primitives.d.ts +2 -1
  15. package/lib/rules/no-useless-usememo-primitives.js +156 -47
  16. package/lib/rules/no-usememo-for-pass-by-value.js +165 -220
  17. package/lib/rules/prefer-params-over-parent-id.d.ts +2 -1
  18. package/lib/rules/prefer-params-over-parent-id.js +122 -39
  19. package/lib/rules/use-latest-callback.js +109 -11
  20. package/lib/rules/vertically-group-related-functions.js +41 -10
  21. package/lib/utils/importRemoval.d.ts +88 -0
  22. package/lib/utils/importRemoval.js +127 -46
  23. package/lib/utils/patternBindingRemoval.d.ts +28 -0
  24. package/lib/utils/patternBindingRemoval.js +199 -0
  25. package/lib/utils/typeDeclarationRemoval.d.ts +32 -0
  26. package/lib/utils/typeDeclarationRemoval.js +105 -0
  27. package/package.json +1 -1
  28. package/release-manifest.json +140 -0
package/lib/index.js CHANGED
@@ -223,7 +223,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
223
223
  module.exports = {
224
224
  meta: {
225
225
  name: '@blumintinc/eslint-plugin-blumint',
226
- version: '1.20.136',
226
+ version: '1.20.138',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -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 retired declaration goes away with it, so
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 survivingReplacements = replacements.filter((replacement) => !removals.some((removal) => replacement.start >= removal.start &&
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([...removals, ...survivingReplacements]);
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
- function isFirestoreDynamicImport(node) {
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 false;
29
+ return null;
26
30
  }
27
31
  const imported = node.argument;
28
- return (imported.type === utils_1.AST_NODE_TYPES.ImportExpression &&
29
- imported.source.type === utils_1.AST_NODE_TYPES.Literal &&
30
- typeof imported.source.value === 'string' &&
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
- isFirestoreDynamicImport(node.init)) {
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
- function isComma(token) {
74
- return token?.type === utils_1.AST_TOKEN_TYPES.Punctuator && token.value === ',';
75
- }
76
- /** Whether every declaration of a visible binding is the given firestore export. */
77
- function bindsFirestoreExport(variable, imported) {
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) => firestoreBindingOf(def)?.imported === imported));
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
- * Drops a binding whose last reference this fix rewrites, so `--fix` does not
564
- * leave an unused import behind. Only the entry and the comma separating it
565
- * from a sibling go, and only when nothing else lives in that span: a comment
566
- * between the entry and its comma belongs to a neighbour as often as to the
567
- * entry, and an unused specifier is inert where a deleted comment is not.
568
- * A list that would end up empty is left alone too, since emptying it means
569
- * rewriting the whole declaration.
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
- function removeBinding(fixer, binding) {
572
- if (binding.entries.length < 2) {
573
- return [];
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
- const before = sourceCode.getTokenBefore(binding.node, {
576
- includeComments: true,
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 after = sourceCode.getTokenAfter(binding.node, {
582
- includeComments: true,
583
- });
584
- if (!isComma(after)) {
585
- return [];
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
- // Stopping at whatever follows the comma — comment or token — keeps a
588
- // directive that documents the next entry attached to it.
589
- const next = sourceCode.getTokenAfter(after, { includeComments: true });
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.removeRange([
592
- binding.node.range[0],
593
- next ? next.range[0] : after.range[1],
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 rewrite ship
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 && !bindsFirestoreExport(setDocVariable, SET_DOC)) {
817
+ if (setDocVariable &&
818
+ !bindsFirestoreExport(setDocVariable, SET_DOC, updateBinding.module)) {
629
819
  return null;
630
820
  }
631
- // Rewriting the last reference to `updateDoc` frees its binding site, so
632
- // the entry is renamed in place and an alias disappears together with
633
- // the reference that used it. Any other reference keeps the old name
634
- // alive: adding `setDoc` alongside it is then the only safe edit, because
635
- // a multi-rule `--fix` can drop a sibling violation's fix and strand that
636
- // reference on a binding this one just removed.
637
- const reads = updateVariable.references.filter((reference) => reference.isRead());
638
- const isSoleReference = reads.length === 1 && reads[0].identifier === callee;
639
- const fixes = [];
640
- if (!setDocVariable) {
641
- if (!plannedSetDocBinding) {
642
- fixes.push(isSoleReference
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
- else if (isSoleReference) {
649
- fixes.push(...removeBinding(fixer, updateBinding));
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 {