eslint-plugin-ts-type-forge 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -13
- package/dist/plugin.d.mts +8 -0
- package/dist/plugin.d.mts.map +1 -1
- package/dist/plugin.mjs +3 -1
- package/dist/plugin.mjs.map +1 -1
- package/dist/rule-types.d.mts +78 -0
- package/dist/rule-types.d.mts.map +1 -1
- package/dist/rules/import-style.d.mts +21 -0
- package/dist/rules/import-style.d.mts.map +1 -0
- package/dist/rules/import-style.mjs +29 -0
- package/dist/rules/import-style.mjs.map +1 -0
- package/dist/rules/prefer-canonical-length-constrained-tuple.d.mts.map +1 -1
- package/dist/rules/prefer-canonical-length-constrained-tuple.mjs +2 -1
- package/dist/rules/prefer-canonical-length-constrained-tuple.mjs.map +1 -1
- package/dist/rules/prefer-readonly-or-mutable-record.d.mts +10 -0
- package/dist/rules/prefer-readonly-or-mutable-record.d.mts.map +1 -0
- package/dist/rules/prefer-readonly-or-mutable-record.mjs +49 -0
- package/dist/rules/prefer-readonly-or-mutable-record.mjs.map +1 -0
- package/dist/rules/prefer-strict-or-relaxed-utility-type.d.mts +10 -0
- package/dist/rules/prefer-strict-or-relaxed-utility-type.d.mts.map +1 -0
- package/dist/rules/prefer-strict-or-relaxed-utility-type.mjs +49 -0
- package/dist/rules/prefer-strict-or-relaxed-utility-type.mjs.map +1 -0
- package/dist/rules/rules.d.mts +6 -0
- package/dist/rules/rules.d.mts.map +1 -1
- package/dist/rules/rules.mjs +5 -1
- package/dist/rules/rules.mjs.map +1 -1
- package/dist/rules/std-type-rule-utils.d.mts +22 -0
- package/dist/rules/std-type-rule-utils.d.mts.map +1 -0
- package/dist/rules/std-type-rule-utils.mjs +40 -0
- package/dist/rules/std-type-rule-utils.mjs.map +1 -0
- package/dist/rules/tuple-rule-utils.d.mts +1 -19
- package/dist/rules/tuple-rule-utils.d.mts.map +1 -1
- package/dist/rules/tuple-rule-utils.mjs +3 -26
- package/dist/rules/tuple-rule-utils.mjs.map +1 -1
- package/package.json +2 -2
- package/src/plugin.mts +2 -0
- package/src/rule-types.mts +92 -0
- package/src/rules/import-style.mts +60 -0
- package/src/rules/prefer-canonical-length-constrained-tuple.mts +2 -3
- package/src/rules/prefer-readonly-or-mutable-record.mts +74 -0
- package/src/rules/prefer-readonly-or-mutable-record.test.mts +160 -0
- package/src/rules/prefer-strict-or-relaxed-utility-type.mts +80 -0
- package/src/rules/prefer-strict-or-relaxed-utility-type.test.mts +279 -0
- package/src/rules/rules.mts +4 -0
- package/src/rules/std-type-rule-utils.mts +103 -0
- 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
|
-
|
|
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;
|
|
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 {
|
|
3
|
-
import { buildTypeImportFix
|
|
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 {
|
|
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 {
|
|
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.
|
|
3
|
+
"version": "0.6.1",
|
|
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.
|
|
40
|
+
"ts-type-forge": "9.1.3"
|
|
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
|
>;
|
package/src/rule-types.mts
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
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
|
+
});
|