ts-codemod-lib 3.3.1 → 3.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +38 -16
  2. package/dist/functions/ast-transformers/append-as-const.mjs +11 -10
  3. package/dist/functions/ast-transformers/append-as-const.mjs.map +1 -1
  4. package/dist/functions/ast-transformers/convert-to-readonly.d.mts.map +1 -1
  5. package/dist/functions/ast-transformers/convert-to-readonly.mjs +16 -12
  6. package/dist/functions/ast-transformers/convert-to-readonly.mjs.map +1 -1
  7. package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.d.mts +32 -9
  8. package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.d.mts.map +1 -1
  9. package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.mjs +23 -0
  10. package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.mjs.map +1 -1
  11. package/dist/functions/ast-transformers/readonly-transformer-helpers/group-union-types.d.mts.map +1 -1
  12. package/dist/functions/ast-transformers/readonly-transformer-helpers/group-union-types.mjs +8 -2
  13. package/dist/functions/ast-transformers/readonly-transformer-helpers/group-union-types.mjs.map +1 -1
  14. package/dist/functions/ast-transformers/replace-record-with-unknown-record.mjs +10 -19
  15. package/dist/functions/ast-transformers/replace-record-with-unknown-record.mjs.map +1 -1
  16. package/dist/functions/ast-transformers/transform-source-code.d.mts.map +1 -1
  17. package/dist/functions/ast-transformers/transform-source-code.mjs +101 -49
  18. package/dist/functions/ast-transformers/transform-source-code.mjs.map +1 -1
  19. package/dist/functions/functions/has-call-or-construct-signature.d.mts +18 -0
  20. package/dist/functions/functions/has-call-or-construct-signature.d.mts.map +1 -0
  21. package/dist/functions/functions/has-call-or-construct-signature.mjs +87 -0
  22. package/dist/functions/functions/has-call-or-construct-signature.mjs.map +1 -0
  23. package/dist/functions/functions/index.d.mts +1 -0
  24. package/dist/functions/functions/index.d.mts.map +1 -1
  25. package/dist/functions/functions/index.mjs +1 -0
  26. package/dist/functions/functions/index.mjs.map +1 -1
  27. package/dist/functions/functions/should-avoid-parentheses-for-readonly.d.mts.map +1 -1
  28. package/dist/functions/functions/should-avoid-parentheses-for-readonly.mjs +1 -4
  29. package/dist/functions/functions/should-avoid-parentheses-for-readonly.mjs.map +1 -1
  30. package/dist/functions/functions/wrap-with-parentheses.d.mts +14 -5
  31. package/dist/functions/functions/wrap-with-parentheses.d.mts.map +1 -1
  32. package/dist/functions/functions/wrap-with-parentheses.mjs +25 -14
  33. package/dist/functions/functions/wrap-with-parentheses.mjs.map +1 -1
  34. package/package.json +43 -41
  35. package/src/functions/ast-transformers/append-as-const.mts +22 -20
  36. package/src/functions/ast-transformers/convert-to-readonly.mts +19 -13
  37. package/src/functions/ast-transformers/convert-to-readonly.test.mts +121 -10
  38. package/src/functions/ast-transformers/enable-no-unchecked-indexed-access.mts +32 -9
  39. package/src/functions/ast-transformers/enable-no-unchecked-indexed-access.test.mts +1 -1
  40. package/src/functions/ast-transformers/readonly-transformer-helpers/group-union-types.mts +8 -1
  41. package/src/functions/ast-transformers/replace-record-with-unknown-record.mts +13 -22
  42. package/src/functions/ast-transformers/transform-source-code.mts +158 -57
  43. package/src/functions/ast-transformers/transform-source-code.test.mts +123 -0
  44. package/src/functions/functions/has-call-or-construct-signature.mts +120 -0
  45. package/src/functions/functions/index.mts +1 -0
  46. package/src/functions/functions/should-avoid-parentheses-for-readonly.mts +1 -5
  47. package/src/functions/functions/wrap-with-parentheses.mts +30 -20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-codemod-lib",
3
- "version": "3.3.1",
3
+ "version": "3.3.3",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "typescript",
@@ -38,44 +38,44 @@
38
38
  "LICENSE"
39
39
  ],
