eslint-plugin-ts-type-forge 0.5.0 → 0.6.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 (46) hide show
  1. package/README.md +80 -13
  2. package/dist/plugin.d.mts +8 -0
  3. package/dist/plugin.d.mts.map +1 -1
  4. package/dist/plugin.mjs +3 -1
  5. package/dist/plugin.mjs.map +1 -1
  6. package/dist/rule-types.d.mts +78 -0
  7. package/dist/rule-types.d.mts.map +1 -1
  8. package/dist/rules/import-style.d.mts +21 -0
  9. package/dist/rules/import-style.d.mts.map +1 -0
  10. package/dist/rules/import-style.mjs +29 -0
  11. package/dist/rules/import-style.mjs.map +1 -0
  12. package/dist/rules/prefer-canonical-length-constrained-tuple.d.mts.map +1 -1
  13. package/dist/rules/prefer-canonical-length-constrained-tuple.mjs +2 -1
  14. package/dist/rules/prefer-canonical-length-constrained-tuple.mjs.map +1 -1
  15. package/dist/rules/prefer-readonly-or-mutable-record.d.mts +10 -0
  16. package/dist/rules/prefer-readonly-or-mutable-record.d.mts.map +1 -0
  17. package/dist/rules/prefer-readonly-or-mutable-record.mjs +49 -0
  18. package/dist/rules/prefer-readonly-or-mutable-record.mjs.map +1 -0
  19. package/dist/rules/prefer-strict-or-relaxed-utility-type.d.mts +10 -0
  20. package/dist/rules/prefer-strict-or-relaxed-utility-type.d.mts.map +1 -0
  21. package/dist/rules/prefer-strict-or-relaxed-utility-type.mjs +49 -0
  22. package/dist/rules/prefer-strict-or-relaxed-utility-type.mjs.map +1 -0
  23. package/dist/rules/rules.d.mts +6 -0
  24. package/dist/rules/rules.d.mts.map +1 -1
  25. package/dist/rules/rules.mjs +5 -1
  26. package/dist/rules/rules.mjs.map +1 -1
  27. package/dist/rules/std-type-rule-utils.d.mts +22 -0
  28. package/dist/rules/std-type-rule-utils.d.mts.map +1 -0
  29. package/dist/rules/std-type-rule-utils.mjs +40 -0
  30. package/dist/rules/std-type-rule-utils.mjs.map +1 -0
  31. package/dist/rules/tuple-rule-utils.d.mts +1 -19
  32. package/dist/rules/tuple-rule-utils.d.mts.map +1 -1
  33. package/dist/rules/tuple-rule-utils.mjs +3 -26
  34. package/dist/rules/tuple-rule-utils.mjs.map +1 -1
  35. package/package.json +2 -2
  36. package/src/plugin.mts +2 -0
  37. package/src/rule-types.mts +92 -0
  38. package/src/rules/import-style.mts +60 -0
  39. package/src/rules/prefer-canonical-length-constrained-tuple.mts +2 -3
  40. package/src/rules/prefer-readonly-or-mutable-record.mts +74 -0
  41. package/src/rules/prefer-readonly-or-mutable-record.test.mts +160 -0
  42. package/src/rules/prefer-strict-or-relaxed-utility-type.mts +80 -0
  43. package/src/rules/prefer-strict-or-relaxed-utility-type.test.mts +279 -0
  44. package/src/rules/rules.mts +4 -0
  45. package/src/rules/std-type-rule-utils.mts +103 -0
  46. package/src/rules/tuple-rule-utils.mts +2 -58
