ts-codemod-lib 4.0.0 → 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.
Files changed (52) hide show
  1. package/README.md +2 -1
  2. package/dist/functions/ast-transformers/append-as-const.d.mts +9 -10
  3. package/dist/functions/ast-transformers/append-as-const.d.mts.map +1 -1
  4. package/dist/functions/ast-transformers/append-as-const.mjs +123 -114
  5. package/dist/functions/ast-transformers/append-as-const.mjs.map +1 -1
  6. package/dist/functions/ast-transformers/convert-to-readonly.d.mts +28 -7
  7. package/dist/functions/ast-transformers/convert-to-readonly.d.mts.map +1 -1
  8. package/dist/functions/ast-transformers/convert-to-readonly.mjs +452 -253
  9. package/dist/functions/ast-transformers/convert-to-readonly.mjs.map +1 -1
  10. package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.d.mts +8 -8
  11. package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.mjs +103 -104
  12. package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.mjs.map +1 -1
  13. package/dist/functions/ast-transformers/replace-record-with-unknown-record.d.mts +2 -2
  14. package/dist/functions/ast-transformers/replace-record-with-unknown-record.mjs +51 -51
  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 +69 -69
  18. package/dist/functions/ast-transformers/transform-source-code.mjs.map +1 -1
  19. package/dist/functions/constants/mutable-prefixes.d.mts +8 -8
  20. package/dist/functions/functions/has-disable-next-line-comment.mjs +23 -23
  21. package/dist/functions/functions/has-disable-next-line-comment.mjs.map +1 -1
  22. package/dist/functions/functions/is-readonly-node.d.mts +2 -2
  23. package/dist/functions/functions/is-readonly-node.d.mts.map +1 -1
  24. package/dist/functions/functions/is-readonly-node.mjs +13 -13
  25. package/dist/functions/functions/is-readonly-node.mjs.map +1 -1
  26. package/dist/functions/functions/wrap-with-parentheses.d.mts +2 -2
  27. package/dist/functions/functions/wrap-with-parentheses.d.mts.map +1 -1
  28. package/dist/functions/functions/wrap-with-parentheses.mjs +87 -32
  29. package/dist/functions/functions/wrap-with-parentheses.mjs.map +1 -1
  30. package/dist/functions/utils/replace-node-with-text.d.mts +10 -11
  31. package/dist/functions/utils/replace-node-with-text.d.mts.map +1 -1
  32. package/dist/functions/utils/replace-node-with-text.mjs +0 -1
  33. package/dist/functions/utils/replace-node-with-text.mjs.map +1 -1
  34. package/package.json +9 -9
  35. package/src/functions/ast-transformers/append-as-const.mts +242 -231
  36. package/src/functions/ast-transformers/append-as-const.test.mts +39 -6
  37. package/src/functions/ast-transformers/convert-to-readonly-record-import.test.mts +458 -0
  38. package/src/functions/ast-transformers/convert-to-readonly.mts +1003 -694
  39. package/src/functions/ast-transformers/convert-to-readonly.test.mts +80 -33
  40. package/src/functions/ast-transformers/enable-no-unchecked-indexed-access.mts +178 -179
  41. package/src/functions/ast-transformers/enable-no-unchecked-indexed-access.test.mts +29 -29
  42. package/src/functions/ast-transformers/replace-any-with-unknown.test.mts +6 -6
  43. package/src/functions/ast-transformers/replace-record-with-unknown-record.mts +75 -75
  44. package/src/functions/ast-transformers/replace-record-with-unknown-record.test.mts +6 -6
  45. package/src/functions/ast-transformers/transform-source-code.mts +110 -110
  46. package/src/functions/ast-transformers/transformer-specific-ignore.test.mts +6 -6
  47. package/src/functions/constants/mutable-prefixes.mts +8 -8
  48. package/src/functions/functions/has-disable-next-line-comment.mts +35 -35
  49. package/src/functions/functions/is-readonly-node.mts +25 -25
  50. package/src/functions/functions/wrap-with-parentheses.mts +138 -52
  51. package/src/functions/functions/wrap-with-parentheses.test.mts +115 -0
  52. package/src/functions/utils/replace-node-with-text.mts +10 -11
