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
@@ -169,3 +169,139 @@ describe('prefer-comparison-over-nullish-guard', () => {
169
169
  },
170
170
  );
171
171
  });
172
+
173
+ describe('prefer-comparison-over-nullish-guard through type wrappers', () => {
174
+ tester.run(
175
+ 'prefer-comparison-over-nullish-guard',
176
+ preferComparisonOverNullishGuard,
177
+ {
178
+ valid: [],
179
+ invalid: [
180
+ {
181
+ name: 'the call wrapped in `satisfies`, `as` or `<T>`',
182
+ code: dedent`
183
+ import { isNull, isNotUndefined } from 'ts-data-forge';
184
+ declare const x: string | null | undefined;
185
+ const a = isNull(x) satisfies boolean;
186
+ const b = isNotUndefined(x) as boolean;
187
+ const c = <boolean>isNull(x);
188
+ `,
189
+ output: dedent`
190
+ import { isNull, isNotUndefined } from 'ts-data-forge';
191
+ declare const x: string | null | undefined;
192
+ const a = (x === null) satisfies boolean;
193
+ const b = (x !== undefined) as boolean;
194
+ const c = <boolean>(x === null);
195
+ `,
196
+ errors: [
197
+ { messageId: 'preferComparison' },
198
+ { messageId: 'preferComparison' },
199
+ { messageId: 'preferComparison' },
200
+ ],
201
+ },
202
+ {
203
+ name: 'a wrapped argument is parenthesized under `===`',
204
+ code: dedent`
205
+ import { isNull } from 'ts-data-forge';
206
+ declare const x: unknown;
207
+ const a = isNull(x as string | null);
208
+ const b = isNull(x satisfies unknown);
209
+ const c = isNull(x!);
210
+ `,
211
+ output: dedent`
212
+ import { isNull } from 'ts-data-forge';
213
+ declare const x: unknown;
214
+ const a = (x as string | null) === null;
215
+ const b = (x satisfies unknown) === null;
216
+ const c = x! === null;
217
+ `,
218
+ errors: [
219
+ { messageId: 'preferComparison' },
220
+ { messageId: 'preferComparison' },
221
+ { messageId: 'preferComparison' },
222
+ ],
223
+ },
224
+ {
225
+ name: 'the guard itself reached through a wrapper',
226
+ code: dedent`
227
+ import { isNull } from 'ts-data-forge';
228
+ declare const x: string | null | undefined;
229
+ const a = (isNull as (u: unknown) => boolean)(x);
230
+ `,
231
+ output: dedent`
232
+ import { isNull } from 'ts-data-forge';
233
+ declare const x: string | null | undefined;
234
+ const a = x === null;
235
+ `,
236
+ errors: [{ messageId: 'preferComparison' }],
237
+ },
238
+ {
239
+ name: 'the namespace reached through a wrapper',
240
+ code: dedent`
241
+ import * as tf from 'ts-data-forge';
242
+ declare const x: string | null | undefined;
243
+ const b = (tf as typeof tf).isNotUndefined(x);
244
+ `,
245
+ output: dedent`
246
+ import * as tf from 'ts-data-forge';
247
+ declare const x: string | null | undefined;
248
+ const b = x !== undefined;
249
+ `,
250
+ errors: [{ messageId: 'preferComparison' }],
251
+ },
252
+ ],
253
+ },
254
+ );
255
+ });
256
+
257
+ describe('prefer-comparison-over-nullish-guard with parenthesized operands', () => {
258
+ tester.run(
259
+ 'prefer-comparison-over-nullish-guard',
260
+ preferComparisonOverNullishGuard,
261
+ {
262
+ valid: [],
263
+ invalid: [
264
+ {
265
+ name: 'bitwise operators bind looser than `===`',
266
+ code: dedent`
267
+ import { isNull } from 'ts-data-forge';
268
+ declare const a: number;
269
+ declare const b: number;
270
+ const p = isNull(a | b);
271
+ const q = isNull(a ^ b);
272
+ const r = isNull((a & b));
273
+ `,
274
+ output: dedent`
275
+ import { isNull } from 'ts-data-forge';
276
+ declare const a: number;
277
+ declare const b: number;
278
+ const p = (a | b) === null;
279
+ const q = (a ^ b) === null;
280
+ const r = (a & b) === null;
281
+ `,
282
+ errors: [
283
+ { messageId: 'preferComparison' },
284
+ { messageId: 'preferComparison' },
285
+ { messageId: 'preferComparison' },
286
+ ],
287
+ },
288
+ {
289
+ name: 'an equality argument is parenthesized for readability',
290
+ code: dedent`
291
+ import { isNotUndefined } from 'ts-data-forge';
292
+ declare const a: unknown;
293
+ declare const b: unknown;
294
+ const p = isNotUndefined(a === b);
295
+ `,
296
+ output: dedent`
297
+ import { isNotUndefined } from 'ts-data-forge';
298
+ declare const a: unknown;
299
+ declare const b: unknown;
300
+ const p = (a === b) !== undefined;
301
+ `,
302
+ errors: [{ messageId: 'preferComparison' }],
303
+ },
304
+ ],
305
+ },
306
+ );
307
+ });
@@ -3,7 +3,7 @@ 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 { skipTypeWrappers } from './ast-utils.mjs';
7
7
  import {
8
8
  buildImportFixes,
9
9
  getNamedImports,
@@ -88,8 +88,15 @@ export const preferIsNonNullObject: TSESLint.RuleModule<MessageIds, Options> = {
88
88
  defaultOptions: [],
89
89
  } as const;
90
90
 
91
+ /**
92
+ * The name of the value checked by `typeof u === 'object' && u !== null`, or
93
+ * `undefined`. Every operand is read through type wrappers:
94
+ * `(u satisfies unknown) !== null` and `u! !== null` check the same `u`.
95
+ */
91
96
  const getNonNullObjectIdentifierName = (
92
- node: DeepReadonly<TSESTree.LogicalExpression>,
97
+ // AST nodes hold mutable child arrays, so they are not deeply readonly.
98
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
99
+ node: TSESTree.LogicalExpression,
93
100
  ): string | undefined => {
94
101
  if (node.operator !== '&&') {
95
102
  return undefined;
@@ -107,7 +114,8 @@ const getNonNullObjectIdentifierName = (
107
114
  };
108
115
 
109
116
  const getTypeofObjectIdentifierName = (
110
- node: DeepReadonly<TSESTree.Expression>,
117
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
118
+ node: TSESTree.Expression,
111
119
  ): string | undefined => {
112
120
  if (node.type !== AST_NODE_TYPES.BinaryExpression) {
113
121
  return undefined;
@@ -117,19 +125,29 @@ const getTypeofObjectIdentifierName = (
117
125
  return undefined;
118
126
  }
119
127
 
120
- const { left, right } = node;
128
+ const left = skipTypeWrappers(node.left);
121
129
 
122
- return left.type !== AST_NODE_TYPES.UnaryExpression ||
130
+ const right = skipTypeWrappers(node.right);
131
+
132
+ if (
133
+ left.type !== AST_NODE_TYPES.UnaryExpression ||
123
134
  left.operator !== 'typeof' ||
124
135
  right.type !== AST_NODE_TYPES.Literal ||
125
- right.value !== 'object' ||
126
- left.argument.type !== AST_NODE_TYPES.Identifier
127
- ? undefined
128
- : left.argument.name;
136
+ right.value !== 'object'
137
+ ) {
138
+ return undefined;
139
+ }
140
+
141
+ const argument = skipTypeWrappers(left.argument);
142
+
143
+ return argument.type === AST_NODE_TYPES.Identifier
144
+ ? argument.name
145
+ : undefined;
129
146
  };
130
147
 
131
148
  const getNonNullCheckIdentifierName = (
132
- node: DeepReadonly<TSESTree.Expression>,
149
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
150
+ node: TSESTree.Expression,
133
151
  ): string | undefined => {
134
152
  if (node.type !== AST_NODE_TYPES.BinaryExpression) {
135
153
  return undefined;
@@ -139,7 +157,9 @@ const getNonNullCheckIdentifierName = (
139
157
  return undefined;
140
158
  }
141
159
 
142
- const { left, right } = node;
160
+ const left = skipTypeWrappers(node.left);
161
+
162
+ const right = skipTypeWrappers(node.right);
143
163
 
144
164
  return left.type !== AST_NODE_TYPES.Identifier ||
145
165
  right.type !== AST_NODE_TYPES.Literal ||
@@ -154,3 +154,45 @@ describe('prefer-is-non-null-object', () => {
154
154
  ],
155
155
  });
156
156
  }, 20000);
157
+
158
+ describe('prefer-is-non-null-object through type wrappers', () => {
159
+ tester.run('prefer-is-non-null-object', preferIsNonNullObject, {
160
+ valid: [
161
+ {
162
+ name: 'checks on two different values, one wrapped',
163
+ code: dedent`
164
+ declare const u: unknown;
165
+ declare const v: unknown;
166
+ const ok = typeof u === 'object' && (v satisfies unknown) !== null;
167
+ `,
168
+ },
169
+ ],
170
+ invalid: [
171
+ {
172
+ name: 'the value wrapped on either side',
173
+ code: dedent`
174
+ import { isNonNullObject } from 'ts-data-forge';
175
+ declare const u: unknown;
176
+ const a = typeof u === 'object' && (u satisfies unknown) !== null;
177
+ const b = typeof u === 'object' && u! !== null;
178
+ const c = typeof (u satisfies unknown) === 'object' && u !== null;
179
+ const d = typeof u === ('object' as const) && u !== (null as null);
180
+ `,
181
+ output: dedent`
182
+ import { isNonNullObject } from 'ts-data-forge';
183
+ declare const u: unknown;
184
+ const a = isNonNullObject(u);
185
+ const b = isNonNullObject(u);
186
+ const c = isNonNullObject(u);
187
+ const d = isNonNullObject(u);
188
+ `,
189
+ errors: [
190
+ { messageId: 'useIsNonNullObject' },
191
+ { messageId: 'useIsNonNullObject' },
192
+ { messageId: 'useIsNonNullObject' },
193
+ { messageId: 'useIsNonNullObject' },
194
+ ],
195
+ },
196
+ ],
197
+ });
198
+ }, 20000);
@@ -5,6 +5,7 @@ import {
5
5
  } from '@typescript-eslint/utils';
6
6
  import { Arr } from 'ts-data-forge';
7
7
  import * as ts from 'typescript';
8
+ import { typeWrapperLayers } from './ast-utils.mjs';
8
9
  import {
9
10
  buildImportFixes,
10
11
  getNamedImports,
@@ -58,12 +59,16 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
58
59
  return false;
59
60
  }
60
61
 
61
- const tsNode = services?.esTreeNodeToTSNodeMap?.get(expression);
62
+ // At every layer of type wrappers (see typeWrapperLayers): an array
63
+ // cast to `Record<string, unknown>` still needs its `isRecord`.
64
+ return typeWrapperLayers(expression).every((layer) => {
65
+ const tsNode = services?.esTreeNodeToTSNodeMap?.get(layer);
62
66
 
63
- return (
64
- tsNode !== undefined &&
65
- isAlreadyRecordType(checker, checker.getTypeAtLocation(tsNode))
66
- );
67
+ return (
68
+ tsNode !== undefined &&
69
+ isAlreadyRecordType(checker, checker.getTypeAtLocation(tsNode))
70
+ );
71
+ });
67
72
  };
68
73
 
69
74
  const mut_nodesToFix: {
@@ -289,3 +289,37 @@ describe('prefer-is-record-and-has-key', () => {
289
289
  ],
290
290
  });
291
291
  }, 20000);
