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.
Files changed (54) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +96 -0
  3. package/dist/index.d.mts +5 -0
  4. package/dist/index.d.mts.map +1 -0
  5. package/dist/index.mjs +3 -0
  6. package/dist/index.mjs.map +1 -0
  7. package/dist/plugin.d.mts +3 -0
  8. package/dist/plugin.d.mts.map +1 -0
  9. package/dist/plugin.mjs +11 -0
  10. package/dist/plugin.mjs.map +1 -0
  11. package/dist/rule-types.d.mts +58 -0
  12. package/dist/rule-types.d.mts.map +1 -0
  13. package/dist/rule-types.mjs +2 -0
  14. package/dist/rule-types.mjs.map +1 -0
  15. package/dist/rules/ast-utils.d.mts +17 -0
  16. package/dist/rules/ast-utils.d.mts.map +1 -0
  17. package/dist/rules/ast-utils.mjs +26 -0
  18. package/dist/rules/ast-utils.mjs.map +1 -0
  19. package/dist/rules/constants.d.mts +3 -0
  20. package/dist/rules/constants.d.mts.map +1 -0
  21. package/dist/rules/constants.mjs +5 -0
  22. package/dist/rules/constants.mjs.map +1 -0
  23. package/dist/rules/import-utils.d.mts +26 -0
  24. package/dist/rules/import-utils.d.mts.map +1 -0
  25. package/dist/rules/import-utils.mjs +35 -0
  26. package/dist/rules/import-utils.mjs.map +1 -0
  27. package/dist/rules/index.d.mts +2 -0
  28. package/dist/rules/index.d.mts.map +1 -0
  29. package/dist/rules/index.mjs +2 -0
  30. package/dist/rules/index.mjs.map +1 -0
  31. package/dist/rules/prefer-non-empty-array.d.mts +11 -0
  32. package/dist/rules/prefer-non-empty-array.d.mts.map +1 -0
  33. package/dist/rules/prefer-non-empty-array.mjs +81 -0
  34. package/dist/rules/prefer-non-empty-array.mjs.map +1 -0
  35. package/dist/rules/rules.d.mts +6 -0
  36. package/dist/rules/rules.d.mts.map +1 -0
  37. package/dist/rules/rules.mjs +8 -0
  38. package/dist/rules/rules.mjs.map +1 -0
  39. package/dist/types.d.mts +4 -0
  40. package/dist/types.d.mts.map +1 -0
  41. package/dist/types.mjs +2 -0
  42. package/dist/types.mjs.map +1 -0
  43. package/package.json +77 -0
  44. package/src/index.mts +7 -0
  45. package/src/plugin.mts +9 -0
  46. package/src/rule-types.mts +89 -0
  47. package/src/rules/ast-utils.mts +76 -0
  48. package/src/rules/constants.mts +3 -0
  49. package/src/rules/import-utils.mts +105 -0
  50. package/src/rules/index.mts +1 -0
  51. package/src/rules/prefer-non-empty-array.mts +129 -0
  52. package/src/rules/prefer-non-empty-array.test.mts +192 -0
  53. package/src/rules/rules.mts +6 -0
  54. 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,8 @@
1
+ import { preferNonEmptyArray } from './prefer-non-empty-array.mjs';
2
+
3
+ const tsTypeForgeRules = {
4
+ "prefer-non-empty-array": preferNonEmptyArray
5
+ };
6
+
7
+ export { tsTypeForgeRules };
8
+ //# sourceMappingURL=rules.mjs.map
@@ -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;;;;"}
@@ -0,0 +1,4 @@
1
+ import { type TSESLint } from '@typescript-eslint/utils';
2
+ import { type DeepReadonly } from 'ts-type-forge';
3
+ export type ESLintPlugin = DeepReadonly<TSESLint.FlatConfig.Plugin>;
4
+ //# sourceMappingURL=types.d.mts.map
@@ -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,2 @@
1
+
2
+ //# sourceMappingURL=types.mjs.map
@@ -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
@@ -0,0 +1,7 @@
1
+ export { eslintPluginTsTypeForge } from './plugin.mjs';
2
+ export type {
3
+ EslintTsTypeForgeRules,
4
+ EslintTsTypeForgeRulesOption,
5
+ } from './rule-types.mjs';
6
+ export { tsTypeForgeRules } from './rules/index.mjs';
7
+ export { type ESLintPlugin } from './types.mjs';
package/src/plugin.mts ADDED
@@ -0,0 +1,9 @@
1
+ import { tsTypeForgeRules } from './rules/index.mjs';
2
+ import { type ESLintPlugin } from './types.mjs';
3
+
4
+ export const eslintPluginTsTypeForge: ESLintPlugin = {
5
+ meta: {
6
+ name: 'eslint-plugin-ts-type-forge',
7
+ },
8
+ rules: tsTypeForgeRules,
9
+ } as const;
@@ -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,3 @@
1
+ export const TS_TYPE_FORGE_MODULE = 'ts-type-forge';
2
+
3
+ export const NON_EMPTY_ARRAY_TYPE_NAME = 'NonEmptyArray';
@@ -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;