eslint-plugin-ts-std-forge 0.1.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/LICENSE +201 -0
- package/README.md +104 -0
- package/dist/index.d.mts +5 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +3 -0
- package/dist/index.mjs.map +1 -0
- package/dist/plugin.d.mts +32 -0
- package/dist/plugin.d.mts.map +1 -0
- package/dist/plugin.mjs +41 -0
- package/dist/plugin.mjs.map +1 -0
- package/dist/rule-types.d.mts +96 -0
- package/dist/rule-types.d.mts.map +1 -0
- package/dist/rule-types.mjs +6 -0
- package/dist/rule-types.mjs.map +1 -0
- package/dist/rules/constants.d.mts +2 -0
- package/dist/rules/constants.d.mts.map +1 -0
- package/dist/rules/constants.mjs +2 -0
- package/dist/rules/constants.mjs.map +1 -0
- package/dist/rules/import-utils.d.mts +6 -0
- package/dist/rules/import-utils.d.mts.map +1 -0
- package/dist/rules/import-utils.mjs +27 -0
- package/dist/rules/import-utils.mjs.map +1 -0
- package/dist/rules/index.d.mts +2 -0
- package/dist/rules/index.d.mts.map +1 -0
- package/dist/rules/index.mjs +2 -0
- package/dist/rules/index.mjs.map +1 -0
- package/dist/rules/prefer-is-non-null-object.d.mts +6 -0
- package/dist/rules/prefer-is-non-null-object.d.mts.map +1 -0
- package/dist/rules/prefer-is-non-null-object.mjs +97 -0
- package/dist/rules/prefer-is-non-null-object.mjs.map +1 -0
- package/dist/rules/prefer-is-record-and-has-key.d.mts +6 -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 +154 -0
- package/dist/rules/prefer-is-record-and-has-key.mjs.map +1 -0
- package/dist/rules/prefer-safe-array-is-array.d.mts +6 -0
- package/dist/rules/prefer-safe-array-is-array.d.mts.map +1 -0
- package/dist/rules/prefer-safe-array-is-array.mjs +68 -0
- package/dist/rules/prefer-safe-array-is-array.mjs.map +1 -0
- package/dist/rules/prefer-safe-array-length-guard.d.mts +6 -0
- package/dist/rules/prefer-safe-array-length-guard.d.mts.map +1 -0
- package/dist/rules/prefer-safe-array-length-guard.mjs +143 -0
- package/dist/rules/prefer-safe-array-length-guard.mjs.map +1 -0
- package/dist/rules/prefer-safe-number-parse-integer.d.mts +13 -0
- package/dist/rules/prefer-safe-number-parse-integer.d.mts.map +1 -0
- package/dist/rules/prefer-safe-number-parse-integer.mjs +109 -0
- package/dist/rules/prefer-safe-number-parse-integer.mjs.map +1 -0
- package/dist/rules/prefer-safe-number-parse.d.mts +19 -0
- package/dist/rules/prefer-safe-number-parse.d.mts.map +1 -0
- package/dist/rules/prefer-safe-number-parse.mjs +111 -0
- package/dist/rules/prefer-safe-number-parse.mjs.map +1 -0
- package/dist/rules/rules.d.mts +9 -0
- package/dist/rules/rules.d.mts.map +1 -0
- package/dist/rules/rules.mjs +15 -0
- package/dist/rules/rules.mjs.map +1 -0
- package/dist/types.d.mts +5 -0
- package/dist/types.d.mts.map +1 -0
- package/dist/types.mjs +2 -0
- package/dist/types.mjs.map +1 -0
- package/package.json +84 -0
- package/src/index.mts +7 -0
- package/src/plugin.mts +48 -0
- package/src/plugin.test.mts +24 -0
- package/src/rule-types.mts +116 -0
- package/src/rules/constants.mts +1 -0
- package/src/rules/import-utils.mts +56 -0
- package/src/rules/index.mts +1 -0
- package/src/rules/prefer-is-non-null-object.mts +142 -0
- package/src/rules/prefer-is-non-null-object.test.mts +54 -0
- package/src/rules/prefer-is-record-and-has-key.mts +217 -0
- package/src/rules/prefer-is-record-and-has-key.test.mts +80 -0
- package/src/rules/prefer-safe-array-is-array.mts +99 -0
- package/src/rules/prefer-safe-array-is-array.test.mts +65 -0
- package/src/rules/prefer-safe-array-length-guard.mts +214 -0
- package/src/rules/prefer-safe-array-length-guard.test.mts +152 -0
- package/src/rules/prefer-safe-number-parse-integer.mts +162 -0
- package/src/rules/prefer-safe-number-parse-integer.test.mts +67 -0
- package/src/rules/prefer-safe-number-parse.mts +161 -0
- package/src/rules/prefer-safe-number-parse.test.mts +109 -0
- package/src/rules/rules.mts +16 -0
- package/src/types.mts +6 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AST_NODE_TYPES,
|
|
3
|
+
type TSESLint,
|
|
4
|
+
type TSESTree,
|
|
5
|
+
} from '@typescript-eslint/utils';
|
|
6
|
+
import { type DeepReadonly } from 'ts-type-forge';
|
|
7
|
+
import {
|
|
8
|
+
buildImportFixes,
|
|
9
|
+
getNamedImports,
|
|
10
|
+
getTsStdForgeImport,
|
|
11
|
+
} from './import-utils.mjs';
|
|
12
|
+
|
|
13
|
+
type Options = readonly [];
|
|
14
|
+
|
|
15
|
+
type MessageIds = 'useIsNonNullObject';
|
|
16
|
+
|
|
17
|
+
export const preferIsNonNullObject: TSESLint.RuleModule<MessageIds, Options> = {
|
|
18
|
+
meta: {
|
|
19
|
+
type: 'suggestion',
|
|
20
|
+
docs: {
|
|
21
|
+
description:
|
|
22
|
+
'Replace `typeof u === "object" && u !== null` with `isNonNullObject(u)` from ts-std-forge.',
|
|
23
|
+
},
|
|
24
|
+
fixable: 'code',
|
|
25
|
+
schema: [],
|
|
26
|
+
messages: {
|
|
27
|
+
useIsNonNullObject:
|
|
28
|
+
'Replace the object/null check with `isNonNullObject()` from ts-std-forge.',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
create: (context) => {
|
|
33
|
+
const sourceCode = context.sourceCode;
|
|
34
|
+
|
|
35
|
+
const program = sourceCode.ast;
|
|
36
|
+
|
|
37
|
+
const tsStdForgeImport = getTsStdForgeImport(program);
|
|
38
|
+
|
|
39
|
+
const mut_nodesToFix: {
|
|
40
|
+
node: TSESTree.LogicalExpression;
|
|
41
|
+
identifierName: string;
|
|
42
|
+
}[] = [];
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
LogicalExpression: (node) => {
|
|
46
|
+
const identifierName = getNonNullObjectIdentifierName(node);
|
|
47
|
+
|
|
48
|
+
if (identifierName === undefined) return;
|
|
49
|
+
|
|
50
|
+
mut_nodesToFix.push({ node, identifierName });
|
|
51
|
+
},
|
|
52
|
+
'Program:exit': () => {
|
|
53
|
+
const hasIsNonNullObjectImport =
|
|
54
|
+
getNamedImports(tsStdForgeImport).includes('isNonNullObject');
|
|
55
|
+
|
|
56
|
+
// The import fix lives on the first report only:
|
|
57
|
+
// `insertTextBefore(program, …)` produces overlapping ranges
|
|
58
|
+
// otherwise and only one of them would survive a pass. A first report
|
|
59
|
+
// silenced by an `eslint-disable` comment therefore takes the import
|
|
60
|
+
// with it.
|
|
61
|
+
for (const [
|
|
62
|
+
index,
|
|
63
|
+
{ node, identifierName },
|
|
64
|
+
] of mut_nodesToFix.entries()) {
|
|
65
|
+
context.report({
|
|
66
|
+
node,
|
|
67
|
+
messageId: 'useIsNonNullObject',
|
|
68
|
+
fix: (fixer) => {
|
|
69
|
+
const replacement = `isNonNullObject(${identifierName})`;
|
|
70
|
+
|
|
71
|
+
const importFixes =
|
|
72
|
+
index === 0 && !hasIsNonNullObjectImport
|
|
73
|
+
? buildImportFixes(fixer, program, ['isNonNullObject'])
|
|
74
|
+
: [];
|
|
75
|
+
|
|
76
|
+
return [...importFixes, fixer.replaceText(node, replacement)];
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
defaultOptions: [],
|
|
84
|
+
} as const;
|
|
85
|
+
|
|
86
|
+
const getNonNullObjectIdentifierName = (
|
|
87
|
+
node: DeepReadonly<TSESTree.LogicalExpression>,
|
|
88
|
+
): string | undefined => {
|
|
89
|
+
if (node.operator !== '&&') return undefined;
|
|
90
|
+
|
|
91
|
+
const leftIdentifierName = getTypeofObjectIdentifierName(node.left);
|
|
92
|
+
|
|
93
|
+
const rightIdentifierName = getNonNullCheckIdentifierName(node.right);
|
|
94
|
+
|
|
95
|
+
if (leftIdentifierName === undefined) return undefined;
|
|
96
|
+
|
|
97
|
+
if (rightIdentifierName === undefined) return undefined;
|
|
98
|
+
|
|
99
|
+
if (leftIdentifierName !== rightIdentifierName) return undefined;
|
|
100
|
+
|
|
101
|
+
return leftIdentifierName;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const getTypeofObjectIdentifierName = (
|
|
105
|
+
node: DeepReadonly<TSESTree.Expression>,
|
|
106
|
+
): string | undefined => {
|
|
107
|
+
if (node.type !== AST_NODE_TYPES.BinaryExpression) return undefined;
|
|
108
|
+
|
|
109
|
+
if (node.operator !== '===') return undefined;
|
|
110
|
+
|
|
111
|
+
const { left, right } = node;
|
|
112
|
+
|
|
113
|
+
if (left.type !== AST_NODE_TYPES.UnaryExpression) return undefined;
|
|
114
|
+
|
|
115
|
+
if (left.operator !== 'typeof') return undefined;
|
|
116
|
+
|
|
117
|
+
if (right.type !== AST_NODE_TYPES.Literal) return undefined;
|
|
118
|
+
|
|
119
|
+
if (right.value !== 'object') return undefined;
|
|
120
|
+
|
|
121
|
+
if (left.argument.type !== AST_NODE_TYPES.Identifier) return undefined;
|
|
122
|
+
|
|
123
|
+
return left.argument.name;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const getNonNullCheckIdentifierName = (
|
|
127
|
+
node: DeepReadonly<TSESTree.Expression>,
|
|
128
|
+
): string | undefined => {
|
|
129
|
+
if (node.type !== AST_NODE_TYPES.BinaryExpression) return undefined;
|
|
130
|
+
|
|
131
|
+
if (node.operator !== '!==') return undefined;
|
|
132
|
+
|
|
133
|
+
const { left, right } = node;
|
|
134
|
+
|
|
135
|
+
if (left.type !== AST_NODE_TYPES.Identifier) return undefined;
|
|
136
|
+
|
|
137
|
+
if (right.type !== AST_NODE_TYPES.Literal) return undefined;
|
|
138
|
+
|
|
139
|
+
if (right.value !== null) return undefined;
|
|
140
|
+
|
|
141
|
+
return left.name;
|
|
142
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
2
|
+
import dedent from 'dedent';
|
|
3
|
+
import { preferIsNonNullObject } from './prefer-is-non-null-object.mjs';
|
|
4
|
+
|
|
5
|
+
const ruleTester = new RuleTester();
|
|
6
|
+
|
|
7
|
+
ruleTester.run('prefer-is-non-null-object', preferIsNonNullObject, {
|
|
8
|
+
valid: [
|
|
9
|
+
{
|
|
10
|
+
name: 'Already using isNonNullObject',
|
|
11
|
+
code: dedent`
|
|
12
|
+
import { isNonNullObject } from 'ts-std-forge';
|
|
13
|
+
const ok = isNonNullObject(u);
|
|
14
|
+
`,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: 'The two halves are about different identifiers',
|
|
18
|
+
code: dedent`
|
|
19
|
+
const ok = typeof u === 'object' && v !== null;
|
|
20
|
+
`,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'Not an object check',
|
|
24
|
+
code: dedent`
|
|
25
|
+
const ok = typeof u === 'string' && u !== null;
|
|
26
|
+
`,
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
invalid: [
|
|
30
|
+
{
|
|
31
|
+
name: 'Replace the object/null check',
|
|
32
|
+
code: dedent`
|
|
33
|
+
const ok = typeof u === 'object' && u !== null;
|
|
34
|
+
`,
|
|
35
|
+
output: dedent`
|
|
36
|
+
import { isNonNullObject } from 'ts-std-forge';
|
|
37
|
+
const ok = isNonNullObject(u);
|
|
38
|
+
`,
|
|
39
|
+
errors: [{ messageId: 'useIsNonNullObject' }],
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'Reuses an existing import',
|
|
43
|
+
code: dedent`
|
|
44
|
+
import { isNonNullObject } from 'ts-std-forge';
|
|
45
|
+
const ok = typeof u === 'object' && u !== null;
|
|
46
|
+
`,
|
|
47
|
+
output: dedent`
|
|
48
|
+
import { isNonNullObject } from 'ts-std-forge';
|
|
49
|
+
const ok = isNonNullObject(u);
|
|
50
|
+
`,
|
|
51
|
+
errors: [{ messageId: 'useIsNonNullObject' }],
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AST_NODE_TYPES,
|
|
3
|
+
type TSESLint,
|
|
4
|
+
type TSESTree,
|
|
5
|
+
} from '@typescript-eslint/utils';
|
|
6
|
+
import { SafeArray } from 'ts-std-forge';
|
|
7
|
+
import * as ts from 'typescript';
|
|
8
|
+
import {
|
|
9
|
+
buildImportFixes,
|
|
10
|
+
getNamedImports,
|
|
11
|
+
getTsStdForgeImport,
|
|
12
|
+
} from './import-utils.mjs';
|
|
13
|
+
|
|
14
|
+
type Options = readonly [];
|
|
15
|
+
|
|
16
|
+
type MessageIds = 'useIsRecordAndHasKey' | 'useHasKey';
|
|
17
|
+
|
|
18
|
+
export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
|
|
19
|
+
{
|
|
20
|
+
meta: {
|
|
21
|
+
type: 'suggestion',
|
|
22
|
+
docs: {
|
|
23
|
+
description:
|
|
24
|
+
'Replace `Object.hasOwn(obj, key)` or `key in obj` with `isRecord(obj) && hasKey(obj, key)` from ts-std-forge',
|
|
25
|
+
},
|
|
26
|
+
fixable: 'code',
|
|
27
|
+
schema: [],
|
|
28
|
+
messages: {
|
|
29
|
+
useIsRecordAndHasKey:
|
|
30
|
+
'Replace `{{original}}` with `isRecord({{objName}}) && hasKey({{objName}}, {{keyName}})` from ts-std-forge.',
|
|
31
|
+
useHasKey:
|
|
32
|
+
'Replace `{{original}}` with `hasKey({{objName}}, {{keyName}})` from ts-std-forge.',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
create: (context) => {
|
|
37
|
+
const sourceCode = context.sourceCode;
|
|
38
|
+
|
|
39
|
+
const program = sourceCode.ast;
|
|
40
|
+
|
|
41
|
+
const tsStdForgeImport = getTsStdForgeImport(program);
|
|
42
|
+
|
|
43
|
+
const services = sourceCode.parserServices;
|
|
44
|
+
|
|
45
|
+
const checker = services?.program?.getTypeChecker();
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether the `isRecord(...)` half of the rewrite would be dead code
|
|
49
|
+
* because the object already satisfies `hasKey`'s
|
|
50
|
+
* `R extends UnknownRecord` constraint. Without type information nothing
|
|
51
|
+
* is known, so the guard is kept.
|
|
52
|
+
*/
|
|
53
|
+
const isKnownRecord = (
|
|
54
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
55
|
+
expression: TSESTree.Expression,
|
|
56
|
+
): boolean => {
|
|
57
|
+
if (checker === undefined) return false;
|
|
58
|
+
|
|
59
|
+
const tsNode = services?.esTreeNodeToTSNodeMap?.get(expression);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
tsNode !== undefined &&
|
|
63
|
+
isAlreadyRecordType(checker, checker.getTypeAtLocation(tsNode))
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const mut_nodesToFix: {
|
|
68
|
+
node: TSESTree.CallExpression | TSESTree.BinaryExpression;
|
|
69
|
+
objExpression: TSESTree.Expression;
|
|
70
|
+
keyExpression: TSESTree.Expression;
|
|
71
|
+
}[] = [];
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
// Handle Object.hasOwn(obj, key)
|
|
75
|
+
CallExpression: (node) => {
|
|
76
|
+
if (
|
|
77
|
+
node.callee.type !== AST_NODE_TYPES.MemberExpression ||
|
|
78
|
+
node.callee.object.type !== AST_NODE_TYPES.Identifier ||
|
|
79
|
+
node.callee.object.name !== 'Object' ||
|
|
80
|
+
node.callee.property.type !== AST_NODE_TYPES.Identifier ||
|
|
81
|
+
node.callee.property.name !== 'hasOwn'
|
|
82
|
+
) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Check arguments: Object.hasOwn(obj, key)
|
|
87
|
+
if (node.arguments.length !== 2) return;
|
|
88
|
+
|
|
89
|
+
const objArg = node.arguments[0];
|
|
90
|
+
|
|
91
|
+
const keyArg = node.arguments[1];
|
|
92
|
+
|
|
93
|
+
if (objArg === undefined || keyArg === undefined) return;
|
|
94
|
+
|
|
95
|
+
// Type guard: ensure arguments are Expressions (not SpreadElement)
|
|
96
|
+
if (
|
|
97
|
+
objArg.type === AST_NODE_TYPES.SpreadElement ||
|
|
98
|
+
keyArg.type === AST_NODE_TYPES.SpreadElement
|
|
99
|
+
) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
mut_nodesToFix.push({
|
|
104
|
+
node,
|
|
105
|
+
objExpression: objArg,
|
|
106
|
+
keyExpression: keyArg,
|
|
107
|
+
});
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
// Handle key in obj
|
|
111
|
+
BinaryExpression: (node) => {
|
|
112
|
+
if (node.operator !== 'in') return;
|
|
113
|
+
|
|
114
|
+
const keyExpression = node.left;
|
|
115
|
+
|
|
116
|
+
const objExpression = node.right;
|
|
117
|
+
|
|
118
|
+
// Type guard: ensure keyExpression is Expression (not PrivateIdentifier)
|
|
119
|
+
if (keyExpression.type === AST_NODE_TYPES.PrivateIdentifier) return;
|
|
120
|
+
|
|
121
|
+
mut_nodesToFix.push({
|
|
122
|
+
node,
|
|
123
|
+
objExpression,
|
|
124
|
+
keyExpression,
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
'Program:exit': () => {
|
|
129
|
+
const namedImports = getNamedImports(tsStdForgeImport);
|
|
130
|
+
|
|
131
|
+
const rewrites = mut_nodesToFix.map((entry) => ({
|
|
132
|
+
...entry,
|
|
133
|
+
// A record-typed object makes the `isRecord(...)` conjunct always
|
|
134
|
+
// true, so it is left out of the replacement entirely.
|
|
135
|
+
needsIsRecord: !isKnownRecord(entry.objExpression),
|
|
136
|
+
}));
|
|
137
|
+
|
|
138
|
+
const importsToAdd: readonly string[] = [
|
|
139
|
+
...(rewrites.some(({ needsIsRecord }) => needsIsRecord) &&
|
|
140
|
+
!namedImports.includes('isRecord')
|
|
141
|
+
? ['isRecord']
|
|
142
|
+
: []),
|
|
143
|
+
...(namedImports.includes('hasKey') ? [] : ['hasKey']),
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
for (const [
|
|
147
|
+
index,
|
|
148
|
+
{ node, objExpression, keyExpression, needsIsRecord },
|
|
149
|
+
] of rewrites.entries()) {
|
|
150
|
+
const objText = sourceCode.getText(objExpression);
|
|
151
|
+
|
|
152
|
+
const keyText = sourceCode.getText(keyExpression);
|
|
153
|
+
|
|
154
|
+
const originalText = sourceCode.getText(node);
|
|
155
|
+
|
|
156
|
+
context.report({
|
|
157
|
+
node,
|
|
158
|
+
messageId: needsIsRecord ? 'useIsRecordAndHasKey' : 'useHasKey',
|
|
159
|
+
data: {
|
|
160
|
+
original: originalText,
|
|
161
|
+
objName: objText,
|
|
162
|
+
keyName: keyText,
|
|
163
|
+
},
|
|
164
|
+
fix: (fixer) => {
|
|
165
|
+
const replacement = needsIsRecord
|
|
166
|
+
? `(isRecord(${objText}) && hasKey(${objText}, ${keyText}))`
|
|
167
|
+
: `hasKey(${objText}, ${keyText})`;
|
|
168
|
+
|
|
169
|
+
// All import fixes have to live on the first report:
|
|
170
|
+
// `insertTextBefore(program, …)` produces overlapping ranges
|
|
171
|
+
// otherwise and only one of them would survive a pass.
|
|
172
|
+
const importFixes =
|
|
173
|
+
index === 0 && importsToAdd.length > 0
|
|
174
|
+
? buildImportFixes(fixer, program, importsToAdd)
|
|
175
|
+
: [];
|
|
176
|
+
|
|
177
|
+
return [...importFixes, fixer.replaceText(node, replacement)];
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
},
|
|
184
|
+
defaultOptions: [],
|
|
185
|
+
} as const;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Whether `type` already satisfies `hasKey`'s `R extends UnknownRecord`
|
|
189
|
+
* constraint, so that guarding the call with `isRecord` adds nothing.
|
|
190
|
+
*
|
|
191
|
+
* `UnknownRecord` is `ReadonlyRecord<string, unknown>`, so a string index
|
|
192
|
+
* signature is exactly what it asks for and its value type is `unknown`, which
|
|
193
|
+
* anything satisfies. Callables and arrays are excluded because `isRecord`
|
|
194
|
+
* rejects them at runtime (`typeof fn === 'object'` is false, `Array.isArray`
|
|
195
|
+
* is true), so dropping the guard there would change behavior.
|
|
196
|
+
*
|
|
197
|
+
* Deliberately conservative: a type TypeScript would accept through an
|
|
198
|
+
* *implicit* index signature (an object type alias without one of its own)
|
|
199
|
+
* keeps the guard, because the checker does not report such a signature here.
|
|
200
|
+
*/
|
|
201
|
+
const isAlreadyRecordType = (
|
|
202
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
203
|
+
checker: ts.TypeChecker,
|
|
204
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
205
|
+
type: ts.Type,
|
|
206
|
+
): boolean =>
|
|
207
|
+
type.isUnion()
|
|
208
|
+
? type.types.every((member) => isAlreadyRecordType(checker, member))
|
|
209
|
+
: checker.getIndexInfoOfType(type, ts.IndexKind.String) !== undefined &&
|
|
210
|
+
SafeArray.isEmpty(
|
|
211
|
+
checker.getSignaturesOfType(type, ts.SignatureKind.Call),
|
|
212
|
+
) &&
|
|
213
|
+
SafeArray.isEmpty(
|
|
214
|
+
checker.getSignaturesOfType(type, ts.SignatureKind.Construct),
|
|
215
|
+
) &&
|
|
216
|
+
!checker.isArrayType(type) &&
|
|
217
|
+
!checker.isTupleType(type);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import parser from '@typescript-eslint/parser';
|
|
2
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
3
|
+
import dedent from 'dedent';
|
|
4
|
+
import { preferIsRecordAndHasKey } from './prefer-is-record-and-has-key.mjs';
|
|
5
|
+
|
|
6
|
+
const tester = new RuleTester({
|
|
7
|
+
languageOptions: {
|
|
8
|
+
parser,
|
|
9
|
+
parserOptions: {
|
|
10
|
+
ecmaVersion: 2020,
|
|
11
|
+
sourceType: 'module',
|
|
12
|
+
projectService: {
|
|
13
|
+
allowDefaultProject: ['*.ts*'],
|
|
14
|
+
},
|
|
15
|
+
tsconfigRootDir: `${import.meta.dirname}/../..`,
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('prefer-is-record-and-has-key', () => {
|
|
21
|
+
tester.run('prefer-is-record-and-has-key', preferIsRecordAndHasKey, {
|
|
22
|
+
valid: [
|
|
23
|
+
{
|
|
24
|
+
name: 'ignores other Object methods',
|
|
25
|
+
code: dedent`
|
|
26
|
+
const obj = { a: 1 };
|
|
27
|
+
const keys = Object.keys(obj);
|
|
28
|
+
`,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'ignores hasOwn with the wrong number of arguments',
|
|
32
|
+
code: dedent`
|
|
33
|
+
const obj = { a: 1 };
|
|
34
|
+
const ok = Object.hasOwn(obj);
|
|
35
|
+
`,
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
invalid: [
|
|
39
|
+
{
|
|
40
|
+
name: 'rewrites Object.hasOwn on an unknown object, keeping isRecord',
|
|
41
|
+
code: dedent`
|
|
42
|
+
declare const obj: unknown;
|
|
43
|
+
const ok = Object.hasOwn(obj, 'a');
|
|
44
|
+
`,
|
|
45
|
+
output: dedent`
|
|
46
|
+
import { isRecord, hasKey } from 'ts-std-forge';
|
|
47
|
+
declare const obj: unknown;
|
|
48
|
+
const ok = (isRecord(obj) && hasKey(obj, 'a'));
|
|
49
|
+
`,
|
|
50
|
+
errors: [{ messageId: 'useIsRecordAndHasKey' }],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'drops isRecord when the object already has a string index signature',
|
|
54
|
+
code: dedent`
|
|
55
|
+
declare const obj: Readonly<Record<string, unknown>>;
|
|
56
|
+
const ok = Object.hasOwn(obj, 'a');
|
|
57
|
+
`,
|
|
58
|
+
output: dedent`
|
|
59
|
+
import { hasKey } from 'ts-std-forge';
|
|
60
|
+
declare const obj: Readonly<Record<string, unknown>>;
|
|
61
|
+
const ok = hasKey(obj, 'a');
|
|
62
|
+
`,
|
|
63
|
+
errors: [{ messageId: 'useHasKey' }],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'rewrites `key in obj` too',
|
|
67
|
+
code: dedent`
|
|
68
|
+
declare const obj: Record<string, number>;
|
|
69
|
+
const ok = 'a' in obj;
|
|
70
|
+
`,
|
|
71
|
+
output: dedent`
|
|
72
|
+
import { hasKey } from 'ts-std-forge';
|
|
73
|
+
declare const obj: Record<string, number>;
|
|
74
|
+
const ok = hasKey(obj, 'a');
|
|
75
|
+
`,
|
|
76
|
+
errors: [{ messageId: 'useHasKey' }],
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AST_NODE_TYPES,
|
|
3
|
+
type TSESLint,
|
|
4
|
+
type TSESTree,
|
|
5
|
+
} from '@typescript-eslint/utils';
|
|
6
|
+
import { type DeepReadonly } from 'ts-type-forge';
|
|
7
|
+
import {
|
|
8
|
+
buildImportFixes,
|
|
9
|
+
getNamedImports,
|
|
10
|
+
getTsStdForgeImport,
|
|
11
|
+
} from './import-utils.mjs';
|
|
12
|
+
|
|
13
|
+
type Options = readonly [];
|
|
14
|
+
|
|
15
|
+
type MessageIds = 'useSafeArrayIsArray';
|
|
16
|
+
|
|
17
|
+
export const preferSafeArrayIsArray: TSESLint.RuleModule<MessageIds, Options> =
|
|
18
|
+
{
|
|
19
|
+
meta: {
|
|
20
|
+
type: 'suggestion',
|
|
21
|
+
docs: {
|
|
22
|
+
description:
|
|
23
|
+
'Replace `Array.isArray` with `SafeArray.isArray` from ts-std-forge.',
|
|
24
|
+
},
|
|
25
|
+
fixable: 'code',
|
|
26
|
+
schema: [],
|
|
27
|
+
messages: {
|
|
28
|
+
useSafeArrayIsArray:
|
|
29
|
+
'Replace `Array.isArray` with `SafeArray.isArray` from ts-std-forge.',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
create: (context) => {
|
|
34
|
+
const sourceCode = context.sourceCode;
|
|
35
|
+
|
|
36
|
+
const program = sourceCode.ast;
|
|
37
|
+
|
|
38
|
+
const tsStdForgeImport = getTsStdForgeImport(program);
|
|
39
|
+
|
|
40
|
+
const mut_nodesToFix: TSESTree.CallExpression[] = [];
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
CallExpression: (node) => {
|
|
44
|
+
if (!isArrayIsArrayCall(node)) return;
|
|
45
|
+
|
|
46
|
+
mut_nodesToFix.push(node);
|
|
47
|
+
},
|
|
48
|
+
'Program:exit': () => {
|
|
49
|
+
const hasSafeArrayImport =
|
|
50
|
+
getNamedImports(tsStdForgeImport).includes('SafeArray');
|
|
51
|
+
|
|
52
|
+
// The import fix lives on the first report only:
|
|
53
|
+
// `insertTextBefore(program, …)` produces overlapping ranges
|
|
54
|
+
// otherwise and only one of them would survive a pass. A first
|
|
55
|
+
// report silenced by an `eslint-disable` comment therefore takes
|
|
56
|
+
// the import with it.
|
|
57
|
+
for (const [index, node] of mut_nodesToFix.entries()) {
|
|
58
|
+
context.report({
|
|
59
|
+
node,
|
|
60
|
+
messageId: 'useSafeArrayIsArray',
|
|
61
|
+
fix: (fixer) => {
|
|
62
|
+
const callee = node.callee;
|
|
63
|
+
|
|
64
|
+
if (callee.type !== AST_NODE_TYPES.MemberExpression) return [];
|
|
65
|
+
|
|
66
|
+
const replacement = `SafeArray.isArray${sourceCode.getText(node).slice(sourceCode.getText(callee).length)}`;
|
|
67
|
+
|
|
68
|
+
const importFixes =
|
|
69
|
+
index === 0 && !hasSafeArrayImport
|
|
70
|
+
? buildImportFixes(fixer, program, ['SafeArray'])
|
|
71
|
+
: [];
|
|
72
|
+
|
|
73
|
+
return [...importFixes, fixer.replaceText(node, replacement)];
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
defaultOptions: [],
|
|
81
|
+
} as const;
|
|
82
|
+
|
|
83
|
+
const isArrayIsArrayCall = (
|
|
84
|
+
node: DeepReadonly<TSESTree.CallExpression>,
|
|
85
|
+
): boolean => {
|
|
86
|
+
if (node.callee.type !== AST_NODE_TYPES.MemberExpression) return false;
|
|
87
|
+
|
|
88
|
+
const { object, property } = node.callee;
|
|
89
|
+
|
|
90
|
+
if (object.type !== AST_NODE_TYPES.Identifier) return false;
|
|
91
|
+
|
|
92
|
+
if (object.name !== 'Array') return false;
|
|
93
|
+
|
|
94
|
+
if (property.type !== AST_NODE_TYPES.Identifier) return false;
|
|
95
|
+
|
|
96
|
+
if (property.name !== 'isArray') return false;
|
|
97
|
+
|
|
98
|
+
return true;
|
|
99
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
2
|
+
import dedent from 'dedent';
|
|
3
|
+
import { preferSafeArrayIsArray } from './prefer-safe-array-is-array.mjs';
|
|
4
|
+
|
|
5
|
+
const ruleTester = new RuleTester();
|
|
6
|
+
|
|
7
|
+
ruleTester.run('prefer-safe-array-is-array', preferSafeArrayIsArray, {
|
|
8
|
+
valid: [
|
|
9
|
+
{
|
|
10
|
+
name: 'Already using SafeArray.isArray',
|
|
11
|
+
code: dedent`
|
|
12
|
+
import { SafeArray } from 'ts-std-forge';
|
|
13
|
+
const result = SafeArray.isArray(value);
|
|
14
|
+
`,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: 'Not Array.isArray call',
|
|
18
|
+
code: dedent`
|
|
19
|
+
const obj = { isArray: () => true };
|
|
20
|
+
const result = obj.isArray(value);
|
|
21
|
+
`,
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
invalid: [
|
|
25
|
+
{
|
|
26
|
+
name: 'Replace Array.isArray with SafeArray.isArray',
|
|
27
|
+
code: dedent`
|
|
28
|
+
const result = Array.isArray(value);
|
|
29
|
+
`,
|
|
30
|
+
output: dedent`
|
|
31
|
+
import { SafeArray } from 'ts-std-forge';
|
|
32
|
+
const result = SafeArray.isArray(value);
|
|
33
|
+
`,
|
|
34
|
+
errors: [{ messageId: 'useSafeArrayIsArray' }],
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'Replace Array.isArray when SafeArray is already imported',
|
|
38
|
+
code: dedent`
|
|
39
|
+
import { SafeArray } from 'ts-std-forge';
|
|
40
|
+
const result = Array.isArray(value);
|
|
41
|
+
`,
|
|
42
|
+
output: dedent`
|
|
43
|
+
import { SafeArray } from 'ts-std-forge';
|
|
44
|
+
const result = SafeArray.isArray(value);
|
|
45
|
+
`,
|
|
46
|
+
errors: [{ messageId: 'useSafeArrayIsArray' }],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'Replace multiple Array.isArray calls, importing once',
|
|
50
|
+
code: dedent`
|
|
51
|
+
const result1 = Array.isArray(value1);
|
|
52
|
+
const result2 = Array.isArray(value2);
|
|
53
|
+
`,
|
|
54
|
+
output: dedent`
|
|
55
|
+
import { SafeArray } from 'ts-std-forge';
|
|
56
|
+
const result1 = SafeArray.isArray(value1);
|
|
57
|
+
const result2 = SafeArray.isArray(value2);
|
|
58
|
+
`,
|
|
59
|
+
errors: [
|
|
60
|
+
{ messageId: 'useSafeArrayIsArray' },
|
|
61
|
+
{ messageId: 'useSafeArrayIsArray' },
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
});
|