eslint-plugin-ts-data-forge 0.8.2 → 0.8.3
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/dist/plugin.d.mts +1 -1
- package/dist/plugin.d.mts.map +1 -1
- package/dist/plugin.mjs +1 -0
- package/dist/plugin.mjs.map +1 -1
- package/dist/rule-types.d.mts +3 -3
- package/dist/rule-types.d.mts.map +1 -1
- package/dist/rules/ast-utils.d.mts +59 -3
- package/dist/rules/ast-utils.d.mts.map +1 -1
- package/dist/rules/ast-utils.mjs +119 -8
- package/dist/rules/ast-utils.mjs.map +1 -1
- package/dist/rules/import-utils.d.mts +1 -1
- package/dist/rules/import-utils.d.mts.map +1 -1
- package/dist/rules/import-utils.mjs +22 -11
- package/dist/rules/import-utils.mjs.map +1 -1
- package/dist/rules/no-side-effect-import.d.mts +1 -1
- package/dist/rules/no-side-effect-import.d.mts.map +1 -1
- package/dist/rules/no-unnecessary-type-guard.d.mts.map +1 -1
- package/dist/rules/no-unnecessary-type-guard.mjs +125 -79
- package/dist/rules/no-unnecessary-type-guard.mjs.map +1 -1
- package/dist/rules/prefer-arr-is-array.mjs +11 -4
- package/dist/rules/prefer-arr-is-array.mjs.map +1 -1
- package/dist/rules/prefer-arr-is-bounded-length-array.d.mts +1 -1
- package/dist/rules/prefer-arr-is-bounded-length-array.d.mts.map +1 -1
- package/dist/rules/prefer-arr-is-bounded-length-array.mjs +5 -19
- package/dist/rules/prefer-arr-is-bounded-length-array.mjs.map +1 -1
- package/dist/rules/prefer-arr-is-fixed-length-array.d.mts +1 -1
- package/dist/rules/prefer-arr-is-fixed-length-array.d.mts.map +1 -1
- package/dist/rules/prefer-arr-is-fixed-length-array.mjs +11 -27
- package/dist/rules/prefer-arr-is-fixed-length-array.mjs.map +1 -1
- package/dist/rules/prefer-arr-is-max-length-array.d.mts +1 -1
- package/dist/rules/prefer-arr-is-max-length-array.d.mts.map +1 -1
- package/dist/rules/prefer-arr-is-max-length-array.mjs +9 -22
- package/dist/rules/prefer-arr-is-max-length-array.mjs.map +1 -1
- package/dist/rules/prefer-arr-is-min-length-array.d.mts +1 -1
- package/dist/rules/prefer-arr-is-min-length-array.d.mts.map +1 -1
- package/dist/rules/prefer-arr-is-min-length-array.mjs +9 -22
- package/dist/rules/prefer-arr-is-min-length-array.mjs.map +1 -1
- package/dist/rules/prefer-arr-is-non-empty.d.mts.map +1 -1
- package/dist/rules/prefer-arr-is-non-empty.mjs +7 -22
- package/dist/rules/prefer-arr-is-non-empty.mjs.map +1 -1
- package/dist/rules/prefer-arr-scan.d.mts +6 -5
- package/dist/rules/prefer-arr-scan.d.mts.map +1 -1
- package/dist/rules/prefer-arr-scan.mjs +33 -13
- package/dist/rules/prefer-arr-scan.mjs.map +1 -1
- package/dist/rules/prefer-arr-sum.d.mts.map +1 -1
- package/dist/rules/prefer-arr-sum.mjs +51 -37
- package/dist/rules/prefer-arr-sum.mjs.map +1 -1
- package/dist/rules/prefer-arr-uniq.d.mts.map +1 -1
- package/dist/rules/prefer-arr-uniq.mjs +33 -10
- package/dist/rules/prefer-arr-uniq.mjs.map +1 -1
- package/dist/rules/prefer-as-int.d.mts.map +1 -1
- package/dist/rules/prefer-as-int.mjs +18 -12
- package/dist/rules/prefer-as-int.mjs.map +1 -1
- package/dist/rules/prefer-canonical-array-slicing.d.mts.map +1 -1
- package/dist/rules/prefer-canonical-array-slicing.mjs +29 -14
- package/dist/rules/prefer-canonical-array-slicing.mjs.map +1 -1
- package/dist/rules/prefer-canonical-length-cast.d.mts.map +1 -1
- package/dist/rules/prefer-canonical-length-cast.mjs +34 -13
- package/dist/rules/prefer-canonical-length-cast.mjs.map +1 -1
- package/dist/rules/prefer-canonical-length-guard.d.mts.map +1 -1
- package/dist/rules/prefer-canonical-length-guard.mjs +10 -4
- package/dist/rules/prefer-canonical-length-guard.mjs.map +1 -1
- package/dist/rules/prefer-comparison-over-nullish-guard.d.mts.map +1 -1
- package/dist/rules/prefer-comparison-over-nullish-guard.mjs +25 -3
- package/dist/rules/prefer-comparison-over-nullish-guard.mjs.map +1 -1
- package/dist/rules/prefer-is-non-null-object.mjs +28 -10
- package/dist/rules/prefer-is-non-null-object.mjs.map +1 -1
- package/dist/rules/prefer-is-record-and-has-key.d.mts.map +1 -1
- package/dist/rules/prefer-is-record-and-has-key.mjs +8 -3
- package/dist/rules/prefer-is-record-and-has-key.mjs.map +1 -1
- package/dist/rules/prefer-num-safe-parse-float.d.mts +3 -1
- package/dist/rules/prefer-num-safe-parse-float.d.mts.map +1 -1
- package/dist/rules/prefer-num-safe-parse-float.mjs +18 -7
- package/dist/rules/prefer-num-safe-parse-float.mjs.map +1 -1
- package/dist/rules/prefer-num-safe-parse-int.d.mts.map +1 -1
- package/dist/rules/prefer-num-safe-parse-int.mjs +15 -8
- package/dist/rules/prefer-num-safe-parse-int.mjs.map +1 -1
- package/dist/rules/prefer-range-for-loop.d.mts.map +1 -1
- package/dist/rules/prefer-range-for-loop.mjs +13 -7
- package/dist/rules/prefer-range-for-loop.mjs.map +1 -1
- package/dist/types.d.mts +2 -2
- package/dist/types.d.mts.map +1 -1
- package/package.json +3 -3
- package/src/plugin.mts +5 -4
- package/src/plugin.test.mts +0 -13
- package/src/rule-types.mts +3 -3
- package/src/rules/ast-utils.mts +156 -13
- package/src/rules/import-utils.mts +35 -15
- package/src/rules/no-side-effect-import.mts +1 -1
- package/src/rules/no-unnecessary-type-guard.mts +192 -124
- package/src/rules/no-unnecessary-type-guard.test.mts +100 -0
- package/src/rules/prefer-arr-is-array.mts +11 -6
- package/src/rules/prefer-arr-is-array.test.mts +40 -0
- package/src/rules/prefer-arr-is-bounded-length-array.mts +13 -25
- package/src/rules/prefer-arr-is-bounded-length-array.test.mts +68 -0
- package/src/rules/prefer-arr-is-fixed-length-array.mts +18 -38
- package/src/rules/prefer-arr-is-fixed-length-array.test.mts +63 -0
- package/src/rules/prefer-arr-is-max-length-array.mts +15 -26
- package/src/rules/prefer-arr-is-max-length-array.test.mts +67 -0
- package/src/rules/prefer-arr-is-min-length-array.mts +15 -26
- package/src/rules/prefer-arr-is-min-length-array.test.mts +70 -0
- package/src/rules/prefer-arr-is-non-empty.mts +16 -25
- package/src/rules/prefer-arr-is-non-empty.test.mts +79 -0
- package/src/rules/prefer-arr-scan.mts +55 -19
- package/src/rules/prefer-arr-scan.test.mts +149 -0
- package/src/rules/prefer-arr-sum.mts +78 -47
- package/src/rules/prefer-arr-sum.test.mts +97 -0
- package/src/rules/prefer-arr-uniq.mts +46 -12
- package/src/rules/prefer-arr-uniq.test.mts +71 -0
- package/src/rules/prefer-as-int.mts +24 -16
- package/src/rules/prefer-as-int.test.mts +51 -0
- package/src/rules/prefer-canonical-array-slicing.mts +38 -15
- package/src/rules/prefer-canonical-array-slicing.test.mts +92 -0
- package/src/rules/prefer-canonical-length-cast.mts +45 -15
- package/src/rules/prefer-canonical-length-cast.test.mts +111 -0
- package/src/rules/prefer-canonical-length-guard.mts +13 -7
- package/src/rules/prefer-canonical-length-guard.test.mts +99 -0
- package/src/rules/prefer-comparison-over-nullish-guard.mts +33 -6
- package/src/rules/prefer-comparison-over-nullish-guard.test.mts +136 -0
- package/src/rules/prefer-is-non-null-object.mts +31 -11
- package/src/rules/prefer-is-non-null-object.test.mts +42 -0
- package/src/rules/prefer-is-record-and-has-key.mts +10 -5
- package/src/rules/prefer-is-record-and-has-key.test.mts +34 -0
- package/src/rules/prefer-num-safe-parse-float.mts +24 -11
- package/src/rules/prefer-num-safe-parse-float.test.mts +79 -0
- package/src/rules/prefer-num-safe-parse-int.mts +25 -11
- package/src/rules/prefer-num-safe-parse-int.test.mts +64 -0
- package/src/rules/prefer-obj-over-entries-round-trip.mts +1 -1
- package/src/rules/prefer-range-for-loop.mts +18 -8
- package/src/rules/prefer-range-for-loop.test.mts +75 -0
- package/src/rules/rules.mts +1 -1
- package/src/types.mts +2 -2
package/src/rules/ast-utils.mts
CHANGED
|
@@ -3,18 +3,125 @@ import {
|
|
|
3
3
|
type TSESLint,
|
|
4
4
|
type TSESTree,
|
|
5
5
|
} from '@typescript-eslint/utils';
|
|
6
|
-
import {
|
|
6
|
+
import type { DeepReadonly } from 'ts-type-forge';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `node` with every `as`, `satisfies`, `!` and `<T>` around it taken off: the
|
|
10
|
+
* expression whose value it is. A wrapper changes no value, so a rule judges
|
|
11
|
+
* syntax on this — `xs.length > (0 as number)` compares with the literal `0`
|
|
12
|
+
* — and writes its fix from the original node so the wrapper is kept. A type
|
|
13
|
+
* is another matter: see {@link typeWrapperLayers}. See
|
|
14
|
+
* `docs/writing-lint-rules.md` at the repository root. A copy of
|
|
15
|
+
* `eslint-config-typed`'s `type-wrapper-utils.mts`, since this package is
|
|
16
|
+
* published on its own.
|
|
17
|
+
*/
|
|
18
|
+
export const skipTypeWrappers = <N extends TSESTree.Node>(
|
|
19
|
+
node: N,
|
|
20
|
+
): N | TSESTree.Expression =>
|
|
21
|
+
isTypeWrapper(node) ? skipTypeWrappers(node.expression) : node;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `node` and every expression inside the type wrappers around it, outermost
|
|
25
|
+
* first: `x as A as B` gives `x as A as B`, `x as A` and `x`.
|
|
26
|
+
*
|
|
27
|
+
* A conclusion a rule draws from a type — that a guard always holds, that a
|
|
28
|
+
* value is an array, that `?? 0` is dead — has to hold for the type of every
|
|
29
|
+
* layer. An assertion therefore only ever makes a rule more cautious: one
|
|
30
|
+
* that widens (`x as string | null`) is the author saying the value may be
|
|
31
|
+
* `null`, and is respected; one that narrows (`x!`, `v as string` on an
|
|
32
|
+
* `unknown`) is a claim the checker cannot verify, and the value's own type
|
|
33
|
+
* still decides.
|
|
34
|
+
*/
|
|
35
|
+
export const typeWrapperLayers = <N extends TSESTree.Node>(
|
|
36
|
+
node: N,
|
|
37
|
+
): readonly (N | TSESTree.Expression)[] =>
|
|
38
|
+
isTypeWrapper(node)
|
|
39
|
+
? ([node, ...typeWrapperLayers(node.expression)] as const)
|
|
40
|
+
: ([node] as const);
|
|
41
|
+
|
|
42
|
+
export const isTypeWrapper = (
|
|
43
|
+
node: DeepReadonly<TSESTree.Node>,
|
|
44
|
+
): node is TypeWrapper => TYPE_WRAPPERS.has(node.type);
|
|
45
|
+
|
|
46
|
+
export type TypeWrapper =
|
|
47
|
+
| TSESTree.TSAsExpression
|
|
48
|
+
| TSESTree.TSNonNullExpression
|
|
49
|
+
| TSESTree.TSSatisfiesExpression
|
|
50
|
+
| TSESTree.TSTypeAssertion;
|
|
51
|
+
|
|
52
|
+
const TYPE_WRAPPERS: ReadonlySet<AST_NODE_TYPES> = new Set([
|
|
53
|
+
AST_NODE_TYPES.TSAsExpression,
|
|
54
|
+
AST_NODE_TYPES.TSNonNullExpression,
|
|
55
|
+
AST_NODE_TYPES.TSSatisfiesExpression,
|
|
56
|
+
AST_NODE_TYPES.TSTypeAssertion,
|
|
57
|
+
]);
|
|
7
58
|
|
|
8
59
|
const isIntegerLiteral = (node: DeepReadonly<TSESTree.Expression>): boolean =>
|
|
9
60
|
node.type === AST_NODE_TYPES.Literal &&
|
|
10
61
|
typeof node.value === 'number' &&
|
|
11
62
|
Number.isInteger(node.value);
|
|
12
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The source text of `node` for an argument position. A node's text never
|
|
66
|
+
* includes the parentheses the source wrote around it, so a comma (sequence)
|
|
67
|
+
* expression gets them back rather than spilling into the next argument.
|
|
68
|
+
*/
|
|
69
|
+
export const toArgumentText = (
|
|
70
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
71
|
+
node: TSESTree.Node,
|
|
72
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
73
|
+
sourceCode: TSESLint.SourceCode,
|
|
74
|
+
): string => {
|
|
75
|
+
const text = sourceCode.getText(node);
|
|
76
|
+
|
|
77
|
+
return node.type === AST_NODE_TYPES.SequenceExpression ? `(${text})` : text;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Whether `node` is an array or a tuple at every layer of type wrappers (see
|
|
82
|
+
* {@link typeWrapperLayers}): a cast does not make a string an array, and the
|
|
83
|
+
* fix, which keeps the wrapper, has to type-check too. `false` without type
|
|
84
|
+
* information.
|
|
85
|
+
*/
|
|
86
|
+
export const isArrayOrTupleExpression = (
|
|
87
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
88
|
+
services: TSESLint.SourceCode['parserServices'],
|
|
89
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
90
|
+
node: TSESTree.Expression,
|
|
91
|
+
): boolean => {
|
|
92
|
+
const checker = services?.program?.getTypeChecker();
|
|
93
|
+
|
|
94
|
+
if (checker === undefined) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return typeWrapperLayers(node).every((expression) => {
|
|
99
|
+
const tsNode = services?.esTreeNodeToTSNodeMap?.get(expression);
|
|
100
|
+
|
|
101
|
+
if (tsNode === undefined) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const type = checker.getTypeAtLocation(tsNode);
|
|
106
|
+
|
|
107
|
+
return checker.isArrayType(type) || checker.isTupleType(type);
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Whether `node`, under any type wrapper, is an integer literal or a `const`
|
|
113
|
+
* initialized with one.
|
|
114
|
+
*/
|
|
13
115
|
export const isIntegerLiteralOrConstant = (
|
|
14
116
|
node: DeepReadonly<TSESTree.Expression>,
|
|
15
117
|
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
16
118
|
sourceCode: TSESLint.SourceCode,
|
|
17
119
|
): boolean => {
|
|
120
|
+
// `(3 as number)`, `3 satisfies number` are still the bound 3.
|
|
121
|
+
if (isTypeWrapper(node)) {
|
|
122
|
+
return isIntegerLiteralOrConstant(node.expression, sourceCode);
|
|
123
|
+
}
|
|
124
|
+
|
|
18
125
|
// Direct integer literal (e.g., 3)
|
|
19
126
|
if (isIntegerLiteral(node)) {
|
|
20
127
|
return true;
|
|
@@ -53,12 +160,40 @@ export const isIntegerLiteralOrConstant = (
|
|
|
53
160
|
);
|
|
54
161
|
};
|
|
55
162
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
163
|
+
/**
|
|
164
|
+
* `node` as a `<array>.length` read, looked for under any type wrapper
|
|
165
|
+
* (`(xs.length satisfies number)`, `xs.length!`), or `undefined`. The property
|
|
166
|
+
* must be written `.length`: `xs[length]` reads whatever index the variable
|
|
167
|
+
* `length` holds.
|
|
168
|
+
*/
|
|
169
|
+
export const asLengthAccess = (
|
|
170
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
171
|
+
node: TSESTree.Expression,
|
|
172
|
+
): TSESTree.MemberExpression | undefined => {
|
|
173
|
+
const inner = skipTypeWrappers(node);
|
|
174
|
+
|
|
175
|
+
return inner.type === AST_NODE_TYPES.MemberExpression &&
|
|
176
|
+
!inner.computed &&
|
|
177
|
+
inner.property.type === AST_NODE_TYPES.Identifier &&
|
|
178
|
+
inner.property.name === 'length'
|
|
179
|
+
? inner
|
|
180
|
+
: undefined;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Whether two array expressions are written the same way, type wrappers aside:
|
|
185
|
+
* `xs.length >= 1 && (xs as readonly T[]).length <= 3` bounds one array.
|
|
186
|
+
*/
|
|
187
|
+
export const isSameArrayText = (
|
|
188
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
189
|
+
a: TSESTree.Expression,
|
|
190
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
191
|
+
b: TSESTree.Expression,
|
|
192
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
193
|
+
sourceCode: TSESLint.SourceCode,
|
|
194
|
+
): boolean =>
|
|
195
|
+
sourceCode.getText(skipTypeWrappers(a)) ===
|
|
196
|
+
sourceCode.getText(skipTypeWrappers(b));
|
|
62
197
|
|
|
63
198
|
type LengthComparison = Readonly<{
|
|
64
199
|
array: TSESTree.Expression;
|
|
@@ -70,7 +205,9 @@ type LengthComparison = Readonly<{
|
|
|
70
205
|
* Parses a `<array>.length <op> <bound>` comparison (in either operand order)
|
|
71
206
|
* into its array expression, integer-literal/`const` `bound`, and whether it is
|
|
72
207
|
* a lower (`min`) or upper (`max`) length bound. Returns `undefined` for
|
|
73
|
-
* anything else.
|
|
208
|
+
* anything else. The length and the bound are read through type wrappers; the
|
|
209
|
+
* `bound` returned is the literal or constant underneath, since a wrapper such
|
|
210
|
+
* as `as number` would only widen the length a guard narrows to.
|
|
74
211
|
*
|
|
75
212
|
* - `xs.length >= n` / `n <= xs.length` → `min`
|
|
76
213
|
* - `xs.length <= n` / `n >= xs.length` → `max`
|
|
@@ -90,13 +227,15 @@ export const parseLengthComparison = (
|
|
|
90
227
|
|
|
91
228
|
const { left, right, operator } = node;
|
|
92
229
|
|
|
93
|
-
const
|
|
230
|
+
const leftLength = asLengthAccess(left);
|
|
231
|
+
|
|
232
|
+
const lengthOnLeft = leftLength !== undefined;
|
|
94
233
|
|
|
95
|
-
const lengthSide =
|
|
234
|
+
const lengthSide = leftLength ?? asLengthAccess(right);
|
|
96
235
|
|
|
97
236
|
const boundSide = lengthOnLeft ? right : left;
|
|
98
237
|
|
|
99
|
-
if (
|
|
238
|
+
if (lengthSide === undefined) {
|
|
100
239
|
return undefined;
|
|
101
240
|
}
|
|
102
241
|
|
|
@@ -110,7 +249,11 @@ export const parseLengthComparison = (
|
|
|
110
249
|
? 'min'
|
|
111
250
|
: 'max';
|
|
112
251
|
|
|
113
|
-
return {
|
|
252
|
+
return {
|
|
253
|
+
array: lengthSide.object,
|
|
254
|
+
bound: skipTypeWrappers(boundSide),
|
|
255
|
+
kind,
|
|
256
|
+
};
|
|
114
257
|
};
|
|
115
258
|
|
|
116
259
|
/**
|
|
@@ -151,7 +294,7 @@ export const isPartOfBoundedLengthCheck = (
|
|
|
151
294
|
|
|
152
295
|
return (
|
|
153
296
|
self.kind !== other.kind &&
|
|
154
|
-
|
|
297
|
+
isSameArrayText(self.array, other.array, sourceCode)
|
|
155
298
|
);
|
|
156
299
|
};
|
|
157
300
|
|
|
@@ -3,7 +3,8 @@ import {
|
|
|
3
3
|
type TSESLint,
|
|
4
4
|
type TSESTree,
|
|
5
5
|
} from '@typescript-eslint/utils';
|
|
6
|
-
import {
|
|
6
|
+
import type { DeepReadonly } from 'ts-type-forge';
|
|
7
|
+
import { isTypeWrapper } from './ast-utils.mjs';
|
|
7
8
|
import { TS_DATA_FORGE_MODULE } from './constants.mjs';
|
|
8
9
|
|
|
9
10
|
/* eslint-disable @typescript-eslint/prefer-readonly-parameter-types */
|
|
@@ -100,7 +101,16 @@ export const buildCalleeResolver = (
|
|
|
100
101
|
}),
|
|
101
102
|
);
|
|
102
103
|
|
|
103
|
-
|
|
104
|
+
// `(isNull as (u: unknown) => boolean)(x)` and `(tf as typeof tf).isNull(x)`
|
|
105
|
+
// still call `isNull`: both the callee and a namespace object are read
|
|
106
|
+
// through type wrappers.
|
|
107
|
+
const resolve = (
|
|
108
|
+
callee: DeepReadonly<TSESTree.Expression>,
|
|
109
|
+
): ResolvedCallee | undefined => {
|
|
110
|
+
if (isTypeWrapper(callee)) {
|
|
111
|
+
return resolve(callee.expression);
|
|
112
|
+
}
|
|
113
|
+
|
|
104
114
|
if (callee.type === AST_NODE_TYPES.Identifier) {
|
|
105
115
|
const canonicalName = localToCanonical.get(callee.name);
|
|
106
116
|
|
|
@@ -115,25 +125,35 @@ export const buildCalleeResolver = (
|
|
|
115
125
|
}
|
|
116
126
|
|
|
117
127
|
if (
|
|
118
|
-
namespaceName
|
|
119
|
-
callee.type
|
|
120
|
-
|
|
121
|
-
callee.
|
|
122
|
-
callee.object.name === namespaceName &&
|
|
123
|
-
callee.property.type === AST_NODE_TYPES.Identifier
|
|
128
|
+
namespaceName === undefined ||
|
|
129
|
+
callee.type !== AST_NODE_TYPES.MemberExpression ||
|
|
130
|
+
callee.computed ||
|
|
131
|
+
callee.property.type !== AST_NODE_TYPES.Identifier
|
|
124
132
|
) {
|
|
125
|
-
return
|
|
126
|
-
canonicalName: callee.property.name,
|
|
127
|
-
// eslint-disable-next-line total-functions/no-unsafe-type-assertion
|
|
128
|
-
propertyNode: callee.property as TSESTree.Node,
|
|
129
|
-
isNamespace: true,
|
|
130
|
-
};
|
|
133
|
+
return undefined;
|
|
131
134
|
}
|
|
132
135
|
|
|
133
|
-
|
|
136
|
+
const object = skipReadonlyTypeWrappers(callee.object);
|
|
137
|
+
|
|
138
|
+
return object.type === AST_NODE_TYPES.Identifier &&
|
|
139
|
+
object.name === namespaceName
|
|
140
|
+
? {
|
|
141
|
+
canonicalName: callee.property.name,
|
|
142
|
+
// eslint-disable-next-line total-functions/no-unsafe-type-assertion
|
|
143
|
+
propertyNode: callee.property as TSESTree.Node,
|
|
144
|
+
isNamespace: true,
|
|
145
|
+
}
|
|
146
|
+
: undefined;
|
|
134
147
|
};
|
|
148
|
+
|
|
149
|
+
return resolve;
|
|
135
150
|
};
|
|
136
151
|
|
|
152
|
+
const skipReadonlyTypeWrappers = (
|
|
153
|
+
node: DeepReadonly<TSESTree.Expression>,
|
|
154
|
+
): DeepReadonly<TSESTree.Expression> =>
|
|
155
|
+
isTypeWrapper(node) ? skipReadonlyTypeWrappers(node.expression) : node;
|
|
156
|
+
|
|
137
157
|
export const buildImportFixes = (
|
|
138
158
|
fixer: TSESLint.RuleFixer,
|
|
139
159
|
program: TSESTree.Program,
|
|
@@ -4,8 +4,9 @@ import {
|
|
|
4
4
|
type TSESLint,
|
|
5
5
|
type TSESTree,
|
|
6
6
|
} from '@typescript-eslint/utils';
|
|
7
|
-
import {
|
|
7
|
+
import type { DeepReadonly, ReadonlyRecord } from 'ts-type-forge';
|
|
8
8
|
import * as ts from 'typescript';
|
|
9
|
+
import { typeWrapperLayers } from './ast-utils.mjs';
|
|
9
10
|
import {
|
|
10
11
|
buildCalleeResolver,
|
|
11
12
|
buildImportFixes,
|
|
@@ -282,132 +283,41 @@ export const noUnnecessaryTypeGuard: TSESLint.RuleModule<MessageIds, Options> =
|
|
|
282
283
|
return;
|
|
283
284
|
}
|
|
284
285
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
286
|
+
// A conclusion drawn from the argument's type has to hold at every
|
|
287
|
+
// layer of type wrappers around it (see typeWrapperLayers):
|
|
288
|
+
// `isNull(x as string | null)` is the author saying `x` may be
|
|
289
|
+
// `null`, and `isNullish(x as string | undefined)` still sees the
|
|
290
|
+
// `null` that `x` may hold.
|
|
291
|
+
const layerTypes = typeWrapperLayers(argument).map((layer) =>
|
|
292
|
+
checker.getTypeAtLocation(
|
|
293
|
+
parserServices.esTreeNodeToTSNodeMap.get(layer),
|
|
294
|
+
),
|
|
295
|
+
);
|
|
290
296
|
|
|
291
|
-
|
|
292
|
-
|
|
297
|
+
const verdict = layerTypes
|
|
298
|
+
.map((type) => verdictOf(spec, type, checker))
|
|
299
|
+
.find(
|
|
300
|
+
(candidate): candidate is Verdict =>
|
|
301
|
+
candidate !== undefined &&
|
|
302
|
+
layerTypes.every((type) =>
|
|
303
|
+
holdsOn(candidate, spec, type, checker),
|
|
304
|
+
),
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
if (verdict === undefined) {
|
|
308
|
+
return;
|
|
293
309
|
}
|
|
294
310
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
switch (spec.kind) {
|
|
306
|
-
case 'excludeFrom': {
|
|
307
|
-
const removable = spec.atoms.filter((a) => inputAtoms.has(a));
|
|
308
|
-
|
|
309
|
-
if (removable.length === 0) {
|
|
310
|
-
// Nothing to exclude → the guard always holds.
|
|
311
|
-
reportConstant(node, canonicalName, true, argument);
|
|
312
|
-
} else if (!hasMemberOutsideTarget(spec.atoms)) {
|
|
313
|
-
// Every member is excluded → the guard never holds.
|
|
314
|
-
reportConstant(node, canonicalName, false, argument);
|
|
315
|
-
} else if (removable.length < spec.atoms.length) {
|
|
316
|
-
const replacement = spec.replacements?.[joinAtoms(removable)];
|
|
317
|
-
|
|
318
|
-
if (replacement !== undefined) {
|
|
319
|
-
reportReplace(
|
|
320
|
-
node,
|
|
321
|
-
canonicalName,
|
|
322
|
-
replacement,
|
|
323
|
-
isNamespace,
|
|
324
|
-
propertyNode,
|
|
325
|
-
);
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
break;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
case 'narrowTo': {
|
|
333
|
-
const present = spec.atoms.filter((a) => inputAtoms.has(a));
|
|
334
|
-
|
|
335
|
-
if (present.length === 0) {
|
|
336
|
-
// No member is in the target set → the guard never holds.
|
|
337
|
-
reportConstant(node, canonicalName, false, argument);
|
|
338
|
-
} else if (!hasMemberOutsideTarget(spec.atoms)) {
|
|
339
|
-
// Input ⊆ target → the guard always holds and narrows nothing.
|
|
340
|
-
reportConstant(node, canonicalName, true, argument);
|
|
341
|
-
} else if (present.length < spec.atoms.length) {
|
|
342
|
-
const replacement = spec.replacements?.[joinAtoms(present)];
|
|
343
|
-
|
|
344
|
-
if (replacement !== undefined) {
|
|
345
|
-
reportReplace(
|
|
346
|
-
node,
|
|
347
|
-
canonicalName,
|
|
348
|
-
replacement,
|
|
349
|
-
isNamespace,
|
|
350
|
-
propertyNode,
|
|
351
|
-
);
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
break;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
case 'record': {
|
|
359
|
-
if (parts.every((p) => isGuaranteedRecord(p, checker))) {
|
|
360
|
-
// Every member already satisfies `UnknownRecord`.
|
|
361
|
-
reportConstant(node, canonicalName, true, argument);
|
|
362
|
-
} else if (
|
|
363
|
-
parts.every((p) => isGuaranteedNotRecord(p, checker))
|
|
364
|
-
) {
|
|
365
|
-
// No member can ever be a non-null, non-array object.
|
|
366
|
-
reportConstant(node, canonicalName, false, argument);
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
break;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
case 'nonEmptyString': {
|
|
373
|
-
const hasNullish =
|
|
374
|
-
inputAtoms.has('undefined') || inputAtoms.has('null');
|
|
375
|
-
|
|
376
|
-
const nonNullishParts = parts.filter((p) => {
|
|
377
|
-
const atom = classifyAtom(p);
|
|
378
|
-
|
|
379
|
-
return atom !== 'undefined' && atom !== 'null';
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
if (!nonNullishParts.some(couldBeNonEmptyString)) {
|
|
383
|
-
// No member can be a non-empty string → the guard never holds.
|
|
384
|
-
reportConstant(node, canonicalName, false, argument);
|
|
385
|
-
|
|
386
|
-
break;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
if (
|
|
390
|
-
nonNullishParts.some(
|
|
391
|
-
(p) => !isGuaranteedNonEmptyString(p, checker),
|
|
392
|
-
)
|
|
393
|
-
) {
|
|
394
|
-
break; // still does real string/empty work
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
if (hasNullish) {
|
|
398
|
-
reportReplace(
|
|
399
|
-
node,
|
|
400
|
-
canonicalName,
|
|
401
|
-
'isNonNullish',
|
|
402
|
-
isNamespace,
|
|
403
|
-
propertyNode,
|
|
404
|
-
);
|
|
405
|
-
} else {
|
|
406
|
-
reportConstant(node, canonicalName, true, argument);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
break;
|
|
410
|
-
}
|
|
311
|
+
if (verdict.kind === 'constant') {
|
|
312
|
+
reportConstant(node, canonicalName, verdict.value, argument);
|
|
313
|
+
} else {
|
|
314
|
+
reportReplace(
|
|
315
|
+
node,
|
|
316
|
+
canonicalName,
|
|
317
|
+
verdict.replacement,
|
|
318
|
+
isNamespace,
|
|
319
|
+
propertyNode,
|
|
320
|
+
);
|
|
411
321
|
}
|
|
412
322
|
},
|
|
413
323
|
};
|
|
@@ -415,6 +325,158 @@ export const noUnnecessaryTypeGuard: TSESLint.RuleModule<MessageIds, Options> =
|
|
|
415
325
|
defaultOptions: [{}],
|
|
416
326
|
} as const;
|
|
417
327
|
|
|
328
|
+
/** What the rule concludes about a guard call from one type of its argument. */
|
|
329
|
+
type Verdict = DeepReadonly<
|
|
330
|
+
| { kind: 'constant'; value: boolean }
|
|
331
|
+
| { kind: 'replace'; replacement: string }
|
|
332
|
+
>;
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* What a guard with `spec` does on an argument of `type`: always the same
|
|
336
|
+
* boolean, the same as a narrower guard, or nothing the rule can say.
|
|
337
|
+
*/
|
|
338
|
+
const verdictOf = (
|
|
339
|
+
spec: GuardSpec,
|
|
340
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
341
|
+
type: ts.Type,
|
|
342
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
343
|
+
checker: ts.TypeChecker,
|
|
344
|
+
): Verdict | undefined => {
|
|
345
|
+
const parts = collectUnionParts(type);
|
|
346
|
+
|
|
347
|
+
if (parts === undefined) {
|
|
348
|
+
return undefined; // opaque / generic → bail conservatively
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const inputAtoms = new Set(parts.map(classifyAtom));
|
|
352
|
+
|
|
353
|
+
const hasMemberOutsideTarget = (atoms: readonly AtomKey[]): boolean => {
|
|
354
|
+
const targetSet = new Set<Atom>(atoms);
|
|
355
|
+
|
|
356
|
+
return parts.some((p) => !targetSet.has(classifyAtom(p)));
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
switch (spec.kind) {
|
|
360
|
+
case 'excludeFrom': {
|
|
361
|
+
const removable = spec.atoms.filter((a) => inputAtoms.has(a));
|
|
362
|
+
|
|
363
|
+
if (removable.length === 0) {
|
|
364
|
+
// Nothing to exclude → the guard always holds.
|
|
365
|
+
return { kind: 'constant', value: true } as const;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (!hasMemberOutsideTarget(spec.atoms)) {
|
|
369
|
+
// Every member is excluded → the guard never holds.
|
|
370
|
+
return { kind: 'constant', value: false } as const;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return replacementVerdict(
|
|
374
|
+
removable.length < spec.atoms.length
|
|
375
|
+
? spec.replacements?.[joinAtoms(removable)]
|
|
376
|
+
: undefined,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
case 'narrowTo': {
|
|
381
|
+
const present = spec.atoms.filter((a) => inputAtoms.has(a));
|
|
382
|
+
|
|
383
|
+
if (present.length === 0) {
|
|
384
|
+
// No member is in the target set → the guard never holds.
|
|
385
|
+
return { kind: 'constant', value: false } as const;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (!hasMemberOutsideTarget(spec.atoms)) {
|
|
389
|
+
// Input ⊆ target → the guard always holds and narrows nothing.
|
|
390
|
+
return { kind: 'constant', value: true } as const;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return replacementVerdict(
|
|
394
|
+
present.length < spec.atoms.length
|
|
395
|
+
? spec.replacements?.[joinAtoms(present)]
|
|
396
|
+
: undefined,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
case 'record':
|
|
401
|
+
// Every member already satisfies `UnknownRecord`, or none can ever be
|
|
402
|
+
// a non-null, non-array object.
|
|
403
|
+
return parts.every((p) => isGuaranteedRecord(p, checker))
|
|
404
|
+
? ({ kind: 'constant', value: true } as const)
|
|
405
|
+
: parts.every((p) => isGuaranteedNotRecord(p, checker))
|
|
406
|
+
? ({ kind: 'constant', value: false } as const)
|
|
407
|
+
: undefined;
|
|
408
|
+
|
|
409
|
+
case 'nonEmptyString': {
|
|
410
|
+
const nonNullishParts = parts.filter((p) => {
|
|
411
|
+
const atom = classifyAtom(p);
|
|
412
|
+
|
|
413
|
+
return atom !== 'undefined' && atom !== 'null';
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
if (!nonNullishParts.some(couldBeNonEmptyString)) {
|
|
417
|
+
// No member can be a non-empty string → the guard never holds.
|
|
418
|
+
return { kind: 'constant', value: false } as const;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (
|
|
422
|
+
nonNullishParts.some((p) => !isGuaranteedNonEmptyString(p, checker))
|
|
423
|
+
) {
|
|
424
|
+
return undefined; // still does real string/empty work
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return inputAtoms.has('undefined') || inputAtoms.has('null')
|
|
428
|
+
? ({ kind: 'replace', replacement: 'isNonNullish' } as const)
|
|
429
|
+
: ({ kind: 'constant', value: true } as const);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
const replacementVerdict = (
|
|
435
|
+
replacement: string | undefined,
|
|
436
|
+
): Verdict | undefined =>
|
|
437
|
+
replacement === undefined
|
|
438
|
+
? undefined
|
|
439
|
+
: ({ kind: 'replace', replacement } as const);
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Whether `verdict`, drawn from one layer's type, also holds for `type`. A
|
|
443
|
+
* constant has to come out the same. A replacement holds where the rule would
|
|
444
|
+
* suggest the same one, or where the guard and its replacement are the same
|
|
445
|
+
* constant: under `x!` on a `string | null`, `isNonNullish` and `isNotNull`
|
|
446
|
+
* are both always `true` on `string`.
|
|
447
|
+
*/
|
|
448
|
+
const holdsOn = (
|
|
449
|
+
verdict: Verdict,
|
|
450
|
+
spec: GuardSpec,
|
|
451
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
452
|
+
type: ts.Type,
|
|
453
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
454
|
+
checker: ts.TypeChecker,
|
|
455
|
+
): boolean => {
|
|
456
|
+
const onType = verdictOf(spec, type, checker);
|
|
457
|
+
|
|
458
|
+
if (verdict.kind === 'constant') {
|
|
459
|
+
return onType?.kind === 'constant' && onType.value === verdict.value;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (onType?.kind === 'replace') {
|
|
463
|
+
return onType.replacement === verdict.replacement;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const replacementSpec = GUARD_SPECS[verdict.replacement];
|
|
467
|
+
|
|
468
|
+
const replacementOnType =
|
|
469
|
+
replacementSpec === undefined
|
|
470
|
+
? undefined
|
|
471
|
+
: verdictOf(replacementSpec, type, checker);
|
|
472
|
+
|
|
473
|
+
return (
|
|
474
|
+
onType?.kind === 'constant' &&
|
|
475
|
+
replacementOnType?.kind === 'constant' &&
|
|
476
|
+
replacementOnType.value === onType.value
|
|
477
|
+
);
|
|
478
|
+
};
|
|
479
|
+
|
|
418
480
|
/**
|
|
419
481
|
* Decomposes a type into its union members. Returns `undefined` when any member
|
|
420
482
|
* is opaque (`any` / `unknown` / `never`) or deferred (type parameter, indexed
|
|
@@ -607,6 +669,12 @@ const isSideEffectFreeArg = (node: DeepReadonly<TSESTree.Node>): boolean => {
|
|
|
607
669
|
(!node.computed || isSideEffectFreeArg(node.property))
|
|
608
670
|
);
|
|
609
671
|
|
|
672
|
+
case AST_NODE_TYPES.TSAsExpression:
|
|
673
|
+
case AST_NODE_TYPES.TSNonNullExpression:
|
|
674
|
+
case AST_NODE_TYPES.TSSatisfiesExpression:
|
|
675
|
+
case AST_NODE_TYPES.TSTypeAssertion:
|
|
676
|
+
return isSideEffectFreeArg(node.expression);
|
|
677
|
+
|
|
610
678
|
default:
|
|
611
679
|
return false;
|
|
612
680
|
}
|