@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 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.14',
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
@@ -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
- // For computed properties with variables (like user[key]), we need the entire object
137
- if (memberExpr.property.type === utils_1.AST_NODE_TYPES.Identifier) {
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, so we need the entire 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
- if (memberExpr.property.type === utils_1.AST_NODE_TYPES.Literal) {
152
- const literalProp = memberExpr.property;
153
- if (typeof literalProp.value === 'number') {
154
- parts.unshift(`[${literalProp.value}]`);
155
- }
156
- else if (typeof literalProp.value === 'string') {
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, use the exact expression
166
- try {
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
- let pathParts = [];
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 && effectiveParent.type === utils_1.AST_NODE_TYPES.MemberExpression;
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 === utils_1.AST_NODE_TYPES.Identifier) {
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, context);
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
- 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.14",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -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",