@blumintinc/eslint-plugin-blumint 1.20.87 → 1.20.89
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/memo-compare-deeply-complex-props.js +53 -0
- package/lib/rules/no-explicit-return-type.js +147 -35
- package/lib/rules/no-redundant-param-types.js +70 -82
- package/lib/rules/no-type-assertion-returns.js +13 -2
- package/lib/rules/use-latest-callback.js +65 -0
- package/lib/utils/importRemoval.d.ts +31 -8
- package/lib/utils/importRemoval.js +42 -9
- package/package.json +1 -1
- package/release-manifest.json +52 -0
package/lib/index.js
CHANGED
|
@@ -577,7 +577,60 @@ function checkUnionType(ts, unionType, checker, visited) {
|
|
|
577
577
|
function isIntersectionType(ts, flags) {
|
|
578
578
|
return (flags & ts.TypeFlags.Intersection) !== 0;
|
|
579
579
|
}
|
|
580
|
+
/**
|
|
581
|
+
* Returns true when `type` holds a JavaScript primitive at runtime — a string,
|
|
582
|
+
* number, boolean, bigint, symbol, enum member, or any literal/template/mapping
|
|
583
|
+
* flavour of those. A union qualifies when every constituent does.
|
|
584
|
+
*
|
|
585
|
+
* Deliberately narrower than `isPrimitiveType`, which also accepts
|
|
586
|
+
* `null`/`undefined`/`void`/`never`: those carry no runtime *value* to reason
|
|
587
|
+
* about, and inside an intersection they collapse the whole type to `never`
|
|
588
|
+
* anyway, so admitting them would prove nothing about the surviving members.
|
|
589
|
+
*/
|
|
590
|
+
function isRuntimePrimitiveType(ts, type) {
|
|
591
|
+
const flags = type.flags ?? 0;
|
|
592
|
+
if ((flags &
|
|
593
|
+
(ts.TypeFlags.StringLike |
|
|
594
|
+
ts.TypeFlags.NumberLike |
|
|
595
|
+
ts.TypeFlags.BooleanLike |
|
|
596
|
+
ts.TypeFlags.BigIntLike |
|
|
597
|
+
ts.TypeFlags.ESSymbolLike |
|
|
598
|
+
ts.TypeFlags.EnumLike)) !==
|
|
599
|
+
0) {
|
|
600
|
+
return true;
|
|
601
|
+
}
|
|
602
|
+
// `('a' | 'b') & {}` can surface the literal union as a single member.
|
|
603
|
+
if ((flags & ts.TypeFlags.Union) !== 0) {
|
|
604
|
+
const members = type.types;
|
|
605
|
+
return (members.length > 0 &&
|
|
606
|
+
members.every((member) => isRuntimePrimitiveType(ts, member)));
|
|
607
|
+
}
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Detects the open-ended literal union idiom — a primitive intersected with an
|
|
612
|
+
* object type, e.g. `'alert' | 'button' | (string & {})`, of which React's own
|
|
613
|
+
* `AriaRole` is the most widespread instance. The `& {}` exists purely to defeat
|
|
614
|
+
* TypeScript's literal-union widening so editors keep offering autocomplete on
|
|
615
|
+
* the named members; it contributes no runtime object identity.
|
|
616
|
+
*
|
|
617
|
+
* A value of `string & X` is still assignable to `string`, so it is a primitive
|
|
618
|
+
* at runtime no matter what `X` is. That makes `compareDeeply('role')` provably
|
|
619
|
+
* inert: the consuming comparator only reaches deep equality behind a
|
|
620
|
+
* `typeof value === 'object'` guard, and `isEqual` on two primitives is `===`
|
|
621
|
+
* regardless. So the reduction keys on "the intersection has a primitive
|
|
622
|
+
* member", not on the object member being empty — branded primitives
|
|
623
|
+
* (`string & { brand: 'x' }`) reduce identically, and the widener is spelled
|
|
624
|
+
* several ways in the wild (`{}`, `Record<never, never>`, `Record<string, never>`)
|
|
625
|
+
* that no structural emptiness test or name allowlist covers uniformly.
|
|
626
|
+
*/
|
|
627
|
+
function isPrimitiveBackedIntersection(ts, intersectionType) {
|
|
628
|
+
return intersectionType.types.some((member) => isRuntimePrimitiveType(ts, member));
|
|
629
|
+
}
|
|
580
630
|
function checkIntersectionType(ts, intersectionType, checker, visited) {
|
|
631
|
+
if (isPrimitiveBackedIntersection(ts, intersectionType)) {
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
581
634
|
return intersectionType.types.some((t) => isComplexTypeInternal(ts, t, checker, visited));
|
|
582
635
|
}
|
|
583
636
|
function isPrimitiveType(ts, flags) {
|
|
@@ -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 disableDirectives_1 = require("../utils/disableDirectives");
|
|
6
7
|
const importRemoval_1 = require("../utils/importRemoval");
|
|
7
8
|
const defaultOptions = {
|
|
8
9
|
allowRecursiveFunctions: true,
|
|
@@ -640,6 +641,80 @@ function declaresVoidResult(returnType) {
|
|
|
640
641
|
return (typeArguments?.length === 1 &&
|
|
641
642
|
typeArguments[0].type === utils_1.AST_NODE_TYPES.TSVoidKeyword);
|
|
642
643
|
}
|
|
644
|
+
function containsRange(outer, inner) {
|
|
645
|
+
return inner[0] >= outer[0] && inner[1] <= outer[1];
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Partitions annotations into the sets whose removals have to travel together.
|
|
649
|
+
*
|
|
650
|
+
* An import read from two annotations is unbound only once both are gone, so
|
|
651
|
+
* neither annotation may unbind it alone — and a fix may only count on the other
|
|
652
|
+
* removal happening if it performs that removal itself. Annotations that jointly
|
|
653
|
+
* keep an import alive are therefore merged into one batch, and every other
|
|
654
|
+
* annotation is a batch of one, judged against the file as it stands.
|
|
655
|
+
*
|
|
656
|
+
* `candidates` must already exclude suppressed reports: a suppressed report's
|
|
657
|
+
* fix never runs, so its annotation — and the reference it holds — outlives the
|
|
658
|
+
* pass.
|
|
659
|
+
*/
|
|
660
|
+
function batchAnnotations(source, candidates) {
|
|
661
|
+
const parents = candidates.map((_, index) => index);
|
|
662
|
+
const rootOf = (index) => {
|
|
663
|
+
let root = index;
|
|
664
|
+
while (parents[root] !== root) {
|
|
665
|
+
root = parents[root];
|
|
666
|
+
}
|
|
667
|
+
return root;
|
|
668
|
+
};
|
|
669
|
+
const ownerOf = (reference) => candidates.findIndex((candidate) => containsRange(candidate.returnType.range, reference));
|
|
670
|
+
for (const binding of (0, importRemoval_1.importBindingReferences)(source)) {
|
|
671
|
+
// A single reference cannot be shared, so the one-annotation judgement
|
|
672
|
+
// already covers it.
|
|
673
|
+
if (binding.references.length < 2)
|
|
674
|
+
continue;
|
|
675
|
+
const owners = new Set();
|
|
676
|
+
const escapes = binding.references.some((reference) => {
|
|
677
|
+
const owner = ownerOf(reference);
|
|
678
|
+
if (owner === -1)
|
|
679
|
+
return true;
|
|
680
|
+
owners.add(owner);
|
|
681
|
+
return false;
|
|
682
|
+
});
|
|
683
|
+
// A reference outside every strippable annotation survives whatever this
|
|
684
|
+
// pass deletes, so no batch can unbind the import.
|
|
685
|
+
if (escapes || owners.size < 2)
|
|
686
|
+
continue;
|
|
687
|
+
const [target, ...rest] = [...owners].map(rootOf);
|
|
688
|
+
for (const root of rest) {
|
|
689
|
+
if (root !== target) {
|
|
690
|
+
parents[root] = target;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
const batches = new Map();
|
|
695
|
+
candidates.forEach((candidate, index) => {
|
|
696
|
+
const root = rootOf(index);
|
|
697
|
+
const batch = batches.get(root);
|
|
698
|
+
if (batch) {
|
|
699
|
+
batch.push(candidate);
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
batches.set(root, [candidate]);
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
return [...batches.values()];
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* The ranges a single fix deletes for `batch`: the annotations themselves plus
|
|
709
|
+
* any import they were the last consumers of. `null` when a binding the removal
|
|
710
|
+
* orphans cannot be unbound safely — the caller then drops the fix rather than
|
|
711
|
+
* emitting the half of it that leaves a binding behind.
|
|
712
|
+
*/
|
|
713
|
+
function planRemoval(source, batch) {
|
|
714
|
+
const annotations = batch.map((entry) => entry.returnType.range);
|
|
715
|
+
const imports = (0, importRemoval_1.planOrphanedImportRemoval)(source, annotations);
|
|
716
|
+
return imports ? [...annotations, ...imports] : null;
|
|
717
|
+
}
|
|
643
718
|
exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
644
719
|
name: 'no-explicit-return-type',
|
|
645
720
|
meta: {
|
|
@@ -677,6 +752,14 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
|
677
752
|
const filename = context.getFilename();
|
|
678
753
|
const sourceCode = context.getSourceCode();
|
|
679
754
|
const visitorKeys = sourceCode.visitorKeys;
|
|
755
|
+
const pending = [];
|
|
756
|
+
/**
|
|
757
|
+
* Whether ESLint will discard a report, resolved the way ESLint resolves
|
|
758
|
+
* it. A fix that deletes several annotations at once is counting on all of
|
|
759
|
+
* them being reportable; a suppressed one keeps its type reference, so it
|
|
760
|
+
* must be left out of every batch.
|
|
761
|
+
*/
|
|
762
|
+
const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
|
|
680
763
|
// Built at most once per file, and only when a direct self-reference has
|
|
681
764
|
// already been ruled out.
|
|
682
765
|
let returnReferenceGraph;
|
|
@@ -727,46 +810,75 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
|
727
810
|
declaresVoidResult(returnType));
|
|
728
811
|
}
|
|
729
812
|
/**
|
|
730
|
-
*
|
|
731
|
-
*
|
|
732
|
-
|
|
813
|
+
* Holds an annotation for `flushReports`, which decides once the file has
|
|
814
|
+
* been walked which removals may travel together.
|
|
815
|
+
*/
|
|
816
|
+
function reportAnnotation(node, returnType, strippable) {
|
|
817
|
+
pending.push({ node, returnType, strippable });
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Emits every held report, each carrying the import cleanup its own
|
|
821
|
+
* removal makes necessary. An annotation and the import it unbinds are one
|
|
822
|
+
* fix: applying either half alone leaves the file worse than applying
|
|
823
|
+
* neither.
|
|
733
824
|
*
|
|
734
|
-
* Orphanhood is judged against
|
|
735
|
-
*
|
|
736
|
-
*
|
|
737
|
-
*
|
|
738
|
-
*
|
|
739
|
-
*
|
|
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.
|
|
825
|
+
* Orphanhood is judged against a single fix's own deletions, never against
|
|
826
|
+
* what the rest of the `--fix` run might also delete. That is what makes
|
|
827
|
+
* the cleanup suppression-safe, and it is why a type several annotations
|
|
828
|
+
* share cannot be unbound by any one of them: a sibling annotation may be
|
|
829
|
+
* `eslint-disable`d, and deleting "its" import strands a reference that
|
|
830
|
+
* outlives the pass — a compile error in place of an unused import.
|
|
743
831
|
*
|
|
744
|
-
*
|
|
745
|
-
*
|
|
746
|
-
*
|
|
747
|
-
*
|
|
832
|
+
* Waiting for the last such annotation does not work either. Once every
|
|
833
|
+
* annotation is stripped the rule has nothing left to report, so no later
|
|
834
|
+
* fix exists to carry the cleanup and the binding stays orphaned for good
|
|
835
|
+
* (issue #1654). The annotations that jointly hold an import alive are
|
|
836
|
+
* therefore removed by one fix, which owes two things: it deletes all of
|
|
837
|
+
* them itself rather than assuming sibling reports land, and it counts
|
|
838
|
+
* only annotations whose reports ESLint will not discard.
|
|
748
839
|
*/
|
|
749
|
-
function
|
|
750
|
-
const
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
840
|
+
function flushReports() {
|
|
841
|
+
const strippable = pending.filter((entry) => entry.strippable);
|
|
842
|
+
// Every annotation starts with the judgement it would get alone, so a
|
|
843
|
+
// batch can only ever add a cleanup, never withdraw one.
|
|
844
|
+
const plans = new Map();
|
|
845
|
+
for (const entry of strippable) {
|
|
846
|
+
const plan = planRemoval(sourceCode, [entry]);
|
|
847
|
+
if (plan) {
|
|
848
|
+
plans.set(entry, plan);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
const batches = batchAnnotations(sourceCode, strippable.filter((entry) => !isReportSuppressed(entry.returnType)));
|
|
852
|
+
for (const batch of batches) {
|
|
853
|
+
if (batch.length < 2)
|
|
854
|
+
continue;
|
|
855
|
+
const plan = planRemoval(sourceCode, batch);
|
|
856
|
+
if (!plan)
|
|
857
|
+
continue;
|
|
858
|
+
for (const entry of batch) {
|
|
859
|
+
plans.set(entry, plan);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
for (const entry of pending) {
|
|
863
|
+
const plan = plans.get(entry);
|
|
864
|
+
context.report({
|
|
865
|
+
node: entry.returnType,
|
|
866
|
+
messageId: entry.strippable
|
|
867
|
+
? 'noExplicitReturnTypeInferable'
|
|
868
|
+
: 'noExplicitReturnTypeNonInferable',
|
|
869
|
+
data: { functionKind: describeFunctionKind(entry.node) },
|
|
870
|
+
...(plan
|
|
871
|
+
? {
|
|
872
|
+
fix: (fixer) => plan.map((range) => fixer.removeRange([range[0], range[1]])),
|
|
873
|
+
}
|
|
874
|
+
: {}),
|
|
875
|
+
});
|
|
876
|
+
}
|
|
768
877
|
}
|
|
769
878
|
return {
|
|
879
|
+
'Program:exit'() {
|
|
880
|
+
flushReports();
|
|
881
|
+
},
|
|
770
882
|
FunctionDeclaration(node) {
|
|
771
883
|
const returnType = node.returnType;
|
|
772
884
|
if (!returnType)
|
|
@@ -3,33 +3,30 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.noRedundantParamTypes = void 0;
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
function
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
function isAssignmentPatternWithTypeAnnotation(node) {
|
|
22
|
-
return (node.type === utils_1.AST_NODE_TYPES.AssignmentPattern &&
|
|
23
|
-
node.left.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
24
|
-
node.left.typeAnnotation !== undefined);
|
|
6
|
+
const importRemoval_1 = require("../utils/importRemoval");
|
|
7
|
+
/**
|
|
8
|
+
* The annotation a parameter carries, or `undefined` when it has none. A
|
|
9
|
+
* parameter with a default value holds its annotation on the pattern it assigns
|
|
10
|
+
* to, and only an identifier pattern is read there: a destructured parameter
|
|
11
|
+
* with a default keeps its annotation.
|
|
12
|
+
*/
|
|
13
|
+
function annotationOf(param) {
|
|
14
|
+
if (param.type === utils_1.AST_NODE_TYPES.AssignmentPattern) {
|
|
15
|
+
return param.left.type === utils_1.AST_NODE_TYPES.Identifier
|
|
16
|
+
? param.left.typeAnnotation
|
|
17
|
+
: undefined;
|
|
18
|
+
}
|
|
19
|
+
return param.typeAnnotation;
|
|
25
20
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
21
|
+
/**
|
|
22
|
+
* The slice a fix deletes to drop `typeAnnotation`. An optional marker goes with
|
|
23
|
+
* it: contextual typing supplies a parameter's optionality along with its type,
|
|
24
|
+
* so a `?` left behind keeps half of the duplication the rule exists to remove.
|
|
25
|
+
*/
|
|
26
|
+
function annotationRemovalRange(typeAnnotation, sourceCode) {
|
|
27
|
+
const [typeStart, typeEnd] = typeAnnotation.range;
|
|
30
28
|
const hasQuestionMark = typeStart > 0 && sourceCode.getText().charAt(typeStart - 1) === '?';
|
|
31
|
-
|
|
32
|
-
return fixer.removeRange([startPos, typeEnd]);
|
|
29
|
+
return [hasQuestionMark ? typeStart - 1 : typeStart, typeEnd];
|
|
33
30
|
}
|
|
34
31
|
function hasRedundantTypeAnnotation(node) {
|
|
35
32
|
const parent = node.parent;
|
|
@@ -69,67 +66,58 @@ exports.noRedundantParamTypes = (0, createRule_1.createRule)({
|
|
|
69
66
|
},
|
|
70
67
|
defaultOptions: [],
|
|
71
68
|
create(context) {
|
|
69
|
+
const sourceCode = context.getSourceCode();
|
|
70
|
+
/**
|
|
71
|
+
* Reports a parameter annotation, taking with it any import it was the only
|
|
72
|
+
* consumer of. The two are one fix: applying either half alone leaves the
|
|
73
|
+
* file worse than applying neither — a stripped annotation with its import
|
|
74
|
+
* left behind fails `no-unused-vars`, and since this rule's own report is
|
|
75
|
+
* resolved by the fix, nothing re-reports the debt.
|
|
76
|
+
*
|
|
77
|
+
* Orphanhood is judged against this one annotation's own removal and the
|
|
78
|
+
* file as it stands, never against what the rest of the `--fix` run might
|
|
79
|
+
* also delete. A sibling annotation naming the same type may be
|
|
80
|
+
* `eslint-disable`d — which a rule cannot see, since suppression is applied
|
|
81
|
+
* to reports after they are emitted — so an edit that assumes its sibling
|
|
82
|
+
* will also go deletes an import the surviving annotation still references,
|
|
83
|
+
* trading an unused import for a dangling type. Judging one edit at a time
|
|
84
|
+
* is suppression-safe by construction: a suppressed report's fix never
|
|
85
|
+
* applies, so it can never have been depended on.
|
|
86
|
+
*
|
|
87
|
+
* The cost is that a type shared by several strippable annotations is not
|
|
88
|
+
* unbound in a single pass; each pass removes the annotations it can see,
|
|
89
|
+
* and only a pass that leaves the binding with no reference at all removes
|
|
90
|
+
* the import.
|
|
91
|
+
*/
|
|
92
|
+
function reportParam(param, typeAnnotation) {
|
|
93
|
+
const removal = annotationRemovalRange(typeAnnotation, sourceCode);
|
|
94
|
+
const importRanges = (0, importRemoval_1.planOrphanedImportRemoval)(sourceCode, [removal]);
|
|
95
|
+
context.report({
|
|
96
|
+
node: param,
|
|
97
|
+
messageId: 'redundantParamType',
|
|
98
|
+
data: {
|
|
99
|
+
paramText: sourceCode.getText(param).replace(/\s+/g, ' ').trim(),
|
|
100
|
+
},
|
|
101
|
+
// No plan means no binding can be unbound safely, so the annotation
|
|
102
|
+
// stays too: the report without a fixer is the lesser damage.
|
|
103
|
+
...(importRanges
|
|
104
|
+
? {
|
|
105
|
+
fix: (fixer) => [
|
|
106
|
+
fixer.removeRange([removal[0], removal[1]]),
|
|
107
|
+
...importRanges.map((range) => fixer.removeRange([range[0], range[1]])),
|
|
108
|
+
],
|
|
109
|
+
}
|
|
110
|
+
: {}),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
72
113
|
return {
|
|
73
114
|
ArrowFunctionExpression(node) {
|
|
74
115
|
if (!hasRedundantTypeAnnotation(node))
|
|
75
116
|
return;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
.getText(param)
|
|
81
|
-
.replace(/\s+/g, ' ')
|
|
82
|
-
.trim();
|
|
83
|
-
if (isIdentifierWithTypeAnnotation(param)) {
|
|
84
|
-
context.report({
|
|
85
|
-
node: param,
|
|
86
|
-
messageId: 'redundantParamType',
|
|
87
|
-
data: { paramText },
|
|
88
|
-
fix(fixer) {
|
|
89
|
-
return removeTypeAnnotation(fixer, param.typeAnnotation, sourceCode);
|
|
90
|
-
},
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
else if (isRestElementWithTypeAnnotation(param)) {
|
|
94
|
-
context.report({
|
|
95
|
-
node: param,
|
|
96
|
-
messageId: 'redundantParamType',
|
|
97
|
-
data: { paramText },
|
|
98
|
-
fix(fixer) {
|
|
99
|
-
return removeTypeAnnotation(fixer, param.typeAnnotation, sourceCode);
|
|
100
|
-
},
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
else if (isObjectPatternWithTypeAnnotation(param)) {
|
|
104
|
-
context.report({
|
|
105
|
-
node: param,
|
|
106
|
-
messageId: 'redundantParamType',
|
|
107
|
-
data: { paramText },
|
|
108
|
-
fix(fixer) {
|
|
109
|
-
return removeTypeAnnotation(fixer, param.typeAnnotation, sourceCode);
|
|
110
|
-
},
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
else if (isArrayPatternWithTypeAnnotation(param)) {
|
|
114
|
-
context.report({
|
|
115
|
-
node: param,
|
|
116
|
-
messageId: 'redundantParamType',
|
|
117
|
-
data: { paramText },
|
|
118
|
-
fix(fixer) {
|
|
119
|
-
return removeTypeAnnotation(fixer, param.typeAnnotation, sourceCode);
|
|
120
|
-
},
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
else if (isAssignmentPatternWithTypeAnnotation(param)) {
|
|
124
|
-
const { left } = param;
|
|
125
|
-
context.report({
|
|
126
|
-
node: param,
|
|
127
|
-
messageId: 'redundantParamType',
|
|
128
|
-
data: { paramText },
|
|
129
|
-
fix(fixer) {
|
|
130
|
-
return removeTypeAnnotation(fixer, left.typeAnnotation, sourceCode);
|
|
131
|
-
},
|
|
132
|
-
});
|
|
117
|
+
node.params.forEach((param) => {
|
|
118
|
+
const typeAnnotation = annotationOf(param);
|
|
119
|
+
if (typeAnnotation) {
|
|
120
|
+
reportParam(param, typeAnnotation);
|
|
133
121
|
}
|
|
134
122
|
});
|
|
135
123
|
},
|
|
@@ -136,14 +136,25 @@ exports.noTypeAssertionReturns = (0, createRule_1.createRule)({
|
|
|
136
136
|
function isInsideJSXAttributeOrObjectProperty(node) {
|
|
137
137
|
let current = node;
|
|
138
138
|
while (current?.parent) {
|
|
139
|
-
// Direct JSX attribute
|
|
140
|
-
|
|
139
|
+
// Direct JSX attribute, in either spelling. A named attribute
|
|
140
|
+
// (`title={x as T}`) and a spread (`{...(x as T)}`) feed the receiving
|
|
141
|
+
// component's props identically, so they earn the same carve-out: what
|
|
142
|
+
// the enclosing function returns is a JSXElement, never the asserted
|
|
143
|
+
// value, and the object is re-checked against the component's prop
|
|
144
|
+
// types at the JSX call site. This is the argument already accepted for
|
|
145
|
+
// call and new arguments above.
|
|
146
|
+
if (current.parent.type === utils_1.AST_NODE_TYPES.JSXAttribute ||
|
|
147
|
+
current.parent.type === utils_1.AST_NODE_TYPES.JSXSpreadAttribute) {
|
|
141
148
|
return true;
|
|
142
149
|
}
|
|
143
150
|
// Object property (which could be JSX props)
|
|
144
151
|
if (current.parent.type === utils_1.AST_NODE_TYPES.Property) {
|
|
145
152
|
return true;
|
|
146
153
|
}
|
|
154
|
+
// A SpreadElement is deliberately absent: `return { ...(x as T) }`
|
|
155
|
+
// splices the asserted value's own members into the returned value, so
|
|
156
|
+
// the cast is exactly the unvalidated data reaching callers that this
|
|
157
|
+
// rule exists to catch. Unlike a JSX spread, nothing re-checks it.
|
|
147
158
|
current = current.parent;
|
|
148
159
|
}
|
|
149
160
|
return false;
|
|
@@ -263,6 +263,55 @@ const brokenOpenCallText = (sourceCode, call, callback, head, indentUnit) => {
|
|
|
263
263
|
const comma = wantsTrailingComma(sourceCode, call) ? ',' : '';
|
|
264
264
|
return `${head}(\n${calleeIndent}${moved}${comma}\n${callIndent})`;
|
|
265
265
|
};
|
|
266
|
+
/** Whether a range sits wholly inside one of the ranges a fix deletes. */
|
|
267
|
+
const fallsInside = (ranges, range) => ranges.some(([start, end]) => start <= range[0] && range[1] <= end);
|
|
268
|
+
/**
|
|
269
|
+
* Whether the binding belongs to a function rather than to the module. A
|
|
270
|
+
* module-scope binding is left alone because this file cannot settle whether it
|
|
271
|
+
* is dead: it may be exported, re-exported, or declared for a side effect,
|
|
272
|
+
* whereas a binding declared inside a component or hook is reachable only from
|
|
273
|
+
* the references this file spells out.
|
|
274
|
+
*/
|
|
275
|
+
const isFunctionLocal = (variable) => {
|
|
276
|
+
for (let scope = variable.scope; scope; scope = scope.upper) {
|
|
277
|
+
if (scope.type === 'function') {
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return false;
|
|
282
|
+
};
|
|
283
|
+
/** The bindings a reference inside one of the deleted ranges resolves to. */
|
|
284
|
+
const variablesReferencedIn = (root, deletedRanges) => {
|
|
285
|
+
const referenced = new Set();
|
|
286
|
+
const walk = (scope) => {
|
|
287
|
+
for (const reference of scope.references) {
|
|
288
|
+
if (reference.resolved &&
|
|
289
|
+
fallsInside(deletedRanges, reference.identifier.range)) {
|
|
290
|
+
referenced.add(reference.resolved);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
scope.childScopes.forEach(walk);
|
|
294
|
+
};
|
|
295
|
+
walk(root);
|
|
296
|
+
return referenced;
|
|
297
|
+
};
|
|
298
|
+
/**
|
|
299
|
+
* Whether deleting the given ranges would strip the last read of a binding
|
|
300
|
+
* declared inside a function, leaving a declaration — and whatever imports feed
|
|
301
|
+
* it — that nothing uses.
|
|
302
|
+
*
|
|
303
|
+
* A dependency array can hold the sole reference to a value computed for it
|
|
304
|
+
* alone, which is precisely what `no-array-length-in-deps` produces when it
|
|
305
|
+
* hoists `const listHash = useMemo(() => stableHash(list), [list])` and points
|
|
306
|
+
* the dependency at it. Dropping the array then strands that statement, turning
|
|
307
|
+
* a clean file into one `no-unused-vars` rejects with a violation this plugin
|
|
308
|
+
* cannot itself fix (issue #1652). Only a write left behind counts as no use at
|
|
309
|
+
* all, matching how an unused-variable check reads the result.
|
|
310
|
+
*/
|
|
311
|
+
const orphansLocalBinding = (root, deletedRanges) => [...variablesReferencedIn(root, deletedRanges)]
|
|
312
|
+
.filter((variable) => variable.defs.length > 0 && isFunctionLocal(variable))
|
|
313
|
+
.some((variable) => variable.references.every((reference) => !reference.isRead() ||
|
|
314
|
+
fallsInside(deletedRanges, reference.identifier.range)));
|
|
266
315
|
exports.useLatestCallback = (0, createRule_1.createRule)({
|
|
267
316
|
name: 'use-latest-callback',
|
|
268
317
|
meta: {
|
|
@@ -676,6 +725,14 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
|
|
|
676
725
|
}
|
|
677
726
|
return texts;
|
|
678
727
|
};
|
|
728
|
+
// Everything a rewritten call carries past its callback argument — the
|
|
729
|
+
// dependency array, and any further argument — is absent from the
|
|
730
|
+
// replacement text, so those ranges are what the fix deletes.
|
|
731
|
+
const droppedRanges = batchedConversions.map((conversion) => [
|
|
732
|
+
conversion.node.arguments[0].range[1],
|
|
733
|
+
conversion.node.range[1],
|
|
734
|
+
]);
|
|
735
|
+
const programScope = ASTHelpers_1.ASTHelpers.getScope(context, program);
|
|
679
736
|
// Every call-site conversion and the import rewrite ride on ONE fix
|
|
680
737
|
// from ONE report. ESLint discards a multi-part fix wholesale when any
|
|
681
738
|
// part conflicts with another rule's fix and retries it on the next
|
|
@@ -700,6 +757,14 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
|
|
|
700
757
|
if (!batchedConversions.every(reachesHook)) {
|
|
701
758
|
return null;
|
|
702
759
|
}
|
|
760
|
+
// Deleting the dependency arrays must not strand a binding that
|
|
761
|
+
// exists only to be listed in one. The batch is atomic, so a single
|
|
762
|
+
// orphaned binding withholds all of it, and the violation still
|
|
763
|
+
// stands as a report: the author drops the dead declaration
|
|
764
|
+
// together with the array.
|
|
765
|
+
if (orphansLocalBinding(programScope, droppedRanges)) {
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
703
768
|
const texts = conversionTexts();
|
|
704
769
|
return [
|
|
705
770
|
...batchedConversions.map((conversion) => fixer.replaceText(conversion.node, texts.get(conversion.node))),
|
|
@@ -34,6 +34,22 @@ export declare function removeImportBindingFixes(source: ImportRemovalSource, fi
|
|
|
34
34
|
* its own rather than a specifier and is left alone.
|
|
35
35
|
*/
|
|
36
36
|
export declare function importBindingSpecifierOf(variable: TSESLint.Scope.Variable): ImportBindingSpecifier | undefined;
|
|
37
|
+
/** An import binding together with the ranges that reference it. */
|
|
38
|
+
export type ImportBindingUse = {
|
|
39
|
+
specifier: ImportBindingSpecifier;
|
|
40
|
+
references: TextRange[];
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Every import binding the file declares, paired with the positions that read
|
|
44
|
+
* it.
|
|
45
|
+
*
|
|
46
|
+
* A caller deleting several regions at once needs this to decide *which* of its
|
|
47
|
+
* own deletions a binding depends on before asking
|
|
48
|
+
* {@link planOrphanedImportRemoval} to unbind it: a binding read from two
|
|
49
|
+
* regions is unbound only if both go, and only a caller that deletes both in one
|
|
50
|
+
* fix may claim that.
|
|
51
|
+
*/
|
|
52
|
+
export declare function importBindingReferences(source: ImportRemovalSource): ImportBindingUse[];
|
|
37
53
|
/**
|
|
38
54
|
* The extra ranges a fix must delete so that removing `removed` leaves nothing
|
|
39
55
|
* bound to nothing, or `null` when some binding would be orphaned yet cannot be
|
|
@@ -45,13 +61,20 @@ export declare function importBindingSpecifierOf(variable: TSESLint.Scope.Variab
|
|
|
45
61
|
* file that lints clean into one that fails `no-unused-vars`, and since the
|
|
46
62
|
* original report is resolved by the fix, nothing re-reports the debt.
|
|
47
63
|
*
|
|
48
|
-
* `removed` is one fix's own deletion
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
64
|
+
* `removed` is **one fix's own deletion**, judged against the file as it stands.
|
|
65
|
+
* Several ranges may be passed only when a single fix deletes all of them, since
|
|
66
|
+
* ESLint applies a fix whole or not at all. Passing ranges that belong to other
|
|
67
|
+
* reports is unsound in two ways: a sibling report may be suppressed (ESLint
|
|
68
|
+
* applies `eslint-disable` after the rule emits its reports, so the sibling's fix
|
|
69
|
+
* never runs), and a sibling fix that overlaps another rule's fix is dropped for
|
|
70
|
+
* the pass. Either way the surviving reference outlives the import this helper
|
|
71
|
+
* was told to unbind — trading an unused import for a dangling type reference, a
|
|
72
|
+
* lint warning for a compile error.
|
|
73
|
+
*
|
|
74
|
+
* A caller batching several of its own edits therefore owes two things: the
|
|
75
|
+
* edits must ship as one fix, and the reports they came from must be checked
|
|
76
|
+
* with `createSuppressionChecker` so that a suppressed one is never counted on.
|
|
77
|
+
* {@link importBindingReferences} exposes the reference sets such a caller needs
|
|
78
|
+
* to work out which edits belong in the same batch.
|
|
56
79
|
*/
|
|
57
80
|
export declare function planOrphanedImportRemoval(source: ImportRemovalSource, removed: readonly TextRange[]): TextRange[] | null;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.planOrphanedImportRemoval = exports.importBindingSpecifierOf = exports.removeImportBindingFixes = exports.planImportBindingRemoval = void 0;
|
|
3
|
+
exports.planOrphanedImportRemoval = exports.importBindingReferences = exports.importBindingSpecifierOf = exports.removeImportBindingFixes = exports.planImportBindingRemoval = void 0;
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const disableDirectives_1 = require("./disableDirectives");
|
|
6
6
|
function contains(outer, inner) {
|
|
@@ -221,6 +221,32 @@ function allVariables(scopeManager) {
|
|
|
221
221
|
}
|
|
222
222
|
return variables;
|
|
223
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* Every import binding the file declares, paired with the positions that read
|
|
226
|
+
* it.
|
|
227
|
+
*
|
|
228
|
+
* A caller deleting several regions at once needs this to decide *which* of its
|
|
229
|
+
* own deletions a binding depends on before asking
|
|
230
|
+
* {@link planOrphanedImportRemoval} to unbind it: a binding read from two
|
|
231
|
+
* regions is unbound only if both go, and only a caller that deletes both in one
|
|
232
|
+
* fix may claim that.
|
|
233
|
+
*/
|
|
234
|
+
function importBindingReferences(source) {
|
|
235
|
+
const uses = [];
|
|
236
|
+
for (const variable of allVariables(source.scopeManager)) {
|
|
237
|
+
if (!isImportBindingVariable(variable))
|
|
238
|
+
continue;
|
|
239
|
+
const specifier = importBindingSpecifierOf(variable);
|
|
240
|
+
if (!specifier)
|
|
241
|
+
continue;
|
|
242
|
+
uses.push({
|
|
243
|
+
specifier,
|
|
244
|
+
references: variable.references.map((reference) => reference.identifier.range),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
return uses;
|
|
248
|
+
}
|
|
249
|
+
exports.importBindingReferences = importBindingReferences;
|
|
224
250
|
function isAstNode(value) {
|
|
225
251
|
return (typeof value === 'object' &&
|
|
226
252
|
value !== null &&
|
|
@@ -329,14 +355,21 @@ function exportedBindingKeys(ast) {
|
|
|
329
355
|
* file that lints clean into one that fails `no-unused-vars`, and since the
|
|
330
356
|
* original report is resolved by the fix, nothing re-reports the debt.
|
|
331
357
|
*
|
|
332
|
-
* `removed` is one fix's own deletion
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
358
|
+
* `removed` is **one fix's own deletion**, judged against the file as it stands.
|
|
359
|
+
* Several ranges may be passed only when a single fix deletes all of them, since
|
|
360
|
+
* ESLint applies a fix whole or not at all. Passing ranges that belong to other
|
|
361
|
+
* reports is unsound in two ways: a sibling report may be suppressed (ESLint
|
|
362
|
+
* applies `eslint-disable` after the rule emits its reports, so the sibling's fix
|
|
363
|
+
* never runs), and a sibling fix that overlaps another rule's fix is dropped for
|
|
364
|
+
* the pass. Either way the surviving reference outlives the import this helper
|
|
365
|
+
* was told to unbind — trading an unused import for a dangling type reference, a
|
|
366
|
+
* lint warning for a compile error.
|
|
367
|
+
*
|
|
368
|
+
* A caller batching several of its own edits therefore owes two things: the
|
|
369
|
+
* edits must ship as one fix, and the reports they came from must be checked
|
|
370
|
+
* with `createSuppressionChecker` so that a suppressed one is never counted on.
|
|
371
|
+
* {@link importBindingReferences} exposes the reference sets such a caller needs
|
|
372
|
+
* to work out which edits belong in the same batch.
|
|
340
373
|
*/
|
|
341
374
|
function planOrphanedImportRemoval(source, removed) {
|
|
342
375
|
const orphaned = [];
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,56 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.89",
|
|
4
|
+
"date": "2026-08-03T12:31:01.823Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "memo-compare-deeply-complex-props",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1656
|
|
11
|
+
],
|
|
12
|
+
"summary": "treat a primitive-backed intersection as primitive, not complex (closes #1656)"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "no-explicit-return-type",
|
|
16
|
+
"changeType": "fix",
|
|
17
|
+
"issues": [
|
|
18
|
+
1654
|
|
19
|
+
],
|
|
20
|
+
"summary": "unbind an import when the annotations jointly keeping it alive are all stripped (closes #1654)"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "no-redundant-param-types",
|
|
24
|
+
"changeType": "fix",
|
|
25
|
+
"issues": [
|
|
26
|
+
1653
|
|
27
|
+
],
|
|
28
|
+
"summary": "remove the type imports a stripped parameter annotation was the sole consumer of (closes #1653)"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "no-type-assertion-returns",
|
|
32
|
+
"changeType": "fix",
|
|
33
|
+
"issues": [
|
|
34
|
+
1655
|
|
35
|
+
],
|
|
36
|
+
"summary": "exempt type assertions in JSX spread attributes (closes #1655)"
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"version": "1.20.88",
|
|
42
|
+
"date": "2026-08-03T09:05:50.265Z",
|
|
43
|
+
"rules": [
|
|
44
|
+
{
|
|
45
|
+
"name": "use-latest-callback",
|
|
46
|
+
"changeType": "fix",
|
|
47
|
+
"issues": [
|
|
48
|
+
1652
|
|
49
|
+
],
|
|
50
|
+
"summary": "decline the fix when dropping the deps array orphans a local binding (closes #1652)"
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
},
|
|
2
54
|
{
|
|
3
55
|
"version": "1.20.87",
|
|
4
56
|
"date": "2026-08-03T08:16:33.082Z",
|