ts-codemod-lib 4.0.1 → 4.1.0

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.
@@ -0,0 +1,458 @@
1
+ /* eslint-disable tree-shakable/import-star */
2
+ /* eslint-disable vitest/expect-expect */
3
+ import dedent from 'dedent';
4
+ import * as prettierPluginEstree from 'prettier/plugins/estree';
5
+ import * as prettierPluginTypeScript from 'prettier/plugins/typescript';
6
+ import * as prettier from 'prettier/standalone';
7
+ import * as tsm from 'ts-morph';
8
+ import {
9
+ convertToReadonlyTransformer,
10
+ type ReadonlyTransformerOptions,
11
+ } from './convert-to-readonly.mjs';
12
+ import { transformSourceCode } from './transform-source-code.mjs';
13
+
14
+ const formatter = (code: string): Promise<string> =>
15
+ prettier.format(code, {
16
+ parser: 'typescript',
17
+ plugins: [prettierPluginTypeScript, prettierPluginEstree],
18
+ });
19
+
20
+ const transform = (
21
+ source: string,
22
+ options?: ReadonlyTransformerOptions,
23
+ ): string =>
24
+ transformSourceCode(source, false, [convertToReadonlyTransformer(options)]);
25
+
26
+ const testFn = async ({
27
+ source,
28
+ expected,
29
+ options,
30
+ }: Readonly<{
31
+ source: string;
32
+ expected: string;
33
+ options?: ReadonlyTransformerOptions;
34
+ }>): Promise<void> => {
35
+ await expect(formatter(transform(source, options))).resolves.toBe(
36
+ await formatter(expected),
37
+ );
38
+ };
39
+
40
+ /**
41
+ * The diagnostics of `code` compiled as a module beside a stand-in for
42
+ * `ts-type-forge` that declares `ReadonlyRecord` as a named export only — no
43
+ * global, as in a package that does not load `ts-type-forge/global`.
44
+ */
45
+ const diagnosticsOf = (code: string): readonly string[] => {
46
+ const project = new tsm.Project({
47
+ useInMemoryFileSystem: true,
48
+ compilerOptions: {
49
+ strict: true,
50
+ noEmit: true,
51
+ target: tsm.ts.ScriptTarget.ESNext,
52
+ module: tsm.ts.ModuleKind.ESNext,
53
+ moduleResolution: tsm.ts.ModuleResolutionKind.Bundler,
54
+ },
55
+ });
56
+
57
+ project.createSourceFile(
58
+ '/node_modules/ts-type-forge/index.d.ts',
59
+ 'export type ReadonlyRecord<K extends PropertyKey, V> = Readonly<Record<K, V>>;',
60
+ );
61
+
62
+ project.createSourceFile(
63
+ '/node_modules/ts-type-forge/package.json',
64
+ '{ "name": "ts-type-forge", "types": "./index.d.ts" }',
65
+ );
66
+
67
+ const sourceFile = project.createSourceFile('/src/source.ts', code);
68
+
69
+ return project
70
+ .getPreEmitDiagnostics()
71
+ .filter(
72
+ (d) => d.getSourceFile()?.getFilePath() === sourceFile.getFilePath(),
73
+ )
74
+ .map((d) =>
75
+ tsm.ts.flattenDiagnosticMessageText(d.compilerObject.messageText, '\n'),
76
+ );
77
+ };
78
+
79
+ describe('convertToReadonlyTransformer imports the ReadonlyRecord it writes', () => {
80
+ test('the output of a module without the import type-checks', () => {
81
+ const source = dedent`
82
+ export const probe = (x: Record<string, number>): number => x['a'] ?? 0;
83
+ `;
84
+
85
+ // The premise: without the import, the name does not resolve.
86
+ expect(
87
+ diagnosticsOf(source.replace('Record<', 'ReadonlyRecord<')),
88
+ ).not.toStrictEqual([]);
89
+
90
+ const output = transform(source);
91
+
92
+ expect(output).toMatch(/ReadonlyRecord<string,\s*number>/u);
93
+
94
+ assert.deepStrictEqual(diagnosticsOf(output), []);
95
+ });
96
+
97
+ test('the output of a script type-checks and is still a script', () => {
98
+ const output = transform(dedent`
99
+ type Foo = Record<string, number>;
100
+ const foo: Foo = { a: 1 };
101
+ `);
102
+
103
+ assert.deepStrictEqual(diagnosticsOf(output), []);
104
+
105
+ expect(output).not.toContain('import');
106
+ });
107
+
108
+ test.each([
109
+ "import { foo } from './foo.mjs';\r\nexport type Foo = Record<string, typeof foo>;\r\n",
110
+ 'export type Foo = Record<string, number>;\r\n',
111
+ "import type {\r\n DeepReadonly,\r\n} from 'ts-type-forge';\r\nexport type Foo = DeepReadonly<{ a: 1 }> | Record<string, number>;\r\n",
112
+ ])('an import takes the CRLF line breaks of the file: %j', (source) => {
113
+ const output = transform(source);
114
+
115
+ expect(output).toMatch(/\bReadonlyRecord\b.*\} from 'ts-type-forge'/u);
116
+
117
+ expect(output).not.toMatch(/[^\r]\n/u);
118
+ });
119
+
120
+ test.each([
121
+ {
122
+ name: 'A new import is added when the file has none',
123
+ source: dedent`
124
+ export type Foo = Record<string, number>;
125
+ `,
126
+ expected: dedent`
127
+ import type { ReadonlyRecord } from 'ts-type-forge';
128
+
129
+ export type Foo = ReadonlyRecord<string, number>;
130
+ `,
131
+ },
132
+ {
133
+ // An import would make it a module, and its declarations would stop being
134
+ // global (a `global.d.ts`).
135
+ name: 'A script gets Readonly<Record<K, V>> rather than an import',
136
+ source: dedent`
137
+ type Foo = Record<string, number>;
138
+ declare module 'x' {
139
+ type Bar = Record<string, number>;
140
+ }
141
+ `,
142
+ expected: dedent`
143
+ type Foo = Readonly<Record<string, number>>;
144
+ declare module 'x' {
145
+ type Bar = Readonly<Record<string, number>>;
146
+ }
147
+ `,
148
+ },
149
+ {
150
+ name: 'A script already using ReadonlyRecord keeps the style',
151
+ source: dedent`
152
+ type Foo = ReadonlyRecord<string, number> | Record<string, number>;
153
+ `,
154
+ expected: dedent`
155
+ type Foo = ReadonlyRecord<string, number> | ReadonlyRecord<string, number>;
156
+ `,
157
+ },
158
+ {
159
+ name: 'A new import goes after the directive prologue',
160
+ source: dedent`
161
+ 'use client'; // a comment on its line
162
+
163
+ export type Foo = Record<string, number>;
164
+ `,
165
+ expected: dedent`
166
+ 'use client'; // a comment on its line
167
+ import type { ReadonlyRecord } from 'ts-type-forge';
168
+
169
+ export type Foo = ReadonlyRecord<string, number>;
170
+ `,
171
+ },
172
+ {
173
+ name: 'A pragma comment stays above a new import',
174
+ source: dedent`
175
+ // @ts-nocheck
176
+ /** @jsxImportSource preact */
177
+ /** Foo. */
178
+ export type Foo = Record<string, number>;
179
+ `,
180
+ expected: dedent`
181
+ // @ts-nocheck
182
+ /** @jsxImportSource preact */
183
+ import type { ReadonlyRecord } from 'ts-type-forge';
184
+
185
+ /** Foo. */
186
+ export type Foo = ReadonlyRecord<string, number>;
187
+ `,
188
+ },
189
+ {
190
+ name: 'A block comment directly above the first statement stays above a new import',
191
+ source: dedent`
192
+ /* eslint-disable some-rule */
193
+ export type Foo = Record<string, number>;
194
+ `,
195
+ expected: dedent`
196
+ /* eslint-disable some-rule */
197
+ import type { ReadonlyRecord } from 'ts-type-forge';
198
+
199
+ export type Foo = ReadonlyRecord<string, number>;
200
+ `,
201
+ },
202
+ {
203
+ name: 'ReadonlyRecord declared in an inner scope does not count',
204
+ source: dedent`
205
+ export function f(): void {
206
+ type ReadonlyRecord = number;
207
+ }
208
+ export type Foo = Record<string, number>;
209
+ `,
210
+ expected: dedent`
211
+ import type { ReadonlyRecord } from 'ts-type-forge';
212
+
213
+ export function f(): void {
214
+ type ReadonlyRecord = number;
215
+ }
216
+ export type Foo = ReadonlyRecord<string, number>;
217
+ `,
218
+ },
219
+ {
220
+ name: 'A top-level value named ReadonlyRecord gets Readonly<Record<K, V>> rather than a clashing import',
221
+ source: dedent`
222
+ export const ReadonlyRecord = 1;
223
+ export type Foo = Record<string, number>;
224
+ `,
225
+ expected: dedent`
226
+ export const ReadonlyRecord = 1;
227
+ export type Foo = Readonly<Record<string, number>>;
228
+ `,
229
+ },
230
+ {
231
+ name: 'A namespace import named ReadonlyRecord gets Readonly<Record<K, V>>',
232
+ source: dedent`
233
+ import * as ReadonlyRecord from './x.mjs';
234
+ export type Foo = Record<string, typeof ReadonlyRecord>;
235
+ `,
236
+ expected: dedent`
237
+ import * as ReadonlyRecord from './x.mjs';
238
+ export type Foo = Readonly<Record<string, typeof ReadonlyRecord>>;
239
+ `,
240
+ },
241
+ {
242
+ name: 'ReadonlyRecord as a qualified name or a property is not a binding',
243
+ source: dedent`
244
+ import * as tf from 'ts-type-forge';
245
+ export type A = tf.ReadonlyRecord<string, 1> | { ReadonlyRecord: true };
246
+ export type B = Record<string, 2>;
247
+ `,
248
+ expected: dedent`
249
+ import * as tf from 'ts-type-forge';
250
+ import type { ReadonlyRecord } from 'ts-type-forge';
251
+ export type A = tf.ReadonlyRecord<string, 1> | Readonly<{ ReadonlyRecord: true }>;
252
+ export type B = ReadonlyRecord<string, 2>;
253
+ `,
254
+ },
255
+ {
256
+ name: 'A new import goes after the other imports',
257
+ source: dedent`
258
+ import { foo } from './foo.mjs';
259
+ import { bar } from './bar.mjs';
260
+
261
+ export type Foo = Record<string, typeof foo | typeof bar>;
262
+ `,
263
+ expected: dedent`
264
+ import { foo } from './foo.mjs';
265
+ import { bar } from './bar.mjs';
266
+ import type { ReadonlyRecord } from 'ts-type-forge';
267
+
268
+ export type Foo = ReadonlyRecord<string, typeof foo | typeof bar>;
269
+ `,
270
+ },
271
+ {
272
+ name: 'A new import goes after a comment on the line of the last import',
273
+ source: dedent`
274
+ import { foo } from './foo.mjs'; // eslint-disable-line some-rule
275
+
276
+ export type Foo = Record<string, typeof foo>;
277
+ `,
278
+ expected: dedent`
279
+ import { foo } from './foo.mjs'; // eslint-disable-line some-rule
280
+ import type { ReadonlyRecord } from 'ts-type-forge';
281
+
282
+ export type Foo = ReadonlyRecord<string, typeof foo>;
283
+ `,
284
+ },
285
+ {
286
+ name: 'A shebang, a header comment and a triple-slash directive stay above a new import',
287
+ source: dedent`
288
+ #!/usr/bin/env node
289
+ /* cSpell:disable */
290
+ /// <reference types="node" />
291
+ export type Foo = Record<string, number>;
292
+ `,
293
+ expected: dedent`
294
+ #!/usr/bin/env node
295
+ /* cSpell:disable */
296
+ /// <reference types="node" />
297
+ import type { ReadonlyRecord } from 'ts-type-forge';
298
+
299
+ export type Foo = ReadonlyRecord<string, number>;
300
+ `,
301
+ },
302
+ {
303
+ name: 'The comments attached to the first statement stay with it',
304
+ source: dedent`
305
+ /* A file header */
306
+
307
+ /** Foo. */
308
+ // eslint-disable-next-line some-rule
309
+ export type Foo = Record<string, number>;
310
+ `,
311
+ expected: dedent`
312
+ /* A file header */
313
+
314
+ import type { ReadonlyRecord } from 'ts-type-forge';
315
+
316
+ /** Foo. */
317
+ // eslint-disable-next-line some-rule
318
+ export type Foo = ReadonlyRecord<string, number>;
319
+ `,
320
+ },
321
+ {
322
+ name: 'Merged into an existing type-only import of ts-type-forge',
323
+ source: dedent`
324
+ import type { DeepReadonly } from 'ts-type-forge';
325
+ export type Foo = DeepReadonly<Record<string, number[]>> | Record<string, number>;
326
+ `,
327
+ expected: dedent`
328
+ import type { DeepReadonly, ReadonlyRecord } from 'ts-type-forge';
329
+ export type Foo = DeepReadonly<Record<string, number[]>> | ReadonlyRecord<string, number>;
330
+ `,
331
+ },
332
+ {
333
+ name: 'Merged into an existing import of ts-type-forge with inline type',
334
+ source: dedent`
335
+ import { type DeepReadonly } from 'ts-type-forge';
336
+ export type Foo = DeepReadonly<Record<string, number[]>> | Record<string, number>;
337
+ `,
338
+ expected: dedent`
339
+ import { type DeepReadonly, type ReadonlyRecord } from 'ts-type-forge';
340
+ export type Foo = DeepReadonly<Record<string, number[]>> | ReadonlyRecord<string, number>;
341
+ `,
342
+ },
343
+ {
344
+ name: 'A namespace import of ts-type-forge is left alone and a new import added',
345
+ source: dedent`
346
+ import type * as TTF from 'ts-type-forge';
347
+ export type Foo = TTF.UnknownRecord | Record<string, number>;
348
+ `,
349
+ expected: dedent`
350
+ import type * as TTF from 'ts-type-forge';
351
+ import type { ReadonlyRecord } from 'ts-type-forge';
352
+ export type Foo = TTF.UnknownRecord | ReadonlyRecord<string, number>;
353
+ `,
354
+ },
355
+ {
356
+ name: 'An aliased import does not bind the name, so it is imported',
357
+ source: dedent`
358
+ import type { ReadonlyRecord as RR } from 'ts-type-forge';
359
+ export type Foo = RR<string, number> | Record<string, number>;
360
+ `,
361
+ expected: dedent`
362
+ import type { ReadonlyRecord as RR, ReadonlyRecord } from 'ts-type-forge';
363
+ export type Foo = RR<string, number> | ReadonlyRecord<string, number>;
364
+ `,
365
+ },
366
+ {
367
+ name: 'An existing import of ReadonlyRecord is not duplicated',
368
+ source: dedent`
369
+ import type { ReadonlyRecord } from 'ts-type-forge';
370
+ export type Foo = Record<string, number>;
371
+ `,
372
+ expected: dedent`
373
+ import type { ReadonlyRecord } from 'ts-type-forge';
374
+ export type Foo = ReadonlyRecord<string, number>;
375
+ `,
376
+ },
377
+ {
378
+ name: 'ReadonlyRecord imported from another module is used as is',
379
+ source: dedent`
380
+ import type { ReadonlyRecord } from './types.mjs';
381
+ export type Foo = Record<string, number>;
382
+ `,
383
+ expected: dedent`
384
+ import type { ReadonlyRecord } from './types.mjs';
385
+ export type Foo = ReadonlyRecord<string, number>;
386
+ `,
387
+ },
388
+ {
389
+ name: 'A file declaring ReadonlyRecord itself gets no import',
390
+ source: dedent`
391
+ type ReadonlyRecord<K extends PropertyKey, V> = { readonly [P in K]: V };
392
+ export type Foo = Record<string, number>;
393
+ `,
394
+ expected: dedent`
395
+ type ReadonlyRecord<K extends PropertyKey, V> = Readonly<{ [P in K]: V }>;
396
+ export type Foo = ReadonlyRecord<string, number>;
397
+ `,
398
+ },
399
+ {
400
+ name: 'A file already using ReadonlyRecord without an import (a global) gets no import',
401
+ source: dedent`
402
+ export type Foo = ReadonlyRecord<string, number> | Record<string, number>;
403
+ `,
404
+ expected: dedent`
405
+ export type Foo = ReadonlyRecord<string, number> | ReadonlyRecord<string, number>;
406
+ `,
407
+ },
408
+ {
409
+ name: 'No Record written, no import',
410
+ source: dedent`
411
+ export type Foo = Map<string, number>;
412
+ `,
413
+ expected: dedent`
414
+ export type Foo = ReadonlyMap<string, number>;
415
+ `,
416
+ },
417
+ {
418
+ name: 'A Record kept as is writes no import',
419
+ source: dedent`
420
+ export type Foo = DeepReadonly<Record<string, number>>;
421
+ `,
422
+ expected: dedent`
423
+ export type Foo = DeepReadonly<Record<string, number>>;
424
+ `,
425
+ },
426
+ {
427
+ name: 'A Record skipped by an ignore comment writes no import',
428
+ source: dedent`
429
+ // transformer-ignore-next-line
430
+ export type Foo = Record<string, number>;
431
+ `,
432
+ expected: dedent`
433
+ // transformer-ignore-next-line
434
+ export type Foo = Record<string, number>;
435
+ `,
436
+ },
437
+ {
438
+ name: 'recordStyle "Readonly<Record>" writes no import',
439
+ source: dedent`
440
+ export type Foo = Record<string, number>;
441
+ `,
442
+ expected: dedent`
443
+ export type Foo = Readonly<Record<string, number>>;
444
+ `,
445
+ options: { recordStyle: 'Readonly<Record>' },
446
+ },
447
+ {
448
+ name: 'importStyle "global" writes no import',
449
+ source: dedent`
450
+ export type Foo = Record<string, number>;
451
+ `,
452
+ expected: dedent`
453
+ export type Foo = ReadonlyRecord<string, number>;
454
+ `,
455
+ options: { importStyle: 'global' },
456
+ },
457
+ ] as const)('$name', testFn);
458
+ });