eslint-plugin-ts-std-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 (80) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +104 -0
  3. package/dist/index.d.mts +5 -0
  4. package/dist/index.d.mts.map +1 -0
  5. package/dist/index.mjs +3 -0
  6. package/dist/index.mjs.map +1 -0
  7. package/dist/plugin.d.mts +32 -0
  8. package/dist/plugin.d.mts.map +1 -0
  9. package/dist/plugin.mjs +41 -0
  10. package/dist/plugin.mjs.map +1 -0
  11. package/dist/rule-types.d.mts +96 -0
  12. package/dist/rule-types.d.mts.map +1 -0
  13. package/dist/rule-types.mjs +6 -0
  14. package/dist/rule-types.mjs.map +1 -0
  15. package/dist/rules/constants.d.mts +2 -0
  16. package/dist/rules/constants.d.mts.map +1 -0
  17. package/dist/rules/constants.mjs +2 -0
  18. package/dist/rules/constants.mjs.map +1 -0
  19. package/dist/rules/import-utils.d.mts +6 -0
  20. package/dist/rules/import-utils.d.mts.map +1 -0
  21. package/dist/rules/import-utils.mjs +27 -0
  22. package/dist/rules/import-utils.mjs.map +1 -0
  23. package/dist/rules/index.d.mts +2 -0
  24. package/dist/rules/index.d.mts.map +1 -0
  25. package/dist/rules/index.mjs +2 -0
  26. package/dist/rules/index.mjs.map +1 -0
  27. package/dist/rules/prefer-is-non-null-object.d.mts +6 -0
  28. package/dist/rules/prefer-is-non-null-object.d.mts.map +1 -0
  29. package/dist/rules/prefer-is-non-null-object.mjs +97 -0
  30. package/dist/rules/prefer-is-non-null-object.mjs.map +1 -0
  31. package/dist/rules/prefer-is-record-and-has-key.d.mts +6 -0
  32. package/dist/rules/prefer-is-record-and-has-key.d.mts.map +1 -0
  33. package/dist/rules/prefer-is-record-and-has-key.mjs +154 -0
  34. package/dist/rules/prefer-is-record-and-has-key.mjs.map +1 -0
  35. package/dist/rules/prefer-safe-array-is-array.d.mts +6 -0
  36. package/dist/rules/prefer-safe-array-is-array.d.mts.map +1 -0
  37. package/dist/rules/prefer-safe-array-is-array.mjs +68 -0
  38. package/dist/rules/prefer-safe-array-is-array.mjs.map +1 -0
  39. package/dist/rules/prefer-safe-array-length-guard.d.mts +6 -0
  40. package/dist/rules/prefer-safe-array-length-guard.d.mts.map +1 -0
  41. package/dist/rules/prefer-safe-array-length-guard.mjs +143 -0
  42. package/dist/rules/prefer-safe-array-length-guard.mjs.map +1 -0
  43. package/dist/rules/prefer-safe-number-parse-integer.d.mts +13 -0
  44. package/dist/rules/prefer-safe-number-parse-integer.d.mts.map +1 -0
  45. package/dist/rules/prefer-safe-number-parse-integer.mjs +109 -0
  46. package/dist/rules/prefer-safe-number-parse-integer.mjs.map +1 -0
  47. package/dist/rules/prefer-safe-number-parse.d.mts +19 -0
  48. package/dist/rules/prefer-safe-number-parse.d.mts.map +1 -0
  49. package/dist/rules/prefer-safe-number-parse.mjs +111 -0
  50. package/dist/rules/prefer-safe-number-parse.mjs.map +1 -0
  51. package/dist/rules/rules.d.mts +9 -0
  52. package/dist/rules/rules.d.mts.map +1 -0
  53. package/dist/rules/rules.mjs +15 -0
  54. package/dist/rules/rules.mjs.map +1 -0
  55. package/dist/types.d.mts +5 -0
  56. package/dist/types.d.mts.map +1 -0
  57. package/dist/types.mjs +2 -0
  58. package/dist/types.mjs.map +1 -0
  59. package/package.json +84 -0
  60. package/src/index.mts +7 -0
  61. package/src/plugin.mts +48 -0
  62. package/src/plugin.test.mts +24 -0
  63. package/src/rule-types.mts +116 -0
  64. package/src/rules/constants.mts +1 -0
  65. package/src/rules/import-utils.mts +56 -0
  66. package/src/rules/index.mts +1 -0
  67. package/src/rules/prefer-is-non-null-object.mts +142 -0
  68. package/src/rules/prefer-is-non-null-object.test.mts +54 -0
  69. package/src/rules/prefer-is-record-and-has-key.mts +217 -0
  70. package/src/rules/prefer-is-record-and-has-key.test.mts +80 -0
  71. package/src/rules/prefer-safe-array-is-array.mts +99 -0
  72. package/src/rules/prefer-safe-array-is-array.test.mts +65 -0
  73. package/src/rules/prefer-safe-array-length-guard.mts +214 -0
  74. package/src/rules/prefer-safe-array-length-guard.test.mts +152 -0
  75. package/src/rules/prefer-safe-number-parse-integer.mts +162 -0
  76. package/src/rules/prefer-safe-number-parse-integer.test.mts +67 -0
  77. package/src/rules/prefer-safe-number-parse.mts +161 -0
  78. package/src/rules/prefer-safe-number-parse.test.mts +109 -0
  79. package/src/rules/rules.mts +16 -0
  80. package/src/types.mts +6 -0
