@blumintinc/eslint-plugin-blumint 1.20.86 → 1.20.87
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-assert-safe-object-key.js +7 -9
- package/lib/rules/enforce-memoize-async.js +8 -16
- package/lib/rules/enforce-memoize-getters.js +12 -16
- package/lib/rules/enforce-microdiff.js +14 -1
- package/lib/rules/enforce-querykey-ts.js +23 -7
- package/lib/rules/enforce-safe-stringify.js +7 -3
- package/lib/rules/enforce-snapshot-state-narrowing.js +24 -7
- package/lib/rules/enforce-stable-hash-spread-props.js +14 -3
- package/lib/rules/logical-top-to-bottom-grouping.js +70 -0
- package/lib/rules/no-array-length-in-deps.js +13 -1
- package/lib/rules/no-empty-dependency-use-callbacks.js +36 -0
- package/lib/rules/no-explicit-return-type.js +47 -59
- package/lib/rules/prefer-fragment-component.js +9 -27
- package/lib/rules/prefer-global-router-state-key.js +18 -24
- package/lib/rules/prefer-use-deep-compare-memo.js +14 -18
- package/lib/rules/react-memoize-literals.js +8 -7
- package/lib/rules/require-memo.js +10 -7
- package/lib/rules/require-memoize-jsx-returners.js +8 -13
- package/lib/utils/importInsertion.d.ts +52 -0
- package/lib/utils/importInsertion.js +121 -0
- package/lib/utils/importRemoval.d.ts +57 -0
- package/lib/utils/importRemoval.js +397 -0
- package/package.json +1 -1
- package/release-manifest.json +150 -0
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.noExplicitReturnType = void 0;
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
|
+
const importRemoval_1 = require("../utils/importRemoval");
|
|
6
7
|
const defaultOptions = {
|
|
7
8
|
allowRecursiveFunctions: true,
|
|
8
9
|
allowOverloadedFunctions: true,
|
|
@@ -725,16 +726,45 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
|
725
726
|
return (Boolean(mergedOptions.allowVoidReturnTypes) &&
|
|
726
727
|
declaresVoidResult(returnType));
|
|
727
728
|
}
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
729
|
+
/**
|
|
730
|
+
* Reports an annotation, taking with it any import it was the only
|
|
731
|
+
* consumer of. The two are one fix: applying either half alone leaves the
|
|
732
|
+
* file worse than applying neither.
|
|
733
|
+
*
|
|
734
|
+
* Orphanhood is judged against this annotation's own removal and the file
|
|
735
|
+
* as it stands, never against what the rest of the `--fix` run might also
|
|
736
|
+
* delete. A sibling annotation naming the same type may be
|
|
737
|
+
* `eslint-disable`d — which a rule cannot see, since suppression is
|
|
738
|
+
* applied to reports after they are emitted — so an edit that assumes its
|
|
739
|
+
* sibling will also go deletes an import the surviving annotation still
|
|
740
|
+
* references, trading an unused import for a dangling type. Judging one
|
|
741
|
+
* edit at a time is suppression-safe by construction: a suppressed
|
|
742
|
+
* report's fix never applies, so it can never have been depended on.
|
|
743
|
+
*
|
|
744
|
+
* The cost is that a type shared by several strippable annotations is not
|
|
745
|
+
* unbound in a single pass; each pass removes the annotations it can see,
|
|
746
|
+
* and only a pass that leaves the binding with no reference at all
|
|
747
|
+
* removes the import.
|
|
748
|
+
*/
|
|
749
|
+
function reportAnnotation(node, returnType, strippable) {
|
|
750
|
+
const importRanges = strippable
|
|
751
|
+
? (0, importRemoval_1.planOrphanedImportRemoval)(sourceCode, [returnType.range])
|
|
752
|
+
: null;
|
|
753
|
+
context.report({
|
|
754
|
+
node: returnType,
|
|
755
|
+
messageId: strippable
|
|
756
|
+
? 'noExplicitReturnTypeInferable'
|
|
757
|
+
: 'noExplicitReturnTypeNonInferable',
|
|
758
|
+
data: { functionKind: describeFunctionKind(node) },
|
|
759
|
+
...(importRanges
|
|
760
|
+
? {
|
|
761
|
+
fix: (fixer) => [
|
|
762
|
+
fixer.remove(returnType),
|
|
763
|
+
...importRanges.map((range) => fixer.removeRange([range[0], range[1]])),
|
|
764
|
+
],
|
|
765
|
+
}
|
|
766
|
+
: {}),
|
|
767
|
+
});
|
|
738
768
|
}
|
|
739
769
|
return {
|
|
740
770
|
FunctionDeclaration(node) {
|
|
@@ -749,17 +779,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
|
749
779
|
isReturnTypeRequiredByRecursion(node)) {
|
|
750
780
|
return;
|
|
751
781
|
}
|
|
752
|
-
|
|
753
|
-
context.report({
|
|
754
|
-
node: returnType,
|
|
755
|
-
messageId: isInferable
|
|
756
|
-
? 'noExplicitReturnTypeInferable'
|
|
757
|
-
: 'noExplicitReturnTypeNonInferable',
|
|
758
|
-
data: { functionKind: describeFunctionKind(node) },
|
|
759
|
-
...(isInferable
|
|
760
|
-
? { fix: (fixer) => fixReturnType(fixer, node) }
|
|
761
|
-
: {}),
|
|
762
|
-
});
|
|
782
|
+
reportAnnotation(node, returnType, Boolean(node.body));
|
|
763
783
|
},
|
|
764
784
|
FunctionExpression(node) {
|
|
765
785
|
const returnType = node.returnType;
|
|
@@ -776,12 +796,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
|
776
796
|
isReturnTypeRequiredByRecursion(node)) {
|
|
777
797
|
return;
|
|
778
798
|
}
|
|
779
|
-
|
|
780
|
-
node: returnType,
|
|
781
|
-
messageId: 'noExplicitReturnTypeInferable',
|
|
782
|
-
data: { functionKind: describeFunctionKind(node) },
|
|
783
|
-
fix: (fixer) => fixReturnType(fixer, node),
|
|
784
|
-
});
|
|
799
|
+
reportAnnotation(node, returnType, true);
|
|
785
800
|
},
|
|
786
801
|
ArrowFunctionExpression(node) {
|
|
787
802
|
const returnType = node.returnType;
|
|
@@ -793,12 +808,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
|
793
808
|
isReturnTypeRequiredByRecursion(node)) {
|
|
794
809
|
return;
|
|
795
810
|
}
|
|
796
|
-
|
|
797
|
-
node: returnType,
|
|
798
|
-
messageId: 'noExplicitReturnTypeInferable',
|
|
799
|
-
data: { functionKind: describeFunctionKind(node) },
|
|
800
|
-
fix: (fixer) => fixReturnType(fixer, node),
|
|
801
|
-
});
|
|
811
|
+
reportAnnotation(node, returnType, true);
|
|
802
812
|
},
|
|
803
813
|
TSMethodSignature(node) {
|
|
804
814
|
const returnType = node.returnType;
|
|
@@ -811,11 +821,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
|
811
821
|
isOverloadedFunction(node)) {
|
|
812
822
|
return;
|
|
813
823
|
}
|
|
814
|
-
|
|
815
|
-
node: returnType,
|
|
816
|
-
messageId: 'noExplicitReturnTypeNonInferable',
|
|
817
|
-
data: { functionKind: describeFunctionKind(node) },
|
|
818
|
-
});
|
|
824
|
+
reportAnnotation(node, returnType, false);
|
|
819
825
|
},
|
|
820
826
|
MethodDefinition(node) {
|
|
821
827
|
const returnType = node.value.returnType;
|
|
@@ -829,17 +835,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
|
829
835
|
isReturnTypeRequiredByRecursion(node)) {
|
|
830
836
|
return;
|
|
831
837
|
}
|
|
832
|
-
|
|
833
|
-
context.report({
|
|
834
|
-
node: returnType,
|
|
835
|
-
messageId: isInferable
|
|
836
|
-
? 'noExplicitReturnTypeInferable'
|
|
837
|
-
: 'noExplicitReturnTypeNonInferable',
|
|
838
|
-
data: { functionKind: describeFunctionKind(node) },
|
|
839
|
-
...(isInferable
|
|
840
|
-
? { fix: (fixer) => fixReturnType(fixer, node) }
|
|
841
|
-
: {}),
|
|
842
|
-
});
|
|
838
|
+
reportAnnotation(node, returnType, Boolean(node.value.body));
|
|
843
839
|
},
|
|
844
840
|
TSAbstractMethodDefinition(node) {
|
|
845
841
|
const returnType = node.value.returnType;
|
|
@@ -852,11 +848,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
|
852
848
|
}
|
|
853
849
|
// Abstract methods never have bodies; they are always non-inferable and
|
|
854
850
|
// intentionally have no fixer.
|
|
855
|
-
|
|
856
|
-
node: returnType,
|
|
857
|
-
messageId: 'noExplicitReturnTypeNonInferable',
|
|
858
|
-
data: { functionKind: describeFunctionKind(node) },
|
|
859
|
-
});
|
|
851
|
+
reportAnnotation(node, returnType, false);
|
|
860
852
|
},
|
|
861
853
|
TSDeclareFunction(node) {
|
|
862
854
|
const returnType = node.returnType;
|
|
@@ -869,11 +861,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
|
869
861
|
isOverloadedTsDeclareFunction(node)) {
|
|
870
862
|
return;
|
|
871
863
|
}
|
|
872
|
-
|
|
873
|
-
node: returnType,
|
|
874
|
-
messageId: 'noExplicitReturnTypeNonInferable',
|
|
875
|
-
data: { functionKind: describeFunctionKind(node) },
|
|
876
|
-
});
|
|
864
|
+
reportAnnotation(node, returnType, false);
|
|
877
865
|
},
|
|
878
866
|
};
|
|
879
867
|
},
|
|
@@ -5,6 +5,7 @@ const utils_1 = require("@typescript-eslint/utils");
|
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
6
|
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
7
7
|
const disableDirectives_1 = require("../utils/disableDirectives");
|
|
8
|
+
const importInsertion_1 = require("../utils/importInsertion");
|
|
8
9
|
const REACT_MODULE = 'react';
|
|
9
10
|
const FRAGMENT_NAME = 'Fragment';
|
|
10
11
|
/**
|
|
@@ -151,29 +152,6 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
|
151
152
|
}
|
|
152
153
|
return null;
|
|
153
154
|
}
|
|
154
|
-
/**
|
|
155
|
-
* Where a brand-new import statement can be spliced in without changing
|
|
156
|
-
* what the file's directives govern. A whole-line insertion directly before
|
|
157
|
-
* the first statement slides in between an `eslint-disable-next-line`
|
|
158
|
-
* comment and the line it applies to, silently retargeting that directive
|
|
159
|
-
* at the import. Anchoring above such comments keeps every directive
|
|
160
|
-
* pointed at the code its author aimed it at.
|
|
161
|
-
*/
|
|
162
|
-
function findImportAnchor() {
|
|
163
|
-
const firstStatement = sourceCode.ast.body[0];
|
|
164
|
-
let anchor = firstStatement;
|
|
165
|
-
const leadingComments = sourceCode.getCommentsBefore(firstStatement);
|
|
166
|
-
for (let index = leadingComments.length - 1; index >= 0; index--) {
|
|
167
|
-
const comment = leadingComments[index];
|
|
168
|
-
const [directive] = (0, disableDirectives_1.parseDisableDirectives)([comment]);
|
|
169
|
-
if (directive?.kind !== 'disable-next-line' ||
|
|
170
|
-
comment.loc.end.line + 1 !== anchor.loc.start.line) {
|
|
171
|
-
break;
|
|
172
|
-
}
|
|
173
|
-
anchor = comment;
|
|
174
|
-
}
|
|
175
|
-
return anchor;
|
|
176
|
-
}
|
|
177
155
|
/**
|
|
178
156
|
* Adds Fragment to an existing react import or creates a new one. The
|
|
179
157
|
* emitted shape is a value named specifier, since the rewritten element
|
|
@@ -205,10 +183,14 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
|
205
183
|
return fixer.insertTextAfter(lastSpecifier, `, { ${FRAGMENT_NAME} }`);
|
|
206
184
|
}
|
|
207
185
|
}
|
|
208
|
-
// No
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
186
|
+
// No react declaration can host the specifier, so the fix emits its own
|
|
187
|
+
// declaration. The shared anchor keeps the file's prologue intact: a
|
|
188
|
+
// `'use client'` directive stays the first statement, a `#!` shebang
|
|
189
|
+
// stays at character 0, a header comment stays above the code it covers,
|
|
190
|
+
// and an `eslint-disable-next-line` keeps pointing at the line its author
|
|
191
|
+
// aimed it at instead of at the inserted import.
|
|
192
|
+
const anchor = (0, importInsertion_1.importInsertionAnchor)(sourceCode);
|
|
193
|
+
return (0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, anchor, `import { ${FRAGMENT_NAME} } from '${REACT_MODULE}';\n`);
|
|
212
194
|
}
|
|
213
195
|
return {
|
|
214
196
|
// Find JSX Fragment shorthand (<></>)
|
|
@@ -8,6 +8,7 @@ const path_1 = __importDefault(require("path"));
|
|
|
8
8
|
const utils_1 = require("@typescript-eslint/utils");
|
|
9
9
|
const createRule_1 = require("../utils/createRule");
|
|
10
10
|
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
11
|
+
const importInsertion_1 = require("../utils/importInsertion");
|
|
11
12
|
// The module's path below the project root doubles as the bare specifier,
|
|
12
13
|
// which is precisely why the root tsconfig `paths` and the Jest mapper resolve
|
|
13
14
|
// it.
|
|
@@ -321,23 +322,6 @@ exports.preferGlobalRouterStateKey = (0, createRule_1.createRule)({
|
|
|
321
322
|
function findImportKey(importMap, predicate) {
|
|
322
323
|
return Array.from(importMap.entries()).find(([, value]) => predicate(value))?.[0];
|
|
323
324
|
}
|
|
324
|
-
/**
|
|
325
|
-
* Helper to find the last directive prologue in a statement list
|
|
326
|
-
*/
|
|
327
|
-
function findLastDirective(body) {
|
|
328
|
-
let lastDirective;
|
|
329
|
-
for (const stmt of body) {
|
|
330
|
-
if (stmt.type === utils_1.AST_NODE_TYPES.ExpressionStatement &&
|
|
331
|
-
stmt.expression.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
332
|
-
typeof stmt.expression.value === 'string' &&
|
|
333
|
-
typeof stmt.directive === 'string') {
|
|
334
|
-
lastDirective = stmt;
|
|
335
|
-
continue;
|
|
336
|
-
}
|
|
337
|
-
break;
|
|
338
|
-
}
|
|
339
|
-
return lastDirective;
|
|
340
|
-
}
|
|
341
325
|
return {
|
|
342
326
|
// Track imports from queryKeys.ts
|
|
343
327
|
ImportDeclaration(node) {
|
|
@@ -514,13 +498,23 @@ exports.preferGlobalRouterStateKey = (0, createRule_1.createRule)({
|
|
|
514
498
|
fixes.push(fixer.insertTextBefore(firstImport, importText));
|
|
515
499
|
}
|
|
516
500
|
else {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
501
|
+
// A file's first import may cross only the
|
|
502
|
+
// whitespace the source opens with. The
|
|
503
|
+
// shared anchor is the floor of that climb:
|
|
504
|
+
// it clears the directive prologue, but
|
|
505
|
+
// also a `#!` shebang or a leading header
|
|
506
|
+
// comment, which text spliced at character
|
|
507
|
+
// 0 would displace.
|
|
508
|
+
const anchor = (0, importInsertion_1.importInsertionAnchor)(sourceCode);
|
|
509
|
+
const anchorIndex = anchor.kind === 'before'
|
|
510
|
+
? anchor.target.range[0]
|
|
511
|
+
: anchor.index;
|
|
512
|
+
const opensFile = sourceCode.text
|
|
513
|
+
.slice(0, anchorIndex)
|
|
514
|
+
.trim() === '';
|
|
515
|
+
fixes.push((0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, opensFile
|
|
516
|
+
? { kind: 'index', index: 0 }
|
|
517
|
+
: anchor, importText));
|
|
524
518
|
}
|
|
525
519
|
}
|
|
526
520
|
scheduledQueryKeyNamedImports.add(suggestedConstant);
|
|
@@ -4,6 +4,7 @@ exports.preferUseDeepCompareMemo = void 0;
|
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
6
|
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
7
|
+
const importInsertion_1 = require("../utils/importInsertion");
|
|
7
8
|
const DEEP_COMPARE_MODULE = '@blumintinc/use-deep-compare';
|
|
8
9
|
const DEEP_COMPARE_HOOK = 'useDeepCompareMemo';
|
|
9
10
|
// Consider these as memoizing hooks producing stable references
|
|
@@ -199,28 +200,23 @@ function bindsHookImport(variable, hookImport) {
|
|
|
199
200
|
variable.defs.every((def) => def.node === hookImport));
|
|
200
201
|
}
|
|
201
202
|
function ensureDeepCompareImportFixes(context, fixer) {
|
|
202
|
-
const fixes = [];
|
|
203
203
|
const sourceCode = context.sourceCode;
|
|
204
204
|
const program = sourceCode.ast;
|
|
205
205
|
// If already imported anywhere, skip adding
|
|
206
206
|
if (findDeepCompareMemoImport(program))
|
|
207
|
-
return
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
importText = `${indent}${importText}`;
|
|
221
|
-
fixes.push(fixer.insertTextBefore(firstImport, importText));
|
|
222
|
-
}
|
|
223
|
-
return fixes;
|
|
207
|
+
return [];
|
|
208
|
+
// The shared anchor keeps the insertion below whatever opens the file: text
|
|
209
|
+
// spliced above a `#!` shebang stops the file parsing, and text above a
|
|
210
|
+
// `'use client'` directive or a header comment strips the prologue of the
|
|
211
|
+
// meaning it only carries while it leads.
|
|
212
|
+
const anchor = (0, importInsertion_1.importInsertionAnchor)(sourceCode);
|
|
213
|
+
const indent = (0, importInsertion_1.importAnchorIndent)(sourceCode, anchor);
|
|
214
|
+
const importText = `${indent}import { ${DEEP_COMPARE_HOOK} } from '${DEEP_COMPARE_MODULE}';\n`;
|
|
215
|
+
// Widened to the anchor's line start because the emitted statement carries
|
|
216
|
+
// its own indentation, leaving the displaced anchor sitting on the original.
|
|
217
|
+
return [
|
|
218
|
+
(0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, (0, importInsertion_1.importAnchorLineStart)(sourceCode, anchor), importText),
|
|
219
|
+
];
|
|
224
220
|
}
|
|
225
221
|
function findVariableInScope(scope, name) {
|
|
226
222
|
if (!scope)
|
|
@@ -4,6 +4,7 @@ exports.reactMemoizeLiterals = void 0;
|
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
6
|
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
7
|
+
const importInsertion_1 = require("../utils/importInsertion");
|
|
7
8
|
const LITERAL_DESCRIPTOR_BY_TYPE = {
|
|
8
9
|
[utils_1.AST_NODE_TYPES.ObjectExpression]: {
|
|
9
10
|
literalType: 'object literal',
|
|
@@ -672,7 +673,8 @@ function bindsReactHook(variable, hookName) {
|
|
|
672
673
|
* already imports it. Extending an existing declaration is preferred over a new
|
|
673
674
|
* one so a file keeps a single `react` specifier list.
|
|
674
675
|
*/
|
|
675
|
-
function buildHookImportFix(fixer,
|
|
676
|
+
function buildHookImportFix(fixer, sourceCode, hookName) {
|
|
677
|
+
const program = sourceCode.ast;
|
|
676
678
|
if (importsHook(program, hookName)) {
|
|
677
679
|
return null;
|
|
678
680
|
}
|
|
@@ -694,12 +696,11 @@ function buildHookImportFix(fixer, program, hookName) {
|
|
|
694
696
|
if (defaultSpecifier) {
|
|
695
697
|
return fixer.insertTextAfter(defaultSpecifier, `, { ${hookName} }`);
|
|
696
698
|
}
|
|
699
|
+
// The shared anchor keeps the declaration below whatever prologue the file
|
|
700
|
+
// opens with: a `'use client'` directive stops being a directive, and a `#!`
|
|
701
|
+
// shebang stops parsing, once a statement is spliced above it.
|
|
697
702
|
const statement = `import { ${hookName} } from '${REACT_MODULE}';\n`;
|
|
698
|
-
|
|
699
|
-
const anchor = firstImport ?? program.body[0];
|
|
700
|
-
return anchor
|
|
701
|
-
? fixer.insertTextBefore(anchor, statement)
|
|
702
|
-
: fixer.insertTextAfterRange([0, 0], statement);
|
|
703
|
+
return (0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, (0, importInsertion_1.importInsertionAnchor)(sourceCode), statement);
|
|
703
704
|
}
|
|
704
705
|
/**
|
|
705
706
|
* Scope kinds whose bindings are established once per module evaluation:
|
|
@@ -834,7 +835,7 @@ function buildMemoSuggestions(node, descriptor, sourceCode, context) {
|
|
|
834
835
|
// two disjoint ranges can be applied independently, stranding the
|
|
835
836
|
// wrapper without its binding.
|
|
836
837
|
const fixes = [];
|
|
837
|
-
const importFix = buildHookImportFix(fixer, sourceCode
|
|
838
|
+
const importFix = buildHookImportFix(fixer, sourceCode, descriptor.memoHook);
|
|
838
839
|
if (importFix) {
|
|
839
840
|
fixes.push(importFix);
|
|
840
841
|
}
|
|
@@ -6,6 +6,7 @@ exports.requireMemo = void 0;
|
|
|
6
6
|
const utils_1 = require("@typescript-eslint/utils");
|
|
7
7
|
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
8
8
|
const createRule_1 = require("../utils/createRule");
|
|
9
|
+
const importInsertion_1 = require("../utils/importInsertion");
|
|
9
10
|
const isComponentExplicitlyUnmemoized = (componentName) => componentName.toLowerCase().includes('unmemoized');
|
|
10
11
|
// React's universal convention: only PascalCase-initial identifiers are
|
|
11
12
|
// treated as components. camelCase names are render-prop callbacks or plain
|
|
@@ -190,15 +191,17 @@ function checkFunction(context, node) {
|
|
|
190
191
|
// Calculate relative path based on current file location
|
|
191
192
|
const currentFilePath = context.getFilename();
|
|
192
193
|
const importPath = calculateImportPath(currentFilePath);
|
|
193
|
-
// Find the first import statement to insert after
|
|
194
|
-
const firstImport = program.body.find((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
|
|
195
|
-
// Add new import statement for memo
|
|
196
194
|
const importStatement = `import { memo } from '${importPath}';`;
|
|
195
|
+
const firstImport = program.body.find((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
|
|
196
|
+
// An existing import hosts the helper import directly after
|
|
197
|
+
// it, keeping the module's imports contiguous. With none to
|
|
198
|
+
// follow, the shared anchor keeps the file's prologue in
|
|
199
|
+
// place: a `'use client'` directive only counts as one while
|
|
200
|
+
// it is the first statement, and a `#!` shebang only parses
|
|
201
|
+
// at character 0.
|
|
197
202
|
importFix = firstImport
|
|
198
|
-
?
|
|
199
|
-
|
|
200
|
-
: // Insert at the start of the file
|
|
201
|
-
fixer.insertTextBeforeRange([program.range[0], program.range[0]], importStatement + '\n');
|
|
203
|
+
? fixer.insertTextAfter(firstImport, `\n${importStatement}`)
|
|
204
|
+
: (0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, (0, importInsertion_1.importInsertionAnchor)(sourceCode), `${importStatement}\n`);
|
|
202
205
|
}
|
|
203
206
|
}
|
|
204
207
|
const functionKeywordRange = [
|
|
@@ -5,6 +5,7 @@ const utils_1 = require("@typescript-eslint/utils");
|
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
6
|
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
7
7
|
const disableDirectives_1 = require("../utils/disableDirectives");
|
|
8
|
+
const importInsertion_1 = require("../utils/importInsertion");
|
|
8
9
|
const MEMOIZE_PREFERRED_MODULE = '@blumintinc/typescript-memoize';
|
|
9
10
|
const MEMOIZE_MODULES = new Set([
|
|
10
11
|
MEMOIZE_PREFERRED_MODULE,
|
|
@@ -407,19 +408,13 @@ function getImportFixes(fixer, sourceCode, hasMemoizeImport, scheduledImportFix)
|
|
|
407
408
|
return { fixes, scheduledImportFix: true };
|
|
408
409
|
}
|
|
409
410
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
const importLine = `${leadingWhitespace}import { Memoize } from '${MEMOIZE_PREFERRED_MODULE}';\n`;
|
|
418
|
-
fixes.push(fixer.insertTextBeforeRange([lineStart, lineStart], importLine));
|
|
419
|
-
}
|
|
420
|
-
else {
|
|
421
|
-
fixes.push(fixer.insertTextBeforeRange([0, 0], `import { Memoize } from '${MEMOIZE_PREFERRED_MODULE}';\n`));
|
|
422
|
-
}
|
|
411
|
+
// The anchor sits past the file's prologue, so a `'use client'` directive
|
|
412
|
+
// keeps its position as the first statement and a `#!` shebang keeps
|
|
413
|
+
// character 0. Emitting the anchor's own indentation after the import leaves
|
|
414
|
+
// the displaced statement indented exactly as it was.
|
|
415
|
+
const anchor = (0, importInsertion_1.importInsertionAnchor)(sourceCode);
|
|
416
|
+
const indent = (0, importInsertion_1.importAnchorIndent)(sourceCode, anchor);
|
|
417
|
+
fixes.push((0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, anchor, `import { Memoize } from '${MEMOIZE_PREFERRED_MODULE}';\n${indent}`));
|
|
423
418
|
return { fixes, scheduledImportFix: true };
|
|
424
419
|
}
|
|
425
420
|
exports.requireMemoizeJsxReturners = (0, createRule_1.createRule)({
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { TSESLint, TSESTree } from '@typescript-eslint/utils';
|
|
2
|
+
/**
|
|
3
|
+
* Where a brand-new top-of-file import belongs. `before` anchors at a node (or
|
|
4
|
+
* a comment bound to the node below it); `index` is a raw character offset for
|
|
5
|
+
* files with nothing to anchor to — a shebang-only, directive-only, or empty
|
|
6
|
+
* file.
|
|
7
|
+
*/
|
|
8
|
+
export type ImportInsertionAnchor = {
|
|
9
|
+
kind: 'before';
|
|
10
|
+
target: TSESTree.Node | TSESTree.Comment;
|
|
11
|
+
} | {
|
|
12
|
+
kind: 'index';
|
|
13
|
+
index: number;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The source a rule needs to place an import. Narrower than
|
|
17
|
+
* `TSESLint.SourceCode` so tests can drive the helper without a full linter
|
|
18
|
+
* run.
|
|
19
|
+
*/
|
|
20
|
+
export type ImportInsertionSource = Pick<TSESLint.SourceCode, 'text' | 'ast' | 'getCommentsBefore'>;
|
|
21
|
+
/**
|
|
22
|
+
* Resolves the position where a fixer may insert a new import declaration
|
|
23
|
+
* without changing what the file's prologue governs. A `'use client'` /
|
|
24
|
+
* `'use server'` directive must stay the first statement or it stops being a
|
|
25
|
+
* directive; a `#!` shebang must stay at character 0 or the file stops
|
|
26
|
+
* parsing; a leading `// @ts-nocheck` (or any header comment) keeps its
|
|
27
|
+
* meaning only above the code it covers. The anchor therefore lands on the
|
|
28
|
+
* first import if one exists, else on the first non-directive statement —
|
|
29
|
+
* both positions sit past the prologue because comments are excluded from a
|
|
30
|
+
* node's range — and only degenerate files (no statements at all) fall back
|
|
31
|
+
* to a computed offset.
|
|
32
|
+
*/
|
|
33
|
+
export declare function importInsertionAnchor(sourceCode: ImportInsertionSource): ImportInsertionAnchor;
|
|
34
|
+
/**
|
|
35
|
+
* Applies `text` at `anchor`. Callers keep full control of the inserted
|
|
36
|
+
* statement and its separators; the only adjustment made here is a leading
|
|
37
|
+
* newline when a raw-offset anchor sits mid-line (after a directive's `;` or
|
|
38
|
+
* at the unterminated end of a shebang), where splicing text verbatim would
|
|
39
|
+
* fuse the import onto the prologue's line.
|
|
40
|
+
*/
|
|
41
|
+
export declare function insertAtImportAnchor(sourceCode: ImportInsertionSource, fixer: TSESLint.RuleFixer, anchor: ImportInsertionAnchor, text: string): TSESLint.RuleFix;
|
|
42
|
+
/**
|
|
43
|
+
* Widens `anchor` to the start of its line, for rules that emit
|
|
44
|
+
* `${indent}import …\n` so the displaced anchor keeps its own indentation.
|
|
45
|
+
* Raw-offset anchors pass through: they never sit inside an indented line.
|
|
46
|
+
*/
|
|
47
|
+
export declare function importAnchorLineStart(sourceCode: ImportInsertionSource, anchor: ImportInsertionAnchor): ImportInsertionAnchor;
|
|
48
|
+
/**
|
|
49
|
+
* The whitespace prefix of the anchor's line, for fixers that replicate the
|
|
50
|
+
* anchor's indentation on the inserted import.
|
|
51
|
+
*/
|
|
52
|
+
export declare function importAnchorIndent(sourceCode: ImportInsertionSource, anchor: ImportInsertionAnchor): string;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.importAnchorIndent = exports.importAnchorLineStart = exports.insertAtImportAnchor = exports.importInsertionAnchor = void 0;
|
|
4
|
+
const utils_1 = require("@typescript-eslint/utils");
|
|
5
|
+
const disableDirectives_1 = require("./disableDirectives");
|
|
6
|
+
/**
|
|
7
|
+
* A comment that governs the line directly below it. Splicing an import
|
|
8
|
+
* between such a comment and its subject silently retargets the suppression
|
|
9
|
+
* at the import, so the anchor must climb above the whole run.
|
|
10
|
+
*/
|
|
11
|
+
function bindsNextLine(comment) {
|
|
12
|
+
const [directive] = (0, disableDirectives_1.parseDisableDirectives)([comment]);
|
|
13
|
+
if (directive?.kind === 'disable-next-line') {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
const value = comment.value.trim();
|
|
17
|
+
return value.startsWith('@ts-expect-error') || value.startsWith('@ts-ignore');
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Walks upward from `anchor` over line-binding suppression comments that sit
|
|
21
|
+
* on consecutive lines, so the eventual insertion lands above them rather
|
|
22
|
+
* than between a suppression and the line it covers.
|
|
23
|
+
*/
|
|
24
|
+
function climbBoundComments(sourceCode, anchor) {
|
|
25
|
+
let target = anchor;
|
|
26
|
+
const comments = sourceCode.getCommentsBefore(anchor);
|
|
27
|
+
for (let index = comments.length - 1; index >= 0; index--) {
|
|
28
|
+
const comment = comments[index];
|
|
29
|
+
if (!bindsNextLine(comment) ||
|
|
30
|
+
comment.loc.end.line + 1 !== target.loc.start.line) {
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
target = comment;
|
|
34
|
+
}
|
|
35
|
+
return target;
|
|
36
|
+
}
|
|
37
|
+
function isDirective(statement) {
|
|
38
|
+
return (statement.type === utils_1.AST_NODE_TYPES.ExpressionStatement &&
|
|
39
|
+
typeof statement.directive === 'string');
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Resolves the position where a fixer may insert a new import declaration
|
|
43
|
+
* without changing what the file's prologue governs. A `'use client'` /
|
|
44
|
+
* `'use server'` directive must stay the first statement or it stops being a
|
|
45
|
+
* directive; a `#!` shebang must stay at character 0 or the file stops
|
|
46
|
+
* parsing; a leading `// @ts-nocheck` (or any header comment) keeps its
|
|
47
|
+
* meaning only above the code it covers. The anchor therefore lands on the
|
|
48
|
+
* first import if one exists, else on the first non-directive statement —
|
|
49
|
+
* both positions sit past the prologue because comments are excluded from a
|
|
50
|
+
* node's range — and only degenerate files (no statements at all) fall back
|
|
51
|
+
* to a computed offset.
|
|
52
|
+
*/
|
|
53
|
+
function importInsertionAnchor(sourceCode) {
|
|
54
|
+
const { body } = sourceCode.ast;
|
|
55
|
+
const anchorNode = body.find((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration) ?? body.find((statement) => !isDirective(statement));
|
|
56
|
+
if (anchorNode) {
|
|
57
|
+
return {
|
|
58
|
+
kind: 'before',
|
|
59
|
+
target: climbBoundComments(sourceCode, anchorNode),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const lastDirective = [...body].reverse().find(isDirective);
|
|
63
|
+
if (lastDirective) {
|
|
64
|
+
// Start of the line after the directive, so a trailing same-line comment
|
|
65
|
+
// stays glued to its statement and no blank line is spliced in.
|
|
66
|
+
const lineEnd = sourceCode.text.indexOf('\n', lastDirective.range[1]);
|
|
67
|
+
return {
|
|
68
|
+
kind: 'index',
|
|
69
|
+
index: lineEnd === -1 ? sourceCode.text.length : lineEnd + 1,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (sourceCode.text.startsWith('#!')) {
|
|
73
|
+
const lineEnd = sourceCode.text.indexOf('\n');
|
|
74
|
+
return {
|
|
75
|
+
kind: 'index',
|
|
76
|
+
index: lineEnd === -1 ? sourceCode.text.length : lineEnd + 1,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return { kind: 'index', index: 0 };
|
|
80
|
+
}
|
|
81
|
+
exports.importInsertionAnchor = importInsertionAnchor;
|
|
82
|
+
/**
|
|
83
|
+
* Applies `text` at `anchor`. Callers keep full control of the inserted
|
|
84
|
+
* statement and its separators; the only adjustment made here is a leading
|
|
85
|
+
* newline when a raw-offset anchor sits mid-line (after a directive's `;` or
|
|
86
|
+
* at the unterminated end of a shebang), where splicing text verbatim would
|
|
87
|
+
* fuse the import onto the prologue's line.
|
|
88
|
+
*/
|
|
89
|
+
function insertAtImportAnchor(sourceCode, fixer, anchor, text) {
|
|
90
|
+
if (anchor.kind === 'before') {
|
|
91
|
+
return fixer.insertTextBefore(anchor.target, text);
|
|
92
|
+
}
|
|
93
|
+
const needsNewline = anchor.index > 0 && sourceCode.text[anchor.index - 1] !== '\n';
|
|
94
|
+
return fixer.insertTextBeforeRange([anchor.index, anchor.index], needsNewline ? `\n${text}` : text);
|
|
95
|
+
}
|
|
96
|
+
exports.insertAtImportAnchor = insertAtImportAnchor;
|
|
97
|
+
/**
|
|
98
|
+
* Widens `anchor` to the start of its line, for rules that emit
|
|
99
|
+
* `${indent}import …\n` so the displaced anchor keeps its own indentation.
|
|
100
|
+
* Raw-offset anchors pass through: they never sit inside an indented line.
|
|
101
|
+
*/
|
|
102
|
+
function importAnchorLineStart(sourceCode, anchor) {
|
|
103
|
+
if (anchor.kind === 'index') {
|
|
104
|
+
return anchor;
|
|
105
|
+
}
|
|
106
|
+
const lineStart = sourceCode.text.lastIndexOf('\n', anchor.target.range[0] - 1) + 1;
|
|
107
|
+
return { kind: 'index', index: lineStart };
|
|
108
|
+
}
|
|
109
|
+
exports.importAnchorLineStart = importAnchorLineStart;
|
|
110
|
+
/**
|
|
111
|
+
* The whitespace prefix of the anchor's line, for fixers that replicate the
|
|
112
|
+
* anchor's indentation on the inserted import.
|
|
113
|
+
*/
|
|
114
|
+
function importAnchorIndent(sourceCode, anchor) {
|
|
115
|
+
const from = anchor.kind === 'before' ? anchor.target.range[0] : anchor.index;
|
|
116
|
+
const lineStart = sourceCode.text.lastIndexOf('\n', from - 1) + 1;
|
|
117
|
+
const match = /^[ \t]*/.exec(sourceCode.text.slice(lineStart, from));
|
|
118
|
+
return match?.[0] ?? '';
|
|
119
|
+
}
|
|
120
|
+
exports.importAnchorIndent = importAnchorIndent;
|
|
121
|
+
//# sourceMappingURL=importInsertion.js.map
|