eslint-plugin-ts-data-forge 0.2.0 → 0.4.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 +16 -14
- package/dist/plugin.d.mts +3 -1
- package/dist/plugin.d.mts.map +1 -1
- package/dist/plugin.mjs +1 -0
- package/dist/plugin.mjs.map +1 -1
- package/dist/rule-types.d.mts +16 -1
- package/dist/rule-types.d.mts.map +1 -1
- package/dist/rules/import-utils.mjs.map +1 -1
- package/dist/rules/no-unnecessary-type-guard.d.mts.map +1 -1
- package/dist/rules/no-unnecessary-type-guard.mjs +13 -1
- package/dist/rules/no-unnecessary-type-guard.mjs.map +1 -1
- package/dist/rules/prefer-arr-is-bounded-length-array.mjs +1 -1
- package/dist/rules/prefer-arr-is-bounded-length-array.mjs.map +1 -1
- package/dist/rules/prefer-arr-is-fixed-length-array.mjs +1 -1
- package/dist/rules/prefer-arr-is-fixed-length-array.mjs.map +1 -1
- package/dist/rules/prefer-arr-is-max-length-array.mjs +1 -1
- package/dist/rules/prefer-arr-is-max-length-array.mjs.map +1 -1
- package/dist/rules/prefer-arr-is-min-length-array.mjs +1 -1
- package/dist/rules/prefer-arr-is-min-length-array.mjs.map +1 -1
- package/dist/rules/prefer-arr-sum.mjs +2 -2
- package/dist/rules/prefer-arr-sum.mjs.map +1 -1
- package/dist/rules/prefer-canonical-array-slicing.mjs.map +1 -1
- package/dist/rules/prefer-canonical-length-cast.d.mts +6 -0
- package/dist/rules/prefer-canonical-length-cast.d.mts.map +1 -0
- package/dist/rules/prefer-canonical-length-cast.mjs +155 -0
- package/dist/rules/prefer-canonical-length-cast.mjs.map +1 -0
- package/dist/rules/prefer-canonical-length-guard.d.mts.map +1 -1
- package/dist/rules/prefer-canonical-length-guard.mjs +12 -8
- package/dist/rules/prefer-canonical-length-guard.mjs.map +1 -1
- package/dist/rules/prefer-is-record-and-has-key.d.mts +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 +30 -16
- package/dist/rules/prefer-is-record-and-has-key.mjs.map +1 -1
- package/dist/rules/prefer-range-for-loop.mjs +1 -1
- package/dist/rules/prefer-range-for-loop.mjs.map +1 -1
- package/dist/rules/rules.d.mts +2 -1
- package/dist/rules/rules.d.mts.map +1 -1
- package/dist/rules/rules.mjs +2 -0
- package/dist/rules/rules.mjs.map +1 -1
- package/package.json +6 -6
- package/src/plugin.mts +1 -0
- package/src/rule-types.mts +17 -1
- package/src/rules/import-utils.mts +0 -1
- package/src/rules/no-unnecessary-type-guard.mts +65 -0
- package/src/rules/no-unnecessary-type-guard.test.mts +94 -0
- package/src/rules/prefer-arr-is-bounded-length-array.mts +1 -1
- package/src/rules/prefer-arr-is-bounded-length-array.test.mts +5 -5
- package/src/rules/prefer-arr-is-fixed-length-array.mts +1 -1
- package/src/rules/prefer-arr-is-fixed-length-array.test.mts +11 -11
- package/src/rules/prefer-arr-is-max-length-array.mts +1 -1
- package/src/rules/prefer-arr-is-max-length-array.test.mts +8 -8
- package/src/rules/prefer-arr-is-min-length-array.mts +1 -1
- package/src/rules/prefer-arr-is-min-length-array.test.mts +9 -9
- package/src/rules/prefer-arr-sum.mts +2 -2
- package/src/rules/prefer-canonical-array-slicing.mts +0 -2
- package/src/rules/prefer-canonical-length-cast.mts +290 -0
- package/src/rules/prefer-canonical-length-cast.test.mts +305 -0
- package/src/rules/prefer-canonical-length-guard.mts +40 -21
- package/src/rules/prefer-canonical-length-guard.test.mts +32 -32
- package/src/rules/prefer-is-record-and-has-key.mts +84 -20
- package/src/rules/prefer-is-record-and-has-key.test.mts +74 -0
- package/src/rules/prefer-range-for-loop.mts +1 -1
- package/src/rules/rules.mts +2 -0
|
@@ -3,6 +3,8 @@ import {
|
|
|
3
3
|
type TSESLint,
|
|
4
4
|
type TSESTree,
|
|
5
5
|
} from '@typescript-eslint/utils';
|
|
6
|
+
import { Arr } from 'ts-data-forge';
|
|
7
|
+
import * as ts from 'typescript';
|
|
6
8
|
import {
|
|
7
9
|
buildImportFixes,
|
|
8
10
|
getNamedImports,
|
|
@@ -11,7 +13,7 @@ import {
|
|
|
11
13
|
|
|
12
14
|
type Options = readonly [];
|
|
13
15
|
|
|
14
|
-
type MessageIds = 'useIsRecordAndHasKey';
|
|
16
|
+
type MessageIds = 'useIsRecordAndHasKey' | 'useHasKey';
|
|
15
17
|
|
|
16
18
|
export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
|
|
17
19
|
{
|
|
@@ -26,6 +28,8 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
|
|
|
26
28
|
messages: {
|
|
27
29
|
useIsRecordAndHasKey:
|
|
28
30
|
'Replace `{{original}}` with `isRecord({{objName}}) && hasKey({{objName}}, {{keyName}})` from ts-data-forge.',
|
|
31
|
+
useHasKey:
|
|
32
|
+
'Replace `{{original}}` with `hasKey({{objName}}, {{keyName}})` from ts-data-forge.',
|
|
29
33
|
},
|
|
30
34
|
},
|
|
31
35
|
|
|
@@ -36,6 +40,30 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
|
|
|
36
40
|
|
|
37
41
|
const tsDataForgeImport = getTsDataForgeImport(program);
|
|
38
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
|
+
|
|
39
67
|
const mut_nodesToFix: {
|
|
40
68
|
node: TSESTree.CallExpression | TSESTree.BinaryExpression;
|
|
41
69
|
objExpression: TSESTree.Expression;
|
|
@@ -100,14 +128,25 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
|
|
|
100
128
|
'Program:exit': () => {
|
|
101
129
|
const namedImports = getNamedImports(tsDataForgeImport);
|
|
102
130
|
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
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
|
+
];
|
|
106
145
|
|
|
107
146
|
for (const [
|
|
108
147
|
index,
|
|
109
|
-
{ node, objExpression, keyExpression },
|
|
110
|
-
] of
|
|
148
|
+
{ node, objExpression, keyExpression, needsIsRecord },
|
|
149
|
+
] of rewrites.entries()) {
|
|
111
150
|
const objText = sourceCode.getText(objExpression);
|
|
112
151
|
|
|
113
152
|
const keyText = sourceCode.getText(keyExpression);
|
|
@@ -116,32 +155,27 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
|
|
|
116
155
|
|
|
117
156
|
context.report({
|
|
118
157
|
node,
|
|
119
|
-
messageId: 'useIsRecordAndHasKey',
|
|
158
|
+
messageId: needsIsRecord ? 'useIsRecordAndHasKey' : 'useHasKey',
|
|
120
159
|
data: {
|
|
121
160
|
original: originalText,
|
|
122
161
|
objName: objText,
|
|
123
162
|
keyName: keyText,
|
|
124
163
|
},
|
|
125
164
|
fix: (fixer) => {
|
|
126
|
-
const replacement =
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (!hasIsRecordImport) {
|
|
131
|
-
mut_importsToAdd.push('isRecord');
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
if (!hasHasKeyImport) {
|
|
135
|
-
mut_importsToAdd.push('hasKey');
|
|
136
|
-
}
|
|
165
|
+
const replacement = needsIsRecord
|
|
166
|
+
? `(isRecord(${objText}) && hasKey(${objText}, ${keyText}))`
|
|
167
|
+
: `hasKey(${objText}, ${keyText})`;
|
|
137
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.
|
|
138
172
|
const importFixes =
|
|
139
|
-
index === 0 &&
|
|
173
|
+
index === 0 && importsToAdd.length > 0
|
|
140
174
|
? buildImportFixes(
|
|
141
175
|
fixer,
|
|
142
176
|
program,
|
|
143
177
|
tsDataForgeImport,
|
|
144
|
-
|
|
178
|
+
importsToAdd,
|
|
145
179
|
)
|
|
146
180
|
: [];
|
|
147
181
|
|
|
@@ -154,3 +188,33 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
|
|
|
154
188
|
},
|
|
155
189
|
defaultOptions: [],
|
|
156
190
|
} as const;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Whether `type` already satisfies `hasKey`'s `R extends UnknownRecord`
|
|
194
|
+
* constraint, so that guarding the call with `isRecord` adds nothing.
|
|
195
|
+
*
|
|
196
|
+
* `UnknownRecord` is `ReadonlyRecord<string, unknown>`, so a string index
|
|
197
|
+
* signature is exactly what it asks for and its value type is `unknown`, which
|
|
198
|
+
* anything satisfies. Callables and arrays are excluded because `isRecord`
|
|
199
|
+
* rejects them at runtime (`typeof fn === 'object'` is false, `Array.isArray`
|
|
200
|
+
* is true), so dropping the guard there would change behavior.
|
|
201
|
+
*
|
|
202
|
+
* Deliberately conservative: a type TypeScript would accept through an
|
|
203
|
+
* *implicit* index signature (an object type alias without one of its own)
|
|
204
|
+
* keeps the guard, because the checker does not report such a signature here.
|
|
205
|
+
*/
|
|
206
|
+
const isAlreadyRecordType = (
|
|
207
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
208
|
+
checker: ts.TypeChecker,
|
|
209
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
210
|
+
type: ts.Type,
|
|
211
|
+
): boolean =>
|
|
212
|
+
type.isUnion()
|
|
213
|
+
? type.types.every((member) => isAlreadyRecordType(checker, member))
|
|
214
|
+
: checker.getIndexInfoOfType(type, ts.IndexKind.String) !== undefined &&
|
|
215
|
+
Arr.isEmpty(checker.getSignaturesOfType(type, ts.SignatureKind.Call)) &&
|
|
216
|
+
Arr.isEmpty(
|
|
217
|
+
checker.getSignaturesOfType(type, ts.SignatureKind.Construct),
|
|
218
|
+
) &&
|
|
219
|
+
!checker.isArrayType(type) &&
|
|
220
|
+
!checker.isTupleType(type);
|
|
@@ -36,6 +36,80 @@ describe('prefer-is-record-and-has-key', () => {
|
|
|
36
36
|
},
|
|
37
37
|
],
|
|
38
38
|
invalid: [
|
|
39
|
+
{
|
|
40
|
+
name: 'drops isRecord when the object already has a string index signature',
|
|
41
|
+
code: dedent`
|
|
42
|
+
declare const obj: Readonly<Record<string, unknown>>;
|
|
43
|
+
const ok = Object.hasOwn(obj, 'a');
|
|
44
|
+
`,
|
|
45
|
+
output: dedent`
|
|
46
|
+
import { hasKey } from 'ts-data-forge';
|
|
47
|
+
declare const obj: Readonly<Record<string, unknown>>;
|
|
48
|
+
const ok = hasKey(obj, 'a');
|
|
49
|
+
`,
|
|
50
|
+
errors: [{ messageId: 'useHasKey' }],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'drops isRecord for `key in obj` on a record-typed object',
|
|
54
|
+
code: dedent`
|
|
55
|
+
declare const obj: Record<string, number>;
|
|
56
|
+
const ok = 'a' in obj;
|
|
57
|
+
`,
|
|
58
|
+
output: dedent`
|
|
59
|
+
import { hasKey } from 'ts-data-forge';
|
|
60
|
+
declare const obj: Record<string, number>;
|
|
61
|
+
const ok = hasKey(obj, 'a');
|
|
62
|
+
`,
|
|
63
|
+
errors: [{ messageId: 'useHasKey' }],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'keeps isRecord when only part of a union is a record',
|
|
67
|
+
code: dedent`
|
|
68
|
+
declare const obj: Record<string, unknown> | readonly unknown[];
|
|
69
|
+
const ok = Object.hasOwn(obj, 'a');
|
|
70
|
+
`,
|
|
71
|
+
output: dedent`
|
|
72
|
+
import { isRecord, hasKey } from 'ts-data-forge';
|
|
73
|
+
declare const obj: Record<string, unknown> | readonly unknown[];
|
|
74
|
+
const ok = (isRecord(obj) && hasKey(obj, 'a'));
|
|
75
|
+
`,
|
|
76
|
+
errors: [{ messageId: 'useIsRecordAndHasKey' }],
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'keeps isRecord for an index-signature-carrying callable',
|
|
80
|
+
code: dedent`
|
|
81
|
+
type Callable = { (): void; [key: string]: unknown };
|
|
82
|
+
declare const obj: Callable;
|
|
83
|
+
const ok = Object.hasOwn(obj, 'a');
|
|
84
|
+
`,
|
|
85
|
+
output: dedent`
|
|
86
|
+
import { isRecord, hasKey } from 'ts-data-forge';
|
|
87
|
+
type Callable = { (): void; [key: string]: unknown };
|
|
88
|
+
declare const obj: Callable;
|
|
89
|
+
const ok = (isRecord(obj) && hasKey(obj, 'a'));
|
|
90
|
+
`,
|
|
91
|
+
errors: [{ messageId: 'useIsRecordAndHasKey' }],
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: 'imports isRecord once when both forms appear in a file',
|
|
95
|
+
code: dedent`
|
|
96
|
+
declare const rec: Record<string, unknown>;
|
|
97
|
+
declare const unk: unknown;
|
|
98
|
+
const a = Object.hasOwn(rec, 'a');
|
|
99
|
+
const b = Object.hasOwn(unk, 'b');
|
|
100
|
+
`,
|
|
101
|
+
output: dedent`
|
|
102
|
+
import { isRecord, hasKey } from 'ts-data-forge';
|
|
103
|
+
declare const rec: Record<string, unknown>;
|
|
104
|
+
declare const unk: unknown;
|
|
105
|
+
const a = hasKey(rec, 'a');
|
|
106
|
+
const b = (isRecord(unk) && hasKey(unk, 'b'));
|
|
107
|
+
`,
|
|
108
|
+
errors: [
|
|
109
|
+
{ messageId: 'useHasKey' },
|
|
110
|
+
{ messageId: 'useIsRecordAndHasKey' },
|
|
111
|
+
],
|
|
112
|
+
},
|
|
39
113
|
{
|
|
40
114
|
name: 'replaces Object.hasOwn(obj, key) with isRecord && hasKey',
|
|
41
115
|
code: dedent`
|
|
@@ -50,7 +50,7 @@ export const preferRangeForLoop: TSESLint.RuleModule<MessageIds, Options> = {
|
|
|
50
50
|
if (
|
|
51
51
|
node.init?.type !== AST_NODE_TYPES.VariableDeclaration ||
|
|
52
52
|
node.init.kind !== 'let' ||
|
|
53
|
-
!Arr.isFixedLengthTuple(node.init.declarations
|
|
53
|
+
!Arr.isFixedLengthTuple(1, node.init.declarations)
|
|
54
54
|
) {
|
|
55
55
|
return;
|
|
56
56
|
}
|
package/src/rules/rules.mts
CHANGED
|
@@ -4,6 +4,7 @@ import { preferArrIsArray } from './prefer-arr-is-array.mjs';
|
|
|
4
4
|
import { preferArrSum } from './prefer-arr-sum.mjs';
|
|
5
5
|
import { preferAsInt } from './prefer-as-int.mjs';
|
|
6
6
|
import { preferCanonicalArraySlicing } from './prefer-canonical-array-slicing.mjs';
|
|
7
|
+
import { preferCanonicalLengthCast } from './prefer-canonical-length-cast.mjs';
|
|
7
8
|
import { preferCanonicalLengthGuard } from './prefer-canonical-length-guard.mjs';
|
|
8
9
|
import { preferComparisonOverNullishGuard } from './prefer-comparison-over-nullish-guard.mjs';
|
|
9
10
|
import { preferIsNonNullObject } from './prefer-is-non-null-object.mjs';
|
|
@@ -15,6 +16,7 @@ import { preferRangeForLoop } from './prefer-range-for-loop.mjs';
|
|
|
15
16
|
export const tsDataForgeRules = {
|
|
16
17
|
'prefer-canonical-array-slicing': preferCanonicalArraySlicing,
|
|
17
18
|
'prefer-canonical-length-guard': preferCanonicalLengthGuard,
|
|
19
|
+
'prefer-canonical-length-cast': preferCanonicalLengthCast,
|
|
18
20
|
'prefer-arr-is-array': preferArrIsArray,
|
|
19
21
|
'prefer-arr-sum': preferArrSum,
|
|
20
22
|
'prefer-as-int': preferAsInt,
|