eslint-plugin-ts-std-forge 0.1.8 → 0.1.10

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 (33) hide show
  1. package/dist/rule-types.d.mts +3 -2
  2. package/dist/rule-types.d.mts.map +1 -1
  3. package/dist/rule-types.mjs.map +1 -1
  4. package/dist/rules/ast-utils.d.mts +8 -5
  5. package/dist/rules/ast-utils.d.mts.map +1 -1
  6. package/dist/rules/ast-utils.mjs +14 -13
  7. package/dist/rules/ast-utils.mjs.map +1 -1
  8. package/dist/rules/prefer-is-non-null-object.d.mts.map +1 -1
  9. package/dist/rules/prefer-is-non-null-object.mjs +113 -38
  10. package/dist/rules/prefer-is-non-null-object.mjs.map +1 -1
  11. package/dist/rules/prefer-is-record-and-has-key.d.mts.map +1 -1
  12. package/dist/rules/prefer-is-record-and-has-key.mjs +95 -34
  13. package/dist/rules/prefer-is-record-and-has-key.mjs.map +1 -1
  14. package/dist/rules/prefer-safe-array-length-guard.d.mts.map +1 -1
  15. package/dist/rules/prefer-safe-array-length-guard.mjs +4 -10
  16. package/dist/rules/prefer-safe-array-length-guard.mjs.map +1 -1
  17. package/dist/rules/prefer-safe-number-parse-integer.mjs +4 -3
  18. package/dist/rules/prefer-safe-number-parse-integer.mjs.map +1 -1
  19. package/dist/rules/prefer-safe-number-parse.mjs +4 -3
  20. package/dist/rules/prefer-safe-number-parse.mjs.map +1 -1
  21. package/package.json +10 -10
  22. package/src/rule-types.mts +3 -2
  23. package/src/rules/ast-utils.mts +20 -21
  24. package/src/rules/prefer-is-non-null-object.mts +152 -49
  25. package/src/rules/prefer-is-non-null-object.test.mts +107 -0
  26. package/src/rules/prefer-is-record-and-has-key.mts +141 -53
  27. package/src/rules/prefer-is-record-and-has-key.test.mts +116 -29
  28. package/src/rules/prefer-safe-array-length-guard.mts +11 -16
  29. package/src/rules/prefer-safe-array-length-guard.test.mts +19 -5
  30. package/src/rules/prefer-safe-number-parse-integer.mts +8 -8
  31. package/src/rules/prefer-safe-number-parse-integer.test.mts +19 -5
  32. package/src/rules/prefer-safe-number-parse.mts +8 -8
  33. package/src/rules/prefer-safe-number-parse.test.mts +19 -5
@@ -12,7 +12,7 @@ import {
12
12
  } from 'eslint-rule-forge';
13
13
  import { SafeArray } from 'ts-std-forge';
14
14
  import * as ts from 'typescript';
15
- import { getValueType, isIdentifierNamed } from './ast-utils.mjs';
15
+ import { isIdentifierNamed, typeHoldsAtEveryLayer } from './ast-utils.mjs';
16
16
  import { TS_STD_FORGE_MODULE } from './constants.mjs';
17
17
 
18
18
  type Options = readonly [];
@@ -47,27 +47,40 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
47
47
  TS_STD_FORGE_MODULE,
48
48
  );
49
49
 
50
- const checker = sourceCode.parserServices?.program?.getTypeChecker();
51
-
52
50
  /**
53
51
  * Whether the `isRecord(...)` half of the rewrite would be dead code
54
52
  * because the object already satisfies `hasKey`'s
55
- * `R extends UnknownRecord` constraint. Without type information nothing
56
- * is known, so the guard is kept. The type is the value's, not a cast's:
57
- * an array cast to a record is still rejected by `isRecord`.
53
+ * `R extends UnknownRecord` constraint, at every layer of type
54
+ * wrappers: an array cast to a record is still rejected by `isRecord`,
55
+ * and a record widened to one that may be an array is the author saying
56
+ * it may need the guard. Without type information nothing is known, so
57
+ * the guard is kept.
58
58
  */
