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,515 @@
1
+ import {
2
+ AST_NODE_TYPES,
3
+ ESLintUtils,
4
+ type TSESLint,
5
+ type TSESTree,
6
+ } from '@typescript-eslint/utils';
7
+ import { type DeepReadonly } from 'ts-type-forge';
8
+ import * as ts from 'typescript';
9
+ import {
10
+ buildCalleeResolver,
11
+ buildImportFixes,
12
+ getImportedLocalName,
13
+ getTsDataForgeImport,
14
+ } from './import-utils.mjs';
15
+
16
+ type Options = readonly [
17
+ Readonly<{
18
+ /** Names of ts-data-forge guard functions to skip checking. */
19
+ ignore?: readonly string[];
20
+ }>?,
21
+ ];
22
+
23
+ type MessageIds = 'alwaysTrue' | 'alwaysFalse' | 'replaceTypeGuard';
24
+
25
+ /**
26
+ * The primitive categories a (non-`null`/`undefined`) union member can fall
27
+ * into. `other` covers anything the rule does not reason about (objects,
28
+ * functions, `void`, …).
29
+ */
30
+ type AtomKey =
31
+ 'undefined' | 'null' | 'boolean' | 'number' | 'string' | 'bigint' | 'symbol';
32
+
33
+ type Atom = AtomKey | 'other';
34
+
35
+ type GuardSpec = DeepReadonly<
36
+ | {
37
+ kind: 'narrowTo';
38
+ atoms: AtomKey[];
39
+ replacements?: Record<string, string>;
40
+ }
41
+ | {
42
+ kind: 'excludeFrom';
43
+ atoms: AtomKey[];
44
+ replacements?: Record<string, string>;
45
+ }
46
+ | { kind: 'nonEmptyString' }
47
+ >;
48
+
49
+ /**
50
+ * Internal marker property attached to every `ts-type-forge` branded type. Its
51
+ * presence (together with the `MinLength` brand key) lets us recognize a
52
+ * `NonEmptyString` without holding a reference to the type itself.
53
+ */
54
+ const BRAND_MARKER_PROPERTY =
55
+ 'TSTypeForgeInternals--edd2f9ce-7ca5-45b0-9d1a-bd61b9b5d9c3';
56
+
57
+ /**
58
+ * Brand key carried by `MinLengthString<N>` — and therefore by
59
+ * `NonEmptyString = MinLengthString<1>`. Its value is `MinLengthTuple<N, 0>`, a
60
+ * tuple of `0`s with at least `N` elements, so a required first element means
61
+ * `N >= 1` (the string cannot be empty).
62
+ */
63
+ const MIN_LENGTH_BRAND_KEY = 'MinLength';
64
+
65
+ const NULLISH_REPLACEMENTS_EXCLUDE: Readonly<Record<string, string>> = {
66
+ undefined: 'isNotUndefined',
67
+ null: 'isNotNull',
68
+ } as const;
69
+
70
+ const NULLISH_REPLACEMENTS_NARROW: Readonly<Record<string, string>> = {
71
+ undefined: 'isUndefined',
72
+ null: 'isNull',
73
+ } as const;
74
+
75
+ const GUARD_SPECS: DeepReadonly<Record<string, GuardSpec>> = {
76
+ isUndefined: { kind: 'narrowTo', atoms: ['undefined'] },
77
+ isNotUndefined: { kind: 'excludeFrom', atoms: ['undefined'] },
78
+ isNull: { kind: 'narrowTo', atoms: ['null'] },
79
+ isNotNull: { kind: 'excludeFrom', atoms: ['null'] },
80
+ isBoolean: { kind: 'narrowTo', atoms: ['boolean'] },
81
+ isNotBoolean: { kind: 'excludeFrom', atoms: ['boolean'] },
82
+ isNumber: { kind: 'narrowTo', atoms: ['number'] },
83
+ isNotNumber: { kind: 'excludeFrom', atoms: ['number'] },
84
+ isString: { kind: 'narrowTo', atoms: ['string'] },
85
+ isNotString: { kind: 'excludeFrom', atoms: ['string'] },
86
+ isBigint: { kind: 'narrowTo', atoms: ['bigint'] },
87
+ isNotBigint: { kind: 'excludeFrom', atoms: ['bigint'] },
88
+ isSymbol: { kind: 'narrowTo', atoms: ['symbol'] },
89
+ isNotSymbol: { kind: 'excludeFrom', atoms: ['symbol'] },
90
+ isNullish: {
91
+ kind: 'narrowTo',
92
+ atoms: ['null', 'undefined'],
93
+ replacements: NULLISH_REPLACEMENTS_NARROW,
94
+ },
95
+ isNonNullish: {
96
+ kind: 'excludeFrom',
97
+ atoms: ['null', 'undefined'],
98
+ replacements: NULLISH_REPLACEMENTS_EXCLUDE,
99
+ },
100
+ isNonEmptyString: { kind: 'nonEmptyString' },
101
+ } as const;
102
+
103
+ const DEFERRED_OR_OPAQUE_FLAGS =
104
+ ts.TypeFlags.Any |
105
+ ts.TypeFlags.Unknown |
106
+ ts.TypeFlags.Never |
107
+ ts.TypeFlags.TypeParameter |
108
+ ts.TypeFlags.IndexedAccess |
109
+ ts.TypeFlags.Conditional |
110
+ ts.TypeFlags.Substitution;
111
+
112
+ export const noUnnecessaryTypeGuard: TSESLint.RuleModule<MessageIds, Options> =
113
+ {
114
+ meta: {
115
+ type: 'suggestion',
116
+ docs: {
117
+ description:
118
+ 'Detect ts-data-forge type guard calls that perform no narrowing (the argument type already satisfies, or can never satisfy, the guard).',
119
+ },
120
+ fixable: 'code',
121
+ schema: [
122
+ {
123
+ type: 'object',
124
+ properties: {
125
+ ignore: {
126
+ type: 'array',
127
+ items: { type: 'string' },
128
+ description:
129
+ 'Names of ts-data-forge guard functions to skip checking.',
130
+ },
131
+ },
132
+ additionalProperties: false,
133
+ },
134
+ ],
135
+ messages: {
136
+ alwaysTrue:
137
+ 'Unnecessary `{{name}}` call: the argument type guarantees it always returns `true`. Replace the call with `true`.',
138
+ alwaysFalse:
139
+ 'Unnecessary `{{name}}` call: the argument type guarantees it always returns `false`. Replace the call with `false`.',
140
+ replaceTypeGuard:
141
+ 'Unnecessary `{{from}}` call: the argument can only be narrowed by `{{to}}`. Use `{{to}}` instead.',
142
+ },
143
+ },
144
+
145
+ create: (context) => {
146
+ const options = context.options[0];
147
+
148
+ const parserServices = ESLintUtils.getParserServices(context);
149
+
150
+ const compilerOptions = parserServices.program.getCompilerOptions();
151
+
152
+ // Without `strictNullChecks`, `null` / `undefined` are erased from the type
153
+ // system, so reasoning about nullish narrowing is unsound. Disable entirely.
154
+ const strictNullChecks =
155
+ compilerOptions.strictNullChecks ?? compilerOptions.strict ?? false;
156
+
157
+ if (!strictNullChecks) return {};
158
+
159
+ const checker = parserServices.program.getTypeChecker();
160
+
161
+ const sourceCode = context.sourceCode;
162
+
163
+ const program = sourceCode.ast;
164
+
165
+ const ignored = new Set(options?.ignore);
166
+
167
+ const tsDataForgeImport = getTsDataForgeImport(program);
168
+
169
+ const resolveGuard = buildCalleeResolver(tsDataForgeImport);
170
+
171
+ const buildReplaceFix =
172
+ (
173
+ isNamespace: boolean,
174
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
175
+ propertyNode: TSESTree.Node,
176
+ replacement: string,
177
+ ): TSESLint.ReportFixFunction =>
178
+ (fixer) => {
179
+ // A namespace member access (`tf.isX`) exposes every export, so the
180
+ // canonical name is always in scope.
181
+ if (isNamespace) {
182
+ return [fixer.replaceText(propertyNode, replacement)];
183
+ }
184
+
185
+ // If the replacement guard is already imported (possibly under an
186
+ // alias), rename to its in-scope local name. Otherwise add a new
187
+ // import and rename to the canonical name.
188
+ const localName = getImportedLocalName(
189
+ tsDataForgeImport,
190
+ replacement,
191
+ );
192
+
193
+ if (localName !== undefined) {
194
+ return [fixer.replaceText(propertyNode, localName)];
195
+ }
196
+
197
+ return [
198
+ ...buildImportFixes(fixer, program, tsDataForgeImport, [
199
+ replacement,
200
+ ]),
201
+ fixer.replaceText(propertyNode, replacement),
202
+ ];
203
+ };
204
+
205
+ const reportConstant = (
206
+ node: DeepReadonly<TSESTree.CallExpression>,
207
+ name: string,
208
+ value: boolean,
209
+ argument: DeepReadonly<TSESTree.Expression>,
210
+ ): void => {
211
+ // Replacing the call with a literal drops the argument expression, so only
212
+ // autofix when evaluating it cannot have observable side effects.
213
+ const canFix = isSideEffectFreeArg(argument);
214
+
215
+ context.report({
216
+ // eslint-disable-next-line total-functions/no-unsafe-type-assertion
217
+ node: node as TSESTree.Node,
218
+ messageId: value ? 'alwaysTrue' : 'alwaysFalse',
219
+ data: { name },
220
+ fix: canFix
221
+ ? (fixer) =>
222
+ fixer.replaceText(
223
+ // eslint-disable-next-line total-functions/no-unsafe-type-assertion
224
+ node as TSESTree.Node,
225
+ value ? 'true' : 'false',
226
+ )
227
+ : undefined,
228
+ });
229
+ };
230
+
231
+ const reportReplace = (
232
+ node: DeepReadonly<TSESTree.CallExpression>,
233
+ from: string,
234
+ to: string,
235
+ isNamespace: boolean,
236
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
237
+ propertyNode: TSESTree.Node,
238
+ ): void => {
239
+ context.report({
240
+ // eslint-disable-next-line total-functions/no-unsafe-type-assertion
241
+ node: node as TSESTree.Node,
242
+ messageId: 'replaceTypeGuard',
243
+ data: { from, to },
244
+ fix: buildReplaceFix(isNamespace, propertyNode, to),
245
+ });
246
+ };
247
+
248
+ return {
249
+ CallExpression: (node) => {
250
+ if (node.arguments.length !== 1) return;
251
+
252
+ const argument = node.arguments[0];
253
+
254
+ if (
255
+ argument === undefined ||
256
+ argument.type === AST_NODE_TYPES.SpreadElement
257
+ ) {
258
+ return;
259
+ }
260
+
261
+ const resolved = resolveGuard(node.callee);
262
+
263
+ if (resolved === undefined) return;
264
+
265
+ const { canonicalName, propertyNode, isNamespace } = resolved;
266
+
267
+ if (ignored.has(canonicalName)) return;
268
+
269
+ const spec = GUARD_SPECS[canonicalName];
270
+
271
+ if (spec === undefined) return;
272
+
273
+ const argTsNode = parserServices.esTreeNodeToTSNodeMap.get(argument);
274
+
275
+ const argType = checker.getTypeAtLocation(argTsNode);
276
+
277
+ const parts = collectUnionParts(argType);
278
+
279
+ if (parts === undefined) return; // opaque / generic → bail conservatively
280
+
281
+ const inputAtoms = new Set(parts.map(classifyAtom));
282
+
283
+ const hasMemberOutsideTarget = (
284
+ atoms: readonly AtomKey[],
285
+ ): boolean => {
286
+ const targetSet = new Set<Atom>(atoms);
287
+
288
+ return parts.some((p) => !targetSet.has(classifyAtom(p)));
289
+ };
290
+
291
+ switch (spec.kind) {
292
+ case 'excludeFrom': {
293
+ const removable = spec.atoms.filter((a) => inputAtoms.has(a));
294
+
295
+ if (removable.length === 0) {
296
+ // Nothing to exclude → the guard always holds.
297
+ reportConstant(node, canonicalName, true, argument);
298
+ } else if (!hasMemberOutsideTarget(spec.atoms)) {
299
+ // Every member is excluded → the guard never holds.
300
+ reportConstant(node, canonicalName, false, argument);
301
+ } else if (removable.length < spec.atoms.length) {
302
+ const replacement = spec.replacements?.[joinAtoms(removable)];
303
+
304
+ if (replacement !== undefined) {
305
+ reportReplace(
306
+ node,
307
+ canonicalName,
308
+ replacement,
309
+ isNamespace,
310
+ propertyNode,
311
+ );
312
+ }
313
+ }
314
+
315
+ break;
316
+ }
317
+
318
+ case 'narrowTo': {
319
+ const present = spec.atoms.filter((a) => inputAtoms.has(a));
320
+
321
+ if (present.length === 0) {
322
+ // No member is in the target set → the guard never holds.
323
+ reportConstant(node, canonicalName, false, argument);
324
+ } else if (!hasMemberOutsideTarget(spec.atoms)) {
325
+ // Input ⊆ target → the guard always holds and narrows nothing.
326
+ reportConstant(node, canonicalName, true, argument);
327
+ } else if (present.length < spec.atoms.length) {
328
+ const replacement = spec.replacements?.[joinAtoms(present)];
329
+
330
+ if (replacement !== undefined) {
331
+ reportReplace(
332
+ node,
333
+ canonicalName,
334
+ replacement,
335
+ isNamespace,
336
+ propertyNode,
337
+ );
338
+ }
339
+ }
340
+
341
+ break;
342
+ }
343
+
344
+ case 'nonEmptyString': {
345
+ const hasNullish =
346
+ inputAtoms.has('undefined') || inputAtoms.has('null');
347
+
348
+ const nonNullishParts = parts.filter((p) => {
349
+ const atom = classifyAtom(p);
350
+
351
+ return atom !== 'undefined' && atom !== 'null';
352
+ });
353
+
354
+ if (!nonNullishParts.some(couldBeNonEmptyString)) {
355
+ // No member can be a non-empty string → the guard never holds.
356
+ reportConstant(node, canonicalName, false, argument);
357
+
358
+ break;
359
+ }
360
+
361
+ if (
362
+ nonNullishParts.some(
363
+ (p) => !isGuaranteedNonEmptyString(p, checker),
364
+ )
365
+ ) {
366
+ break; // still does real string/empty work
367
+ }
368
+
369
+ if (hasNullish) {
370
+ reportReplace(
371
+ node,
372
+ canonicalName,
373
+ 'isNonNullish',
374
+ isNamespace,
375
+ propertyNode,
376
+ );
377
+ } else {
378
+ reportConstant(node, canonicalName, true, argument);
379
+ }
380
+
381
+ break;
382
+ }
383
+ }
384
+ },
385
+ };
386
+ },
387
+ defaultOptions: [{}],
388
+ } as const;
389
+
390
+ /**
391
+ * Decomposes a type into its union members. Returns `undefined` when any member
392
+ * is opaque (`any` / `unknown` / `never`) or deferred (type parameter, indexed
393
+ * access, …), signalling that the rule should not draw conclusions.
394
+ */
395
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
396
+ const collectUnionParts = (type: ts.Type): readonly ts.Type[] | undefined => {
397
+ const parts = type.isUnion() ? type.types : ([type] as const);
398
+
399
+ for (const part of parts) {
400
+ if ((part.flags & DEFERRED_OR_OPAQUE_FLAGS) !== 0) return undefined;
401
+ }
402
+
403
+ return parts;
404
+ };
405
+
406
+ /** Classifies a single (non-union) type into a primitive {@link Atom}. */
407
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
408
+ const classifyAtom = (type: ts.Type): Atom => {
409
+ const { flags } = type;
410
+
411
+ if ((flags & ts.TypeFlags.Undefined) !== 0) return 'undefined';
412
+
413
+ if ((flags & ts.TypeFlags.Null) !== 0) return 'null';
414
+
415
+ if ((flags & ts.TypeFlags.BooleanLike) !== 0) return 'boolean';
416
+
417
+ if ((flags & ts.TypeFlags.NumberLike) !== 0) return 'number';
418
+
419
+ if ((flags & ts.TypeFlags.StringLike) !== 0) return 'string';
420
+
421
+ if ((flags & ts.TypeFlags.BigIntLike) !== 0) return 'bigint';
422
+
423
+ if ((flags & ts.TypeFlags.ESSymbolLike) !== 0) return 'symbol';
424
+
425
+ // Branded primitives (e.g. `NonEmptyString = string & {...}`) are
426
+ // intersections; classify them by their underlying primitive constituent.
427
+ if (type.isIntersection()) {
428
+ for (const constituent of type.types) {
429
+ const atom = classifyAtom(constituent);
430
+
431
+ if (atom !== 'other') return atom;
432
+ }
433
+ }
434
+
435
+ return 'other';
436
+ };
437
+
438
+ const ATOM_ORDER: readonly AtomKey[] = [
439
+ 'undefined',
440
+ 'null',
441
+ 'boolean',
442
+ 'number',
443
+ 'string',
444
+ 'bigint',
445
+ 'symbol',
446
+ ] as const;
447
+
448
+ /** A stable key for a set of atoms, used to look up replacement guards. */
449
+ const joinAtoms = (atoms: readonly AtomKey[]): string =>
450
+ ATOM_ORDER.filter((atom) => atoms.includes(atom)).join('|');
451
+
452
+ /**
453
+ * Returns `true` when the type is guaranteed to be a non-empty string: either a
454
+ * non-empty string literal or a value branded as `NonEmptyString`
455
+ * (`MinLengthString<N>` with `N >= 1`).
456
+ */
457
+ const isGuaranteedNonEmptyString = (
458
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
459
+ type: ts.Type,
460
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
461
+ checker: ts.TypeChecker,
462
+ ): boolean => {
463
+ if (type.isStringLiteral()) return type.value !== '';
464
+
465
+ if (type.getProperty(BRAND_MARKER_PROPERTY) === undefined) return false;
466
+
467
+ const minLengthSymbol = type.getProperty(MIN_LENGTH_BRAND_KEY);
468
+
469
+ if (minLengthSymbol === undefined) return false;
470
+
471
+ // The `MinLength` brand value is `MinLengthTuple<N, 0>`; it has a required
472
+ // first element (`'0'`) exactly when `N >= 1`, i.e. the string is non-empty.
473
+ // `MinLengthString<0>` brands with a plain array and has no such element.
474
+ return (
475
+ checker.getTypeOfSymbol(minLengthSymbol).getProperty('0') !== undefined
476
+ );
477
+ };
478
+
479
+ /**
480
+ * Returns `true` when the type can possibly be a non-empty string (a general
481
+ * `string`, a branded string, or a non-empty string literal). The empty string
482
+ * literal and non-string types cannot.
483
+ */
484
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
485
+ const couldBeNonEmptyString = (type: ts.Type): boolean => {
486
+ if (type.isStringLiteral()) return type.value !== '';
487
+
488
+ return classifyAtom(type) === 'string';
489
+ };
490
+
491
+ /**
492
+ * Returns `true` when evaluating the expression cannot have observable side
493
+ * effects, so replacing the surrounding call with a literal is safe. Limited to
494
+ * simple references (identifiers, `this`, literals, and member-access chains).
495
+ */
496
+
497
+ const isSideEffectFreeArg = (node: DeepReadonly<TSESTree.Node>): boolean => {
498
+ // `node.type` is one of 130+ AST kinds; only a handful are relevant here.
499
+ // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
500
+ switch (node.type) {
501
+ case AST_NODE_TYPES.Identifier:
502
+ case AST_NODE_TYPES.ThisExpression:
503
+ case AST_NODE_TYPES.Literal:
504
+ return true;
505
+
506
+ case AST_NODE_TYPES.MemberExpression:
507
+ return (
508
+ isSideEffectFreeArg(node.object) &&
509
+ (!node.computed || isSideEffectFreeArg(node.property))
510
+ );
511
+
512
+ default:
513
+ return false;
514
+ }
515
+ };