@blumintinc/eslint-plugin-blumint 1.20.150 → 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.150',
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
@@ -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' &&
@@ -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
  }
@@ -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) {
@@ -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.150",
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,50 @@
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
+ },
2
48
  {
3
49
  "version": "1.20.150",
4
50
  "date": "2026-08-13T19:12:01.840Z",