eslint-rule-forge 0.3.2 → 0.5.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 (51) hide show
  1. package/README.md +47 -40
  2. package/dist/bindings.d.mts +4 -5
  3. package/dist/bindings.d.mts.map +1 -1
  4. package/dist/bindings.mjs +0 -1
  5. package/dist/bindings.mjs.map +1 -1
  6. package/dist/comments.d.mts +10 -0
  7. package/dist/comments.d.mts.map +1 -0
  8. package/dist/comments.mjs +11 -0
  9. package/dist/comments.mjs.map +1 -0
  10. package/dist/imports.d.mts +68 -8
  11. package/dist/imports.d.mts.map +1 -1
  12. package/dist/imports.mjs +202 -6
  13. package/dist/imports.mjs.map +1 -1
  14. package/dist/index.d.mts +2 -0
  15. package/dist/index.d.mts.map +1 -1
  16. package/dist/index.mjs +2 -0
  17. package/dist/index.mjs.map +1 -1
  18. package/dist/negation.d.mts +8 -9
  19. package/dist/negation.d.mts.map +1 -1
  20. package/dist/negation.mjs +18 -18
  21. package/dist/negation.mjs.map +1 -1
  22. package/dist/rules/prefer-array-is-array.d.mts.map +1 -1
  23. package/dist/rules/prefer-array-is-array.mjs +13 -19
  24. package/dist/rules/prefer-array-is-array.mjs.map +1 -1
  25. package/dist/rules/prefer-is-non-null-object.d.mts.map +1 -1
  26. package/dist/rules/prefer-is-non-null-object.mjs +75 -76
  27. package/dist/rules/prefer-is-non-null-object.mjs.map +1 -1
  28. package/dist/rules/prefer-is-record-and-has-key.d.mts.map +1 -1
  29. package/dist/rules/prefer-is-record-and-has-key.mjs +32 -44
  30. package/dist/rules/prefer-is-record-and-has-key.mjs.map +1 -1
  31. package/dist/rules/prefer-safe-parse.d.mts +10 -7
  32. package/dist/rules/prefer-safe-parse.d.mts.map +1 -1
  33. package/dist/rules/prefer-safe-parse.mjs +50 -34
  34. package/dist/rules/prefer-safe-parse.mjs.map +1 -1
  35. package/dist/statements.d.mts +12 -0
  36. package/dist/statements.d.mts.map +1 -0
  37. package/dist/statements.mjs +39 -0
  38. package/dist/statements.mjs.map +1 -0
  39. package/dist/type-wrappers.d.mts +3 -3
  40. package/package.json +5 -5
  41. package/src/bindings.mts +4 -5
  42. package/src/comments.mts +23 -0
  43. package/src/imports.mts +363 -19
  44. package/src/index.mts +2 -0
  45. package/src/negation.mts +36 -37
  46. package/src/rules/prefer-array-is-array.mts +23 -36
  47. package/src/rules/prefer-is-non-null-object.mts +107 -112
  48. package/src/rules/prefer-is-record-and-has-key.mts +47 -71
  49. package/src/rules/prefer-safe-parse.mts +92 -77
  50. package/src/statements.mts +57 -0
  51. package/src/type-wrappers.mts +3 -3
@@ -3,11 +3,8 @@ import {
3
3
  type TSESLint,
4
4
  type TSESTree,
5
5
  } from '@typescript-eslint/utils';
6
- import {
7
- buildNamedImportFix,
8
- getImportDeclarations,
9
- getNamedImports,
10
- } from '../imports.mjs';
6
+ import { hasForeignBinding } from '../bindings.mjs';
7
+ import { bindValueImports } from '../imports.mjs';
11
8
  import { getRangeWithParens, parenthesesDepth } from '../parentheses.mjs';
12
9
  import { skipTypeWrappers } from '../type-wrappers.mjs';
13
10
 