40
40
  "dependencies": {
41
- "ts-data-forge": "^14.6.3",
41
+ "ts-data-forge": "^14.7.1",
42
42
  "ts-morph": "^28.0.0",
43
43
  "ts-type-forge": "^9.2.2"
44
44
  },
45
45
  "devDependencies": {
46
- "@types/node": "26.4.1",
47
- "@vitest/browser-playwright": "4.1.11",
48
- "@vitest/coverage-v8": "4.1.11",
49
- "@vitest/ui": "4.1.11",
50
- "cspell": "10.1.1",
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",
51
51
  "dedent": "1.7.2",
52
52
  "esbuild": "0.28.2",
53
- "eslint": "10.9.1",
54
- "eslint-config-typed": "5.11.2",
55
- "eslint-plugin-ts-data-forge": "0.7.0",
56
- "eslint-plugin-ts-fortress": "1.2.0",
57
- "eslint-plugin-ts-type-forge": "0.8.1",
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",
58
58
  "jiti": "2.7.0",
59
59
  "npm-run-all2": "9.0.3",
60
- "playwright": "1.62.1",
61
- "prettier": "3.9.6",
60
+ "playwright": "1.63.0",
61
+ "prettier": "3.9.7",
62
62
  "prettier-plugin-organize-imports": "4.3.0",
63
63
  "prettier-plugin-packagejson": "3.0.2",
64
- "ts-repo-utils": "10.6.0",
65
- "tsx": "4.23.12",
64
+ "ts-repo-utils": "10.7.1",
65
+ "tsx": "4.23.13",
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.2.2",
71
- "vitest": "4.1.11"
70
+ "vite": "8.3.0",
71
+ "vitest": "5.0.1"
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.3.1"
78
+ "node": "26.9.0"
79
79
  },
80
80
  "publishConfig": {
81
81
  "access": "public",
@@ -83,37 +83,39 @@
83
83
  },
