eslint-plugin-ts-data-forge 0.8.1 → 0.8.3

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 (139) hide show
  1. package/dist/plugin.d.mts +1 -1
  2. package/dist/plugin.d.mts.map +1 -1
  3. package/dist/plugin.mjs +1 -0
  4. package/dist/plugin.mjs.map +1 -1
  5. package/dist/rule-types.d.mts +3 -3
  6. package/dist/rule-types.d.mts.map +1 -1
  7. package/dist/rules/ast-utils.d.mts +59 -3
  8. package/dist/rules/ast-utils.d.mts.map +1 -1
  9. package/dist/rules/ast-utils.mjs +142 -31
  10. package/dist/rules/ast-utils.mjs.map +1 -1
  11. package/dist/rules/branded-number-types.mjs.map +1 -1
  12. package/dist/rules/import-utils.d.mts +1 -1
  13. package/dist/rules/import-utils.d.mts.map +1 -1
  14. package/dist/rules/import-utils.mjs +26 -13
  15. package/dist/rules/import-utils.mjs.map +1 -1
  16. package/dist/rules/no-side-effect-import.d.mts +1 -1
  17. package/dist/rules/no-side-effect-import.d.mts.map +1 -1
  18. package/dist/rules/no-unnecessary-type-guard.d.mts.map +1 -1
  19. package/dist/rules/no-unnecessary-type-guard.mjs +161 -101
  20. package/dist/rules/no-unnecessary-type-guard.mjs.map +1 -1
  21. package/dist/rules/prefer-arr-is-array.d.mts.map +1 -1
  22. package/dist/rules/prefer-arr-is-array.mjs +25 -11
  23. package/dist/rules/prefer-arr-is-array.mjs.map +1 -1
  24. package/dist/rules/prefer-arr-is-bounded-length-array.d.mts +1 -1
  25. package/dist/rules/prefer-arr-is-bounded-length-array.d.mts.map +1 -1
  26. package/dist/rules/prefer-arr-is-bounded-length-array.mjs +11 -21
  27. package/dist/rules/prefer-arr-is-bounded-length-array.mjs.map +1 -1
  28. package/dist/rules/prefer-arr-is-fixed-length-array.d.mts +1 -1
  29. package/dist/rules/prefer-arr-is-fixed-length-array.d.mts.map +1 -1
  30. package/dist/rules/prefer-arr-is-fixed-length-array.mjs +16 -28
  31. package/dist/rules/prefer-arr-is-fixed-length-array.mjs.map +1 -1
  32. package/dist/rules/prefer-arr-is-max-length-array.d.mts +1 -1
  33. package/dist/rules/prefer-arr-is-max-length-array.d.mts.map +1 -1
  34. package/dist/rules/prefer-arr-is-max-length-array.mjs +16 -24
  35. package/dist/rules/prefer-arr-is-max-length-array.mjs.map +1 -1
  36. package/dist/rules/prefer-arr-is-min-length-array.d.mts +1 -1
  37. package/dist/rules/prefer-arr-is-min-length-array.d.mts.map +1 -1
  38. package/dist/rules/prefer-arr-is-min-length-array.mjs +16 -24
  39. package/dist/rules/prefer-arr-is-min-length-array.mjs.map +1 -1
  40. package/dist/rules/prefer-arr-is-non-empty.d.mts.map +1 -1
  41. package/dist/rules/prefer-arr-is-non-empty.mjs +9 -22
  42. package/dist/rules/prefer-arr-is-non-empty.mjs.map +1 -1
  43. package/dist/rules/prefer-arr-scan.d.mts +6 -5
  44. package/dist/rules/prefer-arr-scan.d.mts.map +1 -1
  45. package/dist/rules/prefer-arr-scan.mjs +68 -31
  46. package/dist/rules/prefer-arr-scan.mjs.map +1 -1
  47. package/dist/rules/prefer-arr-sum.d.mts.map +1 -1
  48. package/dist/rules/prefer-arr-sum.mjs +57 -40
  49. package/dist/rules/prefer-arr-sum.mjs.map +1 -1
  50. package/dist/rules/prefer-arr-uniq.d.mts.map +1 -1
  51. package/dist/rules/prefer-arr-uniq.mjs +59 -30
  52. package/dist/rules/prefer-arr-uniq.mjs.map +1 -1
  53. package/dist/rules/prefer-as-int.d.mts.map +1 -1
  54. package/dist/rules/prefer-as-int.mjs +27 -24
  55. package/dist/rules/prefer-as-int.mjs.map +1 -1
  56. package/dist/rules/prefer-canonical-array-slicing.d.mts.map +1 -1
  57. package/dist/rules/prefer-canonical-array-slicing.mjs +59 -28
  58. package/dist/rules/prefer-canonical-array-slicing.mjs.map +1 -1
  59. package/dist/rules/prefer-canonical-length-cast.d.mts.map +1 -1
  60. package/dist/rules/prefer-canonical-length-cast.mjs +41 -16
  61. package/dist/rules/prefer-canonical-length-cast.mjs.map +1 -1
  62. package/dist/rules/prefer-canonical-length-guard.d.mts.map +1 -1
  63. package/dist/rules/prefer-canonical-length-guard.mjs +18 -8
  64. package/dist/rules/prefer-canonical-length-guard.mjs.map +1 -1
  65. package/dist/rules/prefer-comparison-over-nullish-guard.d.mts.map +1 -1
  66. package/dist/rules/prefer-comparison-over-nullish-guard.mjs +31 -6
  67. package/dist/rules/prefer-comparison-over-nullish-guard.mjs.map +1 -1
  68. package/dist/rules/prefer-is-non-null-object.d.mts.map +1 -1
  69. package/dist/rules/prefer-is-non-null-object.mjs +56 -40
  70. package/dist/rules/prefer-is-non-null-object.mjs.map +1 -1
  71. package/dist/rules/prefer-is-record-and-has-key.d.mts.map +1 -1
  72. package/dist/rules/prefer-is-record-and-has-key.mjs +18 -8
  73. package/dist/rules/prefer-is-record-and-has-key.mjs.map +1 -1
  74. package/dist/rules/prefer-num-safe-parse-float.d.mts +3 -1
  75. package/dist/rules/prefer-num-safe-parse-float.d.mts.map +1 -1
  76. package/dist/rules/prefer-num-safe-parse-float.mjs +27 -10
  77. package/dist/rules/prefer-num-safe-parse-float.mjs.map +1 -1
  78. package/dist/rules/prefer-num-safe-parse-int.d.mts.map +1 -1
  79. package/dist/rules/prefer-num-safe-parse-int.mjs +24 -11
  80. package/dist/rules/prefer-num-safe-parse-int.mjs.map +1 -1
  81. package/dist/rules/prefer-obj-over-entries-round-trip.d.mts.map +1 -1
  82. package/dist/rules/prefer-obj-over-entries-round-trip.mjs +22 -11
  83. package/dist/rules/prefer-obj-over-entries-round-trip.mjs.map +1 -1
  84. package/dist/rules/prefer-range-for-loop.d.mts.map +1 -1
  85. package/dist/rules/prefer-range-for-loop.mjs +15 -8
  86. package/dist/rules/prefer-range-for-loop.mjs.map +1 -1
  87. package/dist/types.d.mts +2 -2
  88. package/dist/types.d.mts.map +1 -1
  89. package/package.json +25 -23
  90. package/src/plugin.mts +5 -4
  91. package/src/plugin.test.mts +0 -13
  92. package/src/rule-types.mts +3 -3
  93. package/src/rules/ast-utils.mts +192 -37
  94. package/src/rules/branded-number-types.mts +1 -1
  95. package/src/rules/import-utils.mts +41 -17
  96. package/src/rules/no-side-effect-import.mts +1 -1
  97. package/src/rules/no-unnecessary-type-guard.mts +249 -148
  98. package/src/rules/no-unnecessary-type-guard.test.mts +100 -0
  99. package/src/rules/prefer-arr-is-array.mts +32 -13
  100. package/src/rules/prefer-arr-is-array.test.mts +40 -0
  101. package/src/rules/prefer-arr-is-bounded-length-array.mts +22 -26
  102. package/src/rules/prefer-arr-is-bounded-length-array.test.mts +68 -0
  103. package/src/rules/prefer-arr-is-fixed-length-array.mts +26 -38
  104. package/src/rules/prefer-arr-is-fixed-length-array.test.mts +63 -0
  105. package/src/rules/prefer-arr-is-max-length-array.mts +26 -27
  106. package/src/rules/prefer-arr-is-max-length-array.test.mts +67 -0
  107. package/src/rules/prefer-arr-is-min-length-array.mts +26 -27
  108. package/src/rules/prefer-arr-is-min-length-array.test.mts +70 -0
  109. package/src/rules/prefer-arr-is-non-empty.mts +19 -24
  110. package/src/rules/prefer-arr-is-non-empty.test.mts +79 -0
  111. package/src/rules/prefer-arr-scan.mts +107 -37
  112. package/src/rules/prefer-arr-scan.test.mts +149 -0
  113. package/src/rules/prefer-arr-sum.mts +87 -50
  114. package/src/rules/prefer-arr-sum.test.mts +97 -0
  115. package/src/rules/prefer-arr-uniq.mts +83 -33
  116. package/src/rules/prefer-arr-uniq.test.mts +71 -0
  117. package/src/rules/prefer-as-int.mts +38 -38
  118. package/src/rules/prefer-as-int.test.mts +51 -0
  119. package/src/rules/prefer-canonical-array-slicing.mts +84 -29
  120. package/src/rules/prefer-canonical-array-slicing.test.mts +92 -0
  121. package/src/rules/prefer-canonical-length-cast.mts +56 -18
  122. package/src/rules/prefer-canonical-length-cast.test.mts +111 -0
  123. package/src/rules/prefer-canonical-length-guard.mts +25 -11
  124. package/src/rules/prefer-canonical-length-guard.test.mts +99 -0
  125. package/src/rules/prefer-comparison-over-nullish-guard.mts +42 -9
  126. package/src/rules/prefer-comparison-over-nullish-guard.test.mts +136 -0
  127. package/src/rules/prefer-is-non-null-object.mts +57 -33
  128. package/src/rules/prefer-is-non-null-object.test.mts +42 -0
  129. package/src/rules/prefer-is-record-and-has-key.mts +27 -12
  130. package/src/rules/prefer-is-record-and-has-key.test.mts +34 -0
  131. package/src/rules/prefer-num-safe-parse-float.mts +37 -13
  132. package/src/rules/prefer-num-safe-parse-float.test.mts +79 -0
  133. package/src/rules/prefer-num-safe-parse-int.mts +41 -15
  134. package/src/rules/prefer-num-safe-parse-int.test.mts +64 -0
  135. package/src/rules/prefer-obj-over-entries-round-trip.mts +34 -12
  136. package/src/rules/prefer-range-for-loop.mts +21 -9
  137. package/src/rules/prefer-range-for-loop.test.mts +75 -0
  138. package/src/rules/rules.mts +1 -1
  139. package/src/types.mts +2 -2
