@blumintinc/eslint-plugin-blumint 1.18.11 → 1.18.13
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/parallelize-async-operations.js +69 -0
- package/lib/rules/prefer-utility-function-own-file.js +111 -11
- package/lib/rules/react-memoize-literals.js +61 -0
- package/lib/rules/require-props-composition.js +96 -1
- package/lib/rules/vertically-group-related-functions.js +36 -18
- package/package.json +1 -1
- package/release-manifest.json +60 -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
|
|
@@ -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
|
/**
|
|
@@ -53,6 +53,8 @@ function collectReferencedIdentifiers(node) {
|
|
|
53
53
|
// Record parameter names as locals
|
|
54
54
|
for (const param of fn.params) {
|
|
55
55
|
collectPatternNames(param, locals);
|
|
56
|
+
// Default values inside a parameter pattern may reference module scope
|
|
57
|
+
walkPatternDefaults(param);
|
|
56
58
|
}
|
|
57
59
|
walk(fn.body);
|
|
58
60
|
break;
|
|
@@ -60,6 +62,10 @@ function collectReferencedIdentifiers(node) {
|
|
|
60
62
|
case utils_1.AST_NODE_TYPES.VariableDeclarator: {
|
|
61
63
|
const decl = n;
|
|
62
64
|
collectPatternNames(decl.id, locals);
|
|
65
|
+
// Default values inside a destructuring pattern may reference module
|
|
66
|
+
// scope (e.g. `const { runner = runCli } = props`) — the binding names
|
|
67
|
+
// are locals, but the default expressions are real references.
|
|
68
|
+
walkPatternDefaults(decl.id);
|
|
63
69
|
walk(decl.init);
|
|
64
70
|
break;
|
|
65
71
|
}
|
|
@@ -83,6 +89,40 @@ function collectReferencedIdentifiers(node) {
|
|
|
83
89
|
}
|
|
84
90
|
}
|
|
85
91
|
}
|
|
92
|
+
// Walks only the right-hand sides of AssignmentPattern defaults nested inside
|
|
93
|
+
// a binding pattern. The binding names themselves are locals; their defaults
|
|
94
|
+
// are ordinary references that `walk` (which skips patterns via the dedicated
|
|
95
|
+
// VariableDeclarator/function cases) would otherwise never visit.
|
|
96
|
+
function walkPatternDefaults(p) {
|
|
97
|
+
if (!p)
|
|
98
|
+
return;
|
|
99
|
+
switch (p.type) {
|
|
100
|
+
case utils_1.AST_NODE_TYPES.AssignmentPattern:
|
|
101
|
+
walk(p.right);
|
|
102
|
+
walkPatternDefaults(p.left);
|
|
103
|
+
break;
|
|
104
|
+
case utils_1.AST_NODE_TYPES.ArrayPattern:
|
|
105
|
+
for (const el of p.elements) {
|
|
106
|
+
walkPatternDefaults(el);
|
|
107
|
+
}
|
|
108
|
+
break;
|
|
109
|
+
case utils_1.AST_NODE_TYPES.ObjectPattern:
|
|
110
|
+
for (const prop of p.properties) {
|
|
111
|
+
if (prop.type === utils_1.AST_NODE_TYPES.RestElement) {
|
|
112
|
+
walkPatternDefaults(prop.argument);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
walkPatternDefaults(prop.value);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
break;
|
|
119
|
+
case utils_1.AST_NODE_TYPES.RestElement:
|
|
120
|
+
walkPatternDefaults(p.argument);
|
|
121
|
+
break;
|
|
122
|
+
default:
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
86
126
|
walk(node);
|
|
87
127
|
// Remove purely local names from the reference set
|
|
88
128
|
for (const local of locals) {
|
|
@@ -219,6 +259,38 @@ function isExemptFile(filename) {
|
|
|
219
259
|
return true;
|
|
220
260
|
return false;
|
|
221
261
|
}
|
|
262
|
+
/**
|
|
263
|
+
* Returns true if the module top-level self-invokes one of its own functions,
|
|
264
|
+
* e.g. `void autoRunIfMain();` or `main();`. This is the signature of a CLI
|
|
265
|
+
* entry-point module whose colocated helpers ARE its purpose, not foreign
|
|
266
|
+
* utilities that should be extracted.
|
|
267
|
+
*/
|
|
268
|
+
function hasTopLevelSelfInvocation(program, topLevelFunctionNames) {
|
|
269
|
+
for (const statement of program.body) {
|
|
270
|
+
if (statement.type !== utils_1.AST_NODE_TYPES.ExpressionStatement)
|
|
271
|
+
continue;
|
|
272
|
+
// Unwrap `void expr`, `await expr`, and parenthesized wrappers to reach the
|
|
273
|
+
// underlying call (e.g. `void autoRunIfMain();`).
|
|
274
|
+
let expr = statement.expression;
|
|
275
|
+
while (expr &&
|
|
276
|
+
((expr.type === utils_1.AST_NODE_TYPES.UnaryExpression &&
|
|
277
|
+
expr.operator === 'void') ||
|
|
278
|
+
expr.type === utils_1.AST_NODE_TYPES.AwaitExpression ||
|
|
279
|
+
expr.type === 'ParenthesizedExpression')) {
|
|
280
|
+
expr =
|
|
281
|
+
expr.argument ??
|
|
282
|
+
expr.expression;
|
|
283
|
+
}
|
|
284
|
+
if (!expr || expr.type !== utils_1.AST_NODE_TYPES.CallExpression)
|
|
285
|
+
continue;
|
|
286
|
+
const callee = expr.callee;
|
|
287
|
+
if (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
288
|
+
topLevelFunctionNames.has(callee.name)) {
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
222
294
|
exports.preferUtilityFunctionOwnFile = (0, createRule_1.createRule)({
|
|
223
295
|
name: 'prefer-utility-function-own-file',
|
|
224
296
|
meta: {
|
|
@@ -265,6 +337,12 @@ exports.preferUtilityFunctionOwnFile = (0, createRule_1.createRule)({
|
|
|
265
337
|
const basename = fileBasename(filename);
|
|
266
338
|
const topLevelFunctions = [];
|
|
267
339
|
let hasExportDefault = false;
|
|
340
|
+
// Names of every top-level binding (functions, consts, lets, destructured
|
|
341
|
+
// bindings). Used by the closure exemption: a function that references any
|
|
342
|
+
// of these closes over module scope, so extraction would sever that link.
|
|
343
|
+
const topLevelBindingNames = new Set();
|
|
344
|
+
// Whether the module references `require.main` — a CLI entry-point signal.
|
|
345
|
+
let referencesRequireMain = false;
|
|
268
346
|
// Names that appear as the handler inside `export default someWrapper(name)`
|
|
269
347
|
const wrappedDefaultHandlerNames = new Set();
|
|
270
348
|
// Names directly exported as default (export default myFunc style via ExportDefaultDeclaration referencing an identifier)
|
|
@@ -272,6 +350,16 @@ exports.preferUtilityFunctionOwnFile = (0, createRule_1.createRule)({
|
|
|
272
350
|
// Names exported via `export { foo, bar }` specifiers
|
|
273
351
|
const specifierExportedNames = new Set();
|
|
274
352
|
return {
|
|
353
|
+
// Detect `require.main` references (CLI entry-point signal)
|
|
354
|
+
MemberExpression(node) {
|
|
355
|
+
if (!node.computed &&
|
|
356
|
+
node.object.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
357
|
+
node.object.name === 'require' &&
|
|
358
|
+
node.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
359
|
+
node.property.name === 'main') {
|
|
360
|
+
referencesRequireMain = true;
|
|
361
|
+
}
|
|
362
|
+
},
|
|
275
363
|
// Collect export default declarations
|
|
276
364
|
ExportDefaultDeclaration(node) {
|
|
277
365
|
hasExportDefault = true;
|
|
@@ -331,6 +419,7 @@ exports.preferUtilityFunctionOwnFile = (0, createRule_1.createRule)({
|
|
|
331
419
|
if (!node.id)
|
|
332
420
|
return;
|
|
333
421
|
const name = node.id.name;
|
|
422
|
+
topLevelBindingNames.add(name);
|
|
334
423
|
const isNamedExport = parent?.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration;
|
|
335
424
|
const isDefaultExport = parent?.type === utils_1.AST_NODE_TYPES.ExportDefaultDeclaration;
|
|
336
425
|
topLevelFunctions.push({
|
|
@@ -351,6 +440,9 @@ exports.preferUtilityFunctionOwnFile = (0, createRule_1.createRule)({
|
|
|
351
440
|
return;
|
|
352
441
|
const isNamedExport = parent?.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration;
|
|
353
442
|
for (const declarator of node.declarations) {
|
|
443
|
+
// Every top-level binding (including non-function consts like a
|
|
444
|
+
// registry array and destructured bindings) is a closure target.
|
|
445
|
+
collectPatternNames(declarator.id, topLevelBindingNames);
|
|
354
446
|
if (declarator.id.type !== utils_1.AST_NODE_TYPES.Identifier)
|
|
355
447
|
continue;
|
|
356
448
|
const name = declarator.id.name;
|
|
@@ -366,7 +458,17 @@ exports.preferUtilityFunctionOwnFile = (0, createRule_1.createRule)({
|
|
|
366
458
|
});
|
|
367
459
|
}
|
|
368
460
|
},
|
|
369
|
-
'Program:exit'() {
|
|
461
|
+
'Program:exit'(programNode) {
|
|
462
|
+
// CLI entry-point modules deliberately colocate their parser/printer/
|
|
463
|
+
// guard/compute helpers — those functions ARE the file's purpose, not
|
|
464
|
+
// foreign utilities. Recognize them via a `require.main` reference or a
|
|
465
|
+
// top-level self-invocation of one of their own functions
|
|
466
|
+
// (`void autoRunIfMain();`) and exempt the whole file.
|
|
467
|
+
const topLevelFunctionNames = new Set(topLevelFunctions.map((f) => f.name));
|
|
468
|
+
if (referencesRequireMain ||
|
|
469
|
+
hasTopLevelSelfInvocation(programNode, topLevelFunctionNames)) {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
370
472
|
// Determine the file's primary export name (basename heuristic)
|
|
371
473
|
// e.g. "modifyRoleMembers" in "modifyRoleMembers.f.ts"
|
|
372
474
|
const primaryName = basename;
|
|
@@ -419,7 +521,7 @@ exports.preferUtilityFunctionOwnFile = (0, createRule_1.createRule)({
|
|
|
419
521
|
continue;
|
|
420
522
|
// --- ignoreClosures: skip if the function body references module-scoped identifiers not passed as params ---
|
|
421
523
|
if (ignoreClosures) {
|
|
422
|
-
const closesOverModuleScope = functionClosesOverModuleScope(fn,
|
|
524
|
+
const closesOverModuleScope = functionClosesOverModuleScope(fn, topLevelBindingNames);
|
|
423
525
|
if (closesOverModuleScope)
|
|
424
526
|
continue;
|
|
425
527
|
}
|
|
@@ -447,14 +549,14 @@ exports.preferUtilityFunctionOwnFile = (0, createRule_1.createRule)({
|
|
|
447
549
|
* passed as parameters, making it non-trivially extractable.
|
|
448
550
|
*
|
|
449
551
|
* We identify "module-scope" identifiers as names of other top-level
|
|
450
|
-
* declarations in the file (functions, variables) that
|
|
451
|
-
* Imported names and standard globals are considered
|
|
452
|
-
* just re-import them).
|
|
552
|
+
* declarations in the file (functions, variables, destructured bindings) that
|
|
553
|
+
* are NOT imported. Imported names and standard globals are considered
|
|
554
|
+
* "extractable" (you'd just re-import them).
|
|
453
555
|
*
|
|
454
556
|
* Strategy: collect referenced identifiers in the body, subtract param names,
|
|
455
557
|
* then check if any remain that are names of other top-level declarations.
|
|
456
558
|
*/
|
|
457
|
-
function functionClosesOverModuleScope(fn,
|
|
559
|
+
function functionClosesOverModuleScope(fn, topLevelBindingNames) {
|
|
458
560
|
const body = getFunctionBody(fn);
|
|
459
561
|
if (!body)
|
|
460
562
|
return false;
|
|
@@ -469,12 +571,10 @@ function functionClosesOverModuleScope(fn, topLevelFunctions) {
|
|
|
469
571
|
for (const param of paramNames) {
|
|
470
572
|
referencedIds.delete(param);
|
|
471
573
|
}
|
|
472
|
-
//
|
|
473
|
-
|
|
474
|
-
// If ANY referenced identifier is a top-level sibling function name,
|
|
475
|
-
// then this function closes over a module-scope binding.
|
|
574
|
+
// If ANY referenced identifier is a top-level sibling binding name, then this
|
|
575
|
+
// function closes over module scope.
|
|
476
576
|
for (const ref of referencedIds) {
|
|
477
|
-
if (
|
|
577
|
+
if (topLevelBindingNames.has(ref)) {
|
|
478
578
|
return true;
|
|
479
579
|
}
|
|
480
580
|
}
|
|
@@ -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
|
}
|
|
@@ -232,18 +232,23 @@ function collectDependencies(fnNode, knownFunctionNames) {
|
|
|
232
232
|
visit(fnNode.body);
|
|
233
233
|
return [...dependencies];
|
|
234
234
|
}
|
|
235
|
-
function dependencyOrder(functions, direction) {
|
|
235
|
+
function dependencyOrder(functions, direction, groupOrder) {
|
|
236
236
|
const dependencyMap = new Map();
|
|
237
237
|
const originalIndexMap = new Map(functions.map((fn) => [fn.name, fn.originalIndex]));
|
|
238
|
+
const groupRankMap = new Map(functions.map((fn) => [fn.name, groupOrder.indexOf(classifyGroup(fn))]));
|
|
238
239
|
functions.forEach((fn) => {
|
|
239
240
|
dependencyMap.set(fn.name, fn.dependencies);
|
|
240
241
|
});
|
|
242
|
+
// Tiebreak among functions the call graph does NOT order relative to each
|
|
243
|
+
// other (independent roots, sibling callees, cycle members): configured
|
|
244
|
+
// group order first, then original source position. Both keys are intrinsic
|
|
245
|
+
// to the function rather than its current arrangement, so the traversal is a
|
|
246
|
+
// fixed point once reached — obeying a move never spawns a contradicting one.
|
|
247
|
+
const tiebreak = (a, b) => (groupRankMap.get(a) ?? 0) - (groupRankMap.get(b) ?? 0) ||
|
|
248
|
+
(originalIndexMap.get(a) ?? 0) - (originalIndexMap.get(b) ?? 0);
|
|
241
249
|
const visited = new Set();
|
|
242
250
|
const order = [];
|
|
243
|
-
const
|
|
244
|
-
.slice()
|
|
245
|
-
.sort((a, b) => a.originalIndex - b.originalIndex)
|
|
246
|
-
.map((fn) => fn.name);
|
|
251
|
+
const namesInTiebreakOrder = functions.map((fn) => fn.name).sort(tiebreak);
|
|
247
252
|
const incomingCount = new Map();
|
|
248
253
|
functions.forEach((fn) => {
|
|
249
254
|
if (!incomingCount.has(fn.name)) {
|
|
@@ -253,18 +258,13 @@ function dependencyOrder(functions, direction) {
|
|
|
253
258
|
incomingCount.set(dep, (incomingCount.get(dep) || 0) + 1);
|
|
254
259
|
});
|
|
255
260
|
});
|
|
256
|
-
const roots =
|
|
261
|
+
const roots = namesInTiebreakOrder.filter((name) => (incomingCount.get(name) || 0) === 0);
|
|
257
262
|
const visit = (name) => {
|
|
258
263
|
if (visited.has(name)) {
|
|
259
264
|
return;
|
|
260
265
|
}
|
|
261
266
|
visited.add(name);
|
|
262
|
-
const deps = dependencyMap
|
|
263
|
-
.get(name)
|
|
264
|
-
?.slice()
|
|
265
|
-
.sort((a, b) => {
|
|
266
|
-
return ((originalIndexMap.get(a) || 0) - (originalIndexMap.get(b) || 0));
|
|
267
|
-
}) || [];
|
|
267
|
+
const deps = dependencyMap.get(name)?.slice().sort(tiebreak) || [];
|
|
268
268
|
if (direction === 'callees-first') {
|
|
269
269
|
deps.forEach(visit);
|
|
270
270
|
order.push(name);
|
|
@@ -274,12 +274,15 @@ function dependencyOrder(functions, direction) {
|
|
|
274
274
|
deps.forEach(visit);
|
|
275
275
|
}
|
|
276
276
|
};
|
|
277
|
-
|
|
277
|
+
// Depth-first from roots keeps each caller immediately above its own helper
|
|
278
|
+
// subtree (callers-first) — grouping call chains vertically. The call graph
|
|
279
|
+
// is primary; group order only breaks ties the graph leaves open.
|
|
280
|
+
[...roots, ...namesInTiebreakOrder].forEach(visit);
|
|
278
281
|
return order;
|
|
279
282
|
}
|
|
280
283
|
function computeExpectedOrder(functions, options) {
|
|
281
284
|
const groupOrder = normalizeGroupOrder(options.groupOrder);
|
|
282
|
-
const dependencySequence = dependencyOrder(functions, options.dependencyDirection);
|
|
285
|
+
const dependencySequence = dependencyOrder(functions, options.dependencyDirection, groupOrder);
|
|
283
286
|
const dependencyRank = new Map(dependencySequence.map((name, idx) => [name, idx]));
|
|
284
287
|
const exportRank = (fn) => {
|
|
285
288
|
if (options.exportPlacement === 'ignore') {
|
|
@@ -290,10 +293,12 @@ function computeExpectedOrder(functions, options) {
|
|
|
290
293
|
}
|
|
291
294
|
return fn.isExported ? 1 : 0;
|
|
292
295
|
};
|
|
293
|
-
|
|
296
|
+
// Export placement is the only concern allowed to outrank the call graph.
|
|
297
|
+
// The dependency sequence already folds group order in as a tiebreak, so a
|
|
298
|
+
// caller is never sorted below the helpers it invokes on account of its verb
|
|
299
|
+
// prefix — the defect that produced self-contradicting move instructions.
|
|
294
300
|
return functions.slice().sort((a, b) => {
|
|
295
301
|
return (exportRank(a) - exportRank(b) ||
|
|
296
|
-
groupRank(a) - groupRank(b) ||
|
|
297
302
|
(dependencyRank.get(a.name) ?? Number.MAX_SAFE_INTEGER) -
|
|
298
303
|
(dependencyRank.get(b.name) ?? Number.MAX_SAFE_INTEGER) ||
|
|
299
304
|
a.originalIndex - b.originalIndex);
|
|
@@ -415,7 +420,11 @@ exports.verticallyGroupRelatedFunctions = (0, createRule_1.createRule)({
|
|
|
415
420
|
: 'exports stay at the bottom of the file';
|
|
416
421
|
const group = classifyGroup(misplacedInfo);
|
|
417
422
|
const groupOrder = normalizeGroupOrder(normalizedOptions.groupOrder);
|
|
418
|
-
|
|
423
|
+
// Group order only settles ties the call graph leaves open, so cite it
|
|
424
|
+
// as a reason only when this function has no helpers of its own — never
|
|
425
|
+
// paired with "callers should sit above the helpers they invoke".
|
|
426
|
+
const groupReason = misplacedInfo.dependencies.length === 0 &&
|
|
427
|
+
groupOrder.indexOf(group) > 0
|
|
419
428
|
? `${group.replace('-', ' ')} should follow the configured group order`
|
|
420
429
|
: '';
|
|
421
430
|
const reasons = [dependencyReason, exportReason, groupReason]
|
|
@@ -459,7 +468,16 @@ exports.verticallyGroupRelatedFunctions = (0, createRule_1.createRule)({
|
|
|
459
468
|
const slice = node.body.slice(firstFunctionIndex, lastFunctionIndex + 1);
|
|
460
469
|
const blockContainsOnlyFunctions = slice.every((statement) => functionStatements.has(statement));
|
|
461
470
|
if (!blockContainsOnlyFunctions) {
|
|
462
|
-
|
|
471
|
+
// Real modules interleave type aliases, consts, and top-level
|
|
472
|
+
// calls (e.g. `void autoRunIfMain();`) between functions. Rather
|
|
473
|
+
// than bail, reorder only the function statements among their own
|
|
474
|
+
// slots, leaving every other statement exactly where it is. Plain
|
|
475
|
+
// node ranges keep the edits disjoint (no comment-span overlap
|
|
476
|
+
// with the interleaved statements).
|
|
477
|
+
return sourceOrderedInfos.map((info, idx) => {
|
|
478
|
+
const target = expectedOrderInfos[idx];
|
|
479
|
+
return fixer.replaceTextRange(info.statementNode.range, sourceCode.getText(target.statementNode));
|
|
480
|
+
});
|
|
463
481
|
}
|
|
464
482
|
const [start] = statementRanges.get(node.body[firstFunctionIndex]) ||
|
|
465
483
|
getStatementRangeWithComments(node.body[firstFunctionIndex], sourceCode);
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,64 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.18.13",
|
|
4
|
+
"date": "2026-07-13T01:43:11.918Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "no-compositing-layer-props",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1288
|
|
11
|
+
],
|
|
12
|
+
"summary": "exempt transform/opacity inside @keyframes (closes #1288)"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "parallelize-async-operations",
|
|
16
|
+
"changeType": "fix",
|
|
17
|
+
"issues": [
|
|
18
|
+
1287
|
|
19
|
+
],
|
|
20
|
+
"summary": "don't flag write-then-read on the same receiver (closes #1287)"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "react-memoize-literals",
|
|
24
|
+
"changeType": "fix",
|
|
25
|
+
"issues": [
|
|
26
|
+
1290
|
|
27
|
+
],
|
|
28
|
+
"summary": "exempt Array iteration callbacks (.map/.filter/...) (closes #1290)"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "require-props-composition",
|
|
32
|
+
"changeType": "fix",
|
|
33
|
+
"issues": [
|
|
34
|
+
1289
|
|
35
|
+
],
|
|
36
|
+
"summary": "recognize inverse composition (child derives from parent) (closes #1289)"
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"version": "1.18.12",
|
|
42
|
+
"date": "2026-07-12T03:45:44.071Z",
|
|
43
|
+
"rules": [
|
|
44
|
+
{
|
|
45
|
+
"name": "prefer-utility-function-own-file",
|
|
46
|
+
"changeType": "fix",
|
|
47
|
+
"issues": [
|
|
48
|
+
1285
|
|
49
|
+
],
|
|
50
|
+
"summary": "exempt CLI entry-point and registry modules (closes #1285)"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "vertically-group-related-functions",
|
|
54
|
+
"changeType": "fix",
|
|
55
|
+
"issues": [
|
|
56
|
+
1286
|
|
57
|
+
],
|
|
58
|
+
"summary": "make call graph primary over name-prefix groups (closes #1286)"
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
},
|
|
2
62
|
{
|
|
3
63
|
"version": "1.18.11",
|
|
4
64
|
"date": "2026-07-11T16:25:05.418Z",
|