eslint-plugin-ts-data-forge 0.8.5 → 0.9.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.
Files changed (135) hide show
  1. package/README.md +20 -19
  2. package/dist/plugin.d.mts +2 -0
  3. package/dist/plugin.d.mts.map +1 -1
  4. package/dist/plugin.mjs +1 -0
  5. package/dist/plugin.mjs.map +1 -1
  6. package/dist/rule-types.d.mts +16 -1
  7. package/dist/rule-types.d.mts.map +1 -1
  8. package/dist/rule-types.mjs.map +1 -1
  9. package/dist/rules/ast-utils.d.mts +21 -34
  10. package/dist/rules/ast-utils.d.mts.map +1 -1
  11. package/dist/rules/ast-utils.mjs +53 -58
  12. package/dist/rules/ast-utils.mjs.map +1 -1
  13. package/dist/rules/constants.d.mts +2 -0
  14. package/dist/rules/constants.d.mts.map +1 -1
  15. package/dist/rules/constants.mjs +9 -0
  16. package/dist/rules/constants.mjs.map +1 -1
  17. package/dist/rules/mutable-result.d.mts +10 -0
  18. package/dist/rules/mutable-result.d.mts.map +1 -0
  19. package/dist/rules/mutable-result.mjs +145 -0
  20. package/dist/rules/mutable-result.mjs.map +1 -0
  21. package/dist/rules/no-unnecessary-type-guard.d.mts.map +1 -1
  22. package/dist/rules/no-unnecessary-type-guard.mjs +6 -6
  23. package/dist/rules/no-unnecessary-type-guard.mjs.map +1 -1
  24. package/dist/rules/prefer-arr-generate.d.mts +57 -0
  25. package/dist/rules/prefer-arr-generate.d.mts.map +1 -0
  26. package/dist/rules/prefer-arr-generate.mjs +588 -0
  27. package/dist/rules/prefer-arr-generate.mjs.map +1 -0
  28. package/dist/rules/prefer-arr-is-array.d.mts.map +1 -1
  29. package/dist/rules/prefer-arr-is-array.mjs +7 -5
  30. package/dist/rules/prefer-arr-is-array.mjs.map +1 -1
  31. package/dist/rules/prefer-arr-is-bounded-length-array.d.mts.map +1 -1
  32. package/dist/rules/prefer-arr-is-bounded-length-array.mjs +9 -6
  33. package/dist/rules/prefer-arr-is-bounded-length-array.mjs.map +1 -1
  34. package/dist/rules/prefer-arr-is-fixed-length-array.d.mts.map +1 -1
  35. package/dist/rules/prefer-arr-is-fixed-length-array.mjs +9 -6
  36. package/dist/rules/prefer-arr-is-fixed-length-array.mjs.map +1 -1
  37. package/dist/rules/prefer-arr-is-max-length-array.d.mts.map +1 -1
  38. package/dist/rules/prefer-arr-is-max-length-array.mjs +9 -6
  39. package/dist/rules/prefer-arr-is-max-length-array.mjs.map +1 -1
  40. package/dist/rules/prefer-arr-is-min-length-array.d.mts.map +1 -1
  41. package/dist/rules/prefer-arr-is-min-length-array.mjs +9 -6
  42. package/dist/rules/prefer-arr-is-min-length-array.mjs.map +1 -1
  43. package/dist/rules/prefer-arr-is-non-empty.d.mts.map +1 -1
  44. package/dist/rules/prefer-arr-is-non-empty.mjs +9 -6
  45. package/dist/rules/prefer-arr-is-non-empty.mjs.map +1 -1
  46. package/dist/rules/prefer-arr-scan.d.mts.map +1 -1
  47. package/dist/rules/prefer-arr-scan.mjs +8 -11
  48. package/dist/rules/prefer-arr-scan.mjs.map +1 -1
  49. package/dist/rules/prefer-arr-sum.d.mts.map +1 -1
  50. package/dist/rules/prefer-arr-sum.mjs +8 -6
  51. package/dist/rules/prefer-arr-sum.mjs.map +1 -1
  52. package/dist/rules/prefer-arr-uniq.d.mts.map +1 -1
  53. package/dist/rules/prefer-arr-uniq.mjs +12 -166
  54. package/dist/rules/prefer-arr-uniq.mjs.map +1 -1
  55. package/dist/rules/prefer-as-int.d.mts.map +1 -1
  56. package/dist/rules/prefer-as-int.mjs +6 -8
  57. package/dist/rules/prefer-as-int.mjs.map +1 -1
  58. package/dist/rules/prefer-canonical-array-slicing.d.mts.map +1 -1
  59. package/dist/rules/prefer-canonical-array-slicing.mjs +57 -34
  60. package/dist/rules/prefer-canonical-array-slicing.mjs.map +1 -1
  61. package/dist/rules/prefer-canonical-length-cast.d.mts.map +1 -1
  62. package/dist/rules/prefer-canonical-length-cast.mjs +3 -3
  63. package/dist/rules/prefer-canonical-length-cast.mjs.map +1 -1
  64. package/dist/rules/prefer-canonical-length-guard.d.mts.map +1 -1
  65. package/dist/rules/prefer-canonical-length-guard.mjs +4 -4
  66. package/dist/rules/prefer-canonical-length-guard.mjs.map +1 -1
  67. package/dist/rules/prefer-comparison-over-nullish-guard.d.mts.map +1 -1
  68. package/dist/rules/prefer-comparison-over-nullish-guard.mjs +3 -2
  69. package/dist/rules/prefer-comparison-over-nullish-guard.mjs.map +1 -1
  70. package/dist/rules/prefer-is-non-null-object.d.mts.map +1 -1
  71. package/dist/rules/prefer-is-non-null-object.mjs +113 -43
  72. package/dist/rules/prefer-is-non-null-object.mjs.map +1 -1
  73. package/dist/rules/prefer-is-record-and-has-key.d.mts.map +1 -1
  74. package/dist/rules/prefer-is-record-and-has-key.mjs +105 -48
  75. package/dist/rules/prefer-is-record-and-has-key.mjs.map +1 -1
  76. package/dist/rules/prefer-num-safe-parse-float.d.mts.map +1 -1
  77. package/dist/rules/prefer-num-safe-parse-float.mjs +6 -6
  78. package/dist/rules/prefer-num-safe-parse-float.mjs.map +1 -1
  79. package/dist/rules/prefer-num-safe-parse-int.d.mts.map +1 -1
  80. package/dist/rules/prefer-num-safe-parse-int.mjs +6 -6
  81. package/dist/rules/prefer-num-safe-parse-int.mjs.map +1 -1
  82. package/dist/rules/prefer-obj-over-entries-round-trip.d.mts.map +1 -1
  83. package/dist/rules/prefer-obj-over-entries-round-trip.mjs +9 -12
  84. package/dist/rules/prefer-obj-over-entries-round-trip.mjs.map +1 -1
  85. package/dist/rules/prefer-range-for-loop.d.mts.map +1 -1
  86. package/dist/rules/prefer-range-for-loop.mjs +104 -23
  87. package/dist/rules/prefer-range-for-loop.mjs.map +1 -1
  88. package/dist/rules/rules.d.mts +1 -0
  89. package/dist/rules/rules.d.mts.map +1 -1
  90. package/dist/rules/rules.mjs +2 -0
  91. package/dist/rules/rules.mjs.map +1 -1
  92. package/package.json +11 -10
  93. package/src/plugin.mts +1 -0
  94. package/src/rule-types.mts +17 -1
  95. package/src/rules/ast-utils.mts +73 -80
  96. package/src/rules/constants.mts +12 -0
  97. package/src/rules/mutable-result.mts +204 -0
  98. package/src/rules/no-unnecessary-type-guard.mts +14 -10
  99. package/src/rules/prefer-arr-generate.mts +918 -0
  100. package/src/rules/prefer-arr-generate.test.mts +967 -0
  101. package/src/rules/prefer-arr-is-array.mts +14 -7
  102. package/src/rules/prefer-arr-is-array.test.mts +14 -0
  103. package/src/rules/prefer-arr-is-bounded-length-array.mts +16 -10
  104. package/src/rules/prefer-arr-is-bounded-length-array.test.mts +19 -0
  105. package/src/rules/prefer-arr-is-fixed-length-array.mts +17 -11
  106. package/src/rules/prefer-arr-is-max-length-array.mts +17 -11
  107. package/src/rules/prefer-arr-is-max-length-array.test.mts +8 -0
  108. package/src/rules/prefer-arr-is-min-length-array.mts +17 -11
  109. package/src/rules/prefer-arr-is-min-length-array.test.mts +8 -0
  110. package/src/rules/prefer-arr-is-non-empty.mts +16 -13
  111. package/src/rules/prefer-arr-scan.mts +24 -19
  112. package/src/rules/prefer-arr-sum.mts +18 -14
  113. package/src/rules/prefer-arr-uniq.mts +25 -234
  114. package/src/rules/prefer-as-int.mts +19 -12
  115. package/src/rules/prefer-canonical-array-slicing.mts +79 -52
  116. package/src/rules/prefer-canonical-array-slicing.test.mts +55 -0
  117. package/src/rules/prefer-canonical-length-cast.mts +7 -3
  118. package/src/rules/prefer-canonical-length-guard.mts +9 -4
  119. package/src/rules/prefer-comparison-over-nullish-guard.mts +3 -2
  120. package/src/rules/prefer-comparison-over-nullish-guard.test.mts +16 -0
  121. package/src/rules/prefer-is-non-null-object.mts +171 -64
  122. package/src/rules/prefer-is-non-null-object.test.mts +138 -0
  123. package/src/rules/prefer-is-record-and-has-key.mts +159 -74
  124. package/src/rules/prefer-is-record-and-has-key.test.mts +118 -23
  125. package/src/rules/prefer-num-safe-parse-float.mts +15 -10
  126. package/src/rules/prefer-num-safe-parse-int.mts +15 -13
  127. package/src/rules/prefer-obj-over-entries-round-trip.mts +19 -22
  128. package/src/rules/prefer-range-for-loop.mts +157 -31
  129. package/src/rules/prefer-range-for-loop.test.mts +127 -5
  130. package/src/rules/rules.mts +2 -0
  131. package/dist/rules/import-utils.d.mts +0 -30
  132. package/dist/rules/import-utils.d.mts.map +0 -1
  133. package/dist/rules/import-utils.mjs +0 -98
  134. package/dist/rules/import-utils.mjs.map +0 -1
  135. package/src/rules/import-utils.mts +0 -175
