eslint-plugin-ts-data-forge 0.1.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 (45) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +57 -0
  3. package/package.json +74 -0
  4. package/src/index.mts +7 -0
  5. package/src/plugin.mts +9 -0
  6. package/src/rule-types.mts +326 -0
  7. package/src/rules/ast-utils.mts +169 -0
  8. package/src/rules/branded-number-types.mts +47 -0
  9. package/src/rules/constants.mts +1 -0
  10. package/src/rules/import-utils.mts +152 -0
  11. package/src/rules/index.mts +1 -0
  12. package/src/rules/no-unnecessary-type-guard.mts +515 -0
  13. package/src/rules/no-unnecessary-type-guard.test.mts +368 -0
  14. package/src/rules/prefer-arr-is-array.mts +98 -0
  15. package/src/rules/prefer-arr-is-array.test.mts +62 -0
  16. package/src/rules/prefer-arr-is-bounded-length-array.mts +143 -0
  17. package/src/rules/prefer-arr-is-bounded-length-array.test.mts +154 -0
  18. package/src/rules/prefer-arr-is-fixed-length-array.mts +149 -0
  19. package/src/rules/prefer-arr-is-fixed-length-array.test.mts +214 -0
  20. package/src/rules/prefer-arr-is-max-length-array.mts +141 -0
  21. package/src/rules/prefer-arr-is-max-length-array.test.mts +188 -0
  22. package/src/rules/prefer-arr-is-min-length-array.mts +141 -0
  23. package/src/rules/prefer-arr-is-min-length-array.test.mts +202 -0
  24. package/src/rules/prefer-arr-is-non-empty.mts +131 -0
  25. package/src/rules/prefer-arr-is-non-empty.test.mts +90 -0
  26. package/src/rules/prefer-arr-sum.mts +269 -0
  27. package/src/rules/prefer-arr-sum.test.mts +171 -0
  28. package/src/rules/prefer-as-int.mts +131 -0
  29. package/src/rules/prefer-as-int.test.mts +267 -0
  30. package/src/rules/prefer-canonical-array-slicing.mts +800 -0
  31. package/src/rules/prefer-canonical-array-slicing.test.mts +588 -0
  32. package/src/rules/prefer-comparison-over-nullish-guard.mts +141 -0
  33. package/src/rules/prefer-comparison-over-nullish-guard.test.mts +171 -0
  34. package/src/rules/prefer-is-non-null-object.mts +145 -0
  35. package/src/rules/prefer-is-non-null-object.test.mts +156 -0
  36. package/src/rules/prefer-is-record-and-has-key.mts +156 -0
  37. package/src/rules/prefer-is-record-and-has-key.test.mts +217 -0
  38. package/src/rules/prefer-num-safe-parse-float.mts +166 -0
  39. package/src/rules/prefer-num-safe-parse-float.test.mts +163 -0
  40. package/src/rules/prefer-num-safe-parse-int.mts +166 -0
  41. package/src/rules/prefer-num-safe-parse-int.test.mts +137 -0
  42. package/src/rules/prefer-range-for-loop.mts +179 -0
  43. package/src/rules/prefer-range-for-loop.test.mts +156 -0
  44. package/src/rules/rules.mts +36 -0
  45. package/src/types.mts +4 -0
