ts-codemod-lib 3.3.3 → 3.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/functions/ast-transformers/append-as-const.d.mts +2 -2
  2. package/dist/functions/ast-transformers/append-as-const.d.mts.map +1 -1
  3. package/dist/functions/ast-transformers/append-as-const.mjs +13 -3
  4. package/dist/functions/ast-transformers/append-as-const.mjs.map +1 -1
  5. package/dist/functions/ast-transformers/convert-interface-to-type.d.mts +1 -1
  6. package/dist/functions/ast-transformers/convert-interface-to-type.d.mts.map +1 -1
  7. package/dist/functions/ast-transformers/convert-to-readonly.d.mts +2 -2
  8. package/dist/functions/ast-transformers/convert-to-readonly.d.mts.map +1 -1
  9. package/dist/functions/ast-transformers/convert-to-readonly.mjs +74 -1
  10. package/dist/functions/ast-transformers/convert-to-readonly.mjs.map +1 -1
  11. package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.d.mts +2 -2
  12. package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.d.mts.map +1 -1
  13. package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.mjs +24 -6
  14. package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.mjs.map +1 -1
  15. package/dist/functions/ast-transformers/readonly-transformer-helpers/readonly-context.d.mts +1 -1
  16. package/dist/functions/ast-transformers/readonly-transformer-helpers/readonly-context.d.mts.map +1 -1
  17. package/dist/functions/ast-transformers/replace-any-with-unknown.d.mts +1 -1
  18. package/dist/functions/ast-transformers/replace-any-with-unknown.d.mts.map +1 -1
  19. package/dist/functions/ast-transformers/replace-record-with-unknown-record.d.mts +1 -1
  20. package/dist/functions/ast-transformers/replace-record-with-unknown-record.d.mts.map +1 -1
  21. package/dist/functions/ast-transformers/transform-source-code.d.mts +1 -1
  22. package/dist/functions/ast-transformers/transform-source-code.d.mts.map +1 -1
  23. package/dist/functions/functions/is-atomic-type-node.d.mts +1 -1
  24. package/dist/functions/functions/is-atomic-type-node.d.mts.map +1 -1
  25. package/dist/functions/functions/is-readonly-node.d.mts +1 -1
  26. package/dist/functions/functions/is-readonly-node.d.mts.map +1 -1
  27. package/dist/functions/functions/unwrap-readonly.d.mts +1 -1
  28. package/dist/functions/functions/unwrap-readonly.d.mts.map +1 -1
  29. package/package.json +16 -16
  30. package/src/functions/ast-transformers/append-as-const.mts +21 -5
  31. package/src/functions/ast-transformers/append-as-const.test.mts +24 -0
  32. package/src/functions/ast-transformers/convert-interface-to-type.mts +1 -1
  33. package/src/functions/ast-transformers/convert-to-readonly.mts +102 -7
  34. package/src/functions/ast-transformers/convert-to-readonly.test.mts +203 -0
  35. package/src/functions/ast-transformers/enable-no-unchecked-indexed-access.mts +30 -8
  36. package/src/functions/ast-transformers/enable-no-unchecked-indexed-access.test.mts +111 -0
  37. package/src/functions/ast-transformers/readonly-transformer-helpers/compare-union-types.mts +1 -1
  38. package/src/functions/ast-transformers/readonly-transformer-helpers/readonly-context.mts +1 -1
  39. package/src/functions/ast-transformers/replace-any-with-unknown.mts +1 -1
  40. package/src/functions/ast-transformers/replace-record-with-unknown-record.mts +1 -1
  41. package/src/functions/ast-transformers/transform-source-code.mts +1 -1
  42. package/src/functions/functions/is-atomic-type-node.mts +1 -1
  43. package/src/functions/functions/is-readonly-node.mts +1 -1
  44. package/src/functions/functions/unwrap-readonly.mts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-codemod-lib",
3
- "version": "3.3.3",
3
+ "version": "3.4.1",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "typescript",
@@ -43,39 +43,39 @@
43
43
  "ts-type-forge": "^9.2.2"
44
44
  },
