@blumintinc/eslint-plugin-blumint 1.20.82 → 1.20.84
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-identifiable-firestore-type.js +32 -7
- package/lib/rules/no-always-true-false-conditions.js +91 -148
- package/lib/rules/no-conditional-literals-in-jsx.js +16 -5
- package/lib/rules/no-direct-function-state.js +56 -5
- package/lib/rules/no-useless-fragment.js +42 -36
- package/lib/rules/prefer-map-over-conditional-dispatch.js +82 -4
- package/lib/rules/prefer-spread-over-reassembly.js +291 -0
- package/package.json +1 -1
- package/release-manifest.json +68 -0
package/lib/index.js
CHANGED
|
@@ -38,15 +38,27 @@ exports.enforceIdentifiableFirestoreType = (0, createRule_1.createRule)({
|
|
|
38
38
|
}
|
|
39
39
|
// Get the expected type name from the parent folder
|
|
40
40
|
const folderName = path_1.default.basename(path_1.default.dirname(filename));
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
// The folder-matching alias only satisfies the rule if it is reachable by
|
|
42
|
+
// consumers. An alias can be exported inline (`export type X = ...`) or
|
|
43
|
+
// separately (`export { X }` / `export type { X }`), and the separate form
|
|
44
|
+
// can appear either before or after the declaration, so exportedness can't
|
|
45
|
+
// be decided inside the TSTypeAliasDeclaration visitor alone — it's
|
|
46
|
+
// resolved once the whole module has been scanned, in Program:exit.
|
|
47
|
+
let matchingAliasNode = null;
|
|
48
|
+
let matchingAliasInlineExported = false;
|
|
49
|
+
let matchingAliasHasIdentifiable = false;
|
|
50
|
+
const locallyExportedNames = new Set();
|
|
43
51
|
return {
|
|
44
52
|
Program() {
|
|
45
53
|
// Reset flags for each file
|
|
46
|
-
|
|
47
|
-
|
|
54
|
+
matchingAliasNode = null;
|
|
55
|
+
matchingAliasInlineExported = false;
|
|
56
|
+
matchingAliasHasIdentifiable = false;
|
|
57
|
+
locallyExportedNames.clear();
|
|
48
58
|
},
|
|
49
59
|
'Program:exit'(node) {
|
|
60
|
+
const hasExpectedType = matchingAliasNode !== null &&
|
|
61
|
+
(matchingAliasInlineExported || locallyExportedNames.has(folderName));
|
|
50
62
|
if (!hasExpectedType) {
|
|
51
63
|
context.report({
|
|
52
64
|
node,
|
|
@@ -57,7 +69,7 @@ exports.enforceIdentifiableFirestoreType = (0, createRule_1.createRule)({
|
|
|
57
69
|
},
|
|
58
70
|
});
|
|
59
71
|
}
|
|
60
|
-
else if (!
|
|
72
|
+
else if (!matchingAliasHasIdentifiable) {
|
|
61
73
|
context.report({
|
|
62
74
|
node,
|
|
63
75
|
messageId: 'notExtendingIdentifiable',
|
|
@@ -67,9 +79,22 @@ exports.enforceIdentifiableFirestoreType = (0, createRule_1.createRule)({
|
|
|
67
79
|
});
|
|
68
80
|
}
|
|
69
81
|
},
|
|
82
|
+
ExportNamedDeclaration(node) {
|
|
83
|
+
// A re-export (`export { X } from './elsewhere'`) exports a binding
|
|
84
|
+
// from another module, not the local alias declared in this file, so
|
|
85
|
+
// it must never satisfy the gate.
|
|
86
|
+
if (node.source != null) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
for (const specifier of node.specifiers) {
|
|
90
|
+
locallyExportedNames.add(specifier.local.name);
|
|
91
|
+
}
|
|
92
|
+
},
|
|
70
93
|
TSTypeAliasDeclaration(node) {
|
|
71
94
|
if (node.id.name === folderName) {
|
|
72
|
-
|
|
95
|
+
matchingAliasNode = node;
|
|
96
|
+
matchingAliasInlineExported =
|
|
97
|
+
node.parent?.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration;
|
|
73
98
|
const findTypeAliasAnnotation = (typeName) => {
|
|
74
99
|
let scope = context.getScope();
|
|
75
100
|
while (scope) {
|
|
@@ -209,7 +234,7 @@ exports.enforceIdentifiableFirestoreType = (0, createRule_1.createRule)({
|
|
|
209
234
|
}
|
|
210
235
|
return false;
|
|
211
236
|
};
|
|
212
|
-
|
|
237
|
+
matchingAliasHasIdentifiable = checkType(node.typeAnnotation);
|
|
213
238
|
}
|
|
214
239
|
},
|
|
215
240
|
};
|
|
@@ -95,6 +95,79 @@ exports.noAlwaysTrueFalseConditions = (0, createRule_1.createRule)({
|
|
|
95
95
|
return {};
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
|
+
const MATH_FOLDABLE_METHODS = new Set(['max', 'min']);
|
|
99
|
+
/**
|
|
100
|
+
* Resolves a `Math.max`/`Math.min` call over numeric literals to the number
|
|
101
|
+
* it always produces. Calls whose value depends on runtime state (spread
|
|
102
|
+
* arguments, identifiers, computed member access, nested calls) stay
|
|
103
|
+
* unresolved so callers never treat them as constants.
|
|
104
|
+
*/
|
|
105
|
+
function evaluateMathMinMaxCall(node) {
|
|
106
|
+
if (node.type !== utils_1.AST_NODE_TYPES.CallExpression ||
|
|
107
|
+
node.callee.type !== utils_1.AST_NODE_TYPES.MemberExpression ||
|
|
108
|
+
node.callee.computed ||
|
|
109
|
+
node.callee.object.type !== utils_1.AST_NODE_TYPES.Identifier ||
|
|
110
|
+
node.callee.object.name !== 'Math' ||
|
|
111
|
+
node.callee.property.type !== utils_1.AST_NODE_TYPES.Identifier ||
|
|
112
|
+
!MATH_FOLDABLE_METHODS.has(node.callee.property.name) ||
|
|
113
|
+
node.arguments.length < 2) {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
const numbers = [];
|
|
117
|
+
for (const argument of node.arguments) {
|
|
118
|
+
if (argument.type !== utils_1.AST_NODE_TYPES.Literal ||
|
|
119
|
+
typeof argument.value !== 'number') {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
numbers.push(argument.value);
|
|
123
|
+
}
|
|
124
|
+
return node.callee.property.name === 'max'
|
|
125
|
+
? Math.max(...numbers)
|
|
126
|
+
: Math.min(...numbers);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Resolves a comparison operand to the number it always evaluates to.
|
|
130
|
+
* Returns undefined when the operand depends on runtime values.
|
|
131
|
+
*/
|
|
132
|
+
function resolveNumericOperand(node) {
|
|
133
|
+
if (node.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
134
|
+
typeof node.value === 'number') {
|
|
135
|
+
return node.value;
|
|
136
|
+
}
|
|
137
|
+
return evaluateMathMinMaxCall(node);
|
|
138
|
+
}
|
|
139
|
+
function compareNumericValues(leftValue, operator, rightValue) {
|
|
140
|
+
switch (operator) {
|
|
141
|
+
case '>':
|
|
142
|
+
return leftValue > rightValue
|
|
143
|
+
? { isTruthy: true }
|
|
144
|
+
: { isFalsy: true };
|
|
145
|
+
case '>=':
|
|
146
|
+
return leftValue >= rightValue
|
|
147
|
+
? { isTruthy: true }
|
|
148
|
+
: { isFalsy: true };
|
|
149
|
+
case '<':
|
|
150
|
+
return leftValue < rightValue
|
|
151
|
+
? { isTruthy: true }
|
|
152
|
+
: { isFalsy: true };
|
|
153
|
+
case '<=':
|
|
154
|
+
return leftValue <= rightValue
|
|
155
|
+
? { isTruthy: true }
|
|
156
|
+
: { isFalsy: true };
|
|
157
|
+
case '==':
|
|
158
|
+
case '===':
|
|
159
|
+
return leftValue === rightValue
|
|
160
|
+
? { isTruthy: true }
|
|
161
|
+
: { isFalsy: true };
|
|
162
|
+
case '!=':
|
|
163
|
+
case '!==':
|
|
164
|
+
return leftValue !== rightValue
|
|
165
|
+
? { isTruthy: true }
|
|
166
|
+
: { isFalsy: true };
|
|
167
|
+
default:
|
|
168
|
+
return {};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
98
171
|
/**
|
|
99
172
|
* Checks if a binary expression with literals is always truthy or falsy
|
|
100
173
|
*/
|
|
@@ -114,10 +187,17 @@ exports.noAlwaysTrueFalseConditions = (0, createRule_1.createRule)({
|
|
|
114
187
|
}
|
|
115
188
|
}
|
|
116
189
|
}
|
|
117
|
-
//
|
|
190
|
+
// A non-literal operand can still be a compile-time constant, so resolve
|
|
191
|
+
// it before abandoning the comparison. Bailing out unconditionally here
|
|
192
|
+
// hid every folded-call comparison (Math.max(1, 2) === 0) from the rule.
|
|
118
193
|
if (node.left.type !== utils_1.AST_NODE_TYPES.Literal ||
|
|
119
194
|
node.right.type !== utils_1.AST_NODE_TYPES.Literal) {
|
|
120
|
-
|
|
195
|
+
const resolvedLeft = resolveNumericOperand(node.left);
|
|
196
|
+
const resolvedRight = resolveNumericOperand(node.right);
|
|
197
|
+
if (resolvedLeft === undefined || resolvedRight === undefined) {
|
|
198
|
+
return {};
|
|
199
|
+
}
|
|
200
|
+
return compareNumericValues(resolvedLeft, node.operator, resolvedRight);
|
|
121
201
|
}
|
|
122
202
|
const leftValue = node.left.value;
|
|
123
203
|
const rightValue = node.right.value;
|
|
@@ -130,33 +210,9 @@ exports.noAlwaysTrueFalseConditions = (0, createRule_1.createRule)({
|
|
|
130
210
|
}
|
|
131
211
|
// Check numeric comparisons
|
|
132
212
|
if (typeof leftValue === 'number' && typeof rightValue === 'number') {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
? { isTruthy: true }
|
|
137
|
-
: { isFalsy: true };
|
|
138
|
-
case '>=':
|
|
139
|
-
return leftValue >= rightValue
|
|
140
|
-
? { isTruthy: true }
|
|
141
|
-
: { isFalsy: true };
|
|
142
|
-
case '<':
|
|
143
|
-
return leftValue < rightValue
|
|
144
|
-
? { isTruthy: true }
|
|
145
|
-
: { isFalsy: true };
|
|
146
|
-
case '<=':
|
|
147
|
-
return leftValue <= rightValue
|
|
148
|
-
? { isTruthy: true }
|
|
149
|
-
: { isFalsy: true };
|
|
150
|
-
case '==':
|
|
151
|
-
case '===':
|
|
152
|
-
return leftValue === rightValue
|
|
153
|
-
? { isTruthy: true }
|
|
154
|
-
: { isFalsy: true };
|
|
155
|
-
case '!=':
|
|
156
|
-
case '!==':
|
|
157
|
-
return leftValue !== rightValue
|
|
158
|
-
? { isTruthy: true }
|
|
159
|
-
: { isFalsy: true };
|
|
213
|
+
const numericResult = compareNumericValues(leftValue, node.operator, rightValue);
|
|
214
|
+
if (numericResult.isTruthy || numericResult.isFalsy) {
|
|
215
|
+
return numericResult;
|
|
160
216
|
}
|
|
161
217
|
}
|
|
162
218
|
// Check string comparisons
|
|
@@ -624,64 +680,12 @@ exports.noAlwaysTrueFalseConditions = (0, createRule_1.createRule)({
|
|
|
624
680
|
? { isTruthy: true }
|
|
625
681
|
: { isFalsy: true };
|
|
626
682
|
}
|
|
627
|
-
// Handle Math.max/min
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
node.arguments.length >= 2 &&
|
|
634
|
-
node.arguments.every((arg) => arg.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
635
|
-
typeof arg.value === 'number')) {
|
|
636
|
-
const numbers = node.arguments.map((arg) => arg.value);
|
|
637
|
-
const result = node.callee.property.name === 'max'
|
|
638
|
-
? Math.max(...numbers)
|
|
639
|
-
: Math.min(...numbers);
|
|
640
|
-
// If this is part of a comparison, evaluate it
|
|
641
|
-
if (node.parent &&
|
|
642
|
-
node.parent.type === utils_1.AST_NODE_TYPES.BinaryExpression &&
|
|
643
|
-
['===', '!==', '==', '!=', '>', '<', '>=', '<='].includes(node.parent.operator) &&
|
|
644
|
-
((node.parent.left === node &&
|
|
645
|
-
node.parent.right.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
646
|
-
typeof node.parent.right.value === 'number') ||
|
|
647
|
-
(node.parent.right === node &&
|
|
648
|
-
node.parent.left.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
649
|
-
typeof node.parent.left.value === 'number'))) {
|
|
650
|
-
const comparison = node.parent;
|
|
651
|
-
const literalNode = comparison.left === node
|
|
652
|
-
? comparison.right
|
|
653
|
-
: comparison.left;
|
|
654
|
-
const compareValue = literalNode.value;
|
|
655
|
-
switch (comparison.operator) {
|
|
656
|
-
case '===':
|
|
657
|
-
case '==':
|
|
658
|
-
return result === compareValue
|
|
659
|
-
? { isTruthy: true }
|
|
660
|
-
: { isFalsy: true };
|
|
661
|
-
case '!==':
|
|
662
|
-
case '!=':
|
|
663
|
-
return result !== compareValue
|
|
664
|
-
? { isTruthy: true }
|
|
665
|
-
: { isFalsy: true };
|
|
666
|
-
case '>':
|
|
667
|
-
return result > compareValue
|
|
668
|
-
? { isTruthy: true }
|
|
669
|
-
: { isFalsy: true };
|
|
670
|
-
case '<':
|
|
671
|
-
return result < compareValue
|
|
672
|
-
? { isTruthy: true }
|
|
673
|
-
: { isFalsy: true };
|
|
674
|
-
case '>=':
|
|
675
|
-
return result >= compareValue
|
|
676
|
-
? { isTruthy: true }
|
|
677
|
-
: { isFalsy: true };
|
|
678
|
-
case '<=':
|
|
679
|
-
return result <= compareValue
|
|
680
|
-
? { isTruthy: true }
|
|
681
|
-
: { isFalsy: true };
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
return result !== 0 ? { isTruthy: true } : { isFalsy: true };
|
|
683
|
+
// Handle Math.max/min used directly as the condition. Comparisons that
|
|
684
|
+
// contain such a call are folded by checkBinaryExpression instead,
|
|
685
|
+
// since evaluation of a comparison starts at the BinaryExpression.
|
|
686
|
+
const mathValue = evaluateMathMinMaxCall(node);
|
|
687
|
+
if (mathValue !== undefined) {
|
|
688
|
+
return mathValue !== 0 ? { isTruthy: true } : { isFalsy: true };
|
|
685
689
|
}
|
|
686
690
|
// Handle Object.keys().length
|
|
687
691
|
if (node.callee.property.name === 'length' &&
|
|
@@ -1038,67 +1042,6 @@ exports.noAlwaysTrueFalseConditions = (0, createRule_1.createRule)({
|
|
|
1038
1042
|
return result ? { isTruthy: true } : { isFalsy: true };
|
|
1039
1043
|
}
|
|
1040
1044
|
}
|
|
1041
|
-
// Handle Math.max/min
|
|
1042
|
-
if (node.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
1043
|
-
node.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
1044
|
-
node.callee.object.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
1045
|
-
node.callee.object.name === 'Math' &&
|
|
1046
|
-
node.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
1047
|
-
(node.callee.property.name === 'max' ||
|
|
1048
|
-
node.callee.property.name === 'min') &&
|
|
1049
|
-
node.arguments.length >= 2 &&
|
|
1050
|
-
node.arguments.every((arg) => arg.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
1051
|
-
typeof arg.value === 'number')) {
|
|
1052
|
-
const numbers = node.arguments.map((arg) => arg.value);
|
|
1053
|
-
const result = node.callee.property.name === 'max'
|
|
1054
|
-
? Math.max(...numbers)
|
|
1055
|
-
: Math.min(...numbers);
|
|
1056
|
-
// If this is part of a comparison, evaluate it
|
|
1057
|
-
if (node.parent &&
|
|
1058
|
-
node.parent.type === utils_1.AST_NODE_TYPES.BinaryExpression &&
|
|
1059
|
-
['===', '!==', '==', '!=', '>', '<', '>=', '<='].includes(node.parent.operator) &&
|
|
1060
|
-
((node.parent.left === node &&
|
|
1061
|
-
node.parent.right.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
1062
|
-
typeof node.parent.right.value === 'number') ||
|
|
1063
|
-
(node.parent.right === node &&
|
|
1064
|
-
node.parent.left.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
1065
|
-
typeof node.parent.left.value === 'number'))) {
|
|
1066
|
-
const comparison = node.parent;
|
|
1067
|
-
const literalNode = comparison.left === node
|
|
1068
|
-
? comparison.right
|
|
1069
|
-
: comparison.left;
|
|
1070
|
-
const compareValue = literalNode.value;
|
|
1071
|
-
switch (comparison.operator) {
|
|
1072
|
-
case '===':
|
|
1073
|
-
case '==':
|
|
1074
|
-
return result === compareValue
|
|
1075
|
-
? { isTruthy: true }
|
|
1076
|
-
: { isFalsy: true };
|
|
1077
|
-
case '!==':
|
|
1078
|
-
case '!=':
|
|
1079
|
-
return result !== compareValue
|
|
1080
|
-
? { isTruthy: true }
|
|
1081
|
-
: { isFalsy: true };
|
|
1082
|
-
case '>':
|
|
1083
|
-
return result > compareValue
|
|
1084
|
-
? { isTruthy: true }
|
|
1085
|
-
: { isFalsy: true };
|
|
1086
|
-
case '<':
|
|
1087
|
-
return result < compareValue
|
|
1088
|
-
? { isTruthy: true }
|
|
1089
|
-
: { isFalsy: true };
|
|
1090
|
-
case '>=':
|
|
1091
|
-
return result >= compareValue
|
|
1092
|
-
? { isTruthy: true }
|
|
1093
|
-
: { isFalsy: true };
|
|
1094
|
-
case '<=':
|
|
1095
|
-
return result <= compareValue
|
|
1096
|
-
? { isTruthy: true }
|
|
1097
|
-
: { isFalsy: true };
|
|
1098
|
-
}
|
|
1099
|
-
}
|
|
1100
|
-
return result !== 0 ? { isTruthy: true } : { isFalsy: true };
|
|
1101
|
-
}
|
|
1102
1045
|
// Handle nullish coalescing
|
|
1103
1046
|
if (node.type === utils_1.AST_NODE_TYPES.LogicalExpression) {
|
|
1104
1047
|
const logicalNode = node;
|
|
@@ -39,11 +39,22 @@ exports.noConditionalLiteralsInJsx = (0, createRule_1.createRule)({
|
|
|
39
39
|
});
|
|
40
40
|
// If we were evaluating
|
|
41
41
|
// <div>{property} {conditional && 'string'}</div>
|
|
42
|
-
// Then {property} would be one of the siblingExpressionNodes
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
// Then {property} would be one of the siblingExpressionNodes.
|
|
43
|
+
//
|
|
44
|
+
// Any expression container beside the conditional can render text, and
|
|
45
|
+
// it fragments the text node exactly as adjacent JSX text does, so the
|
|
46
|
+
// shape of the sibling's expression is not a useful discriminator.
|
|
47
|
+
// Whether an arbitrary expression renders text rather than an element
|
|
48
|
+
// (or nothing at all) is not decidable syntactically, so the broad
|
|
49
|
+
// reading of "other text or expressions" wins. The one container that
|
|
50
|
+
// provably renders nothing is a comment ({/* ... */}), whose expression
|
|
51
|
+
// is a JSXEmptyExpression.
|
|
52
|
+
const siblingExpressionNodes = parentChildren.filter((n) =>
|
|
53
|
+
// The container under evaluation is not its own sibling: a sole
|
|
54
|
+
// conditional literal fragments nothing.
|
|
55
|
+
n !== node &&
|
|
56
|
+
n.type === utils_1.TSESTree.AST_NODE_TYPES.JSXExpressionContainer &&
|
|
57
|
+
n.expression.type !== utils_1.TSESTree.AST_NODE_TYPES.JSXEmptyExpression);
|
|
47
58
|
const hasSiblingContent = siblingTextNodes.concat(siblingExpressionNodes).length > 0;
|
|
48
59
|
if (!hasSiblingContent) {
|
|
49
60
|
return;
|
|
@@ -10,19 +10,63 @@ const DEFAULT_FUNCTION_PATTERNS = [
|
|
|
10
10
|
'func',
|
|
11
11
|
'on[A-Z].*',
|
|
12
12
|
];
|
|
13
|
+
/**
|
|
14
|
+
* Walks the top-level statements of a Program to build a name -> type-node
|
|
15
|
+
* map of every `type X = ...` alias declared in the file (including ones
|
|
16
|
+
* behind `export`). Populated up front from `Program.body` rather than from
|
|
17
|
+
* a `TSTypeAliasDeclaration` visitor, because ESLint visits in document
|
|
18
|
+
* order: an alias declared AFTER the `useState` call it types would not yet
|
|
19
|
+
* be known if we relied on visitor order alone.
|
|
20
|
+
*/
|
|
21
|
+
function collectTypeAliases(programNode) {
|
|
22
|
+
const aliases = new Map();
|
|
23
|
+
for (const statement of programNode.body) {
|
|
24
|
+
const declaration = statement.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration &&
|
|
25
|
+
statement.declaration
|
|
26
|
+
? statement.declaration
|
|
27
|
+
: statement;
|
|
28
|
+
if (declaration.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration) {
|
|
29
|
+
aliases.set(declaration.id.name, declaration.typeAnnotation);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return aliases;
|
|
33
|
+
}
|
|
13
34
|
/**
|
|
14
35
|
* Returns true if the TSTypeAnnotation node (from useState's type parameter)
|
|
15
36
|
* represents a function type — either directly or as part of a union with
|
|
16
37
|
* null/undefined. We check purely syntactically; no type-checker required.
|
|
38
|
+
*
|
|
39
|
+
* A `TSTypeReference` (e.g. `ToClose` in `useState<ToClose>`) is resolved
|
|
40
|
+
* against same-file type aliases and recursed into. Cross-file aliases
|
|
41
|
+
* (imported types) are syntactically unreachable and are left unreported by
|
|
42
|
+
* this signal — the name-pattern and scope-binding signals still apply.
|
|
43
|
+
* `visitedAliases` guards against infinite recursion on a self-referential
|
|
44
|
+
* or mutually-recursive alias chain (`type A = B; type B = A;`).
|
|
17
45
|
*/
|
|
18
|
-
function isFunctionTypeAnnotation(typeNode) {
|
|
46
|
+
function isFunctionTypeAnnotation(typeNode, aliases, visitedAliases = new Set()) {
|
|
19
47
|
switch (typeNode.type) {
|
|
20
48
|
case utils_1.AST_NODE_TYPES.TSFunctionType:
|
|
21
49
|
case utils_1.AST_NODE_TYPES.TSConstructorType:
|
|
22
50
|
return true;
|
|
23
51
|
case utils_1.AST_NODE_TYPES.TSUnionType:
|
|
24
52
|
// Union like `(() => void) | null` — any member being a function type suffices
|
|
25
|
-
return typeNode.types.some(isFunctionTypeAnnotation);
|
|
53
|
+
return typeNode.types.some((member) => isFunctionTypeAnnotation(member, aliases, visitedAliases));
|
|
54
|
+
case utils_1.AST_NODE_TYPES.TSTypeReference: {
|
|
55
|
+
const { typeName } = typeNode;
|
|
56
|
+
// Qualified names (`Foo.Bar`) aren't resolvable without a type checker
|
|
57
|
+
if (typeName.type !== utils_1.AST_NODE_TYPES.Identifier) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
if (visitedAliases.has(typeName.name)) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const aliasTarget = aliases.get(typeName.name);
|
|
64
|
+
if (!aliasTarget) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
visitedAliases.add(typeName.name);
|
|
68
|
+
return isFunctionTypeAnnotation(aliasTarget, aliases, visitedAliases);
|
|
69
|
+
}
|
|
26
70
|
default:
|
|
27
71
|
return false;
|
|
28
72
|
}
|
|
@@ -31,12 +75,12 @@ function isFunctionTypeAnnotation(typeNode) {
|
|
|
31
75
|
* Checks whether a useState call expression has a type parameter that
|
|
32
76
|
* includes a function type, e.g. useState<(() => void) | null>(null).
|
|
33
77
|
*/
|
|
34
|
-
function useStateHasFunctionTypeParam(callNode) {
|
|
78
|
+
function useStateHasFunctionTypeParam(callNode, aliases) {
|
|
35
79
|
const typeParams = callNode.typeParameters;
|
|
36
80
|
if (!typeParams || typeParams.params.length === 0) {
|
|
37
81
|
return false;
|
|
38
82
|
}
|
|
39
|
-
return isFunctionTypeAnnotation(typeParams.params[0]);
|
|
83
|
+
return isFunctionTypeAnnotation(typeParams.params[0], aliases);
|
|
40
84
|
}
|
|
41
85
|
/**
|
|
42
86
|
* Returns true when the AST node is a safe value to pass to a setter — i.e.,
|
|
@@ -182,7 +226,14 @@ exports.noDirectFunctionState = (0, createRule_1.createRule)({
|
|
|
182
226
|
* useState array-destructuring declarations.
|
|
183
227
|
*/
|
|
184
228
|
const setterFunctionTyped = new Map();
|
|
229
|
+
// Populated by the Program visitor, which runs before any descendant
|
|
230
|
+
// visitor, so declaration order of `type` aliases relative to the
|
|
231
|
+
// `useState` call that references them never matters.
|
|
232
|
+
let typeAliases = new Map();
|
|
185
233
|
return {
|
|
234
|
+
Program(node) {
|
|
235
|
+
typeAliases = collectTypeAliases(node);
|
|
236
|
+
},
|
|
186
237
|
VariableDeclarator(node) {
|
|
187
238
|
// Look for `const [state, setter] = useState<T>(...)` or
|
|
188
239
|
// `const [state, setter] = React.useState<T>(...)`.
|
|
@@ -210,7 +261,7 @@ exports.noDirectFunctionState = (0, createRule_1.createRule)({
|
|
|
210
261
|
return;
|
|
211
262
|
}
|
|
212
263
|
const setterName = setterElement.name;
|
|
213
|
-
const hasFunctionType = useStateHasFunctionTypeParam(callNode);
|
|
264
|
+
const hasFunctionType = useStateHasFunctionTypeParam(callNode, typeAliases);
|
|
214
265
|
setterFunctionTyped.set(setterName, hasFunctionType);
|
|
215
266
|
},
|
|
216
267
|
CallExpression(node) {
|
|
@@ -22,48 +22,54 @@ const describeChild = (child) => {
|
|
|
22
22
|
return 'child node';
|
|
23
23
|
}
|
|
24
24
|
};
|
|
25
|
+
/**
|
|
26
|
+
* A `JSXText` child that is pure whitespace AND spans a line break is
|
|
27
|
+
* formatting padding — the newline + indentation prettier inserts around a
|
|
28
|
+
* single-line child in the multi-line fragment form — and renders no output.
|
|
29
|
+
* A whitespace-only child WITHOUT a newline (e.g. `<> <Foo /> </>`) renders
|
|
30
|
+
* an actual space between siblings, so it still counts as meaningful.
|
|
31
|
+
*/
|
|
32
|
+
const isFormattingWhitespace = (child) => child.type === 'JSXText' &&
|
|
33
|
+
/^\s*$/.test(child.value) &&
|
|
34
|
+
child.value.includes('\n');
|
|
25
35
|
exports.noUselessFragment = (0, createRule_1.createRule)({
|
|
26
36
|
name: 'no-useless-fragment',
|
|
27
37
|
create(context) {
|
|
28
38
|
return {
|
|
29
39
|
JSXFragment(node) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
messageId: 'noUselessFragment',
|
|
45
|
-
data: {
|
|
46
|
-
childKind: describeChild(child),
|
|
47
|
-
},
|
|
48
|
-
fix(fixer) {
|
|
49
|
-
const sourceCode = context.sourceCode;
|
|
50
|
-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
51
|
-
const openingFragment = sourceCode.getFirstToken(node);
|
|
52
|
-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
53
|
-
const closingFragment = sourceCode.getLastToken(node);
|
|
54
|
-
return [
|
|
55
|
-
fixer.removeRange([
|
|
56
|
-
openingFragment.range[0],
|
|
57
|
-
openingFragment.range[0] + 2,
|
|
58
|
-
]),
|
|
59
|
-
fixer.removeRange([
|
|
60
|
-
closingFragment.range[0] - 3,
|
|
61
|
-
closingFragment.range[0],
|
|
62
|
-
]),
|
|
63
|
-
];
|
|
64
|
-
},
|
|
65
|
-
});
|
|
40
|
+
const meaningfulChildren = node.children.filter((child) => !isFormattingWhitespace(child));
|
|
41
|
+
if (meaningfulChildren.length !== 1) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const [child] = meaningfulChildren;
|
|
45
|
+
/**
|
|
46
|
+
* A fragment whose only child is an expression container — e.g.
|
|
47
|
+
* `<>{portal}</>` — is NOT useless. Unwrapping it to a bare
|
|
48
|
+
* `{portal}` is invalid in statement/return position, and wrapping a
|
|
49
|
+
* single ReactNode expression in a fragment is the idiomatic way to
|
|
50
|
+
* render it. (Mirrors the upstream rule's `allowExpressions`.)
|
|
51
|
+
*/
|
|
52
|
+
if (child.type === 'JSXExpressionContainer') {
|
|
53
|
+
return;
|
|
66
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Unwrapping is only sound when the child is itself standalone JSX.
|
|
57
|
+
* A text child (`<>hello</>`) would become a bare identifier
|
|
58
|
+
* reference, and a spread child (`<>{...items}</>`) is not a valid
|
|
59
|
+
* expression on its own — both are report-only so the developer
|
|
60
|
+
* chooses how to restructure the surrounding code.
|
|
61
|
+
*/
|
|
62
|
+
const isFixable = child.type === 'JSXElement' || child.type === 'JSXFragment';
|
|
63
|
+
context.report({
|
|
64
|
+
node,
|
|
65
|
+
messageId: 'noUselessFragment',
|
|
66
|
+
data: {
|
|
67
|
+
childKind: describeChild(child),
|
|
68
|
+
},
|
|
69
|
+
fix: isFixable
|
|
70
|
+
? (fixer) => fixer.replaceText(node, context.sourceCode.getText(child))
|
|
71
|
+
: null,
|
|
72
|
+
});
|
|
67
73
|
},
|
|
68
74
|
};
|
|
69
75
|
},
|
|
@@ -50,6 +50,23 @@ const FUNCTION_TYPES = new Set([
|
|
|
50
50
|
utils_1.AST_NODE_TYPES.FunctionExpression,
|
|
51
51
|
utils_1.AST_NODE_TYPES.FunctionDeclaration,
|
|
52
52
|
]);
|
|
53
|
+
/**
|
|
54
|
+
* Sentinel standing in for the receiver of a `this`-rooted member chain, which
|
|
55
|
+
* has no identifier to key on. `this` is a reserved word, so no binding can
|
|
56
|
+
* carry this name and shadow the sentinel.
|
|
57
|
+
*/
|
|
58
|
+
const THIS_ROOT = 'this';
|
|
59
|
+
/**
|
|
60
|
+
* Node types that bind their own `this`, so a `this` inside one is a different
|
|
61
|
+
* receiver than the enclosing scope's. Arrow functions are deliberately absent:
|
|
62
|
+
* they close over the lexical `this`, exactly as a closure closes over an
|
|
63
|
+
* identifier binding.
|
|
64
|
+
*/
|
|
65
|
+
const THIS_REBINDING_TYPES = new Set([
|
|
66
|
+
utils_1.AST_NODE_TYPES.FunctionExpression,
|
|
67
|
+
utils_1.AST_NODE_TYPES.FunctionDeclaration,
|
|
68
|
+
utils_1.AST_NODE_TYPES.ClassBody,
|
|
69
|
+
]);
|
|
53
70
|
/**
|
|
54
71
|
* Function/constructor/conditional type notation must be parenthesized to
|
|
55
72
|
* appear as a `|` union member, or the emitted annotation does not parse
|
|
@@ -362,12 +379,22 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
|
|
|
362
379
|
}
|
|
363
380
|
return cur.type === utils_1.AST_NODE_TYPES.Identifier;
|
|
364
381
|
}
|
|
365
|
-
/**
|
|
366
|
-
|
|
382
|
+
/**
|
|
383
|
+
* Root of a member chain (`a.b.c` -> `a`, `this.a.b` -> `THIS_ROOT`).
|
|
384
|
+
*
|
|
385
|
+
* A `this`-rooted chain names its receiver with a keyword rather than a
|
|
386
|
+
* binding, so it needs a sentinel to participate in root-keyed analysis at
|
|
387
|
+
* all. `'this'` cannot collide with a real root: `this` is a reserved word,
|
|
388
|
+
* so no identifier can ever carry that name.
|
|
389
|
+
*/
|
|
390
|
+
function chainRootName(node) {
|
|
367
391
|
let cur = node;
|
|
368
392
|
while (cur.type === utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
369
393
|
cur = cur.object;
|
|
370
394
|
}
|
|
395
|
+
if (cur.type === utils_1.AST_NODE_TYPES.ThisExpression) {
|
|
396
|
+
return THIS_ROOT;
|
|
397
|
+
}
|
|
371
398
|
return cur.type === utils_1.AST_NODE_TYPES.Identifier ? cur.name : null;
|
|
372
399
|
}
|
|
373
400
|
function isInlineLiteralKey(node) {
|
|
@@ -485,6 +512,48 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
|
|
|
485
512
|
visit(node);
|
|
486
513
|
return found;
|
|
487
514
|
}
|
|
515
|
+
/**
|
|
516
|
+
* Whether the subtree reads the receiver of the enclosing scope — the `this`
|
|
517
|
+
* counterpart of `referencesIdentifier`. Nested `function`/class bodies are
|
|
518
|
+
* skipped because their `this` is a different receiver, so a `this` inside
|
|
519
|
+
* one is no more a read of the narrowed object than an identifier of the
|
|
520
|
+
* same name declared in an inner scope would be.
|
|
521
|
+
*/
|
|
522
|
+
function referencesThis(node) {
|
|
523
|
+
let found = false;
|
|
524
|
+
const visit = (n) => {
|
|
525
|
+
if (found || !n || typeof n !== 'object') {
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const anyNode = n;
|
|
529
|
+
if (typeof anyNode.type !== 'string') {
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
if (anyNode.type === utils_1.AST_NODE_TYPES.ThisExpression) {
|
|
533
|
+
found = true;
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
if (THIS_REBINDING_TYPES.has(anyNode.type)) {
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
for (const key of Object.keys(anyNode)) {
|
|
540
|
+
if (key === 'parent') {
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
const val = anyNode[key];
|
|
544
|
+
if (Array.isArray(val)) {
|
|
545
|
+
for (const child of val) {
|
|
546
|
+
visit(child);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
else {
|
|
550
|
+
visit(val);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
visit(node);
|
|
555
|
+
return found;
|
|
556
|
+
}
|
|
488
557
|
// ---- Name derivation ----------------------------------------------------
|
|
489
558
|
function toUpperSnake(name) {
|
|
490
559
|
return name
|
|
@@ -799,16 +868,25 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
|
|
|
799
868
|
* Narrowing exemption (Edge Case 1): when the discriminant is `obj.tag`, a
|
|
800
869
|
* flat Record cannot express variant narrowing. If any KEPT branch value
|
|
801
870
|
* references the base object beyond the tag access itself, do not fire.
|
|
871
|
+
*
|
|
872
|
+
* `this.obj.tag` narrows identically — the receiver is reached through a
|
|
873
|
+
* keyword instead of a binding, which changes nothing about what the Record
|
|
874
|
+
* would lose (hoisting `this.obj.data` out of the switch drops the narrowing
|
|
875
|
+
* and the emitted code no longer typechecks), so a `this`-rooted chain is
|
|
876
|
+
* matched against `this` reads in the kept branches.
|
|
802
877
|
*/
|
|
803
878
|
function isNarrowingExempt(discriminant, keptValues) {
|
|
804
879
|
if (discriminant.type !== utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
805
880
|
return false;
|
|
806
881
|
}
|
|
807
|
-
const root =
|
|
882
|
+
const root = chainRootName(discriminant);
|
|
808
883
|
if (!root) {
|
|
809
884
|
return false;
|
|
810
885
|
}
|
|
811
|
-
|
|
886
|
+
const readsRoot = root === THIS_ROOT
|
|
887
|
+
? referencesThis
|
|
888
|
+
: (value) => referencesIdentifier(value, root);
|
|
889
|
+
return keptValues.some(readsRoot);
|
|
812
890
|
}
|
|
813
891
|
// ---- Switch form --------------------------------------------------------
|
|
814
892
|
function handleSwitch(node) {
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.preferSpreadOverReassembly = void 0;
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
|
+
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
6
7
|
const DEFAULT_MIN_FIELDS = 2;
|
|
7
8
|
/**
|
|
8
9
|
* Collects all identifier references (not declarations) used anywhere in a
|
|
@@ -70,6 +71,139 @@ function getSimpleDestructuredNames(pattern) {
|
|
|
70
71
|
}
|
|
71
72
|
return names;
|
|
72
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Array methods that hand each element of the receiver to their callback. They
|
|
76
|
+
* are the contextual route by which an unannotated destructured parameter still
|
|
77
|
+
* has a knowable type: the element type of the array being iterated.
|
|
78
|
+
*/
|
|
79
|
+
const ELEMENT_CALLBACK_METHODS = new Set([
|
|
80
|
+
'map',
|
|
81
|
+
'forEach',
|
|
82
|
+
'filter',
|
|
83
|
+
'flatMap',
|
|
84
|
+
]);
|
|
85
|
+
const ARRAY_TYPE_NAMES = new Set(['Array', 'ReadonlyArray']);
|
|
86
|
+
/**
|
|
87
|
+
* Resolves `Promise<T>` to `T`, leaving anything else as it stands, which is
|
|
88
|
+
* what `await` does to the type of the expression it operates on.
|
|
89
|
+
*/
|
|
90
|
+
function unwrapPromise(typeNode) {
|
|
91
|
+
if (typeNode.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
|
|
92
|
+
typeNode.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
93
|
+
typeNode.typeName.name === 'Promise' &&
|
|
94
|
+
typeNode.typeParameters?.params.length === 1) {
|
|
95
|
+
return typeNode.typeParameters.params[0];
|
|
96
|
+
}
|
|
97
|
+
return typeNode;
|
|
98
|
+
}
|
|
99
|
+
/** The element type of an array type, or null when the type is not an array. */
|
|
100
|
+
function arrayElementTypeOf(typeNode) {
|
|
101
|
+
if (typeNode.type === utils_1.AST_NODE_TYPES.TSArrayType) {
|
|
102
|
+
return typeNode.elementType;
|
|
103
|
+
}
|
|
104
|
+
// `readonly Unit[]` wraps the array type in a type operator.
|
|
105
|
+
if (typeNode.type === utils_1.AST_NODE_TYPES.TSTypeOperator &&
|
|
106
|
+
typeNode.operator === 'readonly' &&
|
|
107
|
+
typeNode.typeAnnotation) {
|
|
108
|
+
return arrayElementTypeOf(typeNode.typeAnnotation);
|
|
109
|
+
}
|
|
110
|
+
if (typeNode.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
|
|
111
|
+
typeNode.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
112
|
+
ARRAY_TYPE_NAMES.has(typeNode.typeName.name) &&
|
|
113
|
+
typeNode.typeParameters?.params.length === 1) {
|
|
114
|
+
return typeNode.typeParameters.params[0];
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The property names a member list declares, or null when the list cannot be
|
|
120
|
+
* enumerated exactly. An index signature, a call/construct signature or a
|
|
121
|
+
* computed key all describe members whose names are not written down, and a
|
|
122
|
+
* member set that may be larger than what is read would let a narrowing pick
|
|
123
|
+
* pass for an exhaustive one.
|
|
124
|
+
*/
|
|
125
|
+
function namesOfMembers(members) {
|
|
126
|
+
const names = new Set();
|
|
127
|
+
for (const member of members) {
|
|
128
|
+
if (member.type !== utils_1.AST_NODE_TYPES.TSPropertySignature &&
|
|
129
|
+
member.type !== utils_1.AST_NODE_TYPES.TSMethodSignature) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
if (member.computed) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const key = member.key;
|
|
136
|
+
if (key.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
137
|
+
names.add(key.name);
|
|
138
|
+
}
|
|
139
|
+
else if (key.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
140
|
+
typeof key.value === 'string') {
|
|
141
|
+
names.add(key.value);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return names;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Finds a type alias or interface declared at the top level of the file being
|
|
151
|
+
* linted, including one that is exported.
|
|
152
|
+
*
|
|
153
|
+
* Resolution stops at the file boundary on purpose: an imported name's members
|
|
154
|
+
* live in a module this rule cannot read, and guessing at them would be the
|
|
155
|
+
* opposite of a proof.
|
|
156
|
+
*/
|
|
157
|
+
function findLocalTypeDeclaration(program, name) {
|
|
158
|
+
for (const statement of program.body) {
|
|
159
|
+
const declaration = statement.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration &&
|
|
160
|
+
statement.declaration
|
|
161
|
+
? statement.declaration
|
|
162
|
+
: statement;
|
|
163
|
+
if ((declaration.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration ||
|
|
164
|
+
declaration.type === utils_1.AST_NODE_TYPES.TSInterfaceDeclaration) &&
|
|
165
|
+
declaration.id.name === name) {
|
|
166
|
+
return declaration;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Enumerates every property name a type node declares, or null when the member
|
|
173
|
+
* list cannot be established with certainty.
|
|
174
|
+
*
|
|
175
|
+
* Only an unambiguous, fully written-out member list qualifies. A union, an
|
|
176
|
+
* intersection, a mapped or conditional type, a generic instantiation and an
|
|
177
|
+
* interface with an `extends` clause all describe a member set assembled
|
|
178
|
+
* elsewhere, so none of them can prove anything here.
|
|
179
|
+
*/
|
|
180
|
+
function memberNamesOf(typeNode, program, seen = new Set()) {
|
|
181
|
+
if (typeNode.type === utils_1.AST_NODE_TYPES.TSTypeLiteral) {
|
|
182
|
+
return namesOfMembers(typeNode.members);
|
|
183
|
+
}
|
|
184
|
+
if (typeNode.type !== utils_1.AST_NODE_TYPES.TSTypeReference ||
|
|
185
|
+
typeNode.typeName.type !== utils_1.AST_NODE_TYPES.Identifier ||
|
|
186
|
+
typeNode.typeParameters) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
const name = typeNode.typeName.name;
|
|
190
|
+
// A self-referential alias (`type T = T`) would otherwise recur forever.
|
|
191
|
+
if (seen.has(name)) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
seen.add(name);
|
|
195
|
+
const declaration = findLocalTypeDeclaration(program, name);
|
|
196
|
+
if (!declaration || declaration.typeParameters) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
if (declaration.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration) {
|
|
200
|
+
return memberNamesOf(declaration.typeAnnotation, program, seen);
|
|
201
|
+
}
|
|
202
|
+
if (declaration.extends && declaration.extends.length > 0) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
return namesOfMembers(declaration.body.body);
|
|
206
|
+
}
|
|
73
207
|
/**
|
|
74
208
|
* For a JSX element, returns the set of destructured names that are forwarded
|
|
75
209
|
* with identical key names (e.g. `hits={hits}`, `isLoading={isLoading}`).
|
|
@@ -492,6 +626,158 @@ exports.preferSpreadOverReassembly = (0, createRule_1.createRule)({
|
|
|
492
626
|
create(context, [options]) {
|
|
493
627
|
const minFields = options?.minFields ?? DEFAULT_MIN_FIELDS;
|
|
494
628
|
const sourceCode = context.getSourceCode();
|
|
629
|
+
const program = sourceCode.ast;
|
|
630
|
+
/**
|
|
631
|
+
* Walks an expression toward its syntactic root and returns the type node
|
|
632
|
+
* that root declares, mirroring the receiver trace in
|
|
633
|
+
* `enforce-firestore-doc-ref-generic`.
|
|
634
|
+
*
|
|
635
|
+
* The trace is syntax only. `parserOptions.project` is absent from the
|
|
636
|
+
* shared testers and from many consumer configs, so a type-checker branch
|
|
637
|
+
* would be dead exactly where the destructured pick it must protect lives —
|
|
638
|
+
* inside an `Array.prototype.map` callback, whose signature comes from
|
|
639
|
+
* `lib.d.ts`.
|
|
640
|
+
*/
|
|
641
|
+
function typeNodeOfExpression(node, visited) {
|
|
642
|
+
// Guards against a self-referential declaration such as `const a = a.b;`.
|
|
643
|
+
if (!node || visited.has(node)) {
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
visited.add(node);
|
|
647
|
+
switch (node.type) {
|
|
648
|
+
case utils_1.AST_NODE_TYPES.AwaitExpression: {
|
|
649
|
+
const awaited = typeNodeOfExpression(node.argument, visited);
|
|
650
|
+
return awaited ? unwrapPromise(awaited) : null;
|
|
651
|
+
}
|
|
652
|
+
case utils_1.AST_NODE_TYPES.ChainExpression:
|
|
653
|
+
case utils_1.AST_NODE_TYPES.TSNonNullExpression:
|
|
654
|
+
return typeNodeOfExpression(node.expression, visited);
|
|
655
|
+
// An assertion states the type outright, which is stronger evidence
|
|
656
|
+
// than anything the operand could supply.
|
|
657
|
+
case utils_1.AST_NODE_TYPES.TSAsExpression:
|
|
658
|
+
case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
|
|
659
|
+
return node.typeAnnotation;
|
|
660
|
+
case utils_1.AST_NODE_TYPES.Identifier:
|
|
661
|
+
return typeNodeOfIdentifier(node, visited);
|
|
662
|
+
case utils_1.AST_NODE_TYPES.CallExpression:
|
|
663
|
+
return typeNodeOfCallResult(node);
|
|
664
|
+
default:
|
|
665
|
+
// A member expression is deliberately absent: a property's type lives
|
|
666
|
+
// in the type of its object, which syntax alone does not supply.
|
|
667
|
+
return null;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
function typeNodeOfIdentifier(node, visited) {
|
|
671
|
+
const scope = ASTHelpers_1.ASTHelpers.getScope(context, node);
|
|
672
|
+
const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(scope, node.name);
|
|
673
|
+
if (!variable || variable.defs.length !== 1) {
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
const def = variable.defs[0];
|
|
677
|
+
if (def.type === 'Parameter') {
|
|
678
|
+
return def.name.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
679
|
+
def.name.typeAnnotation
|
|
680
|
+
? def.name.typeAnnotation.typeAnnotation
|
|
681
|
+
: null;
|
|
682
|
+
}
|
|
683
|
+
if (def.type !== 'Variable' ||
|
|
684
|
+
def.node.type !== utils_1.AST_NODE_TYPES.VariableDeclarator) {
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
687
|
+
const declarator = def.node;
|
|
688
|
+
// An annotation constrains every assignment rather than just the
|
|
689
|
+
// initializer, so it describes the binding even when it is a `let`.
|
|
690
|
+
if (declarator.id.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
691
|
+
declarator.id.typeAnnotation) {
|
|
692
|
+
return declarator.id.typeAnnotation.typeAnnotation;
|
|
693
|
+
}
|
|
694
|
+
// Without an annotation only an immutable binding still holds its
|
|
695
|
+
// initializer's type by the time the callback runs.
|
|
696
|
+
if (def.parent?.type !== utils_1.AST_NODE_TYPES.VariableDeclaration ||
|
|
697
|
+
def.parent.kind !== 'const') {
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
return typeNodeOfExpression(declarator.init, visited);
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* A receiver that is a call result takes its type from what the callee
|
|
704
|
+
* declares it returns; an inferred return type is not written down and so
|
|
705
|
+
* proves nothing.
|
|
706
|
+
*/
|
|
707
|
+
function typeNodeOfCallResult(node) {
|
|
708
|
+
if (node.callee.type !== utils_1.AST_NODE_TYPES.Identifier) {
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
const scope = ASTHelpers_1.ASTHelpers.getScope(context, node.callee);
|
|
712
|
+
const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(scope, node.callee.name);
|
|
713
|
+
if (!variable || variable.defs.length !== 1) {
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
const def = variable.defs[0];
|
|
717
|
+
// A hoisted declaration binds the helper the same way a `const` arrow
|
|
718
|
+
// does, so both spellings are read.
|
|
719
|
+
if (def.type === 'FunctionName') {
|
|
720
|
+
return def.node.returnType?.typeAnnotation ?? null;
|
|
721
|
+
}
|
|
722
|
+
if (def.type !== 'Variable' ||
|
|
723
|
+
def.node.type !== utils_1.AST_NODE_TYPES.VariableDeclarator ||
|
|
724
|
+
def.parent?.type !== utils_1.AST_NODE_TYPES.VariableDeclaration ||
|
|
725
|
+
def.parent.kind !== 'const') {
|
|
726
|
+
return null;
|
|
727
|
+
}
|
|
728
|
+
const init = def.node.init;
|
|
729
|
+
if (init?.type !== utils_1.AST_NODE_TYPES.ArrowFunctionExpression &&
|
|
730
|
+
init?.type !== utils_1.AST_NODE_TYPES.FunctionExpression) {
|
|
731
|
+
return null;
|
|
732
|
+
}
|
|
733
|
+
return init.returnType?.typeAnnotation ?? null;
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* The member names of the element type of the array whose method call this
|
|
737
|
+
* function is the callback of, e.g. `Unit` for `units.map(fn)` where
|
|
738
|
+
* `units` is annotated `Unit[]`.
|
|
739
|
+
*/
|
|
740
|
+
function contextualElementMemberNames(fn) {
|
|
741
|
+
const call = fn.parent;
|
|
742
|
+
if (!call ||
|
|
743
|
+
call.type !== utils_1.AST_NODE_TYPES.CallExpression ||
|
|
744
|
+
call.arguments[0] !== fn ||
|
|
745
|
+
call.callee.type !== utils_1.AST_NODE_TYPES.MemberExpression ||
|
|
746
|
+
call.callee.computed ||
|
|
747
|
+
call.callee.property.type !== utils_1.AST_NODE_TYPES.Identifier ||
|
|
748
|
+
!ELEMENT_CALLBACK_METHODS.has(call.callee.property.name)) {
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
const receiverType = typeNodeOfExpression(call.callee.object, new Set());
|
|
752
|
+
if (!receiverType) {
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
const elementType = arrayElementTypeOf(receiverType);
|
|
756
|
+
return elementType ? memberNamesOf(elementType, program) : null;
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Reports whether the destructured pick is provably a PROPER subset of the
|
|
760
|
+
* source object's own type, in which case spreading the parameter would add
|
|
761
|
+
* the members the author left out and change what the function produces
|
|
762
|
+
* (#1642: a GitHub review payload gained unknown keys).
|
|
763
|
+
*
|
|
764
|
+
* The proof runs in the safe direction only. A member set that matches the
|
|
765
|
+
* pick exactly is exhaustive, so the rewrite is behavior-preserving and the
|
|
766
|
+
* rule still reports; a type it cannot resolve — imported, generic, a union,
|
|
767
|
+
* an index signature — yields no proof and the rule likewise still reports.
|
|
768
|
+
* Silence is reserved for the case where the widening is demonstrated.
|
|
769
|
+
*/
|
|
770
|
+
function isProvablyNarrowingPick(fn, param, destructuredNames) {
|
|
771
|
+
// An explicit annotation overrides whatever the call site would imply,
|
|
772
|
+
// so the contextual route is consulted only in its absence.
|
|
773
|
+
const memberNames = param.typeAnnotation
|
|
774
|
+
? memberNamesOf(param.typeAnnotation.typeAnnotation, program)
|
|
775
|
+
: contextualElementMemberNames(fn);
|
|
776
|
+
if (!memberNames || memberNames.size <= destructuredNames.length) {
|
|
777
|
+
return false;
|
|
778
|
+
}
|
|
779
|
+
return destructuredNames.every((name) => memberNames.has(name));
|
|
780
|
+
}
|
|
495
781
|
function checkFunction(fn) {
|
|
496
782
|
// Must have exactly one parameter that is an ObjectPattern.
|
|
497
783
|
if (fn.params.length !== 1)
|
|
@@ -547,6 +833,11 @@ exports.preferSpreadOverReassembly = (0, createRule_1.createRule)({
|
|
|
547
833
|
return;
|
|
548
834
|
}
|
|
549
835
|
}
|
|
836
|
+
// The pick may exist precisely because the omitted members must not flow
|
|
837
|
+
// through; spreading would reinstate them.
|
|
838
|
+
if (isProvablyNarrowingPick(fn, param, destructuredNames)) {
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
550
841
|
context.report({
|
|
551
842
|
node: param,
|
|
552
843
|
messageId: 'preferSpread',
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,72 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.84",
|
|
4
|
+
"date": "2026-08-03T02:56:38.740Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "prefer-spread-over-reassembly",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1642
|
|
11
|
+
],
|
|
12
|
+
"summary": "stay silent on a provably narrowing pick (closes #1642)"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "1.20.83",
|
|
18
|
+
"date": "2026-08-03T02:05:36.672Z",
|
|
19
|
+
"rules": [
|
|
20
|
+
{
|
|
21
|
+
"name": "enforce-identifiable-firestore-type",
|
|
22
|
+
"changeType": "fix",
|
|
23
|
+
"issues": [
|
|
24
|
+
1635
|
|
25
|
+
],
|
|
26
|
+
"summary": "require the folder-matching type to be exported (closes #1635)"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "no-always-true-false-conditions",
|
|
30
|
+
"changeType": "fix",
|
|
31
|
+
"issues": [
|
|
32
|
+
1625
|
|
33
|
+
],
|
|
34
|
+
"summary": "fold Math.max/min operands inside comparisons (closes #1625)"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "no-conditional-literals-in-jsx",
|
|
38
|
+
"changeType": "fix",
|
|
39
|
+
"issues": [
|
|
40
|
+
1627
|
|
41
|
+
],
|
|
42
|
+
"summary": "count every non-comment expression container as adjacent content (closes #1627)"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"name": "no-direct-function-state",
|
|
46
|
+
"changeType": "fix",
|
|
47
|
+
"issues": [
|
|
48
|
+
1636
|
|
49
|
+
],
|
|
50
|
+
"summary": "see function types behind a same-file alias (closes #1636)"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "no-useless-fragment",
|
|
54
|
+
"changeType": "fix",
|
|
55
|
+
"issues": [
|
|
56
|
+
1634
|
|
57
|
+
],
|
|
58
|
+
"summary": "stop the fixer corrupting source and count meaningful children (closes #1634)"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"name": "prefer-map-over-conditional-dispatch",
|
|
62
|
+
"changeType": "fix",
|
|
63
|
+
"issues": [
|
|
64
|
+
1626
|
|
65
|
+
],
|
|
66
|
+
"summary": "extend the narrowing exemption to this-rooted discriminants (closes #1626)"
|
|
67
|
+
}
|
|
68
|
+
]
|
|
69
|
+
},
|
|
2
70
|
{
|
|
3
71
|
"version": "1.20.82",
|
|
4
72
|
"date": "2026-08-02T21:42:12.697Z",
|