eslint-plugin-ts-data-forge 0.10.5 → 0.10.7

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 (107) hide show
  1. package/README.md +3 -3
  2. package/dist/plugin.d.mts +2 -2
  3. package/dist/plugin.d.mts.map +1 -1
  4. package/dist/plugin.mjs +1 -0
  5. package/dist/plugin.mjs.map +1 -1
  6. package/dist/rule-types.d.mts +5 -5
  7. package/dist/rules/ast-utils.d.mts +8 -9
  8. package/dist/rules/ast-utils.d.mts.map +1 -1
  9. package/dist/rules/ast-utils.mjs +3 -4
  10. package/dist/rules/ast-utils.mjs.map +1 -1
  11. package/dist/rules/import-binding.d.mts +38 -0
  12. package/dist/rules/import-binding.d.mts.map +1 -0
  13. package/dist/rules/import-binding.mjs +53 -0
  14. package/dist/rules/import-binding.mjs.map +1 -0
  15. package/dist/rules/mutable-result.mjs +37 -37
  16. package/dist/rules/mutable-result.mjs.map +1 -1
  17. package/dist/rules/no-unnecessary-type-guard.d.mts.map +1 -1
  18. package/dist/rules/no-unnecessary-type-guard.mjs +146 -143
  19. package/dist/rules/no-unnecessary-type-guard.mjs.map +1 -1
  20. package/dist/rules/prefer-arr-generate.d.mts +2 -2
  21. package/dist/rules/prefer-arr-generate.d.mts.map +1 -1
  22. package/dist/rules/prefer-arr-generate.mjs +146 -136
  23. package/dist/rules/prefer-arr-generate.mjs.map +1 -1
  24. package/dist/rules/prefer-arr-is-bounded-length-array.d.mts.map +1 -1
  25. package/dist/rules/prefer-arr-is-bounded-length-array.mjs +14 -16
  26. package/dist/rules/prefer-arr-is-bounded-length-array.mjs.map +1 -1
  27. package/dist/rules/prefer-arr-is-fixed-length-array.d.mts.map +1 -1
  28. package/dist/rules/prefer-arr-is-fixed-length-array.mjs +15 -17
  29. package/dist/rules/prefer-arr-is-fixed-length-array.mjs.map +1 -1
  30. package/dist/rules/prefer-arr-is-max-length-array.d.mts.map +1 -1
  31. package/dist/rules/prefer-arr-is-max-length-array.mjs +14 -16
  32. package/dist/rules/prefer-arr-is-max-length-array.mjs.map +1 -1
  33. package/dist/rules/prefer-arr-is-min-length-array.d.mts.map +1 -1
  34. package/dist/rules/prefer-arr-is-min-length-array.mjs +14 -16
  35. package/dist/rules/prefer-arr-is-min-length-array.mjs.map +1 -1
  36. package/dist/rules/prefer-arr-is-non-empty.d.mts.map +1 -1
  37. package/dist/rules/prefer-arr-is-non-empty.mjs +14 -16
  38. package/dist/rules/prefer-arr-is-non-empty.mjs.map +1 -1
  39. package/dist/rules/prefer-arr-scan.d.mts.map +1 -1
  40. package/dist/rules/prefer-arr-scan.mjs +9 -18
  41. package/dist/rules/prefer-arr-scan.mjs.map +1 -1
  42. package/dist/rules/prefer-arr-sum.d.mts.map +1 -1
  43. package/dist/rules/prefer-arr-sum.mjs +16 -21
  44. package/dist/rules/prefer-arr-sum.mjs.map +1 -1
  45. package/dist/rules/prefer-arr-uniq.d.mts.map +1 -1
  46. package/dist/rules/prefer-arr-uniq.mjs +38 -43
  47. package/dist/rules/prefer-arr-uniq.mjs.map +1 -1
  48. package/dist/rules/prefer-as-int.d.mts.map +1 -1
  49. package/dist/rules/prefer-as-int.mjs +19 -26
  50. package/dist/rules/prefer-as-int.mjs.map +1 -1
  51. package/dist/rules/prefer-canonical-array-slicing.d.mts.map +1 -1
  52. package/dist/rules/prefer-canonical-array-slicing.mjs +49 -16
  53. package/dist/rules/prefer-canonical-array-slicing.mjs.map +1 -1
  54. package/dist/rules/prefer-canonical-length-cast.mjs +15 -15
  55. package/dist/rules/prefer-canonical-length-cast.mjs.map +1 -1
  56. package/dist/rules/prefer-num-safe-parse-float.d.mts +1 -1
  57. package/dist/rules/prefer-num-safe-parse-float.d.mts.map +1 -1
  58. package/dist/rules/prefer-num-safe-parse-int.d.mts +1 -1
  59. package/dist/rules/prefer-num-safe-parse-int.d.mts.map +1 -1
  60. package/dist/rules/prefer-obj-over-entries-round-trip.d.mts.map +1 -1
  61. package/dist/rules/prefer-obj-over-entries-round-trip.mjs +100 -99
  62. package/dist/rules/prefer-obj-over-entries-round-trip.mjs.map +1 -1
  63. package/dist/rules/prefer-pipe-map-nullable.d.mts +2 -2
  64. package/dist/rules/prefer-pipe-map-nullable.d.mts.map +1 -1
  65. package/dist/rules/prefer-pipe-map-nullable.mjs +2 -4
  66. package/dist/rules/prefer-pipe-map-nullable.mjs.map +1 -1
  67. package/dist/rules/prefer-range-for-loop.d.mts.map +1 -1
  68. package/dist/rules/prefer-range-for-loop.mjs +18 -24
  69. package/dist/rules/prefer-range-for-loop.mjs.map +1 -1
  70. package/dist/rules/rules.d.mts +2 -2
  71. package/package.json +6 -6
  72. package/src/plugin.mts +4 -3
  73. package/src/rule-types.mts +5 -5
  74. package/src/rules/ast-utils.mts +14 -15
  75. package/src/rules/import-binding.mts +94 -0
  76. package/src/rules/mutable-result.mts +56 -56
  77. package/src/rules/no-unnecessary-type-guard.mts +203 -207
  78. package/src/rules/no-unnecessary-type-guard.test.mts +62 -2
  79. package/src/rules/prefer-arr-generate.mts +222 -214
  80. package/src/rules/prefer-arr-generate.test.mts +490 -133
  81. package/src/rules/prefer-arr-is-bounded-length-array.mts +22 -33
  82. package/src/rules/prefer-arr-is-fixed-length-array.mts +26 -38
  83. package/src/rules/prefer-arr-is-fixed-length-array.test.mts +16 -7
  84. package/src/rules/prefer-arr-is-max-length-array.mts +23 -36
  85. package/src/rules/prefer-arr-is-max-length-array.test.mts +16 -7
  86. package/src/rules/prefer-arr-is-min-length-array.mts +23 -36
  87. package/src/rules/prefer-arr-is-min-length-array.test.mts +16 -7
  88. package/src/rules/prefer-arr-is-non-empty.mts +17 -34
  89. package/src/rules/prefer-arr-is-non-empty.test.mts +110 -0
  90. package/src/rules/prefer-arr-scan.mts +17 -42
  91. package/src/rules/prefer-arr-scan.test.mts +55 -0
  92. package/src/rules/prefer-arr-sum.mts +22 -36
  93. package/src/rules/prefer-arr-uniq.mts +51 -62
  94. package/src/rules/prefer-arr-uniq.test.mts +46 -20
  95. package/src/rules/prefer-as-int.mts +27 -52
  96. package/src/rules/prefer-as-int.test.mts +79 -29
  97. package/src/rules/prefer-canonical-array-slicing.mts +101 -40
  98. package/src/rules/prefer-canonical-array-slicing.test.mts +133 -34
  99. package/src/rules/prefer-canonical-length-cast.mts +29 -29
  100. package/src/rules/prefer-canonical-length-guard.mts +2 -2
  101. package/src/rules/prefer-num-safe-parse-float.test.mts +255 -94
  102. package/src/rules/prefer-num-safe-parse-int.test.mts +212 -76
  103. package/src/rules/prefer-obj-over-entries-round-trip.mts +152 -170
  104. package/src/rules/prefer-obj-over-entries-round-trip.test.mts +48 -6
  105. package/src/rules/prefer-pipe-map-nullable.mts +17 -19
  106. package/src/rules/prefer-range-for-loop.mts +37 -61
  107. package/src/rules/prefer-range-for-loop.test.mts +2 -2
