eslint-plugin-ts-std-forge 0.1.4 → 0.1.5

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 (51) hide show
  1. package/dist/plugin.d.mts +1 -1
  2. package/dist/plugin.d.mts.map +1 -1
  3. package/dist/plugin.mjs +1 -0
  4. package/dist/plugin.mjs.map +1 -1
  5. package/dist/rule-types.d.mts +1 -1
  6. package/dist/rule-types.d.mts.map +1 -1
  7. package/dist/rules/ast-utils.d.mts +41 -0
  8. package/dist/rules/ast-utils.d.mts.map +1 -0
  9. package/dist/rules/ast-utils.mjs +101 -0
  10. package/dist/rules/ast-utils.mjs.map +1 -0
  11. package/dist/rules/import-utils.d.mts +1 -1
  12. package/dist/rules/import-utils.d.mts.map +1 -1
  13. package/dist/rules/prefer-is-non-null-object.mjs +26 -12
  14. package/dist/rules/prefer-is-non-null-object.mjs.map +1 -1
  15. package/dist/rules/prefer-is-record-and-has-key.d.mts.map +1 -1
  16. package/dist/rules/prefer-is-record-and-has-key.mjs +12 -12
  17. package/dist/rules/prefer-is-record-and-has-key.mjs.map +1 -1
  18. package/dist/rules/prefer-safe-array-is-array.d.mts.map +1 -1
  19. package/dist/rules/prefer-safe-array-is-array.mjs +15 -13
  20. package/dist/rules/prefer-safe-array-is-array.mjs.map +1 -1
  21. package/dist/rules/prefer-safe-array-length-guard.d.mts.map +1 -1
  22. package/dist/rules/prefer-safe-array-length-guard.mjs +11 -13
  23. package/dist/rules/prefer-safe-array-length-guard.mjs.map +1 -1
  24. package/dist/rules/prefer-safe-number-parse-integer.d.mts.map +1 -1
  25. package/dist/rules/prefer-safe-number-parse-integer.mjs +9 -16
  26. package/dist/rules/prefer-safe-number-parse-integer.mjs.map +1 -1
  27. package/dist/rules/prefer-safe-number-parse.d.mts.map +1 -1
  28. package/dist/rules/prefer-safe-number-parse.mjs +7 -15
  29. package/dist/rules/prefer-safe-number-parse.mjs.map +1 -1
  30. package/dist/types.d.mts +2 -2
  31. package/dist/types.d.mts.map +1 -1
  32. package/package.json +3 -3
  33. package/src/plugin.mts +5 -4
  34. package/src/plugin.test.mts +0 -13
  35. package/src/rule-types.mts +2 -2
  36. package/src/rules/ast-utils.mts +147 -0
  37. package/src/rules/import-utils.mts +1 -1
  38. package/src/rules/prefer-is-non-null-object.mts +34 -13
  39. package/src/rules/prefer-is-non-null-object.test.mts +54 -0
  40. package/src/rules/prefer-is-record-and-has-key.mts +19 -16
  41. package/src/rules/prefer-is-record-and-has-key.test.mts +128 -0
  42. package/src/rules/prefer-safe-array-is-array.mts +22 -18
  43. package/src/rules/prefer-safe-array-is-array.test.mts +93 -0
  44. package/src/rules/prefer-safe-array-length-guard.mts +20 -19
  45. package/src/rules/prefer-safe-array-length-guard.test.mts +98 -0
  46. package/src/rules/prefer-safe-number-parse-integer.mts +18 -24
  47. package/src/rules/prefer-safe-number-parse-integer.test.mts +104 -0
  48. package/src/rules/prefer-safe-number-parse.mts +14 -22
  49. package/src/rules/prefer-safe-number-parse.test.mts +97 -0
  50. package/src/rules/rules.mts +1 -1
  51. package/src/types.mts +2 -2
@@ -78,3 +78,131 @@ describe('prefer-is-record-and-has-key', () => {
78
78
  ],
79
79
  });
80
80
  });
