eslint-plugin-ts-type-forge 0.6.6 → 0.7.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 (41) hide show
  1. package/README.md +23 -0
  2. package/dist/plugin.d.mts +4 -0
  3. package/dist/plugin.d.mts.map +1 -1
  4. package/dist/plugin.mjs +1 -0
  5. package/dist/plugin.mjs.map +1 -1
  6. package/dist/rule-types.d.mts +39 -0
  7. package/dist/rule-types.d.mts.map +1 -1
  8. package/dist/rules/ast-utils.d.mts +11 -0
  9. package/dist/rules/ast-utils.d.mts.map +1 -1
  10. package/dist/rules/ast-utils.mjs +81 -7
  11. package/dist/rules/ast-utils.mjs.map +1 -1
  12. package/dist/rules/prefer-canonical-length-constrained-tuple.mjs +2 -2
  13. package/dist/rules/prefer-canonical-length-constrained-tuple.mjs.map +1 -1
  14. package/dist/rules/prefer-canonical-mutable-record.d.mts +10 -0
  15. package/dist/rules/prefer-canonical-mutable-record.d.mts.map +1 -0
  16. package/dist/rules/prefer-canonical-mutable-record.mjs +83 -0
  17. package/dist/rules/prefer-canonical-mutable-record.mjs.map +1 -0
  18. package/dist/rules/{tuple-rule-utils.d.mts → rewrite-rule-utils.d.mts} +4 -3
  19. package/dist/rules/rewrite-rule-utils.d.mts.map +1 -0
  20. package/dist/rules/{tuple-rule-utils.mjs → rewrite-rule-utils.mjs} +3 -3
  21. package/dist/rules/rewrite-rule-utils.mjs.map +1 -0
  22. package/dist/rules/rules.d.mts +3 -0
  23. package/dist/rules/rules.d.mts.map +1 -1
  24. package/dist/rules/rules.mjs +2 -0
  25. package/dist/rules/rules.mjs.map +1 -1
  26. package/dist/rules/std-type-rule-utils.d.mts.map +1 -1
  27. package/dist/rules/std-type-rule-utils.mjs +2 -5
  28. package/dist/rules/std-type-rule-utils.mjs.map +1 -1
  29. package/package.json +6 -6
  30. package/src/plugin.mts +1 -0
  31. package/src/rule-types.mts +46 -0
  32. package/src/rules/ast-utils.mts +217 -7
  33. package/src/rules/prefer-canonical-length-constrained-tuple.mts +3 -3
  34. package/src/rules/prefer-canonical-length-constrained-tuple.test.mts +71 -0
  35. package/src/rules/prefer-canonical-mutable-record.mts +151 -0
  36. package/src/rules/prefer-canonical-mutable-record.test.mts +164 -0
  37. package/src/rules/{tuple-rule-utils.mts → rewrite-rule-utils.mts} +4 -3
  38. package/src/rules/rules.mts +2 -0
  39. package/src/rules/std-type-rule-utils.mts +1 -21
  40. package/dist/rules/tuple-rule-utils.d.mts.map +0 -1
  41. package/dist/rules/tuple-rule-utils.mjs.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"std-type-rule-utils.d.mts","sourceRoot":"","sources":["../../src/rules/std-type-rule-utils.mts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,QAAQ,EACb,KAAK,QAAQ,EACd,MAAM,0BAA0B,CAAC;AAElC,OAAO,EAAmB,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAKvE;;;GAGG;AACH,MAAM,MAAM,mBAAmB,GAAG,WAAW,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC;AAEzE;;;;;;;;;GASG;AACH,eAAO,MAAM,sBAAsB,GAAI,UAAU,SAAS,MAAM,WACrD,QAAQ,CAAC,WAAW,CAAC,UAAU,EAAE,SAAS,OAAO,EAAE,CAAC,QACvD,QAAQ,CAAC,eAAe,gBAChB,mBAAmB,cACrB,QAAQ,CAAC;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,UAAU,EAAE,UAAU,CAAA;CAAE,CAAC,eACvD,WAAW,KACvB,IA4CF,CAAC"}