@@ -0,0 +1,169 @@
1
+ import {
2
+ AST_NODE_TYPES,
3
+ type TSESLint,
4
+ type TSESTree,
5
+ } from '@typescript-eslint/utils';
6
+ import { type DeepReadonly } from 'ts-type-forge';
7
+
8
+ const isIntegerLiteral = (node: DeepReadonly<TSESTree.Expression>): boolean =>
9
+ node.type === AST_NODE_TYPES.Literal &&
10
+ typeof node.value === 'number' &&
11
+ Number.isInteger(node.value);
12
+
13
+ export const isIntegerLiteralOrConstant = (
14
+ node: DeepReadonly<TSESTree.Expression>,
15
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
16
+ sourceCode: TSESLint.SourceCode,
17
+ ): boolean => {
18
+ // Direct integer literal (e.g., 3)
19
+ if (isIntegerLiteral(node)) return true;
20
+
21
+ // Identifier referencing a const variable initialized with an integer literal
22
+ if (node.type !== AST_NODE_TYPES.Identifier) return false;
23
+
24
+ // eslint-disable-next-line total-functions/no-unsafe-type-assertion
25
+ const scope = sourceCode.getScope(node as TSESTree.Node);
26
+
27
+ const variable = findVariable(scope, node.name);
28
+
29
+ if (variable === undefined) return false;
30
+
31
+ // Must have exactly one definition (const)
32
+ if (variable.defs.length !== 1) return false;
33
+
34
+ const def = variable.defs[0];
35
+
36
+ if (def === undefined) return false;
37
+
38
+ if (!isVariableDefinition(def)) return false;
39
+
40
+ if (def.parent.kind !== 'const') return false;
41
+
42
+ if (def.node.init == null) return false;
43
+
44
+ // Reject if there is an explicit type annotation (e.g., `const n: number = 3`)
45
+ // because the literal type is widened and type guard cannot narrow it.
46
+ if (def.node.id.typeAnnotation !== undefined) return false;
47
+
48
+ return isIntegerLiteral(def.node.init);
49
+ };
50
+
51
+ export const isLengthAccess = (
52
+ node: DeepReadonly<TSESTree.Expression>,
53
+ ): node is TSESTree.MemberExpression =>
54
+ node.type === AST_NODE_TYPES.MemberExpression &&
55
+ node.property.type === AST_NODE_TYPES.Identifier &&
56
+ node.property.name === 'length';
57
+
58
+ type LengthComparison = Readonly<{
59
+ array: TSESTree.Expression;
60
+ bound: TSESTree.Expression;
61
+ kind: 'max' | 'min';
62
+ }>;
63
+
64
+ /**
65
+ * Parses a `<array>.length <op> <bound>` comparison (in either operand order)
66
+ * into its array expression, integer-literal/`const` `bound`, and whether it is
67
+ * a lower (`min`) or upper (`max`) length bound. Returns `undefined` for
68
+ * anything else.
69
+ *
70
+ * - `xs.length >= n` / `n <= xs.length` → `min`
71
+ * - `xs.length <= n` / `n >= xs.length` → `max`
72
+ */
73
+ export const parseLengthComparison = (
74
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
75
+ node: TSESTree.Expression,
76
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
77
+ sourceCode: TSESLint.SourceCode,
78
+ ): LengthComparison | undefined => {
79
+ if (
80
+ node.type !== AST_NODE_TYPES.BinaryExpression ||
81
+ (node.operator !== '>=' && node.operator !== '<=')
82
+ ) {
83
+ return undefined;
84
+ }
85
+
86
+ const { left, right, operator } = node;
87
+
88
+ const lengthOnLeft = isLengthAccess(left);
89
+
90
+ const lengthSide = lengthOnLeft ? left : right;
91
+
92
+ const boundSide = lengthOnLeft ? right : left;
93
+
94
+ if (!isLengthAccess(lengthSide)) return undefined;
95
+
96
+ if (!isIntegerLiteralOrConstant(boundSide, sourceCode)) return undefined;
97
+
98
+ const kind: LengthComparison['kind'] = (
99
+ lengthOnLeft ? operator === '>=' : operator === '<='
100
+ )
101
+ ? 'min'
102
+ : 'max';
103
+
104
+ return { array: lengthSide.object, bound: boundSide, kind };
105
+ };
106
+
107
+ /**
108
+ * `true` when `node` is one operand of an `a && b` whose other operand is the
109
+ * complementary length bound (`min` vs `max`) on the same array — i.e. the two
110
+ * together form the `Arr.isBoundedLengthArray` pattern handled by
111
+ * `prefer-arr-is-bounded-length-array`. The single-bound rules skip such
112
+ * operands so the bounded rule rewrites the whole `&&` instead of the parts.
113
+ */
114
+ export const isPartOfBoundedLengthCheck = (
115
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
116
+ node: TSESTree.BinaryExpression,
117
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
118
+ sourceCode: TSESLint.SourceCode,
119
+ ): boolean => {
120
+ const { parent } = node;
121
+
122
+ if (
123
+ parent.type !== AST_NODE_TYPES.LogicalExpression ||
124
+ parent.operator !== '&&'
125
+ ) {
126
+ return false;
127
+ }
128
+
129
+ const self = parseLengthComparison(node, sourceCode);
130
+
131
+ if (self === undefined) return false;
132
+
133
+ const sibling = parent.left === node ? parent.right : parent.left;
134
+
135
+ const other = parseLengthComparison(sibling, sourceCode);
136
+
137
+ if (other === undefined) return false;
138
+
139
+ return (
140
+ self.kind !== other.kind &&
141
+ sourceCode.getText(self.array) === sourceCode.getText(other.array)
142
+ );
143
+ };
144
+
145
+ type VariableDefinition = Readonly<{
146
+ type: string;
147
+ parent: TSESTree.VariableDeclaration;
148
+ node: TSESTree.VariableDeclarator;
149
+ }>;
150
+
151
+ const isVariableDefinition = (
152
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
153
+ def: TSESLint.Scope.Definition,
154
+ ): def is TSESLint.Scope.Definition & VariableDefinition =>
155
+ (def.type as string) === 'Variable';
156
+
157
+ const findVariable = (
158
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
159
+ scope: TSESLint.Scope.Scope,
160
+ name: string,
161
+ ): TSESLint.Scope.Variable | undefined => {
162
+ const variable = scope.set.get(name);
163
+
164
+ if (variable !== undefined) return variable;
165
+
166
+ if (scope.upper === null) return undefined;
167
+
168
+ return findVariable(scope.upper, name);
169
+ };
@@ -0,0 +1,47 @@
1
+ // Keep this list in sync with the `as<Type>` cast functions exported by
2
+ // ts-data-forge. `pnpm run check:branded-number-types` (also run during the
3
+ // build) fails if a branded number type is missing here or no longer exists.
4
+ const brandedNumberTypes = [
5
+ 'FiniteNumber',
6
+ 'Int',
7
+ 'Int16',
8
+ 'Int32',
9
+ 'Int8',
10
+ 'NegativeFiniteNumber',
11
+ 'NegativeInt',
12
+ 'NegativeSafeInt',
13
+ 'NonNegativeFiniteNumber',
14
+ 'NonNegativeInt16',
15
+ 'NonNegativeInt32',
16
+ 'NonPositiveFiniteNumber',
17
+ 'NonPositiveInt',
18
+ 'NonPositiveSafeInt',
19
+ 'NonZeroFiniteNumber',
20
+ 'NonZeroInt',
21
+ 'NonZeroInt16',
22
+ 'NonZeroInt32',
23
+ 'NonZeroSafeInt',
24
+ 'NonZeroUint16',
25
+ 'NonZeroUint32',
26
+ 'PositiveFiniteNumber',
27
+ 'PositiveInt',
28
+ 'PositiveInt16',
29
+ 'PositiveInt32',
30
+ 'PositiveSafeInt',
31
+ 'PositiveUint16',
32
+ 'PositiveUint32',
33
+ 'SafeInt',
34
+ 'SafeUint',
35
+ 'Uint',
36
+ 'Uint16',
37
+ 'Uint32',
38
+ 'Uint8',
39
+ ] as const;
40
+
41
+ /**
42
+ * Branded number types from ts-data-forge and their corresponding as* functions
43
+ */
44
+ export const brandedNumberTypeNameToFunctionName: ReadonlyMap<
45
+ string,
46
+ `as${string}`
47
+ > = new Map(brandedNumberTypes.map((t) => [t, `as${t}`]));
@@ -0,0 +1 @@
1
+ export const TS_DATA_FORGE_MODULE = 'ts-data-forge';
@@ -0,0 +1,152 @@
1
+ import {
2
+ AST_NODE_TYPES,
3
+ type TSESLint,
4
+ type TSESTree,
5
+ } from '@typescript-eslint/utils';
6
+ import { type DeepReadonly } from 'ts-type-forge';
7
+ import { TS_DATA_FORGE_MODULE } from './constants.mjs';
8
+
9
+ /* eslint-disable @typescript-eslint/prefer-readonly-parameter-types */
10
+
11
+ export const getTsDataForgeImport = (
12
+ program: TSESTree.Program,
13
+ ): TSESTree.ImportDeclaration | undefined =>
14
+ program.body.find(
15
+ (node): node is TSESTree.ImportDeclaration =>
16
+ node.type === AST_NODE_TYPES.ImportDeclaration &&
17
+ node.source.value === TS_DATA_FORGE_MODULE,
18
+ );
19
+
20
+ export const getNamedImports = (
21
+ node: DeepReadonly<TSESTree.ImportDeclaration> | undefined,
22
+ ): readonly string[] => {
23
+ if (node === undefined) return [];
24
+
25
+ return node.specifiers.flatMap((specifier) =>
26
+ specifier.type === AST_NODE_TYPES.ImportSpecifier
27
+ ? (() => {
28
+ const importedName =
29
+ specifier.imported.type === AST_NODE_TYPES.Identifier
30
+ ? specifier.imported.name
31
+ : specifier.imported.value;
32
+
33
+ return typeof importedName === 'string' ? [importedName] : [];
34
+ })()
35
+ : [],
36
+ );
37
+ };
38
+
39
+ /**
40
+ * Returns the in-scope local name a given canonical export is imported as
41
+ * (handling aliases such as `import { isX as y }` → `'y'`), or `undefined` when
42
+ * it is not imported. Used so autofixes reference the binding that actually
43
+ * exists rather than the canonical name.
44
+ */
45
+ export const getImportedLocalName = (
46
+ node: DeepReadonly<TSESTree.ImportDeclaration> | undefined,
47
+ importedName: string,
48
+ ): string | undefined =>
49
+ node?.specifiers.find(
50
+ (specifier) =>
51
+ specifier.type === AST_NODE_TYPES.ImportSpecifier &&
52
+ (specifier.imported.type === AST_NODE_TYPES.Identifier
53
+ ? specifier.imported.name
54
+ : specifier.imported.value) === importedName,
55
+ )?.local.name;
56
+
57
+ type ResolvedCallee = Readonly<{
58
+ /** The canonical (imported) ts-data-forge name, regardless of local alias. */
59
+ canonicalName: string;
60
+ /** The identifier node to rewrite when replacing the callee name. */
61
+ propertyNode: TSESTree.Node;
62
+ /** Whether the call was made through a namespace import (`tf.isX(...)`). */
63
+ isNamespace: boolean;
64
+ }>;
65
+
66
+ /**
67
+ * Builds a resolver that maps a call expression's callee to the canonical
68
+ * ts-data-forge function it refers to. Handles named imports (including aliases
69
+ * such as `import { isX as y }`) and namespace imports (`import * as tf` →
70
+ * `tf.isX(...)`). Returns `undefined` for any callee that does not reference the
71
+ * given ts-data-forge import.
72
+ */
73
+ export const buildCalleeResolver = (
74
+ importDecl: TSESTree.ImportDeclaration | undefined,
75
+ ): ((
76
+ callee: DeepReadonly<TSESTree.Expression>,
77
+ ) => ResolvedCallee | undefined) => {
78
+ const specifiers = importDecl?.specifiers ?? [];
79
+
80
+ const namespaceName = specifiers.find(
81
+ (specifier) => specifier.type === AST_NODE_TYPES.ImportNamespaceSpecifier,
82
+ )?.local.name;
83
+
84
+ const localToCanonical = new Map<string, string>(
85
+ specifiers.flatMap((specifier) => {
86
+ if (specifier.type !== AST_NODE_TYPES.ImportSpecifier) return [];
87
+
88
+ const importedName =
89
+ specifier.imported.type === AST_NODE_TYPES.Identifier
90
+ ? specifier.imported.name
91
+ : specifier.imported.value;
92
+
93
+ return typeof importedName === 'string'
94
+ ? [[specifier.local.name, importedName] as const]
95
+ : [];
96
+ }),
97
+ );
98
+
99
+ return (callee) => {
100
+ if (callee.type === AST_NODE_TYPES.Identifier) {
101
+ const canonicalName = localToCanonical.get(callee.name);
102
+
103
+ return canonicalName === undefined
104
+ ? undefined
105
+ : {
106
+ canonicalName,
107
+ // eslint-disable-next-line total-functions/no-unsafe-type-assertion
108
+ propertyNode: callee as TSESTree.Node,
109
+ isNamespace: false,
110
+ };
111
+ }
112
+
113
+ if (
114
+ callee.type === AST_NODE_TYPES.MemberExpression &&
115
+ !callee.computed &&
116
+ callee.object.type === AST_NODE_TYPES.Identifier &&
117
+ namespaceName !== undefined &&
118
+ callee.object.name === namespaceName &&
119
+ callee.property.type === AST_NODE_TYPES.Identifier
120
+ ) {
121
+ return {
122
+ canonicalName: callee.property.name,
123
+ // eslint-disable-next-line total-functions/no-unsafe-type-assertion
124
+ propertyNode: callee.property as TSESTree.Node,
125
+ isNamespace: true,
126
+ };
127
+ }
128
+
129
+ return undefined;
130
+ };
131
+ };
132
+
133
+ export const buildImportFixes = (
134
+ fixer: TSESLint.RuleFixer,
135
+ program: TSESTree.Program,
136
+ _tsDataForgeImport: TSESTree.ImportDeclaration | undefined,
137
+ requiredNames: readonly string[],
138
+ ): readonly TSESLint.RuleFix[] => {
139
+ const specifierText = requiredNames.join(', ');
140
+
141
+ const importStatement =
142
+ `import { ${specifierText} } from '${TS_DATA_FORGE_MODULE}';` as const;
143
+
144
+ const newLine = '\n';
145
+
146
+ const insertionText = `${importStatement}${newLine}` as const;
147
+
148
+ // Always insert at the beginning of the file
149
+ // (organize-imports will handle merging/deduplication)
150
+ return [fixer.insertTextBefore(program, insertionText)];
151
+ };
152
+ /* eslint-enable @typescript-eslint/prefer-readonly-parameter-types */
@@ -0,0 +1 @@
1
+ export * from './rules.mjs';