eslint-plugin-ts-data-forge 0.10.0 → 0.10.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.
Files changed (43) hide show
  1. package/dist/plugin.d.mts +1 -1
  2. package/dist/rule-types.d.mts +1 -1
  3. package/dist/rules/ast-utils.d.mts +0 -16
  4. package/dist/rules/ast-utils.d.mts.map +1 -1
  5. package/dist/rules/ast-utils.mjs +0 -36
  6. package/dist/rules/ast-utils.mjs.map +1 -1
  7. package/dist/rules/no-side-effect-import.d.mts +1 -8
  8. package/dist/rules/no-side-effect-import.d.mts.map +1 -1
  9. package/dist/rules/no-side-effect-import.mjs +7 -43
  10. package/dist/rules/no-side-effect-import.mjs.map +1 -1
  11. package/dist/rules/prefer-arr-is-array.d.mts +1 -5
  12. package/dist/rules/prefer-arr-is-array.d.mts.map +1 -1
  13. package/dist/rules/prefer-arr-is-array.mjs +5 -83
  14. package/dist/rules/prefer-arr-is-array.mjs.map +1 -1
  15. package/dist/rules/prefer-is-non-null-object.d.mts +1 -5
  16. package/dist/rules/prefer-is-non-null-object.d.mts.map +1 -1
  17. package/dist/rules/prefer-is-non-null-object.mjs +4 -184
  18. package/dist/rules/prefer-is-non-null-object.mjs.map +1 -1
  19. package/dist/rules/prefer-is-record-and-has-key.d.mts +1 -5
  20. package/dist/rules/prefer-is-record-and-has-key.d.mts.map +1 -1
  21. package/dist/rules/prefer-is-record-and-has-key.mjs +4 -219
  22. package/dist/rules/prefer-is-record-and-has-key.mjs.map +1 -1
  23. package/dist/rules/prefer-num-safe-parse-float.d.mts +1 -15
  24. package/dist/rules/prefer-num-safe-parse-float.d.mts.map +1 -1
  25. package/dist/rules/prefer-num-safe-parse-float.mjs +6 -123
  26. package/dist/rules/prefer-num-safe-parse-float.mjs.map +1 -1
  27. package/dist/rules/prefer-num-safe-parse-int.d.mts +1 -13
  28. package/dist/rules/prefer-num-safe-parse-int.d.mts.map +1 -1
  29. package/dist/rules/prefer-num-safe-parse-int.mjs +6 -121
  30. package/dist/rules/prefer-num-safe-parse-int.mjs.map +1 -1
  31. package/dist/rules/rules.d.mts +1 -1
  32. package/package.json +5 -5
  33. package/src/rule-types.mts +1 -1
  34. package/src/rules/ast-utils.mts +0 -51
  35. package/src/rules/no-side-effect-import.mts +7 -61
  36. package/src/rules/prefer-arr-is-array.mts +5 -122
  37. package/src/rules/prefer-arr-is-array.test.mts +50 -7
  38. package/src/rules/prefer-is-non-null-object.mts +4 -274
  39. package/src/rules/prefer-is-record-and-has-key.mts +4 -318
  40. package/src/rules/prefer-num-safe-parse-float.mts +6 -193
  41. package/src/rules/prefer-num-safe-parse-float.test.mts +18 -0
  42. package/src/rules/prefer-num-safe-parse-int.mts +6 -192
  43. package/src/rules/prefer-num-safe-parse-int.test.mts +18 -0
@@ -1,320 +1,6 @@
1
- import {
2
- AST_NODE_TYPES,
3
- type TSESLint,
4
- type TSESTree,
5
- } from '@typescript-eslint/utils';
6
- import {
7
- buildNamedImportFix,
8
- getArgumentText,
9
- getImportDeclarations,
10
- getNamedImports,
11
- skipTypeWrappers,
12
- } from 'eslint-rule-forge';
13
- import { Arr } from 'ts-data-forge';
14
- import * as ts from 'typescript';
15
- import { isIdentifierNamed, typeHoldsAtEveryLayer } from './ast-utils.mjs';
1
+ import { createPreferIsRecordAndHasKey } from 'eslint-rule-forge';
16
2
  import { TS_DATA_FORGE_MODULE } from './constants.mjs';
17
3
 