81
+
82
+ describe('prefer-is-record-and-has-key through type wrappers', () => {
83
+ tester.run('prefer-is-record-and-has-key', preferIsRecordAndHasKey, {
84
+ valid: [],
85
+ invalid: [
86
+ {
87
+ // `isRecord` rejects an array, so dropping it on the cast's word
88
+ // would turn `false` into `true`.
89
+ name: 'keeps isRecord for an array cast to a record',
90
+ code: dedent`
91
+ declare const arr: string[];
92
+ const ok = Object.hasOwn(arr as unknown as Record<string, unknown>, '0');
93
+ `,
94
+ output: dedent`
95
+ import { isRecord, hasKey } from 'ts-std-forge';
96
+ declare const arr: string[];
97
+ const ok = (isRecord(arr as unknown as Record<string, unknown>) && hasKey(arr as unknown as Record<string, unknown>, '0'));
98
+ `,
99
+ errors: [{ messageId: 'useIsRecordAndHasKey' }],
100
+ },
101
+ {
102
+ name: 'keeps isRecord for an unknown value cast to a record in `in`',
103
+ code: dedent`
104
+ declare const u: unknown;
105
+ const ok = 'a' in (<Record<string, unknown>>u);
106
+ `,
107
+ output: dedent`
108
+ import { isRecord, hasKey } from 'ts-std-forge';
109
+ declare const u: unknown;
110
+ const ok = (isRecord(<Record<string, unknown>>u) && hasKey(<Record<string, unknown>>u, 'a'));
111
+ `,
112
+ errors: [{ messageId: 'useIsRecordAndHasKey' }],
113
+ },
114
+ {
115
+ name: 'drops isRecord for a record behind `satisfies` and keeps the wrapper',
116
+ code: dedent`
117
+ declare const obj: Readonly<Record<string, unknown>>;
118
+ const ok = Object.hasOwn(obj satisfies object, 'a');
119
+ `,
120
+ output: dedent`
121
+ import { hasKey } from 'ts-std-forge';
122
+ declare const obj: Readonly<Record<string, unknown>>;
123
+ const ok = hasKey(obj satisfies object, 'a');
124
+ `,
125
+ errors: [{ messageId: 'useHasKey' }],
126
+ },
127
+ {
128
+ name: 'reads `!` on an optional record as the record it asserts',
129
+ code: dedent`
130
+ declare const obj: Readonly<Record<string, unknown>> | undefined;
131
+ const ok = Object.hasOwn(obj!, 'a');
132
+ `,
133
+ output: dedent`
134
+ import { hasKey } from 'ts-std-forge';
135
+ declare const obj: Readonly<Record<string, unknown>> | undefined;
136
+ const ok = hasKey(obj!, 'a');
137
+ `,
138
+ errors: [{ messageId: 'useHasKey' }],
139
+ },
140
+ {
141
+ name: 'reads `Object` through `as`',
142
+ code: dedent`
143
+ declare const obj: Readonly<Record<string, unknown>>;
144
+ const ok = (Object as ObjectConstructor).hasOwn(obj, 'a');
145
+ `,
146
+ output: dedent`
147
+ import { hasKey } from 'ts-std-forge';
148
+ declare const obj: Readonly<Record<string, unknown>>;
149
+ const ok = hasKey(obj, 'a');
150
+ `,
151
+ errors: [{ messageId: 'useHasKey' }],
152
+ },
153
+ ],
154
+ });
155
+ });
156
+
157
+ describe('prefer-is-record-and-has-key with parenthesized operands', () => {
158
+ tester.run('prefer-is-record-and-has-key', preferIsRecordAndHasKey, {
159
+ valid: [],
160
+ invalid: [
161
+ {
162
+ name: 'keeps the parentheses of a sequence object in `in`',
163
+ code: dedent`
164
+ declare const a: number;
165
+ declare const o: unknown;
166
+ const ok = 'k' in (a, o);
167
+ `,
168
+ output: dedent`
169
+ import { isRecord, hasKey } from 'ts-std-forge';
170
+ declare const a: number;
171
+ declare const o: unknown;
172
+ const ok = (isRecord((a, o)) && hasKey((a, o), 'k'));
173
+ `,
174
+ errors: [{ messageId: 'useIsRecordAndHasKey' }],
175
+ },
176
+ {
177
+ name: 'keeps the parentheses of a sequence object in Object.hasOwn',
178
+ code: dedent`
179
+ declare const a: number;
180
+ declare const o: unknown;
181
+ const ok = Object.hasOwn((a, o), 'k');
182
+ `,
183
+ output: dedent`
184
+ import { isRecord, hasKey } from 'ts-std-forge';
185
+ declare const a: number;
186
+ declare const o: unknown;
187
+ const ok = (isRecord((a, o)) && hasKey((a, o), 'k'));
188
+ `,
189
+ errors: [{ messageId: 'useIsRecordAndHasKey' }],
190
+ },
191
+ {
192
+ name: 'keeps the parentheses of a sequence key',
193
+ code: dedent`
194
+ declare const a: number;
195
+ declare const o: Readonly<Record<string, unknown>>;
196
+ const ok = (a, 'k') in o;
197
+ `,
198
+ output: dedent`
199
+ import { hasKey } from 'ts-std-forge';
200
+ declare const a: number;
201
+ declare const o: Readonly<Record<string, unknown>>;
202
+ const ok = hasKey(o, (a, 'k'));
203
+ `,
204
+ errors: [{ messageId: 'useHasKey' }],
205
+ },
206
+ ],
207
+ });
208
+ });
@@ -3,7 +3,11 @@ 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 {
7
+ getRangeWithParens,
8
+ isIdentifierNamed,
9
+ skipTypeWrappers,
10
+ } from './ast-utils.mjs';
7
11
  import {
8
12
  buildImportFixes,
9
13
  getNamedImports,
@@ -61,20 +65,21 @@ export const preferSafeArrayIsArray: TSESLint.RuleModule<MessageIds, Options> =
61
65
  node,
62
66
  messageId: 'useSafeArrayIsArray',
63
67
  fix: (fixer) => {
64
- const callee = node.callee;
65
-
66
- if (callee.type !== AST_NODE_TYPES.MemberExpression) {
67
- return [];
68
- }
69
-
70
- const replacement = `SafeArray.isArray${sourceCode.getText(node).slice(sourceCode.getText(callee).length)}`;
71
-
72
68
  const importFixes =
73
69
  index === 0 && !hasSafeArrayImport
74
70
  ? buildImportFixes(fixer, program, ['SafeArray'])
75
71
  : [];
76
72
 
77
- return [...importFixes, fixer.replaceText(node, replacement)];
73
+ // Only the callee is replaced, type wrappers included, along
74
+ // with the parentheses around it, which its range leaves out;
75
+ // the arguments stay as written.
76
+ return [
77
+ ...importFixes,
78
+ fixer.replaceTextRange(
79
+ getRangeWithParens(sourceCode, node.callee),
80
+ 'SafeArray.isArray',
81
+ ),
82
+ ];
78
83
  },
79
84
  });
