eslint-plugin-ts-data-forge 0.8.2 → 0.8.4

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/dist/index.d.mts +1 -1
  2. package/dist/index.d.mts.map +1 -1
  3. package/dist/plugin.d.mts +1 -1
  4. package/dist/plugin.d.mts.map +1 -1
  5. package/dist/plugin.mjs +1 -0
  6. package/dist/plugin.mjs.map +1 -1
  7. package/dist/rule-types.d.mts +3 -3
  8. package/dist/rule-types.d.mts.map +1 -1
  9. package/dist/rules/ast-utils.d.mts +59 -3
  10. package/dist/rules/ast-utils.d.mts.map +1 -1
  11. package/dist/rules/ast-utils.mjs +119 -8
  12. package/dist/rules/ast-utils.mjs.map +1 -1
  13. package/dist/rules/import-utils.d.mts +1 -1
  14. package/dist/rules/import-utils.d.mts.map +1 -1
  15. package/dist/rules/import-utils.mjs +22 -11
  16. package/dist/rules/import-utils.mjs.map +1 -1
  17. package/dist/rules/no-side-effect-import.d.mts +1 -1
  18. package/dist/rules/no-side-effect-import.d.mts.map +1 -1
  19. package/dist/rules/no-unnecessary-type-guard.d.mts.map +1 -1
  20. package/dist/rules/no-unnecessary-type-guard.mjs +125 -79
  21. package/dist/rules/no-unnecessary-type-guard.mjs.map +1 -1
  22. package/dist/rules/prefer-arr-is-array.mjs +11 -4
  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 +5 -19
  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 +11 -27
  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 +9 -22
  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 +9 -22
  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 +7 -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 +33 -13
  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 +51 -37
  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 +33 -10
  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 +18 -12
  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 +29 -14
  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 +34 -13
  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 +10 -4
  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 +25 -3
  67. package/dist/rules/prefer-comparison-over-nullish-guard.mjs.map +1 -1
  68. package/dist/rules/prefer-is-non-null-object.mjs +28 -10
  69. package/dist/rules/prefer-is-non-null-object.mjs.map +1 -1
  70. package/dist/rules/prefer-is-record-and-has-key.d.mts.map +1 -1
  71. package/dist/rules/prefer-is-record-and-has-key.mjs +8 -3
  72. package/dist/rules/prefer-is-record-and-has-key.mjs.map +1 -1
  73. package/dist/rules/prefer-num-safe-parse-float.d.mts +3 -1
  74. package/dist/rules/prefer-num-safe-parse-float.d.mts.map +1 -1
  75. package/dist/rules/prefer-num-safe-parse-float.mjs +18 -7
  76. package/dist/rules/prefer-num-safe-parse-float.mjs.map +1 -1
  77. package/dist/rules/prefer-num-safe-parse-int.d.mts.map +1 -1
  78. package/dist/rules/prefer-num-safe-parse-int.mjs +15 -8
  79. package/dist/rules/prefer-num-safe-parse-int.mjs.map +1 -1
  80. package/dist/rules/prefer-range-for-loop.d.mts.map +1 -1
  81. package/dist/rules/prefer-range-for-loop.mjs +13 -7
  82. package/dist/rules/prefer-range-for-loop.mjs.map +1 -1
  83. package/dist/types.d.mts +2 -2
  84. package/dist/types.d.mts.map +1 -1
  85. package/package.json +7 -7
  86. package/src/index.mts +1 -1
  87. package/src/plugin.mts +5 -4
  88. package/src/plugin.test.mts +0 -13
  89. package/src/rule-types.mts +3 -3
  90. package/src/rules/ast-utils.mts +156 -13
  91. package/src/rules/import-utils.mts +35 -15
  92. package/src/rules/no-side-effect-import.mts +1 -1
  93. package/src/rules/no-unnecessary-type-guard.mts +192 -124
  94. package/src/rules/no-unnecessary-type-guard.test.mts +100 -0
  95. package/src/rules/prefer-arr-is-array.mts +11 -6
  96. package/src/rules/prefer-arr-is-array.test.mts +40 -0
  97. package/src/rules/prefer-arr-is-bounded-length-array.mts +13 -25
  98. package/src/rules/prefer-arr-is-bounded-length-array.test.mts +68 -0
  99. package/src/rules/prefer-arr-is-fixed-length-array.mts +18 -38
  100. package/src/rules/prefer-arr-is-fixed-length-array.test.mts +63 -0
  101. package/src/rules/prefer-arr-is-max-length-array.mts +15 -26
  102. package/src/rules/prefer-arr-is-max-length-array.test.mts +67 -0
  103. package/src/rules/prefer-arr-is-min-length-array.mts +15 -26
  104. package/src/rules/prefer-arr-is-min-length-array.test.mts +70 -0
  105. package/src/rules/prefer-arr-is-non-empty.mts +16 -25
  106. package/src/rules/prefer-arr-is-non-empty.test.mts +79 -0
  107. package/src/rules/prefer-arr-scan.mts +55 -19
  108. package/src/rules/prefer-arr-scan.test.mts +149 -0
  109. package/src/rules/prefer-arr-sum.mts +78 -47
  110. package/src/rules/prefer-arr-sum.test.mts +97 -0
  111. package/src/rules/prefer-arr-uniq.mts +46 -12
  112. package/src/rules/prefer-arr-uniq.test.mts +71 -0
  113. package/src/rules/prefer-as-int.mts +24 -16
  114. package/src/rules/prefer-as-int.test.mts +51 -0
  115. package/src/rules/prefer-canonical-array-slicing.mts +38 -15
  116. package/src/rules/prefer-canonical-array-slicing.test.mts +92 -0
  117. package/src/rules/prefer-canonical-length-cast.mts +45 -15
  118. package/src/rules/prefer-canonical-length-cast.test.mts +111 -0
  119. package/src/rules/prefer-canonical-length-guard.mts +13 -7
  120. package/src/rules/prefer-canonical-length-guard.test.mts +99 -0
  121. package/src/rules/prefer-comparison-over-nullish-guard.mts +33 -6
  122. package/src/rules/prefer-comparison-over-nullish-guard.test.mts +136 -0
  123. package/src/rules/prefer-is-non-null-object.mts +31 -11
  124. package/src/rules/prefer-is-non-null-object.test.mts +42 -0
  125. package/src/rules/prefer-is-record-and-has-key.mts +10 -5
  126. package/src/rules/prefer-is-record-and-has-key.test.mts +34 -0
  127. package/src/rules/prefer-num-safe-parse-float.mts +24 -11
  128. package/src/rules/prefer-num-safe-parse-float.test.mts +79 -0
  129. package/src/rules/prefer-num-safe-parse-int.mts +25 -11
  130. package/src/rules/prefer-num-safe-parse-int.test.mts +64 -0
  131. package/src/rules/prefer-obj-over-entries-round-trip.mts +1 -1
  132. package/src/rules/prefer-range-for-loop.mts +18 -8
  133. package/src/rules/prefer-range-for-loop.test.mts +75 -0
  134. package/src/rules/rules.mts +1 -1
  135. package/src/types.mts +2 -2
