eslint-plugin-ts-data-forge 0.2.0 → 0.4.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 (63) hide show
  1. package/README.md +16 -14
  2. package/dist/plugin.d.mts +3 -1
  3. package/dist/plugin.d.mts.map +1 -1
  4. package/dist/plugin.mjs +1 -0
  5. package/dist/plugin.mjs.map +1 -1
  6. package/dist/rule-types.d.mts +16 -1
  7. package/dist/rule-types.d.mts.map +1 -1
  8. package/dist/rules/import-utils.mjs.map +1 -1
  9. package/dist/rules/no-unnecessary-type-guard.d.mts.map +1 -1
  10. package/dist/rules/no-unnecessary-type-guard.mjs +13 -1
  11. package/dist/rules/no-unnecessary-type-guard.mjs.map +1 -1
  12. package/dist/rules/prefer-arr-is-bounded-length-array.mjs +1 -1
  13. package/dist/rules/prefer-arr-is-bounded-length-array.mjs.map +1 -1
  14. package/dist/rules/prefer-arr-is-fixed-length-array.mjs +1 -1
  15. package/dist/rules/prefer-arr-is-fixed-length-array.mjs.map +1 -1
  16. package/dist/rules/prefer-arr-is-max-length-array.mjs +1 -1
  17. package/dist/rules/prefer-arr-is-max-length-array.mjs.map +1 -1
  18. package/dist/rules/prefer-arr-is-min-length-array.mjs +1 -1
  19. package/dist/rules/prefer-arr-is-min-length-array.mjs.map +1 -1
  20. package/dist/rules/prefer-arr-sum.mjs +2 -2
  21. package/dist/rules/prefer-arr-sum.mjs.map +1 -1
  22. package/dist/rules/prefer-canonical-array-slicing.mjs.map +1 -1
  23. package/dist/rules/prefer-canonical-length-cast.d.mts +6 -0
  24. package/dist/rules/prefer-canonical-length-cast.d.mts.map +1 -0
  25. package/dist/rules/prefer-canonical-length-cast.mjs +155 -0
  26. package/dist/rules/prefer-canonical-length-cast.mjs.map +1 -0
  27. package/dist/rules/prefer-canonical-length-guard.d.mts.map +1 -1
  28. package/dist/rules/prefer-canonical-length-guard.mjs +12 -8
  29. package/dist/rules/prefer-canonical-length-guard.mjs.map +1 -1
  30. package/dist/rules/prefer-is-record-and-has-key.d.mts +1 -1
  31. package/dist/rules/prefer-is-record-and-has-key.d.mts.map +1 -1
  32. package/dist/rules/prefer-is-record-and-has-key.mjs +30 -16
  33. package/dist/rules/prefer-is-record-and-has-key.mjs.map +1 -1
  34. package/dist/rules/prefer-range-for-loop.mjs +1 -1
  35. package/dist/rules/prefer-range-for-loop.mjs.map +1 -1
  36. package/dist/rules/rules.d.mts +2 -1
  37. package/dist/rules/rules.d.mts.map +1 -1
  38. package/dist/rules/rules.mjs +2 -0
  39. package/dist/rules/rules.mjs.map +1 -1
  40. package/package.json +6 -6
  41. package/src/plugin.mts +1 -0
  42. package/src/rule-types.mts +17 -1
  43. package/src/rules/import-utils.mts +0 -1
  44. package/src/rules/no-unnecessary-type-guard.mts +65 -0
  45. package/src/rules/no-unnecessary-type-guard.test.mts +94 -0
  46. package/src/rules/prefer-arr-is-bounded-length-array.mts +1 -1
  47. package/src/rules/prefer-arr-is-bounded-length-array.test.mts +5 -5
  48. package/src/rules/prefer-arr-is-fixed-length-array.mts +1 -1
  49. package/src/rules/prefer-arr-is-fixed-length-array.test.mts +11 -11
  50. package/src/rules/prefer-arr-is-max-length-array.mts +1 -1
  51. package/src/rules/prefer-arr-is-max-length-array.test.mts +8 -8
  52. package/src/rules/prefer-arr-is-min-length-array.mts +1 -1
  53. package/src/rules/prefer-arr-is-min-length-array.test.mts +9 -9
  54. package/src/rules/prefer-arr-sum.mts +2 -2
  55. package/src/rules/prefer-canonical-array-slicing.mts +0 -2
  56. package/src/rules/prefer-canonical-length-cast.mts +290 -0
  57. package/src/rules/prefer-canonical-length-cast.test.mts +305 -0
  58. package/src/rules/prefer-canonical-length-guard.mts +40 -21
  59. package/src/rules/prefer-canonical-length-guard.test.mts +32 -32
  60. package/src/rules/prefer-is-record-and-has-key.mts +84 -20
  61. package/src/rules/prefer-is-record-and-has-key.test.mts +74 -0
  62. package/src/rules/prefer-range-for-loop.mts +1 -1
  63. package/src/rules/rules.mts +2 -0