@@ -0,0 +1,94 @@
1
+ import type { TSESLint, TSESTree } from '@typescript-eslint/utils';
2
+ import {
3
+ bindValueImports,
4
+ hasForeignBinding,
5
+ type ValueImportBinding,
6
+ } from 'eslint-rule-forge';
7
+ import { memoizeFunction } from 'ts-data-forge';
8
+ import { TS_DATA_FORGE_MODULE } from './constants.mjs';
9
+
10
+ /**
11
+ * Binds ts-data-forge's `exportName` as a value once per file (see
12
+ * `bindValueImports`), and gives, for the code at a node, the name a fix writes
13
+ * and the edits that bring it into scope, or `undefined` when no fix can: an
14
+ * import already binds the name to something else, or something other than the
15
+ * import takes it where the code is.
16
+ *
17
+ * Every fix that writes the name makes these edits itself, so that one applied
18
+ * alone (an editor's quick fix) is complete; `--fix` then takes one pass per
19
+ * report that adds the import.
20
+ */
21
+ export const tsDataForgeValueAt = (
22
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
23
+ sourceCode: TSESLint.SourceCode,
24
+ exportName: string,
25
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
26
+ ): ((node: TSESTree.Node) => TsDataForgeValue | undefined) => {
27
+ const { at } = tsDataForgeValuesAt(sourceCode, [exportName]);
28
+
29
+ return (node) => {
30
+ const binding = at(node);
31
+
32
+ const localName = binding?.localNames[0];
33
+
34
+ return binding === undefined || localName === undefined
35
+ ? undefined
36
+ : { localName, edits: binding.edits };
37
+ };
38
+ };
39
+
40
+ /**
41
+ * {@link tsDataForgeValueAt} for a name that varies from report to report (a
42
+ * cast function, a replacement guard): each name is bound once per file,
43
+ * however many reports write it.
44
+ */
45
+ export const tsDataForgeValueAtByName = (
46
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
47
+ sourceCode: TSESLint.SourceCode,
48
+ ): ((exportName: string) => ReturnType<typeof tsDataForgeValueAt>) =>
49
+ memoizeFunction(
50
+ (exportName: string) => tsDataForgeValueAt(sourceCode, exportName),
51
+ (exportName) => exportName,
52
+ );
53
+
54
+ /**
55
+ * {@link tsDataForgeValueAt} for several names one fix writes together: their
56
+ * edits are made as one, so that two names the file does not import go into a
57
+ * single new declaration. `localNames` are the names to write, known before any
58
+ * node is: the export names themselves when they cannot be bound.
59
+ */
60
+ export const tsDataForgeValuesAt = (
61
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
62
+ sourceCode: TSESLint.SourceCode,
63
+ exportNames: readonly string[],
64
+ ): Readonly<{
65
+ localNames: readonly string[];
66
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
67
+ at: (node: TSESTree.Node) => ValueImportBinding | undefined;
68
+ }> => {
69
+ const binding = bindValueImports(
70
+ sourceCode,
71
+ TS_DATA_FORGE_MODULE,
72
+ exportNames,
73
+ );
74
+
75
+ return {
76
+ localNames: binding?.localNames ?? exportNames,
77
+ at: (node) =>
78
+ binding === undefined ||
79
+ binding.localNames.some((localName) =>
80
+ hasForeignBinding(sourceCode, node, localName, TS_DATA_FORGE_MODULE),
81
+ )
82
+ ? undefined
83
+ : binding,
84
+ };
85
+ };
86
+
87
+ /** What {@link tsDataForgeValueAt} gives for one node. */
88
+ export type TsDataForgeValue = Readonly<{
89
+ /** The name to write. */
90
+ localName: string;
91
+
92
+ /** The edits that bind it. */
93
+ edits: ValueImportBinding['edits'];
94
+ }>;
@@ -74,62 +74,6 @@ const outermostTypePreservingWrapper = (
74
74
  : node;
75
75
  };