45
45
  "devDependencies": {
46
- "@types/node": "26.6.1",
47
- "@vitest/browser-playwright": "5.0.1",
48
- "@vitest/coverage-v8": "5.0.1",
49
- "@vitest/ui": "5.0.1",
50
- "cspell": "10.3.3",
46
+ "@types/node": "26.6.3",
47
+ "@vitest/browser-playwright": "5.0.2",
48
+ "@vitest/coverage-v8": "5.0.2",
49
+ "@vitest/ui": "5.0.2",
50
+ "cspell": "10.3.5",
51
51
  "dedent": "1.7.2",
52
52
  "esbuild": "0.28.2",
53
- "eslint": "10.10.0",
54
- "eslint-config-typed": "5.14.0",
55
- "eslint-plugin-ts-data-forge": "0.8.1",
56
- "eslint-plugin-ts-fortress": "1.2.3",
57
- "eslint-plugin-ts-type-forge": "0.8.4",
53
+ "eslint": "10.11.0",
54
+ "eslint-config-typed": "5.17.0",
55
+ "eslint-plugin-ts-data-forge": "0.8.3",
56
+ "eslint-plugin-ts-fortress": "1.3.0",
57
+ "eslint-plugin-ts-type-forge": "0.8.5",
58
58
  "jiti": "2.7.0",
59
59
  "npm-run-all2": "9.0.3",
60
60
  "playwright": "1.63.0",
61
- "prettier": "3.9.7",
61
+ "prettier": "3.9.9",
62
62
  "prettier-plugin-organize-imports": "4.3.0",
63
63
  "prettier-plugin-packagejson": "3.0.2",
64
64
  "ts-repo-utils": "10.7.1",
65
- "tsx": "4.23.13",
65
+ "tsx": "4.23.15",
66
66
  "typedoc": "0.28.20",
67
67
  "typedoc-github-theme": "0.4.0",
68
68
  "typescript": "6.0.3",
69
69
  "typescript-native": "npm:typescript@7.0.2",
70
- "vite": "8.3.0",
71
- "vitest": "5.0.1"
70
+ "vite": "8.3.1",
71
+ "vitest": "5.0.2"
72
72
  },
73
73
  "engines": {
74
74
  "node": ">=22.22.2",
75
75
  "pnpm": ">=9.0.0"
76
76
  },
77
77
  "volta": {
78
- "node": "26.9.0"
78
+ "node": "26.10.0"
79
79
  },