59
59
  const isKnownRecord = (
60
60
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
61
61
  expression: TSESTree.Expression,
62
- ): boolean => {
63
- if (checker === undefined) {
64
- return false;
65
- }
66
-
67
- const type = getValueType(sourceCode, expression);
62
+ ): boolean =>
63
+ typeHoldsAtEveryLayer(sourceCode, expression, (type, checker) =>
64
+ isAlreadyRecordType(checker, type),
65
+ );
68
66
 
69
- return type !== undefined && isAlreadyRecordType(checker, type);
70
- };
67
+ /**
68
+ * Whether the object may be an array at some layer of type wrappers.
69
+ * `isRecord` rejects an array, so `'0' in xs` would turn from `true` to
70
+ * `false`; a widening cast to a type that may be an array is the author
71
+ * saying it may be one. Without type information nothing is known, and
72
+ * the rule reports as it always has.
73
+ */
74
+ const mayBeArray = (
75
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
76
+ expression: TSESTree.Expression,
77
+ ): boolean =>
78
+ sourceCode.parserServices?.program != null &&
79
+ !typeHoldsAtEveryLayer(
80
+ sourceCode,
81
+ expression,
82
+ (type, checker) => !mayBeArrayType(checker, type),
83
+ );
71
84
 
72
85
  const mut_nodesToFix: {
73
86
  node: TSESTree.CallExpression | TSESTree.BinaryExpression;
@@ -105,7 +118,8 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
105
118
  // Type guard: ensure arguments are Expressions (not SpreadElement)
106
119
  if (
107
120
  objArg.type === AST_NODE_TYPES.SpreadElement ||
108
- keyArg.type === AST_NODE_TYPES.SpreadElement
121
+ keyArg.type === AST_NODE_TYPES.SpreadElement ||
122
+ mayBeArray(objArg)
109
123
  ) {
110
124
  return;
111
125
  }
@@ -128,7 +142,10 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
128
142
  const objExpression = node.right;
129
143
 
130
144
  // Type guard: ensure keyExpression is Expression (not PrivateIdentifier)
131
- if (keyExpression.type === AST_NODE_TYPES.PrivateIdentifier) {
145
+ if (
146
+ keyExpression.type === AST_NODE_TYPES.PrivateIdentifier ||
147
+ mayBeArray(objExpression)
148
+ ) {
132
149
  return;
133
150
  }
134
151
 
@@ -142,25 +159,40 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
142
159
  'Program:exit': () => {
143
160
  const namedImports = getNamedImports(tsStdForgeImports);
144
161
 
145
- const rewrites = mut_nodesToFix.map((entry) => ({
146
- ...entry,
162
+ const rewrites = mut_nodesToFix.map((entry) => {
147
163
  // A record-typed object makes the `isRecord(...)` conjunct always
148
164
  // true, so it is left out of the replacement entirely.
149
- needsIsRecord: !isKnownRecord(entry.objExpression),
150
- }));
151
-
152
- const importsToAdd: readonly string[] = [
153
- ...(rewrites.some(({ needsIsRecord }) => needsIsRecord) &&
154
- !namedImports.includes('isRecord')
155
- ? ['isRecord']
156
- : []),
157
- ...(namedImports.includes('hasKey') ? [] : ['hasKey']),
158
- ];
159
-
160
- for (const [
161
- index,
162
- { node, objExpression, keyExpression, needsIsRecord },
163
- ] of rewrites.entries()) {
165
+ const needsIsRecord = !isKnownRecord(entry.objExpression);
166
+
167
+ return {
168
+ ...entry,
169
+ needsIsRecord,
170
+ // `isRecord(obj) && hasKey(obj, key)` evaluates `obj` twice.
171
+ fixable: !needsIsRecord || isSideEffectFree(entry.objExpression),
172
+ };
173
+ });
174
+
175
+ const fixableRewrites = rewrites.filter(({ fixable }) => fixable);
176
+
177
+ const importsToAdd: readonly string[] = SafeArray.isEmpty(
178
+ fixableRewrites,
179
+ )
180
+ ? []
181
+ : [
182
+ ...(fixableRewrites.some(
183
+ ({ needsIsRecord }) => needsIsRecord,
184
+ ) && !namedImports.includes('isRecord')
185
+ ? ['isRecord']
186
+ : []),
187
+ ...(namedImports.includes('hasKey') ? [] : ['hasKey']),
188
+ ];
189
+
190
+ const importCarrier = fixableRewrites[0];
191
+
192
+ for (const rewrite of rewrites) {
193
+ const { node, objExpression, keyExpression, needsIsRecord } =
194
+ rewrite;
195
+
164
196
  const objText = getArgumentText(sourceCode, objExpression);
165
197
 
166
198
  const keyText = getArgumentText(sourceCode, keyExpression);
@@ -175,26 +207,32 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
175
207
  objName: objText,
176
208
  keyName: keyText,
177
209
  },
178
- fix: (fixer) => {
179
- const replacement = needsIsRecord
180
- ? `(isRecord(${objText}) && hasKey(${objText}, ${keyText}))`
181
- : `hasKey(${objText}, ${keyText})`;
182
-
183
- // All import fixes have to live on the first report:
184
- // `insertTextBefore(program, …)` produces overlapping ranges
185
- // otherwise and only one of them would survive a pass.
186
- const importFixes =
187
- index === 0 && importsToAdd.length > 0
188
- ? buildNamedImportFix(
189
- fixer,
190
- program,
191
- TS_STD_FORGE_MODULE,
192
- importsToAdd,
193
- )
194
- : [];
195
-
196
- return [...importFixes, fixer.replaceText(node, replacement)];
197
- },
210
+ fix: !rewrite.fixable
211
+ ? undefined
212
+ : (fixer) => {
213
+ const replacement = needsIsRecord
214
+ ? `(isRecord(${objText}) && hasKey(${objText}, ${keyText}))`
215
+ : `hasKey(${objText}, ${keyText})`;
216
+
217
+ // All import fixes have to live on the first fixable
218
+ // report: `insertTextBefore(program, …)` produces
219
+ // overlapping ranges otherwise and only one of them would
220
+ // survive a pass.
221
+ const importFixes =
222
+ rewrite === importCarrier && importsToAdd.length > 0
223
+ ? buildNamedImportFix(
224
+ fixer,
225
+ program,
226
+ TS_STD_FORGE_MODULE,
227
+ importsToAdd,
228
+ )
229
+ : [];
230
+
231
+ return [
232
+ ...importFixes,
233
+ fixer.replaceText(node, replacement),
234
+ ];
235
+ },
198
236
  });
199
237
  }
200
238
  },