@@ -0,0 +1,214 @@
1
+ import {
2
+ AST_NODE_TYPES,
3
+ type TSESLint,
4
+ type TSESTree,
5
+ } from '@typescript-eslint/utils';
6
+ import { type ReadonlyRecord } from 'ts-type-forge';
7
+ import {
8
+ buildImportFixes,
9
+ getNamedImports,
10
+ getTsStdForgeImport,
11
+ } from './import-utils.mjs';
12
+
13
+ type Options = readonly [];
14
+
15
+ type MessageIds = 'useIsEmpty' | 'useIsNonEmpty';
16
+
17
+ type Guard = 'isEmpty' | 'isNonEmpty';
18
+
19
+ /**
20
+ * The length comparisons that say "empty" or "non-empty" and nothing more,
21
+ * written with `.length` on the left. A comparison with the operands the other
22
+ * way round is normalized by flipping the operator before this is consulted.
23
+ *
24
+ * Only these two questions are here because only these two guards exist:
25
+ * ts-data-forge answers `xs.length >= n` for an arbitrary `n` with
26
+ * `Arr.isMinLengthArray` and the rest of the branded length family, which
27
+ * ts-std-forge deliberately has no counterpart for (D-26 / D-39).
28
+ */
29
+ const COMPARISONS = [
30
+ { operator: '===', bound: 0, guard: 'isEmpty' },
31
+ { operator: '!==', bound: 0, guard: 'isNonEmpty' },
32
+ { operator: '>', bound: 0, guard: 'isNonEmpty' },
33
+ { operator: '>=', bound: 1, guard: 'isNonEmpty' },
34
+ ] as const satisfies readonly Readonly<{
35
+ operator: string;
36
+ bound: number;
37
+ guard: Guard;
38
+ }>[];
39
+
40
+ /** The operator that means the same thing with the operands swapped. */
41
+ const FLIPPED: ReadonlyRecord<string, string> = {
42
+ '===': '===',
43
+ '!==': '!==',
44
+ '>': '<',
45
+ '<': '>',
46
+ '>=': '<=',
47
+ '<=': '>=',
48
+ } as const;
49
+
50
+ const MESSAGE_IDS: ReadonlyRecord<Guard, MessageIds> = {
51
+ isEmpty: 'useIsEmpty',
52
+ isNonEmpty: 'useIsNonEmpty',
53
+ } as const;
54
+
55
+ export const preferSafeArrayLengthGuard: TSESLint.RuleModule<
56
+ MessageIds,
57
+ Options
58
+ > = {
59
+ meta: {
60
+ type: 'suggestion',
61
+ docs: {
62
+ description:
63
+ 'Replace an emptiness check on an array (`xs.length === 0`, `xs.length > 0`, `xs.length >= 1`, `xs.length !== 0`) with `SafeArray.isEmpty(xs)` / `SafeArray.isNonEmpty(xs)` from ts-std-forge.',
64
+ },
65
+ fixable: 'code',
66
+ schema: [],
67
+ messages: {
68
+ useIsEmpty:
69
+ 'Replace `{{original}}` with `SafeArray.isEmpty({{arrayName}})` from ts-std-forge: the comparison narrows nothing, the guard narrows to `readonly []`.',
70
+ useIsNonEmpty:
71
+ 'Replace `{{original}}` with `SafeArray.isNonEmpty({{arrayName}})` from ts-std-forge: the comparison narrows nothing, so `{{arrayName}}[0]` stays `| undefined`.',
72
+ },
73
+ },
74
+
75
+ create: (context) => {
76
+ const sourceCode = context.sourceCode;
77
+
78
+ const program = sourceCode.ast;
79
+
80
+ const tsStdForgeImport = getTsStdForgeImport(program);
81
+
82
+ const services = sourceCode.parserServices;
83
+
84
+ const checker = services?.program?.getTypeChecker();
85
+
86
+ /**
87
+ * Whether `expression` is an array or tuple. Without type information the
88
+ * answer is no: `.length` is also a string's, a function's and a
89
+ * `TypedArray`'s, and none of those can be handed to the guard.
90
+ */
91
+ const isArrayLike = (
92
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
93
+ expression: TSESTree.Expression,
94
+ ): boolean => {
95
+ if (checker === undefined) return false;
96
+
97
+ const tsNode = services?.esTreeNodeToTSNodeMap?.get(expression);
98
+
99
+ if (tsNode === undefined) return false;
100
+
101
+ const type = checker.getTypeAtLocation(tsNode);
102
+
103
+ return checker.isArrayType(type) || checker.isTupleType(type);
104
+ };
105
+
106
+ const mut_nodesToFix: {
107
+ node: TSESTree.BinaryExpression;
108
+ arrayExpression: TSESTree.Expression;
109
+ guard: Guard;
110
+ }[] = [];
111
+
112
+ return {
113
+ BinaryExpression: (node) => {
114
+ const matched = matchLengthComparison(node);
115
+
116
+ if (matched === undefined) return;
117
+
118
+ if (!isArrayLike(matched.arrayExpression)) return;
119
+
120
+ mut_nodesToFix.push({ node, ...matched });
121
+ },
122
+ 'Program:exit': () => {
123
+ const hasSafeArrayImport =
124
+ getNamedImports(tsStdForgeImport).includes('SafeArray');
125
+
126
+ // The import fix lives on the first report only:
127
+ // `insertTextBefore(program, …)` produces overlapping ranges
128
+ // otherwise and only one of them would survive a pass. A first report
129
+ // silenced by an `eslint-disable` comment therefore takes the import
130
+ // with it.
131
+ for (const [
132
+ index,
133
+ { node, arrayExpression, guard },
134
+ ] of mut_nodesToFix.entries()) {
135
+ const arrayText = sourceCode.getText(arrayExpression);
136
+
137
+ context.report({
138
+ node,
139
+ messageId: MESSAGE_IDS[guard],
140
+ data: {
141
+ original: sourceCode.getText(node),
142
+ arrayName: arrayText,
143
+ },
144
+ fix: (fixer) => {
145
+ const replacement = `SafeArray.${guard}(${arrayText})`;
146
+
147
+ const importFixes =
148
+ index === 0 && !hasSafeArrayImport
149
+ ? buildImportFixes(fixer, program, ['SafeArray'])
150
+ : [];
151
+
152
+ return [...importFixes, fixer.replaceText(node, replacement)];
153
+ },
154
+ });
155
+ }
156
+ },
157
+ };
158
+ },
159
+ defaultOptions: [],
160
+ } as const;
161
+
162
+ /**
163
+ * Parses `<array>.length <op> <literal>` (in either operand order) into the
164
+ * array expression and the guard that replaces it, or `undefined` for anything
165
+ * else.
166
+ */
167
+ const matchLengthComparison = (
168
+ // Deeply readonly here would make `lengthSide.object` a `DeepReadonly<...>`,
169
+ // which then needs an assertion to go back into the report queue.
170
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
171
+ node: TSESTree.BinaryExpression,
172
+ ):
173
+ | Readonly<{ arrayExpression: TSESTree.Expression; guard: Guard }>
174
+ | undefined => {
175
+ const { left, right } = node;
176
+
177
+ if (left.type === AST_NODE_TYPES.PrivateIdentifier) return undefined;
178
+
179
+ const lengthOnLeft = isLengthAccess(left);
180
+
181
+ const lengthSide = lengthOnLeft ? left : right;
182
+
183
+ const boundSide = lengthOnLeft ? right : left;
184
+
185
+ if (!isLengthAccess(lengthSide)) return undefined;
186
+
187
+ // `xs.length === ys.length` has `.length` on both sides and no literal
188
+ // bound, so the bound check below rejects it.
189
+ if (
190
+ boundSide.type !== AST_NODE_TYPES.Literal ||
191
+ typeof boundSide.value !== 'number'
192
+ ) {
193
+ return undefined;
194
+ }
195
+
196
+ const operator = lengthOnLeft ? node.operator : FLIPPED[node.operator];
197
+
198
+ const matched = COMPARISONS.find(
199
+ (entry) => entry.operator === operator && entry.bound === boundSide.value,
200
+ );
201
+
202
+ return matched === undefined
203
+ ? undefined
204
+ : { arrayExpression: lengthSide.object, guard: matched.guard };
205
+ };
206
+
207
+ const isLengthAccess = (
208
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
209
+ node: TSESTree.Expression,
210
+ ): node is TSESTree.MemberExpression =>
211
+ node.type === AST_NODE_TYPES.MemberExpression &&
212
+ !node.computed &&
213
+ node.property.type === AST_NODE_TYPES.Identifier &&
214
+ node.property.name === 'length';
@@ -0,0 +1,152 @@
1
+ import parser from '@typescript-eslint/parser';
2
+ import { RuleTester } from '@typescript-eslint/rule-tester';
3
+ import dedent from 'dedent';
4
+ import { preferSafeArrayLengthGuard } from './prefer-safe-array-length-guard.mjs';
5
+
6
+ const tester = new RuleTester({
7
+ languageOptions: {
8
+ parser,
9
+ parserOptions: {
10
+ ecmaVersion: 2020,
11
+ sourceType: 'module',
12
+ projectService: {
13
+ allowDefaultProject: ['*.ts*'],
14
+ },
15
+ tsconfigRootDir: `${import.meta.dirname}/../..`,
16
+ },
17
+ },
18
+ });
19
+
20
+ describe('prefer-safe-array-length-guard', () => {
21
+ tester.run('prefer-safe-array-length-guard', preferSafeArrayLengthGuard, {
22
+ valid: [
23
+ {
24
+ name: 'a string length check is not an array length check',
25
+ code: dedent`
26
+ declare const s: string;
27
+ const ok = s.length > 0;
28
+ `,
29
+ },
30
+ {
31
+ name: 'a bound with no named guard is left alone',
32
+ code: dedent`
33
+ declare const xs: readonly number[];
34
+ const ok = xs.length > 2;
35
+ `,
36
+ },
37
+ {
38
+ name: 'comparing two lengths is left alone',
39
+ code: dedent`
40
+ declare const xs: readonly number[];
41
+ declare const ys: readonly number[];
42
+ const ok = xs.length === ys.length;
43
+ `,
44
+ },
45
+ {
46
+ name: 'already using the guard',
47
+ code: dedent`
48
+ import { SafeArray } from 'ts-std-forge';
49
+ declare const xs: readonly number[];
50
+ const ok = SafeArray.isNonEmpty(xs);
51
+ `,
52
+ },
53
+ ],
54
+ invalid: [
55
+ {
56
+ name: 'xs.length === 0 becomes isEmpty',
57
+ code: dedent`
58
+ declare const xs: readonly number[];
59
+ const ok = xs.length === 0;
60
+ `,
61
+ output: dedent`
62
+ import { SafeArray } from 'ts-std-forge';
63
+ declare const xs: readonly number[];
64
+ const ok = SafeArray.isEmpty(xs);
65
+ `,
66
+ errors: [{ messageId: 'useIsEmpty' }],
67
+ },
68
+ {
69
+ name: 'xs.length > 0 becomes isNonEmpty',
70
+ code: dedent`
71
+ declare const xs: readonly number[];
72
+ const ok = xs.length > 0;
73
+ `,
74
+ output: dedent`
75
+ import { SafeArray } from 'ts-std-forge';
76
+ declare const xs: readonly number[];
77
+ const ok = SafeArray.isNonEmpty(xs);
78
+ `,
79
+ errors: [{ messageId: 'useIsNonEmpty' }],
80
+ },
81
+ {
82
+ name: 'xs.length >= 1 becomes isNonEmpty',
83
+ code: dedent`
84
+ declare const xs: readonly number[];
85
+ const ok = xs.length >= 1;
86
+ `,
87
+ output: dedent`
88
+ import { SafeArray } from 'ts-std-forge';
89
+ declare const xs: readonly number[];
90
+ const ok = SafeArray.isNonEmpty(xs);
91
+ `,
92
+ errors: [{ messageId: 'useIsNonEmpty' }],
93
+ },
94
+ {
95
+ name: 'xs.length !== 0 becomes isNonEmpty',
96
+ code: dedent`
97
+ declare const xs: readonly number[];
98
+ const ok = xs.length !== 0;
99
+ `,
100
+ output: dedent`
101
+ import { SafeArray } from 'ts-std-forge';
102
+ declare const xs: readonly number[];
103
+ const ok = SafeArray.isNonEmpty(xs);
104
+ `,
105
+ errors: [{ messageId: 'useIsNonEmpty' }],
106
+ },
107
+ {
108
+ name: 'the operands the other way round are normalized',
109
+ code: dedent`
110
+ declare const xs: readonly number[];
111
+ const ok = 0 < xs.length;
112
+ `,
113
+ output: dedent`
114
+ import { SafeArray } from 'ts-std-forge';
115
+ declare const xs: readonly number[];
116
+ const ok = SafeArray.isNonEmpty(xs);
117
+ `,
118
+ errors: [{ messageId: 'useIsNonEmpty' }],
119
+ },
120
+ {
121
+ name: 'a tuple is an array too',
122
+ code: dedent`
123
+ declare const xs: readonly [number, number];
124
+ const ok = xs.length === 0;
125
+ `,
126
+ output: dedent`
127
+ import { SafeArray } from 'ts-std-forge';
128
+ declare const xs: readonly [number, number];
129
+ const ok = SafeArray.isEmpty(xs);
130
+ `,
131
+ errors: [{ messageId: 'useIsEmpty' }],
132
+ },
133
+ {
134
+ name: 'two checks in one file import once',
135
+ code: dedent`
136
+ declare const xs: readonly number[];
137
+ declare const ys: readonly number[];
138
+ const a = xs.length === 0;
139
+ const b = ys.length > 0;
140
+ `,
141
+ output: dedent`
142
+ import { SafeArray } from 'ts-std-forge';
143
+ declare const xs: readonly number[];
144
+ declare const ys: readonly number[];
145
+ const a = SafeArray.isEmpty(xs);
146
+ const b = SafeArray.isNonEmpty(ys);
147
+ `,
148
+ errors: [{ messageId: 'useIsEmpty' }, { messageId: 'useIsNonEmpty' }],
149
+ },
150
+ ],
151
+ });
152
+ });
@@ -0,0 +1,162 @@
1
+ import {
2
+ AST_NODE_TYPES,
3
+ type TSESLint,
4
+ type TSESTree,
5
+ } from '@typescript-eslint/utils';
6
+ import * as ts from 'typescript';
7
+ import {
8
+ buildImportFixes,
9
+ getNamedImports,
10
+ getTsStdForgeImport,
11
+ } from './import-utils.mjs';
12
+
13
+ type Options = readonly [];
14
+
15
+ type MessageIds = 'useSafeNumberParseInteger';
16
+
17
+ /**
18
+ * Returns `true` when every constituent of `type` is string-like, i.e. the
19
+ * type is assignable to the `string` parameter of `SafeNumber.parseInteger`.
20
+ * A union such as `string | undefined` is rejected so the autofix never
21
+ * produces a type error.
22
+ */
23
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
24
+ const isStringType = (type: ts.Type): boolean => {
25
+ const parts = type.isUnion() ? type.types : ([type] as const);
26
+
27
+ return (
28
+ parts.length > 0 &&
29
+ parts.every((t) => (t.flags & ts.TypeFlags.StringLike) !== 0)
30
+ );
31
+ };
32
+
33
+ /**
34
+ * Detects `parseInt(x)` / `Number.parseInt(x, 10)` and replaces them with
35
+ * `Result.unwrapOkOr(SafeNumber.parseInteger(x), Number.NaN)` (ts-std-forge).
36
+ *
37
+ * Only base 10 is rewritten: `SafeNumber.parseInteger` takes no radix, so a
38
+ * call that passes another one means something the replacement cannot say.
39
+ */
40
+ export const preferSafeNumberParseInteger: TSESLint.RuleModule<
41
+ MessageIds,
42
+ Options
43
+ > = {
44
+ meta: {
45
+ type: 'suggestion',
46
+ docs: {
47
+ description:
48
+ 'Replace `parseInt(x)` or `Number.parseInt(x, 10)` with `Result.unwrapOkOr(SafeNumber.parseInteger(x), Number.NaN)` from ts-std-forge.',
49
+ },
50
+ fixable: 'code',
51
+ schema: [],
52
+ messages: {
53
+ useSafeNumberParseInteger:
54
+ 'Replace `{{original}}` with `Result.unwrapOkOr(SafeNumber.parseInteger({{argName}}), Number.NaN)` from ts-std-forge.',
55
+ },
56
+ },
57
+
58
+ create: (context) => {
59
+ const sourceCode = context.sourceCode;
60
+
61
+ const program = sourceCode.ast;
62
+
63
+ const tsStdForgeImport = getTsStdForgeImport(program);
64
+
65
+ const services = sourceCode.parserServices;
66
+
67
+ const mut_nodesToFix: {
68
+ node: TSESTree.CallExpression;
69
+ argExpression: TSESTree.Expression;
70
+ }[] = [];
71
+
72
+ return {
73
+ CallExpression: (node) => {
74
+ const { callee } = node;
75
+
76
+ // Match `parseInt(...)` or `Number.parseInt(...)`.
77
+ const isGlobalParseInt =
78
+ callee.type === AST_NODE_TYPES.Identifier &&
79
+ callee.name === 'parseInt';
80
+
81
+ const isNumberParseInt =
82
+ callee.type === AST_NODE_TYPES.MemberExpression &&
83
+ !callee.computed &&
84
+ callee.object.type === AST_NODE_TYPES.Identifier &&
85
+ callee.object.name === 'Number' &&
86
+ callee.property.type === AST_NODE_TYPES.Identifier &&
87
+ callee.property.name === 'parseInt';
88
+
89
+ if (!isGlobalParseInt && !isNumberParseInt) return;
90
+
91
+ const args = node.arguments;
92
+
93
+ const firstArg = args[0];
94
+
95
+ if (firstArg === undefined) return;
96
+
97
+ // Spread argument (`parseInt(...rest)`) cannot be rewritten safely.
98
+ if (firstArg.type === AST_NODE_TYPES.SpreadElement) return;
99
+
100
+ // Only base 10: accept a missing radix or an explicit literal `10`.
101
+ if (args.length >= 2) {
102
+ const radix = args[1];
103
+
104
+ if (radix?.type !== AST_NODE_TYPES.Literal || radix.value !== 10) {
105
+ return;
106
+ }
107
+ }
108
+
109
+ // The argument must be a `string` so the autofix is type-safe against
110
+ // `SafeNumber.parseInteger(value: string)`. Without type information,
111
+ // skip.
112
+ if (services?.program == null) return;
113
+
114
+ const checker = services.program.getTypeChecker();
115
+
116
+ const tsNode = services.esTreeNodeToTSNodeMap?.get(firstArg);
117
+
118
+ if (tsNode === undefined) return;
119
+
120
+ const argType = checker.getTypeAtLocation(tsNode);
121
+
122
+ if (!isStringType(argType)) return;
123
+
124
+ mut_nodesToFix.push({ node, argExpression: firstArg });
125
+ },
126
+ 'Program:exit': () => {
127
+ const namedImports = getNamedImports(tsStdForgeImport);
128
+
129
+ const missingImports = (['Result', 'SafeNumber'] as const).filter(
130
+ (name) => !namedImports.includes(name),
131
+ );
132
+
133
+ for (const [
134
+ index,
135
+ { node, argExpression },
136
+ ] of mut_nodesToFix.entries()) {
137
+ const argText = sourceCode.getText(argExpression);
138
+
139
+ context.report({
140
+ node,
141
+ messageId: 'useSafeNumberParseInteger',
142
+ data: {
143
+ original: sourceCode.getText(node),
144
+ argName: argText,
145
+ },
146
+ fix: (fixer) => {
147
+ const core = `Result.unwrapOkOr(SafeNumber.parseInteger(${argText}), Number.NaN)`;
148
+
149
+ const importFixes =
150
+ index === 0 && missingImports.length > 0
151
+ ? buildImportFixes(fixer, program, missingImports)
152
+ : [];
153
+
154
+ return [...importFixes, fixer.replaceText(node, core)];
155
+ },
156
+ });
157
+ }
158
+ },
159
+ };
160
+ },
161
+ defaultOptions: [],
162
+ } as const;
@@ -0,0 +1,67 @@
1
+ import parser from '@typescript-eslint/parser';
2
+ import { RuleTester } from '@typescript-eslint/rule-tester';
3
+ import dedent from 'dedent';
4
+ import { preferSafeNumberParseInteger } from './prefer-safe-number-parse-integer.mjs';
5
+
6
+ const tester = new RuleTester({
7
+ languageOptions: {
8
+ parser,
9
+ parserOptions: {
10
+ ecmaVersion: 2020,
11
+ sourceType: 'module',
12
+ projectService: {
13
+ allowDefaultProject: ['*.ts*'],
14
+ },
15
+ tsconfigRootDir: `${import.meta.dirname}/../..`,
16
+ },
17
+ },
18
+ });
19
+
20
+ describe('prefer-safe-number-parse-integer', () => {
21
+ tester.run('prefer-safe-number-parse-integer', preferSafeNumberParseInteger, {
22
+ valid: [
23
+ {
24
+ name: 'ignores a radix other than 10',
25
+ code: dedent`
26
+ declare const s: string;
27
+ const n = parseInt(s, 16);
28
+ `,
29
+ },
30
+ {
31
+ name: 'ignores a non-string argument',
32
+ code: dedent`
33
+ declare const x: number;
34
+ const n = parseInt(x as unknown as string & never, 10);
35
+ `,
36
+ },
37
+ ],
38
+ invalid: [
39
+ {
40
+ name: 'rewrites parseInt without a radix',
41
+ code: dedent`
42
+ declare const s: string;
43
+ const n = parseInt(s);
44
+ `,
45
+ output: dedent`
46
+ import { Result, SafeNumber } from 'ts-std-forge';
47
+ declare const s: string;
48
+ const n = Result.unwrapOkOr(SafeNumber.parseInteger(s), Number.NaN);
49
+ `,
50
+ errors: [{ messageId: 'useSafeNumberParseInteger' }],
51
+ },
52
+ {
53
+ name: 'rewrites Number.parseInt(x, 10)',
54
+ code: dedent`
55
+ declare const s: string;
56
+ const n = Number.parseInt(s, 10);
57
+ `,
58
+ output: dedent`
59
+ import { Result, SafeNumber } from 'ts-std-forge';
60
+ declare const s: string;
61
+ const n = Result.unwrapOkOr(SafeNumber.parseInteger(s), Number.NaN);
62
+ `,
63
+ errors: [{ messageId: 'useSafeNumberParseInteger' }],
64
+ },
65
+ ],
66
+ });
67
+ });