eslint-plugin-ts-data-forge 0.8.2 → 0.8.4

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.
Files changed (135) hide show
  1. package/dist/index.d.mts +1 -1
  2. package/dist/index.d.mts.map +1 -1
  3. package/dist/plugin.d.mts +1 -1
  4. package/dist/plugin.d.mts.map +1 -1
  5. package/dist/plugin.mjs +1 -0
  6. package/dist/plugin.mjs.map +1 -1
  7. package/dist/rule-types.d.mts +3 -3
  8. package/dist/rule-types.d.mts.map +1 -1
  9. package/dist/rules/ast-utils.d.mts +59 -3
  10. package/dist/rules/ast-utils.d.mts.map +1 -1
  11. package/dist/rules/ast-utils.mjs +119 -8
  12. package/dist/rules/ast-utils.mjs.map +1 -1
  13. package/dist/rules/import-utils.d.mts +1 -1
  14. package/dist/rules/import-utils.d.mts.map +1 -1
  15. package/dist/rules/import-utils.mjs +22 -11
  16. package/dist/rules/import-utils.mjs.map +1 -1
  17. package/dist/rules/no-side-effect-import.d.mts +1 -1
  18. package/dist/rules/no-side-effect-import.d.mts.map +1 -1
  19. package/dist/rules/no-unnecessary-type-guard.d.mts.map +1 -1
  20. package/dist/rules/no-unnecessary-type-guard.mjs +125 -79
  21. package/dist/rules/no-unnecessary-type-guard.mjs.map +1 -1
  22. package/dist/rules/prefer-arr-is-array.mjs +11 -4
  23. package/dist/rules/prefer-arr-is-array.mjs.map +1 -1
  24. package/dist/rules/prefer-arr-is-bounded-length-array.d.mts +1 -1
  25. package/dist/rules/prefer-arr-is-bounded-length-array.d.mts.map +1 -1
  26. package/dist/rules/prefer-arr-is-bounded-length-array.mjs +5 -19
  27. package/dist/rules/prefer-arr-is-bounded-length-array.mjs.map +1 -1
  28. package/dist/rules/prefer-arr-is-fixed-length-array.d.mts +1 -1
  29. package/dist/rules/prefer-arr-is-fixed-length-array.d.mts.map +1 -1
  30. package/dist/rules/prefer-arr-is-fixed-length-array.mjs +11 -27
  31. package/dist/rules/prefer-arr-is-fixed-length-array.mjs.map +1 -1
  32. package/dist/rules/prefer-arr-is-max-length-array.d.mts +1 -1
  33. package/dist/rules/prefer-arr-is-max-length-array.d.mts.map +1 -1
  34. package/dist/rules/prefer-arr-is-max-length-array.mjs +9 -22
  35. package/dist/rules/prefer-arr-is-max-length-array.mjs.map +1 -1
  36. package/dist/rules/prefer-arr-is-min-length-array.d.mts +1 -1
  37. package/dist/rules/prefer-arr-is-min-length-array.d.mts.map +1 -1
  38. package/dist/rules/prefer-arr-is-min-length-array.mjs +9 -22
  39. package/dist/rules/prefer-arr-is-min-length-array.mjs.map +1 -1
  40. package/dist/rules/prefer-arr-is-non-empty.d.mts.map +1 -1
  41. package/dist/rules/prefer-arr-is-non-empty.mjs +7 -22
  42. package/dist/rules/prefer-arr-is-non-empty.mjs.map +1 -1
  43. package/dist/rules/prefer-arr-scan.d.mts +6 -5
  44. package/dist/rules/prefer-arr-scan.d.mts.map +1 -1
  45. package/dist/rules/prefer-arr-scan.mjs +33 -13
  46. package/dist/rules/prefer-arr-scan.mjs.map +1 -1
  47. package/dist/rules/prefer-arr-sum.d.mts.map +1 -1
  48. package/dist/rules/prefer-arr-sum.mjs +51 -37
  49. package/dist/rules/prefer-arr-sum.mjs.map +1 -1
  50. package/dist/rules/prefer-arr-uniq.d.mts.map +1 -1
  51. package/dist/rules/prefer-arr-uniq.mjs +33 -10
  52. package/dist/rules/prefer-arr-uniq.mjs.map +1 -1
  53. package/dist/rules/prefer-as-int.d.mts.map +1 -1
  54. package/dist/rules/prefer-as-int.mjs +18 -12
  55. package/dist/rules/prefer-as-int.mjs.map +1 -1
  56. package/dist/rules/prefer-canonical-array-slicing.d.mts.map +1 -1
  57. package/dist/rules/prefer-canonical-array-slicing.mjs +29 -14
  58. package/dist/rules/prefer-canonical-array-slicing.mjs.map +1 -1
  59. package/dist/rules/prefer-canonical-length-cast.d.mts.map +1 -1
  60. package/dist/rules/prefer-canonical-length-cast.mjs +34 -13
  61. package/dist/rules/prefer-canonical-length-cast.mjs.map +1 -1
  62. package/dist/rules/prefer-canonical-length-guard.d.mts.map +1 -1
  63. package/dist/rules/prefer-canonical-length-guard.mjs +10 -4
  64. package/dist/rules/prefer-canonical-length-guard.mjs.map +1 -1
  65. package/dist/rules/prefer-comparison-over-nullish-guard.d.mts.map +1 -1
  66. package/dist/rules/prefer-comparison-over-nullish-guard.mjs +25 -3
  67. package/dist/rules/prefer-comparison-over-nullish-guard.mjs.map +1 -1
  68. package/dist/rules/prefer-is-non-null-object.mjs +28 -10
  69. package/dist/rules/prefer-is-non-null-object.mjs.map +1 -1
  70. package/dist/rules/prefer-is-record-and-has-key.d.mts.map +1 -1
  71. package/dist/rules/prefer-is-record-and-has-key.mjs +8 -3
  72. package/dist/rules/prefer-is-record-and-has-key.mjs.map +1 -1
  73. package/dist/rules/prefer-num-safe-parse-float.d.mts +3 -1
  74. package/dist/rules/prefer-num-safe-parse-float.d.mts.map +1 -1
  75. package/dist/rules/prefer-num-safe-parse-float.mjs +18 -7
  76. package/dist/rules/prefer-num-safe-parse-float.mjs.map +1 -1
  77. package/dist/rules/prefer-num-safe-parse-int.d.mts.map +1 -1
  78. package/dist/rules/prefer-num-safe-parse-int.mjs +15 -8
  79. package/dist/rules/prefer-num-safe-parse-int.mjs.map +1 -1
  80. package/dist/rules/prefer-range-for-loop.d.mts.map +1 -1
  81. package/dist/rules/prefer-range-for-loop.mjs +13 -7
  82. package/dist/rules/prefer-range-for-loop.mjs.map +1 -1
  83. package/dist/types.d.mts +2 -2
  84. package/dist/types.d.mts.map +1 -1
  85. package/package.json +7 -7
  86. package/src/index.mts +1 -1
  87. package/src/plugin.mts +5 -4
  88. package/src/plugin.test.mts +0 -13
  89. package/src/rule-types.mts +3 -3
  90. package/src/rules/ast-utils.mts +156 -13
  91. package/src/rules/import-utils.mts +35 -15
  92. package/src/rules/no-side-effect-import.mts +1 -1
  93. package/src/rules/no-unnecessary-type-guard.mts +192 -124
  94. package/src/rules/no-unnecessary-type-guard.test.mts +100 -0
  95. package/src/rules/prefer-arr-is-array.mts +11 -6
  96. package/src/rules/prefer-arr-is-array.test.mts +40 -0
  97. package/src/rules/prefer-arr-is-bounded-length-array.mts +13 -25
  98. package/src/rules/prefer-arr-is-bounded-length-array.test.mts +68 -0
  99. package/src/rules/prefer-arr-is-fixed-length-array.mts +18 -38
  100. package/src/rules/prefer-arr-is-fixed-length-array.test.mts +63 -0
  101. package/src/rules/prefer-arr-is-max-length-array.mts +15 -26
  102. package/src/rules/prefer-arr-is-max-length-array.test.mts +67 -0
  103. package/src/rules/prefer-arr-is-min-length-array.mts +15 -26
  104. package/src/rules/prefer-arr-is-min-length-array.test.mts +70 -0
  105. package/src/rules/prefer-arr-is-non-empty.mts +16 -25
  106. package/src/rules/prefer-arr-is-non-empty.test.mts +79 -0
  107. package/src/rules/prefer-arr-scan.mts +55 -19
  108. package/src/rules/prefer-arr-scan.test.mts +149 -0
  109. package/src/rules/prefer-arr-sum.mts +78 -47
  110. package/src/rules/prefer-arr-sum.test.mts +97 -0
  111. package/src/rules/prefer-arr-uniq.mts +46 -12
  112. package/src/rules/prefer-arr-uniq.test.mts +71 -0
  113. package/src/rules/prefer-as-int.mts +24 -16
  114. package/src/rules/prefer-as-int.test.mts +51 -0
  115. package/src/rules/prefer-canonical-array-slicing.mts +38 -15
  116. package/src/rules/prefer-canonical-array-slicing.test.mts +92 -0
  117. package/src/rules/prefer-canonical-length-cast.mts +45 -15
  118. package/src/rules/prefer-canonical-length-cast.test.mts +111 -0
  119. package/src/rules/prefer-canonical-length-guard.mts +13 -7
  120. package/src/rules/prefer-canonical-length-guard.test.mts +99 -0
  121. package/src/rules/prefer-comparison-over-nullish-guard.mts +33 -6
  122. package/src/rules/prefer-comparison-over-nullish-guard.test.mts +136 -0
  123. package/src/rules/prefer-is-non-null-object.mts +31 -11
  124. package/src/rules/prefer-is-non-null-object.test.mts +42 -0
  125. package/src/rules/prefer-is-record-and-has-key.mts +10 -5
  126. package/src/rules/prefer-is-record-and-has-key.test.mts +34 -0
  127. package/src/rules/prefer-num-safe-parse-float.mts +24 -11
  128. package/src/rules/prefer-num-safe-parse-float.test.mts +79 -0
  129. package/src/rules/prefer-num-safe-parse-int.mts +25 -11
  130. package/src/rules/prefer-num-safe-parse-int.test.mts +64 -0
  131. package/src/rules/prefer-obj-over-entries-round-trip.mts +1 -1
  132. package/src/rules/prefer-range-for-loop.mts +18 -8
  133. package/src/rules/prefer-range-for-loop.test.mts +75 -0
  134. package/src/rules/rules.mts +1 -1
  135. package/src/types.mts +2 -2
