eslint-rule-forge 0.2.0 → 0.3.1

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 (42) hide show
  1. package/README.md +28 -9
  2. package/dist/index.d.mts +1 -0
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1 -0
  5. package/dist/index.mjs.map +1 -1
  6. package/dist/rules/index.d.mts +6 -0
  7. package/dist/rules/index.d.mts.map +1 -0
  8. package/dist/rules/index.mjs +6 -0
  9. package/dist/rules/index.mjs.map +1 -0
  10. package/dist/rules/no-side-effect-import.d.mts +18 -0
  11. package/dist/rules/no-side-effect-import.d.mts.map +1 -0
  12. package/dist/rules/no-side-effect-import.mjs +45 -0
  13. package/dist/rules/no-side-effect-import.mjs.map +1 -0
  14. package/dist/rules/prefer-array-is-array.d.mts +13 -0
  15. package/dist/rules/prefer-array-is-array.d.mts.map +1 -0
  16. package/dist/rules/prefer-array-is-array.mjs +81 -0
  17. package/dist/rules/prefer-array-is-array.mjs.map +1 -0
  18. package/dist/rules/prefer-is-non-null-object.d.mts +11 -0
  19. package/dist/rules/prefer-is-non-null-object.d.mts.map +1 -0
  20. package/dist/rules/prefer-is-non-null-object.mjs +192 -0
  21. package/dist/rules/prefer-is-non-null-object.mjs.map +1 -0
  22. package/dist/rules/prefer-is-record-and-has-key.d.mts +11 -0
  23. package/dist/rules/prefer-is-record-and-has-key.d.mts.map +1 -0
  24. package/dist/rules/prefer-is-record-and-has-key.mjs +226 -0
  25. package/dist/rules/prefer-is-record-and-has-key.mjs.map +1 -0
  26. package/dist/rules/prefer-safe-parse.d.mts +31 -0
  27. package/dist/rules/prefer-safe-parse.d.mts.map +1 -0
  28. package/dist/rules/prefer-safe-parse.mjs +139 -0
  29. package/dist/rules/prefer-safe-parse.mjs.map +1 -0
  30. package/dist/type-wrappers.d.mts +17 -1
  31. package/dist/type-wrappers.d.mts.map +1 -1
  32. package/dist/type-wrappers.mjs +35 -1
  33. package/dist/type-wrappers.mjs.map +1 -1
  34. package/package.json +9 -9
  35. package/src/index.mts +1 -0
  36. package/src/rules/index.mts +5 -0
  37. package/src/rules/no-side-effect-import.mts +68 -0
  38. package/src/rules/prefer-array-is-array.mts +119 -0
  39. package/src/rules/prefer-is-non-null-object.mts +276 -0
  40. package/src/rules/prefer-is-record-and-has-key.mts +321 -0
  41. package/src/rules/prefer-safe-parse.mts +252 -0
  42. package/src/type-wrappers.mts +55 -1