76
76
 
77
- const isUsedAsMutable = (
78
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
79
- checker: ts.TypeChecker,
80
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
81
- getTsNode: (esNode: TSESTree.Node) => ts.Node | undefined,
82
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
83
- node: TSESTree.Node,
84
- ): boolean => {
85
- const parent = node.parent;
86
-
87
- if (
88
- parent?.type === AST_NODE_TYPES.MemberExpression &&
89
- parent.object === node
90
- ) {
91
- // xs.push(...), xs.sort(), ...
92
- if (
93
- !parent.computed &&
94
- parent.property.type === AST_NODE_TYPES.Identifier &&
95
- mutatingMethods.has(parent.property.name)
96
- ) {
97
- return true;
98
- }
99
-
100
- // xs[0] = ..., xs.length = ..., xs[0]++, delete xs[0]
101
- const grandParent = parent.parent;
102
-
103
- return (
104
- (grandParent.type === AST_NODE_TYPES.AssignmentExpression &&
105
- grandParent.left === parent) ||
106
- grandParent.type === AST_NODE_TYPES.UpdateExpression ||
107
- (grandParent.type === AST_NODE_TYPES.UnaryExpression &&
108
- grandParent.operator === 'delete')
109
- );
110
- }
111
-
112
- // Passed to a parameter, assigned to a variable, or returned from a function
113
- // whose declared type is a mutable array.
114
- const tsNode = getTsNode(node);
115
-
116
- if (tsNode === undefined) {
117
- return false;
118
- }
119
-
120
- if (!ts.isExpression(tsNode)) {
121
- return false;
122
- }
123
-
124
- const expectedType =
125
- getDeclaredParameterType(checker, tsNode) ??
126
- checker.getContextualType(tsNode);
127
-
128
- return (
129
- expectedType !== undefined && isMutableArrayOnly(checker, expectedType)
130
- );
131
- };
132
-
133
77
  /**
134
78
  * The declared type of the parameter `argument` is passed to. The contextual
135
79
  * type of an argument to a generic function is instantiated from that very
@@ -190,6 +134,62 @@ const isMutableArrayOnly = (
190
134
  );
191
135
  };
192
136
 
137
+ const isUsedAsMutable = (
138
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
139
+ checker: ts.TypeChecker,
140
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
141
+ getTsNode: (esNode: TSESTree.Node) => ts.Node | undefined,
142
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
143
+ node: TSESTree.Node,
144
+ ): boolean => {
145
+ const parent = node.parent;
146
+
147
+ if (
148
+ parent?.type === AST_NODE_TYPES.MemberExpression &&
149
+ parent.object === node
150
+ ) {
151
+ // xs.push(...), xs.sort(), ...
152
+ if (
153
+ !parent.computed &&
154
+ parent.property.type === AST_NODE_TYPES.Identifier &&
155
+ mutatingMethods.has(parent.property.name)
156
+ ) {
157
+ return true;
158
+ }
159
+
160
+ // xs[0] = ..., xs.length = ..., xs[0]++, delete xs[0]
161
+ const grandParent = parent.parent;
162
+
163
+ return (
164
+ (grandParent.type === AST_NODE_TYPES.AssignmentExpression &&
165
+ grandParent.left === parent) ||
166
+ grandParent.type === AST_NODE_TYPES.UpdateExpression ||
167
+ (grandParent.type === AST_NODE_TYPES.UnaryExpression &&
168
+ grandParent.operator === 'delete')
169
+ );
170
+ }
171
+
172
+ // Passed to a parameter, assigned to a variable, or returned from a function
173
+ // whose declared type is a mutable array.
174
+ const tsNode = getTsNode(node);
175
+
176
+ if (tsNode === undefined) {
177
+ return false;
178
+ }
179
+
180
+ if (!ts.isExpression(tsNode)) {
181
+ return false;
182
+ }
183
+
184
+ const expectedType =
185
+ getDeclaredParameterType(checker, tsNode) ??
186
+ checker.getContextualType(tsNode);
187
+
188
+ return (
189
+ expectedType !== undefined && isMutableArrayOnly(checker, expectedType)
190
+ );
191
+ };
192
+
193
193
  /** Methods that `ReadonlyArray` lacks and that mutate the receiver. */
