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,119 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AST_NODE_TYPES,
|
|
3
|
+
type TSESLint,
|
|
4
|
+
type TSESTree,
|
|
5
|
+
} from '@typescript-eslint/utils';
|
|
6
|
+
import {
|
|
7
|
+
buildNamedImportFix,
|
|
8
|
+
getImportDeclarations,
|
|
9
|
+
getNamedImports,
|
|
10
|
+
} from '../imports.mjs';
|
|
11
|
+
import { getRangeWithParens } from '../parentheses.mjs';
|
|
12
|
+
import { isIdentifierNamed, skipTypeWrappers } from '../type-wrappers.mjs';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Builds a rule that replaces `Array.isArray` with `<namespace>.isArray`
|
|
16
|
+
* imported from `moduleName`. Only the callee is rewritten; the arguments stay
|
|
17
|
+
* as written.
|
|
18
|
+
*/
|
|
19
|
+
export const createPreferArrayIsArray = ({
|
|
20
|
+
moduleName,
|
|
21
|
+
namespace,
|
|
22
|
+
}: Readonly<{
|
|
23
|
+
/** The module that exports `namespace`. */
|
|
24
|
+
moduleName: string;
|
|
25
|
+
|
|
26
|
+
/** The exported namespace whose `isArray` replaces `Array.isArray`. */
|
|
27
|
+
namespace: string;
|
|
28
|
+
}>): TSESLint.RuleModule<'useIsArray', readonly []> => {
|
|
29
|
+
const replacement = `${namespace}.isArray` as const;
|
|
30
|
+
|
|
31
|
+
const text =
|
|
32
|
+
`Replace \`Array.isArray\` with \`${replacement}\` from ${moduleName}.` as const;
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
meta: {
|
|
36
|
+
type: 'suggestion',
|
|
37
|
+
docs: { description: text },
|
|
38
|
+
fixable: 'code',
|
|
39
|
+
schema: [],
|
|
40
|
+
messages: { useIsArray: text },
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
create: (context) => {
|
|
44
|
+
const sourceCode = context.sourceCode;
|
|
45
|
+
|
|
46
|
+
const program = sourceCode.ast;
|
|
47
|
+
|
|
48
|
+
const moduleImports = getImportDeclarations(program, moduleName);
|
|
49
|
+
|
|
50
|
+
const mut_nodesToFix: TSESTree.CallExpression[] = [];
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
CallExpression: (node) => {
|
|
54
|
+
if (!isArrayIsArrayCall(node)) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
mut_nodesToFix.push(node);
|
|
59
|
+
},
|
|
60
|
+
'Program:exit': () => {
|
|
61
|
+
const hasNamespaceImport =
|
|
62
|
+
getNamedImports(moduleImports).includes(namespace);
|
|
63
|
+
|
|
64
|
+
// The import fix lives on the first report only:
|
|
65
|
+
// `insertTextBefore(program, …)` produces overlapping ranges
|
|
66
|
+
// otherwise and only one of them would survive a pass. A first
|
|
67
|
+
// report silenced by an `eslint-disable` comment therefore takes
|
|
68
|
+
// the import with it.
|
|
69
|
+
for (const [index, node] of mut_nodesToFix.entries()) {
|
|
70
|
+
context.report({
|
|
71
|
+
node,
|
|
72
|
+
messageId: 'useIsArray',
|
|
73
|
+
fix: (fixer) => {
|
|
74
|
+
const importFixes =
|
|
75
|
+
index === 0 && !hasNamespaceImport
|
|
76
|
+
? buildNamedImportFix(fixer, program, moduleName, [
|
|
77
|
+
namespace,
|
|
78
|
+
])
|
|
79
|
+
: ([] as const);
|
|
80
|
+
|
|
81
|
+
// Only the callee is replaced, type wrappers included, along
|
|
82
|
+
// with the parentheses around it, which its range leaves out;
|
|
83
|
+
// the arguments stay as written.
|
|
84
|
+
return [
|
|
85
|
+
...importFixes,
|
|
86
|
+
fixer.replaceTextRange(
|
|
87
|
+
getRangeWithParens(sourceCode, node.callee),
|
|
88
|
+
replacement,
|
|
89
|
+
),
|
|
90
|
+
];
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
defaultOptions: [],
|
|
98
|
+
} as const;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Whether `node` calls `Array.isArray`, the callee and `Array` both read
|
|
103
|
+
* through their type wrappers: `(Array as ArrayConstructor).isArray(x)` and
|
|
104
|
+
* `(Array.isArray as (u: unknown) => boolean)(x)` are the same call.
|
|
105
|
+
*/
|
|
106
|
+
const isArrayIsArrayCall = (
|
|
107
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
108
|
+
node: TSESTree.CallExpression,
|
|
109
|
+
): boolean => {
|
|
110
|
+
const callee = skipTypeWrappers(node.callee);
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
callee.type === AST_NODE_TYPES.MemberExpression &&
|
|
114
|
+
!callee.computed &&
|
|
115
|
+
isIdentifierNamed(callee.object, 'Array') &&
|
|
116
|
+
callee.property.type === AST_NODE_TYPES.Identifier &&
|
|
117
|
+
callee.property.name === 'isArray'
|
|
118
|
+
);
|
|
119
|
+
};
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AST_NODE_TYPES,
|
|
3
|
+
type TSESLint,
|
|
4
|
+
type TSESTree,
|
|
5
|
+
} from '@typescript-eslint/utils';
|
|
6
|
+
import {
|
|
7
|
+
buildNamedImportFix,
|
|
8
|
+
getImportDeclarations,
|
|
9
|
+
getNamedImports,
|
|
10
|
+
} from '../imports.mjs';
|
|
11
|
+
import { getRangeWithParens, parenthesesDepth } from '../parentheses.mjs';
|
|
12
|
+
import { skipTypeWrappers } from '../type-wrappers.mjs';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Builds a rule that replaces `typeof u === 'object' && u !== null`, in either
|
|
16
|
+
* order and anywhere in an `&&` chain, with `isNonNullObject(u)` imported from
|
|
17
|
+
* `moduleName`.
|
|
18
|
+
*/
|
|
19
|
+
export const createPreferIsNonNullObject = ({
|
|
20
|
+
moduleName,
|
|
21
|
+
}: Readonly<{
|
|
22
|
+
/** The module that exports `isNonNullObject`. */
|
|
23
|
+
moduleName: string;
|
|
24
|
+
}>): TSESLint.RuleModule<'useIsNonNullObject', readonly []> =>
|
|
25
|
+
({
|
|
26
|
+
meta: {
|
|
27
|
+
type: 'suggestion',
|
|
28
|
+
docs: {
|
|
29
|
+
description: `Replace \`typeof u === "object" && u !== null\`, in either order and anywhere in an \`&&\` chain, with \`isNonNullObject(u)\` from ${moduleName}.`,
|
|
30
|
+
},
|
|
31
|
+
fixable: 'code',
|
|
32
|
+
schema: [],
|
|
33
|
+
messages: {
|
|
34
|
+
useIsNonNullObject: `Replace the object/null check with \`isNonNullObject()\` from ${moduleName}.`,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
create: (context) => {
|
|
39
|
+
const sourceCode = context.sourceCode;
|
|
40
|
+
|
|
41
|
+
const program = sourceCode.ast;
|
|
42
|
+
|
|
43
|
+
const moduleImports = getImportDeclarations(program, moduleName);
|
|
44
|
+
|
|
45
|
+
const mut_pairsToFix: NonNullObjectPair[] = [];
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
LogicalExpression: (node) => {
|
|
49
|
+
// A chain is read once, from its outermost `&&`.
|
|
50
|
+
if (node.operator !== '&&' || isChainOperand(sourceCode, node)) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
mut_pairsToFix.push(...findNonNullObjectPairs(sourceCode, node));
|
|
55
|
+
},
|
|
56
|
+
'Program:exit': () => {
|
|
57
|
+
const hasIsNonNullObjectImport =
|
|
58
|
+
getNamedImports(moduleImports).includes('isNonNullObject');
|
|
59
|
+
|
|
60
|
+
// The import fix lives on the first report only:
|
|
61
|
+
// `insertTextBefore(program, …)` produces overlapping ranges
|
|
62
|
+
// otherwise and only one of them would survive a pass. A first
|
|
63
|
+
// report silenced by an `eslint-disable` comment therefore takes
|
|
64
|
+
// the import with it.
|
|
65
|
+
for (const [
|
|
66
|
+
index,
|
|
67
|
+
{ range, identifierName },
|
|
68
|
+
] of mut_pairsToFix.entries()) {
|
|
69
|
+
context.report({
|
|
70
|
+
loc: {
|
|
71
|
+
start: sourceCode.getLocFromIndex(range[0]),
|
|
72
|
+
end: sourceCode.getLocFromIndex(range[1]),
|
|
73
|
+
},
|
|
74
|
+
messageId: 'useIsNonNullObject',
|
|
75
|
+
fix: (fixer) => {
|
|
76
|
+
const replacement = `isNonNullObject(${identifierName})`;
|
|
77
|
+
|
|
78
|
+
const importFixes =
|
|
79
|
+
index === 0 && !hasIsNonNullObjectImport
|
|
80
|
+
? buildNamedImportFix(fixer, program, moduleName, [
|
|
81
|
+
'isNonNullObject',
|
|
82
|
+
])
|
|
83
|
+
: [];
|
|
84
|
+
|
|
85
|
+
return [
|
|
86
|
+
...importFixes,
|
|
87
|
+
fixer.replaceTextRange(range, replacement),
|
|
88
|
+
];
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
defaultOptions: [],
|
|
96
|
+
}) as const;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A `typeof u === 'object'` and a `u !== null` next to each other in an `&&`
|
|
100
|
+
* chain, as the source range from the first to the second (parentheses
|
|
101
|
+
* included) and the name of `u`.
|
|
102
|
+
*/
|
|
103
|
+
type NonNullObjectPair = Readonly<{
|
|
104
|
+
range: TSESTree.Range;
|
|
105
|
+
identifierName: string;
|
|
106
|
+
}>;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Whether `node` is an operand of an enclosing `&&` chain: a `&&` written
|
|
110
|
+
* directly under another one, without parentheses.
|
|
111
|
+
*/
|
|
112
|
+
const isChainOperand = (
|
|
113
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
114
|
+
sourceCode: TSESLint.SourceCode,
|
|
115
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
116
|
+
node: TSESTree.LogicalExpression,
|
|
117
|
+
): boolean =>
|
|
118
|
+
node.parent.type === AST_NODE_TYPES.LogicalExpression &&
|
|
119
|
+
node.parent.operator === '&&' &&
|
|
120
|
+
parenthesesDepth(sourceCode, node) === 0;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Every non-overlapping pair in the `&&` chain `node` heads, left to right. The
|
|
124
|
+
* two halves may come in either order, and the pair may have operands before
|
|
125
|
+
* and after it: `a && u !== null && typeof u === 'object' && b`. They have to
|
|
126
|
+
* be next to each other, so that the pair is one range of the source.
|
|
127
|
+
*/
|
|
128
|
+
const findNonNullObjectPairs = (
|
|
129
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
130
|
+
sourceCode: TSESLint.SourceCode,
|
|
131
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
132
|
+
node: TSESTree.LogicalExpression,
|
|
133
|
+
): readonly NonNullObjectPair[] => {
|
|
134
|
+
const operands = chainOperands(sourceCode, node);
|
|
135
|
+
|
|
136
|
+
const pairsFrom = (index: number): readonly NonNullObjectPair[] => {
|
|
137
|
+
const first = operands[index];
|
|
138
|
+
|
|
139
|
+
const second = operands[index + 1];
|
|
140
|
+
|
|
141
|
+
if (first === undefined || second === undefined) {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const identifierName =
|
|
146
|
+
pairIdentifierName(first, second) ?? pairIdentifierName(second, first);
|
|
147
|
+
|
|
148
|
+
return identifierName === undefined
|
|
149
|
+
? pairsFrom(index + 1)
|
|
150
|
+
: [
|
|
151
|
+
{
|
|
152
|
+
range: [
|
|
153
|
+
getRangeWithParens(sourceCode, first)[0],
|
|
154
|
+
getRangeWithParens(sourceCode, second)[1],
|
|
155
|
+
],
|
|
156
|
+
identifierName,
|
|
157
|
+
},
|
|
158
|
+
...pairsFrom(index + 2),
|
|
159
|
+
];
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
return pairsFrom(0);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The operands of the `&&` chain `node`, left to right. A parenthesized `&&`
|
|
167
|
+
* is one operand: its halves cannot be replaced without its parentheses.
|
|
168
|
+
*/
|
|
169
|
+
const chainOperands = (
|
|
170
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
171
|
+
sourceCode: TSESLint.SourceCode,
|
|
172
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
173
|
+
node: TSESTree.LogicalExpression,
|
|
174
|
+
): readonly TSESTree.Expression[] =>
|
|
175
|
+
[node.left, node.right].flatMap((operand) =>
|
|
176
|
+
operand.type === AST_NODE_TYPES.LogicalExpression &&
|
|
177
|
+
operand.operator === '&&' &&
|
|
178
|
+
parenthesesDepth(sourceCode, operand) === 0
|
|
179
|
+
? chainOperands(sourceCode, operand)
|
|
180
|
+
: [operand],
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
// Every operand below is read through its type wrappers: `typeof (u satisfies
|
|
184
|
+
// unknown) === 'object' && u! !== null` is the same check.
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The name of `u` when `typeofCheck` is `typeof u === 'object'` and
|
|
188
|
+
* `nullCheck` is `u !== null` for the same `u`.
|
|
189
|
+
*/
|
|
190
|
+
const pairIdentifierName = (
|
|
191
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
192
|
+
typeofCheck: TSESTree.Expression,
|
|
193
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
194
|
+
nullCheck: TSESTree.Expression,
|
|
195
|
+
): string | undefined => {
|
|
196
|
+
const identifierName = getTypeofObjectIdentifierName(
|
|
197
|
+
skipTypeWrappers(typeofCheck),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
return identifierName !== undefined &&
|
|
201
|
+
identifierName ===
|
|
202
|
+
getNonNullCheckIdentifierName(skipTypeWrappers(nullCheck))
|
|
203
|
+
? identifierName
|
|
204
|
+
: undefined;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/** `u` of `typeof u === 'object'`, in either operand order. */
|
|
208
|
+
const getTypeofObjectIdentifierName = (
|
|
209
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
210
|
+
node: TSESTree.Node,
|
|
211
|
+
): string | undefined => {
|
|
212
|
+
if (
|
|
213
|
+
node.type !== AST_NODE_TYPES.BinaryExpression ||
|
|
214
|
+
node.operator !== '==='
|
|
215
|
+
) {
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const left = skipTypeWrappers(node.left);
|
|
220
|
+
|
|
221
|
+
const right = skipTypeWrappers(node.right);
|
|
222
|
+
|
|
223
|
+
const typeofSide = isLiteralOf(right, 'object')
|
|
224
|
+
? left
|
|
225
|
+
: isLiteralOf(left, 'object')
|
|
226
|
+
? right
|
|
227
|
+
: undefined;
|
|
228
|
+
|
|
229
|
+
if (
|
|
230
|
+
typeofSide?.type !== AST_NODE_TYPES.UnaryExpression ||
|
|
231
|
+
typeofSide.operator !== 'typeof'
|
|
232
|
+
) {
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const argument = skipTypeWrappers(typeofSide.argument);
|
|
237
|
+
|
|
238
|
+
return argument.type === AST_NODE_TYPES.Identifier
|
|
239
|
+
? argument.name
|
|
240
|
+
: undefined;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* `u` of `u !== null`, in either operand order. `u != null` also excludes
|
|
245
|
+
* `undefined`, which `typeof u === 'object'` has already excluded, so it is
|
|
246
|
+
* the same check here.
|
|
247
|
+
*/
|
|
248
|
+
const getNonNullCheckIdentifierName = (
|
|
249
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
250
|
+
node: TSESTree.Node,
|
|
251
|
+
): string | undefined => {
|
|
252
|
+
if (
|
|
253
|
+
node.type !== AST_NODE_TYPES.BinaryExpression ||
|
|
254
|
+
(node.operator !== '!==' && node.operator !== '!=')
|
|
255
|
+
) {
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const left = skipTypeWrappers(node.left);
|
|
260
|
+
|
|
261
|
+
const right = skipTypeWrappers(node.right);
|
|
262
|
+
|
|
263
|
+
const checked = isLiteralOf(right, null)
|
|
264
|
+
? left
|
|
265
|
+
: isLiteralOf(left, null)
|
|
266
|
+
? right
|
|
267
|
+
: undefined;
|
|
268
|
+
|
|
269
|
+
return checked?.type === AST_NODE_TYPES.Identifier ? checked.name : undefined;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const isLiteralOf = (
|
|
273
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
274
|
+
node: TSESTree.Node,
|
|
275
|
+
value: string | null,
|
|
276
|
+
): boolean => node.type === AST_NODE_TYPES.Literal && node.value === value;
|