@blumintinc/eslint-plugin-blumint 1.18.12 → 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.12',
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
  /**
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.18.12",
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,42 @@
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
+ },
2
40
  {
3
41
  "version": "1.18.12",
4
42
  "date": "2026-07-12T03:45:44.071Z",