292
+
293
+ describe('prefer-is-record-and-has-key through type wrappers', () => {
294
+ tester.run('prefer-is-record-and-has-key', preferIsRecordAndHasKey, {
295
+ valid: [],
296
+ invalid: [
297
+ {
298
+ name: 'keeps isRecord for an array cast to a record: the value is still an array',
299
+ code: dedent`
300
+ declare const arr: readonly string[];
301
+ const ok = Object.hasOwn(arr as unknown as Record<string, unknown>, '0');
302
+ `,
303
+ output: dedent`
304
+ import { isRecord, hasKey } from 'ts-data-forge';
305
+ declare const arr: readonly string[];
306
+ const ok = (isRecord(arr as unknown as Record<string, unknown>) && hasKey(arr as unknown as Record<string, unknown>, '0'));
307
+ `,
308
+ errors: [{ messageId: 'useIsRecordAndHasKey' }],
309
+ },
310
+ {
311
+ name: 'keeps isRecord for a record widened to include an array: the author says it may be one',
312
+ code: dedent`
313
+ declare const obj: Readonly<Record<string, unknown>>;
314
+ const ok = Object.hasOwn(obj as Readonly<Record<string, unknown>> | readonly unknown[], 'a');
315
+ `,
316
+ output: dedent`
317
+ import { isRecord, hasKey } from 'ts-data-forge';
318
+ declare const obj: Readonly<Record<string, unknown>>;
319
+ const ok = (isRecord(obj as Readonly<Record<string, unknown>> | readonly unknown[]) && hasKey(obj as Readonly<Record<string, unknown>> | readonly unknown[], 'a'));
320
+ `,
321
+ errors: [{ messageId: 'useIsRecordAndHasKey' }],
322
+ },
323
+ ],
324
+ });
325
+ });
@@ -4,6 +4,7 @@ import {
4
4
  type TSESTree,
5
5
  } from '@typescript-eslint/utils';
