eslint-plugin-ts-type-forge 0.8.14 → 0.8.16

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.
@@ -143,6 +143,114 @@ const isInConditionalTypeExtendsClause = (node: TSESTree.Node): boolean => {
143
143
  );
144
144
  };
145
145
 
146
+ /** The `type X = …` declaration `node` sits in, if any. */
147
+ const enclosingTypeAlias = (
148
+ node: TSESTree.Node,
149
+ ): TSESTree.TSTypeAliasDeclaration | undefined =>
150
+ node.type === AST_NODE_TYPES.TSTypeAliasDeclaration
151
+ ? node
152
+ : node.type === AST_NODE_TYPES.Program
153
+ ? undefined
154
+ : enclosingTypeAlias(node.parent);
155
+
156
+ const isNode = (value: unknown): value is TSESTree.Node =>
157
+ isNonNullObject(value) &&
158
+ typeof (value as Readonly<{ type?: unknown }>).type === 'string';
159
+
160
+ /**
161
+ * Visits `node` and its descendants.
162
+ *
163
+ * Children are found through the parser's visitor keys, so only the properties
164
+ * that actually hold one are read. Reading every own property instead works
165
+ * too, and is the fallback for a parser that declares no keys for a node type,
166
+ * but it allocates an entry pair per property and walks `loc` / `range` as
167
+ * well — on the generated rule-type files that alone was most of the cost.
168
+ */
169
+ const walk = (
170
+ node: TSESTree.Node,
171
+ sourceCode: TSESLint.SourceCode,
172
+ visit: (visited: TSESTree.Node) => void,
173
+ ): void => {
174
+ visit(node);
175
+
176
+ if (!isRecord(node)) {
177
+ return;
178
+ }
179
+
180
+ const keys =
181
+ sourceCode.visitorKeys[node.type] ??
182
+ Object.keys(node).filter((key) => key !== 'parent');
183
+
184
+ for (const key of keys) {
185
+ const value: unknown = hasKey(node, key) ? node[key] : undefined;
186
+
187
+ if (Arr.isArray(value)) {
188
+ for (const element of value) {
189
+ if (isNode(element)) {
190
+ walk(element, sourceCode, visit);
191
+ }
192
+ }
193
+ } else if (isNode(value)) {
194
+ walk(value, sourceCode, visit);
195
+ }
196
+ }
197
+ };
198
+
199
+ /** The names every `X` / `X<…>` reference in the subtree points at. */
200
+ const referencedTypeNames = (
201
+ node: TSESTree.Node,
202
+ sourceCode: TSESLint.SourceCode,
203
+ ): ReadonlySet<string> => {
204
+ const mut_names = new Set<string>();
205
+
206
+ walk(node, sourceCode, (visited) => {
207
+ if (
208
+ visited.type === AST_NODE_TYPES.TSTypeReference &&
209
+ visited.typeName.type === AST_NODE_TYPES.Identifier
210
+ ) {
211
+ mut_names.add(visited.typeName.name);
212
+ }
213
+ });
214
+
215
+ return mut_names;
216
+ };
217
+
218
+ /**
219
+ * Which other type names each `type X = …` in the file refers to, keyed by `X`.
220
+ *
221
+ * Built once per file and cached on the `Program` node: the search below runs
222
+ * per candidate tuple, and rebuilding this for each of them made the rule cost
223
+ * `O(tuples × file size)` — a full second on the largest generated files here.
224
+ */
225
+ const aliasReferenceGraph = (
226
+ program: TSESTree.Program,
227
+ sourceCode: TSESLint.SourceCode,
228
+ ): ReadonlyMap<string, ReadonlySet<string>> => {
229
+ const cached = aliasReferenceGraphCache.get(program);
230
+
231
+ if (cached !== undefined) {
232
+ return cached;
233
+ }
234
+
235
+ const mut_graph = new Map<string, ReadonlySet<string>>();
236
+
237
+ walk(program, sourceCode, (visited) => {
238
+ if (visited.type === AST_NODE_TYPES.TSTypeAliasDeclaration) {
239
+ mut_graph.set(
240
+ visited.id.name,
241
+ referencedTypeNames(visited.typeAnnotation, sourceCode),
242
+ );
243
+ }
244
+ });
245
+
246
+ aliasReferenceGraphCache.set(program, mut_graph);
247
+
248
+ return mut_graph;
249
+ };
250
+
251
+ const programOf = (node: TSESTree.Node): TSESTree.Program =>
252
+ node.type === AST_NODE_TYPES.Program ? node : programOf(node.parent);
253
+
146
254
  /**
147
255
  * Whether the tuple spells out an alias that its own element type leads back
148
256
  * to.
@@ -225,119 +333,11 @@ const definesRecursiveTypeAlias = (
225
333
  }
226
334
  };
227
335
 
228
- /** The `type X = …` declaration `node` sits in, if any. */
229
- const enclosingTypeAlias = (
230
- node: TSESTree.Node,
231
- ): TSESTree.TSTypeAliasDeclaration | undefined =>
232
- node.type === AST_NODE_TYPES.TSTypeAliasDeclaration
233
- ? node
234
- : node.type === AST_NODE_TYPES.Program
235
- ? undefined
236
- : enclosingTypeAlias(node.parent);
237
-
238
- /**
239
- * Which other type names each `type X = …` in the file refers to, keyed by `X`.
240
- *
241
- * Built once per file and cached on the `Program` node: the search below runs
242
- * per candidate tuple, and rebuilding this for each of them made the rule cost
243
- * `O(tuples × file size)` — a full second on the largest generated files here.
244
- */
245
- const aliasReferenceGraph = (
246
- program: TSESTree.Program,
247
- sourceCode: TSESLint.SourceCode,
248
- ): ReadonlyMap<string, ReadonlySet<string>> => {
249
- const cached = aliasReferenceGraphCache.get(program);
250
-
251
- if (cached !== undefined) {
252
- return cached;
253
- }
254
-
255
- const mut_graph = new Map<string, ReadonlySet<string>>();
256
-
257
- walk(program, sourceCode, (visited) => {
258
- if (visited.type === AST_NODE_TYPES.TSTypeAliasDeclaration) {
259
- mut_graph.set(
260
- visited.id.name,
261
- referencedTypeNames(visited.typeAnnotation, sourceCode),
262
- );
263
- }
264
- });
265
-
266
- aliasReferenceGraphCache.set(program, mut_graph);
267
-
268
- return mut_graph;
269
- };
270
-
271
336
  const aliasReferenceGraphCache = new WeakMap<