84
84
  "scripts": {
85
85
  "build": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ./scripts/cmd/build.mts",
86
- "build:min": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ./scripts/cmd/build.mts --skip-check",
86
+ "check:cspell": "cspell \"**\" --gitignore --gitignore-root ../../ --no-progress",
87
+ "check:dist": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ./scripts/cmd/check-dist.mts",
87
88
  "check:ext": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ./scripts/cmd/check-ext.mts",
89
+ "check:lint": "eslint .",
90
+ "check:test": "pnpm run z:vitest:node run",
91
+ "check:test:browser": "pnpm run z:vitest --project='Browser' run",
92
+ "check:test:cov": "pnpm run z:vitest:node run --coverage",
93
+ "check:types": "node ../../node_modules/typescript-native/bin/tsc --noEmit",
88
94
  "check-all": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ./scripts/cmd/check-all.mts",
89
95
  "clean": "pnpm run /clean:.*/",
90
96
  "clean:logs": "npx rimraf ./*.log",
91
- "codemod": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ../ts-codemod-cli/src/cmd/ts-codemod.mts --uncommitted --transformer append-as-const --transformer convert-interface-to-type --transformer convert-to-readonly --transformer replace-any-with-unknown --transformer replace-record-with-unknown-record '{src,scripts,samples,test}/**/*.{mts,tsx}' && pnpm run fmt",
92
- "codemod:diff": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ../ts-codemod-cli/src/cmd/ts-codemod.mts --diff-from origin/main --transformer append-as-const --transformer convert-interface-to-type --transformer convert-to-readonly --transformer replace-any-with-unknown --transformer replace-record-with-unknown-record '{src,scripts,samples,test}/**/*.{mts,tsx}' && pnpm run fmt",
93
- "codemod:full": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ../ts-codemod-cli/src/cmd/ts-codemod.mts --transformer append-as-const --transformer convert-interface-to-type --transformer convert-to-readonly --transformer replace-any-with-unknown --transformer replace-record-with-unknown-record '{src,scripts,samples,test}/**/*.{mts,tsx}' && pnpm run fmt",
94
- "cspell": "cspell \"**\" --gitignore --gitignore-root ../../ --no-progress",
95
97
  "doc": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ./scripts/cmd/gen-docs.mts",
96
- "doc:embed": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ./scripts/cmd/embed-examples.mts",
97
98
  "doc:preview": "vite preview --config ./configs/vite.doc.config.mts",
98
- "doc:watch": "typedoc --options ./configs/typedoc.config.mjs --watch",
99
- "fmt": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ../../libs/ts-repo-utils/src/cmd/format-uncommitted.mts --cwd .",
100
- "fmt:diff": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ../../libs/ts-repo-utils/src/cmd/format-diff-from.mts origin/main --cwd .",
101
- "fmt:full": "prettier --write .",
102
- "gi": "run-s gi:src fmt",
103
- "gi:src": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ../../libs/ts-repo-utils/src/cmd/gen-index-ts.mts ./src --index-ext .mts --export-ext .mjs --target-ext .mts --target-ext .tsx --exclude entry-point.mts",
104
- "lint": "eslint .",
105
- "lint:fix": "eslint . --fix",
106
- "prepare-release": "run-s fmt build doc",
99
+ "fix:codemod": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ../ts-codemod-cli/src/cmd/ts-codemod.mts --uncommitted --transformer append-as-const --transformer convert-interface-to-type --transformer convert-to-readonly --transformer replace-any-with-unknown --transformer replace-record-with-unknown-record '{src,scripts,samples,test}/**/*.{mts,tsx}' && pnpm run fix:fmt",
100
+ "fix:codemod:diff": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ../ts-codemod-cli/src/cmd/ts-codemod.mts --diff-from origin/main --transformer append-as-const --transformer convert-interface-to-type --transformer convert-to-readonly --transformer replace-any-with-unknown --transformer replace-record-with-unknown-record '{src,scripts,samples,test}/**/*.{mts,tsx}' && pnpm run fix:fmt",
101
+ "fix:codemod:full": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ../ts-codemod-cli/src/cmd/ts-codemod.mts --transformer append-as-const --transformer convert-interface-to-type --transformer convert-to-readonly --transformer replace-any-with-unknown --transformer replace-record-with-unknown-record '{src,scripts,samples,test}/**/*.{mts,tsx}' && pnpm run fix:fmt",
102
+ "fix:fmt": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ../../libs/ts-repo-utils/src/cmd/format-uncommitted.mts --cwd .",
103
+ "fix:fmt:diff": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ../../libs/ts-repo-utils/src/cmd/format-diff-from.mts origin/main --cwd .",
104
+ "fix:fmt:full": "prettier --write .",
105
+ "fix:lint": "eslint . --fix",
106
+ "fmt": "pnpm run fix:fmt",
107
+ "gen": "run-s gen:index gen:readme gen:jsdoc",
108
+ "gen:index": "run-s gen:index:src fix:fmt",
109
+ "gen:index:src": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ../../libs/ts-repo-utils/src/cmd/gen-index-ts.mts ./src --index-ext .mts --export-ext .mjs --target-ext .mts --target-ext .tsx --exclude entry-point.mts",
110
+ "gen:jsdoc": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ./scripts/cmd/embed-examples-in-jsdoc.mts",
111
+ "gen:readme": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ./scripts/cmd/embed-examples.mts",
107
112
  "run:test": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ./scripts/test-code/transform-test-code.mts",
108
- "test": "pnpm run z:vitest:node run",
109
- "test:browser": "pnpm run z:vitest --project='Browser' run",
110
- "test:cov": "pnpm run z:vitest:node run --coverage",
113
+ "test": "pnpm run check:test",
111
114
  "test:cov:ui": "vite preview --outDir ./coverage",
112
115
  "test:ui": "pnpm run z:vitest:node --ui",
113
- "testw": "pnpm run z:vitest:node watch",
114
- "tsc": "node ../../node_modules/typescript-native/bin/tsc --noEmit",
115
- "tscw": "node ../../node_modules/typescript-native/bin/tsc --noEmit --watch -p ./tsconfig.json",
116
- "type-check": "node ../../node_modules/typescript-native/bin/tsc --noEmit",
116
+ "watch:doc": "typedoc --options ./configs/typedoc.config.mjs --watch",
117
+ "watch:test": "pnpm run z:vitest:node watch",
118
+ "watch:types": "node ../../node_modules/typescript-native/bin/tsc --noEmit --watch -p ./tsconfig.json",
117
119
  "z:vitest": "vitest --config ./configs/vitest.config.mts",
118
120
  "z:vitest:node": "pnpm run z:vitest --project='Node.js'"
119
121
  }
@@ -362,28 +362,30 @@ const transformNode = (
362
362
  return;
363
363
  }
