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
@@ -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
- * it takes a case from ~300 ms to ~260 ms, because what an edit invalidates is
92
- * the program, not the parsed library behind it. Naming the libraries the
93
- * cases actually need is the rest: together they bring a case to ~25 ms.
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
- * still cover.
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
  {
@@ -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
- * and index signatures `[k: string]: unknown` with `UnknownRecord`.
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 processDeclarations = (
39
- container: tsm.SourceFile | tsm.ModuleDeclaration,
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
- if (hasDisableNextLineComment(interfaceDecl, TRANSFORMER_NAME)) {
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
- // Otherwise, check properties for Record types
101
- return interfaceDecl
102
- .getProperties()
103
- .map((property) => {
104
- const typeNode = property.getTypeNode();
43
+ const returnType = sig.getReturnTypeNode();
105
44
 
106
- return typeNode !== undefined && visitTypeNode(typeNode);
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 isStringUnknownIndexSignature = (
212
- sig: tsm.IndexSignatureDeclaration,
148
+ const processInterfaceDeclaration = (
149
+ interfaceDecl: tsm.InterfaceDeclaration,
213
150
  ): boolean => {
214
- const keyType = sig.getKeyTypeNode();
151
+ if (hasDisableNextLineComment(interfaceDecl, TRANSFORMER_NAME)) {
152
+ return false;
153
+ }
215
154
 
216
- const returnType = sig.getReturnTypeNode();
155
+ // Check if interface has index signature [k: string]: unknown
156
+ const indexSignatures = interfaceDecl.getIndexSignatures();
217
157
 
218
- return keyType.getText() === 'string' && returnType?.getText() === 'unknown';
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
- ): readonly string[] | undefined => {
24
- for (const prefix of fileIgnorePrefixes) {
25
- const names = findFileIgnoreComment(code, prefix);
26
-
27
- if (names !== undefined) {
28
- return names;
29
- }
30
- }
31
-
32
- return undefined;
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 findFileIgnoreComment = (
36
- code: string,
37
- prefix: string,
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 findLineTerminator = createForwardSearch((from) =>
46
- indexOfLineTerminator(code, from),
47
- );
32
+ const ignoredTransformers = findFileIgnoreTransformers(code);
48
33
 
49
- const trimEndBefore = createLastValueCache((commentEnd: number) =>
50
- skipWhitespaceBackward(code, commentEnd),
51
- );
34
+ for (const transformer of transformers) {
35
+ const transformerName = transformer.name;
52
36
 
53
- for (
54
- let mut_open = code.indexOf('/*');
55
- mut_open !== -1;
56
- mut_open = code.indexOf('/*', mut_open + 2)
57
- ) {
58
- const prefixStart = skipWhitespace(code, mut_open + 2);
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
- const namesStart = skipWhitespace(code, prefixStart + prefix.length);
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 undefined;
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
- // Same set as `\s` and `String.prototype.trim`
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
- isTsx: boolean,
181
- transformers: readonly TsMorphTransformer[],
182
- debug: boolean = false,
183
- ): string => {
184
- const project = new tsm.Project({
185
- useInMemoryFileSystem: true,
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 ignoredTransformers = findFileIgnoreTransformers(code);
141
+ const findLineTerminator = createForwardSearch((from) =>
142
+ indexOfLineTerminator(code, from),
143
+ );
199
144
 
200
- for (const transformer of transformers) {
201
- const transformerName = transformer.name;
145
+ const trimEndBefore = createLastValueCache((commentEnd: number) =>
146
+ skipWhitespaceBackward(code, commentEnd),
147
+ );
202
148
 
203
- if (shouldSkipFile(ignoredTransformers, transformerName)) {
204
- if (debug) {
205
- console.debug(
206
- `skipped by ignore-file comment for transformer: ${transformerName}`,
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
- transformer.transform(sourceAst);
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 sourceAst.getFullText();
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
- * private name (`#mut_x`) is matched with or after its `#`.
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 `ignoredMutablePattern` in
7
- * `libs/eslint-config-typed/src/rules/eslint-functional-rules.mts` (and, for a
8
- * property, the `ignoreAccessorPattern` of `functional/immutable-data` there);
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
- * and `append-as-const` adds no `as const` where the value has to be
20
- * assignable to it (an annotated variable, `satisfies`, an arrow's return).
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.