ts-codemod-lib 2.2.0 → 2.2.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 (107) hide show
  1. package/dist/cmd/append-as-const.mjs +51 -48
  2. package/dist/cmd/append-as-const.mjs.map +1 -1
  3. package/dist/cmd/convert-interface-to-type.mjs +51 -48
  4. package/dist/cmd/convert-interface-to-type.mjs.map +1 -1
  5. package/dist/cmd/convert-to-readonly.mjs +51 -48
  6. package/dist/cmd/convert-to-readonly.mjs.map +1 -1
  7. package/dist/cmd/replace-any-with-unknown.mjs +51 -48
  8. package/dist/cmd/replace-any-with-unknown.mjs.map +1 -1
  9. package/dist/cmd/replace-record-with-unknown-record.mjs +51 -48
  10. package/dist/cmd/replace-record-with-unknown-record.mjs.map +1 -1
  11. package/dist/cmd/run-transformer-cli.d.mts.map +1 -1
  12. package/dist/cmd/run-transformer-cli.mjs +138 -127
  13. package/dist/cmd/run-transformer-cli.mjs.map +1 -1
  14. package/dist/entry-point.mjs +1 -0
  15. package/dist/entry-point.mjs.map +1 -1
  16. package/dist/functions/ast-transformers/append-as-const.d.mts.map +1 -1
  17. package/dist/functions/ast-transformers/append-as-const.mjs +205 -191
  18. package/dist/functions/ast-transformers/append-as-const.mjs.map +1 -1
  19. package/dist/functions/ast-transformers/convert-interface-to-type.mjs +62 -82
  20. package/dist/functions/ast-transformers/convert-interface-to-type.mjs.map +1 -1
  21. package/dist/functions/ast-transformers/convert-to-readonly.d.mts.map +1 -1
  22. package/dist/functions/ast-transformers/convert-to-readonly.mjs +743 -828
  23. package/dist/functions/ast-transformers/convert-to-readonly.mjs.map +1 -1
  24. package/dist/functions/ast-transformers/readonly-transformer-helpers/compare-union-types.d.mts.map +1 -1
  25. package/dist/functions/ast-transformers/readonly-transformer-helpers/compare-union-types.mjs +2 -14
  26. package/dist/functions/ast-transformers/readonly-transformer-helpers/compare-union-types.mjs.map +1 -1
  27. package/dist/functions/ast-transformers/readonly-transformer-helpers/constants.d.mts +2 -1
  28. package/dist/functions/ast-transformers/readonly-transformer-helpers/constants.d.mts.map +1 -1
  29. package/dist/functions/ast-transformers/readonly-transformer-helpers/constants.mjs +8 -8
  30. package/dist/functions/ast-transformers/readonly-transformer-helpers/constants.mjs.map +1 -1
  31. package/dist/functions/ast-transformers/readonly-transformer-helpers/group-union-types.d.mts.map +1 -1
  32. package/dist/functions/ast-transformers/readonly-transformer-helpers/group-union-types.mjs +51 -62
  33. package/dist/functions/ast-transformers/readonly-transformer-helpers/group-union-types.mjs.map +1 -1
  34. package/dist/functions/ast-transformers/readonly-transformer-helpers/readonly-context.d.mts +12 -12
  35. package/dist/functions/ast-transformers/readonly-transformer-helpers/readonly-context.d.mts.map +1 -1
  36. package/dist/functions/ast-transformers/readonly-transformer-helpers/readonly-context.mjs +16 -14
  37. package/dist/functions/ast-transformers/readonly-transformer-helpers/readonly-context.mjs.map +1 -1
  38. package/dist/functions/ast-transformers/replace-any-with-unknown.mjs +25 -27
  39. package/dist/functions/ast-transformers/replace-any-with-unknown.mjs.map +1 -1
  40. package/dist/functions/ast-transformers/replace-record-with-unknown-record.mjs +149 -208
  41. package/dist/functions/ast-transformers/replace-record-with-unknown-record.mjs.map +1 -1
  42. package/dist/functions/ast-transformers/transform-source-code.d.mts.map +1 -1
  43. package/dist/functions/ast-transformers/transform-source-code.mjs +70 -72
  44. package/dist/functions/ast-transformers/transform-source-code.mjs.map +1 -1
  45. package/dist/functions/constants/ignore-comment-text.d.mts +2 -2
  46. package/dist/functions/constants/ignore-comment-text.d.mts.map +1 -1
  47. package/dist/functions/constants/ignore-comment-text.mjs +8 -8
  48. package/dist/functions/constants/ignore-comment-text.mjs.map +1 -1
  49. package/dist/functions/functions/has-disable-next-line-comment.d.mts.map +1 -1
  50. package/dist/functions/functions/has-disable-next-line-comment.mjs +33 -65
  51. package/dist/functions/functions/has-disable-next-line-comment.mjs.map +1 -1
  52. package/dist/functions/functions/index.d.mts +1 -0
  53. package/dist/functions/functions/index.d.mts.map +1 -1
  54. package/dist/functions/functions/index.mjs +1 -0
  55. package/dist/functions/functions/index.mjs.map +1 -1
  56. package/dist/functions/functions/is-as-const-node.d.mts +1 -1
  57. package/dist/functions/functions/is-as-const-node.d.mts.map +1 -1
  58. package/dist/functions/functions/is-as-const-node.mjs +15 -23
  59. package/dist/functions/functions/is-as-const-node.mjs.map +1 -1
  60. package/dist/functions/functions/is-atomic-type-node.d.mts.map +1 -1
  61. package/dist/functions/functions/is-atomic-type-node.mjs +22 -36
  62. package/dist/functions/functions/is-atomic-type-node.mjs.map +1 -1
  63. package/dist/functions/functions/is-conditional-type-distribution-guard.d.mts +23 -0
  64. package/dist/functions/functions/is-conditional-type-distribution-guard.d.mts.map +1 -0
  65. package/dist/functions/functions/is-conditional-type-distribution-guard.mjs +16 -0
  66. package/dist/functions/functions/is-conditional-type-distribution-guard.mjs.map +1 -0
  67. package/dist/functions/functions/is-readonly-node.d.mts.map +1 -1
  68. package/dist/functions/functions/is-readonly-node.mjs +11 -21
  69. package/dist/functions/functions/is-readonly-node.mjs.map +1 -1
  70. package/dist/functions/functions/is-spread-parameter-node.d.mts.map +1 -1
  71. package/dist/functions/functions/is-spread-parameter-node.mjs +2 -4
  72. package/dist/functions/functions/is-spread-parameter-node.mjs.map +1 -1
  73. package/dist/functions/functions/remove-parentheses.d.mts.map +1 -1
  74. package/dist/functions/functions/remove-parentheses.mjs +1 -4
  75. package/dist/functions/functions/remove-parentheses.mjs.map +1 -1
  76. package/dist/functions/functions/should-avoid-parentheses-for-readonly.d.mts.map +1 -1
  77. package/dist/functions/functions/should-avoid-parentheses-for-readonly.mjs +7 -24
  78. package/dist/functions/functions/should-avoid-parentheses-for-readonly.mjs.map +1 -1
  79. package/dist/functions/functions/unwrap-readonly.d.mts.map +1 -1
  80. package/dist/functions/functions/unwrap-readonly.mjs +1 -3
  81. package/dist/functions/functions/unwrap-readonly.mjs.map +1 -1
  82. package/dist/functions/functions/wrap-with-parentheses.d.mts.map +1 -1
  83. package/dist/functions/functions/wrap-with-parentheses.mjs +26 -51
  84. package/dist/functions/functions/wrap-with-parentheses.mjs.map +1 -1
  85. package/dist/functions/index.mjs +1 -0
  86. package/dist/functions/index.mjs.map +1 -1
  87. package/dist/functions/utils/replace-with-debug.d.mts.map +1 -1
  88. package/dist/functions/utils/replace-with-debug.mjs +2 -2
  89. package/dist/functions/utils/replace-with-debug.mjs.map +1 -1
  90. package/dist/index.mjs +1 -0
  91. package/dist/index.mjs.map +1 -1
  92. package/package.json +32 -31
  93. package/src/cmd/append-as-const.mts +3 -3
  94. package/src/cmd/convert-interface-to-type.mts +3 -3
  95. package/src/cmd/convert-to-readonly.mts +3 -3
  96. package/src/cmd/replace-any-with-unknown.mts +3 -3
  97. package/src/cmd/replace-record-with-unknown-record.mts +3 -3
  98. package/src/cmd/run-transformer-cli.mts +21 -17
  99. package/src/functions/ast-transformers/convert-interface-to-type.mts +1 -1
  100. package/src/functions/ast-transformers/convert-to-readonly.mts +77 -37
  101. package/src/functions/ast-transformers/convert-to-readonly.test.mts +117 -1
  102. package/src/functions/ast-transformers/readonly-transformer-helpers/readonly-context.mts +6 -6
  103. package/src/functions/ast-transformers/replace-record-with-unknown-record.mts +4 -4
  104. package/src/functions/functions/has-disable-next-line-comment.mts +5 -5
  105. package/src/functions/functions/index.mts +1 -0
  106. package/src/functions/functions/is-conditional-type-distribution-guard.mts +45 -0
  107. package/src/functions/functions/wrap-with-parentheses.mts +17 -23
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import * as cmd from 'cmd-ts';
4
- import { Result } from 'ts-data-forge';
4
+ import { Arr, Result } from 'ts-data-forge';
5
5
  import { convertToReadonlyTransformer } from '../functions/index.mjs';