@@ -305,3 +305,102 @@ describe('prefer-canonical-length-guard', () => {
305
305
  ],
306
306
  });
307
307
  });
308
+
309
+ describe('prefer-canonical-length-guard through type wrappers', () => {
310
+ tester.run('prefer-canonical-length-guard', preferCanonicalLengthGuard, {
311
+ valid: [
312
+ {
313
+ name: 'a wrapped bound other than the degenerate one',
314
+ code: dedent`
315
+ import { Arr } from 'ts-data-forge';
316
+
317
+ declare const xs: readonly number[];
318
+ const ok = Arr.isFixedLengthArray(1 as const, xs);
319
+ `,
320
+ },
321
+ ],
322
+ invalid: [
323
+ {
324
+ name: 'a bound wrapped in `as const` or `satisfies`',
325
+ code: dedent`
326
+ import { Arr } from 'ts-data-forge';
327
+
328
+ declare const xs: readonly number[];
329
+ const a = Arr.isFixedLengthArray(0 as const, xs);
330
+ const b = Arr.isBoundedLengthTuple(0 satisfies number, 0, xs!);
331
+ `,
332
+ output: dedent`
333
+ import { Arr } from 'ts-data-forge';
334
+
335
+ declare const xs: readonly number[];
336
+ const a = Arr.isEmpty(xs);
337
+ const b = Arr.isEmptyTuple(xs!);
338
+ `,
339
+ errors: [
340
+ { messageId: 'useCanonicalGuard' },
341
+ { messageId: 'useCanonicalGuard' },
342
+ ],
343
+ },
344
+ ],
345
+ });
346
+
347
+ typedTester.run('prefer-canonical-length-guard', preferCanonicalLengthGuard, {
348
+ valid: [
349
+ {
350
+ name: 'a computed index named `length` is not the length',
351
+ code: dedent`
352
+ declare const xs: readonly number[];
353
+ declare const length: number;
354
+ const ok = xs[length] > 0;
355
+ `,
356
+ },
357
+ ],
358
+ invalid: [
359
+ {
360
+ name: 'a folded-in comparison on a wrapped length',
361
+ code: dedent`
362
+ import { Arr } from 'ts-data-forge';
363
+ declare const xs: readonly number[];
364
+ const a = (xs.length satisfies number) > 0;
365
+ const b = xs.length >= 1 && (xs as readonly number[]).length <= 3;
366
+ `,
367
+ output: dedent`
368
+ import { Arr } from 'ts-data-forge';
369
+ declare const xs: readonly number[];
370
+ const a = Arr.isNonEmpty(xs);
371
+ const b = Arr.isBoundedLengthArray(1, 3, xs);
372
+ `,
373
+ errors: [
374
+ { messageId: 'useIsNonEmpty' },
375
+ { messageId: 'useIsBoundedLengthArray' },
376
+ ],
377
+ },
378
+ ],
379
+ });
380
+ });
381
+
382
+ describe('prefer-canonical-length-guard with parenthesized operands', () => {
383
+ tester.run('prefer-canonical-length-guard', preferCanonicalLengthGuard, {
384
+ valid: [],
385
+ invalid: [
386
+ {
387
+ name: 'a sequence array keeps its parentheses',
388
+ code: dedent`
389
+ import { Arr } from 'ts-data-forge';
390
+
391
+ declare const xs: readonly number[];
392
+ declare const log: () => void;
393
+ const ok = Arr.isFixedLengthArray(0, (log(), xs));
394
+ `,
395
+ output: dedent`
396
+ import { Arr } from 'ts-data-forge';
397
+
398
+ declare const xs: readonly number[];
399
+ declare const log: () => void;
400
+ const ok = Arr.isEmpty((log(), xs));
401
+ `,
402
+ errors: [{ messageId: 'useCanonicalGuard' }],
403
+ },
404
+ ],
405
+ });
406
+ });
@@ -3,7 +3,7 @@ import {
3
3
  type TSESLint,
4
4
  type TSESTree,
5
5
  } from '@typescript-eslint/utils';
