eslint-plugin-ts-data-forge 0.9.0 → 0.10.1

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 (60) hide show
  1. package/README.md +1 -0
  2. package/dist/plugin.d.mts +3 -1
  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 +16 -1
  7. package/dist/rule-types.d.mts.map +1 -1
  8. package/dist/rule-types.mjs.map +1 -1
  9. package/dist/rules/ast-utils.d.mts +0 -16
  10. package/dist/rules/ast-utils.d.mts.map +1 -1
  11. package/dist/rules/ast-utils.mjs +0 -36
  12. package/dist/rules/ast-utils.mjs.map +1 -1
  13. package/dist/rules/no-side-effect-import.d.mts +1 -8
  14. package/dist/rules/no-side-effect-import.d.mts.map +1 -1
  15. package/dist/rules/no-side-effect-import.mjs +7 -43
  16. package/dist/rules/no-side-effect-import.mjs.map +1 -1
  17. package/dist/rules/prefer-arr-is-array.d.mts +1 -5
  18. package/dist/rules/prefer-arr-is-array.d.mts.map +1 -1
  19. package/dist/rules/prefer-arr-is-array.mjs +5 -83
  20. package/dist/rules/prefer-arr-is-array.mjs.map +1 -1
  21. package/dist/rules/prefer-is-non-null-object.d.mts +1 -5
  22. package/dist/rules/prefer-is-non-null-object.d.mts.map +1 -1
  23. package/dist/rules/prefer-is-non-null-object.mjs +4 -184
  24. package/dist/rules/prefer-is-non-null-object.mjs.map +1 -1
  25. package/dist/rules/prefer-is-record-and-has-key.d.mts +1 -5
  26. package/dist/rules/prefer-is-record-and-has-key.d.mts.map +1 -1
  27. package/dist/rules/prefer-is-record-and-has-key.mjs +4 -219
  28. package/dist/rules/prefer-is-record-and-has-key.mjs.map +1 -1
  29. package/dist/rules/prefer-num-safe-parse-float.d.mts +1 -15
  30. package/dist/rules/prefer-num-safe-parse-float.d.mts.map +1 -1
  31. package/dist/rules/prefer-num-safe-parse-float.mjs +6 -123
  32. package/dist/rules/prefer-num-safe-parse-float.mjs.map +1 -1
  33. package/dist/rules/prefer-num-safe-parse-int.d.mts +1 -13
  34. package/dist/rules/prefer-num-safe-parse-int.d.mts.map +1 -1
  35. package/dist/rules/prefer-num-safe-parse-int.mjs +6 -121
  36. package/dist/rules/prefer-num-safe-parse-int.mjs.map +1 -1
  37. package/dist/rules/prefer-pipe-map-nullable.d.mts +24 -0
  38. package/dist/rules/prefer-pipe-map-nullable.d.mts.map +1 -0
  39. package/dist/rules/prefer-pipe-map-nullable.mjs +266 -0
  40. package/dist/rules/prefer-pipe-map-nullable.mjs.map +1 -0
  41. package/dist/rules/rules.d.mts +2 -1
  42. package/dist/rules/rules.d.mts.map +1 -1
  43. package/dist/rules/rules.mjs +2 -0
  44. package/dist/rules/rules.mjs.map +1 -1
  45. package/package.json +6 -6
  46. package/src/plugin.mts +1 -0
  47. package/src/rule-types.mts +17 -1
  48. package/src/rules/ast-utils.mts +0 -51
  49. package/src/rules/no-side-effect-import.mts +7 -61
  50. package/src/rules/prefer-arr-is-array.mts +5 -122
  51. package/src/rules/prefer-arr-is-array.test.mts +50 -7
  52. package/src/rules/prefer-is-non-null-object.mts +4 -274
  53. package/src/rules/prefer-is-record-and-has-key.mts +4 -318
  54. package/src/rules/prefer-num-safe-parse-float.mts +6 -193
  55. package/src/rules/prefer-num-safe-parse-float.test.mts +18 -0
  56. package/src/rules/prefer-num-safe-parse-int.mts +6 -192
  57. package/src/rules/prefer-num-safe-parse-int.test.mts +18 -0
  58. package/src/rules/prefer-pipe-map-nullable.mts +391 -0
  59. package/src/rules/prefer-pipe-map-nullable.test.mts +470 -0
  60. package/src/rules/rules.mts +2 -0
