@blumintinc/eslint-plugin-blumint 1.20.145 → 1.20.146

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.
package/lib/index.js CHANGED
@@ -223,7 +223,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
223
223
  module.exports = {
224
224
  meta: {
225
225
  name: '@blumintinc/eslint-plugin-blumint',
226
- version: '1.20.145',
226
+ version: '1.20.146',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -196,6 +196,246 @@ function declaresFunctionParameter(params) {
196
196
  }
197
197
  return parameterAnnotation(params[0])?.type === utils_1.AST_NODE_TYPES.TSFunctionType;
198
198
  }
199
+ /**
200
+ * A database transaction handle is valid only for the attempt that created it,
201
+ * and a transaction body is re-run whenever the driver retries — Firestore
202
+ * retries an attempt whose reads a concurrent write invalidated. A memoized
203
+ * body hands the retry the first attempt's cached promise, so the retry queues
204
+ * no writes on its own handle, commits empty, and the caller reads the first
205
+ * attempt's return value and reports success. Memoizing the method that OWNS
206
+ * the transaction is the same defect one level up: the whole transaction, writes
207
+ * included, then runs once per instance.
208
+ */
209
+ const TRANSACTION_TYPE_NAME = 'Transaction';
210
+ const RUN_TRANSACTION_NAME = 'runTransaction';
211
+ /**
212
+ * Keys that hold source positions or the tree's only back-edge rather than
213
+ * child nodes; `parent` would make a subtree walk non-terminating.
214
+ */
215
+ const NON_TRAVERSABLE_KEYS = new Set(['parent', 'range', 'loc', 'type']);
216
+ /** Every node of `root`'s subtree, `root` included. */
217
+ function* subtreeOf(root) {
218
+ yield root;
219
+ for (const [key, value] of Object.entries(root)) {
220
+ if (NON_TRAVERSABLE_KEYS.has(key)) {
221
+ continue;
222
+ }
223
+ if (Array.isArray(value)) {
224
+ for (const element of value) {
225
+ if (ASTHelpers_1.ASTHelpers.isNode(element)) {
226
+ yield* subtreeOf(element);
227
+ }
228
+ }
229
+ }
230
+ else if (ASTHelpers_1.ASTHelpers.isNode(value)) {
231
+ yield* subtreeOf(value);
232
+ }
233
+ }
234
+ }
235
+ /** The name a call invokes, for `f()` and for `o.f()` alike. */
236
+ function calleeName(node) {
237
+ const callee = withoutChain(node.callee);
238
+ if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
239
+ return callee.name;
240
+ }
241
+ if (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
242
+ !callee.computed &&
243
+ callee.property.type === utils_1.AST_NODE_TYPES.Identifier) {
244
+ return callee.property.name;
245
+ }
246
+ return undefined;
247
+ }
248
+ /**
249
+ * Whether the node is a `runTransaction(…)` call, under any receiver:
250
+ * `db.runTransaction`, `firestore.runTransaction` and a bare imported
251
+ * `runTransaction` all open a retryable transaction.
252
+ */
253
+ function isRunTransactionCall(node) {
254
+ return (node.type === utils_1.AST_NODE_TYPES.CallExpression &&
255
+ calleeName(node) === RUN_TRANSACTION_NAME);
256
+ }
257
+ /** Whether the method opens a transaction anywhere in its own body. */
258
+ function ownsTransaction(fn) {
259
+ for (const node of subtreeOf(fn)) {
260
+ if (isRunTransactionCall(node)) {
261
+ return true;
262
+ }
263
+ }
264
+ return false;
265
+ }
266
+ /**
267
+ * Whether a type name denotes the transaction handle. The rightmost segment is
268
+ * the type's own name, so the qualified spellings — `FirebaseFirestore.
269
+ * Transaction`, `admin.firestore.Transaction` — answer alongside the bare one;
270
+ * a locally aliased import (`import { Transaction as Txn }`) answers through
271
+ * the alias set the file's imports define.
272
+ */
273
+ function namesTransaction(typeName, aliases) {
274
+ if (typeName.type === utils_1.AST_NODE_TYPES.Identifier) {
275
+ return (typeName.name === TRANSACTION_TYPE_NAME || aliases.has(typeName.name));
276
+ }
277
+ if (typeName.type === utils_1.AST_NODE_TYPES.TSQualifiedName) {
278
+ return typeName.right.name === TRANSACTION_TYPE_NAME;
279
+ }
280
+ return false;
281
+ }
282
+ /**
283
+ * Whether a declared type hands the method a transaction handle: written
284
+ * directly, as one arm of a union or intersection, or as a property of an
285
+ * object type — the shape a destructured `{ transaction }: { transaction:
286
+ * Transaction }` parameter carries.
287
+ *
288
+ * Type ARGUMENTS are deliberately not entered: `Map<string, Transaction>` or
289
+ * `Promise<Transaction>` describes a collection of handles or a handle yet to
290
+ * exist, neither of which is the attempt-scoped handle this carve-out is about.
291
+ */
292
+ function declaresTransactionType(annotation, aliases) {
293
+ if (!annotation) {
294
+ return false;
295
+ }
296
+ if (annotation.type === utils_1.AST_NODE_TYPES.TSTypeReference) {
297
+ return namesTransaction(annotation.typeName, aliases);
298
+ }
299
+ if (annotation.type === utils_1.AST_NODE_TYPES.TSUnionType ||
300
+ annotation.type === utils_1.AST_NODE_TYPES.TSIntersectionType) {
301
+ return annotation.types.some((member) => declaresTransactionType(member, aliases));
302
+ }
303
+ if (annotation.type === utils_1.AST_NODE_TYPES.TSTypeLiteral) {
304
+ return annotation.members.some((member) => member.type === utils_1.AST_NODE_TYPES.TSPropertySignature &&
305
+ declaresTransactionType(member.typeAnnotation?.typeAnnotation, aliases));
306
+ }
307
+ return false;
308
+ }
309
+ /**
310
+ * The type annotation a parameter declares, including the destructuring shapes
311
+ * `parameterAnnotation` does not reach: an object or array pattern carries its
312
+ * annotation on the pattern itself.
313
+ */
314
+ function bindingAnnotation(param) {
315
+ if (param.type === utils_1.AST_NODE_TYPES.ObjectPattern ||
316
+ param.type === utils_1.AST_NODE_TYPES.ArrayPattern) {
317
+ return param.typeAnnotation?.typeAnnotation;
318
+ }
319
+ return parameterAnnotation(param);
320
+ }
321
+ /**
322
+ * Whether the method declares a parameter typed as a transaction handle.
323
+ *
324
+ * The test reads the ANNOTATION, not the parameter's name: a parameter merely
325
+ * named `transaction` is as likely to hold a payment or a ledger entry, and the
326
+ * rule's other carve-outs (void result, callback parameter) are annotation-driven
327
+ * for the same reason. A bare `async apply(transaction)` therefore keeps
328
+ * reporting — it declares nothing to honour, and under the `noImplicitAny` its
329
+ * consumers compile with it does not type-check anyway. Where the handle arrives
330
+ * through an unresolvable alias (`args: MembershipArgs`), the call-site test
331
+ * below is what recognises it.
332
+ */
333
+ function declaresTransactionParameter(params, aliases) {
334
+ return params.some((param) => declaresTransactionType(bindingAnnotation(param), aliases));
335
+ }
336
+ /**
337
+ * The expression itself, with any `ChainExpression` wrapper removed. ESTree
338
+ * wraps a whole optional chain in that node, so `this?.body` and
339
+ * `this.body.bind?.(this)` reach a bare member/call test as something else
340
+ * entirely. Nullish spellings carry the transaction handle exactly as the plain
341
+ * ones do, and reading through the wrapper is what keeps the carve-out from
342
+ * lapsing on them — a lapse that would restore the empty-commit autofix.
343
+ */
344
+ function withoutChain(node) {
345
+ return node.type === utils_1.AST_NODE_TYPES.ChainExpression
346
+ ? withoutChain(node.expression)
347
+ : node;
348
+ }
349
+ /** The own-method name a `this.foo` reference reads, if it reads one. */
350
+ function thisMemberName(node) {
351
+ const expression = withoutChain(node);
352
+ if (expression.type === utils_1.AST_NODE_TYPES.MemberExpression &&
353
+ !expression.computed &&
354
+ withoutChain(expression.object).type === utils_1.AST_NODE_TYPES.ThisExpression &&
355
+ expression.property.type === utils_1.AST_NODE_TYPES.Identifier) {
356
+ return expression.property.name;
357
+ }
358
+ // `this.body.bind(this)` passes the same method, one wrapper out.
359
+ if (expression.type === utils_1.AST_NODE_TYPES.CallExpression &&
360
+ expression.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
361
+ !expression.callee.computed &&
362
+ expression.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
363
+ expression.callee.property.name === 'bind') {
364
+ return thisMemberName(expression.callee.object);
365
+ }
366
+ return undefined;
367
+ }
368
+ /** Whether the subtree mentions the binding, under any nesting. */
369
+ function mentionsBinding(node, name) {
370
+ for (const descendant of subtreeOf(node)) {
371
+ if (descendant.type === utils_1.AST_NODE_TYPES.Identifier &&
372
+ descendant.name === name) {
373
+ return true;
374
+ }
375
+ }
376
+ return false;
377
+ }
378
+ /**
379
+ * Own methods a `runTransaction` argument hands the attempt to: the method
380
+ * passed as the callback itself, and every `this.method(…)` the callback body
381
+ * invokes with the attempt's handle among its arguments.
382
+ *
383
+ * Passing the handle on is what makes a method part of the attempt, so a
384
+ * helper the callback calls WITHOUT it — a config read, a lookup that takes no
385
+ * transaction — is untouched and keeps reporting.
386
+ */
387
+ function collectTransactionParticipants(argument, participants) {
388
+ const passed = thisMemberName(argument);
389
+ if (passed) {
390
+ participants.add(passed);
391
+ return;
392
+ }
393
+ if (argument.type !== utils_1.AST_NODE_TYPES.ArrowFunctionExpression &&
394
+ argument.type !== utils_1.AST_NODE_TYPES.FunctionExpression) {
395
+ return;
396
+ }
397
+ const handle = argument.params[0];
398
+ if (handle?.type !== utils_1.AST_NODE_TYPES.Identifier) {
399
+ return;
400
+ }
401
+ for (const node of subtreeOf(argument.body)) {
402
+ if (node.type !== utils_1.AST_NODE_TYPES.CallExpression) {
403
+ continue;
404
+ }
405
+ const method = thisMemberName(node.callee);
406
+ if (method &&
407
+ node.arguments.some((arg) => mentionsBinding(arg, handle.name))) {
408
+ participants.add(method);
409
+ }
410
+ }
411
+ }
412
+ /** Every own method the class hands a transaction handle to. */
413
+ function transactionParticipantsOf(body) {
414
+ const participants = new Set();
415
+ for (const node of subtreeOf(body)) {
416
+ if (!isRunTransactionCall(node)) {
417
+ continue;
418
+ }
419
+ for (const argument of node.arguments) {
420
+ collectTransactionParticipants(argument, participants);
421
+ }
422
+ }
423
+ return participants;
424
+ }
425
+ /** The statically known name of a method, for matching call sites against it. */
426
+ function methodName(node) {
427
+ if (node.computed) {
428
+ return undefined;
429
+ }
430
+ const { key } = node;
431
+ if (key.type === utils_1.AST_NODE_TYPES.Identifier) {
432
+ return key.name;
433
+ }
434
+ if (key.type === utils_1.AST_NODE_TYPES.Literal && typeof key.value === 'string') {
435
+ return key.value;
436
+ }
437
+ return undefined;
438
+ }
199
439
  /**
200
440
  * Matches a memoize decorator in supported syntaxes:
201
441
  * - @Alias()
@@ -290,6 +530,66 @@ exports.enforceMemoizeAsync = (0, createRule_1.createRule)({
290
530
  }
291
531
  return memoizeImportCache;
292
532
  };
533
+ /**
534
+ * Local names bound to an imported `Transaction` type, so that an aliased
535
+ * import (`import { Transaction as Txn } from 'firebase-admin/firestore'`)
536
+ * is read as the handle it is. The module is not constrained: a handle is
537
+ * re-exported through as many paths as a codebase has layers, and the
538
+ * imported NAME already carries the signal.
539
+ */
540
+ let transactionAliasCache = null;
541
+ const transactionAliases = () => {
542
+ if (!transactionAliasCache) {
543
+ transactionAliasCache = new Set();
544
+ for (const statement of context.sourceCode.ast.body) {
545
+ if (statement.type !== utils_1.AST_NODE_TYPES.ImportDeclaration) {
546
+ continue;
547
+ }
548
+ for (const spec of statement.specifiers) {
549
+ if (spec.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
550
+ spec.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
551
+ spec.imported.name === TRANSACTION_TYPE_NAME) {
552
+ transactionAliasCache.add(spec.local.name);
553
+ }
554
+ }
555
+ }
556
+ }
557
+ return transactionAliasCache;
558
+ };
559
+ /**
560
+ * The class-level scan is shared by every method of the class, so it runs
561
+ * once per class body rather than once per candidate method.
562
+ */
563
+ const participantCache = new WeakMap();
564
+ const transactionParticipants = (body) => {
565
+ let participants = participantCache.get(body);
566
+ if (!participants) {
567
+ participants = transactionParticipantsOf(body);
568
+ participantCache.set(body, participants);
569
+ }
570
+ return participants;
571
+ };
572
+ /**
573
+ * Whether the method takes part in a database transaction attempt, either
574
+ * by opening one or by being handed the attempt's handle. Caching such a
575
+ * method is never an optimisation: a retried attempt would replay the first
576
+ * attempt's promise, writing nothing on its own handle while the caller
577
+ * reads a success it did not get.
578
+ */
579
+ const participatesInTransaction = (node, fn) => {
580
+ if (declaresTransactionParameter(fn.params, transactionAliases())) {
581
+ return true;
582
+ }
583
+ if (ownsTransaction(fn)) {
584
+ return true;
585
+ }
586
+ const body = node.parent;
587
+ if (body?.type !== utils_1.AST_NODE_TYPES.ClassBody) {
588
+ return false;
589
+ }
590
+ const name = methodName(node);
591
+ return name !== undefined && transactionParticipants(body).has(name);
592
+ };
293
593
  return {
294
594
  MethodDefinition(node) {
295
595
  // Only process async instance methods (skip static methods)
@@ -341,6 +641,17 @@ exports.enforceMemoizeAsync = (0, createRule_1.createRule)({
341
641
  if (declaresVoidResult(node.value.returnType)) {
342
642
  return;
343
643
  }
644
+ // A transaction handle is valid only for the attempt that created it,
645
+ // so a result derived from one must not outlive that attempt. Caching
646
+ // the body of a `runTransaction` callback — or the method that owns the
647
+ // call — turns the retry a concurrent write provokes into a silent
648
+ // no-op: the retry replays the first attempt's promise, queues nothing
649
+ // on its own handle, commits empty, and reports the first attempt's
650
+ // value as success. The fixer would apply that unattended under
651
+ // `--fix`, so both report and fix are withheld.
652
+ if (participatesInTransaction(node, node.value)) {
653
+ return;
654
+ }
344
655
  const { aliases: memoizeAliases, namespaces: memoizeNamespaces } = memoizeImports();
345
656
  const hasMemoizeImport = memoizeAliases.size > 0 || memoizeNamespaces.size > 0;
346
657
  // Check if method already has @Memoize or @Memoize() decorator
@@ -84,31 +84,315 @@ function isNonPrimitiveWithoutTypes(expr) {
84
84
  return false;
85
85
  }
86
86
  }
87
- // TypeScript-aware check. Defensive and conservative.
88
- function isNonPrimitiveWithTypes(context, expr) {
87
+ /**
88
+ * Layer A: what the type checker proves about a dependency expression.
89
+ *
90
+ * Reading the type at the *reference* rather than at the declaration is
91
+ * deliberate: control-flow narrowing is what makes an optional `a?: string`
92
+ * answer `string` at the site React actually compares.
93
+ */
94
+ function primitivenessByType(context, expr) {
89
95
  const services = context.parserServices;
90
96
  if (!services?.program || !services?.esTreeNodeToTSNodeMap) {
91
- return false;
97
+ return 'unproven';
92
98
  }
93
99
  try {
100
+ // Dereferenced inside the function rather than at module scope: reading
101
+ // `ts.TypeFlags` while the module is being loaded takes the whole plugin
102
+ // down wherever `typescript` is not yet resolvable (#1354).
94
103
  // eslint-disable-next-line @typescript-eslint/no-var-requires
95
104
  const ts = require('typescript');
96
105
  const checker = services.program.getTypeChecker();
97
106
  const tsNode = services.esTreeNodeToTSNodeMap.get(expr);
98
107
  if (!tsNode)
99
- return false;
100
- const type = checker.getTypeAtLocation(tsNode);
101
- // Explicitly ignore functions even though they are non-primitive
102
- if (type.getCallSignatures().length > 0)
103
- return false;
104
- // Avoid guessing for unknown/any or non-function types
105
- if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown))
106
- return false;
107
- return false;
108
+ return 'unproven';
109
+ const primitiveFlags = ts.TypeFlags.StringLike |
110
+ ts.TypeFlags.NumberLike |
111
+ ts.TypeFlags.BooleanLike |
112
+ ts.TypeFlags.BigIntLike |
113
+ ts.TypeFlags.ESSymbolLike |
114
+ ts.TypeFlags.Null |
115
+ ts.TypeFlags.Undefined |
116
+ ts.TypeFlags.Void;
117
+ // `any` and `unknown` carry no shape, and an unresolved type parameter is
118
+ // whatever its caller supplies, so none of the three is an answer.
119
+ const uninformativeFlags = ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.TypeParameter;
120
+ const classify = (candidate) => {
121
+ if (candidate.flags & uninformativeFlags)
122
+ return 'unproven';
123
+ if (candidate.isUnion()) {
124
+ const verdicts = candidate.types.map(classify);
125
+ if (verdicts.includes('unproven'))
126
+ return 'unproven';
127
+ return verdicts.every((verdict) => verdict === 'primitive')
128
+ ? 'primitive'
129
+ : 'nonPrimitive';
130
+ }
131
+ return candidate.flags & primitiveFlags ? 'primitive' : 'nonPrimitive';
132
+ };
133
+ return classify(checker.getTypeAtLocation(tsNode));
108
134
  }
109
135
  catch {
136
+ return 'unproven';
137
+ }
138
+ }
139
+ /**
140
+ * Type nodes naming a primitive outright. A `TSTypeReference` is absent by
141
+ * design: an alias resolves only through the checker, which is Layer A's job.
142
+ */
143
+ const PRIMITIVE_TYPE_KEYWORDS = new Set([
144
+ utils_1.AST_NODE_TYPES.TSStringKeyword,
145
+ utils_1.AST_NODE_TYPES.TSNumberKeyword,
146
+ utils_1.AST_NODE_TYPES.TSBooleanKeyword,
147
+ utils_1.AST_NODE_TYPES.TSBigIntKeyword,
148
+ utils_1.AST_NODE_TYPES.TSSymbolKeyword,
149
+ utils_1.AST_NODE_TYPES.TSNullKeyword,
150
+ utils_1.AST_NODE_TYPES.TSUndefinedKeyword,
151
+ utils_1.AST_NODE_TYPES.TSVoidKeyword,
152
+ ]);
153
+ function isPrimitiveLiteralNode(node) {
154
+ if (node.type === utils_1.AST_NODE_TYPES.TemplateLiteral)
155
+ return true;
156
+ if (node.type === utils_1.AST_NODE_TYPES.UnaryExpression) {
157
+ // `-1` and `+1` are literal types spelled across two nodes.
158
+ return ((node.operator === '-' || node.operator === '+') &&
159
+ isPrimitiveLiteralNode(node.argument));
160
+ }
161
+ if (node.type !== utils_1.AST_NODE_TYPES.Literal)
162
+ return false;
163
+ // A regular expression literal is an object, and its `value` is null in
164
+ // hosts that cannot construct it — the same shape a `null` literal has.
165
+ if ('regex' in node && node.regex)
166
+ return false;
167
+ const { value } = node;
168
+ return (value === null ||
169
+ typeof value === 'string' ||
170
+ typeof value === 'number' ||
171
+ typeof value === 'boolean' ||
172
+ typeof value === 'bigint');
173
+ }
174
+ function isPrimitiveTypeNode(node) {
175
+ if (PRIMITIVE_TYPE_KEYWORDS.has(node.type))
176
+ return true;
177
+ if (node.type === utils_1.AST_NODE_TYPES.TSLiteralType) {
178
+ return isPrimitiveLiteralNode(node.literal);
179
+ }
180
+ // A union is primitive only if nothing in it can carry identity, which is
181
+ // what makes `string | undefined` — the type an optional parameter has —
182
+ // answer the same as `string`.
183
+ if (node.type === utils_1.AST_NODE_TYPES.TSUnionType) {
184
+ return node.types.length > 0 && node.types.every(isPrimitiveTypeNode);
185
+ }
186
+ return false;
187
+ }
188
+ function isUseStateCallee(callee) {
189
+ if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
190
+ return callee.name === 'useState';
191
+ }
192
+ return (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
193
+ !callee.computed &&
194
+ callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
195
+ callee.property.name === 'useState');
196
+ }
197
+ /**
198
+ * Whether a `const [x] = useState(<primitive>)` binding holds a primitive.
199
+ *
200
+ * Only element 0 qualifies: element 1 is the setter, a function. The setter is
201
+ * also the only other writer of element 0, and the initial value types what it
202
+ * accepts, so the state stays whatever kind the initializer made it.
203
+ */
204
+ function isPrimitiveUseStateElement(declarator, bound) {
205
+ const { id, init } = declarator;
206
+ if (id.type !== utils_1.AST_NODE_TYPES.ArrayPattern)
110
207
  return false;
208
+ if (id.elements[0] !== bound)
209
+ return false;
210
+ if (!init || init.type !== utils_1.AST_NODE_TYPES.CallExpression)
211
+ return false;
212
+ if (!isUseStateCallee(init.callee))
213
+ return false;
214
+ // An explicit type argument overrides whatever the initial value would have
215
+ // inferred, so it answers instead of the initializer.
216
+ const typeArguments = init.typeParameters?.params;
217
+ if (typeArguments && typeArguments.length > 0) {
218
+ return typeArguments.every(isPrimitiveTypeNode);
111
219
  }
220
+ const [initial] = init.arguments;
221
+ return initial !== undefined && isPrimitiveLiteralNode(initial);
222
+ }
223
+ /**
224
+ * Layer B: primitiveness the parser alone can see.
225
+ *
226
+ * This layer exists because Layer A goes blind exactly where the shared rule
227
+ * testers run it — with no `project`, `useState` imported from react resolves
228
+ * to `any`, and so does every `lib` type. Syntax the file spells out does not
229
+ * degrade that way.
230
+ */
231
+ function isProvablyPrimitiveBinding(definition) {
232
+ const bound = definition.name;
233
+ if (bound.type !== utils_1.AST_NODE_TYPES.Identifier)
234
+ return false;
235
+ // An annotation constrains every assignment to the binding, so it answers for
236
+ // a `let` and a reassigned parameter just as it does for a `const`.
237
+ const annotation = bound.typeAnnotation?.typeAnnotation;
238
+ if (annotation)
239
+ return isPrimitiveTypeNode(annotation);
240
+ const declarator = definition.node;
241
+ const declaration = definition.parent;
242
+ if (declarator.type !== utils_1.AST_NODE_TYPES.VariableDeclarator)
243
+ return false;
244
+ // Without an annotation the initializer is the only evidence, and it only
245
+ // describes the binding for as long as nothing rebinds it.
246
+ if (!declaration ||
247
+ declaration.type !== utils_1.AST_NODE_TYPES.VariableDeclaration ||
248
+ declaration.kind !== 'const') {
249
+ return false;
250
+ }
251
+ if (declarator.id === bound) {
252
+ return declarator.init !== null && isPrimitiveLiteralNode(declarator.init);
253
+ }
254
+ return isPrimitiveUseStateElement(declarator, bound);
255
+ }
256
+ /**
257
+ * Members that exist on primitives and on nothing else in ordinary code.
258
+ *
259
+ * `length`, `slice`, `includes`, `indexOf`, `concat`, `at`, `toString` and
260
+ * `valueOf` are deliberately absent: arrays and objects carry them too, so
261
+ * vetoing on one would stop the rule seeing the array dependencies it exists
262
+ * to catch.
263
+ */
264
+ const PRIMITIVE_ONLY_MEMBERS = new Set([
265
+ 'toUpperCase',
266
+ 'toLowerCase',
267
+ 'toFixed',
268
+ 'toPrecision',
269
+ 'trim',
270
+ 'trimStart',
271
+ 'trimEnd',
272
+ 'padStart',
273
+ 'padEnd',
274
+ 'charAt',
275
+ 'charCodeAt',
276
+ 'codePointAt',
277
+ 'normalize',
278
+ 'localeCompare',
279
+ 'startsWith',
280
+ 'endsWith',
281
+ 'repeat',
282
+ 'toExponential',
283
+ ]);
284
+ /**
285
+ * The children of a node an identifier can be *referenced* from.
286
+ *
287
+ * A non-computed member's property and a non-computed key spell a name rather
288
+ * than read a binding, so counting them as occurrences would let an unrelated
289
+ * `other.trim` decide what `trim` denotes.
290
+ */
291
+ function referencedChildren(node) {
292
+ const children = [];
293
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
294
+ const record = node;
295
+ for (const key of Object.keys(record)) {
296
+ if (key === 'parent')
297
+ continue;
298
+ if (key === 'property' &&
299
+ node.type === utils_1.AST_NODE_TYPES.MemberExpression &&
300
+ !node.computed) {
301
+ continue;
302
+ }
303
+ if (key === 'key' &&
304
+ (node.type === utils_1.AST_NODE_TYPES.Property ||
305
+ node.type === utils_1.AST_NODE_TYPES.MethodDefinition ||
306
+ node.type === utils_1.AST_NODE_TYPES.PropertyDefinition) &&
307
+ !node.computed) {
308
+ continue;
309
+ }
310
+ const child = record[key];
311
+ if (!child)
312
+ continue;
313
+ if (Array.isArray(child)) {
314
+ for (const element of child) {
315
+ if (element && typeof element === 'object' && 'type' in element) {
316
+ children.push(element);
317
+ }
318
+ }
319
+ }
320
+ else if (typeof child === 'object' && 'type' in child) {
321
+ children.push(child);
322
+ }
323
+ }
324
+ return children;
325
+ }
326
+ /**
327
+ * Layer C: every read of the name inside the callback is a member that only a
328
+ * primitive has.
329
+ *
330
+ * The weakest of the three layers, and the only one that reaches a binding the
331
+ * file gives no annotation and no initializer for — `const { asPath } =
332
+ * useRouter()` is the shape the consumer's hooks are written in. It answers
333
+ * only when *every* occurrence agrees: a computed access, a spread, or the bare
334
+ * name passed along says nothing about the value's shape, and one such
335
+ * occurrence withdraws the guess.
336
+ */
337
+ function readsOnlyPrimitiveMembers(callback, name) {
338
+ let accesses = 0;
339
+ let proven = true;
340
+ const visit = (node) => {
341
+ if (!proven)
342
+ return;
343
+ if (node.type === utils_1.AST_NODE_TYPES.MemberExpression &&
344
+ node.object.type === utils_1.AST_NODE_TYPES.Identifier &&
345
+ node.object.name === name) {
346
+ if (node.computed ||
347
+ node.property.type !== utils_1.AST_NODE_TYPES.Identifier ||
348
+ !PRIMITIVE_ONLY_MEMBERS.has(node.property.name)) {
349
+ proven = false;
350
+ return;
351
+ }
352
+ accesses += 1;
353
+ return;
354
+ }
355
+ if (node.type === utils_1.AST_NODE_TYPES.Identifier && node.name === name) {
356
+ proven = false;
357
+ return;
358
+ }
359
+ for (const child of referencedChildren(node)) {
360
+ visit(child);
361
+ }
362
+ };
363
+ visit(callback.body);
364
+ return proven && accesses > 0;
365
+ }
366
+ /**
367
+ * The one binding a name denotes at a call site, or null when the answer is not
368
+ * a single declaration. A name with several definitions — a redeclared `var`, a
369
+ * merged declaration — describes more than one thing, and no one of them speaks
370
+ * for the reference.
371
+ */
372
+ function soleDefinitionOf(context, node, name) {
373
+ const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, node), name);
374
+ if (!variable || variable.defs.length !== 1)
375
+ return null;
376
+ return variable.defs[0];
377
+ }
378
+ /**
379
+ * Whether a dependency is *provably* a primitive, across all three layers.
380
+ *
381
+ * The direction is asymmetric on purpose. A missed deep comparison costs a
382
+ * recomputation; a deep comparison wrapped around a `string` costs an injected
383
+ * dependency, a new import and a hook that cannot help, so nothing short of a
384
+ * proof promotes a dependency here (#1979).
385
+ */
386
+ function isProvablyPrimitiveDependency(context, call, callback, dep) {
387
+ const verdict = primitivenessByType(context, dep);
388
+ if (verdict === 'primitive')
389
+ return true;
390
+ const definition = soleDefinitionOf(context, call, dep.name);
391
+ if (definition && isProvablyPrimitiveBinding(definition))
392
+ return true;
393
+ // Method names are evidence only where nothing better exists: a checker that
394
+ // resolved the type has already answered, and answered from more than a name.
395
+ return (verdict === 'unproven' && readsOnlyPrimitiveMembers(callback, dep.name));
112
396
  }
