@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
@@ -2,6 +2,109 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.keyOnlyOutermostElement = void 0;
4
4
  const createRule_1 = require("../utils/createRule");
5
+ const disableDirectives_1 = require("../utils/disableDirectives");
6
+ const importRemoval_1 = require("../utils/importRemoval");
7
+ /**
8
+ * The span a removed `key` gives up: the attribute together with the whitespace
9
+ * separating it from whatever precedes it.
10
+ *
11
+ * Taking the whitespace BEFORE the attribute rather than after is what makes
12
+ * every position in an attribute list come out right. Consuming the trailing
13
+ * space instead leaves `<div >` whenever the key ends the list — no other
14
+ * attribute follows to donate a space — and consuming both would fuse the
15
+ * survivors on either side into `id="x"className="y"`.
16
+ *
17
+ * Only whitespace is swallowed, so a block comment sitting between the previous
18
+ * token and the key stays where its author put it.
19
+ */
20
+ function removalRangeOf(source, attribute) {
21
+ let start = attribute.range[0];
22
+ while (start > 0 && /\s/.test(source.text[start - 1])) {
23
+ start--;
24
+ }
25
+ return [start, attribute.range[1]];
26
+ }
27
+ /**
28
+ * Partitions violations into the sets whose removals have to travel together.
29
+ *
30
+ * A binding read from two key expressions is unbound only once both are gone, so
31
+ * neither removal may unbind it alone — and a fix may only count on the other
32
+ * removal happening if it performs that removal itself. Keys that jointly hold a
33
+ * binding alive are therefore merged into one batch, and every other key is a
34
+ * batch of one, judged against the file as it stands.
35
+ *
36
+ * EVERY binding is asked about, not the imported ones alone: a local helper named
37
+ * by two keys would otherwise be owned by no fix at all, each key looking
38
+ * innocent by itself while `--fix` strips both in the same run (#1902).
39
+ *
40
+ * `violations` must already exclude suppressed reports, whose fixes never run.
41
+ */
42
+ function batchViolations(source, violations) {
43
+ const parents = violations.map((_violation, index) => index);
44
+ const find = (index) => {
45
+ let current = index;
46
+ while (parents[current] !== current) {
47
+ parents[current] = parents[parents[current]];
48
+ current = parents[current];
49
+ }
50
+ return current;
51
+ };
52
+ const union = (left, right) => {
53
+ const rootLeft = find(left);
54
+ const rootRight = find(right);
55
+ if (rootLeft !== rootRight) {
56
+ parents[rootRight] = rootLeft;
57
+ }
58
+ };
59
+ const ownerOf = (use) => violations.findIndex(({ removal }) => use[0] >= removal[0] && use[1] <= removal[1]);
60
+ for (const { uses } of (0, importRemoval_1.bindingUses)(source)) {
61
+ // A single use cannot be shared, so the one-key judgement already covers it.
62
+ if (uses.length < 2)
63
+ continue;
64
+ const owners = new Set();
65
+ const escapes = uses.some((use) => {
66
+ const owner = ownerOf(use);
67
+ if (owner === -1)
68
+ return true;
69
+ owners.add(owner);
70
+ return false;
71
+ });
72
+ // A use outside every removal keeps the binding alive whatever these fixes
73
+ // do, so its readers owe each other nothing.
74
+ if (escapes || owners.size < 2)
75
+ continue;
76
+ const [first, ...rest] = [...owners];
77
+ for (const other of rest) {
78
+ union(first, other);
79
+ }
80
+ }
81
+ const groups = new Map();
82
+ violations.forEach((violation, index) => {
83
+ const root = find(index);
84
+ const group = groups.get(root);
85
+ if (group) {
86
+ group.push(violation);
87
+ }
88
+ else {
89
+ groups.set(root, [violation]);
90
+ }
91
+ });
92
+ return [...groups.values()];
93
+ }
94
+ /**
95
+ * The ranges one fix deletes for `batch`: the keys themselves plus any import
96
+ * they were the last consumers of. `null` when a binding the removal orphans
97
+ * cannot be unbound safely — a `map` callback parameter read by nothing else, a
98
+ * local helper, an import behind a directive comment. The caller then drops the
99
+ * fix and keeps the report, because emitting the half that strips the last use
100
+ * while leaving the binding behind turns a clean file into one that fails
101
+ * `no-unused-vars`.
102
+ */
103
+ function planBatch(source, batch) {
104
+ const removals = batch.map((violation) => violation.removal);
105
+ const cleanups = (0, importRemoval_1.planOrphanedImportRemoval)(source, removals);
106
+ return cleanups ? [...removals, ...cleanups] : null;
107
+ }
5
108
  const getJSXElementName = (name) => {
6
109
  if (name.type === 'JSXIdentifier') {
7
110
  return name.name;
@@ -34,12 +137,26 @@ exports.keyOnlyOutermostElement = (0, createRule_1.createRule)({
34
137
  },
35
138
  defaultOptions: [],
36
139
  create(context) {
140
+ const sourceCode = context.sourceCode;
37
141
  // Track JSXElements that are direct children of a map callback
38
142
  const mapCallbackElements = new Set();
39
143
  // Track JSXFragments that are direct children of a map callback
40
144
  const mapCallbackFragments = new Set();
41
145
  // Track JSX attributes that have already been reported to avoid duplicate reports
42
146
  const reportedAttributes = new Set();
147
+ /**
148
+ * Nested keys held until `Program:exit`. Whether removing one strands a
149
+ * binding is a whole-file question, and the answer can only be given once
150
+ * every key that reads that binding is known.
151
+ */
152
+ const violations = [];
153
+ /**
154
+ * Whether ESLint will discard a report, resolved the way ESLint resolves it.
155
+ * A batched fix counts on every removal in its batch happening; a suppressed
156
+ * report never fixes, so its key — and the reference it holds — outlives the
157
+ * pass and must not be counted on.
158
+ */
159
+ const isSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
43
160
  // Helper function to process map calls
44
161
  const processMapCall = (node) => {
45
162
  // Get the callback function
@@ -119,27 +236,10 @@ exports.keyOnlyOutermostElement = (0, createRule_1.createRule)({
119
236
  if (isNestedInMapCallback) {
120
237
  // Mark this attribute as reported to avoid duplicate reports
121
238
  reportedAttributes.add(attr);
122
- const sourceCode = context.sourceCode;
123
- context.report({
124
- node: attr,
125
- messageId: 'keyOnlyOutermostElement',
126
- data: {
127
- elementName: getJSXElementName(openingElement.name),
128
- },
129
- fix(fixer) {
130
- // Find the exact range of the attribute in the source code
131
- const startPos = attr.range[0];
132
- const endPos = attr.range[1];
133
- // Get the text before and after the attribute to check for whitespace
134
- const fullText = sourceCode.getText();
135
- // Check if there's a space after the attribute
136
- let rangeEnd = endPos;
137
- if (rangeEnd < fullText.length &&
138
- fullText[rangeEnd] === ' ') {
139
- rangeEnd++;
140
- }
141
- return fixer.replaceTextRange([startPos, rangeEnd], '');
142
- },
239
+ violations.push({
240
+ attribute: attr,
241
+ elementName: getJSXElementName(openingElement.name),
242
+ removal: removalRangeOf(sourceCode, attr),
143
243
  });
144
244
  }
145
245
  }
@@ -170,6 +270,44 @@ exports.keyOnlyOutermostElement = (0, createRule_1.createRule)({
170
270
  processMapCall(node.right);
171
271
  }
172
272
  },
273
+ /**
274
+ * Emits every held report, each carrying the cleanup its own removal makes
275
+ * necessary.
276
+ *
277
+ * Orphanhood is judged against a single fix's own deletions, never against
278
+ * what sibling reports might also delete: ESLint may discard a sibling
279
+ * (a disable directive, a fix that collided with another rule's), and the
280
+ * key it was going to remove then keeps the binding alive. Keys that
281
+ * jointly hold a binding alive are therefore removed by one fix, which
282
+ * deletes all of them itself rather than assuming its siblings land.
283
+ */
284
+ 'Program:exit'() {
285
+ if (violations.length === 0)
286
+ return;
287
+ const fixable = violations.filter((violation) => !isSuppressed(violation.attribute));
288
+ const plans = new Map();
289
+ for (const batch of batchViolations(sourceCode, fixable)) {
290
+ const plan = planBatch(sourceCode, batch);
291
+ if (!plan)
292
+ continue;
293
+ for (const violation of batch) {
294
+ plans.set(violation, plan);
295
+ }
296
+ }
297
+ for (const violation of violations) {
298
+ const plan = plans.get(violation);
299
+ context.report({
300
+ node: violation.attribute,
301
+ messageId: 'keyOnlyOutermostElement',
302
+ data: { elementName: violation.elementName },
303
+ ...(plan
304
+ ? {
305
+ fix: (fixer) => plan.map((range) => fixer.removeRange([range[0], range[1]])),
306
+ }
307
+ : {}),
308
+ });
309
+ }
310
+ },
173
311
  };
174
312
  },
175
313
  });
@@ -828,12 +828,31 @@ function truncateWithEllipsis(text, max = 60) {
828
828
  * handlers with contradictory targets, so the first handler to claim it wins and
829
829
  * the handler call order in `detectViolations` is the tie-break.
830
830
  */
831
- function record(sink, statement, messageId, data, fromIndex, toIndex) {
831
+ function record(sink, statement, messageId, data, fromIndex, toIndex, relocatable = true) {
832
832
  if (sink.flagged.has(statement)) {
833
833
  return;
834
834
  }
835
835
  sink.flagged.add(statement);
836
- sink.violations.push({ statement, messageId, data, fromIndex, toIndex });
836
+ sink.violations.push({
837
+ statement,
838
+ messageId,
839
+ data,
840
+ fromIndex,
841
+ toIndex,
842
+ relocatable,
843
+ });
844
+ }
845
+ /**
846
+ * The violations the reordering search is allowed to expand into moves, and the
847
+ * only ones its zero-violation goal test answers about.
848
+ *
849
+ * Screening here rather than at detection is what keeps the fix path identical to
850
+ * one that never saw an unrelocatable violation: the search's candidate moves, its
851
+ * budget and the order it certifies clean are all computed as if the report were
852
+ * absent, so adding a report can never redirect, weaken or block a fix.
853
+ */
854
+ function relocatableViolations(violations) {
855
+ return violations.filter((violation) => violation.relocatable);
837
856
  }
838
857
  function isGuardIfStatement(statement) {
839
858
  if (statement.type !== utils_1.AST_NODE_TYPES.IfStatement || statement.alternate) {
@@ -1058,23 +1077,58 @@ function unwrappedInitOf(declarator) {
1058
1077
  *
1059
1078
  * The classification runs on the unwrapped initializer: an assertion is erased
1060
1079
  * before the code runs, so `1 as const` is exactly the movable literal `1` is.
1080
+ *
1081
+ * Every declarator must qualify, because the statement is diagnosed — and moved —
1082
+ * as a whole. A sibling binding does not disqualify the statement: how many
1083
+ * bindings a declaration introduces decides whether the fix may relocate it, not
1084
+ * whether the declaration is far from the first use of what it declares (#1889).
1061
1085
  */
1062
- function lateDeclarationCandidateOf(statement) {
1086
+ function lateDeclarationDeclaratorsOf(statement) {
1063
1087
  const declaration = variableDeclarationOf(statement);
1064
- if (!declaration || declaration.declarations.length !== 1) {
1088
+ if (!declaration || declaration.declarations.length === 0) {
1065
1089
  return null;
1066
1090
  }
1067
- const [declarator] = declaration.declarations;
1068
- if (declarator.id.type !== utils_1.AST_NODE_TYPES.Identifier) {
1069
- return null;
1091
+ const declarators = [];
1092
+ for (const declarator of declaration.declarations) {
1093
+ if (declarator.id.type !== utils_1.AST_NODE_TYPES.Identifier) {
1094
+ return null;
1095
+ }
1096
+ const init = unwrappedInitOf(declarator);
1097
+ if (init &&
1098
+ init.type !== utils_1.AST_NODE_TYPES.Identifier &&
1099
+ init.type !== utils_1.AST_NODE_TYPES.Literal) {
1100
+ return null;
1101
+ }
1102
+ declarators.push(declarator);
1070
1103
  }
1071
- const init = unwrappedInitOf(declarator);
1072
- if (init &&
1073
- init.type !== utils_1.AST_NODE_TYPES.Identifier &&
1074
- init.type !== utils_1.AST_NODE_TYPES.Literal) {
1075
- return null;
1104
+ return declarators;
1105
+ }
1106
+ /**
1107
+ * Whether the reordering fix may relocate this statement to satisfy a
1108
+ * late-declaration report.
1109
+ *
1110
+ * The fix moves whole statements, so relocating `const x = 1, y = 2;` carries `y`
1111
+ * along — past its own first use in the general case, and always further than the
1112
+ * report asked for. Splitting the declaration is the developer's call, so a
1113
+ * declaration with sibling bindings is reported and left where it stands.
1114
+ */
1115
+ function isRelocatableLateDeclaration(statement) {
1116
+ return lateDeclarationDeclaratorsOf(statement)?.length === 1;
1117
+ }
1118
+ /**
1119
+ * The binding the report names: the one whose own first use is the group's.
1120
+ *
1121
+ * A statement's bindings share its position, so the declaration is late relative to
1122
+ * whichever of them is read first; naming a sibling read later would point the
1123
+ * reader at the wrong line.
1124
+ */
1125
+ function earliestUsedDeclarator(declarators, body, afterIndex, usageIndex) {
1126
+ if (declarators.length === 1) {
1127
+ return declarators[0];
1076
1128
  }
1077
- return declarator;
1129
+ const earliest = declarators.find((declarator) => findFirstUsageIndex(body, new Set([declarator.id.name]), afterIndex) ===
1130
+ usageIndex);
1131
+ return earliest ?? declarators[0];
1078
1132
  }
1079
1133
  const LOOP_TYPES = new Set([
1080
1134
  utils_1.AST_NODE_TYPES.ForStatement,
@@ -1110,17 +1164,18 @@ function isMutatedInLoop(body, usageIndex, nameSet) {
1110
1164
  }
1111
1165
  function handleLateDeclarations(sink, body) {
1112
1166
  body.forEach((statement, index) => {
1113
- const declarator = lateDeclarationCandidateOf(statement);
1114
- if (!declarator) {
1167
+ const declarators = lateDeclarationDeclaratorsOf(statement);
1168
+ if (!declarators) {
1115
1169
  return;
1116
1170
  }
1117
- const name = declarator.id.name;
1118
1171
  const dependencies = new Set();
1119
- const init = unwrappedInitOf(declarator);
1120
- if (init && init.type === utils_1.AST_NODE_TYPES.Identifier) {
1121
- dependencies.add(init.name);
1122
- }
1123
- const nameSet = new Set([name]);
1172
+ declarators.forEach((declarator) => {
1173
+ const init = unwrappedInitOf(declarator);
1174
+ if (init && init.type === utils_1.AST_NODE_TYPES.Identifier) {
1175
+ dependencies.add(init.name);
1176
+ }
1177
+ });
1178
+ const nameSet = new Set(declarators.map((declarator) => declarator.id.name));
1124
1179
  const usageIndex = findFirstUsageIndex(body, nameSet, index + 1);
1125
1180
  if (usageIndex === -1 || usageIndex <= index + 1) {
1126
1181
  return;
@@ -1140,7 +1195,9 @@ function handleLateDeclarations(sink, body) {
1140
1195
  // Do not hop over another declaration that is used at the same index or earlier
1141
1196
  // if it is also a candidate for being moved.
1142
1197
  // This prevents circular swapping of related declarations (like resolve/reject pairs).
1143
- if (lateDeclarationCandidateOf(stmt)) {
1198
+ // The test asks about *relocation*, so a declaration the fix will never move
1199
+ // is not one of the two ends of such a swap and does not block the hop.
1200
+ if (isRelocatableLateDeclaration(stmt)) {
1144
1201
  const declaredNames = getDeclaredNames(stmt);
1145
1202
  const firstUsageOfIntervening = findFirstUsageIndex(body, declaredNames, index + 1 + i + 1);
1146
1203
  if (firstUsageOfIntervening !== -1 &&
@@ -1163,7 +1220,11 @@ function handleLateDeclarations(sink, body) {
1163
1220
  if (crossesImpureOrTracked) {
1164
1221
  return;
1165
1222
  }
1166
- record(sink, statement, 'moveDeclarationCloser', { name }, index, usageIndex);
1223
+ const subject = earliestUsedDeclarator(declarators, body, index + 1, usageIndex);
1224
+ // The same test `isRelocatableLateDeclaration` applies to an intervening
1225
+ // statement, read off the declarators already in hand.
1226
+ const relocatable = declarators.length === 1;
1227
+ record(sink, statement, 'moveDeclarationCloser', { name: subject.id.name }, index, usageIndex, relocatable);
1167
1228
  });
1168
1229
  }
1169
1230
  /**
@@ -1655,8 +1716,12 @@ function orderKey(order, indices) {
1655
1716
  return order.map((statement) => indices.get(statement)).join(',');
1656
1717
  }
1657
1718
  /**
1658
- * Shortest sequence of moves reaching an order with **zero** violations, or null when
1659
- * the search bounds contain no such order.
1719
+ * Shortest sequence of moves reaching an order with **zero** relocatable violations,
1720
+ * or null when the search bounds contain no such order.
1721
+ *
1722
+ * `violations` is pre-screened by `relocatableViolations`, and so is every detection
1723
+ * the search performs: a violation whose statement the fix may not move is neither a
1724
+ * candidate move nor a reason to reject an order, since no reordering can answer it.
1660
1725
  *
1661
1726
  * Breadth-first for two reasons: the emitted fix is then the smallest reordering that
1662
1727
  * satisfies every constraint, and a block whose single named move already suffices
@@ -1706,7 +1771,7 @@ function findResolvingMoves(sourceCode, body, violations, maxMoves) {
1706
1771
  }
1707
1772
  seen.add(key);
1708
1773
  budget -= 1;
1709
- const next = detectViolations(sourceCode, order);
1774
+ const next = relocatableViolations(detectViolations(sourceCode, order));
1710
1775
  const moves = [...node.moves, { fromIndex, toIndex }];
1711
1776
  if (next.length === 0) {
1712
1777
  return moves;
@@ -1765,10 +1830,15 @@ function buildReorderFix(body, moves, parent, sourceCode, fixer) {
1765
1830
  return fixer.replaceTextRange([bounds[first], bounds[last + 1]], reordered.slice(first, last + 1).join(''));
1766
1831
  }
1767
1832
  /**
1768
- * A fix is emitted only for a reordering the detector scores at zero violations, and
1769
- * the whole reordering ships as a single fix. Relocating one statement per report
1770
- * satisfies its own adjacency constraint while breaking another's, which under
1771
- * `--fix` oscillates or exhausts the pass budget (#1405).
1833
+ * A fix is emitted only for a reordering the detector scores at zero relocatable
1834
+ * violations, and the whole reordering ships as a single fix. Relocating one
1835
+ * statement per report satisfies its own adjacency constraint while breaking
1836
+ * another's, which under `--fix` oscillates or exhausts the pass budget (#1405).
1837
+ *
1838
+ * A violation no reordering may answer — a declaration whose sibling bindings the
1839
+ * fix would drag along — is reported and otherwise invisible here: it does not
1840
+ * carry the fix, does not veto one, and cannot keep the block from settling, since
1841
+ * the fixed text scores it exactly as the input did.
1772
1842
  *
1773
1843
  * Convergence is structural rather than argued: the emitted order is verified clean
1774
1844
  * by the same detector that produced the reports, and detection depends only on
@@ -1786,13 +1856,14 @@ function handleBlock(ruleContext, node) {
1786
1856
  if (violations.length === 0) {
1787
1857
  return;
1788
1858
  }
1789
- const moves = findResolvingMoves(sourceCode, body, violations, searchDepthFor(violations.length, body.length));
1790
- violations.forEach((violation, index) => {
1859
+ const relocatable = relocatableViolations(violations);
1860
+ const moves = findResolvingMoves(sourceCode, body, relocatable, searchDepthFor(relocatable.length, body.length));
1861
+ violations.forEach((violation) => {
1791
1862
  context.report({
1792
1863
  node: violation.statement,
1793
1864
  messageId: violation.messageId,
1794
1865
  data: violation.data,
1795
- fix: moves && index === 0
1866
+ fix: moves && violation === relocatable[0]
1796
1867
  ? (fixer) => buildReorderFix(body, moves, node, sourceCode, fixer)
1797
1868
  : null,
1798
1869
  });
@@ -5,6 +5,7 @@ const utils_1 = require("@typescript-eslint/utils");
5
5
  const minimatch_1 = require("minimatch");
6
6
  const ASTHelpers_1 = require("../utils/ASTHelpers");
7
7
  const createRule_1 = require("../utils/createRule");
8
+ const disableDirectives_1 = require("../utils/disableDirectives");
8
9
  const importRemoval_1 = require("../utils/importRemoval");
9
10
  const DEFAULT_TEST_PATTERNS = ['**/__tests__/**', '**/*.test.*', '**/*.spec.*'];
10
11
  const PREFER_UTILITY_FUNCTION_MESSAGE = [
@@ -464,35 +465,10 @@ function dedentedCallbackText(sourceCode, callback, hoistTarget) {
464
465
  })
465
466
  .join('\n');
466
467
  }
467
- /**
468
- * The imports the hoist leaves bound to nothing, or `null` when none can be
469
- * unbound safely.
470
- *
471
- * The hoist is not a deletion: the declared identifier and the callback text
472
- * are re-emitted at module scope, so every reference inside them outlives the
473
- * fix. Only the wrapper disappears — the `const` keyword, the ` = hook(`
474
- * between the identifier and the callback, and the trailing `, []);`. Orphan-
475
- * hood is judged against those three slices alone, which keeps a hook call
476
- * nested in the callback body counted as a live reference (unwrapping the
477
- * outer call carries the inner one along verbatim).
478
- *
479
- * A member callee (`React.useCallback`) is left alone. Its object is usually
480
- * the JSX pragma, and under the classic runtime JSX consumes that binding
481
- * through a transform no scope analysis records — a use this cannot see, so it
482
- * must not delete it.
483
- */
484
- function planHoistImportRemoval(sourceCode, callExpression, declarator, callback, idRangeEnd, removal) {
485
- if (callExpression.callee.type !== utils_1.AST_NODE_TYPES.Identifier) {
486
- return null;
487
- }
488
- const deleted = [
489
- [removal[0], declarator.id.range[0]],
490
- [idRangeEnd, callback.range[0]],
491
- [callback.range[1], removal[1]],
492
- ];
493
- return (0, importRemoval_1.planOrphanedImportRemoval)(sourceCode, deleted);
468
+ function rangesOverlap(a, b) {
469
+ return a[0] < b[1] && b[0] < a[1];
494
470
  }
495
- function buildHoistFixes(context, callExpression, callback, hoistedIdentifierCache) {
471
+ function buildHoistPlan(context, callExpression, callback, hoistedIdentifierCache) {
496
472
  if (!callExpression.parent ||
497
473
  callExpression.parent.type !== utils_1.AST_NODE_TYPES.VariableDeclarator) {
498
474
  return null;
@@ -565,17 +541,18 @@ function buildHoistFixes(context, callExpression, callback, hoistedIdentifierCac
565
541
  if (hasOnlyIndentBefore && hasOnlyWhitespaceAfter && lineEnd !== -1) {
566
542
  removeEnd = lineEnd + 1;
567
543
  }
568
- // The hook import the call was the last consumer of goes with the hoist, in
569
- // the same fix: applying the hoist alone trades this rule's report for an
570
- // unused-import one, and nothing re-reports that debt once the hoist has
571
- // resolved the original violation. An unremovable binding costs only the
572
- // unused import — the hoist is the fix's value and is kept regardless.
573
- const importRanges = planHoistImportRemoval(sourceCode, callExpression, declarator, callback, idRangeEnd, [removeStart, removeEnd]) ?? [];
574
- return (fixer) => [
575
- fixer.insertTextBefore(hoistTarget, hoisted),
576
- fixer.removeRange([removeStart, removeEnd]),
577
- ...importRanges.map((range) => fixer.removeRange([range[0], range[1]])),
578
- ];
544
+ return {
545
+ edits: [
546
+ { range: [hoistTarget.range[0], hoistTarget.range[0]], text: hoisted },
547
+ { range: [removeStart, removeEnd], text: '' },
548
+ ],
549
+ removed: [
550
+ [removeStart, declarator.id.range[0]],
551
+ [idRangeEnd, callback.range[0]],
552
+ [callback.range[1], removeEnd],
553
+ ],
554
+ span: [hoistTarget.range[0], removeEnd],
555
+ };
579
556
  }
580
557
  exports.noEmptyDependencyUseCallbacks = (0, createRule_1.createRule)({
581
558
  name: 'no-empty-dependency-use-callbacks',
@@ -613,8 +590,68 @@ exports.noEmptyDependencyUseCallbacks = (0, createRule_1.createRule)({
613
590
  const testPatterns = options?.testFilePatterns ?? DEFAULT_TEST_PATTERNS;
614
591
  const ignoreUseLatestCallback = options?.ignoreUseLatestCallback === true;
615
592
  const hoistedIdentifierCache = new WeakMap();
593
+ const sourceCode = context.getSourceCode();
616
594
  const filename = context.getFilename();
617
595
  const isTest = ignoreTestFiles && filenameMatchesPatterns(filename, testPatterns);
596
+ /**
597
+ * Every hoistable callback the rule finds, in traversal order.
598
+ *
599
+ * Reporting is deferred to `Program:exit` because the binding the call
600
+ * reads — the `useCallback` specifier, or the `React` default import behind
601
+ * `React.useCallback` — is unbound only once no surviving call references
602
+ * it. Judged one call at a time, a file with two hoistable callbacks never
603
+ * sees either as the binding's last use, and the pass that hoists both
604
+ * resolves every report — so nothing ever revisits the stranded import.
605
+ */
606
+ const violations = [];
607
+ /**
608
+ * A suppressed report is dropped together with its fix, so its hoist never
609
+ * happens: counting it toward the batch would unbind an import the
610
+ * surviving call still reads.
611
+ */
612
+ const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
613
+ /**
614
+ * The hoists that actually ship.
615
+ *
616
+ * Each is screened alone before it joins the batch: a hoist whose own
617
+ * deletion orphans something that cannot be unbound safely — a local
618
+ * variable, an import behind a directive comment — would otherwise poison
619
+ * every other hoist in the file.
620
+ *
621
+ * Candidates are then ordered and screened for collisions exactly as ESLint
622
+ * orders and screens competing reports, so batching cannot change which of
623
+ * two colliding hoists lands. A nested pair collides: both insert at the
624
+ * same module-scope offset, but the inner declaration's span ends first, so
625
+ * the inner hoist wins and the outer one waits for a later pass — hoisting
626
+ * the outer instead would relocate the inner hook call to module scope.
627
+ * Sibling hoists in one component share only that zero-width insertion
628
+ * point, so both ship together.
629
+ */
630
+ function planViolations() {
631
+ const candidates = [];
632
+ for (const violation of violations) {
633
+ if (isReportSuppressed(violation.node))
634
+ continue;
635
+ const plan = buildHoistPlan(context, violation.node, violation.callback, hoistedIdentifierCache);
636
+ if (!plan)
637
+ continue;
638
+ if ((0, importRemoval_1.planOrphanedImportRemoval)(sourceCode, plan.removed) === null) {
639
+ continue;
640
+ }
641
+ candidates.push({ violation, ...plan });
642
+ }
643
+ candidates.sort((left, right) => left.span[0] - right.span[0] || left.span[1] - right.span[1]);
644
+ const planned = [];
645
+ const claimed = [];
646
+ for (const candidate of candidates) {
647
+ if (claimed.some((taken) => candidate.edits.some((edit) => rangesOverlap(edit.range, taken)))) {
648
+ continue;
649
+ }
650
+ claimed.push(...candidate.edits.map((edit) => edit.range));
651
+ planned.push(candidate);
652
+ }
653
+ return planned;
654
+ }
618
655
  function reportIfStaticCallback(callExpression, messageId) {
619
656
  const callee = callExpression.callee;
620
657
  if (callee.type !== utils_1.AST_NODE_TYPES.Identifier &&
@@ -648,12 +685,11 @@ exports.noEmptyDependencyUseCallbacks = (0, createRule_1.createRule)({
648
685
  callExpression.parent.id.type === utils_1.AST_NODE_TYPES.Identifier
649
686
  ? callExpression.parent.id.name
650
687
  : 'this callback';
651
- const fix = buildHoistFixes(context, callExpression, callback, hoistedIdentifierCache);
652
- context.report({
688
+ violations.push({
653
689
  node: callExpression,
690
+ callback,
654
691
  messageId,
655
- data: { name: callbackName },
656
- fix,
692
+ name: callbackName,
657
693
  });
658
694
  }
659
695
  return {
@@ -678,6 +714,43 @@ exports.noEmptyDependencyUseCallbacks = (0, createRule_1.createRule)({
678
714
  reportIfStaticCallback(node, 'preferUtilityLatest');
679
715
  }
680
716
  },
717
+ 'Program:exit'() {
718
+ if (violations.length === 0)
719
+ return;
720
+ const planned = planViolations();
721
+ // One plan over every surviving hoist: the binding behind the callee is
722
+ // left unreferenced by their union even when no single hoist strips its
723
+ // last use, and the pass that applies them all resolves every report —
724
+ // so this is the only moment the stranded import is visible.
725
+ const importRemoval = planned.length > 0
726
+ ? (0, importRemoval_1.planOrphanedImportRemoval)(sourceCode, planned.flatMap((entry) => entry.removed))
727
+ : null;
728
+ // The whole batch ships as one fix, so no hoist can land without the
729
+ // others the import's orphanhood was judged against, and no unbinding
730
+ // can land without the hoist it was claimed on. The other violations
731
+ // report without a fixer; the carrier's pass already resolves them.
732
+ //
733
+ // No plan at all means some binding would be left unreferenced yet
734
+ // cannot be unbound safely, so every hoist stays behind: reports
735
+ // without a fixer are the lesser damage. Hoisting anyway would trade
736
+ // this rule's report for an unused-import one, and nothing re-reports
737
+ // that debt once the hoist has resolved the original violation.
738
+ const removalRanges = importRemoval ?? [];
739
+ const carrier = importRemoval ? planned[0] : undefined;
740
+ for (const violation of violations) {
741
+ context.report({
742
+ node: violation.node,
743
+ messageId: violation.messageId,
744
+ data: { name: violation.name },
745
+ fix: violation === carrier?.violation
746
+ ? (fixer) => [
747
+ ...removalRanges.map((range) => fixer.removeRange([range[0], range[1]])),
748
+ ...planned.flatMap((entry) => entry.edits.map((edit) => fixer.replaceTextRange([edit.range[0], edit.range[1]], edit.text))),
749
+ ]
750
+ : undefined,
751
+ });
752
+ }
753
+ },
681
754
  };
682
755
  },
683
756
  });