@@ -14,39 +14,6 @@ import {
14
14
  } from './convert-to-readonly.mjs';
15
15
  import { transformSourceCode } from './transform-source-code.mjs';
16
16
 
17
- const testFn = async ({
18
- source,
19
- expected,
20
- debug,
21
- options,
22
- isTsx = false,
23
- }: Readonly<{
24
- source: string;
25
- expected: string;
26
- debug?: boolean;
27
- options?: ReadonlyTransformerOptions;
28
- isTsx?: boolean;
29
- }>): Promise<void> => {
30
- if (debug !== true) {
31
- // eslint-disable-next-line vitest/no-restricted-vi-methods
32
- vi.spyOn(console, 'debug').mockImplementation(() => {});
33
-
34
- // eslint-disable-next-line vitest/no-restricted-vi-methods
35
- vi.spyOn(console, 'log').mockImplementation(() => {});
36
-
37
- // eslint-disable-next-line vitest/no-restricted-vi-methods
38
- vi.spyOn(console, 'trace').mockImplementation(() => {});
39
- }
40
-
41
- const transformedCodeRaw = await formatter(
42
- transformSourceCode(source, isTsx, [convertToReadonlyTransformer(options)]),
43
- );
44
-
45
- const expectedFormatted = await formatter(expected);
46
-
47
- expect(transformedCodeRaw).toBe(expectedFormatted);
48
- };
49
-
50
17
  /**
51
18
  * Normalizes whitespace in a code string, primarily for comparing AST structure outputs.
52
19
  * - Preserves newlines immediately following line comments (`//`).
@@ -93,6 +60,39 @@ const formatter = async (code: string): Promise<string> => {
93
60
  });
94
61
  };
95
62
 
63
+ const testFn = async ({
64
+ source,
65
+ expected,
66
+ debug,
67
+ options,
68
+ isTsx = false,
69
+ }: Readonly<{
70
+ source: string;
71
+ expected: string;
72
+ debug?: boolean;
73
+ options?: ReadonlyTransformerOptions;
74
+ isTsx?: boolean;
75
+ }>): Promise<void> => {
76
+ if (debug !== true) {
77
+ // eslint-disable-next-line vitest/no-restricted-vi-methods
78
+ vi.spyOn(console, 'debug').mockImplementation(() => {});
79
+
80
+ // eslint-disable-next-line vitest/no-restricted-vi-methods
81
+ vi.spyOn(console, 'log').mockImplementation(() => {});
82
+
83
+ // eslint-disable-next-line vitest/no-restricted-vi-methods
84
+ vi.spyOn(console, 'trace').mockImplementation(() => {});
85
+ }
86
+
87
+ const transformedCodeRaw = await formatter(
88
+ transformSourceCode(source, isTsx, [convertToReadonlyTransformer(options)]),
89
+ );
90
+
91
+ const expectedFormatted = await formatter(expected);
92
+
93
+ expect(transformedCodeRaw).toBe(expectedFormatted);
94
+ };
95
+
96
96
  describe(convertToReadonlyTransformer, () => {
97
97
  describe('TypeReferences', () => {
98
98
  describe('Sets', () => {
@@ -215,6 +215,13 @@ describe(convertToReadonlyTransformer, () => {
215
215
 
216
216
  describe('Records', () => {
217
217
  describe('recordStyle: "ReadonlyRecord" (default)', () => {
218
+ // These sources are scripts, which get `Readonly<Record<K, V>>` unless
219
+ // the name is in scope; how it is brought into scope is tested in
220
+ // convert-to-readonly-record-import.test.mts.
221
+ const options = {
222
+ importStyle: 'global',
223
+ } as const satisfies ReadonlyTransformerOptions;
224
+
218
225
  test.each([
219
226
  {
220
227
  name: 'Type alias',
@@ -224,6 +231,7 @@ describe(convertToReadonlyTransformer, () => {
224
231
  expected: dedent`
225
232
  type Foo = ReadonlyRecord<string, number>
226
233
  `,
234
+ options,
227
235
  },
228
236
  {
229
237
  name: 'ReadonlyRecord<K, V> is unchanged',
@@ -233,6 +241,7 @@ describe(convertToReadonlyTransformer, () => {
233
241
  expected: dedent`
234
242
  type Foo = ReadonlyRecord<string, number>
235
243
  `,
244
+ options,
236
245
  },
237
246
  {
238
247
  name: 'Readonly<Record<K, V>> is unified to ReadonlyRecord<K, V>',
@@ -242,6 +251,7 @@ describe(convertToReadonlyTransformer, () => {
242
251
  expected: dedent`
243
252
  type Foo = ReadonlyRecord<string, number>
244
253
  `,
254
+ options,
245
255
  },
246
256
  {
247
257
  name: 'Redundant Readonly<ReadonlyRecord<K, V>> is normalized',
@@ -251,6 +261,7 @@ describe(convertToReadonlyTransformer, () => {
251
261
  expected: dedent`
252
262
  type Foo = ReadonlyRecord<string, number>
253
263
  `,
264
+ options,
254
265
  },
255
266
  {
256
267
  name: 'Value type is converted recursively',
@@ -260,6 +271,7 @@ describe(convertToReadonlyTransformer, () => {
260
271
  expected: dedent`
261
272
  type Foo = ReadonlyRecord<string, readonly number[]>
262
273
  `,
274
+ options,
263
275
  },
264
276
  {
265
277
  name: 'Value type inside Readonly<Record<K, V>> is converted recursively',
@@ -269,6 +281,7 @@ describe(convertToReadonlyTransformer, () => {
269
281
  expected: dedent`
270
282
  type Foo = ReadonlyRecord<string, readonly number[]>
271
283
  `,
284
+ options,
272
285
  },
273
286
  {
274
287
  name: 'Value type inside ReadonlyRecord<K, V> is converted recursively',
@@ -278,6 +291,7 @@ describe(convertToReadonlyTransformer, () => {
278
291
  expected: dedent`
279
292
  type Foo = ReadonlyRecord<string, readonly number[]>
280
293
  `,
294
+ options,
281
295
  },
282
296
  {
283
297
  name: 'In type literal member',
@@ -287,6 +301,7 @@ describe(convertToReadonlyTransformer, () => {
287
301
  expected: dedent`
288
302
  type Foo = Readonly<{ a: ReadonlyRecord<string, number> }>
289
303
  `,
304
+ options,
290
305
  },
291
306
  {
292
307
  name: 'In function args',
@@ -296,6 +311,7 @@ describe(convertToReadonlyTransformer, () => {
296
311
  expected: dedent`
297
312
  function foo(a: ReadonlyRecord<string, number>, b: Promise<ReadonlyRecord<string, number>>) {}
298
313
  `,
314
+ options,
299
315
  },
300
316
  {
301
317
  name: 'DeepReadonly<Record<K, V>> is unchanged',
@@ -305,6 +321,7 @@ describe(convertToReadonlyTransformer, () => {
305
321
  expected: dedent`
306
322
  type Foo = DeepReadonly<Record<string, number[]>>
307
323
  `,
324
+ options,
308
325
  },
309
326
  {
310
327
  name: 'DeepReadonly<ReadonlyRecord<K, V>> is unchanged',
@@ -314,6 +331,7 @@ describe(convertToReadonlyTransformer, () => {
314
331
  expected: dedent`
315
332
  type Foo = DeepReadonly<ReadonlyRecord<string, number>>
316
333
  `,
334
+ options,
317
335
  },
318
336
  {
319
337
  name: 'Record<K, V> under indexed access is kept as is',
@@ -323,6 +341,7 @@ describe(convertToReadonlyTransformer, () => {
323
341
  expected: dedent`
324
342
  type Foo = Record<string, number>[string]
325
343
  `,
344
+ options,
326
345
  },
327
346
  {
328
347
  name: 'ReadonlyRecord<K, V> under indexed access is unchanged',
@@ -332,6 +351,7 @@ describe(convertToReadonlyTransformer, () => {
332
351
  expected: dedent`
333
352
  type Foo = ReadonlyRecord<string, number>[string]
334
353
  `,
354
+ options,
335
355
  },
336
356
  ])('$name', testFn);
337
357
  });
@@ -788,6 +808,33 @@ describe(convertToReadonlyTransformer, () => {
788
808
  function foo(a: readonly number[], b: Promise<readonly number[]>) {}
789
809
  `,
790
810
  },
811
+ {
812
+ name: 'Array<T> whose element type has a parenthesis inside a string literal type',
813
+ source: dedent`
814
+ type T = Array<(A | '(') | (B)>;
815
+ `,
816
+ expected: dedent`
817
+ type T = readonly ((A | '(') | (B))[];
818
+ `,
819
+ },
820
+ {
821
+ name: 'Array<T> whose element type has a parenthesis inside a comment',
822
+ source: dedent`
823
+ type T = Array<(A | /* ( */ C) | (B)>;
824
+ `,
825
+ expected: dedent`
826
+ type T = readonly ((A | /* ( */ C) | (B))[];
827
+ `,
828
+ },
829
+ {
830
+ name: 'Array<T> whose element type has a parenthesis inside a template literal type',
831
+ source: dedent`
832
+ type T = Array<(\`(\${A}\` | C) | (B)>;
833
+ `,
834
+ expected: dedent`
835
+ type T = readonly ((\`(\${A}\` | C) | (B))[];
836
+ `,
837
+ },
791
838
  {
792
839
  name: 'Mutable array types in function args (generic)',
793
840
  source: dedent`
@@ -9,15 +9,15 @@ const TRANSFORMER_NAME = 'enable-no-unchecked-indexed-access';
9
9
  /**
10
10
  * Appends `!` to the index accesses that turning on the
11
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.
12
+ * compiler option would turn into type errors, as a stopgap while the option is
13
+ * being enabled.
14
14
  *
15
15
  * The rewrite is driven by the type checker rather than by syntax: the file is
16
16
  * checked twice, once with `noUncheckedIndexedAccess` off and once with it on,
17
17
  * and `!` is appended only where the option is what added `undefined` to the
18
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
19
+ * guarantees keeps its access untouched — `[T, T][1]`, `[T, T, ...T[]][0]`, and
20
+ * `({ a: T })['a']` are all left as they are, as is an element type that
21
21
  * contained `undefined` to begin with.
22
22
  *
23
23
  * Both spellings of an index read are covered, because the option widens both:
@@ -31,10 +31,10 @@ const TRANSFORMER_NAME = 'enable-no-unchecked-indexed-access';
31
31
  * on its own — which is how {@link transformSourceCode} drives every
32
32
  * transformer here — resolves neither a type from another module nor a
33
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.
34
+ * assertions a whole-program check does not need. Most of those are reported by
35
+ * `@typescript-eslint/no-unnecessary-type-assertion`, whose fixer removes them;
36
+ * what no rule can see is an assertion that makes a later check dead, as the
37
+ * `if` of `const v = xs[0]!; if (v === undefined) …` now is.
38
38
  *
39
39
  * @example
40
40
  *
@@ -91,9 +91,9 @@ export const enableNoUncheckedIndexedAccessTransformer = (
91
91
  return;
92
92
  }
93
93
 
94
- // One text edit for the whole file: `insertText` forgets every
95
- // descendant node, so inserting one `!` at a time would reparse the
96
- // file once per assertion.
94
+ // One text edit for the whole file: `insertText` forgets every descendant
95
+ // node, so inserting one `!` at a time would reparse the file once per
96
+ // assertion.
97
97
  sourceAst.replaceText(
98
98
  [0, sourceAst.getEnd()],
99
99
  insertNonNullAssertions(sourceAst.getFullText(), positions),
@@ -134,102 +134,6 @@ type EnableNoUncheckedIndexedAccessTransformerOptionsInternal = DeepReadonly<{
134
134
  type IndexReadExpression =
135
135
  tsm.ElementAccessExpression | tsm.PropertyAccessExpression;
136
136
 
137
- /**
138
- * Returns the positions at which a `!` has to be inserted, i.e. the end
139
- * offsets of the reads whose type `noUncheckedIndexedAccess` widens with
140
- * `undefined`.
141
- */
142
- const collectAssertionPositions = (
143
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
144
- sourceAst: tsm.SourceFile,
145
- options: EnableNoUncheckedIndexedAccessTransformerOptionsInternal,
146
- ): readonly number[] => {
147
- const candidates: readonly IndexReadExpression[] = [
148
- ...sourceAst.getDescendantsOfKind(tsm.SyntaxKind.ElementAccessExpression),
149
- ...sourceAst.getDescendantsOfKind(tsm.SyntaxKind.PropertyAccessExpression),
150
- ].filter(
151
- (node) => isAssertableReadPosition(node) && !isIgnoredByComment(node),
152
- );
153
-
154
- if (Arr.isEmpty(candidates)) {
155
- return [];
156
- }
157
-
158
- const project = sourceAst.getProject();
159
-
160
- const originalOptions = project.getCompilerOptions();
161
-
162
- try {
163
- // With the option on first, because that narrows the candidates down to
164
- // the few reads worth checking a second time. `strictNullChecks` comes
165
- // with it: `noUncheckedIndexedAccess` does nothing without it, and the
166
- // project handed to the transformer may not have it on.
167
- project.compilerOptions.set({
168
- strictNullChecks: true,
169
- noUncheckedIndexedAccess: true,
170
- });
171
-
172
- // The type asked is the access's own, so an `as`, `satisfies` or `!` on
173
- // its object is already in it. That is the type the option's error is
174
- // reported against, and the `!` appended has no run-time effect for the
175
- // unwrapped object to contradict.
176
- const widened = candidates.filter(
177
- (node) =>
178
- typeIncludesUndefined(node.getType()) &&
179
- !(
180
- options.applyLevel === 'avoidWhereUndefinedIsAllowed' &&
181
- contextAcceptsUndefined(node)
182
- ),
183
- );
184
-
185
- if (Arr.isEmpty(widened)) {
186
- return [];
187
- }
188
-
189
- project.compilerOptions.set({ noUncheckedIndexedAccess: false });
190
-
191
- // What is possibly `undefined` without the option too is not the option's
192
- // doing — the element type says so, or the expression is erroneous (an
193
- // unresolved import resolves to `any`, which is never widened).
194
- return widened
195
- .filter((node) => !typeIncludesUndefined(node.getType()))
196
- .map((node) => {
197
- options.debugPrint(`${node.getText()} -> ${node.getText()}!`);
198
-
199
- return node.getEnd();
200
- });
201
- } finally {
202
- // `set` merges, so the two options overridden above have to be named
203
- // explicitly: they are absent from `originalOptions` when the project did
204
- // not carry them, and would otherwise stay overridden for whichever
205
- // transformer runs next.
206
- project.compilerOptions.set({
207
- ...originalOptions,
208
- strictNullChecks: originalOptions.strictNullChecks,
209
- noUncheckedIndexedAccess: originalOptions.noUncheckedIndexedAccess,
210
- });
211
- }
212
- };
213
-
214
- const insertNonNullAssertions = (
215
- fullText: string,
216
- positions: readonly number[],
217
- ): string => {
218
- const mut_parts: string[] = [];
219
-
220
- let mut_restStart: number = 0;
221
-
222
- for (const position of positions.toSorted((a, b) => a - b)) {
223
- mut_parts.push(fullText.slice(mut_restStart, position), '!');
224
-
225
- mut_restStart = position;
226
- }
227
-
228
- mut_parts.push(fullText.slice(mut_restStart));
229
-
230
- return mut_parts.join('');
231
- };
232
-
233
137
  const typeIncludesUndefined = (
234
138
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
235
139
  type: tsm.Type,
@@ -238,9 +142,9 @@ const typeIncludesUndefined = (
238
142
  (type.isUnion() && type.getUnionTypes().some((t) => t.isUndefined()));
239
143
 
240
144
  /**
241
- * Whether the position the access sits in already accepts `undefined`, in
242
- * which case `noUncheckedIndexedAccess` reports nothing there and the
243
- * assertion would only be noise.
145
+ * Whether the position the access sits in already accepts `undefined`, in which
146
+ * case `noUncheckedIndexedAccess` reports nothing there and the assertion would
147
+ * only be noise.
244
148
  */
245
149
  const contextAcceptsUndefined = (
246
150
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
@@ -276,38 +180,6 @@ const isIgnoredByComment = (
276
180
  );
277
181
  };
278
182
 
279
- /**
280
- * Whether a `!` appended to this access would be both syntactically valid and
281
- * meaningful.
282
- *
283
- * Note that most write positions need no rule of their own: TypeScript gives
284
- * the target of an assignment the element type without `undefined`, so the
285
- * type comparison in {@link collectAssertionPositions} already passes them
286
- * over. The exceptions handled here are the ones it does widen.
287
- *
288
- * The position is that of the outermost parenthesis around the access:
289
- * `(xs[0]) === undefined` tests the value exactly as `xs[0] === undefined`
290
- * does, while the `!` still goes right after the access, inside them.
291
- */
292
- const isAssertableReadPosition = (
293
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
294
- node: IndexReadExpression,
295
- ): boolean => {
296
- const outer = outermostParenthesized(node);
297
-
298
- const parent = outer.getParent();
299
-
300
- if (parent === undefined) {
301
- return false;
302
- }
303
-
304
- return (
305
- !isAlreadyAssertedOrGuarded(node, outer, parent) &&
306
- !isWriteTarget(outer, parent) &&
307
- !isTestedForAbsence(outer, parent)
308
- );
309
- };
310
-
311
183
  /** `node`, or the outermost of the parentheses written around it. */
312
184
  const outermostParenthesized = (node: tsm.Node): tsm.Node => {
313
185
  const parent = node.getParent();
@@ -317,12 +189,38 @@ const outermostParenthesized = (node: tsm.Node): tsm.Node => {
317
189
  : node;
318
190
  };
319
191
 
192
+ /**
193
+ * Whether the read sits in an optional chain that can short-circuit before it,
194
+ * as the `xs[0]?.toFixed` of `xs[0]?.toFixed()` does. `undefined` in such a
195
+ * type comes from the chain rather than from the index, and the chain already
196
+ * accounts for it; asserting there would assert away the author's own `?.`.
197
+ *
198
+ * Parentheses end a chain (`(a?.b).c` is not one), which is why the walk stops
199
+ * at anything that is not a chain link.
200
+ */
201
+ const isInOptionalChain = (node: tsm.Node): boolean => {
202
+ if (
203
+ node.isKind(tsm.SyntaxKind.PropertyAccessExpression) ||
204
+ node.isKind(tsm.SyntaxKind.ElementAccessExpression) ||
205
+ node.isKind(tsm.SyntaxKind.CallExpression)
206
+ ) {
207
+ return (
208
+ node.hasQuestionDotToken() || isInOptionalChain(node.getExpression())
209
+ );
210
+ }
211
+
212
+ return (
213
+ node.isKind(tsm.SyntaxKind.NonNullExpression) &&
214
+ isInOptionalChain(node.getExpression())
215
+ );
216
+ };
217
+
320
218
  /**
321
219
  * `xs[0]!`, `xs[0] as T`, `<T>xs[0]`, `xs[0]?.foo`, `xs[0]?.()`, `xs[0]?.[1]`.
322
220
  *
323
- * `outer` is `node` with its parentheses (see
324
- * {@link outermostParenthesized}); a chain continues from the access itself
325
- * but is guarded from the outside of the parentheses (`(xs[0])?.foo`).
221
+ * `outer` is `node` with its parentheses (see {@link outermostParenthesized});
222
+ * a chain continues from the access itself but is guarded from the outside of
223
+ * the parentheses (`(xs[0])?.foo`).
326
224
  */
327
225
  const isAlreadyAssertedOrGuarded = (
328
226
  node: tsm.Node,
@@ -341,8 +239,8 @@ const isAlreadyAssertedOrGuarded = (
341
239
  return true;
342
240
  }
343
241
 
344
- // Only the object of the optional chain is guarded; an index argument such
345
- // as the `xs[0]` of `m?.[xs[0]]` is not.
242
+ // Only the object of the optional chain is guarded; an index argument such as
243
+ // the `xs[0]` of `m?.[xs[0]]` is not.
346
244
  return (
347
245
  (parent.isKind(tsm.SyntaxKind.PropertyAccessExpression) ||
348
246
  parent.isKind(tsm.SyntaxKind.ElementAccessExpression) ||
@@ -353,36 +251,19 @@ const isAlreadyAssertedOrGuarded = (
353
251
  };
354
252
 
355
253
  /**
356
- * Whether the read sits in an optional chain that can short-circuit before it,
357
- * as the `xs[0]?.toFixed` of `xs[0]?.toFixed()` does. `undefined` in such a
358
- * type comes from the chain rather than from the index, and the chain already
359
- * accounts for it; asserting there would assert away the author's own `?.`.
360
- *
361
- * Parentheses end a chain (`(a?.b).c` is not one), which is why the walk stops
362
- * at anything that is not a chain link.
254
+ * `=` through `^=`, i.e. every assignment operator. TypeScript keeps them in
255
+ * one contiguous block of {@link tsm.SyntaxKind} and marks its ends, which is
256
+ * how the compiler itself recognizes them.
363
257
  */
364
- const isInOptionalChain = (node: tsm.Node): boolean => {
365
- if (
366
- node.isKind(tsm.SyntaxKind.PropertyAccessExpression) ||
367
- node.isKind(tsm.SyntaxKind.ElementAccessExpression) ||
368
- node.isKind(tsm.SyntaxKind.CallExpression)
369
- ) {
370
- return (
371
- node.hasQuestionDotToken() || isInOptionalChain(node.getExpression())
372
- );
373
- }
374
-
375
- return (
376
- node.isKind(tsm.SyntaxKind.NonNullExpression) &&
377
- isInOptionalChain(node.getExpression())
378
- );
379
- };
258
+ const isAssignmentOperatorKind = (kind: tsm.SyntaxKind): boolean =>
259
+ tsm.SyntaxKind.FirstAssignment <= kind &&
260
+ kind <= tsm.SyntaxKind.LastAssignment;
380
261
 
381
262
  /**
382
263
  * Positions that write the value rather than read it, where `!` cannot be
383
- * appended at all: an assignment target is not an expression (`xs[0]! = 1`
384
- * does not parse as an assignment), and `delete xs[0]!` and `xs[0]!++` are
385
- * equally invalid.
264
+ * appended at all: an assignment target is not an expression (`xs[0]! = 1` does
265
+ * not parse as an assignment), and `delete xs[0]!` and `xs[0]!++` are equally
266
+ * invalid.
386
267
  *
387
268
  * `xs[0] += 1` is the one case this leaves a type error behind: its left-hand
388
269
  * side is read as well as written, so `noUncheckedIndexedAccess` reports it,
@@ -465,13 +346,131 @@ const isTestedForAbsence = (node: tsm.Node, parent: tsm.Node): boolean => {
465
346
  };
466
347
 
467
348
  /**
468
- * `=` through `^=`, i.e. every assignment operator. TypeScript keeps them in
469
- * one contiguous block of {@link tsm.SyntaxKind} and marks its ends, which is
470
- * how the compiler itself recognizes them.
349
+ * Whether a `!` appended to this access would be both syntactically valid and
350
+ * meaningful.
351
+ *
352
+ * Note that most write positions need no rule of their own: TypeScript gives
353
+ * the target of an assignment the element type without `undefined`, so the type
354
+ * comparison in {@link collectAssertionPositions} already passes them over. The
355
+ * exceptions handled here are the ones it does widen.
356
+ *
357
+ * The position is that of the outermost parenthesis around the access:
358
+ * `(xs[0]) === undefined` tests the value exactly as `xs[0] === undefined`
359
+ * does, while the `!` still goes right after the access, inside them.
471
360
  */
472
- const isAssignmentOperatorKind = (kind: tsm.SyntaxKind): boolean =>
473
- tsm.SyntaxKind.FirstAssignment <= kind &&
474
- kind <= tsm.SyntaxKind.LastAssignment;
361
+ const isAssertableReadPosition = (
362
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
363
+ node: IndexReadExpression,
364
+ ): boolean => {
365
+ const outer = outermostParenthesized(node);
366
+
367
+ const parent = outer.getParent();
368
+
369
+ if (parent === undefined) {
370
+ return false;
371
+ }
372
+
373
+ return (
374
+ !isAlreadyAssertedOrGuarded(node, outer, parent) &&
375
+ !isWriteTarget(outer, parent) &&
376
+ !isTestedForAbsence(outer, parent)
377
+ );
378
+ };
379
+
380
+ /**
381
+ * Returns the positions at which a `!` has to be inserted, i.e. the end offsets
382
+ * of the reads whose type `noUncheckedIndexedAccess` widens with `undefined`.
383
+ */
384
+ const collectAssertionPositions = (
385
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
386
+ sourceAst: tsm.SourceFile,
387
+ options: EnableNoUncheckedIndexedAccessTransformerOptionsInternal,
388
+ ): readonly number[] => {
389
+ const candidates: readonly IndexReadExpression[] = [
390
+ ...sourceAst.getDescendantsOfKind(tsm.SyntaxKind.ElementAccessExpression),
391
+ ...sourceAst.getDescendantsOfKind(tsm.SyntaxKind.PropertyAccessExpression),
392
+ ].filter(
393
+ (node) => isAssertableReadPosition(node) && !isIgnoredByComment(node),
394
+ );
395
+
396
+ if (Arr.isEmpty(candidates)) {
397
+ return [];
398
+ }
399
+
400
+ const project = sourceAst.getProject();
401
+
402
+ const originalOptions = project.getCompilerOptions();
403
+
404
+ try {
405
+ // With the option on first, because that narrows the candidates down to the
406
+ // few reads worth checking a second time. `strictNullChecks` comes with it:
407
+ // `noUncheckedIndexedAccess` does nothing without it, and the project
408
+ // handed to the transformer may not have it on.
409
+ project.compilerOptions.set({
410
+ strictNullChecks: true,
411
+ noUncheckedIndexedAccess: true,
412
+ });
413
+
414
+ // The type asked is the access's own, so an `as`, `satisfies` or `!` on its
415
+ // object is already in it. That is the type the option's error is reported
416
+ // against, and the `!` appended has no run-time effect for the unwrapped
417
+ // object to contradict.
418
+ const widened = candidates.filter(
419
+ (node) =>
420
+ typeIncludesUndefined(node.getType()) &&
421
+ !(
422
+ options.applyLevel === 'avoidWhereUndefinedIsAllowed' &&
423
+ contextAcceptsUndefined(node)
424
+ ),
425
+ );
426
+
427
+ if (Arr.isEmpty(widened)) {
428
+ return [];
429
+ }
430
+
431
+ project.compilerOptions.set({ noUncheckedIndexedAccess: false });
432
+
433
+ // What is possibly `undefined` without the option too is not the option's
434
+ // doing — the element type says so, or the expression is erroneous (an
435
+ // unresolved import resolves to `any`, which is never widened).
436
+ return widened
437
+ .filter((node) => !typeIncludesUndefined(node.getType()))
438
+ .map((node) => {
439
+ options.debugPrint(`${node.getText()} -> ${node.getText()}!`);
440
+
441
+ return node.getEnd();
442
+ });
443
+ } finally {
444
+ // `set` merges, so the two options overridden above have to be named
445
+ // explicitly: they are absent from `originalOptions` when the project did
446
+ // not carry them, and would otherwise stay overridden for whichever
447
+ // transformer runs next.
448
+ project.compilerOptions.set({
449
+ ...originalOptions,
450
+ strictNullChecks: originalOptions.strictNullChecks,
451
+ noUncheckedIndexedAccess: originalOptions.noUncheckedIndexedAccess,
452
+ });
453
+ }
454
+ };
455
+
456
+ const insertNonNullAssertions = (
457
+ fullText: string,
458
+ positions: readonly number[],
459
+ ): string => {
460
+ const mut_parts: string[] = [];
461
+
462
+ let mut_restStart: number = 0;
463
+
464
+ for (const position of positions.toSorted((a, b) => a - b)) {
465
+ mut_parts.push(fullText.slice(mut_restStart, position), '!');
466
+
467
+ mut_restStart = position;
468
+ }
469
+
470
+ mut_parts.push(fullText.slice(mut_restStart));
471
+
472
+ return mut_parts.join('');
473
+ };
475
474
 
476
475
  const equalityOperatorKinds: readonly tsm.SyntaxKind[] = [
477
476
  tsm.SyntaxKind.EqualsEqualsToken,