@@ -4,8 +4,9 @@ import {
4
4
  type TSESLint,
5
5
  type TSESTree,
6
6
  } from '@typescript-eslint/utils';
7
- import { type ReadonlyRecord } from 'ts-type-forge';
7
+ import type { ReadonlyRecord } from 'ts-type-forge';
8
8
  import * as ts from 'typescript';
9
+ import { skipTypeWrappers, typeWrapperLayers } from './ast-utils.mjs';
9
10
  import {
10
11
  buildImportFixes,
11
12
  getImportedLocalName,
@@ -48,19 +49,24 @@ const normalizeCountedOp = (
48
49
  : ({ fn, count } as const);
49
50
 
50
51
  const asNonNegativeIntegerLiteral = (
51
- node: TSESTree.Node,
52
- ): number | undefined =>
53
- node.type === AST_NODE_TYPES.Literal &&
54
- typeof node.value === 'number' &&
55
- Number.isInteger(node.value) &&
56
- node.value >= 0
52
+ wrapped: TSESTree.Node,
53
+ ): number | undefined => {
54
+ const node = skipTypeWrappers(wrapped);
55
+
56
+ return node.type === AST_NODE_TYPES.Literal &&
57
+ typeof node.value === 'number' &&
58
+ Number.isInteger(node.value) &&
59
+ node.value >= 0
57
60
  ? node.value
58
61
  : undefined;
62
+ };
59
63
 
60
64
  /** Matches `-N` (unary minus of a positive integer literal) and returns `N`. */
61
65
  const asNegatedPositiveIntegerLiteral = (
62
- node: TSESTree.Node,
66
+ wrapped: TSESTree.Node,
63
67
  ): number | undefined => {
68
+ const node = skipTypeWrappers(wrapped);
69
+
64
70
  if (
65
71
  node.type !== AST_NODE_TYPES.UnaryExpression ||
66
72
  node.operator !== '-' ||
@@ -238,6 +244,18 @@ export const preferCanonicalArraySlicing: TSESLint.RuleModule<
238
244
  const getTypeOf = (node: TSESTree.Node): ts.Type =>
239
245
  checker.getTypeAtLocation(parserServices.esTreeNodeToTSNodeMap.get(node));
240
246
 
247
+ /**
248
+ * Whether `predicate` holds for the type of every layer of type wrappers
249
+ * around `node` (see typeWrapperLayers): a cast neither makes a `Set` an
250
+ * array nor stops an array from being flattened by `concat`, and the fix,
251
+ * which keeps the wrapper, has to type-check as well.
252
+ */
253
+ const holdsThroughWrappers = (
254
+ node: TSESTree.Node,
255
+ predicate: (type: ts.Type) => boolean,
256
+ ): boolean =>
257
+ typeWrapperLayers(node).every((layer) => predicate(getTypeOf(layer)));
258
+
241
259
  /**
242
260
  * `true` only when the type can never be an array in any branch, so
243
261
  * `xs.concat(v)` is guaranteed to append `v` as one element rather than
@@ -395,9 +413,14 @@ export const preferCanonicalArraySlicing: TSESLint.RuleModule<
395
413
  // eslint-disable-next-line total-functions/no-unsafe-type-assertion
396
414
  const rawOperator = body.operator as ComparisonOperator;
397
415
 
398
- const isIndex = (side: TSESTree.Node): boolean =>
399
- side.type === AST_NODE_TYPES.Identifier &&
400
- side.name === indexParam.name;
416
+ const isIndex = (wrapped: TSESTree.Node): boolean => {
417
+ const side = skipTypeWrappers(wrapped);
418
+
419
+ return (
420
+ side.type === AST_NODE_TYPES.Identifier &&
421
+ side.name === indexParam.name
422
+ );
423
+ };
401
424
 
402
425
  const [op, other] = isIndex(body.left)
403
426
  ? ([rawOperator, body.right] as const)
@@ -644,7 +667,7 @@ export const preferCanonicalArraySlicing: TSESLint.RuleModule<
644
667
  element.type !== AST_NODE_TYPES.SpreadElement &&
645
668
  node.arguments.length === 1 &&
646
669
  arg.type !== AST_NODE_TYPES.SpreadElement &&
647
- isArrayOrTupleType(getTypeOf(arg))
670
+ holdsThroughWrappers(arg, isArrayOrTupleType)
648
671
  ) {
649
672
  report(
650
673
  node,
@@ -667,7 +690,7 @@ export const preferCanonicalArraySlicing: TSESLint.RuleModule<
667
690
 
668
691
  // The receiver must be an array or tuple (not a string / typed array
669
692
  // / other object that happens to share the method name).
670
- if (!isArrayOrTupleType(getTypeOf(receiver))) {
693
+ if (!holdsThroughWrappers(receiver, isArrayOrTupleType)) {
671
694
  return;
672
695
  }
673
696
 
@@ -747,7 +770,7 @@ export const preferCanonicalArraySlicing: TSESLint.RuleModule<
747
770
 
748
771
  // `xs.concat(item)` appends only when `item` can never be an
749
772
  // array (otherwise `concat` would flatten it).
750
- if (isDefinitelyNotArrayType(getTypeOf(arg))) {
773
+ if (holdsThroughWrappers(arg, isDefinitelyNotArrayType)) {
751
774
  report(
752
775
  node,
753
776
  { fn: 'toPushed', itemText: toArgText(arg) },
@@ -794,7 +817,7 @@ export const preferCanonicalArraySlicing: TSESLint.RuleModule<
794
817
 
795
818
  // Spreading a non-array iterable (`[v, ...someSet]`) is a genuine
796
819
  // conversion, not an element addition to an array.
797
- if (!isArrayOrTupleType(getTypeOf(spread.argument))) {
820
+ if (!holdsThroughWrappers(spread.argument, isArrayOrTupleType)) {
798
821
  return;
799
822
  }
800
823
 
@@ -586,3 +586,95 @@ describe('prefer-canonical-array-slicing', () => {
586
586
  ],
587
587
  });
588
588
  });
589
+
590
+ describe('prefer-canonical-array-slicing through type wrappers', () => {
591
+ tester.run('prefer-canonical-array-slicing', preferCanonicalArraySlicing, {
592
+ valid: [
593
+ {
594
+ name: 'a concat argument cast away from an array may still be flattened',
595
+ code: dedent`
596
+ declare const xs: readonly number[];
597
+ declare const v: number | readonly number[];
598
+ const ys = xs.concat(v as number);
599
+ `,
600
+ },
601
+ {
602
+ name: 'a spread Set cast to an array is still a conversion',
603
+ code: dedent`
604
+ declare const s: ReadonlySet<number>;
605
+ const ys = [...(s as unknown as readonly number[]), 1];
606
+ `,
607
+ },
608
+ {
609
+ name: 'a receiver cast to an array is still not one',
610
+ code: dedent`
611
+ declare const s: string;
612
+ const ys = (s as unknown as readonly string[]).slice(1);
613
+ `,
614
+ },
615
+ {
616
+ name: 'a concat argument cast to an array is still not one',
617
+ code: dedent`
618
+ declare const s: ReadonlySet<number>;
619
+ const ys = [1].concat(s as unknown as readonly number[]);
620
+ `,
621
+ },
622
+ ],
623
+ invalid: [
624
+ {
625
+ name: 'a count wrapped in `as` or `satisfies`',
626
+ code: dedent`
627
+ declare const xs: readonly number[];
628
+ const a = xs.slice(1 as number);
629
+ const b = xs.slice(0 satisfies number, -1 as number);
630
+ const c = xs.toSpliced(2 satisfies number);
631
+ `,
632
+ output: dedent`
633
+ import { Arr } from 'ts-data-forge';
634
+ declare const xs: readonly number[];
635
+ const a = Arr.tail(xs);
636
+ const b = Arr.butLast(xs);
637
+ const c = Arr.take(xs, 2);
638
+ `,
639
+ errors: [
640
+ { messageId: 'preferCanonicalArraySlicing' },
641
+ { messageId: 'preferCanonicalArraySlicing' },
642
+ { messageId: 'preferCanonicalArraySlicing' },
643
+ ],
644
+ },
645
+ {
646
+ name: 'a filter comparison on a wrapped index',
647
+ code: dedent`
648
+ declare const xs: readonly number[];
649
+ const ys = xs.filter((_, i) => (i satisfies number) >= 1);
650
+ `,
651
+ output: dedent`
652
+ import { Arr } from 'ts-data-forge';
653
+ declare const xs: readonly number[];
654
+ const ys = Arr.tail(xs);
655
+ `,
656
+ errors: [{ messageId: 'preferCanonicalArraySlicing' }],
657
+ },
658
+ {
659
+ name: 'a wrapped receiver and item keep their wrappers',
660
+ code: dedent`
661
+ declare const xs: readonly number[];
662
+ declare const v: number | undefined;
663
+ const ys = (xs satisfies readonly number[]).slice(1);
664
+ const zs = [...xs!, v!];
665
+ `,
666
+ output: dedent`
667
+ import { Arr } from 'ts-data-forge';
668
+ declare const xs: readonly number[];
669
+ declare const v: number | undefined;
670
+ const ys = Arr.tail(xs satisfies readonly number[]);
671
+ const zs = Arr.toPushed(xs!, v!);
672
+ `,
673
+ errors: [
674
+ { messageId: 'preferCanonicalArraySlicing' },
675
+ { messageId: 'preferCanonicalArraySlicing' },
676
+ ],
677
+ },
678
+ ],
679
+ });
680
+ });
@@ -1,8 +1,10 @@
1
1
  import {
2
2
  AST_NODE_TYPES,
3
+ ASTUtils,
3
4
  type TSESLint,
4
5
  type TSESTree,
5
6
  } from '@typescript-eslint/utils';
7
+ import { skipTypeWrappers } from './ast-utils.mjs';
6
8
  import { getImportedLocalName, getTsDataForgeImport } from './import-utils.mjs';
7
9
 
8
10
  type Options = readonly [];
@@ -185,7 +187,7 @@ export const preferCanonicalLengthCast: TSESLint.RuleModule<
185
187
  node.callee,
186
188
  `${arrLocalName}.${rewrite.replacement}`,
187
189
  ),
188
- ...dropBoundsFix(fixer, node, rewrite),
190
+ ...dropBoundsFix(fixer, sourceCode, node, rewrite),
189
191
  ],
190
192
  });
191
193
  },
@@ -263,11 +265,16 @@ const matchesBounds = (
263
265
 
264
266
  /**
265
267
  * Removes the length arguments the rewrite drops. Each removal runs from the
266
- * dropped argument up to the start of the next one, so the comma, whitespace
267
- * and any comments in between go with it; the ranges never overlap.
268
+ * first token after the delimiter before the dropped argument (the call's `(`
269
+ * or the previous comma) up to the first token after the comma that follows
270
+ * it. Parentheses the source put around the dropped argument, the comma,
271
+ * whitespace and any comments in between go with it; parentheses around the
272
+ * next argument stay. The ranges never overlap.
268
273
  */
269
274
  const dropBoundsFix = (
270
275
  fixer: TSESLint.RuleFixer,
276
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
277
+ sourceCode: TSESLint.SourceCode,
271
278
  // AST nodes hold mutable child arrays, so they are not deeply readonly.
272
279
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
273
280
  node: TSESTree.CallExpression,
@@ -283,23 +290,46 @@ const dropBoundsFix = (
283
290
  // "not of this type".
284
291
  const dropped = node.arguments[index];
285
292
 
286
- const next = node.arguments[index + 1];
293
+ const previous = node.arguments[index - 1];
294
+
295
+ if (dropped === undefined || rewrite.keep.includes(index)) {
296
+ return [];
297
+ }
298
+
299
+ const delimiterBefore =
300
+ previous === undefined
301
+ ? sourceCode.getTokenAfter(node.callee, ASTUtils.isOpeningParenToken)
302
+ : sourceCode.getTokenAfter(previous, ASTUtils.isCommaToken);
303
+
304
+ const start =
305
+ delimiterBefore === null
306
+ ? null
307
+ : sourceCode.getTokenAfter(delimiterBefore);
287
308
 
288
- return dropped === undefined ||
289
- next === undefined ||
290
- rewrite.keep.includes(index)
309
+ const commaAfter = sourceCode.getTokenAfter(dropped, ASTUtils.isCommaToken);
310
+
311
+ const end =
312
+ commaAfter === null ? null : sourceCode.getTokenAfter(commaAfter);
313
+
314
+ return start === null || end === null
291
315
  ? []
292
- : [fixer.removeRange([dropped.range[0], next.range[0]])];
316
+ : [fixer.removeRange([start.range[0], end.range[0]])];
293
317
  });
294
318
 
295
- /** A non-negative integer literal's value, or `undefined` for anything else. */
319
+ /**
320
+ * A non-negative integer literal's value, or `undefined` for anything else.
321
+ * Read through type wrappers: `1 as const` is still the bound `1`.
322
+ */
296
323
  const getLengthLiteral = (
297
324
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
298
- node: TSESTree.Node,
299
- ): number | undefined =>
300
- node.type === AST_NODE_TYPES.Literal &&
301
- typeof node.value === 'number' &&
302
- Number.isSafeInteger(node.value) &&
303
- node.value >= 0
325
+ wrapped: TSESTree.Node,
326
+ ): number | undefined => {
327
+ const node = skipTypeWrappers(wrapped);
328
+
329
+ return node.type === AST_NODE_TYPES.Literal &&
330
+ typeof node.value === 'number' &&
331
+ Number.isSafeInteger(node.value) &&
332
+ node.value >= 0
304
333
  ? node.value
305
334
  : undefined;
335
+ };
@@ -303,3 +303,114 @@ describe('prefer-canonical-length-cast', () => {
303
303
  ],
304
304
  });
305
305
  });