364
364
 
365
- if (tsm.Node.isConditionalExpression(node)) {
366
- // For conditional expressions, traverse both branches in a non-const context
367
- transformNode(
368
- node.getWhenTrue(),
369
- {
370
- isDirectUnderConstInitializer: context.isDirectUnderConstInitializer,
371
- isUnderConstContext: false,
372
- },
373
- options,
374
- );
365
+ if (!tsm.Node.isConditionalExpression(node)) {
366
+ return;
367
+ }
375
368
 
376
- transformNode(
377
- node.getWhenFalse(),
378
- {
379
- isDirectUnderConstInitializer: context.isDirectUnderConstInitializer,
380
- isUnderConstContext: false,
381
- },
382
- options,
383
- );
369
+ // For conditional expressions, traverse both branches in a non-const context
370
+ transformNode(
371
+ node.getWhenTrue(),
372
+ {
373
+ isDirectUnderConstInitializer: context.isDirectUnderConstInitializer,
374
+ isUnderConstContext: false,
375
+ },
376
+ options,
377
+ );
384
378
 
385
- // return;
386
- }
379
+ transformNode(
380
+ node.getWhenFalse(),
381
+ {
382
+ isDirectUnderConstInitializer: context.isDirectUnderConstInitializer,
383
+ isUnderConstContext: false,
384
+ },
385
+ options,
386
+ );
387
+
388
+ // return;
387
389
  };
388
390
 
