eslint-plugin-ts-std-forge 0.1.3 → 0.1.5

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 (54) hide show
  1. package/dist/plugin.d.mts +1 -1
  2. package/dist/plugin.d.mts.map +1 -1
  3. package/dist/plugin.mjs +1 -0
  4. package/dist/plugin.mjs.map +1 -1
  5. package/dist/rule-types.d.mts +1 -1
  6. package/dist/rule-types.d.mts.map +1 -1
  7. package/dist/rules/ast-utils.d.mts +41 -0
  8. package/dist/rules/ast-utils.d.mts.map +1 -0
  9. package/dist/rules/ast-utils.mjs +101 -0
  10. package/dist/rules/ast-utils.mjs.map +1 -0
  11. package/dist/rules/import-utils.d.mts +1 -1
  12. package/dist/rules/import-utils.d.mts.map +1 -1
  13. package/dist/rules/import-utils.mjs +2 -1
  14. package/dist/rules/import-utils.mjs.map +1 -1
  15. package/dist/rules/prefer-is-non-null-object.d.mts.map +1 -1
  16. package/dist/rules/prefer-is-non-null-object.mjs +55 -43
  17. package/dist/rules/prefer-is-non-null-object.mjs.map +1 -1
  18. package/dist/rules/prefer-is-record-and-has-key.d.mts.map +1 -1
  19. package/dist/rules/prefer-is-record-and-has-key.mjs +22 -17
  20. package/dist/rules/prefer-is-record-and-has-key.mjs.map +1 -1
  21. package/dist/rules/prefer-safe-array-is-array.d.mts.map +1 -1
  22. package/dist/rules/prefer-safe-array-is-array.mjs +23 -14
  23. package/dist/rules/prefer-safe-array-is-array.mjs.map +1 -1
  24. package/dist/rules/prefer-safe-array-length-guard.d.mts.map +1 -1
  25. package/dist/rules/prefer-safe-array-length-guard.mjs +19 -15
  26. package/dist/rules/prefer-safe-array-length-guard.mjs.map +1 -1
  27. package/dist/rules/prefer-safe-number-parse-integer.d.mts.map +1 -1
  28. package/dist/rules/prefer-safe-number-parse-integer.mjs +16 -17
  29. package/dist/rules/prefer-safe-number-parse-integer.mjs.map +1 -1
  30. package/dist/rules/prefer-safe-number-parse.d.mts.map +1 -1
  31. package/dist/rules/prefer-safe-number-parse.mjs +14 -16
  32. package/dist/rules/prefer-safe-number-parse.mjs.map +1 -1
  33. package/dist/types.d.mts +2 -2
  34. package/dist/types.d.mts.map +1 -1
  35. package/package.json +24 -22
  36. package/src/plugin.mts +5 -4
  37. package/src/plugin.test.mts +0 -13
  38. package/src/rule-types.mts +2 -2
  39. package/src/rules/ast-utils.mts +147 -0
  40. package/src/rules/import-utils.mts +4 -2
  41. package/src/rules/prefer-is-non-null-object.mts +63 -38
  42. package/src/rules/prefer-is-non-null-object.test.mts +54 -0
  43. package/src/rules/prefer-is-record-and-has-key.mts +34 -21
  44. package/src/rules/prefer-is-record-and-has-key.test.mts +128 -0
  45. package/src/rules/prefer-safe-array-is-array.mts +34 -16
  46. package/src/rules/prefer-safe-array-is-array.test.mts +93 -0
  47. package/src/rules/prefer-safe-array-length-guard.mts +32 -19
  48. package/src/rules/prefer-safe-array-length-guard.test.mts +98 -0
  49. package/src/rules/prefer-safe-number-parse-integer.mts +29 -23
  50. package/src/rules/prefer-safe-number-parse-integer.test.mts +104 -0
  51. package/src/rules/prefer-safe-number-parse.mts +25 -21
  52. package/src/rules/prefer-safe-number-parse.test.mts +97 -0
  53. package/src/rules/rules.mts +1 -1
  54. package/src/types.mts +2 -2
@@ -3,7 +3,7 @@ import {
3
3
  type TSESLint,
4
4
  type TSESTree,
5
5
  } from '@typescript-eslint/utils';