194
194
  const mutatingMethods: ReadonlySet<string> = new Set<string>([
195
195
  'copyWithin',
@@ -6,14 +6,13 @@ import {
6
6
  } from '@typescript-eslint/utils';
7
7
  import {
8
8
  buildCalleeResolver,
9
- buildNamedImportFix,
10
9
  getImportDeclarations,
11
- getImportedLocalName,
12
10
  typeWrapperLayers,
13
11
  } from 'eslint-rule-forge';
14
12
  import type { DeepReadonly, ReadonlyRecord } from 'ts-type-forge';
15
13
  import * as ts from 'typescript';
16
14
  import { TS_DATA_FORGE_MODULE } from './constants.mjs';
15
+ import { tsDataForgeValueAtByName } from './import-binding.mjs';
17
16
 
18
17
  type Options = readonly [
19
18
  Readonly<{
@@ -178,39 +177,32 @@ export const noUnnecessaryTypeGuard: TSESLint.RuleModule<MessageIds, Options> =
178
177
 
179
178
  const resolveGuard = buildCalleeResolver(tsDataForgeImports);
180
179
 
181
- const buildReplaceFix =
182
- (
183
- isNamespace: boolean,
184
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
185
- propertyNode: TSESTree.Node,
186
- replacement: string,
187
- ): TSESLint.ReportFixFunction =>
188
- (fixer) => {
189
- // A namespace member access (`tf.isX`) exposes every export, so the
190
- // canonical name is always in scope.
191
- if (isNamespace) {
192
- return [fixer.replaceText(propertyNode, replacement)];
193
- }
194
-
195
- // If the replacement guard is already imported (possibly under an
196
- // alias), rename to its in-scope local name. Otherwise add a new
197
- // import and rename to the canonical name.
198
- const localName = getImportedLocalName(
199
- tsDataForgeImports,
200
- replacement,
201
- );
180
+ // One binding per replacement guard, however many reports use it.
181
+ const guardAt = tsDataForgeValueAtByName(sourceCode);
202
182
 
203
- if (localName !== undefined) {
204
- return [fixer.replaceText(propertyNode, localName)];
205
- }
206
-
207
- return [
208
- ...buildNamedImportFix(fixer, program, TS_DATA_FORGE_MODULE, [
209
- replacement,
210
- ]),
211
- fixer.replaceText(propertyNode, replacement),
212
- ];
213
- };
183
+ const buildReplaceFix = (
184
+ isNamespace: boolean,
185
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
186
+ propertyNode: TSESTree.Node,
187
+ replacement: string,
188
+ ): TSESLint.ReportFixFunction | null => {
189
+ // A namespace member access (`tf.isX`) exposes every export, so the
190
+ // canonical name is always in scope.
191
+ if (isNamespace) {
192
+ return (fixer) => [fixer.replaceText(propertyNode, replacement)];
193
+ }
194
+
195
+ // The replacement guard's local name, added to the imports when the
196
+ // file does not import it; no fix when it cannot be bound there.
197
+ const guard = guardAt(replacement)(propertyNode);
198
+
199
+ return guard === undefined
200
+ ? null
201
+ : (fixer) => [
202
+ ...guard.edits(fixer),
203
+ fixer.replaceText(propertyNode, guard.localName),
204
+ ];
205
+ };
214
206
 
215
207
  const reportConstant = (
216
208
  node: DeepReadonly<TSESTree.CallExpression>,
@@ -250,7 +242,11 @@ export const noUnnecessaryTypeGuard: TSESLint.RuleModule<MessageIds, Options> =
250
242
  // eslint-disable-next-line total-functions/no-unsafe-type-assertion
251
243
  node: node as TSESTree.Node,
252
244
  messageId: 'replaceTypeGuard',
253
- data: { from, to },
245
+ // The name the fix writes: an aliased import's local name.
246
+ data: {
247
+ from,
248
+ to: isNamespace ? to : (guardAt(to)(propertyNode)?.localName ?? to),
249
+ },
254
250
  fix: buildReplaceFix(isNamespace, propertyNode, to),
255
251
  });
256
252
  };
@@ -335,6 +331,174 @@ type Verdict = DeepReadonly<
335
331
  | { kind: 'replace'; replacement: string }
336
332
  >;
337
333
 
334
+ const replacementVerdict = (
335
+ replacement: string | undefined,
336
+ ): Verdict | undefined =>
337
+ replacement === undefined
338
+ ? undefined
339
+ : ({ kind: 'replace', replacement } as const);
340
+
341
+ /**
342
+ * Decomposes a type into its union members. Returns `undefined` when any member
343
+ * is opaque (`any` / `unknown` / `never`) or deferred (type parameter, indexed
344
+ * access, …), signalling that the rule should not draw conclusions.
345
+ */
346
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
347
+ const collectUnionParts = (type: ts.Type): readonly ts.Type[] | undefined => {
348
+ const parts = type.isUnion() ? type.types : ([type] as const);
349
+
350
+ for (const part of parts) {
351
+ if ((part.flags & DEFERRED_OR_OPAQUE_FLAGS) !== 0) {
352
+ return undefined;
353
+ }
354
+ }
355
+
356
+ return parts;
357
+ };
358
+
359
+ const isCallableType = (
360
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
361
+ type: ts.Type,
362
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
363
+ checker: ts.TypeChecker,
364
+ ): boolean =>
365
+ checker.getSignaturesOfType(type, ts.SignatureKind.Call).length > 0 ||
366
+ checker.getSignaturesOfType(type, ts.SignatureKind.Construct).length > 0;
367
+
368
+ /**
369
+ * Whether `isRecord` is guaranteed to accept this (non-union) type.
370
+ *
371
+ * `isRecord` narrows to `UnknownRecord` (`ReadonlyRecord<string, unknown>`), so
372
+ * a string index signature is exactly what it asks for and its value type is
373
+ * `unknown`, which anything satisfies. Arrays and callables are excluded even
374
+ * when they carry one, because `isRecord` rejects them at runtime
375
+ * (`Array.isArray` is true; `typeof fn === 'object'` is false).
376
+ *
377
+ * Deliberately conservative: an object type without an index signature of its
378
+ * own is reported as neither guaranteed-accepted nor guaranteed-rejected, even
379
+ * where TypeScript would accept it through an implicit index signature.
380
+ */
381
+ const isGuaranteedRecord = (
382
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
383
+ type: ts.Type,
384
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
385
+ checker: ts.TypeChecker,
386
+ ): boolean =>
387
+ checker.getIndexInfoOfType(type, ts.IndexKind.String) !== undefined &&
388
+ !isCallableType(type, checker) &&
389
+ !checker.isArrayType(type) &&
390
+ !checker.isTupleType(type);
391
+
392
+ /** Classifies a single (non-union) type into a primitive {@link Atom}. */
393
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
394
+ const classifyAtom = (type: ts.Type): Atom => {
395
+ const { flags } = type;
396
+
397
+ if ((flags & ts.TypeFlags.Undefined) !== 0) {
398
+ return 'undefined';
399
+ }
400
+
401
+ if ((flags & ts.TypeFlags.Null) !== 0) {
402
+ return 'null';
403
+ }
404
+
405
+ if ((flags & ts.TypeFlags.BooleanLike) !== 0) {
406
+ return 'boolean';
407
+ }
408
+
409
+ if ((flags & ts.TypeFlags.NumberLike) !== 0) {
410
+ return 'number';
411
+ }
412
+
413
+ if ((flags & ts.TypeFlags.StringLike) !== 0) {
414
+ return 'string';
415
+ }
416
+
417
+ if ((flags & ts.TypeFlags.BigIntLike) !== 0) {
418
+ return 'bigint';
419
+ }
420
+
421
+ if ((flags & ts.TypeFlags.ESSymbolLike) !== 0) {
422
+ return 'symbol';
423
+ }
424
+
425
+ // Branded primitives (e.g. `NonEmptyString = string & {...}`) are
426
+ // intersections; classify them by their underlying primitive constituent.
427
+ if (type.isIntersection()) {
428
+ for (const constituent of type.types) {
429
+ const atom = classifyAtom(constituent);
430
+
431
+ if (atom !== 'other') {
432
+ return atom;
433
+ }
434
+ }
435
+ }
436
+
437
+ return 'other';
438
+ };
439
+
440
+ /**
441
+ * Whether `isRecord` is guaranteed to reject this (non-union) type: every
442
+ * primitive (`null` included), every array or tuple, and every callable fails
443
+ * its `typeof x === 'object' && x !== null && !Array.isArray(x)` check.
444
+ */
445
+ const isGuaranteedNotRecord = (
446
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
447
+ type: ts.Type,
448
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
449
+ checker: ts.TypeChecker,
450
+ ): boolean =>
451
+ classifyAtom(type) !== 'other' ||
452
+ checker.isArrayType(type) ||
453
+ checker.isTupleType(type) ||
454
+ isCallableType(type, checker);
455
+
456
+ /** A stable key for a set of atoms, used to look up replacement guards. */
457
+ const joinAtoms = (atoms: readonly AtomKey[]): string =>
458
+ ATOM_ORDER.filter((atom) => atoms.includes(atom)).join('|');
459
+
460
+ /**
461
+ * Returns `true` when the type is guaranteed to be a non-empty string: either a
462
+ * non-empty string literal or a value branded as `NonEmptyString`
463
+ * (`MinLengthString<N>` with `N >= 1`).
464
+ */
465
+ const isGuaranteedNonEmptyString = (
466
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
467
+ type: ts.Type,
468
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
469
+ checker: ts.TypeChecker,
470
+ ): boolean => {
471
+ if (type.isStringLiteral()) {
472
+ return type.value !== '';
473
+ }
474
+
475
+ if (type.getProperty(BRAND_MARKER_PROPERTY) === undefined) {
476
+ return false;
477
+ }
478
+
479
+ const minLengthSymbol = type.getProperty(MIN_LENGTH_BRAND_KEY);
480
+
481
+ if (minLengthSymbol === undefined) {
482
+ return false;
483
+ }
484
+
485
+ // The `MinLength` brand value is `MinLengthTuple<N, 0>`; it has a required
486
+ // first element (`'0'`) exactly when `N >= 1`, i.e. the string is non-empty.
487
+ // `MinLengthString<0>` brands with a plain array and has no such element.
488
+ return (
489
+ checker.getTypeOfSymbol(minLengthSymbol).getProperty('0') !== undefined
490
+ );
491
+ };
492
+
493
+ /**
494
+ * Returns `true` when the type can possibly be a non-empty string (a general
495
+ * `string`, a branded string, or a non-empty string literal). The empty string
496
+ * literal and non-string types cannot.
497
+ */
498
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
499
+ const couldBeNonEmptyString = (type: ts.Type): boolean =>
500
+ type.isStringLiteral() ? type.value !== '' : classifyAtom(type) === 'string';
501
+
338
502
  /**
339
503
  * What a guard with `spec` does on an argument of `type`: always the same
340
504
  * boolean, the same as a narrower guard, or nothing the rule can say.
@@ -402,8 +566,8 @@ const verdictOf = (
402
566
  }
403
567
 
404
568
  case 'record':
405
- // Every member already satisfies `UnknownRecord`, or none can ever be
406
- // a non-null, non-array object.
569
+ // Every member already satisfies `UnknownRecord`, or none can ever be a
570
+ // non-null, non-array object.
407
571
  return parts.every((p) => isGuaranteedRecord(p, checker))
408
572
  ? ({ kind: 'constant', value: true } as const)
409
573
  : parts.every((p) => isGuaranteedNotRecord(p, checker))
@@ -435,19 +599,12 @@ const verdictOf = (
435
599
  }
436
600
  };
437
601
 
438
- const replacementVerdict = (
439
- replacement: string | undefined,
440
- ): Verdict | undefined =>
441
- replacement === undefined
442
- ? undefined
443
- : ({ kind: 'replace', replacement } as const);
444
-
445
602
  /**
446
603
  * Whether `verdict`, drawn from one layer's type, also holds for `type`. A
447
604
  * constant has to come out the same. A replacement holds where the rule would
448
605
  * suggest the same one, or where the guard and its replacement are the same
449
- * constant: under `x!` on a `string | null`, `isNonNullish` and `isNotNull`
450
- * are both always `true` on `string`.
606
+ * constant: under `x!` on a `string | null`, `isNonNullish` and `isNotNull` are
607
+ * both always `true` on `string`.
451
608
  */
452
609
  const holdsOn = (
453
610
  verdict: Verdict,
@@ -481,121 +638,6 @@ const holdsOn = (
481
638
  );
482
639
  };
483
640
 
484
- /**
485
- * Decomposes a type into its union members. Returns `undefined` when any member
486
- * is opaque (`any` / `unknown` / `never`) or deferred (type parameter, indexed
487
- * access, …), signalling that the rule should not draw conclusions.
488
- */
489
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
490
- const collectUnionParts = (type: ts.Type): readonly ts.Type[] | undefined => {
491
- const parts = type.isUnion() ? type.types : ([type] as const);
492
-
493
- for (const part of parts) {
494
- if ((part.flags & DEFERRED_OR_OPAQUE_FLAGS) !== 0) {
495
- return undefined;
496
- }
497
- }
498
-
499
- return parts;
500
- };
501
-
502
- /**
503
- * Whether `isRecord` is guaranteed to accept this (non-union) type.
504
- *
505
- * `isRecord` narrows to `UnknownRecord` (`ReadonlyRecord<string, unknown>`), so
506
- * a string index signature is exactly what it asks for and its value type is
507
- * `unknown`, which anything satisfies. Arrays and callables are excluded even
508
- * when they carry one, because `isRecord` rejects them at runtime
509
- * (`Array.isArray` is true; `typeof fn === 'object'` is false).
510
- *
511
- * Deliberately conservative: an object type without an index signature of its
512
- * own is reported as neither guaranteed-accepted nor guaranteed-rejected, even
513
- * where TypeScript would accept it through an implicit index signature.
514
- */
515
- const isGuaranteedRecord = (
516
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
517
- type: ts.Type,
518
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
519
- checker: ts.TypeChecker,
520
- ): boolean =>
521
- checker.getIndexInfoOfType(type, ts.IndexKind.String) !== undefined &&
522
- !isCallableType(type, checker) &&
523
- !checker.isArrayType(type) &&
524
- !checker.isTupleType(type);
525
-
526
- /**
527
- * Whether `isRecord` is guaranteed to reject this (non-union) type: every
528
- * primitive (`null` included), every array or tuple, and every callable fails
529
- * its `typeof x === 'object' && x !== null && !Array.isArray(x)` check.
530
- */
531
- const isGuaranteedNotRecord = (
532
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
533
- type: ts.Type,
534
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
535
- checker: ts.TypeChecker,
536
- ): boolean =>
537
- classifyAtom(type) !== 'other' ||
538
- checker.isArrayType(type) ||
539
- checker.isTupleType(type) ||
540
- isCallableType(type, checker);
541
-
542
- const isCallableType = (
543
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
544
- type: ts.Type,
545
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
546
- checker: ts.TypeChecker,
547
- ): boolean =>
548
- checker.getSignaturesOfType(type, ts.SignatureKind.Call).length > 0 ||
549
- checker.getSignaturesOfType(type, ts.SignatureKind.Construct).length > 0;
550
-
551
- /** Classifies a single (non-union) type into a primitive {@link Atom}. */
552
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
553
- const classifyAtom = (type: ts.Type): Atom => {
554
- const { flags } = type;
555
-
556
- if ((flags & ts.TypeFlags.Undefined) !== 0) {
557
- return 'undefined';
558
- }
559
-
560
- if ((flags & ts.TypeFlags.Null) !== 0) {
561
- return 'null';
562
- }
563
-
564
- if ((flags & ts.TypeFlags.BooleanLike) !== 0) {
565
- return 'boolean';
566
- }
567
-
568
- if ((flags & ts.TypeFlags.NumberLike) !== 0) {
569
- return 'number';
570
- }
571
-
572
- if ((flags & ts.TypeFlags.StringLike) !== 0) {
573
- return 'string';
574
- }
575
-
576
- if ((flags & ts.TypeFlags.BigIntLike) !== 0) {
577
- return 'bigint';
578
- }
579
-
580
- if ((flags & ts.TypeFlags.ESSymbolLike) !== 0) {
581
- return 'symbol';
582
- }
583
-
584
- // Branded primitives (e.g. `NonEmptyString = string & {...}`) are
585
- // intersections; classify them by their underlying primitive constituent.
586
- if (type.isIntersection()) {
587
- for (const constituent of type.types) {
588
- const atom = classifyAtom(constituent);
589
-
590
- if (atom !== 'other') {
591
- return atom;
592
- }
593
- }
594
- }
595
-
596
- return 'other';
597
- };
598
-
599
641
  const ATOM_ORDER: readonly AtomKey[] = [
600
642
  'undefined',
601
643
  'null',
@@ -606,52 +648,6 @@ const ATOM_ORDER: readonly AtomKey[] = [
606
648
  'symbol',
607
649
  ] as const;
608
650
 
609
- /** A stable key for a set of atoms, used to look up replacement guards. */
610
- const joinAtoms = (atoms: readonly AtomKey[]): string =>
611
- ATOM_ORDER.filter((atom) => atoms.includes(atom)).join('|');
612
-
613
- /**
614
- * Returns `true` when the type is guaranteed to be a non-empty string: either a
615
- * non-empty string literal or a value branded as `NonEmptyString`
616
- * (`MinLengthString<N>` with `N >= 1`).
617
- */
618
- const isGuaranteedNonEmptyString = (
619
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
620
- type: ts.Type,
621
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
622
- checker: ts.TypeChecker,
623
- ): boolean => {
624
- if (type.isStringLiteral()) {
625
- return type.value !== '';
626
- }
627
-
628
- if (type.getProperty(BRAND_MARKER_PROPERTY) === undefined) {
629
- return false;
630
- }
631
-
632
- const minLengthSymbol = type.getProperty(MIN_LENGTH_BRAND_KEY);
633
-
634
- if (minLengthSymbol === undefined) {
635
- return false;
636
- }
637
-
638
- // The `MinLength` brand value is `MinLengthTuple<N, 0>`; it has a required
639
- // first element (`'0'`) exactly when `N >= 1`, i.e. the string is non-empty.
640
- // `MinLengthString<0>` brands with a plain array and has no such element.
641
- return (
642
- checker.getTypeOfSymbol(minLengthSymbol).getProperty('0') !== undefined
643
- );
644
- };
645
-
646
- /**
647
- * Returns `true` when the type can possibly be a non-empty string (a general
648
- * `string`, a branded string, or a non-empty string literal). The empty string
649
- * literal and non-string types cannot.
650
- */
651
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
652
- const couldBeNonEmptyString = (type: ts.Type): boolean =>
653
- type.isStringLiteral() ? type.value !== '' : classifyAtom(type) === 'string';
654
-
655
651
  /**
656
652
  * Returns `true` when evaluating the expression cannot have observable side
657
653
  * effects, so replacing the surrounding call with a literal is safe. Limited to