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
|
@@ -12,8 +12,8 @@ const UNKNOWN_RECORD_MODULE = 'ts-type-forge';
|
|
|
12
12
|
const UNKNOWN_RECORD_NAME = 'UnknownRecord';
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* Replaces `Readonly<Record<string, unknown>>`, `Record<string, unknown>`,
|
|
16
|
-
*
|
|
15
|
+
* Replaces `Readonly<Record<string, unknown>>`, `Record<string, unknown>`, and
|
|
16
|
+
* index signatures `[k: string]: unknown` with `UnknownRecord`.
|
|
17
17
|
*
|
|
18
18
|
* When a replacement occurs, also inserts
|
|
19
19
|
* `import { type UnknownRecord } from 'ts-type-forge';` at the top of the
|
|
@@ -35,77 +35,14 @@ export const replaceRecordWithUnknownRecordTransformer =
|
|
|
35
35
|
},
|
|
36
36
|
}) as const;
|
|
37
37
|
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
): boolean => {
|
|
41
|
-
const typeAliasModifications = container.getTypeAliases().map((typeAlias) => {
|
|
42
|
-
if (hasDisableNextLineComment(typeAlias, TRANSFORMER_NAME)) {
|
|
43
|
-
return false;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const typeNode = typeAlias.getTypeNode();
|
|
47
|
-
|
|
48
|
-
return typeNode !== undefined && visitTypeNode(typeNode);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
const interfaceModifications = container
|
|
52
|
-
.getInterfaces()
|
|
53
|
-
.map(processInterfaceDeclaration);
|
|
54
|
-
|
|
55
|
-
return [...typeAliasModifications, ...interfaceModifications].includes(true);
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
const processInterfaceDeclaration = (
|
|
59
|
-
interfaceDecl: tsm.InterfaceDeclaration,
|
|
38
|
+
const isStringUnknownIndexSignature = (
|
|
39
|
+
sig: tsm.IndexSignatureDeclaration,
|
|
60
40
|
): boolean => {
|
|
61
|
-
|
|
62
|
-
return false;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Check if interface has index signature [k: string]: unknown
|
|
66
|
-
const indexSignatures = interfaceDecl.getIndexSignatures();
|
|
67
|
-
|
|
68
|
-
const hasStringUnknownSignature = indexSignatures.some((sig) => {
|
|
69
|
-
const keyType = sig.getKeyType();
|
|
70
|
-
|
|
71
|
-
const returnType = sig.getReturnType();
|
|
72
|
-
|
|
73
|
-
return keyType.getText() === 'string' && returnType.getText() === 'unknown';
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
// If it has the signature and no other members, replace entire interface with type alias.
|
|
77
|
-
// Skip when the interface carries semantics that a plain type alias would lose
|
|
78
|
-
// (type parameters, extends clauses, declare modifier, default export).
|
|
79
|
-
if (
|
|
80
|
-
hasStringUnknownSignature &&
|
|
81
|
-
Arr.isEmpty(interfaceDecl.getProperties()) &&
|
|
82
|
-
Arr.isFixedLengthArray(1, indexSignatures) &&
|
|
83
|
-
Arr.isEmpty(interfaceDecl.getTypeParameters()) &&
|
|
84
|
-
Arr.isEmpty(interfaceDecl.getExtends()) &&
|
|
85
|
-
!interfaceDecl.hasDeclareKeyword() &&
|
|
86
|
-
!interfaceDecl.hasDefaultKeyword()
|
|
87
|
-
) {
|
|
88
|
-
const exportPrefix = interfaceDecl.hasExportKeyword() ? 'export ' : '';
|
|
89
|
-
|
|
90
|
-
const interfaceName = interfaceDecl.getName();
|
|
91
|
-
|
|
92
|
-
replaceNodeWithText(
|
|
93
|
-
interfaceDecl,
|
|
94
|
-
`${exportPrefix}type ${interfaceName} = UnknownRecord;`,
|
|
95
|
-
);
|
|
96
|
-
|
|
97
|
-
return true;
|
|
98
|
-
}
|
|
41
|
+
const keyType = sig.getKeyTypeNode();
|
|
99
42
|
|
|
100
|
-
|
|
101
|
-
return interfaceDecl
|
|
102
|
-
.getProperties()
|
|
103
|
-
.map((property) => {
|
|
104
|
-
const typeNode = property.getTypeNode();
|
|
43
|
+
const returnType = sig.getReturnTypeNode();
|
|
105
44
|
|
|
106
|
-
|
|
107
|
-
})
|
|
108
|
-
.includes(true);
|
|
45
|
+
return keyType.getText() === 'string' && returnType?.getText() === 'unknown';
|
|
109
46
|
};
|
|
110
47
|
|
|
111
48
|
const visitTypeNode = (node: tsm.TypeNode): boolean => {
|
|
@@ -208,14 +145,77 @@ const visitTypeNode = (node: tsm.TypeNode): boolean => {
|
|
|
208
145
|
);
|
|
209
146
|
};
|
|
210
147
|
|
|
211
|
-
const
|
|
212
|
-
|
|
148
|
+
const processInterfaceDeclaration = (
|
|
149
|
+
interfaceDecl: tsm.InterfaceDeclaration,
|
|
213
150
|
): boolean => {
|
|
214
|
-
|
|
151
|
+
if (hasDisableNextLineComment(interfaceDecl, TRANSFORMER_NAME)) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
215
154
|
|
|
216
|
-
|
|
155
|
+
// Check if interface has index signature [k: string]: unknown
|
|
156
|
+
const indexSignatures = interfaceDecl.getIndexSignatures();
|
|
217
157
|
|
|
218
|
-
|
|
158
|
+
const hasStringUnknownSignature = indexSignatures.some((sig) => {
|
|
159
|
+
const keyType = sig.getKeyType();
|
|
160
|
+
|
|
161
|
+
const returnType = sig.getReturnType();
|
|
162
|
+
|
|
163
|
+
return keyType.getText() === 'string' && returnType.getText() === 'unknown';
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// If it has the signature and no other members, replace entire interface with type alias.
|
|
167
|
+
// Skip when the interface carries semantics that a plain type alias would lose
|
|
168
|
+
// (type parameters, extends clauses, declare modifier, default export).
|
|
169
|
+
if (
|
|
170
|
+
hasStringUnknownSignature &&
|
|
171
|
+
Arr.isEmpty(interfaceDecl.getProperties()) &&
|
|
172
|
+
Arr.isFixedLengthArray(1, indexSignatures) &&
|
|
173
|
+
Arr.isEmpty(interfaceDecl.getTypeParameters()) &&
|
|
174
|
+
Arr.isEmpty(interfaceDecl.getExtends()) &&
|
|
175
|
+
!interfaceDecl.hasDeclareKeyword() &&
|
|
176
|
+
!interfaceDecl.hasDefaultKeyword()
|
|
177
|
+
) {
|
|
178
|
+
const exportPrefix = interfaceDecl.hasExportKeyword() ? 'export ' : '';
|
|
179
|
+
|
|
180
|
+
const interfaceName = interfaceDecl.getName();
|
|
181
|
+
|
|
182
|
+
replaceNodeWithText(
|
|
183
|
+
interfaceDecl,
|
|
184
|
+
`${exportPrefix}type ${interfaceName} = UnknownRecord;`,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Otherwise, check properties for Record types
|
|
191
|
+
return interfaceDecl
|
|
192
|
+
.getProperties()
|
|
193
|
+
.map((property) => {
|
|
194
|
+
const typeNode = property.getTypeNode();
|
|
195
|
+
|
|
196
|
+
return typeNode !== undefined && visitTypeNode(typeNode);
|
|
197
|
+
})
|
|
198
|
+
.includes(true);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const processDeclarations = (
|
|
202
|
+
container: tsm.SourceFile | tsm.ModuleDeclaration,
|
|
203
|
+
): boolean => {
|
|
204
|
+
const typeAliasModifications = container.getTypeAliases().map((typeAlias) => {
|
|
205
|
+
if (hasDisableNextLineComment(typeAlias, TRANSFORMER_NAME)) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const typeNode = typeAlias.getTypeNode();
|
|
210
|
+
|
|
211
|
+
return typeNode !== undefined && visitTypeNode(typeNode);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const interfaceModifications = container
|
|
215
|
+
.getInterfaces()
|
|
216
|
+
.map(processInterfaceDeclaration);
|
|
217
|
+
|
|
218
|
+
return [...typeAliasModifications, ...interfaceModifications].includes(true);
|
|
219
219
|
};
|
|
220
220
|
|
|
221
221
|
const replaceIfRecordUnknown = (node: tsm.TypeReferenceNode): boolean => {
|
|
@@ -7,6 +7,12 @@ import * as prettier from 'prettier/standalone';
|
|
|
7
7
|
import { replaceRecordWithUnknownRecordTransformer } from './replace-record-with-unknown-record.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,
|
|
@@ -25,12 +31,6 @@ const testFn = async ({
|
|
|
25
31
|
expect(transformed).toBe(expectedFormatted);
|
|
26
32
|
};
|
|
27
33
|
|
|
28
|
-
const formatter = (code: string): Promise<string> =>
|
|
29
|
-
prettier.format(code, {
|
|
30
|
-
parser: 'typescript',
|
|
31
|
-
plugins: [prettierPluginTypeScript, prettierPluginEstree],
|
|
32
|
-
});
|
|
33
|
-
|
|
34
34
|
describe(replaceRecordWithUnknownRecordTransformer, () => {
|
|
35
35
|
test.each([
|
|
36
36
|
{
|
|
@@ -9,94 +9,47 @@ const fileIgnorePrefixes = [
|
|
|
9
9
|
'transform-ignore',
|
|
10
10
|
] as const;
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
* Finds the file-level ignore comment `/* <prefix> name, name *\/` and returns
|
|
14
|
-
* the transformer names it lists (empty for all transformers), or `undefined`
|
|
15
|
-
* when the file has none. Whitespace, line breaks included, may surround the
|
|
16
|
-
* prefix and the names, but the names themselves must stay on one line. The
|
|
17
|
-
* prefixes are tried in order, each against the whole file.
|
|
18
|
-
*
|
|
19
|
-
* The scan runs in time linear in the length of `code`, whatever it contains.
|
|
20
|
-
*/
|
|
21
|
-
const findFileIgnoreTransformers = (
|
|
12
|
+
export const transformSourceCode = (
|
|
22
13
|
code: string,
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
14
|
+
isTsx: boolean,
|
|
15
|
+
transformers: readonly TsMorphTransformer[],
|
|
16
|
+
debug: boolean = false,
|
|
17
|
+
): string => {
|
|
18
|
+
const project = new tsm.Project({
|
|
19
|
+
useInMemoryFileSystem: true,
|
|
20
|
+
compilerOptions: {
|
|
21
|
+
jsx: isTsx ? tsm.ts.JsxEmit.React : undefined,
|
|
22
|
+
target: tsm.ts.ScriptTarget.ESNext,
|
|
23
|
+
module: tsm.ts.ModuleKind.ESNext,
|
|
24
|
+
},
|
|
25
|
+
});
|
|
34
26
|
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
): readonly string[] | undefined => {
|
|
39
|
-
// Queries into these searches only move forward, so each part of `code` is
|
|
40
|
-
// scanned a bounded number of times.
|
|
41
|
-
const findCommentEnd = createForwardSearch((from) =>
|
|
42
|
-
code.indexOf('*/', from),
|
|
27
|
+
const sourceAst = project.createSourceFile(
|
|
28
|
+
`source.${isTsx ? 'tsx' : 'ts'}`,
|
|
29
|
+
code,
|
|
43
30
|
);
|
|
44
31
|
|
|
45
|
-
const
|
|
46
|
-
indexOfLineTerminator(code, from),
|
|
47
|
-
);
|
|
32
|
+
const ignoredTransformers = findFileIgnoreTransformers(code);
|
|
48
33
|
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
);
|
|
34
|
+
for (const transformer of transformers) {
|
|
35
|
+
const transformerName = transformer.name;
|
|
52
36
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
37
|
+
if (shouldSkipFile(ignoredTransformers, transformerName)) {
|
|
38
|
+
if (debug) {
|
|
39
|
+
console.debug(
|
|
40
|
+
`skipped by ignore-file comment for transformer: ${transformerName}`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
59
43
|
|
|
60
|
-
if (!code.startsWith(prefix, prefixStart)) {
|
|
61
44
|
continue;
|
|
62
45
|
}
|
|
63
46
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const commentEnd = findCommentEnd(namesStart);
|
|
67
|
-
|
|
68
|
-
// Nothing after this point is closed, so no later comment can match.
|
|
69
|
-
if (commentEnd === -1) {
|
|
70
|
-
return undefined;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const namesEnd = Math.max(namesStart, trimEndBefore(commentEnd));
|
|
74
|
-
|
|
75
|
-
const lineTerminator = findLineTerminator(namesStart);
|
|
76
|
-
|
|
77
|
-
if (lineTerminator === -1 || lineTerminator >= namesEnd) {
|
|
78
|
-
const names = code.slice(namesStart, namesEnd);
|
|
79
|
-
|
|
80
|
-
// Empty means ignore all transformers; otherwise parse comma-separated
|
|
81
|
-
// transformer names
|
|
82
|
-
return names === '' ? [] : names.split(',').map((name) => name.trim());
|
|
83
|
-
}
|
|
47
|
+
transformer.transform(sourceAst);
|
|
84
48
|
}
|
|
85
49
|
|
|
86
|
-
return
|
|
50
|
+
return sourceAst.getFullText();
|
|
87
51
|
};
|
|
88
52
|
|
|
89
|
-
const shouldSkipFile = (
|
|
90
|
-
ignoredTransformers: readonly string[] | undefined,
|
|
91
|
-
transformerName: string,
|
|
92
|
-
): boolean =>
|
|
93
|
-
ignoredTransformers !== undefined &&
|
|
94
|
-
// Empty array means ignore all transformers (file-level ignore without
|
|
95
|
-
// specific transformers); otherwise check if the transformer is in the
|
|
96
|
-
// ignore list
|
|
97
|
-
(Arr.isEmpty(ignoredTransformers) ||
|
|
98
|
-
ignoredTransformers.includes(transformerName));
|
|
99
|
-
|
|
100
53
|
/**
|
|
101
54
|
* Wraps a search returning the first index at or after `from` (or -1), reusing
|
|
102
55
|
* the previous answer while it still holds for a later `from`.
|
|
@@ -137,6 +90,10 @@ const createLastValueCache = (
|
|
|
137
90
|
};
|
|
138
91
|
};
|
|
139
92
|
|
|
93
|
+
// Same set as `\s` and `String.prototype.trim`
|
|
94
|
+
const isWhitespace = (char: string | undefined): boolean =>
|
|
95
|
+
char !== undefined && /^\s$/u.test(char);
|
|
96
|
+
|
|
140
97
|
const skipWhitespace = (code: string, from: number): number => {
|
|
141
98
|
let mut_index = from;
|
|
142
99
|
|
|
@@ -157,6 +114,10 @@ const skipWhitespaceBackward = (code: string, end: number): number => {
|
|
|
157
114
|
return mut_index;
|
|
158
115
|
};
|
|
159
116
|
|
|
117
|
+
// The characters `.` does not match
|
|
118
|
+
const isLineTerminator = (char: string | undefined): boolean =>
|
|
119
|
+
char !== undefined && /^[\n\r\u{2028}\u{2029}]$/u.test(char);
|
|
120
|
+
|
|
160
121
|
const indexOfLineTerminator = (code: string, from: number): number => {
|
|
161
122
|
let mut_index = from;
|
|
162
123
|
|
|
@@ -167,51 +128,90 @@ const indexOfLineTerminator = (code: string, from: number): number => {
|
|
|
167
128
|
return mut_index < code.length ? mut_index : -1;
|
|
168
129
|
};
|
|
169
130
|
|
|
170
|
-
|
|
171
|
-
const isWhitespace = (char: string | undefined): boolean =>
|
|
172
|
-
char !== undefined && /^\s$/u.test(char);
|
|
173
|
-
|
|
174
|
-
// The characters `.` does not match
|
|
175
|
-
const isLineTerminator = (char: string | undefined): boolean =>
|
|
176
|
-
char !== undefined && /^[\n\r\u{2028}\u{2029}]$/u.test(char);
|
|
177
|
-
|
|
178
|
-
export const transformSourceCode = (
|
|
131
|
+
const findFileIgnoreComment = (
|
|
179
132
|
code: string,
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
compilerOptions: {
|
|
187
|
-
jsx: isTsx ? tsm.ts.JsxEmit.React : undefined,
|
|
188
|
-
target: tsm.ts.ScriptTarget.ESNext,
|
|
189
|
-
module: tsm.ts.ModuleKind.ESNext,
|
|
190
|
-
},
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
const sourceAst = project.createSourceFile(
|
|
194
|
-
`source.${isTsx ? 'tsx' : 'ts'}`,
|
|
195
|
-
code,
|
|
133
|
+
prefix: string,
|
|
134
|
+
): readonly string[] | undefined => {
|
|
135
|
+
// Queries into these searches only move forward, so each part of `code` is
|
|
136
|
+
// scanned a bounded number of times.
|
|
137
|
+
const findCommentEnd = createForwardSearch((from) =>
|
|
138
|
+
code.indexOf('*/', from),
|
|
196
139
|
);
|
|
197
140
|
|
|
198
|
-
const
|
|
141
|
+
const findLineTerminator = createForwardSearch((from) =>
|
|
142
|
+
indexOfLineTerminator(code, from),
|
|
143
|
+
);
|
|
199
144
|
|
|
200
|
-
|
|
201
|
-
|
|
145
|
+
const trimEndBefore = createLastValueCache((commentEnd: number) =>
|
|
146
|
+
skipWhitespaceBackward(code, commentEnd),
|
|
147
|
+
);
|
|
202
148
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
149
|
+
for (
|
|
150
|
+
let mut_open = code.indexOf('/*');
|
|
151
|
+
mut_open !== -1;
|
|
152
|
+
mut_open = code.indexOf('/*', mut_open + 2)
|
|
153
|
+
) {
|
|
154
|
+
const prefixStart = skipWhitespace(code, mut_open + 2);
|
|
209
155
|
|
|
156
|
+
if (!code.startsWith(prefix, prefixStart)) {
|
|
210
157
|
continue;
|
|
211
158
|
}
|
|
212
159
|
|
|
213
|
-
|
|
160
|
+
const namesStart = skipWhitespace(code, prefixStart + prefix.length);
|
|
161
|
+
|
|
162
|
+
const commentEnd = findCommentEnd(namesStart);
|
|
163
|
+
|
|
164
|
+
// Nothing after this point is closed, so no later comment can match.
|
|
165
|
+
if (commentEnd === -1) {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const namesEnd = Math.max(namesStart, trimEndBefore(commentEnd));
|
|
170
|
+
|
|
171
|
+
const lineTerminator = findLineTerminator(namesStart);
|
|
172
|
+
|
|
173
|
+
if (lineTerminator === -1 || lineTerminator >= namesEnd) {
|
|
174
|
+
const names = code.slice(namesStart, namesEnd);
|
|
175
|
+
|
|
176
|
+
// Empty means ignore all transformers; otherwise parse comma-separated
|
|
177
|
+
// transformer names
|
|
178
|
+
return names === '' ? [] : names.split(',').map((name) => name.trim());
|
|
179
|
+
}
|
|
214
180
|
}
|
|
215
181
|
|
|
216
|
-
return
|
|
182
|
+
return undefined;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Finds the file-level ignore comment `/* <prefix> name, name *\/` and returns
|
|
187
|
+
* the transformer names it lists (empty for all transformers), or `undefined`
|
|
188
|
+
* when the file has none. Whitespace, line breaks included, may surround the
|
|
189
|
+
* prefix and the names, but the names themselves must stay on one line. The
|
|
190
|
+
* prefixes are tried in order, each against the whole file.
|
|
191
|
+
*
|
|
192
|
+
* The scan runs in time linear in the length of `code`, whatever it contains.
|
|
193
|
+
*/
|
|
194
|
+
const findFileIgnoreTransformers = (
|
|
195
|
+
code: string,
|
|
196
|
+
): readonly string[] | undefined => {
|
|
197
|
+
for (const prefix of fileIgnorePrefixes) {
|
|
198
|
+
const names = findFileIgnoreComment(code, prefix);
|
|
199
|
+
|
|
200
|
+
if (names !== undefined) {
|
|
201
|
+
return names;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return undefined;
|
|
217
206
|
};
|
|
207
|
+
|
|
208
|
+
const shouldSkipFile = (
|
|
209
|
+
ignoredTransformers: readonly string[] | undefined,
|
|
210
|
+
transformerName: string,
|
|
211
|
+
): boolean =>
|
|
212
|
+
ignoredTransformers !== undefined &&
|
|
213
|
+
// Empty array means ignore all transformers (file-level ignore without
|
|
214
|
+
// specific transformers); otherwise check if the transformer is in the ignore
|
|
215
|
+
// list
|
|
216
|
+
(Arr.isEmpty(ignoredTransformers) ||
|
|
217
|
+
ignoredTransformers.includes(transformerName));
|
|
@@ -9,6 +9,12 @@ import { convertToReadonlyTransformer } from './convert-to-readonly.mjs';
|
|
|
9
9
|
import { replaceAnyWithUnknownTransformer } from './replace-any-with-unknown.mjs';
|
|
10
10
|
import { transformSourceCode } from './transform-source-code.mjs';
|
|
11
11
|
|
|
12
|
+
const formatter = (code: string): Promise<string> =>
|
|
13
|
+
prettier.format(code, {
|
|
14
|
+
parser: 'typescript',
|
|
15
|
+
plugins: [prettierPluginTypeScript, prettierPluginEstree],
|
|
16
|
+
});
|
|
17
|
+
|
|
12
18
|
const testFn = async ({
|
|
13
19
|
source,
|
|
14
20
|
expected,
|
|
@@ -39,12 +45,6 @@ const testFn = async ({
|
|
|
39
45
|
expect(transformed).toBe(expectedFormatted);
|
|
40
46
|
};
|
|
41
47
|
|
|
42
|
-
const formatter = (code: string): Promise<string> =>
|
|
43
|
-
prettier.format(code, {
|
|
44
|
-
parser: 'typescript',
|
|
45
|
-
plugins: [prettierPluginTypeScript, prettierPluginEstree],
|
|
46
|
-
});
|
|
47
|
-
|
|
48
48
|
describe('Transformer-specific ignore comments (Integration)', () => {
|
|
49
49
|
test.each([
|
|
50
50
|
{
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The default `mutableVariablePrefixes` and `mutablePropertyPrefixes` of the
|
|
3
|
-
* transformers: a name that starts with one of these is left mutable. A
|
|
4
|
-
*
|
|
3
|
+
* transformers: a name that starts with one of these is left mutable. A private
|
|
4
|
+
* name (`#mut_x`) is matched with or after its `#`.
|
|
5
5
|
*
|
|
6
|
-
* The ESLint counterpart is `
|
|
7
|
-
* `libs/eslint-config-typed/src/
|
|
8
|
-
*
|
|
6
|
+
* The ESLint counterpart is `mutablePrefix` in
|
|
7
|
+
* `libs/eslint-config-typed/src/constants/mutable-prefix.mts`, from which
|
|
8
|
+
* `eslint-functional-rules.mts` there builds its name and property patterns;
|
|
9
9
|
* `libs/eslint-config-typed/src/rules/eslint-functional-rules.test.mts` fails
|
|
10
10
|
* when the two differ; it reads this package's `dist/`, so build first.
|
|
11
11
|
*/
|
|
@@ -15,9 +15,9 @@ export const DEFAULT_MUTABLE_PREFIXES: readonly string[] = Object.freeze([
|
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* The default `mutableTypePrefixes` of the transformers: a type alias, an
|
|
18
|
-
* interface or a class whose name starts with one of these is left mutable,
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* interface or a class whose name starts with one of these is left mutable, and
|
|
19
|
+
* `append-as-const` adds no `as const` where the value has to be assignable to
|
|
20
|
+
* it (an annotated variable, `satisfies`, an arrow's return).
|
|
21
21
|
* `Mutable` suits the PascalCase of type names; `mut_` is accepted as for
|
|
22
22
|
* variables. No ESLint rule reads type names, so there is no counterpart to
|
|
23
23
|
* keep in step.
|
|
@@ -84,9 +84,9 @@ export const hasDisableNextLineComment = (
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
// If we found *any* comment on the preceding line, but it wasn't
|
|
88
|
-
//
|
|
89
|
-
//
|
|
87
|
+
// If we found *any* comment on the preceding line, but it wasn't the
|
|
88
|
+
// correct ignore comment, then the node is not ignored by a comment on
|
|
89
|
+
// the *immediately* preceding line. Stop checking further back.
|
|
90
90
|
return false;
|
|
91
91
|
}
|
|
92
92
|
|
|
@@ -99,38 +99,6 @@ export const hasDisableNextLineComment = (
|
|
|
99
99
|
return false;
|
|
100
100
|
};
|
|
101
101
|
|
|
102
|
-
/**
|
|
103
|
-
* The 1-based line number of a position in `sourceFile`, counted the way
|
|
104
|
-
* ts-morph's `getStartLineNumber` and `getLineAndColumnAtPos` count it: one
|
|
105
|
-
* plus the number of `\n` before the position (a lone `\r` starts no line,
|
|
106
|
-
* unlike in the compiler's own line map).
|
|
107
|
-
*
|
|
108
|
-
* ts-morph scans the text from its start on every call, which made this
|
|
109
|
-
* check, asked of every node a transformer visits, quadratic in the size of
|
|
110
|
-
* the file. The newline positions are collected once per compiler source file
|
|
111
|
-
* instead. ts-morph replaces that object whenever the file is edited, so an
|
|
112
|
-
* entry never outlives the text it was collected from.
|
|
113
|
-
*/
|
|
114
|
-
const lineNumberLookup = (
|
|
115
|
-
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
116
|
-
sourceFile: tsm.ts.SourceFile,
|
|
117
|
-
): ((pos: number) => number) => {
|
|
118
|
-
const cached = newlinePositionsCache.get(sourceFile);
|
|
119
|
-
|
|
120
|
-
const newlinePositions = cached ?? newlinePositionsOf(sourceFile.text);
|
|
121
|
-
|
|
122
|
-
if (cached === undefined) {
|
|
123
|
-
newlinePositionsCache.set(sourceFile, newlinePositions);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
return (pos) => countLessThan(newlinePositions, pos) + 1;
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
const newlinePositionsCache = new WeakMap<
|
|
130
|
-
tsm.ts.SourceFile,
|
|
131
|
-
readonly number[]
|
|
132
|
-
>();
|
|
133
|
-
|
|
134
102
|
const newlinePositionsOf = (text: string): readonly number[] => {
|
|
135
103
|
const mut_positions: number[] = [];
|
|
136
104
|
|
|
@@ -164,3 +132,35 @@ const countLessThan = (sorted: readonly number[], value: number): number => {
|
|
|
164
132
|
|
|
165
133
|
return mut_low;
|
|
166
134
|
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The 1-based line number of a position in `sourceFile`, counted the way
|
|
138
|
+
* ts-morph's `getStartLineNumber` and `getLineAndColumnAtPos` count it: one
|
|
139
|
+
* plus the number of `\n` before the position (a lone `\r` starts no line,
|
|
140
|
+
* unlike in the compiler's own line map).
|
|
141
|
+
*
|
|
142
|
+
* ts-morph scans the text from its start on every call, which made this
|
|
143
|
+
* check, asked of every node a transformer visits, quadratic in the size of the
|
|
144
|
+
* file. The newline positions are collected once per compiler source file
|
|
145
|
+
* instead. ts-morph replaces that object whenever the file is edited, so an
|
|
146
|
+
* entry never outlives the text it was collected from.
|
|
147
|
+
*/
|
|
148
|
+
const lineNumberLookup = (
|
|
149
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
150
|
+
sourceFile: tsm.ts.SourceFile,
|
|
151
|
+
): ((pos: number) => number) => {
|
|
152
|
+
const cached = newlinePositionsCache.get(sourceFile);
|
|
153
|
+
|
|
154
|
+
const newlinePositions = cached ?? newlinePositionsOf(sourceFile.text);
|
|
155
|
+
|
|
156
|
+
if (cached === undefined) {
|
|
157
|
+
newlinePositionsCache.set(sourceFile, newlinePositions);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return (pos) => countLessThan(newlinePositions, pos) + 1;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const newlinePositionsCache = new WeakMap<
|
|
164
|
+
tsm.ts.SourceFile,
|
|
165
|
+
readonly number[]
|
|
166
|
+
>();
|
|
@@ -30,6 +30,31 @@ export const isReadonlyArrayTypeNode = (
|
|
|
30
30
|
node.getOperator() === tsm.SyntaxKind.ReadonlyKeyword &&
|
|
31
31
|
node.getTypeNode().isKind(tsm.SyntaxKind.ArrayType);
|
|
32
32
|
|
|
33
|
+
export const isReadonlyTupleTypeNode = (
|
|
34
|
+
node: DeepReadonly<tsm.Node>,
|
|
35
|
+
): node is ReadonlyTupleTypeNode =>
|
|
36
|
+
node.isKind(tsm.SyntaxKind.TypeOperator) &&
|
|
37
|
+
node.getOperator() === tsm.SyntaxKind.ReadonlyKeyword &&
|
|
38
|
+
node.getTypeNode().isKind(tsm.SyntaxKind.TupleType);
|
|
39
|
+
|
|
40
|
+
export const isReadonlyTypeReferenceNode = (
|
|
41
|
+
node: DeepReadonly<tsm.Node>,
|
|
42
|
+
): node is ReadonlyTypeReferenceNode => {
|
|
43
|
+
if (!node.isKind(tsm.SyntaxKind.TypeReference)) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const typeName = node.getTypeName();
|
|
48
|
+
|
|
49
|
+
const typeArguments = node.getTypeArguments();
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
typeName.isKind(tsm.SyntaxKind.Identifier) &&
|
|
53
|
+
typeName.getText() === 'Readonly' &&
|
|
54
|
+
Arr.isFixedLengthArray(1, typeArguments)
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
33
58
|
// Helper to get a specific type node from source code
|
|
34
59
|
const getTypeNodeFromSource = (
|
|
35
60
|
sourceCode: string,
|
|
@@ -97,13 +122,6 @@ export type ReadonlyTupleTypeNode = tsm.TypeNode &
|
|
|
97
122
|
getTypeNode: () => tsm.TupleTypeNode;
|
|
98
123
|
}>;
|
|
99
124
|
|
|
100
|
-
export const isReadonlyTupleTypeNode = (
|
|
101
|
-
node: DeepReadonly<tsm.Node>,
|
|
102
|
-
): node is ReadonlyTupleTypeNode =>
|
|
103
|
-
node.isKind(tsm.SyntaxKind.TypeOperator) &&
|
|
104
|
-
node.getOperator() === tsm.SyntaxKind.ReadonlyKeyword &&
|
|
105
|
-
node.getTypeNode().isKind(tsm.SyntaxKind.TupleType);
|
|
106
|
-
|
|
107
125
|
if (import.meta.vitest !== undefined) {
|
|
108
126
|
describe('isReadonlyTupleTypeNode', () => {
|
|
109
127
|
test('should return true for readonly tuple type', () => {
|
|
@@ -162,24 +180,6 @@ export type ReadonlyTypeReferenceNode = tsm.TypeNode &
|
|
|
162
180
|
getTypeArguments: () => readonly [tsm.TypeNode];
|
|
163
181
|
}>;
|
|
164
182
|
|
|
165
|
-
export const isReadonlyTypeReferenceNode = (
|
|
166
|
-
node: DeepReadonly<tsm.Node>,
|
|
167
|
-
): node is ReadonlyTypeReferenceNode => {
|
|
168
|
-
if (!node.isKind(tsm.SyntaxKind.TypeReference)) {
|
|
169
|
-
return false;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const typeName = node.getTypeName();
|
|
173
|
-
|
|
174
|
-
const typeArguments = node.getTypeArguments();
|
|
175
|
-
|
|
176
|
-
return (
|
|
177
|
-
typeName.isKind(tsm.SyntaxKind.Identifier) &&
|
|
178
|
-
typeName.getText() === 'Readonly' &&
|
|
179
|
-
Arr.isFixedLengthArray(1, typeArguments)
|
|
180
|
-
);
|
|
181
|
-
};
|
|
182
|
-
|
|
183
183
|
if (import.meta.vitest !== undefined) {
|
|
184
184
|
describe('isReadonlyTypeReferenceNode', () => {
|
|
185
185
|
test('should return true for Readonly<T>', () => {
|