@blumintinc/eslint-plugin-blumint 1.20.146 → 1.20.147

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
@@ -223,7 +223,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
223
223
  module.exports = {
224
224
  meta: {
225
225
  name: '@blumintinc/eslint-plugin-blumint',
226
- version: '1.20.146',
226
+ version: '1.20.147',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -194,6 +194,184 @@ function unwrapExpression(expr) {
194
194
  }
195
195
  return current;
196
196
  }
197
+ /** Applies `visitChild` to every AST child of `node`, skipping `parent` links. */
198
+ function forEachChildNode(node, visitChild) {
199
+ for (const key in node) {
200
+ if (key === 'parent')
201
+ continue;
202
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
203
+ const child = node[key];
204
+ if (!child || typeof child !== 'object')
205
+ continue;
206
+ if (Array.isArray(child)) {
207
+ for (const item of child) {
208
+ if (item && typeof item === 'object' && 'type' in item) {
209
+ visitChild(item);
210
+ }
211
+ }
212
+ }
213
+ else if ('type' in child) {
214
+ visitChild(child);
215
+ }
216
+ }
217
+ }
218
+ /**
219
+ * Whether `node` is the callee of a call — `u.date.toISOString` in
220
+ * `u.date.toISOString()` — looking through the wrappers that can sit between
221
+ * the member expression and its call (`?.` chains, `!`, `as T`).
222
+ */
223
+ function isCallCallee(node) {
224
+ let current = node;
225
+ let parent = node.parent;
226
+ while (parent &&
227
+ (parent.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
228
+ parent.type === utils_1.AST_NODE_TYPES.TSTypeAssertion ||
229
+ parent.type === utils_1.AST_NODE_TYPES.ChainExpression ||
230
+ parent.type === utils_1.AST_NODE_TYPES.TSNonNullExpression)) {
231
+ current = parent;
232
+ parent = parent.parent;
233
+ }
234
+ return (!!parent &&
235
+ parent.type === utils_1.AST_NODE_TYPES.CallExpression &&
236
+ parent.callee === current);
237
+ }
238
+ /**
239
+ * Renders `node`'s access path when the chain is rooted at `objectName`, or
240
+ * null when it is rooted elsewhere or holds a link with no stable rendering.
241
+ *
242
+ * why: guard collection asks a different question than `buildAccessPath` —
243
+ * "which value did this condition establish something about?" rather than
244
+ * "which dependency should replace the object?" — so it must not apply that
245
+ * function's narrowing policy (method carve-outs, whole-object escalation) nor
246
+ * its side effects on the usage set.
247
+ */
248
+ function renderMemberPathIfRootedAt(node, objectName) {
249
+ const segments = [];
250
+ let current = node;
251
+ while (current.type === utils_1.AST_NODE_TYPES.MemberExpression) {
252
+ const memberExpr = current;
253
+ if (memberExpr.computed) {
254
+ const literalValue = memberExpr.property.type === utils_1.AST_NODE_TYPES.Literal
255
+ ? memberExpr.property.value
256
+ : undefined;
257
+ if (typeof literalValue !== 'number' &&
258
+ typeof literalValue !== 'string') {
259
+ return null;
260
+ }
261
+ segments.unshift({
262
+ text: typeof literalValue === 'number'
263
+ ? `[${literalValue}]`
264
+ : `[${JSON.stringify(literalValue)}]`,
265
+ computed: true,
266
+ optional: memberExpr.optional,
267
+ });
268
+ }
269
+ else {
270
+ if (memberExpr.property.type !== utils_1.AST_NODE_TYPES.Identifier) {
271
+ return null;
272
+ }
273
+ segments.unshift({
274
+ text: memberExpr.property.name,
275
+ computed: false,
276
+ optional: memberExpr.optional,
277
+ });
278
+ }
279
+ current = unwrapExpression(memberExpr.object);
280
+ }
281
+ const base = unwrapExpression(current);
282
+ if (segments.length === 0 ||
283
+ base.type !== utils_1.AST_NODE_TYPES.Identifier ||
284
+ base.name !== objectName) {
285
+ return null;
286
+ }
287
+ return renderPathSegments(objectName, segments);
288
+ }
289
+ /**
290
+ * Every path of `objectName` whose dereferenceability the hook body establishes
291
+ * with a guard rather than with the plain shape of the source.
292
+ *
293
+ * why: a dependency array is an array literal, so every element is evaluated
294
+ * eagerly on every render — outside the `if`, the `&&`, the ternary and the
295
+ * `!` assertion that made the access safe inside the body. Extending a
296
+ * dependency path *through* such a link therefore turns guarded code into an
297
+ * unconditional `TypeError`. The paths collected here mark where a path must
298
+ * stop; see `safePrefixOf`.
299
+ *
300
+ * The collection is deliberately over-broad — it accepts any member path
301
+ * appearing anywhere in a condition, not only one that provably governs the
302
+ * access. Over-collecting costs a coarser dependency (the memo recomputes more
303
+ * often than strictly needed); under-collecting is the crash.
304
+ */
305
+ function collectGuardedPaths(hookBody, objectName) {
306
+ const guarded = new Set();
307
+ const visited = new Set();
308
+ function markConditionPaths(node) {
309
+ if (!node)
310
+ return;
311
+ if (node.type === utils_1.AST_NODE_TYPES.MemberExpression) {
312
+ const path = renderMemberPathIfRootedAt(node, objectName);
313
+ if (path) {
314
+ guarded.add(path);
315
+ // why: only the outermost link of the chain is what the condition
316
+ // established. `if (a.b.c)` dereferences `a.b` unconditionally, so it
317
+ // proves nothing about `a.b` and must not truncate paths there. A
318
+ // computed key can still hold a condition-worthy read of its own.
319
+ if (node.computed) {
320
+ markConditionPaths(node.property);
321
+ }
322
+ return;
323
+ }
324
+ }
325
+ forEachChildNode(node, markConditionPaths);
326
+ }
327
+ function walk(node) {
328
+ if (!node || visited.has(node))
329
+ return;
330
+ visited.add(node);
331
+ if (node.type === utils_1.AST_NODE_TYPES.IfStatement ||
332
+ node.type === utils_1.AST_NODE_TYPES.ConditionalExpression ||
333
+ node.type === utils_1.AST_NODE_TYPES.WhileStatement ||
334
+ node.type === utils_1.AST_NODE_TYPES.DoWhileStatement ||
335
+ node.type === utils_1.AST_NODE_TYPES.ForStatement) {
336
+ // Covers `if (a.b)`, `a.b ? x : y`, and the early-return form
337
+ // `if (!a.b) return;` — the `!` is reached by walking the test.
338
+ markConditionPaths(node.test);
339
+ }
340
+ else if (node.type === utils_1.AST_NODE_TYPES.LogicalExpression) {
341
+ // `a.b && a.b.c`, `a.b || fallback`, `a.b ?? fallback`: the left operand
342
+ // decides whether the right one runs at all.
343
+ markConditionPaths(node.left);
344
+ }
345
+ else if (node.type === utils_1.AST_NODE_TYPES.UnaryExpression) {
346
+ if (node.operator === '!' || node.operator === 'typeof') {
347
+ markConditionPaths(node.argument);
348
+ }
349
+ }
350
+ else if (node.type === utils_1.AST_NODE_TYPES.BinaryExpression) {
351
+ if (node.operator === 'instanceof') {
352
+ markConditionPaths(node.left);
353
+ }
354
+ else if (node.operator === 'in') {
355
+ markConditionPaths(node.right);
356
+ }
357
+ }
358
+ else if (node.type === utils_1.AST_NODE_TYPES.TSNonNullExpression) {
359
+ // `a.b!.c` — the assertion is the author's guard, and it exists only in
360
+ // the type system. The emitted dependency has to stop at `a.b`.
361
+ //
362
+ // Unlike a condition, an assertion speaks about exactly one value, so
363
+ // this does NOT descend: in `load(a.b)!` the `!` covers the call's
364
+ // result and says nothing about `a.b`.
365
+ const asserted = unwrapExpression(node.expression);
366
+ if (asserted.type === utils_1.AST_NODE_TYPES.MemberExpression) {
367
+ markConditionPaths(asserted);
368
+ }
369
+ }
370
+ forEachChildNode(node, walk);
371
+ }
372
+ walk(hookBody);
373
+ return guarded;
374
+ }
197
375
  /**
198
376
  * Whether the hook body anywhere calls the state setter that corresponds to
199
377
  * `dependencyName` (dep `count` -> `setCount(...)`).
@@ -250,8 +428,46 @@ function getObjectUsagesInHook(hookBody, objectName, typeInfo) {
250
428
  // rendered path cannot place `?.` markers correctly.
251
429
  const pathSegments = new Map();
252
430
  const visited = new Set();
431
+ const guardedPaths = collectGuardedPaths(hookBody, objectName);
253
432
  let needsEntireObject = false;
254
433
  let isUsed = false;
434
+ /**
435
+ * The longest prefix of `segments` that a dependency array may evaluate
436
+ * unconditionally.
437
+ *
438
+ * why: the hook body reaches a deep path under guards the array cannot
439
+ * carry, so the deepest path the body reads is not always a legal dependency.
440
+ * Truncating at the first unsafe link yields a coarser dependency — the memo
441
+ * recomputes whenever the parent object's identity changes rather than only
442
+ * when the leaf does — which can only cost precision, never correctness, and
443
+ * still delivers the narrowing this rule exists for.
444
+ */
445
+ function safePrefixOf(segments) {
446
+ let limit = segments.length;
447
+ // A link the source reached with `?.` is one the author expects to be
448
+ // nullish. When the very next link is spelled *without* `?.`, the source
449
+ // only survives because something outside the expression established the
450
+ // value — a guard, a narrowing assertion, an invariant. The dependency
451
+ // array inherits none of it, so the path stops at the optional link.
452
+ // A trailing optional link (`a.b?.[0]`, `state?.[0]`) is safe as written
453
+ // and keeps its full rendering.
454
+ for (let index = 0; index < segments.length - 1; index += 1) {
455
+ if (segments[index].optional && !segments[index + 1].optional) {
456
+ limit = index + 1;
457
+ break;
458
+ }
459
+ }
460
+ // A prefix whose dereferenceability a condition established is unusable in
461
+ // the array for the same reason. The shortest such prefix wins, since it is
462
+ // the most conservative stopping point.
463
+ for (let index = 1; index < limit; index += 1) {
464
+ if (guardedPaths.has(renderPathSegments(objectName, segments.slice(0, index)))) {
465
+ limit = index;
466
+ break;
467
+ }
468
+ }
469
+ return segments.slice(0, limit);
470
+ }
255
471
  // Built-in array methods that indicate usage of the entire array
256
472
  const ARRAY_METHODS = new Set([
257
473
  'map',
@@ -314,6 +530,36 @@ function getObjectUsagesInHook(hookBody, objectName, typeInfo) {
314
530
  // Collect all links from leaf to root
315
531
  while (current.type === utils_1.AST_NODE_TYPES.MemberExpression) {
316
532
  const memberExpr = current;
533
+ // A member called through a chain never terminates a dependency path:
534
+ // depend on the receiver instead.
535
+ //
536
+ // why: two reasons converge. A method reached through a chain
537
+ // (`u.date.toISOString`) is a prototype-shared reference — the same value
538
+ // for every receiver of that type — so pinning it in the array makes the
539
+ // hook stop invalidating and serve a stale value forever. And it is the
540
+ // link that dereferences the receiver, so a receiver whose safety came
541
+ // from a guard (`if (u.date)`, `u.date!`) throws when the array is
542
+ // evaluated on a render that never entered the guard.
543
+ //
544
+ // Two conditions bound this. The link must be spelled without `?.`: a
545
+ // fully optional chain (`userData?.date?.toISOString`) short-circuits
546
+ // instead of throwing, which is exactly the per-link rendering this rule
547
+ // already gets right. And the receiver must itself be a member path —
548
+ // where it is the dependency object (`userData?.getName?.()`), falling
549
+ // back to it would surrender the narrowing entirely, and a function held
550
+ // directly on a plain dependency object is per-instance state whose
551
+ // identity legitimately changes; `isMethodMember` decides that case with
552
+ // the type checker.
553
+ if (!memberExpr.optional && isCallCallee(memberExpr)) {
554
+ const receiver = unwrapExpression(memberExpr.object);
555
+ if (receiver.type === utils_1.AST_NODE_TYPES.MemberExpression) {
556
+ const receiverPath = buildAccessPath(receiver);
557
+ if (receiverPath) {
558
+ usages.set(receiverPath, memberExpr.range?.[0] || 0);
559
+ }
560
+ return null;
561
+ }
562
+ }
317
563
  // Handle computed properties (like array indices)
318
564
  if (memberExpr.computed) {
319
565
  // why: only a *literal* string/number computed key (obj[0],
@@ -613,8 +859,25 @@ function getObjectUsagesInHook(hookBody, objectName, typeInfo) {
613
859
  }
614
860
  }
615
861
  visit(hookBody);
862
+ // Replace every collected path with the prefix that survives evaluation
863
+ // outside the hook body. Distinct reads can collapse onto one dependency
864
+ // (`a.b` and a truncated `a.b.c` are the same entry), so the earliest source
865
+ // position is kept for the ordering below.
866
+ const safeUsages = new Map();
867
+ usages.forEach((position, rawPath) => {
868
+ const segments = pathSegments.get(rawPath);
869
+ const safeSegments = segments ? safePrefixOf(segments) : undefined;
870
+ const safePath = safeSegments
871
+ ? renderPathSegments(objectName, safeSegments)
872
+ : rawPath;
873
+ if (safeSegments) {
874
+ pathSegments.set(safePath, safeSegments);
875
+ }
876
+ const recorded = safeUsages.get(safePath);
877
+ safeUsages.set(safePath, recorded === undefined ? position : Math.min(recorded, position));
878
+ });
616
879
  // Process paths and determine which ones to include
617
- const paths = Array.from(usages.keys());
880
+ const paths = Array.from(safeUsages.keys());
618
881
  const finalPaths = new Set();
619
882
  paths.forEach((path) => {
620
883
  // Always include the main path
@@ -658,8 +921,8 @@ function getObjectUsagesInHook(hookBody, objectName, typeInfo) {
658
921
  });
659
922
  // Sort paths: longer/more specific paths first, then by optional chaining preference
660
923
  const sortedPaths = filteredPaths.sort((a, b) => {
661
- const posA = usages.get(a) || 0;
662
- const posB = usages.get(b) || 0;
924
+ const posA = safeUsages.get(a) || 0;
925
+ const posB = safeUsages.get(b) || 0;
663
926
  // For paths with the same base, put longer ones first
664
927
  const aDepth = a.split('.').length + (a.includes('[') ? 1 : 0);
665
928
  const bDepth = b.split('.').length + (b.includes('[') ? 1 : 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.146",
3
+ "version": "1.20.147",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "version": "1.20.147",
4
+ "date": "2026-08-13T06:24:35.769Z",
5
+ "rules": [
6
+ {
7
+ "name": "no-entire-object-hook-deps",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1985
11
+ ],
12
+ "summary": "stop hoisting a guarded dereference into the dependency array (closes #1985)"
13
+ }
14
+ ]
15
+ },
2
16
  {
3
17
  "version": "1.20.146",
4
18
  "date": "2026-08-13T00:14:52.842Z",