6
- import { type DeepReadonly } from 'ts-type-forge';
6
+ import { skipTypeWrappers } from './ast-utils.mjs';
7
7
  import {
8
8
  buildImportFixes,
9
9
  getNamedImports,
@@ -45,7 +45,9 @@ export const preferIsNonNullObject: TSESLint.RuleModule<MessageIds, Options> = {
45
45
  LogicalExpression: (node) => {
46
46
  const identifierName = getNonNullObjectIdentifierName(node);
47
47
 
48
- if (identifierName === undefined) return;
48
+ if (identifierName === undefined) {
49
+ return;
50
+ }
49
51
 
50
52
  mut_nodesToFix.push({ node, identifierName });
51
53
  },
@@ -83,60 +85,83 @@ export const preferIsNonNullObject: TSESLint.RuleModule<MessageIds, Options> = {
83
85
  defaultOptions: [],
84
86
  } as const;
85
87
 
88
+ // Every operand below is read through its type wrappers: `typeof (u satisfies
89
+ // unknown) === 'object' && u! !== null` is the same check.
90
+
86
91
  const getNonNullObjectIdentifierName = (
87
- node: DeepReadonly<TSESTree.LogicalExpression>,
92
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
93
+ node: TSESTree.LogicalExpression,
88
94
  ): string | undefined => {
89
- if (node.operator !== '&&') return undefined;
90
-
91
- const leftIdentifierName = getTypeofObjectIdentifierName(node.left);
92
-
93
- const rightIdentifierName = getNonNullCheckIdentifierName(node.right);
94
-
95
- if (leftIdentifierName === undefined) return undefined;
96
-
97
- if (rightIdentifierName === undefined) return undefined;
98
-
99
- if (leftIdentifierName !== rightIdentifierName) return undefined;
100
-
101
- return leftIdentifierName;
95
+ if (node.operator !== '&&') {
96
+ return undefined;
97
+ }
98
+
99
+ const leftIdentifierName = getTypeofObjectIdentifierName(
100
+ skipTypeWrappers(node.left),
101
+ );
102
+
103
+ const rightIdentifierName = getNonNullCheckIdentifierName(
104
+ skipTypeWrappers(node.right),
105
+ );
106
+
107
+ return leftIdentifierName === undefined ||
108
+ rightIdentifierName === undefined ||
109
+ leftIdentifierName !== rightIdentifierName
110
+ ? undefined
111
+ : leftIdentifierName;
102
112
  };
103
113
 
104
114
  const getTypeofObjectIdentifierName = (
105
- node: DeepReadonly<TSESTree.Expression>,
115
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
116
+ node: TSESTree.Node,
106
117
  ): string | undefined => {
107
- if (node.type !== AST_NODE_TYPES.BinaryExpression) return undefined;
108
-
109
- if (node.operator !== '===') return undefined;
118
+ if (node.type !== AST_NODE_TYPES.BinaryExpression) {
119
+ return undefined;
120
+ }
110
121
 
111
- const { left, right } = node;
122
+ if (node.operator !== '===') {
123
+ return undefined;
124
+ }
112
125
 
113
- if (left.type !== AST_NODE_TYPES.UnaryExpression) return undefined;
126
+ const left = skipTypeWrappers(node.left);
114
127
 
115
- if (left.operator !== 'typeof') return undefined;
128
+ const right = skipTypeWrappers(node.right);
116
129
 
117
- if (right.type !== AST_NODE_TYPES.Literal) return undefined;
130
+ if (
131
+ left.type !== AST_NODE_TYPES.UnaryExpression ||
132
+ left.operator !== 'typeof' ||
133
+ right.type !== AST_NODE_TYPES.Literal ||
134
+ right.value !== 'object'
135
+ ) {
136
+ return undefined;
137
+ }
118
138
 
119
- if (right.value !== 'object') return undefined;
139
+ const argument = skipTypeWrappers(left.argument);
120
140
 
121
- if (left.argument.type !== AST_NODE_TYPES.Identifier) return undefined;
122
-
123
- return left.argument.name;
141
+ return argument.type === AST_NODE_TYPES.Identifier
142
+ ? argument.name
143
+ : undefined;
124
144
  };
125
145
 
126
146
  const getNonNullCheckIdentifierName = (
127
- node: DeepReadonly<TSESTree.Expression>,
147
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
148
+ node: TSESTree.Node,
128
149
  ): string | undefined => {
129
- if (node.type !== AST_NODE_TYPES.BinaryExpression) return undefined;
130
-
131
- if (node.operator !== '!==') return undefined;
132
-
133
- const { left, right } = node;
150
+ if (node.type !== AST_NODE_TYPES.BinaryExpression) {
151
+ return undefined;
152
+ }
134
153
 
135
- if (left.type !== AST_NODE_TYPES.Identifier) return undefined;
154
+ if (node.operator !== '!==') {
155
+ return undefined;
156
+ }
136
157
 
137
- if (right.type !== AST_NODE_TYPES.Literal) return undefined;
158
+ const left = skipTypeWrappers(node.left);
138
159
 
139
- if (right.value !== null) return undefined;
160
+ const right = skipTypeWrappers(node.right);
140
161
 
141
- return left.name;
162
+ return left.type !== AST_NODE_TYPES.Identifier ||
163
+ right.type !== AST_NODE_TYPES.Literal ||
164
+ right.value !== null
165
+ ? undefined
166
+ : left.name;
142
167
  };
@@ -52,3 +52,57 @@ ruleTester.run('prefer-is-non-null-object', preferIsNonNullObject, {
52
52
  },
53
53
  ],
54
54
  });
