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.
- package/LICENSE +201 -0
- package/README.md +57 -0
- package/package.json +74 -0
- package/src/index.mts +7 -0
- package/src/plugin.mts +9 -0
- package/src/rule-types.mts +326 -0
- package/src/rules/ast-utils.mts +169 -0
- package/src/rules/branded-number-types.mts +47 -0
- package/src/rules/constants.mts +1 -0
- package/src/rules/import-utils.mts +152 -0
- package/src/rules/index.mts +1 -0
- package/src/rules/no-unnecessary-type-guard.mts +515 -0
- package/src/rules/no-unnecessary-type-guard.test.mts +368 -0
- package/src/rules/prefer-arr-is-array.mts +98 -0
- package/src/rules/prefer-arr-is-array.test.mts +62 -0
- package/src/rules/prefer-arr-is-bounded-length-array.mts +143 -0
- package/src/rules/prefer-arr-is-bounded-length-array.test.mts +154 -0
- package/src/rules/prefer-arr-is-fixed-length-array.mts +149 -0
- package/src/rules/prefer-arr-is-fixed-length-array.test.mts +214 -0
- package/src/rules/prefer-arr-is-max-length-array.mts +141 -0
- package/src/rules/prefer-arr-is-max-length-array.test.mts +188 -0
- package/src/rules/prefer-arr-is-min-length-array.mts +141 -0
- package/src/rules/prefer-arr-is-min-length-array.test.mts +202 -0
- package/src/rules/prefer-arr-is-non-empty.mts +131 -0
- package/src/rules/prefer-arr-is-non-empty.test.mts +90 -0
- package/src/rules/prefer-arr-sum.mts +269 -0
- package/src/rules/prefer-arr-sum.test.mts +171 -0
- package/src/rules/prefer-as-int.mts +131 -0
- package/src/rules/prefer-as-int.test.mts +267 -0
- package/src/rules/prefer-canonical-array-slicing.mts +800 -0
- package/src/rules/prefer-canonical-array-slicing.test.mts +588 -0
- package/src/rules/prefer-comparison-over-nullish-guard.mts +141 -0
- package/src/rules/prefer-comparison-over-nullish-guard.test.mts +171 -0
- package/src/rules/prefer-is-non-null-object.mts +145 -0
- package/src/rules/prefer-is-non-null-object.test.mts +156 -0
- package/src/rules/prefer-is-record-and-has-key.mts +156 -0
- package/src/rules/prefer-is-record-and-has-key.test.mts +217 -0
- package/src/rules/prefer-num-safe-parse-float.mts +166 -0
- package/src/rules/prefer-num-safe-parse-float.test.mts +163 -0
- package/src/rules/prefer-num-safe-parse-int.mts +166 -0
- package/src/rules/prefer-num-safe-parse-int.test.mts +137 -0
- package/src/rules/prefer-range-for-loop.mts +179 -0
- package/src/rules/prefer-range-for-loop.test.mts +156 -0
- package/src/rules/rules.mts +36 -0
- 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';
|