@@ -39,11 +36,8 @@ export const createPreferIsNonNullObject = ({
39
36
  create: (context) => {
40
37
  const sourceCode = context.sourceCode;
41
38
 
42
- const program = sourceCode.ast;
43
-
44
- const moduleImports = getImportDeclarations(program, moduleName);
45
-
46
- const mut_pairsToFix: NonNullObjectPair[] = [];
39
+ const mut_pairsToFix: (NonNullObjectPair &
40
+ Readonly<{ chain: TSESTree.LogicalExpression }>)[] = [];
47
41
 
48
42
  return {
49
43
  LogicalExpression: (node) => {
@@ -52,42 +46,43 @@ export const createPreferIsNonNullObject = ({
52
46
  return;
53
47
  }
54
48
 
55
- mut_pairsToFix.push(...findNonNullObjectPairs(sourceCode, node));
49
+ mut_pairsToFix.push(
50
+ ...findNonNullObjectPairs(sourceCode, node).map((pair) => ({
51
+ ...pair,
52
+ chain: node,
53
+ })),
54
+ );
56
55
  },
57
56
  'Program:exit': () => {
58
- const hasIsNonNullObjectImport =
59
- getNamedImports(moduleImports).includes('isNonNullObject');
60
-
61
- // The import fix lives on the first report only:
62
- // `insertTextBefore(program, …)` produces overlapping ranges
63
- // otherwise and only one of them would survive a pass. A first
64
- // report silenced by an `eslint-disable` comment therefore takes
65
- // the import with it.
66
- for (const [
67
- index,
68
- { range, identifierName },
69
- ] of mut_pairsToFix.entries()) {
57
+ const binding = bindValueImports(sourceCode, moduleName, [
58
+ 'isNonNullObject',
59
+ ]);
60
+
61
+ const localName = binding?.localNames[0];
62
+
63
+ // Every fix brings `isNonNullObject` into scope itself, so that one
64
+ // applied alone (an editor's quick fix) is complete.
65
+ for (const { range, identifierName, chain } of mut_pairsToFix) {
66
+ const loc = {
67
+ start: sourceCode.getLocFromIndex(range[0]),
68
+ end: sourceCode.getLocFromIndex(range[1]),
69
+ } as const;
70
+
70
71
  context.report({
71
- loc: {
72
- start: sourceCode.getLocFromIndex(range[0]),
73
- end: sourceCode.getLocFromIndex(range[1]),
74
- },
72
+ loc,
75
73
  messageId: 'useIsNonNullObject',
76
- fix: (fixer) => {
77
- const replacement = `isNonNullObject(${identifierName})`;
78
-
79
- const importFixes =
80
- index === 0 && !hasIsNonNullObjectImport
81
- ? buildNamedImportFix(fixer, program, moduleName, [
82
- 'isNonNullObject',
83
- ])
84
- : [];
85
-
86
- return [
87
- ...importFixes,
88
- fixer.replaceTextRange(range, replacement),
89
- ];
90
- },
74
+ fix:
75
+ binding === undefined ||
76
+ localName === undefined ||
77
+ hasForeignBinding(sourceCode, chain, localName, moduleName)
78
+ ? null
79
+ : (fixer) => [
80
+ ...binding.edits(fixer),
81
+ fixer.replaceTextRange(
82
+ range,
83
+ `${localName}(${identifierName})`,
84
+ ),
85
+ ],
91
86
  });
92
87
  }
93
88
  },
@@ -120,51 +115,8 @@ const isChainOperand = (
120
115
  parenthesesDepth(sourceCode, node) === 0;
121
116
 
122
117
  /**
123
- * Every non-overlapping pair in the `&&` chain `node` heads, left to right. The
124
- * two halves may come in either order, and the pair may have operands before
125
- * and after it: `a && u !== null && typeof u === 'object' && b`. They have to
126
- * be next to each other, so that the pair is one range of the source.
127
- */
128
- const findNonNullObjectPairs = (
129
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
130
- sourceCode: TSESLint.SourceCode,
131
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
132
- node: TSESTree.LogicalExpression,
133
- ): readonly NonNullObjectPair[] => {
134
- const operands = chainOperands(sourceCode, node);
135
-
136
- const pairsFrom = (index: number): readonly NonNullObjectPair[] => {
137
- const first = operands[index];
138
-
139
- const second = operands[index + 1];
140
-
141
- if (first === undefined || second === undefined) {
142
- return [];
143
- }
144
-
145
- const identifierName =
146
- pairIdentifierName(first, second) ?? pairIdentifierName(second, first);
147
-
148
- return identifierName === undefined
149
- ? pairsFrom(index + 1)
150
- : [
151
- {
152
- range: [
153
- getRangeWithParens(sourceCode, first)[0],
154
- getRangeWithParens(sourceCode, second)[1],
155
- ],
156
- identifierName,
157
- },
158
- ...pairsFrom(index + 2),
159
- ];
160
- };
161
-
162
- return pairsFrom(0);
163
- };
164
-
165
- /**
166
- * The operands of the `&&` chain `node`, left to right. A parenthesized `&&`
167
- * is one operand: its halves cannot be replaced without its parentheses.
118
+ * The operands of the `&&` chain `node`, left to right. A parenthesized `&&` is
119
+ * one operand: its halves cannot be replaced without its parentheses.
168
120
  */
169
121
  const chainOperands = (
170
122
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
@@ -183,26 +135,11 @@ const chainOperands = (
183
135
  // Every operand below is read through its type wrappers: `typeof (u satisfies
184
136
  // unknown) === 'object' && u! !== null` is the same check.
185
137
 
186
- /**
187
- * The name of `u` when `typeofCheck` is `typeof u === 'object'` and
188
- * `nullCheck` is `u !== null` for the same `u`.
189
- */
190
- const pairIdentifierName = (
191
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
192
- typeofCheck: TSESTree.Expression,
138
+ const isLiteralOf = (
193
139
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
194
- nullCheck: TSESTree.Expression,
195
- ): string | undefined => {
196
- const identifierName = getTypeofObjectIdentifierName(
197
- skipTypeWrappers(typeofCheck),
198
- );
199
-
200
- return identifierName !== undefined &&
201
- identifierName ===
202
- getNonNullCheckIdentifierName(skipTypeWrappers(nullCheck))
203
- ? identifierName
204
- : undefined;
205
- };
140
+ node: TSESTree.Node,
141
+ value: string | null,
142
+ ): boolean => node.type === AST_NODE_TYPES.Literal && node.value === value;
206
143
 
207
144
  /** `u` of `typeof u === 'object'`, in either operand order. */
208
145
  const getTypeofObjectIdentifierName = (
@@ -242,8 +179,8 @@ const getTypeofObjectIdentifierName = (
242
179
 
243
180
  /**
244
181
  * `u` of `u !== null`, in either operand order. `u != null` also excludes
245
- * `undefined`, which `typeof u === 'object'` has already excluded, so it is
246
- * the same check here.
182
+ * `undefined`, which `typeof u === 'object'` has already excluded, so it is the
183
+ * same check here.
247
184
  */
248
185
  const getNonNullCheckIdentifierName = (
249
186
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
@@ -269,8 +206,66 @@ const getNonNullCheckIdentifierName = (
269
206
  return checked?.type === AST_NODE_TYPES.Identifier ? checked.name : undefined;
270
207
  };
271
208
 
272
- const isLiteralOf = (
209
+ /**
210
+ * The name of `u` when `typeofCheck` is `typeof u === 'object'` and `nullCheck`
211
+ * is `u !== null` for the same `u`.
212
+ */
213
+ const pairIdentifierName = (
273
214
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
274
- node: TSESTree.Node,
275
- value: string | null,
276
- ): boolean => node.type === AST_NODE_TYPES.Literal && node.value === value;
215
+ typeofCheck: TSESTree.Expression,
216
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
217
+ nullCheck: TSESTree.Expression,
218
+ ): string | undefined => {
219
+ const identifierName = getTypeofObjectIdentifierName(
220
+ skipTypeWrappers(typeofCheck),
221
+ );
222
+
223
+ return identifierName !== undefined &&
224
+ identifierName ===
225
+ getNonNullCheckIdentifierName(skipTypeWrappers(nullCheck))
226
+ ? identifierName
227
+ : undefined;
228
+ };
229
+
230
+ /**
231
+ * Every non-overlapping pair in the `&&` chain `node` heads, left to right. The
232
+ * two halves may come in either order, and the pair may have operands before
233
+ * and after it: `a && u !== null && typeof u === 'object' && b`. They have to
234
+ * be next to each other, so that the pair is one range of the source.
235
+ */
236
+ const findNonNullObjectPairs = (
237
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
238
+ sourceCode: TSESLint.SourceCode,
239
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
240
+ node: TSESTree.LogicalExpression,
241
+ ): readonly NonNullObjectPair[] => {
242
+ const operands = chainOperands(sourceCode, node);
243
+
244
+ const pairsFrom = (index: number): readonly NonNullObjectPair[] => {
245
+ const first = operands[index];
246
+
247
+ const second = operands[index + 1];
248
+
249
+ if (first === undefined || second === undefined) {
250
+ return [];
251
+ }
252
+
253
+ const identifierName =
254
+ pairIdentifierName(first, second) ?? pairIdentifierName(second, first);
255
+
256
+ return identifierName === undefined
257
+ ? pairsFrom(index + 1)
258
+ : [
259
+ {
260
+ range: [
261
+ getRangeWithParens(sourceCode, first)[0],
262
+ getRangeWithParens(sourceCode, second)[1],
263
+ ],
264
+ identifierName,
265
+ },
266
+ ...pairsFrom(index + 2),
267
+ ];
268
+ };
269
+
270
+ return pairsFrom(0);
271
+ };
@@ -4,11 +4,8 @@ import {
4
4
  type TSESTree,
5
5
  } from '@typescript-eslint/utils';
6
6
  import * as ts from 'typescript';
7
- import {
8
- buildNamedImportFix,
9
- getImportDeclarations,
10
- getNamedImports,
11
- } from '../imports.mjs';
7
+ import { hasForeignBinding } from '../bindings.mjs';
8
+ import { bindValueImports } from '../imports.mjs';
12
9
  import { getArgumentText } from '../parentheses.mjs';
13
10
  import {
14
11
  isIdentifierNamed,
@@ -45,18 +42,14 @@ export const createPreferIsRecordAndHasKey = ({
45
42
  create: (context) => {
46
43
  const sourceCode = context.sourceCode;
47
44
 
48
- const program = sourceCode.ast;
49
-
50
- const moduleImports = getImportDeclarations(program, moduleName);
51
-
52
45
  /**
53
46
  * Whether the `isRecord(...)` half of the rewrite would be dead code
54
47
  * because the object already satisfies `hasKey`'s
55
- * `R extends UnknownRecord` constraint, at every layer of type
56
- * wrappers: an array cast to a record is still rejected by `isRecord`,
57
- * and a record widened to one that may be an array is the author saying
58
- * it may need the guard. Without type information nothing is known, so
59
- * the guard is kept.
48
+ * `R extends UnknownRecord` constraint, at every layer of type wrappers:
49
+ * an array cast to a record is still rejected by `isRecord`, and a record
50
+ * widened to one that may be an array is the author saying it may need
51
+ * the guard. Without type information nothing is known, so the guard is
52
+ * kept.
60
53
  */
61
54
  const isKnownRecord = (
62
55
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
@@ -159,81 +152,64 @@ export const createPreferIsRecordAndHasKey = ({
159
152
  },
160
153
 
161
154
  'Program:exit': () => {
162
- const namedImports = getNamedImports(moduleImports);
163
-
164
- const rewrites = mut_nodesToFix.map((entry) => {
155
+ // Bound once for each shape of rewrite: a file may import `hasKey`
156
+ // but not `isRecord`, which only the rewrites that need it add.
157
+ const withIsRecord = bindValueImports(sourceCode, moduleName, [
158
+ 'isRecord',
159
+ 'hasKey',
160
+ ]);
161
+
162
+ const hasKeyOnly = bindValueImports(sourceCode, moduleName, [
163
+ 'hasKey',
164
+ ]);
165
+
166
+ for (const { node, objExpression, keyExpression } of mut_nodesToFix) {
165
167
  // A record-typed object makes the `isRecord(...)` conjunct always
166
168
  // true, so it is left out of the replacement entirely.
167
- const needsIsRecord = !isKnownRecord(entry.objExpression);
169
+ const needsIsRecord = !isKnownRecord(objExpression);
168
170
 
169
- return {
170
- ...entry,
171
- needsIsRecord,
172
- // `isRecord(obj) && hasKey(obj, key)` evaluates `obj` twice.
173
- fixable: !needsIsRecord || isSideEffectFree(entry.objExpression),
174
- };
175
- });
176
-
177
- const fixableRewrites = rewrites.filter(({ fixable }) => fixable);
171
+ const binding = needsIsRecord ? withIsRecord : hasKeyOnly;
178
172
 
179
- const importsToAdd: readonly string[] =
180
- fixableRewrites.length === 0
181
- ? []
182
- : [
183
- ...(fixableRewrites.some(
184
- ({ needsIsRecord }) => needsIsRecord,
185
- ) && !namedImports.includes('isRecord')
186
- ? ['isRecord']
187
- : []),
188
- ...(namedImports.includes('hasKey') ? [] : ['hasKey']),
189
- ];
173
+ const hasKeyName = binding?.localNames.at(-1);
190
174
 
191
- const importCarrier = fixableRewrites[0];
192
-
193
- for (const rewrite of rewrites) {
194
- const { node, objExpression, keyExpression, needsIsRecord } =
195
- rewrite;
175
+ const isRecordName = needsIsRecord
176
+ ? binding?.localNames[0]
177
+ : undefined;
196
178
 
197
179
  const objText = getArgumentText(sourceCode, objExpression);
198
180
 
199
181
  const keyText = getArgumentText(sourceCode, keyExpression);
200
182
 
201
- const originalText = sourceCode.getText(node);
183
+ const fixable =
184
+ binding !== undefined &&
185
+ hasKeyName !== undefined &&
186
+ (!needsIsRecord || isRecordName !== undefined) &&
187
+ // `isRecord(obj) && hasKey(obj, key)` evaluates `obj` twice.
188
+ (!needsIsRecord || isSideEffectFree(objExpression)) &&
189
+ binding.localNames.every(
190
+ (name) =>
191
+ !hasForeignBinding(sourceCode, node, name, moduleName),
192
+ );
202
193
 
203
194
  context.report({
204
195
  node,
205
196
  messageId: needsIsRecord ? 'useIsRecordAndHasKey' : 'useHasKey',
206
197
  data: {
207
- original: originalText,
198
+ original: sourceCode.getText(node),
208
199
  objName: objText,
209
200
  keyName: keyText,
210
201
  },
211
- fix: !rewrite.fixable
212
- ? undefined
213
- : (fixer) => {
214
- const replacement = needsIsRecord
215
- ? `(isRecord(${objText}) && hasKey(${objText}, ${keyText}))`
216
- : `hasKey(${objText}, ${keyText})`;
217
-
218
- // All import fixes have to live on the first fixable
219
- // report: `insertTextBefore(program, …)` produces
220
- // overlapping ranges otherwise and only one of them would
221
- // survive a pass.
222
- const importFixes =
223
- rewrite === importCarrier && importsToAdd.length > 0
224
- ? buildNamedImportFix(
225
- fixer,
226
- program,
227
- moduleName,
228
- importsToAdd,
229
- )
230
- : [];
231
-
232
- return [
233
- ...importFixes,
234
- fixer.replaceText(node, replacement),
235
- ];
236
- },
202
+ fix: fixable
203
+ ? (fixer) => [
204
+ ...binding.edits(fixer),
205
+ fixer.replaceText(
206
+ node,
207
+ needsIsRecord
208
+ ? `(${isRecordName}(${objText}) && ${hasKeyName}(${objText}, ${keyText}))`
209
+ : `${hasKeyName}(${objText}, ${keyText})`,
210
+ ),
211
+ ]
212
+ : null,
237
213
  });
238
214
  }
239
215
  },