@@ -3,14 +3,17 @@ 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';
8
- import { typeWrapperLayers } from './ast-utils.mjs';
9
6
  import {
10
- buildImportFixes,
7
+ buildNamedImportFix,
8
+ getArgumentText,
9
+ getImportDeclarations,
11
10
  getNamedImports,
12
- getTsDataForgeImport,
13
- } from './import-utils.mjs';
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';
16
+ import { TS_DATA_FORGE_MODULE } from './constants.mjs';
14
17
 
15
18
  type Options = readonly [];
16
19
 
@@ -39,37 +42,45 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
39
42
 
40
43
  const program = sourceCode.ast;
41
44
 
42
- const tsDataForgeImport = getTsDataForgeImport(program);
43
-
44
- const services = sourceCode.parserServices;
45
-
46
- const checker = services?.program?.getTypeChecker();
45
+ const tsDataForgeImports = getImportDeclarations(
46
+ program,
47
+ TS_DATA_FORGE_MODULE,
48
+ );
47
49
 
48
50
  /**
49
51
  * Whether the `isRecord(...)` half of the rewrite would be dead code
50
52
  * because the object already satisfies `hasKey`'s
51
- * `R extends UnknownRecord` constraint. Without type information nothing
52
- * is known, so the guard is kept.
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.
53
58
  */