6
6
  import { runTransformerCLI } from './run-transformer-cli.mjs';
7
7
 
@@ -9,7 +9,7 @@ const transformer = convertToReadonlyTransformer();
9
9
 
10
10
  const cmdDef = cmd.command({
11
11
  name: transformer.name,
12
- version: '2.2.0',
12
+ version: '2.2.2',
13
13
  args: {
14
14
  baseDir: cmd.positional({
15
15
  type: cmd.string,
@@ -64,4 +64,4 @@ const cmdDef = cmd.command({
64
64
  },
65
65
  });
66
66
 
67
- await cmd.run(cmdDef, process.argv.slice(2));
67
+ await cmd.run(cmdDef, Arr.skip(process.argv, 2));
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import * as cmd from 'cmd-ts';
4
- import { Result } from 'ts-data-forge';
4
+ import { Arr, Result } from 'ts-data-forge';
5
5
  import { replaceAnyWithUnknownTransformer } from '../functions/index.mjs';
6
6
  import { runTransformerCLI } from './run-transformer-cli.mjs';
7
7
 
@@ -9,7 +9,7 @@ const transformer = replaceAnyWithUnknownTransformer();
9
9
 
10
10
  const cmdDef = cmd.command({
11
11
  name: transformer.name,
12
- version: '2.2.0',
12
+ version: '2.2.2',
13
13
  args: {
14
14
  baseDir: cmd.positional({
15
15
  type: cmd.string,
@@ -64,4 +64,4 @@ const cmdDef = cmd.command({
64
64
  },
65
65
  });
66
66
 
67
- await cmd.run(cmdDef, process.argv.slice(2));
67
+ await cmd.run(cmdDef, Arr.skip(process.argv, 2));
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import * as cmd from 'cmd-ts';
4
- import { Result } from 'ts-data-forge';
4
+ import { Arr, Result } from 'ts-data-forge';
5
5
  import { replaceRecordWithUnknownRecordTransformer } from '../functions/index.mjs';
6
6
  import { runTransformerCLI } from './run-transformer-cli.mjs';
7
7
 
@@ -9,7 +9,7 @@ const transformer = replaceRecordWithUnknownRecordTransformer();
9
9
 
10
10
  const cmdDef = cmd.command({
11
11
  name: transformer.name,
12
- version: '2.2.0',
12
+ version: '2.2.2',
13
13
  args: {
14
14
  baseDir: cmd.positional({
15
15
  type: cmd.string,
@@ -64,4 +64,4 @@ const cmdDef = cmd.command({
64
64
  },
65
65
  });
66
66
 
67
- await cmd.run(cmdDef, process.argv.slice(2));
67
+ await cmd.run(cmdDef, Arr.skip(process.argv, 2));
@@ -3,7 +3,7 @@
3
3
  import dedent from 'dedent';
4
4
  import * as fs from 'node:fs/promises';
5
5
  import * as path from 'node:path';
6
- import { Arr, Result, unknownToString } from 'ts-data-forge';
6
+ import { Arr, expectType, Result, unknownToString } from 'ts-data-forge';
7
7
  import {
8
8
  getDiffFrom,
9
9
  getModifiedFiles,
@@ -199,8 +199,16 @@ const getFilesFromGlob = async (
199
199
  >
200
200
  > => {
201
201
  const globResult = await glob(baseDir, {
202
- ignore: exclude,
202
+ // Never transform files inside dependencies. With pnpm,
203
+ // `packages/*/node_modules/<dep>` are symlinks whose `src/` directory would
204
+ // otherwise be matched by patterns such as
205
+ // `packages/**/{src,scripts,samples,test}/**`, so the codemod would rewrite
206
+ // dependency source files (and could hit pathological types there).
207
+ ignore: Arr.toPushed(exclude, '**/node_modules/**'),
203
208
  absolute: true,
209
+ // Do not escape the project tree by following symlinks (e.g. the pnpm
210
+ // `node_modules` symlinks above, or workspace-package links).
211
+ followSymbolicLinks: false,
204
212
  });
205
213
 
206
214
  if (Result.isErr(globResult)) {
@@ -235,16 +243,12 @@ const transformFiles = async (
235
243
  const result = await transformOneFile(filePath, transformers, silent);
236
244
 
237
245
  if (Result.isOk(result)) {
238
- switch (result.value) {
239
- case 'transformed':
240
- mut_transformedCount += 1;
246
+ expectType<typeof result.value, 'unchanged' | 'transformed'>('=');
241
247
 
242
- break;
243
-
244
- case 'unchanged':
245
- mut_unchangedCount += 1;
246
-
247
- break;
248
+ if (result.value === 'transformed') {
249
+ mut_transformedCount += 1;
250
+ } else {
251
+ mut_unchangedCount += 1;
248
252
  }
249
253
  } else {
250
254
  mut_errorFiles.push(path.basename(filePath));
@@ -285,14 +289,14 @@ const transformOneFile = async (
285
289
  echoIfNotSilent(`⏭️ ${fileName} - no changes needed`);
286
290
 
287
291
  return Result.ok('unchanged');
288
- } else {
289
- // eslint-disable-next-line security/detect-non-literal-fs-filename
290
- await fs.writeFile(filePath, transformedCode, 'utf8');
292
+ }
291
293
 
292
- echoIfNotSilent(`✅ ${fileName} - transformed`);
294
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
295
+ await fs.writeFile(filePath, transformedCode, 'utf8');
293
296
 
294
- return Result.ok('transformed');
295
- }
297
+ echoIfNotSilent(`✅ ${fileName} - transformed`);
298
+
299
+ return Result.ok('transformed');
296
300
  } catch (error) {
297
301
  const errStr = unknownToString(error);
298
302
 
@@ -70,7 +70,7 @@ export const convertInterfaceToTypeTransformer = (): TsMorphTransformer =>
70
70
 
71
71
  const ownType = buildTypeLiteral(members);
72
72
 
73
- return [...extendedTypesWithMembers, ownType].join(' & ');
73
+ return Arr.toPushed(extendedTypesWithMembers, ownType).join(' & ');
74
74
  })();
75
75
 
76
76
  // Get export keyword
@@ -12,6 +12,7 @@ import { type DeepReadonly, type SafeUintWithSmallInt } from 'ts-type-forge';
12
12
  import {
13
13
  hasDisableNextLineComment,
14
14
  isAtomicTypeNode,
15
+ isConditionalTypeDistributionGuard,
15
16
  isReadonlyArrayTypeNode,
16
17
  isReadonlyTupleOrArrayTypeNode,
17
18
  isReadonlyTupleTypeNode,
@@ -705,7 +706,12 @@ const transformArrayTypeNode = (
705
706
  // Unnecessary `Readonly` wrapper will be remove in transformTypeReferenceNode
706
707
  case 'Readonly':
707
708
  case 'none':
708
- if (readonlyContext.indexedAccessDepth === 0) {
709
+ // Skip the `A[] extends B[] ? ...` union-distribution-guard idiom: adding
710
+ // `readonly` there does not improve immutability and breaks the idiom.
711
+ if (
712
+ readonlyContext.indexedAccessDepth === 0 &&
713
+ !isConditionalTypeDistributionGuard(node)
714
+ ) {
709
715
  const readonlyText = `readonly ${node.getFullText()}` as const;
710
716
 
711
717
  options.replaceNode(
@@ -752,7 +758,12 @@ const transformTupleTypeNode = (
752
758
  // Unnecessary `Readonly` wrapper will be remove in transformTypeReferenceNode
753
759
  case 'Readonly':
754
760
  case 'none':
755
- if (readonlyContext.indexedAccessDepth === 0) {
761
+ // Skip the `[A] extends [B] ? ...` union-distribution-guard idiom: adding
762
+ // `readonly` there does not improve immutability and breaks the idiom.
763
+ if (
764
+ readonlyContext.indexedAccessDepth === 0 &&
765
+ !isConditionalTypeDistributionGuard(node)
766
+ ) {
756
767
  const readonlyText = `readonly ${node.getFullText()}` as const;
757
768
 
758
769
  options.replaceNode(
@@ -1043,50 +1054,54 @@ const transformClassDeclarationNode = (
1043
1054
 
1044
1055
  for (const ctor of node.getConstructors()) {
1045
1056
  for (const param of ctor.getParameters()) {
1046
- if (hasDisableNextLineComment(param)) {
1047
- options.debugPrint(
1048
- 'skipped class constructor parameter by disable-next-line comment',
1049
- );
1057
+ transformConstructorParameter(param, options);
1058
+ }
1050
1059
 
1051
- continue;
1052
- }
1060
+ const body = ctor.getBody();
1053
1061
 
1054
- {
1055
- const name = (param satisfies tsm.ParameterDeclaration).getName();
1062
+ if (body !== undefined) {
1063
+ transformNode(body, initialReadonlyContext, options);
1064
+ }
1065
+ }
1066
+ };
1056
1067
 
1057
- if (options.ignoredPrefixes.some((p) => name.startsWith(p))) {
1058
- continue;
1059
- }
1060
- }
1068
+ const transformConstructorParameter = (
1069
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
1070
+ param: tsm.ParameterDeclaration,
1071
+ options: ReadonlyTransformerOptionsInternal,
1072
+ ): void => {
1073
+ if (hasDisableNextLineComment(param)) {
1074
+ options.debugPrint(
1075
+ 'skipped class constructor parameter by disable-next-line comment',
1076
+ );
1061
1077
 
1062
- // Check if parameter is a property declaration (public/protected/private)
1063
- const scope = param.getScope();
1078
+ return;
1079
+ }
1064
1080
 
1065
- // public -> public readonly
1066
- // protected -> protected readonly
1067
- // private -> private readonly
1068
- if (
1069
- scope === tsm.Scope.Public ||
1070
- scope === tsm.Scope.Protected ||
1071
- scope === tsm.Scope.Private
1072
- ) {
1073
- param.setIsReadonly(true);
1074
- }
1081
+ const name = param.getName();
1075
1082
 
1076
- const type = param.getTypeNode();
1083
+ if (options.ignoredPrefixes.some((p) => name.startsWith(p))) {
1084
+ return;
1085
+ }
1077
1086
 
1078
- if (type !== undefined) {
1079
- transformNode(type, initialReadonlyContext, options);
1080
- }
1081
- }
1087
+ // Check if parameter is a property declaration (public/protected/private)
1088
+ const scope = param.getScope();
1082
1089
 
1083
- const body = ctor.getBody();
1090
+ // public -> public readonly
1091
+ // protected -> protected readonly
1092
+ // private -> private readonly
1093
+ if (
1094
+ scope === tsm.Scope.Public ||
1095
+ scope === tsm.Scope.Protected ||
1096
+ scope === tsm.Scope.Private
1097
+ ) {
1098
+ param.setIsReadonly(true);
1099
+ }
1084
1100
 
1085
- if (body !== undefined) {
1086
- transformNode(body, initialReadonlyContext, options);
1087
- }
1101
+ const type = param.getTypeNode();
1088
1102
 
1089
- continue;
1103
+ if (type !== undefined) {
1104
+ transformNode(type, initialReadonlyContext, options);
1090
1105
  }
1091
1106
  };
1092
1107
 
@@ -1238,6 +1253,20 @@ const transformUnionOrIntersectionTypeNodeImpl = (
1238
1253
 
1239
1254
  // Readonly<number | { x: X } | { y: Y } | readonly E[]>
1240
1255
  // -> number | readonly E[] | Readonly<{ x: X } | { y: Y }>
1256
+ // Readonly<number & { x: X } & { y: Y } & readonly E[]>
1257
+ // -> number & readonly E[] & Readonly<{ x: X } & { y: Y }>
1258
+
1259
+ // Readonly<number | { x: X } | { y: Y } | readonly E[]>
1260
+ // -> number | readonly E[] | Readonly<{ x: X } | { y: Y }>
1261
+ //
1262
+ // NOTE: The result is wrapped in parentheses (via `unionToString`). This is
1263
+ // intentional and required for precedence safety: without it, a member such
1264
+ // as a function type (`() => T`), or a mix of `&` and `|`, or a `readonly`
1265
+ // operator would produce invalid syntax when this node is spliced back in.
1266
+ // Idempotency is guaranteed by `transformParenthesizedTypeNode`, which
1267
+ // collapses the redundant nesting (`((X))` -> `(X)`) this wrapping can create
1268
+ // — see the note there. That prevents the parentheses from growing without
1269
+ // bound when a union/intersection sits inside a `ParenthesizedType`.
1241
1270
  options.replaceNode(
1242
1271
  node,
1243
1272
  unionToString({ types: sorted, op: operator, wrapWithReadonly: false }),
@@ -1296,7 +1325,18 @@ const transformParenthesizedTypeNode = (
1296
1325
  isAtomicTypeNode(T) ||
1297
1326
  // remove () if T is TypeLiteralNode
1298
1327
  // e.g. `({ member: V }) |-> { member: V }`
1299
- T.isKind(tsm.SyntaxKind.TypeLiteral)
1328
+ T.isKind(tsm.SyntaxKind.TypeLiteral) ||
1329
+ // collapse redundant nested parentheses if T is itself a ParenthesizedType
1330
+ // e.g. `((X)) |-> (X)`
1331
+ //
1332
+ // This is what makes the transform terminate on nested parentheses. The
1333
+ // union/intersection handler defensively wraps its result in parentheses
1334
+ // for precedence safety, which turns `(A | B)` into `((A | B))` when the
1335
+ // union already sits inside a `ParenthesizedType`. Collapsing the redundant
1336
+ // outer layer here means each pass removes exactly the layer the wrapping
1337
+ // added, so the parentheses no longer grow without bound (which previously
1338
+ // overflowed the parser stack on inputs like `type T = (('a' | 'b'))`).
1339
+ T.isKind(tsm.SyntaxKind.ParenthesizedType)
1300
1340
  ) {
1301
1341
  options.replaceNode(node, T.getFullText());
1302
1342
  }
@@ -59,7 +59,10 @@ const normalizeWhitespaceForComparison = (code: string): string => {
59
59
  const placeholder = '___LINE_COMMENT_NEWLINE_PLACEHOLDER___';
60
60
 
61
61
  // 1. Protect newlines immediately following line comments
62
- const protectedCode = code.replaceAll(/(\/\/.*?)\r?\n/gu, `$1${placeholder}`);
62
+ const protectedCode = code.replaceAll(
63
+ /(\/\/.*?)\r?\n/gu,
64
+ (_match, comment: string) => `${comment}${placeholder}`,
65
+ );
63
66
 
64
67
  // 2. Collapse multiple whitespace characters (including unprotected newlines) into a single space
65
68
  const collapsedCode = protectedCode.replaceAll(/\s+/gu, ' ');
@@ -738,6 +741,56 @@ describe(convertToReadonlyTransformer, () => {
738
741
  },
739
742
  ])('$name', testFn);
740
743
 
744
+ describe('Conditional type union-distribution-guard idiom', () => {
745
+ test.each([
746
+ {
747
+ name: 'Single-element tuples in check and extends positions are left as-is',
748
+ source: dedent`
749
+ type TypeExtends<A, B> = [A] extends [B] ? true : false;
750
+ `,
751
+ expected: dedent`
752
+ type TypeExtends<A, B> = [A] extends [B] ? true : false;
753
+ `,
754
+ },
755
+ {
756
+ name: 'Multi-element guard tuples are left as-is',
757
+ source: dedent`
758
+ type Eq<A, B> = [A, B] extends [B, A] ? true : false;
759
+ `,
760
+ expected: dedent`
761
+ type Eq<A, B> = [A, B] extends [B, A] ? true : false;
762
+ `,
763
+ },
764
+ {
765
+ name: 'Tuples in true/false branches are still converted',
766
+ source: dedent`
767
+ type T<A> = [A] extends [string] ? [A, A] : [number];
768
+ `,
769
+ expected: dedent`
770
+ type T<A> = [A] extends [string] ? readonly [A, A] : readonly [number];
771
+ `,
772
+ },
773
+ {
774
+ name: 'Arrays in check and extends positions are left as-is',
775
+ source: dedent`
776
+ type ArrayExtends<A, B> = A[] extends B[] ? true : false;
777
+ `,
778
+ expected: dedent`
779
+ type ArrayExtends<A, B> = A[] extends B[] ? true : false;
780
+ `,
781
+ },
782
+ {
783
+ name: 'Arrays in true/false branches are still converted',
784
+ source: dedent`
785
+ type T<A> = A[] extends string[] ? A[] : number[];
786
+ `,
787
+ expected: dedent`
788
+ type T<A> = A[] extends string[] ? readonly A[] : readonly number[];
789
+ `,
790
+ },
791
+ ])('$name', testFn);
792
+ });
793
+
741
794
  describe('Rest types', () => {
742
795
  test.each([
743
796
  {
@@ -2574,6 +2627,69 @@ describe(convertToReadonlyTransformer, () => {
2574
2627
  type ParenReadonly = Readonly<{ a: number }>
2575
2628
  `,
2576
2629
  },
2630
+ {
2631
+ // Regression: redundant nested parentheses around a union used to make
2632
+ // the transformer re-wrap the union on every pass, growing parentheses
2633
+ // without bound until ts-morph's parser overflowed its stack (hang).
2634
+ name: 'Doubled parentheses around a union (no infinite paren growth)',
2635
+ source: dedent`
2636
+ type DoubleParenUnion = (('a' | 'b'));
2637
+ `,
2638
+ expected: dedent`
2639
+ type DoubleParenUnion = 'a' | 'b';
2640
+ `,
2641
+ },
2642
+ {
2643
+ name: 'Doubled parentheses around an object union (no infinite paren growth)',
2644
+ source: dedent`
2645
+ type DoubleParenObjUnion = (({ a: number[] } | { b: string[] }));
2646
+ `,
2647
+ expected: dedent`
2648
+ type DoubleParenObjUnion = Readonly<
2649
+ { a: readonly number[] } | { b: readonly string[] }
2650
+ >;
2651
+ `,
2652
+ },
2653
+ {
2654
+ name: 'Doubled parentheses around a union in a conditional type (no infinite paren growth)',
2655
+ source: dedent`
2656
+ type DoubleParenCond = true extends true ? (('a' | 'b')) : 'c';
2657
+ `,
2658
+ expected: dedent`
2659
+ type DoubleParenCond = true extends true ? 'a' | 'b' : 'c';
2660
+ `,
2661
+ },
2662
+ {
2663
+ // Precedence guard: the defensive parentheses around a union/intersection
2664
+ // must NOT be dropped. A function type used as a union member is invalid
2665
+ // TypeScript unless parenthesized (`string | () => number` is a syntax
2666
+ // error), so the wrapping has to survive the transform.
2667
+ name: 'Function type in a union stays parenthesized (precedence)',
2668
+ source: dedent`
2669
+ type FnUnion = string | (() => number);
2670
+ `,
2671
+ expected: dedent`
2672
+ type FnUnion = string | (() => number);
2673
+ `,
2674
+ },
2675
+ {
2676
+ name: 'Bare function type in a union is parenthesized (precedence)',
2677
+ source: dedent`
2678
+ type FnUnionBare = string | () => number;
2679
+ `,
2680
+ expected: dedent`
2681
+ type FnUnionBare = string | (() => number);
2682
+ `,
2683
+ },
2684
+ {
2685
+ name: 'Mixed & and | keeps precedence parentheses',
2686
+ source: dedent`
2687
+ type Mixed = number & { a: string } | { b: number };
2688
+ `,
2689
+ expected: dedent`
2690
+ type Mixed = (number & Readonly<{ a: string }>) | Readonly<{ b: number }>;
2691
+ `,
2692
+ },
2577
2693
  ])('$name', testFn);
2578
2694
  });
2579
2695
 
@@ -15,12 +15,12 @@ import { type SafeUintWithSmallInt } from 'ts-type-forge';
15
15
  * - `"none"`: All other cases
16
16
  */
17
17
  export type ReadonlyContext = Readonly<
18
- { indexedAccessDepth: SafeUintWithSmallInt } & (
18
+ (
19
19
  | { type: 'DeepReadonly' }
20
20
  | { type: 'Readonly' }
21
21
  | { type: 'readonly' }
22
22
  | { type: 'none' }
23
- )
23
+ ) & { indexedAccessDepth: SafeUintWithSmallInt }
24
24
  >;
25
25
 
26
26
  export const nextReadonlyContext = <
@@ -31,9 +31,7 @@ export const nextReadonlyContext = <
31
31
  nextReadonlyContextType: next,
32
32
  indexedAccessDepthChange = 'infinity',
33
33
  }: Readonly<
34
- {
35
- currentReadonlyContext: Curr;
36
- } & (
34
+ (
37
35
  | {
38
36
  nextReadonlyContextType: Next;
39
37
  indexedAccessDepthChange: 'decr' | 'incr' | 'keep' | 'infinity';
@@ -45,7 +43,9 @@ export const nextReadonlyContext = <
45
43
  >;
46
44
  indexedAccessDepthChange?: 'infinity';
47
45
  }
48
- )
46
+ ) & {
47
+ currentReadonlyContext: Curr;
48
+ }
49
49
  >): Readonly<{
50
50
  type: Extract<ReadonlyContext['type'], 'DeepReadonly'> | Next;
51
51
  indexedAccessDepth: SafeUintWithSmallInt;
@@ -23,12 +23,12 @@ export const replaceRecordWithUnknownRecordTransformer =
23
23
  ({
24
24
  name: TRANSFORMER_NAME,
25
25
  transform: (sourceAst) => {
26
- const containerModifications = [
27
- processDeclarations(sourceAst),
28
- ...sourceAst
26
+ const containerModifications = Arr.toUnshifted(
27
+ sourceAst
29
28
  .getModules()
30
29
  .map((namespace) => processDeclarations(namespace)),
31
- ];
30
+ processDeclarations(sourceAst),
31
+ );
32
32
 
33
33
  if (containerModifications.includes(true)) {
34
34
  ensureUnknownRecordImport(sourceAst);
@@ -52,17 +52,17 @@ export const hasDisableNextLineComment = (
52
52
  );
53
53
 
54
54
  if (matchedPrefix !== undefined) {
55
+ // If no transformer name specified, check if comment applies to all transformers
56
+ if (transformerName === undefined) {
57
+ return true;
58
+ }
59
+
55
60
  // Extract the part after the prefix
56
61
  const afterPrefix = commentText
57
62
  .slice(commentText.indexOf(matchedPrefix))
58
63
  .replace(matchedPrefix, '')
59
64
  .trim();
60
65
 
61
- // If no transformer name specified, check if comment applies to all transformers
62
- if (transformerName === undefined) {
63
- return true;
64
- }
65
-
66
66
  // If no specific transformers listed in comment, it applies to all
67
67
  if (afterPrefix === '') {
68
68
  return true;
@@ -1,6 +1,7 @@
1
1
  export * from './has-disable-next-line-comment.mjs';
2
2
  export * from './is-as-const-node.mjs';
3
3
  export * from './is-atomic-type-node.mjs';
4
+ export * from './is-conditional-type-distribution-guard.mjs';
4
5
  export * from './is-readonly-node.mjs';
5
6
  export * from './is-spread-parameter-node.mjs';
6
7
  export * from './remove-parentheses.mjs';
@@ -0,0 +1,45 @@
1
+ import * as tsm from 'ts-morph';
2
+
3
+ /**
4
+ * Determines whether a type node is an operand (check or extends type) of a
5
+ * conditional type whose check type is wrapped to suppress union distribution,
6
+ * i.e. the `X` and `Y` in `X extends Y ? ... : ...` where `X` is itself a tuple
7
+ * or array.
8
+ *
9
+ * Wrapping a naked type parameter so that it is no longer naked is a well-known
10
+ * type-implementation idiom used to suppress union distribution. Both
11
+ * `[A] extends [B] ? ...` (tuple wrapping) and `A[] extends B[] ? ...` (array
12
+ * wrapping) rely on this. Adding a `readonly` modifier to those operands neither
13
+ * makes the type more immutable in any meaningful way nor preserves the idiom's
14
+ * intent, so such operands must be left untouched.
15
+ *
16
+ * A naked check type such as `T extends string[] ? ...` still distributes, so it
17
+ * is not a guard; arrays/tuples in its extends position are converted as usual.
18
+ *
19
+ * @param node - The type node to check
20
+ * @returns true if the node is the check or extends type of a distribution-guarded
21
+ * conditional type
22
+ */
23
+ export const isConditionalTypeDistributionGuard = (node: tsm.Node): boolean => {
24
+ const parent = node.getParent();
25
+
26
+ if (parent?.isKind(tsm.SyntaxKind.ConditionalType) !== true) {
27
+ return false;
28
+ }
29
+
30
+ const checkType = parent.getCheckType();
31
+
32
+ // The guard is only in effect when the check type itself is wrapped in a tuple
33
+ // or array; otherwise (e.g. a naked `T`) the conditional still distributes.
34
+ if (
35
+ !checkType.isKind(tsm.SyntaxKind.TupleType) &&
36
+ !checkType.isKind(tsm.SyntaxKind.ArrayType)
37
+ ) {
38
+ return false;
39
+ }
40
+
41
+ return (
42
+ checkType.compilerNode === node.compilerNode ||
43
+ parent.getExtendsType().compilerNode === node.compilerNode
44
+ );
45
+ };
@@ -10,6 +10,19 @@
10
10
  */
11
11
  import { asUint32, range } from 'ts-data-forge';
12
12
 
13
+ const parenDepthDelta = (char: string): number => {
14
+ switch (char) {
15
+ case '(':
16
+ return 1;
17
+
18
+ case ')':
19
+ return -1;
20
+
21
+ default:
22
+ return 0;
23
+ }
24
+ };
25
+
13
26
  const isWrappedWithParentheses = (str: string): boolean => {
14
27
  const trimmed = str.trim();
15
28
 
@@ -22,30 +35,11 @@ const isWrappedWithParentheses = (str: string): boolean => {
22
35
  let mut_depth = 0;
23
36
 
24
37
  for (const mut_i of range(0, asUint32(trimmed.length))) {
25
- const char: string = trimmed.charAt(mut_i);
26
-
27
- switch (char) {
28
- case '(': {
29
- mut_depth += 1;
30
-
31
- break;
32
- }
33
-
34
- case ')': {
35
- mut_depth -= 1;
36
-
37
- // If we reach depth 0 before the end, the outer parentheses don't wrap everything
38
- if (mut_depth === 0 && mut_i < trimmed.length - 1) {
39
- return false;
40
- }
41
-
42
- break;
43
- }
38
+ mut_depth += parenDepthDelta(trimmed.charAt(mut_i));
44
39
 
45
- default: {
46
- // Other characters - no action needed
47
- break;
48
- }
40
+ // If we reach depth 0 before the end, the outer parentheses don't wrap everything
41
+ if (mut_depth === 0 && mut_i < trimmed.length - 1) {
42
+ return false;
49
43
  }
50
44
  }
51
45