@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
@@ -5,6 +5,8 @@ const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
6
  const disableDirectives_1 = require("../utils/disableDirectives");
7
7
  const importRemoval_1 = require("../utils/importRemoval");
8
+ const typeDeclarationRemoval_1 = require("../utils/typeDeclarationRemoval");
9
+ const replacementSegments_1 = require("../utils/replacementSegments");
8
10
  const lexicalScope_1 = require("../utils/lexicalScope");
9
11
  const defaultOptions = {
10
12
  allowRecursiveFunctions: true,
@@ -716,12 +718,17 @@ function containsRange(outer, inner) {
716
718
  /**
717
719
  * Partitions annotations into the sets whose removals have to travel together.
718
720
  *
719
- * An import read from two annotations is unbound only once both are gone, so
721
+ * A binding read from two annotations is unbound only once both are gone, so
720
722
  * neither annotation may unbind it alone — and a fix may only count on the other
721
723
  * removal happening if it performs that removal itself. Annotations that jointly
722
- * keep an import alive are therefore merged into one batch, and every other
724
+ * keep a binding alive are therefore merged into one batch, and every other
723
725
  * annotation is a batch of one, judged against the file as it stands.
724
726
  *
727
+ * EVERY binding is asked about, not only the imported ones. Partitioning over
728
+ * imports alone left a local `type` alias named by two annotations owned by no
729
+ * fix: each annotation looked innocent alone, both were stripped, and the alias
730
+ * survived with nothing referencing it (#1902).
731
+ *
725
732
  * `candidates` must already exclude suppressed reports: a suppressed report's
726
733
  * fix never runs, so its annotation — and the reference it holds — outlives the
727
734
  * pass.
@@ -736,13 +743,13 @@ function batchAnnotations(source, candidates) {
736
743
  return root;
737
744
  };
738
745
  const ownerOf = (reference) => candidates.findIndex((candidate) => containsRange(candidate.returnType.range, reference));
739
- for (const binding of (0, importRemoval_1.importBindingReferences)(source)) {
746
+ for (const binding of (0, importRemoval_1.bindingUses)(source)) {
740
747
  // A single reference cannot be shared, so the one-annotation judgement
741
748
  // already covers it.
742
- if (binding.references.length < 2)
749
+ if (binding.uses.length < 2)
743
750
  continue;
744
751
  const owners = new Set();
745
- const escapes = binding.references.some((reference) => {
752
+ const escapes = binding.uses.some((reference) => {
746
753
  const owner = ownerOf(reference);
747
754
  if (owner === -1)
748
755
  return true;
@@ -774,15 +781,110 @@ function batchAnnotations(source, candidates) {
774
781
  return [...batches.values()];
775
782
  }
776
783
  /**
777
- * The ranges a single fix deletes for `batch`: the annotations themselves plus
778
- * any import they were the last consumers of. `null` when a binding the removal
784
+ * A comment whose meaning is tied to where it sits. Re-emitting one somewhere
785
+ * else retargets it — a disable directive lands on an unrelated line and a
786
+ * `@ts-expect-error` becomes an error of its own — so a removal that would move
787
+ * one is withheld instead.
788
+ */
789
+ function isPositionalDirective(comment) {
790
+ if ((0, disableDirectives_1.parseDisableDirectives)([comment]).length > 0) {
791
+ return true;
792
+ }
793
+ const value = comment.value.trim();
794
+ return value.startsWith('@ts-expect-error') || value.startsWith('@ts-ignore');
795
+ }
796
+ /** The indentation of the line `offset` sits on, for a carried line break. */
797
+ function indentAt(source, offset) {
798
+ const lineStart = source.text.lastIndexOf('\n', offset - 1) + 1;
799
+ const [indent] = /^[ \t]*/.exec(source.text.slice(lineStart, offset)) ?? [''];
800
+ return indent;
801
+ }
802
+ /**
803
+ * Whether the span deletes a whole declaration of the program.
804
+ *
805
+ * Such a span owns the comments inside it: they describe the declaration that is
806
+ * going away, and re-emitting them would leave prose behind with no subject. The
807
+ * spans that reach across a specifier list are the opposite case — the
808
+ * declaration survives them, so a comment they cover has to survive too.
809
+ */
810
+ function removesWholeStatement(source, range) {
811
+ return source.ast.body.some((statement) => statement.range[0] >= range[0] && statement.range[1] <= range[1]);
812
+ }
813
+ /**
814
+ * What a removal range is replaced with: nothing, or the comments it covers
815
+ * re-emitted so the deletion carries them instead of dropping them. `null`
816
+ * withholds the fix, for a comment whose meaning is its position.
817
+ *
818
+ * The separators around the carried run are chosen from the text on either side
819
+ * rather than added unconditionally, so a range that already sits between
820
+ * whitespace does not gain any. A trailing line break is mandatory where the
821
+ * last comment is a line comment or the range consumed its own newline: without
822
+ * one the surviving code moves onto the comment's line and is commented out.
823
+ */
824
+ function carriedText(source, range) {
825
+ const comments = source
826
+ .getAllComments()
827
+ .filter((comment) => comment.range[0] >= range[0] && comment.range[1] <= range[1]);
828
+ if (comments.length === 0)
829
+ return '';
830
+ if (comments.some(isPositionalDirective))
831
+ return null;
832
+ const indent = indentAt(source, range[0]);
833
+ const segments = comments.map((comment) => ({
834
+ text: source.text.slice(comment.range[0], comment.range[1]),
835
+ breakAfter: (0, replacementSegments_1.requiresLineBreakAfter)(comment),
836
+ }));
837
+ const body = (0, replacementSegments_1.joinSegmentBody)(segments, indent);
838
+ const before = range[0] > 0 ? source.text[range[0] - 1] : '';
839
+ const after = source.text[range[1]] ?? '';
840
+ const lead = before === '' || /\s/.test(before) ? '' : ' ';
841
+ const trail = segments[segments.length - 1].breakAfter ||
842
+ source.text.slice(range[0], range[1]).endsWith('\n')
843
+ ? `\n${indent}`
844
+ : after === '' || /\s/.test(after)
845
+ ? ''
846
+ : ' ';
847
+ return `${lead}${body}${trail}`;
848
+ }
849
+ /**
850
+ * ESLint applies a fix whole or not at all, and rejects one whose edits
851
+ * overlap. Two spans planned independently — an annotation and the declaration
852
+ * that strands it — can only overlap if a premise here is wrong, so an overlap
853
+ * withdraws the fix rather than throwing at apply time.
854
+ */
855
+ function isDisjoint(edits) {
856
+ const sorted = [...edits].sort((left, right) => left.range[0] - right.range[0]);
857
+ return sorted.every((edit, index) => index === 0 || sorted[index - 1].range[1] <= edit.range[0]);
858
+ }
859
+ /**
860
+ * The edits a single fix makes for `batch`: the annotations themselves plus the
861
+ * bindings they were the last consumers of. `null` when a binding the removal
779
862
  * orphans cannot be unbound safely — the caller then drops the fix rather than
780
863
  * emitting the half of it that leaves a binding behind.
864
+ *
865
+ * A cleanup span is REPLACED by the comments it covers rather than deleted
866
+ * outright. The planner computes spans that reach across separators, so a
867
+ * comment among the specifiers sits inside one; declining on that comment is no
868
+ * remedy, since it lets a comment decide whether the annotations are stripped at
869
+ * all, which is a comment changing the transform just the same (#1877).
781
870
  */
782
- function planRemoval(source, batch) {
871
+ function planRemoval(source, removalSource, batch) {
783
872
  const annotations = batch.map((entry) => entry.returnType.range);
784
- const imports = (0, importRemoval_1.planOrphanedImportRemoval)(source, annotations);
785
- return imports ? [...annotations, ...imports] : null;
873
+ const cleanups = (0, importRemoval_1.planOrphanedBindingRemoval)(removalSource, annotations, (variables, planned) => (0, typeDeclarationRemoval_1.planTypeDeclarationRemoval)(removalSource, variables, planned));
874
+ if (!cleanups)
875
+ return null;
876
+ const edits = annotations.map((range) => ({ range, text: '' }));
877
+ for (const range of cleanups) {
878
+ if (removesWholeStatement(source, range)) {
879
+ edits.push({ range, text: '' });
880
+ continue;
881
+ }
882
+ const carried = carriedText(source, range);
883
+ if (carried === null)
884
+ return null;
885
+ edits.push({ range, text: carried });
886
+ }
887
+ return isDisjoint(edits) ? edits : null;
786
888
  }
787
889
  exports.noExplicitReturnType = (0, createRule_1.createRule)({
788
890
  name: 'no-explicit-return-type',
@@ -822,6 +924,35 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
822
924
  const sourceCode = context.getSourceCode();
823
925
  const visitorKeys = sourceCode.visitorKeys;
824
926
  const pending = [];
927
+ /**
928
+ * The source the removal planner reads, with the comments inside a
929
+ * declaration hidden from it.
930
+ *
931
+ * `planImportBindingRemoval` declines outright on any comment inside the
932
+ * declaration it is asked to edit, because the spans it computes reach
933
+ * across separators and a comment nested among the specifiers would be
934
+ * swallowed. That decline is not available here: the annotations that
935
+ * jointly name an import are stripped by one fix or not at all, so
936
+ * declining only the import half strands the binding, and declining the
937
+ * whole fix lets a comment decide whether the rule's own transform fires
938
+ * (#1877, #1902).
939
+ *
940
+ * The planner is therefore asked for the spans as if the declaration
941
+ * carried no comments, and {@link carriedText} re-emits every comment
942
+ * those spans cover in place of the text they delete. Only
943
+ * `getCommentsInside` is blinded: `getCommentsBefore` still answers for
944
+ * the directive that binds a whole statement, which is the one shape no
945
+ * re-emission can save.
946
+ */
947
+ const removalSource = {
948
+ text: sourceCode.text,
949
+ ast: sourceCode.ast,
950
+ scopeManager: sourceCode.scopeManager,
951
+ getTokenBefore: sourceCode.getTokenBefore.bind(sourceCode),
952
+ getTokenAfter: sourceCode.getTokenAfter.bind(sourceCode),
953
+ getCommentsBefore: sourceCode.getCommentsBefore.bind(sourceCode),
954
+ getCommentsInside: () => [],
955
+ };
825
956
  /**
826
957
  * Whether ESLint will discard a report, resolved the way ESLint resolves
827
958
  * it. A fix that deletes several annotations at once is counting on all of
@@ -884,9 +1015,9 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
884
1015
  pending.push({ node, returnType, strippable });
885
1016
  }
886
1017
  /**
887
- * Emits every held report, each carrying the import cleanup its own
888
- * removal makes necessary. An annotation and the import it unbinds are one
889
- * fix: applying either half alone leaves the file worse than applying
1018
+ * Emits every held report, each carrying the binding cleanup its own
1019
+ * removal makes necessary. An annotation and the binding it unbinds are
1020
+ * one fix: applying either half alone leaves the file worse than applying
890
1021
  * neither.
891
1022
  *
892
1023
  * Orphanhood is judged against a single fix's own deletions, never against
@@ -899,18 +1030,20 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
899
1030
  * Waiting for the last such annotation does not work either. Once every
900
1031
  * annotation is stripped the rule has nothing left to report, so no later
901
1032
  * fix exists to carry the cleanup and the binding stays orphaned for good
902
- * (issue #1654). The annotations that jointly hold an import alive are
1033
+ * (issue #1654). The annotations that jointly hold a binding alive are
903
1034
  * therefore removed by one fix, which owes two things: it deletes all of
904
1035
  * them itself rather than assuming sibling reports land, and it counts
905
1036
  * only annotations whose reports ESLint will not discard.
906
1037
  */
907
1038
  function flushReports() {
908
1039
  const strippable = pending.filter((entry) => entry.strippable);
909
- // Every annotation starts with the judgement it would get alone, so a
910
- // batch can only ever add a cleanup, never withdraw one.
1040
+ // Each annotation starts with the judgement it would get alone. That
1041
+ // judgement holds only while the annotation is the whole story for every
1042
+ // binding it names; a batch below either extends it to cover the
1043
+ // binding they share, or withdraws it.
911
1044
  const plans = new Map();
912
1045
  for (const entry of strippable) {
913
- const plan = planRemoval(sourceCode, [entry]);
1046
+ const plan = planRemoval(sourceCode, removalSource, [entry]);
914
1047
  if (plan) {
915
1048
  plans.set(entry, plan);
916
1049
  }
@@ -919,11 +1052,21 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
919
1052
  for (const batch of batches) {
920
1053
  if (batch.length < 2)
921
1054
  continue;
922
- const plan = planRemoval(sourceCode, batch);
923
- if (!plan)
1055
+ const plan = planRemoval(sourceCode, removalSource, batch);
1056
+ if (plan) {
1057
+ for (const entry of batch) {
1058
+ plans.set(entry, plan);
1059
+ }
924
1060
  continue;
1061
+ }
1062
+ // The batch exists because its members jointly hold a binding alive,
1063
+ // so falling back to the one-at-a-time strip is not the harmless
1064
+ // status quo it looks like: `--fix` applies those strips in the same
1065
+ // run, the last one leaves the binding referenced by nothing, and no
1066
+ // report survives to clean it up (#1902). Every member gives up its
1067
+ // fix and keeps its report.
925
1068
  for (const entry of batch) {
926
- plans.set(entry, plan);
1069
+ plans.delete(entry);
927
1070
  }
928
1071
  }
929
1072
  for (const entry of pending) {
@@ -936,7 +1079,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
936
1079
  data: { functionKind: describeFunctionKind(entry.node) },
937
1080
  ...(plan
938
1081
  ? {
939
- fix: (fixer) => plan.map((range) => fixer.removeRange([range[0], range[1]])),
1082
+ fix: (fixer) => plan.map((edit) => fixer.replaceTextRange([edit.range[0], edit.range[1]], edit.text)),
940
1083
  }
941
1084
  : {}),
942
1085
  });
@@ -11,8 +11,10 @@ const createRule_1 = require("../utils/createRule");
11
11
  const FIREBASE_ADMIN_MODULE = 'firebaseAdmin';
12
12
  const MODULE_EXTENSION = /\.(?:tsx?|jsx?|mjs|cjs)$/;
13
13
  /**
14
- * Backend tier: `jest.setup.node.js` auto-mocks this module repo-wide, so a
15
- * local `jest.mock()` bypasses the shared stub the rule exists to protect.
14
+ * Backend tier: `jest.setup.node.js` runs a bare
15
+ * `jest.mock('./functions/src/config/firebaseAdmin')`, which activates the
16
+ * manual mock in `functions/src/config/__mocks__/`. A local *factory* replaces
17
+ * that manual mock and is what the rule exists to catch.
16
18
  */
17
19
  const BACKEND_FIREBASE_ADMIN = 'functions/src/config/firebaseAdmin';
18
20
  /**
@@ -35,19 +37,60 @@ const comparisonPathOf = (mockPath, filename) => {
35
37
  }
36
38
  return toPosix(path_1.default.resolve(path_1.default.dirname(filename), mockPath));
37
39
  };
38
- const bypassesSharedMock = (comparisonPath) => {
40
+ const tierOf = (comparisonPath) => {
39
41
  // Backend is tested first because the backend path *contains* the frontend
40
42
  // path as a substring; checking frontend first would exempt every backend
41
43
  // mock and silently disable the rule.
42
44
  if (comparisonPath.includes(BACKEND_FIREBASE_ADMIN)) {
43
- return true;
45
+ return 'backend';
44
46
  }
45
47
  if (comparisonPath.includes(FRONTEND_FIREBASE_ADMIN)) {
46
- return false;
48
+ return 'frontend';
47
49
  }
48
50
  // A specifier attributable to neither tier keeps the rule's protection:
49
51
  // prefer reporting over silently allowing an unknown layout to shadow a
50
52
  // shared mock.
53
+ return 'unknown';
54
+ };
55
+ /**
56
+ * The factory argument, or `undefined` when the call supplies none. An explicit
57
+ * `undefined` placeholder — `jest.mock(path, undefined, { virtual: true })` — is
58
+ * the same call as the one-argument form, so it must read as "no factory" too.
59
+ */
60
+ const factoryOf = (node) => {
61
+ const factory = node.arguments[1];
62
+ if (!factory) {
63
+ return undefined;
64
+ }
65
+ if (factory.type === utils_1.AST_NODE_TYPES.Identifier &&
66
+ factory.name === 'undefined') {
67
+ return undefined;
68
+ }
69
+ return factory;
70
+ };
71
+ /**
72
+ * Whether the call overrides the shared mock rather than re-activating it.
73
+ *
74
+ * A BARE backend `jest.mock()` is byte-for-byte the call `jest.setup.node.js`
75
+ * already makes: it activates the manual mock at
76
+ * `functions/src/config/__mocks__/firebaseAdmin.ts`. It replaces nothing and
77
+ * supplies no divergent state, so the message's rationale simply does not apply
78
+ * — and suites need the local form to obtain per-call spies (`db.doc`,
79
+ * `db.runTransaction`) that the shared `FakeFirestore` instance never exposes.
80
+ * Only a factory substitutes a different module body.
81
+ *
82
+ * The exemption stops at the backend tier on purpose. For an unattributable
83
+ * specifier there is no known `__mocks__` sibling, so a bare call may fall
84
+ * through to Jest's AUTOMOCK — every export replaced by an empty `jest.fn()` —
85
+ * which genuinely is divergent state.
86
+ */
87
+ const overridesSharedMock = (tier, factory) => {
88
+ if (tier === 'frontend') {
89
+ return false;
90
+ }
91
+ if (tier === 'backend') {
92
+ return factory !== undefined;
93
+ }
51
94
  return true;
52
95
  };
53
96
  /**
@@ -126,7 +169,7 @@ exports.noMockFirebaseAdmin = (0, createRule_1.createRule)({
126
169
  },
127
170
  schema: [],
128
171
  messages: {
129
- noMockFirebaseAdmin: 'Do not mock firebaseAdmin module "{{modulePath}}". The project already ships a stable mock in jest.setup.node.js; overriding it creates divergent Firestore/Auth state and brittle test fixtures. Keep the shared mock and use __test-utils__/mockFirestore to seed data instead of replacing the module.',
172
+ noMockFirebaseAdmin: 'Do not override the firebaseAdmin module mock for "{{modulePath}}". jest.setup.node.js already activates the shared manual mock; substituting your own module body creates divergent Firestore/Auth state and brittle test fixtures. Use __test-utils__/mockFirestore to seed data, and where a suite only needs the shared mock active, write a bare jest.mock() of functions/src/config/firebaseAdmin with no factory argument.',
130
173
  },
131
174
  },
132
175
  defaultOptions: [],
@@ -165,10 +208,11 @@ exports.noMockFirebaseAdmin = (0, createRule_1.createRule)({
165
208
  if (!targetsFirebaseAdmin(mockPath)) {
166
209
  return;
167
210
  }
168
- if (!bypassesSharedMock(comparisonPathOf(mockPath, filename))) {
211
+ const tier = tierOf(comparisonPathOf(mockPath, filename));
212
+ const factory = factoryOf(node);
213
+ if (!overridesSharedMock(tier, factory)) {
169
214
  return;
170
215
  }
171
- const factory = node.arguments[1];
172
216
  if (factory && exercisesCollectionGroup(factory)) {
173
217
  return;
174
218
  }
@@ -1,8 +1,9 @@
1
+ import { TSESLint } from '@typescript-eslint/utils';
1
2
  type Options = [
2
3
  {
3
4
  memoizedHookNames?: string[];
4
5
  assumeAllUseAreMemoized?: boolean;
5
6
  }
6
7
  ];
7
- export declare const noRedundantUseCallbackWrapper: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"redundantWrapper", Options, import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
8
+ export declare const noRedundantUseCallbackWrapper: TSESLint.RuleModule<"redundantWrapper", Options, TSESLint.RuleListener>;
8
9
  export default noRedundantUseCallbackWrapper;
@@ -4,6 +4,11 @@ exports.noRedundantUseCallbackWrapper = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
6
  const ASTHelpers_1 = require("../utils/ASTHelpers");
7
+ const disableDirectives_1 = require("../utils/disableDirectives");
8
+ const importRemoval_1 = require("../utils/importRemoval");
9
+ function rangesOverlap(a, b) {
10
+ return a[0] < b[1] && b[0] < a[1];
11
+ }
7
12
  const LATEST_CALLBACK_MODULE = 'use-latest-callback';
8
13
  const LATEST_CALLBACK_HOOK = 'useLatestCallback';
9
14
  const MEMO_HOOK = 'useMemo';
@@ -290,6 +295,86 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
290
295
  // Track identifiers coming from hook-like calls
291
296
  const hookReturnObjects = new Set(); // variables assigned to a hook call result (object or function)
292
297
  const hookReturnProps = new Set(); // properties destructured from a hook call result
298
+ /**
299
+ * Every redundant wrapper the rule finds, in traversal order.
300
+ *
301
+ * Reporting is deferred to `Program:exit` because the wrapper's import is
302
+ * unbound only once no surviving call references it. Judged one call at a
303
+ * time, a file with two wrappers never sees either as the binding's last
304
+ * use, and the pass that collapses both resolves every report — so nothing
305
+ * ever revisits the stranded import.
306
+ */
307
+ const violations = [];
308
+ /**
309
+ * A suppressed report is dropped together with its fix, so its rewrite
310
+ * never happens: counting it toward the batch would unbind an import the
311
+ * surviving text still calls.
312
+ */
313
+ const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
314
+ /**
315
+ * A wrapper collapses to the delegate's bare name, so the delegate is the
316
+ * only text the rewrite keeps. Everything else inside the call — the
317
+ * wrapper's callee, the callback's syntax, the dependency array — is
318
+ * genuinely deleted, and only that may be handed to the orphan planner: a
319
+ * span covering the delegate would read whatever it names as unreferenced
320
+ * and delete that import too, an over-removal strictly worse than the
321
+ * stranded import this exists to prevent.
322
+ */
323
+ const planViolation = (violation, delegate) => ({
324
+ violation,
325
+ edit: { range: violation.node.range, text: delegate.name },
326
+ removed: [
327
+ [violation.node.range[0], delegate.range[0]],
328
+ [delegate.range[1], violation.node.range[1]],
329
+ ],
330
+ });
331
+ /**
332
+ * The rewrites that actually ship, in traversal order.
333
+ *
334
+ * Each is screened alone before it joins the batch: a rewrite whose own
335
+ * deletion orphans something that cannot be unbound safely — a local
336
+ * variable, an import behind a directive comment — would otherwise poison
337
+ * every other rewrite in the file. Edits colliding with an already accepted
338
+ * one are dropped for the same reason a single report's overlapping fixes
339
+ * are: ESLint asserts on the overlap and discards every message for the
340
+ * file. A nested pair collides this way, and the enclosing rewrite wins
341
+ * because it is visited first; the inner one is fixed on a later pass.
342
+ */
343
+ const planViolations = () => {
344
+ const planned = [];
345
+ const claimed = [];
346
+ for (const violation of violations) {
347
+ if (!violation.delegate)
348
+ continue;
349
+ if (isReportSuppressed(violation.node))
350
+ continue;
351
+ const candidate = planViolation(violation, violation.delegate);
352
+ if ((0, importRemoval_1.planOrphanedImportRemoval)(sourceCode, candidate.removed) === null) {
353
+ continue;
354
+ }
355
+ if (claimed.some((taken) => rangesOverlap(candidate.edit.range, taken))) {
356
+ continue;
357
+ }
358
+ claimed.push(candidate.edit.range);
359
+ planned.push(candidate);
360
+ }
361
+ return planned;
362
+ };
363
+ /**
364
+ * Records a redundant wrapper without reporting it yet.
365
+ *
366
+ * A member delegate (`ctx.submit`) is recorded unfixed: rewriting the
367
+ * wrapper to a bare member reference would detach the callback from its
368
+ * receiver and change what `this` binds to.
369
+ */
370
+ const recordViolation = (node, wrapper, delegate) => {
371
+ violations.push({
372
+ node,
373
+ wrapper,
374
+ callbackName: sourceCode.getText(delegate),
375
+ delegate: delegate.type === utils_1.AST_NODE_TYPES.Identifier ? delegate : null,
376
+ });
377
+ };
293
378
  return {
294
379
  ImportDeclaration(node) {
295
380
  // The module's sole export is the hook, so its DEFAULT specifier binds
@@ -364,29 +449,7 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
364
449
  (unwrappedArg.type === utils_1.AST_NODE_TYPES.MemberExpression &&
365
450
  unwrappedArg.object.type === utils_1.AST_NODE_TYPES.Identifier &&
366
451
  hookReturnObjects.has(unwrappedArg.object.name))) {
367
- if (unwrappedArg.type === utils_1.AST_NODE_TYPES.Identifier) {
368
- const replaceText = unwrappedArg.name;
369
- context.report({
370
- node,
371
- messageId: 'redundantWrapper',
372
- data: {
373
- wrapper,
374
- callbackName: sourceCode.getText(unwrappedArg),
375
- },
376
- fix: (fixer) => fixer.replaceText(node, replaceText),
377
- });
378
- }
379
- else {
380
- // Member function — report only, no fix to avoid breaking `this`.
381
- context.report({
382
- node,
383
- messageId: 'redundantWrapper',
384
- data: {
385
- wrapper,
386
- callbackName: sourceCode.getText(unwrappedArg),
387
- },
388
- });
389
- }
452
+ recordViolation(node, wrapper, unwrappedArg);
390
453
  }
391
454
  return;
392
455
  }
@@ -417,29 +480,7 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
417
480
  return;
418
481
  }
419
482
  else {
420
- if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
421
- const replaceText = callee.name;
422
- context.report({
423
- node,
424
- messageId: 'redundantWrapper',
425
- data: {
426
- wrapper,
427
- callbackName: sourceCode.getText(callee),
428
- },
429
- fix: (fixer) => fixer.replaceText(node, replaceText),
430
- });
431
- }
432
- else {
433
- // Member function — report only, no fix to avoid breaking `this`.
434
- context.report({
435
- node,
436
- messageId: 'redundantWrapper',
437
- data: {
438
- wrapper,
439
- callbackName: sourceCode.getText(callee),
440
- },
441
- });
442
- }
483
+ recordViolation(node, wrapper, callee);
443
484
  }
444
485
  }
445
486
  }
@@ -490,30 +531,7 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
490
531
  }
491
532
  else {
492
533
  // No args and trivial wrapper
493
- if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
494
- const replaceText = callee
495
- .name;
496
- context.report({
497
- node,
498
- messageId: 'redundantWrapper',
499
- data: {
500
- wrapper,
501
- callbackName: sourceCode.getText(callee),
502
- },
503
- fix: (fixer) => fixer.replaceText(node, replaceText),
504
- });
505
- }
506
- else {
507
- // Member function — report only, no fix to avoid breaking `this`.
508
- context.report({
509
- node,
510
- messageId: 'redundantWrapper',
511
- data: {
512
- wrapper,
513
- callbackName: sourceCode.getText(callee),
514
- },
515
- });
516
- }
534
+ recordViolation(node, wrapper, callee);
517
535
  }