6
- import { type DeepReadonly, type ReadonlyRecord } from 'ts-type-forge';
6
+ import type { DeepReadonly, ReadonlyRecord } from 'ts-type-forge';
7
7
  import { buildCalleeResolver, getTsDataForgeImport } from './import-utils.mjs';
8
8
 
9
9
  type Options = readonly [];
@@ -29,7 +29,11 @@ const COMPARISON_GUARDS: ReadonlyRecord<string, ComparisonSpec> = {
29
29
 
30
30
  /**
31
31
  * Argument expression kinds whose precedence is lower than equality, so they
32
- * must be parenthesized when placed on the left of `===` / `!==`.
32
+ * must be parenthesized when placed on the left of `===` / `!==`. A node's text
33
+ * never includes the parentheses the source wrote around it, so this is decided
34
+ * on the kind alone. `as` / `satisfies` and a nested equality would parse as
35
+ * intended, but are parenthesized so that the type or the grouping is not read
36
+ * as part of the comparison.
33
37
  */
34
38
  const ARG_NEEDS_PARENS: ReadonlySet<string> = new Set([
35
39
  AST_NODE_TYPES.LogicalExpression,
@@ -38,11 +42,33 @@ const ARG_NEEDS_PARENS: ReadonlySet<string> = new Set([
38
42
  AST_NODE_TYPES.SequenceExpression,
39
43
  AST_NODE_TYPES.ArrowFunctionExpression,
40
44
  AST_NODE_TYPES.YieldExpression,
45
+ AST_NODE_TYPES.TSAsExpression,
46
+ AST_NODE_TYPES.TSSatisfiesExpression,
41
47
  ]);
42
48
 
49
+ /** Binary operators that bind no tighter than `===` / `!==`. */
50
+ const LOOSE_BINARY_OPERATORS: ReadonlySet<string> = new Set([
51
+ '|',
52
+ '^',
53
+ '&',
54
+ '==',
55
+ '!=',
56
+ '===',
57
+ '!==',
58
+ ]);
59
+
60
+ const argumentNeedsParens = (
61
+ argument: DeepReadonly<TSESTree.Expression>,
62
+ ): boolean =>
63
+ ARG_NEEDS_PARENS.has(argument.type) ||
64
+ (argument.type === AST_NODE_TYPES.BinaryExpression &&
65
+ LOOSE_BINARY_OPERATORS.has(argument.operator));
66
+
43
67
  /**
44
68
  * Returns `true` when the comparison must be wrapped in parentheses to preserve
45
- * the original grouping given the call expression's parent context.
69
+ * the original grouping given the call expression's parent context. A type
70
+ * wrapper around the call is one such context: `x === null satisfies boolean`
71
+ * would apply the wrapper to `null`.
46
72
  */
47
73
  const needsWrappingInParent = (
48
74
  node: DeepReadonly<TSESTree.CallExpression>,
@@ -56,6 +82,9 @@ const needsWrappingInParent = (
56
82
  case AST_NODE_TYPES.AwaitExpression:
57
83
  case AST_NODE_TYPES.BinaryExpression:
58
84
  case AST_NODE_TYPES.TSNonNullExpression:
85
+ case AST_NODE_TYPES.TSAsExpression:
86
+ case AST_NODE_TYPES.TSSatisfiesExpression:
87
+ case AST_NODE_TYPES.TSTypeAssertion:
59
88
  return true;
60
89
 
61
90
  case AST_NODE_TYPES.MemberExpression:
@@ -97,7 +126,9 @@ export const preferComparisonOverNullishGuard: TSESLint.RuleModule<
97
126
 
98
127
  return {
99
128
  CallExpression: (node) => {
100
- if (node.arguments.length !== 1) return;
129
+ if (node.arguments.length !== 1) {
130
+ return;
131
+ }
101
132
 
102
133
  const argument = node.arguments[0];
103
134
 
@@ -110,17 +141,19 @@ export const preferComparisonOverNullishGuard: TSESLint.RuleModule<
110
141
 
111
142
  const resolved = resolveGuard(node.callee);
112
143
 
113
- if (resolved === undefined) return;
144
+ if (resolved === undefined) {
145
+ return;
146
+ }
114
147
 
115
148
  const spec = COMPARISON_GUARDS[resolved.canonicalName];
116
149
 
117
- if (spec === undefined) return;
150
+ if (spec === undefined) {
151
+ return;
152
+ }
118
153
 
119
154
  const argRaw = sourceCode.getText(argument);
120
155
 
121
- const argText = ARG_NEEDS_PARENS.has(argument.type)
122
- ? `(${argRaw})`
123
- : argRaw;
156
+ const argText = argumentNeedsParens(argument) ? `(${argRaw})` : argRaw;
124
157
 
125
158
  const comparison = `${argText} ${spec.op} ${spec.literal}`;
126
159
 
@@ -169,3 +169,139 @@ describe('prefer-comparison-over-nullish-guard', () => {
169
169
  },
170
170
  );
171
171
  });
172
+
173
+ describe('prefer-comparison-over-nullish-guard through type wrappers', () => {
174
+ tester.run(
175
+ 'prefer-comparison-over-nullish-guard',
176
+ preferComparisonOverNullishGuard,
177
+ {
178
+ valid: [],
179
+ invalid: [
180
+ {
181
+ name: 'the call wrapped in `satisfies`, `as` or `<T>`',
182
+ code: dedent`
183
+ import { isNull, isNotUndefined } from 'ts-data-forge';
184
+ declare const x: string | null | undefined;
185
+ const a = isNull(x) satisfies boolean;
186
+ const b = isNotUndefined(x) as boolean;
187
+ const c = <boolean>isNull(x);
188
+ `,
189
+ output: dedent`
190
+ import { isNull, isNotUndefined } from 'ts-data-forge';
191
+ declare const x: string | null | undefined;
192
+ const a = (x === null) satisfies boolean;
193
+ const b = (x !== undefined) as boolean;
194
+ const c = <boolean>(x === null);
195
+ `,
196
+ errors: [
197
+ { messageId: 'preferComparison' },
198
+ { messageId: 'preferComparison' },
199
+ { messageId: 'preferComparison' },
200
+ ],
201
+ },
202
+ {
203
+ name: 'a wrapped argument is parenthesized under `===`',
204
+ code: dedent`
205
+ import { isNull } from 'ts-data-forge';
206
+ declare const x: unknown;
207
+ const a = isNull(x as string | null);
208
+ const b = isNull(x satisfies unknown);
209
+ const c = isNull(x!);
210
+ `,
211
+ output: dedent`
212
+ import { isNull } from 'ts-data-forge';
213
+ declare const x: unknown;
214
+ const a = (x as string | null) === null;
215
+ const b = (x satisfies unknown) === null;
216
+ const c = x! === null;
217
+ `,
218
+ errors: [
219
+ { messageId: 'preferComparison' },
220
+ { messageId: 'preferComparison' },
221
+ { messageId: 'preferComparison' },
222
+ ],
223
+ },
224
+ {
225
+ name: 'the guard itself reached through a wrapper',
226
+ code: dedent`
227
+ import { isNull } from 'ts-data-forge';
228
+ declare const x: string | null | undefined;
229
+ const a = (isNull as (u: unknown) => boolean)(x);
230
+ `,
231
+ output: dedent`
232
+ import { isNull } from 'ts-data-forge';
233
+ declare const x: string | null | undefined;
234
+ const a = x === null;
235
+ `,
236
+ errors: [{ messageId: 'preferComparison' }],
237
+ },
238
+ {
239
+ name: 'the namespace reached through a wrapper',
240
+ code: dedent`
241
+ import * as tf from 'ts-data-forge';
242
+ declare const x: string | null | undefined;
243
+ const b = (tf as typeof tf).isNotUndefined(x);
244
+ `,
245
+ output: dedent`
246
+ import * as tf from 'ts-data-forge';
247
+ declare const x: string | null | undefined;
248
+ const b = x !== undefined;
249
+ `,
250
+ errors: [{ messageId: 'preferComparison' }],
251
+ },
252
+ ],
253
+ },
254
+ );
255
+ });
256
+
257
+ describe('prefer-comparison-over-nullish-guard with parenthesized operands', () => {
258
+ tester.run(
259
+ 'prefer-comparison-over-nullish-guard',
260
+ preferComparisonOverNullishGuard,
261
+ {
262
+ valid: [],
263
+ invalid: [
264
+ {
265
+ name: 'bitwise operators bind looser than `===`',
266
+ code: dedent`
267
+ import { isNull } from 'ts-data-forge';
268
+ declare const a: number;
269
+ declare const b: number;
270
+ const p = isNull(a | b);
271
+ const q = isNull(a ^ b);
272
+ const r = isNull((a & b));
273
+ `,
274
+ output: dedent`
275
+ import { isNull } from 'ts-data-forge';
276
+ declare const a: number;
277
+ declare const b: number;
278
+ const p = (a | b) === null;
279
+ const q = (a ^ b) === null;
280
+ const r = (a & b) === null;
281
+ `,
282
+ errors: [
283
+ { messageId: 'preferComparison' },
284
+ { messageId: 'preferComparison' },
285
+ { messageId: 'preferComparison' },
286
+ ],
287
+ },
288
+ {
289
+ name: 'an equality argument is parenthesized for readability',
290
+ code: dedent`
291
+ import { isNotUndefined } from 'ts-data-forge';
292
+ declare const a: unknown;
293
+ declare const b: unknown;
294
+ const p = isNotUndefined(a === b);
295
+ `,
296
+ output: dedent`
297
+ import { isNotUndefined } from 'ts-data-forge';
298
+ declare const a: unknown;
299
+ declare const b: unknown;
300
+ const p = (a === b) !== undefined;
301
+ `,
302
+ errors: [{ messageId: 'preferComparison' }],
303
+ },
304
+ ],
305
+ },
306
+ );
307
+ });
@@ -3,7 +3,7 @@ import {
3
3
  type TSESLint,
4
4
  type TSESTree,
5
5
  } from '@typescript-eslint/utils';
6
- import { type DeepReadonly } from 'ts-type-forge';
6
+ import { skipTypeWrappers } from './ast-utils.mjs';
7
7
  import {
8
8
  buildImportFixes,
9
9
  getNamedImports,
@@ -45,7 +45,9 @@ export const preferIsNonNullObject: TSESLint.RuleModule<MessageIds, Options> = {
45
45
  LogicalExpression: (node) => {
46
46
  const identifierName = getNonNullObjectIdentifierName(node);
47
47
 
48
- if (identifierName === undefined) return;
48
+ if (identifierName === undefined) {
49
+ return;
50
+ }
49
51
 
50
52
  mut_nodesToFix.push({ node, identifierName });
51
53
  },
@@ -86,60 +88,82 @@ export const preferIsNonNullObject: TSESLint.RuleModule<MessageIds, Options> = {
86
88
  defaultOptions: [],
87
89
  } as const;
88
90
 
91
+ /**
92
+ * The name of the value checked by `typeof u === 'object' && u !== null`, or
93
+ * `undefined`. Every operand is read through type wrappers:
94
+ * `(u satisfies unknown) !== null` and `u! !== null` check the same `u`.
95
+ */
89
96
  const getNonNullObjectIdentifierName = (
90
- node: DeepReadonly<TSESTree.LogicalExpression>,
97
+ // AST nodes hold mutable child arrays, so they are not deeply readonly.
98
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
99
+ node: TSESTree.LogicalExpression,
91
100
  ): string | undefined => {
92
- if (node.operator !== '&&') return undefined;
101
+ if (node.operator !== '&&') {
102
+ return undefined;
103
+ }
93
104
 
94
105
  const leftIdentifierName = getTypeofObjectIdentifierName(node.left);
95
106
 
96
107
  const rightIdentifierName = getNonNullCheckIdentifierName(node.right);
97
108
 
98
- if (leftIdentifierName === undefined) return undefined;
99
-
100
- if (rightIdentifierName === undefined) return undefined;
101
-
102
- if (leftIdentifierName !== rightIdentifierName) return undefined;
103
-
104
- return leftIdentifierName;
109
+ return leftIdentifierName === undefined ||
110
+ rightIdentifierName === undefined ||
111
+ leftIdentifierName !== rightIdentifierName
112
+ ? undefined
113
+ : leftIdentifierName;
105
114
  };
106
115
 
107
116
  const getTypeofObjectIdentifierName = (
108
- node: DeepReadonly<TSESTree.Expression>,
117
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
118
+ node: TSESTree.Expression,
109
119
  ): string | undefined => {
110
- if (node.type !== AST_NODE_TYPES.BinaryExpression) return undefined;
120
+ if (node.type !== AST_NODE_TYPES.BinaryExpression) {
121
+ return undefined;
122
+ }
111
123
 
112
- if (node.operator !== '===') return undefined;
124
+ if (node.operator !== '===') {
125
+ return undefined;
126
+ }
113
127
 
114
- const { left, right } = node;
128
+ const left = skipTypeWrappers(node.left);
115
129
 
116
- if (left.type !== AST_NODE_TYPES.UnaryExpression) return undefined;
130
+ const right = skipTypeWrappers(node.right);
117
131
 
118
- if (left.operator !== 'typeof') return undefined;
132
+ if (
133
+ left.type !== AST_NODE_TYPES.UnaryExpression ||
134
+ left.operator !== 'typeof' ||
135
+ right.type !== AST_NODE_TYPES.Literal ||
136
+ right.value !== 'object'
137
+ ) {
138
+ return undefined;
139
+ }
119
140
 
120
- if (right.type !== AST_NODE_TYPES.Literal) return undefined;
141
+ const argument = skipTypeWrappers(left.argument);
121
142
 
122
- if (right.value !== 'object') return undefined;
123
-
124
- if (left.argument.type !== AST_NODE_TYPES.Identifier) return undefined;
125
-
126
- return left.argument.name;
143
+ return argument.type === AST_NODE_TYPES.Identifier
144
+ ? argument.name
145
+ : undefined;
127
146
  };
128
147
 
129
148
  const getNonNullCheckIdentifierName = (
130
- node: DeepReadonly<TSESTree.Expression>,
149
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
150
+ node: TSESTree.Expression,
131
151
  ): string | undefined => {
132
- if (node.type !== AST_NODE_TYPES.BinaryExpression) return undefined;
133
-
134
- if (node.operator !== '!==') return undefined;
135
-
136
- const { left, right } = node;
152
+ if (node.type !== AST_NODE_TYPES.BinaryExpression) {
153
+ return undefined;
154
+ }
137
155
 
138
- if (left.type !== AST_NODE_TYPES.Identifier) return undefined;
156
+ if (node.operator !== '!==') {
157
+ return undefined;
158
+ }
139
159
 
140
- if (right.type !== AST_NODE_TYPES.Literal) return undefined;
160
+ const left = skipTypeWrappers(node.left);
141
161
 
142
- if (right.value !== null) return undefined;
162
+ const right = skipTypeWrappers(node.right);
143
163
 
144
- return left.name;
164
+ return left.type !== AST_NODE_TYPES.Identifier ||
165
+ right.type !== AST_NODE_TYPES.Literal ||
166
+ right.value !== null
167
+ ? undefined
168
+ : left.name;
145
169
  };
@@ -154,3 +154,45 @@ describe('prefer-is-non-null-object', () => {
154
154
  ],
155
155
  });
156
156
  }, 20000);
157
+
158
+ describe('prefer-is-non-null-object through type wrappers', () => {
159
+ tester.run('prefer-is-non-null-object', preferIsNonNullObject, {
160
+ valid: [
161
+ {
162
+ name: 'checks on two different values, one wrapped',
163
+ code: dedent`
164
+ declare const u: unknown;
165
+ declare const v: unknown;
166
+ const ok = typeof u === 'object' && (v satisfies unknown) !== null;
167
+ `,
168
+ },
169
+ ],
170
+ invalid: [
171
+ {
172
+ name: 'the value wrapped on either side',
173
+ code: dedent`
174
+ import { isNonNullObject } from 'ts-data-forge';
175
+ declare const u: unknown;
176
+ const a = typeof u === 'object' && (u satisfies unknown) !== null;
177
+ const b = typeof u === 'object' && u! !== null;
178
+ const c = typeof (u satisfies unknown) === 'object' && u !== null;
179
+ const d = typeof u === ('object' as const) && u !== (null as null);
180
+ `,
181
+ output: dedent`
182
+ import { isNonNullObject } from 'ts-data-forge';
183
+ declare const u: unknown;
184
+ const a = isNonNullObject(u);
185
+ const b = isNonNullObject(u);
186
+ const c = isNonNullObject(u);
187
+ const d = isNonNullObject(u);
188
+ `,
189
+ errors: [
190
+ { messageId: 'useIsNonNullObject' },
191
+ { messageId: 'useIsNonNullObject' },
192
+ { messageId: 'useIsNonNullObject' },
193
+ { messageId: 'useIsNonNullObject' },
194
+ ],
195
+ },
196
+ ],
197
+ });
198
+ }, 20000);
@@ -5,6 +5,7 @@ import {
5
5
  } from '@typescript-eslint/utils';
6
6
  import { Arr } from 'ts-data-forge';
7
7
  import * as ts from 'typescript';
8
+ import { typeWrapperLayers } from './ast-utils.mjs';
8
9
  import {
9
10
  buildImportFixes,
10
11
  getNamedImports,
@@ -54,14 +55,20 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
54
55
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
55
56
  expression: TSESTree.Expression,
56
57
  ): 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
- );
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
+ });
65
72
  };
