eslint-rule-forge 0.4.0 → 0.5.0
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 +17 -9
- package/dist/bindings.d.mts +4 -5
- package/dist/bindings.d.mts.map +1 -1
- package/dist/bindings.mjs +0 -1
- package/dist/bindings.mjs.map +1 -1
- package/dist/imports.d.mts +36 -17
- package/dist/imports.d.mts.map +1 -1
- package/dist/imports.mjs +92 -13
- package/dist/imports.mjs.map +1 -1
- 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/negation.d.mts +8 -9
- package/dist/negation.d.mts.map +1 -1
- package/dist/negation.mjs +18 -18
- package/dist/negation.mjs.map +1 -1
- package/dist/rules/prefer-is-non-null-object.mjs +51 -51
- package/dist/rules/prefer-is-non-null-object.mjs.map +1 -1
- package/dist/rules/prefer-safe-parse.d.mts +10 -7
- package/dist/rules/prefer-safe-parse.d.mts.map +1 -1
- package/dist/rules/prefer-safe-parse.mjs +41 -26
- package/dist/rules/prefer-safe-parse.mjs.map +1 -1
- package/dist/statements.d.mts +12 -0
- package/dist/statements.d.mts.map +1 -0
- package/dist/statements.mjs +39 -0
- package/dist/statements.mjs.map +1 -0
- package/dist/type-wrappers.d.mts +3 -3
- package/package.json +5 -5
- package/src/bindings.mts +4 -5
- package/src/imports.mts +181 -37
- package/src/index.mts +1 -0
- package/src/negation.mts +36 -37
- package/src/rules/prefer-is-non-null-object.mts +70 -70
- package/src/rules/prefer-is-record-and-has-key.mts +5 -5
- package/src/rules/prefer-safe-parse.mts +72 -50
- package/src/statements.mts +57 -0
- package/src/type-wrappers.mts +3 -3
|
@@ -30,16 +30,20 @@ export type SafeParseRuleConfig = Readonly<{
|
|
|
30
30
|
}>;
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* Builds a rule that
|
|
34
|
-
* `Number.parseInt` — `parseInt(x)` and `parseInt(x, 10)` —
|
|
35
|
-
* `Result.unwrapOkOr(<namespace>.<method>(x), Number.NaN)
|
|
33
|
+
* Builds a rule that reports the base-10 forms of `parseInt` and
|
|
34
|
+
* `Number.parseInt` — `parseInt(x)` and `parseInt(x, 10)` — and suggests
|
|
35
|
+
* `Result.unwrapOkOr(<namespace>.<method>(x), Number.NaN)` instead. A
|
|
36
|
+
* suggestion, not an autofix: the replacement reads some strings differently.
|
|
36
37
|
*
|
|
37
38
|
* A call that passes another radix is left alone: the replacement takes none,
|
|
38
39
|
* so it would mean something the replacement cannot say.
|
|
39
40
|
*/
|
|
40
41
|
export const createPreferSafeParseInt = (
|
|
41
42
|
config: SafeParseRuleConfig,
|
|
42
|
-
): TSESLint.RuleModule<
|
|
43
|
+
): TSESLint.RuleModule<
|
|
44
|
+
'useSafeParseInt' | 'replaceWithSafeParse',
|
|
45
|
+
readonly []
|
|
46
|
+
> =>
|
|
43
47
|
createSafeParseRule(config, 'useSafeParseInt', {
|
|
44
48
|
messages: { useSafeParseInt: messageText(config) },
|
|
45
49
|
description: `Replace \`parseInt(x)\` or \`Number.parseInt(x, 10)\` with \`Result.unwrapOkOr(${config.namespace}.${config.method}(x), Number.NaN)\` from ${config.moduleName}.`,
|
|
@@ -51,8 +55,8 @@ export const createPreferSafeParseInt = (
|
|
|
51
55
|
return false;
|
|
52
56
|
}
|
|
53
57
|
|
|
54
|
-
// Only base 10: accept a missing radix or an explicit literal `10`,
|
|
55
|
-
//
|
|
58
|
+
// Only base 10: accept a missing radix or an explicit literal `10`, read
|
|
59
|
+
// through its type wrappers, which leave the value as it is.
|
|
56
60
|
const radix =
|
|
57
61
|
args[1] === undefined ? undefined : skipTypeWrappers(args[1]);
|
|
58
62
|
|
|
@@ -64,15 +68,20 @@ export const createPreferSafeParseInt = (
|
|
|
64
68
|
});
|
|
65
69
|
|
|
66
70
|
/**
|
|
67
|
-
* Builds a rule that
|
|
68
|
-
* `Number(x)
|
|
71
|
+
* Builds a rule that reports `parseFloat(x)`, `Number.parseFloat(x)` and
|
|
72
|
+
* `Number(x)`, and suggests
|
|
73
|
+
* `Result.unwrapOkOr(<namespace>.<method>(x), Number.NaN)` instead, as a
|
|
74
|
+
* suggestion for the same reason.
|
|
69
75
|
*
|
|
70
76
|
* Like every rewrite here, `Number(x)` is only flagged when `x` is purely a
|
|
71
77
|
* `string`, which leaves `Number(someBoolean)` and `Number(someNumber)` alone.
|
|
72
78
|
*/
|
|
73
79
|
export const createPreferSafeParseFloat = (
|
|
74
80
|
config: SafeParseRuleConfig,
|
|
75
|
-
): TSESLint.RuleModule<
|
|
81
|
+
): TSESLint.RuleModule<
|
|
82
|
+
'useSafeParseFloat' | 'replaceWithSafeParse',
|
|
83
|
+
readonly []
|
|
84
|
+
> =>
|
|
76
85
|
createSafeParseRule(config, 'useSafeParseFloat', {
|
|
77
86
|
messages: { useSafeParseFloat: messageText(config) },
|
|
78
87
|
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}.`,
|
|
@@ -86,6 +95,20 @@ export const createPreferSafeParseFloat = (
|
|
|
86
95
|
|
|
87
96
|
/* eslint-disable @typescript-eslint/prefer-readonly-parameter-types */
|
|
88
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Whether every constituent of `type` is string-like, i.e. the type is
|
|
100
|
+
* assignable to the parse function's `string` parameter. A union such as
|
|
101
|
+
* `string | undefined` is rejected so the suggestion never produces a type error.
|
|
102
|
+
*/
|
|
103
|
+
const isStringType = (type: ts.Type): boolean => {
|
|
104
|
+
const parts = type.isUnion() ? type.types : ([type] as const);
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
parts.length > 0 &&
|
|
108
|
+
parts.every((t) => (t.flags & ts.TypeFlags.StringLike) !== 0)
|
|
109
|
+
);
|
|
110
|
+
};
|
|
111
|
+
|
|
89
112
|
const createSafeParseRule = <MessageId extends string>(
|
|
90
113
|
{ moduleName, namespace, method }: SafeParseRuleConfig,
|
|
91
114
|
messageId: MessageId,
|
|
@@ -100,14 +123,14 @@ const createSafeParseRule = <MessageId extends string>(
|
|
|
100
123
|
|
|
101
124
|
/**
|
|
102
125
|
* Whether the call, its callee read through its type wrappers, is one to
|
|
103
|
-
*
|
|
126
|
+
* report.
|
|
104
127
|
*/
|
|
105
128
|
isTarget: (
|
|
106
129
|
callee: TSESTree.Expression,
|
|
107
130
|
args: readonly TSESTree.CallExpressionArgument[],
|
|
108
131
|
) => boolean;
|
|
109
132
|
}>,
|
|
110
|
-
): TSESLint.RuleModule<MessageId, readonly []> => {
|
|
133
|
+
): TSESLint.RuleModule<MessageId | 'replaceWithSafeParse', readonly []> => {
|
|
111
134
|
// Sorted, as an import sorter would write them.
|
|
112
135
|
const requiredImports = [namespace, 'Result'].toSorted();
|
|
113
136
|
|
|
@@ -115,16 +138,19 @@ const createSafeParseRule = <MessageId extends string>(
|
|
|
115
138
|
meta: {
|
|
116
139
|
type: 'suggestion',
|
|
117
140
|
docs: { description },
|
|
118
|
-
|
|
141
|
+
hasSuggestions: true,
|
|
119
142
|
schema: [],
|
|
120
|
-
messages
|
|
143
|
+
messages: {
|
|
144
|
+
...messages,
|
|
145
|
+
replaceWithSafeParse: `Replace with \`Result.unwrapOkOr(${namespace}.${method}({{argName}}), Number.NaN)\`.`,
|
|
146
|
+
},
|
|
121
147
|
defaultOptions: [],
|
|
122
148
|
},
|
|
123
149
|
|
|
124
150
|
create: (context) => {
|
|
125
151
|
const sourceCode = context.sourceCode;
|
|
126
152
|
|
|
127
|
-
const
|
|
153
|
+
const mut_reports: {
|
|
128
154
|
node: TSESTree.CallExpression;
|
|
129
155
|
argExpression: TSESTree.Expression;
|
|
130
156
|
}[] = [];
|
|
@@ -137,8 +163,7 @@ const createSafeParseRule = <MessageId extends string>(
|
|
|
137
163
|
|
|
138
164
|
const firstArg = node.arguments[0];
|
|
139
165
|
|
|
140
|
-
// A spread argument (`parseInt(...rest)`) cannot be rewritten
|
|
141
|
-
// safely.
|
|
166
|
+
// A spread argument (`parseInt(...rest)`) cannot be rewritten safely.
|
|
142
167
|
if (
|
|
143
168
|
firstArg === undefined ||
|
|
144
169
|
firstArg.type === AST_NODE_TYPES.SpreadElement
|
|
@@ -148,16 +173,16 @@ const createSafeParseRule = <MessageId extends string>(
|
|
|
148
173
|
|
|
149
174
|
// The argument must be purely `string` at every layer of type
|
|
150
175
|
// wrappers: the value's own type is what the original sees at run
|
|
151
|
-
// time (`parseInt(1e21 as unknown as string)` is 1, the rewrite
|
|
152
|
-
//
|
|
153
|
-
//
|
|
176
|
+
// time (`parseInt(1e21 as unknown as string)` is 1, the rewrite 1e21;
|
|
177
|
+
// `Number(true as unknown as string)` is 1, the rewrite NaN), and the
|
|
178
|
+
// type a wrapper states is what the suggestion, which keeps the
|
|
154
179
|
// wrapper, hands the parse function's `string` parameter. Without
|
|
155
180
|
// type information, skip.
|
|
156
181
|
if (!typeHoldsAtEveryLayer(sourceCode, firstArg, isStringType)) {
|
|
157
182
|
return;
|
|
158
183
|
}
|
|
159
184
|
|
|
160
|
-
|
|
185
|
+
mut_reports.push({ node, argExpression: firstArg });
|
|
161
186
|
},
|
|
162
187
|
'Program:exit': () => {
|
|
163
188
|
const binding = bindValueImports(
|
|
@@ -173,32 +198,43 @@ const createSafeParseRule = <MessageId extends string>(
|
|
|
173
198
|
|
|
174
199
|
const resultName = localNameOf('Result');
|
|
175
200
|
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
|
|
201
|
+
// A suggestion, never an autofix: the replacement parses some
|
|
202
|
+
// strings differently (`parseInt('1e3')` is 1, the rewrite 1000;
|
|
203
|
+
// `parseFloat('12px')` is 12, the rewrite NaN; `Number('')` is 0, the
|
|
204
|
+
// rewrite NaN), so applying it is a person's decision. Each one
|
|
205
|
+
// brings its names into scope itself, so that it is complete alone.
|
|
206
|
+
for (const { node, argExpression } of mut_reports) {
|
|
179
207
|
const argText = getArgumentText(sourceCode, argExpression);
|
|
180
208
|
|
|
209
|
+
const data = {
|
|
210
|
+
original: sourceCode.getText(node),
|
|
211
|
+
argName: argText,
|
|
212
|
+
} as const;
|
|
213
|
+
|
|
181
214
|
context.report({
|
|
182
215
|
node,
|
|
183
216
|
messageId,
|
|
184
|
-
data
|
|
185
|
-
|
|
186
|
-
argName: argText,
|
|
187
|
-
},
|
|
188
|
-
fix:
|
|
217
|
+
data,
|
|
218
|
+
suggest:
|
|
189
219
|
binding === undefined ||
|
|
190
220
|
namespaceName === undefined ||
|
|
191
221
|
resultName === undefined ||
|
|
192
222
|
[namespaceName, resultName].some((name) =>
|
|
193
223
|
hasForeignBinding(sourceCode, node, name, moduleName),
|
|
194
224
|
)
|
|
195
|
-
?
|
|
196
|
-
:
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
225
|
+
? []
|
|
226
|
+
: [
|
|
227
|
+
{
|
|
228
|
+
messageId: 'replaceWithSafeParse',
|
|
229
|
+
data,
|
|
230
|
+
fix: (fixer) => [
|
|
231
|
+
...binding.edits(fixer),
|
|
232
|
+
fixer.replaceText(
|
|
233
|
+
node,
|
|
234
|
+
`${resultName}.unwrapOkOr(${namespaceName}.${method}(${argText}), Number.NaN)`,
|
|
235
|
+
),
|
|
236
|
+
],
|
|
237
|
+
},
|
|
202
238
|
],
|
|
203
239
|
});
|
|
204
240
|
}
|
|
@@ -213,7 +249,7 @@ const messageText = ({
|
|
|
213
249
|
namespace,
|
|
214
250
|
method,
|
|
215
251
|
}: SafeParseRuleConfig): string =>
|
|
216
|
-
`Replace \`{{original}}\` with \`Result.unwrapOkOr(${namespace}.${method}({{argName}}), Number.NaN)\` from ${moduleName}.` as const;
|
|
252
|
+
`Replace \`{{original}}\` with \`Result.unwrapOkOr(${namespace}.${method}({{argName}}), Number.NaN)\` from ${moduleName}. It parses some strings differently from the call it replaces, so check the input before applying it.` as const;
|
|
217
253
|
|
|
218
254
|
/** Whether `callee` is the global function `name`, as in `parseInt(...)`. */
|
|
219
255
|
const isGlobalCall = (callee: TSESTree.Expression, name: string): boolean =>
|
|
@@ -229,17 +265,3 @@ const isNumberMethod = (callee: TSESTree.Expression, name: string): boolean =>
|
|
|
229
265
|
isIdentifierNamed(callee.object, 'Number') &&
|
|
230
266
|
callee.property.type === AST_NODE_TYPES.Identifier &&
|
|
231
267
|
callee.property.name === name;
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Whether every constituent of `type` is string-like, i.e. the type is
|
|
235
|
-
* assignable to the parse function's `string` parameter. A union such as
|
|
236
|
-
* `string | undefined` is rejected so the autofix never produces a type error.
|
|
237
|
-
*/
|
|
238
|
-
const isStringType = (type: ts.Type): boolean => {
|
|
239
|
-
const parts = type.isUnion() ? type.types : ([type] as const);
|
|
240
|
-
|
|
241
|
-
return (
|
|
242
|
-
parts.length > 0 &&
|
|
243
|
-
parts.every((t) => (t.flags & ts.TypeFlags.StringLike) !== 0)
|
|
244
|
-
);
|
|
245
|
-
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils';
|
|
2
|
+
import type { DeepReadonly } from 'ts-type-forge';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The statement next to `node` (`offset` 1) or before it (-1) in the statement
|
|
6
|
+
* list `node` is one of: the program's, a block's, a class static block's, a
|
|
7
|
+
* namespace's or a `case`'s. What an `export` declares (or, for
|
|
8
|
+
* `export default`, the expression it exports) is read as the `export`
|
|
9
|
+
* statement. `undefined` when there is none, or when `node` is not in a
|
|
10
|
+
* statement list (an arrow function's expression body, a `for` initializer).
|
|
11
|
+
*/
|
|
12
|
+
export const siblingOf = (
|
|
13
|
+
node: DeepReadonly<TSESTree.Node>,
|
|
14
|
+
offset: 1 | -1,
|
|
15
|
+
): DeepReadonly<TSESTree.Node> | undefined => {
|
|
16
|
+
const { parent } = node;
|
|
17
|
+
|
|
18
|
+
const statement =
|
|
19
|
+
(parent?.type === AST_NODE_TYPES.ExportNamedDeclaration ||
|
|
20
|
+
parent?.type === AST_NODE_TYPES.ExportDefaultDeclaration) &&
|
|
21
|
+
parent.declaration === node
|
|
22
|
+
? parent
|
|
23
|
+
: node;
|
|
24
|
+
|
|
25
|
+
const list = statementListOf(statement);
|
|
26
|
+
|
|
27
|
+
if (list === undefined) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const index = list.indexOf(statement);
|
|
32
|
+
|
|
33
|
+
return index === -1 ? undefined : list[index + offset];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const statementListOf = (
|
|
37
|
+
node: DeepReadonly<TSESTree.Node>,
|
|
38
|
+
): readonly DeepReadonly<TSESTree.Node>[] | undefined => {
|
|
39
|
+
const { parent } = node;
|
|
40
|
+
|
|
41
|
+
if (parent === undefined) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (
|
|
46
|
+
parent.type === AST_NODE_TYPES.Program ||
|
|
47
|
+
parent.type === AST_NODE_TYPES.BlockStatement ||
|
|
48
|
+
parent.type === AST_NODE_TYPES.StaticBlock ||
|
|
49
|
+
parent.type === AST_NODE_TYPES.TSModuleBlock
|
|
50
|
+
) {
|
|
51
|
+
return parent.body;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return parent.type === AST_NODE_TYPES.SwitchCase
|
|
55
|
+
? parent.consequent
|
|
56
|
+
: undefined;
|
|
57
|
+
};
|
package/src/type-wrappers.mts
CHANGED
|
@@ -89,9 +89,9 @@ export const isIdentifierNamed = (
|
|
|
89
89
|
* Whether `holds` is true of the type of every layer of type wrappers around
|
|
90
90
|
* `node` (see {@link typeWrapperLayers}), outermost to innermost; `false`
|
|
91
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
|
-
*
|
|
94
|
-
*
|
|
92
|
+
* author saying the value may be more than it is, and is respected; a narrowing
|
|
93
|
+
* one (`s!`, `v as unknown as string`) cannot vouch for what the value's own
|
|
94
|
+
* type does not, and the fix, which keeps the wrapper, has to
|
|
95
95
|
* type-check at every layer too.
|
|
96
96
|
*/
|
|
97
97
|
export const typeHoldsAtEveryLayer = (
|