54
59
  const isKnownRecord = (
55
60
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
56
61
  expression: TSESTree.Expression,
57
- ): boolean => {
58
- if (checker === undefined) {
59
- return false;
60
- }
61
-
62
- // At every layer of type wrappers (see typeWrapperLayers): an array
63
- // cast to `Record<string, unknown>` still needs its `isRecord`.
64
- return typeWrapperLayers(expression).every((layer) => {
65
- const tsNode = services?.esTreeNodeToTSNodeMap?.get(layer);
66
-
67
- return (
68
- tsNode !== undefined &&
69
- isAlreadyRecordType(checker, checker.getTypeAtLocation(tsNode))
70
- );
71
- });
72
- };
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
+ );
73
84
 
74
85
  const mut_nodesToFix: {
75
86
  node: TSESTree.CallExpression | TSESTree.BinaryExpression;
@@ -80,12 +91,13 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
80
91
  return {
81
92
  // Handle Object.hasOwn(obj, key)
82
93
  CallExpression: (node) => {
94
+ const callee = skipTypeWrappers(node.callee);
95
+
83
96
  if (
84
- node.callee.type !== AST_NODE_TYPES.MemberExpression ||
85
- node.callee.object.type !== AST_NODE_TYPES.Identifier ||
86
- node.callee.object.name !== 'Object' ||
87
- node.callee.property.type !== AST_NODE_TYPES.Identifier ||
88
- node.callee.property.name !== 'hasOwn'
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'
89
101
  ) {
90
102
  return;
91
103
  }
@@ -106,7 +118,8 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
106
118
  // Type guard: ensure arguments are Expressions (not SpreadElement)
107
119
  if (
108
120
  objArg.type === AST_NODE_TYPES.SpreadElement ||
109
- keyArg.type === AST_NODE_TYPES.SpreadElement
121
+ keyArg.type === AST_NODE_TYPES.SpreadElement ||
122
+ mayBeArray(objArg)
110
123
  ) {
111
124
  return;
112
125
  }
@@ -129,7 +142,10 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
129
142
  const objExpression = node.right;
130
143
 
131
144
  // Type guard: ensure keyExpression is Expression (not PrivateIdentifier)
132
- if (keyExpression.type === AST_NODE_TYPES.PrivateIdentifier) {
145
+ if (
146
+ keyExpression.type === AST_NODE_TYPES.PrivateIdentifier ||
147
+ mayBeArray(objExpression)
148
+ ) {
133
149
  return;
134
150
  }
135
151
 
@@ -141,30 +157,43 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
141
157
  },
142
158
 
143
159
  'Program:exit': () => {
144
- const namedImports = getNamedImports(tsDataForgeImport);
160
+ const namedImports = getNamedImports(tsDataForgeImports);
145
161
 
146
- const rewrites = mut_nodesToFix.map((entry) => ({
147
- ...entry,
162
+ const rewrites = mut_nodesToFix.map((entry) => {
148
163
  // A record-typed object makes the `isRecord(...)` conjunct always
149
164
  // true, so it is left out of the replacement entirely.
150
- needsIsRecord: !isKnownRecord(entry.objExpression),
151
- }));
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
+ });
152
174
 
153
- const importsToAdd: readonly string[] = [
154
- ...(rewrites.some(({ needsIsRecord }) => needsIsRecord) &&
155
- !namedImports.includes('isRecord')
156
- ? ['isRecord']
157
- : []),
158
- ...(namedImports.includes('hasKey') ? [] : ['hasKey']),
159
- ];
175
+ const fixableRewrites = rewrites.filter(({ fixable }) => fixable);
160
176
 
161
- for (const [
162
- index,
163
- { node, objExpression, keyExpression, needsIsRecord },
164
- ] of rewrites.entries()) {
165
- const objText = sourceCode.getText(objExpression);
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
+ ];
166
187
 
167
- const keyText = sourceCode.getText(keyExpression);
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);
168
197
 
169
198
  const originalText = sourceCode.getText(node);
170
199
 
@@ -176,26 +205,32 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
176
205
  objName: objText,
177
206
  keyName: keyText,
178
207
  },
179
- fix: (fixer) => {
180
- const replacement = needsIsRecord
181
- ? `(isRecord(${objText}) && hasKey(${objText}, ${keyText}))`
182
- : `hasKey(${objText}, ${keyText})`;
183
-
184
- // All import fixes have to live on the first report:
185
- // `insertTextBefore(program, …)` produces overlapping ranges
186
- // otherwise and only one of them would survive a pass.
187
- const importFixes =
188
- index === 0 && importsToAdd.length > 0
189
- ? buildImportFixes(
190
- fixer,
191
- program,
192
- tsDataForgeImport,
193
- importsToAdd,
194
- )
195
- : [];
196
-
197
- return [...importFixes, fixer.replaceText(node, replacement)];
198
- },
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
+ },
199
234
  });