6
6
  import * as ts from 'typescript';
7
+ import { toArgumentText, typeWrapperLayers } from './ast-utils.mjs';
7
8
  import {
8
9
  buildImportFixes,
9
10
  getNamedImports,
@@ -36,7 +37,9 @@ const isStringType = (type: ts.Type): boolean => {
36
37
  * `Result.unwrapOkOr(Num.safeParseFloat(x), Number.NaN)` (ts-data-forge).
37
38
  *
38
39
  * `Number(x)` is only flagged when x is purely a string type to avoid false
39
- * positives for `Number(someBoolean)` or `Number(someNumber)` uses.
40
+ * positives for `Number(someBoolean)` or `Number(someNumber)` uses. The type is
41
+ * asked of the value under any `as` / `!` / `satisfies` too, so a cast does not
42
+ * vouch for a string that is not one.
40
43
  */
41
44
  export const preferNumSafeParseFloat: TSESLint.RuleModule<MessageIds, Options> =
42
45
  {
@@ -117,15 +120,25 @@ export const preferNumSafeParseFloat: TSESLint.RuleModule<MessageIds, Options> =
117
120
 
118
121
  const checker = services.program.getTypeChecker();
119
122
 
120
- const tsNode = services.esTreeNodeToTSNodeMap?.get(firstArg);
121
-
122
- if (tsNode === undefined) {
123
- return;
124
- }
125
-
126
- const argType = checker.getTypeAtLocation(tsNode);
127
-
128
- if (!isStringType(argType)) {
123
+ // Every layer of type wrappers must be `string` (see
124
+ // typeWrapperLayers): the value underneath is what `Number` sees at
125
+ // run time (`Number(v as string)` may still be handed `true`), and
126
+ // the type a wrapper states is what the fix, which keeps the
127
+ // wrapper, passes on.
128
+ const isString = (
129
+ // AST nodes hold mutable child arrays, so they are not deeply readonly.
130
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
131
+ expression: TSESTree.Node,
132
+ ): boolean => {
133
+ const tsNode = services.esTreeNodeToTSNodeMap?.get(expression);
134
+
135
+ return (
136
+ tsNode !== undefined &&
137
+ isStringType(checker.getTypeAtLocation(tsNode))
138
+ );
139
+ };
140
+
141
+ if (!typeWrapperLayers(firstArg).every(isString)) {
129
142
  return;
130
143
  }
131
144
 
@@ -142,7 +155,7 @@ export const preferNumSafeParseFloat: TSESLint.RuleModule<MessageIds, Options> =
142
155
  index,
143
156
  { node, argExpression },
144
157
  ] of mut_nodesToFix.entries()) {
145
- const argText = sourceCode.getText(argExpression);
158
+ const argText = toArgumentText(argExpression, sourceCode);
146
159
 
147
160
  const originalText = sourceCode.getText(node);
148
161
 
@@ -161,3 +161,82 @@ describe('prefer-num-safe-parse-float', () => {
161
161
  ],
162
162
  });
163
163
  }, 20000);
