@blumintinc/eslint-plugin-blumint 1.20.149 → 1.20.151

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.149',
226
+ version: '1.20.151',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -65,6 +65,42 @@ function unwrapComponentFunction(node, resolveComponent, isWrapped = false, seen
65
65
  }
66
66
  return undefined;
67
67
  }
68
+ /**
69
+ * Resolves the component a default export ships.
70
+ *
71
+ * `require-memo` cannot rewrite `export default function Banner(props: P)` in
72
+ * place, since `export default const Banner = memo(...)` is a syntax error, so
73
+ * it splits the declaration from the export instead:
74
+ *
75
+ * ```
76
+ * const Banner = memo(function BannerUnmemoized(props: P) {...});
77
+ * export default Banner;
78
+ * ```
79
+ *
80
+ * The exported expression is then a bare identifier, and the component it
81
+ * stands for is one hop away on a declaration carrying no `export` of its own —
82
+ * invisible to every other visitor here.
83
+ *
84
+ * Following a bare identifier stays confined to the default export, where the
85
+ * named declaration IS the component this module ships. The named form
86
+ * `export const Banner = Other` re-exports a value whose props are the other
87
+ * declaration's concern, and an identifier belonging to another module resolves
88
+ * to nothing either way.
89
+ */
90
+ function unwrapDefaultExportComponent(declaration, resolveComponent) {
91
+ if (declaration.type !== utils_1.AST_NODE_TYPES.Identifier) {
92
+ return unwrapComponentFunction(declaration, resolveComponent);
93
+ }
94
+ // The exported name counts as already followed, so a declaration naming
95
+ // itself (`const Banner = memo(Banner)`) terminates on the hop back.
96
+ const component = unwrapComponentFunction(resolveComponent(declaration.name), resolveComponent, false, new Set([declaration.name]));
97
+ if (!component)
98
+ return undefined;
99
+ return {
100
+ ...component,
101
+ resolvedName: component.resolvedName ?? declaration.name,
102
+ };
103
+ }
68
104
  exports.enforceExportedFunctionTypes = (0, createRule_1.createRule)({
69
105
  name: 'enforce-exported-function-types',
70
106
  meta: {
@@ -732,9 +768,10 @@ exports.enforceExportedFunctionTypes = (0, createRule_1.createRule)({
732
768
  },
733
769
  // `export default memo(function Banner(props: P) {...})` mirrors the
734
770
  // `export default function Banner(props: P)` form the declaration
735
- // visitors already cover.
771
+ // visitors already cover, as does `export default Banner` naming either
772
+ // of them on a separate declaration.
736
773
  ExportDefaultDeclaration(node) {
737
- const component = unwrapComponentFunction(node.declaration, findModuleScopeDeclaration);
774
+ const component = unwrapDefaultExportComponent(node.declaration, findModuleScopeDeclaration);
738
775
  if (!component)
739
776
  return;
740
777
  // The binding a wrapper argument named outranks the inner function's
@@ -748,35 +785,6 @@ exports.enforceExportedFunctionTypes = (0, createRule_1.createRule)({
748
785
  return;
749
786
  checkComponentProps(component.fn);
750
787
  },
751
- // Skip type checking for React components since we handle them separately
752
- 'FunctionDeclaration[id.name=/^[A-Z]/] > TSTypeAnnotation'() { },
753
- 'FunctionDeclaration[id.name=/^[A-Z]/] > TSTypeAnnotation > TSTypeReference'() { },
754
- 'VariableDeclarator[id.name=/^[A-Z]/] > ArrowFunctionExpression > TSTypeAnnotation'() { },
755
- 'VariableDeclarator[id.name=/^[A-Z]/] > ArrowFunctionExpression > TSTypeAnnotation > TSTypeReference'() { },
756
- 'FunctionDeclaration[id.name=/^[A-Z]/] > Identifier[typeAnnotation] > TSTypeAnnotation'() { },
757
- 'VariableDeclarator[id.name=/^[A-Z]/] > ArrowFunctionExpression > Identifier[typeAnnotation] > TSTypeAnnotation'() { },
758
- 'FunctionDeclaration[id.name=/^[A-Z]/] > Identifier[typeAnnotation] > TSTypeAnnotation > TSTypeReference'() { },
759
- 'VariableDeclarator[id.name=/^[A-Z]/] > ArrowFunctionExpression > Identifier[typeAnnotation] > TSTypeAnnotation > TSTypeReference'() { },
760
- 'FunctionDeclaration[id.name=/^[A-Z]/] > Identifier[typeAnnotation] > TSTypeAnnotation > TSTypeReference > Identifier'() { },
761
- 'VariableDeclarator[id.name=/^[A-Z]/] > ArrowFunctionExpression > Identifier[typeAnnotation] > TSTypeAnnotation > TSTypeReference > Identifier'() { },
762
- 'FunctionDeclaration[id.name=/^[A-Z]/] > Identifier[typeAnnotation] > TSTypeAnnotation > TSTypeReference > TSQualifiedName'() { },
763
- 'VariableDeclarator[id.name=/^[A-Z]/] > ArrowFunctionExpression > Identifier[typeAnnotation] > TSTypeAnnotation > TSTypeReference > TSQualifiedName'() { },
764
- 'FunctionDeclaration[id.name=/^[A-Z]/] > Identifier[typeAnnotation] > TSTypeAnnotation > TSTypeReference > TSQualifiedName > Identifier'() { },
765
- 'VariableDeclarator[id.name=/^[A-Z]/] > ArrowFunctionExpression > Identifier[typeAnnotation] > TSTypeAnnotation > TSTypeReference > TSQualifiedName > Identifier'() { },
766
- 'FunctionDeclaration[id.name=/^[A-Z]/] > Identifier[typeAnnotation] > TSTypeAnnotation > TSTypeReference > TSQualifiedName > TSQualifiedName'() { },
767
- 'VariableDeclarator[id.name=/^[A-Z]/] > ArrowFunctionExpression > Identifier[typeAnnotation] > TSTypeAnnotation > TSTypeReference > TSQualifiedName > TSQualifiedName'() { },
768
- 'FunctionDeclaration[id.name=/^[A-Z]/] > Identifier[typeAnnotation] > TSTypeAnnotation > TSTypeReference > TSQualifiedName > TSQualifiedName > Identifier'() { },
769
- 'VariableDeclarator[id.name=/^[A-Z]/] > ArrowFunctionExpression > Identifier[typeAnnotation] > TSTypeAnnotation > TSTypeReference > TSQualifiedName > TSQualifiedName > Identifier'() { },
770
- 'FunctionDeclaration[id.name=/^[A-Z]/] > Identifier[typeAnnotation] > TSTypeAnnotation > TSTypeReference > TSQualifiedName > TSQualifiedName > TSQualifiedName'() { },
771
- 'VariableDeclarator[id.name=/^[A-Z]/] > ArrowFunctionExpression > Identifier[typeAnnotation] > TSTypeAnnotation > TSTypeReference > TSQualifiedName > TSQualifiedName > TSQualifiedName'() { },
772
- 'FunctionDeclaration[id.name=/^[A-Z]/] > Identifier[typeAnnotation] > TSTypeAnnotation > TSTypeReference > TSQualifiedName > TSQualifiedName > TSQualifiedName > Identifier'() { },
773
- 'VariableDeclarator[id.name=/^[A-Z]/] > ArrowFunctionExpression > Identifier[typeAnnotation] > TSTypeAnnotation > TSTypeReference > TSQualifiedName > TSQualifiedName > TSQualifiedName > Identifier'() { },
774
- 'FunctionDeclaration[id.name=/^[A-Z]/] > Identifier[typeAnnotation] > TSTypeAnnotation > TSTypeReference > TSQualifiedName > TSQualifiedName > TSQualifiedName > TSQualifiedName'() { },
775
- 'VariableDeclarator[id.name=/^[A-Z]/] > ArrowFunctionExpression > Identifier[typeAnnotation] > TSTypeAnnotation > TSTypeReference > TSQualifiedName > TSQualifiedName > TSQualifiedName > TSQualifiedName'() { },
776
- 'FunctionDeclaration[id.name=/^[A-Z]/] > Identifier[typeAnnotation] > TSTypeAnnotation > TSTypeReference > TSQualifiedName > TSQualifiedName > TSQualifiedName > TSQualifiedName > Identifier'() { },
777
- 'VariableDeclarator[id.name=/^[A-Z]/] > ArrowFunctionExpression > Identifier[typeAnnotation] > TSTypeAnnotation > TSTypeReference > TSQualifiedName > TSQualifiedName > TSQualifiedName > TSQualifiedName > Identifier'() { },
778
- 'FunctionDeclaration[id.name=/^[A-Z]/] > Identifier[typeAnnotation] > TSTypeAnnotation > TSTypeReference > TSQualifiedName > TSQualifiedName > TSQualifiedName > TSQualifiedName > TSQualifiedName'() { },
779
- 'VariableDeclarator[id.name=/^[A-Z]/] > ArrowFunctionExpression > Identifier[typeAnnotation] > TSTypeAnnotation > TSTypeReference > TSQualifiedName > TSQualifiedName > TSQualifiedName > TSQualifiedName > TSQualifiedName'() { },
780
788
  };
781
789
  },
782
790
  });
@@ -20,7 +20,20 @@ const REFERENCE_TYPE_NAMES = new Set([
20
20
  ]);
21
21
  /**
22
22
  * The final segment of a type reference's name, so `FirebaseFirestore.
23
- * DocumentReference` is recognized as the same type as `DocumentReference`.
23
+ * DocumentReference` is read as the same name as `DocumentReference`.
24
+ */
25
+ const rightmostTypeName = (typeName) => {
26
+ if (typeName.type === utils_1.AST_NODE_TYPES.Identifier) {
27
+ return typeName.name;
28
+ }
29
+ if (typeName.type === utils_1.AST_NODE_TYPES.TSQualifiedName &&
30
+ typeName.right.type === utils_1.AST_NODE_TYPES.Identifier) {
31
+ return typeName.right.name;
32
+ }
33
+ return undefined;
34
+ };
35
+ /**
36
+ * The reference type a name states, matched on that final segment.
24
37
  *
25
38
  * The rightmost segment is the right granularity because the namespace is
26
39
  * arbitrary — `FirebaseFirestore.`, `admin.firestore.` and any
@@ -29,16 +42,69 @@ const REFERENCE_TYPE_NAMES = new Set([
29
42
  * `DocumentReference` is not a realistic collision.
30
43
  */
31
44
  const referenceTypeNameOf = (typeName) => {
32
- if (typeName.type === utils_1.AST_NODE_TYPES.Identifier) {
33
- return REFERENCE_TYPE_NAMES.has(typeName.name) ? typeName.name : undefined;
45
+ const name = rightmostTypeName(typeName);
46
+ return name && REFERENCE_TYPE_NAMES.has(name) ? name : undefined;
47
+ };
48
+ /**
49
+ * The type names an assertion can state that carry a document-shape generic.
50
+ *
51
+ * `Query` joins the three reference types here alone: `.where(...)` narrows a
52
+ * collection to it while keeping the document generic, so `as Query<User>`
53
+ * states the schema exactly as `as CollectionReference<User>` does. It is not a
54
+ * reference type the rule reports on, which is why it is not in
55
+ * `REFERENCE_TYPE_NAMES`.
56
+ */
57
+ const SCHEMA_TYPE_NAMES = new Set([...REFERENCE_TYPE_NAMES, 'Query']);
58
+ /**
59
+ * The name a type reference or an interface heritage clause states, whichever
60
+ * spelling names it, so `FirebaseFirestore.DocumentReference` and
61
+ * `DocumentReference` are read as the same type.
62
+ */
63
+ const namedTypeOf = (node) => {
64
+ if (node.type === utils_1.AST_NODE_TYPES.TSTypeReference) {
65
+ return rightmostTypeName(node.typeName);
34
66
  }
35
- if (typeName.type === utils_1.AST_NODE_TYPES.TSQualifiedName &&
36
- typeName.right.type === utils_1.AST_NODE_TYPES.Identifier &&
37
- REFERENCE_TYPE_NAMES.has(typeName.right.name)) {
38
- return typeName.right.name;
67
+ if (node.type === utils_1.AST_NODE_TYPES.TSInterfaceHeritage) {
68
+ const { expression } = node;
69
+ if (expression.type === utils_1.AST_NODE_TYPES.Identifier) {
70
+ return expression.name;
71
+ }
72
+ if (expression.type === utils_1.AST_NODE_TYPES.MemberExpression &&
73
+ expression.property.type === utils_1.AST_NODE_TYPES.Identifier) {
74
+ return expression.property.name;
75
+ }
39
76
  }
40
77
  return undefined;
41
78
  };
79
+ /**
80
+ * The child nodes of a node, read generically so that every type syntax — an
81
+ * array, a union, a tuple, a mapped or conditional type — is traversed without
82
+ * enumerating the kinds one by one. `parent` is skipped because following it
83
+ * walks back out of the subtree and never terminates.
84
+ */
85
+ const childNodesOf = (node) => {
86
+ const children = [];
87
+ for (const [key, value] of Object.entries(node)) {
88
+ if (key === 'parent') {
89
+ continue;
90
+ }
91
+ if (Array.isArray(value)) {
92
+ for (const item of value) {
93
+ if (ASTHelpers_1.ASTHelpers.isNode(item)) {
94
+ children.push(item);
95
+ }
96
+ }
97
+ }
98
+ else if (ASTHelpers_1.ASTHelpers.isNode(value)) {
99
+ children.push(value);
100
+ }
101
+ }
102
+ return children;
103
+ };
104
+ /** The type nodes a declaration states about the type it declares. */
105
+ const statedTypeNodesOf = (declaration) => declaration.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration
106
+ ? [declaration.typeAnnotation]
107
+ : declaration.extends ?? [];
42
108
  /**
43
109
  * The expression an optional link wraps, so a receiver spelled with `?.` is
44
110
  * read as the expression it actually evaluates.
@@ -136,6 +202,56 @@ function declarationOfType(from, name) {
136
202
  return undefined;
137
203
  });
138
204
  }
205
+ /**
206
+ * Whether an asserted type states a Firestore reference surface, and so
207
+ * describes the document schema of the expression it is applied to.
208
+ *
209
+ * The presence of an assertion is not evidence on its own. `as const` states no
210
+ * type at all — it preserves whatever the operand already infers to and adds
211
+ * `readonly` — so every reference beneath one keeps the loose `DocumentData`
212
+ * schema this rule exists to reject, byte for byte. `global-const-style` ships
213
+ * `error` in the same recommended config and is fixable, and its fix appends
214
+ * exactly that assertion to a module-scope literal, so crediting any ancestor
215
+ * assertion lets one `eslint --fix` pass silence the rule without repairing
216
+ * anything (#2007).
217
+ *
218
+ * The search is structural rather than positional, because an assertion types
219
+ * the references inside a literal it wraps: `[db.collection('a')] as
220
+ * CollectionReference<T>[]` and `{...} as Record<string, CollectionReference<T>>`
221
+ * both state the schema from several type nodes away. Proximity therefore
222
+ * cannot be the test — the minimal repro, `db.collection('x') as const`, has the
223
+ * assertion as the call's own parent.
224
+ *
225
+ * A name that resolves to a declaration in the file is followed, since an alias
226
+ * states what it stands for. Both declaration spellings are read for the reason
227
+ * `declaredMembersOf` reads both: `prefer-type-over-interface` ships in the same
228
+ * config and is fixable, so an interface and the alias it becomes have to answer
229
+ * alike.
230
+ */
231
+ function statesDocumentSchema(node, visited) {
232
+ const name = namedTypeOf(node);
233
+ if (name) {
234
+ if (SCHEMA_TYPE_NAMES.has(name)) {
235
+ return true;
236
+ }
237
+ if (declaredTypeStatesDocumentSchema(node, name, visited)) {
238
+ return true;
239
+ }
240
+ }
241
+ return childNodesOf(node).some((child) => statesDocumentSchema(child, visited));
242
+ }
243
+ /**
244
+ * The declaration set doubles as the recursion guard, so a self-referential
245
+ * alias such as `type Loop = Loop[]` terminates instead of exhausting the stack.
246
+ */
247
+ function declaredTypeStatesDocumentSchema(reference, name, visited) {
248
+ const declaration = declarationOfType(reference, name);
249
+ if (!declaration || visited.has(declaration)) {
250
+ return false;
251
+ }
252
+ visited.add(declaration);
253
+ return statedTypeNodesOf(declaration).some((stated) => statesDocumentSchema(stated, visited));
254
+ }
139
255
  /**
140
256
  * @type {import('eslint').Rule.RuleModule}
141
257
  */
@@ -325,8 +441,12 @@ exports.enforceFirestoreDocRefGeneric = (0, createRule_1.createRule)({
325
441
  let previous;
326
442
  let current = node;
327
443
  while (current) {
328
- // Type assertions using 'as' keyword
329
- if (current.type === utils_1.AST_NODE_TYPES.TSAsExpression) {
444
+ // Type assertions using 'as' keyword, credited only when the asserted
445
+ // type states the document schema. A non-stating assertion does not end
446
+ // the walk: `db.doc(p) as unknown as DocumentReference<User>` reaches
447
+ // the stating one an assertion further out.
448
+ if (current.type === utils_1.AST_NODE_TYPES.TSAsExpression &&
449
+ statesDocumentSchema(current.typeAnnotation, new Set())) {
330
450
  nodeCache.set(node, true);
331
451
  return true;
332
452
  }
@@ -428,7 +548,17 @@ exports.enforceFirestoreDocRefGeneric = (0, createRule_1.createRule)({
428
548
  return isTypedCollectionReferenceCache.get(node);
429
549
  }
430
550
  let result = false;
431
- if (node.type === utils_1.AST_NODE_TYPES.CallExpression &&
551
+ // A receiver reached through an assertion states its schema exactly as an
552
+ // annotated binding does, and `isTypedCollectionInitializer` already reads
553
+ // the same spelling one hop later. Without this, the receiver in
554
+ // `(matchRef.collection('m') as CollectionReference<T>).doc(id)` looks
555
+ // untyped and `.doc()` draws a report whose only remedy — `doc<T>(id)` —
556
+ // does not compile, since `CollectionReference<T>.doc` declares zero type
557
+ // parameters.
558
+ if (node.type === utils_1.AST_NODE_TYPES.TSAsExpression) {
559
+ result = hasCollectionReferenceType(node.typeAnnotation);
560
+ }
561
+ else if (node.type === utils_1.AST_NODE_TYPES.CallExpression &&
432
562
  node.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
433
563
  node.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
434
564
  node.callee.property.name === 'collection' &&
@@ -385,6 +385,16 @@ exports.enforceQueryKeyTs = (0, createRule_1.createRule)({
385
385
  * Track variable assignments to detect variables derived from query key constants
386
386
  */
387
387
  const variableAssignments = new Map();
388
+ /**
389
+ * Names whose declaration leaves them holding `undefined` — `let key;` and
390
+ * `let key = undefined;` alike. Both falsy and nullish, which is what makes
391
+ * a later `||=`/`??=` onto one of them assign unconditionally.
392
+ */
393
+ const declaredUndefined = new Set();
394
+ /** Declarations and assignments seen per name, to bound the above to the provable case. */
395
+ const declarationCounts = new Map();
396
+ const assignmentCounts = new Map();
397
+ const isUndefinedIdentifier = (node) => node.type === utils_1.AST_NODE_TYPES.Identifier && node.name === 'undefined';
388
398
  /**
389
399
  * Check if a node represents a valid query key usage
390
400
  */
@@ -631,13 +641,45 @@ exports.enforceQueryKeyTs = (0, createRule_1.createRule)({
631
641
  },
632
642
  // Track variable declarations that might derive from query key constants
633
643
  VariableDeclarator(node) {
634
- if (node.id.type === utils_1.AST_NODE_TYPES.Identifier && node.init) {
635
- variableAssignments.set(node.id.name, node.init);
644
+ if (node.id.type !== utils_1.AST_NODE_TYPES.Identifier)
645
+ return;
646
+ const { name } = node.id;
647
+ declarationCounts.set(name, (declarationCounts.get(name) ?? 0) + 1);
648
+ if (node.init) {
649
+ variableAssignments.set(name, node.init);
650
+ }
651
+ if (!node.init || isUndefinedIdentifier(node.init)) {
652
+ declaredUndefined.add(name);
636
653
  }
637
654
  },
638
655
  AssignmentExpression(node) {
639
- if (node.left.type === utils_1.AST_NODE_TYPES.Identifier && node.right) {
640
- variableAssignments.set(node.left.name, node.right);
656
+ if (node.left.type !== utils_1.AST_NODE_TYPES.Identifier)
657
+ return;
658
+ const { name } = node.left;
659
+ const assignmentCount = (assignmentCounts.get(name) ?? 0) + 1;
660
+ assignmentCounts.set(name, assignmentCount);
661
+ // Only a plain `=` makes the right-hand side the variable's value in
662
+ // general. A compound assignment leaves the prior value reachable
663
+ // (`key ||= K` is the old key OR K), so recording the operand would
664
+ // launder an unapproved key into an approved one.
665
+ if (node.operator === '=') {
666
+ variableAssignments.set(name, node.right);
667
+ return;
668
+ }
669
+ // The exception is a `||=`/`??=` onto a variable still holding
670
+ // `undefined`, which is both falsy and nullish: the assignment always
671
+ // happens, so the operand IS the value. A single declaration and a
672
+ // single assignment are what keep that provable — a second assignment
673
+ // anywhere puts a prior value back in play. `+=` is excluded because it
674
+ // concatenates onto `undefined` and yields neither operand.
675
+ //
676
+ // The operand is recorded rather than exempted, so it still has to be
677
+ // an approved constant to pass.
678
+ if ((node.operator === '||=' || node.operator === '??=') &&
679
+ declaredUndefined.has(name) &&
680
+ declarationCounts.get(name) === 1 &&
681
+ assignmentCount === 1) {
682
+ variableAssignments.set(name, node.right);
641
683
  }
642
684
  },
643
685
  // Check useRouterState calls
@@ -79,6 +79,46 @@ function producesFunction(factory) {
79
79
  }
80
80
  return isFunctionLiteral(unwrapValueExpression(only.argument));
81
81
  }
82
+ /**
83
+ * Whether the binding is declared where the program evaluates it exactly once.
84
+ *
85
+ * A function's identity is fixed by the execution of its declaration, so what
86
+ * makes one a stable callback is not the function but the scope holding it.
87
+ * Only a module-level declaration answers yes: the scope chain reaches the
88
+ * `Program` without crossing a function, so nothing between the declaration and
89
+ * a render re-runs it. A function declared in a component body — or in any
90
+ * nested block of one, or in a custom hook — is a *fresh* function on every
91
+ * render, and there the wrapper is the only thing stabilizing it.
92
+ *
93
+ * Module-level blocks and loop bodies qualify: they run before any render, and
94
+ * each closure a loop creates captures the one binding it was created with.
95
+ */
96
+ function isEvaluatedOnce(scope) {
97
+ return scope.variableScope.block.type === utils_1.AST_NODE_TYPES.Program;
98
+ }
99
+ /**
100
+ * Whether anything assigns to the binding after its declaration.
101
+ *
102
+ * The `const` keyword already rules this out for a declarator, but a function
103
+ * declaration binds a writable name, and the question the proof needs is about
104
+ * writes rather than about a keyword. Asking it of the resolved variable keeps
105
+ * one answer for every declaration form.
106
+ */
107
+ function isNeverReassigned(variable) {
108
+ return variable.references.every((reference) => !reference.isWrite() || reference.init === true);
109
+ }
110
+ /**
111
+ * Whether the binding holds one function for the program's lifetime.
112
+ *
113
+ * The two questions are asked of the *binding* rather than of the syntax that
114
+ * introduced it, so a `const` arrow and the `function` declaration it is one
115
+ * rewrite away from answer alike. A proof that changed with the spelling would
116
+ * either withhold the report from half the codebase or bless code it flags in
117
+ * the other half.
118
+ */
119
+ function isProgramLifetimeFunction(variable) {
120
+ return isEvaluatedOnce(variable.scope) && isNeverReassigned(variable);
121
+ }
82
122
  function isHookLikeName(name) {
83
123
  return name.startsWith('use');
84
124
  }
@@ -237,7 +277,7 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
237
277
  };
238
278
  /**
239
279
  * Whether the binding this identifier resolves to holds a callback whose
240
- * memoization is visible in this very file.
280
+ * stability is visible in this very file.
241
281
  *
242
282
  * `memoizedHookNames` exists for callbacks whose stability only the consumer
243
283
  * knows about. A `const` initialized from `useCallback`, `useLatestCallback`
@@ -247,6 +287,10 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
247
287
  * which the rule can report nothing at all in a config that does not set
248
288
  * `memoizedHookNames`.
249
289
  *
290
+ * A module-level function is the same proof without the call: a memoizing
291
+ * hook exists to give a function one identity across renders, and a
292
+ * declaration the program evaluates once already has one.
293
+ *
250
294
  * The binding is resolved through scope analysis rather than matched by
251
295
  * name, because a name set cannot tell the memoized `inner` of one component
252
296
  * from the `inner` prop of the next, and would report the prop — the wrapper
@@ -258,6 +302,11 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
258
302
  return false;
259
303
  }
260
304
  const declarator = variable.defs[0].node;
305
+ // A function declaration binds the function itself, so the binding's
306
+ // lifetime settles it outright — there is no initializer to read.
307
+ if (declarator.type === utils_1.AST_NODE_TYPES.FunctionDeclaration) {
308
+ return isProgramLifetimeFunction(variable);
309
+ }
261
310
  if (declarator.type !== utils_1.AST_NODE_TYPES.VariableDeclarator ||
262
311
  declarator.id.type !== utils_1.AST_NODE_TYPES.Identifier ||
263
312
  !declarator.init) {
@@ -277,6 +326,16 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
277
326
  return false;
278
327
  }
279
328
  const init = unwrapValueExpression(declarator.init);
329
+ // A function literal needs no memoizing call to be stable: it is created
330
+ // once by its declaration, so a module-level one holds a single identity
331
+ // for the program's lifetime — at least as stable as anything `useMemo`
332
+ // or `useCallback` hands back, and stable for the same reason. The
333
+ // conservative stance stays in force for *calls*: `memoize(fn)` is an
334
+ // unknown function whose result the rule cannot reason about, whatever it
335
+ // returns, and hoisting such a call to module scope does not change that.
336
+ if (isFunctionLiteral(init)) {
337
+ return isProgramLifetimeFunction(variable);
338
+ }
280
339
  if (init.type !== utils_1.AST_NODE_TYPES.CallExpression) {
281
340
  return false;
282
341
  }
@@ -352,10 +352,6 @@ exports.noTypeAssertionReturns = (0, createRule_1.createRule)({
352
352
  checkFunctionReturnType(node);
353
353
  }
354
354
  },
355
- // Check for array methods with type assertions
356
- CallExpression() {
357
- // Skip this check as we'll handle it in the TSAsExpression and TSTypeAssertion handlers
358
- },
359
355
  // Check for type assertions in expressions
360
356
  TSAsExpression(node) {
361
357
  // Allow 'as const' assertions if configured
@@ -6,6 +6,62 @@ const createRule_1 = require("../utils/createRule");
6
6
  const ASTHelpers_1 = require("../utils/ASTHelpers");
7
7
  const PAREN_TYPE = utils_1.AST_NODE_TYPES.TSParenthesizedType ??
8
8
  'TSParenthesizedType';
9
+ /**
10
+ * `require-memo` rewrites `const Widget = (props: WidgetProps) => ...` into
11
+ * `const Widget = memo(...)`, and pairs that wrapper with `forwardRef` whenever
12
+ * a ref is forwarded, so a component declarator's initializer is a wrapper CALL
13
+ * at least as often as it is a function. Peeling them is what keeps the
14
+ * recommended config's own autofix from hiding the component from this rule
15
+ * (#2004).
16
+ */
17
+ const COMPONENT_WRAPPERS = new Set(['memo', 'forwardRef']);
18
+ const isComponentWrapperCallee = (callee) => {
19
+ if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
20
+ return COMPONENT_WRAPPERS.has(callee.name);
21
+ }
22
+ if (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
23
+ !callee.computed &&
24
+ callee.property.type === utils_1.AST_NODE_TYPES.Identifier) {
25
+ return COMPONENT_WRAPPERS.has(callee.property.name);
26
+ }
27
+ return false;
28
+ };
29
+ /**
30
+ * The function that receives the props behind any nesting of component
31
+ * wrappers (`memo(forwardRef(fn))`), or `null` when no function is reachable.
32
+ * Only the first argument is followed: a wrapper's remaining arguments are
33
+ * comparators (`memo(fn, compareDeeply('used'))`), never the component.
34
+ *
35
+ * An argument that merely NAMES a function (`memo(WidgetUnmemoized)`) is left
36
+ * alone, unlike in `enforce-exported-function-types`. That name belongs to a
37
+ * declarator of its own, which reaches this rule on its own and reports against
38
+ * the same props-type member; following the binding would report every unused
39
+ * prop once per binding that re-wraps the component.
40
+ */
41
+ const unwrapComponentFunction = (node) => {
42
+ if (!node) {
43
+ return null;
44
+ }
45
+ // ESTree wraps `memo?.(fn)` and `React?.memo(fn)` in a ChainExpression, so an
46
+ // optionally-called wrapper sits one node deeper than the plain spelling.
47
+ if (node.type === utils_1.AST_NODE_TYPES.ChainExpression) {
48
+ return unwrapComponentFunction(node.expression);
49
+ }
50
+ if (node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
51
+ node.type === utils_1.AST_NODE_TYPES.FunctionExpression) {
52
+ return node;
53
+ }
54
+ if (node.type === utils_1.AST_NODE_TYPES.CallExpression &&
55
+ isComponentWrapperCallee(node.callee)) {
56
+ const [firstArgument] = node.arguments;
57
+ // A spread argument (`memo(...candidates)`) hides which value is wrapped.
58
+ if (!firstArgument || firstArgument.type === utils_1.AST_NODE_TYPES.SpreadElement) {
59
+ return null;
60
+ }
61
+ return unwrapComponentFunction(firstArgument);
62
+ }
63
+ return null;
64
+ };
9
65
  exports.noUnusedProps = (0, createRule_1.createRule)({
10
66
  name: 'no-unused-props',
11
67
  meta: {
@@ -469,14 +525,17 @@ exports.noUnusedProps = (0, createRule_1.createRule)({
469
525
  * holds no props-typed function. Each declarator answers on its own: this
470
526
  * rule reports a member of a props type and rewrites nothing, so a sibling
471
527
  * binding in the same statement has no bearing on the verdict (#1890).
528
+ *
529
+ * The props come from the wrapped function's own parameter, so a wrapper
530
+ * changes nothing about the verdict — only about how far in the function
531
+ * sits. The declarator's binding still supplies any FC-shaped annotation.
472
532
  */
473
533
  const componentUsageOfDeclarator = (declaration) => {
474
- const { init } = declaration;
475
- if (init?.type !== utils_1.AST_NODE_TYPES.ArrowFunctionExpression &&
476
- init?.type !== utils_1.AST_NODE_TYPES.FunctionExpression) {
534
+ const fn = unwrapComponentFunction(declaration.init);
535
+ if (!fn) {
477
536
  return null;
478
537
  }
479
- return componentUsageOfFunction(init, declaration.id);
538
+ return componentUsageOfFunction(fn, declaration.id);
480
539
  };
481
540
  return {
482
541
  TSTypeAliasDeclaration(node) {
@@ -122,6 +122,16 @@ exports.preferGlobalRouterStateKey = (0, createRule_1.createRule)({
122
122
  const defaultImports = new Map();
123
123
  // Track re-exports and variable assignments
124
124
  const variableAssignments = new Map();
125
+ /**
126
+ * Names whose declaration leaves them holding `undefined` — `let key;` and
127
+ * `let key = undefined;` alike. Both falsy and nullish, which is what makes
128
+ * a later `||=`/`??=` onto one of them assign unconditionally.
129
+ */
130
+ const declaredUndefined = new Set();
131
+ /** Declarations and assignments seen per name, to bound the above to the provable case. */
132
+ const declarationCounts = new Map();
133
+ const assignmentCounts = new Map();
134
+ const isUndefinedIdentifier = (node) => node.type === utils_1.AST_NODE_TYPES.Identifier && node.name === 'undefined';
125
135
  const validQueryKeySources = new Set([
126
136
  'util/routing/queryKeys',
127
137
  'constants',
@@ -444,15 +454,46 @@ exports.preferGlobalRouterStateKey = (0, createRule_1.createRule)({
444
454
  },
445
455
  // Track variable declarations that might derive from query key constants
446
456
  VariableDeclarator(node) {
447
- if (node.id.type === utils_1.AST_NODE_TYPES.Identifier && node.init) {
448
- variableAssignments.set(node.id.name, node.init);
457
+ if (node.id.type !== utils_1.AST_NODE_TYPES.Identifier)
458
+ return;
459
+ const { name } = node.id;
460
+ declarationCounts.set(name, (declarationCounts.get(name) ?? 0) + 1);
461
+ if (node.init) {
462
+ variableAssignments.set(name, node.init);
463
+ }
464
+ if (!node.init || isUndefinedIdentifier(node.init)) {
465
+ declaredUndefined.add(name);
449
466
  }
450
467
  },
451
468
  // Track assignment expressions
452
469
  AssignmentExpression(node) {
453
- if (node.left.type === utils_1.AST_NODE_TYPES.Identifier &&
454
- node.operator === '=') {
455
- variableAssignments.set(node.left.name, node.right);
470
+ if (node.left.type !== utils_1.AST_NODE_TYPES.Identifier)
471
+ return;
472
+ const { name } = node.left;
473
+ const assignmentCount = (assignmentCounts.get(name) ?? 0) + 1;
474
+ assignmentCounts.set(name, assignmentCount);
475
+ // Only a plain `=` makes the right-hand side the variable's value in
476
+ // general. A compound assignment leaves the prior value reachable
477
+ // (`key ||= K` is the old key OR K), so recording the operand would
478
+ // launder an unapproved key into an approved one.
479
+ if (node.operator === '=') {
480
+ variableAssignments.set(name, node.right);
481
+ return;
482
+ }
483
+ // The exception is a `||=`/`??=` onto a variable still holding
484
+ // `undefined`, which is both falsy and nullish: the assignment always
485
+ // happens, so the operand IS the value. A single declaration and a
486
+ // single assignment are what keep that provable — a second assignment
487
+ // anywhere puts a prior value back in play. `+=` is excluded because it
488
+ // concatenates onto `undefined` and yields neither operand.
489
+ //
490
+ // The operand is recorded rather than exempted, so it still has to be
491
+ // an approved constant to pass.
492
+ if ((node.operator === '||=' || node.operator === '??=') &&
493
+ declaredUndefined.has(name) &&
494
+ declarationCounts.get(name) === 1 &&
495
+ assignmentCount === 1) {
496
+ variableAssignments.set(name, node.right);
456
497
  }
457
498
  },
458
499
  // Check useRouterState calls
@@ -35,6 +35,37 @@ function isHookName(name) {
35
35
  function isPascalCase(name) {
36
36
  return /^[A-Z]/.test(name);
37
37
  }
38
+ /**
39
+ * Wrappers that exist purely at the type level: they leave the wrapped
40
+ * expression's runtime value untouched, so a value wrapped in them is still the
41
+ * value the enclosing declarator binds.
42
+ */
43
+ const TYPE_ONLY_WRAPPERS = new Set([
44
+ utils_1.AST_NODE_TYPES.TSAsExpression,
45
+ utils_1.AST_NODE_TYPES.TSSatisfiesExpression,
46
+ utils_1.AST_NODE_TYPES.TSNonNullExpression,
47
+ utils_1.AST_NODE_TYPES.TSTypeAssertion,
48
+ ]);
49
+ /**
50
+ * `require-memo` rewrites `const Widget = (props) => ...` into
51
+ * `const Widget = memo(...)`, and pairs that wrapper with `forwardRef` whenever
52
+ * a ref is forwarded, so a component's function reaches this rule behind a
53
+ * wrapper CALL at least as often as it sits directly under its declarator.
54
+ * Climbing them is what keeps the recommended config's own autofix from hiding
55
+ * the component from every handler here (#2005).
56
+ */
57
+ const COMPONENT_WRAPPERS = new Set(['memo', 'forwardRef']);
58
+ function isComponentWrapperCallee(callee) {
59
+ if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
60
+ return COMPONENT_WRAPPERS.has(callee.name);
61
+ }
62
+ if (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
63
+ !callee.computed &&
64
+ callee.property.type === utils_1.AST_NODE_TYPES.Identifier) {
65
+ return COMPONENT_WRAPPERS.has(callee.property.name);
66
+ }
67
+ return false;
68
+ }
38
69
  /**
39
70
  * Checks whether a node is a React component or hook function.
40
71
  * Hooks: function whose name starts with "use" followed by an uppercase letter.
@@ -47,15 +78,53 @@ function isComponentOrHook(node) {
47
78
  }
48
79
  // FunctionExpression or ArrowFunctionExpression assigned to a variable:
49
80
  // const MyComponent = () => {} or const useHook = () => {}
50
- const parent = node.parent;
51
- if (parent?.type === utils_1.AST_NODE_TYPES.VariableDeclarator) {
52
- const id = parent.id;
81
+ const declarator = getBindingDeclarator(node);
82
+ if (declarator) {
83
+ const id = declarator.id;
53
84
  if (id.type === utils_1.AST_NODE_TYPES.Identifier) {
54
85
  return isPascalCase(id.name) || isHookName(id.name);
55
86
  }
56
87
  }
57
88
  return false;
58
89
  }
90
+ /**
91
+ * The declarator whose binding names the given function, seen through the
92
+ * wrappers that may stand between the two, or undefined when the function is
93
+ * not bound by a declarator at all.
94
+ *
95
+ * Only the FIRST argument of a wrapper is followed: a wrapper's remaining
96
+ * arguments are comparators (`memo(Widget, compareDeeply('id'))`), which run per
97
+ * comparison rather than per render and are no part of the component. Wrappers
98
+ * nest (`memo(forwardRef(fn))`), so the climb loops.
99
+ */
100
+ function getBindingDeclarator(node) {
101
+ let child = node;
102
+ let current = node.parent;
103
+ while (current) {
104
+ // A type-only wrapper may sit on either side of the call, as in
105
+ // `const Widget = memo(fn) as FC`, and changes nothing at runtime.
106
+ // ESTree wraps `memo?.(fn)` and `React?.memo(fn)` in a ChainExpression, so
107
+ // an optionally-called wrapper sits one node deeper than the plain
108
+ // spelling.
109
+ if (TYPE_ONLY_WRAPPERS.has(current.type) ||
110
+ current.type === utils_1.AST_NODE_TYPES.ChainExpression) {
111
+ child = current;
112
+ current = current.parent;
113
+ continue;
114
+ }
115
+ if (current.type === utils_1.AST_NODE_TYPES.CallExpression &&
116
+ isComponentWrapperCallee(current.callee) &&
117
+ current.arguments[0] === child) {
118
+ child = current;
119
+ current = current.parent;
120
+ continue;
121
+ }
122
+ break;
123
+ }
124
+ return current?.type === utils_1.AST_NODE_TYPES.VariableDeclarator
125
+ ? current
126
+ : undefined;
127
+ }
59
128
  /**
60
129
  * Recursively searches the given node tree for any CallExpression that calls
61
130
  * one of the tracked uuidv4Base62 local names.
@@ -131,17 +200,6 @@ function hasEmptyDepsArray(callNode) {
131
200
  const deps = callNode.arguments[1];
132
201
  return (deps.type === utils_1.AST_NODE_TYPES.ArrayExpression && deps.elements.length === 0);
133
202
  }
134
- /**
135
- * Wrappers that exist purely at the type level: they leave the wrapped
136
- * expression's runtime value untouched, so a value wrapped in them is still the
137
- * value the enclosing declarator binds.
138
- */
139
- const TYPE_ONLY_WRAPPERS = new Set([
140
- utils_1.AST_NODE_TYPES.TSAsExpression,
141
- utils_1.AST_NODE_TYPES.TSSatisfiesExpression,
142
- utils_1.AST_NODE_TYPES.TSNonNullExpression,
143
- utils_1.AST_NODE_TYPES.TSTypeAssertion,
144
- ]);
145
203
  /**
146
204
  * Returns the nearest ancestor that carries runtime meaning, skipping the
147
205
  * type-only wrappers that may sit between an expression and its binding site.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.149",
3
+ "version": "1.20.151",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,89 @@
1
1
  [
2
+ {
3
+ "version": "1.20.151",
4
+ "date": "2026-08-14T02:27:13.012Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-exported-function-types",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 2006
11
+ ],
12
+ "summary": "follow a default-exported identifier to its local memo() declaration (closes #2006)"
13
+ },
14
+ {
15
+ "name": "enforce-firestore-doc-ref-generic",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 2007
19
+ ],
20
+ "summary": "require an ancestor assertion to state a document schema (closes #2007)"
21
+ },
22
+ {
23
+ "name": "no-redundant-usecallback-wrapper",
24
+ "changeType": "fix",
25
+ "issues": [
26
+ 2008
27
+ ],
28
+ "summary": "treat a module-level function as a stable delegate (closes #2008)"
29
+ },
30
+ {
31
+ "name": "no-unused-props",
32
+ "changeType": "fix",
33
+ "issues": [
34
+ 2004
35
+ ],
36
+ "summary": "see through memo/forwardRef wrappers require-memo emits (closes #2004)"
37
+ },
38
+ {
39
+ "name": "prefer-use-base62-id",
40
+ "changeType": "fix",
41
+ "issues": [
42
+ 2005
43
+ ],
44
+ "summary": "climb wrapper calls so memo() no longer hides a component (closes #2005)"
45
+ }
46
+ ]
47
+ },
48
+ {
49
+ "version": "1.20.150",
50
+ "date": "2026-08-13T19:12:01.840Z",
51
+ "rules": [
52
+ {
53
+ "name": "enforce-exported-function-types",
54
+ "changeType": "fix",
55
+ "issues": [
56
+ 2000
57
+ ],
58
+ "summary": "drop 28 inert empty-bodied selectors (closes #2000)"
59
+ },
60
+ {
61
+ "name": "enforce-querykey-ts",
62
+ "changeType": "fix",
63
+ "issues": [
64
+ 1999,
65
+ 2001
66
+ ],
67
+ "summary": "follow a `||=`/`??=` onto an undefined key (#2001); guard variable tracking on a plain `=` assignment (closes #1999)"
68
+ },
69
+ {
70
+ "name": "no-type-assertion-returns",
71
+ "changeType": "fix",
72
+ "issues": [
73
+ 2002
74
+ ],
75
+ "summary": "drop the inert empty CallExpression handler (closes #2002)"
76
+ },
77
+ {
78
+ "name": "prefer-global-router-state-key",
79
+ "changeType": "fix",
80
+ "issues": [
81
+ 2001
82
+ ],
83
+ "summary": "follow a `||=`/`??=` onto an undefined key (closes #2001)"
84
+ }
85
+ ]
86
+ },
2
87
  {
3
88
  "version": "1.20.149",
4
89
  "date": "2026-08-13T16:22:27.196Z",