@@ -1,194 +1,8 @@
1
- import {
2
- AST_NODE_TYPES,
3
- type TSESLint,
4
- type TSESTree,
5
- } from '@typescript-eslint/utils';
6
- import {
7
- buildNamedImportFix,
8
- getArgumentText,
9
- getImportDeclarations,
10
- getNamedImports,
11
- skipTypeWrappers,
12
- typeWrapperLayers,
13
- } from 'eslint-rule-forge';
14
- import * as ts from 'typescript';
1
+ import { createPreferSafeParseInt } from 'eslint-rule-forge';
15
2
  import { TS_DATA_FORGE_MODULE } from './constants.mjs';
16
3
 
17
- type Options = readonly [];
18
-
19
- type MessageIds = 'useSafeParseInt';
20
-
21
- /**
22
- * Returns `true` when every constituent of `type` is string-like, i.e. the type
23
- * is assignable to the `string` parameter of `Num.safeParseInt`. A union such
24
- * as `string | undefined` is rejected so the autofix never produces a type
25
- * error.
26
- */
27
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
28
- const isStringType = (type: ts.Type): boolean => {
29
- const parts = type.isUnion() ? type.types : ([type] as const);
30
-
31
- return (
32
- parts.length > 0 &&
33
- parts.every((t) => (t.flags & ts.TypeFlags.StringLike) !== 0)
34
- );
35
- };
36
-
37
- /**
38
- * Detects the base-10 forms of `parseInt` / `Number.parseInt` (`parseInt(x)` or
39
- * `parseInt(x, 10)`) and replaces them with
40
- * `Result.unwrapOkOr(Num.safeParseInt(x), Number.NaN)` (ts-data-forge).
41
- *
42
- * Other radixes (e.g. `parseInt(x, 16)`) are intentionally left untouched, as
43
- * `Num.safeParseInt` only supports base 10.
44
- */
45
- export const preferNumSafeParseInt: TSESLint.RuleModule<MessageIds, Options> = {
46
- meta: {
47
- type: 'suggestion',
48
- docs: {
49
- description:
50
- 'Replace `parseInt(x, 10)` with `Result.unwrapOkOr(Num.safeParseInt(x), Number.NaN)` from ts-data-forge.',
51
- },
52
- fixable: 'code',
53
- schema: [],
54
- messages: {
55
- useSafeParseInt:
56
- 'Replace `{{original}}` with `Result.unwrapOkOr(Num.safeParseInt({{argName}}), Number.NaN)` from ts-data-forge.',
57
- },
58
- },
59
-
60
- create: (context) => {
61
- const sourceCode = context.sourceCode;
62
-
63
- const program = sourceCode.ast;
64
-
65
- const tsDataForgeImports = getImportDeclarations(
66
- program,
67
- TS_DATA_FORGE_MODULE,
68
- );
69
-
70
- const services = context.sourceCode.parserServices;
71
-
72
- const mut_nodesToFix: {
73
- node: TSESTree.CallExpression;
74
- argExpression: TSESTree.Expression;
75
- }[] = [];
76
-
77
- return {
78
- CallExpression: (node) => {
79
- const { callee } = node;
80
-
81
- // Match `parseInt(...)` or `Number.parseInt(...)`.
82
- const isGlobalParseInt =
83
- callee.type === AST_NODE_TYPES.Identifier &&
84
- callee.name === 'parseInt';
85
-
86
- const isNumberParseInt =
87
- callee.type === AST_NODE_TYPES.MemberExpression &&
88
- !callee.computed &&
89
- callee.object.type === AST_NODE_TYPES.Identifier &&
90
- callee.object.name === 'Number' &&
91
- callee.property.type === AST_NODE_TYPES.Identifier &&
92
- callee.property.name === 'parseInt';
93
-
94
- if (!isGlobalParseInt && !isNumberParseInt) {
95
- return;
96
- }
97
-
98
- const args = node.arguments;
99
-
100
- const firstArg = args[0];
101
-
102
- if (firstArg === undefined) {
103
- return;
104
- }
105
-
106
- // Spread argument (`parseInt(...rest)`) cannot be rewritten safely.
107
- if (firstArg.type === AST_NODE_TYPES.SpreadElement) {
108
- return;
109
- }
110
-
111
- // Only base 10: accept a missing radix or an explicit literal `10`,
112
- // under any type wrapper (`10 as const`).
113
- if (args.length >= 2) {
114
- const radix =
115
- args[1] === undefined ? undefined : skipTypeWrappers(args[1]);
116
-
117
- if (radix?.type !== AST_NODE_TYPES.Literal || radix.value !== 10) {
118
- return;
119
- }
120
- }
121
-
122
- // The argument must be a `string` so the autofix is type-safe against
123
- // `Num.safeParseInt(s: string)`. Without type information, skip. Every
124
- // layer of type wrappers is asked (see typeWrapperLayers): a cast
125
- // does not make a number a string.
126
- if (services?.program == null) {
127
- return;
128
- }
129
-
130
- const checker = services.program.getTypeChecker();
131
-
132
- const isString = (
133
- // AST nodes hold mutable child arrays, so they are not deeply readonly.
134
- // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
135
- expression: TSESTree.Node,
136
- ): boolean => {
137
- const tsNode = services.esTreeNodeToTSNodeMap?.get(expression);
138
-
139
- return (
140
- tsNode !== undefined &&
141
- isStringType(checker.getTypeAtLocation(tsNode))
142
- );
143
- };
144
-
145
- if (!typeWrapperLayers(firstArg).every(isString)) {
146
- return;
147
- }
148
-
149
- mut_nodesToFix.push({ node, argExpression: firstArg });
150
- },
151
- 'Program:exit': () => {
152
- const namedImports = getNamedImports(tsDataForgeImports);
153
-
154
- const missingImports = (['Num', 'Result'] as const).filter(
155
- (name) => !namedImports.includes(name),
156
- );
157
-
158
- for (const [
159
- index,
160
- { node, argExpression },
161
- ] of mut_nodesToFix.entries()) {
162
- const argText = getArgumentText(sourceCode, argExpression);
163
-
164
- const originalText = sourceCode.getText(node);
165
-
166
- context.report({
167
- node,
168
- messageId: 'useSafeParseInt',
169
- data: {
170
- original: originalText,
171
- argName: argText,
172
- },
173
- fix: (fixer) => {
174
- const core = `Result.unwrapOkOr(Num.safeParseInt(${argText}), Number.NaN)`;
175
-
176
- const importFixes =
177
- index === 0 && missingImports.length > 0
178
- ? buildNamedImportFix(
179
- fixer,
180
- program,
181
- TS_DATA_FORGE_MODULE,
182
- missingImports,
183
- )
184
- : [];
185
-
186
- return [...importFixes, fixer.replaceText(node, core)];
187
- },
188
- });
189
- }
190
- },
191
- };
192
- },
193
- defaultOptions: [],
194
- } as const;
4
+ export const preferNumSafeParseInt = createPreferSafeParseInt({
5
+ moduleName: TS_DATA_FORGE_MODULE,
6
+ namespace: 'Num',
7
+ method: 'safeParseInt',
8
+ });
@@ -173,6 +173,24 @@ describe('prefer-num-safe-parse-int through type wrappers', () => {
173
173
  { messageId: 'useSafeParseInt' },
174
174
  ],
175
175
  },