80
80
  "publishConfig": {
81
81
  "access": "public",
@@ -1,12 +1,12 @@
1
1
  import { Arr, ISet } from 'ts-data-forge';
2
2
  import * as tsm from 'ts-morph';
3
- import { type DeepReadonly } from 'ts-type-forge';
3
+ import type { DeepReadonly } from 'ts-type-forge';
4
4
  import {
5
5
  hasDisableNextLineComment,
6
6
  isAsConstNode,
7
7
  } from '../functions/index.mjs';
8
8
  import { replaceNodeWithDebugPrint } from '../utils/index.mjs';
9
- import { type TsMorphTransformer } from './types.mjs';
9
+ import type { TsMorphTransformer } from './types.mjs';
10
10
 
11
11
  const TRANSFORMER_NAME = 'append-as-const';
12
12
 
@@ -423,7 +423,9 @@ const removeAsConstInConstTypeParameterArgs = (
423
423
  const args = call.getArguments();
424
424
 
425
425
  // Cheap syntactic pre-check before touching the type checker
426
- if (!args.some(isAsConstNode)) {
426
+ if (
427
+ args.every((arg) => !isAsConstNode(skipParenthesizedExpressions(arg)))
428
+ ) {
427
429
  continue;
428
430
  }
429
431
 
@@ -478,7 +480,14 @@ const removeAsConstArgsOfCall = (
478
480
  return;
479
481
  }
480
482
 
481
- if (argument.wasForgotten() || !isAsConstNode(argument)) {
483
+ if (argument.wasForgotten()) {
484
+ continue;
485
+ }
486
+
487
+ // `f(([1] as const))` is the same argument as `f([1] as const)`.
488
+ const asConst = skipParenthesizedExpressions(argument);
489
+
490
+ if (!isAsConstNode(asConst)) {
482
491
  continue;
483
492
  }
484
493
 
@@ -503,10 +512,17 @@ const removeAsConstArgsOfCall = (
503
512
  argument.getText(),
504
513
  );
505
514
 
506
- options.replaceNode(argument, argument.getExpression().getText());
515
+ // The parentheses go with it: an argument needs none of its own.
516
+ options.replaceNode(argument, asConst.getExpression().getText());
507
517
  }
508
518
  };
509
519
 
520
+ /** `node` with the parentheses written around it taken off. */
521
+ const skipParenthesizedExpressions = (node: tsm.Node): tsm.Node =>
522
+ tsm.Node.isParenthesizedExpression(node)
523
+ ? skipParenthesizedExpressions(node.getExpression())
524
+ : node;
525
+
510
526
  type ResolvedCallTarget = Readonly<{
511
527
  parameters: readonly tsm.ParameterDeclaration[];
512
528
  constTypeParameterNames: ISet<string>;
@@ -759,6 +759,30 @@ describe(appendAsConstTransformer, () => {
759
759
  const r = rest(1, [2, 3]);
760
760
  `,
761
761
  },
762
+ {
763
+ name: 'const type parameter should remove a parenthesized as const',
764
+ source: dedent`
765
+ function f<const T>(x: T): T { return x; }
766
+ const a = f(([1, 2] as const));
767
+ const b = f(((({ x: 1 }) as const)));
768
+ `,
769
+ expected: dedent`
770
+ function f<const T>(x: T): T { return x; }
771
+ const a = f([1, 2]);
772
+ const b = f({ x: 1 });
773
+ `,
774
+ },
775
+ {
776
+ name: 'non-const type parameter should keep a parenthesized as const',
777
+ source: dedent`
778
+ function g<T>(x: T): T { return x; }
779
+ const b = g(([1, 2] as const));
780
+ `,
781
+ expected: dedent`
782
+ function g<T>(x: T): T { return x; }
783
+ const b = g([1, 2] as const);
784
+ `,
785
+ },
762
786
  {
763
787
  name: 'mixed const and non-const type parameters should only remove for const ones',
764
788
  source: dedent`
@@ -3,7 +3,7 @@
3
3
  import { Arr } from 'ts-data-forge';
4
4
  import type * as tsm from 'ts-morph';
5
5
  import { hasDisableNextLineComment } from '../functions/index.mjs';
6
- import { type TsMorphTransformer } from './types.mjs';
6
+ import type { TsMorphTransformer } from './types.mjs';
7
7
 
8
8
  const TRANSFORMER_NAME = 'convert-interface-to-type';
9
9
 
@@ -8,11 +8,11 @@ import {
8
8
  pipe,
9
9
  } from 'ts-data-forge';
10
10
  import * as tsm from 'ts-morph';
11
- import {
12
- type DeepReadonly,
13
- type SafeUintWithSmallInt,
14
- type StrictExclude,
15
- type StrictExtract,
11
+ import type {
12
+ DeepReadonly,
13
+ SafeUintWithSmallInt,
14
+ StrictExclude,
15
+ StrictExtract,
16
16
  } from 'ts-type-forge';
17
17
  import {
18
18
  hasCallOrConstructSignature,
@@ -36,7 +36,7 @@ import {
36
36
  nextReadonlyContext,
37
37
  type ReadonlyContext,
38
38
  } from './readonly-transformer-helpers/index.mjs';
39
- import { type TsMorphTransformer } from './types.mjs';
39
+ import type { TsMorphTransformer } from './types.mjs';
40
40
 
41
41
  const TRANSFORMER_NAME = 'convert-to-readonly';
42
42
 
@@ -1303,6 +1303,7 @@ const transformIntersectionTypeNode = (
1303
1303
  /**
1304
1304
  * - `tr(A | B) |-> tr(A) | tr(B)`
1305
1305
  * - `tr(Readonly<A> | Readonly<B>) |-> Readonly<tr(A) | tr(B)>`
1306
+ * - `tr(DeepReadonly<A> | DeepReadonly<B>) |-> DeepReadonly<tr(A) | tr(B)>`
1306
1307
  */
1307
1308
  const transformUnionTypeNode = (
1308
1309
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
@@ -1416,7 +1417,14 @@ const transformUnionOrIntersectionTypeNodeImpl = (
1416
1417
  others,
1417
1418
  (a) =>
1418
1419
  [
1419
- a.nodes.map((n) => wrapWithParentheses(n.getFullText())),
1420
+ // `&` is deliberately not merged, unlike `Readonly<A> & Readonly<B>`
1421
+ // above. See `mergeDeepReadonlyUnionMembers` for why.
1422
+ operator === '|'
1423
+ ? mergeDeepReadonlyUnionMembers(
1424
+ a.nodes,
1425
+ options.DeepReadonly.typeName,
1426
+ )
1427
+ : a.nodes.map((n) => wrapWithParentheses(n.getFullText())),
1420
1428
  a.firstPosition,
1421
1429
  ] as const,
1422
1430
  ),
@@ -1450,6 +1458,93 @@ const transformUnionOrIntersectionTypeNodeImpl = (
1450
1458
  );
1451
1459
  };
1452
1460
 
1461
+ /**
1462
+ * - `tr(DeepReadonly<A> | B | DeepReadonly<C>) |-> DeepReadonly<A | C> | B`
1463
+ *
1464
+ * The merged member takes the place of the first `DeepReadonly` member.
1465
+ *
1466
+ * Only a union is merged, although `Readonly<A> & Readonly<B>` is merged into
1467
+ * `Readonly<A & B>`. The two differ in kind:
1468
+ *
1469
+ * - `Readonly` is a homomorphic mapped type, and over type literals
1470
+ * `Readonly<A & B>` and `Readonly<A> & Readonly<B>` have the same properties
1471
+ * with the same modifiers. (That merge also takes `Readonly<T>` with any
1472
+ * `T`, where this does not hold for an array `T`; it is not relied on here.)
1473
+ * - `DeepReadonly` is a conditional type that picks a branch by what `T` is
1474
+ * (primitive, function, Map, Set, array, object). It distributes over a union
1475
+ * because each member is tested on its own, but an intersection is tested as
1476
+ * a whole and matches every branch any one member matches: with
1477
+ * `F = () => void`, `F & { x: number[] }` extends a function type because `F`
1478
+ * does, so `DeepReadonly<F & { x: number[] }>` hits that branch and returns
1479
+ * the intersection unchanged, leaving `x` mutable, while `DeepReadonly<F> &
1480
+ * DeepReadonly<{ x: number[] }>` is `F & { readonly x: readonly number[] }`.
1481
+ * The codemod cannot tell whether a type argument is such a member, so it
1482
+ * merges no intersection.
1483
+ *
1484
+ * The test "premises of merging DeepReadonly members" pins both facts against
1485
+ * ts-type-forge's `DeepReadonly`.
1486
+ */
1487
+ const mergeDeepReadonlyUnionMembers = (
1488
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
1489
+ nodes: readonly tsm.TypeNode[],
1490
+ deepReadonlyTypeName: string,
1491
+ ): readonly string[] => {
1492
+ const typeArgsOfDeepReadonly = nodes.map((n) =>
1493
+ getDeepReadonlyTypeArg(n, deepReadonlyTypeName),
1494
+ );
1495
+
1496
+ const typeArgs = typeArgsOfDeepReadonly.filter((t) => t !== undefined);
1497
+
1498
+ if (typeArgs.length < 2) {
1499
+ return nodes.map((n) => wrapWithParentheses(n.getFullText()));
1500
+ }
1501
+
1502
+ const firstIndex = typeArgsOfDeepReadonly.findIndex((t) => t !== undefined);
1503
+
1504
+ const merged = unionToString({
1505
+ types: typeArgs.map((t_) => {
1506
+ const t = removeParentheses(t_);
1507
+
1508
+ // NOTE: DeepReadonly<A | B> | DeepReadonly<C> -> DeepReadonly<A | B | C>
1509
+ // NOTE: DeepReadonly<() => void> | DeepReadonly<B> -> DeepReadonly<(() => void) | B>
1510
+ return t.isKind(tsm.SyntaxKind.UnionType)
1511
+ ? t.getFullText()
1512
+ : wrapWithParentheses(t.getFullText());
1513
+ }),
1514
+ op: '|',
1515
+ wrapWithReadonly: deepReadonlyTypeName,
1516
+ });
1517
+
1518
+ return nodes.flatMap((n, i) =>
1519
+ typeArgsOfDeepReadonly[i] === undefined
1520
+ ? [wrapWithParentheses(n.getFullText())]
1521
+ : i === firstIndex
1522
+ ? [merged]
1523
+ : [],
1524
+ );
1525
+ };
1526
+
1527
+ /** `DeepReadonly<T>` |-> `T`, anything else |-> `undefined` */
1528
+ const getDeepReadonlyTypeArg = (
1529
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
1530
+ node: tsm.TypeNode,
1531
+ deepReadonlyTypeName: string,
1532
+ ): tsm.TypeNode | undefined => {
1533
+ if (!node.isKind(tsm.SyntaxKind.TypeReference)) {
1534
+ return undefined;
1535
+ }
1536
+
1537
+ const typeName = node.getTypeName();
1538
+
1539
+ const typeArguments = node.getTypeArguments();
1540
+
1541
+ return typeName.isKind(tsm.SyntaxKind.Identifier) &&
1542
+ typeName.getText() === deepReadonlyTypeName &&
1543
+ Arr.isFixedLengthArray(1, typeArguments)
1544
+ ? typeArguments[0]
1545
+ : undefined;
1546
+ };
1547
+
1453
1548
  const unionToString = ({
1454
1549
  types: typesRaw,
1455
1550
  op,
@@ -6,6 +6,10 @@ import dedent from 'dedent';
6
6
  import * as prettierPluginEstree from 'prettier/plugins/estree';
7
7
  import * as prettierPluginTypeScript from 'prettier/plugins/typescript';
8
8
  import * as prettier from 'prettier/standalone';
9
+ import { expectType } from 'ts-data-forge';
10
+ // Aliased: an import named `DeepReadonly` makes `vitest/prefer-describe-function-title`
11
+ // rewrite `describe('DeepReadonly', ...)` into a reference to this type.
12
+ import type { DeepReadonly as TsTypeForgeDeepReadonly } from 'ts-type-forge';
9
13
  import {
10
14
  convertToReadonlyTransformer,
11
15
  type ReadonlyTransformerOptions,
@@ -2831,6 +2835,205 @@ describe(convertToReadonlyTransformer, () => {
2831
2835
  ])('$name', testFn);
2832
2836
  });
2833
2837
 
2838
+ describe('Union of DeepReadonly members', () => {
2839
+ test.each([
2840
+ {
2841
+ name: 'DeepReadonly<A> | DeepReadonly<B> -> DeepReadonly<A | B>',
2842
+ source: dedent`
2843
+ type T = DeepReadonly<{ a: number[] }> | DeepReadonly<{ b: string[] }>;
2844
+ `,
2845
+ expected: dedent`
2846
+ type T = DeepReadonly<{ a: number[] } | { b: string[] }>;
2847
+ `,
2848
+ },
2849
+ {
2850
+ name: 'Three DeepReadonly members',
2851
+ source: dedent`
2852
+ type T<A, B> = DeepReadonly<A> | DeepReadonly<B> | DeepReadonly<{ c: boolean[] }>;
2853
+ `,
2854
+ expected: dedent`
2855
+ type T<A, B> = DeepReadonly<A | B | { c: boolean[] }>;
2856
+ `,
2857
+ },
2858
+ {
2859
+ name: 'DeepReadonly members separated by other members',
2860
+ source: dedent`
2861
+ type T = DeepReadonly<{ a: number[] }> | string | Map<string, number> | DeepReadonly<{ b: string[] }>;
2862
+ `,
2863
+ expected: dedent`
2864
+ type T = DeepReadonly<{ a: number[] } | { b: string[] }> | ReadonlyMap<string, number> | string;
2865
+ `,
2866
+ },
2867
+ {
2868
+ name: 'A DeepReadonly member whose argument is itself a union',
2869
+ source: dedent`
2870
+ type T = DeepReadonly<{ a: number[] } | { b: string[] }> | DeepReadonly<{ c: boolean[] }>;
2871
+ `,
2872
+ expected: dedent`
2873
+ type T = DeepReadonly<{ a: number[] } | { b: string[] } | { c: boolean[] }>;
2874
+ `,
2875
+ },
2876
+ {
2877
+ // `() => void | X` would parse as a function returning `void | X`.
2878
+ name: 'A function type argument keeps its parentheses',
2879
+ source: dedent`
2880
+ type T = DeepReadonly<() => void> | DeepReadonly<{ b: string[] }>;
2881
+ `,
2882
+ expected: dedent`
2883
+ type T = DeepReadonly<(() => void) | { b: string[] }>;
2884
+ `,
2885
+ },
2886
+ {
2887
+ name: 'Nested in a type literal',
2888
+ source: dedent`
2889
+ type T = { x: DeepReadonly<{ a: number[] }> | DeepReadonly<{ b: string[] }> };
2890
+ `,
2891
+ expected: dedent`
2892
+ type T = Readonly<{ x: DeepReadonly<{ a: number[] } | { b: string[] }> }>;
2893
+ `,
2894
+ },
2895
+ {
2896
+ name: 'A single DeepReadonly member is left as written',
2897
+ source: dedent`
2898
+ type T = DeepReadonly<{ a: number[] }> | { b: string[] };
2899
+ `,
2900
+ expected: dedent`
2901
+ type T = DeepReadonly<{ a: number[] }> | Readonly<{ b: readonly string[] }>;
2902
+ `,
2903
+ },
2904
+ {
2905
+ name: 'DeepReadonly and Readonly members are not merged',
2906
+ source: dedent`
2907
+ type T = DeepReadonly<{ a: number[] }> | Readonly<{ b: string[] }>;
2908
+ `,
2909
+ expected: dedent`
2910
+ type T = DeepReadonly<{ a: number[] }> | Readonly<{ b: readonly string[] }>;
2911
+ `,
2912
+ },
2913
+ // Unlike `Readonly<A> & Readonly<B>`, an intersection of DeepReadonly
2914
+ // members is never merged; see `mergeDeepReadonlyUnionMembers` and the
2915
+ // premises test below.
2916
+ {
2917
+ name: 'An intersection of DeepReadonly members is left as written',
2918
+ source: dedent`
2919
+ type T = DeepReadonly<{ a: number[] }> & DeepReadonly<{ b: string[] }>;
2920
+ `,
2921
+ expected: dedent`
2922
+ type T = DeepReadonly<{ a: number[] }> & DeepReadonly<{ b: string[] }>;
2923
+ `,
2924
+ },
2925
+ {
2926
+ // The case merging would get wrong: `DeepReadonly<F & X>` is `F & X`.
2927
+ name: 'An intersection with a function type argument is left as written',
2928
+ source: dedent`
2929
+ type T = DeepReadonly<() => void> & DeepReadonly<{ x: number[] }>;
2930
+ `,
2931
+ expected: dedent`
2932
+ type T = DeepReadonly<() => void> & DeepReadonly<{ x: number[] }>;
2933
+ `,
2934
+ },
2935
+ {
2936
+ name: 'An intersection of generic DeepReadonly members is left as written',
2937
+ source: dedent`
2938
+ type T<A, B> = DeepReadonly<A> & DeepReadonly<B>;
2939
+ `,
2940
+ expected: dedent`
2941
+ type T<A, B> = DeepReadonly<A> & DeepReadonly<B>;
2942
+ `,
2943
+ },
2944
+ {
2945
+ name: 'An intersection under a custom typeName is left as written',
2946
+ source: dedent`
2947
+ type T = DeepRO<{ a: number[] }> & DeepRO<{ b: string[] }>;
2948
+ `,
2949
+ expected: dedent`
2950
+ type T = DeepRO<{ a: number[] }> & DeepRO<{ b: string[] }>;
2951
+ `,
2952
+ options: { DeepReadonly: { typeName: 'DeepRO' } },
2953
+ },
2954
+ {
2955
+ // The union around it has one DeepReadonly member, so nothing merges.
2956
+ name: 'An intersection of DeepReadonly members inside a union is left as written',
2957
+ source: dedent`
2958
+ type T = (DeepReadonly<{ a: number[] }> & DeepReadonly<{ b: string[] }>) | DeepReadonly<{ c: boolean[] }>;
2959
+ `,
2960
+ expected: dedent`
2961
+ type T = (DeepReadonly<{ a: number[] }> & DeepReadonly<{ b: string[] }>) | DeepReadonly<{ c: boolean[] }>;
2962
+ `,
2963
+ },
2964
+ {
2965
+ name: 'Custom DeepReadonly typeName',
2966
+ source: dedent`
2967
+ type T = DeepRO<{ a: number[] }> | DeepRO<{ b: string[] }>;
2968
+ `,
2969
+ expected: dedent`
2970
+ type T = DeepRO<{ a: number[] } | { b: string[] }>;
2971
+ `,
2972
+ options: { DeepReadonly: { typeName: 'DeepRO' } },
2973
+ },
2974
+ {
2975
+ name: 'Other generic types are not merged',
2976
+ source: dedent`
2977
+ type T<A, B> = Partial<A> | Partial<B>;
2978
+ `,
2979
+ expected: dedent`
2980
+ type T<A, B> = Partial<A> | Partial<B>;
2981
+ `,
2982
+ },
2983
+ ])('$name', testFn);
2984
+
2985
+ // What `mergeDeepReadonlyUnionMembers` relies on. If ts-type-forge's
2986
+ // `DeepReadonly` changes so that one of these no longer holds, revisit
2987
+ // which operators the codemod merges.
2988
+ test('premises of merging DeepReadonly members', () => {
2989
+ type F = () => void;
2990
+
2991
+ type X = Readonly<{ x: number[] }>;
2992
+
2993
+ type A = Readonly<{ a: number[] }>;
2994
+
2995
+ type B = Readonly<{ b: string[] }>;
2996
+
2997
+ // `|` commutes with DeepReadonly, so merging a union is safe.
2998
+ expectType<
2999
+ TsTypeForgeDeepReadonly<A | B>,
3000
+ TsTypeForgeDeepReadonly<A> | TsTypeForgeDeepReadonly<B>
3001
+ >('=');
3002
+
3003
+ expectType<
3004
+ TsTypeForgeDeepReadonly<F | X>,
3005
+ TsTypeForgeDeepReadonly<F> | TsTypeForgeDeepReadonly<X>
3006
+ >('=');
3007
+
3008
+ expectType<
3009
+ TsTypeForgeDeepReadonly<readonly string[] | X>,
3010
+ TsTypeForgeDeepReadonly<readonly string[]> | TsTypeForgeDeepReadonly<X>
3011
+ >('=');
3012
+
3013
+ // `&` does not: an intersection is tested as a whole and matches every
3014
+ // branch any one member matches. `F & X` hits the function branch and
3015
+ // comes back unchanged, leaving `x` mutable.
3016
+ expectType<TsTypeForgeDeepReadonly<F & X>, F & X>('=');
3017
+
3018
+ expectType<
3019
+ TsTypeForgeDeepReadonly<F & X>,
3020
+ TsTypeForgeDeepReadonly<F> & TsTypeForgeDeepReadonly<X>
3021
+ >('!=');
3022
+
3023
+ expectType<
3024
+ TsTypeForgeDeepReadonly<readonly string[] & X>,
3025
+ TsTypeForgeDeepReadonly<readonly string[]> & TsTypeForgeDeepReadonly<X>
3026
+ >('!=');
3027
+
3028
+ // `Readonly` over type literals does commute with `&`, which is what
3029
+ // merging `{ a: A } & Readonly<{ b: B }>` into one `Readonly` relies on.
3030
+ expectType<
3031
+ Readonly<{ a: number[] } & { b: string[] }>,
3032
+ Readonly<{ a: number[] }> & Readonly<{ b: string[] }>
3033
+ >('~=');
3034
+ });
3035
+ });
3036
+
2834
3037
  describe('Parenthesized types', () => {
2835
3038
  test.each([
2836
3039
  {
@@ -1,8 +1,8 @@
1
1
  import { Arr } from 'ts-data-forge';
2
2
  import * as tsm from 'ts-morph';
3
- import { type DeepReadonly } from 'ts-type-forge';
3
+ import type { DeepReadonly } from 'ts-type-forge';
4
4
  import { hasDisableNextLineComment } from '../functions/index.mjs';
5
- import { type TsMorphTransformer } from './types.mjs';
5
+ import type { TsMorphTransformer } from './types.mjs';
6
6
 
7
7
  const TRANSFORMER_NAME = 'enable-no-unchecked-indexed-access';
8
8
 
@@ -280,27 +280,49 @@ const isIgnoredByComment = (
280
280
  * the target of an assignment the element type without `undefined`, so the
281
281
  * type comparison in {@link collectAssertionPositions} already passes them
282
282
  * over. The exceptions handled here are the ones it does widen.
283
+ *
284
+ * The position is that of the outermost parenthesis around the access:
285
+ * `(xs[0]) === undefined` tests the value exactly as `xs[0] === undefined`
286
+ * does, while the `!` still goes right after the access, inside them.
283
287
  */
284
288
  const isAssertableReadPosition = (
285
289
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
286
290
  node: IndexReadExpression,
287
291
  ): boolean => {
288
- const parent = node.getParent();
292
+ const outer = outermostParenthesized(node);
293
+
294
+ const parent = outer.getParent();
289
295
 
290
296
  if (parent === undefined) {
291
297
  return false;
292
298
  }
293
299
 
294
300
  return (
295
- !isAlreadyAssertedOrGuarded(node, parent) &&
296
- !isWriteTarget(node, parent) &&
297
- !isTestedForAbsence(node, parent)
301
+ !isAlreadyAssertedOrGuarded(node, outer, parent) &&
302
+ !isWriteTarget(outer, parent) &&
303
+ !isTestedForAbsence(outer, parent)
298
304
  );
299
305
  };
300
306
 
301
- /** `xs[0]!`, `xs[0] as T`, `<T>xs[0]`, `xs[0]?.foo`, `xs[0]?.()`, `xs[0]?.[1]`. */
307
+ /** `node`, or the outermost of the parentheses written around it. */
308
+ const outermostParenthesized = (node: tsm.Node): tsm.Node => {
309
+ const parent = node.getParent();
310
+
311
+ return parent?.isKind(tsm.SyntaxKind.ParenthesizedExpression) === true
312
+ ? outermostParenthesized(parent)
313
+ : node;
314
+ };
315
+
316
+ /**
317
+ * `xs[0]!`, `xs[0] as T`, `<T>xs[0]`, `xs[0]?.foo`, `xs[0]?.()`, `xs[0]?.[1]`.
318
+ *
319
+ * `outer` is `node` with its parentheses (see
320
+ * {@link outermostParenthesized}); a chain continues from the access itself
321
+ * but is guarded from the outside of the parentheses (`(xs[0])?.foo`).
322
+ */
302
323
  const isAlreadyAssertedOrGuarded = (
303
324
  node: tsm.Node,
325
+ outer: tsm.Node,
304
326
  parent: tsm.Node,
305
327
  ): boolean => {
306
328
  if (
@@ -322,7 +344,7 @@ const isAlreadyAssertedOrGuarded = (
322
344
  parent.isKind(tsm.SyntaxKind.ElementAccessExpression) ||
323
345
  parent.isKind(tsm.SyntaxKind.CallExpression)) &&
324
346
  parent.hasQuestionDotToken() &&
325
- parent.getExpression() === node
347
+ parent.getExpression() === outer
326
348
  );
327
349
  };
328
350