306
+
307
+ describe('prefer-canonical-length-cast through type wrappers', () => {
308
+ tester.run('prefer-canonical-length-cast', preferCanonicalLengthCast, {
309
+ valid: [
310
+ {
311
+ name: 'a wrapped bound other than the degenerate one',
312
+ code: dedent`
313
+ import { Arr } from 'ts-data-forge';
314
+
315
+ declare const xs: readonly number[];
316
+ const ys = Arr.asMinLengthArray(2 as const, xs);
317
+ `,
318
+ },
319
+ ],
320
+ invalid: [
321
+ {
322
+ name: 'a bound wrapped in `as const`',
323
+ code: dedent`
324
+ import { Arr } from 'ts-data-forge';
325
+
326
+ declare const xs: readonly number[];
327
+ const ys = Arr.asMinLengthArray(1 as const, xs);
328
+ `,
329
+ output: dedent`
330
+ import { Arr } from 'ts-data-forge';
331
+
332
+ declare const xs: readonly number[];
333
+ const ys = Arr.asNonEmptyArray(xs);
334
+ `,
335
+ errors: [{ messageId: 'useCanonicalCast' }],
336
+ },
337
+ {
338
+ name: 'bounds wrapped in `satisfies`, the kept one keeping its wrapper',
339
+ code: dedent`
340
+ import { Arr } from 'ts-data-forge';
341
+
342
+ declare const xs: readonly number[];
343
+ const ys = Arr.asBoundedLengthTuple(3 satisfies number, 3 as const, xs!);
344
+ `,
345
+ output: dedent`
346
+ import { Arr } from 'ts-data-forge';
347
+
348
+ declare const xs: readonly number[];
349
+ const ys = Arr.asFixedLengthTuple(3 satisfies number, xs!);
350
+ `,
351
+ errors: [{ messageId: 'useCanonicalCast' }],
352
+ },
353
+ ],
354
+ });
355
+ });
356
+
357
+ describe('prefer-canonical-length-cast with parenthesized operands', () => {
358
+ tester.run('prefer-canonical-length-cast', preferCanonicalLengthCast, {
359
+ valid: [],
360
+ invalid: [
361
+ {
362
+ name: 'a parenthesized array after the dropped bound',
363
+ code: dedent`
364
+ import { Arr } from 'ts-data-forge';
365
+
366
+ declare const xs: readonly number[];
367
+ const ys = Arr.asMinLengthArray(1, (xs));
368
+ `,
369
+ output: dedent`
370
+ import { Arr } from 'ts-data-forge';
371
+
372
+ declare const xs: readonly number[];
373
+ const ys = Arr.asNonEmptyArray((xs));
374
+ `,
375
+ errors: [{ messageId: 'useCanonicalCast' }],
376
+ },
377
+ {
378
+ name: 'a parenthesized dropped bound',
379
+ code: dedent`
380
+ import { Arr } from 'ts-data-forge';
381
+
382
+ declare const xs: readonly number[];
383
+ const ys = Arr.asMinLengthArray((1), xs);
384
+ `,
385
+ output: dedent`
386
+ import { Arr } from 'ts-data-forge';
387
+
388
+ declare const xs: readonly number[];
389
+ const ys = Arr.asNonEmptyArray(xs);
390
+ `,
391
+ errors: [{ messageId: 'useCanonicalCast' }],
392
+ },
393
+ {
394
+ name: 'a parenthesized second bound of a bounded cast',
395
+ code: dedent`
396
+ import { Arr } from 'ts-data-forge';
397
+
398
+ declare const xs: readonly number[];
399
+ const ys = Arr.asBoundedLengthArray(2, ((2)), xs);
400
+ const zs = Arr.asBoundedLengthArray((0), (0), (xs));
401
+ `,
402
+ output: dedent`
403
+ import { Arr } from 'ts-data-forge';
404
+
405
+ declare const xs: readonly number[];
406
+ const ys = Arr.asFixedLengthArray(2, xs);
407
+ const zs = Arr.asEmptyArray((xs));
408
+ `,
409
+ errors: [
410
+ { messageId: 'useCanonicalCast' },
411
+ { messageId: 'useCanonicalCast' },
412
+ ],
413
+ },
414
+ ],
415
+ });
416
+ });
@@ -3,7 +3,8 @@ import {
3
3
  type TSESLint,
4
4
  type TSESTree,
5
5
  } from '@typescript-eslint/utils';