55
+
56
+ // The replacement names the bare identifier: that is the reference both
57
+ // halves narrow, and a wrapper around the argument would stop the guard from
58
+ // narrowing it.
59
+ describe('prefer-is-non-null-object through type wrappers', () => {
60
+ ruleTester.run('prefer-is-non-null-object', preferIsNonNullObject, {
61
+ valid: [
62
+ {
63
+ name: 'The two halves are about different identifiers behind wrappers',
64
+ code: dedent`
65
+ const ok = typeof (u satisfies unknown) === 'object' && v! !== null;
66
+ `,
67
+ },
68
+ ],
69
+ invalid: [
70
+ {
71
+ name: 'reads the typeof operand through `satisfies`',
72
+ code: dedent`
73
+ const ok = typeof (u satisfies unknown) === 'object' && u !== null;
74
+ `,
75
+ output: dedent`
76
+ import { isNonNullObject } from 'ts-std-forge';
77
+ const ok = isNonNullObject(u);
78
+ `,
79
+ errors: [{ messageId: 'useIsNonNullObject' }],
80
+ },
81
+ {
82
+ name: 'reads the null check through `!` and `as`',
83
+ code: dedent`
84
+ const ok = typeof u === 'object' && u! !== (null as null);
85
+ `,
86
+ output: dedent`
87
+ import { isNonNullObject } from 'ts-std-forge';
88
+ const ok = isNonNullObject(u);
89
+ `,
90
+ errors: [{ messageId: 'useIsNonNullObject' }],
91
+ },
92
+ {
93
+ name: 'reads the literal and both halves through wrappers',
94
+ code: dedent`
95
+ const ok =
96
+ ((typeof (<unknown>u) === ('object' as const)) satisfies boolean) &&
97
+ ((u !== null) as boolean);
98
+ `,
99
+ output: dedent`
100
+ import { isNonNullObject } from 'ts-std-forge';
101
+ const ok =
102
+ isNonNullObject(u);
103
+ `,
104
+ errors: [{ messageId: 'useIsNonNullObject' }],
105
+ },
106
+ ],
107
+ });
108
+ });
@@ -5,6 +5,12 @@ import {
5
5
  } from '@typescript-eslint/utils';
6
6
  import { SafeArray } from 'ts-std-forge';
7
7
  import * as ts from 'typescript';
