@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 +1 -1
- package/lib/rules/enforce-callback-memo.js +7 -0
- package/lib/rules/enforce-firestore-set-merge.js +51 -13
- package/lib/rules/prefer-block-comments-for-declarations.js +28 -2
- package/lib/rules/require-memo.js +135 -19
- package/lib/rules/require-props-composition.js +174 -92
- package/lib/utils/ASTHelpers.d.ts +30 -3
- package/lib/utils/ASTHelpers.js +61 -5
- package/package.json +1 -1
- package/release-manifest.json +46 -0
package/lib/index.js
CHANGED
|
@@ -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 — `
|
|
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
|
-
*
|
|
289
|
-
*
|
|
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
|
-
|
|
292
|
-
function
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
297
|
-
for (const statement of
|
|
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
|
-
|
|
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
|
-
|
|
319
|
+
classes.set(declarator.id.name, declarator.init.body);
|
|
312
320
|
}
|
|
313
321
|
}
|
|
314
322
|
}
|
|
315
323
|
}
|
|
316
|
-
|
|
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 =
|
|
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
|
|
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,
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
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:
|
|
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
|
-
* `
|
|
125
|
-
* alias to its definition, so
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
* (e.g. two union arms
|
|
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,
|
|
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,
|
|
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 (
|
|
187
|
+
if (scope) {
|
|
158
188
|
const aliasName = getTypeReferenceName(typeNode);
|
|
159
189
|
if (aliasName && !seenAliases.has(aliasName)) {
|
|
160
|
-
const alias = findPropsTypeAliasByName(
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
354
|
+
* Looks for `type <ComponentName>Props = ...` in `scope`'s lexical chain.
|
|
325
355
|
*/
|
|
326
|
-
function findPropsTypeAlias(
|
|
327
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
481
|
-
const fn = findComponentFunction(
|
|
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(
|
|
896
|
-
const alias = findPropsTypeAliasByName(
|
|
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(
|
|
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,
|
|
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,
|
|
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,
|
|
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(
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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(
|
|
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,
|
|
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(
|
|
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,
|
|
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,
|
|
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(
|
|
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(
|
|
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
|
|
1237
|
-
const resolved = findPropsTypeAliasByName(
|
|
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,
|
|
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(
|
|
1328
|
+
const depPropsSource = getDependencyPropsSourceType(bodyScope, dep);
|
|
1259
1329
|
if (depPropsSource &&
|
|
1260
|
-
typeNodeComposesWithProps(depPropsSource, propsTypeName,
|
|
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
|
|
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(
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
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.
|
|
101
|
-
*
|
|
102
|
-
*
|
|
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
|
/**
|
package/lib/utils/ASTHelpers.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
848
|
-
*
|
|
849
|
-
*
|
|
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
package/release-manifest.json
CHANGED
|
@@ -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",
|