@@ -0,0 +1,40 @@
1
+ import { AST_NODE_TYPES, ASTUtils } from '@typescript-eslint/utils';
2
+ import { Arr } from 'ts-data-forge';
3
+ import { resolveTypeName } from './import-style.mjs';
4
+ import { buildTypeImportFix } from './import-utils.mjs';
5
+
6
+ const reportStdTypeReference = (context, node, alternatives, messageIds, importStyle) => {
7
+ const typeName = node.typeName;
8
+ if (typeName.type !== AST_NODE_TYPES.Identifier) return;
9
+ const stdName = typeName.name;
10
+ const candidates = alternatives.get(stdName);
11
+ if (candidates === void 0) return;
12
+ if (isLocallyBound(context.sourceCode, node, stdName)) return;
13
+ const program = context.sourceCode.ast;
14
+ context.report({
15
+ node: typeName,
16
+ messageId: messageIds.report,
17
+ data: { name: stdName, alternatives: formatAlternatives(candidates) },
18
+ suggest: candidates.flatMap((candidate) => {
19
+ const target = resolveTypeName(program, candidate, importStyle);
20
+ return target === void 0 ? [] : [
21
+ {
22
+ messageId: messageIds.suggestion,
23
+ data: { name: stdName, replacement: target.localName },
24
+ fix: (fixer) => Arr.toPushed(
25
+ target.needsImport ? buildTypeImportFix(fixer, program, candidate) : [],
26
+ fixer.replaceText(typeName, target.localName)
27
+ )
28
+ }
29
+ ];
30
+ })
31
+ });
32
+ };
33
+ const formatAlternatives = (candidates) => candidates.map((candidate) => `\`${candidate}\``).join(" or ");
34
+ const isLocallyBound = (sourceCode, node, name) => {
35
+ const variable = ASTUtils.findVariable(sourceCode.getScope(node), name);
36
+ return variable !== null && Arr.isNonEmpty(variable.defs);
37
+ };
38
+
39
+ export { reportStdTypeReference };
40
+ //# sourceMappingURL=std-type-rule-utils.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"std-type-rule-utils.mjs","sources":["../../src/rules/std-type-rule-utils.mts"],"sourcesContent":["import {\n AST_NODE_TYPES,\n ASTUtils,\n type TSESLint,\n type TSESTree,\n} from '@typescript-eslint/utils';\nimport { Arr } from 'ts-data-forge';\nimport { resolveTypeName, type ImportStyle } from './import-style.mjs';\nimport { buildTypeImportFix } from './import-utils.mjs';\n\n/* eslint-disable @typescript-eslint/prefer-readonly-parameter-types */\n\n/**\n * The ts-type-forge replacements offered for one standard-library type, in the\n * order they are suggested.\n */\nexport type StdTypeAlternatives = ReadonlyMap<string, readonly string[]>;\n\n/**\n * Reports a reference to a banned standard-library type and offers one\n * suggestion per ts-type-forge alternative.\n *\n * Choosing between the alternatives is a semantic decision — `StrictExclude`\n * additionally checks that the subtrahend is part of the union, `ReadonlyRecord`\n * and `MutableRecord` describe different types — so this is deliberately *not*\n * an autofix. Each suggestion carries its own import fix, which is safe because\n * ESLint applies suggestions one at a time.\n */\nexport const reportStdTypeReference = <MessageIds extends string>(\n context: TSESLint.RuleContext<MessageIds, readonly unknown[]>,\n node: TSESTree.TSTypeReference,\n alternatives: StdTypeAlternatives,\n messageIds: Readonly<{ report: MessageIds; suggestion: MessageIds }>,\n importStyle: ImportStyle,\n): void => {\n const typeName = node.typeName;\n\n // `ns.Pick` / `import('…').Pick` never denote the standard-library type.\n if (typeName.type !== AST_NODE_TYPES.Identifier) return;\n\n const stdName = typeName.name;\n\n const candidates = alternatives.get(stdName);\n\n if (candidates === undefined) return;\n\n // A file that declares — or imports — its own `Pick` does not mean the\n // standard-library one, so there is nothing to steer.\n if (isLocallyBound(context.sourceCode, node, stdName)) return;\n\n const program = context.sourceCode.ast;\n\n context.report({\n node: typeName,\n messageId: messageIds.report,\n data: { name: stdName, alternatives: formatAlternatives(candidates) },\n suggest: candidates.flatMap((candidate) => {\n const target = resolveTypeName(program, candidate, importStyle);\n\n // The replacement name is already bound to something else in this file,\n // so rewriting to it would resolve to the wrong declaration.\n return target === undefined\n ? []\n : [\n {\n messageId: messageIds.suggestion,\n data: { name: stdName, replacement: target.localName },\n fix: (fixer: TSESLint.RuleFixer): readonly TSESLint.RuleFix[] =>\n Arr.toPushed(\n target.needsImport\n ? buildTypeImportFix(fixer, program, candidate)\n : [],\n fixer.replaceText(typeName, target.localName),\n ),\n },\n ];\n }),\n });\n};\n\n/** `['StrictPick', 'RelaxedPick']` → `` '`StrictPick` or `RelaxedPick`' ``. */\nconst formatAlternatives = (candidates: readonly string[]): string =>\n candidates.map((candidate) => `\\`${candidate}\\``).join(' or ');\n\n/**\n * Whether `name` resolves to a declaration in this file (a type alias, an\n * interface, a class, an import, …) rather than to the ambient standard-library\n * type of the same name.\n *\n * The `defs` check is what separates the two: ESLint materializes an *implicit*\n * global variable for every unresolved reference, so `findVariable` also\n * succeeds for `Exclude` in a file that never declares it. Only a variable with\n * at least one definition was actually written down somewhere in this file.\n */\nconst isLocallyBound = (\n sourceCode: TSESLint.SourceCode,\n node: TSESTree.Node,\n name: string,\n): boolean => {\n const variable = ASTUtils.findVariable(sourceCode.getScope(node), name);\n\n return variable !== null && Arr.isNonEmpty(variable.defs);\n};\n"],"names":[],"mappings":";;;;;AA4BO,MAAM,yBAAyB,CACpC,OAAA,EACA,IAAA,EACA,YAAA,EACA,YACA,WAAA,KACS;AACT,EAAA,MAAM,WAAW,IAAA,CAAK,QAAA;AAGtB,EAAA,IAAI,QAAA,CAAS,IAAA,KAAS,cAAA,CAAe,UAAA,EAAY;AAEjD,EAAA,MAAM,UAAU,QAAA,CAAS,IAAA;AAEzB,EAAA,MAAM,UAAA,GAAa,YAAA,CAAa,GAAA,CAAI,OAAO,CAAA;AAE3C,EAAA,IAAI,eAAe,MAAA,EAAW;AAI9B,EAAA,IAAI,cAAA,CAAe,OAAA,CAAQ,UAAA,EAAY,IAAA,EAAM,OAAO,CAAA,EAAG;AAEvD,EAAA,MAAM,OAAA,GAAU,QAAQ,UAAA,CAAW,GAAA;AAEnC,EAAA,OAAA,CAAQ,MAAA,CAAO;AAAA,IACb,IAAA,EAAM,QAAA;AAAA,IACN,WAAW,UAAA,CAAW,MAAA;AAAA,IACtB,MAAM,EAAE,IAAA,EAAM,SAAS,YAAA,EAAc,kBAAA,CAAmB,UAAU,CAAA,EAAE;AAAA,IACpE,OAAA,EAAS,UAAA,CAAW,OAAA,CAAQ,CAAC,SAAA,KAAc;AACzC,MAAA,MAAM,MAAA,GAAS,eAAA,CAAgB,OAAA,EAAS,SAAA,EAAW,WAAW,CAAA;AAI9D,MAAA,OAAO,MAAA,KAAW,MAAA,GACd,EAAC,GACD;AAAA,QACE;AAAA,UACE,WAAW,UAAA,CAAW,UAAA;AAAA,UACtB,MAAM,EAAE,IAAA,EAAM,OAAA,EAAS,WAAA,EAAa,OAAO,SAAA,EAAU;AAAA,UACrD,GAAA,EAAK,CAAC,KAAA,KACJ,GAAA,CAAI,QAAA;AAAA,YACF,OAAO,WAAA,GACH,kBAAA,CAAmB,OAAO,OAAA,EAAS,SAAS,IAC5C,EAAC;AAAA,YACL,KAAA,CAAM,WAAA,CAAY,QAAA,EAAU,MAAA,CAAO,SAAS;AAAA;AAC9C;AACJ,OACF;AAAA,IACN,CAAC;AAAA,GACF,CAAA;AACH;AAGA,MAAM,kBAAA,GAAqB,CAAC,UAAA,KAC1B,UAAA,CAAW,GAAA,CAAI,CAAC,SAAA,KAAc,CAAA,EAAA,EAAK,SAAS,CAAA,EAAA,CAAI,CAAA,CAAE,IAAA,CAAK,MAAM,CAAA;AAY/D,MAAM,cAAA,GAAiB,CACrB,UAAA,EACA,IAAA,EACA,IAAA,KACY;AACZ,EAAA,MAAM,WAAW,QAAA,CAAS,YAAA,CAAa,WAAW,QAAA,CAAS,IAAI,GAAG,IAAI,CAAA;AAEtE,EAAA,OAAO,QAAA,KAAa,IAAA,IAAQ,GAAA,CAAI,UAAA,CAAW,SAAS,IAAI,CAAA;AAC1D,CAAA;;;;"}
@@ -1,22 +1,5 @@
1
1
  import { type TSESLint, type TSESTree } from '@typescript-eslint/utils';