66
73
 
67
74
  const mut_nodesToFix: {
@@ -84,13 +91,17 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
84
91
  }
85
92
 
86
93
  // Check arguments: Object.hasOwn(obj, key)
87
- if (node.arguments.length !== 2) return;
94
+ if (node.arguments.length !== 2) {
95
+ return;
96
+ }
88
97
 
89
98
  const objArg = node.arguments[0];
90
99
 
91
100
  const keyArg = node.arguments[1];
92
101
 
93
- if (objArg === undefined || keyArg === undefined) return;
102
+ if (objArg === undefined || keyArg === undefined) {
103
+ return;
104
+ }
94
105
 
95
106
  // Type guard: ensure arguments are Expressions (not SpreadElement)
96
107
  if (
@@ -109,14 +120,18 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
109
120
 
110
121
  // Handle key in obj
111
122
  BinaryExpression: (node) => {
112
- if (node.operator !== 'in') return;
123
+ if (node.operator !== 'in') {
124
+ return;
125
+ }
113
126
 
114
127
  const keyExpression = node.left;
115
128
 
116
129
  const objExpression = node.right;
117
130
 
118
131
  // Type guard: ensure keyExpression is Expression (not PrivateIdentifier)
119
- if (keyExpression.type === AST_NODE_TYPES.PrivateIdentifier) return;
132
+ if (keyExpression.type === AST_NODE_TYPES.PrivateIdentifier) {
133
+ return;
134
+ }
120
135
 
121
136
  mut_nodesToFix.push({
122
137
  node,
@@ -289,3 +289,37 @@ describe('prefer-is-record-and-has-key', () => {
289
289
  ],
290
290
  });
291
291
  }, 20000);
292
+
293
+ describe('prefer-is-record-and-has-key through type wrappers', () => {
294
+ tester.run('prefer-is-record-and-has-key', preferIsRecordAndHasKey, {
295
+ valid: [],
296
+ invalid: [
297
+ {
298
+ name: 'keeps isRecord for an array cast to a record: the value is still an array',
299
+ code: dedent`
300
+ declare const arr: readonly string[];
301
+ const ok = Object.hasOwn(arr as unknown as Record<string, unknown>, '0');
302
+ `,
303
+ output: dedent`
304
+ import { isRecord, hasKey } from 'ts-data-forge';
305
+ 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'));
307
+ `,
308
+ errors: [{ messageId: 'useIsRecordAndHasKey' }],
309
+ },
310
+ {
311
+ name: 'keeps isRecord for a record widened to include an array: the author says it may be one',
312
+ code: dedent`
313
+ declare const obj: Readonly<Record<string, unknown>>;
314
+ const ok = Object.hasOwn(obj as Readonly<Record<string, unknown>> | readonly unknown[], 'a');
315
+ `,
316
+ output: dedent`
317
+ 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'));
320
+ `,
321
+ errors: [{ messageId: 'useIsRecordAndHasKey' }],
322
+ },
323
+ ],
324
+ });
325
+ });