@@ -0,0 +1,321 @@
1
+ import {
2
+ AST_NODE_TYPES,
3
+ type TSESLint,
4
+ type TSESTree,
5
+ } from '@typescript-eslint/utils';
6
+ import * as ts from 'typescript';
7
+ import {
8
+ buildNamedImportFix,
9
+ getImportDeclarations,
10
+ getNamedImports,
11
+ } from '../imports.mjs';
12
+ import { getArgumentText } from '../parentheses.mjs';
13
+ import {
14
+ isIdentifierNamed,
15
+ skipTypeWrappers,
16
+ typeHoldsAtEveryLayer,
17
+ } from '../type-wrappers.mjs';
18
+
19
+ /**
20
+ * Builds a rule that replaces `Object.hasOwn(obj, key)` and `key in obj` with
21
+ * `isRecord(obj) && hasKey(obj, key)` imported from `moduleName`, or with
22
+ * `hasKey(obj, key)` alone when `obj` is already a record.
23
+ */
24
+ export const createPreferIsRecordAndHasKey = ({
25
+ moduleName,
26
+ }: Readonly<{
27
+ /** The module that exports `isRecord` and `hasKey`. */
28
+ moduleName: string;
29
+ }>): TSESLint.RuleModule<'useIsRecordAndHasKey' | 'useHasKey', readonly []> =>
30
+ ({
31
+ meta: {
32
+ type: 'suggestion',
33
+ docs: {
34
+ description: `Replace \`Object.hasOwn(obj, key)\` or \`key in obj\` with \`isRecord(obj) && hasKey(obj, key)\` from ${moduleName}`,
35
+ },
36
+ fixable: 'code',
37
+ schema: [],
38
+ messages: {
39
+ useIsRecordAndHasKey: `Replace \`{{original}}\` with \`isRecord({{objName}}) && hasKey({{objName}}, {{keyName}})\` from ${moduleName}.`,
40
+ useHasKey: `Replace \`{{original}}\` with \`hasKey({{objName}}, {{keyName}})\` from ${moduleName}.`,
41
+ },
42
+ },
43
+
44
+ create: (context) => {
45
+ const sourceCode = context.sourceCode;
46
+
47
+ const program = sourceCode.ast;
48
+
49
+ const moduleImports = getImportDeclarations(program, moduleName);
50
+
51
+ /**
52
+ * Whether the `isRecord(...)` half of the rewrite would be dead code
53
+ * because the object already satisfies `hasKey`'s
54
+ * `R extends UnknownRecord` constraint, at every layer of type
55
+ * wrappers: an array cast to a record is still rejected by `isRecord`,
56
+ * and a record widened to one that may be an array is the author saying
57
+ * it may need the guard. Without type information nothing is known, so
58
+ * the guard is kept.
59
+ */
60
+ const isKnownRecord = (
61
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
62
+ expression: TSESTree.Expression,
63
+ ): boolean =>
64
+ typeHoldsAtEveryLayer(sourceCode, expression, (type, checker) =>
65
+ isAlreadyRecordType(checker, type),
66
+ );
67
+
68
+ /**
69
+ * Whether the object may be an array at some layer of type wrappers.
70
+ * `isRecord` rejects an array, so `'0' in xs` would turn from `true` to
71
+ * `false`; a widening cast to a type that may be an array is the author
72
+ * saying it may be one. Without type information nothing is known, and
73
+ * the rule reports as it always has.
74
+ */
75
+ const mayBeArray = (
76
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
77
+ expression: TSESTree.Expression,
78
+ ): boolean =>
79
+ sourceCode.parserServices?.program != null &&
80
+ !typeHoldsAtEveryLayer(
81
+ sourceCode,
82
+ expression,
83
+ (type, checker) => !mayBeArrayType(checker, type),
84
+ );
85
+
86
+ const mut_nodesToFix: {
87
+ node: TSESTree.CallExpression | TSESTree.BinaryExpression;
88
+ objExpression: TSESTree.Expression;
89
+ keyExpression: TSESTree.Expression;
90
+ }[] = [];
91
+
92
+ return {
93
+ // Handle Object.hasOwn(obj, key)
94
+ CallExpression: (node) => {
95
+ const callee = skipTypeWrappers(node.callee);
96
+
97
+ if (
98
+ callee.type !== AST_NODE_TYPES.MemberExpression ||
99
+ !isIdentifierNamed(callee.object, 'Object') ||
100
+ callee.property.type !== AST_NODE_TYPES.Identifier ||
101
+ callee.property.name !== 'hasOwn'
102
+ ) {
103
+ return;
104
+ }
105
+
106
+ // Check arguments: Object.hasOwn(obj, key)
107
+ if (node.arguments.length !== 2) {
108
+ return;
109
+ }
110
+
111
+ const objArg = node.arguments[0];
112
+
113
+ const keyArg = node.arguments[1];
114
+
115
+ if (objArg === undefined || keyArg === undefined) {
116
+ return;
117
+ }
118
+
119
+ // Type guard: ensure arguments are Expressions (not SpreadElement)
120
+ if (
121
+ objArg.type === AST_NODE_TYPES.SpreadElement ||
122
+ keyArg.type === AST_NODE_TYPES.SpreadElement ||
123
+ mayBeArray(objArg)
124
+ ) {
125
+ return;
126
+ }
127
+
128
+ mut_nodesToFix.push({
129
+ node,
130
+ objExpression: objArg,
131
+ keyExpression: keyArg,
132
+ });
133
+ },
134
+
135
+ // Handle key in obj
136
+ BinaryExpression: (node) => {
137
+ if (node.operator !== 'in') {
138
+ return;
139
+ }
140
+
141
+ const keyExpression = node.left;
142
+
143
+ const objExpression = node.right;
144
+
145
+ // Type guard: ensure keyExpression is Expression (not PrivateIdentifier)
146
+ if (
147
+ keyExpression.type === AST_NODE_TYPES.PrivateIdentifier ||
148
+ mayBeArray(objExpression)
149
+ ) {
150
+ return;
151
+ }
152
+
153
+ mut_nodesToFix.push({
154
+ node,
155
+ objExpression,
156
+ keyExpression,
157
+ });
158
+ },
159
+
160
+ 'Program:exit': () => {
161
+ const namedImports = getNamedImports(moduleImports);
162
+
163
+ const rewrites = mut_nodesToFix.map((entry) => {
164
+ // A record-typed object makes the `isRecord(...)` conjunct always
165
+ // true, so it is left out of the replacement entirely.
166
+ const needsIsRecord = !isKnownRecord(entry.objExpression);
167
+
168
+ return {
169
+ ...entry,
170
+ needsIsRecord,
171
+ // `isRecord(obj) && hasKey(obj, key)` evaluates `obj` twice.
172
+ fixable: !needsIsRecord || isSideEffectFree(entry.objExpression),
173
+ };
174
+ });
175
+
176
+ const fixableRewrites = rewrites.filter(({ fixable }) => fixable);
177
+
178
+ const importsToAdd: readonly string[] =
179
+ fixableRewrites.length === 0
180
+ ? []
181
+ : [
182
+ ...(fixableRewrites.some(
183
+ ({ needsIsRecord }) => needsIsRecord,
184
+ ) && !namedImports.includes('isRecord')
185
+ ? ['isRecord']
186
+ : []),
187
+ ...(namedImports.includes('hasKey') ? [] : ['hasKey']),
188
+ ];
189
+
190
+ const importCarrier = fixableRewrites[0];
191
+
192
+ for (const rewrite of rewrites) {
193
+ const { node, objExpression, keyExpression, needsIsRecord } =
194
+ rewrite;
195
+
196
+ const objText = getArgumentText(sourceCode, objExpression);
197
+
198
+ const keyText = getArgumentText(sourceCode, keyExpression);
199
+
200
+ const originalText = sourceCode.getText(node);
201
+
202
+ context.report({
203
+ node,
204
+ messageId: needsIsRecord ? 'useIsRecordAndHasKey' : 'useHasKey',
205
+ data: {
206
+ original: originalText,
207
+ objName: objText,
208
+ keyName: keyText,
209
+ },
210
+ fix: !rewrite.fixable
211
+ ? undefined
212
+ : (fixer) => {
213
+ const replacement = needsIsRecord
214
+ ? `(isRecord(${objText}) && hasKey(${objText}, ${keyText}))`
215
+ : `hasKey(${objText}, ${keyText})`;
216
+
217
+ // All import fixes have to live on the first fixable
218
+ // report: `insertTextBefore(program, …)` produces
219
+ // overlapping ranges otherwise and only one of them would
220
+ // survive a pass.
221
+ const importFixes =
222
+ rewrite === importCarrier && importsToAdd.length > 0
223
+ ? buildNamedImportFix(
224
+ fixer,
225
+ program,
226
+ moduleName,
227
+ importsToAdd,
228
+ )
229
+ : [];
230
+
231
+ return [
232
+ ...importFixes,
233
+ fixer.replaceText(node, replacement),
234
+ ];
235
+ },
236
+ });
237
+ }
238
+ },
239
+ };
240
+ },
241
+ defaultOptions: [],
242
+ }) as const;
243
+
244
+ /**
245
+ * Whether reading `node` again has no side effects: a variable, `this`, or a
246
+ * non-computed property chain on one, type wrappers aside.
247
+ */
248
+ const isSideEffectFree = (
249
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
250
+ wrapped: TSESTree.Node,
251
+ ): boolean => {
252
+ const node = skipTypeWrappers(wrapped);
253
+
254
+ return (
255
+ node.type === AST_NODE_TYPES.Identifier ||
256
+ node.type === AST_NODE_TYPES.ThisExpression ||
257
+ (node.type === AST_NODE_TYPES.MemberExpression &&
258
+ !node.computed &&
259
+ isSideEffectFree(node.object))
260
+ );
261
+ };
262
+
263
+ /**
264
+ * Whether a value of `type` may be an array or a tuple: some member of a union
265
+ * or an intersection is one, or the constraint of a deferred type is.
266
+ * `object` and `unknown` say nothing either way and are not counted.
267
+ */
268
+ const mayBeArrayType = (
269
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
270
+ checker: ts.TypeChecker,
271
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
272
+ type: ts.Type,
273
+ ): boolean => {
274
+ if (type.isUnionOrIntersection()) {
275
+ return type.types.some((member) => mayBeArrayType(checker, member));
276
+ }
277
+
278
+ if (
279
+ (type.flags &
280
+ (ts.TypeFlags.TypeParameter |
281
+ ts.TypeFlags.IndexedAccess |
282
+ ts.TypeFlags.Conditional |
283
+ ts.TypeFlags.Substitution)) !==
284
+ 0
285
+ ) {
286
+ const constraint = checker.getBaseConstraintOfType(type);
287
+
288
+ return constraint !== undefined && mayBeArrayType(checker, constraint);
289
+ }
290
+
291
+ return checker.isArrayType(type) || checker.isTupleType(type);
292
+ };
293
+
294
+ /**
295
+ * Whether `type` already satisfies `hasKey`'s `R extends UnknownRecord`
296
+ * constraint, so that guarding the call with `isRecord` adds nothing.
297
+ *
298
+ * `UnknownRecord` is `ReadonlyRecord<string, unknown>`, so a string index
299
+ * signature is exactly what it asks for and its value type is `unknown`, which
300
+ * anything satisfies. Callables and arrays are excluded because `isRecord`
301
+ * rejects them at runtime (`typeof fn === 'object'` is false, `Array.isArray`
302
+ * is true), so dropping the guard there would change behavior.
303
+ *
304
+ * Deliberately conservative: a type TypeScript would accept through an
305
+ * *implicit* index signature (an object type alias without one of its own)
306
+ * keeps the guard, because the checker does not report such a signature here.
307
+ */
308
+ const isAlreadyRecordType = (
309
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
310
+ checker: ts.TypeChecker,
311
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
312
+ type: ts.Type,
313
+ ): boolean =>
314
+ type.isUnion()
315
+ ? type.types.every((member) => isAlreadyRecordType(checker, member))
316
+ : checker.getIndexInfoOfType(type, ts.IndexKind.String) !== undefined &&
317
+ checker.getSignaturesOfType(type, ts.SignatureKind.Call).length === 0 &&
318
+ checker.getSignaturesOfType(type, ts.SignatureKind.Construct).length ===
319
+ 0 &&
320
+ !checker.isArrayType(type) &&
321
+ !checker.isTupleType(type);
@@ -0,0 +1,252 @@
1
+ import {
2
+ AST_NODE_TYPES,
3
+ type TSESLint,
4
+ type TSESTree,
5
+ } from '@typescript-eslint/utils';
6
+ import type { ReadonlyRecord } from 'ts-type-forge';
7
+ import * as ts from 'typescript';
8
+ import {
9
+ buildNamedImportFix,
10
+ getImportDeclarations,
11
+ getNamedImports,
12
+ } from '../imports.mjs';
13
+ import { getArgumentText } from '../parentheses.mjs';
14
+ import {
15
+ isIdentifierNamed,
16
+ skipTypeWrappers,
17
+ typeHoldsAtEveryLayer,
18
+ } from '../type-wrappers.mjs';
19
+
20
+ /** What {@link createPreferSafeParseInt} and its float twin are told. */
21
+ export type SafeParseRuleConfig = Readonly<{
22
+ /** The module that exports `namespace` and `Result`. */
23
+ moduleName: string;
24
+
25
+ /** The exported namespace the parse function is reached through. */
26
+ namespace: string;
27
+
28
+ /**
29
+ * The member of `namespace` that parses a `string` into a
30
+ * `Result<number, …>`.
31
+ */
32
+ method: string;
33
+ }>;
34
+
35
+ /**
36
+ * Builds a rule that replaces the base-10 forms of `parseInt` and
37
+ * `Number.parseInt` — `parseInt(x)` and `parseInt(x, 10)` — with
38
+ * `Result.unwrapOkOr(<namespace>.<method>(x), Number.NaN)`.
39
+ *
40
+ * A call that passes another radix is left alone: the replacement takes none,
41
+ * so it would mean something the replacement cannot say.
42
+ */
43
+ export const createPreferSafeParseInt = (
44
+ config: SafeParseRuleConfig,
45
+ ): TSESLint.RuleModule<'useSafeParseInt', readonly []> =>
46
+ createSafeParseRule(config, 'useSafeParseInt', {
47
+ messages: { useSafeParseInt: messageText(config) },
48
+ description: `Replace \`parseInt(x)\` or \`Number.parseInt(x, 10)\` with \`Result.unwrapOkOr(${config.namespace}.${config.method}(x), Number.NaN)\` from ${config.moduleName}.`,
49
+ isTarget: (callee, args) => {
50
+ if (
51
+ !isGlobalCall(callee, 'parseInt') &&
52
+ !isNumberMethod(callee, 'parseInt')
53
+ ) {
54
+ return false;
55
+ }
56
+
57
+ // Only base 10: accept a missing radix or an explicit literal `10`,
58
+ // read through its type wrappers, which leave the value as it is.
59
+ const radix =
60
+ args[1] === undefined ? undefined : skipTypeWrappers(args[1]);
61
+
62
+ return (
63
+ radix === undefined ||
64
+ (radix.type === AST_NODE_TYPES.Literal && radix.value === 10)
65
+ );
66
+ },
67
+ });
68
+
69
+ /**
70
+ * Builds a rule that replaces `parseFloat(x)`, `Number.parseFloat(x)` and
71
+ * `Number(x)` with `Result.unwrapOkOr(<namespace>.<method>(x), Number.NaN)`.
72
+ *
73
+ * Like every rewrite here, `Number(x)` is only flagged when `x` is purely a
74
+ * `string`, which leaves `Number(someBoolean)` and `Number(someNumber)` alone.
75
+ */
76
+ export const createPreferSafeParseFloat = (
77
+ config: SafeParseRuleConfig,
78
+ ): TSESLint.RuleModule<'useSafeParseFloat', readonly []> =>
79
+ createSafeParseRule(config, 'useSafeParseFloat', {
80
+ messages: { useSafeParseFloat: messageText(config) },
81
+ description: `Replace \`parseFloat(x)\`, \`Number.parseFloat(x)\`, or \`Number(x)\` (when x is a string) with \`Result.unwrapOkOr(${config.namespace}.${config.method}(x), Number.NaN)\` from ${config.moduleName}.`,
82
+ // `Number(...)` is matched only as a call; `new Number(...)` is a
83
+ // `NewExpression`, which this rule never visits.
84
+ isTarget: (callee) =>
85
+ isGlobalCall(callee, 'parseFloat') ||
86
+ isNumberMethod(callee, 'parseFloat') ||
87
+ isGlobalCall(callee, 'Number'),
88
+ });
89
+
90
+ /* eslint-disable @typescript-eslint/prefer-readonly-parameter-types */
91
+
92
+ const createSafeParseRule = <MessageId extends string>(
93
+ { moduleName, namespace, method }: SafeParseRuleConfig,
94
+ messageId: MessageId,
95
+ {
96
+ messages,
97
+ description,
98
+ isTarget,
99
+ }: Readonly<{
100
+ messages: ReadonlyRecord<MessageId, string>;
101
+
102
+ description: string;
103
+
104
+ /**
105
+ * Whether the call, its callee read through its type wrappers, is one to
106
+ * rewrite.
107
+ */
108
+ isTarget: (
109
+ callee: TSESTree.Expression,
110
+ args: readonly TSESTree.CallExpressionArgument[],
111
+ ) => boolean;
112
+ }>,
113
+ ): TSESLint.RuleModule<MessageId, readonly []> => {
114
+ const parseFunction = `${namespace}.${method}` as const;
115
+
116
+ // Sorted, as an import sorter would write them.
117
+ const requiredImports = [namespace, 'Result'].toSorted();
118
+
119
+ return {
120
+ meta: {
121
+ type: 'suggestion',
122
+ docs: { description },
123
+ fixable: 'code',
124
+ schema: [],
125
+ messages,
126
+ },
127
+
128
+ create: (context) => {
129
+ const sourceCode = context.sourceCode;
130
+
131
+ const program = sourceCode.ast;
132
+
133
+ const moduleImports = getImportDeclarations(program, moduleName);
134
+
135
+ const mut_nodesToFix: {
136
+ node: TSESTree.CallExpression;
137
+ argExpression: TSESTree.Expression;
138
+ }[] = [];
139
+
140
+ return {
141
+ CallExpression: (node) => {
142
+ if (!isTarget(skipTypeWrappers(node.callee), node.arguments)) {
143
+ return;
144
+ }
145
+
146
+ const firstArg = node.arguments[0];
147
+
148
+ // A spread argument (`parseInt(...rest)`) cannot be rewritten
149
+ // safely.
150
+ if (
151
+ firstArg === undefined ||
152
+ firstArg.type === AST_NODE_TYPES.SpreadElement
153
+ ) {
154
+ return;
155
+ }
156
+
157
+ // The argument must be purely `string` at every layer of type
158
+ // wrappers: the value's own type is what the original sees at run
159
+ // time (`parseInt(1e21 as unknown as string)` is 1, the rewrite
160
+ // 1e21; `Number(true as unknown as string)` is 1, the rewrite NaN),
161
+ // and the type a wrapper states is what the fix, which keeps the
162
+ // wrapper, hands the parse function's `string` parameter. Without
163
+ // type information, skip.
164
+ if (!typeHoldsAtEveryLayer(sourceCode, firstArg, isStringType)) {
165
+ return;
166
+ }
167
+
168
+ mut_nodesToFix.push({ node, argExpression: firstArg });
169
+ },
170
+ 'Program:exit': () => {
171
+ const namedImports = getNamedImports(moduleImports);
172
+
173
+ const missingImports = requiredImports.filter(
174
+ (name) => !namedImports.includes(name),
175
+ );
176
+
177
+ // The import fix lives on the first report only:
178
+ // `insertTextBefore(program, …)` produces overlapping ranges
179
+ // otherwise and only one of them would survive a pass.
180
+ for (const [
181
+ index,
182
+ { node, argExpression },
183
+ ] of mut_nodesToFix.entries()) {
184
+ const argText = getArgumentText(sourceCode, argExpression);
185
+
186
+ context.report({
187
+ node,
188
+ messageId,
189
+ data: {
190
+ original: sourceCode.getText(node),
191
+ argName: argText,
192
+ },
193
+ fix: (fixer) => {
194
+ const core =
195
+ `Result.unwrapOkOr(${parseFunction}(${argText}), Number.NaN)` as const;
196
+
197
+ const importFixes =
198
+ index === 0 && missingImports.length > 0
199
+ ? buildNamedImportFix(
200
+ fixer,
201
+ program,
202
+ moduleName,
203
+ missingImports,
204
+ )
205
+ : ([] as const);
206
+
207
+ return [...importFixes, fixer.replaceText(node, core)];
208
+ },
209
+ });
210
+ }
211
+ },
212
+ };
213
+ },
214
+ defaultOptions: [],
215
+ } as const;
216
+ };
217
+
218
+ const messageText = ({
219
+ moduleName,
220
+ namespace,
221
+ method,
222
+ }: SafeParseRuleConfig): string =>
223
+ `Replace \`{{original}}\` with \`Result.unwrapOkOr(${namespace}.${method}({{argName}}), Number.NaN)\` from ${moduleName}.` as const;
224
+
225
+ /** Whether `callee` is the global function `name`, as in `parseInt(...)`. */
226
+ const isGlobalCall = (callee: TSESTree.Expression, name: string): boolean =>
227
+ callee.type === AST_NODE_TYPES.Identifier && callee.name === name;
228
+
229
+ /**
230
+ * Whether `callee` is `Number.<name>`, the `Number` read through its type
231
+ * wrappers.
232
+ */
233
+ const isNumberMethod = (callee: TSESTree.Expression, name: string): boolean =>
234
+ callee.type === AST_NODE_TYPES.MemberExpression &&
235
+ !callee.computed &&
236
+ isIdentifierNamed(callee.object, 'Number') &&
237
+ callee.property.type === AST_NODE_TYPES.Identifier &&
238
+ callee.property.name === name;
239
+
240
+ /**
241
+ * Whether every constituent of `type` is string-like, i.e. the type is
242
+ * assignable to the parse function's `string` parameter. A union such as
243
+ * `string | undefined` is rejected so the autofix never produces a type error.
244
+ */
245
+ const isStringType = (type: ts.Type): boolean => {
246
+ const parts = type.isUnion() ? type.types : ([type] as const);
247
+
248
+ return (
249
+ parts.length > 0 &&
250
+ parts.every((t) => (t.flags & ts.TypeFlags.StringLike) !== 0)
251
+ );
252
+ };
@@ -1,5 +1,10 @@
1
- import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils';
1
+ import {
2
+ AST_NODE_TYPES,
3
+ type TSESLint,
4
+ type TSESTree,
5
+ } from '@typescript-eslint/utils';
2
6
  import type { DeepReadonly, RelaxedExtract } from 'ts-type-forge';