6
- import { type MutableRecord, type ReadonlyRecord } from 'ts-type-forge';
6
+ import type { MutableRecord, ReadonlyRecord } from 'ts-type-forge';
7
+ import { skipTypeWrappers, toArgumentText } from './ast-utils.mjs';
7
8
  import { getImportedLocalName, getTsDataForgeImport } from './import-utils.mjs';
8
9
  import { preferArrIsBoundedLengthArray } from './prefer-arr-is-bounded-length-array.mjs';
9
10
  import { preferArrIsFixedLengthArray } from './prefer-arr-is-fixed-length-array.mjs';
@@ -153,7 +154,7 @@ export const preferCanonicalLengthGuard: TSESLint.RuleModule<
153
154
  return;
154
155
  }
155
156
 
156
- const arrayText = sourceCode.getText(array);
157
+ const arrayText = toArgumentText(array, sourceCode);
157
158
 
158
159
  context.report({
159
160
  node,
@@ -234,7 +235,8 @@ const getGuardName = (
234
235
 
235
236
  /**
236
237
  * Whether the call is `guard(...bounds, array)` with every bound written as the
237
- * exact numeric literal the rewrite requires.
238
+ * exact numeric literal the rewrite requires, under any type wrapper
239
+ * (`0 as const` is still the bound 0).
238
240
  *
239
241
  * The arity check also excludes the curried form (`guard(...bounds)`): the
240
242
  * degenerate guards take only the array, so there is nothing to rewrite it to.
@@ -250,10 +252,14 @@ const matchesBounds = (
250
252
  return false;
251
253
  }
252
254
 
253
- return args.every(
254
- (arg, index) =>
255
+ return args.every((arg, index) => {
256
+ const bound = skipTypeWrappers(arg);
257
+
258
+ return (
255
259
  arg.type !== AST_NODE_TYPES.SpreadElement &&
256
260
  (index === bounds.length ||
257
- (arg.type === AST_NODE_TYPES.Literal && arg.value === bounds[index])),
258
- );
261
+ (bound.type === AST_NODE_TYPES.Literal &&
262
+ bound.value === bounds[index]))
263
+ );
264
+ });
259
265
  };
@@ -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:
@@ -124,9 +153,7 @@ export const preferComparisonOverNullishGuard: TSESLint.RuleModule<
124
153
 
125
154
  const argRaw = sourceCode.getText(argument);
126
155
 
127
- const argText = ARG_NEEDS_PARENS.has(argument.type)
128
- ? `(${argRaw})`
129
- : argRaw;
156
+ const argText = argumentNeedsParens(argument) ? `(${argRaw})` : argRaw;
130
157
 
131
158
  const comparison = `${argText} ${spec.op} ${spec.literal}`;
132
159