2
- /** `JSONSchema4`, reached through the public rule-module types. */
3
- type JsonSchema = Exclude<TSESLint.RuleModule<string, readonly unknown[]>['meta']['schema'], readonly unknown[]>;
4
- export type ImportStyle = 'global' | 'named';
5
- export declare const DEFAULT_IMPORT_STYLE: ImportStyle;
6
- export declare const IMPORT_STYLE_SCHEMA_PROPERTY: JsonSchema;
7
- /**
8
- * Resolves how a ts-type-forge type name should be written in this file, and
9
- * whether the autofix has to add an import for it.
10
- *
11
- * Returns `undefined` when the name is already bound to something else (a local
12
- * alias, or an import from another module) — rewriting to it would then silently
13
- * resolve to the wrong declaration, which is exactly what happens inside
14
- * ts-type-forge's own sources.
15
- */
16
- export declare const resolveTypeName: (program: TSESTree.Program, canonicalName: string, importStyle: ImportStyle) => Readonly<{
17
- localName: string;
18
- needsImport: boolean;
19
- }> | undefined;
2
+ import { type ImportStyle } from './import-style.mjs';
20
3
  export type TupleRewrite = Readonly<{
21
4
  node: TSESTree.Node;
22
5
  canonicalName: string;
@@ -33,5 +16,4 @@ export type TupleRewrite = Readonly<{
33
16
  * survives per pass.
34
17
  */
35
18
  export declare const reportTupleRewrites: <MessageIds extends string>(context: TSESLint.RuleContext<MessageIds, readonly unknown[]>, rewrites: readonly TupleRewrite[], messageId: MessageIds, importStyle: ImportStyle) => void;
36
- export {};
37
19
  //# sourceMappingURL=tuple-rule-utils.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"tuple-rule-utils.d.mts","sourceRoot":"","sources":["../../src/rules/tuple-rule-utils.mts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,QAAQ,EAAE,KAAK,QAAQ,EAAE,MAAM,0BAA0B,CAAC;AAYxE,mEAAmE;AACnE,KAAK,UAAU,GAAG,OAAO,CACvB,QAAQ,CAAC,UAAU,CAAC,MAAM,EAAE,SAAS,OAAO,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,EACjE,SAAS,OAAO,EAAE,CACnB,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG,QAAQ,GAAG,OAAO,CAAC;AAE7C,eAAO,MAAM,oBAAoB,EAAE,WAAqB,CAAC;AAEzD,eAAO,MAAM,4BAA4B,EAAE,UAW1C,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe,YACjB,QAAQ,CAAC,OAAO,iBACV,MAAM,eACR,WAAW,KACvB,QAAQ,CAAC;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,OAAO,CAAA;CAAE,CAAC,GAAG,SAa1D,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG,QAAQ,CAAC;IAClC,IAAI,EAAE,QAAQ,CAAC,IAAI,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,sEAAsE;IACtE,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC,CAAC;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAAI,UAAU,SAAS,MAAM,WAClD,QAAQ,CAAC,WAAW,CAAC,UAAU,EAAE,SAAS,OAAO,EAAE,CAAC,YACnD,SAAS,YAAY,EAAE,aACtB,UAAU,eACR,WAAW,KACvB,IAuCF,CAAC"}
1
+ {"version":3,"file":"tuple-rule-utils.d.mts","sourceRoot":"","sources":["../../src/rules/tuple-rule-utils.mts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,QAAQ,EAAE,KAAK,QAAQ,EAAE,MAAM,0BAA0B,CAAC;AAExE,OAAO,EAAmB,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAKvE,MAAM,MAAM,YAAY,GAAG,QAAQ,CAAC;IAClC,IAAI,EAAE,QAAQ,CAAC,IAAI,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,sEAAsE;IACtE,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC,CAAC;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAAI,UAAU,SAAS,MAAM,WAClD,QAAQ,CAAC,WAAW,CAAC,UAAU,EAAE,SAAS,OAAO,EAAE,CAAC,YACnD,SAAS,YAAY,EAAE,aACtB,UAAU,eACR,WAAW,KACvB,IAuCF,CAAC"}
@@ -1,30 +1,7 @@
1
1
  import { Arr } from 'ts-data-forge';
2
- import { TS_TYPE_FORGE_MODULE } from './constants.mjs';
3
- import { buildTypeImportFix, getImportedLocalName, getTsTypeForgeImports, hasConflictingDeclaration } from './import-utils.mjs';
2
+ import { resolveTypeName } from './import-style.mjs';
3
+ import { buildTypeImportFix } from './import-utils.mjs';
4
4
 
5
- const DEFAULT_IMPORT_STYLE = "named";
6
- const IMPORT_STYLE_SCHEMA_PROPERTY = {
7
- type: "string",
8
- enum: ["global", "named"],
9
- description: [
10
- "How the ts-type-forge type is brought into scope.",
11
- "'named' (default) makes the autofix add the corresponding",
12
- `\`import { type \u2026 } from '${TS_TYPE_FORGE_MODULE}';\``,
13
- "when the name is not imported yet; 'global' assumes the",
14
- "ambient globals of `ts-type-forge/global` and never touches",
15
- "imports."
16
- ].join(" ")
17
- };
18
- const resolveTypeName = (program, canonicalName, importStyle) => {
19
- const importedLocalName = getImportedLocalName(
20
- getTsTypeForgeImports(program),
21
- canonicalName
22
- );
23
- if (importedLocalName !== void 0) {
24
- return { localName: importedLocalName, needsImport: false };
25
- }
26
- return hasConflictingDeclaration(program, canonicalName) ? void 0 : { localName: canonicalName, needsImport: importStyle === "named" };
27
- };
28
5
  const reportTupleRewrites = (context, rewrites, messageId, importStyle) => {
29
6
  const sourceCode = context.sourceCode;
30
7
  const program = sourceCode.ast;
@@ -53,5 +30,5 @@ const reportTupleRewrites = (context, rewrites, messageId, importStyle) => {
53
30
  }
54
31
  };
55
32
 
56
- export { DEFAULT_IMPORT_STYLE, IMPORT_STYLE_SCHEMA_PROPERTY, reportTupleRewrites, resolveTypeName };
33
+ export { reportTupleRewrites };
57
34
  //# sourceMappingURL=tuple-rule-utils.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"tuple-rule-utils.mjs","sources":["../../src/rules/tuple-rule-utils.mts"],"sourcesContent":["import { type TSESLint, type TSESTree } from '@typescript-eslint/utils';\nimport { Arr } from 'ts-data-forge';\nimport { TS_TYPE_FORGE_MODULE } from './constants.mjs';\nimport {\n buildTypeImportFix,\n getImportedLocalName,\n getTsTypeForgeImports,\n hasConflictingDeclaration,\n} from './import-utils.mjs';\n\n/* eslint-disable @typescript-eslint/prefer-readonly-parameter-types */\n\n/** `JSONSchema4`, reached through the public rule-module types. */\ntype JsonSchema = Exclude<\n TSESLint.RuleModule<string, readonly unknown[]>['meta']['schema'],\n readonly unknown[]\n>;\n\nexport type ImportStyle = 'global' | 'named';\n\nexport const DEFAULT_IMPORT_STYLE: ImportStyle = 'named';\n\nexport const IMPORT_STYLE_SCHEMA_PROPERTY: JsonSchema = {\n type: 'string',\n enum: ['global', 'named'],\n description: [\n 'How the ts-type-forge type is brought into scope.',\n \"'named' (default) makes the autofix add the corresponding\",\n `\\`import { type … } from '${TS_TYPE_FORGE_MODULE}';\\``,\n \"when the name is not imported yet; 'global' assumes the\",\n 'ambient globals of `ts-type-forge/global` and never touches',\n 'imports.',\n ].join(' '),\n};\n\n/**\n * Resolves how a ts-type-forge type name should be written in this file, and\n * whether the autofix has to add an import for it.\n *\n * Returns `undefined` when the name is already bound to something else (a local\n * alias, or an import from another module) — rewriting to it would then silently\n * resolve to the wrong declaration, which is exactly what happens inside\n * ts-type-forge's own sources.\n */\nexport const resolveTypeName = (\n program: TSESTree.Program,\n canonicalName: string,\n importStyle: ImportStyle,\n): Readonly<{ localName: string; needsImport: boolean }> | undefined => {\n const importedLocalName = getImportedLocalName(\n getTsTypeForgeImports(program),\n canonicalName,\n );\n\n if (importedLocalName !== undefined) {\n return { localName: importedLocalName, needsImport: false };\n }\n\n return hasConflictingDeclaration(program, canonicalName)\n ? undefined\n : { localName: canonicalName, needsImport: importStyle === 'named' };\n};\n\nexport type TupleRewrite = Readonly<{\n node: TSESTree.Node;\n canonicalName: string;\n /** Text inside the `<...>` of the replacement, e.g. `'2, string'`. */\n typeArgs: string;\n}>;\n\n/**\n * Emits one report per rewrite, resolving each target name and attaching every\n * required import to the *first* report.\n *\n * All import fixes must live on a single report: `insertTextBefore(program, …)`\n * produces overlapping ranges, and ESLint merges each report's fixes into one\n * span — so spreading them across reports makes the fixes conflict and only one\n * survives per pass.\n */\nexport const reportTupleRewrites = <MessageIds extends string>(\n context: TSESLint.RuleContext<MessageIds, readonly unknown[]>,\n rewrites: readonly TupleRewrite[],\n messageId: MessageIds,\n importStyle: ImportStyle,\n): void => {\n const sourceCode = context.sourceCode;\n\n const program = sourceCode.ast;\n\n const resolved = rewrites.flatMap((rewrite) => {\n const target = resolveTypeName(program, rewrite.canonicalName, importStyle);\n\n // The name is already bound to something else in this file, so the rewrite\n // would resolve to the wrong declaration.\n return target === undefined ? [] : [{ rewrite, target }];\n });\n\n const importNames = Array.from(\n new Set(\n resolved\n .filter(({ target }) => target.needsImport)\n .map(({ rewrite }) => rewrite.canonicalName),\n ),\n );\n\n for (const [index, { rewrite, target }] of resolved.entries()) {\n const replacement = `${target.localName}<${rewrite.typeArgs}>`;\n\n context.report({\n node: rewrite.node,\n messageId,\n data: { original: sourceCode.getText(rewrite.node), replacement },\n fix: (fixer) =>\n Arr.toPushed(\n index === 0\n ? importNames.flatMap((name) =>\n buildTypeImportFix(fixer, program, name),\n )\n : [],\n fixer.replaceText(rewrite.node, replacement),\n ),\n });\n }\n};\n"],"names":[],"mappings":";;;;AAoBO,MAAM,oBAAA,GAAoC;AAE1C,MAAM,4BAAA,GAA2C;AAAA,EACtD,IAAA,EAAM,QAAA;AAAA,EACN,IAAA,EAAM,CAAC,QAAA,EAAU,OAAO,CAAA;AAAA,EACxB,WAAA,EAAa;AAAA,IACX,mDAAA;AAAA,IACA,2DAAA;AAAA,IACA,kCAA6B,oBAAoB,CAAA,IAAA,CAAA;AAAA,IACjD,yDAAA;AAAA,IACA,6DAAA;AAAA,IACA;AAAA,GACF,CAAE,KAAK,GAAG;AACZ;AAWO,MAAM,eAAA,GAAkB,CAC7B,OAAA,EACA,aAAA,EACA,WAAA,KACsE;AACtE,EAAA,MAAM,iBAAA,GAAoB,oBAAA;AAAA,IACxB,sBAAsB,OAAO,CAAA;AAAA,IAC7B;AAAA,GACF;AAEA,EAAA,IAAI,sBAAsB,MAAA,EAAW;AACnC,IAAA,OAAO,EAAE,SAAA,EAAW,iBAAA,EAAmB,WAAA,EAAa,KAAA,EAAM;AAAA,EAC5D;AAEA,EAAA,OAAO,yBAAA,CAA0B,OAAA,EAAS,aAAa,CAAA,GACnD,MAAA,GACA,EAAE,SAAA,EAAW,aAAA,EAAe,WAAA,EAAa,WAAA,KAAgB,OAAA,EAAQ;AACvE;AAkBO,MAAM,mBAAA,GAAsB,CACjC,OAAA,EACA,QAAA,EACA,WACA,WAAA,KACS;AACT,EAAA,MAAM,aAAa,OAAA,CAAQ,UAAA;AAE3B,EAAA,MAAM,UAAU,UAAA,CAAW,GAAA;AAE3B,EAAA,MAAM,QAAA,GAAW,QAAA,CAAS,OAAA,CAAQ,CAAC,OAAA,KAAY;AAC7C,IAAA,MAAM,MAAA,GAAS,eAAA,CAAgB,OAAA,EAAS,OAAA,CAAQ,eAAe,WAAW,CAAA;AAI1E,IAAA,OAAO,MAAA,KAAW,SAAY,EAAC,GAAI,CAAC,EAAE,OAAA,EAAS,QAAQ,CAAA;AAAA,EACzD,CAAC,CAAA;AAED,EAAA,MAAM,cAAc,KAAA,CAAM,IAAA;AAAA,IACxB,IAAI,GAAA;AAAA,MACF,QAAA,CACG,MAAA,CAAO,CAAC,EAAE,QAAO,KAAM,MAAA,CAAO,WAAW,CAAA,CACzC,IAAI,CAAC,EAAE,OAAA,EAAQ,KAAM,QAAQ,aAAa;AAAA;AAC/C,GACF;AAEA,EAAA,KAAA,MAAW,CAAC,OAAO,EAAE,OAAA,EAAS,QAAQ,CAAA,IAAK,QAAA,CAAS,OAAA,EAAQ,EAAG;AAC7D,IAAA,MAAM,cAAc,CAAA,EAAG,MAAA,CAAO,SAAS,CAAA,CAAA,EAAI,QAAQ,QAAQ,CAAA,CAAA,CAAA;AAE3D,IAAA,OAAA,CAAQ,MAAA,CAAO;AAAA,MACb,MAAM,OAAA,CAAQ,IAAA;AAAA,MACd,SAAA;AAAA,MACA,IAAA,EAAM,EAAE,QAAA,EAAU,UAAA,CAAW,QAAQ,OAAA,CAAQ,IAAI,GAAG,WAAA,EAAY;AAAA,MAChE,GAAA,EAAK,CAAC,KAAA,KACJ,GAAA,CAAI,QAAA;AAAA,QACF,KAAA,KAAU,IACN,WAAA,CAAY,OAAA;AAAA,UAAQ,CAAC,IAAA,KACnB,kBAAA,CAAmB,KAAA,EAAO,SAAS,IAAI;AAAA,YAEzC,EAAC;AAAA,QACL,KAAA,CAAM,WAAA,CAAY,OAAA,CAAQ,IAAA,EAAM,WAAW;AAAA;AAC7C,KACH,CAAA;AAAA,EACH;AACF;;;;"}
1
+ {"version":3,"file":"tuple-rule-utils.mjs","sources":["../../src/rules/tuple-rule-utils.mts"],"sourcesContent":["import { type TSESLint, type TSESTree } from '@typescript-eslint/utils';\nimport { Arr } from 'ts-data-forge';\nimport { resolveTypeName, type ImportStyle } from './import-style.mjs';\nimport { buildTypeImportFix } from './import-utils.mjs';\n\n/* eslint-disable @typescript-eslint/prefer-readonly-parameter-types */\n\nexport type TupleRewrite = Readonly<{\n node: TSESTree.Node;\n canonicalName: string;\n /** Text inside the `<...>` of the replacement, e.g. `'2, string'`. */\n typeArgs: string;\n}>;\n\n/**\n * Emits one report per rewrite, resolving each target name and attaching every\n * required import to the *first* report.\n *\n * All import fixes must live on a single report: `insertTextBefore(program, …)`\n * produces overlapping ranges, and ESLint merges each report's fixes into one\n * span — so spreading them across reports makes the fixes conflict and only one\n * survives per pass.\n */\nexport const reportTupleRewrites = <MessageIds extends string>(\n context: TSESLint.RuleContext<MessageIds, readonly unknown[]>,\n rewrites: readonly TupleRewrite[],\n messageId: MessageIds,\n importStyle: ImportStyle,\n): void => {\n const sourceCode = context.sourceCode;\n\n const program = sourceCode.ast;\n\n const resolved = rewrites.flatMap((rewrite) => {\n const target = resolveTypeName(program, rewrite.canonicalName, importStyle);\n\n // The name is already bound to something else in this file, so the rewrite\n // would resolve to the wrong declaration.\n return target === undefined ? [] : [{ rewrite, target }];\n });\n\n const importNames = Array.from(\n new Set(\n resolved\n .filter(({ target }) => target.needsImport)\n .map(({ rewrite }) => rewrite.canonicalName),\n ),\n );\n\n for (const [index, { rewrite, target }] of resolved.entries()) {\n const replacement = `${target.localName}<${rewrite.typeArgs}>`;\n\n context.report({\n node: rewrite.node,\n messageId,\n data: { original: sourceCode.getText(rewrite.node), replacement },\n fix: (fixer) =>\n Arr.toPushed(\n index === 0\n ? importNames.flatMap((name) =>\n buildTypeImportFix(fixer, program, name),\n )\n : [],\n fixer.replaceText(rewrite.node, replacement),\n ),\n });\n }\n};\n"],"names":[],"mappings":";;;;AAuBO,MAAM,mBAAA,GAAsB,CACjC,OAAA,EACA,QAAA,EACA,WACA,WAAA,KACS;AACT,EAAA,MAAM,aAAa,OAAA,CAAQ,UAAA;AAE3B,EAAA,MAAM,UAAU,UAAA,CAAW,GAAA;AAE3B,EAAA,MAAM,QAAA,GAAW,QAAA,CAAS,OAAA,CAAQ,CAAC,OAAA,KAAY;AAC7C,IAAA,MAAM,MAAA,GAAS,eAAA,CAAgB,OAAA,EAAS,OAAA,CAAQ,eAAe,WAAW,CAAA;AAI1E,IAAA,OAAO,MAAA,KAAW,SAAY,EAAC,GAAI,CAAC,EAAE,OAAA,EAAS,QAAQ,CAAA;AAAA,EACzD,CAAC,CAAA;AAED,EAAA,MAAM,cAAc,KAAA,CAAM,IAAA;AAAA,IACxB,IAAI,GAAA;AAAA,MACF,QAAA,CACG,MAAA,CAAO,CAAC,EAAE,QAAO,KAAM,MAAA,CAAO,WAAW,CAAA,CACzC,IAAI,CAAC,EAAE,OAAA,EAAQ,KAAM,QAAQ,aAAa;AAAA;AAC/C,GACF;AAEA,EAAA,KAAA,MAAW,CAAC,OAAO,EAAE,OAAA,EAAS,QAAQ,CAAA,IAAK,QAAA,CAAS,OAAA,EAAQ,EAAG;AAC7D,IAAA,MAAM,cAAc,CAAA,EAAG,MAAA,CAAO,SAAS,CAAA,CAAA,EAAI,QAAQ,QAAQ,CAAA,CAAA,CAAA;AAE3D,IAAA,OAAA,CAAQ,MAAA,CAAO;AAAA,MACb,MAAM,OAAA,CAAQ,IAAA;AAAA,MACd,SAAA;AAAA,MACA,IAAA,EAAM,EAAE,QAAA,EAAU,UAAA,CAAW,QAAQ,OAAA,CAAQ,IAAI,GAAG,WAAA,EAAY;AAAA,MAChE,GAAA,EAAK,CAAC,KAAA,KACJ,GAAA,CAAI,QAAA;AAAA,QACF,KAAA,KAAU,IACN,WAAA,CAAY,OAAA;AAAA,UAAQ,CAAC,IAAA,KACnB,kBAAA,CAAmB,KAAA,EAAO,SAAS,IAAI;AAAA,YAEzC,EAAC;AAAA,QACL,KAAA,CAAM,WAAA,CAAY,OAAA,CAAQ,IAAA,EAAM,WAAW;AAAA;AAC7C,KACH,CAAA;AAAA,EACH;AACF;;;;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-ts-type-forge",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "description": "ESLint rules that steer type declarations toward ts-type-forge idioms.",
6
6
  "keywords": [
@@ -37,7 +37,7 @@
37
37
  "dependencies": {
38
38
  "@typescript-eslint/utils": "~8.65.0",
39
39
  "ts-data-forge": "~14.0.0",
40
- "ts-type-forge": "9.1.1"
40
+ "ts-type-forge": "9.1.2"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@typescript-eslint/parser": "8.65.0",
package/src/plugin.mts CHANGED
@@ -9,6 +9,8 @@ import { type ESLintFlatConfig, type ESLintPlugin } from './types.mjs';
9
9
  */
10
10
  const recommendedRules = {
11
11
  'ts-type-forge/prefer-canonical-length-constrained-tuple': 'error',
12
+ 'ts-type-forge/prefer-readonly-or-mutable-record': 'error',
13
+ 'ts-type-forge/prefer-strict-or-relaxed-utility-type': 'error',
12
14
  } as const satisfies Readonly<
13
15
  Record<`ts-type-forge/${keyof typeof tsTypeForgeRules}`, 'error'>
14
16
  >;
@@ -76,12 +76,104 @@ namespace PreferCanonicalLengthConstrainedTuple {
76
76
  | SpreadOptionsIfIsArray<readonly [Linter.StringSeverity, Options]>;
77
77
  }
78
78
 
79
+ /**
80
+ * @description Replace the built-in `Record` with the ts-type-forge `ReadonlyRecord` or `MutableRecord`, so that the mutability of the record is stated rather than implied.
81
+ *
82
+ * ```md
83
+ * | key | value |
84
+ * | :--------- | :--------- |
85
+ * | type | suggestion |
86
+ * | deprecated | false |
87
+ * | fixable | false |
88
+ * ```
89
+ */
90
+ namespace PreferReadonlyOrMutableRecord {
91
+ /**
92
+ * ### schema
93
+ *
94
+ * ```json
95
+ * [
96
+ * {
97
+ * "type": "object",
98
+ * "properties": {
99
+ * "importStyle": {
100
+ * "type": "string",
101
+ * "enum": [
102
+ * "global",
103
+ * "named"
104
+ * ],
105
+ * "description": "How the ts-type-forge type is brought into scope. 'named' (default) makes the autofix add the corresponding `import { type … } from 'ts-type-forge';` when the name is not imported yet; 'global' assumes the ambient globals of `ts-type-forge/global` and never touches imports."
106
+ * }
107
+ * },
108
+ * "additionalProperties": false
109
+ * }
110
+ * ]
111
+ * ```
112
+ */
113
+ export type Options = NonNullable<
114
+ RuleOptionsOf<'prefer-readonly-or-mutable-record'>[0]
115
+ >;
116
+
117
+ export type RuleEntry =
118
+ | 'off'
119
+ | Linter.Severity
120
+ | SpreadOptionsIfIsArray<readonly [Linter.StringSeverity, Options]>;
121
+ }
122
+
123
+ /**
124
+ * @description Replace the built-in `Exclude` / `Extract` / `Omit` / `Pick` with the ts-type-forge `Strict*` or `Relaxed*` counterpart, so that the unchecked second argument becomes an explicit choice.
125
+ *
126
+ * ```md
127
+ * | key | value |
128
+ * | :--------- | :--------- |
129
+ * | type | suggestion |
130
+ * | deprecated | false |
131
+ * | fixable | false |
132
+ * ```
133
+ */
134
+ namespace PreferStrictOrRelaxedUtilityType {
135
+ /**
136
+ * ### schema
137
+ *
138
+ * ```json
139
+ * [
140
+ * {
141
+ * "type": "object",
142
+ * "properties": {
143
+ * "importStyle": {
144
+ * "type": "string",
145
+ * "enum": [
146
+ * "global",
147
+ * "named"
148
+ * ],
149
+ * "description": "How the ts-type-forge type is brought into scope. 'named' (default) makes the autofix add the corresponding `import { type … } from 'ts-type-forge';` when the name is not imported yet; 'global' assumes the ambient globals of `ts-type-forge/global` and never touches imports."
150
+ * }
151
+ * },
152
+ * "additionalProperties": false
153
+ * }
154
+ * ]
155
+ * ```
156
+ */
157
+ export type Options = NonNullable<
158
+ RuleOptionsOf<'prefer-strict-or-relaxed-utility-type'>[0]
159
+ >;
160
+
161
+ export type RuleEntry =
162
+ | 'off'
163
+ | Linter.Severity
164
+ | SpreadOptionsIfIsArray<readonly [Linter.StringSeverity, Options]>;
165
+ }
166
+
79
167
  export type EslintTsTypeForgeRules = Readonly<{
80
168
  'ts-type-forge/prefer-canonical-length-constrained-tuple': PreferCanonicalLengthConstrainedTuple.RuleEntry;
169
+ 'ts-type-forge/prefer-readonly-or-mutable-record': PreferReadonlyOrMutableRecord.RuleEntry;
170
+ 'ts-type-forge/prefer-strict-or-relaxed-utility-type': PreferStrictOrRelaxedUtilityType.RuleEntry;
81
171
  }>;
82
172
 
83
173
  export type EslintTsTypeForgeRulesOption = Readonly<{
84
174
  'ts-type-forge/prefer-canonical-length-constrained-tuple': PreferCanonicalLengthConstrainedTuple.Options;
175
+ 'ts-type-forge/prefer-readonly-or-mutable-record': PreferReadonlyOrMutableRecord.Options;
176
+ 'ts-type-forge/prefer-strict-or-relaxed-utility-type': PreferStrictOrRelaxedUtilityType.Options;
85
177
  }>;
86
178
 
87
179
  // If this assertion fails to type-check, this generated file has drifted from
@@ -0,0 +1,60 @@
1
+ import { type TSESLint, type TSESTree } from '@typescript-eslint/utils';
2
+ import { TS_TYPE_FORGE_MODULE } from './constants.mjs';
3
+ import {
4
+ getImportedLocalName,
5
+ getTsTypeForgeImports,
6
+ hasConflictingDeclaration,
7
+ } from './import-utils.mjs';
8
+
9
+ /* eslint-disable @typescript-eslint/prefer-readonly-parameter-types */
10
+
11
+ /** `JSONSchema4`, reached through the public rule-module types. */
12
+ type JsonSchema = Exclude<
13
+ TSESLint.RuleModule<string, readonly unknown[]>['meta']['schema'],
14
+ readonly unknown[]
15
+ >;
16
+
17
+ export type ImportStyle = 'global' | 'named';
18
+
19
+ export const DEFAULT_IMPORT_STYLE: ImportStyle = 'named';
20
+
21
+ export const IMPORT_STYLE_SCHEMA_PROPERTY: JsonSchema = {
22
+ type: 'string',
23
+ enum: ['global', 'named'],
24
+ description: [
25
+ 'How the ts-type-forge type is brought into scope.',
26
+ "'named' (default) makes the autofix add the corresponding",
27
+ `\`import { type … } from '${TS_TYPE_FORGE_MODULE}';\``,
28
+ "when the name is not imported yet; 'global' assumes the",
29
+ 'ambient globals of `ts-type-forge/global` and never touches',
30
+ 'imports.',
31
+ ].join(' '),
32
+ };
33
+
34
+ /**
35
+ * Resolves how a ts-type-forge type name should be written in this file, and
36
+ * whether the fix has to add an import for it.
37
+ *
38
+ * Returns `undefined` when the name is already bound to something else (a local
39
+ * alias, or an import from another module) — rewriting to it would then silently
40
+ * resolve to the wrong declaration, which is exactly what happens inside
41
+ * ts-type-forge's own sources.
42
+ */
43
+ export const resolveTypeName = (
44
+ program: TSESTree.Program,
45
+ canonicalName: string,
46
+ importStyle: ImportStyle,
47
+ ): Readonly<{ localName: string; needsImport: boolean }> | undefined => {
48
+ const importedLocalName = getImportedLocalName(
49
+ getTsTypeForgeImports(program),
50
+ canonicalName,
51
+ );
52
+
53
+ if (importedLocalName !== undefined) {
54
+ return { localName: importedLocalName, needsImport: false };
55
+ }
56
+
57
+ return hasConflictingDeclaration(program, canonicalName)
58
+ ? undefined
59
+ : { localName: canonicalName, needsImport: importStyle === 'named' };
60
+ };
@@ -12,9 +12,8 @@ import {
12
12
  import {
13
13
  DEFAULT_IMPORT_STYLE,
14
14
  IMPORT_STYLE_SCHEMA_PROPERTY,
15
- reportTupleRewrites,
16
- type TupleRewrite,
17
- } from './tuple-rule-utils.mjs';
15
+ } from './import-style.mjs';
16
+ import { reportTupleRewrites, type TupleRewrite } from './tuple-rule-utils.mjs';
18
17
 
19
18
  type Options = readonly [
20
19
  Readonly<{
@@ -0,0 +1,74 @@
1
+ import { type TSESLint } from '@typescript-eslint/utils';
2
+ import {
3
+ DEFAULT_IMPORT_STYLE,
4
+ IMPORT_STYLE_SCHEMA_PROPERTY,
5
+ } from './import-style.mjs';
6
+ import {
7
+ reportStdTypeReference,
8
+ type StdTypeAlternatives,
9
+ } from './std-type-rule-utils.mjs';
10
+
11
+ type Options = readonly [
12
+ Readonly<{
13
+ importStyle?: 'global' | 'named';
14
+ }>?,
15
+ ];
16
+
17
+ type MessageIds = 'useReadonlyOrMutableRecord' | 'replaceWith';
18
+
19
+ /**
20
+ * `Record` says nothing about whether the properties may be reassigned, so the
21
+ * mutability of every record type spelled with it is decided by whoever reads
22
+ * it. The ts-type-forge pair spells it out.
23
+ */
24
+ const ALTERNATIVES: StdTypeAlternatives = new Map([
25
+ ['Record', ['ReadonlyRecord', 'MutableRecord']],
26
+ ]);
27
+
28
+ export const preferReadonlyOrMutableRecord: TSESLint.RuleModule<
29
+ MessageIds,
30
+ Options
31
+ > = {
32
+ meta: {
33
+ type: 'suggestion',
34
+ docs: {
35
+ description:
36
+ 'Replace the built-in `Record` with the ts-type-forge `ReadonlyRecord` or `MutableRecord`, so that the mutability of the record is stated rather than implied.',
37
+ },
38
+ hasSuggestions: true,
39
+ schema: [
40
+ {
41
+ type: 'object',
42
+ properties: {
43
+ importStyle: IMPORT_STYLE_SCHEMA_PROPERTY,
44
+ },
45
+ additionalProperties: false,
46
+ },
47
+ ],
48
+ messages: {
49
+ useReadonlyOrMutableRecord:
50
+ 'Use {{alternatives}} from ts-type-forge instead of the built-in `{{name}}`.',
51
+ replaceWith: 'Replace `{{name}}` with `{{replacement}}`.',
52
+ },
53
+ },
54
+
55
+ create: (context) => {
56
+ const importStyle = context.options[0]?.importStyle ?? DEFAULT_IMPORT_STYLE;
57
+
58
+ return {
59
+ TSTypeReference: (node) => {
60
+ reportStdTypeReference(
61
+ context,
62
+ node,
63
+ ALTERNATIVES,
64
+ {
65
+ report: 'useReadonlyOrMutableRecord',
66
+ suggestion: 'replaceWith',
67
+ },
68
+ importStyle,
69
+ );
70
+ },
71
+ };
72
+ },
73
+ defaultOptions: [{ importStyle: DEFAULT_IMPORT_STYLE }],
74
+ } as const;
@@ -0,0 +1,160 @@
1
+ import parser from '@typescript-eslint/parser';
2
+ import { RuleTester } from '@typescript-eslint/rule-tester';
3
+ import dedent from 'dedent';
4
+ import { preferReadonlyOrMutableRecord } from './prefer-readonly-or-mutable-record.mjs';
5
+
6
+ const tester = new RuleTester({
7
+ languageOptions: {
8
+ parser,
9
+ parserOptions: { ecmaVersion: 2020, sourceType: 'module' },
10
+ },
11
+ });
12
+
13
+ describe('prefer-readonly-or-mutable-record', () => {
14
+ tester.run(
15
+ 'prefer-readonly-or-mutable-record',
16
+ preferReadonlyOrMutableRecord,
17
+ {
18
+ valid: [
19
+ {
20
+ name: 'accepts the ts-type-forge counterparts',
21
+ code: dedent`
22
+ type A = ReadonlyRecord<string, number>;
23
+ type B = MutableRecord<'a' | 'b', number>;
24
+ `,
25
+ },
26
+ {
27
+ name: 'ignores unrelated standard-library types',
28
+ code: 'type A = Readonly<Partial<{ a: 1 }>>;',
29
+ },
30
+ {
31
+ name: 'ignores a qualified name that merely ends in `Record`',
32
+ code: 'type A = Lib.Record<string, number>;',
33
+ },
34
+ {
35
+ name: 'ignores a locally declared type of the same name',
36
+ code: dedent`
37
+ type Record<K extends PropertyKey, V> = { readonly [P in K]: V };
38
+ type A = Record<string, number>;
39
+ `,
40
+ },
41
+ {
42
+ name: 'ignores a name imported from another module',
43
+ code: dedent`
44
+ import { type Record } from './my-types.mjs';
45
+ type A = Record<string, number>;
46
+ `,
47
+ },
48
+ ],
49
+ invalid: [
50
+ {
51
+ name: 'reports `Record` and suggests both counterparts',
52
+ code: 'type A = Record<string, number>;',
53
+ errors: [
54
+ {
55
+ messageId: 'useReadonlyOrMutableRecord',
56
+ data: {
57
+ name: 'Record',
58
+ alternatives: '`ReadonlyRecord` or `MutableRecord`',
59
+ },
60
+ suggestions: [
61
+ {
62
+ messageId: 'replaceWith',
63
+ data: { name: 'Record', replacement: 'ReadonlyRecord' },
64
+ output: dedent`
65
+ import { type ReadonlyRecord } from 'ts-type-forge';
66
+ type A = ReadonlyRecord<string, number>;
67
+ `,
68
+ },
69
+ {
70
+ messageId: 'replaceWith',
71
+ data: { name: 'Record', replacement: 'MutableRecord' },
72
+ output: dedent`
73
+ import { type MutableRecord } from 'ts-type-forge';
74
+ type A = MutableRecord<string, number>;
75
+ `,
76
+ },
77
+ ],
78
+ },
79
+ ],
80
+ },
81
+ {
82
+ name: 'touches no import under the `global` import style',
83
+ options: [{ importStyle: 'global' }],
84
+ code: 'type A = Record<string, number>;',
85
+ errors: [
86
+ {
87
+ messageId: 'useReadonlyOrMutableRecord',
88
+ suggestions: [
89
+ {
90
+ messageId: 'replaceWith',
91
+ output: 'type A = ReadonlyRecord<string, number>;',
92
+ },
93
+ {
94
+ messageId: 'replaceWith',
95
+ output: 'type A = MutableRecord<string, number>;',
96
+ },
97
+ ],
98
+ },
99
+ ],
100
+ },
101
+ {
102
+ name: 'reuses an existing ts-type-forge import, alias included',
103
+ code: dedent`
104
+ import { type ReadonlyRecord as RRecord } from 'ts-type-forge';
105
+ type A = Record<string, number>;
106
+ `,
107
+ errors: [
108
+ {
109
+ messageId: 'useReadonlyOrMutableRecord',
110
+ suggestions: [
111
+ {
112
+ messageId: 'replaceWith',
113
+ data: { name: 'Record', replacement: 'RRecord' },
114
+ output: dedent`
115
+ import { type ReadonlyRecord as RRecord } from 'ts-type-forge';
116
+ type A = RRecord<string, number>;
117
+ `,
118
+ },
119
+ {
120
+ messageId: 'replaceWith',
121
+ data: { name: 'Record', replacement: 'MutableRecord' },
122
+ output: dedent`
123
+ import { type MutableRecord } from 'ts-type-forge';
124
+ import { type ReadonlyRecord as RRecord } from 'ts-type-forge';
125
+ type A = MutableRecord<string, number>;
126
+ `,
127
+ },
128
+ ],
129
+ },
130
+ ],
131
+ },
132
+ {
133
+ name: 'reports `Record` in a type-parameter constraint too',
134
+ code: 'type A<T extends Record<string, unknown>> = keyof T;',
135
+ errors: [
136
+ {
137
+ messageId: 'useReadonlyOrMutableRecord',
138
+ suggestions: [
139
+ {
140
+ messageId: 'replaceWith',
141
+ output: dedent`
142
+ import { type ReadonlyRecord } from 'ts-type-forge';
143
+ type A<T extends ReadonlyRecord<string, unknown>> = keyof T;
144
+ `,
145
+ },
146
+ {
147
+ messageId: 'replaceWith',
148
+ output: dedent`
149
+ import { type MutableRecord } from 'ts-type-forge';
150
+ type A<T extends MutableRecord<string, unknown>> = keyof T;
151
+ `,
152
+ },
153
+ ],
154
+ },
155
+ ],
156
+ },
157
+ ],
158
+ },
159
+ );
160
+ });