@blumintinc/eslint-plugin-blumint 1.18.12 → 1.18.14
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/no-compositing-layer-props.js +35 -0
- package/lib/rules/no-entire-object-hook-deps.js +29 -32
- package/lib/rules/parallelize-async-operations.js +69 -0
- package/lib/rules/react-memoize-literals.js +61 -0
- package/lib/rules/require-props-composition.js +96 -1
- package/package.json +1 -1
- package/release-manifest.json +52 -0
package/lib/index.js
CHANGED
|
@@ -104,6 +104,38 @@ exports.noCompositingLayerProps = (0, createRule_1.createRule)({
|
|
|
104
104
|
}
|
|
105
105
|
return false;
|
|
106
106
|
}
|
|
107
|
+
// A property key names a `@keyframes` at-rule when it is a string literal
|
|
108
|
+
// (`'@keyframes spin'`) or a template literal whose leading text starts the
|
|
109
|
+
// at-rule (`` [`@keyframes ${name}`] `` for a dynamically-named animation).
|
|
110
|
+
function keyNamesKeyframes(key) {
|
|
111
|
+
if (key.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
112
|
+
typeof key.value === 'string') {
|
|
113
|
+
return /^\s*@keyframes/i.test(key.value);
|
|
114
|
+
}
|
|
115
|
+
if (key.type === utils_1.AST_NODE_TYPES.TemplateLiteral &&
|
|
116
|
+
key.quasis.length > 0) {
|
|
117
|
+
const leading = key.quasis[0].value.cooked ?? key.quasis[0].value.raw;
|
|
118
|
+
return /^\s*@keyframes/i.test(leading);
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
// Compositing props declared inside a `@keyframes` block are exempt.
|
|
123
|
+
// Animating `transform`/`opacity` via `@keyframes` is the web-standard,
|
|
124
|
+
// GPU-accelerated animation pattern; this rule targets gratuitous *static*
|
|
125
|
+
// layer promotion, not a deliberate keyframe animation. Scoped to
|
|
126
|
+
// descendants of the `@keyframes` value object, so a static compositing prop
|
|
127
|
+
// sitting as a *sibling* of the `@keyframes` key is still flagged.
|
|
128
|
+
function isInKeyframes(node) {
|
|
129
|
+
let current = node.parent;
|
|
130
|
+
while (current) {
|
|
131
|
+
if (current.type === utils_1.AST_NODE_TYPES.Property &&
|
|
132
|
+
keyNamesKeyframes(current.key)) {
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
current = current.parent;
|
|
136
|
+
}
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
107
139
|
function isStyleContext(node) {
|
|
108
140
|
let current = node;
|
|
109
141
|
while (current?.parent) {
|
|
@@ -145,6 +177,9 @@ exports.noCompositingLayerProps = (0, createRule_1.createRule)({
|
|
|
145
177
|
// Skip if not in a style context
|
|
146
178
|
if (!isStyleContext(node))
|
|
147
179
|
return;
|
|
180
|
+
// Skip compositing props inside a @keyframes animation definition
|
|
181
|
+
if (isInKeyframes(node))
|
|
182
|
+
return;
|
|
148
183
|
let propertyName = '';
|
|
149
184
|
let propertyValue = '';
|
|
150
185
|
// Get property name
|
|
@@ -65,9 +65,7 @@ function unwrapExpression(expr) {
|
|
|
65
65
|
}
|
|
66
66
|
return current;
|
|
67
67
|
}
|
|
68
|
-
function getObjectUsagesInHook(hookBody, objectName
|
|
69
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
70
|
-
context) {
|
|
68
|
+
function getObjectUsagesInHook(hookBody, objectName) {
|
|
71
69
|
const usages = new Map(); // Track usage and its position
|
|
72
70
|
const visited = new Set();
|
|
73
71
|
let needsEntireObject = false;
|
|
@@ -133,8 +131,15 @@ context) {
|
|
|
133
131
|
const memberExpr = current;
|
|
134
132
|
// Handle computed properties (like array indices)
|
|
135
133
|
if (memberExpr.computed) {
|
|
136
|
-
//
|
|
137
|
-
|
|
134
|
+
// why: only a *literal* computed key (obj[0], obj['special-key'])
|
|
135
|
+
// narrows to a single, stable field. EVERY other computed key —
|
|
136
|
+
// Identifier (obj[i]), CallExpression (obj[assertSafe(i)]),
|
|
137
|
+
// BinaryExpression (obj[i+1]), MemberExpression (obj[keys[j]]),
|
|
138
|
+
// TSAsExpression (obj[k as K]), TemplateLiteral (obj[`row-${i}`]), etc.
|
|
139
|
+
// — is a dynamic access that can read arbitrary elements across an
|
|
140
|
+
// iteration. There is no single narrowable field, so the whole object
|
|
141
|
+
// is a legitimate dependency: resolve the base and mark it accordingly.
|
|
142
|
+
if (memberExpr.property.type !== utils_1.AST_NODE_TYPES.Literal) {
|
|
138
143
|
// Check if this is accessing our target object
|
|
139
144
|
let currentBase = unwrapExpression(memberExpr.object);
|
|
140
145
|
while (currentBase.type === utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
@@ -142,35 +147,23 @@ context) {
|
|
|
142
147
|
}
|
|
143
148
|
if (currentBase.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
144
149
|
currentBase.name === objectName) {
|
|
145
|
-
// This is a computed property access on our target object,
|
|
150
|
+
// This is a dynamic computed property access on our target object,
|
|
151
|
+
// so we need the entire object (no narrowable field exists).
|
|
146
152
|
needsEntireObject = true;
|
|
147
153
|
}
|
|
148
154
|
return null;
|
|
149
155
|
}
|
|
150
156
|
// For computed properties with literals
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
parts.unshift(`[${JSON.stringify(literalProp.value)}]`);
|
|
158
|
-
}
|
|
159
|
-
else {
|
|
160
|
-
// For other computed properties, use a wildcard
|
|
161
|
-
parts.unshift('[*]');
|
|
162
|
-
}
|
|
157
|
+
const literalProp = memberExpr.property;
|
|
158
|
+
if (typeof literalProp.value === 'number') {
|
|
159
|
+
parts.unshift(`[${literalProp.value}]`);
|
|
160
|
+
}
|
|
161
|
+
else if (typeof literalProp.value === 'string') {
|
|
162
|
+
parts.unshift(`[${JSON.stringify(literalProp.value)}]`);
|
|
163
163
|
}
|
|
164
164
|
else {
|
|
165
|
-
// For other computed properties
|
|
166
|
-
|
|
167
|
-
const propertyText = context.sourceCode.getText(memberExpr.property);
|
|
168
|
-
parts.unshift(`[${propertyText}]`);
|
|
169
|
-
}
|
|
170
|
-
catch (e) {
|
|
171
|
-
// Fallback to wildcard if we can't get the source text
|
|
172
|
-
parts.unshift('[*]');
|
|
173
|
-
}
|
|
165
|
+
// For other literal computed properties (e.g. boolean/null), wildcard
|
|
166
|
+
parts.unshift('[*]');
|
|
174
167
|
}
|
|
175
168
|
}
|
|
176
169
|
else {
|
|
@@ -184,7 +177,7 @@ context) {
|
|
|
184
177
|
STRING_METHODS.has(memberExpr.property.name))) {
|
|
185
178
|
// Check if this is accessing our target object or a property of it
|
|
186
179
|
let currentBase = unwrapExpression(memberExpr.object);
|
|
187
|
-
|
|
180
|
+
const pathParts = [];
|
|
188
181
|
let hasOptionalChainingInMethod = false;
|
|
189
182
|
// Build the path to the array/string being accessed
|
|
190
183
|
while (currentBase.type === utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
@@ -352,7 +345,8 @@ context) {
|
|
|
352
345
|
effectiveParent.type === utils_1.AST_NODE_TYPES.TSNonNullExpression)) {
|
|
353
346
|
effectiveParent = effectiveParent.parent;
|
|
354
347
|
}
|
|
355
|
-
const isIntermediate = effectiveParent &&
|
|
348
|
+
const isIntermediate = effectiveParent &&
|
|
349
|
+
effectiveParent.type === utils_1.AST_NODE_TYPES.MemberExpression;
|
|
356
350
|
if (!isIntermediate) {
|
|
357
351
|
// Check if this member expression involves our target object
|
|
358
352
|
let current = memberExpr;
|
|
@@ -361,9 +355,12 @@ context) {
|
|
|
361
355
|
// Walk up the member expression chain to see if it involves our target object
|
|
362
356
|
while (current.type === utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
363
357
|
const currentMember = current;
|
|
364
|
-
// Check if this level uses dynamic computed property access
|
|
358
|
+
// Check if this level uses dynamic computed property access.
|
|
359
|
+
// why: any non-literal computed key reads arbitrary elements, so it
|
|
360
|
+
// requires the entire object. Only a literal key (obj[0], obj['k'])
|
|
361
|
+
// narrows to a single, stable field.
|
|
365
362
|
if (currentMember.computed &&
|
|
366
|
-
currentMember.property.type
|
|
363
|
+
currentMember.property.type !== utils_1.AST_NODE_TYPES.Literal) {
|
|
367
364
|
hasDynamicComputed = true;
|
|
368
365
|
}
|
|
369
366
|
current = unwrapExpression(currentMember.object);
|
|
@@ -582,7 +579,7 @@ exports.noEntireObjectHookDeps = (0, createRule_1.createRule)({
|
|
|
582
579
|
}
|
|
583
580
|
}
|
|
584
581
|
// For testing without TypeScript services, we'll assume all identifiers are objects
|
|
585
|
-
const result = getObjectUsagesInHook(callbackArg.body, objectName
|
|
582
|
+
const result = getObjectUsagesInHook(callbackArg.body, objectName);
|
|
586
583
|
// If the object is not used at all, suggest removing it
|
|
587
584
|
if (result.notUsed) {
|
|
588
585
|
context.report({
|
|
@@ -206,6 +206,50 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
|
|
|
206
206
|
}
|
|
207
207
|
return null;
|
|
208
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* Extracts the bare receiver identifier of an awaited *named-method* call:
|
|
211
|
+
* the `object` of a `MemberExpression` callee when that object is a plain
|
|
212
|
+
* identifier and the accessed member is a named method -- either a
|
|
213
|
+
* non-computed property (`versionRef.set(...)`) or a computed string-literal
|
|
214
|
+
* key (`api['getData']()`). Both denote invoking a method on the shared
|
|
215
|
+
* receiver, so they are equivalent for ordering purposes.
|
|
216
|
+
*
|
|
217
|
+
* Returns null when the receiver is not a bare identifier -- a computed
|
|
218
|
+
* chain (`realtimeDb.ref(path).remove()`), a nested member
|
|
219
|
+
* (`api.users.getAll()`), a `this`/`super` receiver, or a non-member callee.
|
|
220
|
+
* Also returns null for a numeric or dynamic index (`operations[0]()`,
|
|
221
|
+
* `operations[i]()`): those select a distinct callable from a container
|
|
222
|
+
* rather than invoking a method on a stateful receiver, so they are
|
|
223
|
+
* genuinely independent and must not be collapsed onto a shared receiver.
|
|
224
|
+
* Handles optional-call ChainExpressions.
|
|
225
|
+
*/
|
|
226
|
+
function getCalleeReceiverName(awaitExpr) {
|
|
227
|
+
let callExpr = null;
|
|
228
|
+
if (awaitExpr.argument.type === utils_1.AST_NODE_TYPES.CallExpression) {
|
|
229
|
+
callExpr = awaitExpr.argument;
|
|
230
|
+
}
|
|
231
|
+
else if (awaitExpr.argument.type === utils_1.AST_NODE_TYPES.ChainExpression &&
|
|
232
|
+
awaitExpr.argument.expression.type === utils_1.AST_NODE_TYPES.CallExpression) {
|
|
233
|
+
callExpr = awaitExpr.argument.expression;
|
|
234
|
+
}
|
|
235
|
+
if (!callExpr) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
const callee = callExpr.callee;
|
|
239
|
+
if (callee.type !== utils_1.AST_NODE_TYPES.MemberExpression ||
|
|
240
|
+
callee.object.type !== utils_1.AST_NODE_TYPES.Identifier) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
const property = callee.property;
|
|
244
|
+
const isNamedMember = callee.computed
|
|
245
|
+
? property.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
246
|
+
typeof property.value === 'string'
|
|
247
|
+
: property.type === utils_1.AST_NODE_TYPES.Identifier;
|
|
248
|
+
if (!isNamedMember) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
return callee.object.name;
|
|
252
|
+
}
|
|
209
253
|
/**
|
|
210
254
|
* Checks if there are dependencies between await expressions
|
|
211
255
|
*/
|
|
@@ -290,6 +334,31 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
|
|
|
290
334
|
}
|
|
291
335
|
}
|
|
292
336
|
}
|
|
337
|
+
// 6. Shared-receiver ordering barrier. Two awaited calls whose callees are
|
|
338
|
+
// member expressions on the SAME receiver identifier (e.g. `ref.set(x)`
|
|
339
|
+
// then `ref.get()`) can carry a read-after-write / write-after-write
|
|
340
|
+
// dependency: the later call may observe or overwrite state the earlier
|
|
341
|
+
// one produced on that shared object. Promise.all runs its operands
|
|
342
|
+
// concurrently, so it would race that ordering and let the read see the
|
|
343
|
+
// stale value. Keep such a run sequential. Skipping a genuinely
|
|
344
|
+
// independent pair of reads on one receiver is only a missed
|
|
345
|
+
// parallelization (a safe no-op), which is preferable to silently
|
|
346
|
+
// reordering a real data dependency. The receiver must be a bare
|
|
347
|
+
// identifier; computed chains and nested members are left untouched.
|
|
348
|
+
const receiverNames = awaitNodes.map((node) => {
|
|
349
|
+
const awaitExpr = getAwaitExpression(node);
|
|
350
|
+
return awaitExpr ? getCalleeReceiverName(awaitExpr) : null;
|
|
351
|
+
});
|
|
352
|
+
for (let i = 1; i < receiverNames.length; i++) {
|
|
353
|
+
const receiver = receiverNames[i];
|
|
354
|
+
if (!receiver)
|
|
355
|
+
continue;
|
|
356
|
+
for (let j = 0; j < i; j++) {
|
|
357
|
+
if (receiverNames[j] === receiver) {
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
293
362
|
return false;
|
|
294
363
|
}
|
|
295
364
|
/**
|
|
@@ -62,6 +62,30 @@ const HOOKS_ALLOWING_NESTED_LITERALS = new Set([
|
|
|
62
62
|
* MUI's `sx` and the standard `style` prop both fall into this category.
|
|
63
63
|
*/
|
|
64
64
|
const STYLE_JSX_ATTRIBUTE_NAMES = new Set(['sx', 'style']);
|
|
65
|
+
/**
|
|
66
|
+
* Array iteration higher-order methods that invoke their callback argument
|
|
67
|
+
* synchronously and then discard it. A function literal passed directly as such
|
|
68
|
+
* a callback is never observed by any hook dependency, prop, effect, or memoized
|
|
69
|
+
* child, so re-creating it each render costs nothing and memoizing it buys
|
|
70
|
+
* nothing — the same "identity is never observably compared" rationale that
|
|
71
|
+
* exempts throw-argument literals. Matched by (non-computed) method name only;
|
|
72
|
+
* the rule is not type-aware, mirroring how the hook-argument sets work.
|
|
73
|
+
*/
|
|
74
|
+
const ITERATION_METHODS = new Set([
|
|
75
|
+
'map',
|
|
76
|
+
'filter',
|
|
77
|
+
'forEach',
|
|
78
|
+
'reduce',
|
|
79
|
+
'reduceRight',
|
|
80
|
+
'some',
|
|
81
|
+
'every',
|
|
82
|
+
'find',
|
|
83
|
+
'findIndex',
|
|
84
|
+
'findLast',
|
|
85
|
+
'findLastIndex',
|
|
86
|
+
'flatMap',
|
|
87
|
+
'sort',
|
|
88
|
+
]);
|
|
65
89
|
const MEMOIZATION_DEPS_TODO_PLACEHOLDER = '__TODO_MEMOIZATION_DEPENDENCIES__';
|
|
66
90
|
const TODO_DEPS_COMMENT = `/* ${MEMOIZATION_DEPS_TODO_PLACEHOLDER} */`;
|
|
67
91
|
const PARENTHESIZED_EXPRESSION_TYPE = utils_1.AST_NODE_TYPES.ParenthesizedExpression ??
|
|
@@ -82,6 +106,31 @@ function isHookName(name) {
|
|
|
82
106
|
function isComponentName(name) {
|
|
83
107
|
return !!name && /^[A-Z]/.test(name);
|
|
84
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* True when `node` is a function literal passed directly as an argument to an
|
|
111
|
+
* Array iteration method call (`items.map((x) => ...)`,
|
|
112
|
+
* `list.filter(fn)`, `xs.reduce(fn, init)`, ...). Such a callback is invoked
|
|
113
|
+
* synchronously during render and then discarded, so its referential identity
|
|
114
|
+
* is never observed. Matched by (non-computed) method name only, since the rule
|
|
115
|
+
* is not type-aware.
|
|
116
|
+
*/
|
|
117
|
+
function isIterationMethodCallback(node) {
|
|
118
|
+
if (node.type !== utils_1.AST_NODE_TYPES.ArrowFunctionExpression &&
|
|
119
|
+
node.type !== utils_1.AST_NODE_TYPES.FunctionExpression) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
const parent = node.parent;
|
|
123
|
+
if (!parent ||
|
|
124
|
+
parent.type !== utils_1.AST_NODE_TYPES.CallExpression ||
|
|
125
|
+
!parent.arguments.includes(node)) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
const callee = parent.callee;
|
|
129
|
+
return (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
130
|
+
!callee.computed &&
|
|
131
|
+
callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
132
|
+
ITERATION_METHODS.has(callee.property.name));
|
|
133
|
+
}
|
|
85
134
|
/**
|
|
86
135
|
* Type guard for parenthesized expressions to unwrap safely.
|
|
87
136
|
* @param node Node to evaluate.
|
|
@@ -773,6 +822,18 @@ exports.reactMemoizeLiterals = (0, createRule_1.createRule)({
|
|
|
773
822
|
if (isTerminalUsage(node)) {
|
|
774
823
|
return;
|
|
775
824
|
}
|
|
825
|
+
// A function literal passed directly as the callback to an Array
|
|
826
|
+
// iteration method (.map/.filter/.reduce/.forEach/...) is invoked
|
|
827
|
+
// synchronously during render and then discarded, so its identity is
|
|
828
|
+
// never observed by a hook dependency, prop, effect, or memoized child —
|
|
829
|
+
// memoizing it buys nothing. The rule's own advice is also unfollowable
|
|
830
|
+
// here: the callback closes over loop/render scope so it can't be hoisted
|
|
831
|
+
// to module level, and useCallback can't run inside a .map loop. Inline
|
|
832
|
+
// functions passed as JSX-attribute props *inside* the callback body are
|
|
833
|
+
// separate nodes and remain flagged.
|
|
834
|
+
if (isIterationMethodCallback(node)) {
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
776
837
|
// Inline literals that resolve to a style JSX attribute (sx, style),
|
|
777
838
|
// whether directly or via conditional/logical/array wrappers, are
|
|
778
839
|
// consumed by the library without reference equality checks, so
|
|
@@ -236,6 +236,88 @@ function getPropsTypeNameFromParam(funcNode) {
|
|
|
236
236
|
}
|
|
237
237
|
return null;
|
|
238
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* Returns the first-parameter type annotation node of a component function, or
|
|
241
|
+
* null when the parameter is untyped or a rest element. Unlike
|
|
242
|
+
* getPropsTypeNameFromParam, this returns the raw TypeNode (e.g. the whole
|
|
243
|
+
* `Omit<ParentProps, 'children'>`) so it can be tested for composition.
|
|
244
|
+
*/
|
|
245
|
+
function getFirstParamTypeNode(funcNode) {
|
|
246
|
+
const firstParam = funcNode.params[0];
|
|
247
|
+
if (!firstParam)
|
|
248
|
+
return null;
|
|
249
|
+
if ((firstParam.type === utils_1.AST_NODE_TYPES.Identifier ||
|
|
250
|
+
firstParam.type === utils_1.AST_NODE_TYPES.ObjectPattern) &&
|
|
251
|
+
firstParam.typeAnnotation) {
|
|
252
|
+
return firstParam.typeAnnotation.typeAnnotation;
|
|
253
|
+
}
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Resolve the function node for a component name in the program, following a
|
|
258
|
+
* single-identifier alias (`const Live = LiveUnmemoized`) and unwrapping a HOC
|
|
259
|
+
* call (`memo((props) => ...)`). Returns null when no function is found. The
|
|
260
|
+
* `seen` set guards against alias cycles.
|
|
261
|
+
*/
|
|
262
|
+
function findComponentFunction(program, name, seen = new Set()) {
|
|
263
|
+
if (seen.has(name))
|
|
264
|
+
return null;
|
|
265
|
+
seen.add(name);
|
|
266
|
+
for (const stmt of program.body) {
|
|
267
|
+
const decl = stmt.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration
|
|
268
|
+
? stmt.declaration
|
|
269
|
+
: stmt;
|
|
270
|
+
if (!decl)
|
|
271
|
+
continue;
|
|
272
|
+
if (decl.type === utils_1.AST_NODE_TYPES.FunctionDeclaration &&
|
|
273
|
+
decl.id?.name === name) {
|
|
274
|
+
return decl;
|
|
275
|
+
}
|
|
276
|
+
if (decl.type === utils_1.AST_NODE_TYPES.VariableDeclaration) {
|
|
277
|
+
for (const declarator of decl.declarations) {
|
|
278
|
+
if (declarator.id.type !== utils_1.AST_NODE_TYPES.Identifier ||
|
|
279
|
+
declarator.id.name !== name ||
|
|
280
|
+
!declarator.init) {
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const init = declarator.init;
|
|
284
|
+
if (init.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
285
|
+
init.type === utils_1.AST_NODE_TYPES.FunctionExpression) {
|
|
286
|
+
return init;
|
|
287
|
+
}
|
|
288
|
+
if (init.type === utils_1.AST_NODE_TYPES.CallExpression) {
|
|
289
|
+
const arg0 = init.arguments[0];
|
|
290
|
+
if (arg0 &&
|
|
291
|
+
(arg0.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
292
|
+
arg0.type === utils_1.AST_NODE_TYPES.FunctionExpression)) {
|
|
293
|
+
return arg0;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (init.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
297
|
+
return findComponentFunction(program, init.name, seen);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Resolve the type node that defines a rendered dependency's props: its
|
|
306
|
+
* `{Dep}Props` alias if one exists, otherwise the dependency component's
|
|
307
|
+
* first-parameter type annotation. Used to detect inverse composition, where
|
|
308
|
+
* the child derives its props from the parent's props type.
|
|
309
|
+
*/
|
|
310
|
+
function getDependencyPropsSourceType(program, depName) {
|
|
311
|
+
const alias = findPropsTypeAliasByName(program, toPropsTypeName(depName));
|
|
312
|
+
if (alias) {
|
|
313
|
+
return alias.typeAnnotation;
|
|
314
|
+
}
|
|
315
|
+
const fn = findComponentFunction(program, depName);
|
|
316
|
+
if (fn) {
|
|
317
|
+
return getFirstParamTypeNode(fn);
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
239
321
|
exports.requirePropsComposition = (0, createRule_1.createRule)({
|
|
240
322
|
name: 'require-props-composition',
|
|
241
323
|
meta: {
|
|
@@ -384,7 +466,20 @@ exports.requirePropsComposition = (0, createRule_1.createRule)({
|
|
|
384
466
|
const missingComposition = [];
|
|
385
467
|
for (const dep of depComponents) {
|
|
386
468
|
const expectedPropsType = toPropsTypeName(dep);
|
|
387
|
-
|
|
469
|
+
let composes = typeNodeComposesWithProps(propsTypeNode, expectedPropsType);
|
|
470
|
+
// Inverse composition: the child derives its props FROM this parent's
|
|
471
|
+
// props type (e.g. `Omit<ParentProps, 'children'>`, often with no named
|
|
472
|
+
// ChildProps at all). The parent is then the single shared source of
|
|
473
|
+
// truth, so the DRY guarantee is already met; requiring the parent to
|
|
474
|
+
// *also* compose from ChildProps would invert the source of truth or
|
|
475
|
+
// create a circular dependency.
|
|
476
|
+
if (!composes && propsTypeName) {
|
|
477
|
+
const depPropsSource = getDependencyPropsSourceType(prog, dep);
|
|
478
|
+
if (depPropsSource &&
|
|
479
|
+
typeNodeComposesWithProps(depPropsSource, propsTypeName)) {
|
|
480
|
+
composes = true;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
388
483
|
if (composes) {
|
|
389
484
|
composedWith.add(dep);
|
|
390
485
|
}
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,56 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.18.14",
|
|
4
|
+
"date": "2026-07-13T08:30:23.444Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "no-entire-object-hook-deps",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1291
|
|
11
|
+
],
|
|
12
|
+
"summary": "treat non-literal computed keys as whole-object access (closes #1291)"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "1.18.13",
|
|
18
|
+
"date": "2026-07-13T01:43:11.918Z",
|
|
19
|
+
"rules": [
|
|
20
|
+
{
|
|
21
|
+
"name": "no-compositing-layer-props",
|
|
22
|
+
"changeType": "fix",
|
|
23
|
+
"issues": [
|
|
24
|
+
1288
|
|
25
|
+
],
|
|
26
|
+
"summary": "exempt transform/opacity inside @keyframes (closes #1288)"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "parallelize-async-operations",
|
|
30
|
+
"changeType": "fix",
|
|
31
|
+
"issues": [
|
|
32
|
+
1287
|
|
33
|
+
],
|
|
34
|
+
"summary": "don't flag write-then-read on the same receiver (closes #1287)"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "react-memoize-literals",
|
|
38
|
+
"changeType": "fix",
|
|
39
|
+
"issues": [
|
|
40
|
+
1290
|
|
41
|
+
],
|
|
42
|
+
"summary": "exempt Array iteration callbacks (.map/.filter/...) (closes #1290)"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"name": "require-props-composition",
|
|
46
|
+
"changeType": "fix",
|
|
47
|
+
"issues": [
|
|
48
|
+
1289
|
|
49
|
+
],
|
|
50
|
+
"summary": "recognize inverse composition (child derives from parent) (closes #1289)"
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
},
|
|
2
54
|
{
|
|
3
55
|
"version": "1.18.12",
|
|
4
56
|
"date": "2026-07-12T03:45:44.071Z",
|