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
@@ -5,11 +5,8 @@ import {
5
5
  } from '@typescript-eslint/utils';
6
6
  import type { ReadonlyRecord } from 'ts-type-forge';
7
7
  import * as ts from 'typescript';
8
- import {
9
- buildNamedImportFix,
10
- getImportDeclarations,
11
- getNamedImports,
12
- } from '../imports.mjs';
8
+ import { hasForeignBinding } from '../bindings.mjs';
9
+ import { bindValueImports } from '../imports.mjs';
13
10
  import { getArgumentText } from '../parentheses.mjs';
14
11
  import {
15
12
  isIdentifierNamed,
@@ -33,16 +30,20 @@ export type SafeParseRuleConfig = Readonly<{
33
30
  }>;
34
31
 
35
32
  /**
36
- * Builds a rule that replaces the base-10 forms of `parseInt` and
37
- * `Number.parseInt` — `parseInt(x)` and `parseInt(x, 10)` — with
38
- * `Result.unwrapOkOr(<namespace>.<method>(x), Number.NaN)`.
33
+ * Builds a rule that reports the base-10 forms of `parseInt` and
34
+ * `Number.parseInt` — `parseInt(x)` and `parseInt(x, 10)` — and suggests
35
+ * `Result.unwrapOkOr(<namespace>.<method>(x), Number.NaN)` instead. A
36
+ * suggestion, not an autofix: the replacement reads some strings differently.
39
37
  *
40
38
  * A call that passes another radix is left alone: the replacement takes none,
41
39
  * so it would mean something the replacement cannot say.
42
40
  */
43
41
  export const createPreferSafeParseInt = (
44
42
  config: SafeParseRuleConfig,
45
- ): TSESLint.RuleModule<'useSafeParseInt', readonly []> =>
43
+ ): TSESLint.RuleModule<
44
+ 'useSafeParseInt' | 'replaceWithSafeParse',
45
+ readonly []
46
+ > =>
46
47
  createSafeParseRule(config, 'useSafeParseInt', {
47
48
  messages: { useSafeParseInt: messageText(config) },
48
49
  description: `Replace \`parseInt(x)\` or \`Number.parseInt(x, 10)\` with \`Result.unwrapOkOr(${config.namespace}.${config.method}(x), Number.NaN)\` from ${config.moduleName}.`,
@@ -54,8 +55,8 @@ export const createPreferSafeParseInt = (
54
55
  return false;
55
56
  }
56
57
 
57
- // Only base 10: accept a missing radix or an explicit literal `10`,
58
- // read through its type wrappers, which leave the value as it is.
58
+ // Only base 10: accept a missing radix or an explicit literal `10`, read
59
+ // through its type wrappers, which leave the value as it is.
59
60
  const radix =
60
61
  args[1] === undefined ? undefined : skipTypeWrappers(args[1]);
61
62
 
@@ -67,15 +68,20 @@ export const createPreferSafeParseInt = (
67
68
  });
68
69
 
69
70
  /**
70
- * Builds a rule that replaces `parseFloat(x)`, `Number.parseFloat(x)` and
71
- * `Number(x)` with `Result.unwrapOkOr(<namespace>.<method>(x), Number.NaN)`.
71
+ * Builds a rule that reports `parseFloat(x)`, `Number.parseFloat(x)` and
72
+ * `Number(x)`, and suggests
73
+ * `Result.unwrapOkOr(<namespace>.<method>(x), Number.NaN)` instead, as a
74
+ * suggestion for the same reason.
72
75
  *
73
76
  * Like every rewrite here, `Number(x)` is only flagged when `x` is purely a
74
77
  * `string`, which leaves `Number(someBoolean)` and `Number(someNumber)` alone.
75
78
  */
76
79
  export const createPreferSafeParseFloat = (
77
80
  config: SafeParseRuleConfig,
78
- ): TSESLint.RuleModule<'useSafeParseFloat', readonly []> =>
81
+ ): TSESLint.RuleModule<
82
+ 'useSafeParseFloat' | 'replaceWithSafeParse',
83
+ readonly []
84
+ > =>
79
85
  createSafeParseRule(config, 'useSafeParseFloat', {
80
86
  messages: { useSafeParseFloat: messageText(config) },
81
87
  description: `Replace \`parseFloat(x)\`, \`Number.parseFloat(x)\`, or \`Number(x)\` (when x is a string) with \`Result.unwrapOkOr(${config.namespace}.${config.method}(x), Number.NaN)\` from ${config.moduleName}.`,
@@ -89,6 +95,20 @@ export const createPreferSafeParseFloat = (
89
95
 
90
96
  /* eslint-disable @typescript-eslint/prefer-readonly-parameter-types */
91
97
 
98
+ /**
99
+ * Whether every constituent of `type` is string-like, i.e. the type is
100
+ * assignable to the parse function's `string` parameter. A union such as
101
+ * `string | undefined` is rejected so the suggestion never produces a type error.
102
+ */
103
+ const isStringType = (type: ts.Type): boolean => {
104
+ const parts = type.isUnion() ? type.types : ([type] as const);
105
+
106
+ return (
107
+ parts.length > 0 &&
108
+ parts.every((t) => (t.flags & ts.TypeFlags.StringLike) !== 0)
109
+ );
110
+ };
111
+
92
112
  const createSafeParseRule = <MessageId extends string>(
93
113
  { moduleName, namespace, method }: SafeParseRuleConfig,
94
114
  messageId: MessageId,
@@ -103,16 +123,14 @@ const createSafeParseRule = <MessageId extends string>(
103
123
 
104
124
  /**
105
125
  * Whether the call, its callee read through its type wrappers, is one to
106
- * rewrite.
126
+ * report.
107
127
  */
108
128
  isTarget: (
109
129
  callee: TSESTree.Expression,
110
130
  args: readonly TSESTree.CallExpressionArgument[],
111
131
  ) => boolean;
112
132
  }>,
113
- ): TSESLint.RuleModule<MessageId, readonly []> => {
114
- const parseFunction = `${namespace}.${method}` as const;
115
-
133
+ ): TSESLint.RuleModule<MessageId | 'replaceWithSafeParse', readonly []> => {
116
134
  // Sorted, as an import sorter would write them.
117
135
  const requiredImports = [namespace, 'Result'].toSorted();
118
136
 
@@ -120,20 +138,19 @@ const createSafeParseRule = <MessageId extends string>(
120
138
  meta: {
121
139
  type: 'suggestion',
122
140
  docs: { description },
123
- fixable: 'code',
141
+ hasSuggestions: true,
124
142
  schema: [],
125
- messages,
143
+ messages: {
144
+ ...messages,
145
+ replaceWithSafeParse: `Replace with \`Result.unwrapOkOr(${namespace}.${method}({{argName}}), Number.NaN)\`.`,
146
+ },
126
147
  defaultOptions: [],
127
148
  },
128
149
 
129
150
  create: (context) => {
130
151
  const sourceCode = context.sourceCode;
131
152
 
132
- const program = sourceCode.ast;
133
-
134
- const moduleImports = getImportDeclarations(program, moduleName);
135
-
136
- const mut_nodesToFix: {
153
+ const mut_reports: {
137
154
  node: TSESTree.CallExpression;
138
155
  argExpression: TSESTree.Expression;
139
156
  }[] = [];
@@ -146,8 +163,7 @@ const createSafeParseRule = <MessageId extends string>(
146
163
 
147
164
  const firstArg = node.arguments[0];
148
165
 
149
- // A spread argument (`parseInt(...rest)`) cannot be rewritten
150
- // safely.
166
+ // A spread argument (`parseInt(...rest)`) cannot be rewritten safely.
151
167
  if (
152
168
  firstArg === undefined ||
153
169
  firstArg.type === AST_NODE_TYPES.SpreadElement
@@ -157,56 +173,69 @@ const createSafeParseRule = <MessageId extends string>(
157
173
 
158
174
  // The argument must be purely `string` at every layer of type
159
175
  // wrappers: the value's own type is what the original sees at run
160
- // time (`parseInt(1e21 as unknown as string)` is 1, the rewrite
161
- // 1e21; `Number(true as unknown as string)` is 1, the rewrite NaN),
162
- // and the type a wrapper states is what the fix, which keeps the
176
+ // time (`parseInt(1e21 as unknown as string)` is 1, the rewrite 1e21;
177
+ // `Number(true as unknown as string)` is 1, the rewrite NaN), and the
178
+ // type a wrapper states is what the suggestion, which keeps the
163
179
  // wrapper, hands the parse function's `string` parameter. Without
164
180
  // type information, skip.
165
181
  if (!typeHoldsAtEveryLayer(sourceCode, firstArg, isStringType)) {
166
182
  return;
167
183
  }
168
184
 
169
- mut_nodesToFix.push({ node, argExpression: firstArg });
185
+ mut_reports.push({ node, argExpression: firstArg });
170
186
  },
171
187
  'Program:exit': () => {
172
- const namedImports = getNamedImports(moduleImports);
173
-
174
- const missingImports = requiredImports.filter(
175
- (name) => !namedImports.includes(name),
188
+ const binding = bindValueImports(
189
+ sourceCode,
190
+ moduleName,
191
+ requiredImports,
176
192
  );
177
193
 
178
- // The import fix lives on the first report only:
179
- // `insertTextBefore(program, …)` produces overlapping ranges
180
- // otherwise and only one of them would survive a pass.
181
- for (const [
182
- index,
183
- { node, argExpression },
184
- ] of mut_nodesToFix.entries()) {
194
+ const localNameOf = (name: string): string | undefined =>
195
+ binding?.localNames[requiredImports.indexOf(name)];
196
+
197
+ const namespaceName = localNameOf(namespace);
198
+
199
+ const resultName = localNameOf('Result');
200
+
201
+ // A suggestion, never an autofix: the replacement parses some
202
+ // strings differently (`parseInt('1e3')` is 1, the rewrite 1000;
203
+ // `parseFloat('12px')` is 12, the rewrite NaN; `Number('')` is 0, the
204
+ // rewrite NaN), so applying it is a person's decision. Each one
205
+ // brings its names into scope itself, so that it is complete alone.
206
+ for (const { node, argExpression } of mut_reports) {
185
207
  const argText = getArgumentText(sourceCode, argExpression);
186
208
 
209
+ const data = {
210
+ original: sourceCode.getText(node),
211
+ argName: argText,
212
+ } as const;
213
+
187
214
  context.report({
188
215
  node,
189
216
  messageId,
190
- data: {
191
- original: sourceCode.getText(node),
192
- argName: argText,
193
- },
194
- fix: (fixer) => {
195
- const core =
196
- `Result.unwrapOkOr(${parseFunction}(${argText}), Number.NaN)` as const;
197
-
198
- const importFixes =
199
- index === 0 && missingImports.length > 0
200
- ? buildNamedImportFix(
201
- fixer,
202
- program,
203
- moduleName,
204
- missingImports,
205
- )
206
- : ([] as const);
207
-
208
- return [...importFixes, fixer.replaceText(node, core)];
209
- },
217
+ data,
218
+ suggest:
219
+ binding === undefined ||
220
+ namespaceName === undefined ||
221
+ resultName === undefined ||
222
+ [namespaceName, resultName].some((name) =>
223
+ hasForeignBinding(sourceCode, node, name, moduleName),
224
+ )
225
+ ? []
226
+ : [
227
+ {
228
+ messageId: 'replaceWithSafeParse',
229
+ data,
230
+ fix: (fixer) => [
231
+ ...binding.edits(fixer),
232
+ fixer.replaceText(
233
+ node,
234
+ `${resultName}.unwrapOkOr(${namespaceName}.${method}(${argText}), Number.NaN)`,
235
+ ),
236
+ ],
237
+ },
238
+ ],
210
239
  });
211
240
  }
212
241
  },
@@ -220,7 +249,7 @@ const messageText = ({
220
249
  namespace,
221
250
  method,
222
251
  }: SafeParseRuleConfig): string =>
223
- `Replace \`{{original}}\` with \`Result.unwrapOkOr(${namespace}.${method}({{argName}}), Number.NaN)\` from ${moduleName}.` as const;
252
+ `Replace \`{{original}}\` with \`Result.unwrapOkOr(${namespace}.${method}({{argName}}), Number.NaN)\` from ${moduleName}. It parses some strings differently from the call it replaces, so check the input before applying it.` as const;
224
253
 
225
254
  /** Whether `callee` is the global function `name`, as in `parseInt(...)`. */
226
255
  const isGlobalCall = (callee: TSESTree.Expression, name: string): boolean =>
@@ -236,17 +265,3 @@ const isNumberMethod = (callee: TSESTree.Expression, name: string): boolean =>
236
265
  isIdentifierNamed(callee.object, 'Number') &&
237
266
  callee.property.type === AST_NODE_TYPES.Identifier &&
238
267
  callee.property.name === name;
239
-
240
- /**
241
- * Whether every constituent of `type` is string-like, i.e. the type is
242
- * assignable to the parse function's `string` parameter. A union such as
243
- * `string | undefined` is rejected so the autofix never produces a type error.
244
- */
245
- const isStringType = (type: ts.Type): boolean => {
246
- const parts = type.isUnion() ? type.types : ([type] as const);
247
-
248
- return (
249
- parts.length > 0 &&
250
- parts.every((t) => (t.flags & ts.TypeFlags.StringLike) !== 0)
251
- );
252
- };
@@ -0,0 +1,57 @@
1
+ import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils';
2
+ import type { DeepReadonly } from 'ts-type-forge';
3
+
4
+ /**
5
+ * The statement next to `node` (`offset` 1) or before it (-1) in the statement
6
+ * list `node` is one of: the program's, a block's, a class static block's, a
7
+ * namespace's or a `case`'s. What an `export` declares (or, for
8
+ * `export default`, the expression it exports) is read as the `export`
9
+ * statement. `undefined` when there is none, or when `node` is not in a
10
+ * statement list (an arrow function's expression body, a `for` initializer).
11
+ */
12
+ export const siblingOf = (
13
+ node: DeepReadonly<TSESTree.Node>,
14
+ offset: 1 | -1,
15
+ ): DeepReadonly<TSESTree.Node> | undefined => {
16
+ const { parent } = node;
17
+
18
+ const statement =
19
+ (parent?.type === AST_NODE_TYPES.ExportNamedDeclaration ||
20
+ parent?.type === AST_NODE_TYPES.ExportDefaultDeclaration) &&
21
+ parent.declaration === node
22
+ ? parent
23
+ : node;
24
+
25
+ const list = statementListOf(statement);
26
+
27
+ if (list === undefined) {
28
+ return undefined;
29
+ }
30
+
31
+ const index = list.indexOf(statement);
32
+
33
+ return index === -1 ? undefined : list[index + offset];
34
+ };
35
+
36
+ const statementListOf = (
37
+ node: DeepReadonly<TSESTree.Node>,
38
+ ): readonly DeepReadonly<TSESTree.Node>[] | undefined => {
39
+ const { parent } = node;
40
+
41
+ if (parent === undefined) {
42
+ return undefined;
43
+ }
44
+
45
+ if (
46
+ parent.type === AST_NODE_TYPES.Program ||
47
+ parent.type === AST_NODE_TYPES.BlockStatement ||
48
+ parent.type === AST_NODE_TYPES.StaticBlock ||
49
+ parent.type === AST_NODE_TYPES.TSModuleBlock
50
+ ) {
51
+ return parent.body;
52
+ }
53
+
54
+ return parent.type === AST_NODE_TYPES.SwitchCase
55
+ ? parent.consequent
56
+ : undefined;
57
+ };
@@ -89,9 +89,9 @@ export const isIdentifierNamed = (
89
89
  * Whether `holds` is true of the type of every layer of type wrappers around
90
90
  * `node` (see {@link typeWrapperLayers}), outermost to innermost; `false`
91
91
  * without type information. A widening cast (`s as string | number`) is the
92
- * author saying the value may be more than it is, and is respected; a
93
- * narrowing one (`s!`, `v as unknown as string`) cannot vouch for what the
94
- * value's own type does not, and the fix, which keeps the wrapper, has to
92
+ * author saying the value may be more than it is, and is respected; a narrowing
93
+ * one (`s!`, `v as unknown as string`) cannot vouch for what the value's own
94
+ * type does not, and the fix, which keeps the wrapper, has to
95
95
  * type-check at every layer too.
96
96
  */
97
97
  export const typeHoldsAtEveryLayer = (