113
397
  function collectMemoizedIdentifiers(context) {
114
398
  const memoized = new Set();
@@ -401,12 +685,8 @@ exports.preferUseDeepCompareMemo = (0, createRule_1.createRule)({
401
685
  if (el.type === utils_1.AST_NODE_TYPES.SpreadElement)
402
686
  continue;
403
687
  const expr = el;
404
- // TS-aware check first
405
- let isNonPrimitive = isNonPrimitiveWithTypes(context, expr);
406
- // Fallback heuristic without type info for literals/arrays/objects/functions
407
- if (!isNonPrimitive) {
408
- isNonPrimitive = isNonPrimitiveWithoutTypes(expr);
409
- }
688
+ // Syntactic classification for literals/arrays/objects/functions
689
+ let isNonPrimitive = isNonPrimitiveWithoutTypes(expr);
410
690
  // Identifier-specific heuristic: consider non-primitive only if used as object or function in callback
411
691
  if (!isNonPrimitive && expr.type === utils_1.AST_NODE_TYPES.Identifier) {
412
692
  // Imported identifiers are treated as stable
@@ -414,7 +694,15 @@ exports.preferUseDeepCompareMemo = (0, createRule_1.createRule)({
414
694
  isNonPrimitive = false;
415
695
  }
416
696
  else if (identifierUsedAsObjectOrArray(callback, expr.name) &&
417
- !isIdentifierMemoizedAbove(expr.name, memoizedIds)) {
697
+ !isIdentifierMemoizedAbove(expr.name, memoizedIds) &&
698
+ // Reading a member off a name proves the receiver has members,
699
+ // which every primitive also has: `slug.toUpperCase()` and
700
+ // `cfg.a` are the same shape. The promotion therefore stands only
701
+ // while the receiver is not provably a primitive — a bare
702
+ // Identifier reaches this branch through no other signal, so the
703
+ // veto can withhold nothing the rule established some other way
704
+ // (#1979).
705
+ !isProvablyPrimitiveDependency(context, node, callback, expr)) {
418
706
  isNonPrimitive = true;
419
707
  }
420
708
  }
@@ -103,33 +103,205 @@ function aliasDeclarationNamed(statement, name) {
103
103
  : undefined;
104
104
  }
105
105
  /**
106
- * Lexical alias lookup over pre-collected statement lists, innermost first, so
107
- * an inner declaration shadows a same-named outer one.
106
+ * The string members of `const NAME = ['a', 'b'] as const`, or `undefined` when
107
+ * the statement declares something else or an element is not a string literal.
108
+ *
109
+ * `prefer-union-from-const-array` rewrites a string-literal union alias into
110
+ * exactly this pair of declarations plus `(typeof NAME)[number]`, so a keep-list
111
+ * that reads decidable before that transform has to stay decidable after it.
112
+ */
113
+ function constArrayMembersNamed(statement, name) {
114
+ const declared = (0, lexicalScope_1.declarationOf)(statement);
115
+ if (declared.type !== utils_1.AST_NODE_TYPES.VariableDeclaration ||
116
+ declared.kind !== 'const') {
117
+ return undefined;
118
+ }
119
+ for (const declarator of declared.declarations) {
120
+ if (declarator.id.type !== utils_1.AST_NODE_TYPES.Identifier ||
121
+ declarator.id.name !== name ||
122
+ declarator.init?.type !== utils_1.AST_NODE_TYPES.TSAsExpression) {
123
+ continue;
124
+ }
125
+ const { expression, typeAnnotation } = declarator.init;
126
+ // Without `as const` the elements widen to `string`, and the indexed access
127
+ // yields `string` rather than a union of the literals.
128
+ const isConstAssertion = typeAnnotation.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
129
+ typeAnnotation.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
130
+ typeAnnotation.typeName.name === 'const';
131
+ if (!isConstAssertion || expression.type !== utils_1.AST_NODE_TYPES.ArrayExpression)
132
+ return undefined;
133
+ const members = [];
134
+ for (const element of expression.elements) {
135
+ if (element?.type !== utils_1.AST_NODE_TYPES.Literal ||
136
+ typeof element.value !== 'string') {
137
+ return undefined;
138
+ }
139
+ members.push(element.value);
140
+ }
141
+ return members;
142
+ }
143
+ return undefined;
144
+ }
145
+ /**
146
+ * Lexical lookup over pre-collected statement lists, innermost first, so an
147
+ * inner declaration shadows a same-named outer one.
108
148
  *
109
149
  * Resolution is by scope rather than by a map built during traversal because
110
150
  * type aliases hoist: a component declared above its own props alias must still
111
151
  * resolve it.
112
152
  */
113
153
  function aliasResolverFrom(lists, startIndex = 0) {
114
- return (name) => {
115
- for (let index = startIndex; index < lists.length; index += 1) {
116
- for (const statement of lists[index]) {
117
- const declaration = aliasDeclarationNamed(statement, name);
118
- if (declaration) {
119
- return {
120
- typeNode: declaration.typeAnnotation,
121
- resolve: aliasResolverFrom(lists, index),
122
- };
154
+ return {
155
+ typeAlias(name) {
156
+ for (let index = startIndex; index < lists.length; index += 1) {
157
+ for (const statement of lists[index]) {
158
+ const declaration = aliasDeclarationNamed(statement, name);
159
+ if (declaration) {
160
+ return {
161
+ typeNode: declaration.typeAnnotation,
162
+ resolve: aliasResolverFrom(lists, index),
163
+ };
164
+ }
123
165
  }
124
166
  }
125
- }
126
- return undefined;
167
+ return undefined;
168
+ },
169
+ constArrayKeys(name) {
170
+ for (let index = startIndex; index < lists.length; index += 1) {
171
+ for (const statement of lists[index]) {
172
+ const members = constArrayMembersNamed(statement, name);
173
+ if (members)
174
+ return members;
175
+ }
176
+ }
177
+ return undefined;
178
+ },
127
179
  };
128
180
  }
129
181
  function aliasResolverAt(from) {
130
182
  return aliasResolverFrom((0, lexicalScope_1.enclosingStatementLists)(from));
131
183
  }
132
- function typeNodeExcludesProperty(node, propertyName, resolveAlias, seen = new Set()) {
184
+ /**
185
+ * The property names a `Pick<T, K>` keep-list names, or `null` when the list is
186
+ * not decidable from syntax alone.
187
+ *
188
+ * `null` means "prove nothing", never "keeps nothing": a keep-list spelled as a
189
+ * type parameter, `keyof T`, `string`, or a template-literal type can include
190
+ * `children`, and treating an undecidable list as an empty one would exempt
191
+ * exactly those.
192
+ */
193
+ function stringLiteralKeySet(node, resolveAlias, seen = new Set()) {
194
+ if (node.type === utils_1.AST_NODE_TYPES.TSLiteralType) {
195
+ return node.literal.type === utils_1.AST_NODE_TYPES.Literal &&
196
+ typeof node.literal.value === 'string'
197
+ ? new Set([node.literal.value])
198
+ : null;
199
+ }
200
+ if (node.type === utils_1.AST_NODE_TYPES.TSUnionType) {
201
+ const keys = new Set();
202
+ for (const member of node.types) {
203
+ const memberKeys = stringLiteralKeySet(member, resolveAlias, seen);
204
+ if (!memberKeys)
205
+ return null;
206
+ for (const key of memberKeys) {
207
+ keys.add(key);
208
+ }
209
+ }
210
+ return keys;
211
+ }
212
+ // `(typeof KEYS)[number]` over a `const KEYS = [...] as const` names exactly
213
+ // the array's members — the spelling `prefer-union-from-const-array` rewrites
214
+ // a literal union alias into.
215
+ if (node.type === utils_1.AST_NODE_TYPES.TSIndexedAccessType &&
216
+ node.indexType.type === utils_1.AST_NODE_TYPES.TSNumberKeyword &&
217
+ node.objectType.type === utils_1.AST_NODE_TYPES.TSTypeQuery &&
218
+ node.objectType.exprName.type === utils_1.AST_NODE_TYPES.Identifier) {
219
+ const members = resolveAlias?.constArrayKeys(node.objectType.exprName.name);
220
+ return members ? new Set(members) : null;
221
+ }
222
+ // A bare alias standing for a literal union (`type Keys = 'sx' | 'size'`) is
223
+ // as decidable as the union written inline. A parameterized reference is not:
224
+ // it is a computed key list whose members this rule cannot enumerate.
225
+ if (node.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
226
+ node.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
227
+ !node.typeParameters) {
228
+ const name = node.typeName.name;
229
+ if (!resolveAlias || seen.has(name))
230
+ return null;
231
+ const alias = resolveAlias.typeAlias(name);
232
+ if (!alias)
233
+ return null;
234
+ // Guarding before the recursion terminates a self-referential alias.
235
+ seen.add(name);
236
+ return stringLiteralKeySet(alias.typeNode, alias.resolve, seen);
237
+ }
238
+ return null;
239
+ }
240
+ /**
241
+ * Whether a closed object type provably declares no `propertyName` member.
242
+ *
243
+ * An index signature reopens the type — `{ [key: string]: unknown }` admits
244
+ * `children` — and so does a computed key the rule cannot evaluate, since the
245
+ * constant behind `[KEY]` may well be `'children'`. Both surrender the proof.
246
+ */
247
+ function typeLiteralExcludesProperty(node, propertyName) {
248
+ return node.members.every((member) => {
249
+ if (member.type === utils_1.AST_NODE_TYPES.TSIndexSignature)
250
+ return false;
251
+ if (member.type !== utils_1.AST_NODE_TYPES.TSPropertySignature &&
252
+ member.type !== utils_1.AST_NODE_TYPES.TSMethodSignature) {
253
+ // Call and construct signatures declare no named member.
254
+ return true;
255
+ }
256
+ const key = member.key;
257
+ if (member.computed) {
258
+ return (key.type === utils_1.AST_NODE_TYPES.Literal &&
259
+ String(key.value) !== propertyName);
260
+ }
261
+ if (key.type === utils_1.AST_NODE_TYPES.Identifier) {
262
+ return key.name !== propertyName;
263
+ }
264
+ if (key.type === utils_1.AST_NODE_TYPES.Literal) {
265
+ return String(key.value) !== propertyName;
266
+ }
267
+ return false;
268
+ });
269
+ }
270
+ /**
271
+ * Generic wrappers that re-map a type's own members without contributing any of
272
+ * their own, so their argument still stands for the whole props type.
273
+ *
274
+ * `PropsWithChildren` is the counter-example the list exists for: it ADDS
275
+ * `children`, so a proof about its argument says nothing about the wrapper.
276
+ */
277
+ const PROPERTY_PRESERVING_WRAPPERS = new Set([
278
+ 'Readonly',
279
+ 'Required',
280
+ 'Partial',
281
+ 'NonNullable',
282
+ ]);
283
+ function wrapperNameOf(typeName) {
284
+ if (typeName.type === utils_1.AST_NODE_TYPES.Identifier)
285
+ return typeName.name;
286
+ if (typeName.type === utils_1.AST_NODE_TYPES.TSQualifiedName) {
287
+ return typeName.right.type === utils_1.AST_NODE_TYPES.Identifier
288
+ ? typeName.right.name
289
+ : null;
290
+ }
291
+ return null;
292
+ }
293
+ /**
294
+ * Whether a type node provably lacks `propertyName`.
295
+ *
296
+ * `closedLiteralCounts` records whether this position IS the props type rather
297
+ * than merely a part of it. Two of the proofs below — a `Pick<>` keep-list and
298
+ * a closed object literal — describe the type they sit on, so they only carry
299
+ * to the binding when nothing can add members on the way back out. The blanket
300
+ * recursion through a type reference's arguments is where that distinction
301
+ * bites: `PropsWithChildren<{ sx?: string }>` and `Record<string, { a: number }>`
302
+ * both wrap a children-free argument in something that admits `children`.
303
+ */
304
+ function typeNodeExcludesProperty(node, propertyName, resolveAlias, seen = new Set(), closedLiteralCounts = false) {
133
305
  if (node.type === utils_1.AST_NODE_TYPES.TSTypeReference) {
134
306
  const typeName = node.typeName.type === utils_1.AST_NODE_TYPES.Identifier
135
307
  ? node.typeName.name
@@ -141,34 +313,62 @@ function typeNodeExcludesProperty(node, propertyName, resolveAlias, seen = new S
141
313
  return true;
142
314
  }
143
315
  }
316
+ // A keep-list is a stronger guarantee than an omit-list: `Pick` drops every
317
+ // member it does not name, so a fully decidable list without `propertyName`
318
+ // excludes it outright.
319
+ if (closedLiteralCounts &&
320
+ typeName === 'Pick' &&
321
+ node.typeParameters?.params?.[1]) {
322
+ const kept = stringLiteralKeySet(node.typeParameters.params[1], resolveAlias);
323
+ if (kept && !kept.has(propertyName)) {
324
+ return true;
325
+ }
326
+ }
144
327
  if (node.typeParameters?.params) {
145
- return node.typeParameters.params.some((param) => typeNodeExcludesProperty(param, propertyName, resolveAlias, seen));
328
+ const argumentsAreTheType = closedLiteralCounts &&
329
+ PROPERTY_PRESERVING_WRAPPERS.has(wrapperNameOf(node.typeName) ?? '');
330
+ return node.typeParameters.params.some((param) => typeNodeExcludesProperty(param, propertyName, resolveAlias, seen, argumentsAreTheType));
146
331
  }
147
- if (typeName && resolveAlias && !seen.has(typeName)) {
148
- const alias = resolveAlias(typeName);
332
+ // Keyed by position as well as by name: the same alias proves different
333
+ // things in a props position than inside an arbitrary generic, so a visit
334
+ // in one position must not suppress the visit in the other.
335
+ const seenKey = `${closedLiteralCounts ? 'props' : 'part'}:${typeName}`;
336
+ if (typeName && resolveAlias && !seen.has(seenKey)) {
337
+ const alias = resolveAlias.typeAlias(typeName);
149
338
  if (alias) {
150
339
  // Guarding before the recursion terminates a self-referential alias.
151
- seen.add(typeName);
152
- if (typeNodeExcludesProperty(alias.typeNode, propertyName, alias.resolve, seen)) {
340
+ seen.add(seenKey);
341
+ if (typeNodeExcludesProperty(alias.typeNode, propertyName, alias.resolve, seen, closedLiteralCounts)) {
153
342
  return true;
154
343
  }
155
344
  }
156
345
  }
157
346
  }
347
+ if (node.type === utils_1.AST_NODE_TYPES.TSTypeLiteral) {
348
+ return (closedLiteralCounts && typeLiteralExcludesProperty(node, propertyName));
349
+ }
158
350
  if (node.type === utils_1.AST_NODE_TYPES.TSUnionType) {
159
- return node.types.every((typeNode) => typeNodeExcludesProperty(typeNode, propertyName, resolveAlias, seen));
351
+ return node.types.every((typeNode) => typeNodeExcludesProperty(typeNode, propertyName, resolveAlias, seen, closedLiteralCounts));
160
352
  }
161
353
  if (node.type === utils_1.AST_NODE_TYPES.TSIntersectionType) {
162
- return node.types.every((typeNode) => typeNodeExcludesProperty(typeNode, propertyName, resolveAlias, seen));
354
+ return node.types.every((typeNode) => typeNodeExcludesProperty(typeNode, propertyName, resolveAlias, seen, closedLiteralCounts));
163
355
  }
164
356
  return false;
165
357
  }
166
- function typeAnnotationExcludesProperty(annotation, propertyName, resolveAlias) {
167
- if (!annotation)
358
+ /**
359
+ * Entry point for a type node that stands for a whole props type, whether it
360
+ * was written as a parameter annotation or supplied as a `forwardRef` type
361
+ * argument.
362
+ */
363
+ function propsTypeNodeExcludesProperty(typeNode, propertyName, resolveAlias) {
364
+ if (!typeNode)
168
365
  return false;
169
- return typeNodeExcludesProperty(annotation.typeAnnotation, propertyName, resolveAlias);
366
+ return typeNodeExcludesProperty(typeNode, propertyName, resolveAlias, new Set(), true);
367
+ }
368
+ function typeAnnotationExcludesProperty(annotation, propertyName, resolveAlias) {
369
+ return propsTypeNodeExcludesProperty(annotation?.typeAnnotation, propertyName, resolveAlias);
170
370
  }
171
- function collectRestBindingsFromPattern(pattern, ctx, annotation, resolveAlias, sourceChildrenSourceId) {
371
+ function collectRestBindingsFromPattern(pattern, ctx, typeNode, resolveAlias, sourceChildrenSourceId) {
172
372
  const childrenPresent = patternHasChildrenProperty(pattern);
173
373
  for (const prop of pattern.properties) {
174
374
  if (prop.type === utils_1.AST_NODE_TYPES.RestElement &&
@@ -177,7 +377,7 @@ function collectRestBindingsFromPattern(pattern, ctx, annotation, resolveAlias,
177
377
  ctx.bindings.set(prop.argument.name, {
178
378
  identifier: prop.argument,
179
379
  childrenExcluded: childrenPresent,
180
- typeAnnotationExcludesProperty: typeAnnotationExcludesProperty(annotation, 'children', resolveAlias),
380
+ typeAnnotationExcludesProperty: propsTypeNodeExcludesProperty(typeNode, 'children', resolveAlias),
181
381
  childrenSourceId: sourceChildrenSourceId ?? prop.argument.name,
182
382
  });
183
383
  }
@@ -212,13 +412,31 @@ function recordChildrenValueBindingsFromPattern(pattern, ctx, sourceChildrenSour
212
412
  }
213
413
  }
214
414
  }
215
- function recordParamBindings(param, ctx, resolveAlias) {
415
+ /**
416
+ * The props type a parameter carries: its own annotation when it has one, and
417
+ * otherwise the type the call site supplies contextually.
418
+ *
419
+ * An explicit annotation always wins. It is the type the body is checked
420
+ * against, so a `forwardRef<E, Clean>((props: MenuProps, ref) => …)` still
421
+ * carries whatever `MenuProps` declares.
422
+ */
423
+ function propsTypeNodeOf(param, contextualTypeNode) {
424
+ // A `TSParameterProperty` (a constructor's `private x: T`) carries its
425
+ // annotation on the parameter it wraps and never takes part in a component's
426
+ // props, so it simply has no props type of its own.
427
+ const own = param.type === utils_1.AST_NODE_TYPES.TSParameterProperty
428
+ ? undefined
429
+ : param.typeAnnotation?.typeAnnotation;
430
+ return own ?? contextualTypeNode;
431
+ }
432
+ function recordParamBindings(param, ctx, resolveAlias, contextualTypeNode) {
433
+ const typeNode = propsTypeNodeOf(param, contextualTypeNode);
216
434
  if (param.type === utils_1.AST_NODE_TYPES.Identifier) {
217
435
  ctx.propsLikeIdentifiers.add(param.name);
218
436
  ctx.bindings.set(param.name, {
219
437
  identifier: param,
220
438
  childrenExcluded: false,
221
- typeAnnotationExcludesProperty: typeAnnotationExcludesProperty(param.typeAnnotation, 'children', resolveAlias),
439
+ typeAnnotationExcludesProperty: propsTypeNodeExcludesProperty(typeNode, 'children', resolveAlias),
222
440
  childrenSourceId: param.name,
223
441
  });
224
442
  return;
@@ -229,20 +447,61 @@ function recordParamBindings(param, ctx, resolveAlias) {
229
447
  ctx.bindings.set(param.left.name, {
230
448
  identifier: param.left,
231
449
  childrenExcluded: false,
232
- typeAnnotationExcludesProperty: typeAnnotationExcludesProperty(param.typeAnnotation, 'children', resolveAlias),
450
+ typeAnnotationExcludesProperty: propsTypeNodeExcludesProperty(typeNode, 'children', resolveAlias),
233
451
  childrenSourceId: param.left.name,
234
452
  });
235
453
  return;
236
454
  }
237
455
  if (param.type === utils_1.AST_NODE_TYPES.AssignmentPattern &&
238
456
  param.left.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
239
- collectRestBindingsFromPattern(param.left, ctx, param.typeAnnotation, resolveAlias);
457
+ collectRestBindingsFromPattern(param.left, ctx, typeNode, resolveAlias);
240
458
  return;
241
459
  }
242
460
  if (param.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
243
- collectRestBindingsFromPattern(param, ctx, param.typeAnnotation, resolveAlias);
461
+ collectRestBindingsFromPattern(param, ctx, typeNode, resolveAlias);
244
462
  }
245
463
  }
464
+ /**
465
+ * The props type a `forwardRef<Element, Props>(…)` call supplies to its render
466
+ * callback, whose parameters carry no annotation of their own.
467
+ *
468
+ * Without this the props type is invisible in the `forwardRef` spelling, so
469
+ * even the documented `Omit<…, 'children'>` remedy could not be seen and the
470
+ * rule reported on props it could prove children-free in every other spelling.
471
+ */
472
+ function forwardRefPropsTypeNode(node) {
473
+ const call = node.parent;
474
+ if (call?.type !== utils_1.AST_NODE_TYPES.CallExpression)
475
+ return undefined;
476
+ if (call.arguments[0] !== node)
477
+ return undefined;
478
+ // An optional call detaches the type arguments onto a
479
+ // `TSInstantiationExpression` — `forwardRef<E, P>?.(…)` parses as
480
+ // instantiate-then-call — so both the callee and the type arguments are read
481
+ // through it. The node predates the `LeftHandSideExpression` union this
482
+ // version declares, hence the hand-written narrowing.
483
+ const rawCallee = call.callee;
484
+ const instantiation = rawCallee.type === utils_1.AST_NODE_TYPES.TSInstantiationExpression
485
+ ? rawCallee
486
+ : undefined;
487
+ const callee = instantiation?.expression ?? call.callee;
488
+ const calleeName = callee.type === utils_1.AST_NODE_TYPES.Identifier
489
+ ? callee.name
490
+ : callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
491
+ !callee.computed &&
492
+ callee.property.type === utils_1.AST_NODE_TYPES.Identifier
493
+ ? callee.property.name
494
+ : null;
495
+ if (calleeName !== 'forwardRef')
496
+ return undefined;
497
+ // `typeParameters` is where @typescript-eslint/utils 5 puts a call's type
498
+ // arguments; `typeArguments` is the name later majors use. Reading both keeps
499
+ // this working across the rename.
500
+ const instantiated = (instantiation ?? call);
501
+ const typeArguments = instantiated.typeParameters ?? instantiated.typeArguments;
502
+ // The first type argument is the element type; the props type is the second.
503
+ return typeArguments?.params?.[1];
504
+ }
246
505
  function findNearestComponentContext(stack) {
247
506
  for (let i = stack.length - 1; i >= 0; i -= 1) {
248
507
  if (stack[i].isComponent)
@@ -460,9 +719,12 @@ exports.preventChildrenClobber = (0, createRule_1.createRule)({
460
719
  // ESLint has assigned `parent` up the chain by the time this visitor
461
720
  // runs, but not yet onto the parameter's own type annotation.
462
721
  const resolveAlias = aliasResolverAt(node);
463
- for (const param of node.params) {
464
- recordParamBindings(param, ctx, resolveAlias);
465
- }
722
+ // Only the first parameter receives the contextual props type; the
723
+ // second is the forwarded ref.
724
+ const contextualPropsType = forwardRefPropsTypeNode(node);
725
+ node.params.forEach((param, index) => {
726
+ recordParamBindings(param, ctx, resolveAlias, index === 0 ? contextualPropsType : undefined);
727
+ });
466
728
  }
467
729
  functionStack.push(ctx);
468
730
  },
@@ -528,7 +790,7 @@ exports.preventChildrenClobber = (0, createRule_1.createRule)({
528
790
  const initBinding = findBinding(init.name, functionStack);
529
791
  const sourceChildrenSourceId = initBinding?.childrenSourceId ?? init.name;
530
792
  recordChildrenValueBindingsFromPattern(id, componentCtx, sourceChildrenSourceId);
531
- collectRestBindingsFromPattern(id, componentCtx, id.typeAnnotation ?? null, aliasResolverAt(node), sourceChildrenSourceId);
793
+ collectRestBindingsFromPattern(id, componentCtx, id.typeAnnotation?.typeAnnotation ?? null, aliasResolverAt(node), sourceChildrenSourceId);
532
794
  }
533
795
  },
534
796
  JSXElement(node) {
@@ -422,6 +422,109 @@ function collectReferencedNames(node) {
422
422
  visit(node, null);
423
423
  return names;
424
424
  }
425
+ // Type-space shells whose contents are erased at runtime. A reference inside
426
+ // one (`const x: ReturnType<typeof buildHit> = …`) never reads the binding at
427
+ // module evaluation, so it must not count as an eager reference — counting it
428
+ // would withhold autofixes that are perfectly safe to apply.
429
+ const TYPE_SPACE_NODE_TYPES = new Set([
430
+ 'TSTypeAnnotation',
431
+ 'TSTypeParameterInstantiation',
432
+ 'TSTypeParameterDeclaration',
433
+ 'TSTypeAliasDeclaration',
434
+ 'TSInterfaceDeclaration',
435
+ 'TSTypeQuery',
436
+ 'TSDeclareFunction',
437
+ ]);
438
+ // Names a statement reads at MODULE EVALUATION time. A read inside a function
439
+ // body is deferred to call time and constrains nothing — except when that
440
+ // function is an immediately invoked callee, which runs during evaluation. The
441
+ // distinction is the whole point of this rule: callers referencing helpers
442
+ // inside their bodies is the safe pattern being enforced, while an initializer
443
+ // calling a helper runs the moment the module loads.
444
+ function collectEagerlyReferencedNames(node) {
445
+ const names = new Set();
446
+ const visit = (current, parent) => {
447
+ if (!current || !ASTHelpers_1.ASTHelpers.isNode(current)) {
448
+ return;
449
+ }
450
+ if (TYPE_SPACE_NODE_TYPES.has(current.type)) {
451
+ return;
452
+ }
453
+ // An `as`/`satisfies` wrapper evaluates only its expression; the type side
454
+ // is erased.
455
+ if (current.type === 'TSAsExpression' ||
456
+ current.type === 'TSSatisfiesExpression') {
457
+ visit(current.expression, current);
458
+ return;
459
+ }
460
+ if (current.type === 'FunctionDeclaration' ||
461
+ current.type === 'FunctionExpression' ||
462
+ current.type === 'ArrowFunctionExpression') {
463
+ // Deferred to call time — unless this function is the callee of the call
464
+ // being visited, which the CallExpression case enters directly.
465
+ return;
466
+ }
467
+ if (current.type === 'CallExpression' &&
468
+ (current.callee.type === 'ArrowFunctionExpression' ||
469
+ current.callee.type === 'FunctionExpression')) {
470
+ // An IIFE's body runs during module evaluation, so its reads are eager.
471
+ visit(current.callee.body, current.callee);
472
+ current.callee.params.forEach((param) => visit(param, current.callee));
473
+ current.arguments.forEach((argument) => visit(argument, current));
474
+ return;
475
+ }
476
+ if (current.type === 'ClassDeclaration' ||
477
+ current.type === 'ClassExpression') {
478
+ // A class's heritage clause, computed member keys, static initializers,
479
+ // and static blocks all run at evaluation; method bodies and instance
480
+ // property values wait for construction.
481
+ visit(current.superClass, current);
482
+ current.body.body.forEach((member) => {
483
+ if (member.type === 'StaticBlock') {
484
+ member.body.forEach((statement) => visit(statement, member));
485
+ return;
486
+ }
487
+ if ('computed' in member && member.computed && 'key' in member) {
488
+ visit(member.key, member);
489
+ }
490
+ if (member.type === 'PropertyDefinition' &&
491
+ member.static &&
492
+ member.value) {
493
+ visit(member.value, member);
494
+ }
495
+ });
496
+ return;
497
+ }
498
+ if (current.type === 'Identifier') {
499
+ const isMemberProperty = parent?.type === 'MemberExpression' &&
500
+ parent.property === current &&
501
+ !parent.computed;
502
+ const isObjectKey = parent?.type === 'Property' &&
503
+ parent.key === current &&
504
+ !parent.computed;
505
+ if (!isMemberProperty && !isObjectKey) {
506
+ names.add(current.name);
507
+ }
508
+ }
509
+ Object.values(current).forEach((value) => {
510
+ if (!value || value === current || current.parent === value) {
511
+ return;
512
+ }
513
+ if (Array.isArray(value)) {
514
+ value.forEach((child) => {
515
+ if (ASTHelpers_1.ASTHelpers.isNode(child)) {
516
+ visit(child, current);
517
+ }
518
+ });
519
+ }
520
+ else if (ASTHelpers_1.ASTHelpers.isNode(value)) {
521
+ visit(value, current);
522
+ }
523
+ });
524
+ };
525
+ visit(node, null);
526
+ return names;
527
+ }
425
528
  // Names bound by an interleaved VALUE declaration (const/let/var) — but not by a
426
529
  // reorderable function. Hoisting a function above a value binding it references
427
530
  // is a genuine runtime declare-before-use. Type aliases are tracked separately
@@ -548,6 +651,50 @@ function reorderHoistsFunctionAboveDependency(regionStatements, functionStatemen
548
651
  }
549
652
  return false;
550
653
  }
654
+ // Decline (return true) any reorder that would place a const/let/var-declared
655
+ // function BELOW an interleaved statement that reads it at module evaluation
656
+ // time. The fixer pins interleaved statements and swaps functions among their
657
+ // own slots, so a helper `const` can be carried past its own module-scope
658
+ // caller (`const CHAMPION = buildHit(…)`) — the emitted file parses and
659
+ // type-checks but throws `ReferenceError: Cannot access '…' before
660
+ // initialization` the moment anything imports it, and `--fix` reports nothing
661
+ // about the file it just broke. Function declarations hoist, so demoting one
662
+ // past an eager reference stays loadable and is not declined. This is the
663
+ // mirror of reorderHoistsFunctionAboveDependency: that guard protects a moved
664
+ // function from losing a dependency ABOVE it; this one protects a pinned
665
+ // statement from losing its dependency BELOW it.
666
+ function reorderDemotesDeclarationBelowEagerReference(regionStatements, functionStatements, expectedOrderInfos) {
667
+ const variableDeclaredNames = new Set();
668
+ expectedOrderInfos.forEach((info) => {
669
+ const declaration = info.statementNode.type === 'ExportNamedDeclaration'
670
+ ? info.statementNode.declaration
671
+ : info.statementNode;
672
+ if (declaration?.type === 'VariableDeclaration') {
673
+ variableDeclaredNames.add(info.name);
674
+ }
675
+ });
676
+ if (variableDeclaredNames.size === 0) {
677
+ return false;
678
+ }
679
+ const placedNames = new Set();
680
+ let slotCursor = 0;
681
+ for (const statement of regionStatements) {
682
+ if (functionStatements.has(statement)) {
683
+ const occupant = expectedOrderInfos[slotCursor];
684
+ slotCursor += 1;
685
+ if (occupant) {
686
+ placedNames.add(occupant.name);
687
+ }
688
+ continue;
689
+ }
690
+ for (const name of collectEagerlyReferencedNames(statement)) {
691
+ if (variableDeclaredNames.has(name) && !placedNames.has(name)) {
692
+ return true;
693
+ }
694
+ }
695
+ }
696
+ return false;
697
+ }
551
698
  exports.verticallyGroupRelatedFunctions = (0, createRule_1.createRule)({
552
699
  name: 'vertically-group-related-functions',
553
700
  meta: {
@@ -729,6 +876,13 @@ exports.verticallyGroupRelatedFunctions = (0, createRule_1.createRule)({
729
876
  if (reorderHoistsFunctionAboveDependency(slice, functionStatements, expectedOrderInfos)) {
730
877
  return null;
731
878
  }
879
+ // The mirror hazard: a const-declared helper carried below a pinned
880
+ // statement that calls it at module evaluation. The emitted file
881
+ // would throw at import time, so the rewrite is withheld; the
882
+ // misorderedFunction report still fires.
883
+ if (reorderDemotesDeclarationBelowEagerReference(slice, functionStatements, expectedOrderInfos)) {
884
+ return null;
885
+ }
732
886
  if (!blockContainsOnlyFunctions) {
733
887
  // Real modules interleave type aliases, consts, and top-level
734
888
  // calls (e.g. `void autoRunIfMain();`) between functions. Rather
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.145",
3
+ "version": "1.20.146",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,42 @@
1
1
  [
2
+ {
3
+ "version": "1.20.146",
4
+ "date": "2026-08-13T00:14:52.842Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-memoize-async",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1975
11
+ ],
12
+ "summary": "exempt methods taking part in a transaction (closes #1975)"
13
+ },
14
+ {
15
+ "name": "prefer-use-deep-compare-memo",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1979
19
+ ],
20
+ "summary": "stop promoting primitive deps via member access (closes #1979)"
21
+ },
22
+ {
23
+ "name": "prevent-children-clobber",
24
+ "changeType": "fix",
25
+ "issues": [
26
+ 1980
27
+ ],
28
+ "summary": "widen the syntactic exemption beyond Omit<> (closes #1980)"
29
+ },
30
+ {
31
+ "name": "vertically-group-related-functions",
32
+ "changeType": "fix",
33
+ "issues": [
34
+ 1983
35
+ ],
36
+ "summary": "decline reorders that demote a helper below its module-scope caller (closes #1983)"
37
+ }
38
+ ]
39
+ },
2
40
  {
3
41
  "version": "1.20.145",
4
42
  "date": "2026-08-12T16:05:12.174Z",