@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 CHANGED
@@ -218,7 +218,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
218
218
  module.exports = {
219
219
  meta: {
220
220
  name: '@blumintinc/eslint-plugin-blumint',
221
- version: '1.18.11',
221
+ version: '1.18.13',
222
222
  },
223
223
  parseOptions: {
224
224
  ecmaVersion: 2020,
@@ -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, topLevelFunctions);
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 are NOT imported.
451
- * Imported names and standard globals are considered "extractable" (you'd
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, topLevelFunctions) {
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
- // Get the set of top-level sibling function names
473
- const topLevelNames = new Set(topLevelFunctions.map((f) => f.name));
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 (topLevelNames.has(ref)) {
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
- const composes = typeNodeComposesWithProps(propsTypeNode, expectedPropsType);
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 namesInOriginalOrder = functions
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 = namesInOriginalOrder.filter((name) => (incomingCount.get(name) || 0) === 0);
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
- [...roots, ...namesInOriginalOrder].forEach(visit);
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
- const groupRank = (fn) => groupOrder.indexOf(classifyGroup(fn));
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
- const groupReason = groupOrder.indexOf(group) > 0
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
- return null;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.18.11",
3
+ "version": "1.18.13",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -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",