ts-codemod-lib 3.4.0 → 3.4.2

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 (50) hide show
  1. package/README.md +4 -4
  2. package/dist/functions/ast-transformers/append-as-const.d.mts +2 -2
  3. package/dist/functions/ast-transformers/append-as-const.d.mts.map +1 -1
  4. package/dist/functions/ast-transformers/append-as-const.mjs +13 -3
  5. package/dist/functions/ast-transformers/append-as-const.mjs.map +1 -1
  6. package/dist/functions/ast-transformers/convert-interface-to-type.d.mts +1 -1
  7. package/dist/functions/ast-transformers/convert-interface-to-type.d.mts.map +1 -1
  8. package/dist/functions/ast-transformers/convert-to-readonly.d.mts +2 -2
  9. package/dist/functions/ast-transformers/convert-to-readonly.d.mts.map +1 -1
  10. package/dist/functions/ast-transformers/convert-to-readonly.mjs +157 -33
  11. package/dist/functions/ast-transformers/convert-to-readonly.mjs.map +1 -1
  12. package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.d.mts +2 -2
  13. package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.d.mts.map +1 -1
  14. package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.mjs +24 -6
  15. package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.mjs.map +1 -1
  16. package/dist/functions/ast-transformers/readonly-transformer-helpers/group-union-types.d.mts +32 -2
  17. package/dist/functions/ast-transformers/readonly-transformer-helpers/group-union-types.d.mts.map +1 -1
  18. package/dist/functions/ast-transformers/readonly-transformer-helpers/group-union-types.mjs +88 -2
  19. package/dist/functions/ast-transformers/readonly-transformer-helpers/group-union-types.mjs.map +1 -1
  20. package/dist/functions/ast-transformers/readonly-transformer-helpers/readonly-context.d.mts +1 -1
  21. package/dist/functions/ast-transformers/readonly-transformer-helpers/readonly-context.d.mts.map +1 -1
  22. package/dist/functions/ast-transformers/replace-any-with-unknown.d.mts +1 -1
  23. package/dist/functions/ast-transformers/replace-any-with-unknown.d.mts.map +1 -1
  24. package/dist/functions/ast-transformers/replace-record-with-unknown-record.d.mts +1 -1
  25. package/dist/functions/ast-transformers/replace-record-with-unknown-record.d.mts.map +1 -1
  26. package/dist/functions/ast-transformers/transform-source-code.d.mts +1 -1
  27. package/dist/functions/ast-transformers/transform-source-code.d.mts.map +1 -1
  28. package/dist/functions/functions/is-atomic-type-node.d.mts +1 -1
  29. package/dist/functions/functions/is-atomic-type-node.d.mts.map +1 -1
  30. package/dist/functions/functions/is-readonly-node.d.mts +1 -1
  31. package/dist/functions/functions/is-readonly-node.d.mts.map +1 -1
  32. package/dist/functions/functions/unwrap-readonly.d.mts +1 -1
  33. package/dist/functions/functions/unwrap-readonly.d.mts.map +1 -1
  34. package/package.json +14 -14
  35. package/src/functions/ast-transformers/append-as-const.mts +21 -5
  36. package/src/functions/ast-transformers/append-as-const.test.mts +24 -0
  37. package/src/functions/ast-transformers/convert-interface-to-type.mts +1 -1
  38. package/src/functions/ast-transformers/convert-to-readonly.mts +249 -52
  39. package/src/functions/ast-transformers/convert-to-readonly.test.mts +421 -7
  40. package/src/functions/ast-transformers/enable-no-unchecked-indexed-access.mts +30 -8
  41. package/src/functions/ast-transformers/enable-no-unchecked-indexed-access.test.mts +111 -0
  42. package/src/functions/ast-transformers/readonly-transformer-helpers/compare-union-types.mts +1 -1
  43. package/src/functions/ast-transformers/readonly-transformer-helpers/group-union-types.mts +124 -3
  44. package/src/functions/ast-transformers/readonly-transformer-helpers/readonly-context.mts +1 -1
  45. package/src/functions/ast-transformers/replace-any-with-unknown.mts +1 -1
  46. package/src/functions/ast-transformers/replace-record-with-unknown-record.mts +1 -1
  47. package/src/functions/ast-transformers/transform-source-code.mts +1 -1
  48. package/src/functions/functions/is-atomic-type-node.mts +1 -1
  49. package/src/functions/functions/is-readonly-node.mts +1 -1
  50. 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.4.0",