272
337
  TSESTree.Program,
273
338
  ReadonlyMap<string, ReadonlySet<string>>
274
339
  >();
275
340
 
276
- const programOf = (node: TSESTree.Node): TSESTree.Program =>
277
- node.type === AST_NODE_TYPES.Program ? node : programOf(node.parent);
278
-
279
- /** The names every `X` / `X<…>` reference in the subtree points at. */
280
- const referencedTypeNames = (
281
- node: TSESTree.Node,
282
- sourceCode: TSESLint.SourceCode,
283
- ): ReadonlySet<string> => {
284
- const mut_names = new Set<string>();
285
-
286
- walk(node, sourceCode, (visited) => {
287
- if (
288
- visited.type === AST_NODE_TYPES.TSTypeReference &&
289
- visited.typeName.type === AST_NODE_TYPES.Identifier
290
- ) {
291
- mut_names.add(visited.typeName.name);
292
- }
293
- });
294
-
295
- return mut_names;
296
- };
297
-
298
- /**
299
- * Visits `node` and its descendants.
300
- *
301
- * Children are found through the parser's visitor keys, so only the properties
302
- * that actually hold one are read. Reading every own property instead works
303
- * too, and is the fallback for a parser that declares no keys for a node type,
304
- * but it allocates an entry pair per property and walks `loc` / `range` as
305
- * well — on the generated rule-type files that alone was most of the cost.
306
- */
307
- const walk = (
308
- node: TSESTree.Node,
309
- sourceCode: TSESLint.SourceCode,
310
- visit: (visited: TSESTree.Node) => void,
311
- ): void => {
312
- visit(node);
313
-
314
- if (!isRecord(node)) {
315
- return;
316
- }
317
-
318
- const keys =
319
- sourceCode.visitorKeys[node.type] ??
320
- Object.keys(node).filter((key) => key !== 'parent');
321
-
322
- for (const key of keys) {
323
- const value: unknown = hasKey(node, key) ? node[key] : undefined;
324
-
325
- if (Arr.isArray(value)) {
326
- for (const element of value) {
327
- if (isNode(element)) {
328
- walk(element, sourceCode, visit);
329
- }
330
- }
331
- } else if (isNode(value)) {
332
- walk(value, sourceCode, visit);
333
- }
334
- }
335
- };
336
-
337
- const isNode = (value: unknown): value is TSESTree.Node =>
338
- isNonNullObject(value) &&
339
- typeof (value as Readonly<{ type?: unknown }>).type === 'string';
340
-
341
341
  /** Unwraps `V[]` and `readonly V[]` to `V`. */