7
+ import type * as ts from 'typescript';
3
8
 
4
9
  /**
5
10
  * The four expressions that change a value's type and nothing else: `x as T`,
@@ -65,6 +70,55 @@ export const typeWrapperLayers = <N extends DeepReadonly<TSESTree.Node>>(
65
70
  ? ([node, ...typeWrapperLayers(node.expression)] as const)
66
71
  : ([node] as const);
67
72
 
73
+ /**
74
+ * Whether `node`, read through its type wrappers, is the identifier `name`:
75
+ * `Number`, `(Number as NumberConstructor)` and `Number!` are the same object.
76
+ */
77
+ export const isIdentifierNamed = (
78
+ node: DeepReadonly<TSESTree.Node>,
79
+ name: string,
80
+ ): boolean => {
81
+ const unwrapped = skipTypeWrappers(node);
82
+
83
+ return (
84
+ unwrapped.type === AST_NODE_TYPES.Identifier && unwrapped.name === name
85
+ );
86
+ };
87
+
88
+ /**
89
+ * Whether `holds` is true of the type of every layer of type wrappers around
90
+ * `node` (see {@link typeWrapperLayers}), outermost to innermost; `false`
91
+ * without type information. A widening cast (`s as string | number`) is the
92
+ * author saying the value may be more than it is, and is respected; a
93
+ * narrowing one (`s!`, `v as unknown as string`) cannot vouch for what the
94
+ * value's own type does not, and the fix, which keeps the wrapper, has to
95
+ * type-check at every layer too.
96
+ */
97
+ export const typeHoldsAtEveryLayer = (
98
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
99
+ sourceCode: TSESLint.SourceCode,
100
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
101
+ node: TSESTree.Node,
102
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
103
+ holds: (type: ts.Type, typeChecker: ts.TypeChecker) => boolean,
104
+ ): boolean => {
105
+ const services = sourceCode.parserServices;
106
+
107
+ const checker = services?.program?.getTypeChecker();
108
+
109
+ if (checker === undefined) {
110
+ return false;
111
+ }
112
+
113
+ return typeWrapperLayers(node).every((layer) => {
114
+ const tsNode = services?.esTreeNodeToTSNodeMap?.get(layer);
115
+
116
+ return (
117
+ tsNode !== undefined && holds(checker.getTypeAtLocation(tsNode), checker)
118
+ );
119
+ });
120
+ };
121
+
68
122
  const TYPE_WRAPPERS: ReadonlySet<AST_NODE_TYPES> = new Set([
69
123
  AST_NODE_TYPES.TSAsExpression,
70
124
  AST_NODE_TYPES.TSNonNullExpression,