164
+
165
+ describe('prefer-num-safe-parse-float through type wrappers', () => {
166
+ tester.run('prefer-num-safe-parse-float', preferNumSafeParseFloat, {
167
+ valid: [
168
+ {
169
+ name: 'an unknown value cast to string',
170
+ code: dedent`
171
+ declare const v: unknown;
172
+ const a = Number(v as string);
173
+ const b = parseFloat(<string>v);
174
+ `,
175
+ },
176
+ {
177
+ name: 'a number cast to string',
178
+ code: dedent`
179
+ declare const n: number;
180
+ const a = Number(n as unknown as string);
181
+ `,
182
+ },
183
+ {
184
+ name: 'a string whose type a middle layer does not vouch for',
185
+ code: dedent`
186
+ declare const s: string;
187
+ const a = Number(s as unknown as string);
188
+ `,
189
+ },
190
+ {
191
+ name: 'a `!` that leaves null in place (`Number(null)` is 0)',
192
+ code: dedent`
193
+ declare const s: string | null;
194
+ const a = Number(s!);
195
+ `,
196
+ },
197
+ ],
198
+ invalid: [
199
+ {
200
+ name: 'a string argument keeps its wrapper',
201
+ code: dedent`
202
+ declare const s: string;
203
+ const a = Number(s!);
204
+ const b = parseFloat(s satisfies string);
205
+ `,
206
+ output: dedent`
207
+ import { Num, Result } from 'ts-data-forge';
208
+ declare const s: string;
209
+ const a = Result.unwrapOkOr(Num.safeParseFloat(s!), Number.NaN);
210
+ const b = Result.unwrapOkOr(Num.safeParseFloat(s satisfies string), Number.NaN);
211
+ `,
212
+ errors: [
213
+ { messageId: 'useSafeParseFloat' },
214
+ { messageId: 'useSafeParseFloat' },
215
+ ],
216
+ },
217
+ ],
218
+ });
219
+ }, 20000);
220
+
221
+ describe('prefer-num-safe-parse-float with parenthesized operands', () => {
222
+ tester.run('prefer-num-safe-parse-float', preferNumSafeParseFloat, {
223
+ valid: [],
224
+ invalid: [
225
+ {
226
+ name: 'a sequence argument keeps its parentheses',
227
+ code: dedent`
228
+ declare const s: string;
229
+ declare const log: () => void;
230
+ const a = Number((log(), s));
231
+ `,
232
+ output: dedent`
233
+ import { Num, Result } from 'ts-data-forge';
234
+ declare const s: string;
235
+ declare const log: () => void;
236
+ const a = Result.unwrapOkOr(Num.safeParseFloat((log(), s)), Number.NaN);
237
+ `,
238
+ errors: [{ messageId: 'useSafeParseFloat' }],
239
+ },
240
+ ],
241
+ });
242
+ }, 20000);
@@ -4,6 +4,11 @@ import {
4
4
  type TSESTree,
5
5
  } from '@typescript-eslint/utils';