1
+ {"version":3,"file":"std-type-rule-utils.d.mts","sourceRoot":"","sources":["../../src/rules/std-type-rule-utils.mts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,QAAQ,EACb,KAAK,QAAQ,EACd,MAAM,0BAA0B,CAAC;AAGlC,OAAO,EAAmB,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAKvE;;;GAGG;AACH,MAAM,MAAM,mBAAmB,GAAG,WAAW,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC;AAEzE;;;;;;;;;GASG;AACH,eAAO,MAAM,sBAAsB,GAAI,UAAU,SAAS,MAAM,WACrD,QAAQ,CAAC,WAAW,CAAC,UAAU,EAAE,SAAS,OAAO,EAAE,CAAC,QACvD,QAAQ,CAAC,eAAe,gBAChB,mBAAmB,cACrB,QAAQ,CAAC;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,UAAU,EAAE,UAAU,CAAA;CAAE,CAAC,eACvD,WAAW,KACvB,IA4CF,CAAC"}
@@ -1,5 +1,6 @@
1
- import { AST_NODE_TYPES, ASTUtils } from '@typescript-eslint/utils';
1
+ import { AST_NODE_TYPES } from '@typescript-eslint/utils';
2
2
  import { Arr } from 'ts-data-forge';
3
+ import { isLocallyBound } from './ast-utils.mjs';
3
4
  import { resolveTypeName } from './import-style.mjs';
4
5
  import { buildTypeImportFix } from './import-utils.mjs';
5
6
 
@@ -31,10 +32,6 @@ const reportStdTypeReference = (context, node, alternatives, messageIds, importS
31
32
  });
32
33
  };
33
34
  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
35
 
39
36
  export { reportStdTypeReference };
40
37
  //# sourceMappingURL=std-type-rule-utils.mjs.map