3
+ "version": "3.4.2",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "typescript",
@@ -43,18 +43,18 @@
43
43
  "ts-type-forge": "^9.2.2"
44
44
  },
45
45
  "devDependencies": {
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",
46
+ "@types/node": "26.6.4",
47
+ "@vitest/browser-playwright": "5.0.3",
48
+ "@vitest/coverage-v8": "5.0.3",
49
+ "@vitest/ui": "5.0.3",
50
+ "cspell": "10.3.6",
51
51
  "dedent": "1.7.2",
52
52
  "esbuild": "0.28.2",
53
- "eslint": "10.11.0",
54
- "eslint-config-typed": "5.16.0",
55
- "eslint-plugin-ts-data-forge": "0.8.2",
56
- "eslint-plugin-ts-fortress": "1.2.4",
57
- "eslint-plugin-ts-type-forge": "0.8.5",
53
+ "eslint": "10.12.0",
54
+ "eslint-config-typed": "5.17.1",
55
+ "eslint-plugin-ts-data-forge": "0.8.4",
56
+ "eslint-plugin-ts-fortress": "1.3.1",
57
+ "eslint-plugin-ts-type-forge": "0.8.6",
58
58
  "jiti": "2.7.0",
59
59
  "npm-run-all2": "9.0.3",
60
60
  "playwright": "1.63.0",
@@ -67,15 +67,15 @@
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.1",
71
- "vitest": "5.0.2"
70
+ "vite": "8.3.2",
71
+ "vitest": "5.0.3"
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.10.0"
78
+ "node": "26.11.1"
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,
@@ -27,16 +27,16 @@ import {
27
27
  shouldAvoidParenthesesForReadonly,
28
28
  unwrapReadonlyTypeArgText,
29
29
  wrapWithParentheses,
30
- type ReadonlyTypeReferenceNode,
31
30
  } from '../functions/index.mjs';
32
31
  import { replaceNodeWithDebugPrint } from '../utils/index.mjs';
33
32
  import {
34
33
  groupUnionIntersectionTypes,
35
34
  invalidDeepReadonlyTypeName,
35
+ isMergeableReadonlyTypeReferenceNode,
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
 
@@ -655,32 +655,52 @@ const transformTypeReferenceNode = (
655
655
  );
656
656
 
657
657
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
658
- const T = removeParentheses(node.getTypeArguments()[0]!);
658
+ const typeArgument = node.getTypeArguments()[0]!;
659
+
660
+ const T = removeParentheses(typeArgument);
661
+
662
+ // The comments in front of the type argument and inside its parentheses,
663
+ // which `T.getFullText()` would leave behind with the parentheses.
664
+ const commentsInFrontOfT = commentsBetween(
665
+ node.getSourceFile(),
666
+ typeArgument.getPos(),
667
+ T.getStart(),
668
+ );
669
+
670
+ const TText = `${commentsInFrontOfT}${T.getText()}` as const;
671
+
672
+ // `T` with the parentheses that `Readonly<...>` made unnecessary and its
673
+ // position may need (`Readonly<A | B>[]` -> `(A | B)[]`).
674
+ const unwrappedText =
675
+ T.isKind(tsm.SyntaxKind.UnionType) ||
676
+ T.isKind(tsm.SyntaxKind.IntersectionType)
677
+ ? wrapWithParentheses(TText)
678
+ : TText;
659
679
 
660
680
  // DeepReadonly<Readonly<T>> -> DeepReadonly<T>
661
681
  if (readonlyContext.type === 'DeepReadonly') {
662
- options.replaceNode(node, T.getFullText());
682
+ options.replaceNode(node, unwrappedText);
663
683
 
664
684
  return;
665
685
  }
666
686
 
667
687
  // Readonly<{ ... }>[I] -> { ... }[I]
668
688
  if (readonlyContext.indexedAccessDepth > 0) {
669
- options.replaceNode(node, T.getFullText());
689
+ options.replaceNode(node, unwrappedText);
670
690
 
671
691
  return;
672
692
  }
673
693
 
674
694
  // Readonly<Readonly<T>> -> Readonly<T>
675
695
  if (isReadonlyTypeReferenceNode(T)) {
676
- options.replaceNode(node, T.getFullText());
696
+ options.replaceNode(node, TText);
677
697
 
678
698
  return;
679
699
  }
680
700
 
681
701
  // Readonly<number> -> number
682
702
  if (isAtomicTypeNode(T)) {
683
- options.replaceNode(node, T.getFullText());
703
+ options.replaceNode(node, TText);
684
704
 
685
705
  return;
686
706
  }
@@ -696,11 +716,11 @@ const transformTypeReferenceNode = (
696
716
  if (Arr.isFixedLengthArray(2, recordTypeArguments)) {
697
717
  options.replaceNode(
698
718
  node,
699
- readonlyRecordText(
719
+ `${commentsInFrontOfT}${readonlyRecordText(
700
720
  recordTypeArguments[0].getFullText(),
701
721
  recordTypeArguments[1].getFullText(),
702
722
  options,
703
- ),
723
+ )}`,
704
724
  );
705
725
 
706
726
  return;
@@ -716,7 +736,7 @@ const transformTypeReferenceNode = (
716
736
  T.isKind(tsm.SyntaxKind.ArrayType) ||
717
737
  T.isKind(tsm.SyntaxKind.TupleType)
718
738
  ) {
719
- const readonlyText = `readonly ${T.getFullText()}` as const;
739
+ const readonlyText = `readonly ${TText}` as const;
720
740
 
721
741
  options.replaceNode(
722
742
  node,
@@ -732,18 +752,21 @@ const transformTypeReferenceNode = (
732
752
  // Readonly<readonly E[]> -> readonly E[]
733
753
  // Readonly<readonly [E1, E2, E3]> -> readonly [E1, E2, E3]
734
754
  if (isReadonlyTupleOrArrayTypeNode(T)) {
735
- options.replaceNode(node, T.getFullText());
755
+ options.replaceNode(node, TText);
736
756
 
737
757
  return;
738
758
  }
739
759
 
740
760
  // T = A | B | C
741
761
  // T = A & B & C
762
+ //
763
+ // The union or intersection has already put back under `Readonly` every
764
+ // member that still needs it, so this one is redundant.
742
765
  if (
743
766
  T.isKind(tsm.SyntaxKind.UnionType) ||
744
767
  T.isKind(tsm.SyntaxKind.IntersectionType)
745
768
  ) {
746
- options.replaceNode(node, T.getFullText());
769
+ options.replaceNode(node, unwrappedText);
747
770
 
748
771
  return;
749
772
  }
@@ -883,7 +906,7 @@ const transformArrayTypeNode = (
883
906
  readonlyContext.indexedAccessDepth === 0 &&
884
907
  !isConditionalTypeDistributionGuard(node)
885
908
  ) {
886
- const readonlyText = `readonly ${node.getFullText()}` as const;
909
+ const readonlyText = `readonly ${node.getText()}` as const;
887
910
 
888
911
  options.replaceNode(
889
912
  node,
@@ -935,7 +958,7 @@ const transformTupleTypeNode = (
935
958
  readonlyContext.indexedAccessDepth === 0 &&
936
959
  !isConditionalTypeDistributionGuard(node)
937
960
  ) {
938
- const readonlyText = `readonly ${node.getFullText()}` as const;
961
+ const readonlyText = `readonly ${node.getText()}` as const;
939
962
 
940
963
  options.replaceNode(
941
964
  node,
@@ -969,12 +992,22 @@ const transformRestTypeNode = (
969
992
 
970
993
  // `tr("...readonly E[]") |-> ...tr(E)[]`
971
994
  // `tr("...readonly [E1, E2]") |-> ...[tr(E1), tr(E2)]`
972
- if (isReadonlyArrayTypeNode(R) || isReadonlyTupleTypeNode(R)) {
973
- options.replaceNode(
974
- node,
975
- `...${R.getTypeNode().getFullText()}` /* = tr(E)[] or [tr(E1), tr(E2)] */,
976
- );
995
+ if (!isReadonlyArrayTypeNode(R) && !isReadonlyTupleTypeNode(R)) {
996
+ return;
977
997
  }
998
+
999
+ // The comments in front of `R` and inside the parentheses around it, which
1000
+ // `R.getTypeNode().getFullText()` does not reach.
1001
+ const commentsInFrontOfR = commentsBetween(
1002
+ node.getSourceFile(),
1003
+ node.getTypeNode().getPos(),
1004
+ R.getStart(),
1005
+ );
1006
+
1007
+ options.replaceNode(
1008
+ node,
1009
+ `...${commentsInFrontOfR}${R.getTypeNode().getFullText()}` /* = tr(E)[] or [tr(E1), tr(E2)] */,
1010
+ );
978
1011
  };
979
1012
 
980
1013
  /**
@@ -1061,7 +1094,7 @@ const transformTypeLiteralNode = (
1061
1094
 
1062
1095
  if (wrappingApplies && !skipWrapping) {
1063
1096
  // `{ readonly x: X, readonly y: Y } |-> Readonly<{ x: X, y: Y }>`
1064
- options.replaceNode(node, `Readonly<${node.getFullText()}>`);
1097
+ options.replaceNode(node, `Readonly<${node.getText()}>`);
1065
1098
  }
1066
1099
  };
1067
1100
 
@@ -1286,7 +1319,8 @@ const transformConstructorParameter = (
1286
1319
 
1287
1320
  /**
1288
1321
  * - `tr(A & B) -> tr(A) & tr(B)`
1289
- * - `tr(Readonly<A> & Readonly<B>) -> Readonly<tr(A) & tr(B)>`
1322
+ * - `tr(Readonly<A> & Readonly<B>) -> Readonly<tr(A) & tr(B)>`, when `A` and `B`
1323
+ * are type literals (see `isMergeableReadonlyTypeReferenceNode`)
1290
1324
  */
1291
1325
  const transformIntersectionTypeNode = (
1292
1326
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
@@ -1340,15 +1374,30 @@ const transformUnionOrIntersectionTypeNodeImpl = (
1340
1374
 
1341
1375
  const newTypes = node.getTypeNodes();
1342
1376
 
1377
+ const withLeadingComments = (
1378
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
1379
+ member: tsm.TypeNode,
1380
+ memberText: string,
1381
+ ): string => `${leadingCommentsOfMember(node, member)}${memberText}` as const;
1382
+
1343
1383
  const { primitives, arraysAndTuples, typeLiterals, others } =
1344
- groupUnionIntersectionTypes(newTypes);
1384
+ groupUnionIntersectionTypes(newTypes, {
1385
+ operator,
1386
+ // `Readonly<A & { b: B }>`: `A` stays under the `Readonly`, which the
1387
+ // handler of `Readonly` then takes off as redundant.
1388
+ underReadonly:
1389
+ readonlyContext.type === 'Readonly' &&
1390
+ readonlyContext.indexedAccessDepth === 0 &&
1391
+ isTypeArgumentOfReadonly(node),
1392
+ deepReadonlyTypeName: options.DeepReadonly.typeName,
1393
+ });
1345
1394
 
1346
1395
  options.debugPrint({ primitives, arraysAndTuples, typeLiterals, others });
1347
1396
 
1348
1397
  // Separate empty type literals from non-empty ones when ignoreEmptyObjectTypes is true
1349
1398
  const isEmptyTypeLiteral = (
1350
1399
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
1351
- n: tsm.TypeLiteralNode | ReadonlyTypeReferenceNode,
1400
+ n: tsm.TypeNode,
1352
1401
  ): boolean =>
1353
1402
  n.isKind(tsm.SyntaxKind.TypeLiteral) && Arr.isEmpty(n.getMembers());
1354
1403
 
@@ -1370,10 +1419,16 @@ const transformUnionOrIntersectionTypeNodeImpl = (
1370
1419
  : ([
1371
1420
  unionToString({
1372
1421
  types: nonEmptyTypeLiterals.map((n) =>
1373
- isReadonlyTypeReferenceNode(n)
1374
- ? // NOTE: Readonly<A & B> -> (A & B)
1375
- unwrapReadonlyTypeArgText(n)
1376
- : (n satisfies tsm.TypeLiteralNode).getFullText(),
1422
+ withLeadingComments(
1423
+ n,
1424
+ isMergeableReadonlyTypeReferenceNode(n, operator)
1425
+ ? // NOTE: Readonly<A & B> -> (A & B)
1426
+ unwrapReadonlyTypeArgText(n)
1427
+ : n.isKind(tsm.SyntaxKind.TypeLiteral)
1428
+ ? n.getText()
1429
+ : // A member kept under `Readonly`; see `underReadonly`
1430
+ wrapWithParentheses(n.getText()),
1431
+ ),
1377
1432
  ),
1378
1433
  op: operator,
1379
1434
  wrapWithReadonly:
@@ -1386,11 +1441,19 @@ const transformUnionOrIntersectionTypeNodeImpl = (
1386
1441
  const sorted: readonly string[] = [
1387
1442
  mapNullable(
1388
1443
  primitives,
1389
- (a) => [a.nodes.map((n) => n.getFullText()), a.firstPosition] as const,
1444
+ (a) =>
1445
+ [
1446
+ a.nodes.map((n) => withLeadingComments(n, n.getText())),
1447
+ a.firstPosition,
1448
+ ] as const,
1390
1449
  ),
1391
1450
  mapNullable(
1392
1451
  arraysAndTuples,
1393
- (a) => [a.nodes.map((n) => n.getFullText()), a.firstPosition] as const,
1452
+ (a) =>
1453
+ [
1454
+ a.nodes.map((n) => withLeadingComments(n, n.getText())),
1455
+ a.firstPosition,
1456
+ ] as const,
1394
1457
  ),
1395
1458
  nonEmptyTypeLiterals !== undefined && Arr.isNonEmpty(nonEmptyTypeLiterals)
1396
1459
  ? mapNullable(
@@ -1403,7 +1466,7 @@ const transformUnionOrIntersectionTypeNodeImpl = (
1403
1466
  typeLiterals,
1404
1467
  (a) =>
1405
1468
  [
1406
- emptyTypeLiterals.map((n) => n.getFullText()),
1469
+ emptyTypeLiterals.map((n) => withLeadingComments(n, n.getText())),
1407
1470
  // Use a position after non-empty type literals if they exist
1408
1471
  a.firstPosition +
1409
1472
  (nonEmptyTypeLiterals !== undefined &&
@@ -1423,8 +1486,11 @@ const transformUnionOrIntersectionTypeNodeImpl = (
1423
1486
  ? mergeDeepReadonlyUnionMembers(
1424
1487
  a.nodes,
1425
1488
  options.DeepReadonly.typeName,
1489
+ withLeadingComments,
1426
1490
  )
1427
- : a.nodes.map((n) => wrapWithParentheses(n.getFullText())),
1491
+ : a.nodes.map((n) =>
1492
+ withLeadingComments(n, wrapWithParentheses(n.getText())),
1493
+ ),
1428
1494
  a.firstPosition,
1429
1495
  ] as const,
1430
1496
  ),
@@ -1468,8 +1534,9 @@ const transformUnionOrIntersectionTypeNodeImpl = (
1468
1534
  *
1469
1535
  * - `Readonly` is a homomorphic mapped type, and over type literals
1470
1536
  * `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.)
1537
+ * with the same modifiers. That does not hold for an array `A`, so an
1538
+ * intersection merges only `Readonly<T>` whose `T` is known to be a type
1539
+ * literal; see `isMergeableReadonlyTypeReferenceNode`.
1473
1540
  * - `DeepReadonly` is a conditional type that picks a branch by what `T` is
1474
1541
  * (primitive, function, Map, Set, array, object). It distributes over a union
1475
1542
  * because each member is tested on its own, but an intersection is tested as
@@ -1488,28 +1555,52 @@ const mergeDeepReadonlyUnionMembers = (
1488
1555
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
1489
1556
  nodes: readonly tsm.TypeNode[],
1490
1557
  deepReadonlyTypeName: string,
1558
+ withLeadingComments: (
1559
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
1560
+ member: tsm.TypeNode,
1561
+ memberText: string,
1562
+ ) => string,
1491
1563
  ): readonly string[] => {
1492
1564
  const typeArgsOfDeepReadonly = nodes.map((n) =>
1493
1565
  getDeepReadonlyTypeArg(n, deepReadonlyTypeName),
1494
1566
  );
1495
1567
 
1496
- const typeArgs = typeArgsOfDeepReadonly.filter((t) => t !== undefined);
1568
+ const asIs = (
1569
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
1570
+ n: tsm.TypeNode,
1571
+ ): string => withLeadingComments(n, wrapWithParentheses(n.getText()));
1497
1572
 
1498
- if (typeArgs.length < 2) {
1499
- return nodes.map((n) => wrapWithParentheses(n.getFullText()));
1573
+ if (typeArgsOfDeepReadonly.filter((t) => t !== undefined).length < 2) {
1574
+ return nodes.map(asIs);
1500
1575
  }
1501
1576
 
1502
1577
  const firstIndex = typeArgsOfDeepReadonly.findIndex((t) => t !== undefined);
1503
1578
 
1504
1579
  const merged = unionToString({
1505
- types: typeArgs.map((t_) => {
1580
+ // A member's comments move inside, onto its type argument:
1581
+ // `/** a */ DeepReadonly<A> | /** b */ DeepReadonly<B>`
1582
+ // -> `DeepReadonly</** a */ A | /** b */ B>`
1583
+ types: nodes.flatMap((n, i) => {
1584
+ const t_ = typeArgsOfDeepReadonly[i];
1585
+
1586
+ if (t_ === undefined) {
1587
+ return [];
1588
+ }
1589
+
1506
1590
  const t = removeParentheses(t_);
1507
1591
 
1508
1592
  // NOTE: DeepReadonly<A | B> | DeepReadonly<C> -> DeepReadonly<A | B | C>
1509
1593
  // NOTE: DeepReadonly<() => void> | DeepReadonly<B> -> DeepReadonly<(() => void) | B>
1510
- return t.isKind(tsm.SyntaxKind.UnionType)
1511
- ? t.getFullText()
1512
- : wrapWithParentheses(t.getFullText());
1594
+ const tText = textWithoutParentheses(t_, t);
1595
+
1596
+ return [
1597
+ withLeadingComments(
1598
+ n,
1599
+ t.isKind(tsm.SyntaxKind.UnionType)
1600
+ ? tText
1601
+ : wrapWithParentheses(tText),
1602
+ ),
1603
+ ];
1513
1604
  }),
1514
1605
  op: '|',
1515
1606
  wrapWithReadonly: deepReadonlyTypeName,
@@ -1517,13 +1608,120 @@ const mergeDeepReadonlyUnionMembers = (
1517
1608
 
1518
1609
  return nodes.flatMap((n, i) =>
1519
1610
  typeArgsOfDeepReadonly[i] === undefined
1520
- ? [wrapWithParentheses(n.getFullText())]
1611
+ ? [asIs(n)]
1521
1612
  : i === firstIndex
1522
1613
  ? [merged]
1523
1614
  : [],
1524
1615
  );
1525
1616
  };
1526
1617
 
1618
+ /** Whether `node` is the type argument of `Readonly<...>`, parentheses aside. */
1619
+ const isTypeArgumentOfReadonly = (
1620
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
1621
+ node: tsm.TypeNode,
1622
+ ): boolean => {
1623
+ const parent = node.getParent();
1624
+
1625
+ return (
1626
+ parent !== undefined &&
1627
+ (parent.isKind(tsm.SyntaxKind.ParenthesizedType)
1628
+ ? isTypeArgumentOfReadonly(parent)
1629
+ : isReadonlyTypeReferenceNode(parent))
1630
+ );
1631
+ };
1632
+
1633
+ /**
1634
+ * The comments in front of a union or intersection member, for the text that
1635
+ * replaces the whole type.
1636
+ *
1637
+ * `replaceWithText` overwrites a node from its `getStart()`, so whatever lies
1638
+ * in front of that stays in the file by itself. With no leading `|` / `&`, the
1639
+ * first member starts there too, and its comments are the type's: repeating
1640
+ * them in the replacement would copy them once per run. Every other member
1641
+ * has a separator in front of it, and a comment before the separator belongs
1642
+ * to no member's full text, so it is gone once the separator is rewritten. It
1643
+ * goes with the member after it, like the comments after the separator.
1644
+ *
1645
+ * `member` may be the type inside a member's parentheses; the comments inside
1646
+ * them are collected too, and the parentheses are not.
1647
+ */
1648
+ const leadingCommentsOfMember = (
1649
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
1650
+ node: tsm.IntersectionTypeNode | tsm.UnionTypeNode,
1651
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
1652
+ member: tsm.TypeNode,
1653
+ ): string =>
1654
+ commentsBetween(
1655
+ node.getSourceFile(),
1656
+ node
1657
+ .getTypeNodes()
1658
+ .findLast((m) => m.getEnd() <= member.getStart())
1659
+ ?.getEnd() ?? node.getStart(),
1660
+ member.getStart(),
1661
+ );
1662
+
1663
+ /**
1664
+ * The text of `inner`, a type `removeParentheses` took out of `outer`, with
1665
+ * the comments in `outer`'s full text in front of it — before the parentheses
1666
+ * and inside them — and without the parentheses.
1667
+ *
1668
+ * `inner.getFullText()` would drop those before the parentheses, which are
1669
+ * as much a part of `outer` as the rest of it.
1670
+ */
1671
+ const textWithoutParentheses = (
1672
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
1673
+ outer: tsm.TypeNode,
1674
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
1675
+ inner: tsm.TypeNode,
1676
+ ): string =>
1677
+ `${commentsBetween(outer.getSourceFile(), outer.getPos(), inner.getStart())}${inner.getText()}` as const;
1678
+
1679
+ /**
1680
+ * The comments in `[from, to)` of a source file, each followed by what keeps
1681
+ * the next token off it, and nothing else in that range — no whitespace, and
1682
+ * no token such as a separator or a parenthesis.
1683
+ */
1684
+ const commentsBetween = (
1685
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
1686
+ sourceFile: tsm.SourceFile,
1687
+ from: number,
1688
+ to: number,
1689
+ ): string => {
1690
+ if (to <= from) {
1691
+ return '';
1692
+ }
1693
+
1694
+ const scanner = tsm.ts.createScanner(
1695
+ tsm.ts.ScriptTarget.Latest,
1696
+ false,
1697
+ tsm.ts.LanguageVariant.Standard,
1698
+ sourceFile.getFullText(),
1699
+ undefined,
1700
+ from,
1701
+ to - from,
1702
+ );
1703
+
1704
+ const mut_comments: string[] = [];
1705
+
1706
+ while (scanner.scan() !== tsm.ts.SyntaxKind.EndOfFileToken) {
1707
+ const token = scanner.getToken();
1708
+
1709
+ // A line comment runs to the end of the line, so it takes one along.
1710
+ const separator =
1711
+ token === tsm.ts.SyntaxKind.SingleLineCommentTrivia
1712
+ ? '\n'
1713
+ : token === tsm.ts.SyntaxKind.MultiLineCommentTrivia
1714
+ ? ' '
1715
+ : undefined;
1716
+
1717
+ if (separator !== undefined) {
1718
+ mut_comments.push(`${scanner.getTokenText()}${separator}`);
1719
+ }
1720
+ }
1721
+
1722
+ return mut_comments.join('');
1723
+ };
1724
+
1527
1725
  /** `DeepReadonly<T>` |-> `T`, anything else |-> `undefined` */
1528
1726
  const getDeepReadonlyTypeArg = (
1529
1727
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
@@ -1554,12 +1752,11 @@ const unionToString = ({
1554
1752
  op: '&' | '|';
1555
1753
  wrapWithReadonly: boolean | string;
1556
1754
  }>): string => {
1557
- // The members arrive as `getFullText()`, which carries the whitespace in
1558
- // front of each one, and they are joined with a separator that supplies its
1559
- // own space. Left alone, that whitespace accumulates: every run of the
1560
- // transformer adds one more space before every member but the first, so a
1561
- // file never reaches a fixed point. Comments live in the same trivia and are
1562
- // not whitespace, so trimming keeps them.
1755
+ // The members are joined with a separator that supplies its own space, so
1756
+ // whitespace left at a member's edge would accumulate: every run of the
1757
+ // transformer would add one more space before every member but the first,
1758
+ // and a file would never reach a fixed point. Trimming keeps the comments in
1759
+ // front of a member, which are not whitespace.
1563
1760
  const types = typesRaw.map((s) => s.trim());
1564
1761
 
1565
1762
  return unionToStringImpl({ types, op, wrapWithReadonly });