eslint-plugin-ts-data-forge 0.1.6 → 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/src/plugin.mts CHANGED
@@ -1,9 +1,52 @@
1
1
  import { tsDataForgeRules } from './rules/index.mjs';
2
- import { type ESLintPlugin } from './types.mjs';
2
+ import { type ESLintFlatConfig, type ESLintPlugin } from './types.mjs';
3
3
 
4
- export const eslintPluginTsDataForge: ESLintPlugin = {
4
+ /**
5
+ * Every rule this plugin ships, at `error`.
6
+ *
7
+ * The `satisfies` clause below is keyed off {@link tsDataForgeRules}, so adding
8
+ * a rule without listing it here fails to type-check.
9
+ */
10
+ const recommendedRules = {
11
+ 'ts-data-forge/prefer-canonical-array-slicing': 'error',
12
+ 'ts-data-forge/prefer-canonical-length-guard': 'error',
13
+ 'ts-data-forge/prefer-arr-is-array': 'error',
14
+ 'ts-data-forge/prefer-arr-sum': 'error',
15
+ 'ts-data-forge/prefer-as-int': 'error',
16
+ 'ts-data-forge/prefer-is-non-null-object': 'error',
17
+ 'ts-data-forge/prefer-range-for-loop': 'error',
18
+ 'ts-data-forge/prefer-is-record-and-has-key': 'error',
19
+ 'ts-data-forge/prefer-num-safe-parse-int': 'error',
20
+ 'ts-data-forge/prefer-num-safe-parse-float': 'error',
21
+ 'ts-data-forge/no-unnecessary-type-guard': 'error',
22
+ 'ts-data-forge/prefer-comparison-over-nullish-guard': 'error',
23
+ } as const satisfies Readonly<
24
+ Record<`ts-data-forge/${keyof typeof tsDataForgeRules}`, 'error'>
25
+ >;
26
+
27
+ const recommendedConfig = {
28
+ name: 'ts-data-forge/recommended',
29
+ plugins: {
30
+ // Resolved lazily so that this config registers the *same* object that
31
+ // `eslintPluginTsDataForge` refers to. ESLint rejects a plugin name that
32
+ // maps to two different objects with `Cannot redefine plugin`, which is
33
+ // what a user combining this preset with their own
34
+ // `plugins: { 'ts-data-forge': eslintPluginTsDataForge }` entry would hit
35
+ // if the preset embedded a separate copy of the plugin.
36
+ get 'ts-data-forge'(): ESLintPlugin {
37
+ return eslintPluginTsDataForge;
38
+ },
39
+ },
40
+ rules: recommendedRules,
41
+ } as const satisfies ESLintFlatConfig;
42
+
43
+ export const eslintPluginTsDataForge = {
5
44
  meta: {
6
45
  name: 'eslint-plugin-ts-data-forge',
7
46
  },
8
47
  rules: tsDataForgeRules,
9
- } as const;
48
+ configs: {
49
+ /** Enables every rule of this plugin at `error`. */
50
+ recommended: recommendedConfig,
51
+ },
52
+ } as const satisfies ESLintPlugin;
@@ -0,0 +1,24 @@
1
+ import { eslintPluginTsDataForge } from './plugin.mjs';
2
+ import { tsDataForgeRules } from './rules/index.mjs';
3
+
4
+ describe('eslintPluginTsDataForge.configs.recommended', () => {
5
+ const recommended = eslintPluginTsDataForge.configs.recommended;
6
+
7
+ test('enables every rule of the plugin, and nothing else, at "error"', () => {
8
+ assert.deepStrictEqual(
9
+ recommended.rules,
10
+ Object.fromEntries(
11
+ Object.keys(tsDataForgeRules).map((name) => [
12
+ `ts-data-forge/${name}`,
13
+ 'error',
14
+ ]),
15
+ ),
16
+ );
17
+ });
18
+
19
+ test('registers the exported plugin object itself', () => {
20
+ // Registering a *copy* would make `Cannot redefine plugin` errors possible
21
+ // for users who also list the plugin in their own `plugins` record.
22
+ expect(recommended.plugins['ts-data-forge']).toBe(eslintPluginTsDataForge);
23
+ });
24
+ });
@@ -43,7 +43,7 @@ namespace PreferCanonicalArraySlicing {
43
43
  }