@@ -3,6 +3,8 @@ import {
3
3
  type TSESLint,
4
4
  type TSESTree,
5
5
  } from '@typescript-eslint/utils';
6
+ import { Arr } from 'ts-data-forge';
7
+ import * as ts from 'typescript';
6
8
  import {
7
9
  buildImportFixes,
8
10
  getNamedImports,
@@ -11,7 +13,7 @@ import {
11
13
 
12
14
  type Options = readonly [];
13
15
 
14
- type MessageIds = 'useIsRecordAndHasKey';
16
+ type MessageIds = 'useIsRecordAndHasKey' | 'useHasKey';
15
17
 
16
18
  export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
17
19
  {
@@ -26,6 +28,8 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
26
28
  messages: {
27
29
  useIsRecordAndHasKey:
28
30
  'Replace `{{original}}` with `isRecord({{objName}}) && hasKey({{objName}}, {{keyName}})` from ts-data-forge.',
31
+ useHasKey:
32
+ 'Replace `{{original}}` with `hasKey({{objName}}, {{keyName}})` from ts-data-forge.',
29
33
  },
30
34
  },
31
35
 
@@ -36,6 +40,30 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
36
40
 
37
41
  const tsDataForgeImport = getTsDataForgeImport(program);
38
42
 
43
+ const services = sourceCode.parserServices;
44
+
45
+ const checker = services?.program?.getTypeChecker();
46
+
47
+ /**
48
+ * Whether the `isRecord(...)` half of the rewrite would be dead code
49
+ * because the object already satisfies `hasKey`'s
50
+ * `R extends UnknownRecord` constraint. Without type information nothing
51
+ * is known, so the guard is kept.
52
+ */
53
+ const isKnownRecord = (
54
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
55
+ expression: TSESTree.Expression,
56
+ ): boolean => {
57
+ if (checker === undefined) return false;
58
+
59
+ const tsNode = services?.esTreeNodeToTSNodeMap?.get(expression);
60
+
61
+ return (
62
+ tsNode !== undefined &&
63
+ isAlreadyRecordType(checker, checker.getTypeAtLocation(tsNode))
64
+ );
65
+ };
66
+
39
67
  const mut_nodesToFix: {
40
68
  node: TSESTree.CallExpression | TSESTree.BinaryExpression;
41
69
  objExpression: TSESTree.Expression;
@@ -100,14 +128,25 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
100
128
  'Program:exit': () => {
101
129
  const namedImports = getNamedImports(tsDataForgeImport);
102
130
 
103
- const hasIsRecordImport = namedImports.includes('isRecord');
104
-
105
- const hasHasKeyImport = namedImports.includes('hasKey');
131
+ const rewrites = mut_nodesToFix.map((entry) => ({
132
+ ...entry,
133
+ // A record-typed object makes the `isRecord(...)` conjunct always
134
+ // true, so it is left out of the replacement entirely.
135
+ needsIsRecord: !isKnownRecord(entry.objExpression),
136
+ }));
137
+
138
+ const importsToAdd: readonly string[] = [
139
+ ...(rewrites.some(({ needsIsRecord }) => needsIsRecord) &&
140
+ !namedImports.includes('isRecord')
141
+ ? ['isRecord']
142
+ : []),
143
+ ...(namedImports.includes('hasKey') ? [] : ['hasKey']),
144
+ ];
106
145
 
107
146
  for (const [
108
147
  index,
109
- { node, objExpression, keyExpression },
110
- ] of mut_nodesToFix.entries()) {
148
+ { node, objExpression, keyExpression, needsIsRecord },
149
+ ] of rewrites.entries()) {
111
150
  const objText = sourceCode.getText(objExpression);
112
151
 
113
152
  const keyText = sourceCode.getText(keyExpression);
@@ -116,32 +155,27 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
116
155
 
117
156
  context.report({
118
157
  node,
119
- messageId: 'useIsRecordAndHasKey',
158
+ messageId: needsIsRecord ? 'useIsRecordAndHasKey' : 'useHasKey',
120
159
  data: {
121
160
  original: originalText,
122
161
  objName: objText,
123
162
  keyName: keyText,
124
163
  },
125
164
  fix: (fixer) => {
126
- const replacement = `(isRecord(${objText}) && hasKey(${objText}, ${keyText}))`;
127
-
128
- const mut_importsToAdd: string[] = [];
129
-
130
- if (!hasIsRecordImport) {
131
- mut_importsToAdd.push('isRecord');
132
- }
133
-
134
- if (!hasHasKeyImport) {
135
- mut_importsToAdd.push('hasKey');
136
- }
165
+ const replacement = needsIsRecord
166
+ ? `(isRecord(${objText}) && hasKey(${objText}, ${keyText}))`
167
+ : `hasKey(${objText}, ${keyText})`;
137
168
 
169
+ // All import fixes have to live on the first report:
170
+ // `insertTextBefore(program, …)` produces overlapping ranges
171
+ // otherwise and only one of them would survive a pass.
138
172
  const importFixes =
139
- index === 0 && mut_importsToAdd.length > 0
173
+ index === 0 && importsToAdd.length > 0
140
174
  ? buildImportFixes(
141
175
  fixer,
142
176
  program,
143
177
  tsDataForgeImport,
144
- mut_importsToAdd,
178
+ importsToAdd,
145
179
  )
146
180
  : [];
147
181
 
@@ -154,3 +188,33 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
154
188
  },
155
189
  defaultOptions: [],
156
190
  } as const;
191
+
192
+ /**
193
+ * Whether `type` already satisfies `hasKey`'s `R extends UnknownRecord`
194
+ * constraint, so that guarding the call with `isRecord` adds nothing.
195
+ *
196
+ * `UnknownRecord` is `ReadonlyRecord<string, unknown>`, so a string index
197
+ * signature is exactly what it asks for and its value type is `unknown`, which
198
+ * anything satisfies. Callables and arrays are excluded because `isRecord`
199
+ * rejects them at runtime (`typeof fn === 'object'` is false, `Array.isArray`
200
+ * is true), so dropping the guard there would change behavior.
201
+ *
202
+ * Deliberately conservative: a type TypeScript would accept through an
203
+ * *implicit* index signature (an object type alias without one of its own)
204
+ * keeps the guard, because the checker does not report such a signature here.
205
+ */
206
+ const isAlreadyRecordType = (
207
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
208
+ checker: ts.TypeChecker,
209
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
210
+ type: ts.Type,
211
+ ): boolean =>
212
+ type.isUnion()
213
+ ? type.types.every((member) => isAlreadyRecordType(checker, member))
214
+ : checker.getIndexInfoOfType(type, ts.IndexKind.String) !== undefined &&
215
+ Arr.isEmpty(checker.getSignaturesOfType(type, ts.SignatureKind.Call)) &&
216
+ Arr.isEmpty(
217
+ checker.getSignaturesOfType(type, ts.SignatureKind.Construct),
218
+ ) &&
219
+ !checker.isArrayType(type) &&
220
+ !checker.isTupleType(type);
@@ -36,6 +36,80 @@ describe('prefer-is-record-and-has-key', () => {
36
36
  },
37
37
  ],
38
38
  invalid: [
39
+ {
40
+ name: 'drops isRecord when the object already has a string index signature',
41
+ code: dedent`
42
+ declare const obj: Readonly<Record<string, unknown>>;
43
+ const ok = Object.hasOwn(obj, 'a');
44
+ `,
45
+ output: dedent`
46
+ import { hasKey } from 'ts-data-forge';
47
+ declare const obj: Readonly<Record<string, unknown>>;
48
+ const ok = hasKey(obj, 'a');
49
+ `,
50
+ errors: [{ messageId: 'useHasKey' }],
51
+ },
52
+ {
53
+ name: 'drops isRecord for `key in obj` on a record-typed object',
54
+ code: dedent`
55
+ declare const obj: Record<string, number>;
56
+ const ok = 'a' in obj;
57
+ `,
58
+ output: dedent`
59
+ import { hasKey } from 'ts-data-forge';
60
+ declare const obj: Record<string, number>;
61
+ const ok = hasKey(obj, 'a');
62
+ `,
63
+ errors: [{ messageId: 'useHasKey' }],
64
+ },
65
+ {
66
+ name: 'keeps isRecord when only part of a union is a record',
67
+ code: dedent`
68
+ declare const obj: Record<string, unknown> | readonly unknown[];
69
+ const ok = Object.hasOwn(obj, 'a');
70
+ `,
71
+ output: dedent`
72
+ import { isRecord, hasKey } from 'ts-data-forge';
73
+ declare const obj: Record<string, unknown> | readonly unknown[];
74
+ const ok = (isRecord(obj) && hasKey(obj, 'a'));
75
+ `,
76
+ errors: [{ messageId: 'useIsRecordAndHasKey' }],
77
+ },
78
+ {
79
+ name: 'keeps isRecord for an index-signature-carrying callable',
80
+ code: dedent`
81
+ type Callable = { (): void; [key: string]: unknown };
82
+ declare const obj: Callable;
83
+ const ok = Object.hasOwn(obj, 'a');
84
+ `,
85
+ output: dedent`
86
+ import { isRecord, hasKey } from 'ts-data-forge';
87
+ type Callable = { (): void; [key: string]: unknown };
88
+ declare const obj: Callable;
89
+ const ok = (isRecord(obj) && hasKey(obj, 'a'));
90
+ `,
91
+ errors: [{ messageId: 'useIsRecordAndHasKey' }],
92
+ },
93
+ {
94
+ name: 'imports isRecord once when both forms appear in a file',
95
+ code: dedent`
96
+ declare const rec: Record<string, unknown>;
97
+ declare const unk: unknown;
98
+ const a = Object.hasOwn(rec, 'a');
99
+ const b = Object.hasOwn(unk, 'b');
100
+ `,
101
+ output: dedent`
102
+ import { isRecord, hasKey } from 'ts-data-forge';
103
+ declare const rec: Record<string, unknown>;
104
+ declare const unk: unknown;
105
+ const a = hasKey(rec, 'a');
106
+ const b = (isRecord(unk) && hasKey(unk, 'b'));
107
+ `,
108
+ errors: [
109
+ { messageId: 'useHasKey' },
110
+ { messageId: 'useIsRecordAndHasKey' },
111
+ ],
112
+ },
39
113
  {
40
114
  name: 'replaces Object.hasOwn(obj, key) with isRecord && hasKey',
41
115
  code: dedent`
@@ -50,7 +50,7 @@ export const preferRangeForLoop: TSESLint.RuleModule<MessageIds, Options> = {
50
50
  if (
51
51
  node.init?.type !== AST_NODE_TYPES.VariableDeclaration ||
52
52
  node.init.kind !== 'let' ||
53
- !Arr.isFixedLengthTuple(node.init.declarations, 1)
53
+ !Arr.isFixedLengthTuple(1, node.init.declarations)
54
54
  ) {
55
55
  return;
56
56
  }
@@ -4,6 +4,7 @@ import { preferArrIsArray } from './prefer-arr-is-array.mjs';
4
4
  import { preferArrSum } from './prefer-arr-sum.mjs';
5
5
  import { preferAsInt } from './prefer-as-int.mjs';
6
6
  import { preferCanonicalArraySlicing } from './prefer-canonical-array-slicing.mjs';
7
+ import { preferCanonicalLengthCast } from './prefer-canonical-length-cast.mjs';
7
8
  import { preferCanonicalLengthGuard } from './prefer-canonical-length-guard.mjs';
8
9
  import { preferComparisonOverNullishGuard } from './prefer-comparison-over-nullish-guard.mjs';
9
10
  import { preferIsNonNullObject } from './prefer-is-non-null-object.mjs';
@@ -15,6 +16,7 @@ import { preferRangeForLoop } from './prefer-range-for-loop.mjs';
15
16
  export const tsDataForgeRules = {
16
17
  'prefer-canonical-array-slicing': preferCanonicalArraySlicing,
17
18
  'prefer-canonical-length-guard': preferCanonicalLengthGuard,
19
+ 'prefer-canonical-length-cast': preferCanonicalLengthCast,
18
20
  'prefer-arr-is-array': preferArrIsArray,
19
21
  'prefer-arr-sum': preferArrSum,
20
22
  'prefer-as-int': preferAsInt,