ts-codemod-lib 3.2.0 → 3.3.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,438 @@
1
+ import { Arr } from 'ts-data-forge';
2
+ import * as tsm from 'ts-morph';
3
+ import { type DeepReadonly } from 'ts-type-forge';
4
+ import { hasDisableNextLineComment } from '../functions/index.mjs';
5
+ import { type TsMorphTransformer } from './types.mjs';
6
+
7
+ const TRANSFORMER_NAME = 'enable-no-unchecked-indexed-access';
8
+
9
+ /**
10
+ * Appends `!` to the index accesses that turning on the
11
+ * [`noUncheckedIndexedAccess`](https://www.typescriptlang.org/tsconfig/#noUncheckedIndexedAccess)
12
+ * compiler option would turn into type errors, as a stopgap while the option
13
+ * is being enabled.
14
+ *
15
+ * The rewrite is driven by the type checker rather than by syntax: the file is
16
+ * checked twice, once with `noUncheckedIndexedAccess` off and once with it on,
17
+ * and `!` is appended only where the option is what added `undefined` to the
18
+ * expression's type. Consequently an index whose presence the type already
19
+ * guarantees keeps its access untouched — `[T, T][1]`, `[T, T, ...T[]][0]`,
20
+ * and `({ a: T })['a']` are all left as they are, as is an element type that
21
+ * contained `undefined` to begin with.
22
+ *
23
+ * Both spellings of an index read are covered, because the option widens both:
24
+ * `rec['a']` and the dotted `rec.a` of a type carrying an index signature.
25
+ *
26
+ * `strictNullChecks` is turned on for both checks, because
27
+ * `noUncheckedIndexedAccess` does nothing without it and the project the
28
+ * transformer is handed may not have it on.
29
+ *
30
+ * The result is a stopgap, not a fix, and wants reviewing. A file transformed
31
+ * on its own — which is how {@link transformSourceCode} drives every
32
+ * transformer here — resolves neither a type from another module nor a
33
+ * narrowing that depends on an imported type guard, and the latter produces
34
+ * assertions a whole-program check does not need. Most of those are reported
35
+ * by `@typescript-eslint/no-unnecessary-type-assertion`, whose fixer removes
36
+ * them; what no rule can see is an assertion that makes a later check dead,
37
+ * as the `if` of `const v = xs[0]!; if (v === undefined) …` now is.
38
+ *
39
+ * @example
40
+ * ```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 };
45
+ *
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)
51
+ * ```
52
+ */
53
+ export const enableNoUncheckedIndexedAccessTransformer = (
54
+ options?: EnableNoUncheckedIndexedAccessTransformerOptions,
55
+ ): TsMorphTransformer => {
56
+ const optionsInternal: EnableNoUncheckedIndexedAccessTransformerOptionsInternal =
57
+ {
58
+ applyLevel: options?.applyLevel ?? 'all',
59
+ debugPrint: options?.debug === true ? console.debug : () => {},
60
+ } as const;
61
+
62
+ return {
63
+ name: TRANSFORMER_NAME,
64
+ transform: (sourceAst) => {
65
+ const positions = collectAssertionPositions(sourceAst, optionsInternal);
66
+
67
+ if (Arr.isEmpty(positions)) {
68
+ return;
69
+ }
70
+
71
+ // One text edit for the whole file: `insertText` forgets every
72
+ // descendant node, so inserting one `!` at a time would reparse the
73
+ // file once per assertion.
74
+ sourceAst.replaceText(
75
+ [0, sourceAst.getEnd()],
76
+ insertNonNullAssertions(sourceAst.getFullText(), positions),
77
+ );
78
+ },
79
+ };
80
+ };
81
+
82
+ export type EnableNoUncheckedIndexedAccessTransformerOptions = DeepReadonly<{
83
+ /**
84
+ * How eagerly `!` is appended.
85
+ *
86
+ * - `"all"`: append it to every read whose type `noUncheckedIndexedAccess`
87
+ * widens with `undefined`, so that as few type errors as possible remain.
88
+ * - `"avoidWhereUndefinedIsAllowed"`: additionally leave the access alone
89
+ * where its contextual type already accepts `undefined` (e.g.
90
+ * `const x: number | undefined = xs[0];` or an argument of a
91
+ * `(x: number | undefined) => void` parameter), which produces fewer but
92
+ * only load-bearing assertions.
93
+ *
94
+ * @default "all"
95
+ */
96
+ applyLevel?: 'all' | 'avoidWhereUndefinedIsAllowed';
97
+
98
+ debug?: boolean;
99
+ }>;
100
+
101
+ type EnableNoUncheckedIndexedAccessTransformerOptionsInternal = DeepReadonly<{
102
+ applyLevel: 'all' | 'avoidWhereUndefinedIsAllowed';
103
+ debugPrint: (...args: readonly unknown[]) => void;
104
+ }>;
105
+
106
+ /**
107
+ * An index read that `noUncheckedIndexedAccess` can widen: an element access,
108
+ * and the dotted spelling of the same thing on a type with an index signature
109
+ * (`rec.a`, which the option widens exactly as it widens `rec['a']`).
110
+ */
111
+ type IndexReadExpression =
112
+ tsm.ElementAccessExpression | tsm.PropertyAccessExpression;
113
+
114
+ /**
115
+ * Returns the positions at which a `!` has to be inserted, i.e. the end
116
+ * offsets of the reads whose type `noUncheckedIndexedAccess` widens with
117
+ * `undefined`.
118
+ */
119
+ const collectAssertionPositions = (
120
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
121
+ sourceAst: tsm.SourceFile,
122
+ options: EnableNoUncheckedIndexedAccessTransformerOptionsInternal,
123
+ ): readonly number[] => {
124
+ const candidates: readonly IndexReadExpression[] = [
125
+ ...sourceAst.getDescendantsOfKind(tsm.SyntaxKind.ElementAccessExpression),
126
+ ...sourceAst.getDescendantsOfKind(tsm.SyntaxKind.PropertyAccessExpression),
127
+ ].filter(
128
+ (node) => isAssertableReadPosition(node) && !isIgnoredByComment(node),
129
+ );
130
+
131
+ if (Arr.isEmpty(candidates)) {
132
+ return [];
133
+ }
134
+
135
+ const project = sourceAst.getProject();
136
+
137
+ const originalOptions = project.getCompilerOptions();
138
+
139
+ try {
140
+ // With the option on first, because that narrows the candidates down to
141
+ // the few reads worth checking a second time. `strictNullChecks` comes
142
+ // with it: `noUncheckedIndexedAccess` does nothing without it, and the
143
+ // project handed to the transformer may not have it on.
144
+ project.compilerOptions.set({
145
+ strictNullChecks: true,
146
+ noUncheckedIndexedAccess: true,
147
+ });
148
+
149
+ const widened = candidates.filter(
150
+ (node) =>
151
+ typeIncludesUndefined(node.getType()) &&
152
+ !(
153
+ options.applyLevel === 'avoidWhereUndefinedIsAllowed' &&
154
+ contextAcceptsUndefined(node)
155
+ ),
156
+ );
157
+
158
+ if (Arr.isEmpty(widened)) {
159
+ return [];
160
+ }
161
+
162
+ project.compilerOptions.set({ noUncheckedIndexedAccess: false });
163
+
164
+ // What is possibly `undefined` without the option too is not the option's
165
+ // doing — the element type says so, or the expression is erroneous (an
166
+ // unresolved import resolves to `any`, which is never widened).
167
+ return widened
168
+ .filter((node) => !typeIncludesUndefined(node.getType()))
169
+ .map((node) => {
170
+ options.debugPrint(`${node.getText()} -> ${node.getText()}!`);
171
+
172
+ return node.getEnd();
173
+ });
174
+ } finally {
175
+ // `set` merges, so the two options overridden above have to be named
176
+ // explicitly: they are absent from `originalOptions` when the project did
177
+ // not carry them, and would otherwise stay overridden for whichever
178
+ // transformer runs next.
179
+ project.compilerOptions.set({
180
+ ...originalOptions,
181
+ strictNullChecks: originalOptions.strictNullChecks,
182
+ noUncheckedIndexedAccess: originalOptions.noUncheckedIndexedAccess,
183
+ });
184
+ }
185
+ };
186
+
187
+ const insertNonNullAssertions = (
188
+ fullText: string,
189
+ positions: readonly number[],
190
+ ): string => {
191
+ const mut_parts: string[] = [];
192
+
193
+ let mut_restStart: number = 0;
194
+
195
+ for (const position of positions.toSorted((a, b) => a - b)) {
196
+ mut_parts.push(fullText.slice(mut_restStart, position), '!');
197
+
198
+ mut_restStart = position;
199
+ }
200
+
201
+ mut_parts.push(fullText.slice(mut_restStart));
202
+
203
+ return mut_parts.join('');
204
+ };
205
+
206
+ const typeIncludesUndefined = (
207
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
208
+ type: tsm.Type,
209
+ ): boolean =>
210
+ type.isUndefined() ||
211
+ (type.isUnion() && type.getUnionTypes().some((t) => t.isUndefined()));
212
+
213
+ /**
214
+ * Whether the position the access sits in already accepts `undefined`, in
215
+ * which case `noUncheckedIndexedAccess` reports nothing there and the
216
+ * assertion would only be noise.
217
+ */
218
+ const contextAcceptsUndefined = (
219
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
220
+ node: IndexReadExpression,
221
+ ): boolean => {
222
+ const contextualType = node.getContextualType();
223
+
224
+ return (
225
+ contextualType !== undefined &&
226
+ (contextualType.isAny() ||
227
+ contextualType.isUnknown() ||
228
+ typeIncludesUndefined(contextualType))
229
+ );
230
+ };
231
+
232
+ const isIgnoredByComment = (
233
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
234
+ node: IndexReadExpression,
235
+ ): boolean => {
236
+ if (hasDisableNextLineComment(node, TRANSFORMER_NAME)) {
237
+ return true;
238
+ }
239
+
240
+ // An index access is rarely the first thing on its own line, so the comment
241
+ // that covers it is the one above the statement it appears in.
242
+ const statement = node.getFirstAncestor((ancestor) =>
243
+ tsm.Node.isStatement(ancestor),
244
+ );
245
+
246
+ return (
247
+ statement !== undefined &&
248
+ hasDisableNextLineComment(statement, TRANSFORMER_NAME)
249
+ );
250
+ };
251
+
252
+ /**
253
+ * Whether a `!` appended to this access would be both syntactically valid and
254
+ * meaningful.
255
+ *
256
+ * Note that most write positions need no rule of their own: TypeScript gives
257
+ * the target of an assignment the element type without `undefined`, so the
258
+ * type comparison in {@link collectAssertionPositions} already passes them
259
+ * over. The exceptions handled here are the ones it does widen.
260
+ */
261
+ const isAssertableReadPosition = (
262
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
263
+ node: IndexReadExpression,
264
+ ): boolean => {
265
+ const parent = node.getParent();
266
+
267
+ if (parent === undefined) {
268
+ return false;
269
+ }
270
+
271
+ return (
272
+ !isAlreadyAssertedOrGuarded(node, parent) &&
273
+ !isWriteTarget(node, parent) &&
274
+ !isTestedForAbsence(node, parent)
275
+ );
276
+ };
277
+
278
+ /** `xs[0]!`, `xs[0] as T`, `<T>xs[0]`, `xs[0]?.foo`, `xs[0]?.()`, `xs[0]?.[1]`. */
279
+ const isAlreadyAssertedOrGuarded = (
280
+ node: tsm.Node,
281
+ parent: tsm.Node,
282
+ ): boolean => {
283
+ if (
284
+ parent.isKind(tsm.SyntaxKind.NonNullExpression) ||
285
+ parent.isKind(tsm.SyntaxKind.AsExpression) ||
286
+ parent.isKind(tsm.SyntaxKind.TypeAssertionExpression)
287
+ ) {
288
+ return true;
289
+ }
290
+
291
+ if (isInOptionalChain(node)) {
292
+ return true;
293
+ }
294
+
295
+ // Only the object of the optional chain is guarded; an index argument such
296
+ // as the `xs[0]` of `m?.[xs[0]]` is not.
297
+ return (
298
+ (parent.isKind(tsm.SyntaxKind.PropertyAccessExpression) ||
299
+ parent.isKind(tsm.SyntaxKind.ElementAccessExpression) ||
300
+ parent.isKind(tsm.SyntaxKind.CallExpression)) &&
301
+ parent.hasQuestionDotToken() &&
302
+ parent.getExpression() === node
303
+ );
304
+ };
305
+
306
+ /**
307
+ * Whether the read sits in an optional chain that can short-circuit before it,
308
+ * as the `xs[0]?.toFixed` of `xs[0]?.toFixed()` does. `undefined` in such a
309
+ * type comes from the chain rather than from the index, and the chain already
310
+ * accounts for it; asserting there would assert away the author's own `?.`.
311
+ *
312
+ * Parentheses end a chain (`(a?.b).c` is not one), which is why the walk stops
313
+ * at anything that is not a chain link.
314
+ */
315
+ const isInOptionalChain = (node: tsm.Node): boolean => {
316
+ if (
317
+ node.isKind(tsm.SyntaxKind.PropertyAccessExpression) ||
318
+ node.isKind(tsm.SyntaxKind.ElementAccessExpression) ||
319
+ node.isKind(tsm.SyntaxKind.CallExpression)
320
+ ) {
321
+ return (
322
+ node.hasQuestionDotToken() || isInOptionalChain(node.getExpression())
323
+ );
324
+ }
325
+
326
+ return (
327
+ node.isKind(tsm.SyntaxKind.NonNullExpression) &&
328
+ isInOptionalChain(node.getExpression())
329
+ );
330
+ };
331
+
332
+ /**
333
+ * Positions that write the value rather than read it, where `!` cannot be
334
+ * appended at all: an assignment target is not an expression (`xs[0]! = 1`
335
+ * does not parse as an assignment), and `delete xs[0]!` and `xs[0]!++` are
336
+ * equally invalid.
337
+ *
338
+ * `xs[0] += 1` is the one case this leaves a type error behind: its left-hand
339
+ * side is read as well as written, so `noUncheckedIndexedAccess` reports it,
340
+ * but no assertion can be spelled there.
341
+ */
342
+ const isWriteTarget = (node: tsm.Node, parent: tsm.Node): boolean => {
343
+ if (
344
+ parent.isKind(tsm.SyntaxKind.DeleteExpression) ||
345
+ parent.isKind(tsm.SyntaxKind.PostfixUnaryExpression)
346
+ ) {
347
+ return true;
348
+ }
349
+
350
+ if (parent.isKind(tsm.SyntaxKind.PrefixUnaryExpression)) {
351
+ const operator = parent.getOperatorToken();
352
+
353
+ return (
354
+ operator === tsm.SyntaxKind.PlusPlusToken ||
355
+ operator === tsm.SyntaxKind.MinusMinusToken
356
+ );
357
+ }
358
+
359
+ if (
360
+ parent.isKind(tsm.SyntaxKind.ForOfStatement) ||
361
+ parent.isKind(tsm.SyntaxKind.ForInStatement)
362
+ ) {
363
+ return parent.getInitializer() === node;
364
+ }
365
+
366
+ return (
367
+ parent.isKind(tsm.SyntaxKind.BinaryExpression) &&
368
+ parent.getLeft() === node &&
369
+ isAssignmentOperatorKind(parent.getOperatorToken().getKind())
370
+ );
371
+ };
372
+
373
+ /**
374
+ * Positions that read the access precisely to find out whether the value is
375
+ * there — a truthiness check, an equality comparison, `??`, `typeof`. None of
376
+ * them is an error under `noUncheckedIndexedAccess`, and asserting inside one
377
+ * would defeat the check it performs (`case undefined:` would stop compiling
378
+ * against an asserted `switch` subject).
379
+ */
380
+ const isTestedForAbsence = (node: tsm.Node, parent: tsm.Node): boolean => {
381
+ if (parent.isKind(tsm.SyntaxKind.TypeOfExpression)) {
382
+ return true;
383
+ }
384
+
385
+ if (parent.isKind(tsm.SyntaxKind.PrefixUnaryExpression)) {
386
+ return parent.getOperatorToken() === tsm.SyntaxKind.ExclamationToken;
387
+ }
388
+
389
+ if (parent.isKind(tsm.SyntaxKind.BinaryExpression)) {
390
+ const operator = parent.getOperatorToken().getKind();
391
+
392
+ return (
393
+ equalityOperatorKinds.includes(operator) ||
394
+ (parent.getLeft() === node &&
395
+ shortCircuitOperatorKinds.includes(operator))
396
+ );
397
+ }
398
+
399
+ if (
400
+ parent.isKind(tsm.SyntaxKind.IfStatement) ||
401
+ parent.isKind(tsm.SyntaxKind.WhileStatement) ||
402
+ parent.isKind(tsm.SyntaxKind.DoStatement) ||
403
+ parent.isKind(tsm.SyntaxKind.SwitchStatement)
404
+ ) {
405
+ return parent.getExpression() === node;
406
+ }
407
+
408
+ if (parent.isKind(tsm.SyntaxKind.ForStatement)) {
409
+ return parent.getCondition() === node;
410
+ }
411
+
412
+ return (
413
+ parent.isKind(tsm.SyntaxKind.ConditionalExpression) &&
414
+ parent.getCondition() === node
415
+ );
416
+ };
417
+
418
+ /**
419
+ * `=` through `^=`, i.e. every assignment operator. TypeScript keeps them in
420
+ * one contiguous block of {@link tsm.SyntaxKind} and marks its ends, which is
421
+ * how the compiler itself recognizes them.
422
+ */
423
+ const isAssignmentOperatorKind = (kind: tsm.SyntaxKind): boolean =>
424
+ tsm.SyntaxKind.FirstAssignment <= kind &&
425
+ kind <= tsm.SyntaxKind.LastAssignment;
426
+
427
+ const equalityOperatorKinds: readonly tsm.SyntaxKind[] = [
428
+ tsm.SyntaxKind.EqualsEqualsToken,
429
+ tsm.SyntaxKind.EqualsEqualsEqualsToken,
430
+ tsm.SyntaxKind.ExclamationEqualsToken,
431
+ tsm.SyntaxKind.ExclamationEqualsEqualsToken,
432
+ ] as const;
433
+
434
+ const shortCircuitOperatorKinds: readonly tsm.SyntaxKind[] = [
435
+ tsm.SyntaxKind.QuestionQuestionToken,
436
+ tsm.SyntaxKind.AmpersandAmpersandToken,
437
+ tsm.SyntaxKind.BarBarToken,
438
+ ] as const;