eslint-rule-forge 0.3.2 → 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 +47 -40
- 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/comments.d.mts +10 -0
- package/dist/comments.d.mts.map +1 -0
- package/dist/comments.mjs +11 -0
- package/dist/comments.mjs.map +1 -0
- package/dist/imports.d.mts +68 -8
- package/dist/imports.d.mts.map +1 -1
- package/dist/imports.mjs +202 -6
- package/dist/imports.mjs.map +1 -1
- package/dist/index.d.mts +2 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -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-array-is-array.d.mts.map +1 -1
- package/dist/rules/prefer-array-is-array.mjs +13 -19
- package/dist/rules/prefer-array-is-array.mjs.map +1 -1
- package/dist/rules/prefer-is-non-null-object.d.mts.map +1 -1
- package/dist/rules/prefer-is-non-null-object.mjs +75 -76
- package/dist/rules/prefer-is-non-null-object.mjs.map +1 -1
- package/dist/rules/prefer-is-record-and-has-key.d.mts.map +1 -1
- package/dist/rules/prefer-is-record-and-has-key.mjs +32 -44
- package/dist/rules/prefer-is-record-and-has-key.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 +50 -34
- 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/comments.mts +23 -0
- package/src/imports.mts +363 -19
- package/src/index.mts +2 -0
- package/src/negation.mts +36 -37
- package/src/rules/prefer-array-is-array.mts +23 -36
- package/src/rules/prefer-is-non-null-object.mts +107 -112
- package/src/rules/prefer-is-record-and-has-key.mts +47 -71
- package/src/rules/prefer-safe-parse.mts +92 -77
- package/src/statements.mts +57 -0
- package/src/type-wrappers.mts +3 -3
|
@@ -5,11 +5,8 @@ import {
|
|
|
5
5
|
} from '@typescript-eslint/utils';
|
|
6
6
|
import type { ReadonlyRecord } from 'ts-type-forge';
|
|
7
7
|
import * as ts from 'typescript';
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
getImportDeclarations,
|
|
11
|
-
getNamedImports,
|
|
12
|
-
} from '../imports.mjs';
|
|
8
|
+
import { hasForeignBinding } from '../bindings.mjs';
|
|
9
|
+
import { bindValueImports } from '../imports.mjs';
|
|
13
10
|
import { getArgumentText } from '../parentheses.mjs';
|
|
14
11
|
import {
|
|
15
12
|
isIdentifierNamed,
|
|
@@ -33,16 +30,20 @@ export type SafeParseRuleConfig = Readonly<{
|
|
|
33
30
|
}>;
|
|
34
31
|
|
|
35
32
|
/**
|
|
36
|
-
* Builds a rule that
|
|
37
|
-
* `Number.parseInt` — `parseInt(x)` and `parseInt(x, 10)` —
|
|
38
|
-
* `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.
|
|
39
37
|
*
|
|
40
38
|
* A call that passes another radix is left alone: the replacement takes none,
|
|
41
39
|
* so it would mean something the replacement cannot say.
|
|
42
40
|
*/
|
|
43
41
|
export const createPreferSafeParseInt = (
|
|
44
42
|
config: SafeParseRuleConfig,
|
|
45
|
-
): TSESLint.RuleModule<
|
|
43
|
+
): TSESLint.RuleModule<
|
|
44
|
+
'useSafeParseInt' | 'replaceWithSafeParse',
|
|
45
|
+
readonly []
|
|
46
|
+
> =>
|
|
46
47
|
createSafeParseRule(config, 'useSafeParseInt', {
|
|
47
48
|
messages: { useSafeParseInt: messageText(config) },
|
|
48
49
|
description: `Replace \`parseInt(x)\` or \`Number.parseInt(x, 10)\` with \`Result.unwrapOkOr(${config.namespace}.${config.method}(x), Number.NaN)\` from ${config.moduleName}.`,
|
|
@@ -54,8 +55,8 @@ export const createPreferSafeParseInt = (
|
|
|
54
55
|
return false;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
// Only base 10: accept a missing radix or an explicit literal `10`,
|
|
58
|
-
//
|
|
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.
|
|
59
60
|
const radix =
|
|
60
61
|
args[1] === undefined ? undefined : skipTypeWrappers(args[1]);
|
|
61
62
|
|
|
@@ -67,15 +68,20 @@ export const createPreferSafeParseInt = (
|
|
|
67
68
|
});
|
|
68
69
|
|
|
69
70
|
/**
|
|
70
|
-
* Builds a rule that
|
|
71
|
-
* `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.
|
|
72
75
|
*
|
|
73
76
|
* Like every rewrite here, `Number(x)` is only flagged when `x` is purely a
|
|
74
77
|
* `string`, which leaves `Number(someBoolean)` and `Number(someNumber)` alone.
|
|
75
78
|
*/
|
|
76
79
|
export const createPreferSafeParseFloat = (
|
|
77
80
|
config: SafeParseRuleConfig,
|
|
78
|
-
): TSESLint.RuleModule<
|
|
81
|
+
): TSESLint.RuleModule<
|
|
82
|
+
'useSafeParseFloat' | 'replaceWithSafeParse',
|
|
83
|
+
readonly []
|
|
84
|
+
> =>
|
|
79
85
|
createSafeParseRule(config, 'useSafeParseFloat', {
|
|
80
86
|
messages: { useSafeParseFloat: messageText(config) },
|
|
81
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}.`,
|
|
@@ -89,6 +95,20 @@ export const createPreferSafeParseFloat = (
|
|
|
89
95
|
|
|
90
96
|
/* eslint-disable @typescript-eslint/prefer-readonly-parameter-types */
|
|
91
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
|
+
|
|
92
112
|
const createSafeParseRule = <MessageId extends string>(
|
|
93
113
|
{ moduleName, namespace, method }: SafeParseRuleConfig,
|
|
94
114
|
messageId: MessageId,
|
|
@@ -103,16 +123,14 @@ const createSafeParseRule = <MessageId extends string>(
|
|
|
103
123
|
|
|
104
124
|
/**
|
|
105
125
|
* Whether the call, its callee read through its type wrappers, is one to
|
|
106
|
-
*
|
|
126
|
+
* report.
|
|
107
127
|
*/
|
|
108
128
|
isTarget: (
|
|
109
129
|
callee: TSESTree.Expression,
|
|
110
130
|
args: readonly TSESTree.CallExpressionArgument[],
|
|
111
131
|
) => boolean;
|
|
112
132
|
}>,
|
|
113
|
-
): TSESLint.RuleModule<MessageId, readonly []> => {
|
|
114
|
-
const parseFunction = `${namespace}.${method}` as const;
|
|
115
|
-
|
|
133
|
+
): TSESLint.RuleModule<MessageId | 'replaceWithSafeParse', readonly []> => {
|
|
116
134
|
// Sorted, as an import sorter would write them.
|
|
117
135
|
const requiredImports = [namespace, 'Result'].toSorted();
|
|
118
136
|
|
|
@@ -120,20 +138,19 @@ const createSafeParseRule = <MessageId extends string>(
|
|
|
120
138
|
meta: {
|
|
121
139
|
type: 'suggestion',
|
|
122
140
|
docs: { description },
|
|
123
|
-
|
|
141
|
+
hasSuggestions: true,
|
|
124
142
|
schema: [],
|
|
125
|
-
messages
|
|
143
|
+
messages: {
|
|
144
|
+
...messages,
|
|
145
|
+
replaceWithSafeParse: `Replace with \`Result.unwrapOkOr(${namespace}.${method}({{argName}}), Number.NaN)\`.`,
|
|
146
|
+
},
|
|
126
147
|
defaultOptions: [],
|
|
127
148
|
},
|
|
128
149
|
|
|
129
150
|
create: (context) => {
|
|
130
151
|
const sourceCode = context.sourceCode;
|
|
131
152
|
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
const moduleImports = getImportDeclarations(program, moduleName);
|
|
135
|
-
|
|
136
|
-
const mut_nodesToFix: {
|
|
153
|
+
const mut_reports: {
|
|
137
154
|
node: TSESTree.CallExpression;
|
|
138
155
|
argExpression: TSESTree.Expression;
|
|
139
156
|
}[] = [];
|
|
@@ -146,8 +163,7 @@ const createSafeParseRule = <MessageId extends string>(
|
|
|
146
163
|
|
|
147
164
|
const firstArg = node.arguments[0];
|
|
148
165
|
|
|
149
|
-
// A spread argument (`parseInt(...rest)`) cannot be rewritten
|
|
150
|
-
// safely.
|
|
166
|
+
// A spread argument (`parseInt(...rest)`) cannot be rewritten safely.
|
|
151
167
|
if (
|
|
152
168
|
firstArg === undefined ||
|
|
153
169
|
firstArg.type === AST_NODE_TYPES.SpreadElement
|
|
@@ -157,56 +173,69 @@ const createSafeParseRule = <MessageId extends string>(
|
|
|
157
173
|
|
|
158
174
|
// The argument must be purely `string` at every layer of type
|
|
159
175
|
// wrappers: the value's own type is what the original sees at run
|
|
160
|
-
// time (`parseInt(1e21 as unknown as string)` is 1, the rewrite
|
|
161
|
-
//
|
|
162
|
-
//
|
|
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
|
|
163
179
|
// wrapper, hands the parse function's `string` parameter. Without
|
|
164
180
|
// type information, skip.
|
|
165
181
|
if (!typeHoldsAtEveryLayer(sourceCode, firstArg, isStringType)) {
|
|
166
182
|
return;
|
|
167
183
|
}
|
|
168
184
|
|
|
169
|
-
|
|
185
|
+
mut_reports.push({ node, argExpression: firstArg });
|
|
170
186
|
},
|
|
171
187
|
'Program:exit': () => {
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
188
|
+
const binding = bindValueImports(
|
|
189
|
+
sourceCode,
|
|
190
|
+
moduleName,
|
|
191
|
+
requiredImports,
|
|
176
192
|
);
|
|
177
193
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
194
|
+
const localNameOf = (name: string): string | undefined =>
|
|
195
|
+
binding?.localNames[requiredImports.indexOf(name)];
|
|
196
|
+
|
|
197
|
+
const namespaceName = localNameOf(namespace);
|
|
198
|
+
|
|
199
|
+
const resultName = localNameOf('Result');
|
|
200
|
+
|
|
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) {
|
|
185
207
|
const argText = getArgumentText(sourceCode, argExpression);
|
|
186
208
|
|
|
209
|
+
const data = {
|
|
210
|
+
original: sourceCode.getText(node),
|
|
211
|
+
argName: argText,
|
|
212
|
+
} as const;
|
|
213
|
+
|
|
187
214
|
context.report({
|
|
188
215
|
node,
|
|
189
216
|
messageId,
|
|
190
|
-
data
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
217
|
+
data,
|
|
218
|
+
suggest:
|
|
219
|
+
binding === undefined ||
|
|
220
|
+
namespaceName === undefined ||
|
|
221
|
+
resultName === undefined ||
|
|
222
|
+
[namespaceName, resultName].some((name) =>
|
|
223
|
+
hasForeignBinding(sourceCode, node, name, moduleName),
|
|
224
|
+
)
|
|
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
|
+
},
|
|
238
|
+
],
|
|
210
239
|
});
|
|
211
240
|
}
|
|
212
241
|
},
|
|
@@ -220,7 +249,7 @@ const messageText = ({
|
|
|
220
249
|
namespace,
|
|
221
250
|
method,
|
|
222
251
|
}: SafeParseRuleConfig): string =>
|
|
223
|
-
`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;
|
|
224
253
|
|
|
225
254
|
/** Whether `callee` is the global function `name`, as in `parseInt(...)`. */
|
|
226
255
|
const isGlobalCall = (callee: TSESTree.Expression, name: string): boolean =>
|
|
@@ -236,17 +265,3 @@ const isNumberMethod = (callee: TSESTree.Expression, name: string): boolean =>
|
|
|
236
265
|
isIdentifierNamed(callee.object, 'Number') &&
|
|
237
266
|
callee.property.type === AST_NODE_TYPES.Identifier &&
|
|
238
267
|
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
|
-
};
|
|
@@ -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 = (
|