342
342
  const getArrayElementType = (
343
343
  node: TSESTree.TypeNode,
@@ -20,9 +20,9 @@ export const IMPORT_STYLE_SCHEMA_PROPERTY: JsonSchema = {
20
20
  enum: ['global', 'named'],
21
21
  description: [
22
22
  'How the ts-type-forge type is brought into scope.',
23
- "'named' (default) makes the autofix add the corresponding",
24
- `\`import { type … } from '${TS_TYPE_FORGE_MODULE}';\``,
25
- "when the name is not imported yet; 'global' assumes the",
23
+ "'named' (default) makes the autofix import the type from",
24
+ `\`${TS_TYPE_FORGE_MODULE}\` when the name is not imported yet,`,
25
+ "adding it to an existing import of the module where there is one; 'global' assumes the",
26
26
  'ambient globals of `ts-type-forge/global` and never touches',
27
27
  'imports.',
28
28
  ].join(' '),
@@ -33,7 +33,8 @@ export const IMPORT_STYLE_SCHEMA_PROPERTY: JsonSchema = {
33
33
  * whether the fix has to add an import for it.
34
34
  *
35
35
  * Returns `undefined` when the name is already bound to something else (a local
36
- * alias, or an import from another module) — rewriting to it would then silently
36
+ * alias, an import from another module, or another ts-type-forge export
37
+ * imported under the name) — rewriting to it would then silently
37
38
  * resolve to the wrong declaration, which is exactly what happens inside
38
39
  * ts-type-forge's own sources.
39
40
  */
@@ -1,12 +1,91 @@
1
- import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils';
1
+ import {
2
+ AST_NODE_TYPES,
3
+ type TSESLint,
4
+ type TSESTree,
5
+ } from '@typescript-eslint/utils';
6
+ import { buildNamedImportFix, getImportDeclarations } from 'eslint-rule-forge';
7
+ import { Arr } from 'ts-data-forge';
2
8
  import { TS_TYPE_FORGE_MODULE } from './constants.mjs';
3
9
 
4
10
  /* eslint-disable @typescript-eslint/prefer-readonly-parameter-types */
5
11
 
12
+ /**
13
+ * The edits that import the ts-type-forge types `names` the file does not
14
+ * import yet, as types: added to an existing `import type { … }` from the
15
+ * module (`, X`), else to a named import from it (`, type X`), else after a
16
+ * lone default import (`, { type X }`), and only failing all of those as a
17
+ * new `import { type X } from 'ts-type-forge'` after the directive prologue.
18
+ * Empty braces count as a host. Adding to an existing declaration keeps
19
+ * `import-x/no-duplicates` satisfied.
20
+ *
21
+ * Fixes that add an import overlap, so `--fix` applies one per pass, and the
22
+ * rest in later passes, by when the file imports their names (see
23
+ * `resolveTypeName`).
24
+ */
25
+ export const buildTypeImportFix = (
26
+ fixer: TSESLint.RuleFixer,
27
+ sourceCode: Readonly<TSESLint.SourceCode>,
28
+ names: readonly string[],
29
+ ): readonly TSESLint.RuleFix[] => {
30
+ if (!Arr.isNonEmpty(names)) {
31
+ return [];
32
+ }
33
+
34
+ const declarations = getImportDeclarations(
35
+ sourceCode.ast,
36
+ TS_TYPE_FORGE_MODULE,
37
+ );
38
+
39
+ const typeOnlyFix = declarations
40
+ .filter((declaration) => declaration.importKind === 'type')
41
+ .map((declaration) => addToBraces(fixer, sourceCode, declaration, names))
42
+ .find((fix) => fix !== undefined);
43
+
44
+ if (typeOnlyFix !== undefined) {
45
+ return [typeOnlyFix];
46
+ }
47
+
48
+ const typeSpecifiers = names.map((name) => `type ${name}`);
49
+
50
+ const valueDeclarations = declarations.filter(
51
+ (declaration) => declaration.importKind !== 'type',
52
+ );
53
+
54
+ const valueFix = valueDeclarations
55
+ .map((declaration) =>
56
+ addToBraces(fixer, sourceCode, declaration, typeSpecifiers),
57
+ )
58
+ .find((fix) => fix !== undefined);
59
+
60
+ if (valueFix !== undefined) {
61
+ return [valueFix];
62
+ }
63
+
64
+ const defaultHost = valueDeclarations.find(
65
+ (declaration) =>
66
+ Arr.isFixedLengthArray(1, declaration.specifiers) &&
67
+ declaration.specifiers[0].type === AST_NODE_TYPES.ImportDefaultSpecifier,
68
+ )?.specifiers[0];
69
+
70
+ if (defaultHost !== undefined) {
71
+ return [
72
+ fixer.insertTextAfter(defaultHost, `, { ${typeSpecifiers.join(', ')} }`),
73
+ ];
74
+ }
75
+
76
+ return buildNamedImportFix(
77
+ fixer,
78
+ sourceCode.ast,
79
+ TS_TYPE_FORGE_MODULE,
80
+ typeSpecifiers,
81
+ );
82
+ };
83
+
6
84
  /**
7
85
  * Whether the file already binds `name` to something that is not the
8
86
  * ts-type-forge export of the same name — a local type alias / interface /
9
- * class / enum, or an import from another module. Autofixes that introduce
87
+ * class / enum, an import from another module, or another ts-type-forge
88
+ * export imported under that name. Autofixes that introduce
10
89
  * `name` must bail out in that case, because the rewritten type would resolve
11
90
  * to the wrong declaration. (ts-type-forge's own sources, which declare
12
91
  * `NonEmptyArray` themselves, are the motivating example.)
@@ -20,9 +99,16 @@ export const hasConflictingDeclaration = (
20
99
  ): boolean =>
21
100
  program.body.some((node) => {
22
101
  if (node.type === AST_NODE_TYPES.ImportDeclaration) {
23
- return (
24
- node.source.value !== TS_TYPE_FORGE_MODULE &&
25
- node.specifiers.some((specifier) => specifier.local.name === name)
102
+ return node.specifiers.some(
103
+ (specifier) =>
104
+ specifier.local.name === name &&
105
+ !(
106
+ node.source.value === TS_TYPE_FORGE_MODULE &&
107
+ specifier.type === AST_NODE_TYPES.ImportSpecifier &&
108
+ (specifier.imported.type === AST_NODE_TYPES.Identifier
109
+ ? specifier.imported.name
110
+ : specifier.imported.value) === name
111
+ ),
26
112
  );
27
113
  }
28
114
 
@@ -35,6 +121,39 @@ export const hasConflictingDeclaration = (
35
121
  );
36
122
  });
37
123
 
124
+ /**
125
+ * Adds `specifiers` to the `{ … }` of `declaration`: after its last named
126
+ * specifier, or inside empty braces (`import d, {} from …`). `undefined` when
127
+ * the declaration has no braces (a default or namespace import alone).
128
+ */
129
+ const addToBraces = (
130
+ fixer: TSESLint.RuleFixer,
131
+ sourceCode: Readonly<TSESLint.SourceCode>,
132
+ declaration: TSESTree.ImportDeclaration,
133
+ specifiers: readonly string[],
134
+ ): TSESLint.RuleFix | undefined => {
135
+ const lastNamed = declaration.specifiers
136
+ .filter(
137
+ (specifier): specifier is TSESTree.ImportSpecifier =>
138
+ specifier.type === AST_NODE_TYPES.ImportSpecifier,
139
+ )
140
+ .at(-1);
141
+
142
+ if (lastNamed !== undefined) {
143
+ return fixer.insertTextAfter(lastNamed, `, ${specifiers.join(', ')}`);
144
+ }
145
+
146
+ // Before the source, or the brace found is that of `with { … }`.
147
+ const openingBrace = sourceCode.getFirstToken(declaration, {
148
+ filter: (token) =>
149
+ token.value === '{' && token.range[1] <= declaration.source.range[0],
150
+ });
151
+
152
+ return openingBrace === null
153
+ ? undefined
154
+ : fixer.insertTextAfter(openingBrace, ` ${specifiers.join(', ')} `);
155
+ };
156
+
38
157
  const declaresName = (
39
158
  node: TSESTree.Node | null | undefined,
40
159
  name: string,
@@ -255,13 +255,20 @@ describe('prefer-canonical-length-constrained-tuple', () => {
255
255
  type B = readonly [number, number, number];
256
256
  type C = [number, number, ...number[]];
257
257
  `,
258
- output: dedent`
259
- import { type FixedLengthTuple } from 'ts-type-forge';
260
- import { type MutableMinLengthTuple } from 'ts-type-forge';
261
- type A = FixedLengthTuple<2, string>;
262
- type B = FixedLengthTuple<3, number>;
263
- type C = MutableMinLengthTuple<2, number>;
264
- `,
258
+ output: [
259
+ dedent`
260
+ import { type FixedLengthTuple, type MutableMinLengthTuple } from 'ts-type-forge';
261
+ type A = FixedLengthTuple<2, string>;
262
+ type B = readonly [number, number, number];
263
+ type C = [number, number, ...number[]];
264
+ `,
265
+ dedent`
266
+ import { type FixedLengthTuple, type MutableMinLengthTuple } from 'ts-type-forge';
267
+ type A = FixedLengthTuple<2, string>;
268
+ type B = FixedLengthTuple<3, number>;
269
+ type C = MutableMinLengthTuple<2, number>;
270
+ `,
271
+ ],
265
272
  errors: [
266
273
  { messageId: 'useCanonicalTuple' },
267
274
  { messageId: 'useCanonicalTuple' },
@@ -290,13 +297,43 @@ describe('prefer-canonical-length-constrained-tuple', () => {
290
297
  errors: [{ messageId: 'useCanonicalTuple' }],
291
298
  },
292
299
  {
293
- name: 'rewrites parameter and return type positions',
294
- code: 'declare const f: (xs: readonly [number, number]) => [string, string];',
295
- output: dedent`
300
+ name: 'gives a report that needs no import no import edit',
301
+ code: dedent`
296
302
  import { type FixedLengthTuple } from 'ts-type-forge';
297
- import { type MutableFixedLengthTuple } from 'ts-type-forge';
298
- declare const f: (xs: FixedLengthTuple<2, number>) => MutableFixedLengthTuple<2, string>;
303
+ type A = readonly [number, number];
304
+ type C = [number, number, ...number[]];
299
305
  `,
306
+ // C's import edit spans A, so A follows in the next pass.
307
+ output: [
308
+ dedent`
309
+ import { type FixedLengthTuple, type MutableMinLengthTuple } from 'ts-type-forge';
310
+ type A = readonly [number, number];
311
+ type C = MutableMinLengthTuple<2, number>;
312
+ `,
313
+ dedent`
314
+ import { type FixedLengthTuple, type MutableMinLengthTuple } from 'ts-type-forge';
315
+ type A = FixedLengthTuple<2, number>;
316
+ type C = MutableMinLengthTuple<2, number>;
317
+ `,
318
+ ],
319
+ errors: [
320
+ { messageId: 'useCanonicalTuple' },
321
+ { messageId: 'useCanonicalTuple' },
322
+ ],
323
+ },
324
+ {
325
+ name: 'rewrites parameter and return type positions',
326
+ code: 'declare const f: (xs: readonly [number, number]) => [string, string];',
327
+ output: [
328
+ dedent`
329
+ import { type FixedLengthTuple, type MutableFixedLengthTuple } from 'ts-type-forge';
330
+ declare const f: (xs: FixedLengthTuple<2, number>) => [string, string];
331
+ `,
332
+ dedent`
333
+ import { type FixedLengthTuple, type MutableFixedLengthTuple } from 'ts-type-forge';
334
+ declare const f: (xs: FixedLengthTuple<2, number>) => MutableFixedLengthTuple<2, string>;
335
+ `,
336
+ ],
300
337
  errors: [
301
338
  { messageId: 'useCanonicalTuple' },
302
339
  { messageId: 'useCanonicalTuple' },
@@ -50,6 +50,13 @@ describe('prefer-canonical-mutable-record', () => {
50
50
  type A = Mutable<Record<string, number>>;
51
51
  `,
52
52
  },
53
+ {
54
+ name: 'stays silent when another ts-type-forge export is imported as `MutableRecord`',
55
+ code: dedent`
56
+ import { type DeepReadonly as MutableRecord } from 'ts-type-forge';
57
+ type A = Mutable<Record<string, number>>;
58
+ `,
59
+ },
53
60
  {
54
61
  name: 'stays silent when `MutableRecord` is bound to another module',
55
62
  code: dedent`
@@ -107,16 +114,124 @@ describe('prefer-canonical-mutable-record', () => {
107
114
  type A = Mutable<Record<string, number>>;
108
115
  type B = Mutable<ReadonlyRecord<string, boolean>>;
109
116
  `,
110
- output: dedent`
111
- import { type MutableRecord } from 'ts-type-forge';
112
- type A = MutableRecord<string, number>;
113
- type B = MutableRecord<string, boolean>;
114
- `,
117
+ output: [
118
+ dedent`
119
+ import { type MutableRecord } from 'ts-type-forge';
120
+ type A = MutableRecord<string, number>;
121
+ type B = Mutable<ReadonlyRecord<string, boolean>>;
122
+ `,
123
+ dedent`
124
+ import { type MutableRecord } from 'ts-type-forge';
125
+ type A = MutableRecord<string, number>;
126
+ type B = MutableRecord<string, boolean>;
127
+ `,
128
+ ],
115
129
  errors: [
116
130
  { messageId: 'useMutableRecord' },
117
131
  { messageId: 'useMutableRecord' },
118
132
  ],
119
133
  },
134
+ {
135
+ name: 'adds the name to an existing `import type` from ts-type-forge',
136
+ code: dedent`
137
+ import type { Mutable } from 'ts-type-forge';
138
+ type A = Mutable<Record<string, number>>;
139
+ `,
140
+ output: dedent`
141
+ import type { Mutable, MutableRecord } from 'ts-type-forge';
142
+ type A = MutableRecord<string, number>;
143
+ `,
144
+ errors: [{ messageId: 'useMutableRecord' }],
145
+ },
146
+ {
147
+ name: 'adds the name to an existing `import { … }` from ts-type-forge',
148
+ code: dedent`
149
+ import { type Mutable, type DeepReadonly } from 'ts-type-forge';
150
+ type A = Mutable<Record<string, number>>;
151
+ `,
152
+ output: dedent`
153
+ import { type Mutable, type DeepReadonly, type MutableRecord } from 'ts-type-forge';
154
+ type A = MutableRecord<string, number>;
155
+ `,
156
+ errors: [{ messageId: 'useMutableRecord' }],
157
+ },
158
+ {
159
+ name: 'adds named braces after a lone default import',
160
+ code: dedent`
161
+ import forge from 'ts-type-forge';
162
+ type A = Mutable<Record<string, number>>;
163
+ `,
164
+ output: dedent`
165
+ import forge, { type MutableRecord } from 'ts-type-forge';
166
+ type A = MutableRecord<string, number>;
167
+ `,
168
+ errors: [{ messageId: 'useMutableRecord' }],
169
+ },
170
+ {
171
+ name: 'fills the empty braces of `import d, {}`',
172
+ code: dedent`
173
+ import forge, {} from 'ts-type-forge';
174
+ type A = Mutable<Record<string, number>>;
175
+ `,
176
+ output: dedent`
177
+ import forge, { type MutableRecord } from 'ts-type-forge';
178
+ type A = MutableRecord<string, number>;
179
+ `,
180
+ errors: [{ messageId: 'useMutableRecord' }],
181
+ },
182
+ {
183
+ name: 'fills the empty braces of `import type {}`',
184
+ code: dedent`
185
+ import type {} from 'ts-type-forge';
186
+ type A = Mutable<Record<string, number>>;
187
+ `,
188
+ output: dedent`
189
+ import type { MutableRecord } from 'ts-type-forge';
190
+ type A = MutableRecord<string, number>;
191
+ `,
192
+ errors: [{ messageId: 'useMutableRecord' }],
193
+ },
194
+ {
195
+ name: 'does not take the brace of import attributes for the specifiers',
196
+ code: dedent`
197
+ import type forge from 'ts-type-forge' with { 'resolution-mode': 'import' };
198
+ type A = Mutable<Record<string, number>>;
199
+ `,
200
+ output: dedent`
201
+ import { type MutableRecord } from 'ts-type-forge';
202
+ import type forge from 'ts-type-forge' with { 'resolution-mode': 'import' };
203
+ type A = MutableRecord<string, number>;
204
+ `,
205
+ errors: [{ messageId: 'useMutableRecord' }],
206
+ },
207
+ {
208
+ name: 'adds a new declaration beside a type-only default import',
209
+ code: dedent`
210
+ import type forge from 'ts-type-forge';
211
+ type A = Mutable<Record<string, number>>;
212
+ `,
213
+ output: dedent`
214
+ import { type MutableRecord } from 'ts-type-forge';
215
+ import type forge from 'ts-type-forge';
216
+ type A = MutableRecord<string, number>;
217
+ `,
218
+ errors: [{ messageId: 'useMutableRecord' }],
219
+ },
220
+ {
221
+ name: 'adds a new declaration beside a namespace import, after the directives',
222
+ code: dedent`
223
+ 'use strict';
224
+ import * as forge from 'ts-type-forge';
225
+ type A = Mutable<Record<string, number>>;
226
+ `,
227
+ output: dedent`
228
+ 'use strict';
229
+ import { type MutableRecord } from 'ts-type-forge';
230
+ import * as forge from 'ts-type-forge';
231
+ type A = MutableRecord<string, number>;
232
+ `,
233
+ errors: [{ messageId: 'useMutableRecord' }],
234
+ },
120
235
  {
121
236
  name: 'follows ts-type-forge import aliases on both sides',
122
237
  code: dedent`
@@ -120,8 +120,35 @@ describe('prefer-readonly-or-mutable-record', () => {
120
120
  messageId: 'replaceWith',
121
121
  data: { name: 'Record', replacement: 'MutableRecord' },
122
122
  output: dedent`
123
- import { type MutableRecord } from 'ts-type-forge';
124
- import { type ReadonlyRecord as RRecord } from 'ts-type-forge';
123
+ import { type ReadonlyRecord as RRecord, type MutableRecord } from 'ts-type-forge';
124
+ type A = MutableRecord<string, number>;
125
+ `,
126
+ },
127
+ ],
128
+ },
129
+ ],
130
+ },
131
+ {
132
+ name: 'adds the suggested name to an existing `import type`',
133
+ code: dedent`
134
+ import type { DeepReadonly } from 'ts-type-forge';
135
+ type A = Record<string, number>;
136
+ `,
137
+ errors: [
138
+ {
139
+ messageId: 'useReadonlyOrMutableRecord',
140
+ suggestions: [
141
+ {
142
+ messageId: 'replaceWith',
143
+ output: dedent`
144
+ import type { DeepReadonly, ReadonlyRecord } from 'ts-type-forge';
145
+ type A = ReadonlyRecord<string, number>;
146
+ `,
147
+ },
148
+ {
149
+ messageId: 'replaceWith',
150
+ output: dedent`
151
+ import type { DeepReadonly, MutableRecord } from 'ts-type-forge';
125
152
  type A = MutableRecord<string, number>;
126
153
  `,
127
154
  },
@@ -197,8 +197,7 @@ describe('prefer-strict-or-relaxed-utility-type', () => {
197
197
  messageId: 'replaceWith',
198
198
  data: { name: 'Pick', replacement: 'RelaxedPick' },
199
199
  output: dedent`
200
- import { type RelaxedPick } from 'ts-type-forge';
201
- import { type StrictPick as SPick } from 'ts-type-forge';
200
+ import { type StrictPick as SPick, type RelaxedPick } from 'ts-type-forge';
202
201
  type A = RelaxedPick<{ a: 1 }, 'a'>;
203
202
  `,
204
203
  },