44
44
 
45
45
  /**
46
- * @description Replace `xs.length >= n` with `Arr.isMinLengthArray(xs, n)` from ts-data-forge.
46
+ * @description Normalize array-length checks to their canonical `Arr` guard: `xs.length <op> n` becomes the matching `Arr.is*` guard, and degenerate guards (e.g. `Arr.isFixedLengthTuple(xs, 0)`) become `Arr.isEmpty` / `Arr.isNonEmpty`.
47
47
  *
48
48
  * ```md
49
49
  * | key | value |
@@ -53,52 +53,7 @@ namespace PreferCanonicalArraySlicing {
53
53
  * | fixable | code |
54
54
  * ```
55
55
  */
56
- namespace PreferArrIsMinLengthArray {
57
- export type RuleEntry = Linter.StringSeverity;
58
- }
59
-
60
- /**
61
- * @description Replace `xs.length <= n` with `Arr.isMaxLengthArray(xs, n)` from ts-data-forge.
62
- *
63
- * ```md
64
- * | key | value |
65
- * | :--------- | :--------- |
66
- * | type | suggestion |
67
- * | deprecated | false |
68
- * | fixable | code |
69
- * ```
70
- */
71
- namespace PreferArrIsMaxLengthArray {
72
- export type RuleEntry = Linter.StringSeverity;
73
- }
74
-
75
- /**
76
- * @description Replace `xs.length >= min && xs.length <= max` with `Arr.isBoundedLengthArray(xs, min, max)` from ts-data-forge.
77
- *
78
- * ```md
79
- * | key | value |
80
- * | :--------- | :--------- |
81
- * | type | suggestion |
82
- * | deprecated | false |
83
- * | fixable | code |
84
- * ```
85
- */
86
- namespace PreferArrIsBoundedLengthArray {
87
- export type RuleEntry = Linter.StringSeverity;
88
- }
89
-
90
- /**
91
- * @description Replace `xs.length === n` with `Arr.isFixedLengthArray(xs, n)` from ts-data-forge.
92
- *
93
- * ```md
94
- * | key | value |
95
- * | :--------- | :--------- |
96
- * | type | suggestion |
97
- * | deprecated | false |
98
- * | fixable | code |
99
- * ```
100
- */
101
- namespace PreferArrIsFixedLengthArray {
56
+ namespace PreferCanonicalLengthGuard {
102
57
  export type RuleEntry = Linter.StringSeverity;
103
58
  }
104
59
 
@@ -117,21 +72,6 @@ namespace PreferArrIsArray {
117
72
  export type RuleEntry = Linter.StringSeverity;
118
73
  }
119
74
 
120
- /**
121
- * @description Replace `xs.length > 0` with `Arr.isNonEmpty(xs)` from ts-data-forge.
122
- *
123
- * ```md
124
- * | key | value |
125
- * | :--------- | :--------- |
126
- * | type | suggestion |
127
- * | deprecated | false |
128
- * | fixable | code |
129
- * ```
130
- */
131
- namespace PreferArrIsNonEmpty {
132
- export type RuleEntry = Linter.StringSeverity;
133
- }
134
-
135
75
  /**
136
76
  * @description Replace `xs.reduce((a, b) => a + b, 0)` with `Arr.sum(xs)` or `Arr.sumBy(xs, fn)` from ts-data-forge.
137
77
  *
@@ -297,12 +237,8 @@ namespace PreferComparisonOverNullishGuard {
297
237
 
298
238
  export type EslintTsDataForgeRules = Readonly<{
299
239
  'ts-data-forge/prefer-canonical-array-slicing': PreferCanonicalArraySlicing.RuleEntry;
300
- 'ts-data-forge/prefer-arr-is-min-length-array': PreferArrIsMinLengthArray.RuleEntry;
301
- 'ts-data-forge/prefer-arr-is-max-length-array': PreferArrIsMaxLengthArray.RuleEntry;
302
- 'ts-data-forge/prefer-arr-is-bounded-length-array': PreferArrIsBoundedLengthArray.RuleEntry;
303
- 'ts-data-forge/prefer-arr-is-fixed-length-array': PreferArrIsFixedLengthArray.RuleEntry;
240
+ 'ts-data-forge/prefer-canonical-length-guard': PreferCanonicalLengthGuard.RuleEntry;
304
241
  'ts-data-forge/prefer-arr-is-array': PreferArrIsArray.RuleEntry;
305
- 'ts-data-forge/prefer-arr-is-non-empty': PreferArrIsNonEmpty.RuleEntry;
306
242
  'ts-data-forge/prefer-arr-sum': PreferArrSum.RuleEntry;
307
243
  'ts-data-forge/prefer-as-int': PreferAsInt.RuleEntry;
308
244
  'ts-data-forge/prefer-is-non-null-object': PreferIsNonNullObject.RuleEntry;
@@ -0,0 +1,231 @@
1
+ import {
2
+ AST_NODE_TYPES,
3
+ type TSESLint,
4
+ type TSESTree,
5
+ } from '@typescript-eslint/utils';
6
+ import { getImportedLocalName, getTsDataForgeImport } from './import-utils.mjs';
7
+ import { preferArrIsBoundedLengthArray } from './prefer-arr-is-bounded-length-array.mjs';
8
+ import { preferArrIsFixedLengthArray } from './prefer-arr-is-fixed-length-array.mjs';
9
+ import { preferArrIsMaxLengthArray } from './prefer-arr-is-max-length-array.mjs';
10
+ import { preferArrIsMinLengthArray } from './prefer-arr-is-min-length-array.mjs';
11
+ import { preferArrIsNonEmpty } from './prefer-arr-is-non-empty.mjs';
12
+
13
+ type Options = readonly [];
14
+
15
+ type MessageIds =
16
+ | 'useCanonicalGuard'
17
+ | 'useIsBoundedLengthArray'
18
+ | 'useIsFixedLengthArray'
19
+ | 'useIsMaxLengthArray'
20
+ | 'useIsMinLengthArray'
21
+ | 'useIsNonEmpty';
22
+
23
+ /**
24
+ * The `xs.length <op> n` → `Arr.is*` rules folded into this one.
25
+ *
26
+ * Their implementations are reused as-is rather than rewritten: this rule
27
+ * merges their visitors, so every case they already cover (type-aware array
28
+ * checks, `Arr` import insertion, the `&&`-bounded-range special case) keeps
29
+ * behaving exactly as before, and their co-located tests keep exercising them
30
+ * directly.
31
+ */
32
+ const COMPARISON_RULES = [
33
+ preferArrIsNonEmpty,
34
+ preferArrIsMinLengthArray,
35
+ preferArrIsMaxLengthArray,
36
+ preferArrIsBoundedLengthArray,
37
+ preferArrIsFixedLengthArray,
38
+ ] as const;
39
+
40
+ const MESSAGES = {
41
+ useCanonicalGuard:
42
+ 'Replace `{{arrName}}.{{guard}}({{boundsText}})` with `{{arrName}}.{{replacement}}(...)`: the bound makes the two guards equivalent.',
43
+ useIsNonEmpty: preferArrIsNonEmpty.meta.messages.useIsNonEmpty,
44
+ useIsMinLengthArray:
45
+ preferArrIsMinLengthArray.meta.messages.useIsMinLengthArray,
46
+ useIsMaxLengthArray:
47
+ preferArrIsMaxLengthArray.meta.messages.useIsMaxLengthArray,
48
+ useIsBoundedLengthArray:
49
+ preferArrIsBoundedLengthArray.meta.messages.useIsBoundedLengthArray,
50
+ useIsFixedLengthArray:
51
+ preferArrIsFixedLengthArray.meta.messages.useIsFixedLengthArray,
52
+ } as const satisfies Record<MessageIds, string>;
53
+
54
+ /**
55
+ * Length guards whose bound makes them redundant with `Arr.isEmpty` /
56
+ * `Arr.isNonEmpty`, keyed by guard name.
57
+ *
58
+ * `isEmpty` narrows to `FixedLengthArray<0, E> & Xs` and `isNonEmpty` to
59
+ * `MinLengthArray<1, E> & Xs`, so:
60
+ *
61
+ * - The `*Array` entries are **type-identical** — `isFixedLengthArray(xs, 0)`
62
+ * and `isMinLengthArray(xs, 1)` produce exactly those types.
63
+ * - The `*Tuple` entries **narrow**: they resolve to the structural
64
+ * `readonly []` / `readonly [E, ...E[]]`, and the canonical guards add the
65
+ * brand on top. The result stays assignable everywhere the old type was, so
66
+ * the rewrite is safe, but it is a strengthening rather than a pure rename.
67
+ */
68
+ const GUARD_REWRITES = [
69
+ { guard: 'isFixedLengthArray', bounds: [0], replacement: 'isEmpty' },
70
+ { guard: 'isFixedLengthTuple', bounds: [0], replacement: 'isEmpty' },
71
+ { guard: 'isMaxLengthTuple', bounds: [0], replacement: 'isEmpty' },
72
+ { guard: 'isMaxLengthArray', bounds: [0], replacement: 'isEmpty' },
73
+ { guard: 'isBoundedLengthTuple', bounds: [0, 0], replacement: 'isEmpty' },
74
+ { guard: 'isBoundedLengthArray', bounds: [0, 0], replacement: 'isEmpty' },
75
+ { guard: 'isMinLengthArray', bounds: [1], replacement: 'isNonEmpty' },
76
+ { guard: 'isMinLengthTuple', bounds: [1], replacement: 'isNonEmpty' },
77
+ ] as const satisfies readonly Readonly<{
78
+ guard: string;
79
+ bounds: readonly number[];
80
+ replacement: string;
81
+ }>[];
82
+
83
+ export const preferCanonicalLengthGuard: TSESLint.RuleModule<
84
+ MessageIds,
85
+ Options
86
+ > = {
87
+ meta: {
88
+ type: 'suggestion',
89
+ docs: {
90
+ description:
91
+ 'Normalize array-length checks to their canonical `Arr` guard: `xs.length <op> n` becomes the matching `Arr.is*` guard, and degenerate guards (e.g. `Arr.isFixedLengthTuple(xs, 0)`) become `Arr.isEmpty` / `Arr.isNonEmpty`.',
92
+ },
93
+ fixable: 'code',
94
+ schema: [],
95
+ messages: MESSAGES,
96
+ },
97
+
98
+ create: (context) => {
99
+ const sourceCode = context.sourceCode;
100
+
101
+ const arrLocalName = getImportedLocalName(
102
+ getTsDataForgeImport(sourceCode.ast),
103
+ 'Arr',
104
+ );
105
+
106
+ const comparisonVisitors = COMPARISON_RULES.map((rule) =>
107
+ rule.create(context),
108
+ );
109
+
110
+ if (arrLocalName === undefined) return mergeVisitors(comparisonVisitors);
111
+
112
+ return mergeVisitors([
113
+ ...comparisonVisitors,
114
+ {
115
+ CallExpression: (node) => {
116
+ const guardName = getGuardName(node, arrLocalName);
117
+
118
+ if (guardName === undefined) return;
119
+
120
+ const rewrite = GUARD_REWRITES.find(
121
+ (entry) => entry.guard === guardName,
122
+ );
123
+
124
+ if (rewrite === undefined || !matchesBounds(node, rewrite.bounds)) {
125
+ return;
126
+ }
127
+
128
+ const [array] = node.arguments;
129
+
130
+ if (array === undefined) return;
131
+
132
+ const arrayText = sourceCode.getText(array);
133
+
134
+ context.report({
135
+ node,
136
+ messageId: 'useCanonicalGuard',
137
+ data: {
138
+ arrName: arrLocalName,
139
+ guard: guardName,
140
+ boundsText: ['…', ...rewrite.bounds.map(String)].join(', '),
141
+ replacement: rewrite.replacement,
142
+ },
143
+ fix: (fixer) =>
144
+ fixer.replaceText(
145
+ node,
146
+ `${arrLocalName}.${rewrite.replacement}(${arrayText})`,
147
+ ),
148
+ });
149
+ },
150
+ },
151
+ ]);
152
+ },
153
+ defaultOptions: [],
154
+ } as const;
155
+
156
+ /**
157
+ * Combines several rule listeners into one, calling every handler registered
158
+ * for a given selector in order.
159
+ */
160
+ const mergeVisitors = (
161
+ // `RuleListener` holds mutable AST handler signatures, so it is not deeply
162
+ // readonly.
163
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
164
+ visitors: readonly TSESLint.RuleListener[],
165
+ ): TSESLint.RuleListener => {
166
+ const mut_merged: Record<string, ((node: never) => void)[]> = {};
167
+
168
+ for (const visitor of visitors) {
169
+ for (const [selector, handler] of Object.entries(visitor)) {
170
+ if (typeof handler !== 'function') continue;
171
+
172
+ const mut_handlers = mut_merged[selector] ?? [];
173
+
174
+ mut_handlers.push(handler);
175
+
176
+ mut_merged[selector] = mut_handlers;
177
+ }
178
+ }
179
+
180
+ return Object.fromEntries(
181
+ Object.entries(mut_merged).map(([selector, handlers]) => [
182
+ selector,
183
+ (node: never) => {
184
+ for (const handler of handlers) {
185
+ handler(node);
186
+ }
187
+ },
188
+ ]),
189
+ );
190
+ };
191
+
192
+ /** The `Arr.<guard>` method name of `node`, or `undefined` for other calls. */
193
+ const getGuardName = (
194
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
195
+ node: TSESTree.CallExpression,
196
+ arrLocalName: string,
197
+ ): string | undefined => {
198
+ const { callee } = node;
199
+
200
+ return callee.type === AST_NODE_TYPES.MemberExpression &&
201
+ !callee.computed &&
202
+ callee.object.type === AST_NODE_TYPES.Identifier &&
203
+ callee.object.name === arrLocalName &&
204
+ callee.property.type === AST_NODE_TYPES.Identifier
205
+ ? callee.property.name
206
+ : undefined;
207
+ };
208
+
209
+ /**
210
+ * Whether the call is `guard(array, ...bounds)` with every bound written as the
211
+ * exact numeric literal the rewrite requires.
212
+ */
213
+ const matchesBounds = (
214
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
215
+ node: TSESTree.CallExpression,
216
+ bounds: readonly number[],
217
+ ): boolean => {
218
+ const args = node.arguments;
219
+
220
+ if (args.length !== bounds.length + 1 || node.typeArguments !== undefined) {
221
+ return false;
222
+ }
223
+
224
+ return args.every(
225
+ (arg, index) =>
226
+ arg.type !== AST_NODE_TYPES.SpreadElement &&
227
+ (index === 0 ||
228
+ (arg.type === AST_NODE_TYPES.Literal &&
229
+ arg.value === bounds[index - 1])),
230
+ );
231
+ };