80
85
  }
@@ -85,19 +90,18 @@ export const preferSafeArrayIsArray: TSESLint.RuleModule<MessageIds, Options> =
85
90
  } as const;
86
91
 
87
92
  const isArrayIsArrayCall = (
88
- node: DeepReadonly<TSESTree.CallExpression>,
93
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
94
+ node: TSESTree.CallExpression,
89
95
  ): boolean => {
90
- if (node.callee.type !== AST_NODE_TYPES.MemberExpression) {
91
- return false;
92
- }
96
+ const callee = skipTypeWrappers(node.callee);
93
97
 
94
- const { object, property } = node.callee;
95
-
96
- if (object.type !== AST_NODE_TYPES.Identifier) {
98
+ if (callee.type !== AST_NODE_TYPES.MemberExpression) {
97
99
  return false;
98
100
  }
99
101
 
100
- if (object.name !== 'Array') {
102
+ const { object, property } = callee;
103
+
104
+ if (!isIdentifierNamed(object, 'Array')) {
101
105
  return false;
102
106
  }
103
107
 
@@ -63,3 +63,96 @@ ruleTester.run('prefer-safe-array-is-array', preferSafeArrayIsArray, {
63
63
  },
64
64
  ],
65
65
  });
66
+
67
+ describe('prefer-safe-array-is-array through type wrappers', () => {
68
+ ruleTester.run('prefer-safe-array-is-array', preferSafeArrayIsArray, {
69
+ valid: [],
70
+ invalid: [
71
+ {
72
+ name: 'reads `Array` through `as`',
73
+ code: dedent`
74
+ const result = (Array as ArrayConstructor).isArray(value);
75
+ `,
76
+ output: dedent`
77
+ import { SafeArray } from 'ts-std-forge';
78
+ const result = SafeArray.isArray(value);
79
+ `,
80
+ errors: [{ messageId: 'useSafeArrayIsArray' }],
81
+ },
82
+ {
83
+ name: 'reads `Array` through `!`',
84
+ code: dedent`
85
+ const result = Array!.isArray(value);
86
+ `,
87
+ output: dedent`
88
+ import { SafeArray } from 'ts-std-forge';
89
+ const result = SafeArray.isArray(value);
90
+ `,
91
+ errors: [{ messageId: 'useSafeArrayIsArray' }],
92
+ },
93
+ {
94
+ name: 'reads the callee through `satisfies`',
95
+ code: dedent`
96
+ const result = (Array.isArray satisfies unknown)(value);
97
+ `,
98
+ output: dedent`
99
+ import { SafeArray } from 'ts-std-forge';
100
+ const result = SafeArray.isArray(value);
101
+ `,
102
+ errors: [{ messageId: 'useSafeArrayIsArray' }],
103
+ },
104
+ {
105
+ name: 'keeps a wrapper on the argument',
106
+ code: dedent`
107
+ const result = Array.isArray(value satisfies unknown);
108
+ `,
109
+ output: dedent`
110
+ import { SafeArray } from 'ts-std-forge';
111
+ const result = SafeArray.isArray(value satisfies unknown);
112
+ `,
113
+ errors: [{ messageId: 'useSafeArrayIsArray' }],
114
+ },
115
+ ],
116
+ });
117
+ });
118
+
119
+ describe('prefer-safe-array-is-array with parenthesized operands', () => {
120
+ ruleTester.run('prefer-safe-array-is-array', preferSafeArrayIsArray, {
121
+ valid: [],
122
+ invalid: [
123
+ {
124
+ name: 'replaces a parenthesized callee with its parentheses',
125
+ code: dedent`
126
+ const result = (Array.isArray)(x);
127
+ `,
128
+ output: dedent`
129
+ import { SafeArray } from 'ts-std-forge';
130
+ const result = SafeArray.isArray(x);
131
+ `,
132
+ errors: [{ messageId: 'useSafeArrayIsArray' }],
133
+ },
134
+ {
135
+ name: 'replaces a doubly parenthesized callee of an optional call',
136
+ code: dedent`
137
+ const result = ((Array.isArray))?.(x);
138
+ `,
139
+ output: dedent`
140
+ import { SafeArray } from 'ts-std-forge';
141
+ const result = SafeArray.isArray?.(x);
142
+ `,
143
+ errors: [{ messageId: 'useSafeArrayIsArray' }],
144
+ },
145
+ {
146
+ name: 'keeps a parenthesized argument',
147
+ code: dedent`
148
+ const result = Array.isArray((a, x));
149
+ `,
150
+ output: dedent`
151
+ import { SafeArray } from 'ts-std-forge';
152
+ const result = SafeArray.isArray((a, x));
153
+ `,
154
+ errors: [{ messageId: 'useSafeArrayIsArray' }],
155
+ },
156
+ ],
157
+ });
158
+ });
@@ -3,7 +3,12 @@ import {
3
3
  type TSESLint,
4
4
  type TSESTree,
5
5
  } from '@typescript-eslint/utils';
6
- import { type ReadonlyRecord } from 'ts-type-forge';
6
+ import type { ReadonlyRecord } from 'ts-type-forge';
7
+ import {
8
+ getArgumentText,
9
+ getValueType,
10
+ skipTypeWrappers,
11
+ } from './ast-utils.mjs';
7
12
  import {
8
13
  buildImportFixes,
9
14
  getNamedImports,
@@ -79,14 +84,13 @@ export const preferSafeArrayLengthGuard: TSESLint.RuleModule<
79
84
 
80
85
  const tsStdForgeImport = getTsStdForgeImport(program);
81
86
 
82
- const services = sourceCode.parserServices;
83
-
84
- const checker = services?.program?.getTypeChecker();
87
+ const checker = sourceCode.parserServices?.program?.getTypeChecker();
85
88
 
86
89
  /**
87
90
  * Whether `expression` is an array or tuple. Without type information the
88
91
  * answer is no: `.length` is also a string's, a function's and a
89
- * `TypedArray`'s, and none of those can be handed to the guard.
92
+ * `TypedArray`'s, and none of those can be handed to the guard. The type is
93
+ * the value's, not a cast's: a string cast to an array is still a string.
90
94
  */
91
95
  const isArrayLike = (
92
96
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
@@ -96,15 +100,12 @@ export const preferSafeArrayLengthGuard: TSESLint.RuleModule<
96
100
  return false;
97
101
  }
98
102
 
99
- const tsNode = services?.esTreeNodeToTSNodeMap?.get(expression);
100
-
101
- if (tsNode === undefined) {
102
- return false;
103
- }
104
-
105
- const type = checker.getTypeAtLocation(tsNode);
103
+ const type = getValueType(sourceCode, expression);
106
104
 
107
- return checker.isArrayType(type) || checker.isTupleType(type);
105
+ return (
106
+ type !== undefined &&
107
+ (checker.isArrayType(type) || checker.isTupleType(type))
108
+ );
108
109
  };
109
110
 
110
111
  const mut_nodesToFix: {
@@ -140,7 +141,7 @@ export const preferSafeArrayLengthGuard: TSESLint.RuleModule<
140
141
  index,
141
142
  { node, arrayExpression, guard },
142
143
  ] of mut_nodesToFix.entries()) {
143
- const arrayText = sourceCode.getText(arrayExpression);
144
+ const arrayText = getArgumentText(sourceCode, arrayExpression);
144
145
 
145
146
  context.report({
146
147
  node,
@@ -180,11 +181,11 @@ const matchLengthComparison = (
180
181
  ):
181
182
  | Readonly<{ arrayExpression: TSESTree.Expression; guard: Guard }>
182
183
  | undefined => {
183
- const { left, right } = node;
184
+ // Both operands are read through their type wrappers, which change neither
185
+ // the length nor the bound: `(xs.length as number) > (0 as const)`.
186
+ const left = skipTypeWrappers(node.left);
184
187
 
185
- if (left.type === AST_NODE_TYPES.PrivateIdentifier) {
186
- return undefined;
187
- }
188
+ const right = skipTypeWrappers(node.right);
188
189
 
189
190
  const lengthOnLeft = isLengthAccess(left);
190
191
 
@@ -218,7 +219,7 @@ const matchLengthComparison = (
218
219
 
219
220
  const isLengthAccess = (
220
221
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
221
- node: TSESTree.Expression,
222
+ node: TSESTree.Node,
222
223
  ): node is TSESTree.MemberExpression =>
223
224
  node.type === AST_NODE_TYPES.MemberExpression &&
224
225
  !node.computed &&
@@ -150,3 +150,101 @@ describe('prefer-safe-array-length-guard', () => {
150
150
  ],
151
151
  });
152
152
  });
153
+
154
+ describe('prefer-safe-array-length-guard through type wrappers', () => {
155
+ tester.run('prefer-safe-array-length-guard', preferSafeArrayLengthGuard, {
156
+ valid: [
157
+ {
158
+ name: 'a string cast to an array is still a string',
159
+ code: dedent`
160
+ declare const s: string;
161
+ const ok = (s as unknown as readonly string[]).length > 0;
162
+ `,
163
+ },
164
+ {
165
+ name: 'a bound with no named guard behind `as const` is left alone',
166
+ code: dedent`
167
+ declare const xs: readonly number[];
168
+ const ok = xs.length > (2 as const);
169
+ `,
170
+ },
171
+ ],
172
+ invalid: [
173
+ {
174
+ name: 'reads the bound through `as const`',
175
+ code: dedent`
176
+ declare const xs: readonly number[];
177
+ const ok = xs.length > (0 as const);
178
+ `,
179
+ output: dedent`
180
+ import { SafeArray } from 'ts-std-forge';
181
+ declare const xs: readonly number[];
182
+ const ok = SafeArray.isNonEmpty(xs);
183
+ `,
184
+ errors: [{ messageId: 'useIsNonEmpty' }],
185
+ },
186
+ {
187
+ name: 'reads the length through `as`',
188
+ code: dedent`
189
+ declare const xs: readonly number[];
190
+ const ok = (xs.length as number) > 0;
191
+ `,
192
+ output: dedent`
193
+ import { SafeArray } from 'ts-std-forge';
194
+ declare const xs: readonly number[];
195
+ const ok = SafeArray.isNonEmpty(xs);
196
+ `,
197
+ errors: [{ messageId: 'useIsNonEmpty' }],
198
+ },
199
+ {
200
+ name: 'reads the length through `!`, with the bound on the left',
201
+ code: dedent`
202
+ declare const xs: readonly number[];
203
+ const ok = (0 satisfies number) === xs.length!;
204
+ `,
205
+ output: dedent`
206
+ import { SafeArray } from 'ts-std-forge';
207
+ declare const xs: readonly number[];
208
+ const ok = SafeArray.isEmpty(xs);
209
+ `,
210
+ errors: [{ messageId: 'useIsEmpty' }],
211
+ },
212
+ {
213
+ name: 'keeps a wrapper on the array and reads `!` as the array it asserts',
214
+ code: dedent`
215
+ declare const xs: readonly number[] | undefined;
216
+ const ok = xs!.length !== 0;
217
+ `,
218
+ output: dedent`
219
+ import { SafeArray } from 'ts-std-forge';
220
+ declare const xs: readonly number[] | undefined;
221
+ const ok = SafeArray.isNonEmpty(xs!);
222
+ `,
223
+ errors: [{ messageId: 'useIsNonEmpty' }],
224
+ },
225
+ ],
226
+ });
227
+ });
228
+
229
+ describe('prefer-safe-array-length-guard with parenthesized operands', () => {
230
+ tester.run('prefer-safe-array-length-guard', preferSafeArrayLengthGuard, {
231
+ valid: [],
232
+ invalid: [
233
+ {
234
+ name: 'keeps the parentheses of a sequence array',
235
+ code: dedent`
236
+ declare const a: number;
237
+ declare const xs: readonly number[];
238
+ const ok = (a, xs).length > 0;
239
+ `,
240
+ output: dedent`
241
+ import { SafeArray } from 'ts-std-forge';
242
+ declare const a: number;
243
+ declare const xs: readonly number[];
244
+ const ok = SafeArray.isNonEmpty((a, xs));
245
+ `,
246
+ errors: [{ messageId: 'useIsNonEmpty' }],
247
+ },
248
+ ],
249
+ });
250
+ });
@@ -4,6 +4,12 @@ import {
4
4
  type TSESTree,
5
5
  } from '@typescript-eslint/utils';
6
6
  import * as ts from 'typescript';
7
+ import {
8
+ getArgumentText,
9
+ getValueType,
10
+ isIdentifierNamed,
11
+ skipTypeWrappers,
12
+ } from './ast-utils.mjs';
7
13
  import {
8
14
  buildImportFixes,
9
15
  getNamedImports,
@@ -62,8 +68,6 @@ export const preferSafeNumberParseInteger: TSESLint.RuleModule<
62
68
 
63
69
  const tsStdForgeImport = getTsStdForgeImport(program);
64
70
 
65
- const services = sourceCode.parserServices;
66
-
67
71
  const mut_nodesToFix: {
68
72
  node: TSESTree.CallExpression;
69
73
  argExpression: TSESTree.Expression;
@@ -71,7 +75,7 @@ export const preferSafeNumberParseInteger: TSESLint.RuleModule<
71
75
 
72
76
  return {
73
77
  CallExpression: (node) => {
74
- const { callee } = node;
78
+ const callee = skipTypeWrappers(node.callee);
75
79
 
76
80
  // Match `parseInt(...)` or `Number.parseInt(...)`.
77
81
  const isGlobalParseInt =
@@ -81,8 +85,7 @@ export const preferSafeNumberParseInteger: TSESLint.RuleModule<
81
85
  const isNumberParseInt =
82
86
  callee.type === AST_NODE_TYPES.MemberExpression &&
83
87
  !callee.computed &&
84
- callee.object.type === AST_NODE_TYPES.Identifier &&
85
- callee.object.name === 'Number' &&
88
+ isIdentifierNamed(callee.object, 'Number') &&
86
89
  callee.property.type === AST_NODE_TYPES.Identifier &&
87
90
  callee.property.name === 'parseInt';
88
91
 
@@ -103,9 +106,11 @@ export const preferSafeNumberParseInteger: TSESLint.RuleModule<
103
106
  return;
104
107
  }
105
108
 
106
- // 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
+ // read through its type wrappers, which leave the value as it is.
107
111
  if (args.length >= 2) {
108
- const radix = args[1];
112
+ const radix =
113
+ args[1] === undefined ? undefined : skipTypeWrappers(args[1]);
109
114
 
110
115
  if (radix?.type !== AST_NODE_TYPES.Literal || radix.value !== 10) {
111
116
  return;
@@ -113,23 +118,12 @@ export const preferSafeNumberParseInteger: TSESLint.RuleModule<
113
118
  }
114
119
 
115
120
  // The argument must be a `string` so the autofix is type-safe against
116
- // `SafeNumber.parseInteger(value: string)`. Without type information,
117
- // skip.
118
- if (services?.program == null) {
119
- return;
120
- }
121
-
122
- const checker = services.program.getTypeChecker();
123
-
124
- const tsNode = services.esTreeNodeToTSNodeMap?.get(firstArg);
125
-
126
- if (tsNode === undefined) {
127
- return;
128
- }
129
-
130
- const argType = checker.getTypeAtLocation(tsNode);
121
+ // `SafeNumber.parseInteger(value: string)`, and that of the value, not
122
+ // of a cast: `parseInt(1e21 as unknown as string)` is 1, the rewrite
123
+ // 1e21. Without type information, skip.
124
+ const argType = getValueType(sourceCode, firstArg);
131
125
 
132
- if (!isStringType(argType)) {
126
+ if (argType === undefined || !isStringType(argType)) {
133
127
  return;
134
128
  }
135
129
 
@@ -146,7 +140,7 @@ export const preferSafeNumberParseInteger: TSESLint.RuleModule<
146
140
  index,
147
141
  { node, argExpression },
148
142
  ] of mut_nodesToFix.entries()) {
149
- const argText = sourceCode.getText(argExpression);
143
+ const argText = getArgumentText(sourceCode, argExpression);
150
144
 
151
145
  context.report({
152
146
  node,
@@ -65,3 +65,107 @@ describe('prefer-safe-number-parse-integer', () => {
65
65
  ],
66
66
  });
67
67
  });
68
+
69
+ describe('prefer-safe-number-parse-integer through type wrappers', () => {
70
+ tester.run('prefer-safe-number-parse-integer', preferSafeNumberParseInteger, {
71
+ valid: [
72
+ {
73
+ // `parseInt(1e21)` is 1, `SafeNumber.parseInteger(1e21)` is 1e21.
74
+ name: 'ignores a number cast to string',
75
+ code: dedent`
76
+ declare const x: number;
77
+ const n = parseInt(x as unknown as string);
78
+ `,
79
+ },
80
+ {
81
+ name: 'ignores a radix other than 10 behind `as const`',
82
+ code: dedent`
83
+ declare const s: string;
84
+ const n = parseInt(s, 16 as const);
85
+ `,
86
+ },
87
+ ],
88
+ invalid: [
89
+ {
90
+ name: 'reads a radix of 10 through `as const`',
91
+ code: dedent`
92
+ declare const s: string;
93
+ const n = parseInt(s, 10 as const);
94
+ `,
95
+ output: dedent`
96
+ import { Result, SafeNumber } from 'ts-std-forge';
97
+ declare const s: string;
98
+ const n = Result.unwrapOkOr(SafeNumber.parseInteger(s), Number.NaN);
99
+ `,
100
+ errors: [{ messageId: 'useSafeNumberParseInteger' }],
101
+ },
102
+ {
103
+ name: 'reads a radix of 10 through `satisfies` and keeps `satisfies` on the argument',
104
+ code: dedent`
105
+ declare const s: string;
106
+ const n = Number.parseInt(s satisfies string, 10 satisfies number);
107
+ `,
108
+ output: dedent`
109
+ import { Result, SafeNumber } from 'ts-std-forge';
110
+ declare const s: string;
111
+ const n = Result.unwrapOkOr(SafeNumber.parseInteger(s satisfies string), Number.NaN);
112
+ `,
113
+ errors: [{ messageId: 'useSafeNumberParseInteger' }],
114
+ },
115
+ {
116
+ name: 'reads the callee through `as` and `!`',
117
+ code: dedent`
118
+ declare const s: string;
119
+ const a = (parseInt as typeof parseInt)(s);
120
+ const b = (Number as NumberConstructor)!.parseInt(s, 10);
121
+ `,
122
+ output: dedent`
123
+ import { Result, SafeNumber } from 'ts-std-forge';
124
+ declare const s: string;
125
+ const a = Result.unwrapOkOr(SafeNumber.parseInteger(s), Number.NaN);
126
+ const b = Result.unwrapOkOr(SafeNumber.parseInteger(s), Number.NaN);
127
+ `,
128
+ errors: [
129
+ { messageId: 'useSafeNumberParseInteger' },
130
+ { messageId: 'useSafeNumberParseInteger' },
131
+ ],
132
+ },
133
+ {
134
+ name: 'reads `!` on an optional string as the string it asserts',
135
+ code: dedent`
136
+ declare const s: string | undefined;
137
+ const n = parseInt(s!);
138
+ `,
139
+ output: dedent`
140
+ import { Result, SafeNumber } from 'ts-std-forge';
141
+ declare const s: string | undefined;
142
+ const n = Result.unwrapOkOr(SafeNumber.parseInteger(s!), Number.NaN);
143
+ `,
144
+ errors: [{ messageId: 'useSafeNumberParseInteger' }],
145
+ },
146
+ ],
147
+ });
148
+ });
149
+
150
+ describe('prefer-safe-number-parse-integer with parenthesized operands', () => {
151
+ tester.run('prefer-safe-number-parse-integer', preferSafeNumberParseInteger, {
152
+ valid: [],
153
+ invalid: [
154
+ {
155
+ name: 'keeps the parentheses of a sequence argument',
156
+ code: dedent`
157
+ declare const a: number;
158
+ declare const s: string;
159
+ const n = parseInt((a, s), (10));
160
+ `,
161
+ output: dedent`
162
+ import { Result, SafeNumber } from 'ts-std-forge';
163
+ declare const a: number;
164
+ declare const s: string;
165
+ const n = Result.unwrapOkOr(SafeNumber.parseInteger((a, s)), Number.NaN);
166
+ `,
167
+ errors: [{ messageId: 'useSafeNumberParseInteger' }],
168
+ },
169
+ ],
170
+ });
171
+ });