@blumintinc/eslint-plugin-blumint 1.20.195 → 1.20.196

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -223,7 +223,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
223
223
  module.exports = {
224
224
  meta: {
225
225
  name: '@blumintinc/eslint-plugin-blumint',
226
- version: '1.20.195',
226
+ version: '1.20.196',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -5,4 +5,5 @@ import { TSESLint, TSESTree } from '@typescript-eslint/utils';
5
5
  */
6
6
  export declare const noUnusedUseState: TSESLint.RuleModule<"unusedUseState", never[], {
7
7
  VariableDeclarator(node: TSESTree.VariableDeclarator): void;
8
+ 'Program:exit'(): void;
8
9
  }>;
@@ -2,6 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.noUnusedUseState = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
+ const disableDirectives_1 = require("../utils/disableDirectives");
6
+ const importRemoval_1 = require("../utils/importRemoval");
5
7
  const createRule = utils_1.ESLintUtils.RuleCreator((name) => `https://github.com/BluMintInc/eslint-custom-rules/blob/main/docs/rules/${name}.md`);
6
8
  /**
7
9
  * A destructuring binding counts as live when anything other than its own
@@ -11,6 +13,49 @@ const createRule = utils_1.ESLintUtils.RuleCreator((name) => `https://github.com
11
13
  const isBindingReferenced = (variable) => {
12
14
  return variable.references.some((reference) => !reference.init);
13
15
  };
16
+ const isWithinAny = (range, ranges) => ranges.some(([start, end]) => range[0] >= start && range[1] <= end);
17
+ const rangesOverlap = (left, right) => left[0] < right[1] && right[0] < left[1];
18
+ /**
19
+ * The slice a fix deletes to retire `node`, separators included, or `null` when
20
+ * the declaration sits somewhere this rule does not rewrite.
21
+ *
22
+ * The sole declarator of a statement takes the statement with it, up to the next
23
+ * token or comment so the line it occupied does not survive as blank space. One
24
+ * declarator among several takes exactly one separator: the comma after it, plus
25
+ * the whitespace up to the next declarator so no double space is left behind —
26
+ * or the comma before it when it ends the list. A comment stops the removal so
27
+ * it survives the fix.
28
+ */
29
+ const declarationRemovalRange = (sourceCode, node) => {
30
+ const parentStatement = node.parent;
31
+ if (!parentStatement ||
32
+ parentStatement.type !== utils_1.TSESTree.AST_NODE_TYPES.VariableDeclaration) {
33
+ return null;
34
+ }
35
+ if (parentStatement.declarations.length === 1) {
36
+ const nextToken = sourceCode.getTokenAfter(parentStatement, {
37
+ includeComments: true,
38
+ });
39
+ return nextToken
40
+ ? [parentStatement.range[0], nextToken.range[0]]
41
+ : [parentStatement.range[0], parentStatement.range[1]];
42
+ }
43
+ const tokenAfter = sourceCode.getTokenAfter(node);
44
+ if (tokenAfter && tokenAfter.value === ',') {
45
+ const tokenAfterComma = sourceCode.getTokenAfter(tokenAfter, {
46
+ includeComments: true,
47
+ });
48
+ return [
49
+ node.range[0],
50
+ tokenAfterComma ? tokenAfterComma.range[0] : tokenAfter.range[1],
51
+ ];
52
+ }
53
+ const tokenBefore = sourceCode.getTokenBefore(node);
54
+ if (tokenBefore && tokenBefore.value === ',') {
55
+ return [tokenBefore.range[0], node.range[1]];
56
+ }
57
+ return [node.range[0], node.range[1]];
58
+ };
14
59
  /**
15
60
  * Rule to detect and remove unused useState hooks in React components
16
61
  * This rule identifies cases where the state variable from useState is ignored (e.g., replaced with _)
@@ -31,6 +76,66 @@ exports.noUnusedUseState = createRule({
31
76
  },
32
77
  defaultOptions: [],
33
78
  create(context) {
79
+ const sourceCode = context.sourceCode;
80
+ /**
81
+ * Every discarded pair the rule finds, in traversal order.
82
+ *
83
+ * Reporting waits for `Program:exit` because the `useState` import is left
84
+ * unreferenced only once NO surviving call mentions it. Judged one
85
+ * declaration at a time, a file with two dead pairs never sees either as the
86
+ * import's last use, and the pass that deletes both resolves every report —
87
+ * so nothing ever revisits the stranded import.
88
+ */
89
+ const violations = [];
90
+ /**
91
+ * A suppressed report is discarded together with its fix, so its removal
92
+ * never happens: counting it toward the batch would unbind an import the
93
+ * surviving text still calls.
94
+ */
95
+ const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
96
+ /**
97
+ * The extra deletions that keep `removed` from stranding a binding.
98
+ *
99
+ * Deleting a `useState` declaration strands two kinds of binding. The
100
+ * import is unbound by dropping its specifier, which the shared helper
101
+ * plans. The pattern's own `_` and setter are unbound by the very deletion
102
+ * being planned — their declarations sit inside `removed`, so they need
103
+ * nothing further and the unbinder claims them with an empty plan. Anything
104
+ * else the deletion leaves unreferenced (a `const` read only by the
105
+ * discarded initializer) declines the whole fix: leaving the report standing
106
+ * costs less than trading it for an unused-variable error the fixer resolved
107
+ * out of view.
108
+ */
109
+ const planRemoval = (removed) => (0, importRemoval_1.planOrphanedBindingRemoval)(sourceCode, removed, (variables, ranges) => variables.every((variable) => variable.identifiers.every((identifier) => isWithinAny(identifier.range, ranges)))
110
+ ? []
111
+ : null);
112
+ /**
113
+ * The removals that ship, in traversal order.
114
+ *
115
+ * Each is screened alone before joining the batch: a deletion that strands
116
+ * something unbindable would otherwise withhold every other removal in the
117
+ * file. Overlapping deletions are dropped because ESLint rejects a fix whose
118
+ * own edits collide — two dead declarators of one statement overlap on the
119
+ * separator between them, and the later one is deleted on a following pass.
120
+ */
121
+ const planViolations = () => {
122
+ const planned = [];
123
+ const claimed = [];
124
+ for (const violation of violations) {
125
+ const { removal } = violation;
126
+ if (!removal)
127
+ continue;
128
+ if (isReportSuppressed(violation.node))
129
+ continue;
130
+ if (planRemoval([removal]) === null)
131
+ continue;
132
+ if (claimed.some((taken) => rangesOverlap(removal, taken)))
133
+ continue;
134
+ claimed.push(removal);
135
+ planned.push({ violation, removal });
136
+ }
137
+ return planned;
138
+ };
34
139
  return {
35
140
  // Look for variable declarations that destructure from useState
36
141
  VariableDeclarator(node) {
@@ -60,72 +165,56 @@ exports.noUnusedUseState = createRule({
60
165
  // Every other binding of the pattern (the setter, and any nested
61
166
  // or rest binding) must be dead before the declaration can be
62
167
  // deleted. Removing it while the setter is still called strands
63
- // the call sites and breaks the component.
168
+ // the call sites and breaks the component. A live setter therefore
169
+ // yields a report without a fix.
64
170
  const hasLiveSiblingBinding = declaredVariables.some((variable) => variable !== stateVariable && isBindingReferenced(variable));
65
- context.report({
171
+ violations.push({
66
172
  node,
67
- messageId: 'unusedUseState',
68
- data: {
69
- stateName: stateIdentifier.name,
70
- },
71
- fix: (fixer) => {
72
- // A live setter still needs its declaration, so report the
73
- // discarded value without offering a destructive fix.
74
- if (hasLiveSiblingBinding) {
75
- return null;
76
- }
77
- // Remove the entire useState declaration
78
- const sourceCode = context.sourceCode;
79
- const parentStatement = node.parent;
80
- if (parentStatement &&
81
- parentStatement.type ===
82
- utils_1.TSESTree.AST_NODE_TYPES.VariableDeclaration) {
83
- // If this is the only declarator, remove the entire statement and any extra whitespace
84
- if (parentStatement.declarations.length === 1) {
85
- // Get the next token after the statement to handle whitespace properly
86
- const nextToken = sourceCode.getTokenAfter(parentStatement, { includeComments: true });
87
- if (nextToken) {
88
- // Remove the statement and any whitespace up to the next token
89
- return fixer.removeRange([
90
- parentStatement.range[0],
91
- nextToken.range[0],
92
- ]);
93
- }
94
- return fixer.remove(parentStatement);
95
- }
96
- // Otherwise, just remove this declarator and any trailing comma
97
- const declaratorRange = node.range;
98
- // Check if there's a comma after this declarator
99
- const tokenAfter = sourceCode.getTokenAfter(node);
100
- if (tokenAfter && tokenAfter.value === ',') {
101
- // Consume the separator plus the whitespace before the
102
- // surviving declarator so no double space is left behind.
103
- // Comments stop the removal so they survive the fix.
104
- const tokenAfterComma = sourceCode.getTokenAfter(tokenAfter, { includeComments: true });
105
- return fixer.removeRange([
106
- declaratorRange[0],
107
- tokenAfterComma
108
- ? tokenAfterComma.range[0]
109
- : tokenAfter.range[1],
110
- ]);
111
- }
112
- // Check if there's a comma before this declarator
113
- const tokenBefore = sourceCode.getTokenBefore(node);
114
- if (tokenBefore && tokenBefore.value === ',') {
115
- return fixer.removeRange([
116
- tokenBefore.range[0],
117
- declaratorRange[1],
118
- ]);
119
- }
120
- return fixer.remove(node);
121
- }
122
- return null;
123
- },
173
+ stateName: stateIdentifier.name,
174
+ removal: hasLiveSiblingBinding
175
+ ? null
176
+ : declarationRemovalRange(sourceCode, node),
124
177
  });
125
178
  }
126
179
  }
127
180
  }
128
181
  },
182
+ 'Program:exit'() {
183
+ if (violations.length === 0)
184
+ return;
185
+ const planned = planViolations();
186
+ // One plan over every surviving removal: the `useState` binding is left
187
+ // unreferenced by their union even when no single deletion strips its
188
+ // last call, and the pass that applies them all resolves every report —
189
+ // so this is the only moment the stranded import is visible.
190
+ const orphanRemoval = planned.length > 0
191
+ ? planRemoval(planned.map((entry) => entry.removal))
192
+ : null;
193
+ // The whole batch ships as one fix, so no deletion lands without the
194
+ // others the import's orphanhood was judged against, and no unbinding
195
+ // lands without the deletion it was claimed on. The other violations
196
+ // report without a fixer; the carrier's pass already resolves them.
197
+ //
198
+ // No plan at all means some binding would be left unreferenced yet
199
+ // cannot be unbound safely, so every deletion stays behind: reports
200
+ // without a fixer are the lesser damage.
201
+ const carrier = orphanRemoval ? planned[0] : undefined;
202
+ const removals = orphanRemoval
203
+ ? [...orphanRemoval, ...planned.map((entry) => entry.removal)]
204
+ : [];
205
+ for (const violation of violations) {
206
+ context.report({
207
+ node: violation.node,
208
+ messageId: 'unusedUseState',
209
+ data: {
210
+ stateName: violation.stateName,
211
+ },
212
+ fix: violation === carrier?.violation
213
+ ? (fixer) => removals.map((range) => fixer.removeRange([range[0], range[1]]))
214
+ : undefined,
215
+ });
216
+ }
217
+ },
129
218
  };
130
219
  },
131
220
  });