18
- type Options = readonly [];
19
-
20
- type MessageIds = 'useIsRecordAndHasKey' | 'useHasKey';
21
-
22
- export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
23
- {
24
- meta: {
25
- type: 'suggestion',
26
- docs: {
27
- description:
28
- 'Replace `Object.hasOwn(obj, key)` or `key in obj` with `isRecord(obj) && hasKey(obj, key)` from ts-data-forge',
29
- },
30
- fixable: 'code',
31
- schema: [],
32
- messages: {
33
- useIsRecordAndHasKey:
34
- 'Replace `{{original}}` with `isRecord({{objName}}) && hasKey({{objName}}, {{keyName}})` from ts-data-forge.',
35
- useHasKey:
36
- 'Replace `{{original}}` with `hasKey({{objName}}, {{keyName}})` from ts-data-forge.',
37
- },
38
- },
39
-
40
- create: (context) => {
41
- const sourceCode = context.sourceCode;
42
-
43
- const program = sourceCode.ast;
44
-
45
- const tsDataForgeImports = getImportDeclarations(
46
- program,
47
- TS_DATA_FORGE_MODULE,
48
- );
49
-
50
- /**
51
- * Whether the `isRecord(...)` half of the rewrite would be dead code
52
- * because the object already satisfies `hasKey`'s
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
- */
59
- const isKnownRecord = (
60
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
61
- expression: TSESTree.Expression,
62
- ): boolean =>
63
- typeHoldsAtEveryLayer(sourceCode, expression, (type, checker) =>
64
- isAlreadyRecordType(checker, type),
65
- );
66
-
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
- );
84
-
85
- const mut_nodesToFix: {
86
- node: TSESTree.CallExpression | TSESTree.BinaryExpression;
87
- objExpression: TSESTree.Expression;
88
- keyExpression: TSESTree.Expression;
89
- }[] = [];
90
-
91
- return {
92
- // Handle Object.hasOwn(obj, key)
93
- CallExpression: (node) => {
94
- const callee = skipTypeWrappers(node.callee);
95
-
96
- if (
97
- callee.type !== AST_NODE_TYPES.MemberExpression ||
98
- !isIdentifierNamed(callee.object, 'Object') ||
99
- callee.property.type !== AST_NODE_TYPES.Identifier ||
100
- callee.property.name !== 'hasOwn'
101
- ) {
102
- return;
103
- }
104
-
105
- // Check arguments: Object.hasOwn(obj, key)
106
- if (node.arguments.length !== 2) {
107
- return;
108
- }
109
-
110
- const objArg = node.arguments[0];
111
-
112
- const keyArg = node.arguments[1];
113
-
114
- if (objArg === undefined || keyArg === undefined) {
115
- return;
116
- }
117
-
118
- // Type guard: ensure arguments are Expressions (not SpreadElement)
119
- if (
120
- objArg.type === AST_NODE_TYPES.SpreadElement ||
121
- keyArg.type === AST_NODE_TYPES.SpreadElement ||
122
- mayBeArray(objArg)
123
- ) {
124
- return;
125
- }
126
-
127
- mut_nodesToFix.push({
128
- node,
129
- objExpression: objArg,
130
- keyExpression: keyArg,
131
- });
132
- },
133
-
134
- // Handle key in obj
135
- BinaryExpression: (node) => {
136
- if (node.operator !== 'in') {
137
- return;
138
- }
139
-
140
- const keyExpression = node.left;
141
-
142
- const objExpression = node.right;
143
-
144
- // Type guard: ensure keyExpression is Expression (not PrivateIdentifier)
145
- if (
146
- keyExpression.type === AST_NODE_TYPES.PrivateIdentifier ||
147
- mayBeArray(objExpression)
148
- ) {
149
- return;
150
- }
151
-
152
- mut_nodesToFix.push({
153
- node,
154
- objExpression,
155
- keyExpression,
156
- });
157
- },
158
-
159
- 'Program:exit': () => {
160
- const namedImports = getNamedImports(tsDataForgeImports);
161
-
162
- const rewrites = mut_nodesToFix.map((entry) => {
163
- // A record-typed object makes the `isRecord(...)` conjunct always
164
- // true, so it is left out of the replacement entirely.
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[] = Arr.isEmpty(fixableRewrites)
178
- ? []
179
- : [
180
- ...(fixableRewrites.some(
181
- ({ needsIsRecord }) => needsIsRecord,
182
- ) && !namedImports.includes('isRecord')
183
- ? ['isRecord']
184
- : []),
185
- ...(namedImports.includes('hasKey') ? [] : ['hasKey']),
186
- ];
187
-
188
- const importCarrier = fixableRewrites[0];
189
-
190
- for (const rewrite of rewrites) {
191
- const { node, objExpression, keyExpression, needsIsRecord } =
192
- rewrite;
193
-
194
- const objText = getArgumentText(sourceCode, objExpression);
195
-
196
- const keyText = getArgumentText(sourceCode, keyExpression);
197
-
198
- const originalText = sourceCode.getText(node);
199
-
200
- context.report({
201
- node,
202
- messageId: needsIsRecord ? 'useIsRecordAndHasKey' : 'useHasKey',
203
- data: {
204
- original: originalText,
205
- objName: objText,
206
- keyName: keyText,
207
- },
208
- fix: !rewrite.fixable
209
- ? undefined
210
- : (fixer) => {
211
- const replacement = needsIsRecord
212
- ? `(isRecord(${objText}) && hasKey(${objText}, ${keyText}))`
213
- : `hasKey(${objText}, ${keyText})`;
214
-
215
- // All import fixes have to live on the first fixable
216
- // report: `insertTextBefore(program, …)` produces
217
- // overlapping ranges otherwise and only one of them would
218
- // survive a pass.
219
- const importFixes =
220
- rewrite === importCarrier && importsToAdd.length > 0
221
- ? buildNamedImportFix(
222
- fixer,
223
- program,
224
- TS_DATA_FORGE_MODULE,
225
- importsToAdd,
226
- )
227
- : [];
228
-
229
- return [
230
- ...importFixes,
231
- fixer.replaceText(node, replacement),
232
- ];
233
- },
234
- });
235
- }
236
- },
237
- };
238
- },
239
- defaultOptions: [],
240
- } as const;
241
-
242
- /**
243
- * Whether reading `node` again has no side effects: a variable, `this`, or a
244
- * non-computed property chain on one, type wrappers aside.
245
- */
246
- const isSideEffectFree = (
247
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
248
- wrapped: TSESTree.Node,
249
- ): boolean => {
250
- const node = skipTypeWrappers(wrapped);
251
-
252
- return (
253
- node.type === AST_NODE_TYPES.Identifier ||
254
- node.type === AST_NODE_TYPES.ThisExpression ||
255
- (node.type === AST_NODE_TYPES.MemberExpression &&
256
- !node.computed &&
257
- isSideEffectFree(node.object))
258
- );
259
- };
260
-
261
- /**
262
- * Whether a value of `type` may be an array or a tuple: some member of a union
263
- * or an intersection is one, or the constraint of a deferred type is.
264
- * `object` and `unknown` say nothing either way and are not counted.
265
- */
266
- const mayBeArrayType = (
267
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
268
- checker: ts.TypeChecker,
269
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
270
- type: ts.Type,
271
- ): boolean => {
272
- if (type.isUnionOrIntersection()) {
273
- return type.types.some((member) => mayBeArrayType(checker, member));
274
- }
275
-
276
- if (
277
- (type.flags &
278
- (ts.TypeFlags.TypeParameter |
279
- ts.TypeFlags.IndexedAccess |
280
- ts.TypeFlags.Conditional |
281
- ts.TypeFlags.Substitution)) !==
282
- 0
283
- ) {
284
- const constraint = checker.getBaseConstraintOfType(type);
285
-
286
- return constraint !== undefined && mayBeArrayType(checker, constraint);
287
- }
288
-
289
- return checker.isArrayType(type) || checker.isTupleType(type);
290
- };
291
-
292
- /**
293
- * Whether `type` already satisfies `hasKey`'s `R extends UnknownRecord`
294
- * constraint, so that guarding the call with `isRecord` adds nothing.
295
- *
296
- * `UnknownRecord` is `ReadonlyRecord<string, unknown>`, so a string index
297
- * signature is exactly what it asks for and its value type is `unknown`, which
298
- * anything satisfies. Callables and arrays are excluded because `isRecord`
299
- * rejects them at runtime (`typeof fn === 'object'` is false, `Array.isArray`
300
- * is true), so dropping the guard there would change behavior.
301
- *
302
- * Deliberately conservative: a type TypeScript would accept through an
303
- * *implicit* index signature (an object type alias without one of its own)
304
- * keeps the guard, because the checker does not report such a signature here.
305
- */
306
- const isAlreadyRecordType = (
307
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
308
- checker: ts.TypeChecker,
309
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
310
- type: ts.Type,
311
- ): boolean =>
312
- type.isUnion()
313
- ? type.types.every((member) => isAlreadyRecordType(checker, member))
314
- : checker.getIndexInfoOfType(type, ts.IndexKind.String) !== undefined &&
315
- Arr.isEmpty(checker.getSignaturesOfType(type, ts.SignatureKind.Call)) &&
316
- Arr.isEmpty(
317
- checker.getSignaturesOfType(type, ts.SignatureKind.Construct),
318
- ) &&
319
- !checker.isArrayType(type) &&
320
- !checker.isTupleType(type);
4
+ export const preferIsRecordAndHasKey = createPreferIsRecordAndHasKey({
5
+ moduleName: TS_DATA_FORGE_MODULE,
6
+ });
@@ -1,195 +1,8 @@
1
- import {
2
- AST_NODE_TYPES,
3
- type TSESLint,
4
- type TSESTree,
5
- } from '@typescript-eslint/utils';
6
- import {
7
- buildNamedImportFix,
8
- getArgumentText,
9
- getImportDeclarations,
10
- getNamedImports,
11
- typeWrapperLayers,
12
- } from 'eslint-rule-forge';
13
- import * as ts from 'typescript';
1
+ import { createPreferSafeParseFloat } from 'eslint-rule-forge';
14
2
  import { TS_DATA_FORGE_MODULE } from './constants.mjs';
15
3
 
16
- type Options = readonly [];
17
-
18
- type MessageIds = 'useSafeParseFloat';
19
-
20
- /**
21
- * Returns `true` when every constituent of `type` is string-like, i.e. the
22
- * type is assignable to the `string` parameter of `Num.safeParseFloat`. A
23
- * union such as `string | undefined` is rejected so the autofix never
24
- * produces a type error.
25
- */
26
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
27
- const isStringType = (type: ts.Type): boolean => {
28
- const parts = type.isUnion() ? type.types : ([type] as const);
29
-
30
- return (
31
- parts.length > 0 &&
32
- parts.every((t) => (t.flags & ts.TypeFlags.StringLike) !== 0)
33
- );
34
- };
35
-
36
- /**
37
- * Detects `parseFloat(x)`, `Number.parseFloat(x)`, and `Number(x)` (when x
38
- * is typed as `string`) and replaces them with
39
- * `Result.unwrapOkOr(Num.safeParseFloat(x), Number.NaN)` (ts-data-forge).
40
- *
41
- * `Number(x)` is only flagged when x is purely a string type to avoid false
42
- * positives for `Number(someBoolean)` or `Number(someNumber)` uses. The type is
43
- * asked of the value under any `as` / `!` / `satisfies` too, so a cast does not
44
- * vouch for a string that is not one.
45
- */
46
- export const preferNumSafeParseFloat: TSESLint.RuleModule<MessageIds, Options> =
47
- {
48
- meta: {
49
- type: 'suggestion',
50
- docs: {
51
- description:
52
- 'Replace `parseFloat(x)`, `Number.parseFloat(x)`, or `Number(x)` (when x is a string) with `Result.unwrapOkOr(Num.safeParseFloat(x), Number.NaN)` from ts-data-forge.',
53
- },
54
- fixable: 'code',
55
- schema: [],
56
- messages: {
57
- useSafeParseFloat:
58
- 'Replace `{{original}}` with `Result.unwrapOkOr(Num.safeParseFloat({{argName}}), Number.NaN)` from ts-data-forge.',
59
- },
60
- },
61
-
62
- create: (context) => {
63
- const sourceCode = context.sourceCode;
64
-
65
- const program = sourceCode.ast;
66
-
67
- const tsDataForgeImports = getImportDeclarations(
68
- program,
69
- TS_DATA_FORGE_MODULE,
70
- );
71
-
72
- const services = context.sourceCode.parserServices;
73
-
74
- const mut_nodesToFix: {
75
- node: TSESTree.CallExpression;
76
- argExpression: TSESTree.Expression;
77
- }[] = [];
78
-
79
- return {
80
- CallExpression: (node) => {
81
- const { callee } = node;
82
-
83
- // Match `parseFloat(...)` (global function).
84
- const isGlobalParseFloat =
85
- callee.type === AST_NODE_TYPES.Identifier &&
86
- callee.name === 'parseFloat';
87
-
88
- // Match `Number.parseFloat(...)`.
89
- const isNumberParseFloat =
90
- callee.type === AST_NODE_TYPES.MemberExpression &&
91
- !callee.computed &&
92
- callee.object.type === AST_NODE_TYPES.Identifier &&
93
- callee.object.name === 'Number' &&
94
- callee.property.type === AST_NODE_TYPES.Identifier &&
95
- callee.property.name === 'parseFloat';
96
-
97
- // Match `Number(...)` called as a function (not `new Number(...)`).
98
- const isNumberCall =
99
- callee.type === AST_NODE_TYPES.Identifier &&
100
- callee.name === 'Number';
101
-
102
- if (!isGlobalParseFloat && !isNumberParseFloat && !isNumberCall) {
103
- return;
104
- }
105
-
106
- const args = node.arguments;
107
-
108
- const firstArg = args[0];
109
-
110
- if (firstArg === undefined) {
111
- return;
112
- }
113
-
114
- // Spread argument (`parseFloat(...rest)`) cannot be rewritten safely.
115
- if (firstArg.type === AST_NODE_TYPES.SpreadElement) {
116
- return;
117
- }
118
-
119
- // The argument must be purely `string` so the autofix is type-safe
120
- // against `Num.safeParseFloat(s: string)`. Without type information,
121
- // skip.
122
- if (services?.program == null) {
123
- return;
124
- }
125
-
126
- const checker = services.program.getTypeChecker();
127
-
128
- // Every layer of type wrappers must be `string` (see
129
- // typeWrapperLayers): the value underneath is what `Number` sees at
130
- // run time (`Number(v as string)` may still be handed `true`), and
131
- // the type a wrapper states is what the fix, which keeps the
132
- // wrapper, passes on.
133
- const isString = (
134
- // AST nodes hold mutable child arrays, so they are not deeply readonly.
135
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
136
- expression: TSESTree.Node,
137
- ): boolean => {
138
- const tsNode = services.esTreeNodeToTSNodeMap?.get(expression);
139
-
140
- return (
141
- tsNode !== undefined &&
142
- isStringType(checker.getTypeAtLocation(tsNode))
143
- );
144
- };
145
-
146
- if (!typeWrapperLayers(firstArg).every(isString)) {
147
- return;
148
- }
149
-
150
- mut_nodesToFix.push({ node, argExpression: firstArg });
151
- },
152
- 'Program:exit': () => {
153
- const namedImports = getNamedImports(tsDataForgeImports);
154
-
155
- const missingImports = (['Num', 'Result'] as const).filter(
156
- (name) => !namedImports.includes(name),
157
- );
158
-
159
- for (const [
160
- index,
161
- { node, argExpression },
162
- ] of mut_nodesToFix.entries()) {
163
- const argText = getArgumentText(sourceCode, argExpression);
164
-
165
- const originalText = sourceCode.getText(node);
166
-
167
- context.report({
168
- node,
169
- messageId: 'useSafeParseFloat',
170
- data: {
171
- original: originalText,
172
- argName: argText,
173
- },
174
- fix: (fixer) => {
175
- const core = `Result.unwrapOkOr(Num.safeParseFloat(${argText}), Number.NaN)`;
176
-
177
- const importFixes =
178
- index === 0 && missingImports.length > 0
179
- ? buildNamedImportFix(
180
- fixer,
181
- program,
182
- TS_DATA_FORGE_MODULE,
183
- missingImports,
184
- )
185
- : [];
186
-
187
- return [...importFixes, fixer.replaceText(node, core)];
188
- },
189
- });
190
- }
191
- },
192
- };
193
- },
194
- defaultOptions: [],
195
- } as const;
4
+ export const preferNumSafeParseFloat = createPreferSafeParseFloat({
5
+ moduleName: TS_DATA_FORGE_MODULE,
6
+ namespace: 'Num',
7
+ method: 'safeParseFloat',
8
+ });
@@ -214,6 +214,24 @@ describe('prefer-num-safe-parse-float through type wrappers', () => {
214
214
  { messageId: 'useSafeParseFloat' },
215
215
  ],
216
216
  },
217
+ {
218
+ name: 'the callee read through `as` and `!`',
219
+ code: dedent`
220
+ declare const s: string;
221
+ const a = (Number as NumberConstructor)(s);
222
+ const b = Number!.parseFloat(s);
223
+ `,
224
+ output: dedent`
225
+ import { Num, Result } from 'ts-data-forge';
226
+ declare const s: string;
227
+ const a = Result.unwrapOkOr(Num.safeParseFloat(s), Number.NaN);
228
+ const b = Result.unwrapOkOr(Num.safeParseFloat(s), Number.NaN);
229
+ `,
230
+ errors: [
231
+ { messageId: 'useSafeParseFloat' },
232
+ { messageId: 'useSafeParseFloat' },
233
+ ],
234
+ },
217
235
  ],
218
236
  });
219
237
  }, 20000);