6
6
  import * as ts from 'typescript';
7
+ import {
8
+ skipTypeWrappers,
9
+ toArgumentText,
10
+ typeWrapperLayers,
11
+ } from './ast-utils.mjs';
7
12
  import {
8
13
  buildImportFixes,
9
14
  getNamedImports,
@@ -101,9 +106,11 @@ export const preferNumSafeParseInt: TSESLint.RuleModule<MessageIds, Options> = {
101
106
  return;
102
107
  }
103
108
 
104
- // Only base 10: accept a missing radix or an explicit literal `10`.
109
+ // Only base 10: accept a missing radix or an explicit literal `10`,
110
+ // under any type wrapper (`10 as const`).
105
111
  if (args.length >= 2) {
106
- const radix = args[1];
112
+ const radix =
113
+ args[1] === undefined ? undefined : skipTypeWrappers(args[1]);
107
114
 
108
115
  if (radix?.type !== AST_NODE_TYPES.Literal || radix.value !== 10) {
109
116
  return;
@@ -111,22 +118,29 @@ export const preferNumSafeParseInt: TSESLint.RuleModule<MessageIds, Options> = {
111
118
  }
112
119
 
113
120
  // The argument must be a `string` so the autofix is type-safe against
114
- // `Num.safeParseInt(s: string)`. Without type information, skip.
121
+ // `Num.safeParseInt(s: string)`. Without type information, skip. Every
122
+ // layer of type wrappers is asked (see typeWrapperLayers): a cast
123
+ // does not make a number a string.
115
124
  if (services?.program == null) {
116
125
  return;
117
126
  }
118
127
 
119
128
  const checker = services.program.getTypeChecker();
120
129
 
121
- const tsNode = services.esTreeNodeToTSNodeMap?.get(firstArg);
122
-
123
- if (tsNode === undefined) {
124
- return;
125
- }
130
+ const isString = (
131
+ // AST nodes hold mutable child arrays, so they are not deeply readonly.
132
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
133
+ expression: TSESTree.Node,
134
+ ): boolean => {
135
+ const tsNode = services.esTreeNodeToTSNodeMap?.get(expression);
126
136
 
127
- const argType = checker.getTypeAtLocation(tsNode);
137
+ return (
138
+ tsNode !== undefined &&
139
+ isStringType(checker.getTypeAtLocation(tsNode))
140
+ );
141
+ };
128
142
 
129
- if (!isStringType(argType)) {
143
+ if (!typeWrapperLayers(firstArg).every(isString)) {
130
144
  return;
131
145
  }
132
146
 
@@ -143,7 +157,7 @@ export const preferNumSafeParseInt: TSESLint.RuleModule<MessageIds, Options> = {
143
157
  index,
144
158
  { node, argExpression },
145
159
  ] of mut_nodesToFix.entries()) {
146
- const argText = sourceCode.getText(argExpression);
160
+ const argText = toArgumentText(argExpression, sourceCode);
147
161
 
148
162
  const originalText = sourceCode.getText(node);
149
163
 
@@ -135,3 +135,67 @@ describe('prefer-num-safe-parse-int', () => {
135
135
  ],
136
136
  });
137
137
  }, 20000);
138
+
139
+ describe('prefer-num-safe-parse-int through type wrappers', () => {
140
+ tester.run('prefer-num-safe-parse-int', preferNumSafeParseInt, {
141
+ valid: [
142
+ {
143
+ name: 'a number cast to string',
144
+ code: dedent`
145
+ declare const n: number;
146
+ const a = parseInt(n as unknown as string, 10);
147
+ `,
148
+ },
149
+ {
150
+ name: 'a wrapped radix other than 10',
151
+ code: dedent`
152
+ declare const s: string;
153
+ const a = parseInt(s, 16 as const);
154
+ `,
155
+ },
156
+ ],
157
+ invalid: [
158
+ {
159
+ name: 'a radix wrapped in `as const` or `satisfies`',
160
+ code: dedent`
161
+ declare const s: string;
162
+ const a = parseInt(s, 10 as const);
163
+ const b = Number.parseInt(s!, 10 satisfies number);
164
+ `,
165
+ output: dedent`
166
+ import { Num, Result } from 'ts-data-forge';
167
+ declare const s: string;
168
+ const a = Result.unwrapOkOr(Num.safeParseInt(s), Number.NaN);
169
+ const b = Result.unwrapOkOr(Num.safeParseInt(s!), Number.NaN);
170
+ `,
171
+ errors: [
172
+ { messageId: 'useSafeParseInt' },
173
+ { messageId: 'useSafeParseInt' },
174
+ ],
175
+ },
176
+ ],
177
+ });
178
+ }, 20000);
179
+
180
+ describe('prefer-num-safe-parse-int with parenthesized operands', () => {
181
+ tester.run('prefer-num-safe-parse-int', preferNumSafeParseInt, {
182
+ valid: [],
183
+ invalid: [
184
+ {
185
+ name: 'a sequence argument keeps its parentheses',
186
+ code: dedent`
187
+ declare const s: string;
188
+ declare const log: () => void;
189
+ const a = parseInt((log(), s), 10);
190
+ `,
191
+ output: dedent`
192
+ import { Num, Result } from 'ts-data-forge';
193
+ declare const s: string;
194
+ declare const log: () => void;
195
+ const a = Result.unwrapOkOr(Num.safeParseInt((log(), s)), Number.NaN);
196
+ `,
197
+ errors: [{ messageId: 'useSafeParseInt' }],
198
+ },
199
+ ],
200
+ });
201
+ }, 20000);
@@ -4,7 +4,7 @@ import {
4
4
  type TSESTree,
5
5
  } from '@typescript-eslint/utils';
6
6
  import { Arr } from 'ts-data-forge';
7
- import { type ReadonlyRecord } from 'ts-type-forge';
7
+ import type { ReadonlyRecord } from 'ts-type-forge';
8
8
  import {
9
9
  buildImportFixes,
10
10
  getImportedLocalName,