389
391
  /**
@@ -15,6 +15,7 @@ import {
15
15
  type StrictExtract,
16
16
  } from 'ts-type-forge';
17
17
  import {
18
+ hasCallOrConstructSignature,
18
19
  hasDisableNextLineComment,
19
20
  isAtomicTypeNode,
20
21
  isConditionalTypeDistributionGuard,
@@ -1032,10 +1033,24 @@ const transformTypeLiteralNode = (
1032
1033
  return;
1033
1034
  }
1034
1035
 
1036
+ // Whether this position is one where the literal is wrapped at all — a
1037
+ // context that already carries `Readonly` or `DeepReadonly` does it, and
1038
+ // inside an indexed access the modifier makes no difference to the result.
1039
+ const wrappingApplies =
1040
+ readonlyContext.type === 'none' && readonlyContext.indexedAccessDepth === 0;
1041
+
1042
+ // A call or construct signature does not survive `Readonly<T>`, so a literal
1043
+ // carrying one is left unwrapped and marked readonly member by member
1044
+ // instead. See `hasCallOrConstructSignature`.
1045
+ const skipWrapping = wrappingApplies && hasCallOrConstructSignature(node);
1046
+
1035
1047
  // Recursive processing
1036
1048
  transformMembers(
1037
1049
  node.getMembers(),
1038
- 'remove',
1050
+ // A wrapper — the one added below, or the one the context already carries —
1051
+ // is what makes the members readonly, so an inner modifier would be
1052
+ // redundant. Without one, each member has to say so itself.
1053
+ skipWrapping ? 'add' : 'remove',
1039
1054
  nextReadonlyContext({
1040
1055
  currentReadonlyContext: readonlyContext,
1041
1056
  nextReadonlyContextType: 'none',
@@ -1044,18 +1059,9 @@ const transformTypeLiteralNode = (
1044
1059
  options,
1045
1060
  );
1046
1061
 
1047
- switch (readonlyContext.type) {
1048
- case 'DeepReadonly':
1049
- case 'Readonly':
1050
- // Don't wrap with Readonly if already readonly
1051
- return;
1052
-
1053
- case 'none': {
1054
- if (readonlyContext.indexedAccessDepth === 0) {
1055
- // `{ readonly x: X, readonly y: Y } |-> Readonly<{ x: X, y: Y }>`
1056
- options.replaceNode(node, `Readonly<${node.getFullText()}>`);
1057
- }
1058
- }
1062
+ if (wrappingApplies && !skipWrapping) {
1063
+ // `{ readonly x: X, readonly y: Y } |-> Readonly<{ x: X, y: Y }>`
1064
+ options.replaceNode(node, `Readonly<${node.getFullText()}>`);
1059
1065
  }
1060
1066
  };
1061
1067
 
@@ -6,7 +6,6 @@ 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 { isString } from 'ts-data-forge';
10
9
  import {
11
10
  convertToReadonlyTransformer,
12
11
  type ReadonlyTransformerOptions,
@@ -64,15 +63,8 @@ const normalizeWhitespaceForComparison = (code: string): string => {
64
63
  // 1. Protect newlines immediately following line comments
65
64
  const protectedCode = code.replaceAll(
66
65
  /(\/\/.*?)\r?\n/gu,
67
- // The strict standard library types a capture group as `unknown`: an
68
- // optional group is `undefined` when it does not participate, so `string`
69
- // is not something the signature can promise. This group is not optional,
70
- // so the narrowing never actually fails — it just has to be written.
71
- (match: string, ...args: readonly unknown[]): string => {
72
- const comment = args[0];
73
-
74
- return isString(comment) ? `${comment}${placeholder}` : match;
75
- },
66
+ (match: string, comment: string | undefined): string =>
67
+ comment === undefined ? match : `${comment}${placeholder}`,
76
68
  );
77
69
 
78
70
  // 2. Collapse multiple whitespace characters (including unprotected newlines) into a single space
@@ -3255,6 +3247,125 @@ describe(convertToReadonlyTransformer, () => {
3255
3247
  ])('$name', testFn);
3256
3248
  });
3257
3249
 
3250
+ describe('Call and construct signatures', () => {
3251
+ // `Readonly<T>` is a mapped type over `keyof T`, and a call or construct
3252
+ // signature is not a property, so wrapping a type literal that carries one
3253
+ // silently drops it — the value stops being callable. See #1881.
3254
+ test.each([
3255
+ {
3256
+ name: 'Overloaded call signatures are not wrapped',
3257
+ source: dedent`
3258
+ const panic: {
3259
+ (message: string, options?: { cause?: unknown }): never;
3260
+ (error: Error): never;
3261
+ } = impl;
3262
+ `,
3263
+ expected: dedent`
3264
+ const panic: {
3265
+ (message: string, options?: Readonly<{ cause?: unknown }>): never;
3266
+ (error: Error): never;
3267
+ } = impl;
3268
+ `,
3269
+ },
3270
+ {
3271
+ name: 'A single call signature is not wrapped',
3272
+ source: dedent`
3273
+ type F = { (arg: number[]): string[] };
3274
+ `,
3275
+ expected: dedent`
3276
+ type F = { (arg: readonly number[]): readonly string[] };
3277
+ `,
3278
+ },
3279
+ {
3280
+ name: 'A construct signature is not wrapped',
3281
+ source: dedent`
3282
+ type C = { new (arg: string[]): { prop: number[] } };
3283
+ `,
3284
+ expected: dedent`
3285
+ type C = { new (arg: readonly string[]): Readonly<{ prop: readonly number[] }> };
3286
+ `,
3287
+ },
3288
+ {
3289
+ name: 'Members alongside a call signature are marked readonly in place',
3290
+ source: dedent`
3291
+ type F = {
3292
+ (arg: number[]): void;
3293
+ prop: string[];
3294
+ [key: string]: unknown;
3295
+ };
3296
+ `,
3297
+ expected: dedent`
3298
+ type F = {
3299
+ (arg: readonly number[]): void;
3300
+ readonly prop: readonly string[];
3301
+ readonly [key: string]: unknown;
3302
+ };
3303
+ `,
3304
+ },
3305
+ {
3306
+ name: 'Members alongside a construct signature are marked readonly in place',
3307
+ source: dedent`
3308
+ type C = {
3309
+ new (arg: string[]): unknown;
3310
+ readonly prop: number[];
3311
+ };
3312
+ `,
3313
+ expected: dedent`
3314
+ type C = {
3315
+ new (arg: readonly string[]): unknown;
3316
+ readonly prop: readonly number[];
3317
+ };
3318
+ `,
3319
+ },
3320
+ {
3321
+ name: 'A callable type literal in a union is left alone',
3322
+ source: dedent`
3323
+ type T = { (): void } | { a: number[] };
3324
+ `,
3325
+ expected: dedent`
3326
+ type T = { (): void } | Readonly<{ a: readonly number[] }>;
3327
+ `,
3328
+ },
3329
+ {
3330
+ name: 'A callable type literal in an intersection is left alone',
3331
+ source: dedent`
3332
+ type T = { (): void } & { a: number[] };
3333
+ `,
3334
+ expected: dedent`
3335
+ type T = { (): void } & Readonly<{ a: readonly number[] }>;
3336
+ `,
3337
+ },
3338
+ {
3339
+ name: 'A nested callable type literal is left alone',
3340
+ source: dedent`
3341
+ type T = { fn: { (): void }; a: number[] };
3342
+ `,
3343
+ expected: dedent`
3344
+ type T = Readonly<{ fn: { (): void }; a: readonly number[] }>;
3345
+ `,
3346
+ },
3347
+ ])('$name', testFn);
3348
+
3349
+ test('a callable type literal reaches a fixed point', () => {
3350
+ const code = dedent`
3351
+ const panic: {
3352
+ (message: string, options?: { cause?: unknown }): never;
3353
+ (error: Error): never;
3354
+ } = impl;
3355
+ `;
3356
+
3357
+ const once = transformSourceCode(code, false, [
3358
+ convertToReadonlyTransformer(),
3359
+ ]);
3360
+
3361
+ const twice = transformSourceCode(once, false, [
3362
+ convertToReadonlyTransformer(),
3363
+ ]);
3364
+
3365
+ expect(twice).toBe(once);
3366
+ });
3367
+ });
3368
+
3258
3369
  describe('Generic Types', () => {
3259
3370
  test.each([
3260
3371
  {
@@ -37,17 +37,40 @@ const TRANSFORMER_NAME = 'enable-no-unchecked-indexed-access';
37
37
  * as the `if` of `const v = xs[0]!; if (v === undefined) …` now is.
38
38
  *
39
39
  * @example
40
+ *
40
41
  * ```ts
41
- * declare const xs: readonly number[];
42
- * declare const pair: readonly [number, number];
43
- * declare const rec: Record<string, number>;
44
- * declare const known: { a: number };
42
+ * const before = dedent`
43
+ * declare const xs: readonly number[];
44
+ * declare const pair: readonly [number, number];
45
+ * declare const rec: Record<string, number>;
46
+ * declare const known: { a: number };
47
+ *
48
+ * xs[0].toFixed();
49
+ * rec['a'].toFixed();
50
+ * rec.a.toFixed();
51
+ * pair[1].toFixed();
52
+ * known.a.toFixed();
53
+ * `;
54
+ *
55
+ * const after = transformSourceCode(before, false, [
56
+ * enableNoUncheckedIndexedAccessTransformer(),
57
+ * ]);
58
+ *
59
+ * assert.deepStrictEqual(
60
+ * after.trim(),
61
+ * dedent`
62
+ * declare const xs: readonly number[];
63
+ * declare const pair: readonly [number, number];
64
+ * declare const rec: Record<string, number>;
65
+ * declare const known: { a: number };
45
66
  *
46
- * xs[0].toFixed(); // -> xs[0]!.toFixed();
47
- * rec['a'].toFixed(); // -> rec['a']!.toFixed();
48
- * rec.a.toFixed(); // -> rec.a!.toFixed();
49
- * pair[1].toFixed(); // -> pair[1].toFixed(); (guaranteed to exist)
50
- * known.a.toFixed(); // -> known.a.toFixed(); (guaranteed to exist)
67
+ * xs[0]!.toFixed();
68
+ * rec['a']!.toFixed();
69
+ * rec.a!.toFixed();
70
+ * pair[1].toFixed();
71
+ * known.a.toFixed();
72
+ * `,
73
+ * );
51
74
  * ```
52
75
  */
