ts-codemod-lib 1.4.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,7 @@ import { runTransformerCLI } from './run-transformer-cli.mjs';
8
8
 
9
9
  const cmdDef = cmd.command({
10
10
  name: 'convert-interface-to-type',
11
- version: '1.4.0',
11
+ version: '1.4.1',
12
12
  args: {
13
13
  baseDir: cmd.positional({
14
14
  type: cmd.string,
@@ -8,7 +8,7 @@ import { runTransformerCLI } from './run-transformer-cli.mjs';
8
8
 
9
9
  const cmdDef = cmd.command({
10
10
  name: 'convert-to-readonly',
11
- version: '1.4.0',
11
+ version: '1.4.1',
12
12
  args: {
13
13
  baseDir: cmd.positional({
14
14
  type: cmd.string,
@@ -8,7 +8,7 @@ import { runTransformerCLI } from './run-transformer-cli.mjs';
8
8
 
9
9
  const cmdDef = cmd.command({
10
10
  name: 'replace-any-with-unknown',
11
- version: '1.4.0',
11
+ version: '1.4.1',
12
12
  args: {
13
13
  baseDir: cmd.positional({
14
14
  type: cmd.string,
@@ -8,7 +8,7 @@ import { runTransformerCLI } from './run-transformer-cli.mjs';
8
8
 
9
9
  const cmdDef = cmd.command({
10
10
  name: 'replace-record-with-unknown-record',
11
- version: '1.4.0',
11
+ version: '1.4.1',
12
12
  args: {
13
13
  baseDir: cmd.positional({
14
14
  type: cmd.string,
@@ -175,7 +175,7 @@ const getFilesFromGlob = async (
175
175
  >
176
176
  > => {
177
177
  const globResult = await glob(baseDir, {
178
- ignore: Array.from(exclude),
178
+ ignore: exclude,
179
179
  });
180
180
 
181
181
  if (Result.isErr(globResult)) {
@@ -1,9 +1,10 @@
1
- import { ISet } from 'ts-data-forge';
1
+ import { Arr, expectType, ISet, pipe } from 'ts-data-forge';
2
2
  import * as tsm from 'ts-morph';
3
3
  import {
4
4
  hasDisableNextLineComment,
5
5
  isAsConstNode,
6
6
  } from '../functions/index.mjs';
7
+ import { replaceNodeWithDebugPrint } from '../utils/index.mjs';
7
8
  import { type TsMorphTransformer } from './types.mjs';
8
9
 
9
10
  const TRANSFORMER_NAME = 'append-as-const';
@@ -18,11 +19,25 @@ export const appendAsConstTransformer = (
18
19
  const optionsInternal: AppendAsConstTransformerOptionsInternal = {
19
20
  applyLevel: options?.applyLevel ?? 'avoidInFunctionArgs',
20
21
  ignoredPrefixes: ignorePrefixes,
22
+
23
+ debugPrint: options?.debug === true ? console.debug : () => {},
24
+ replaceNode:
25
+ options?.debug === true
26
+ ? replaceNodeWithDebugPrint
27
+ : (node, newNodeText) => node.replaceWithText(newNodeText),
21
28
  };
22
29
 
23
30
  const transformer: TsMorphTransformer = (sourceAst) => {
24
31
  for (const node of sourceAst.getChildren()) {
25
- transformNode(node, optionsInternal);
32
+ transformNode(
33
+ node,
34
+ {
35
+ isUnderConstContext: false,
36
+ isDirectUnderConstInitializer: false,
37
+ isUnderSpreadElement: false,
38
+ },
39
+ optionsInternal,
40
+ );
26
41
  }
27
42
  };
28
43
 
@@ -44,186 +59,421 @@ export type AppendAsConstTransformerOptions = DeepReadonly<{
44
59
  */
45
60
  ignorePrefixes?: string[];
46
61
 
47
- ignoreConstTypeParameter?: boolean;
62
+ // TODO
63
+ // ignoreConstTypeParameter?: boolean;
64
+
65
+ debug?: boolean;
48
66
  }>;
49
67
 
50
68
  type AppendAsConstTransformerOptionsInternal = DeepReadonly<{
51
69
  applyLevel: 'all' | 'avoidInFunctionArgs';
52
70
  ignoredPrefixes: ISet<string>;
71
+
72
+ debugPrint: (...args: readonly unknown[]) => void;
73
+ replaceNode: (node: tsm.Node, newNodeText: string) => void;
74
+ }>;
75
+
76
+ type AsConstContext = Readonly<{
77
+ isUnderConstContext: boolean;
78
+ isDirectUnderConstInitializer: boolean;
79
+ isUnderSpreadElement: boolean;
53
80
  }>;
54
81
 
55
82
  const transformNode = (
56
83
  node: tsm.Node,
84
+ context: AsConstContext,
57
85
  options: AppendAsConstTransformerOptionsInternal,
58
86
  ): void => {
59
87
  if (hasDisableNextLineComment(node, TRANSFORMER_NAME)) {
60
- console.debug('skipped by disable-next-line comment');
88
+ options.debugPrint('skipped by disable-next-line comment');
89
+
90
+ return;
91
+ }
92
+
93
+ options.debugPrint(node.getKindName(), node.getText());
94
+
95
+ if (
96
+ options.applyLevel === 'avoidInFunctionArgs' &&
97
+ tsm.Node.isCallExpression(node)
98
+ ) {
99
+ return;
100
+ }
101
+
102
+ if (
103
+ node.isKind(tsm.SyntaxKind.LiteralType) ||
104
+ node.isKind(tsm.SyntaxKind.TypeLiteral) ||
105
+ node.isKind(tsm.SyntaxKind.TypeReference) ||
106
+ node.isKind(tsm.SyntaxKind.UnionType) ||
107
+ node.isKind(tsm.SyntaxKind.TypeAliasDeclaration)
108
+ ) {
109
+ return; // skip type annotations
110
+ }
111
+
112
+ if (
113
+ node.isKind(tsm.SyntaxKind.NoSubstitutionTemplateLiteral) || // `abc`
114
+ node.isKind(tsm.SyntaxKind.NumericLiteral) || // 123
115
+ node.isKind(tsm.SyntaxKind.BigIntLiteral) || // 123n
116
+ node.isKind(tsm.SyntaxKind.StringLiteral) || // 'abc'
117
+ node.isKind(tsm.SyntaxKind.TrueKeyword) || // true
118
+ node.isKind(tsm.SyntaxKind.FalseKeyword) // false
119
+ ) {
120
+ if (context.isDirectUnderConstInitializer || context.isUnderConstContext) {
121
+ return;
122
+ }
123
+
124
+ options.replaceNode(node, `${node.getText()} as const`);
125
+
126
+ return;
127
+ }
128
+
129
+ if (node.isKind(tsm.SyntaxKind.TemplateExpression)) {
130
+ options.debugPrint(node.getKindName(), node.getText());
131
+
132
+ options.debugPrint(
133
+ node
134
+ .getChildren()
135
+ .map((c) => c.getText())
136
+ .join(''),
137
+ );
138
+
139
+ options.replaceNode(node, `${node.getText()} as const`);
61
140
 
62
141
  return;
63
142
  }
64
143
 
65
- // check for ignorePrefix
66
144
  if (node.isKind(tsm.SyntaxKind.VariableDeclaration)) {
67
145
  const nodeName = node.getName();
68
146
 
147
+ // check for ignorePrefix
69
148
  if (options.ignoredPrefixes.some((p) => nodeName.startsWith(p))) {
70
149
  // Skip conversion for variable declarations with ignored prefixes
71
150
  // Example: const mut_foo: string[] = []; -> remains as is, without appending `as const`
72
- console.debug('skipped variable declaration by ignorePrefixes');
151
+ options.debugPrint('skipped variable declaration by ignorePrefixes');
152
+
153
+ return;
154
+ }
73
155
 
156
+ const initializer = node.getInitializer();
157
+
158
+ if (initializer === undefined) {
74
159
  return;
75
160
  }
76
161
 
162
+ const declarationKindKeywords = node
163
+ .getVariableStatement()
164
+ ?.getDeclarationKindKeywords()
165
+ .map((k) => k.getText());
166
+
167
+ if (
168
+ declarationKindKeywords !== undefined &&
169
+ Arr.isArrayOfLength(declarationKindKeywords, 1)
170
+ ) {
171
+ transformNode(
172
+ initializer,
173
+ {
174
+ isDirectUnderConstInitializer: declarationKindKeywords[0] === 'const',
175
+ isUnderConstContext: false,
176
+ isUnderSpreadElement: context.isUnderSpreadElement,
177
+ },
178
+ options,
179
+ );
180
+
181
+ return;
182
+ }
183
+
184
+ // const [a, b] = ...;
77
185
  // TODO: Support ignoredPrefixes in ArrayBindingPattern
78
186
  // if (ts.isArrayBindingPattern(nodeName)) {
79
187
  // // for (const [i, el] of nodeName.elements.entries())
80
188
  // }
81
189
 
190
+ // const { x, y } = ...;
82
191
  // TODO: Support ignoredPrefixes in ObjectBindingPattern
83
192
  // if (ts.isObjectBindingPattern(nodeName)) {
84
193
  // // for (const [i, el] of nodeName.elements.entries())
85
194
  // }
86
195
  }
87
196
 
88
- if (
89
- options.applyLevel === 'avoidInFunctionArgs' &&
90
- tsm.Node.isCallExpression(node)
91
- ) {
92
- return;
93
- }
94
-
95
197
  // `as const` node
96
198
  if (isAsConstNode(node)) {
97
- const expression = removeParenthesis(node.getExpression());
199
+ options.debugPrint(node.getKindName(), node.getText());
200
+
201
+ if (context.isDirectUnderConstInitializer) {
202
+ // In const variable declarations, remove `as const` first and then re-append it later if needed
203
+
204
+ transformNode(
205
+ node.getExpression(),
206
+ {
207
+ isUnderConstContext: false,
208
+ isDirectUnderConstInitializer: true,
209
+ isUnderSpreadElement: context.isUnderSpreadElement,
210
+ },
211
+ options,
212
+ );
213
+
214
+ options.replaceNode(
215
+ node,
216
+ // The expression may be marked "as const"
217
+ node.getExpression().getText(),
218
+ ); // remove `as const`
98
219
 
99
- if (
100
- !tsm.Node.isArrayLiteralExpression(expression) &&
101
- !tsm.Node.isObjectLiteralExpression(expression)
102
- ) {
103
- // `as const` is not needed for primitive types
104
- // Example: `0 as const` -> `0`
105
- node.replaceWithText(expression.getText());
220
+ return;
221
+ }
222
+
223
+ if (context.isUnderConstContext) {
224
+ transformNode(
225
+ node.getExpression(),
226
+ {
227
+ isUnderConstContext: true,
228
+ isDirectUnderConstInitializer: false,
229
+ isUnderSpreadElement: context.isUnderSpreadElement,
230
+ },
231
+ options,
232
+ );
233
+
234
+ options.replaceNode(
235
+ node,
236
+ // The expression may be marked "as const"
237
+ node.getExpression().getText(),
238
+ ); // remove `as const`
106
239
 
107
240
  return;
108
241
  }
109
242
 
110
- // Avoid appending `as const` twice
111
- removeAsConstRecursively(node.getExpression());
243
+ transformNode(
244
+ node.getExpression(),
245
+ {
246
+ isUnderConstContext: true,
247
+ isDirectUnderConstInitializer: false,
248
+ isUnderSpreadElement: context.isUnderSpreadElement,
249
+ },
250
+ options,
251
+ );
112
252
 
113
253
  return;
114
254
  }
115
255
 
116
- if (tsm.Node.isArrayLiteralExpression(node)) {
117
- for (const el of node.getElements()) {
118
- removeAsConstRecursively(el);
119
- }
256
+ if (tsm.Node.isAsExpression(node)) {
257
+ return;
258
+ }
120
259
 
121
- node.replaceWithText(`${node.getText()} as const`);
260
+ if (tsm.Node.isSpreadElement(node)) {
261
+ transformNode(
262
+ node.getExpression(),
263
+ {
264
+ isDirectUnderConstInitializer: false,
265
+ isUnderConstContext: context.isUnderConstContext,
266
+ isUnderSpreadElement: true,
267
+ },
268
+ options,
269
+ );
122
270
 
123
271
  return;
124
272
  }
125
273
 
126
- if (tsm.Node.isObjectLiteralExpression(node)) {
127
- for (const el of node.getProperties()) {
128
- removeAsConstRecursively(el);
274
+ if (tsm.Node.isConditionalExpression(node)) {
275
+ if (context.isUnderSpreadElement) {
276
+ // When under spread element, keep `as const` in both branches
277
+ transformNode(
278
+ node.getWhenTrue(),
279
+ {
280
+ isDirectUnderConstInitializer: false,
281
+ isUnderConstContext: false,
282
+ isUnderSpreadElement: false,
283
+ },
284
+ options,
285
+ );
286
+
287
+ transformNode(
288
+ node.getWhenFalse(),
289
+ {
290
+ isDirectUnderConstInitializer: false,
291
+ isUnderConstContext: false,
292
+ isUnderSpreadElement: false,
293
+ },
294
+ options,
295
+ );
129
296
  }
130
297
 
131
- node.replaceWithText(`${node.getText()} as const`);
132
-
133
298
  return;
134
299
  }
135
300
 
136
- for (const child of node.getChildren()) {
137
- transformNode(child, options);
138
- }
139
- };
140
-
141
- const removeAsConstRecursively = (
142
- node: tsm.Node,
143
- insideSpreadWithConditional: boolean = false,
144
- ): void => {
145
- if (hasDisableNextLineComment(node)) {
146
- console.debug('skipped by disable-next-line comment');
301
+ if (tsm.Node.isParenthesizedExpression(node)) {
302
+ transformNode(node.getExpression(), context, options);
147
303
 
148
304
  return;
149
305
  }
150
306
 
151
- if (isAsConstNode(node)) {
152
- // If we're inside a spread element with conditional, keep the `as const`
153
- if (insideSpreadWithConditional) {
154
- return;
155
- }
307
+ if (tsm.Node.isArrayLiteralExpression(node)) {
308
+ // options.debugPrint(node.getKindName(), node.getText());
156
309
 
157
- // Extract node.expression to remove `as const` and recursively call the function
158
- // to remove `as const` from nested nodes
159
- // Example: `[[1,2] as const, [3,4]] as const` -> `[[1,2], [3,4]]`
160
- removeAsConstRecursively(node.getExpression(), insideSpreadWithConditional);
310
+ for (const el of node.getElements()) {
311
+ transformNode(
312
+ el,
313
+ {
314
+ isUnderConstContext: true, // [...] as const
315
+ isDirectUnderConstInitializer: false,
316
+ isUnderSpreadElement: context.isUnderSpreadElement,
317
+ },
318
+ options,
319
+ );
320
+ }
161
321
 
162
- node.replaceWithText(node.getExpression().getText());
322
+ if (!context.isUnderConstContext) {
323
+ options.replaceNode(node, `${node.getText()} as const`);
324
+ }
163
325
 
164
326
  return;
165
327
  }
166
328
 
167
- // If we're inside a spread with conditional and encounter array/object literal without `as const`, add it
168
- if (insideSpreadWithConditional) {
169
- if (tsm.Node.isArrayLiteralExpression(node)) {
170
- // Don't add `as const` to empty arrays
171
- if (node.getElements().length === 0) {
172
- return;
173
- }
174
-
175
- // Add `as const` to the array itself, but don't recursively process elements
176
- // Elements will be processed normally by the outer transform
177
- node.replaceWithText(`${node.getText()} as const`);
178
-
179
- return;
329
+ if (tsm.Node.isObjectLiteralExpression(node)) {
330
+ for (const el of node.getProperties()) {
331
+ transformNode(
332
+ el,
333
+ {
334
+ isUnderConstContext: true, // {...} as const
335
+ isDirectUnderConstInitializer: false,
336
+ isUnderSpreadElement: context.isUnderSpreadElement,
337
+ },
338
+ options,
339
+ );
180
340
  }
181
341
 
182
- if (tsm.Node.isObjectLiteralExpression(node)) {
183
- // Don't add `as const` to empty objects
184
- if (node.getProperties().length === 0) {
185
- return;
186
- }
342
+ if (!context.isUnderConstContext) {
343
+ options.replaceNode(node, `${node.getText()} as const`);
344
+ }
187
345
 
188
- // Add `as const` to the object itself, but don't recursively process properties
189
- node.replaceWithText(`${node.getText()} as const`);
346
+ return;
347
+ }
190
348
 
349
+ if (node.isKind(tsm.SyntaxKind.ClassDeclaration)) {
350
+ // Skip conversion for class declarations with ignored prefixes
351
+ // Example: class mut_Class {...} -> properties remain without readonly
352
+ if (
353
+ options.ignoredPrefixes.some(
354
+ (p) => node.getName()?.startsWith(p) === true,
355
+ )
356
+ ) {
191
357
  return;
192
358
  }
193
- }
194
-
195
- // Mark that we're inside a spread element's expression only if it contains conditional
196
- // Example: `...(flag ? [1, 2] as const : [])` keeps inner `as const`
197
- // Example: `...[1, 2] as const` removes inner `as const`
198
- if (tsm.Node.isSpreadElement(node)) {
199
- const expression = node.getExpression();
200
359
 
201
- const hasConditional = containsConditionalExpression(expression);
202
-
203
- removeAsConstRecursively(expression, hasConditional);
360
+ transformClassDeclarationNode(node, context, options);
204
361
 
205
362
  return;
206
363
  }
207
364
 
208
365
  for (const child of node.getChildren()) {
209
- removeAsConstRecursively(child, insideSpreadWithConditional);
366
+ transformNode(
367
+ child,
368
+ {
369
+ isDirectUnderConstInitializer: false,
370
+ isUnderConstContext: context.isUnderConstContext,
371
+ isUnderSpreadElement: context.isUnderSpreadElement,
372
+ },
373
+ options,
374
+ );
210
375
  }
211
376
  };
212
377
 
213
- const containsConditionalExpression = (node: tsm.Node): boolean => {
214
- if (tsm.Node.isConditionalExpression(node)) {
215
- return true;
216
- }
378
+ const transformClassDeclarationNode = (
379
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
380
+ node: tsm.ClassDeclaration,
381
+ context: AsConstContext,
382
+ options: AppendAsConstTransformerOptionsInternal,
383
+ ): void => {
384
+ for (const mb of node.getMembers()) {
385
+ if (hasDisableNextLineComment(mb)) {
386
+ options.debugPrint('skipped member by disable-next-line comment');
217
387
 
218
- // Check children recursively, but stop at AsExpression boundaries
219
- if (isAsConstNode(node)) {
220
- return false;
221
- }
388
+ continue;
389
+ }
390
+
391
+ if (mb.isKind(tsm.SyntaxKind.PropertyDeclaration)) {
392
+ if (!checkIfPropertyNameShouldBeIgnored(mb.getNameNode(), options)) {
393
+ const type = mb.getTypeNode();
394
+
395
+ if (type !== undefined) {
396
+ transformNode(
397
+ type,
398
+ {
399
+ isDirectUnderConstInitializer: false,
400
+ isUnderConstContext: false,
401
+ isUnderSpreadElement: context.isUnderSpreadElement,
402
+ },
403
+ options,
404
+ );
405
+ }
406
+
407
+ const initializer = mb.getInitializer();
408
+
409
+ if (initializer !== undefined) {
410
+ transformNode(
411
+ initializer,
412
+ {
413
+ isDirectUnderConstInitializer: false,
414
+ isUnderConstContext: false,
415
+ isUnderSpreadElement: context.isUnderSpreadElement,
416
+ },
417
+ options,
418
+ );
419
+ }
420
+ }
222
421
 
223
- return node.getChildren().some(containsConditionalExpression);
422
+ continue;
423
+ }
424
+
425
+ transformNode(
426
+ mb,
427
+ {
428
+ isDirectUnderConstInitializer: false,
429
+ isUnderConstContext: false,
430
+ isUnderSpreadElement: context.isUnderSpreadElement,
431
+ },
432
+ options,
433
+ );
434
+ }
224
435
  };
225
436
 
226
- const removeParenthesis = (node: tsm.Node): tsm.Node =>
227
- tsm.Node.isParenthesizedExpression(node)
228
- ? removeParenthesis(node.getExpression())
229
- : node;
437
+ const checkIfPropertyNameShouldBeIgnored = (
438
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
439
+ nameNode: tsm.PropertyName,
440
+ options: AppendAsConstTransformerOptionsInternal,
441
+ ): boolean => {
442
+ expectType<typeof nameNode, tsm.PropertyName>('=');
443
+
444
+ expectType<
445
+ tsm.PropertyName,
446
+ | tsm.NumericLiteral // skip
447
+ | tsm.BigIntLiteral // skip
448
+ | tsm.NoSubstitutionTemplateLiteral // invalid syntax
449
+ | tsm.Identifier // mut_x: number[]
450
+ | tsm.StringLiteral // "mut_x": number[]
451
+ | tsm.PrivateIdentifier // #memberName: number[] (class only)
452
+ | tsm.ComputedPropertyName // [`mut_x`]: number[]
453
+ >('=');
454
+
455
+ return (
456
+ (nameNode.isKind(tsm.SyntaxKind.Identifier) &&
457
+ pipe(nameNode.getText()).map((nm) =>
458
+ options.ignoredPrefixes.some((p) => nm.startsWith(p)),
459
+ ).value) ||
460
+ (nameNode.isKind(tsm.SyntaxKind.StringLiteral) &&
461
+ pipe(nameNode.getLiteralValue()).map((nm) =>
462
+ options.ignoredPrefixes.some((p) => nm.startsWith(p)),
463
+ ).value) ||
464
+ (nameNode.isKind(tsm.SyntaxKind.PrivateIdentifier) &&
465
+ pipe(nameNode.getText()).map((nm) =>
466
+ options.ignoredPrefixes.some((p) => nm.startsWith(`#${p}`)),
467
+ ).value) ||
468
+ (nameNode.isKind(tsm.SyntaxKind.ComputedPropertyName) &&
469
+ pipe(nameNode.getExpression()).map((exp) => {
470
+ if (exp.isKind(tsm.SyntaxKind.StringLiteral)) {
471
+ const nm = exp.getLiteralValue();
472
+
473
+ return options.ignoredPrefixes.some((p) => nm.startsWith(p));
474
+ }
475
+
476
+ return false;
477
+ }).value)
478
+ );
479
+ };