@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.
- package/lib/index.js +1 -1
- package/lib/rules/enforce-centralized-mock-firestore.js +46 -3
- package/lib/rules/enforce-firestore-set-merge.js +278 -66
- package/lib/rules/enforce-microdiff.js +87 -20
- package/lib/rules/fast-deep-equal-over-microdiff.js +220 -71
- package/lib/rules/key-only-outermost-element.js +159 -21
- package/lib/rules/logical-top-to-bottom-grouping.js +104 -33
- package/lib/rules/no-empty-dependency-use-callbacks.js +116 -43
- package/lib/rules/no-explicit-return-type.js +164 -21
- package/lib/rules/no-mock-firebase-admin.js +52 -8
- package/lib/rules/no-redundant-usecallback-wrapper.d.ts +2 -1
- package/lib/rules/no-redundant-usecallback-wrapper.js +126 -70
- package/lib/rules/no-unused-props.js +69 -57
- package/lib/rules/no-useless-usememo-primitives.d.ts +2 -1
- package/lib/rules/no-useless-usememo-primitives.js +156 -47
- package/lib/rules/no-usememo-for-pass-by-value.js +165 -220
- package/lib/rules/prefer-params-over-parent-id.d.ts +2 -1
- package/lib/rules/prefer-params-over-parent-id.js +122 -39
- package/lib/rules/use-latest-callback.js +109 -11
- package/lib/rules/vertically-group-related-functions.js +41 -10
- package/lib/utils/importRemoval.d.ts +88 -0
- package/lib/utils/importRemoval.js +127 -46
- package/lib/utils/patternBindingRemoval.d.ts +28 -0
- package/lib/utils/patternBindingRemoval.js +199 -0
- package/lib/utils/typeDeclarationRemoval.d.ts +32 -0
- package/lib/utils/typeDeclarationRemoval.js +105 -0
- package/package.json +1 -1
- 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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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({
|
|
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
|
|
1086
|
+
function lateDeclarationDeclaratorsOf(statement) {
|
|
1063
1087
|
const declaration = variableDeclarationOf(statement);
|
|
1064
|
-
if (!declaration || declaration.declarations.length
|
|
1088
|
+
if (!declaration || declaration.declarations.length === 0) {
|
|
1065
1089
|
return null;
|
|
1066
1090
|
}
|
|
1067
|
-
const
|
|
1068
|
-
|
|
1069
|
-
|
|
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
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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
|
-
|
|
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
|
|
1114
|
-
if (!
|
|
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
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
1769
|
-
* the whole reordering ships as a single fix. Relocating one
|
|
1770
|
-
* satisfies its own adjacency constraint while breaking
|
|
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
|
|
1790
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
652
|
-
context.report({
|
|
688
|
+
violations.push({
|
|
653
689
|
node: callExpression,
|
|
690
|
+
callback,
|
|
654
691
|
messageId,
|
|
655
|
-
|
|
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
|
});
|