@@ -203,6 +241,56 @@ export const preferIsRecordAndHasKey: TSESLint.RuleModule<MessageIds, Options> =
203
241
  defaultOptions: [],
204
242
  } as const;
205
243
 
244
+ /**
245
+ * Whether reading `node` again has no side effects: a variable, `this`, or a
246
+ * non-computed property chain on one, type wrappers aside.
247
+ */
248
+ const isSideEffectFree = (
249
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
250
+ wrapped: TSESTree.Node,
251
+ ): boolean => {
252
+ const node = skipTypeWrappers(wrapped);
253
+
254
+ return (
255
+ node.type === AST_NODE_TYPES.Identifier ||
256
+ node.type === AST_NODE_TYPES.ThisExpression ||
257
+ (node.type === AST_NODE_TYPES.MemberExpression &&
258
+ !node.computed &&
259
+ isSideEffectFree(node.object))
260
+ );
261
+ };
262
+
263
+ /**
264
+ * Whether a value of `type` may be an array or a tuple: some member of a union
265
+ * or an intersection is one, or the constraint of a deferred type is.
266
+ * `object` and `unknown` say nothing either way and are not counted.
267
+ */
268
+ const mayBeArrayType = (
269
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
270
+ checker: ts.TypeChecker,
271
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
272
+ type: ts.Type,
273
+ ): boolean => {
274
+ if (type.isUnionOrIntersection()) {
275
+ return type.types.some((member) => mayBeArrayType(checker, member));
276
+ }
277
+
278
+ if (
279
+ (type.flags &
280
+ (ts.TypeFlags.TypeParameter |
281
+ ts.TypeFlags.IndexedAccess |
282
+ ts.TypeFlags.Conditional |
283
+ ts.TypeFlags.Substitution)) !==
284
+ 0
285
+ ) {
286
+ const constraint = checker.getBaseConstraintOfType(type);
287
+
288
+ return constraint !== undefined && mayBeArrayType(checker, constraint);
289
+ }
290
+
291
+ return checker.isArrayType(type) || checker.isTupleType(type);
292
+ };
293
+
206
294
  /**
207
295
  * Whether `type` already satisfies `hasKey`'s `R extends UnknownRecord`
208
296
  * constraint, so that guarding the call with `isRecord` adds nothing.
@@ -83,21 +83,6 @@ describe('prefer-is-record-and-has-key through type wrappers', () => {
83
83
  tester.run('prefer-is-record-and-has-key', preferIsRecordAndHasKey, {
84
84
  valid: [],
85
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
86
  {
102
87
  name: 'keeps isRecord for an unknown value cast to a record in `in`',
103
88
  code: dedent`
@@ -125,15 +110,29 @@ describe('prefer-is-record-and-has-key through type wrappers', () => {
125
110
  errors: [{ messageId: 'useHasKey' }],
126
111
  },
127
112
  {
128
- name: 'reads `!` on an optional record as the record it asserts',
113
+ // `Object.hasOwn(undefined, 'a')` throws; `isRecord` answers false.
114
+ name: 'keeps isRecord for `!` on an optional record, which leaves undefined in place',
129
115
  code: dedent`
130
116
  declare const obj: Readonly<Record<string, unknown>> | undefined;
131
117
  const ok = Object.hasOwn(obj!, 'a');
132
118
  `,
133
119
  output: dedent`
134
- import { hasKey } from 'ts-std-forge';
120
+ import { isRecord, hasKey } from 'ts-std-forge';
135
121
  declare const obj: Readonly<Record<string, unknown>> | undefined;
136
- const ok = hasKey(obj!, 'a');
122
+ const ok = (isRecord(obj!) && hasKey(obj!, 'a'));
123
+ `,
124
+ errors: [{ messageId: 'useIsRecordAndHasKey' }],
125
+ },
126
+ {
127
+ name: 'drops isRecord for a record that is a record at every layer',
128
+ code: dedent`
129
+ declare const obj: Readonly<Record<string, unknown>>;
130
+ const ok = Object.hasOwn(obj! as Readonly<Record<string, unknown>>, 'a');
131
+ `,
132
+ output: dedent`
133
+ import { hasKey } from 'ts-std-forge';
134
+ declare const obj: Readonly<Record<string, unknown>>;
135
+ const ok = hasKey(obj! as Readonly<Record<string, unknown>>, 'a');
137
136
  `,
138
137
  errors: [{ messageId: 'useHasKey' }],
139
138
  },
@@ -159,34 +158,39 @@ describe('prefer-is-record-and-has-key with parenthesized operands', () => {
159
158
  valid: [],
160
159
  invalid: [
161
160
  {
162
- name: 'keeps the parentheses of a sequence object in `in`',
161
+ name: 'a sequence object in `in`, read twice by the fix, gets no fix',
163
162
  code: dedent`
164
163
  declare const a: number;
165
164
  declare const o: unknown;
166
165
  const ok = 'k' in (a, o);
167
166
  `,
168
- output: dedent`
169
- import { isRecord, hasKey } from 'ts-std-forge';
167
+ output: null,
168
+ errors: [{ messageId: 'useIsRecordAndHasKey' }],
169
+ },
170
+ {
171
+ name: 'a sequence object in Object.hasOwn, read twice by the fix, gets no fix',
172
+ code: dedent`
170
173
  declare const a: number;
171
174
  declare const o: unknown;
172
- const ok = (isRecord((a, o)) && hasKey((a, o), 'k'));
175
+ const ok = Object.hasOwn((a, o), 'k');
173
176
  `,
177
+ output: null,
174
178
  errors: [{ messageId: 'useIsRecordAndHasKey' }],
175
179
  },
176
180
  {
177
- name: 'keeps the parentheses of a sequence object in Object.hasOwn',
181
+ name: 'keeps the parentheses of a sequence object read once',
178
182
  code: dedent`
179
183
  declare const a: number;
180
- declare const o: unknown;
181
- const ok = Object.hasOwn((a, o), 'k');
184
+ declare const o: Readonly<Record<string, unknown>>;
185
+ const ok = 'k' in (a, o);
182
186
  `,
183
187
  output: dedent`
184
- import { isRecord, hasKey } from 'ts-std-forge';
188
+ import { hasKey } from 'ts-std-forge';
185
189
  declare const a: number;
186
- declare const o: unknown;
187
- const ok = (isRecord((a, o)) && hasKey((a, o), 'k'));
190
+ declare const o: Readonly<Record<string, unknown>>;
191
+ const ok = hasKey((a, o), 'k');
188
192
  `,
189
- errors: [{ messageId: 'useIsRecordAndHasKey' }],
193
+ errors: [{ messageId: 'useHasKey' }],
190
194
  },
191
195
  {
192
196
  name: 'keeps the parentheses of a sequence key',
@@ -206,3 +210,86 @@ describe('prefer-is-record-and-has-key with parenthesized operands', () => {
206
210
  ],
207
211
  });
208
212
  });
213
+
214
+ describe('prefer-is-record-and-has-key where the rewrite would change behavior', () => {
215
+ tester.run('prefer-is-record-and-has-key', preferIsRecordAndHasKey, {
216
+ valid: [
217
+ {
218
+ // `isRecord` rejects an array, so `'0' in xs` would turn false.
219
+ name: 'an array',
220
+ code: dedent`
221
+ declare const xs: readonly number[];
222
+ const a = '0' in xs;
223
+ const b = Object.hasOwn(xs, 'length');
224
+ `,
225
+ },
226
+ {
227
+ name: 'a union with an array',
228
+ code: dedent`
229
+ declare const v: Readonly<Record<string, unknown>> | readonly number[];
230
+ const ok = 'k' in v;
231
+ `,
232
+ },
233
+ {
234
+ name: 'an array cast to a record',
235
+ code: dedent`
236
+ declare const arr: readonly string[];
237
+ const ok = Object.hasOwn(arr as unknown as Record<string, unknown>, '0');
238
+ `,
239
+ },
240
+ {
241
+ name: 'a record widened by `as` to one that may be an array: the author says it may be one',
242
+ code: dedent`
243
+ declare const obj: Readonly<Record<string, unknown>>;
244
+ const ok = Object.hasOwn(obj as Readonly<Record<string, unknown>> | readonly string[], 'a');
245
+ `,
246
+ },
247
+ ],
248
+ invalid: [
249
+ {
250
+ // `isRecord(…) && hasKey(…)` would evaluate the object twice.
251
+ name: 'an object with side effects is reported without a fix',
252
+ code: dedent`
253
+ declare const getObj: () => object;
254
+ declare const log: () => void;
255
+ declare const o: object;
256
+ const a = 'k' in getObj();
257
+ const b = Object.hasOwn(getObj(), 'k');
258
+ const c = 'k' in (log(), o);
259
+ `,
260
+ output: null,
261
+ errors: [
262
+ { messageId: 'useIsRecordAndHasKey' },
263
+ { messageId: 'useIsRecordAndHasKey' },
264
+ { messageId: 'useIsRecordAndHasKey' },
265
+ ],
266
+ },
267
+ {
268
+ name: 'an object with side effects is fixed when isRecord is dropped: it is read once',
269
+ code: dedent`
270
+ declare const getRec: () => Readonly<Record<string, unknown>>;
271
+ const ok = 'k' in getRec();
272
+ `,
273
+ output: dedent`
274
+ import { hasKey } from 'ts-std-forge';
275
+ declare const getRec: () => Readonly<Record<string, unknown>>;
276
+ const ok = hasKey(getRec(), 'k');
277
+ `,
278
+ errors: [{ messageId: 'useHasKey' }],
279
+ },
280
+ {
281
+ name: 'a reference read through type wrappers is still read without side effects',
282
+ code: dedent`
283
+ declare const o: { readonly p: object };
284
+ const ok = 'k' in (o.p satisfies object);
285
+ `,
286
+ output: dedent`
287
+ import { isRecord, hasKey } from 'ts-std-forge';
288
+ declare const o: { readonly p: object };
289
+ const ok = (isRecord(o.p satisfies object) && hasKey(o.p satisfies object, 'k'));
290
+ `,
291
+ errors: [{ messageId: 'useIsRecordAndHasKey' }],
292
+ },
293
+ ],
294
+ });
295
+ });
@@ -11,7 +11,7 @@ import {
11
11
  skipTypeWrappers,
12
12
  } from 'eslint-rule-forge';
13
13
  import type { ReadonlyRecord } from 'ts-type-forge';
14
- import { getValueType } from './ast-utils.mjs';
14
+ import { typeHoldsAtEveryLayer } from './ast-utils.mjs';
15
15
  import { TS_STD_FORGE_MODULE } from './constants.mjs';
16
16
 
17
17
  type Options = readonly [];
@@ -86,29 +86,24 @@ export const preferSafeArrayLengthGuard: TSESLint.RuleModule<
86
86
  TS_STD_FORGE_MODULE,
87
87
  );
88
88
 
89
- const checker = sourceCode.parserServices?.program?.getTypeChecker();
90
-
91
89
  /**
92
90
  * Whether `expression` is an array or tuple. Without type information the
93
91
  * answer is no: `.length` is also a string's, a function's and a
94
- * `TypedArray`'s, and none of those can be handed to the guard. The type is
95
- * the value's, not a cast's: a string cast to an array is still a string.
92
+ * `TypedArray`'s, and none of those can be handed to the guard. It has to
93
+ * be one at every layer of type wrappers: a string cast to an array is
94
+ * still a string, and the fix, which keeps the wrapper, hands the guard
95
+ * the type the outermost layer states.
96
96
  */