518
536
  }
519
537
  }
@@ -523,6 +541,44 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
523
541
  }
524
542
  }
525
543
  },
544
+ 'Program:exit'() {
545
+ if (violations.length === 0)
546
+ return;
547
+ const planned = planViolations();
548
+ // One plan over every surviving rewrite: the wrapper's binding is left
549
+ // unreferenced by their union even when no single collapse strips its
550
+ // last use, and the pass that applies them all resolves every report —
551
+ // so this is the only moment the stranded import is visible.
552
+ const importRemoval = planned.length > 0
553
+ ? (0, importRemoval_1.planOrphanedImportRemoval)(sourceCode, planned.flatMap((entry) => entry.removed))
554
+ : null;
555
+ // The whole batch ships as one fix, so no collapse can land without the
556
+ // others the import's orphanhood was judged against, and no unbinding
557
+ // can land without the collapse it was claimed on. The other violations
558
+ // report without a fixer; the carrier's pass already resolves them.
559
+ //
560
+ // No plan at all means some binding would be left unreferenced yet
561
+ // cannot be unbound safely, so every collapse stays behind: reports
562
+ // without a fixer are the lesser damage.
563
+ const removalRanges = importRemoval ?? [];
564
+ const carrier = importRemoval ? planned[0] : undefined;
565
+ for (const violation of violations) {
566
+ context.report({
567
+ node: violation.node,
568
+ messageId: 'redundantWrapper',
569
+ data: {
570
+ wrapper: violation.wrapper,
571
+ callbackName: violation.callbackName,
572
+ },
573
+ fix: violation === carrier?.violation
574
+ ? (fixer) => [
575
+ ...removalRanges.map((range) => fixer.removeRange([range[0], range[1]])),
576
+ ...planned.map((entry) => fixer.replaceTextRange(entry.edit.range, entry.edit.text)),
577
+ ]
578
+ : undefined,
579
+ });
580
+ }
581
+ },
526
582
  };
527
583
  },
528
584
  });