200
235
  }
201
236
  },
@@ -204,6 +239,56 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
204
239
  defaultOptions: [],
205
240
  } as const;
206
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
+
207
292
  /**
208
293
  * Whether `type` already satisfies `hasKey`'s `R extends UnknownRecord`
209
294
  * constraint, so that guarding the call with `isRecord` adds nothing.
@@ -62,19 +62,6 @@ describe('prefer-is-record-and-has-key', () => {
62
62
  `,
63
63
  errors: [{ messageId: 'useHasKey' }],
64
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
65
  {
79
66
  name: 'keeps isRecord for an index-signature-carrying callable',
80
67
  code: dedent`
@@ -295,28 +282,136 @@ describe('prefer-is-record-and-has-key through type wrappers', () => {
295
282
  valid: [],
296
283
  invalid: [
297
284
  {
298
- name: 'keeps isRecord for an array cast to a record: the value is still an array',
285
+ name: 'reads `Object` through `as`',
299
286
  code: dedent`
300
- declare const arr: readonly string[];
301
- const ok = Object.hasOwn(arr as unknown as Record<string, unknown>, '0');
287
+ declare const obj: Readonly<Record<string, unknown>>;
288
+ const ok = (Object as ObjectConstructor).hasOwn(obj, 'a');
302
289
  `,
303
290
  output: dedent`
304
- import { isRecord, hasKey } from 'ts-data-forge';
291
+ import { hasKey } from 'ts-data-forge';
292
+ declare const obj: Readonly<Record<string, unknown>>;
293
+ const ok = hasKey(obj, 'a');
294
+ `,
295
+ errors: [{ messageId: 'useHasKey' }],
296
+ },
297
+ ],
298
+ });
299
+ });
300
+
301
+ describe('prefer-is-record-and-has-key with parenthesized operands', () => {
302
+ tester.run('prefer-is-record-and-has-key', preferIsRecordAndHasKey, {
303
+ valid: [],
304
+ invalid: [
305
+ {
306
+ name: 'keeps the parentheses of a sequence object',
307
+ code: dedent`
308
+ declare const a: number;
309
+ declare const o: Readonly<Record<string, unknown>>;
310
+ const ok = 'k' in (a, o);
311
+ `,
312
+ output: dedent`
313
+ import { hasKey } from 'ts-data-forge';
314
+ declare const a: number;
315
+ declare const o: Readonly<Record<string, unknown>>;
316
+ const ok = hasKey((a, o), 'k');
317
+ `,
318
+ errors: [{ messageId: 'useHasKey' }],
319
+ },
320
+ {
321
+ name: 'keeps the parentheses of a sequence key',
322
+ code: dedent`
323
+ declare const a: number;
324
+ declare const o: Readonly<Record<string, unknown>>;
325
+ const ok = (a, 'k') in o;
326
+ `,
327
+ output: dedent`
328
+ import { hasKey } from 'ts-data-forge';
329
+ declare const a: number;
330
+ declare const o: Readonly<Record<string, unknown>>;
331
+ const ok = hasKey(o, (a, 'k'));
332
+ `,
333
+ errors: [{ messageId: 'useHasKey' }],
334
+ },
335
+ ],
336
+ });
337
+ });
338
+
339
+ describe('prefer-is-record-and-has-key where the rewrite would change behavior', () => {
340
+ tester.run('prefer-is-record-and-has-key', preferIsRecordAndHasKey, {
341
+ valid: [
342
+ {
343
+ // `isRecord` rejects an array, so `'0' in xs` would turn false.
344
+ name: 'an array',
345
+ code: dedent`
346
+ declare const xs: readonly number[];
347
+ const a = '0' in xs;
348
+ const b = Object.hasOwn(xs, 'length');
349
+ `,
350
+ },
351
+ {
352
+ name: 'a union with an array',
353
+ code: dedent`
354
+ declare const v: Readonly<Record<string, unknown>> | readonly number[];
355
+ const ok = 'k' in v;
356
+ `,
357
+ },
358
+ {
359
+ name: 'an array cast to a record',
360
+ code: dedent`
305
361
  declare const arr: readonly string[];
306
- const ok = (isRecord(arr as unknown as Record<string, unknown>) && hasKey(arr as unknown as Record<string, unknown>, '0'));
362
+ const ok = Object.hasOwn(arr as unknown as Record<string, unknown>, '0');
307
363
  `,
308
- errors: [{ messageId: 'useIsRecordAndHasKey' }],
309
364
  },
310
365
  {
311
- name: 'keeps isRecord for a record widened to include an array: the author says it may be one',
366
+ name: 'a record widened by `as` to one that may be an array: the author says it may be one',
312
367
  code: dedent`
313
368
  declare const obj: Readonly<Record<string, unknown>>;
314
- const ok = Object.hasOwn(obj as Readonly<Record<string, unknown>> | readonly unknown[], 'a');
369
+ const ok = Object.hasOwn(obj as Readonly<Record<string, unknown>> | readonly string[], 'a');
370
+ `,
371
+ },
372
+ ],
373
+ invalid: [
374
+ {
375
+ // `isRecord(…) && hasKey(…)` would evaluate the object twice.
376
+ name: 'an object with side effects is reported without a fix',
377
+ code: dedent`
378
+ declare const getObj: () => object;
379
+ declare const log: () => void;
380
+ declare const o: object;
381
+ const a = 'k' in getObj();
382
+ const b = Object.hasOwn(getObj(), 'k');
383
+ const c = 'k' in (log(), o);
384
+ `,
385
+ output: null,
386
+ errors: [
387
+ { messageId: 'useIsRecordAndHasKey' },
388
+ { messageId: 'useIsRecordAndHasKey' },
389
+ { messageId: 'useIsRecordAndHasKey' },
390
+ ],
391
+ },
392
+ {
393
+ name: 'an object with side effects is fixed when isRecord is dropped: it is read once',
394
+ code: dedent`
395
+ declare const getRec: () => Readonly<Record<string, unknown>>;
396
+ const ok = 'k' in getRec();
397
+ `,
398
+ output: dedent`
399
+ import { hasKey } from 'ts-data-forge';
400
+ declare const getRec: () => Readonly<Record<string, unknown>>;
401
+ const ok = hasKey(getRec(), 'k');
402
+ `,
403
+ errors: [{ messageId: 'useHasKey' }],
404
+ },
405
+ {
406
+ name: 'a reference read through type wrappers is still read without side effects',
407
+ code: dedent`
408
+ declare const o: { readonly p: object };
409
+ const ok = 'k' in (o.p satisfies object);
315
410
  `,
316
411
  output: dedent`
317
412
  import { isRecord, hasKey } from 'ts-data-forge';
318
- declare const obj: Readonly<Record<string, unknown>>;
319
- const ok = (isRecord(obj as Readonly<Record<string, unknown>> | readonly unknown[]) && hasKey(obj as Readonly<Record<string, unknown>> | readonly unknown[], 'a'));
413
+ declare const o: { readonly p: object };
414
+ const ok = (isRecord(o.p satisfies object) && hasKey(o.p satisfies object, 'k'));
320
415
  `,
321
416
  errors: [{ messageId: 'useIsRecordAndHasKey' }],
322
417
  },
@@ -3,13 +3,15 @@ import {
3
3
  type TSESLint,
4
4
  type TSESTree,
5
5
  } from '@typescript-eslint/utils';
6
- import * as ts from 'typescript';
7
- import { toArgumentText, typeWrapperLayers } from './ast-utils.mjs';
8
6
  import {
9
- buildImportFixes,
7
+ buildNamedImportFix,
8
+ getArgumentText,
9
+ getImportDeclarations,
10
10
  getNamedImports,
11
- getTsDataForgeImport,
12
- } from './import-utils.mjs';
11
+ typeWrapperLayers,
12
+ } from 'eslint-rule-forge';
13
+ import * as ts from 'typescript';
14
+ import { TS_DATA_FORGE_MODULE } from './constants.mjs';
13
15
 
14
16
  type Options = readonly [];
15
17
 
@@ -62,7 +64,10 @@ export const preferNumSafeParseFloat: TSESLint.RuleModule<MessageIds, Options> =
62
64
 
63
65
  const program = sourceCode.ast;
64
66
 
65
- const tsDataForgeImport = getTsDataForgeImport(program);
67
+ const tsDataForgeImports = getImportDeclarations(
68
+ program,
69
+ TS_DATA_FORGE_MODULE,
70
+ );
66
71
 
67
72
  const services = context.sourceCode.parserServices;
68
73
 
@@ -145,7 +150,7 @@ export const preferNumSafeParseFloat: TSESLint.RuleModule<MessageIds, Options> =
145
150
  mut_nodesToFix.push({ node, argExpression: firstArg });
146
151
  },
147
152
  'Program:exit': () => {
148
- const namedImports = getNamedImports(tsDataForgeImport);
153
+ const namedImports = getNamedImports(tsDataForgeImports);
149
154
 
150
155
  const missingImports = (['Num', 'Result'] as const).filter(
151
156
  (name) => !namedImports.includes(name),
@@ -155,7 +160,7 @@ export const preferNumSafeParseFloat: TSESLint.RuleModule<MessageIds, Options> =
155
160
  index,
156
161
  { node, argExpression },
157
162
  ] of mut_nodesToFix.entries()) {
158
- const argText = toArgumentText(argExpression, sourceCode);
163
+ const argText = getArgumentText(sourceCode, argExpression);
159
164
 
160
165
  const originalText = sourceCode.getText(node);
161
166
 
@@ -171,10 +176,10 @@ export const preferNumSafeParseFloat: TSESLint.RuleModule<MessageIds, Options> =
171
176
 
172
177
  const importFixes =
173
178
  index === 0 && missingImports.length > 0
174
- ? buildImportFixes(
179
+ ? buildNamedImportFix(
175
180
  fixer,
176
181
  program,
177
- tsDataForgeImport,
182
+ TS_DATA_FORGE_MODULE,
178
183
  missingImports,
179
184
  )
180
185
  : [];
@@ -3,17 +3,16 @@ import {
3
3
  type TSESLint,
4
4
  type TSESTree,
5
5
  } from '@typescript-eslint/utils';
6
- import * as ts from 'typescript';
7
6
  import {
7
+ buildNamedImportFix,
8
+ getArgumentText,
9
+ getImportDeclarations,
10
+ getNamedImports,
8
11
  skipTypeWrappers,
9
- toArgumentText,
10
12
  typeWrapperLayers,
11
- } from './ast-utils.mjs';
12
- import {
13
- buildImportFixes,
14
- getNamedImports,
15
- getTsDataForgeImport,
16
- } from './import-utils.mjs';
13
+ } from 'eslint-rule-forge';
14
+ import * as ts from 'typescript';
15
+ import { TS_DATA_FORGE_MODULE } from './constants.mjs';
17
16
 
18
17
  type Options = readonly [];
19
18
 
@@ -63,7 +62,10 @@ export const preferNumSafeParseInt: TSESLint.RuleModule<MessageIds, Options> = {
63
62
 
64
63
  const program = sourceCode.ast;
65
64
 
66
- const tsDataForgeImport = getTsDataForgeImport(program);
65
+ const tsDataForgeImports = getImportDeclarations(
66
+ program,
67
+ TS_DATA_FORGE_MODULE,
68
+ );
67
69
 
68
70
  const services = context.sourceCode.parserServices;
69
71
 
@@ -147,7 +149,7 @@ export const preferNumSafeParseInt: TSESLint.RuleModule<MessageIds, Options> = {
147
149
  mut_nodesToFix.push({ node, argExpression: firstArg });
148
150
  },
149
151
  'Program:exit': () => {
150
- const namedImports = getNamedImports(tsDataForgeImport);
152
+ const namedImports = getNamedImports(tsDataForgeImports);
151
153
 
152
154
  const missingImports = (['Num', 'Result'] as const).filter(
153
155
  (name) => !namedImports.includes(name),
@@ -157,7 +159,7 @@ export const preferNumSafeParseInt: TSESLint.RuleModule<MessageIds, Options> = {
157
159
  index,
158
160
  { node, argExpression },
159
161
  ] of mut_nodesToFix.entries()) {
160
- const argText = toArgumentText(argExpression, sourceCode);
162
+ const argText = getArgumentText(sourceCode, argExpression);
161
163
 
162
164
  const originalText = sourceCode.getText(node);
163
165
 
@@ -173,10 +175,10 @@ export const preferNumSafeParseInt: TSESLint.RuleModule<MessageIds, Options> = {
173
175
 
174
176
  const importFixes =
175
177
  index === 0 && missingImports.length > 0
176
- ? buildImportFixes(
178
+ ? buildNamedImportFix(
177
179
  fixer,
178
180
  program,
179
- tsDataForgeImport,
181
+ TS_DATA_FORGE_MODULE,
180
182
  missingImports,
181
183
  )
182
184
  : [];