97
97
  const isArrayLike = (
98
98
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
99
99
  expression: TSESTree.Expression,
100
- ): boolean => {
101
- if (checker === undefined) {
102
- return false;
103
- }
104
-
105
- const type = getValueType(sourceCode, expression);
106
-
107
- return (
108
- type !== undefined &&
109
- (checker.isArrayType(type) || checker.isTupleType(type))
100
+ ): boolean =>
101
+ typeHoldsAtEveryLayer(
102
+ sourceCode,
103
+ expression,
104
+ (type, checker) =>
105
+ checker.isArrayType(type) || checker.isTupleType(type),
110
106
  );
111
- };
112
107
 
113
108
  const mut_nodesToFix: {
114
109
  node: TSESTree.BinaryExpression;
@@ -168,6 +168,20 @@ describe('prefer-safe-array-length-guard through type wrappers', () => {
168
168
  const ok = xs.length > (2 as const);
169
169
  `,
170
170
  },
171
+ {
172
+ name: 'an array widened by `as` to one that may be a string',
173
+ code: dedent`
174
+ declare const xs: readonly string[];
175
+ const ok = (xs as readonly string[] | string).length > 0;
176
+ `,
177
+ },
178
+ {
179
+ name: '`!` on an optional array, which leaves undefined in place',
180
+ code: dedent`
181
+ declare const xs: readonly number[] | undefined;
182
+ const ok = xs!.length !== 0;
183
+ `,
184
+ },
171
185
  ],
172
186
  invalid: [
173
187
  {
@@ -210,15 +224,15 @@ describe('prefer-safe-array-length-guard through type wrappers', () => {
210
224
  errors: [{ messageId: 'useIsEmpty' }],
211
225
  },
212
226
  {
213
- name: 'keeps a wrapper on the array and reads `!` as the array it asserts',
227
+ name: 'keeps a wrapper on an array that is an array at every layer',
214
228
  code: dedent`
215
- declare const xs: readonly number[] | undefined;
216
- const ok = xs!.length !== 0;
229
+ declare const xs: readonly number[];
230
+ const ok = (xs! as readonly number[]).length !== 0;
217
231
  `,
218
232
  output: dedent`
219
233
  import { SafeArray } from 'ts-std-forge';
220
- declare const xs: readonly number[] | undefined;
221
- const ok = SafeArray.isNonEmpty(xs!);
234
+ declare const xs: readonly number[];
235
+ const ok = SafeArray.isNonEmpty(xs! as readonly number[]);
222
236
  `,
223
237
  errors: [{ messageId: 'useIsNonEmpty' }],
224
238
  },
@@ -11,7 +11,7 @@ import {
11
11
  skipTypeWrappers,
12
12
  } from 'eslint-rule-forge';
13
13
  import * as ts from 'typescript';
14
- import { getValueType, isIdentifierNamed } from './ast-utils.mjs';
14
+ import { isIdentifierNamed, typeHoldsAtEveryLayer } from './ast-utils.mjs';
15
15
  import { TS_STD_FORGE_MODULE } from './constants.mjs';
16
16
 
17
17
  type Options = readonly [];
@@ -118,13 +118,13 @@ export const preferSafeNumberParseInteger: TSESLint.RuleModule<
118
118
  }
119
119
  }
120
120
 
121
- // The argument must be a `string` so the autofix is type-safe against
122
- // `SafeNumber.parseInteger(value: string)`, and that of the value, not
123
- // of a cast: `parseInt(1e21 as unknown as string)` is 1, the rewrite
124
- // 1e21. Without type information, skip.
125
- const argType = getValueType(sourceCode, firstArg);
126
-
127
- if (argType === undefined || !isStringType(argType)) {
121
+ // The argument must be a `string` at every layer of type wrappers:
122
+ // the value's own type is what `parseInt` sees at run time
123
+ // (`parseInt(1e21 as unknown as string)` is 1, the rewrite 1e21), and
124
+ // the type a wrapper states is what the fix, which keeps the wrapper,
125
+ // hands `SafeNumber.parseInteger(value: string)`. Without type
126
+ // information, skip.
127
+ if (!typeHoldsAtEveryLayer(sourceCode, firstArg, isStringType)) {
128
128
  return;
129
129
  }
130
130
 
@@ -84,6 +84,20 @@ describe('prefer-safe-number-parse-integer through type wrappers', () => {
84
84
  const n = parseInt(s, 16 as const);
85
85
  `,
86
86
  },
87
+ {
88
+ name: 'ignores a string widened by `as` to one that may be a number',
89
+ code: dedent`
90
+ declare const s: string;
91
+ const n = parseInt(s as string | number as string);
92
+ `,
93
+ },
94
+ {
95
+ name: 'ignores `!` on an optional string, which leaves undefined in place',
96
+ code: dedent`
97
+ declare const s: string | undefined;
98
+ const n = parseInt(s!);
99
+ `,
100
+ },
87
101
  ],
88
102
  invalid: [
89
103
  {
@@ -131,15 +145,15 @@ describe('prefer-safe-number-parse-integer through type wrappers', () => {
131
145
  ],
132
146
  },
133
147
  {
134
- name: 'reads `!` on an optional string as the string it asserts',
148
+ name: 'rewrites a string that is a string at every layer',
135
149
  code: dedent`
136
- declare const s: string | undefined;
137
- const n = parseInt(s!);
150
+ declare const s: string;
151
+ const n = parseInt(s! as string);
138
152
  `,
139
153
  output: dedent`
140
154
  import { Result, SafeNumber } from 'ts-std-forge';
141
- declare const s: string | undefined;
142
- const n = Result.unwrapOkOr(SafeNumber.parseInteger(s!), Number.NaN);
155
+ declare const s: string;
156
+ const n = Result.unwrapOkOr(SafeNumber.parseInteger(s! as string), Number.NaN);
143
157
  `,
144
158
  errors: [{ messageId: 'useSafeNumberParseInteger' }],
145
159
  },
@@ -11,7 +11,7 @@ import {
11
11
  skipTypeWrappers,
12
12
  } from 'eslint-rule-forge';
13
13
  import * as ts from 'typescript';
14
- import { getValueType, isIdentifierNamed } from './ast-utils.mjs';
14
+ import { isIdentifierNamed, typeHoldsAtEveryLayer } from './ast-utils.mjs';
15
15
  import { TS_STD_FORGE_MODULE } from './constants.mjs';
16
16
 
17
17
  type Options = readonly [];
@@ -115,13 +115,13 @@ export const preferSafeNumberParse: TSESLint.RuleModule<MessageIds, Options> = {
115
115
  return;
116
116
  }
117
117
 
118
- // The argument must be purely `string` so the autofix is type-safe
119
- // against `SafeNumber.parse(value: string)`, and that of the value, not
120
- // of a cast: `Number(true as unknown as string)` is 1, the rewrite NaN.
121
- // Without type information, skip.
122
- const argType = getValueType(sourceCode, firstArg);
123
-
124
- if (argType === undefined || !isStringType(argType)) {
118
+ // The argument must be purely `string` at every layer of type
119
+ // wrappers: the value's own type is what `Number` sees at run time
120
+ // (`Number(true as unknown as string)` is 1, the rewrite NaN), and the
121
+ // type a wrapper states is what the fix, which keeps the wrapper,
122
+ // hands `SafeNumber.parse(value: string)`. Without type information,
123
+ // skip.
124
+ if (!typeHoldsAtEveryLayer(sourceCode, firstArg, isStringType)) {
125
125
  return;
126
126
  }
127
127