@@ -3,18 +3,125 @@ import {
3
3
  type TSESLint,
4
4
  type TSESTree,
5
5
  } from '@typescript-eslint/utils';
6
- import { type DeepReadonly } from 'ts-type-forge';
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
- export const isLengthAccess = (
57
- node: DeepReadonly<TSESTree.Expression>,
58
- ): node is TSESTree.MemberExpression =>
59
- node.type === AST_NODE_TYPES.MemberExpression &&
60
- node.property.type === AST_NODE_TYPES.Identifier &&
61
- node.property.name === 'length';
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 lengthOnLeft = isLengthAccess(left);
230
+ const leftLength = asLengthAccess(left);
231
+
232
+ const lengthOnLeft = leftLength !== undefined;
94
233
 
95
- const lengthSide = lengthOnLeft ? left : right;
234
+ const lengthSide = leftLength ?? asLengthAccess(right);
96
235
 
97
236
  const boundSide = lengthOnLeft ? right : left;
98
237
 
99
- if (!isLengthAccess(lengthSide)) {
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 { array: lengthSide.object, bound: boundSide, kind };
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
- sourceCode.getText(self.array) === sourceCode.getText(other.array)
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 { type DeepReadonly } from 'ts-type-forge';
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
- return (callee) => {
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 !== undefined &&
119
- callee.type === AST_NODE_TYPES.MemberExpression &&
120
- !callee.computed &&
121
- callee.object.type === AST_NODE_TYPES.Identifier &&
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
- return undefined;
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,
@@ -1,4 +1,4 @@
1
- import { type TSESLint, type TSESTree } from '@typescript-eslint/utils';
1
+ import type { TSESLint, TSESTree } from '@typescript-eslint/utils';
2
2
  import { Arr } from 'ts-data-forge';
3
3
  import { TS_DATA_FORGE_MODULE } from './constants.mjs';
4
4
 
@@ -4,8 +4,9 @@ import {
4
4
  type TSESLint,
5
5
  type TSESTree,
6
6
  } from '@typescript-eslint/utils';
7
- import { type DeepReadonly, type ReadonlyRecord } from 'ts-type-forge';
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
- const argTsNode = parserServices.esTreeNodeToTSNodeMap.get(argument);
286
-
287
- const argType = checker.getTypeAtLocation(argTsNode);
288
-
289
- const parts = collectUnionParts(argType);
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
- if (parts === undefined) {
292
- return; // opaque / generic → bail conservatively
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
- const inputAtoms = new Set(parts.map(classifyAtom));
296
-
297
- const hasMemberOutsideTarget = (
298
- atoms: readonly AtomKey[],
299
- ): boolean => {
300
- const targetSet = new Set<Atom>(atoms);
301
-
302
- return parts.some((p) => !targetSet.has(classifyAtom(p)));
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
  }