@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
@@ -417,10 +417,12 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
417
417
  DIFF_FUNCTION_NAMES.has(specifier.local.name));
418
418
  }
419
419
  /**
420
- * Whether retiring `declaration` leaves every name it binds accounted for.
420
+ * How many references retiring `declaration` hands to the call fixes, or
421
+ * null when retiring it would strand one.
422
+ *
421
423
  * The fix removes the whole declaration, so each reference it serves has to
422
424
  * be one the call fixes rewrite to `diff` in the same pass — an import swap
423
- * that strands a reference behind is the defect this guards.
425
+ * that strands a reference behind is the defect the null answer guards.
424
426
  *
425
427
  * Two things have to hold at every reference. `diff` must reach microdiff
426
428
  * there: the import rewrite lands at module scope while the references it
@@ -428,28 +430,71 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
428
430
  * would resolve to the shadow. And the reference has to sit where a rewrite
429
431
  * exists at all, which is the callee position of a convertible call.
430
432
  *
431
- * A specifier nothing references is vacuously safe: retiring it removes an
432
- * import no code reads.
433
+ * The count answers the mirror-image question. A specifier nothing
434
+ * references strands nothing, but it hands the import that replaces it no
435
+ * call either, so a declaration serving zero rewrites is exactly the case
436
+ * where writing `import diff from '@blumintinc/microdiff'` over it binds a
437
+ * name no code reads.
433
438
  */
