@blumintinc/eslint-plugin-blumint 1.20.195 → 1.20.197
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 +1 -1
- package/lib/rules/no-entire-object-hook-deps.js +72 -6
- package/lib/rules/no-unused-usestate.d.ts +1 -0
- package/lib/rules/no-unused-usestate.js +148 -59
- package/lib/rules/prefer-fragment-component.js +160 -187
- package/lib/rules/prefer-map-over-conditional-dispatch.js +171 -28
- package/lib/utils/composedFixConfig.d.ts +46 -0
- package/lib/utils/composedFixConfig.js +80 -0
- package/lib/utils/fixtureTypeProgram.d.ts +341 -0
- package/lib/utils/fixtureTypeProgram.js +1058 -0
- package/lib/utils/validCaseFalsifiability.d.ts +65 -7
- package/lib/utils/validCaseFalsifiability.js +111 -8
- package/package.json +1 -1
- package/release-manifest.json +44 -0
package/lib/index.js
CHANGED
|
@@ -1310,6 +1310,46 @@ exports.noEntireObjectHookDeps = (0, createRule_1.createRule)({
|
|
|
1310
1310
|
dependencyEntryIdentifiers = entries;
|
|
1311
1311
|
return entries;
|
|
1312
1312
|
}
|
|
1313
|
+
/**
|
|
1314
|
+
* Whether a parameter's binding comes out of a DESTRUCTURING pattern - a
|
|
1315
|
+
* destructured prop rather than a positional parameter.
|
|
1316
|
+
*
|
|
1317
|
+
* why: this is the line `noUnusedParameters` draws, measured against the
|
|
1318
|
+
* consumer's own compiler. It reports a destructured property wherever it
|
|
1319
|
+
* sits, including a rest sibling and including an `_`-prefixed one; a
|
|
1320
|
+
* positional parameter it reports only when the name does not start with
|
|
1321
|
+
* `_`. Keying on the pattern rather than on the name is therefore the
|
|
1322
|
+
* accurate test for the destructured case, and `_`-prefixing must NOT be
|
|
1323
|
+
* read as an opt-out there - tsc ignores the name inside a pattern, so
|
|
1324
|
+
* honouring it would readmit the strand on a binding that merely looks
|
|
1325
|
+
* deliberate.
|
|
1326
|
+
*
|
|
1327
|
+
* The AssignmentPattern step is carried but not reachable from the report
|
|
1328
|
+
* path: measured, the rule emits nothing at all for a DEFAULTED
|
|
1329
|
+
* destructured prop (`({ label, revision = 0 })`), so the fixer never gets
|
|
1330
|
+
* to judge one. It stays because dropping it would classify such a prop as
|
|
1331
|
+
* positional the moment that reporting gap is closed, which is the strand
|
|
1332
|
+
* this function exists to prevent - not because a fixture exercises it.
|
|
1333
|
+
*/
|
|
1334
|
+
function isDestructuredParameter(name) {
|
|
1335
|
+
if (name.type !== utils_1.AST_NODE_TYPES.Identifier)
|
|
1336
|
+
return false;
|
|
1337
|
+
let node = name.parent;
|
|
1338
|
+
while (node) {
|
|
1339
|
+
if (node.type === utils_1.AST_NODE_TYPES.ObjectPattern ||
|
|
1340
|
+
node.type === utils_1.AST_NODE_TYPES.ArrayPattern) {
|
|
1341
|
+
return true;
|
|
1342
|
+
}
|
|
1343
|
+
if (node.type === utils_1.AST_NODE_TYPES.Property ||
|
|
1344
|
+
node.type === utils_1.AST_NODE_TYPES.RestElement ||
|
|
1345
|
+
node.type === utils_1.AST_NODE_TYPES.AssignmentPattern) {
|
|
1346
|
+
node = node.parent;
|
|
1347
|
+
continue;
|
|
1348
|
+
}
|
|
1349
|
+
return false;
|
|
1350
|
+
}
|
|
1351
|
+
return false;
|
|
1352
|
+
}
|
|
1313
1353
|
/**
|
|
1314
1354
|
* Whether removing `element`'s binding from every dependency array that
|
|
1315
1355
|
* lists it would leave the binding with no reader in the file.
|
|
@@ -1336,11 +1376,35 @@ exports.noEntireObjectHookDeps = (0, createRule_1.createRule)({
|
|
|
1336
1376
|
* inference is safe in the conservative direction — if a sibling report is
|
|
1337
1377
|
* suppressed the cost is a withheld fix, never a dangling reference.
|
|
1338
1378
|
*
|
|
1339
|
-
*
|
|
1340
|
-
*
|
|
1341
|
-
*
|
|
1342
|
-
*
|
|
1343
|
-
*
|
|
1379
|
+
* A DESTRUCTURED parameter is not exempt; a positional one still is.
|
|
1380
|
+
*
|
|
1381
|
+
* why: the instrument that covers a parameter is `noUnusedParameters`, not
|
|
1382
|
+
* `noUnusedLocals`, and the consumer sets `noUnusedParameters: true` while
|
|
1383
|
+
* setting `noUnusedLocals: false` — so a blanket parameter exemption reads
|
|
1384
|
+
* the one flag the consumer has turned OFF and misses the one it has turned
|
|
1385
|
+
* ON. It also cites `no-unused-vars` with `args: 'none'`, which is not the
|
|
1386
|
+
* consumer's setting either. Stranding a destructured prop is therefore a
|
|
1387
|
+
* red build there, from a `tsc --noEmit` gate, and `no-unused-props` cannot
|
|
1388
|
+
* clean up after this fixer because that rule is report-only.
|
|
1389
|
+
*
|
|
1390
|
+
* Nearly every dependency entry in a React component is a destructured
|
|
1391
|
+
* prop, so this is the common case rather than an edge: 21 composed
|
|
1392
|
+
* findings over 13 distinct fixture shapes, every one of them a
|
|
1393
|
+
* destructured prop this fixer stranded (#2236).
|
|
1394
|
+
*
|
|
1395
|
+
* The POSITIONAL parameter stays exempt, and deliberately so. tsc reports
|
|
1396
|
+
* one too, so this IS a residue — but the composed sweep over 23,785
|
|
1397
|
+
* fixtures reached zero of them on its own, and declining there would
|
|
1398
|
+
* settle the reporting question #1621 defers on unmeasured ground while
|
|
1399
|
+
* withholding fixes the corpus shows to be safe, including the #2208
|
|
1400
|
+
* margin-comment arm whose subject is a positional parameter. The residue
|
|
1401
|
+
* is carried deliberately and it is WITNESSED: the control fixture added
|
|
1402
|
+
* with this fix is now the sweep's only surviving stranded parameter, so
|
|
1403
|
+
* the cost of the exemption is visible in that guard's dump rather than
|
|
1404
|
+
* asserted here and forgotten.
|
|
1405
|
+
*
|
|
1406
|
+
* The report stands either way. Only the rewrite is withheld, which is the
|
|
1407
|
+
* conservative direction the rest of this function already takes.
|
|
1344
1408
|
*/
|
|
1345
1409
|
function wouldStrandBinding(element) {
|
|
1346
1410
|
const identifier = unwrapExpression(element);
|
|
@@ -1349,7 +1413,9 @@ exports.noEntireObjectHookDeps = (0, createRule_1.createRule)({
|
|
|
1349
1413
|
const variable = resolveBinding(identifier);
|
|
1350
1414
|
if (!variable || variable.defs.length === 0)
|
|
1351
1415
|
return false;
|
|
1352
|
-
|
|
1416
|
+
const parameterDefs = variable.defs.filter((def) => def.type === 'Parameter');
|
|
1417
|
+
if (parameterDefs.length === variable.defs.length &&
|
|
1418
|
+
!parameterDefs.some((def) => isDestructuredParameter(def.name))) {
|
|
1353
1419
|
return false;
|
|
1354
1420
|
}
|
|
1355
1421
|
const entries = collectDependencyEntryIdentifiers();
|
|
@@ -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
|
-
|
|
171
|
+
violations.push({
|
|
66
172
|
node,
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
});
|