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.
- package/README.md +28 -9
- package/dist/index.d.mts +1 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -0
- package/dist/index.mjs.map +1 -1
- package/dist/rules/index.d.mts +6 -0
- package/dist/rules/index.d.mts.map +1 -0
- package/dist/rules/index.mjs +6 -0
- package/dist/rules/index.mjs.map +1 -0
- package/dist/rules/no-side-effect-import.d.mts +18 -0
- package/dist/rules/no-side-effect-import.d.mts.map +1 -0
- package/dist/rules/no-side-effect-import.mjs +45 -0
- package/dist/rules/no-side-effect-import.mjs.map +1 -0
- package/dist/rules/prefer-array-is-array.d.mts +13 -0
- package/dist/rules/prefer-array-is-array.d.mts.map +1 -0
- package/dist/rules/prefer-array-is-array.mjs +81 -0
- package/dist/rules/prefer-array-is-array.mjs.map +1 -0
- package/dist/rules/prefer-is-non-null-object.d.mts +11 -0
- package/dist/rules/prefer-is-non-null-object.d.mts.map +1 -0
- package/dist/rules/prefer-is-non-null-object.mjs +192 -0
- package/dist/rules/prefer-is-non-null-object.mjs.map +1 -0
- package/dist/rules/prefer-is-record-and-has-key.d.mts +11 -0
- package/dist/rules/prefer-is-record-and-has-key.d.mts.map +1 -0
- package/dist/rules/prefer-is-record-and-has-key.mjs +226 -0
- package/dist/rules/prefer-is-record-and-has-key.mjs.map +1 -0
- package/dist/rules/prefer-safe-parse.d.mts +31 -0
- package/dist/rules/prefer-safe-parse.d.mts.map +1 -0
- package/dist/rules/prefer-safe-parse.mjs +139 -0
- package/dist/rules/prefer-safe-parse.mjs.map +1 -0
- package/dist/type-wrappers.d.mts +17 -1
- package/dist/type-wrappers.d.mts.map +1 -1
- package/dist/type-wrappers.mjs +35 -1
- package/dist/type-wrappers.mjs.map +1 -1
- package/package.json +9 -9
- package/src/index.mts +1 -0
- package/src/rules/index.mts +5 -0
- package/src/rules/no-side-effect-import.mts +68 -0
- package/src/rules/prefer-array-is-array.mts +119 -0
- package/src/rules/prefer-is-non-null-object.mts +276 -0
- package/src/rules/prefer-is-record-and-has-key.mts +321 -0
- package/src/rules/prefer-safe-parse.mts +252 -0
- 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
|
+
};
|
package/src/type-wrappers.mts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import {
|
|
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,
|