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.
Files changed (49) hide show
  1. package/dist/functions/ast-transformers/append-as-const.d.mts +9 -10
  2. package/dist/functions/ast-transformers/append-as-const.d.mts.map +1 -1
  3. package/dist/functions/ast-transformers/append-as-const.mjs +123 -114
  4. package/dist/functions/ast-transformers/append-as-const.mjs.map +1 -1
  5. package/dist/functions/ast-transformers/convert-to-readonly.d.mts +4 -4
  6. package/dist/functions/ast-transformers/convert-to-readonly.mjs +248 -252
  7. package/dist/functions/ast-transformers/convert-to-readonly.mjs.map +1 -1
  8. package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.d.mts +8 -8
  9. package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.mjs +103 -104
  10. package/dist/functions/ast-transformers/enable-no-unchecked-indexed-access.mjs.map +1 -1
  11. package/dist/functions/ast-transformers/replace-record-with-unknown-record.d.mts +2 -2
  12. package/dist/functions/ast-transformers/replace-record-with-unknown-record.mjs +51 -51
  13. package/dist/functions/ast-transformers/replace-record-with-unknown-record.mjs.map +1 -1
  14. package/dist/functions/ast-transformers/transform-source-code.d.mts.map +1 -1
  15. package/dist/functions/ast-transformers/transform-source-code.mjs +69 -69
  16. package/dist/functions/ast-transformers/transform-source-code.mjs.map +1 -1
  17. package/dist/functions/constants/mutable-prefixes.d.mts +8 -8
  18. package/dist/functions/functions/has-disable-next-line-comment.mjs +23 -23
  19. package/dist/functions/functions/has-disable-next-line-comment.mjs.map +1 -1
  20. package/dist/functions/functions/is-readonly-node.d.mts +2 -2
  21. package/dist/functions/functions/is-readonly-node.d.mts.map +1 -1
  22. package/dist/functions/functions/is-readonly-node.mjs +13 -13
  23. package/dist/functions/functions/is-readonly-node.mjs.map +1 -1
  24. package/dist/functions/functions/wrap-with-parentheses.d.mts +2 -2
  25. package/dist/functions/functions/wrap-with-parentheses.d.mts.map +1 -1
  26. package/dist/functions/functions/wrap-with-parentheses.mjs +87 -32
  27. package/dist/functions/functions/wrap-with-parentheses.mjs.map +1 -1
  28. package/dist/functions/utils/replace-node-with-text.d.mts +10 -11
  29. package/dist/functions/utils/replace-node-with-text.d.mts.map +1 -1
  30. package/dist/functions/utils/replace-node-with-text.mjs +0 -1
  31. package/dist/functions/utils/replace-node-with-text.mjs.map +1 -1
  32. package/package.json +6 -6
  33. package/src/functions/ast-transformers/append-as-const.mts +242 -231
  34. package/src/functions/ast-transformers/append-as-const.test.mts +39 -6
  35. package/src/functions/ast-transformers/convert-to-readonly.mts +467 -471
  36. package/src/functions/ast-transformers/convert-to-readonly.test.mts +60 -33
  37. package/src/functions/ast-transformers/enable-no-unchecked-indexed-access.mts +178 -179
  38. package/src/functions/ast-transformers/enable-no-unchecked-indexed-access.test.mts +29 -29
  39. package/src/functions/ast-transformers/replace-any-with-unknown.test.mts +6 -6
  40. package/src/functions/ast-transformers/replace-record-with-unknown-record.mts +75 -75
  41. package/src/functions/ast-transformers/replace-record-with-unknown-record.test.mts +6 -6
  42. package/src/functions/ast-transformers/transform-source-code.mts +110 -110
  43. package/src/functions/ast-transformers/transformer-specific-ignore.test.mts +6 -6
  44. package/src/functions/constants/mutable-prefixes.mts +8 -8
  45. package/src/functions/functions/has-disable-next-line-comment.mts +35 -35
  46. package/src/functions/functions/is-readonly-node.mts +25 -25
  47. package/src/functions/functions/wrap-with-parentheses.mts +138 -52
  48. package/src/functions/functions/wrap-with-parentheses.test.mts +115 -0
  49. 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
- * 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.
@@ -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
- // the correct ignore comment, then the node is not ignored by
89
- // a comment on the *immediately* preceding line. Stop checking further back.
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>', () => {