eslint-plugin-ts-std-forge 0.1.8 → 0.1.10
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/dist/rule-types.d.mts +3 -2
- package/dist/rule-types.d.mts.map +1 -1
- package/dist/rule-types.mjs.map +1 -1
- package/dist/rules/ast-utils.d.mts +8 -5
- package/dist/rules/ast-utils.d.mts.map +1 -1
- package/dist/rules/ast-utils.mjs +14 -13
- package/dist/rules/ast-utils.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 +113 -38
- 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 +95 -34
- package/dist/rules/prefer-is-record-and-has-key.mjs.map +1 -1
- package/dist/rules/prefer-safe-array-length-guard.d.mts.map +1 -1
- package/dist/rules/prefer-safe-array-length-guard.mjs +4 -10
- package/dist/rules/prefer-safe-array-length-guard.mjs.map +1 -1
- package/dist/rules/prefer-safe-number-parse-integer.mjs +4 -3
- package/dist/rules/prefer-safe-number-parse-integer.mjs.map +1 -1
- package/dist/rules/prefer-safe-number-parse.mjs +4 -3
- package/dist/rules/prefer-safe-number-parse.mjs.map +1 -1
- package/package.json +10 -10
- package/src/rule-types.mts +3 -2
- package/src/rules/ast-utils.mts +20 -21
- package/src/rules/prefer-is-non-null-object.mts +152 -49
- package/src/rules/prefer-is-non-null-object.test.mts +107 -0
- package/src/rules/prefer-is-record-and-has-key.mts +141 -53
- package/src/rules/prefer-is-record-and-has-key.test.mts +116 -29
- package/src/rules/prefer-safe-array-length-guard.mts +11 -16
- package/src/rules/prefer-safe-array-length-guard.test.mts +19 -5
- package/src/rules/prefer-safe-number-parse-integer.mts +8 -8
- package/src/rules/prefer-safe-number-parse-integer.test.mts +19 -5
- package/src/rules/prefer-safe-number-parse.mts +8 -8
- package/src/rules/prefer-safe-number-parse.test.mts +19 -5
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
} from 'eslint-rule-forge';
|
|
13
13
|
import { SafeArray } from 'ts-std-forge';
|
|
14
14
|
import * as ts from 'typescript';
|
|
15
|
-
import {
|
|
15
|
+
import { isIdentifierNamed, typeHoldsAtEveryLayer } from './ast-utils.mjs';
|
|
16
16
|
import { TS_STD_FORGE_MODULE } from './constants.mjs';
|
|
17
17
|
|
|
18
18
|
type Options = readonly [];
|
|
@@ -47,27 +47,40 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
|
|
|
47
47
|
TS_STD_FORGE_MODULE,
|
|
48
48
|
);
|
|
49
49
|
|
|
50
|
-
const checker = sourceCode.parserServices?.program?.getTypeChecker();
|
|
51
|
-
|
|
52
50
|
/**
|
|
53
51
|
* Whether the `isRecord(...)` half of the rewrite would be dead code
|
|
54
52
|
* because the object already satisfies `hasKey`'s
|
|
55
|
-
* `R extends UnknownRecord` constraint
|
|
56
|
-
*
|
|
57
|
-
*
|
|
53
|
+
* `R extends UnknownRecord` constraint, at every layer of type
|
|
54
|
+
* wrappers: an array cast to a record is still rejected by `isRecord`,
|
|
55
|
+
* and a record widened to one that may be an array is the author saying
|
|
56
|
+
* it may need the guard. Without type information nothing is known, so
|
|
57
|
+
* the guard is kept.
|
|
58
58
|
*/
|
|
59
59
|
const isKnownRecord = (
|
|
60
60
|
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
61
61
|
expression: TSESTree.Expression,
|
|
62
|
-
): boolean =>
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const type = getValueType(sourceCode, expression);
|
|
62
|
+
): boolean =>
|
|
63
|
+
typeHoldsAtEveryLayer(sourceCode, expression, (type, checker) =>
|
|
64
|
+
isAlreadyRecordType(checker, type),
|
|
65
|
+
);
|
|
68
66
|
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Whether the object may be an array at some layer of type wrappers.
|
|
69
|
+
* `isRecord` rejects an array, so `'0' in xs` would turn from `true` to
|
|
70
|
+
* `false`; a widening cast to a type that may be an array is the author
|
|
71
|
+
* saying it may be one. Without type information nothing is known, and
|
|
72
|
+
* the rule reports as it always has.
|
|
73
|
+
*/
|
|
74
|
+
const mayBeArray = (
|
|
75
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
76
|
+
expression: TSESTree.Expression,
|
|
77
|
+
): boolean =>
|
|
78
|
+
sourceCode.parserServices?.program != null &&
|
|
79
|
+
!typeHoldsAtEveryLayer(
|
|
80
|
+
sourceCode,
|
|
81
|
+
expression,
|
|
82
|
+
(type, checker) => !mayBeArrayType(checker, type),
|
|
83
|
+
);
|
|
71
84
|
|
|
72
85
|
const mut_nodesToFix: {
|
|
73
86
|
node: TSESTree.CallExpression | TSESTree.BinaryExpression;
|
|
@@ -105,7 +118,8 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
|
|
|
105
118
|
// Type guard: ensure arguments are Expressions (not SpreadElement)
|
|
106
119
|
if (
|
|
107
120
|
objArg.type === AST_NODE_TYPES.SpreadElement ||
|
|
108
|
-
keyArg.type === AST_NODE_TYPES.SpreadElement
|
|
121
|
+
keyArg.type === AST_NODE_TYPES.SpreadElement ||
|
|
122
|
+
mayBeArray(objArg)
|
|
109
123
|
) {
|
|
110
124
|
return;
|
|
111
125
|
}
|
|
@@ -128,7 +142,10 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
|
|
|
128
142
|
const objExpression = node.right;
|
|
129
143
|
|
|
130
144
|
// Type guard: ensure keyExpression is Expression (not PrivateIdentifier)
|
|
131
|
-
if (
|
|
145
|
+
if (
|
|
146
|
+
keyExpression.type === AST_NODE_TYPES.PrivateIdentifier ||
|
|
147
|
+
mayBeArray(objExpression)
|
|
148
|
+
) {
|
|
132
149
|
return;
|
|
133
150
|
}
|
|
134
151
|
|
|
@@ -142,25 +159,40 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
|
|
|
142
159
|
'Program:exit': () => {
|
|
143
160
|
const namedImports = getNamedImports(tsStdForgeImports);
|
|
144
161
|
|
|
145
|
-
const rewrites = mut_nodesToFix.map((entry) =>
|
|
146
|
-
...entry,
|
|
162
|
+
const rewrites = mut_nodesToFix.map((entry) => {
|
|
147
163
|
// A record-typed object makes the `isRecord(...)` conjunct always
|
|
148
164
|
// true, so it is left out of the replacement entirely.
|
|
149
|
-
needsIsRecord
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
165
|
+
const needsIsRecord = !isKnownRecord(entry.objExpression);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
...entry,
|
|
169
|
+
needsIsRecord,
|
|
170
|
+
// `isRecord(obj) && hasKey(obj, key)` evaluates `obj` twice.
|
|
171
|
+
fixable: !needsIsRecord || isSideEffectFree(entry.objExpression),
|
|
172
|
+
};
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const fixableRewrites = rewrites.filter(({ fixable }) => fixable);
|
|
176
|
+
|
|
177
|
+
const importsToAdd: readonly string[] = SafeArray.isEmpty(
|
|
178
|
+
fixableRewrites,
|
|
179
|
+
)
|
|
180
|
+
? []
|
|
181
|
+
: [
|
|
182
|
+
...(fixableRewrites.some(
|
|
183
|
+
({ needsIsRecord }) => needsIsRecord,
|
|
184
|
+
) && !namedImports.includes('isRecord')
|
|
185
|
+
? ['isRecord']
|
|
186
|
+
: []),
|
|
187
|
+
...(namedImports.includes('hasKey') ? [] : ['hasKey']),
|
|
188
|
+
];
|
|
189
|
+
|
|
190
|
+
const importCarrier = fixableRewrites[0];
|
|
191
|
+
|
|
192
|
+
for (const rewrite of rewrites) {
|
|
193
|
+
const { node, objExpression, keyExpression, needsIsRecord } =
|
|
194
|
+
rewrite;
|
|
195
|
+
|
|
164
196
|
const objText = getArgumentText(sourceCode, objExpression);
|
|
165
197
|
|
|
166
198
|
const keyText = getArgumentText(sourceCode, keyExpression);
|
|
@@ -175,26 +207,32 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
|
|
|
175
207
|
objName: objText,
|
|
176
208
|
keyName: keyText,
|
|
177
209
|
},
|
|
178
|
-
fix:
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
210
|
+
fix: !rewrite.fixable
|
|
211
|
+
? undefined
|
|
212
|
+
: (fixer) => {
|
|
213
|
+
const replacement = needsIsRecord
|
|
214
|
+
? `(isRecord(${objText}) && hasKey(${objText}, ${keyText}))`
|
|
215
|
+
: `hasKey(${objText}, ${keyText})`;
|
|
216
|
+
|
|
217
|
+
// All import fixes have to live on the first fixable
|
|
218
|
+
// report: `insertTextBefore(program, …)` produces
|
|
219
|
+
// overlapping ranges otherwise and only one of them would
|
|
220
|
+
// survive a pass.
|
|
221
|
+
const importFixes =
|
|
222
|
+
rewrite === importCarrier && importsToAdd.length > 0
|
|
223
|
+
? buildNamedImportFix(
|
|
224
|
+
fixer,
|
|
225
|
+
program,
|
|
226
|
+
TS_STD_FORGE_MODULE,
|
|
227
|
+
importsToAdd,
|
|
228
|
+
)
|
|
229
|
+
: [];
|
|
230
|
+
|
|
231
|
+
return [
|
|
232
|
+
...importFixes,
|
|
233
|
+
fixer.replaceText(node, replacement),
|
|
234
|
+
];
|
|
235
|
+
},
|
|
198
236
|
});
|
|
199
237
|
}
|
|
200
238
|
},
|
|
@@ -203,6 +241,56 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
|
|
|
203
241
|
defaultOptions: [],
|
|
204
242
|
} as const;
|
|
205
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Whether reading `node` again has no side effects: a variable, `this`, or a
|
|
246
|
+
* non-computed property chain on one, type wrappers aside.
|
|
247
|
+
*/
|
|
248
|
+
const isSideEffectFree = (
|
|
249
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
250
|
+
wrapped: TSESTree.Node,
|
|
251
|
+
): boolean => {
|
|
252
|
+
const node = skipTypeWrappers(wrapped);
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
node.type === AST_NODE_TYPES.Identifier ||
|
|
256
|
+
node.type === AST_NODE_TYPES.ThisExpression ||
|
|
257
|
+
(node.type === AST_NODE_TYPES.MemberExpression &&
|
|
258
|
+
!node.computed &&
|
|
259
|
+
isSideEffectFree(node.object))
|
|
260
|
+
);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Whether a value of `type` may be an array or a tuple: some member of a union
|
|
265
|
+
* or an intersection is one, or the constraint of a deferred type is.
|
|
266
|
+
* `object` and `unknown` say nothing either way and are not counted.
|
|
267
|
+
*/
|
|
268
|
+
const mayBeArrayType = (
|
|
269
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
270
|
+
checker: ts.TypeChecker,
|
|
271
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
272
|
+
type: ts.Type,
|
|
273
|
+
): boolean => {
|
|
274
|
+
if (type.isUnionOrIntersection()) {
|
|
275
|
+
return type.types.some((member) => mayBeArrayType(checker, member));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (
|
|
279
|
+
(type.flags &
|
|
280
|
+
(ts.TypeFlags.TypeParameter |
|
|
281
|
+
ts.TypeFlags.IndexedAccess |
|
|
282
|
+
ts.TypeFlags.Conditional |
|
|
283
|
+
ts.TypeFlags.Substitution)) !==
|
|
284
|
+
0
|
|
285
|
+
) {
|
|
286
|
+
const constraint = checker.getBaseConstraintOfType(type);
|
|
287
|
+
|
|
288
|
+
return constraint !== undefined && mayBeArrayType(checker, constraint);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return checker.isArrayType(type) || checker.isTupleType(type);
|
|
292
|
+
};
|
|
293
|
+
|
|
206
294
|
/**
|
|
207
295
|
* Whether `type` already satisfies `hasKey`'s `R extends UnknownRecord`
|
|
208
296
|
* constraint, so that guarding the call with `isRecord` adds nothing.
|
|
@@ -83,21 +83,6 @@ describe('prefer-is-record-and-has-key through type wrappers', () => {
|
|
|
83
83
|
tester.run('prefer-is-record-and-has-key', preferIsRecordAndHasKey, {
|
|
84
84
|
valid: [],
|
|
85
85
|
invalid: [
|
|
86
|
-
{
|
|
87
|
-
// `isRecord` rejects an array, so dropping it on the cast's word
|
|
88
|
-
// would turn `false` into `true`.
|
|
89
|
-
name: 'keeps isRecord for an array cast to a record',
|
|
90
|
-
code: dedent`
|
|
91
|
-
declare const arr: string[];
|
|
92
|
-
const ok = Object.hasOwn(arr as unknown as Record<string, unknown>, '0');
|
|
93
|
-
`,
|
|
94
|
-
output: dedent`
|
|
95
|
-
import { isRecord, hasKey } from 'ts-std-forge';
|
|
96
|
-
declare const arr: string[];
|
|
97
|
-
const ok = (isRecord(arr as unknown as Record<string, unknown>) && hasKey(arr as unknown as Record<string, unknown>, '0'));
|
|
98
|
-
`,
|
|
99
|
-
errors: [{ messageId: 'useIsRecordAndHasKey' }],
|
|
100
|
-
},
|
|
101
86
|
{
|
|
102
87
|
name: 'keeps isRecord for an unknown value cast to a record in `in`',
|
|
103
88
|
code: dedent`
|
|
@@ -125,15 +110,29 @@ describe('prefer-is-record-and-has-key through type wrappers', () => {
|
|
|
125
110
|
errors: [{ messageId: 'useHasKey' }],
|
|
126
111
|
},
|
|
127
112
|
{
|
|
128
|
-
|
|
113
|
+
// `Object.hasOwn(undefined, 'a')` throws; `isRecord` answers false.
|
|
114
|
+
name: 'keeps isRecord for `!` on an optional record, which leaves undefined in place',
|
|
129
115
|
code: dedent`
|
|
130
116
|
declare const obj: Readonly<Record<string, unknown>> | undefined;
|
|
131
117
|
const ok = Object.hasOwn(obj!, 'a');
|
|
132
118
|
`,
|
|
133
119
|
output: dedent`
|
|
134
|
-
import { hasKey } from 'ts-std-forge';
|
|
120
|
+
import { isRecord, hasKey } from 'ts-std-forge';
|
|
135
121
|
declare const obj: Readonly<Record<string, unknown>> | undefined;
|
|
136
|
-
const ok = hasKey(obj!, 'a');
|
|
122
|
+
const ok = (isRecord(obj!) && hasKey(obj!, 'a'));
|
|
123
|
+
`,
|
|
124
|
+
errors: [{ messageId: 'useIsRecordAndHasKey' }],
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: 'drops isRecord for a record that is a record at every layer',
|
|
128
|
+
code: dedent`
|
|
129
|
+
declare const obj: Readonly<Record<string, unknown>>;
|
|
130
|
+
const ok = Object.hasOwn(obj! as Readonly<Record<string, unknown>>, 'a');
|
|
131
|
+
`,
|
|
132
|
+
output: dedent`
|
|
133
|
+
import { hasKey } from 'ts-std-forge';
|
|
134
|
+
declare const obj: Readonly<Record<string, unknown>>;
|
|
135
|
+
const ok = hasKey(obj! as Readonly<Record<string, unknown>>, 'a');
|
|
137
136
|
`,
|
|
138
137
|
errors: [{ messageId: 'useHasKey' }],
|
|
139
138
|
},
|
|
@@ -159,34 +158,39 @@ describe('prefer-is-record-and-has-key with parenthesized operands', () => {
|
|
|
159
158
|
valid: [],
|
|
160
159
|
invalid: [
|
|
161
160
|
{
|
|
162
|
-
name: '
|
|
161
|
+
name: 'a sequence object in `in`, read twice by the fix, gets no fix',
|
|
163
162
|
code: dedent`
|
|
164
163
|
declare const a: number;
|
|
165
164
|
declare const o: unknown;
|
|
166
165
|
const ok = 'k' in (a, o);
|
|
167
166
|
`,
|
|
168
|
-
output:
|
|
169
|
-
|
|
167
|
+
output: null,
|
|
168
|
+
errors: [{ messageId: 'useIsRecordAndHasKey' }],
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
name: 'a sequence object in Object.hasOwn, read twice by the fix, gets no fix',
|
|
172
|
+
code: dedent`
|
|
170
173
|
declare const a: number;
|
|
171
174
|
declare const o: unknown;
|
|
172
|
-
const ok = (
|
|
175
|
+
const ok = Object.hasOwn((a, o), 'k');
|
|
173
176
|
`,
|
|
177
|
+
output: null,
|
|
174
178
|
errors: [{ messageId: 'useIsRecordAndHasKey' }],
|
|
175
179
|
},
|
|
176
180
|
{
|
|
177
|
-
name: 'keeps the parentheses of a sequence object
|
|
181
|
+
name: 'keeps the parentheses of a sequence object read once',
|
|
178
182
|
code: dedent`
|
|
179
183
|
declare const a: number;
|
|
180
|
-
declare const o: unknown
|
|
181
|
-
const ok =
|
|
184
|
+
declare const o: Readonly<Record<string, unknown>>;
|
|
185
|
+
const ok = 'k' in (a, o);
|
|
182
186
|
`,
|
|
183
187
|
output: dedent`
|
|
184
|
-
import {
|
|
188
|
+
import { hasKey } from 'ts-std-forge';
|
|
185
189
|
declare const a: number;
|
|
186
|
-
declare const o: unknown
|
|
187
|
-
const ok =
|
|
190
|
+
declare const o: Readonly<Record<string, unknown>>;
|
|
191
|
+
const ok = hasKey((a, o), 'k');
|
|
188
192
|
`,
|
|
189
|
-
errors: [{ messageId: '
|
|
193
|
+
errors: [{ messageId: 'useHasKey' }],
|
|
190
194
|
},
|
|
191
195
|
{
|
|
192
196
|
name: 'keeps the parentheses of a sequence key',
|
|
@@ -206,3 +210,86 @@ describe('prefer-is-record-and-has-key with parenthesized operands', () => {
|
|
|
206
210
|
],
|
|
207
211
|
});
|
|
208
212
|
});
|
|
213
|
+
|
|
214
|
+
describe('prefer-is-record-and-has-key where the rewrite would change behavior', () => {
|
|
215
|
+
tester.run('prefer-is-record-and-has-key', preferIsRecordAndHasKey, {
|
|
216
|
+
valid: [
|
|
217
|
+
{
|
|
218
|
+
// `isRecord` rejects an array, so `'0' in xs` would turn false.
|
|
219
|
+
name: 'an array',
|
|
220
|
+
code: dedent`
|
|
221
|
+
declare const xs: readonly number[];
|
|
222
|
+
const a = '0' in xs;
|
|
223
|
+
const b = Object.hasOwn(xs, 'length');
|
|
224
|
+
`,
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
name: 'a union with an array',
|
|
228
|
+
code: dedent`
|
|
229
|
+
declare const v: Readonly<Record<string, unknown>> | readonly number[];
|
|
230
|
+
const ok = 'k' in v;
|
|
231
|
+
`,
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
name: 'an array cast to a record',
|
|
235
|
+
code: dedent`
|
|
236
|
+
declare const arr: readonly string[];
|
|
237
|
+
const ok = Object.hasOwn(arr as unknown as Record<string, unknown>, '0');
|
|
238
|
+
`,
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
name: 'a record widened by `as` to one that may be an array: the author says it may be one',
|
|
242
|
+
code: dedent`
|
|
243
|
+
declare const obj: Readonly<Record<string, unknown>>;
|
|
244
|
+
const ok = Object.hasOwn(obj as Readonly<Record<string, unknown>> | readonly string[], 'a');
|
|
245
|
+
`,
|
|
246
|
+
},
|
|
247
|
+
],
|
|
248
|
+
invalid: [
|
|
249
|
+
{
|
|
250
|
+
// `isRecord(…) && hasKey(…)` would evaluate the object twice.
|
|
251
|
+
name: 'an object with side effects is reported without a fix',
|
|
252
|
+
code: dedent`
|
|
253
|
+
declare const getObj: () => object;
|
|
254
|
+
declare const log: () => void;
|
|
255
|
+
declare const o: object;
|
|
256
|
+
const a = 'k' in getObj();
|
|
257
|
+
const b = Object.hasOwn(getObj(), 'k');
|
|
258
|
+
const c = 'k' in (log(), o);
|
|
259
|
+
`,
|
|
260
|
+
output: null,
|
|
261
|
+
errors: [
|
|
262
|
+
{ messageId: 'useIsRecordAndHasKey' },
|
|
263
|
+
{ messageId: 'useIsRecordAndHasKey' },
|
|
264
|
+
{ messageId: 'useIsRecordAndHasKey' },
|
|
265
|
+
],
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
name: 'an object with side effects is fixed when isRecord is dropped: it is read once',
|
|
269
|
+
code: dedent`
|
|
270
|
+
declare const getRec: () => Readonly<Record<string, unknown>>;
|
|
271
|
+
const ok = 'k' in getRec();
|
|
272
|
+
`,
|
|
273
|
+
output: dedent`
|
|
274
|
+
import { hasKey } from 'ts-std-forge';
|
|
275
|
+
declare const getRec: () => Readonly<Record<string, unknown>>;
|
|
276
|
+
const ok = hasKey(getRec(), 'k');
|
|
277
|
+
`,
|
|
278
|
+
errors: [{ messageId: 'useHasKey' }],
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
name: 'a reference read through type wrappers is still read without side effects',
|
|
282
|
+
code: dedent`
|
|
283
|
+
declare const o: { readonly p: object };
|
|
284
|
+
const ok = 'k' in (o.p satisfies object);
|
|
285
|
+
`,
|
|
286
|
+
output: dedent`
|
|
287
|
+
import { isRecord, hasKey } from 'ts-std-forge';
|
|
288
|
+
declare const o: { readonly p: object };
|
|
289
|
+
const ok = (isRecord(o.p satisfies object) && hasKey(o.p satisfies object, 'k'));
|
|
290
|
+
`,
|
|
291
|
+
errors: [{ messageId: 'useIsRecordAndHasKey' }],
|
|
292
|
+
},
|
|
293
|
+
],
|
|
294
|
+
});
|
|
295
|
+
});
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
skipTypeWrappers,
|
|
12
12
|
} from 'eslint-rule-forge';
|
|
13
13
|
import type { ReadonlyRecord } from 'ts-type-forge';
|
|
14
|
-
import {
|
|
14
|
+
import { typeHoldsAtEveryLayer } from './ast-utils.mjs';
|
|
15
15
|
import { TS_STD_FORGE_MODULE } from './constants.mjs';
|
|
16
16
|
|
|
17
17
|
type Options = readonly [];
|
|
@@ -86,29 +86,24 @@ export const preferSafeArrayLengthGuard: TSESLint.RuleModule<
|
|
|
86
86
|
TS_STD_FORGE_MODULE,
|
|
87
87
|
);
|
|
88
88
|
|
|
89
|
-
const checker = sourceCode.parserServices?.program?.getTypeChecker();
|
|
90
|
-
|
|
91
89
|
/**
|
|
92
90
|
* Whether `expression` is an array or tuple. Without type information the
|
|
93
91
|
* answer is no: `.length` is also a string's, a function's and a
|
|
94
|
-
* `TypedArray`'s, and none of those can be handed to the guard.
|
|
95
|
-
*
|
|
92
|
+
* `TypedArray`'s, and none of those can be handed to the guard. It has to
|
|
93
|
+
* be one at every layer of type wrappers: a string cast to an array is
|
|
94
|
+
* still a string, and the fix, which keeps the wrapper, hands the guard
|
|
95
|
+
* the type the outermost layer states.
|
|
96
96
|
*/
|
|
97
97
|
const isArrayLike = (
|
|
98
98
|
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
99
99
|
expression: TSESTree.Expression,
|
|
100
|
-
): boolean =>
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return (
|
|
108
|
-
type !== undefined &&
|
|
109
|
-
(checker.isArrayType(type) || checker.isTupleType(type))
|
|
100
|
+
): boolean =>
|
|
101
|
+
typeHoldsAtEveryLayer(
|
|
102
|
+
sourceCode,
|
|
103
|
+
expression,
|
|
104
|
+
(type, checker) =>
|
|
105
|
+
checker.isArrayType(type) || checker.isTupleType(type),
|
|
110
106
|
);
|
|
111
|
-
};
|
|
112
107
|
|
|
113
108
|
const mut_nodesToFix: {
|
|
114
109
|
node: TSESTree.BinaryExpression;
|
|
@@ -168,6 +168,20 @@ describe('prefer-safe-array-length-guard through type wrappers', () => {
|
|
|
168
168
|
const ok = xs.length > (2 as const);
|
|
169
169
|
`,
|
|
170
170
|
},
|
|
171
|
+
{
|
|
172
|
+
name: 'an array widened by `as` to one that may be a string',
|
|
173
|
+
code: dedent`
|
|
174
|
+
declare const xs: readonly string[];
|
|
175
|
+
const ok = (xs as readonly string[] | string).length > 0;
|
|
176
|
+
`,
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: '`!` on an optional array, which leaves undefined in place',
|
|
180
|
+
code: dedent`
|
|
181
|
+
declare const xs: readonly number[] | undefined;
|
|
182
|
+
const ok = xs!.length !== 0;
|
|
183
|
+
`,
|
|
184
|
+
},
|
|
171
185
|
],
|
|
172
186
|
invalid: [
|
|
173
187
|
{
|
|
@@ -210,15 +224,15 @@ describe('prefer-safe-array-length-guard through type wrappers', () => {
|
|
|
210
224
|
errors: [{ messageId: 'useIsEmpty' }],
|
|
211
225
|
},
|
|
212
226
|
{
|
|
213
|
-
name: 'keeps a wrapper on
|
|
227
|
+
name: 'keeps a wrapper on an array that is an array at every layer',
|
|
214
228
|
code: dedent`
|
|
215
|
-
declare const xs: readonly number[]
|
|
216
|
-
const ok = xs
|
|
229
|
+
declare const xs: readonly number[];
|
|
230
|
+
const ok = (xs! as readonly number[]).length !== 0;
|
|
217
231
|
`,
|
|
218
232
|
output: dedent`
|
|
219
233
|
import { SafeArray } from 'ts-std-forge';
|
|
220
|
-
declare const xs: readonly number[]
|
|
221
|
-
const ok = SafeArray.isNonEmpty(xs!);
|
|
234
|
+
declare const xs: readonly number[];
|
|
235
|
+
const ok = SafeArray.isNonEmpty(xs! as readonly number[]);
|
|
222
236
|
`,
|
|
223
237
|
errors: [{ messageId: 'useIsNonEmpty' }],
|
|
224
238
|
},
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
skipTypeWrappers,
|
|
12
12
|
} from 'eslint-rule-forge';
|
|
13
13
|
import * as ts from 'typescript';
|
|
14
|
-
import {
|
|
14
|
+
import { isIdentifierNamed, typeHoldsAtEveryLayer } from './ast-utils.mjs';
|
|
15
15
|
import { TS_STD_FORGE_MODULE } from './constants.mjs';
|
|
16
16
|
|
|
17
17
|
type Options = readonly [];
|
|
@@ -118,13 +118,13 @@ export const preferSafeNumberParseInteger: TSESLint.RuleModule<
|
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
// The argument must be a `string`
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (
|
|
121
|
+
// The argument must be a `string` at every layer of type wrappers:
|
|
122
|
+
// the value's own type is what `parseInt` sees at run time
|
|
123
|
+
// (`parseInt(1e21 as unknown as string)` is 1, the rewrite 1e21), and
|
|
124
|
+
// the type a wrapper states is what the fix, which keeps the wrapper,
|
|
125
|
+
// hands `SafeNumber.parseInteger(value: string)`. Without type
|
|
126
|
+
// information, skip.
|
|
127
|
+
if (!typeHoldsAtEveryLayer(sourceCode, firstArg, isStringType)) {
|
|
128
128
|
return;
|
|
129
129
|
}
|
|
130
130
|
|
|
@@ -84,6 +84,20 @@ describe('prefer-safe-number-parse-integer through type wrappers', () => {
|
|
|
84
84
|
const n = parseInt(s, 16 as const);
|
|
85
85
|
`,
|
|
86
86
|
},
|
|
87
|
+
{
|
|
88
|
+
name: 'ignores a string widened by `as` to one that may be a number',
|
|
89
|
+
code: dedent`
|
|
90
|
+
declare const s: string;
|
|
91
|
+
const n = parseInt(s as string | number as string);
|
|
92
|
+
`,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: 'ignores `!` on an optional string, which leaves undefined in place',
|
|
96
|
+
code: dedent`
|
|
97
|
+
declare const s: string | undefined;
|
|
98
|
+
const n = parseInt(s!);
|
|
99
|
+
`,
|
|
100
|
+
},
|
|
87
101
|
],
|
|
88
102
|
invalid: [
|
|
89
103
|
{
|
|
@@ -131,15 +145,15 @@ describe('prefer-safe-number-parse-integer through type wrappers', () => {
|
|
|
131
145
|
],
|
|
132
146
|
},
|
|
133
147
|
{
|
|
134
|
-
name: '
|
|
148
|
+
name: 'rewrites a string that is a string at every layer',
|
|
135
149
|
code: dedent`
|
|
136
|
-
declare const s: string
|
|
137
|
-
const n = parseInt(s!);
|
|
150
|
+
declare const s: string;
|
|
151
|
+
const n = parseInt(s! as string);
|
|
138
152
|
`,
|
|
139
153
|
output: dedent`
|
|
140
154
|
import { Result, SafeNumber } from 'ts-std-forge';
|
|
141
|
-
declare const s: string
|
|
142
|
-
const n = Result.unwrapOkOr(SafeNumber.parseInteger(s!), Number.NaN);
|
|
155
|
+
declare const s: string;
|
|
156
|
+
const n = Result.unwrapOkOr(SafeNumber.parseInteger(s! as string), Number.NaN);
|
|
143
157
|
`,
|
|
144
158
|
errors: [{ messageId: 'useSafeNumberParseInteger' }],
|
|
145
159
|
},
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
skipTypeWrappers,
|
|
12
12
|
} from 'eslint-rule-forge';
|
|
13
13
|
import * as ts from 'typescript';
|
|
14
|
-
import {
|
|
14
|
+
import { isIdentifierNamed, typeHoldsAtEveryLayer } from './ast-utils.mjs';
|
|
15
15
|
import { TS_STD_FORGE_MODULE } from './constants.mjs';
|
|
16
16
|
|
|
17
17
|
type Options = readonly [];
|
|
@@ -115,13 +115,13 @@ export const preferSafeNumberParse: TSESLint.RuleModule<MessageIds, Options> = {
|
|
|
115
115
|
return;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
// The argument must be purely `string`
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (
|
|
118
|
+
// The argument must be purely `string` at every layer of type
|
|
119
|
+
// wrappers: the value's own type is what `Number` sees at run time
|
|
120
|
+
// (`Number(true as unknown as string)` is 1, the rewrite NaN), and the
|
|
121
|
+
// type a wrapper states is what the fix, which keeps the wrapper,
|
|
122
|
+
// hands `SafeNumber.parse(value: string)`. Without type information,
|
|
123
|
+
// skip.
|
|
124
|
+
if (!typeHoldsAtEveryLayer(sourceCode, firstArg, isStringType)) {
|
|
125
125
|
return;
|
|
126
126
|
}
|
|
127
127
|
|