eslint-plugin-ts-type-forge 0.2.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/LICENSE +201 -0
- package/README.md +96 -0
- package/dist/index.d.mts +5 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +3 -0
- package/dist/index.mjs.map +1 -0
- package/dist/plugin.d.mts +3 -0
- package/dist/plugin.d.mts.map +1 -0
- package/dist/plugin.mjs +11 -0
- package/dist/plugin.mjs.map +1 -0
- package/dist/rule-types.d.mts +58 -0
- package/dist/rule-types.d.mts.map +1 -0
- package/dist/rule-types.mjs +2 -0
- package/dist/rule-types.mjs.map +1 -0
- package/dist/rules/ast-utils.d.mts +17 -0
- package/dist/rules/ast-utils.d.mts.map +1 -0
- package/dist/rules/ast-utils.mjs +26 -0
- package/dist/rules/ast-utils.mjs.map +1 -0
- package/dist/rules/constants.d.mts +3 -0
- package/dist/rules/constants.d.mts.map +1 -0
- package/dist/rules/constants.mjs +5 -0
- package/dist/rules/constants.mjs.map +1 -0
- package/dist/rules/import-utils.d.mts +26 -0
- package/dist/rules/import-utils.d.mts.map +1 -0
- package/dist/rules/import-utils.mjs +35 -0
- package/dist/rules/import-utils.mjs.map +1 -0
- package/dist/rules/index.d.mts +2 -0
- package/dist/rules/index.d.mts.map +1 -0
- package/dist/rules/index.mjs +2 -0
- package/dist/rules/index.mjs.map +1 -0
- package/dist/rules/prefer-non-empty-array.d.mts +11 -0
- package/dist/rules/prefer-non-empty-array.d.mts.map +1 -0
- package/dist/rules/prefer-non-empty-array.mjs +81 -0
- package/dist/rules/prefer-non-empty-array.mjs.map +1 -0
- package/dist/rules/rules.d.mts +6 -0
- package/dist/rules/rules.d.mts.map +1 -0
- package/dist/rules/rules.mjs +8 -0
- package/dist/rules/rules.mjs.map +1 -0
- package/dist/types.d.mts +4 -0
- package/dist/types.d.mts.map +1 -0
- package/dist/types.mjs +2 -0
- package/dist/types.mjs.map +1 -0
- package/package.json +77 -0
- package/src/index.mts +7 -0
- package/src/plugin.mts +9 -0
- package/src/rule-types.mts +89 -0
- package/src/rules/ast-utils.mts +76 -0
- package/src/rules/constants.mts +3 -0
- package/src/rules/import-utils.mts +105 -0
- package/src/rules/index.mts +1 -0
- package/src/rules/prefer-non-empty-array.mts +129 -0
- package/src/rules/prefer-non-empty-array.test.mts +192 -0
- package/src/rules/rules.mts +6 -0
- package/src/types.mts +4 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { getNonEmptyArrayElementText } from './ast-utils.mjs';
|
|
2
|
+
import { NON_EMPTY_ARRAY_TYPE_NAME, TS_TYPE_FORGE_MODULE } from './constants.mjs';
|
|
3
|
+
import { getImportedLocalName, getTsTypeForgeImports, hasConflictingDeclaration, buildTypeImportFix } from './import-utils.mjs';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_IMPORT_STYLE = "global";
|
|
6
|
+
const preferNonEmptyArray = {
|
|
7
|
+
meta: {
|
|
8
|
+
type: "suggestion",
|
|
9
|
+
docs: {
|
|
10
|
+
description: "Replace the `readonly [V, ...V[]]` tuple spelling with `NonEmptyArray<V>` from ts-type-forge."
|
|
11
|
+
},
|
|
12
|
+
fixable: "code",
|
|
13
|
+
schema: [
|
|
14
|
+
{
|
|
15
|
+
type: "object",
|
|
16
|
+
properties: {
|
|
17
|
+
importStyle: {
|
|
18
|
+
type: "string",
|
|
19
|
+
enum: ["global", "named"],
|
|
20
|
+
description: [
|
|
21
|
+
"How `NonEmptyArray` is brought into scope.",
|
|
22
|
+
"'global' (default) assumes the ambient globals of",
|
|
23
|
+
"`ts-type-forge/global` and never touches imports;",
|
|
24
|
+
"'named' makes the autofix add",
|
|
25
|
+
`\`import { type ${NON_EMPTY_ARRAY_TYPE_NAME} } from '${TS_TYPE_FORGE_MODULE}';\``,
|
|
26
|
+
"when the name is not imported yet."
|
|
27
|
+
].join(" ")
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
additionalProperties: false
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
messages: {
|
|
34
|
+
useNonEmptyArray: "Replace `{{original}}` with `{{replacement}}` from ts-type-forge."
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
create: (context) => {
|
|
38
|
+
const sourceCode = context.sourceCode;
|
|
39
|
+
const program = sourceCode.ast;
|
|
40
|
+
const importStyle = context.options[0]?.importStyle ?? DEFAULT_IMPORT_STYLE;
|
|
41
|
+
const importedLocalName = getImportedLocalName(
|
|
42
|
+
getTsTypeForgeImports(program),
|
|
43
|
+
NON_EMPTY_ARRAY_TYPE_NAME
|
|
44
|
+
);
|
|
45
|
+
const mut_nodesToFix = [];
|
|
46
|
+
return {
|
|
47
|
+
TSTypeOperator: (node) => {
|
|
48
|
+
const elementText = getNonEmptyArrayElementText(node, sourceCode);
|
|
49
|
+
if (elementText === void 0) return;
|
|
50
|
+
mut_nodesToFix.push({ node, elementText });
|
|
51
|
+
},
|
|
52
|
+
"Program:exit": () => {
|
|
53
|
+
if (mut_nodesToFix.length === 0) return;
|
|
54
|
+
if (importedLocalName === void 0 && hasConflictingDeclaration(program, NON_EMPTY_ARRAY_TYPE_NAME)) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const localName = importedLocalName ?? NON_EMPTY_ARRAY_TYPE_NAME;
|
|
58
|
+
const needsImport = importStyle === "named" && importedLocalName === void 0;
|
|
59
|
+
for (const [index, { node, elementText }] of mut_nodesToFix.entries()) {
|
|
60
|
+
const replacement = `${localName}<${elementText}>`;
|
|
61
|
+
context.report({
|
|
62
|
+
node,
|
|
63
|
+
messageId: "useNonEmptyArray",
|
|
64
|
+
data: {
|
|
65
|
+
original: sourceCode.getText(node),
|
|
66
|
+
replacement
|
|
67
|
+
},
|
|
68
|
+
fix: (fixer) => [
|
|
69
|
+
...index === 0 && needsImport ? buildTypeImportFix(fixer, program, NON_EMPTY_ARRAY_TYPE_NAME) : [],
|
|
70
|
+
fixer.replaceText(node, replacement)
|
|
71
|
+
]
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
defaultOptions: [{ importStyle: DEFAULT_IMPORT_STYLE }]
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export { preferNonEmptyArray };
|
|
81
|
+
//# sourceMappingURL=prefer-non-empty-array.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"prefer-non-empty-array.mjs","sources":["../../src/rules/prefer-non-empty-array.mts"],"sourcesContent":["import { type TSESLint, type TSESTree } from '@typescript-eslint/utils';\nimport { getNonEmptyArrayElementText } from './ast-utils.mjs';\nimport {\n NON_EMPTY_ARRAY_TYPE_NAME,\n TS_TYPE_FORGE_MODULE,\n} from './constants.mjs';\nimport {\n buildTypeImportFix,\n getImportedLocalName,\n getTsTypeForgeImports,\n hasConflictingDeclaration,\n} from './import-utils.mjs';\n\ntype ImportStyle = 'global' | 'named';\n\ntype Options = readonly [\n Readonly<{\n importStyle?: ImportStyle;\n }>?,\n];\n\ntype MessageIds = 'useNonEmptyArray';\n\nconst DEFAULT_IMPORT_STYLE: ImportStyle = 'global';\n\nexport const preferNonEmptyArray: TSESLint.RuleModule<MessageIds, Options> = {\n meta: {\n type: 'suggestion',\n docs: {\n description:\n 'Replace the `readonly [V, ...V[]]` tuple spelling with `NonEmptyArray<V>` from ts-type-forge.',\n },\n fixable: 'code',\n schema: [\n {\n type: 'object',\n properties: {\n importStyle: {\n type: 'string',\n enum: ['global', 'named'],\n description: [\n 'How `NonEmptyArray` is brought into scope.',\n \"'global' (default) assumes the ambient globals of\",\n '`ts-type-forge/global` and never touches imports;',\n \"'named' makes the autofix add\",\n `\\`import { type ${NON_EMPTY_ARRAY_TYPE_NAME} } from '${TS_TYPE_FORGE_MODULE}';\\``,\n 'when the name is not imported yet.',\n ].join(' '),\n },\n },\n additionalProperties: false,\n },\n ],\n messages: {\n useNonEmptyArray:\n 'Replace `{{original}}` with `{{replacement}}` from ts-type-forge.',\n },\n },\n\n create: (context) => {\n const sourceCode = context.sourceCode;\n\n const program = sourceCode.ast;\n\n // `context.options` is what the user actually configured; `defaultOptions`\n // is only merged in by `RuleCreator`, which plain rule objects do not use.\n const importStyle = context.options[0]?.importStyle ?? DEFAULT_IMPORT_STYLE;\n\n const importedLocalName = getImportedLocalName(\n getTsTypeForgeImports(program),\n NON_EMPTY_ARRAY_TYPE_NAME,\n );\n\n const mut_nodesToFix: {\n node: TSESTree.TSTypeOperator;\n elementText: string;\n }[] = [];\n\n return {\n TSTypeOperator: (node) => {\n const elementText = getNonEmptyArrayElementText(node, sourceCode);\n\n if (elementText === undefined) return;\n\n mut_nodesToFix.push({ node, elementText });\n },\n 'Program:exit': () => {\n if (mut_nodesToFix.length === 0) return;\n\n // Rewriting to `NonEmptyArray<...>` is only sound when that name is\n // free (or already bound to the ts-type-forge export). Otherwise the\n // rewritten type would resolve to the wrong declaration — most\n // notably inside ts-type-forge's own sources, which define\n // `NonEmptyArray`.\n if (\n importedLocalName === undefined &&\n hasConflictingDeclaration(program, NON_EMPTY_ARRAY_TYPE_NAME)\n ) {\n return;\n }\n\n const localName = importedLocalName ?? NON_EMPTY_ARRAY_TYPE_NAME;\n\n const needsImport =\n importStyle === 'named' && importedLocalName === undefined;\n\n for (const [index, { node, elementText }] of mut_nodesToFix.entries()) {\n const replacement = `${localName}<${elementText}>`;\n\n context.report({\n node,\n messageId: 'useNonEmptyArray',\n data: {\n original: sourceCode.getText(node),\n replacement,\n },\n fix: (fixer) => [\n ...(index === 0 && needsImport\n ? buildTypeImportFix(fixer, program, NON_EMPTY_ARRAY_TYPE_NAME)\n : []),\n fixer.replaceText(node, replacement),\n ],\n });\n }\n },\n };\n },\n defaultOptions: [{ importStyle: DEFAULT_IMPORT_STYLE }],\n} as const;\n"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const tsTypeForgeRules: {
|
|
2
|
+
readonly 'prefer-non-empty-array': import("@typescript-eslint/utils/ts-eslint").RuleModule<"useNonEmptyArray", readonly [(Readonly<{
|
|
3
|
+
importStyle?: "global" | "named";
|
|
4
|
+
}> | undefined)?], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener>;
|
|
5
|
+
};
|
|
6
|
+
//# sourceMappingURL=rules.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rules.d.mts","sourceRoot":"","sources":["../../src/rules/rules.mts"],"names":[],"mappings":"AAGA,eAAO,MAAM,gBAAgB;;;;CAEa,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rules.mjs","sources":["../../src/rules/rules.mts"],"sourcesContent":["import { type ESLintPlugin } from '../types.mjs';\nimport { preferNonEmptyArray } from './prefer-non-empty-array.mjs';\n\nexport const tsTypeForgeRules = {\n 'prefer-non-empty-array': preferNonEmptyArray,\n} as const satisfies ESLintPlugin['rules'];\n"],"names":[],"mappings":";;AAGO,MAAM,gBAAA,GAAmB;AAAA,EAC9B,wBAAA,EAA0B;AAC5B;;;;"}
|
package/dist/types.d.mts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.mts","sourceRoot":"","sources":["../src/types.mts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,QAAQ,EAAE,MAAM,0BAA0B,CAAC;AACzD,OAAO,EAAE,KAAK,YAAY,EAAE,MAAM,eAAe,CAAC;AAElD,MAAM,MAAM,YAAY,GAAG,YAAY,CAAC,QAAQ,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC"}
|
package/dist/types.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.mjs","sources":[],"sourcesContent":[],"names":[],"mappings":""}
|
package/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "eslint-plugin-ts-type-forge",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "ESLint rules that steer type declarations toward ts-type-forge idioms.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"eslint",
|
|
8
|
+
"eslintplugin",
|
|
9
|
+
"eslint-plugin",
|
|
10
|
+
"ts-type-forge",
|
|
11
|
+
"typescript",
|
|
12
|
+
"types"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/noshiro-pf/ts-type-forge.git",
|
|
17
|
+
"directory": "packages/eslint-plugin-ts-type-forge"
|
|
18
|
+
},
|
|
19
|
+
"license": "Apache-2.0",
|
|
20
|
+
"author": "noshiro-pf <noshiro.pf@gmail.com>",
|
|
21
|
+
"sideEffects": false,
|
|
22
|
+
"type": "module",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.mts",
|
|
26
|
+
"import": "./dist/index.mjs"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"module": "./dist/index.mjs",
|
|
30
|
+
"types": "./dist/index.d.mts",
|
|
31
|
+
"files": [
|
|
32
|
+
"src",
|
|
33
|
+
"dist",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsx ./scripts/cmd/build.mts",
|
|
39
|
+
"build:min": "tsx ./scripts/cmd/build.mts --skip-check",
|
|
40
|
+
"check:ext": "tsx ./scripts/cmd/check-ext.mts",
|
|
41
|
+
"fmt": "format-uncommitted --cwd .",
|
|
42
|
+
"fmt:diff": "format-diff-from origin/main --cwd .",
|
|
43
|
+
"fmt:full": "pnpm run z:prettier .",
|
|
44
|
+
"gen:rule-types": "tsx ./scripts/cmd/gen-rule-types.mts",
|
|
45
|
+
"lint": "eslint .",
|
|
46
|
+
"lint:fix": "eslint . --fix",
|
|
47
|
+
"test": "pnpm run z:vitest run",
|
|
48
|
+
"test:cov": "pnpm run z:vitest run --coverage",
|
|
49
|
+
"testw": "pnpm run z:vitest",
|
|
50
|
+
"tsc": "node ../../node_modules/typescript-native/bin/tsc --noEmit",
|
|
51
|
+
"tscw": "node ../../node_modules/typescript-native/bin/tsc --noEmit --watch -p ./tsconfig.json",
|
|
52
|
+
"type-check": "node ../../node_modules/typescript-native/bin/tsc --noEmit",
|
|
53
|
+
"z:prettier": "prettier --config ../../.prettierrc --ignore-path ../../.prettierignore --ignore-path ../../.gitignore --ignore-unknown --no-error-on-unmatched-pattern --write",
|
|
54
|
+
"z:vitest": "vitest --config ./configs/vitest.config.mts"
|
|
55
|
+
},
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"@typescript-eslint/utils": "8.65.0",
|
|
58
|
+
"ts-type-forge": "workspace:*"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@typescript-eslint/parser": "8.65.0",
|
|
62
|
+
"@typescript-eslint/rule-tester": "8.65.0",
|
|
63
|
+
"dedent": "1.7.2"
|
|
64
|
+
},
|
|
65
|
+
"peerDependencies": {
|
|
66
|
+
"eslint": ">=9.0.0",
|
|
67
|
+
"typescript": ">=5.0.0"
|
|
68
|
+
},
|
|
69
|
+
"engines": {
|
|
70
|
+
"node": ">=22.22.2",
|
|
71
|
+
"pnpm": ">=9.0.0"
|
|
72
|
+
},
|
|
73
|
+
"publishConfig": {
|
|
74
|
+
"access": "public",
|
|
75
|
+
"provenance": true
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/index.mts
ADDED
package/src/plugin.mts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/* cSpell:disable */
|
|
2
|
+
|
|
3
|
+
// This file is auto-generated by `pnpm run gen:rule-types`
|
|
4
|
+
// (`scripts/cmd/gen-rule-types.mts`). Do not edit it manually.
|
|
5
|
+
|
|
6
|
+
import { type TSESLint } from '@typescript-eslint/utils';
|
|
7
|
+
import { type Linter } from 'eslint';
|
|
8
|
+
import { type TypeEq } from 'ts-type-forge';
|
|
9
|
+
import { type tsTypeForgeRules } from './rules/index.mjs';
|
|
10
|
+
|
|
11
|
+
type SpreadOptionsIfIsArray<
|
|
12
|
+
T extends readonly [Linter.StringSeverity, unknown],
|
|
13
|
+
> = T[1] extends readonly unknown[]
|
|
14
|
+
? readonly [Linter.StringSeverity, ...T[1]]
|
|
15
|
+
: T;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Extracts the options tuple type of a rule from its TypeScript
|
|
19
|
+
* implementation, so that option types stay in sync with the rule source
|
|
20
|
+
* without converting `meta.schema` (JSON Schema) back into TypeScript types.
|
|
21
|
+
*/
|
|
22
|
+
type RuleOptionsOf<Name extends keyof typeof tsTypeForgeRules> =
|
|
23
|
+
(typeof tsTypeForgeRules)[Name] extends TSESLint.RuleModule<
|
|
24
|
+
infer _MessageIds extends string,
|
|
25
|
+
infer Options extends readonly unknown[]
|
|
26
|
+
>
|
|
27
|
+
? Options
|
|
28
|
+
: never;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @description Replace the `readonly [V, ...V[]]` tuple spelling with `NonEmptyArray<V>` from ts-type-forge.
|
|
32
|
+
*
|
|
33
|
+
* ```md
|
|
34
|
+
* | key | value |
|
|
35
|
+
* | :--------- | :--------- |
|
|
36
|
+
* | type | suggestion |
|
|
37
|
+
* | deprecated | false |
|
|
38
|
+
* | fixable | code |
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
namespace PreferNonEmptyArray {
|
|
42
|
+
/**
|
|
43
|
+
* ### schema
|
|
44
|
+
*
|
|
45
|
+
* ```json
|
|
46
|
+
* [
|
|
47
|
+
* {
|
|
48
|
+
* "type": "object",
|
|
49
|
+
* "properties": {
|
|
50
|
+
* "importStyle": {
|
|
51
|
+
* "type": "string",
|
|
52
|
+
* "enum": [
|
|
53
|
+
* "global",
|
|
54
|
+
* "named"
|
|
55
|
+
* ],
|
|
56
|
+
* "description": "How `NonEmptyArray` is brought into scope. 'global' (default) assumes the ambient globals of `ts-type-forge/global` and never touches imports; 'named' makes the autofix add `import { type NonEmptyArray } from 'ts-type-forge';` when the name is not imported yet."
|
|
57
|
+
* }
|
|
58
|
+
* },
|
|
59
|
+
* "additionalProperties": false
|
|
60
|
+
* }
|
|
61
|
+
* ]
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export type Options = NonNullable<RuleOptionsOf<'prefer-non-empty-array'>[0]>;
|
|
65
|
+
|
|
66
|
+
export type RuleEntry =
|
|
67
|
+
| 'off'
|
|
68
|
+
| Linter.Severity
|
|
69
|
+
| SpreadOptionsIfIsArray<readonly [Linter.StringSeverity, Options]>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type EslintTsTypeForgeRules = Readonly<{
|
|
73
|
+
'ts-type-forge/prefer-non-empty-array': PreferNonEmptyArray.RuleEntry;
|
|
74
|
+
}>;
|
|
75
|
+
|
|
76
|
+
export type EslintTsTypeForgeRulesOption = Readonly<{
|
|
77
|
+
'ts-type-forge/prefer-non-empty-array': PreferNonEmptyArray.Options;
|
|
78
|
+
}>;
|
|
79
|
+
|
|
80
|
+
// If this assertion fails to type-check, this generated file has drifted from
|
|
81
|
+
// the actual plugin rules — re-run `pnpm run gen:rule-types`.
|
|
82
|
+
type AssertTrue<B extends true> = B;
|
|
83
|
+
|
|
84
|
+
export type RuleNamesAreInSync = AssertTrue<
|
|
85
|
+
TypeEq<
|
|
86
|
+
keyof EslintTsTypeForgeRules,
|
|
87
|
+
`ts-type-forge/${keyof typeof tsTypeForgeRules}`
|
|
88
|
+
>
|
|
89
|
+
>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AST_NODE_TYPES,
|
|
3
|
+
type TSESLint,
|
|
4
|
+
type TSESTree,
|
|
5
|
+
} from '@typescript-eslint/utils';
|
|
6
|
+
|
|
7
|
+
/* eslint-disable @typescript-eslint/prefer-readonly-parameter-types */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Matches the "non-empty array" tuple spelling — `readonly [V, ...V[]]` (and
|
|
11
|
+
* its equivalent `readonly [V, ...(readonly V[])]`) — and returns the source
|
|
12
|
+
* text of `V`, or `undefined` when `node` is not that shape.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately narrow:
|
|
15
|
+
*
|
|
16
|
+
* - Only the `readonly` operator is matched. A mutable `[V, ...V[]]` is a
|
|
17
|
+
* different type (`MutableNonEmptyArray<V>`) and is left alone.
|
|
18
|
+
* - Exactly two elements, the second being a rest element whose element type is
|
|
19
|
+
* textually identical to the first.
|
|
20
|
+
* - Labelled members (`readonly [head: V, ...tail: V[]]`) are skipped, since
|
|
21
|
+
* rewriting them would silently drop the labels.
|
|
22
|
+
*/
|
|
23
|
+
export const getNonEmptyArrayElementText = (
|
|
24
|
+
node: TSESTree.TSTypeOperator,
|
|
25
|
+
sourceCode: TSESLint.SourceCode,
|
|
26
|
+
): string | undefined => {
|
|
27
|
+
if (node.operator !== 'readonly') return undefined;
|
|
28
|
+
|
|
29
|
+
const tuple = node.typeAnnotation;
|
|
30
|
+
|
|
31
|
+
if (tuple?.type !== AST_NODE_TYPES.TSTupleType) return undefined;
|
|
32
|
+
|
|
33
|
+
const [head, tail, ...rest] = tuple.elementTypes;
|
|
34
|
+
|
|
35
|
+
if (head === undefined || tail === undefined || rest.length > 0) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (
|
|
40
|
+
head.type === AST_NODE_TYPES.TSRestType ||
|
|
41
|
+
head.type === AST_NODE_TYPES.TSOptionalType ||
|
|
42
|
+
head.type === AST_NODE_TYPES.TSNamedTupleMember ||
|
|
43
|
+
tail.type !== AST_NODE_TYPES.TSRestType
|
|
44
|
+
) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const restElementType = getArrayElementType(tail.typeAnnotation);
|
|
49
|
+
|
|
50
|
+
if (restElementType === undefined) return undefined;
|
|
51
|
+
|
|
52
|
+
const headText = sourceCode.getText(head);
|
|
53
|
+
|
|
54
|
+
return normalizeWhitespace(headText) ===
|
|
55
|
+
normalizeWhitespace(sourceCode.getText(restElementType))
|
|
56
|
+
? headText
|
|
57
|
+
: undefined;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Unwraps `V[]` and `readonly V[]` to `V`. */
|
|
61
|
+
const getArrayElementType = (
|
|
62
|
+
node: TSESTree.TypeNode,
|
|
63
|
+
): TSESTree.TypeNode | undefined => {
|
|
64
|
+
if (node.type === AST_NODE_TYPES.TSArrayType) return node.elementType;
|
|
65
|
+
|
|
66
|
+
return node.type === AST_NODE_TYPES.TSTypeOperator &&
|
|
67
|
+
node.operator === 'readonly' &&
|
|
68
|
+
node.typeAnnotation?.type === AST_NODE_TYPES.TSArrayType
|
|
69
|
+
? node.typeAnnotation.elementType
|
|
70
|
+
: undefined;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const normalizeWhitespace = (text: string): string =>
|
|
74
|
+
text.replaceAll(/\s+/gu, ' ').trim();
|
|
75
|
+
|
|
76
|
+
/* eslint-enable @typescript-eslint/prefer-readonly-parameter-types */
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AST_NODE_TYPES,
|
|
3
|
+
type TSESLint,
|
|
4
|
+
type TSESTree,
|
|
5
|
+
} from '@typescript-eslint/utils';
|
|
6
|
+
import { TS_TYPE_FORGE_MODULE } from './constants.mjs';
|
|
7
|
+
|
|
8
|
+
/* eslint-disable @typescript-eslint/prefer-readonly-parameter-types */
|
|
9
|
+
|
|
10
|
+
/** Every `import ... from 'ts-type-forge'` declaration in the file. */
|
|
11
|
+
export const getTsTypeForgeImports = (
|
|
12
|
+
program: TSESTree.Program,
|
|
13
|
+
): readonly TSESTree.ImportDeclaration[] =>
|
|
14
|
+
program.body.filter(
|
|
15
|
+
(node): node is TSESTree.ImportDeclaration =>
|
|
16
|
+
node.type === AST_NODE_TYPES.ImportDeclaration &&
|
|
17
|
+
node.source.value === TS_TYPE_FORGE_MODULE,
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Returns the in-scope local name a given ts-type-forge export is imported as
|
|
22
|
+
* (handling aliases such as `import type { NonEmptyArray as NEA }` → `'NEA'`),
|
|
23
|
+
* or `undefined` when it is not imported. Used so autofixes reference the
|
|
24
|
+
* binding that actually exists rather than the canonical name.
|
|
25
|
+
*/
|
|
26
|
+
export const getImportedLocalName = (
|
|
27
|
+
importDeclarations: readonly TSESTree.ImportDeclaration[],
|
|
28
|
+
importedName: string,
|
|
29
|
+
): string | undefined =>
|
|
30
|
+
importDeclarations
|
|
31
|
+
.flatMap((declaration) => declaration.specifiers)
|
|
32
|
+
.find(
|
|
33
|
+
(specifier) =>
|
|
34
|
+
specifier.type === AST_NODE_TYPES.ImportSpecifier &&
|
|
35
|
+
(specifier.imported.type === AST_NODE_TYPES.Identifier
|
|
36
|
+
? specifier.imported.name
|
|
37
|
+
: specifier.imported.value) === importedName,
|
|
38
|
+
)?.local.name;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether the file already binds `name` to something that is not the
|
|
42
|
+
* ts-type-forge export of the same name — a local type alias / interface /
|
|
43
|
+
* class / enum, or an import from another module. Autofixes that introduce
|
|
44
|
+
* `name` must bail out in that case, because the rewritten type would resolve
|
|
45
|
+
* to the wrong declaration. (ts-type-forge's own sources, which declare
|
|
46
|
+
* `NonEmptyArray` themselves, are the motivating example.)
|
|
47
|
+
*/
|
|
48
|
+
export const hasConflictingDeclaration = (
|
|
49
|
+
program: TSESTree.Program,
|
|
50
|
+
name: string,
|
|
51
|
+
): boolean =>
|
|
52
|
+
program.body.some((node) => {
|
|
53
|
+
if (node.type === AST_NODE_TYPES.ImportDeclaration) {
|
|
54
|
+
return (
|
|
55
|
+
node.source.value !== TS_TYPE_FORGE_MODULE &&
|
|
56
|
+
node.specifiers.some((specifier) => specifier.local.name === name)
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return declaresName(
|
|
61
|
+
node.type === AST_NODE_TYPES.ExportNamedDeclaration ||
|
|
62
|
+
node.type === AST_NODE_TYPES.ExportDefaultDeclaration
|
|
63
|
+
? node.declaration
|
|
64
|
+
: node,
|
|
65
|
+
name,
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Inserts `import { type <name> } from 'ts-type-forge';` at the top of the
|
|
71
|
+
* file. Placement is deliberately naive: prettier-plugin-organize-imports (or
|
|
72
|
+
* any equivalent) merges and sorts it afterwards.
|
|
73
|
+
*/
|
|
74
|
+
export const buildTypeImportFix = (
|
|
75
|
+
fixer: TSESLint.RuleFixer,
|
|
76
|
+
program: TSESTree.Program,
|
|
77
|
+
name: string,
|
|
78
|
+
): readonly TSESLint.RuleFix[] =>
|
|
79
|
+
[
|
|
80
|
+
fixer.insertTextBefore(
|
|
81
|
+
program,
|
|
82
|
+
`import { type ${name} } from '${TS_TYPE_FORGE_MODULE}';\n`,
|
|
83
|
+
),
|
|
84
|
+
] as const;
|
|
85
|
+
|
|
86
|
+
const declaresName = (
|
|
87
|
+
node: TSESTree.Node | null | undefined,
|
|
88
|
+
name: string,
|
|
89
|
+
): boolean => {
|
|
90
|
+
if (node === null || node === undefined) return false;
|
|
91
|
+
|
|
92
|
+
if (
|
|
93
|
+
node.type === AST_NODE_TYPES.TSTypeAliasDeclaration ||
|
|
94
|
+
node.type === AST_NODE_TYPES.TSInterfaceDeclaration ||
|
|
95
|
+
node.type === AST_NODE_TYPES.TSEnumDeclaration
|
|
96
|
+
) {
|
|
97
|
+
return node.id.name === name;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
node.type === AST_NODE_TYPES.ClassDeclaration && node.id?.name === name
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/* eslint-enable @typescript-eslint/prefer-readonly-parameter-types */
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './rules.mjs';
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { type TSESLint, type TSESTree } from '@typescript-eslint/utils';
|
|
2
|
+
import { getNonEmptyArrayElementText } from './ast-utils.mjs';
|
|
3
|
+
import {
|
|
4
|
+
NON_EMPTY_ARRAY_TYPE_NAME,
|
|
5
|
+
TS_TYPE_FORGE_MODULE,
|
|
6
|
+
} from './constants.mjs';
|
|
7
|
+
import {
|
|
8
|
+
buildTypeImportFix,
|
|
9
|
+
getImportedLocalName,
|
|
10
|
+
getTsTypeForgeImports,
|
|
11
|
+
hasConflictingDeclaration,
|
|
12
|
+
} from './import-utils.mjs';
|
|
13
|
+
|
|
14
|
+
type ImportStyle = 'global' | 'named';
|
|
15
|
+
|
|
16
|
+
type Options = readonly [
|
|
17
|
+
Readonly<{
|
|
18
|
+
importStyle?: ImportStyle;
|
|
19
|
+
}>?,
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
type MessageIds = 'useNonEmptyArray';
|
|
23
|
+
|
|
24
|
+
const DEFAULT_IMPORT_STYLE: ImportStyle = 'global';
|
|
25
|
+
|
|
26
|
+
export const preferNonEmptyArray: TSESLint.RuleModule<MessageIds, Options> = {
|
|
27
|
+
meta: {
|
|
28
|
+
type: 'suggestion',
|
|
29
|
+
docs: {
|
|
30
|
+
description:
|
|
31
|
+
'Replace the `readonly [V, ...V[]]` tuple spelling with `NonEmptyArray<V>` from ts-type-forge.',
|
|
32
|
+
},
|
|
33
|
+
fixable: 'code',
|
|
34
|
+
schema: [
|
|
35
|
+
{
|
|
36
|
+
type: 'object',
|
|
37
|
+
properties: {
|
|
38
|
+
importStyle: {
|
|
39
|
+
type: 'string',
|
|
40
|
+
enum: ['global', 'named'],
|
|
41
|
+
description: [
|
|
42
|
+
'How `NonEmptyArray` is brought into scope.',
|
|
43
|
+
"'global' (default) assumes the ambient globals of",
|
|
44
|
+
'`ts-type-forge/global` and never touches imports;',
|
|
45
|
+
"'named' makes the autofix add",
|
|
46
|
+
`\`import { type ${NON_EMPTY_ARRAY_TYPE_NAME} } from '${TS_TYPE_FORGE_MODULE}';\``,
|
|
47
|
+
'when the name is not imported yet.',
|
|
48
|
+
].join(' '),
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
additionalProperties: false,
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
messages: {
|
|
55
|
+
useNonEmptyArray:
|
|
56
|
+
'Replace `{{original}}` with `{{replacement}}` from ts-type-forge.',
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
create: (context) => {
|
|
61
|
+
const sourceCode = context.sourceCode;
|
|
62
|
+
|
|
63
|
+
const program = sourceCode.ast;
|
|
64
|
+
|
|
65
|
+
// `context.options` is what the user actually configured; `defaultOptions`
|
|
66
|
+
// is only merged in by `RuleCreator`, which plain rule objects do not use.
|
|
67
|
+
const importStyle = context.options[0]?.importStyle ?? DEFAULT_IMPORT_STYLE;
|
|
68
|
+
|
|
69
|
+
const importedLocalName = getImportedLocalName(
|
|
70
|
+
getTsTypeForgeImports(program),
|
|
71
|
+
NON_EMPTY_ARRAY_TYPE_NAME,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const mut_nodesToFix: {
|
|
75
|
+
node: TSESTree.TSTypeOperator;
|
|
76
|
+
elementText: string;
|
|
77
|
+
}[] = [];
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
TSTypeOperator: (node) => {
|
|
81
|
+
const elementText = getNonEmptyArrayElementText(node, sourceCode);
|
|
82
|
+
|
|
83
|
+
if (elementText === undefined) return;
|
|
84
|
+
|
|
85
|
+
mut_nodesToFix.push({ node, elementText });
|
|
86
|
+
},
|
|
87
|
+
'Program:exit': () => {
|
|
88
|
+
if (mut_nodesToFix.length === 0) return;
|
|
89
|
+
|
|
90
|
+
// Rewriting to `NonEmptyArray<...>` is only sound when that name is
|
|
91
|
+
// free (or already bound to the ts-type-forge export). Otherwise the
|
|
92
|
+
// rewritten type would resolve to the wrong declaration — most
|
|
93
|
+
// notably inside ts-type-forge's own sources, which define
|
|
94
|
+
// `NonEmptyArray`.
|
|
95
|
+
if (
|
|
96
|
+
importedLocalName === undefined &&
|
|
97
|
+
hasConflictingDeclaration(program, NON_EMPTY_ARRAY_TYPE_NAME)
|
|
98
|
+
) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const localName = importedLocalName ?? NON_EMPTY_ARRAY_TYPE_NAME;
|
|
103
|
+
|
|
104
|
+
const needsImport =
|
|
105
|
+
importStyle === 'named' && importedLocalName === undefined;
|
|
106
|
+
|
|
107
|
+
for (const [index, { node, elementText }] of mut_nodesToFix.entries()) {
|
|
108
|
+
const replacement = `${localName}<${elementText}>`;
|
|
109
|
+
|
|
110
|
+
context.report({
|
|
111
|
+
node,
|
|
112
|
+
messageId: 'useNonEmptyArray',
|
|
113
|
+
data: {
|
|
114
|
+
original: sourceCode.getText(node),
|
|
115
|
+
replacement,
|
|
116
|
+
},
|
|
117
|
+
fix: (fixer) => [
|
|
118
|
+
...(index === 0 && needsImport
|
|
119
|
+
? buildTypeImportFix(fixer, program, NON_EMPTY_ARRAY_TYPE_NAME)
|
|
120
|
+
: []),
|
|
121
|
+
fixer.replaceText(node, replacement),
|
|
122
|
+
],
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
defaultOptions: [{ importStyle: DEFAULT_IMPORT_STYLE }],
|
|
129
|
+
} as const;
|