@@ -1 +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
+ {"version":3,"file":"std-type-rule-utils.mjs","sources":["../../src/rules/std-type-rule-utils.mts"],"sourcesContent":["import {\n AST_NODE_TYPES,\n type TSESLint,\n type TSESTree,\n} from '@typescript-eslint/utils';\nimport { Arr } from 'ts-data-forge';\nimport { isLocallyBound } from './ast-utils.mjs';\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"],"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;;;;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-ts-type-forge",
3
- "version": "0.6.6",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "description": "ESLint rules that steer type declarations toward ts-type-forge idioms.",
6
6
  "keywords": [
@@ -37,20 +37,20 @@
37
37
  "dependencies": {
38
38
  "@typescript-eslint/utils": "~8.67.0",
39
39
  "ts-data-forge": "~14.4.0",
40
- "ts-type-forge": "9.2.1"
40
+ "ts-type-forge": "9.2.2"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@typescript-eslint/parser": "8.67.0",
44
44
  "@typescript-eslint/rule-tester": "8.67.0",
45
45
  "dedent": "1.7.2",
46
+ "eslint-config-typed": "5.9.5",
47
+ "eslint-plugin-ts-data-forge": "0.5.0",
48
+ "eslint-plugin-ts-fortress": "1.0.4",
46
49
  "prettier": "3.9.6",
47
50
  "prettier-plugin-organize-imports": "4.3.0",
48
51
  "prettier-plugin-packagejson": "3.0.2",
49
52
  "rollup": "4.62.4",
50
- "eslint-config-typed": "5.9.3",
51
- "eslint-plugin-ts-data-forge": "0.4.8",
52
- "eslint-plugin-ts-fortress": "1.0.4",
53
- "ts-repo-utils": "10.2.0"
53
+ "ts-repo-utils": "10.3.2"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "eslint": ">=9.0.0",
package/src/plugin.mts CHANGED
@@ -9,6 +9,7 @@ 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-canonical-mutable-record': 'error',
12
13
  'ts-type-forge/prefer-readonly-or-mutable-record': 'error',
13
14
  'ts-type-forge/prefer-strict-or-relaxed-utility-type': 'error',
14
15
  } as const satisfies Readonly<
@@ -76,6 +76,50 @@ namespace PreferCanonicalLengthConstrainedTuple {
76
76
  | SpreadOptionsIfIsArray<readonly [Linter.StringSeverity, Options]>;
77
77
  }
78
78
 
79
+ /**
80
+ * @description Replace `Mutable<Record<K, V>>` (and `Mutable` applied to `ReadonlyRecord` / `MutableRecord`) with the canonical ts-type-forge `MutableRecord<K, V>`.
81
+ *
82
+ * ```md
83
+ * | key | value |
84
+ * | :--------- | :--------- |
85
+ * | type | suggestion |
86
+ * | deprecated | false |
87
+ * | fixable | code |
88
+ * ```
89
+ */
90
+ namespace PreferCanonicalMutableRecord {
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-canonical-mutable-record'>[0]
115
+ >;
116
+
117
+ export type RuleEntry =
118
+ | 'off'
119
+ | Linter.Severity
120
+ | SpreadOptionsIfIsArray<readonly [Linter.StringSeverity, Options]>;
121
+ }
122
+
79
123
  /**
80
124
  * @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
125
  *
@@ -166,12 +210,14 @@ namespace PreferStrictOrRelaxedUtilityType {
166
210
 
167
211
  export type EslintTsTypeForgeRules = Readonly<{
168
212
  'ts-type-forge/prefer-canonical-length-constrained-tuple': PreferCanonicalLengthConstrainedTuple.RuleEntry;
213
+ 'ts-type-forge/prefer-canonical-mutable-record': PreferCanonicalMutableRecord.RuleEntry;
169
214
  'ts-type-forge/prefer-readonly-or-mutable-record': PreferReadonlyOrMutableRecord.RuleEntry;
170
215
  'ts-type-forge/prefer-strict-or-relaxed-utility-type': PreferStrictOrRelaxedUtilityType.RuleEntry;
171
216
  }>;
172
217
 
173
218
  export type EslintTsTypeForgeRulesOption = Readonly<{
174
219
  'ts-type-forge/prefer-canonical-length-constrained-tuple': PreferCanonicalLengthConstrainedTuple.Options;
220
+ 'ts-type-forge/prefer-canonical-mutable-record': PreferCanonicalMutableRecord.Options;
175
221
  'ts-type-forge/prefer-readonly-or-mutable-record': PreferReadonlyOrMutableRecord.Options;
176
222
  'ts-type-forge/prefer-strict-or-relaxed-utility-type': PreferStrictOrRelaxedUtilityType.Options;
177
223
  }>;
@@ -1,12 +1,33 @@
1
1
  import {
2
2
  AST_NODE_TYPES,
3
+ ASTUtils,
3
4
  type TSESLint,
4
5
  type TSESTree,
5
6
  } from '@typescript-eslint/utils';
6
- import { Arr } from 'ts-data-forge';
7
+ import { Arr, hasKey, isNonNullObject, isRecord } from 'ts-data-forge';
7
8
 
8
9
  /* eslint-disable @typescript-eslint/prefer-readonly-parameter-types */
9
10
 
11
+ /**
12
+ * Whether `name` resolves to a declaration in this file (a type alias, an
13
+ * interface, a class, an import, …) rather than to the ambient standard-library
14
+ * type of the same name.
15
+ *
16
+ * The `defs` check is what separates the two: ESLint materializes an *implicit*
17
+ * global variable for every unresolved reference, so `findVariable` also
18
+ * succeeds for `Exclude` in a file that never declares it. Only a variable with
19
+ * at least one definition was actually written down somewhere in this file.
20
+ */
21
+ export const isLocallyBound = (
22
+ sourceCode: TSESLint.SourceCode,
23
+ node: TSESTree.Node,
24
+ name: string,
25
+ ): boolean => {
26
+ const variable = ASTUtils.findVariable(sourceCode.getScope(node), name);
27
+
28
+ return variable !== null && Arr.isNonEmpty(variable.defs);
29
+ };
30
+
10
31
  export type UniformTupleShape = Readonly<{
11
32
  /** The node an autofix must replace: the tuple, or the `readonly` operator. */
12
33
  node: TSESTree.Node;
@@ -91,17 +112,24 @@ export const analyzeUniformTuple = (
91
112
  ...(restElementType === undefined ? [] : [restElementType]),
92
113
  ];
93
114
 
94
- return others.every(
95
- (element) => normalizeWhitespace(sourceCode.getText(element)) === expected,
96
- )
97
- ? {
115
+ if (
116
+ others.some(
117
+ (element) =>
118
+ normalizeWhitespace(sourceCode.getText(element)) !== expected,
119
+ )
120
+ ) {
121
+ return undefined;
122
+ }
123
+
124
+ return definesRecursiveTypeAlias(tuple, sourceCode)
125
+ ? undefined
126
+ : {
98
127
  node: isReadonly ? parent : tuple,
99
128
  isReadonly,
100
129
  elementText,
101
130
  fixedCount: leading.length,
102
131
  hasRest,
103
- }
104
- : undefined;
132
+ };
105
133
  };
106
134
 
107
135
  /**
@@ -128,6 +156,188 @@ const isInConditionalTypeExtendsClause = (node: TSESTree.Node): boolean => {
128
156
  );
129
157
  };
130
158
 
159
+ /**
160
+ * Whether the tuple spells out an alias that its own element type leads back
161
+ * to.
162
+ *
163
+ * A tuple literal is how TypeScript's cycle-breaking works: `type T = readonly
164
+ * [T, T]` resolves, because the element positions are deferred. Rewriting it to
165
+ * `FixedLengthTuple<2, T>` routes the same cycle through a mapped type, which
166
+ * the checker will not compute — `T` becomes an error type, and every use of it
167
+ * silently reads as `any`. `tsc` reports the alias once; the typed linter then
168
+ * reports each use, which is how this was found.
169
+ *
170
+ * The cycle is often indirect. `LambdaApplication = readonly [LambdaTerm,
171
+ * LambdaTerm]` looks fine on its own, and only recurses because `LambdaTerm` is
172
+ * a union that includes `LambdaApplication`. So this follows references through
173
+ * the other aliases in the file rather than looking for the enclosing name in
174
+ * the element text.
175
+ *
176
+ * **Any cycle counts, which leaves some rewritable tuples alone.** Not every
177
+ * cycle actually breaks: TypeScript resolves an object type, an array, a tuple
178
+ * and a function type without resolving what is inside them, so `type Pair =
179
+ * FixedLengthTuple<2, Foo>` alongside `type Foo = { p: Pair }` compiles, and so
180
+ * does `type Tree = { kids: FixedLengthTuple<2, Tree> }`. Skipping those
181
+ * constructs while following references was tried and does not hold up — the
182
+ * deferral is undone by anything that asks for the structure, and that can sit
183
+ * anywhere in the file:
184
+ *
185
+ * ```ts
186
+ * type Pair = readonly [Foo, Foo]; // rewriting this breaks Pair and Foo
187
+ * type Foo = Wrapper[number]; // …because of the indexed access here
188
+ * type Wrapper = readonly Pair[];
189
+ * ```
190
+ *
191
+ * Deciding it properly means knowing every use of every alias, which no
192
+ * syntactic rule can do — so a cycle of any shape suppresses the rewrite. The
193
+ * cost is a tuple left spelled out; the alternative is an autofix that turns a
194
+ * type into `any`.
195
+ *
196
+ * A cycle closed through an `import` is not visible here and is not detected.
197
+ */
198
+ const definesRecursiveTypeAlias = (
199
+ tuple: TSESTree.TSTupleType,
200
+ sourceCode: TSESLint.SourceCode,
201
+ ): boolean => {
202
+ const alias = enclosingTypeAlias(tuple);
203
+
204
+ if (alias === undefined) return false;
205
+
206
+ const references = aliasReferenceGraph(programOf(alias), sourceCode);
207
+
208
+ const mut_seen = new Set<string>();
209
+
210
+ const mut_queue: string[] = tuple.elementTypes.flatMap((element) =>
211
+ Array.from(referencedTypeNames(element, sourceCode)),
212
+ );
213
+
214
+ while (true) {
215
+ const next = mut_queue.pop();
216
+
217
+ if (next === undefined) return false;
218
+
219
+ if (next === alias.id.name) return true;
220
+
221
+ if (mut_seen.has(next)) continue;
222
+
223
+ mut_seen.add(next);
224
+
225
+ const referenced = references.get(next);
226
+
227
+ if (referenced !== undefined) {
228
+ mut_queue.push(...referenced);
229
+ }
230
+ }
231
+ };
232
+
233
+ /** The `type X = …` declaration `node` sits in, if any. */
234
+ const enclosingTypeAlias = (
235
+ node: TSESTree.Node,
236
+ ): TSESTree.TSTypeAliasDeclaration | undefined => {
237
+ if (node.type === AST_NODE_TYPES.TSTypeAliasDeclaration) return node;
238
+
239
+ if (node.type === AST_NODE_TYPES.Program) return undefined;
240
+
241
+ return enclosingTypeAlias(node.parent);
242
+ };
243
+
244
+ /**
245
+ * Which other type names each `type X = …` in the file refers to, keyed by `X`.
246
+ *
247
+ * Built once per file and cached on the `Program` node: the search below runs
248
+ * per candidate tuple, and rebuilding this for each of them made the rule cost
249
+ * `O(tuples × file size)` — a full second on the largest generated files here.
250
+ */
251
+ const aliasReferenceGraph = (
252
+ program: TSESTree.Program,
253
+ sourceCode: TSESLint.SourceCode,
254
+ ): ReadonlyMap<string, ReadonlySet<string>> => {
255
+ const cached = aliasReferenceGraphCache.get(program);
256
+
257
+ if (cached !== undefined) return cached;
258
+
259
+ const mut_graph = new Map<string, ReadonlySet<string>>();
260
+
261
+ walk(program, sourceCode, (visited) => {
262
+ if (visited.type === AST_NODE_TYPES.TSTypeAliasDeclaration) {
263
+ mut_graph.set(
264
+ visited.id.name,
265
+ referencedTypeNames(visited.typeAnnotation, sourceCode),
266
+ );
267
+ }
268
+ });
269
+
270
+ aliasReferenceGraphCache.set(program, mut_graph);
271
+
272
+ return mut_graph;
273
+ };
274
+
275
+ const aliasReferenceGraphCache = new WeakMap<
276
+ TSESTree.Program,
277
+ ReadonlyMap<string, ReadonlySet<string>>
278
+ >();
279
+
280
+ const programOf = (node: TSESTree.Node): TSESTree.Program =>
281
+ node.type === AST_NODE_TYPES.Program ? node : programOf(node.parent);
282
+
283
+ /** The names every `X` / `X<…>` reference in the subtree points at. */
284
+ const referencedTypeNames = (
285
+ node: TSESTree.Node,
286
+ sourceCode: TSESLint.SourceCode,
287
+ ): ReadonlySet<string> => {
288
+ const mut_names = new Set<string>();
289
+
290
+ walk(node, sourceCode, (visited) => {
291
+ if (
292
+ visited.type === AST_NODE_TYPES.TSTypeReference &&
293
+ visited.typeName.type === AST_NODE_TYPES.Identifier
294
+ ) {
295
+ mut_names.add(visited.typeName.name);
296
+ }
297
+ });
298
+
299
+ return mut_names;
300
+ };
301
+
302
+ /**
303
+ * Visits `node` and its descendants.
304
+ *
305
+ * Children are found through the parser's visitor keys, so only the properties
306
+ * that actually hold one are read. Reading every own property instead works
307
+ * too, and is the fallback for a parser that declares no keys for a node type,
308
+ * but it allocates an entry pair per property and walks `loc` / `range` as
309
+ * well — on the generated rule-type files that alone was most of the cost.
310
+ */
311
+ const walk = (
312
+ node: TSESTree.Node,
313
+ sourceCode: TSESLint.SourceCode,
314
+ visit: (visited: TSESTree.Node) => void,
315
+ ): void => {
316
+ visit(node);
317
+
318
+ if (!isRecord(node)) return;
319
+
320
+ const keys =
321
+ sourceCode.visitorKeys[node.type] ??
322
+ Object.keys(node).filter((key) => key !== 'parent');
323
+
324
+ for (const key of keys) {
325
+ const value: unknown = hasKey(node, key) ? node[key] : undefined;
326
+
327
+ if (Arr.isArray(value)) {
328
+ for (const element of value) {
329
+ if (isNode(element)) walk(element, sourceCode, visit);
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
+
131
341
  /** Unwraps `V[]` and `readonly V[]` to `V`. */
132
342
  const getArrayElementType = (
133
343
  node: TSESTree.TypeNode,
@@ -13,7 +13,7 @@ import {
13
13
  DEFAULT_IMPORT_STYLE,
14
14
  IMPORT_STYLE_SCHEMA_PROPERTY,
15
15
  } from './import-style.mjs';
16
- import { reportTupleRewrites, type TupleRewrite } from './tuple-rule-utils.mjs';
16
+ import { reportTypeRewrites, type TypeRewrite } from './rewrite-rule-utils.mjs';
17
17
 
18
18
  type Options = readonly [
19
19
  Readonly<{
@@ -75,7 +75,7 @@ export const preferCanonicalLengthConstrainedTuple: TSESLint.RuleModule<
75
75
 
76
76
  const maxLength = options?.maxLength ?? DEFAULT_MAX_TUPLE_LENGTH;
77
77
 
78
- const mut_rewrites: TupleRewrite[] = [];
78
+ const mut_rewrites: TypeRewrite[] = [];
79
79
 
80
80
  return {
81
81
  TSTupleType: (node) => {
@@ -97,7 +97,7 @@ export const preferCanonicalLengthConstrainedTuple: TSESLint.RuleModule<
97
97
  });
98
98
  },
99
99
  'Program:exit': () => {
100
- reportTupleRewrites(
100
+ reportTypeRewrites(
101
101
  context,
102
102
  mut_rewrites,
103
103
  'useCanonicalTuple',
@@ -41,6 +41,77 @@ describe('prefer-canonical-length-constrained-tuple', () => {
41
41
  name: 'ignores rest-only tuples',
42
42
  code: 'type T = readonly [...string[]];',
43
43
  },
44
+ {
45
+ name: 'ignores a tuple that defines a directly recursive alias',
46
+ code: 'type T = readonly [T, T];',
47
+ },
48
+ {
49
+ name: 'ignores a tuple whose alias recurses through another alias',
50
+ code: dedent`
51
+ type LambdaTerm = string | LambdaApplication;
52
+ type LambdaApplication = readonly [LambdaTerm, LambdaTerm];
53
+ `,
54
+ },
55
+ {
56
+ name: 'ignores a tuple whose alias recurses through a chain of aliases',
57
+ code: dedent`
58
+ type A = readonly [B, B];
59
+ type B = C;
60
+ type C = A | string;
61
+ `,
62
+ },
63
+ {
64
+ name: 'ignores a tuple whose alias recurses through an intersection',
65
+ code: dedent`
66
+ type Pair = readonly [Foo, Foo];
67
+ type Foo = Pair & { readonly tag: string };
68
+ `,
69
+ },
70
+ {
71
+ name: 'ignores a tuple whose alias recurses through a type argument',
72
+ code: dedent`
73
+ type Pair = readonly [Box<Pair>, Box<Pair>];
74
+ type Box<T> = { readonly v: T };
75
+ `,
76
+ },
77
+ {
78
+ name: 'ignores a tuple whose alias recurses through an indexed access',
79
+ code: dedent`
80
+ type Pair = readonly [Foo, Foo];
81
+ type Foo = Wrapper[number];
82
+ type Wrapper = readonly Pair[];
83
+ `,
84
+ },
85
+ {
86
+ // Both of these compile either way: TypeScript resolves an object
87
+ // type without resolving its members, so the rewritten form is fine
88
+ // here. The rule suppresses them all the same — see the note on
89
+ // `definesRecursiveTypeAlias` for why the exception cannot be made
90
+ // safely.
91
+ name: 'conservatively ignores a cycle closed through an object type',
92
+ code: dedent`
93
+ type Pair = readonly [Foo, Foo];
94
+ type Foo = { readonly p: Pair };
95
+ `,
96
+ },
97
+ {
98
+ name: 'conservatively ignores a cycle closed through an array',
99
+ code: dedent`
100
+ type Pair = readonly [Foo, Foo];
101
+ type Foo = readonly Pair[];
102
+ `,
103
+ },
104
+ {
105
+ name: 'conservatively ignores a tuple nested in its own alias',
106
+ code: 'type Tree = { readonly kids: readonly [Tree, Tree] };',
107
+ },
108
+ {
109
+ name: 'still ignores nothing when the cycle does not reach the tuple',
110
+ code: dedent`
111
+ type Loop = readonly Loop[];
112
+ type Unrelated = string;
113
+ `,
114
+ },
44
115
  {
45
116
  name: 'ignores plain arrays',
46
117
  code: 'type T = readonly string[];',
@@ -0,0 +1,151 @@
1
+ import {
2
+ AST_NODE_TYPES,
3
+ type TSESLint,
4
+ type TSESTree,
5
+ } from '@typescript-eslint/utils';
6
+ import { Arr } from 'ts-data-forge';
7
+ import { isLocallyBound } from './ast-utils.mjs';
8
+ import {
9
+ DEFAULT_IMPORT_STYLE,
10
+ IMPORT_STYLE_SCHEMA_PROPERTY,
11
+ } from './import-style.mjs';
12
+ import {
13
+ getImportedLocalName,
14
+ getTsTypeForgeImports,
15
+ } from './import-utils.mjs';
16
+ import { reportTypeRewrites, type TypeRewrite } from './rewrite-rule-utils.mjs';
17
+
18
+ type Options = readonly [
19
+ Readonly<{
20
+ importStyle?: 'global' | 'named';
21
+ }>?,
22
+ ];
23
+
24
+ type MessageIds = 'useMutableRecord';
25
+
26
+ /**
27
+ * The record spellings that `Mutable<…>` collapses to `MutableRecord<K, V>`.
28
+ * `Mutable` strips the `readonly` modifier from every property, so applied to
29
+ * any of these it produces exactly the type `MutableRecord` spells directly.
30
+ */
31
+ const WRAPPED_RECORD_NAMES = [
32
+ 'Record',
33
+ 'ReadonlyRecord',
34
+ 'MutableRecord',
35
+ ] as const;
36
+
37
+ const MUTABLE_TYPE_NAME = 'Mutable';
38
+
39
+ const MUTABLE_RECORD_TYPE_NAME = 'MutableRecord';
40
+
41
+ export const preferCanonicalMutableRecord: TSESLint.RuleModule<
42
+ MessageIds,
43
+ Options
44
+ > = {
45
+ meta: {
46
+ type: 'suggestion',
47
+ docs: {
48
+ description:
49
+ 'Replace `Mutable<Record<K, V>>` (and `Mutable` applied to `ReadonlyRecord` / `MutableRecord`) with the canonical ts-type-forge `MutableRecord<K, V>`.',
50
+ },
51
+ fixable: 'code',
52
+ schema: [
53
+ {
54
+ type: 'object',
55
+ properties: {
56
+ importStyle: IMPORT_STYLE_SCHEMA_PROPERTY,
57
+ },
58
+ additionalProperties: false,
59
+ },
60
+ ],
61
+ messages: {
62
+ useMutableRecord:
63
+ 'Replace `{{original}}` with `{{replacement}}` from ts-type-forge.',
64
+ },
65
+ },
66
+
67
+ create: (context) => {
68
+ const importStyle = context.options[0]?.importStyle ?? DEFAULT_IMPORT_STYLE;
69
+
70
+ const sourceCode = context.sourceCode;
71
+
72
+ const tsTypeForgeImports = getTsTypeForgeImports(sourceCode.ast);
73
+
74
+ /**
75
+ * Whether this reference denotes the ts-type-forge type `canonicalName` —
76
+ * either through an import from ts-type-forge (aliases included) or as the
77
+ * bare ambient global. A name bound to anything else in this file (a local
78
+ * alias, an import from another module) denotes that declaration instead.
79
+ * The built-in `Record` goes through the same check: ts-type-forge exports
80
+ * no `Record`, so only the unbound global spelling matches.
81
+ */
82
+ const referencesTsTypeForgeType = (
83
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
84
+ node: TSESTree.TSTypeReference,
85
+ canonicalName: string,
86
+ ): boolean => {
87
+ const typeName = node.typeName;
88
+
89
+ // `ns.Mutable` / `import('…').Mutable` never denote the ts-type-forge type.
90
+ if (typeName.type !== AST_NODE_TYPES.Identifier) return false;
91
+
92
+ return (
93
+ typeName.name ===
94
+ getImportedLocalName(tsTypeForgeImports, canonicalName) ||
95
+ (typeName.name === canonicalName &&
96
+ !isLocallyBound(sourceCode, node, canonicalName))
97
+ );
98
+ };
99
+
100
+ const mut_rewrites: TypeRewrite[] = [];
101
+
102
+ return {
103
+ TSTypeReference: (node) => {
104
+ if (!referencesTsTypeForgeType(node, MUTABLE_TYPE_NAME)) return;
105
+
106
+ const wrapped = node.typeArguments?.params;
107
+
108
+ if (wrapped === undefined || !Arr.isFixedLengthArray(1, wrapped)) {
109
+ return;
110
+ }
111
+
112
+ const record = wrapped[0];
113
+
114
+ if (
115
+ record.type !== AST_NODE_TYPES.TSTypeReference ||
116
+ WRAPPED_RECORD_NAMES.every(
117
+ (name) => !referencesTsTypeForgeType(record, name),
118
+ )
119
+ ) {
120
+ return;
121
+ }
122
+
123
+ const recordTypeArgs = record.typeArguments?.params;
124
+
125
+ if (
126
+ recordTypeArgs === undefined ||
127
+ !Arr.isFixedLengthArray(2, recordTypeArgs)
128
+ ) {
129
+ return;
130
+ }
131
+
132
+ const [K, V] = recordTypeArgs;
133
+
134
+ mut_rewrites.push({
135
+ node,
136
+ canonicalName: MUTABLE_RECORD_TYPE_NAME,
137
+ typeArgs: `${sourceCode.getText(K)}, ${sourceCode.getText(V)}`,
138
+ });
139
+ },
140
+ 'Program:exit': () => {
141
+ reportTypeRewrites(
142
+ context,
143
+ mut_rewrites,
144
+ 'useMutableRecord',
145
+ importStyle,
146
+ );
147
+ },
148
+ };
149
+ },
150
+ defaultOptions: [{ importStyle: DEFAULT_IMPORT_STYLE }],
151
+ } as const;