@blumintinc/eslint-plugin-blumint 1.20.119 → 1.20.121
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-dynamic-firebase-imports.js +55 -10
- package/lib/rules/enforce-microdiff.js +124 -98
- package/lib/rules/no-jsx-in-hooks.js +16 -0
- package/lib/rules/no-redundant-usecallback-wrapper.js +8 -1
- package/lib/rules/no-undefined-null-passthrough.js +47 -22
- package/lib/rules/prefer-use-base62-id.js +31 -2
- package/lib/rules/prefer-usememo-over-useeffect-usestate.js +26 -6
- package/lib/rules/require-memo.js +149 -71
- package/lib/utils/fixtureCorpus.d.ts +2 -3
- package/lib/utils/fixtureCorpus.js +54 -5
- package/package.json +1 -1
- package/release-manifest.json +76 -0
package/lib/index.js
CHANGED
|
@@ -6,8 +6,8 @@ const isFunctionNode = (node) => node.type === utils_1.AST_NODE_TYPES.ArrowFunct
|
|
|
6
6
|
node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
|
|
7
7
|
node.type === utils_1.AST_NODE_TYPES.FunctionExpression;
|
|
8
8
|
/**
|
|
9
|
-
* Walks outward from a reference to the innermost `async` function whose
|
|
10
|
-
*
|
|
9
|
+
* Walks outward from a reference to the innermost `async` function whose body
|
|
10
|
+
* both contains it and satisfies `hostsDeclaration`.
|
|
11
11
|
*
|
|
12
12
|
* A reference sitting in a *synchronous* callback nested inside an async
|
|
13
13
|
* function still resolves once the declaration heads the async body, because
|
|
@@ -19,12 +19,12 @@ const isFunctionNode = (node) => node.type === utils_1.AST_NODE_TYPES.ArrowFunct
|
|
|
19
19
|
* before the body runs, so a declaration at the top of the body would come too
|
|
20
20
|
* late for it.
|
|
21
21
|
*/
|
|
22
|
-
const
|
|
22
|
+
const enclosingAsyncFunctionOf = (identifier, hostsDeclaration) => {
|
|
23
23
|
let current = identifier.parent;
|
|
24
24
|
while (current) {
|
|
25
25
|
if (isFunctionNode(current) &&
|
|
26
26
|
current.async &&
|
|
27
|
-
current
|
|
27
|
+
hostsDeclaration(current) &&
|
|
28
28
|
identifier.range[0] >= current.body.range[0] &&
|
|
29
29
|
identifier.range[1] <= current.body.range[1]) {
|
|
30
30
|
return current;
|
|
@@ -33,6 +33,16 @@ const enclosingAsyncBodyOf = (identifier) => {
|
|
|
33
33
|
}
|
|
34
34
|
return undefined;
|
|
35
35
|
};
|
|
36
|
+
const enclosingAsyncBodyOf = (identifier) => enclosingAsyncFunctionOf(identifier, (fn) => fn.body.type === utils_1.AST_NODE_TYPES.BlockStatement);
|
|
37
|
+
/**
|
|
38
|
+
* The innermost `async` arrow whose *concise* body holds the reference.
|
|
39
|
+
*
|
|
40
|
+
* A concise body is a single expression with no statement list to head, so the
|
|
41
|
+
* declaration only becomes expressible once the arrow gains a block. That is a
|
|
42
|
+
* larger edit than heading an existing body, which is why this search runs only
|
|
43
|
+
* for references no async block encloses — an enclosing block always wins.
|
|
44
|
+
*/
|
|
45
|
+
const enclosingConciseAsyncArrowOf = (identifier) => enclosingAsyncFunctionOf(identifier, (fn) => fn.body.type !== utils_1.AST_NODE_TYPES.BlockStatement);
|
|
36
46
|
const THIRD_PARTY_DIRECTORY = /(^|\/)node_modules(\/|$)/;
|
|
37
47
|
// Anchored at the end of the path so multi-part suffixes such as
|
|
38
48
|
// `useStartMatch.integration.test.ts` are recognized while production modules
|
|
@@ -174,7 +184,8 @@ const enforceFirebaseImports = (0, createRule_1.createRule)({
|
|
|
174
184
|
}
|
|
175
185
|
let target;
|
|
176
186
|
for (const reference of references) {
|
|
177
|
-
const enclosing = enclosingAsyncBodyOf(reference.identifier)
|
|
187
|
+
const enclosing = enclosingAsyncBodyOf(reference.identifier) ??
|
|
188
|
+
enclosingConciseAsyncArrowOf(reference.identifier);
|
|
178
189
|
if (!enclosing || (target && target !== enclosing)) {
|
|
179
190
|
return undefined;
|
|
180
191
|
}
|
|
@@ -205,12 +216,50 @@ const enforceFirebaseImports = (0, createRule_1.createRule)({
|
|
|
205
216
|
}
|
|
206
217
|
return cursor;
|
|
207
218
|
};
|
|
219
|
+
/**
|
|
220
|
+
* Gives a concise-bodied arrow the block its declaration needs, turning
|
|
221
|
+
* the returned expression into an explicit `return`.
|
|
222
|
+
*
|
|
223
|
+
* The expression is spliced verbatim out of the source rather than
|
|
224
|
+
* reprinted from the AST: the parentheses around an object literal are
|
|
225
|
+
* not part of its node, and a comment sitting between `=>` and the
|
|
226
|
+
* expression belongs to neither, so both survive only by copying the
|
|
227
|
+
* text the arrow already owns.
|
|
228
|
+
*/
|
|
229
|
+
const blockifyConciseBody = (fixer, arrow, statements) => {
|
|
230
|
+
const arrowToken = sourceCode.getTokenBefore(arrow.body, {
|
|
231
|
+
filter: (token) => token.type === utils_1.AST_TOKEN_TYPES.Punctuator && token.value === '=>',
|
|
232
|
+
});
|
|
233
|
+
if (!arrowToken) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
const expression = sourceCode
|
|
237
|
+
.getText()
|
|
238
|
+
.slice(arrowToken.range[1], arrow.range[1])
|
|
239
|
+
.trim();
|
|
240
|
+
const indent = indentationAt(arrow.loc.start.line);
|
|
241
|
+
const bodyIndent = `${indent} `;
|
|
242
|
+
const lines = [...statements, `return ${expression};`]
|
|
243
|
+
.map((statement) => `\n${bodyIndent}${statement}`)
|
|
244
|
+
.join('');
|
|
245
|
+
return fixer.replaceTextRange([arrowToken.range[1], arrow.range[1]], ` {${lines}\n${indent}}`);
|
|
246
|
+
};
|
|
208
247
|
const buildFix = (fixer) => {
|
|
209
248
|
const target = findRelocationTarget();
|
|
210
249
|
const statements = buildValueStatements();
|
|
211
250
|
if (!target || statements.length === 0) {
|
|
212
251
|
return null;
|
|
213
252
|
}
|
|
253
|
+
// Type-only specifiers are erased at compile time, so they stay where
|
|
254
|
+
// they are instead of riding along into the function body.
|
|
255
|
+
const importEdit = typeOnlySpecifiers.length > 0
|
|
256
|
+
? fixer.replaceText(node, `import type { ${buildTypeNames()} } from '${importPath}';`)
|
|
257
|
+
: fixer.removeRange([node.range[0], removalEnd()]);
|
|
258
|
+
if (target.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression &&
|
|
259
|
+
target.body.type !== utils_1.AST_NODE_TYPES.BlockStatement) {
|
|
260
|
+
const blockified = blockifyConciseBody(fixer, target, statements);
|
|
261
|
+
return blockified ? [importEdit, blockified] : null;
|
|
262
|
+
}
|
|
214
263
|
const body = target.body;
|
|
215
264
|
// A directive stops being a directive the moment a declaration
|
|
216
265
|
// precedes it, so `'use server'` on a server action would silently
|
|
@@ -239,11 +288,7 @@ const enforceFirebaseImports = (0, createRule_1.createRule)({
|
|
|
239
288
|
})
|
|
240
289
|
.join('');
|
|
241
290
|
return [
|
|
242
|
-
|
|
243
|
-
// where they are instead of riding along into the function body.
|
|
244
|
-
typeOnlySpecifiers.length > 0
|
|
245
|
-
? fixer.replaceText(node, `import type { ${buildTypeNames()} } from '${importPath}';`)
|
|
246
|
-
: fixer.removeRange([node.range[0], removalEnd()]),
|
|
291
|
+
importEdit,
|
|
247
292
|
lastDirective
|
|
248
293
|
? fixer.insertTextAfter(lastDirective, insertion)
|
|
249
294
|
: fixer.insertTextAfterRange([body.range[0], body.range[0] + 1], insertion),
|
|
@@ -37,6 +37,58 @@ const DIFF_FUNCTION_NAMES = new Set([
|
|
|
37
37
|
'detailedDiff',
|
|
38
38
|
// 'fastDeepEqual' and 'isEqual' stay out: they are allowed alternatives.
|
|
39
39
|
]);
|
|
40
|
+
/**
|
|
41
|
+
* The names a hand-rolled comparison function is conventionally given. One
|
|
42
|
+
* shared set answers for both spellings of such a function — a `function`
|
|
43
|
+
* declaration and an arrow bound to a `const` — so neither can drift into
|
|
44
|
+
* recognising a name the other misses.
|
|
45
|
+
*/
|
|
46
|
+
const COMPARISON_FUNCTION_NAMES = new Set([
|
|
47
|
+
'detectChanges',
|
|
48
|
+
'hasConfigChanged',
|
|
49
|
+
'compareObjects',
|
|
50
|
+
'compareArrays',
|
|
51
|
+
'findChanges',
|
|
52
|
+
'detectDifferences',
|
|
53
|
+
'hasStateChanged',
|
|
54
|
+
'stateHasUpdated',
|
|
55
|
+
'arrayHasChanged',
|
|
56
|
+
'settingsChanged',
|
|
57
|
+
]);
|
|
58
|
+
/**
|
|
59
|
+
* The comparison function whose body the fix rewrites. A name alone does not
|
|
60
|
+
* fix what such a function returns — a boolean, the changed keys, the changes
|
|
61
|
+
* themselves — and a change list swapped in for the wrong one of those
|
|
62
|
+
* compiles, so the rest of the set is reported and left to its authors.
|
|
63
|
+
*/
|
|
64
|
+
const REWRITABLE_COMPARISON_NAME = 'hasConfigChanged';
|
|
65
|
+
/**
|
|
66
|
+
* The markers that make a body look like a hand-rolled comparison. Text is
|
|
67
|
+
* enough to raise the report because the report says only that the body should
|
|
68
|
+
* be using microdiff; nothing is rewritten off these.
|
|
69
|
+
*/
|
|
70
|
+
function hasComparisonMarkers(bodyText) {
|
|
71
|
+
return (bodyText.includes('JSON.stringify') ||
|
|
72
|
+
bodyText.includes('Object.keys') ||
|
|
73
|
+
bodyText.includes('for (') ||
|
|
74
|
+
bodyText.includes('.some(') ||
|
|
75
|
+
bodyText.includes('.every('));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Whether a comparison function is one whose body the fix attempts. The gate is
|
|
79
|
+
* a text-level pre-filter over the name and the body: `collectStringifyComparisons`
|
|
80
|
+
* reads the AST afterwards and has the last word on whether a rewrite exists,
|
|
81
|
+
* so a body that clears this gate is still routinely left alone.
|
|
82
|
+
*
|
|
83
|
+
* The `!==` marker keeps the rewrite to bodies that phrase the question the way
|
|
84
|
+
* the name does — "has it changed?" — while an all-`===` body is reported and
|
|
85
|
+
* left for its author in either spelling.
|
|
86
|
+
*/
|
|
87
|
+
function isRewritableComparison(name, bodyText) {
|
|
88
|
+
return (name === REWRITABLE_COMPARISON_NAME &&
|
|
89
|
+
bodyText.includes('JSON.stringify') &&
|
|
90
|
+
bodyText.includes('!=='));
|
|
91
|
+
}
|
|
40
92
|
/**
|
|
41
93
|
* The exports of a competing library whose call sites this rule rewrites,
|
|
42
94
|
* whatever local name the import binds them to.
|
|
@@ -457,6 +509,62 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
|
|
|
457
509
|
const emptiness = comparison.isEqual ? '.length === 0' : '.length > 0';
|
|
458
510
|
return `${DIFF_NAME}(${left}, ${right})${emptiness}`;
|
|
459
511
|
}
|
|
512
|
+
/**
|
|
513
|
+
* The rewrite of a comparison function's body, or null when the body offers
|
|
514
|
+
* no single expression to replace or the emitted `diff` would not reach
|
|
515
|
+
* microdiff at `fn`.
|
|
516
|
+
*
|
|
517
|
+
* Exactly one comparison is the condition for a fix. With none there is no
|
|
518
|
+
* expression to rewrite, and with several the rule cannot tell which one the
|
|
519
|
+
* function's answer turns on, so the report stands on its own.
|
|
520
|
+
*
|
|
521
|
+
* Only the comparison's own range is rewritten. The signature keeps its type
|
|
522
|
+
* annotations, its modifiers and any `export` in front of it, and the body
|
|
523
|
+
* keeps everything the comparison shares it with: side effects, guard
|
|
524
|
+
* clauses, locals, and the comments around them. Re-emitting the body as a
|
|
525
|
+
* single return drops all of that silently — the fix compiles, so nothing
|
|
526
|
+
* downstream flags the loss.
|
|
527
|
+
*
|
|
528
|
+
* Replacing the comparison rather than the statement holding it is also what
|
|
529
|
+
* lets one implementation serve every spelling of the function: an arrow's
|
|
530
|
+
* concise expression body takes no `return` and no semicolon, and it needs
|
|
531
|
+
* none, because the text around the comparison is never part of the range.
|
|
532
|
+
*/
|
|
533
|
+
function buildComparisonBodyFix(fixer, fn, body) {
|
|
534
|
+
const comparisons = collectStringifyComparisons(body);
|
|
535
|
+
if (comparisons.length !== 1 || !canEmitDiffAt(fn)) {
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
const bodyFix = fixer.replaceText(comparisons[0].node, buildDiffComparison(comparisons[0]));
|
|
539
|
+
const importFix = buildMicrodiffImportFix(fixer);
|
|
540
|
+
return importFix ? [importFix, bodyFix] : bodyFix;
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Reports a hand-rolled comparison function, carrying the rewrite whenever
|
|
544
|
+
* its body offers one. Both spellings route through here so an identical
|
|
545
|
+
* violation is auto-remediable however it is written: a report with a fix in
|
|
546
|
+
* one spelling and without it in the other leaves the same code manual to
|
|
547
|
+
* resolve for no reason the author can see.
|
|
548
|
+
*/
|
|
549
|
+
function reportComparisonFunction(node, name, body) {
|
|
550
|
+
const bodyText = sourceCode.getText(body);
|
|
551
|
+
if (isRewritableComparison(name, bodyText)) {
|
|
552
|
+
reportedNodes.add(node);
|
|
553
|
+
context.report({
|
|
554
|
+
node,
|
|
555
|
+
messageId: 'enforceMicrodiff',
|
|
556
|
+
fix: (fixer) => buildComparisonBodyFix(fixer, node, body),
|
|
557
|
+
});
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (hasComparisonMarkers(bodyText)) {
|
|
561
|
+
reportedNodes.add(node);
|
|
562
|
+
context.report({
|
|
563
|
+
node,
|
|
564
|
+
messageId: 'enforceMicrodiff',
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
}
|
|
460
568
|
// Add a specific set to track which import names are used
|
|
461
569
|
const usedImportNames = new Set();
|
|
462
570
|
// Check if a node is an object or array type
|
|
@@ -692,71 +800,14 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
|
|
|
692
800
|
if (reportedNodes.has(node)) {
|
|
693
801
|
return;
|
|
694
802
|
}
|
|
695
|
-
//
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
'compareArrays',
|
|
702
|
-
'findChanges',
|
|
703
|
-
'detectDifferences',
|
|
704
|
-
'hasStateChanged',
|
|
705
|
-
'stateHasUpdated',
|
|
706
|
-
'arrayHasChanged',
|
|
707
|
-
'settingsChanged',
|
|
708
|
-
].includes(node.id.name)) {
|
|
709
|
-
// Check if function has two parameters that might be objects/arrays
|
|
710
|
-
if (node.params.length >= 2) {
|
|
711
|
-
const body = node.body;
|
|
712
|
-
const bodyText = sourceCode.getText(body);
|
|
713
|
-
// Check if the function body contains a JSON.stringify comparison
|
|
714
|
-
if (node.id.name === 'hasConfigChanged' &&
|
|
715
|
-
bodyText.includes('JSON.stringify') &&
|
|
716
|
-
bodyText.includes('!==')) {
|
|
717
|
-
reportedNodes.add(node);
|
|
718
|
-
// Exactly one comparison is the condition for a fix. With none
|
|
719
|
-
// there is no expression to rewrite, and with several the rule
|
|
720
|
-
// cannot tell which one the function's answer turns on, so the
|
|
721
|
-
// report stands on its own.
|
|
722
|
-
const comparisons = collectStringifyComparisons(body);
|
|
723
|
-
const comparison = comparisons.length === 1 ? comparisons[0] : null;
|
|
724
|
-
context.report({
|
|
725
|
-
node,
|
|
726
|
-
messageId: 'enforceMicrodiff',
|
|
727
|
-
fix(fixer) {
|
|
728
|
-
if (!comparison || !canEmitDiffAt(node)) {
|
|
729
|
-
return null;
|
|
730
|
-
}
|
|
731
|
-
// Only the comparison's own range is rewritten. The signature
|
|
732
|
-
// keeps its type annotations, its modifiers and any `export`
|
|
733
|
-
// in front of it, and the body keeps everything the
|
|
734
|
-
// comparison shares it with: side effects, guard clauses,
|
|
735
|
-
// locals, and the comments around them. Re-emitting the body
|
|
736
|
-
// as a single return drops all of that silently — the fix
|
|
737
|
-
// compiles, so nothing downstream flags the loss.
|
|
738
|
-
const bodyFix = fixer.replaceText(comparison.node, buildDiffComparison(comparison));
|
|
739
|
-
const importFix = buildMicrodiffImportFix(fixer);
|
|
740
|
-
return importFix ? [importFix, bodyFix] : bodyFix;
|
|
741
|
-
},
|
|
742
|
-
});
|
|
743
|
-
return;
|
|
744
|
-
}
|
|
745
|
-
// Look for patterns that suggest object/array comparison
|
|
746
|
-
const hasComparisonLogic = bodyText.includes('JSON.stringify') ||
|
|
747
|
-
bodyText.includes('Object.keys') ||
|
|
748
|
-
bodyText.includes('for (') ||
|
|
749
|
-
bodyText.includes('.some(') ||
|
|
750
|
-
bodyText.includes('.every(');
|
|
751
|
-
if (hasComparisonLogic) {
|
|
752
|
-
reportedNodes.add(node);
|
|
753
|
-
context.report({
|
|
754
|
-
node,
|
|
755
|
-
messageId: 'enforceMicrodiff',
|
|
756
|
-
});
|
|
757
|
-
}
|
|
758
|
-
}
|
|
803
|
+
// Two parameters are what a comparison function needs, and what the
|
|
804
|
+
// `diff(a, b)` it is rewritten to needs as well.
|
|
805
|
+
if (!node.id ||
|
|
806
|
+
!COMPARISON_FUNCTION_NAMES.has(node.id.name) ||
|
|
807
|
+
node.params.length < 2) {
|
|
808
|
+
return;
|
|
759
809
|
}
|
|
810
|
+
reportComparisonFunction(node, node.id.name, node.body);
|
|
760
811
|
},
|
|
761
812
|
// Check for custom deep comparison in arrow functions
|
|
762
813
|
ArrowFunctionExpression(node) {
|
|
@@ -764,42 +815,17 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
|
|
|
764
815
|
if (reportedNodes.has(node)) {
|
|
765
816
|
return;
|
|
766
817
|
}
|
|
767
|
-
//
|
|
818
|
+
// The name an arrow answers to is the one its declarator binds, so an
|
|
819
|
+
// arrow passed straight to a call names nothing and is left alone.
|
|
768
820
|
const parent = node.parent;
|
|
769
|
-
if (parent
|
|
770
|
-
parent.type
|
|
771
|
-
parent.id.type
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
'compareObjects',
|
|
776
|
-
'compareArrays',
|
|
777
|
-
'findChanges',
|
|
778
|
-
'detectDifferences',
|
|
779
|
-
'hasStateChanged',
|
|
780
|
-
'stateHasUpdated',
|
|
781
|
-
'arrayHasChanged',
|
|
782
|
-
'settingsChanged',
|
|
783
|
-
].includes(parent.id.name)) {
|
|
784
|
-
// Check if function has two parameters that might be objects/arrays
|
|
785
|
-
if (node.params.length >= 2) {
|
|
786
|
-
const body = node.body;
|
|
787
|
-
// Look for patterns that suggest object/array comparison
|
|
788
|
-
const bodyText = sourceCode.getText(body);
|
|
789
|
-
const hasComparisonLogic = bodyText.includes('JSON.stringify') ||
|
|
790
|
-
bodyText.includes('Object.keys') ||
|
|
791
|
-
bodyText.includes('for (') ||
|
|
792
|
-
bodyText.includes('.some(') ||
|
|
793
|
-
bodyText.includes('.every(');
|
|
794
|
-
if (hasComparisonLogic) {
|
|
795
|
-
reportedNodes.add(node);
|
|
796
|
-
context.report({
|
|
797
|
-
node,
|
|
798
|
-
messageId: 'enforceMicrodiff',
|
|
799
|
-
});
|
|
800
|
-
}
|
|
801
|
-
}
|
|
821
|
+
if (!parent ||
|
|
822
|
+
parent.type !== utils_1.AST_NODE_TYPES.VariableDeclarator ||
|
|
823
|
+
parent.id.type !== utils_1.AST_NODE_TYPES.Identifier ||
|
|
824
|
+
!COMPARISON_FUNCTION_NAMES.has(parent.id.name) ||
|
|
825
|
+
node.params.length < 2) {
|
|
826
|
+
return;
|
|
802
827
|
}
|
|
828
|
+
reportComparisonFunction(node, parent.id.name, node.body);
|
|
803
829
|
},
|
|
804
830
|
};
|
|
805
831
|
},
|
|
@@ -227,6 +227,22 @@ exports.noJsxInHooks = (0, createRule_1.createRule)({
|
|
|
227
227
|
data: { hookName: parent.id.name },
|
|
228
228
|
});
|
|
229
229
|
}
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* A concise body that is a call expression returns whatever the call
|
|
234
|
+
* yields, so `() => useMemo(() => <div />, [])` is the same violation
|
|
235
|
+
* as its block-bodied spelling. Without this branch the block scanner
|
|
236
|
+
* — the only place the useMemo unwrapper is reached from — never runs
|
|
237
|
+
* for it, and rewriting a hook to a concise arrow silences the rule.
|
|
238
|
+
*/
|
|
239
|
+
if (node.body.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
240
|
+
containsJsxInUseMemo(node.body)) {
|
|
241
|
+
context.report({
|
|
242
|
+
node: parent.id,
|
|
243
|
+
messageId: 'noJsxInHooks',
|
|
244
|
+
data: { hookName: parent.id.name },
|
|
245
|
+
});
|
|
230
246
|
}
|
|
231
247
|
}
|
|
232
248
|
},
|
|
@@ -442,9 +442,16 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
|
|
|
442
442
|
: first.expression;
|
|
443
443
|
if (expr && expr.type === utils_1.AST_NODE_TYPES.CallExpression) {
|
|
444
444
|
const callee = unwrapChainExpression(expr.callee);
|
|
445
|
+
// A bare identifier is a memoized callback whether the hook
|
|
446
|
+
// handed it back directly (`const signIn = useThing()`) or
|
|
447
|
+
// through a destructuring pattern, so both sets answer here.
|
|
448
|
+
// The arrow-body spelling is not part of the question: a
|
|
449
|
+
// block body delegating to the same callback is the same
|
|
450
|
+
// redundant wrapper the concise spelling is.
|
|
445
451
|
const isHookProp = callee &&
|
|
446
452
|
callee.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
447
|
-
(
|
|
453
|
+
(hookReturnObjects.has(callee.name) ||
|
|
454
|
+
hookReturnProps.has(callee.name) ||
|
|
448
455
|
isLocallyMemoizedCallback(callee, node));
|
|
449
456
|
const isHookObjMember = callee &&
|
|
450
457
|
callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
@@ -82,6 +82,21 @@ function checkFunctionBody(body, param, context) {
|
|
|
82
82
|
}
|
|
83
83
|
if (!paramName)
|
|
84
84
|
return;
|
|
85
|
+
// A block whose only statement is `return <expr>;` makes exactly the claim
|
|
86
|
+
// the implicit-return spelling of that same expression makes, so it is
|
|
87
|
+
// answered by the same predicate. A block that does other work first is a
|
|
88
|
+
// different claim and stays out of scope.
|
|
89
|
+
const soleStatement = body.body.length === 1 ? body.body[0] : null;
|
|
90
|
+
if (soleStatement?.type === 'ReturnStatement' &&
|
|
91
|
+
soleStatement.argument &&
|
|
92
|
+
isNullishPassthroughExpression(soleStatement.argument, paramName)) {
|
|
93
|
+
context.report({
|
|
94
|
+
node: soleStatement,
|
|
95
|
+
messageId: 'unexpected',
|
|
96
|
+
data: { paramName },
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
85
100
|
// Look for early returns based on parameter being null/undefined
|
|
86
101
|
for (const statement of body.body) {
|
|
87
102
|
if (statement.type === 'IfStatement') {
|
|
@@ -209,6 +224,32 @@ function containsParameterTransformation(node, paramName) {
|
|
|
209
224
|
// are NOT considered transformations for the purpose of this rule
|
|
210
225
|
return false;
|
|
211
226
|
}
|
|
227
|
+
/**
|
|
228
|
+
* Recognizes the expressions that hand a nullish parameter straight back to the
|
|
229
|
+
* caller: `param && ...` and `param ? ... : null/undefined`.
|
|
230
|
+
*
|
|
231
|
+
* A function states this shape either as an implicit return or as a block whose
|
|
232
|
+
* sole statement returns it; both spellings mean the same thing, so they share
|
|
233
|
+
* this predicate rather than each carrying a copy that can drift.
|
|
234
|
+
*
|
|
235
|
+
* The bare-identifier passthrough (`(param) => param`) is deliberately absent.
|
|
236
|
+
* Its boundary is unsettled — inline callback arguments such as
|
|
237
|
+
* `items.filter((x) => x)` reach that shape without the prescribed remedy
|
|
238
|
+
* applying — so it stays confined to the implicit-return path instead of being
|
|
239
|
+
* duplicated into a second spelling that would have to be narrowed twice.
|
|
240
|
+
*/
|
|
241
|
+
function isNullishPassthroughExpression(node, paramName) {
|
|
242
|
+
// (param) => param ? param.value : null
|
|
243
|
+
if (node.type === 'ConditionalExpression') {
|
|
244
|
+
return (isParameterReference(node.test, paramName) &&
|
|
245
|
+
isNullOrUndefinedLiteral(node.alternate));
|
|
246
|
+
}
|
|
247
|
+
// (param) => param && doSomething(param)
|
|
248
|
+
if (node.type === 'LogicalExpression') {
|
|
249
|
+
return node.operator === '&&' && isParameterReference(node.left, paramName);
|
|
250
|
+
}
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
212
253
|
/**
|
|
213
254
|
* Check arrow functions with expression bodies (implicit returns)
|
|
214
255
|
*/
|
|
@@ -224,28 +265,12 @@ function checkImplicitReturn(node, context) {
|
|
|
224
265
|
}
|
|
225
266
|
if (!paramName)
|
|
226
267
|
return;
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
node,
|
|
234
|
-
messageId: 'unexpected',
|
|
235
|
-
data: { paramName },
|
|
236
|
-
});
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
else if (node.body.type === 'LogicalExpression') {
|
|
240
|
-
// Check for (param) => param && doSomething(param)
|
|
241
|
-
if (node.body.operator === '&&' &&
|
|
242
|
-
isParameterReference(node.body.left, paramName)) {
|
|
243
|
-
context.report({
|
|
244
|
-
node,
|
|
245
|
-
messageId: 'unexpected',
|
|
246
|
-
data: { paramName },
|
|
247
|
-
});
|
|
248
|
-
}
|
|
268
|
+
if (isNullishPassthroughExpression(node.body, paramName)) {
|
|
269
|
+
context.report({
|
|
270
|
+
node,
|
|
271
|
+
messageId: 'unexpected',
|
|
272
|
+
data: { paramName },
|
|
273
|
+
});
|
|
249
274
|
}
|
|
250
275
|
else if (node.body.type === 'Identifier' && node.body.name === paramName) {
|
|
251
276
|
// Check for (param) => param
|
|
@@ -131,6 +131,30 @@ function hasEmptyDepsArray(callNode) {
|
|
|
131
131
|
const deps = callNode.arguments[1];
|
|
132
132
|
return (deps.type === utils_1.AST_NODE_TYPES.ArrayExpression && deps.elements.length === 0);
|
|
133
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Wrappers that exist purely at the type level: they leave the wrapped
|
|
136
|
+
* expression's runtime value untouched, so a value wrapped in them is still the
|
|
137
|
+
* value the enclosing declarator binds.
|
|
138
|
+
*/
|
|
139
|
+
const TYPE_ONLY_WRAPPERS = new Set([
|
|
140
|
+
utils_1.AST_NODE_TYPES.TSAsExpression,
|
|
141
|
+
utils_1.AST_NODE_TYPES.TSSatisfiesExpression,
|
|
142
|
+
utils_1.AST_NODE_TYPES.TSNonNullExpression,
|
|
143
|
+
utils_1.AST_NODE_TYPES.TSTypeAssertion,
|
|
144
|
+
]);
|
|
145
|
+
/**
|
|
146
|
+
* Returns the nearest ancestor that carries runtime meaning, skipping the
|
|
147
|
+
* type-only wrappers that may sit between an expression and its binding site.
|
|
148
|
+
* A double assertion (`as unknown as T`) nests two of them, so the climb loops
|
|
149
|
+
* rather than peeling a single layer.
|
|
150
|
+
*/
|
|
151
|
+
function getRuntimeParent(node) {
|
|
152
|
+
let current = node.parent;
|
|
153
|
+
while (current && TYPE_ONLY_WRAPPERS.has(current.type)) {
|
|
154
|
+
current = current.parent;
|
|
155
|
+
}
|
|
156
|
+
return current;
|
|
157
|
+
}
|
|
134
158
|
/**
|
|
135
159
|
* Checks whether the given identifier (the ref variable name) has its
|
|
136
160
|
* `.current` property assigned anywhere in the enclosing function body.
|
|
@@ -398,8 +422,13 @@ exports.preferUseBase62Id = (0, createRule_1.createRule)({
|
|
|
398
422
|
return;
|
|
399
423
|
if (!useRefArgContainsUuid(node, trackedUuidNames))
|
|
400
424
|
return;
|
|
401
|
-
// Find the variable name assigned to the ref
|
|
402
|
-
|
|
425
|
+
// Find the variable name assigned to the ref. A type assertion
|
|
426
|
+
// between the call and its declarator is semantically neutral, so the
|
|
427
|
+
// name stays knowable through it and the `.current`-reassignment
|
|
428
|
+
// exemption still applies. Genuinely nameless shapes — a destructure,
|
|
429
|
+
// a discarded call, a returned ref — leave `refName` null and keep
|
|
430
|
+
// the conservative report.
|
|
431
|
+
const parent = getRuntimeParent(node);
|
|
403
432
|
let refName = null;
|
|
404
433
|
if (parent?.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
|
|
405
434
|
parent.id.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
@@ -77,6 +77,26 @@ exports.preferUseMemoOverUseEffectUseState = (0, createRule_1.createRule)({
|
|
|
77
77
|
const isIdentifierReference = (node) => {
|
|
78
78
|
return node.type === 'Identifier';
|
|
79
79
|
};
|
|
80
|
+
// The expression a lazy useState initializer produces, if it produces one.
|
|
81
|
+
// A concise arrow body and a single `return` statement declare the same
|
|
82
|
+
// initializer, so both spellings must resolve to the same expression rather
|
|
83
|
+
// than the exemption below recognizing only one of them. A block with any
|
|
84
|
+
// other shape does work beyond producing a value, which is outside what the
|
|
85
|
+
// exemption covers.
|
|
86
|
+
const lazyInitializerResult = (node) => {
|
|
87
|
+
if (node.type !== 'ArrowFunctionExpression' &&
|
|
88
|
+
node.type !== 'FunctionExpression') {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
if (node.body.type !== 'BlockStatement') {
|
|
92
|
+
return node.body;
|
|
93
|
+
}
|
|
94
|
+
if (node.body.body.length !== 1) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const statement = node.body.body[0];
|
|
98
|
+
return statement.type === 'ReturnStatement' ? statement.argument : null;
|
|
99
|
+
};
|
|
80
100
|
// Helper to check if this is a state synchronization pattern
|
|
81
101
|
const isStateSynchronization = (initialValue, setterArgument) => {
|
|
82
102
|
// If the initial value is a reference to a prop/variable and the setter argument
|
|
@@ -88,13 +108,13 @@ exports.preferUseMemoOverUseEffectUseState = (0, createRule_1.createRule)({
|
|
|
88
108
|
setterArgument.name) {
|
|
89
109
|
return true;
|
|
90
110
|
}
|
|
91
|
-
// If the initial value is a
|
|
92
|
-
// is that same prop, this is likely state synchronization
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
111
|
+
// If the initial value is a lazy initializer producing a prop and the
|
|
112
|
+
// setter argument is that same prop, this is likely state synchronization
|
|
113
|
+
const lazyResult = initialValue && lazyInitializerResult(initialValue);
|
|
114
|
+
if (lazyResult &&
|
|
115
|
+
lazyResult.type === 'Identifier' &&
|
|
96
116
|
isIdentifierReference(setterArgument) &&
|
|
97
|
-
|
|
117
|
+
lazyResult.name === setterArgument.name) {
|
|
98
118
|
return true;
|
|
99
119
|
}
|
|
100
120
|
return false;
|
|
@@ -239,6 +239,143 @@ function buildImportExtensionFix(fixer, program) {
|
|
|
239
239
|
}
|
|
240
240
|
return null;
|
|
241
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* An `async` or generator function cannot be a React component — React renders
|
|
244
|
+
* neither a promise nor an iterator — so memoizing one would enshrine a shape
|
|
245
|
+
* that never renders. The report stands, the edit is withheld.
|
|
246
|
+
*/
|
|
247
|
+
const isRewritableFunction = (node) => !node.async && !node.generator;
|
|
248
|
+
/**
|
|
249
|
+
* Whether the emitted `memo(...)` call can reach the helper, and the import edit
|
|
250
|
+
* that makes it so (null when the helper is already imported).
|
|
251
|
+
*
|
|
252
|
+
* `available: false` withholds the whole rewrite. `memo` is resolved through the
|
|
253
|
+
* scope chain at the rewritten component because a binding that is not the
|
|
254
|
+
* helper import breaks the edit two ways: the inserted import collides with the
|
|
255
|
+
* existing declaration (TS2440, or TS2300 when that declaration is itself an
|
|
256
|
+
* import), and a binding visible at the fix site captures the emitted call with
|
|
257
|
+
* no compile error at all. Declining leaves the report standing so the author
|
|
258
|
+
* resolves the clash deliberately. `React.memo` is a member access on the
|
|
259
|
+
* default import rather than a `memo` binding, so it never reaches this path.
|
|
260
|
+
*/
|
|
261
|
+
function planMemoBinding(context, fixer, node) {
|
|
262
|
+
const existingMemo = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, node), MEMO_NAME);
|
|
263
|
+
if (existingMemo && !bindsMemoHelper(existingMemo)) {
|
|
264
|
+
return { available: false, importFix: null };
|
|
265
|
+
}
|
|
266
|
+
const sourceCode = context.sourceCode;
|
|
267
|
+
const program = sourceCode.ast;
|
|
268
|
+
if (importsMemo(program)) {
|
|
269
|
+
return { available: true, importFix: null };
|
|
270
|
+
}
|
|
271
|
+
const extensionFix = buildImportExtensionFix(fixer, program);
|
|
272
|
+
if (extensionFix) {
|
|
273
|
+
return { available: true, importFix: extensionFix };
|
|
274
|
+
}
|
|
275
|
+
const importPath = calculateImportPath(context.getFilename());
|
|
276
|
+
const importStatement = `import { memo } from '${importPath}';`;
|
|
277
|
+
const firstImport = program.body.find((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
|
|
278
|
+
// An existing import hosts the helper import directly after it, keeping the
|
|
279
|
+
// module's imports contiguous. With none to follow, the shared anchor keeps
|
|
280
|
+
// the file's prologue in place: a `'use client'` directive only counts as one
|
|
281
|
+
// while it is the first statement, and a `#!` shebang only parses at
|
|
282
|
+
// character 0.
|
|
283
|
+
return {
|
|
284
|
+
available: true,
|
|
285
|
+
importFix: firstImport
|
|
286
|
+
? fixer.insertTextAfter(firstImport, `\n${importStatement}`)
|
|
287
|
+
: (0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, (0, importInsertion_1.importInsertionAnchor)(sourceCode), `${importStatement}\n`),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Whether `memo(...)` can be wrapped around the initializer of `declarator`
|
|
292
|
+
* without changing what the binding means.
|
|
293
|
+
*
|
|
294
|
+
* A type annotation on the binding is the decisive exclusion: the wrapper's
|
|
295
|
+
* return type is the memo helper's, which need not be assignable to the
|
|
296
|
+
* declared type (`const Row: FC<Props> = ...`), so the edit would trade a
|
|
297
|
+
* lint report for a type error. A lone `const` declarator is the shape whose
|
|
298
|
+
* initializer is the binding's only definition — `let`/`var` can be reassigned
|
|
299
|
+
* afterwards, leaving the name bound to an unmemoized value that the edit only
|
|
300
|
+
* appears to have fixed, and a shared declaration's other declarators may carry
|
|
301
|
+
* reports of their own whose edits then compete for the same import anchor.
|
|
302
|
+
*/
|
|
303
|
+
function isWrappableInitializer(declarator) {
|
|
304
|
+
if (declarator.id.type !== utils_1.AST_NODE_TYPES.Identifier ||
|
|
305
|
+
declarator.id.typeAnnotation) {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
const declaration = declarator.parent;
|
|
309
|
+
return (declaration?.type === utils_1.AST_NODE_TYPES.VariableDeclaration &&
|
|
310
|
+
declaration.kind === 'const' &&
|
|
311
|
+
declaration.declarations.length === 1);
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Rewrites a function declaration into a memoized `const`, renaming the function
|
|
315
|
+
* itself to `<Name>Unmemoized` so the wrapped component keeps a display name.
|
|
316
|
+
*/
|
|
317
|
+
function memoizeDeclaration(context, node) {
|
|
318
|
+
return function fix(fixer) {
|
|
319
|
+
if (!isRewritableFunction(node)) {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
const { available, importFix } = planMemoBinding(context, fixer, node);
|
|
323
|
+
if (!available) {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
const functionKeywordRange = [
|
|
327
|
+
node.range[0],
|
|
328
|
+
node.range[0] + 'function'.length,
|
|
329
|
+
];
|
|
330
|
+
const functionKeywordReplacement = `const ${node.id.name} = memo(`;
|
|
331
|
+
const functionNameReplacement = `function ${node.id.name}Unmemoized`;
|
|
332
|
+
// `export default const X = memo(...)` is a syntax error, so a
|
|
333
|
+
// default-exported declaration becomes a memoized const plus a trailing
|
|
334
|
+
// `export default X;`. The local binding is preserved because other
|
|
335
|
+
// statements in the module may reference it.
|
|
336
|
+
const defaultExport = node.parent.type === utils_1.AST_NODE_TYPES.ExportDefaultDeclaration
|
|
337
|
+
? node.parent
|
|
338
|
+
: null;
|
|
339
|
+
const fixes = [
|
|
340
|
+
fixer.replaceTextRange(functionKeywordRange, functionKeywordReplacement),
|
|
341
|
+
fixer.insertTextAfterRange([node.range[1], node.range[1]], defaultExport ? `);\nexport default ${node.id.name};` : ');'),
|
|
342
|
+
fixer.replaceTextRange([node.id.range[0] - 1, node.id.range[1]], functionNameReplacement),
|
|
343
|
+
];
|
|
344
|
+
if (defaultExport) {
|
|
345
|
+
fixes.push(fixer.removeRange([defaultExport.range[0], node.range[0]]));
|
|
346
|
+
}
|
|
347
|
+
if (importFix) {
|
|
348
|
+
fixes.push(importFix);
|
|
349
|
+
}
|
|
350
|
+
return fixes;
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Wraps a `const X = <function>` initializer in `memo(...)` where it stands.
|
|
355
|
+
*
|
|
356
|
+
* The binding, its name and the function's own text are left untouched, so
|
|
357
|
+
* every reference to the component keeps resolving to the same name and an
|
|
358
|
+
* anonymous initializer is not forced into a spelling it did not have.
|
|
359
|
+
*/
|
|
360
|
+
function memoizeInitializer(context, node) {
|
|
361
|
+
return function fix(fixer) {
|
|
362
|
+
if (!isRewritableFunction(node)) {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
const { available, importFix } = planMemoBinding(context, fixer, node);
|
|
366
|
+
if (!available) {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
const fixes = [
|
|
370
|
+
fixer.insertTextBefore(node, `${MEMO_NAME}(`),
|
|
371
|
+
fixer.insertTextAfter(node, ')'),
|
|
372
|
+
];
|
|
373
|
+
if (importFix) {
|
|
374
|
+
fixes.push(importFix);
|
|
375
|
+
}
|
|
376
|
+
return fixes;
|
|
377
|
+
};
|
|
378
|
+
}
|
|
242
379
|
function checkFunction(context, node) {
|
|
243
380
|
const fileName = context.getFilename();
|
|
244
381
|
if (!fileName.endsWith('.tsx')) {
|
|
@@ -261,83 +398,24 @@ function checkFunction(context, node) {
|
|
|
261
398
|
parentNode.id.type === 'Identifier' &&
|
|
262
399
|
parentNode.id.name) ||
|
|
263
400
|
'component';
|
|
401
|
+
// Both spellings of a component carry the same remedy, so both carry an
|
|
402
|
+
// edit: the declaration one becomes a memoized const, and an initializer
|
|
403
|
+
// is wrapped in place.
|
|
404
|
+
const fixDeclaration = isDeclarationComponent && canHostConstDeclaration(parentNode);
|
|
405
|
+
const fixInitializer = isArrowComponent &&
|
|
406
|
+
parentNode.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
|
|
407
|
+
isWrappableInitializer(parentNode);
|
|
264
408
|
context.report({
|
|
265
409
|
node,
|
|
266
410
|
messageId: 'requireMemo',
|
|
267
411
|
data: {
|
|
268
412
|
name: componentName,
|
|
269
413
|
},
|
|
270
|
-
fix:
|
|
271
|
-
?
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const sourceCode = context.sourceCode;
|
|
276
|
-
const program = sourceCode.ast;
|
|
277
|
-
// Resolve `memo` through the scope chain at the fixed node. A
|
|
278
|
-
// binding that is not the helper import breaks the edit two
|
|
279
|
-
// ways: the inserted import collides with the existing
|
|
280
|
-
// declaration (TS2440, or TS2300 when that declaration is
|
|
281
|
-
// itself an import), and a binding visible at the fix site
|
|
282
|
-
// captures the emitted call with no compile error at all.
|
|
283
|
-
// Declining leaves the report standing so the author resolves
|
|
284
|
-
// the clash deliberately. `React.memo` is a member access on
|
|
285
|
-
// the default import rather than a `memo` binding, so it never
|
|
286
|
-
// reaches this path.
|
|
287
|
-
const existingMemo = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, node), MEMO_NAME);
|
|
288
|
-
if (existingMemo && !bindsMemoHelper(existingMemo)) {
|
|
289
|
-
return null;
|
|
290
|
-
}
|
|
291
|
-
let importFix = null;
|
|
292
|
-
if (!importsMemo(program)) {
|
|
293
|
-
importFix = buildImportExtensionFix(fixer, program);
|
|
294
|
-
if (!importFix) {
|
|
295
|
-
// Calculate relative path based on current file location
|
|
296
|
-
const currentFilePath = context.getFilename();
|
|
297
|
-
const importPath = calculateImportPath(currentFilePath);
|
|
298
|
-
const importStatement = `import { memo } from '${importPath}';`;
|
|
299
|
-
const firstImport = program.body.find((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
|
|
300
|
-
// An existing import hosts the helper import directly after
|
|
301
|
-
// it, keeping the module's imports contiguous. With none to
|
|
302
|
-
// follow, the shared anchor keeps the file's prologue in
|
|
303
|
-
// place: a `'use client'` directive only counts as one while
|
|
304
|
-
// it is the first statement, and a `#!` shebang only parses
|
|
305
|
-
// at character 0.
|
|
306
|
-
importFix = firstImport
|
|
307
|
-
? fixer.insertTextAfter(firstImport, `\n${importStatement}`)
|
|
308
|
-
: (0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, (0, importInsertion_1.importInsertionAnchor)(sourceCode), `${importStatement}\n`);
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
const functionKeywordRange = [
|
|
312
|
-
node.range[0],
|
|
313
|
-
node.range[0] + 'function'.length,
|
|
314
|
-
];
|
|
315
|
-
const functionKeywordReplacement = `const ${node.id.name} = memo(`;
|
|
316
|
-
// Step 3: Rename function
|
|
317
|
-
const functionNameReplacement = `function ${node.id.name}Unmemoized`;
|
|
318
|
-
// `export default const X = memo(...)` is a syntax error, so a
|
|
319
|
-
// default-exported declaration becomes a memoized const plus a
|
|
320
|
-
// trailing `export default X;`. The local binding is preserved
|
|
321
|
-
// because other statements in the module may reference it.
|
|
322
|
-
const defaultExport = parentNode.type === utils_1.AST_NODE_TYPES.ExportDefaultDeclaration
|
|
323
|
-
? parentNode
|
|
324
|
-
: null;
|
|
325
|
-
const fixes = [
|
|
326
|
-
fixer.replaceTextRange(functionKeywordRange, functionKeywordReplacement),
|
|
327
|
-
fixer.insertTextAfterRange([node.range[1], node.range[1]], defaultExport
|
|
328
|
-
? `);\nexport default ${node.id.name};`
|
|
329
|
-
: ');'),
|
|
330
|
-
fixer.replaceTextRange([node.id.range[0] - 1, node.id.range[1]], functionNameReplacement),
|
|
331
|
-
];
|
|
332
|
-
if (defaultExport) {
|
|
333
|
-
fixes.push(fixer.removeRange([defaultExport.range[0], node.range[0]]));
|
|
334
|
-
}
|
|
335
|
-
if (importFix) {
|
|
336
|
-
fixes.push(importFix);
|
|
337
|
-
}
|
|
338
|
-
return fixes;
|
|
339
|
-
}
|
|
340
|
-
: undefined,
|
|
414
|
+
fix: fixDeclaration
|
|
415
|
+
? memoizeDeclaration(context, node)
|
|
416
|
+
: fixInitializer
|
|
417
|
+
? memoizeInitializer(context, node)
|
|
418
|
+
: undefined,
|
|
341
419
|
});
|
|
342
420
|
}
|
|
343
421
|
}
|
|
@@ -72,9 +72,8 @@ export declare const ruleNameByIdentity: Map<unknown, string>;
|
|
|
72
72
|
*
|
|
73
73
|
* `RuleTester` passes `undefined` in that situation, which ESLint renders as
|
|
74
74
|
* `<input>` — a name with no extension, under which every path-gated rule is
|
|
75
|
-
* silent and contributes nothing. A bare `file
|
|
76
|
-
* departure that keeps those rules reachable
|
|
77
|
-
* fixture's own tester implies.
|
|
75
|
+
* silent and contributes nothing. A bare `file`/`react` basename is the smallest
|
|
76
|
+
* departure that keeps those rules reachable.
|
|
78
77
|
*/
|
|
79
78
|
export declare const defaultFilenameFor: (testCase: FixtureCase) => string;
|
|
80
79
|
/**
|
|
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.harvestFixtureCorpus = exports.severityWithOptions = exports.suggestionEditsOf = exports.suggestionRuleNames = exports.parserOptionsFor = exports.FALLBACK_FILENAMES = exports.defaultFilenameFor = exports.ruleNameByIdentity = exports.typeAwareRuleNames = exports.TS_TESTERS = exports.harvestOnce = void 0;
|
|
7
7
|
const fs_1 = __importDefault(require("fs"));
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const typescript_estree_1 = require("@typescript-eslint/typescript-estree");
|
|
9
10
|
const harvestRuleTesterCases_1 = require("./harvestRuleTesterCases");
|
|
10
11
|
/**
|
|
11
12
|
* The fixture corpus every fixer guard probes, keyed by RULE NAME.
|
|
@@ -98,17 +99,65 @@ exports.typeAwareRuleNames = new Set(fs_1.default
|
|
|
98
99
|
* suites and `../index` resolve to the same module instance under jest.
|
|
99
100
|
*/
|
|
100
101
|
exports.ruleNameByIdentity = new Map(Object.entries(plugin.rules).map(([name, rule]) => [rule, name]));
|
|
102
|
+
const parsesWithJsx = (code, jsx) => {
|
|
103
|
+
try {
|
|
104
|
+
// `range`/`loc` are not optional in practice: without them any comment in
|
|
105
|
+
// the snippet throws, which reads as an unparsable fixture.
|
|
106
|
+
(0, typescript_estree_1.parse)(code, { jsx, range: true, loc: true });
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
/** Decided once per snippet+preference; the corpus reprobes the same code. */
|
|
114
|
+
const extensionByCode = new Map();
|
|
115
|
+
/**
|
|
116
|
+
* `.ts` and `.tsx` are not ordered by permissiveness, so neither one can be the
|
|
117
|
+
* blanket default: only `.ts` accepts the angle-bracket assertion `<T>expr`, and
|
|
118
|
+
* only `.tsx` accepts JSX. The tester supplies a PREFERENCE, and the snippet
|
|
119
|
+
* overrides it only when that preference cannot parse the snippet at all.
|
|
120
|
+
*
|
|
121
|
+
* Taking the tester's word for it instead makes a JSX fixture in a `ruleTesterTs`
|
|
122
|
+
* suite a FATAL parse. Every consumer filters messages by `ruleId`, so the fatal
|
|
123
|
+
* is indistinguishable from the rule staying silent — a false clean over 168
|
|
124
|
+
* cases, four fifths of some rules' corpora. The converse costs one case: an
|
|
125
|
+
* angle-bracket assertion declared in a `ruleTesterJsx` suite.
|
|
126
|
+
*
|
|
127
|
+
* Correcting only on a fatal is what keeps this from churning: a snippet legal
|
|
128
|
+
* both ways stays on the extension it has always been probed under, so no
|
|
129
|
+
* path-gated rule silently changes which fixtures reach it.
|
|
130
|
+
*/
|
|
131
|
+
const extensionFor = (code, preferred) => {
|
|
132
|
+
const key = `${preferred}\u0000${code}`;
|
|
133
|
+
const cached = extensionByCode.get(key);
|
|
134
|
+
if (cached)
|
|
135
|
+
return cached;
|
|
136
|
+
const alternate = preferred === '.tsx' ? '.ts' : '.tsx';
|
|
137
|
+
// Only a `<` can make the two disagree, so the common case never parses.
|
|
138
|
+
const extension = !code.includes('<') || parsesWithJsx(code, preferred === '.tsx')
|
|
139
|
+
? preferred
|
|
140
|
+
: parsesWithJsx(code, alternate === '.tsx')
|
|
141
|
+
? alternate
|
|
142
|
+
: preferred;
|
|
143
|
+
extensionByCode.set(key, extension);
|
|
144
|
+
return extension;
|
|
145
|
+
};
|
|
101
146
|
/**
|
|
102
147
|
* The filename a case is probed under when it declares none.
|
|
103
148
|
*
|
|
104
149
|
* `RuleTester` passes `undefined` in that situation, which ESLint renders as
|
|
105
150
|
* `<input>` — a name with no extension, under which every path-gated rule is
|
|
106
|
-
* silent and contributes nothing. A bare `file
|
|
107
|
-
* departure that keeps those rules reachable
|
|
108
|
-
* fixture's own tester implies.
|
|
151
|
+
* silent and contributes nothing. A bare `file`/`react` basename is the smallest
|
|
152
|
+
* departure that keeps those rules reachable.
|
|
109
153
|
*/
|
|
110
|
-
const defaultFilenameFor = (testCase) =>
|
|
111
|
-
(testCase.
|
|
154
|
+
const defaultFilenameFor = (testCase) => {
|
|
155
|
+
if (testCase.filename)
|
|
156
|
+
return testCase.filename;
|
|
157
|
+
const jsxTester = testCase.tester === 'ruleTesterJsx';
|
|
158
|
+
const basename = jsxTester ? 'react' : 'file';
|
|
159
|
+
return `${basename}${extensionFor(testCase.code, jsxTester ? '.tsx' : '.ts')}`;
|
|
160
|
+
};
|
|
112
161
|
exports.defaultFilenameFor = defaultFilenameFor;
|
|
113
162
|
/**
|
|
114
163
|
* Second-chance filenames, used ONLY for a rule that produced no probe at all
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,80 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.121",
|
|
4
|
+
"date": "2026-08-06T10:37:08.150Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "enforce-dynamic-firebase-imports",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1790
|
|
11
|
+
],
|
|
12
|
+
"summary": "remediate a concise-bodied async arrow (closes #1790)"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "no-jsx-in-hooks",
|
|
16
|
+
"changeType": "fix",
|
|
17
|
+
"issues": [
|
|
18
|
+
1792
|
|
19
|
+
],
|
|
20
|
+
"summary": "report a concise arrow body that is itself a memoized JSX call (closes #1792)"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "no-redundant-usecallback-wrapper",
|
|
24
|
+
"changeType": "fix",
|
|
25
|
+
"issues": [
|
|
26
|
+
1793
|
|
27
|
+
],
|
|
28
|
+
"summary": "consult hookReturnObjects from the block-body arm (closes #1793)"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "no-undefined-null-passthrough",
|
|
32
|
+
"changeType": "fix",
|
|
33
|
+
"issues": [
|
|
34
|
+
1794
|
|
35
|
+
],
|
|
36
|
+
"summary": "detect the guard shapes in a block-bodied arrow (closes #1794)"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "prefer-usememo-over-useeffect-usestate",
|
|
40
|
+
"changeType": "fix",
|
|
41
|
+
"issues": [
|
|
42
|
+
1791
|
|
43
|
+
],
|
|
44
|
+
"summary": "recognise a block-bodied lazy initializer as state synchronization (closes #1791)"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"name": "require-memo",
|
|
48
|
+
"changeType": "fix",
|
|
49
|
+
"issues": [
|
|
50
|
+
1789
|
|
51
|
+
],
|
|
52
|
+
"summary": "wrap arrow and function-expression components in memo (closes #1789)"
|
|
53
|
+
}
|
|
54
|
+
]
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"version": "1.20.120",
|
|
58
|
+
"date": "2026-08-06T05:46:26.078Z",
|
|
59
|
+
"rules": [
|
|
60
|
+
{
|
|
61
|
+
"name": "enforce-microdiff",
|
|
62
|
+
"changeType": "fix",
|
|
63
|
+
"issues": [
|
|
64
|
+
1784
|
|
65
|
+
],
|
|
66
|
+
"summary": "give the arrow spelling the rewrite its declaration twin carries (closes #1784)"
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"name": "prefer-use-base62-id",
|
|
70
|
+
"changeType": "fix",
|
|
71
|
+
"issues": [
|
|
72
|
+
1782
|
|
73
|
+
],
|
|
74
|
+
"summary": "look through type-only wrappers to the ref name (closes #1782)"
|
|
75
|
+
}
|
|
76
|
+
]
|
|
77
|
+
},
|
|
2
78
|
{
|
|
3
79
|
"version": "1.20.119",
|
|
4
80
|
"date": "2026-08-06T04:34:04.981Z",
|