ts-codemod-lib 4.0.0 → 4.0.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.
- package/dist/functions/ast-transformers/append-as-const.d.mts +9 -10
- package/dist/functions/ast-transformers/append-as-const.d.mts.map +1 -1
- package/dist/functions/ast-transformers/append-as-const.mjs +123 -114
- package/dist/functions/ast-transformers/append-as-const.mjs.map +1 -1
- package/dist/functions/ast-transformers/convert-to-readonly.d.mts +4 -4
- package/dist/functions/ast-transformers/convert-to-readonly.mjs +248 -252
- package/dist/functions/ast-transformers/convert-to-readonly.mjs.map +1 -1
- package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.d.mts +8 -8
- package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.mjs +103 -104
- package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.mjs.map +1 -1
- package/dist/functions/ast-transformers/replace-record-with-unknown-record.d.mts +2 -2
- package/dist/functions/ast-transformers/replace-record-with-unknown-record.mjs +51 -51
- package/dist/functions/ast-transformers/replace-record-with-unknown-record.mjs.map +1 -1
- package/dist/functions/ast-transformers/transform-source-code.d.mts.map +1 -1
- package/dist/functions/ast-transformers/transform-source-code.mjs +69 -69
- package/dist/functions/ast-transformers/transform-source-code.mjs.map +1 -1
- package/dist/functions/constants/mutable-prefixes.d.mts +8 -8
- package/dist/functions/functions/has-disable-next-line-comment.mjs +23 -23
- package/dist/functions/functions/has-disable-next-line-comment.mjs.map +1 -1
- package/dist/functions/functions/is-readonly-node.d.mts +2 -2
- package/dist/functions/functions/is-readonly-node.d.mts.map +1 -1
- package/dist/functions/functions/is-readonly-node.mjs +13 -13
- package/dist/functions/functions/is-readonly-node.mjs.map +1 -1
- package/dist/functions/functions/wrap-with-parentheses.d.mts +2 -2
- package/dist/functions/functions/wrap-with-parentheses.d.mts.map +1 -1
- package/dist/functions/functions/wrap-with-parentheses.mjs +87 -32
- package/dist/functions/functions/wrap-with-parentheses.mjs.map +1 -1
- package/dist/functions/utils/replace-node-with-text.d.mts +10 -11
- package/dist/functions/utils/replace-node-with-text.d.mts.map +1 -1
- package/dist/functions/utils/replace-node-with-text.mjs +0 -1
- package/dist/functions/utils/replace-node-with-text.mjs.map +1 -1
- package/package.json +6 -6
- package/src/functions/ast-transformers/append-as-const.mts +242 -231
- package/src/functions/ast-transformers/append-as-const.test.mts +39 -6
- package/src/functions/ast-transformers/convert-to-readonly.mts +467 -471
- package/src/functions/ast-transformers/convert-to-readonly.test.mts +60 -33
- package/src/functions/ast-transformers/enable-no-unchecked-indexed-access.mts +178 -179
- package/src/functions/ast-transformers/enable-no-unchecked-indexed-access.test.mts +29 -29
- package/src/functions/ast-transformers/replace-any-with-unknown.test.mts +6 -6
- package/src/functions/ast-transformers/replace-record-with-unknown-record.mts +75 -75
- package/src/functions/ast-transformers/replace-record-with-unknown-record.test.mts +6 -6
- package/src/functions/ast-transformers/transform-source-code.mts +110 -110
- package/src/functions/ast-transformers/transformer-specific-ignore.test.mts +6 -6
- package/src/functions/constants/mutable-prefixes.mts +8 -8
- package/src/functions/functions/has-disable-next-line-comment.mts +35 -35
- package/src/functions/functions/is-readonly-node.mts +25 -25
- package/src/functions/functions/wrap-with-parentheses.mts +138 -52
- package/src/functions/functions/wrap-with-parentheses.test.mts +115 -0
- 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', () => {
|
|
@@ -788,6 +788,33 @@ describe(convertToReadonlyTransformer, () => {
|
|
|
788
788
|
function foo(a: readonly number[], b: Promise<readonly number[]>) {}
|
|
789
789
|
`,
|
|
790
790
|
},
|
|
791
|
+
{
|
|
792
|
+
name: 'Array<T> whose element type has a parenthesis inside a string literal type',
|
|
793
|
+
source: dedent`
|
|
794
|
+
type T = Array<(A | '(') | (B)>;
|
|
795
|
+
`,
|
|
796
|
+
expected: dedent`
|
|
797
|
+
type T = readonly ((A | '(') | (B))[];
|
|
798
|
+
`,
|
|
799
|
+
},
|
|
800
|
+
{
|
|
801
|
+
name: 'Array<T> whose element type has a parenthesis inside a comment',
|
|
802
|
+
source: dedent`
|
|
803
|
+
type T = Array<(A | /* ( */ C) | (B)>;
|
|
804
|
+
`,
|
|
805
|
+
expected: dedent`
|
|
806
|
+
type T = readonly ((A | /* ( */ C) | (B))[];
|
|
807
|
+
`,
|
|
808
|
+
},
|
|
809
|
+
{
|
|
810
|
+
name: 'Array<T> whose element type has a parenthesis inside a template literal type',
|
|
811
|
+
source: dedent`
|
|
812
|
+
type T = Array<(\`(\${A}\` | C) | (B)>;
|
|
813
|
+
`,
|
|
814
|
+
expected: dedent`
|
|
815
|
+
type T = readonly ((\`(\${A}\` | C) | (B))[];
|
|
816
|
+
`,
|
|
817
|
+
},
|
|
791
818
|
{
|
|
792
819
|
name: 'Mutable array types in function args (generic)',
|
|
793
820
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
//
|
|
96
|
-
//
|
|
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
|
-
*
|
|
243
|
-
*
|
|
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
|
-
*
|
|
325
|
-
*
|
|
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
|
-
//
|
|
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
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
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
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
*
|
|
385
|
-
*
|
|
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
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
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
|
|
473
|
-
|
|
474
|
-
|
|
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,
|
|
@@ -34,6 +34,29 @@ const preamble = dedent`
|
|
|
34
34
|
declare const k: string;
|
|
35
35
|
`;
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Runs the transformer over `code` in {@link project}, and returns what it made
|
|
39
|
+
* of it.
|
|
40
|
+
*/
|
|
41
|
+
const transformInProject = (
|
|
42
|
+
code: string,
|
|
43
|
+
options?: Parameters<typeof enableNoUncheckedIndexedAccessTransformer>[0],
|
|
44
|
+
): string => {
|
|
45
|
+
const sourceAst = project.createSourceFile('source.ts', code, {
|
|
46
|
+
overwrite: true,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
enableNoUncheckedIndexedAccessTransformer(options).transform(sourceAst);
|
|
50
|
+
|
|
51
|
+
return sourceAst.getFullText();
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const formatter = (code: string): Promise<string> =>
|
|
55
|
+
prettier.format(code, {
|
|
56
|
+
parser: 'typescript',
|
|
57
|
+
plugins: [prettierPluginTypeScript, prettierPluginEstree],
|
|
58
|
+
});
|
|
59
|
+
|
|
37
60
|
const testFn = async ({
|
|
38
61
|
source,
|
|
39
62
|
expected,
|
|
@@ -59,23 +82,6 @@ const testFn = async ({
|
|
|
59
82
|
expect(transformed).toBe(expectedFormatted);
|
|
60
83
|
};
|
|
61
84
|
|
|
62
|
-
/**
|
|
63
|
-
* Runs the transformer over `code` in {@link project}, and returns what it
|
|
64
|
-
* made of it.
|
|
65
|
-
*/
|
|
66
|
-
const transformInProject = (
|
|
67
|
-
code: string,
|
|
68
|
-
options?: Parameters<typeof enableNoUncheckedIndexedAccessTransformer>[0],
|
|
69
|
-
): string => {
|
|
70
|
-
const sourceAst = project.createSourceFile('source.ts', code, {
|
|
71
|
-
overwrite: true,
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
enableNoUncheckedIndexedAccessTransformer(options).transform(sourceAst);
|
|
75
|
-
|
|
76
|
-
return sourceAst.getFullText();
|
|
77
|
-
};
|
|
78
|
-
|
|
79
85
|
/**
|
|
80
86
|
* The one project every case is checked in, rather than one project per case.
|
|
81
87
|
*
|
|
@@ -87,14 +93,14 @@ const transformInProject = (
|
|
|
87
93
|
* fraction of a millisecond. Sixty of those took `check:test:cov` past Vitest's
|
|
88
94
|
* timeout on CI.
|
|
89
95
|
*
|
|
90
|
-
* Reusing the project is half of the answer and the smaller half — measured,
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
96
|
+
* Reusing the project is half of the answer and the smaller half — measured, it
|
|
97
|
+
* takes a case from ~300 ms to ~260 ms, because what an edit invalidates is the
|
|
98
|
+
* program, not the parsed library behind it. Naming the libraries the cases
|
|
99
|
+
* actually need is the rest: together they bring a case to ~25 ms.
|
|
94
100
|
*
|
|
95
101
|
* What that leaves out — `transformSourceCode` itself, and the transformer
|
|
96
|
-
* against the full library — is what the tests at the bottom of this file
|
|
97
|
-
*
|
|
102
|
+
* against the full library — is what the tests at the bottom of this file still
|
|
103
|
+
* cover.
|
|
98
104
|
*/
|
|
99
105
|
const project = new tsm.Project({
|
|
100
106
|
useInMemoryFileSystem: true,
|
|
@@ -107,12 +113,6 @@ const project = new tsm.Project({
|
|
|
107
113
|
},
|
|
108
114
|
});
|
|
109
115
|
|
|
110
|
-
const formatter = (code: string): Promise<string> =>
|
|
111
|
-
prettier.format(code, {
|
|
112
|
-
parser: 'typescript',
|
|
113
|
-
plugins: [prettierPluginTypeScript, prettierPluginEstree],
|
|
114
|
-
});
|
|
115
|
-
|
|
116
116
|
describe(enableNoUncheckedIndexedAccessTransformer, () => {
|
|
117
117
|
describe('accesses the option widens with undefined', () => {
|
|
118
118
|
test.each([
|
|
@@ -7,6 +7,12 @@ import * as prettier from 'prettier/standalone';
|
|
|
7
7
|
import { replaceAnyWithUnknownTransformer } from './replace-any-with-unknown.mjs';
|
|
8
8
|
import { transformSourceCode } from './transform-source-code.mjs';
|
|
9
9
|
|
|
10
|
+
const formatter = (code: string): Promise<string> =>
|
|
11
|
+
prettier.format(code, {
|
|
12
|
+
parser: 'typescript',
|
|
13
|
+
plugins: [prettierPluginTypeScript, prettierPluginEstree],
|
|
14
|
+
});
|
|
15
|
+
|
|
10
16
|
const testFn = async ({
|
|
11
17
|
source,
|
|
12
18
|
expected,
|
|
@@ -33,12 +39,6 @@ const testFn = async ({
|
|
|
33
39
|
expect(transformed).toBe(expectedFormatted);
|
|
34
40
|
};
|
|
35
41
|
|
|
36
|
-
const formatter = (code: string): Promise<string> =>
|
|
37
|
-
prettier.format(code, {
|
|
38
|
-
parser: 'typescript',
|
|
39
|
-
plugins: [prettierPluginTypeScript, prettierPluginEstree],
|
|
40
|
-
});
|
|
41
|
-
|
|
42
42
|
describe(replaceAnyWithUnknownTransformer, () => {
|
|
43
43
|
test.each([
|
|
44
44
|
{
|