@blumintinc/eslint-plugin-blumint 1.20.117 → 1.20.118

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.117',
226
+ version: '1.20.118',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -200,6 +200,13 @@ exports.default = (0, createRule_1.createRule)({
200
200
  // that happens to build JSX, and JSX rendered from a test body are all
201
201
  // outside any render path, so wrapping there would throw
202
202
  // "Invalid hook call" while saving no re-render.
203
+ //
204
+ // The question the gate asks is RELATIVE — is a render function interposed
205
+ // between this attribute and whatever encloses it further out — so a
206
+ // component built by a plain factory (`function makeCard() { return
207
+ // memo(() => <X onClick={...} />); }`) reports: the arrow handed to `memo`
208
+ // IS the component, and `useCallback` is legal inside it no matter what
209
+ // the factory is called.
203
210
  if (!ASTHelpers_1.ASTHelpers.isInsideComponentOrHook(node, context)) {
204
211
  return;
205
212
  }
@@ -221,7 +221,7 @@ function initializesFirestore(declarator) {
221
221
  * deeper, inside an `ExportNamedDeclaration`. Reading the statement without
222
222
  * unwrapping makes the `export` keyword alone decide whether the file's
223
223
  * Firestore evidence is visible, which is not a distinction a `db` handle knows
224
- * anything about — `classBodiesByName()` already unwraps it for the same
224
+ * anything about — `resolveClassBody()` unwraps it for the same
225
225
  * "find the in-file declaration that carries the evidence" purpose.
226
226
  */
227
227
  function declaresFirestoreInstance(statement) {
@@ -285,35 +285,73 @@ exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
285
285
  const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
286
286
  let plannedSetDocBinding = false;
287
287
  /**
288
- * Top-level classes by name, so a field inherited from a superclass declared
289
- * in the same file resolves to the declaration that carries its evidence.
288
+ * Classes declared directly in one statement container, by name both the
289
+ * `class X {}` spelling and the `const X = class {}` one, each looked
290
+ * through its optional `export` wrapper.
291
+ *
292
+ * The memo hangs off the container rather than off the file. What a single
293
+ * container declares is the same answer for every call site that asks, so
294
+ * caching it is safe, whereas a file-wide name map is not: lexical
295
+ * resolution is position dependent, and one map computed for whichever site
296
+ * asked first would hand that site's answer to every other one.
290
297
  */
291
- let topLevelClasses = null;
292
- function classBodiesByName() {
293
- if (topLevelClasses) {
294
- return topLevelClasses;
298
+ const classesByContainer = new WeakMap();
299
+ function containerClasses(container, statements) {
300
+ const cached = classesByContainer.get(container);
301
+ if (cached) {
302
+ return cached;
295
303
  }
296
- topLevelClasses = new Map();
297
- for (const statement of sourceCode.ast.body) {
304
+ const classes = new Map();
305
+ for (const statement of statements) {
298
306
  const declaration = statement.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration ||
299
307
  statement.type === utils_1.AST_NODE_TYPES.ExportDefaultDeclaration
300
308
  ? statement.declaration
301
309
  : statement;
302
310
  if (declaration?.type === utils_1.AST_NODE_TYPES.ClassDeclaration &&
303
311
  declaration.id) {
304
- topLevelClasses.set(declaration.id.name, declaration.body);
312
+ classes.set(declaration.id.name, declaration.body);
305
313
  continue;
306
314
  }
307
315
  if (declaration?.type === utils_1.AST_NODE_TYPES.VariableDeclaration) {
308
316
  for (const declarator of declaration.declarations) {
309
317
  if (declarator.id.type === utils_1.AST_NODE_TYPES.Identifier &&
310
318
  declarator.init?.type === utils_1.AST_NODE_TYPES.ClassExpression) {
311
- topLevelClasses.set(declarator.id.name, declarator.init.body);
319
+ classes.set(declarator.id.name, declarator.init.body);
312
320
  }
313
321
  }
314
322
  }
315
323
  }
316
- return topLevelClasses;
324
+ classesByContainer.set(container, classes);
325
+ return classes;
326
+ }
327
+ /**
328
+ * The class a superclass reference names, searched from the reference
329
+ * outward through every enclosing statement container so the declaration
330
+ * that carries the field's evidence is found wherever it sits.
331
+ *
332
+ * Scanning `Program.body` alone made the `export` keyword and the depth of a
333
+ * declaration decide whether a base class is visible, which is not a
334
+ * distinction an inherited field knows anything about. This map feeds the
335
+ * Realtime Database carve-out, so a miss switches the exemption off and
336
+ * turns a call the rule cannot legally rewrite into a report — the same
337
+ * hole `hasFirestoreInstanceInScope` closes for the detection direction,
338
+ * and the two must agree. The innermost container wins, so a nested class
339
+ * shadowing an outer one of the same name answers for the code that sees
340
+ * the shadow.
341
+ */
342
+ function resolveClassBody(name, reference) {
343
+ let current = reference;
344
+ while (current) {
345
+ const statements = statementsOf(current);
346
+ if (statements) {
347
+ const found = containerClasses(current, statements).get(name);
348
+ if (found) {
349
+ return found;
350
+ }
351
+ }
352
+ current = current.parent;
353
+ }
354
+ return null;
317
355
  }
318
356
  function enclosingClassBody(node) {
319
357
  let current = node.parent;
@@ -342,7 +380,7 @@ exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
342
380
  if (superClass?.type !== utils_1.AST_NODE_TYPES.Identifier) {
343
381
  return false;
344
382
  }
345
- const superBody = classBodiesByName().get(superClass.name);
383
+ const superBody = resolveClassBody(superClass.name, superClass);
346
384
  return superBody ? classBindsRealtime(superBody, name, seen) : false;
347
385
  }
348
386
  function identifierBindsRealtime(identifier) {
@@ -57,6 +57,31 @@ exports.preferBlockCommentsForDeclarations = (0, createRule_1.createRule)({
57
57
  }
58
58
  return false;
59
59
  };
60
+ /**
61
+ * Resolve the node that owns the declaration's leading comments.
62
+ *
63
+ * A leading comment sits before the first token of the whole statement, so
64
+ * an `export` wrapper takes ownership of it: the `export` keyword becomes
65
+ * the token preceding the inner declaration and `getCommentsBefore` on that
66
+ * declaration returns nothing. Walking out to the wrapper keeps exported
67
+ * declarations — the public API this rule exists to document — in scope.
68
+ *
69
+ * Only the visitors for the inner declaration types are registered, so
70
+ * unwrapping here reports each declaration once; registering the export
71
+ * node types as extra visitors instead would report the same comment twice.
72
+ */
73
+ const resolveCommentAnchor = (node) => {
74
+ let anchor = node;
75
+ let parent = anchor.parent;
76
+ while (parent &&
77
+ (parent.type === 'ExportNamedDeclaration' ||
78
+ parent.type === 'ExportDefaultDeclaration') &&
79
+ parent.declaration === anchor) {
80
+ anchor = parent;
81
+ parent = anchor.parent;
82
+ }
83
+ return anchor;
84
+ };
60
85
  /**
61
86
  * Process a node that might have a declaration comment
62
87
  */
@@ -66,10 +91,11 @@ exports.preferBlockCommentsForDeclarations = (0, createRule_1.createRule)({
66
91
  return;
67
92
  }
68
93
  const sourceCode = context.sourceCode;
69
- const comments = sourceCode.getCommentsBefore(node);
94
+ const anchor = resolveCommentAnchor(node);
95
+ const comments = sourceCode.getCommentsBefore(anchor);
70
96
  // Find the closest comment to the node
71
97
  const lastComment = comments[comments.length - 1];
72
- if (lastComment && isLineCommentBeforeDeclaration(lastComment, node)) {
98
+ if (lastComment && isLineCommentBeforeDeclaration(lastComment, anchor)) {
73
99
  const commentText = lastComment.value.trim();
74
100
  const commentLabel = commentText || 'declaration comment';
75
101
  context.report({
@@ -43,20 +43,127 @@ const isUnmemoizedArrowFunction = (parentNode) => {
43
43
  startsWithUppercase(parentNode.id.name) &&
44
44
  !isComponentExplicitlyUnmemoized(parentNode.id.name));
45
45
  };
46
- const isUnmemoizedFunctionComponent = (parentNode, node) => {
47
- return (node.type === 'FunctionDeclaration' &&
48
- parentNode.type === 'Program' &&
49
- node.id &&
50
- startsWithUppercase(node.id.name) &&
51
- !isComponentExplicitlyUnmemoized(node.id.name));
52
- };
53
- const isUnmemoizedExportedFunctionComponent = (parentNode, node) => {
46
+ /**
47
+ * The nearest function whose body lexically contains `node`. Climbing from the
48
+ * parent keeps a function from being treated as its own enclosing function.
49
+ */
50
+ function enclosingFunctionOf(node) {
51
+ let current = node.parent;
52
+ while (current) {
53
+ if (isFunction(current)) {
54
+ return current;
55
+ }
56
+ current = current.parent;
57
+ }
58
+ return null;
59
+ }
60
+ /**
61
+ * Return arguments belonging to `fn` itself. Descent stops at a nested
62
+ * function, whose returns belong to it rather than to `fn`.
63
+ */
64
+ function ownReturnArguments(fn) {
65
+ if (fn.body.type !== utils_1.AST_NODE_TYPES.BlockStatement) {
66
+ return [fn.body];
67
+ }
68
+ const args = [];
69
+ const visit = (node) => {
70
+ if (isFunction(node)) {
71
+ return;
72
+ }
73
+ if (node.type === utils_1.AST_NODE_TYPES.ReturnStatement) {
74
+ if (node.argument) {
75
+ args.push(node.argument);
76
+ }
77
+ return;
78
+ }
79
+ for (const [key, value] of Object.entries(node)) {
80
+ if (key === 'parent') {
81
+ continue;
82
+ }
83
+ if (Array.isArray(value)) {
84
+ for (const item of value) {
85
+ if (ASTHelpers_1.ASTHelpers.isNode(item)) {
86
+ visit(item);
87
+ }
88
+ }
89
+ }
90
+ else if (ASTHelpers_1.ASTHelpers.isNode(value)) {
91
+ visit(value);
92
+ }
93
+ }
94
+ };
95
+ fn.body.body.forEach(visit);
96
+ return args;
97
+ }
98
+ /** Strips type-level wrappers so `return Row as ComponentType<P>` still reads as `Row`. */
99
+ function unwrapValue(node) {
100
+ if (node.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
101
+ node.type === utils_1.AST_NODE_TYPES.TSSatisfiesExpression ||
102
+ node.type === utils_1.AST_NODE_TYPES.TSTypeAssertion ||
103
+ node.type === utils_1.AST_NODE_TYPES.TSNonNullExpression) {
104
+ return unwrapValue(node.expression);
105
+ }
106
+ return node;
107
+ }
108
+ /**
109
+ * Whether `enclosing` is an HOC factory that hands `componentName` straight back
110
+ * to its callers. Such a component reaches callers as-is, so wrapping it in
111
+ * memo() at its declaration is the correct remedy — exactly as it is at module
112
+ * scope. A `memo(Row)` / `forwardRef(Row)` return is not a bare hand-back: the
113
+ * component is already memoized and a second wrapper would be redundant.
114
+ */
115
+ function handsComponentToCallers(enclosing, componentName, context) {
116
+ // A function that renders JSX is a render body, not a factory. Components
117
+ // declared in one are recreated on every render, which memo() cannot fix;
118
+ // `memo-nested-react-components` owns that shape and says so in its message.
119
+ if (ASTHelpers_1.ASTHelpers.returnsJSX(enclosing.body, context)) {
120
+ return false;
121
+ }
122
+ return ownReturnArguments(enclosing).some((argument) => {
123
+ const value = unwrapValue(argument);
124
+ return (value.type === utils_1.AST_NODE_TYPES.Identifier && value.name === componentName);
125
+ });
126
+ }
127
+ /**
128
+ * Whether wrapping the declaration in memo() where it stands is the right fix.
129
+ *
130
+ * This is the rule's real question, and it is about the binding's lifetime, not
131
+ * about which node happens to be the declaration's parent: a component whose
132
+ * binding outlives a render (module scope — including a block, a namespace and
133
+ * `export default`) is memoizable in place, and so is one an HOC factory returns
134
+ * unwrapped. A component created inside a render body is not; it gets a fresh
135
+ * identity on every render and `memo-nested-react-components` owns it.
136
+ */
137
+ function isMemoizableInPlace(node, context) {
138
+ const enclosing = enclosingFunctionOf(node);
139
+ if (!enclosing) {
140
+ return true;
141
+ }
142
+ return handsComponentToCallers(enclosing, node.id?.name ?? '', context);
143
+ }
144
+ const isUnmemoizedFunctionComponent = (node, context) => {
54
145
  return (node.type === 'FunctionDeclaration' &&
55
- parentNode.type === 'ExportNamedDeclaration' &&
56
- node.id &&
146
+ !!node.id &&
57
147
  startsWithUppercase(node.id.name) &&
58
- !isComponentExplicitlyUnmemoized(node.id.name));
148
+ !isComponentExplicitlyUnmemoized(node.id.name) &&
149
+ isMemoizableInPlace(node, context));
59
150
  };
151
+ /**
152
+ * Statement positions where the rewritten `const X = memo(...)` is legal. A
153
+ * function declaration is also grammatical as the lone body of an `if` or a
154
+ * labelled statement, where a lexical declaration is not, so the report there
155
+ * stands without an edit.
156
+ */
157
+ const CONST_HOSTING_PARENTS = new Set([
158
+ utils_1.AST_NODE_TYPES.Program,
159
+ utils_1.AST_NODE_TYPES.ExportNamedDeclaration,
160
+ utils_1.AST_NODE_TYPES.ExportDefaultDeclaration,
161
+ utils_1.AST_NODE_TYPES.BlockStatement,
162
+ utils_1.AST_NODE_TYPES.StaticBlock,
163
+ utils_1.AST_NODE_TYPES.SwitchCase,
164
+ utils_1.AST_NODE_TYPES.TSModuleBlock,
165
+ ]);
166
+ const canHostConstDeclaration = (parentNode) => CONST_HOSTING_PARENTS.has(parentNode.type);
60
167
  const MEMO_NAME = 'memo';
61
168
  function isMemoImport(importPath) {
62
169
  // Match both absolute and relative paths ending with util/memo
@@ -146,12 +253,9 @@ function checkFunction(context, node) {
146
253
  }
147
254
  if (ASTHelpers_1.ASTHelpers.returnsJSX(node.body, context) &&
148
255
  ASTHelpers_1.ASTHelpers.hasParameters(node)) {
149
- const results = [
150
- isUnmemoizedArrowFunction,
151
- isUnmemoizedFunctionComponent,
152
- isUnmemoizedExportedFunctionComponent,
153
- ].map((fn) => fn(parentNode, node));
154
- if (results.some((result) => !!result)) {
256
+ const isDeclarationComponent = isUnmemoizedFunctionComponent(node, context);
257
+ const isArrowComponent = isUnmemoizedArrowFunction(parentNode);
258
+ if (isDeclarationComponent || isArrowComponent) {
155
259
  const componentName = (node.type === 'FunctionDeclaration' && node.id?.name) ||
156
260
  (parentNode.type === 'VariableDeclarator' &&
157
261
  parentNode.id.type === 'Identifier' &&
@@ -163,7 +267,7 @@ function checkFunction(context, node) {
163
267
  data: {
164
268
  name: componentName,
165
269
  },
166
- fix: results[2] || results[1]
270
+ fix: isDeclarationComponent && canHostConstDeclaration(parentNode)
167
271
  ? function fix(fixer) {
168
272
  if (node.async || node.generator) {
169
273
  return null;
@@ -211,11 +315,23 @@ function checkFunction(context, node) {
211
315
  const functionKeywordReplacement = `const ${node.id.name} = memo(`;
212
316
  // Step 3: Rename function
213
317
  const functionNameReplacement = `function ${node.id.name}Unmemoized`;
318
+ // `export default const X = memo(...)` is a syntax error, so a
319
+ // default-exported declaration becomes a memoized const plus a
320
+ // trailing `export default X;`. The local binding is preserved
321
+ // because other statements in the module may reference it.
322
+ const defaultExport = parentNode.type === utils_1.AST_NODE_TYPES.ExportDefaultDeclaration
323
+ ? parentNode
324
+ : null;
214
325
  const fixes = [
215
326
  fixer.replaceTextRange(functionKeywordRange, functionKeywordReplacement),
216
- fixer.insertTextAfterRange([node.range[1], node.range[1]], ');'),
327
+ fixer.insertTextAfterRange([node.range[1], node.range[1]], defaultExport
328
+ ? `);\nexport default ${node.id.name};`
329
+ : ');'),
217
330
  fixer.replaceTextRange([node.id.range[0] - 1, node.id.range[1]], functionNameReplacement),
218
331
  ];
332
+ if (defaultExport) {
333
+ fixes.push(fixer.removeRange([defaultExport.range[0], node.range[0]]));
334
+ }
219
335
  if (importFix) {
220
336
  fixes.push(importFix);
221
337
  }
@@ -101,6 +101,36 @@ function typeReferenceContainsPickOrOmit(node, propsTypeName) {
101
101
  }
102
102
  return false;
103
103
  }
104
+ /**
105
+ * The statement list a declaration can sit directly inside. A node that holds no
106
+ * statement list yields undefined, so a lexical walk simply steps past it.
107
+ */
108
+ function statementsOf(node) {
109
+ switch (node.type) {
110
+ case utils_1.AST_NODE_TYPES.Program:
111
+ case utils_1.AST_NODE_TYPES.BlockStatement:
112
+ case utils_1.AST_NODE_TYPES.TSModuleBlock:
113
+ case utils_1.AST_NODE_TYPES.StaticBlock:
114
+ return node.body;
115
+ case utils_1.AST_NODE_TYPES.SwitchCase:
116
+ return node.consequent;
117
+ default:
118
+ return undefined;
119
+ }
120
+ }
121
+ /**
122
+ * Look through an `export` wrapper to the declaration it carries. `export type
123
+ * XProps = …` and `export const X = …` are the same declaration one AST node
124
+ * deeper, and the `export` keyword says nothing about what the declaration
125
+ * declares.
126
+ */
127
+ function unwrapExport(statement) {
128
+ if (statement.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration ||
129
+ statement.type === utils_1.AST_NODE_TYPES.ExportDefaultDeclaration) {
130
+ return statement.declaration ?? null;
131
+ }
132
+ return statement;
133
+ }
104
134
  /**
105
135
  * Returns the identifier name of a TSTypeReference node.
106
136
  */
@@ -121,14 +151,14 @@ function getTypeReferenceName(node) {
121
151
  * named-alias indirection, or nested in a TSTypeLiteral property's type
122
152
  * annotation).
123
153
  *
124
- * `program` (when supplied) enables resolving a locally-declared named type
125
- * alias to its definition, so composition can be seen through named union arms
126
- * and shared bases. `seenAliases` guards against recursive-alias cycles; each
127
- * descent *through* an alias extends a copy of the set so that sibling paths
128
- * (e.g. two union arms sharing a base) each resolve the shared alias
129
- * independently.
154
+ * `scope` (when supplied) is the node the alias lookup walks outward from, which
155
+ * enables resolving a locally-declared named type alias to its definition, so
156
+ * composition can be seen through named union arms and shared bases.
157
+ * `seenAliases` guards against recursive-alias cycles; each descent *through* an
158
+ * alias extends a copy of the set so that sibling paths (e.g. two union arms
159
+ * sharing a base) each resolve the shared alias independently.
130
160
  */
131
- function typeNodeComposesWithProps(typeNode, propsTypeName, program, seenAliases = new Set()) {
161
+ function typeNodeComposesWithProps(typeNode, propsTypeName, scope, seenAliases = new Set()) {
132
162
  switch (typeNode.type) {
133
163
  case utils_1.AST_NODE_TYPES.TSTypeReference: {
134
164
  // A direct reference to the child's whole props type (bare `ChildProps`
@@ -144,7 +174,7 @@ function typeNodeComposesWithProps(typeNode, propsTypeName, program, seenAliases
144
174
  // Also recurse into type params (e.g. Readonly<Pick<XProps, ...>>)
145
175
  if (typeNode.typeParameters) {
146
176
  for (const param of typeNode.typeParameters.params) {
147
- if (typeNodeComposesWithProps(param, propsTypeName, program, seenAliases)) {
177
+ if (typeNodeComposesWithProps(param, propsTypeName, scope, seenAliases)) {
148
178
  return true;
149
179
  }
150
180
  }
@@ -154,14 +184,14 @@ function typeNodeComposesWithProps(typeNode, propsTypeName, program, seenAliases
154
184
  // shared bases (issue #1343): `RowActionableProps` → `RowBaseProps & {…}`
155
185
  // → `Pick<MenuItemProps, …>`. Only in-file aliases resolve; imported
156
186
  // names (e.g. MenuItemProps) return null and are left as-is.
157
- if (program) {
187
+ if (scope) {
158
188
  const aliasName = getTypeReferenceName(typeNode);
159
189
  if (aliasName && !seenAliases.has(aliasName)) {
160
- const alias = findPropsTypeAliasByName(program, aliasName);
190
+ const alias = findPropsTypeAliasByName(scope, aliasName);
161
191
  if (alias) {
162
192
  const nextSeen = new Set(seenAliases);
163
193
  nextSeen.add(aliasName);
164
- if (typeNodeComposesWithProps(alias.typeAnnotation, propsTypeName, program, nextSeen)) {
194
+ if (typeNodeComposesWithProps(alias.typeAnnotation, propsTypeName, scope, nextSeen)) {
165
195
  return true;
166
196
  }
167
197
  }
@@ -172,7 +202,7 @@ function typeNodeComposesWithProps(typeNode, propsTypeName, program, seenAliases
172
202
  case utils_1.AST_NODE_TYPES.TSIntersectionType: {
173
203
  // Check each member of an intersection (A & B & C) — the whole
174
204
  // intersection composes if any member does.
175
- return typeNode.types.some((t) => typeNodeComposesWithProps(t, propsTypeName, program, seenAliases));
205
+ return typeNode.types.some((t) => typeNodeComposesWithProps(t, propsTypeName, scope, seenAliases));
176
206
  }
177
207
  case utils_1.AST_NODE_TYPES.TSUnionType: {
178
208
  // A union (A | B) composes if ANY arm composes. `.some` (not `.every`) is
@@ -182,7 +212,7 @@ function typeNodeComposesWithProps(typeNode, propsTypeName, program, seenAliases
182
212
  // every rendered child would flag that legitimate pattern — a false
183
213
  // positive the repo prefers to avoid. `.some` still passes the target
184
214
  // case, where every arm composes with the single shared child.
185
- return typeNode.types.some((t) => typeNodeComposesWithProps(t, propsTypeName, program, seenAliases));
215
+ return typeNode.types.some((t) => typeNodeComposesWithProps(t, propsTypeName, scope, seenAliases));
186
216
  }
187
217
  case utils_1.AST_NODE_TYPES.TSTypeLiteral: {
188
218
  // Check property signatures for nested composition
@@ -190,7 +220,7 @@ function typeNodeComposesWithProps(typeNode, propsTypeName, program, seenAliases
190
220
  return typeNode.members.some((member) => {
191
221
  if (member.type === utils_1.AST_NODE_TYPES.TSPropertySignature &&
192
222
  member.typeAnnotation) {
193
- return typeNodeComposesWithProps(member.typeAnnotation.typeAnnotation, propsTypeName, program, seenAliases);
223
+ return typeNodeComposesWithProps(member.typeAnnotation.typeAnnotation, propsTypeName, scope, seenAliases);
194
224
  }
195
225
  return false;
196
226
  });
@@ -321,24 +351,10 @@ function collectPropSlotNames(funcNode) {
321
351
  }
322
352
  /**
323
353
  * Find the Props type alias node that corresponds to a component by name.
324
- * Looks for `type <ComponentName>Props = ...` in the program body.
354
+ * Looks for `type <ComponentName>Props = ...` in `scope`'s lexical chain.
325
355
  */
326
- function findPropsTypeAlias(program, componentName) {
327
- const expectedTypeName = toPropsTypeName(componentName);
328
- for (const stmt of program.body) {
329
- // type XProps = ...
330
- if (stmt.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration &&
331
- stmt.id.name === expectedTypeName) {
332
- return stmt;
333
- }
334
- // export type XProps = ...
335
- if (stmt.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration &&
336
- stmt.declaration?.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration &&
337
- stmt.declaration.id.name === expectedTypeName) {
338
- return stmt.declaration;
339
- }
340
- }
341
- return null;
356
+ function findPropsTypeAlias(scope, componentName) {
357
+ return findPropsTypeAliasByName(scope, toPropsTypeName(componentName));
342
358
  }
343
359
  /**
344
360
  * Given a component function node, find the props parameter type annotation
@@ -416,58 +432,92 @@ function isPropsPreservingHocCallee(callee) {
416
432
  callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
417
433
  PROPS_PRESERVING_HOCS.has(callee.property.name));
418
434
  }
419
- /**
420
- * Resolve the function node for a component name in the program, following a
421
- * single-identifier alias (`const Live = LiveUnmemoized`) and unwrapping a HOC
422
- * call (`memo((props) => ...)`). Returns null when no function is found.
423
- */
424
- function findComponentFunction(program, name, lookup = {}) {
425
- const seen = lookup.seen ?? new Set();
426
- const nextLookup = { ...lookup, seen };
427
- if (seen.has(name))
428
- return null;
429
- seen.add(name);
430
- for (const stmt of program.body) {
431
- const decl = stmt.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration
432
- ? stmt.declaration
433
- : stmt;
435
+ const UNPROVABLE = { fn: null };
436
+ function findComponentFunctionInStatements(statements, name, lookup, nextLookup) {
437
+ for (const stmt of statements) {
438
+ const decl = unwrapExport(stmt);
434
439
  if (!decl)
435
440
  continue;
436
- if (decl.type === utils_1.AST_NODE_TYPES.FunctionDeclaration &&
441
+ if ((decl.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
442
+ decl.type === utils_1.AST_NODE_TYPES.ClassDeclaration) &&
437
443
  decl.id?.name === name) {
438
- return decl;
444
+ // A class component binds the name without being a ComponentFunction, so
445
+ // it proves nothing and still ends the search.
446
+ return decl.type === utils_1.AST_NODE_TYPES.FunctionDeclaration
447
+ ? { fn: decl }
448
+ : UNPROVABLE;
439
449
  }
440
450
  if (decl.type === utils_1.AST_NODE_TYPES.VariableDeclaration) {
441
451
  for (const declarator of decl.declarations) {
442
452
  if (declarator.id.type !== utils_1.AST_NODE_TYPES.Identifier ||
443
- declarator.id.name !== name ||
444
- !declarator.init) {
453
+ declarator.id.name !== name) {
445
454
  continue;
446
455
  }
447
456
  const init = declarator.init;
457
+ if (!init) {
458
+ return UNPROVABLE;
459
+ }
448
460
  if (init.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
449
461
  init.type === utils_1.AST_NODE_TYPES.FunctionExpression) {
450
- return init;
462
+ return { fn: init };
451
463
  }
452
464
  if (init.type === utils_1.AST_NODE_TYPES.CallExpression) {
453
465
  if (lookup.propsPreservingHocsOnly &&
454
466
  !isPropsPreservingHocCallee(init.callee)) {
455
467
  // The binding IS this call, and the call is not known to preserve
456
468
  // props — so nothing about its props surface is provable.
457
- return null;
469
+ return UNPROVABLE;
458
470
  }
459
471
  const arg0 = init.arguments[0];
460
472
  if (arg0 &&
461
473
  (arg0.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
462
474
  arg0.type === utils_1.AST_NODE_TYPES.FunctionExpression)) {
463
- return arg0;
475
+ return { fn: arg0 };
464
476
  }
477
+ return UNPROVABLE;
465
478
  }
466
479
  if (init.type === utils_1.AST_NODE_TYPES.Identifier) {
467
- return findComponentFunction(program, init.name, nextLookup);
480
+ // The alias target is resolved from the alias's own declaration site,
481
+ // which is the scope the alias was written in — not the site that
482
+ // asked, which may sit several containers deeper.
483
+ return {
484
+ fn: findComponentFunction(declarator, init.name, nextLookup),
485
+ };
468
486
  }
487
+ return UNPROVABLE;
488
+ }
489
+ }
490
+ }
491
+ return null;
492
+ }
493
+ /**
494
+ * Resolve the function node a component name binds to, following a
495
+ * single-identifier alias (`const Live = LiveUnmemoized`) and unwrapping a HOC
496
+ * call (`memo((props) => ...)`). Returns null when no function is found.
497
+ *
498
+ * The search runs from `scope` outward through every enclosing statement
499
+ * container, so a child declared beside the JSX that renders it resolves exactly
500
+ * as a top-level one does. Anchoring at `Program.body` made a nested
501
+ * `const Spinner = memo(() => <div />)` unresolvable, and an unresolvable child
502
+ * is treated as one that takes props: the rule then demanded a `SpinnerProps`
503
+ * that cannot exist, because Spinner declares no parameters (issue #1776).
504
+ */
505
+ function findComponentFunction(scope, name, lookup = {}) {
506
+ const seen = lookup.seen ?? new Set();
507
+ const nextLookup = { ...lookup, seen };
508
+ if (seen.has(name))
509
+ return null;
510
+ seen.add(name);
511
+ let current = scope;
512
+ while (current) {
513
+ const statements = statementsOf(current);
514
+ if (statements) {
515
+ const resolved = findComponentFunctionInStatements(statements, name, lookup, nextLookup);
516
+ if (resolved) {
517
+ return resolved.fn;
469
518
  }
470
519
  }
520
+ current = current.parent;
471
521
  }
472
522
  return null;
473
523
  }
@@ -476,9 +526,15 @@ function findComponentFunction(program, name, lookup = {}) {
476
526
  * parameters has no props surface to compose with, so it is not a composition
477
527
  * dependency (same category as a decorative icon). Only in-file resolution is
478
528
  * used; imported children are left to the normal composition check.
529
+ *
530
+ * `scope` must be anchored at the JSX site's own container rather than at the
531
+ * program: a child declared inside the very component that renders it is the
532
+ * commonplace shape (`memo-nested-react-components` ships as an error, which
533
+ * presumes nested components exist), and reading its unresolvability as "takes
534
+ * props" names a props type the author cannot write.
479
535
  */
480
- function isZeroPropComponent(program, name) {
481
- const fn = findComponentFunction(program, name, {
536
+ function isZeroPropComponent(scope, name) {
537
+ const fn = findComponentFunction(scope, name, {
482
538
  propsPreservingHocsOnly: true,
483
539
  });
484
540
  return fn !== null && fn.params.length === 0;
@@ -892,12 +948,12 @@ function isPropLessImportedComponent(program, localName, filename, componentRoot
892
948
  * first-parameter type annotation. Used to detect inverse composition, where
893
949
  * the child derives its props from the parent's props type.
894
950
  */
895
- function getDependencyPropsSourceType(program, depName) {
896
- const alias = findPropsTypeAliasByName(program, toPropsTypeName(depName));
951
+ function getDependencyPropsSourceType(scope, depName) {
952
+ const alias = findPropsTypeAliasByName(scope, toPropsTypeName(depName));
897
953
  if (alias) {
898
954
  return alias.typeAnnotation;
899
955
  }
900
- const fn = findComponentFunction(program, depName);
956
+ const fn = findComponentFunction(scope, depName);
901
957
  if (fn) {
902
958
  return getFirstParamTypeNode(fn);
903
959
  }
@@ -912,10 +968,10 @@ function getDependencyPropsSourceType(program, depName) {
912
968
  * followed to its definition too, because an alias of a union flattens into the
913
969
  * enclosing union in TypeScript, so its own arms are arms here.
914
970
  */
915
- function collectUnionArmNames(arm, program, seenAliases, into) {
971
+ function collectUnionArmNames(arm, scope, seenAliases, into) {
916
972
  if (arm.type === utils_1.AST_NODE_TYPES.TSUnionType) {
917
973
  for (const nested of arm.types) {
918
- collectUnionArmNames(nested, program, seenAliases, into);
974
+ collectUnionArmNames(nested, scope, seenAliases, into);
919
975
  }
920
976
  return;
921
977
  }
@@ -927,7 +983,7 @@ function collectUnionArmNames(arm, program, seenAliases, into) {
927
983
  // A `Readonly<X>` arm is the X arm: the wrapper adds no surface of its own.
928
984
  const inner = arm.typeParameters?.params[0];
929
985
  if (inner) {
930
- collectUnionArmNames(inner, program, seenAliases, into);
986
+ collectUnionArmNames(inner, scope, seenAliases, into);
931
987
  }
932
988
  return;
933
989
  }
@@ -935,11 +991,11 @@ function collectUnionArmNames(arm, program, seenAliases, into) {
935
991
  return;
936
992
  }
937
993
  into.add(name);
938
- const alias = findPropsTypeAliasByName(program, name);
994
+ const alias = findPropsTypeAliasByName(scope, name);
939
995
  if (alias) {
940
996
  const nextSeen = new Set(seenAliases);
941
997
  nextSeen.add(name);
942
- collectUnionArmNames(alias.typeAnnotation, program, nextSeen, into);
998
+ collectUnionArmNames(alias.typeAnnotation, scope, nextSeen, into);
943
999
  }
944
1000
  }
945
1001
  /**
@@ -952,13 +1008,13 @@ function collectUnionArmNames(arm, program, seenAliases, into) {
952
1008
  * real union is a member, so an alias chain that ends at a single object type
953
1009
  * credits none of the names it passed through.
954
1010
  */
955
- function collectUnionMemberNames(typeNode, program, seenAliases = new Set(), into = new Set()) {
1011
+ function collectUnionMemberNames(typeNode, scope, seenAliases = new Set(), into = new Set()) {
956
1012
  if (!typeNode) {
957
1013
  return into;
958
1014
  }
959
1015
  if (typeNode.type === utils_1.AST_NODE_TYPES.TSUnionType) {
960
1016
  for (const arm of typeNode.types) {
961
- collectUnionArmNames(arm, program, seenAliases, into);
1017
+ collectUnionArmNames(arm, scope, seenAliases, into);
962
1018
  }
963
1019
  return into;
964
1020
  }
@@ -967,16 +1023,16 @@ function collectUnionMemberNames(typeNode, program, seenAliases = new Set(), int
967
1023
  if (name === 'Readonly') {
968
1024
  const inner = typeNode.typeParameters?.params[0];
969
1025
  if (inner) {
970
- collectUnionMemberNames(inner, program, seenAliases, into);
1026
+ collectUnionMemberNames(inner, scope, seenAliases, into);
971
1027
  }
972
1028
  return into;
973
1029
  }
974
1030
  if (name && !seenAliases.has(name)) {
975
- const alias = findPropsTypeAliasByName(program, name);
1031
+ const alias = findPropsTypeAliasByName(scope, name);
976
1032
  if (alias) {
977
1033
  const nextSeen = new Set(seenAliases);
978
1034
  nextSeen.add(name);
979
- collectUnionMemberNames(alias.typeAnnotation, program, nextSeen, into);
1035
+ collectUnionMemberNames(alias.typeAnnotation, scope, nextSeen, into);
980
1036
  }
981
1037
  }
982
1038
  }
@@ -1192,33 +1248,47 @@ exports.requirePropsComposition = (0, createRule_1.createRule)({
1192
1248
  *
1193
1249
  * A dependency whose props type is not a union yields no members, so this
1194
1250
  * never credits a parent that composes with nothing.
1251
+ *
1252
+ * `scope` resolves in-file declarations lexically, while `prog` answers the
1253
+ * questions that are genuinely module-level: an import can only ever appear
1254
+ * at the top of the file, so its spelling does not depend on where the JSX
1255
+ * sits.
1195
1256
  */
1196
- function composesWithUnionMember(dep, propsTypeNode, prog, componentRoot) {
1197
- const localMembers = collectUnionMemberNames(getDependencyPropsSourceType(prog, dep), prog);
1257
+ function composesWithUnionMember(dep, propsTypeNode, prog, scope, componentRoot) {
1258
+ const localMembers = collectUnionMemberNames(getDependencyPropsSourceType(scope, dep), scope);
1198
1259
  for (const member of localMembers) {
1199
- if (typeNodeComposesWithProps(propsTypeNode, member, prog)) {
1260
+ if (typeNodeComposesWithProps(propsTypeNode, member, scope)) {
1200
1261
  return true;
1201
1262
  }
1202
1263
  }
1203
1264
  const importedMembers = getImportedDependencyUnionMembers(prog, dep, rawFilename, componentRoot, cwd);
1204
- return importedMembers.some((member) => collectImportSpellings(prog, member).some((spelling) => typeNodeComposesWithProps(propsTypeNode, spelling, prog)));
1265
+ return importedMembers.some((member) => collectImportSpellings(prog, member).some((spelling) => typeNodeComposesWithProps(propsTypeNode, spelling, scope)));
1205
1266
  }
1206
1267
  function checkComponentWithProgram(componentName, funcNode, reportNode, prog) {
1207
1268
  // Collect all JSX element names used in the component body
1208
1269
  const body = funcNode.body ?? funcNode;
1209
1270
  const allJsxNames = collectJsxElementNames(body);
1210
1271
  const propSlots = collectPropSlotNames(funcNode);
1272
+ // A rendered child is resolved from the JSX site outward, so a component
1273
+ // declared inside this very body is found; the component's own props alias
1274
+ // is resolved from the *declaration* site outward, because a parameter
1275
+ // annotation is read in the scope enclosing the function, never in its
1276
+ // body. `bodyScope`'s chain contains `declarationScope`'s, so the two only
1277
+ // differ over declarations local to the body — exactly the nested children
1278
+ // the dependency lookups must see and the props lookups must not.
1279
+ const bodyScope = body;
1280
+ const declarationScope = funcNode;
1211
1281
  // Filter to non-excluded custom components
1212
1282
  const depComponents = Array.from(allJsxNames).filter((name) => !excludeComponents.has(name) &&
1213
1283
  !isDecorativeIcon(name) &&
1214
1284
  name !== componentName &&
1215
1285
  !propSlots.has(name) &&
1216
- !isZeroPropComponent(prog, name));
1286
+ !isZeroPropComponent(bodyScope, name));
1217
1287
  if (depComponents.length < minDependencyCount) {
1218
1288
  return;
1219
1289
  }
1220
1290
  // Resolve the props type for this component
1221
- const propsTypeAlias = findPropsTypeAlias(prog, componentName);
1291
+ const propsTypeAlias = findPropsTypeAlias(declarationScope, componentName);
1222
1292
  let propsTypeName = null;
1223
1293
  let propsTypeNode = null;
1224
1294
  if (propsTypeAlias) {
@@ -1233,8 +1303,8 @@ exports.requirePropsComposition = (0, createRule_1.createRule)({
1233
1303
  return;
1234
1304
  }
1235
1305
  propsTypeName = paramTypeName;
1236
- // Try to find this type alias in the program too
1237
- const resolved = findPropsTypeAliasByName(prog, paramTypeName);
1306
+ // Try to find this type alias in scope too
1307
+ const resolved = findPropsTypeAliasByName(declarationScope, paramTypeName);
1238
1308
  if (resolved) {
1239
1309
  propsTypeNode = resolved.typeAnnotation;
1240
1310
  }
@@ -1247,7 +1317,7 @@ exports.requirePropsComposition = (0, createRule_1.createRule)({
1247
1317
  const missingComposition = [];
1248
1318
  for (const dep of depComponents) {
1249
1319
  const expectedPropsType = toPropsTypeName(dep);
1250
- let composes = typeNodeComposesWithProps(propsTypeNode, expectedPropsType, prog);
1320
+ let composes = typeNodeComposesWithProps(propsTypeNode, expectedPropsType, declarationScope);
1251
1321
  // Inverse composition: the child derives its props FROM this parent's
1252
1322
  // props type (e.g. `Omit<ParentProps, 'children'>`, often with no named
1253
1323
  // ChildProps at all). The parent is then the single shared source of
@@ -1255,9 +1325,9 @@ exports.requirePropsComposition = (0, createRule_1.createRule)({
1255
1325
  // *also* compose from ChildProps would invert the source of truth or
1256
1326
  // create a circular dependency.
1257
1327
  if (!composes && propsTypeName) {
1258
- const depPropsSource = getDependencyPropsSourceType(prog, dep);
1328
+ const depPropsSource = getDependencyPropsSourceType(bodyScope, dep);
1259
1329
  if (depPropsSource &&
1260
- typeNodeComposesWithProps(depPropsSource, propsTypeName, prog)) {
1330
+ typeNodeComposesWithProps(depPropsSource, propsTypeName, bodyScope)) {
1261
1331
  composes = true;
1262
1332
  }
1263
1333
  }
@@ -1281,7 +1351,7 @@ exports.requirePropsComposition = (0, createRule_1.createRule)({
1281
1351
  (requireAllDependencies || composedWith.size === 0)) {
1282
1352
  for (let index = missingComposition.length - 1; index >= 0; index--) {
1283
1353
  const dep = missingComposition[index];
1284
- if (composesWithUnionMember(dep, propsTypeNode, prog, funcNode)) {
1354
+ if (composesWithUnionMember(dep, propsTypeNode, prog, bodyScope, funcNode)) {
1285
1355
  composedWith.add(dep);
1286
1356
  missingComposition.splice(index, 1);
1287
1357
  }
@@ -1331,19 +1401,31 @@ exports.requirePropsComposition = (0, createRule_1.createRule)({
1331
1401
  },
1332
1402
  });
1333
1403
  /**
1334
- * Find a type alias by name anywhere in the program body (exported or not).
1404
+ * Find a type alias by name (exported or not), searching from `scope` outward
1405
+ * through every enclosing statement container.
1406
+ *
1407
+ * Scanning `Program.body` alone made the *depth* of a declaration decide whether
1408
+ * it exists, a distinction a props type knows nothing about: a component and its
1409
+ * props alias written inside a factory, a `describe` block or an
1410
+ * `export namespace` resolved to nothing, so the rule returned early and went
1411
+ * silent (issue #1776). The innermost container wins, so an alias declared
1412
+ * beside the component shadows a same-named one further out — a file-wide search
1413
+ * would instead hand one scope's verdict to another.
1335
1414
  */
1336
- function findPropsTypeAliasByName(program, typeName) {
1337
- for (const stmt of program.body) {
1338
- if (stmt.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration &&
1339
- stmt.id.name === typeName) {
1340
- return stmt;
1341
- }
1342
- if (stmt.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration &&
1343
- stmt.declaration?.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration &&
1344
- stmt.declaration.id.name === typeName) {
1345
- return stmt.declaration;
1415
+ function findPropsTypeAliasByName(scope, typeName) {
1416
+ let current = scope;
1417
+ while (current) {
1418
+ const statements = statementsOf(current);
1419
+ if (statements) {
1420
+ for (const stmt of statements) {
1421
+ const declaration = unwrapExport(stmt);
1422
+ if (declaration?.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration &&
1423
+ declaration.id.name === typeName) {
1424
+ return declaration;
1425
+ }
1426
+ }
1346
1427
  }
1428
+ current = current.parent;
1347
1429
  }
1348
1430
  return null;
1349
1431
  }
@@ -69,6 +69,22 @@ export declare class ASTHelpers {
69
69
  private static readonly TRANSPARENT_WRAPPER_CALLEES;
70
70
  private static isFunctionNode;
71
71
  private static isTransparentWrapperCall;
72
+ /**
73
+ * Calls whose function argument IS a component render function. `memo`,
74
+ * `forwardRef` and `observer` define a component out of the callback they are
75
+ * handed, so that callback is a render path even with no binding to take a
76
+ * name from. `useCallback`/`useMemo` are deliberately absent: they wrap a
77
+ * value or an event handler produced inside a component, not a component.
78
+ */
79
+ private static readonly COMPONENT_DEFINING_CALLEES;
80
+ private static hasCalleeNamed;
81
+ /**
82
+ * Whether an anonymous function is the argument of a call that defines a
83
+ * component from it (`memo(() => <div />)`, `React.forwardRef((p, ref) =>
84
+ * ...)`). TS assertions between the function and the call are stepped over so
85
+ * `memo((() => <div />) as FC)` classifies the same way.
86
+ */
87
+ private static isComponentDefiningArgument;
72
88
  private static staticPropertyName;
73
89
  /**
74
90
  * Resolves the name a function is known by: its own identifier, or the
@@ -97,9 +113,20 @@ export declare class ASTHelpers {
97
113
  * that name alone — `buildTree` is not a component even though it returns
98
114
  * JSX, because the name is an explicit signal about its role. Only a truly
99
115
  * anonymous function falls back to "does it return JSX", which is what makes
100
- * `memo(() => <div />)` a component. That fallback is suppressed when some
101
- * enclosing function carries a non-component name, since a callback nested in
102
- * a plain helper is no more of a render path than the helper itself.
116
+ * `memo(() => <div />)` a component.
117
+ *
118
+ * Both component spellings answer the moment they are met, walking outwards,
119
+ * so the question stays RELATIVE: is a render function interposed between the
120
+ * node and whatever encloses it further out? A component nested in a plain
121
+ * helper is still a component, and the hook is legal inside it — `function
122
+ * makeCard() { return memo(() => <X onClick={...} />); }` is a render path
123
+ * even though `makeCard` is not.
124
+ *
125
+ * The remaining `hasNamedNonComponent` veto only settles the case where no
126
+ * component was found at all. It keeps a bare callback such as
127
+ * `items.map((i) => <Row />)` inside a plain helper silent: that callback is
128
+ * anonymous and returns JSX, but nothing turns it into a component, so it is
129
+ * no more of a render path than the helper holding it.
103
130
  */
104
131
  static isInsideComponentOrHook(node: TSESTree.Node, context?: Readonly<TSESLint.RuleContext<string, readonly unknown[]>>): boolean;
105
132
  /**
@@ -744,14 +744,43 @@ class ASTHelpers {
744
744
  node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration);
745
745
  }
746
746
  static isTransparentWrapperCall(node) {
747
+ return this.hasCalleeNamed(node, this.TRANSPARENT_WRAPPER_CALLEES);
748
+ }
749
+ static hasCalleeNamed(node, names) {
747
750
  const { callee } = node;
748
751
  if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
749
- return this.TRANSPARENT_WRAPPER_CALLEES.has(callee.name);
752
+ return names.has(callee.name);
750
753
  }
751
754
  return (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
752
755
  !callee.computed &&
753
756
  callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
754
- this.TRANSPARENT_WRAPPER_CALLEES.has(callee.property.name));
757
+ names.has(callee.property.name));
758
+ }
759
+ /**
760
+ * Whether an anonymous function is the argument of a call that defines a
761
+ * component from it (`memo(() => <div />)`, `React.forwardRef((p, ref) =>
762
+ * ...)`). TS assertions between the function and the call are stepped over so
763
+ * `memo((() => <div />) as FC)` classifies the same way.
764
+ */
765
+ static isComponentDefiningArgument(node) {
766
+ let child = node;
767
+ let parent = node.parent;
768
+ while (parent) {
769
+ if (parent.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
770
+ parent.type === utils_1.AST_NODE_TYPES.TSSatisfiesExpression ||
771
+ parent.type === utils_1.AST_NODE_TYPES.TSNonNullExpression ||
772
+ parent.type === utils_1.AST_NODE_TYPES.TSTypeAssertion) {
773
+ child = parent;
774
+ parent = parent.parent;
775
+ continue;
776
+ }
777
+ if (parent.type !== utils_1.AST_NODE_TYPES.CallExpression) {
778
+ return false;
779
+ }
780
+ return (parent.arguments.includes(child) &&
781
+ this.hasCalleeNamed(parent, this.COMPONENT_DEFINING_CALLEES));
782
+ }
783
+ return false;
755
784
  }
756
785
  static staticPropertyName(key, computed) {
757
786
  if (computed) {
@@ -844,9 +873,20 @@ class ASTHelpers {
844
873
  * that name alone — `buildTree` is not a component even though it returns
845
874
  * JSX, because the name is an explicit signal about its role. Only a truly
846
875
  * anonymous function falls back to "does it return JSX", which is what makes
847
- * `memo(() => <div />)` a component. That fallback is suppressed when some
848
- * enclosing function carries a non-component name, since a callback nested in
849
- * a plain helper is no more of a render path than the helper itself.
876
+ * `memo(() => <div />)` a component.
877
+ *
878
+ * Both component spellings answer the moment they are met, walking outwards,
879
+ * so the question stays RELATIVE: is a render function interposed between the
880
+ * node and whatever encloses it further out? A component nested in a plain
881
+ * helper is still a component, and the hook is legal inside it — `function
882
+ * makeCard() { return memo(() => <X onClick={...} />); }` is a render path
883
+ * even though `makeCard` is not.
884
+ *
885
+ * The remaining `hasNamedNonComponent` veto only settles the case where no
886
+ * component was found at all. It keeps a bare callback such as
887
+ * `items.map((i) => <Row />)` inside a plain helper silent: that callback is
888
+ * anonymous and returns JSX, but nothing turns it into a component, so it is
889
+ * no more of a render path than the helper holding it.
850
890
  */
851
891
  static isInsideComponentOrHook(node, context) {
852
892
  const anonymousFunctions = [];
@@ -856,6 +896,10 @@ class ASTHelpers {
856
896
  if (this.isFunctionNode(current)) {
857
897
  const name = this.inferFunctionName(current);
858
898
  if (name === null) {
899
+ if (this.isComponentDefiningArgument(current) &&
900
+ this.returnsJSX(current, context)) {
901
+ return true;
902
+ }
859
903
  anonymousFunctions.push(current);
860
904
  }
861
905
  else if (this.isComponentOrHookName(name)) {
@@ -894,5 +938,17 @@ ASTHelpers.TRANSPARENT_WRAPPER_CALLEES = new Set([
894
938
  'useCallback',
895
939
  'useMemo',
896
940
  ]);
941
+ /**
942
+ * Calls whose function argument IS a component render function. `memo`,
943
+ * `forwardRef` and `observer` define a component out of the callback they are
944
+ * handed, so that callback is a render path even with no binding to take a
945
+ * name from. `useCallback`/`useMemo` are deliberately absent: they wrap a
946
+ * value or an event handler produced inside a component, not a component.
947
+ */
948
+ ASTHelpers.COMPONENT_DEFINING_CALLEES = new Set([
949
+ 'forwardRef',
950
+ 'memo',
951
+ 'observer',
952
+ ]);
897
953
  exports.ASTHelpers = ASTHelpers;
898
954
  //# sourceMappingURL=ASTHelpers.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.117",
3
+ "version": "1.20.118",
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.118",
4
+ "date": "2026-08-06T03:08:14.815Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-callback-memo",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1777
11
+ ],
12
+ "summary": "answer the anonymous component where it is met (closes #1777)"
13
+ },
14
+ {
15
+ "name": "enforce-firestore-set-merge",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1773
19
+ ],
20
+ "summary": "resolve the base class lexically too (closes #1773)"
21
+ },
22
+ {
23
+ "name": "prefer-block-comments-for-declarations",
24
+ "changeType": "fix",
25
+ "issues": [
26
+ 1775
27
+ ],
28
+ "summary": "anchor the comment lookup on the export wrapper (closes #1775)"
29
+ },
30
+ {
31
+ "name": "require-memo",
32
+ "changeType": "fix",
33
+ "issues": [
34
+ 1774
35
+ ],
36
+ "summary": "claim a component by lifetime, not by parent node type (closes #1774)"
37
+ },
38
+ {
39
+ "name": "require-props-composition",
40
+ "changeType": "fix",
41
+ "issues": [
42
+ 1776
43
+ ],
44
+ "summary": "resolve names lexically in both directions (closes #1776)"
45
+ }
46
+ ]
47
+ },
2
48
  {
3
49
  "version": "1.20.117",
4
50
  "date": "2026-08-06T01:05:58.683Z",