176
+ {
177
+ name: 'the callee read through `as` and `!`',
178
+ code: dedent`
179
+ declare const s: string;
180
+ const a = (parseInt as typeof parseInt)(s);
181
+ const b = (Number as NumberConstructor)!.parseInt(s, 10);
182
+ `,
183
+ output: dedent`
184
+ import { Num, Result } from 'ts-data-forge';
185
+ declare const s: string;
186
+ const a = Result.unwrapOkOr(Num.safeParseInt(s), Number.NaN);
187
+ const b = Result.unwrapOkOr(Num.safeParseInt(s), Number.NaN);
188
+ `,
189
+ errors: [
190
+ { messageId: 'useSafeParseInt' },
191
+ { messageId: 'useSafeParseInt' },
192
+ ],
193
+ },
176
194
  ],
177
195
  });
178
196
  }, 20000);
@@ -0,0 +1,391 @@
1
+ import {
2
+ AST_NODE_TYPES,
3
+ type TSESLint,
4
+ type TSESTree,
5
+ } from '@typescript-eslint/utils';
6
+ import {
7
+ buildCalleeResolver,
8
+ getArgumentText,
9
+ getImportDeclarations,
10
+ hasForeignBinding,
11
+ isTypeWrapper,
12
+ skipTypeWrappers,
13
+ } from 'eslint-rule-forge';
14
+ import { Arr } from 'ts-data-forge';
15
+ import * as ts from 'typescript';
16
+ import { TS_DATA_FORGE_MODULE } from './constants.mjs';
17
+
18
+ type Options = readonly [];
19
+
20
+ type MessageIds = 'useMapNullable' | 'suggestMapNullable';
21
+
22
+ /**
23
+ * `pipe(x).map(mapNullable(fn))` and `pipe(x).mapNullable(fn)` compute the same
24
+ * value: `mapNullable(fn)` is `(v) => v == null ? undefined : fn(v)`, and
25
+ * `Pipe.mapNullable(fn)` is `pipe(a == null ? undefined : fn(a))`.
26
+ *
27
+ * Only the curried `mapNullable(fn)` imported from ts-data-forge is rewritten,
28
+ * and only on a receiver this rule can see is a pipe: a `pipe(…)` call, or a
29
+ * chain of `map` / `mapNullable` / `mapOptional` calls on one.
30
+ *
31
+ * The static type can differ. `mapNullable` declares `const B` and
32
+ * `Pipe.mapNullable` a plain `B`, so for an inline function `(v) => 'a'` the
33
+ * first infers `'a'` and the second `string`, and `(v) => [v, 1]` becomes
34
+ * `number[]` instead of `readonly [number, 1]`. The fix is therefore applied
35
+ * automatically only when the function's return type is not inferred, or is
36
+ * inferred with nothing such a widening could change; otherwise, and whenever
37
+ * a comment or a type wrapper around `mapNullable` would be dropped, it is
38
+ * offered as a suggestion.
39
+ */
40
+ export const preferPipeMapNullable: TSESLint.RuleModule<MessageIds, Options> = {
41
+ meta: {
42
+ type: 'suggestion',
43
+ docs: {
44
+ description:
45
+ 'Replace `pipe(x).map(mapNullable(fn))` with `pipe(x).mapNullable(fn)`.',
46
+ },
47
+ fixable: 'code',
48
+ hasSuggestions: true,
49
+ schema: [],
50
+ messages: {
51
+ useMapNullable:
52
+ 'Replace `.map({{mapNullableName}}(…))` with `.mapNullable(…)`.',
53
+ suggestMapNullable:
54
+ 'Replace `.map({{mapNullableName}}(…))` with `.mapNullable(…)` (the inferred type may be widened, or a comment or type assertion dropped).',
55
+ },
56
+ },
57
+
58
+ create: (context) => {
59
+ const sourceCode = context.sourceCode;
60
+
61
+ const program = sourceCode.ast;
62
+
63
+ const resolveTsDataForge = buildCalleeResolver(
64
+ getImportDeclarations(program, TS_DATA_FORGE_MODULE),
65
+ );
66
+
67
+ const pipeResolvers = PIPE_MODULES.map(
68
+ (moduleName) =>
69
+ [
70
+ moduleName,
71
+ buildCalleeResolver(getImportDeclarations(program, moduleName)),
72
+ ] as const,
73
+ );
74
+
75
+ const services = sourceCode.parserServices;
76
+
77
+ const checker = services?.program?.getTypeChecker();
78
+
79
+ const esTreeNodeToTSNodeMap = services?.esTreeNodeToTSNodeMap;
80
+
81
+ /**
82
+ * Whether `callee` calls the export `name` of the module `resolve`
83
+ * reads, rather than a local binding that shadows the import.
84
+ */
85
+ const calls = (
86
+ resolve: ReturnType<typeof buildCalleeResolver>,
87
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
88
+ callee: TSESTree.Expression,
89
+ name: string,
90
+ moduleName: string,
91
+ ): boolean => {
92
+ if (resolve(callee)?.canonicalName !== name) {
93
+ return false;
94
+ }
95
+
96
+ const unwrapped = skipTypeWrappers(callee);
97
+
98
+ // The named import itself, or the namespace of `m.f`.
99
+ const binding =
100
+ unwrapped.type === AST_NODE_TYPES.MemberExpression
101
+ ? skipTypeWrappers(unwrapped.object)
102
+ : unwrapped;
103
+
104
+ return (
105
+ binding.type === AST_NODE_TYPES.Identifier &&
106
+ !hasForeignBinding(sourceCode, binding, binding.name, moduleName)
107
+ );
108
+ };
109
+
110
+ /**
111
+ * Whether `node` is `pipe(…)`, or `p.map(…)` / `p.mapNullable(…)` /
112
+ * `p.mapOptional(…)` on such a `p`.
113
+ */
114
+ const isPipe = (
115
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
116
+ wrapped: TSESTree.Node,
117
+ ): boolean => {
118
+ const node = skipTypeWrappers(wrapped);
119
+
120
+ if (node.type !== AST_NODE_TYPES.CallExpression || node.optional) {
121
+ return false;
122
+ }
123
+
124
+ if (
125
+ pipeResolvers.some(([moduleName, resolve]) =>
126
+ calls(resolve, node.callee, 'pipe', moduleName),
127
+ )
128
+ ) {
129
+ return true;
130
+ }
131
+
132
+ const { callee } = node;
133
+
134
+ return (
135
+ callee.type === AST_NODE_TYPES.MemberExpression &&
136
+ !callee.computed &&
137
+ !callee.optional &&
138
+ callee.property.type === AST_NODE_TYPES.Identifier &&
139
+ PIPE_CHAIN_METHODS.has(callee.property.name) &&
140
+ isPipe(callee.object)
141
+ );
142
+ };
143
+
144
+ /**
145
+ * Whether `Pipe.mapNullable(mapFn)` infers the same result type as
146
+ * `mapNullable(mapFn)` did (see the rule's comment).
147
+ */
148
+ const keepsResultType = (
149
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
150
+ mapNullableCall: TSESTree.CallExpression,
151
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
152
+ mapFn: TSESTree.Expression,
153
+ ): boolean => {
154
+ // `satisfies` leaves the function's own type to inference.
155
+ const fn = skipSatisfies(mapFn);
156
+
157
+ if (
158
+ (fn.type !== AST_NODE_TYPES.ArrowFunctionExpression &&
159
+ fn.type !== AST_NODE_TYPES.FunctionExpression) ||
160
+ fn.returnType !== undefined
161
+ ) {
162
+ // A function whose type is fixed before `B` is inferred.
163
+ return true;
164
+ }
165
+
166
+ if (checker === undefined || esTreeNodeToTSNodeMap === undefined) {
167
+ return false;
168
+ }
169
+
170
+ // The `B | undefined` that `mapNullable(fn)` returns, with `B` as the
171
+ // `const` type parameter inferred it. The function's own return type
172
+ // is no guide: for `(v) => ({ kind: 'n', v })` it is already widened
173
+ // to `{ kind: string; v: number }`, while `B` keeps `'n'`.
174
+ const signatures = checker
175
+ .getTypeAtLocation(esTreeNodeToTSNodeMap.get(mapNullableCall))
176
+ .getCallSignatures();
177
+
178
+ return (
179
+ Arr.isFixedLengthTuple(1, signatures) &&
180
+ !hasConstInferredPart(
181
+ checker,
182
+ checker.getReturnTypeOfSignature(signatures[0]),
183
+ 0,
184
+ )
185
+ );
186
+ };
187
+
188
+ return {
189
+ CallExpression: (node) => {
190
+ // <pipe>.map(mapNullable(fn))
191
+ if (
192
+ node.optional ||
193
+ node.typeArguments !== undefined ||
194
+ node.callee.type !== AST_NODE_TYPES.MemberExpression ||
195
+ node.callee.computed ||
196
+ node.callee.optional ||
197
+ node.callee.property.type !== AST_NODE_TYPES.Identifier ||
198
+ node.callee.property.name !== 'map' ||
199
+ !Arr.isFixedLengthTuple(1, node.arguments)
200
+ ) {
201
+ return;
202
+ }
203
+
204
+ const mapNullableCall = node.arguments[0];
205
+
206
+ if (
207
+ mapNullableCall.type !== AST_NODE_TYPES.CallExpression ||
208
+ mapNullableCall.optional ||
209
+ // `mapNullable<A, B>(fn)` pins types `Pipe.mapNullable<B>` has no
210
+ // place for.
211
+ mapNullableCall.typeArguments !== undefined ||
212
+ // `mapNullable(x, fn)` is the two-argument form, which returns the
213
+ // mapped value rather than a function.
214
+ !Arr.isFixedLengthTuple(1, mapNullableCall.arguments)
215
+ ) {
216
+ return;
217
+ }
218
+
219
+ const mapFn = mapNullableCall.arguments[0];
220
+
221
+ if (
222
+ mapFn.type === AST_NODE_TYPES.SpreadElement ||
223
+ !calls(
224
+ resolveTsDataForge,
225
+ mapNullableCall.callee,
226
+ 'mapNullable',
227
+ TS_DATA_FORGE_MODULE,
228
+ ) ||
229
+ !isPipe(node.callee.object)
230
+ ) {
231
+ return;
232
+ }
233
+
234
+ // Everything the fix removes: `mapNullable(` and `)` around `fn`.
235
+ const droppedRanges = [
236
+ [mapNullableCall.range[0], mapFn.range[0]],
237
+ [mapFn.range[1], mapNullableCall.range[1]],
238
+ ] as const;
239
+
240
+ const dropsComment = sourceCode
241
+ .getCommentsInside(mapNullableCall)
242
+ .some((comment) =>
243
+ droppedRanges.some(
244
+ ([start, end]) =>
245
+ start <= comment.range[0] && comment.range[1] <= end,
246
+ ),
247
+ );
248
+
249
+ const autofixable =
250
+ !dropsComment &&
251
+ !isTypeWrapper(mapNullableCall.callee) &&
252
+ keepsResultType(mapNullableCall, mapFn);
253
+
254
+ const mapProperty = node.callee.property;
255
+
256
+ const fix: TSESLint.ReportFixFunction = (fixer) => [
257
+ fixer.replaceText(mapProperty, 'mapNullable'),
258
+ fixer.replaceText(
259
+ mapNullableCall,
260
+ getArgumentText(sourceCode, mapFn),
261
+ ),
262
+ ];
263
+
264
+ const data = {
265
+ mapNullableName: sourceCode.getText(mapNullableCall.callee),
266
+ } as const;
267
+
268
+ if (autofixable) {
269
+ context.report({
270
+ node,
271
+ messageId: 'useMapNullable',
272
+ data,
273
+ fix,
274
+ });
275
+ } else {
276
+ context.report({
277
+ node,
278
+ messageId: 'useMapNullable',
279
+ data,
280
+ suggest: [{ messageId: 'suggestMapNullable', data, fix }],
281
+ });
282
+ }
283
+ },
284
+ };
285
+ },
286
+ defaultOptions: [],
287
+ } as const;
288
+
289
+ /**
290
+ * `ts-data-forge` re-exports `pipe` from `ts-std-forge`, so a pipe built
291
+ * through either package is the same object. `mapNullable` is exported by
292
+ * `ts-data-forge` only.
293
+ */
294
+ const PIPE_MODULES = [TS_DATA_FORGE_MODULE, 'ts-std-forge'] as const;
295
+
296
+ /** The methods of a `Pipe` that return another `Pipe`. */
297
+ const PIPE_CHAIN_METHODS: ReadonlySet<string> = new Set([
298
+ 'map',
299
+ 'mapNullable',
300
+ 'mapOptional',
301
+ ]);
302
+
303
+ /** Literal types, which a `const` type parameter keeps and a plain one widens. */
304
+ const literalTypeFlags =
305
+ ts.TypeFlags.StringLiteral |
306
+ ts.TypeFlags.NumberLiteral |
307
+ ts.TypeFlags.BigIntLiteral |
308
+ ts.TypeFlags.BooleanLiteral |
309
+ ts.TypeFlags.EnumLiteral |
310
+ ts.TypeFlags.TemplateLiteral |
311
+ ts.TypeFlags.UniqueESSymbol;
312
+
313
+ /** Deeper than this, a type is assumed to have a `const`-inferred part. */
314
+ const MAX_DEPTH = 4;
315
+
316
+ /**
317
+ * Whether `type`, inferred for a `const` type parameter, has a part a plain
318
+ * type parameter would infer differently: a literal (`'a'` against `string`)
319
+ * or a tuple (`readonly [number, 1]` against `number[]`), at the top level or
320
+ * inside an array, a generic type's arguments or an object literal's
321
+ * properties. Conservative: a declared literal type, which neither widens,
322
+ * also counts.
323
+ */
324
+ const hasConstInferredPart = (
325
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
326
+ checker: ts.TypeChecker,
327
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
328
+ type: ts.Type,
329
+ depth: number,
330
+ ): boolean => {
331
+ if (depth > MAX_DEPTH) {
332
+ return true;
333
+ }
334
+
335
+ // `boolean` is the union `true | false`, which widens to itself.
336
+ if ((type.flags & ts.TypeFlags.Boolean) !== 0) {
337
+ return false;
338
+ }
339
+
340
+ if (type.isUnion() || type.isIntersection()) {
341
+ return type.types.some((t) => hasConstInferredPart(checker, t, depth + 1));
342
+ }
343
+
344
+ if ((type.flags & literalTypeFlags) !== 0 || checker.isTupleType(type)) {
345
+ return true;
346
+ }
347
+
348
+ if ((type.flags & ts.TypeFlags.Object) === 0) {
349
+ return false;
350
+ }
351
+
352
+ // eslint-disable-next-line total-functions/no-unsafe-type-assertion
353
+ const { objectFlags } = type as ts.ObjectType;
354
+
355
+ // `Promise<'a'>`, `readonly 'a'[]`.
356
+ if (
357
+ (objectFlags & ts.ObjectFlags.Reference) !== 0 &&
358
+ checker
359
+ // eslint-disable-next-line total-functions/no-unsafe-type-assertion
360
+ .getTypeArguments(type as ts.TypeReference)
361
+ .some((t) => hasConstInferredPart(checker, t, depth + 1))
362
+ ) {
363
+ return true;
364
+ }
365
+
366
+ return (
367
+ // An object literal's type, which reaches here instantiated (`Anonymous`)
368
+ // rather than flagged `ObjectLiteral`.
369
+ (objectFlags &
370
+ (ts.ObjectFlags.ObjectLiteral | ts.ObjectFlags.Anonymous)) !==
371
+ 0 &&
372
+ type
373
+ .getProperties()
374
+ .some((property) =>
375
+ hasConstInferredPart(
376
+ checker,
377
+ checker.getTypeOfSymbol(property),
378
+ depth + 1,
379
+ ),
380
+ )
381
+ );
382
+ };
383
+
384
+ /** `node` with every `satisfies` around it taken off. */
385
+ const skipSatisfies = (
386
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
387
+ node: TSESTree.Expression,
388
+ ): TSESTree.Expression =>
389
+ node.type === AST_NODE_TYPES.TSSatisfiesExpression
390
+ ? skipSatisfies(node.expression)
391
+ : node;