8
+ import {
9
+ getArgumentText,
10
+ getValueType,
11
+ isIdentifierNamed,
12
+ skipTypeWrappers,
13
+ } from './ast-utils.mjs';
8
14
  import {
9
15
  buildImportFixes,
10
16
  getNamedImports,
@@ -40,28 +46,26 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
40
46
 
41
47
  const tsStdForgeImport = getTsStdForgeImport(program);
42
48
 
43
- const services = sourceCode.parserServices;
44
-
45
- const checker = services?.program?.getTypeChecker();
49
+ const checker = sourceCode.parserServices?.program?.getTypeChecker();
46
50
 
47
51
  /**
48
52
  * Whether the `isRecord(...)` half of the rewrite would be dead code
49
53
  * because the object already satisfies `hasKey`'s
50
54
  * `R extends UnknownRecord` constraint. Without type information nothing
51
- * is known, so the guard is kept.
55
+ * is known, so the guard is kept. The type is the value's, not a cast's:
56
+ * an array cast to a record is still rejected by `isRecord`.
52
57
  */
53
58
  const isKnownRecord = (
54
59
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
55
60
  expression: TSESTree.Expression,
56
61
  ): boolean => {
57
- if (checker === undefined) return false;
62
+ if (checker === undefined) {
63
+ return false;
64
+ }
58
65
 
59
- const tsNode = services?.esTreeNodeToTSNodeMap?.get(expression);
66
+ const type = getValueType(sourceCode, expression);
60
67
 
61
- return (
62
- tsNode !== undefined &&
63
- isAlreadyRecordType(checker, checker.getTypeAtLocation(tsNode))
64
- );
68
+ return type !== undefined && isAlreadyRecordType(checker, type);
65
69
  };
66
70
 
67
71
  const mut_nodesToFix: {
@@ -73,24 +77,29 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
73
77
  return {
74
78
  // Handle Object.hasOwn(obj, key)
75
79
  CallExpression: (node) => {
80
+ const callee = skipTypeWrappers(node.callee);
81
+
76
82
  if (
77
- node.callee.type !== AST_NODE_TYPES.MemberExpression ||
78
- node.callee.object.type !== AST_NODE_TYPES.Identifier ||
79
- node.callee.object.name !== 'Object' ||
80
- node.callee.property.type !== AST_NODE_TYPES.Identifier ||
81
- node.callee.property.name !== 'hasOwn'
83
+ callee.type !== AST_NODE_TYPES.MemberExpression ||
84
+ !isIdentifierNamed(callee.object, 'Object') ||
85
+ callee.property.type !== AST_NODE_TYPES.Identifier ||
86
+ callee.property.name !== 'hasOwn'
82
87
  ) {
83
88
  return;
84
89
  }
85
90
 
86
91
  // Check arguments: Object.hasOwn(obj, key)
87
- if (node.arguments.length !== 2) return;
92
+ if (node.arguments.length !== 2) {
93
+ return;
94
+ }
88
95
 
89
96
  const objArg = node.arguments[0];
90
97
 
91
98
  const keyArg = node.arguments[1];
92
99
 
93
- if (objArg === undefined || keyArg === undefined) return;
100
+ if (objArg === undefined || keyArg === undefined) {
101
+ return;
102
+ }
94
103
 
95
104
  // Type guard: ensure arguments are Expressions (not SpreadElement)
96
105
  if (
@@ -109,14 +118,18 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
109
118
 
110
119
  // Handle key in obj
111
120
  BinaryExpression: (node) => {
112
- if (node.operator !== 'in') return;
121
+ if (node.operator !== 'in') {
122
+ return;
123
+ }
113
124
 
114
125
  const keyExpression = node.left;
115
126
 
116
127
  const objExpression = node.right;
117
128
 
118
129
  // Type guard: ensure keyExpression is Expression (not PrivateIdentifier)
119
- if (keyExpression.type === AST_NODE_TYPES.PrivateIdentifier) return;
130
+ if (keyExpression.type === AST_NODE_TYPES.PrivateIdentifier) {
131
+ return;
132
+ }
120
133
 
121
134
  mut_nodesToFix.push({
122
135
  node,
@@ -147,9 +160,9 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
147
160
  index,
148
161
  { node, objExpression, keyExpression, needsIsRecord },
149
162
  ] of rewrites.entries()) {
150
- const objText = sourceCode.getText(objExpression);
163
+ const objText = getArgumentText(sourceCode, objExpression);
151
164
 
152
- const keyText = sourceCode.getText(keyExpression);
165
+ const keyText = getArgumentText(sourceCode, keyExpression);
153
166
 
154
167
  const originalText = sourceCode.getText(node);
155
168
 
@@ -78,3 +78,131 @@ describe('prefer-is-record-and-has-key', () => {
78
78
  ],
79
79
  });
80
80
  });
81
+
82
+ describe('prefer-is-record-and-has-key through type wrappers', () => {
83
+ tester.run('prefer-is-record-and-has-key', preferIsRecordAndHasKey, {
84
+ valid: [],
85
+ invalid: [
86
+ {
87
+ // `isRecord` rejects an array, so dropping it on the cast's word
88
+ // would turn `false` into `true`.
89
+ name: 'keeps isRecord for an array cast to a record',
90
+ code: dedent`
91
+ declare const arr: string[];
92
+ const ok = Object.hasOwn(arr as unknown as Record<string, unknown>, '0');
93
+ `,
94
+ output: dedent`
95
+ import { isRecord, hasKey } from 'ts-std-forge';
96
+ declare const arr: string[];
97
+ const ok = (isRecord(arr as unknown as Record<string, unknown>) && hasKey(arr as unknown as Record<string, unknown>, '0'));
98
+ `,
99
+ errors: [{ messageId: 'useIsRecordAndHasKey' }],
100
+ },
101
+ {
102
+ name: 'keeps isRecord for an unknown value cast to a record in `in`',
103
+ code: dedent`
104
+ declare const u: unknown;
105
+ const ok = 'a' in (<Record<string, unknown>>u);
106
+ `,
107
+ output: dedent`
108
+ import { isRecord, hasKey } from 'ts-std-forge';
109
+ declare const u: unknown;
110
+ const ok = (isRecord(<Record<string, unknown>>u) && hasKey(<Record<string, unknown>>u, 'a'));
111
+ `,
112
+ errors: [{ messageId: 'useIsRecordAndHasKey' }],
113
+ },
114
+ {
115
+ name: 'drops isRecord for a record behind `satisfies` and keeps the wrapper',
116
+ code: dedent`
117
+ declare const obj: Readonly<Record<string, unknown>>;
118
+ const ok = Object.hasOwn(obj satisfies object, 'a');
119
+ `,
120
+ output: dedent`
121
+ import { hasKey } from 'ts-std-forge';
122
+ declare const obj: Readonly<Record<string, unknown>>;
123
+ const ok = hasKey(obj satisfies object, 'a');
124
+ `,
125
+ errors: [{ messageId: 'useHasKey' }],
126
+ },
127
+ {
128
+ name: 'reads `!` on an optional record as the record it asserts',
129
+ code: dedent`
130
+ declare const obj: Readonly<Record<string, unknown>> | undefined;
131
+ const ok = Object.hasOwn(obj!, 'a');
132
+ `,
133
+ output: dedent`
134
+ import { hasKey } from 'ts-std-forge';
135
+ declare const obj: Readonly<Record<string, unknown>> | undefined;
136
+ const ok = hasKey(obj!, 'a');
137
+ `,
138
+ errors: [{ messageId: 'useHasKey' }],
139
+ },
140
+ {
141
+ name: 'reads `Object` through `as`',
142
+ code: dedent`
143
+ declare const obj: Readonly<Record<string, unknown>>;
144
+ const ok = (Object as ObjectConstructor).hasOwn(obj, 'a');
145
+ `,
146
+ output: dedent`
147
+ import { hasKey } from 'ts-std-forge';
148
+ declare const obj: Readonly<Record<string, unknown>>;
149
+ const ok = hasKey(obj, 'a');
150
+ `,
151
+ errors: [{ messageId: 'useHasKey' }],
152
+ },
153
+ ],
154
+ });
155
+ });
156
+
157
+ describe('prefer-is-record-and-has-key with parenthesized operands', () => {
158
+ tester.run('prefer-is-record-and-has-key', preferIsRecordAndHasKey, {
159
+ valid: [],
160
+ invalid: [
161
+ {
162
+ name: 'keeps the parentheses of a sequence object in `in`',
163
+ code: dedent`
164
+ declare const a: number;
165
+ declare const o: unknown;
166
+ const ok = 'k' in (a, o);
167
+ `,
168
+ output: dedent`
169
+ import { isRecord, hasKey } from 'ts-std-forge';
170
+ declare const a: number;
171
+ declare const o: unknown;
172
+ const ok = (isRecord((a, o)) && hasKey((a, o), 'k'));
173
+ `,
174
+ errors: [{ messageId: 'useIsRecordAndHasKey' }],
175
+ },
176
+ {
177
+ name: 'keeps the parentheses of a sequence object in Object.hasOwn',
178
+ code: dedent`
179
+ declare const a: number;
180
+ declare const o: unknown;
181
+ const ok = Object.hasOwn((a, o), 'k');
182
+ `,
183
+ output: dedent`
184
+ import { isRecord, hasKey } from 'ts-std-forge';
185
+ declare const a: number;
186
+ declare const o: unknown;
187
+ const ok = (isRecord((a, o)) && hasKey((a, o), 'k'));
188
+ `,
189
+ errors: [{ messageId: 'useIsRecordAndHasKey' }],
190
+ },
191
+ {
192
+ name: 'keeps the parentheses of a sequence key',
193
+ code: dedent`
194
+ declare const a: number;
195
+ declare const o: Readonly<Record<string, unknown>>;
196
+ const ok = (a, 'k') in o;
197
+ `,
198
+ output: dedent`
199
+ import { hasKey } from 'ts-std-forge';
200
+ declare const a: number;
201
+ declare const o: Readonly<Record<string, unknown>>;
202
+ const ok = hasKey(o, (a, 'k'));
203
+ `,
204
+ errors: [{ messageId: 'useHasKey' }],
205
+ },
206
+ ],
207
+ });
208
+ });
@@ -3,7 +3,11 @@ import {
3
3
  type TSESLint,
4
4
  type TSESTree,
5
5
  } from '@typescript-eslint/utils';
6
- import { type DeepReadonly } from 'ts-type-forge';
6
+ import {
7
+ getRangeWithParens,
8
+ isIdentifierNamed,
9
+ skipTypeWrappers,
10
+ } from './ast-utils.mjs';
7
11
  import {
8
12
  buildImportFixes,
9
13
  getNamedImports,
@@ -41,7 +45,9 @@ export const preferSafeArrayIsArray: TSESLint.RuleModule<MessageIds, Options> =
41
45
 
42
46
  return {
43
47
  CallExpression: (node) => {
44
- if (!isArrayIsArrayCall(node)) return;
48
+ if (!isArrayIsArrayCall(node)) {
49
+ return;
50
+ }
45
51
 
46
52
  mut_nodesToFix.push(node);
47
53
  },
@@ -59,18 +65,21 @@ export const preferSafeArrayIsArray: TSESLint.RuleModule<MessageIds, Options> =
59
65
  node,
60
66
  messageId: 'useSafeArrayIsArray',
61
67
  fix: (fixer) => {
62
- const callee = node.callee;
63
-
64
- if (callee.type !== AST_NODE_TYPES.MemberExpression) return [];
65
-
66
- const replacement = `SafeArray.isArray${sourceCode.getText(node).slice(sourceCode.getText(callee).length)}`;
67
-
68
68
  const importFixes =
69
69
  index === 0 && !hasSafeArrayImport
70
70
  ? buildImportFixes(fixer, program, ['SafeArray'])
71
71
  : [];
72
72
 
73
- return [...importFixes, fixer.replaceText(node, replacement)];
73
+ // Only the callee is replaced, type wrappers included, along
74
+ // with the parentheses around it, which its range leaves out;
75
+ // the arguments stay as written.
76
+ return [
77
+ ...importFixes,
78
+ fixer.replaceTextRange(
79
+ getRangeWithParens(sourceCode, node.callee),
80
+ 'SafeArray.isArray',
81
+ ),
82
+ ];
74
83
  },
75
84
  });
76
85
  }
@@ -81,19 +90,28 @@ export const preferSafeArrayIsArray: TSESLint.RuleModule<MessageIds, Options> =
81
90
  } as const;
82
91
 
83
92
  const isArrayIsArrayCall = (
84
- node: DeepReadonly<TSESTree.CallExpression>,
93
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
94
+ node: TSESTree.CallExpression,
85
95
  ): boolean => {
86
- if (node.callee.type !== AST_NODE_TYPES.MemberExpression) return false;
96
+ const callee = skipTypeWrappers(node.callee);
87
97
 
88
- const { object, property } = node.callee;
98
+ if (callee.type !== AST_NODE_TYPES.MemberExpression) {
99
+ return false;
100
+ }
89
101
 
90
- if (object.type !== AST_NODE_TYPES.Identifier) return false;
102
+ const { object, property } = callee;
91
103
 
92
- if (object.name !== 'Array') return false;
104
+ if (!isIdentifierNamed(object, 'Array')) {
105
+ return false;
106
+ }
93
107
 
94
- if (property.type !== AST_NODE_TYPES.Identifier) return false;
108
+ if (property.type !== AST_NODE_TYPES.Identifier) {
109
+ return false;
110
+ }
95
111
 
96
- if (property.name !== 'isArray') return false;
112
+ if (property.name !== 'isArray') {
113
+ return false;
114
+ }
97
115
 
98
116
  return true;
99
117
  };
@@ -63,3 +63,96 @@ ruleTester.run('prefer-safe-array-is-array', preferSafeArrayIsArray, {
63
63
  },
64
64
  ],
65
65
  });