53
76
  export const enableNoUncheckedIndexedAccessTransformer = (
@@ -84,7 +84,7 @@ const transformInProject = (
84
84
  * off. A case that went through it therefore paid for the whole default
85
85
  * library, `lib.esnext.full.d.ts` with the DOM inside it, being parsed and
86
86
  * checked from scratch: ~300 ms a case, of which the file under test was a
87
- * fraction of a millisecond. Sixty of those took `test:cov` past Vitest's
87
+ * fraction of a millisecond. Sixty of those took `check:test:cov` past Vitest's
88
88
  * timeout on CI.
89
89
  *
90
90
  * Reusing the project is half of the answer and the smaller half — measured,
@@ -1,5 +1,6 @@
1
1
  import * as tsm from 'ts-morph';
2
2
  import {
3
+ hasCallOrConstructSignature,
3
4
  isAtomicTypeNode,
4
5
  isReadonlyTupleOrArrayTypeNode,
5
6
  isReadonlyTypeReferenceNode,
@@ -121,8 +122,14 @@ export const groupUnionIntersectionTypes = (
121
122
  continue;
122
123
  }
123
124
 
125
+ // A type literal that declares a call or construct signature is deliberately
126
+ // not grouped here: this group is joined and wrapped in a single
127
+ // `Readonly<...>`, which such a literal does not survive. It falls through
128
+ // to `others`, which leaves it as written. See
129
+ // `hasCallOrConstructSignature`.
124
130
  if (
125
- t.isKind(tsm.SyntaxKind.TypeLiteral) ||
131
+ (t.isKind(tsm.SyntaxKind.TypeLiteral) &&
132
+ !hasCallOrConstructSignature(t)) ||
126
133
  isReadonlyTypeReferenceNode(t)
127
134
  ) {
128
135
  if (mut_grouped.typeLiterals === undefined) {
@@ -44,9 +44,7 @@ const processDeclarations = (
44
44
 
45
45
  const typeNode = typeAlias.getTypeNode();
46
46
 
47
- if (typeNode === undefined) return false;
48
-
49
- return visitTypeNode(typeNode);
47
+ return typeNode !== undefined && visitTypeNode(typeNode);
50
48
  });
51
49
 
52
50
  const interfaceModifications = container
@@ -103,9 +101,7 @@ const processInterfaceDeclaration = (
103
101
  .map((property) => {
104
102
  const typeNode = property.getTypeNode();
105
103
 
106
- if (typeNode === undefined) return false;
107
-
108
- return visitTypeNode(typeNode);
104
+ return typeNode !== undefined && visitTypeNode(typeNode);
109
105
  })
110
106
  .includes(true);
111
107
  };
@@ -170,13 +166,13 @@ const visitTypeNode = (node: tsm.TypeNode): boolean => {
170
166
  if (tsm.Node.isPropertySignature(member)) {
171
167
  const typeNode = member.getTypeNode();
172
168
 
173
- return typeNode === undefined ? false : visitTypeNode(typeNode);
169
+ return typeNode !== undefined && visitTypeNode(typeNode);
174
170
  }
175
171
 
176
172
  if (tsm.Node.isIndexSignatureDeclaration(member)) {
177
173
  const typeNode = member.getReturnTypeNode();
178
174
 
179
- return typeNode === undefined ? false : visitTypeNode(typeNode);
175
+ return typeNode !== undefined && visitTypeNode(typeNode);
180
176
  }
181
177
 
182
178
  return false;
@@ -202,19 +198,12 @@ const visitTypeNode = (node: tsm.TypeNode): boolean => {
202
198
  }
203
199
 
204
200
  // Handles `readonly T[]`, `readonly [...]`, `keyof T`
205
- if (tsm.Node.isTypeOperatorTypeNode(node)) {
206
- return visitTypeNode(node.getTypeNode());
207
- }
208
-
209
- if (tsm.Node.isRestTypeNode(node)) {
210
- return visitTypeNode(node.getTypeNode());
211
- }
212
-
213
- if (tsm.Node.isNamedTupleMember(node)) {
214
- return visitTypeNode(node.getTypeNode());
215
- }
216
-
217
- return false;
201
+ return (
202
+ (tsm.Node.isTypeOperatorTypeNode(node) ||
203
+ tsm.Node.isRestTypeNode(node) ||
204
+ tsm.Node.isNamedTupleMember(node)) &&
205
+ visitTypeNode(node.getTypeNode())
206
+ );
218
207
  };
219
208
 
220
209
  const isStringUnknownIndexSignature = (
@@ -305,7 +294,9 @@ const ensureUnknownRecordImport = (sourceFile: tsm.SourceFile): void => {
305
294
  .getNamedImports()
306
295
  .some((ni) => ni.getName() === UNKNOWN_RECORD_NAME);
307
296
 
308
- if (alreadyImported) return;
297
+ if (alreadyImported) {
298
+ return;
299
+ }
309
300
 
310
301
  existingImport.addNamedImport({
311
302
  name: UNKNOWN_RECORD_NAME,