eslint-plugin-ts-type-forge 0.6.5 → 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.
- package/README.md +23 -0
- package/dist/plugin.d.mts +4 -0
- package/dist/plugin.d.mts.map +1 -1
- package/dist/plugin.mjs +1 -0
- package/dist/plugin.mjs.map +1 -1
- package/dist/rule-types.d.mts +39 -0
- package/dist/rule-types.d.mts.map +1 -1
- package/dist/rules/ast-utils.d.mts +11 -0
- package/dist/rules/ast-utils.d.mts.map +1 -1
- package/dist/rules/ast-utils.mjs +81 -7
- package/dist/rules/ast-utils.mjs.map +1 -1
- package/dist/rules/prefer-canonical-length-constrained-tuple.mjs +2 -2
- package/dist/rules/prefer-canonical-length-constrained-tuple.mjs.map +1 -1
- package/dist/rules/prefer-canonical-mutable-record.d.mts +10 -0
- package/dist/rules/prefer-canonical-mutable-record.d.mts.map +1 -0
- package/dist/rules/prefer-canonical-mutable-record.mjs +83 -0
- package/dist/rules/prefer-canonical-mutable-record.mjs.map +1 -0
- package/dist/rules/{tuple-rule-utils.d.mts → rewrite-rule-utils.d.mts} +4 -3
- package/dist/rules/rewrite-rule-utils.d.mts.map +1 -0
- package/dist/rules/{tuple-rule-utils.mjs → rewrite-rule-utils.mjs} +3 -3
- package/dist/rules/rewrite-rule-utils.mjs.map +1 -0
- package/dist/rules/rules.d.mts +3 -0
- package/dist/rules/rules.d.mts.map +1 -1
- package/dist/rules/rules.mjs +2 -0
- package/dist/rules/rules.mjs.map +1 -1
- package/dist/rules/std-type-rule-utils.d.mts.map +1 -1
- package/dist/rules/std-type-rule-utils.mjs +2 -5
- package/dist/rules/std-type-rule-utils.mjs.map +1 -1
- package/package.json +10 -10
- package/src/plugin.mts +1 -0
- package/src/rule-types.mts +46 -0
- package/src/rules/ast-utils.mts +217 -7
- package/src/rules/prefer-canonical-length-constrained-tuple.mts +3 -3
- package/src/rules/prefer-canonical-length-constrained-tuple.test.mts +71 -0
- package/src/rules/prefer-canonical-mutable-record.mts +151 -0
- package/src/rules/prefer-canonical-mutable-record.test.mts +164 -0
- package/src/rules/{tuple-rule-utils.mts → rewrite-rule-utils.mts} +4 -3
- package/src/rules/rules.mts +2 -0
- package/src/rules/std-type-rule-utils.mts +1 -21
- package/dist/rules/tuple-rule-utils.d.mts.map +0 -1
- 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,
|
|
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
|
|
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
|
|
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.
|
|
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": [
|
|
@@ -35,22 +35,22 @@
|
|
|
35
35
|
"LICENSE"
|
|
36
36
|
],
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@typescript-eslint/utils": "~8.
|
|
39
|
-
"ts-data-forge": "~14.
|
|
40
|
-
"ts-type-forge": "9.2.
|
|
38
|
+
"@typescript-eslint/utils": "~8.67.0",
|
|
39
|
+
"ts-data-forge": "~14.4.0",
|
|
40
|
+
"ts-type-forge": "9.2.2"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
|
-
"@typescript-eslint/parser": "8.
|
|
44
|
-
"@typescript-eslint/rule-tester": "8.
|
|
43
|
+
"@typescript-eslint/parser": "8.67.0",
|
|
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
|
-
"
|
|
51
|
-
"eslint-plugin-ts-data-forge": "0.4.7",
|
|
52
|
-
"eslint-plugin-ts-fortress": "1.0.3",
|
|
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<
|
package/src/rule-types.mts
CHANGED
|
@@ -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
|
}>;
|
package/src/rules/ast-utils.mts
CHANGED
|
@@ -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
|
-
|
|
95
|
-
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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;
|