434
- function canRetireImport(declaration) {
439
+ function rewrittenReferenceCount(declaration) {
435
440
  const claimable = collectClaimableSpecifiers(sourceCode.ast);
436
441
  const declarationScope = ASTHelpers_1.ASTHelpers.getScope(context, declaration);
437
- return declaration.specifiers.every((specifier) => {
442
+ let rewrites = 0;
443
+ for (const specifier of declaration.specifiers) {
438
444
  const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(declarationScope, specifier.local.name);
439
445
  if (!variable) {
440
- return true;
446
+ continue;
441
447
  }
442
448
  const rewritten = isRewrittenSpecifier(specifier);
443
- return variable.references.every((reference) => canEmitDiff(reference.from, claimable) &&
444
- rewritten &&
445
- isRewrittenCallee(reference.identifier));
446
- });
449
+ for (const reference of variable.references) {
450
+ if (!rewritten ||
451
+ !canEmitDiff(reference.from, claimable) ||
452
+ !isRewrittenCallee(reference.identifier)) {
453
+ return null;
454
+ }
455
+ rewrites += 1;
456
+ }
457
+ }
458
+ return rewrites;
447
459
  }
448
460
  /**
449
- * Whether the import fix retires `declaration` in this same pass.
461
+ * Whether the import fix retires `declaration` in this same pass. The
462
+ * branches mirror the fix's own: a declaration serving no call rewrite is
463
+ * dropped when the file already imports microdiff, and left alone when
464
+ * dropping it would mean writing an unread `diff` in its place.
450
465
  */
451
466
  function willRetireImport(declaration) {
452
- return canEmitDiffAt(declaration) && canRetireImport(declaration);
467
+ if (!canEmitDiffAt(declaration)) {
468
+ return false;
469
+ }
470
+ const rewrites = rewrittenReferenceCount(declaration);
471
+ if (rewrites === null) {
472
+ return false;
473
+ }
474
+ return rewrites > 0 || !!findMicrodiffImport(sourceCode.ast);
475
+ }
476
+ /**
477
+ * Whether a callee's binding keeps a reference this pass leaves alone.
478
+ *
479
+ * Renaming a call to `diff` stops it referencing whatever bound the callee.
480
+ * When the import binding it is retired in the same pass, the binding goes
481
+ * with the declaration and nothing is left behind. A declaration that
482
+ * survives — because a sibling specifier has no rewrite of its own — keeps a
483
+ * name that nothing reads unless some other reference outlives the pass, and
484
+ * an import bound to nothing is what the consumer's `no-unused-vars` and
485
+ * `noUnusedLocals` both fail the build on.
486
+ *
487
+ * A reference the call fixes cannot rewrite is what keeps the binding alive:
488
+ * one in a position that has no rewrite, or one standing where the emitted
489
+ * `diff` would resolve to something else.
490
+ */
491
+ function keepsUnrewrittenReference(variable) {
492
+ if (!variable) {
493
+ return false;
494
+ }
495
+ const claimable = collectClaimableSpecifiers(sourceCode.ast);
496
+ return variable.references.some((reference) => !isRewrittenCallee(reference.identifier) ||
497
+ !canEmitDiff(reference.from, claimable));
453
498
  }
454
499
  /**
455
500
  * Whether the call at `node`, whose callee `declaration` binds, may be
@@ -461,15 +506,22 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
461
506
  * file already has, or the one that replaces `declaration` in this same
462
507
  * pass. Renaming a callee whose import survives strands the rewritten call
463
508
  * exactly as retiring an import whose callee survives strands that one.
509
+ *
510
+ * It also needs the binding it stops referencing to be accounted for. An
511
+ * existing microdiff import binds the emitted `diff` whatever happens to
512
+ * `declaration`, which is what lets a rename land beside an import too
513
+ * entangled to retire — and that is precisely when the renamed call can have
514
+ * been the last thing reading the name the surviving import binds.
464
515
  */
465
- function canRewriteCall(node, declaration) {
516
+ function canRewriteCall(node, declaration, calleeVariable) {
466
517
  if (!canEmitDiffAt(node) || !hasRewritableArity(node)) {
467
518
  return false;
468
519
  }
469
- if (findMicrodiffImport(sourceCode.ast)) {
470
- return true;
520
+ const retires = !!declaration && willRetireImport(declaration);
521
+ if (!retires && !keepsUnrewrittenReference(calleeVariable)) {
522
+ return false;
471
523
  }
472
- return !!declaration && willRetireImport(declaration);
524
+ return retires || !!findMicrodiffImport(sourceCode.ast);
473
525
  }
474
526
  /**
475
527
  * The fix element that puts microdiff's import at the top of the file, or
@@ -628,13 +680,28 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
628
680
  // already binds to something else, and rather than retire an
629
681
  // import whose references no call fix rewrites. The report stands
630
682
  // either way, so the author resolves it deliberately.
631
- if (!canEmitDiffAt(node) || !canRetireImport(node)) {
683
+ if (!canEmitDiffAt(node)) {
684
+ return null;
685
+ }
686
+ const rewrites = rewrittenReferenceCount(node);
687
+ if (rewrites === null) {
632
688
  return null;
633
689
  }
634
690
  // If we already have a microdiff import, just remove this import
635
691
  if (findMicrodiffImport(sourceCode.ast)) {
636
692
  return fixer.remove(node);
637
693
  }
694
+ // A declaration whose names no call fix rewrites has nothing to
695
+ // hand the import that would replace it, so writing microdiff's
696
+ // import over it trades one unread name for another: the file
697
+ // comes out of the fix with `diff` bound to nothing, which the
698
+ // consumer's `no-unused-vars` and `noUnusedLocals` both fail on.
699
+ // A local shadowing the import is exactly that shape — the calls
700
+ // belong to the shadow, so the import's own reference list is
701
+ // empty — and so is an import nothing uses at all.
702
+ if (rewrites === 0) {
703
+ return null;
704
+ }
638
705
  // Otherwise, replace with microdiff import
639
706
  return fixer.replaceText(node, MICRODIFF_IMPORT);
640
707
  },
@@ -691,7 +758,7 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
691
758
  node,
692
759
  messageId: 'enforceMicrodiff',
693
760
  fix(fixer) {
694
- if (!canRewriteCall(node, competingImport)) {
761
+ if (!canRewriteCall(node, competingImport, calleeVariable)) {
695
762
  return null;
696
763
  }
697
764
  return fixer.replaceText(callee, DIFF_NAME);
@@ -718,7 +785,7 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
718
785
  node,
719
786
  messageId: 'enforceMicrodiff',
720
787
  fix(fixer) {
721
- if (!canRewriteCall(node, competingImport)) {
788
+ if (!canRewriteCall(node, competingImport, calleeVariable)) {
722
789
  return null;
723
790
  }
724
791
  // When handling fast-diff and similar libraries, need to ensure the function name is replaced
@@ -6,6 +6,7 @@ const createRule_1 = require("../utils/createRule");
6
6
  const ASTHelpers_1 = require("../utils/ASTHelpers");
7
7
  const disableDirectives_1 = require("../utils/disableDirectives");
8
8
  const microdiffModules_1 = require("../utils/microdiffModules");
9
+ const importRemoval_1 = require("../utils/importRemoval");
9
10
  const fastDeepEqualModules_1 = require("../utils/fastDeepEqualModules");
10
11
  const DIFF_EXPORT_NAME = 'diff';
11
12
  /**
@@ -86,6 +87,14 @@ function unwrapChain(node) {
86
87
  function rangesOverlap(a, b) {
87
88
  return a[0] < b[1] && b[0] < a[1];
88
89
  }
90
+ /**
91
+ * Whether the diff call sits inside the comparison being rewritten.
92
+ * `diff(a, b).length === 0` rewrites the call where it stands, while
93
+ * `changes.length === 0` compares a call declared elsewhere that the fix moves.
94
+ */
95
+ function isCallInPlace(node, diffCall) {
96
+ return (diffCall.range[0] >= node.range[0] && diffCall.range[1] <= node.range[1]);
97
+ }
89
98
  exports.fastDeepEqualOverMicrodiff = (0, createRule_1.createRule)({
90
99
  name: 'fast-deep-equal-over-microdiff',
91
100
  meta: {
@@ -110,12 +119,22 @@ exports.fastDeepEqualOverMicrodiff = (0, createRule_1.createRule)({
110
119
  let microdiffImportName = 'diff';
111
120
  let fastDeepEqualImportName = 'isEqual';
112
121
  const reportedNodes = new Set();
113
- let plannedFastDeepEqualImport = false;
114
122
  /**
115
- * The `import ... from '@blumintinc/fast-deep-equal'` statement rides on a
116
- * single violation's fix, so that violation is the file's import carrier. A
117
- * suppressed carrier would take the import down with it while the surviving
118
- * violations still emit `isEqual(...)` calls, leaving them unbound.
123
+ * Every comparison the rule reports, in traversal order.
124
+ *
125
+ * Reporting is deferred to `Program:exit` because the microdiff import is
126
+ * unbound only once no rewrite leaves a reference to it standing. Judged one
127
+ * comparison at a time, a file with two of them never sees either as the
128
+ * binding's last use, and the pass that rewrites both resolves every report
129
+ * — so nothing ever revisits the stranded import.
130
+ */
131
+ const violations = [];
132
+ /**
133
+ * A suppressed report is dropped together with its fix, so its rewrite never
134
+ * happens: counting it toward the batch would unbind an import the surviving
135
+ * text still calls, and would hand the `import ... from
136
+ * '@blumintinc/fast-deep-equal'` statement — which rides on one violation's
137
+ * fix — to a violation ESLint is about to discard.
119
138
  */
120
139
  const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
121
140
  function isMicrodiffCallee(callee) {
@@ -434,66 +453,121 @@ exports.fastDeepEqualOverMicrodiff = (0, createRule_1.createRule)({
434
453
  /**
435
454
  * The edits that turn the comparison itself into a fast-deep-equal call.
436
455
  */
437
- function createComparisonFixes(fixer, node, diffCall, isEquality) {
438
- // Comparing a call in place — `diff(a, b).length === 0` — is rewritten by
439
- // splicing the two ranges that actually change: the callee name and the
440
- // `.length` comparison tail (plus the `!`/`0 ===` head). Re-emitting the
441
- // argument list instead destroyed everything between the arguments, and a
442
- // dropped `eslint-disable` comment silently re-enables the rule it was
443
- // suppressing.
444
- const isCallInPlace = diffCall.range[0] >= node.range[0] &&
445
- diffCall.range[1] <= node.range[1];
446
- if (!isCallInPlace) {
456
+ function createComparisonEdits(node, diffCall, isEquality) {
457
+ if (!isCallInPlace(node, diffCall)) {
447
458
  // `changes.length === 0` compares a call declared elsewhere, so that
448
459
  // call moves to the comparison's position and its text has to be
449
460
  // re-emitted — verbatim, so its comments move with it.
450
461
  const call = renameCallee(diffCall);
451
- return [fixer.replaceText(node, isEquality ? call : `!${call}`)];
462
+ return [{ range: node.range, text: isEquality ? call : `!${call}` }];
452
463
  }
453
- const fixes = [];
464
+ // Comparing a call in place — `diff(a, b).length === 0` — is rewritten by
465
+ // splicing the ranges that actually change: the callee name and the
466
+ // `.length` comparison tail (plus the `!`/`0 ===` head). Re-emitting the
467
+ // argument list instead destroyed everything between the arguments, and a
468
+ // dropped `eslint-disable` comment silently re-enables the rule it was
469
+ // suppressing.
470
+ const edits = [];
454
471
  const headRange = [node.range[0], diffCall.range[0]];
455
472
  if (!isEquality) {
456
- fixes.push(fixer.replaceTextRange(headRange, '!'));
473
+ edits.push({ range: headRange, text: '!' });
457
474
  }
458
475
  else if (headRange[0] !== headRange[1]) {
459
- fixes.push(fixer.removeRange(headRange));
476
+ edits.push({ range: headRange, text: '' });
477
+ }
478
+ edits.push({
479
+ range: diffCall.callee.range,
480
+ text: fastDeepEqualImportName,
481
+ });
482
+ edits.push({ range: [diffCall.range[1], node.range[1]], text: '' });
483
+ return edits;
484
+ }
485
+ /**
486
+ * The positions a rewrite genuinely erases, which is all the orphan
487
+ * analysis may be told about.
488
+ *
489
+ * Only deleted text counts, and the argument list is never deleted: it is
490
+ * spliced around where it stands, or moved verbatim to the comparison's
491
+ * position. Handing over the enclosing span instead would read every binding
492
+ * the arguments mention as unreferenced and delete its import — an
493
+ * over-removal strictly worse than the stranded import this exists to
494
+ * prevent. So each shape gives up the span around the call, plus the callee
495
+ * the rewrite renames.
496
+ *
497
+ * The one case that lists the comparison itself is a hoisted
498
+ * `const changes = diff(...)` this rule will not delete, since then the
499
+ * rewrite really does strip `changes` of its last read. Where the
500
+ * declaration goes too, `changes` goes with it, and listing the comparison
501
+ * would only make the planner refuse a rewrite that orphans nothing.
502
+ */
503
+ function removedReferenceRanges(node, diffCall, declarationRemoval) {
504
+ if (isCallInPlace(node, diffCall)) {
505
+ return [
506
+ [node.range[0], diffCall.range[0]],
507
+ diffCall.callee.range,
508
+ [diffCall.range[1], node.range[1]],
509
+ ];
510
+ }
511
+ if (!declarationRemoval) {
512
+ return [node.range];
460
513
  }
461
- fixes.push(fixer.replaceText(diffCall.callee, fastDeepEqualImportName));
462
- fixes.push(fixer.removeRange([diffCall.range[1], node.range[1]]));
463
- return fixes;
514
+ return [
515
+ [declarationRemoval[0], diffCall.range[0]],
516
+ diffCall.callee.range,
517
+ [diffCall.range[1], declarationRemoval[1]],
518
+ ];
464
519
  }
465
520
  /**
466
- * The `import ... from '@blumintinc/fast-deep-equal'` edit, scheduled at
467
- * most once per file. Claiming the carrier slot is a side effect, so this
468
- * runs last — after every reason to decline the fix has been ruled out —
469
- * otherwise a declining violation takes the import down with it and leaves
470
- * the surviving violations' `isEqual(...)` calls unbound.
521
+ * The `import ... from '@blumintinc/fast-deep-equal'` edit, emitted once as
522
+ * part of the file's single fix.
523
+ *
524
+ * The anchor is the microdiff declaration, which the same fix may be
525
+ * deleting. Inserting *after* a declaration that is going away puts the
526
+ * insertion point inside the deleted span, and ESLint rejects that pair as
527
+ * overlapping and discards every message for the file — so a displaced
528
+ * anchor is taken over rather than followed: the new import lands on the
529
+ * exact position the old one occupied.
471
530
  *
472
531
  * The specifier is the scoped fork: it is the dependency this codebase
473
532
  * declares, so any other name would be written as an import that resolves
474
533
  * nowhere.
475
534
  */
476
- function planFastDeepEqualImport(fixer) {
477
- if (hasFastDeepEqualImport || plannedFastDeepEqualImport) {
535
+ function planFastDeepEqualImport(fixer, removalRanges) {
536
+ if (hasFastDeepEqualImport) {
478
537
  return [];
479
538
  }
480
- plannedFastDeepEqualImport = true;
481
539
  const importStatement = (0, fastDeepEqualModules_1.fastDeepEqualImport)(fastDeepEqualImportName);
482
540
  const importDeclarations = sourceCode.ast.body.filter((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
483
- const microdiffImport = importDeclarations.find((declaration) => microdiffModules_1.MICRODIFF_MODULES.has(String(declaration.source.value)));
484
- const anchor = microdiffImport ?? importDeclarations[importDeclarations.length - 1];
541
+ const removalStartOf = (declaration) => removalRanges.find((range) => range[0] <= declaration.range[0] &&
542
+ range[1] >= declaration.range[1])?.[0];
543
+ const microdiffImports = importDeclarations.filter((declaration) => microdiffModules_1.MICRODIFF_MODULES.has(String(declaration.source.value)));
544
+ for (const declaration of microdiffImports) {
545
+ const start = removalStartOf(declaration);
546
+ if (start !== undefined) {
547
+ return [
548
+ fixer.insertTextBeforeRange([start, start], `${importStatement}\n`),
549
+ ];
550
+ }
551
+ }
552
+ const surviving = importDeclarations.filter((declaration) => removalStartOf(declaration) === undefined);
553
+ const anchor = microdiffImports[0] ?? surviving[surviving.length - 1];
485
554
  if (anchor) {
486
555
  return [fixer.insertTextAfter(anchor, `\n${importStatement}`)];
487
556
  }
488
557
  return [fixer.insertTextBeforeRange([0, 0], `${importStatement}\n`)];
489
558
  }
490
559
  /**
491
- * Create a fix for replacing microdiff equality check with fast-deep-equal
560
+ * The edits one violation contributes, or `null` when its rewrite is not
561
+ * provably safe.
562
+ *
563
+ * `removedDeclarations` carries the hoisted declarations earlier violations
564
+ * already claimed, so two comparisons measuring the same
565
+ * `const changes = diff(...)` delete it once and both get rewritten —
566
+ * deleting it twice is an overlap, and deleting it while leaving the second
567
+ * comparison reading `changes` unbinds that read.
492
568
  */
493
- function createFix(fixer, node, diffCall, isEquality) {
494
- // A suppressed report is dropped together with its fix. Producing no fix
495
- // — and leaving the import unscheduled — passes the carrier slot to the
496
- // first violation that survives.
569
+ function planViolation(violation, removedDeclarations) {
570
+ const { node, diffCall, isEquality, scope } = violation;
497
571
  if (isReportSuppressed(node)) {
498
572
  return null;
499
573
  }
@@ -501,57 +575,93 @@ exports.fastDeepEqualOverMicrodiff = (0, createRule_1.createRule)({
501
575
  // makes both halves of the edit wrong: an inserted import collides with a
502
576
  // same-scope declaration (TS2440/TS2300), and a narrower-scope shadow
503
577
  // rebinds the emitted call to the local value with no diagnostic at all.
504
- // Resolving from the fixed node's scope chain catches both. Declining
505
- // before the import is scheduled leaves the carrier slot to a violation
506
- // whose scope is safe, and drops the whole edit — including the removal
507
- // of a redundant `const changes = diff(...)` — rather than half of it.
508
- const existingBinding = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, node), fastDeepEqualImportName);
578
+ // Resolving from the fixed node's scope chain catches both. Dropping this
579
+ // violation from the batch leaves the carrier slot to one whose scope is
580
+ // safe, and drops its whole edit — including the removal of a redundant
581
+ // `const changes = diff(...)` — rather than half of it.
582
+ const existingBinding = ASTHelpers_1.ASTHelpers.findVariableInScope(scope, fastDeepEqualImportName);
509
583
  if (existingBinding && !bindsFastDeepEqual(existingBinding)) {
510
584
  return null;
511
585
  }
512
586
  if (diffCall.arguments.length !== 2) {
513
587
  return null; // Can't fix if not exactly 2 arguments
514
588
  }
515
- const comparisonFixes = createComparisonFixes(fixer, node, diffCall, isEquality);
589
+ const comparisonEdits = createComparisonEdits(node, diffCall, isEquality);
516
590
  // If the equality was via an identifier like `changes.length`, and that
517
591
  // identifier is declared as `const changes = diff(...);` and used ONLY for
518
592
  // `.length` checks, remove the redundant variable declaration.
519
593
  const declaration = findRedundantDeclaration(node);
520
- const declarationFixes = [];
521
- if (declaration) {
522
- const removalRange = redundantDeclarationRange(declaration);
523
- // A comparison nested inside the declaration it would delete — a diff
524
- // argument that reads `changes.length` — admits no pair of disjoint
525
- // edits. Reporting without a fix beats an overlap, which aborts the
526
- // file, and beats a half-applied edit that deletes the surviving code.
527
- if (comparisonFixes.some(({ range }) => rangesOverlap(range, removalRange))) {
528
- return null;
594
+ if (!declaration) {
595
+ return {
596
+ violation,
597
+ edits: comparisonEdits,
598
+ removed: removedReferenceRanges(node, diffCall),
599
+ };
600
+ }
601
+ const claimed = removedDeclarations.get(declaration);
602
+ const removalRange = claimed ?? redundantDeclarationRange(declaration);
603
+ // A comparison nested inside the declaration it would delete — a diff
604
+ // argument that reads `changes.length` — admits no pair of disjoint
605
+ // edits. Reporting without a fix beats an overlap, which aborts the
606
+ // file, and beats a half-applied edit that deletes the surviving code.
607
+ if (comparisonEdits.some(({ range }) => rangesOverlap(range, removalRange))) {
608
+ return null;
609
+ }
610
+ return {
611
+ violation,
612
+ declaration,
613
+ declarationRemoval: removalRange,
614
+ edits: claimed
615
+ ? comparisonEdits
616
+ : [...comparisonEdits, { range: removalRange, text: '' }],
617
+ removed: removedReferenceRanges(node, diffCall, removalRange),
618
+ };
619
+ }
620
+ /**
621
+ * The rewrites that actually ship, in traversal order.
622
+ *
623
+ * Each is screened alone before it joins the batch: a rewrite whose own
624
+ * deletion orphans something that cannot be unbound safely — a local
625
+ * variable, an import behind a directive comment — would otherwise poison
626
+ * every other rewrite in the file. Edits that collide with an already
627
+ * accepted one are dropped for the same reason a single report's
628
+ * overlapping fixes are: ESLint asserts on the overlap and discards every
629
+ * message for the file.
630
+ */
631
+ function planViolations() {
632
+ const planned = [];
633
+ const claimedRanges = [];
634
+ const removedDeclarations = new Map();
635
+ for (const violation of violations) {
636
+ const candidate = planViolation(violation, removedDeclarations);
637
+ if (!candidate)
638
+ continue;
639
+ if ((0, importRemoval_1.planOrphanedImportRemoval)(sourceCode, candidate.removed) === null) {
640
+ continue;
529
641
  }
530
- declarationFixes.push(fixer.removeRange(removalRange));
642
+ if (candidate.edits.some(({ range }) => claimedRanges.some((taken) => rangesOverlap(range, taken)))) {
643
+ continue;
644
+ }
645
+ claimedRanges.push(...candidate.edits.map(({ range }) => range));
646
+ if (candidate.declaration && candidate.declarationRemoval) {
647
+ removedDeclarations.set(candidate.declaration, candidate.declarationRemoval);
648
+ }
649
+ planned.push(candidate);
531
650
  }
532
- return [
533
- ...planFastDeepEqualImport(fixer),
534
- ...declarationFixes,
535
- ...comparisonFixes,
536
- ];
651
+ return planned;
537
652
  }
538
- function reportEqualityCheck(node, result) {
653
+ function collectEqualityCheck(node, result) {
539
654
  const { diffCall, isEquality } = result;
540
655
  if (!diffCall)
541
656
  return;
542
657
  if (reportedNodes.has(node))
543
658
  return;
544
659
  reportedNodes.add(node);
545
- context.report({
660
+ violations.push({
546
661
  node,
547
- messageId: 'useFastDeepEqual',
548
- data: {
549
- diffName: microdiffImportName,
550
- fastEqualName: fastDeepEqualImportName,
551
- },
552
- fix(fixer) {
553
- return createFix(fixer, node, diffCall, isEquality);
554
- },
662
+ diffCall,
663
+ isEquality,
664
+ scope: ASTHelpers_1.ASTHelpers.getScope(context, node),
555
665
  });
556
666
  }
557
667
  return {
@@ -599,7 +709,7 @@ exports.fastDeepEqualOverMicrodiff = (0, createRule_1.createRule)({
599
709
  if (reportedNodes.has(node)) {
600
710
  return;
601
711
  }
602
- reportEqualityCheck(node, isMicrodiffEqualityCheck(node));
712
+ collectEqualityCheck(node, isMicrodiffEqualityCheck(node));
603
713
  },
604
714
  // Check if statements for microdiff equality patterns
605
715
  IfStatement(node) {
@@ -609,7 +719,7 @@ exports.fastDeepEqualOverMicrodiff = (0, createRule_1.createRule)({
609
719
  if (reportedNodes.has(node.test)) {
610
720
  return;
611
721
  }
612
- reportEqualityCheck(node.test, isMicrodiffEqualityCheck(node.test));
722
+ collectEqualityCheck(node.test, isMicrodiffEqualityCheck(node.test));
613
723
  },
614
724
  // Check return statements for microdiff equality patterns
615
725
  ReturnStatement(node) {
@@ -620,7 +730,46 @@ exports.fastDeepEqualOverMicrodiff = (0, createRule_1.createRule)({
620
730
  if (!argument || reportedNodes.has(argument)) {
621
731
  return;
622
732
  }
623
- reportEqualityCheck(argument, isMicrodiffEqualityCheck(argument));
733
+ collectEqualityCheck(argument, isMicrodiffEqualityCheck(argument));
734
+ },
735
+ 'Program:exit'() {
736
+ if (violations.length === 0)
737
+ return;
738
+ const planned = planViolations();
739
+ // One plan over every surviving rewrite: the microdiff binding is left
740
+ // unreferenced by their union even when no single rewrite would strip
741
+ // its last use, and the pass that applies them all resolves every
742
+ // report — so this is the only moment the stranded import is visible.
743
+ const importRemoval = planned.length > 0
744
+ ? (0, importRemoval_1.planOrphanedImportRemoval)(sourceCode, planned.flatMap((entry) => entry.removed))
745
+ : null;
746
+ const removalRanges = importRemoval ?? [];
747
+ // The whole batch ships as one fix, so no rewrite can land without the
748
+ // others the import's orphanhood was judged against, and no unbinding
749
+ // can land without the rewrite it was claimed on. The other violations
750
+ // report without a fixer; the carrier's pass already resolves them.
751
+ //
752
+ // No plan at all means some binding would be left unreferenced yet
753
+ // cannot be unbound safely, so every rewrite stays: reports without a
754
+ // fixer are the lesser damage.
755
+ const carrier = importRemoval ? planned[0] : undefined;
756
+ for (const violation of violations) {
757
+ context.report({
758
+ node: violation.node,
759
+ messageId: 'useFastDeepEqual',
760
+ data: {
761
+ diffName: microdiffImportName,
762
+ fastEqualName: fastDeepEqualImportName,
763
+ },
764
+ fix: violation === carrier?.violation
765
+ ? (fixer) => [
766
+ ...planFastDeepEqualImport(fixer, removalRanges),
767
+ ...removalRanges.map((range) => fixer.removeRange([range[0], range[1]])),
768
+ ...planned.flatMap((entry) => entry.edits.map(({ range, text }) => fixer.replaceTextRange(range, text))),
769
+ ]
770
+ : undefined,
771
+ });
772
+ }
624
773
  },
625
774
  };
626
775
  },