66
+
67
+ describe('prefer-safe-array-is-array through type wrappers', () => {
68
+ ruleTester.run('prefer-safe-array-is-array', preferSafeArrayIsArray, {
69
+ valid: [],
70
+ invalid: [
71
+ {
72
+ name: 'reads `Array` through `as`',
73
+ code: dedent`
74
+ const result = (Array as ArrayConstructor).isArray(value);
75
+ `,
76
+ output: dedent`
77
+ import { SafeArray } from 'ts-std-forge';
78
+ const result = SafeArray.isArray(value);
79
+ `,
80
+ errors: [{ messageId: 'useSafeArrayIsArray' }],
81
+ },
82
+ {
83
+ name: 'reads `Array` through `!`',
84
+ code: dedent`
85
+ const result = Array!.isArray(value);
86
+ `,
87
+ output: dedent`
88
+ import { SafeArray } from 'ts-std-forge';
89
+ const result = SafeArray.isArray(value);
90
+ `,
91
+ errors: [{ messageId: 'useSafeArrayIsArray' }],
92
+ },
93
+ {
94
+ name: 'reads the callee through `satisfies`',
95
+ code: dedent`
96
+ const result = (Array.isArray satisfies unknown)(value);
97
+ `,
98
+ output: dedent`
99
+ import { SafeArray } from 'ts-std-forge';
100
+ const result = SafeArray.isArray(value);
101
+ `,
102
+ errors: [{ messageId: 'useSafeArrayIsArray' }],
103
+ },
104
+ {
105
+ name: 'keeps a wrapper on the argument',
106
+ code: dedent`
107
+ const result = Array.isArray(value satisfies unknown);
108
+ `,
109
+ output: dedent`
110
+ import { SafeArray } from 'ts-std-forge';
111
+ const result = SafeArray.isArray(value satisfies unknown);
112
+ `,
113
+ errors: [{ messageId: 'useSafeArrayIsArray' }],
114
+ },
115
+ ],
116
+ });
117
+ });
118
+
119
+ describe('prefer-safe-array-is-array with parenthesized operands', () => {
120
+ ruleTester.run('prefer-safe-array-is-array', preferSafeArrayIsArray, {
121
+ valid: [],
122
+ invalid: [
123
+ {
124
+ name: 'replaces a parenthesized callee with its parentheses',
125
+ code: dedent`
126
+ const result = (Array.isArray)(x);
127
+ `,
128
+ output: dedent`
129
+ import { SafeArray } from 'ts-std-forge';
130
+ const result = SafeArray.isArray(x);
131
+ `,
132
+ errors: [{ messageId: 'useSafeArrayIsArray' }],
133
+ },
134
+ {
135
+ name: 'replaces a doubly parenthesized callee of an optional call',
136
+ code: dedent`
137
+ const result = ((Array.isArray))?.(x);
138
+ `,
139
+ output: dedent`
140
+ import { SafeArray } from 'ts-std-forge';
141
+ const result = SafeArray.isArray?.(x);
142
+ `,
143
+ errors: [{ messageId: 'useSafeArrayIsArray' }],
144
+ },
145
+ {
146
+ name: 'keeps a parenthesized argument',
147
+ code: dedent`
148
+ const result = Array.isArray((a, x));
149
+ `,
150
+ output: dedent`
151
+ import { SafeArray } from 'ts-std-forge';
152
+ const result = SafeArray.isArray((a, x));
153
+ `,
154
+ errors: [{ messageId: 'useSafeArrayIsArray' }],
155
+ },
156
+ ],
157
+ });
158
+ });