@blumintinc/eslint-plugin-blumint 1.20.147 → 1.20.148

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.147',
226
+ version: '1.20.148',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -8,14 +8,72 @@ const ASTHelpers_1 = require("../utils/ASTHelpers");
8
8
  // derive names from the same function: any disagreement makes the two arrays
9
9
  // differ in length and silently skips the whole class body.
10
10
  const getMemberName = ClassGraphBuilder_1.classMemberNameOf;
11
+ /**
12
+ * The class members each member reads once its own body executes, keyed by
13
+ * member name. A method (or accessor) contributes its whole body; a field
14
+ * holding a function contributes that function's body, since `this.f()` runs it
15
+ * with the instance bound.
16
+ *
17
+ * An initializer that invokes such a member runs that body during
18
+ * construction, which makes those reads exactly as eager as the initializer's
19
+ * own — the syntactic eager-read scan stops at the call and cannot see them.
20
+ */
21
+ function readsWhenInvokedOf(node, className) {
22
+ const readsByMember = new Map();
23
+ for (const member of node.body) {
24
+ const name = getMemberName(member);
25
+ if (name === null) {
26
+ continue;
27
+ }
28
+ // The constructor is unreachable from an initializer — field initializers
29
+ // run inside it — so its body constrains nothing.
30
+ if (member.type === 'MethodDefinition' && member.kind !== 'constructor') {
31
+ readsByMember.set(name, ASTHelpers_1.ASTHelpers.classMemberNamesReferenced(member, className));
32
+ continue;
33
+ }
34
+ if (member.type === 'PropertyDefinition' &&
35
+ (member.value?.type === 'ArrowFunctionExpression' ||
36
+ member.value?.type === 'FunctionExpression')) {
37
+ // The function's body is passed rather than the function itself, because
38
+ // a `function` expression called as `this.f()` binds `this` to the
39
+ // instance even though the node type otherwise rebinds it.
40
+ readsByMember.set(name, ASTHelpers_1.ASTHelpers.classMemberNamesReferenced(member.value.body, className));
41
+ }
42
+ }
43
+ return readsByMember;
44
+ }
45
+ /**
46
+ * Every member name an initializer reads during construction, following each
47
+ * invoked member into what its own body reads until the set stops growing.
48
+ *
49
+ * The closure is deliberately blind to whether a named member is called or
50
+ * merely referenced: treating a bare reference as an invocation only adds
51
+ * constraints, and an extra constraint costs a declined reorder while a missing
52
+ * one ships a class that throws at construction.
53
+ */
54
+ function eagerReadClosureOf(initializer, className, readsWhenInvoked) {
55
+ const reached = new Set();
56
+ const pending = ASTHelpers_1.ASTHelpers.classMemberNamesReadEagerly(initializer, className);
57
+ while (pending.length > 0) {
58
+ const name = pending.pop();
59
+ if (reached.has(name)) {
60
+ continue;
61
+ }
62
+ reached.add(name);
63
+ pending.push(...(readsWhenInvoked.get(name) || []));
64
+ }
65
+ return [...reached];
66
+ }
11
67
  /**
12
68
  * Whether the proposed order still declares every field above the initializer
13
69
  * that reads it. Only field-to-field reads constrain the layout: methods (and
14
70
  * private methods) are installed before any initializer runs, so relocating one
15
- * is unobservable.
71
+ * is unobservable — but a field read reached THROUGH such a method still
72
+ * constrains it, because the call happens while the initializer runs.
16
73
  */
17
74
  function initializerReadsPrecedeDeclarations(node, sortedOrder, graph, className) {
18
75
  const positionOf = new Map(sortedOrder.map((name, index) => [name, index]));
76
+ const readsWhenInvoked = readsWhenInvokedOf(node, className);
19
77
  return node.body.every((member) => {
20
78
  if (member.type !== 'PropertyDefinition' || !member.value) {
21
79
  return true;
@@ -23,13 +81,24 @@ function initializerReadsPrecedeDeclarations(node, sortedOrder, graph, className
23
81
  const reader = getMemberName(member);
24
82
  const readerPosition = reader === null ? undefined : positionOf.get(reader);
25
83
  if (readerPosition === undefined) {
26
- return true;
84
+ // An initializer the sorted order cannot place is one whose reads cannot
85
+ // be compared against it, so no order can be certified safe.
86
+ return false;
27
87
  }
28
- return ASTHelpers_1.ASTHelpers.classMemberNamesReadEagerly(member.value, className)
29
- .filter((name) => graph[name]?.type === 'property' && name !== reader)
88
+ return eagerReadClosureOf(member.value, className, readsWhenInvoked)
89
+ .filter((name) => name !== reader)
30
90
  .every((name) => {
91
+ const target = graph[name];
92
+ // A read the sort cannot place — inherited, mixed in, or a constructor
93
+ // parameter property — leaves the layout uncertifiable.
94
+ if (!target) {
95
+ return false;
96
+ }
97
+ if (target.type !== 'property') {
98
+ return true;
99
+ }
31
100
  const readPosition = positionOf.get(name);
32
- return readPosition === undefined || readPosition < readerPosition;
101
+ return readPosition !== undefined && readPosition < readerPosition;
33
102
  });
34
103
  });
35
104
  }
@@ -691,6 +691,140 @@ function hasPriorConditionalGuard(node, baseName, visitorKeys) {
691
691
  Boolean(statement.test) &&
692
692
  testContainsObjectMember(statement.test, baseName, visitorKeys));
693
693
  }
694
+ /**
695
+ * Statement forms that can skip the declaration nested beneath them, so a
696
+ * declaration inside one is not reached on every pass through the callback.
697
+ * `finally` is folded in with the rest of `try`: the distinction costs a branch
698
+ * and buys back a shape nobody writes.
699
+ */
700
+ const CONDITIONAL_CONTAINERS = new Set([
701
+ utils_1.AST_NODE_TYPES.IfStatement,
702
+ utils_1.AST_NODE_TYPES.TryStatement,
703
+ utils_1.AST_NODE_TYPES.SwitchStatement,
704
+ utils_1.AST_NODE_TYPES.SwitchCase,
705
+ utils_1.AST_NODE_TYPES.ForStatement,
706
+ utils_1.AST_NODE_TYPES.ForInStatement,
707
+ utils_1.AST_NODE_TYPES.ForOfStatement,
708
+ utils_1.AST_NODE_TYPES.WhileStatement,
709
+ utils_1.AST_NODE_TYPES.DoWhileStatement,
710
+ ]);
711
+ /**
712
+ * Whether the pattern binds anything below its own root. Only the root gets the
713
+ * synthesized `?? {}` rescue; a nested pattern is re-emitted verbatim because a
714
+ * synthesized `= {}` under it would be checked against every binding beneath it
715
+ * (see formatPropertyText). So a nested pattern dereferences an intermediate
716
+ * that nothing in the hoisted text guards.
717
+ */
718
+ function patternBindsBeneathRoot(pattern) {
719
+ const stack = [...pattern.properties];
720
+ while (stack.length) {
721
+ const current = stack.pop();
722
+ if (!current)
723
+ continue;
724
+ if (current.type === utils_1.AST_NODE_TYPES.ObjectPattern ||
725
+ current.type === utils_1.AST_NODE_TYPES.ArrayPattern) {
726
+ return true;
727
+ }
728
+ if (current.type === utils_1.AST_NODE_TYPES.Property) {
729
+ stack.push(current.value);
730
+ continue;
731
+ }
732
+ if (current.type === utils_1.AST_NODE_TYPES.RestElement) {
733
+ stack.push(current.argument);
734
+ continue;
735
+ }
736
+ // A default's right-hand side is an expression, never a binding site, so
737
+ // only the left of an assignment pattern can hide a further pattern.
738
+ if (current.type === utils_1.AST_NODE_TYPES.AssignmentPattern) {
739
+ stack.push(current.left);
740
+ }
741
+ }
742
+ return false;
743
+ }
744
+ function containsTerminatingStatement(node, visitorKeys) {
745
+ const stack = [node];
746
+ while (stack.length) {
747
+ const current = stack.pop();
748
+ if (!current)
749
+ continue;
750
+ // A `return` belonging to a nested function exits that function, not the
751
+ // block the declaration sits in, so it guards nothing here.
752
+ if (current !== node && isAnyFunctionLikeNode(current)) {
753
+ continue;
754
+ }
755
+ if (current.type === utils_1.AST_NODE_TYPES.ReturnStatement ||
756
+ current.type === utils_1.AST_NODE_TYPES.ThrowStatement ||
757
+ current.type === utils_1.AST_NODE_TYPES.BreakStatement ||
758
+ current.type === utils_1.AST_NODE_TYPES.ContinueStatement) {
759
+ return true;
760
+ }
761
+ const keys = visitorKeys[current.type] ?? [];
762
+ for (const key of keys) {
763
+ const value = current[key];
764
+ if (Array.isArray(value)) {
765
+ for (const child of value) {
766
+ if (child && typeof child === 'object') {
767
+ stack.push(child);
768
+ }
769
+ }
770
+ }
771
+ else if (value && typeof value === 'object') {
772
+ stack.push(value);
773
+ }
774
+ }
775
+ }
776
+ return false;
777
+ }
778
+ function isEarlyExitGuard(statement, visitorKeys) {
779
+ return (statement.type === utils_1.AST_NODE_TYPES.IfStatement &&
780
+ containsTerminatingStatement(statement, visitorKeys));
781
+ }
782
+ /**
783
+ * Whether some conditional between the declaration and the callback body decides
784
+ * that the declaration runs at all — either a container that can skip it, or an
785
+ * earlier sibling that can leave the block before reaching it.
786
+ *
787
+ * Only the span up to the callback matters: the hoist lands immediately before
788
+ * the statement holding the hook call, so it keeps every conditional wrapping
789
+ * the hook itself and escapes exactly the ones inside the callback.
790
+ */
791
+ function isConditionallyReached(node, callback, visitorKeys) {
792
+ let current = node;
793
+ while (current !== callback.body) {
794
+ const parent = current.parent;
795
+ if (!parent)
796
+ return false;
797
+ if (CONDITIONAL_CONTAINERS.has(parent.type)) {
798
+ return true;
799
+ }
800
+ if (parent.type === utils_1.AST_NODE_TYPES.BlockStatement) {
801
+ const index = parent.body.indexOf(current);
802
+ if (index > 0 &&
803
+ parent.body
804
+ .slice(0, index)
805
+ .some((statement) => isEarlyExitGuard(statement, visitorKeys))) {
806
+ return true;
807
+ }
808
+ }
809
+ current = parent;
810
+ }
811
+ return false;
812
+ }
813
+ /**
814
+ * A nested destructure whose execution a guard controls stays put, whatever the
815
+ * guard tests. `hasPriorConditionalGuard` and `isTypeNarrowingContext` recognise
816
+ * only a guard naming the destructured object, which misses the ubiquitous
817
+ * `if (ready)` / `if (!isLoaded) return;` / `try` spellings that license the
818
+ * dereference without mentioning it. Hoisting past one of those evaluates the
819
+ * nested pattern on every render, where `?? {}` covers the root and leaves the
820
+ * intermediate to throw.
821
+ *
822
+ * The flat case keeps hoisting: there the `?? {}` rescue is the whole pattern.
823
+ */
824
+ function isGuardedNestedDestructure(declaration, pattern, callback, visitorKeys) {
825
+ return (patternBindsBeneathRoot(pattern) &&
826
+ isConditionallyReached(declaration, callback, visitorKeys));
827
+ }
694
828
  function isIdentifierReference(node) {
695
829
  const parent = node.parent;
696
830
  if (!parent)
@@ -747,7 +881,8 @@ function buildDestructuringGroups(callback, depTextSet, visitorKeys, sourceCode)
747
881
  continue;
748
882
  const baseName = getBaseIdentifier(declarator.init);
749
883
  if (hasPriorConditionalGuard(current, baseName, visitorKeys) ||
750
- isTypeNarrowingContext(current, baseName, visitorKeys)) {
884
+ isTypeNarrowingContext(current, baseName, visitorKeys) ||
885
+ isGuardedNestedDestructure(current, declarator.id, callback, visitorKeys)) {
751
886
  continue;
752
887
  }
753
888
  const existingGroup = groups.get(depKey);
@@ -126,6 +126,96 @@ function findInsertionPoint(node) {
126
126
  }
127
127
  return null;
128
128
  }
129
+ /**
130
+ * Whether an optional link sits at or below `expr` on its member/call spine,
131
+ * making everything further right in the chain unreachable when that link is
132
+ * nullish. `a?.b.run(x)` short-circuits at `a?.b`, so the optional flag the
133
+ * check needs is not the outer call's own.
134
+ */
135
+ function hasOptionalLink(expr) {
136
+ let current = expr;
137
+ while (current) {
138
+ switch (current.type) {
139
+ case utils_1.AST_NODE_TYPES.MemberExpression:
140
+ if (current.optional)
141
+ return true;
142
+ current = current.object;
143
+ break;
144
+ case utils_1.AST_NODE_TYPES.CallExpression:
145
+ if (current.optional)
146
+ return true;
147
+ current = current.callee;
148
+ break;
149
+ case utils_1.AST_NODE_TYPES.ChainExpression:
150
+ case utils_1.AST_NODE_TYPES.TSNonNullExpression:
151
+ current = current.expression;
152
+ break;
153
+ default:
154
+ return false;
155
+ }
156
+ }
157
+ return false;
158
+ }
159
+ /**
160
+ * Whether `child` sits in a position of `parent` that control flow may skip.
161
+ * Positions evaluated regardless of the branch taken — an `if` test, a `&&`
162
+ * left operand, a `switch` discriminant, a `do` body, an optional chain's
163
+ * object spine — are excluded, since hoisting above them preserves evaluation.
164
+ */
165
+ function isSkippableBranch(child, parent) {
166
+ switch (parent.type) {
167
+ case utils_1.AST_NODE_TYPES.IfStatement:
168
+ case utils_1.AST_NODE_TYPES.ConditionalExpression:
169
+ return parent.consequent === child || parent.alternate === child;
170
+ case utils_1.AST_NODE_TYPES.LogicalExpression:
171
+ return parent.right === child;
172
+ case utils_1.AST_NODE_TYPES.SwitchStatement:
173
+ return parent.discriminant !== child;
174
+ // A case's test and body are both reached only once the switch selects it.
175
+ case utils_1.AST_NODE_TYPES.SwitchCase:
176
+ return true;
177
+ // A loop body may run zero times; a `do` body always runs once, so it is
178
+ // not listed here.
179
+ case utils_1.AST_NODE_TYPES.WhileStatement:
180
+ case utils_1.AST_NODE_TYPES.ForStatement:
181
+ case utils_1.AST_NODE_TYPES.ForInStatement:
182
+ case utils_1.AST_NODE_TYPES.ForOfStatement:
183
+ return parent.body === child;
184
+ // Arguments and computed keys of an optional chain go unevaluated when the
185
+ // chain short-circuits, so they guard their subtree exactly as an `if` does.
186
+ case utils_1.AST_NODE_TYPES.MemberExpression:
187
+ return parent.object !== child && hasOptionalLink(parent);
188
+ case utils_1.AST_NODE_TYPES.CallExpression:
189
+ return parent.callee !== child && hasOptionalLink(parent);
190
+ default:
191
+ return false;
192
+ }
193
+ }
194
+ /**
195
+ * Whether the climb from the hook call to the insertion statement escapes a
196
+ * position that control flow may skip. A guard that wraps the hook without a
197
+ * block of its own gives the climb no in-branch statement position, so the memo
198
+ * lands above the guard while its dependency array `[<base>]` dereferences the
199
+ * guarded value on every render — turning code that does not throw into code
200
+ * that does. The dereference was safe only because of the surrounding
201
+ * narrowing, which the hoisted position no longer enjoys, so the fix is
202
+ * withheld and the report stands alone. A braced guard needs no bail: the
203
+ * insertion block is then the guarded block itself.
204
+ */
205
+ function crossesConditionalGuard(node, insertion) {
206
+ let current = node;
207
+ while (current !== insertion.statement) {
208
+ const parent = current.parent;
209
+ // An insertion statement absent from the hook's ancestor chain contradicts
210
+ // how it was derived; decline rather than guess at the geometry.
211
+ if (!parent)
212
+ return true;
213
+ if (isSkippableBranch(current, parent))
214
+ return true;
215
+ current = parent;
216
+ }
217
+ return false;
218
+ }
129
219
  /**
130
220
  * Collects the identifiers a hoisted `stableHash(<base>)` expression would
131
221
  * read: the root object of the member chain plus any computed keys. Property
@@ -496,6 +586,8 @@ exports.noArrayLengthInDeps = (0, createRule_1.createRule)({
496
586
  const insertion = findInsertionPoint(node);
497
587
  if (!insertion)
498
588
  return null;
589
+ if (crossesConditionalGuard(node, insertion))
590
+ return null;
499
591
  for (const { member } of lengthDeps) {
500
592
  if (!isBaseSafeToHoist(context, getBaseExpression(member), insertion)) {
501
593
  return null;
@@ -11,6 +11,16 @@ const HOOK_NAMES = new Set(['useEffect', 'useCallback', 'useMemo']);
11
11
  * — see `callsCorrespondingSetter`.
12
12
  */
13
13
  const EFFECT_HOOK_NAMES = new Set(['useEffect']);
14
+ /**
15
+ * Hooks that do not run their callback: they hand it back as a value, so the
16
+ * body executes only if — and when — the consumer invokes it.
17
+ *
18
+ * why: the dependency array is evaluated on every render, while such a body may
19
+ * never run at all, or run only once the data it reads has arrived. A path the
20
+ * body dereferences is therefore not licensed to appear in the array; see
21
+ * `collectGuardedPaths`.
22
+ */
23
+ const DEFERRED_BODY_HOOK_NAMES = new Set(['useCallback']);
14
24
  function isHookCall(node) {
15
25
  const callee = node.callee;
16
26
  return (callee.type === utils_1.AST_NODE_TYPES.Identifier && HOOK_NAMES.has(callee.name));
@@ -20,6 +30,11 @@ function isEffectHookCall(node) {
20
30
  return (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
21
31
  EFFECT_HOOK_NAMES.has(callee.name));
22
32
  }
33
+ function isDeferredBodyHookCall(node) {
34
+ const callee = node.callee;
35
+ return (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
36
+ DEFERRED_BODY_HOOK_NAMES.has(callee.name));
37
+ }
23
38
  /**
24
39
  * The rule whose suppression marks a dependency array as hand-maintained.
25
40
  */
@@ -236,8 +251,8 @@ function isCallCallee(node) {
236
251
  parent.callee === current);
237
252
  }
238
253
  /**
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.
254
+ * The links of `node`'s access path when the chain is rooted at `objectName`,
255
+ * or null when it is rooted elsewhere or holds a link with no stable rendering.
241
256
  *
242
257
  * why: guard collection asks a different question than `buildAccessPath` —
243
258
  * "which value did this condition establish something about?" rather than
@@ -245,7 +260,7 @@ function isCallCallee(node) {
245
260
  * function's narrowing policy (method carve-outs, whole-object escalation) nor
246
261
  * its side effects on the usage set.
247
262
  */
248
- function renderMemberPathIfRootedAt(node, objectName) {
263
+ function memberPathSegmentsIfRootedAt(node, objectName) {
249
264
  const segments = [];
250
265
  let current = node;
251
266
  while (current.type === utils_1.AST_NODE_TYPES.MemberExpression) {
@@ -284,7 +299,28 @@ function renderMemberPathIfRootedAt(node, objectName) {
284
299
  base.name !== objectName) {
285
300
  return null;
286
301
  }
287
- return renderPathSegments(objectName, segments);
302
+ return segments;
303
+ }
304
+ function renderMemberPathIfRootedAt(node, objectName) {
305
+ const segments = memberPathSegmentsIfRootedAt(node, objectName);
306
+ return segments ? renderPathSegments(objectName, segments) : null;
307
+ }
308
+ /**
309
+ * How many links of `segments` a dependency array may evaluate on a render that
310
+ * never reaches the position the path was read in.
311
+ *
312
+ * why: the array already dereferences the dependency object, so its first link
313
+ * is as safe as the entry it replaces. Every further link dereferences a value
314
+ * whose existence only the skipped position established, unless it is spelled
315
+ * `?.` — an optional link short-circuits instead of throwing and so extends the
316
+ * prefix.
317
+ */
318
+ function eagerlyReachableLength(segments) {
319
+ let length = 1;
320
+ while (length < segments.length && segments[length].optional) {
321
+ length += 1;
322
+ }
323
+ return length;
288
324
  }
289
325
  /**
290
326
  * Every path of `objectName` whose dereferenceability the hook body establishes
@@ -297,14 +333,23 @@ function renderMemberPathIfRootedAt(node, objectName) {
297
333
  * unconditional `TypeError`. The paths collected here mark where a path must
298
334
  * stop; see `safePrefixOf`.
299
335
  *
336
+ * A condition is not the only licence a body can hold. A `try`/`catch` swallows
337
+ * the very `TypeError` a deep dereference raises, and a body that runs later —
338
+ * an inner callback, the function a `useCallback` hands back — may not run at
339
+ * all on the render whose array is being evaluated. Neither licence travels
340
+ * into the array, so both stop a path exactly as a condition does.
341
+ *
300
342
  * The collection is deliberately over-broad — it accepts any member path
301
343
  * appearing anywhere in a condition, not only one that provably governs the
302
344
  * access. Over-collecting costs a coarser dependency (the memo recomputes more
303
345
  * often than strictly needed); under-collecting is the crash.
346
+ *
347
+ * `bodyIsDeferred` says the hook itself never runs the body it was handed.
304
348
  */
305
- function collectGuardedPaths(hookBody, objectName) {
349
+ function collectGuardedPaths(hookBody, objectName, bodyIsDeferred) {
306
350
  const guarded = new Set();
307
351
  const visited = new Set();
352
+ const deferredVisited = new Set();
308
353
  function markConditionPaths(node) {
309
354
  if (!node)
310
355
  return;
@@ -324,6 +369,35 @@ function collectGuardedPaths(hookBody, objectName) {
324
369
  }
325
370
  forEachChildNode(node, markConditionPaths);
326
371
  }
372
+ /**
373
+ * Records where every path read inside a position the array cannot reach —
374
+ * a protected `try` block, a body that runs later — has to stop.
375
+ *
376
+ * Unlike a condition, such a position licenses the whole dereference chain
377
+ * rather than one link of it, so the stopping point is derived from the path
378
+ * itself: everything the array can evaluate on its own is kept, and the first
379
+ * link that would have to trust the skipped code ends it.
380
+ */
381
+ function markDeferredPaths(node) {
382
+ if (!node || deferredVisited.has(node))
383
+ return;
384
+ deferredVisited.add(node);
385
+ if (node.type === utils_1.AST_NODE_TYPES.MemberExpression) {
386
+ const segments = memberPathSegmentsIfRootedAt(node, objectName);
387
+ if (segments) {
388
+ const reachable = eagerlyReachableLength(segments);
389
+ if (reachable < segments.length) {
390
+ guarded.add(renderPathSegments(objectName, segments.slice(0, reachable)));
391
+ }
392
+ // A computed key can still hold a read of its own.
393
+ if (node.computed) {
394
+ markDeferredPaths(node.property);
395
+ }
396
+ return;
397
+ }
398
+ }
399
+ forEachChildNode(node, markDeferredPaths);
400
+ }
327
401
  function walk(node) {
328
402
  if (!node || visited.has(node))
329
403
  return;
@@ -367,8 +441,33 @@ function collectGuardedPaths(hookBody, objectName) {
367
441
  markConditionPaths(asserted);
368
442
  }
369
443
  }
444
+ else if (node.type === utils_1.AST_NODE_TYPES.TryStatement) {
445
+ // A `catch` is a licence to dereference: the author writes the deep
446
+ // access knowing the TypeError it can raise is swallowed. The array
447
+ // evaluates the same access outside the `try`, where the throw is
448
+ // uncaught, so the path stops before the link the `catch` covers.
449
+ //
450
+ // The handler is what makes the difference: a `try`/`finally` with no
451
+ // handler re-raises, so its block reads like ordinary code. The handler's
452
+ // own body is deferred instead — it runs only if something threw.
453
+ if (node.handler) {
454
+ markDeferredPaths(node.block);
455
+ markDeferredPaths(node.handler.body);
456
+ }
457
+ }
458
+ else if (node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
459
+ node.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
460
+ node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration) {
461
+ // An inner callback runs on someone else's schedule — an event, a
462
+ // resolved promise, an iteration over an array that may be empty — so a
463
+ // path it reads says nothing about the render evaluating the array.
464
+ markDeferredPaths(node.body);
465
+ }
370
466
  forEachChildNode(node, walk);
371
467
  }
468
+ if (bodyIsDeferred) {
469
+ markDeferredPaths(hookBody);
470
+ }
372
471
  walk(hookBody);
373
472
  return guarded;
374
473
  }
@@ -421,14 +520,14 @@ function callsCorrespondingSetter(hookBody, dependencyName) {
421
520
  }
422
521
  return visit(hookBody);
423
522
  }
424
- function getObjectUsagesInHook(hookBody, objectName, typeInfo) {
523
+ function getObjectUsagesInHook(hookBody, objectName, typeInfo, bodyIsDeferred = false) {
425
524
  const usages = new Map(); // Track usage and its position
426
525
  // why: derived dependency paths (first-optional intermediate, array base)
427
526
  // must be re-rendered from structured links — string surgery on the
428
527
  // rendered path cannot place `?.` markers correctly.
429
528
  const pathSegments = new Map();
430
529
  const visited = new Set();
431
- const guardedPaths = collectGuardedPaths(hookBody, objectName);
530
+ const guardedPaths = collectGuardedPaths(hookBody, objectName, bodyIsDeferred);
432
531
  let needsEntireObject = false;
433
532
  let isUsed = false;
434
533
  /**
@@ -1060,6 +1159,7 @@ exports.noEntireObjectHookDeps = (0, createRule_1.createRule)({
1060
1159
  }
1061
1160
  const callbackBody = callbackArg.body;
1062
1161
  const isEffect = isEffectHookCall(node);
1162
+ const bodyIsDeferred = isDeferredBodyHookCall(node);
1063
1163
  const manuallyManagedDeps = hasManuallyManagedDeps(node);
1064
1164
  // Check each dependency in the array
1065
1165
  depsArg.elements.forEach((element) => {
@@ -1077,7 +1177,7 @@ exports.noEntireObjectHookDeps = (0, createRule_1.createRule)({
1077
1177
  }
1078
1178
  }
1079
1179
  // For testing without TypeScript services, we'll assume all identifiers are objects
1080
- const result = getObjectUsagesInHook(callbackBody, objectName, dependencyTypeInfo);
1180
+ const result = getObjectUsagesInHook(callbackBody, objectName, dependencyTypeInfo, bodyIsDeferred);
1081
1181
  // If the object is not used at all, suggest removing it
1082
1182
  if (result.notUsed) {
1083
1183
  // why: deleting an entry from an array the author maintains by
@@ -379,6 +379,31 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
379
379
  * from that instance (`super.users` versus `this.posts`) stay distinct.
380
380
  */
381
381
  const INSTANCE_RECEIVER_KEY = 'this';
382
+ /**
383
+ * Strips the wrappers that punctuate an expression without changing which
384
+ * value it denotes, so one path is recognized however it is spelled
385
+ * (`a.b`, `a?.b`, `a!.b`, `(a as T).b`).
386
+ */
387
+ function unwrapExpression(node) {
388
+ let current = node;
389
+ while (current.type === utils_1.AST_NODE_TYPES.ChainExpression ||
390
+ current.type === utils_1.AST_NODE_TYPES.TSNonNullExpression ||
391
+ current.type === utils_1.AST_NODE_TYPES.TSAsExpression) {
392
+ current = current.expression;
393
+ }
394
+ return current;
395
+ }
396
+ /**
397
+ * Walks a member chain down to the expression it is rooted at: the binding,
398
+ * the instance, or whatever else the chain starts from.
399
+ */
400
+ function getPathRoot(node) {
401
+ let root = unwrapExpression(node);
402
+ while (root.type === utils_1.AST_NODE_TYPES.MemberExpression) {
403
+ root = unwrapExpression(root.object);
404
+ }
405
+ return root;
406
+ }
382
407
  /**
383
408
  * Builds a stable textual key for the object a method is invoked on, so two
384
409
  * awaits can be compared for "same receiver" by string equality.
@@ -548,6 +573,104 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
548
573
  visit(node);
549
574
  return keys;
550
575
  }
576
+ /**
577
+ * Collects the member paths an expression DEREFERENCES: the object of a
578
+ * member access that is itself a member access, keyed the same way a
579
+ * receiver is.
580
+ *
581
+ * Depth is what makes the path a REQUIREMENT rather than a mere read.
582
+ * Evaluating `store.data.id` demands that `store.data` already hold an
583
+ * object, and throws when it does not; evaluating `store.data` demands
584
+ * nothing, since an unfilled slot yields `undefined` and passing `undefined`
585
+ * onward is silent. Only the former can turn the Promise.all rewrite into a
586
+ * crash, so only the former mints a key here.
587
+ *
588
+ * The traversal stops at function boundaries, the opposite of
589
+ * getInstancePathKeys: the hazard is eager evaluation at array-literal
590
+ * construction, and a path inside a callback handed to the operation is
591
+ * dereferenced when that callback runs, which the rewrite does not move.
592
+ *
593
+ * The root node travels with the key because the two questions the barrier
594
+ * asks of a path -- which slot it names, and where its root is bound -- are
595
+ * answered by different things.
596
+ */
597
+ function getDereferencedPaths(node) {
598
+ const paths = [];
599
+ const visit = (current) => {
600
+ if (current.type === utils_1.AST_NODE_TYPES.MemberExpression) {
601
+ const object = unwrapExpression(current.object);
602
+ if (object.type === utils_1.AST_NODE_TYPES.MemberExpression) {
603
+ const key = getReceiverKey(object);
604
+ if (key !== null) {
605
+ paths.push({ key, root: getPathRoot(object) });
606
+ }
607
+ }
608
+ }
609
+ if (FUNCTION_BOUNDARY_TYPES.has(current.type)) {
610
+ return;
611
+ }
612
+ for (const key in current) {
613
+ if (key === 'parent' || key === 'range' || key === 'loc')
614
+ continue;
615
+ const child = current[key];
616
+ if (!child || typeof child !== 'object')
617
+ continue;
618
+ if (Array.isArray(child)) {
619
+ for (const item of child) {
620
+ if (item && typeof item === 'object' && 'type' in item) {
621
+ visit(item);
622
+ }
623
+ }
624
+ }
625
+ else if ('type' in child) {
626
+ visit(child);
627
+ }
628
+ }
629
+ };
630
+ visit(node);
631
+ return paths;
632
+ }
633
+ /**
634
+ * Reports whether a path is rooted at state an opaque call could reach
635
+ * without ever being handed it: a module-scope binding (a module-level
636
+ * `const`, or an import), or a name that resolves nowhere and is therefore
637
+ * an implicit global.
638
+ *
639
+ * A function-local root fails the test. A parameter or a local `const` is
640
+ * not visible to a callee that receives neither, so a bare-identifier call
641
+ * cannot rebind it, and reading a slot beneath it after that call is not the
642
+ * hazard this barrier answers.
643
+ *
644
+ * The instance fails it too: `this` names an object a free function was not
645
+ * given, so an instance path can only be written through code the run
646
+ * itself shows -- which the closure-write barrier already reads.
647
+ */
648
+ function isAmbientlyReachableRoot(path, root, declaredNames) {
649
+ if (root.type !== utils_1.AST_NODE_TYPES.Identifier) {
650
+ return false;
651
+ }
652
+ if (isInstancePathKey(path) || declaredNames.has(root.name)) {
653
+ return false;
654
+ }
655
+ const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, root), root.name);
656
+ if (!variable) {
657
+ return true;
658
+ }
659
+ return (variable.scope.type === utils_1.TSESLint.Scope.ScopeType.module ||
660
+ variable.scope.type === utils_1.TSESLint.Scope.ScopeType.global);
661
+ }
662
+ /**
663
+ * Reports whether an awaited expression is a call through a bare identifier
664
+ * (`hydrate()`, `initAnalytics()`), the one callee shape that names no
665
+ * receiver at all. Such a call's effects are reachable only through the
666
+ * module scope it closes over, so nothing in the run describes what it
667
+ * touches. Handles optional-call ChainExpressions.
668
+ */
669
+ function hasBareIdentifierCallee(awaitExpr) {
670
+ const argument = unwrapExpression(awaitExpr.argument);
671
+ return (argument.type === utils_1.AST_NODE_TYPES.CallExpression &&
672
+ unwrapExpression(argument.callee).type === utils_1.AST_NODE_TYPES.Identifier);
673
+ }
551
674
  /**
552
675
  * Extracts the receiver key of an awaited *named-method* call: the key of
553
676
  * the `object` of a `MemberExpression` callee, when the accessed member is
@@ -584,6 +707,105 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
584
707
  }
585
708
  return getReceiverKey(callee.object);
586
709
  }
710
+ /**
711
+ * Finds the function a class declares for a member name, searching the
712
+ * class body that encloses a node. Both spellings of a method-valued member
713
+ * qualify: a `MethodDefinition` and a `PropertyDefinition` initialized with
714
+ * a function.
715
+ *
716
+ * The NEAREST enclosing class body is the one searched. A method reached
717
+ * through `super` resolves against a base class, and an inner class shadows
718
+ * an outer one, so the answer can name a function other than the one that
719
+ * actually runs -- but the caller only ever ADDS the resolved body's writes
720
+ * to the writes it already sees, so a wrong answer can only introduce a
721
+ * barrier, the direction this rule's trade-off prefers.
722
+ */
723
+ function findClassMethodFunction(node, memberName) {
724
+ let current = node;
725
+ while (current && current.type !== utils_1.AST_NODE_TYPES.ClassBody) {
726
+ current = current.parent;
727
+ }
728
+ if (!current) {
729
+ return null;
730
+ }
731
+ for (const member of current.body) {
732
+ if (member.type !== utils_1.AST_NODE_TYPES.MethodDefinition &&
733
+ member.type !== utils_1.AST_NODE_TYPES.PropertyDefinition) {
734
+ continue;
735
+ }
736
+ if (member.computed) {
737
+ continue;
738
+ }
739
+ const key = member.key;
740
+ const name = key.type === utils_1.AST_NODE_TYPES.PrivateIdentifier
741
+ ? `#${key.name}`
742
+ : key.type === utils_1.AST_NODE_TYPES.Identifier
743
+ ? key.name
744
+ : null;
745
+ if (name !== memberName) {
746
+ continue;
747
+ }
748
+ const value = member.value;
749
+ if (value && FUNCTION_BOUNDARY_TYPES.has(value.type)) {
750
+ return value;
751
+ }
752
+ }
753
+ return null;
754
+ }
755
+ /**
756
+ * Resolves an awaited call to the function this file DECLARES for its
757
+ * callee: a hoisted `function` declaration, a binding initialized with a
758
+ * function, or a method of the enclosing class reached through
759
+ * `this`/`super`.
760
+ *
761
+ * The body is where an operation's ordering constraint usually lives.
762
+ * `await hydrate()` discards its result and mentions nothing it touches, so
763
+ * every barrier keyed on the run's own text reads it as independent, while
764
+ * the body assigns the very state the next await dereferences. Reading the
765
+ * body turns that guess into a fact wherever the file supplies one.
766
+ *
767
+ * Only one level is followed, so a write reached through a helper's own
768
+ * helper stays invisible. A callee that resolves nowhere -- an import, a
769
+ * parameter, a method on some other object -- yields null and leaves the
770
+ * syntactic barriers to answer for it.
771
+ */
772
+ function resolveCalleeFunction(awaitExpr) {
773
+ const argument = unwrapExpression(awaitExpr.argument);
774
+ if (argument.type !== utils_1.AST_NODE_TYPES.CallExpression) {
775
+ return null;
776
+ }
777
+ const callee = unwrapExpression(argument.callee);
778
+ if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
779
+ const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, callee), callee.name);
780
+ if (!variable) {
781
+ return null;
782
+ }
783
+ for (const definition of variable.defs) {
784
+ const declaration = definition.node;
785
+ if (declaration.type === utils_1.AST_NODE_TYPES.FunctionDeclaration) {
786
+ return declaration;
787
+ }
788
+ if (declaration.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
789
+ declaration.init &&
790
+ FUNCTION_BOUNDARY_TYPES.has(declaration.init.type)) {
791
+ return declaration.init;
792
+ }
793
+ }
794
+ return null;
795
+ }
796
+ if (callee.type !== utils_1.AST_NODE_TYPES.MemberExpression) {
797
+ return null;
798
+ }
799
+ const object = unwrapExpression(callee.object);
800
+ if (object.type !== utils_1.AST_NODE_TYPES.ThisExpression &&
801
+ object.type !== utils_1.AST_NODE_TYPES.Super) {
802
+ return null;
803
+ }
804
+ const memberName = getMemberSegment(callee);
805
+ return memberName === null
806
+ ? null
807
+ : findClassMethodFunction(awaitExpr, memberName);
808
+ }
587
809
  /**
588
810
  * Matches the `Promise` combinators that take an array of promises and
589
811
  * return a single promise standing in for the whole group. Awaiting one of
@@ -1196,11 +1418,32 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
1196
1418
  // a write to `this.alpha` leaves a later `this.beta` parallelizable while
1197
1419
  // a write to `this.state` still blocks a later `this.state.mutator`.
1198
1420
  // (#1924)
1421
+ //
1422
+ // A write inside the awaited call's own BODY reaches the enclosing scope
1423
+ // exactly as a write inside a callback does -- `await hydrate()`, where
1424
+ // `hydrate` assigns `store.state`, publishes that slot by the time it
1425
+ // settles. The body is not part of the awaited expression, so the
1426
+ // traversal above cannot see it; wherever the file declares the callee,
1427
+ // its writes are read from there and joined to the ones spelled out in
1428
+ // the run. (#1989)
1199
1429
  const assignedState = awaitNodes.map((node) => {
1200
1430
  const awaitExpr = getAwaitExpression(node);
1201
- return awaitExpr
1202
- ? getAssignedState(awaitExpr.argument)
1203
- : { names: new Set(), instancePaths: new Set() };
1431
+ if (!awaitExpr) {
1432
+ return { names: new Set(), instancePaths: new Set() };
1433
+ }
1434
+ const written = getAssignedState(awaitExpr.argument);
1435
+ const calleeFunction = resolveCalleeFunction(awaitExpr);
1436
+ if (!calleeFunction) {
1437
+ return written;
1438
+ }
1439
+ const calleeWritten = getAssignedState(calleeFunction);
1440
+ return {
1441
+ names: new Set([...written.names, ...calleeWritten.names]),
1442
+ instancePaths: new Set([
1443
+ ...written.instancePaths,
1444
+ ...calleeWritten.instancePaths,
1445
+ ]),
1446
+ };
1204
1447
  });
1205
1448
  const readInstancePaths = awaitNodes.map((node) => {
1206
1449
  const awaitExpr = getAwaitExpression(node);
@@ -1249,6 +1492,75 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
1249
1492
  return true;
1250
1493
  }
1251
1494
  }
1495
+ // 12. Deferred-dereference barrier. A statement AFTER an await occupies a
1496
+ // deferred position: it is evaluated only once the preceding promise
1497
+ // settles. The rewrite splices its awaited operand into a `Promise.all`
1498
+ // ARRAY LITERAL, whose elements evaluate eagerly at construction. Any slot
1499
+ // the later operand must dereference is therefore read BEFORE the earlier
1500
+ // operation runs, so a slot that operation installs is read while still
1501
+ // `undefined` and the element throws a TypeError -- code that returned a
1502
+ // value before the fix crashes after it. The two awaits are genuinely
1503
+ // ordered, so the run is not a latency mistake and the rule declines to
1504
+ // report it rather than merely declining the fix. (#1989)
1505
+ //
1506
+ // The write itself is invisible: it happens inside the awaited call's own
1507
+ // BODY, so no value flows out of the await, no variable is declared, and
1508
+ // no assignment appears anywhere the closure-write barrier looks. What the
1509
+ // run does show is REACH -- whether the earlier operation could have
1510
+ // installed the slot at all -- and only a discarded-result await qualifies
1511
+ // as the writer, since a captured result is a value dependency the
1512
+ // identifier comparison already sees.
1513
+ //
1514
+ // Reach comes in two shapes, and only these two, so that sibling slots
1515
+ // under a shared namespace (`api.users.getAll()` then
1516
+ // `api.posts.getRecent()`) stay parallelizable:
1517
+ //
1518
+ // (a) The earlier call is invoked ON a receiver that CONTAINS the slot
1519
+ // (`this.connect()` then `this.client.send()`, `store.load()` then
1520
+ // `send(store.data.id)`). A method can fill any slot beneath its own
1521
+ // receiver, which is what makes such a pair ordered. This widens the
1522
+ // shared-receiver barrier from equality to containment: barrier 7
1523
+ // compares two receivers, whereas here the earlier receiver is
1524
+ // compared against a path the later operand merely reads.
1525
+ //
1526
+ // (b) The earlier call goes through a bare identifier (`hydrate()`,
1527
+ // `initAnalytics()`) and the slot hangs off module-scope state. Such
1528
+ // a call names no receiver, so the run says nothing about what it
1529
+ // touches, while module-scope state is reachable from inside it
1530
+ // without ever being passed in. Function-local roots and instance
1531
+ // paths are excluded: a free function handed neither cannot rebind
1532
+ // them, so writes that reach them are visible in the run and belong
1533
+ // to the closure-write barrier.
1534
+ const dereferencedPaths = awaitNodes.map((node) => {
1535
+ const awaitExpr = getAwaitExpression(node);
1536
+ return awaitExpr ? getDereferencedPaths(awaitExpr.argument) : [];
1537
+ });
1538
+ for (let i = 1; i < awaitNodes.length; i++) {
1539
+ const paths = dereferencedPaths[i];
1540
+ if (paths.length === 0)
1541
+ continue;
1542
+ for (let j = 0; j < i; j++) {
1543
+ if (awaitNodes[j].type !== utils_1.AST_NODE_TYPES.ExpressionStatement) {
1544
+ continue;
1545
+ }
1546
+ const priorExpr = getAwaitExpression(awaitNodes[j]);
1547
+ if (!priorExpr)
1548
+ continue;
1549
+ const priorReceiver = receiverKeys[j];
1550
+ for (const path of paths) {
1551
+ if (priorReceiver !== null &&
1552
+ (path.key === priorReceiver ||
1553
+ path.key.startsWith(`${priorReceiver}.`))) {
1554
+ return true;
1555
+ }
1556
+ if (priorReceiver === null &&
1557
+ hasBareIdentifierCallee(priorExpr) &&
1558
+ isAmbientlyReachableRoot(path.key, path.root, variableNames)) {
1559
+ return true;
1560
+ }
1561
+ }
1562
+ }
1563
+ }
1252
1564
  return false;
1253
1565
  }
1254
1566
  /**
@@ -823,6 +823,68 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
823
823
  }
824
824
  return values.some((value) => referencesThis(value) || referencesPrivateName(value));
825
825
  }
826
+ /**
827
+ * Whether stepping from `child` up to `parent` crosses a boundary that
828
+ * decides WHETHER — or how many times — the child is evaluated, or that
829
+ * owns bindings the child may read.
830
+ *
831
+ * The question is about the child's POSITION, not the parent's type alone:
832
+ * an `if` test, a `for` init and a `for…of` right-hand side are evaluated
833
+ * exactly once, before control reaches the construct they head, so text
834
+ * lifted out of them runs precisely when it used to. A while/do-while test
835
+ * is re-evaluated per iteration, so it is a boundary like the bodies are.
836
+ */
837
+ function crossingGuardsEvaluation(child, parent) {
838
+ switch (parent.type) {
839
+ case utils_1.AST_NODE_TYPES.IfStatement:
840
+ return parent.test !== child;
841
+ case utils_1.AST_NODE_TYPES.WhileStatement:
842
+ case utils_1.AST_NODE_TYPES.DoWhileStatement:
843
+ return true;
844
+ case utils_1.AST_NODE_TYPES.ForStatement:
845
+ return parent.init !== child;
846
+ case utils_1.AST_NODE_TYPES.ForInStatement:
847
+ case utils_1.AST_NODE_TYPES.ForOfStatement:
848
+ return parent.right !== child;
849
+ case utils_1.AST_NODE_TYPES.LogicalExpression:
850
+ // `&&`/`||`/`??` evaluate the right operand only for some values of
851
+ // the left; the left operand always runs.
852
+ return parent.right === child;
853
+ case utils_1.AST_NODE_TYPES.ConditionalExpression:
854
+ return parent.test !== child;
855
+ default:
856
+ return false;
857
+ }
858
+ }
859
+ /**
860
+ * Whether the ternary form's hoist would carry the branch values across a
861
+ * guard that decides whether they are evaluated, or out of a scope that
862
+ * owns bindings they read.
863
+ *
864
+ * The hoist walk treats a braceless body as transparent — it stops only at
865
+ * a `BlockStatement`/`Program`/`SwitchCase` — so `if (box) return kind ===
866
+ * 'a' ? box.a.v : box.b.v;` lands the `Record` ABOVE the guard and
867
+ * dereferences both values with the narrowing left behind (#1990). Braces
868
+ * are what make the hoist safe: with them the walk stops inside the guarded
869
+ * block, where the values are still narrowed and every loop binding is
870
+ * still in scope, so the boundary test also cures the scope violation that
871
+ * hoisting out of `for (const r of rows)` produces (TS2304).
872
+ *
873
+ * Nothing else sees this: the branch values are ordinary member reads, so
874
+ * `EAGER_UNSAFE_NODES` passes them, and `isNarrowingExempt` inspects only
875
+ * narrowing by the discriminant's own root, never narrowing applied by a
876
+ * surrounding statement.
877
+ */
878
+ function hoistCrossesGuard(node) {
879
+ let child = node;
880
+ while (child.parent && !CONTAINER_TYPES.has(child.parent.type)) {
881
+ if (crossingGuardsEvaluation(child, child.parent)) {
882
+ return true;
883
+ }
884
+ child = child.parent;
885
+ }
886
+ return false;
887
+ }
826
888
  // ---- Name derivation ----------------------------------------------------
827
889
  function toUpperSnake(name) {
828
890
  return name
@@ -1075,6 +1137,9 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
1075
1137
  if (flags.hoistEscapesClass) {
1076
1138
  return 'the Record would hoist outside the class body, where the branch values’ `this`/`#private` reads do not resolve; extract it manually inside the class';
1077
1139
  }
1140
+ if (flags.hoistCrossesGuard) {
1141
+ return 'the Record would hoist above the guard or loop that decides whether the branch values are evaluated, dropping their narrowing and any binding the guard scopes; add braces around the branch — or lift the guarded expression into its own statement — then convert';
1142
+ }
1078
1143
  if (!flags.inStatementList) {
1079
1144
  return 'the dispatch is the whole body of a braceless branch, where a declaration is not allowed; add braces around it, then convert';
1080
1145
  }
@@ -1090,6 +1155,9 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
1090
1155
  const { entries, contributingValues, dText, form, assignTargetText, fullCoverage, hasNullish, canPlaceFix, commentBlocked, } = analysis;
1091
1156
  const eagerSafe = contributingValues.every((expr) => !containsEagerUnsafe(expr));
1092
1157
  const hoistEscapesClass = form === 'expr' && hoistLeavesClassBody(node, contributingValues);
1158
+ // Only the ternary hoists; every other form is replaced where it stands,
1159
+ // so no guard can be crossed.
1160
+ const crossesGuard = form === 'expr' && hoistCrossesGuard(node);
1093
1161
  // Every non-ternary form replaces the construct with a declaration plus a
1094
1162
  // statement, which needs a statement LIST to land in. As the sole body of
1095
1163
  // a braceless `if`/`else`/loop the construct sits where exactly one
@@ -1106,6 +1174,7 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
1106
1174
  eagerSafe &&
1107
1175
  canPlaceFix &&
1108
1176
  !hoistEscapesClass &&
1177
+ !crossesGuard &&
1109
1178
  inStatementList &&
1110
1179
  !commentBlocked) {
1111
1180
  derivation = deriveLookupName(discriminantOf(node), fixScope);
@@ -1121,6 +1190,7 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
1121
1190
  eagerSafe,
1122
1191
  canPlaceFix,
1123
1192
  hoistEscapesClass,
1193
+ hoistCrossesGuard: crossesGuard,
1124
1194
  inStatementList,
1125
1195
  commentSafe: !commentBlocked,
1126
1196
  derivation,
@@ -49,6 +49,17 @@ export declare class ASTHelpers {
49
49
  * happens to share a member's name is not a reference to that member.
50
50
  */
51
51
  static classMethodDependenciesOf(node: TSESTree.Node | null, graph: Graph, className: string): string[];
52
+ /**
53
+ * Every class member a node reaches through `this.<member>` (or
54
+ * `<ClassName>.<member>`), fields included and unfiltered by any graph.
55
+ *
56
+ * Whatever a member's body reads, it reads as soon as that body runs, so a
57
+ * caller deciding whether an invocation is order-sensitive needs the field
58
+ * reads that `classMethodDependenciesOf` drops. A read nested in a callback
59
+ * counts: `arr.map((x) => this.field)` runs the callback before the
60
+ * enclosing body returns.
61
+ */
62
+ static classMemberNamesReferenced(node: TSESTree.Node | null, className: string): string[];
52
63
  /**
53
64
  * Collects the class members a property initializer reads while that
54
65
  * initializer runs.
@@ -308,15 +308,26 @@ class ASTHelpers {
308
308
  * happens to share a member's name is not a reference to that member.
309
309
  */
310
310
  static classMethodDependenciesOf(node, graph, className) {
311
- const dependencies = [];
312
- this.collectClassMemberReferences(node, className, true, dependencies);
313
- return [
314
- ...new Set(dependencies.filter((dep) => {
315
- // Only include dependencies that exist exactly in the graph
316
- // This prevents substring matches (e.g., 'nextMatches' vs 'nextMatchesWithResults')
317
- return (graph?.[dep] !== undefined && graph?.[dep]?.type !== 'property');
318
- })),
319
- ];
311
+ return this.classMemberNamesReferenced(node, className).filter((dep) => {
312
+ // Only include dependencies that exist exactly in the graph
313
+ // This prevents substring matches (e.g., 'nextMatches' vs 'nextMatchesWithResults')
314
+ return graph?.[dep] !== undefined && graph?.[dep]?.type !== 'property';
315
+ });
316
+ }
317
+ /**
318
+ * Every class member a node reaches through `this.<member>` (or
319
+ * `<ClassName>.<member>`), fields included and unfiltered by any graph.
320
+ *
321
+ * Whatever a member's body reads, it reads as soon as that body runs, so a
322
+ * caller deciding whether an invocation is order-sensitive needs the field
323
+ * reads that `classMethodDependenciesOf` drops. A read nested in a callback
324
+ * counts: `arr.map((x) => this.field)` runs the callback before the
325
+ * enclosing body returns.
326
+ */
327
+ static classMemberNamesReferenced(node, className) {
328
+ const references = [];
329
+ this.collectClassMemberReferences(node, className, true, references);
330
+ return [...new Set(references)];
320
331
  }
321
332
  /**
322
333
  * Collects the class members a property initializer reads while that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.147",
3
+ "version": "1.20.148",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,58 @@
1
1
  [
2
+ {
3
+ "version": "1.20.148",
4
+ "date": "2026-08-13T13:06:17.038Z",
5
+ "rules": [
6
+ {
7
+ "name": "class-methods-read-top-to-bottom",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1988
11
+ ],
12
+ "summary": "follow eager initializer reads through invoked members (closes #1988)"
13
+ },
14
+ {
15
+ "name": "enforce-early-destructuring",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1993
19
+ ],
20
+ "summary": "keep a guarded nested destructure in place (closes #1993)"
21
+ },
22
+ {
23
+ "name": "no-array-length-in-deps",
24
+ "changeType": "fix",
25
+ "issues": [
26
+ 1992
27
+ ],
28
+ "summary": "withhold the hoist out of a skippable branch (closes #1992)"
29
+ },
30
+ {
31
+ "name": "no-entire-object-hook-deps",
32
+ "changeType": "fix",
33
+ "issues": [
34
+ 1991
35
+ ],
36
+ "summary": "stop a dep path at a try or deferred body (closes #1991)"
37
+ },
38
+ {
39
+ "name": "parallelize-async-operations",
40
+ "changeType": "fix",
41
+ "issues": [
42
+ 1989
43
+ ],
44
+ "summary": "decline ordered awaits whose later operand dereferences an installed slot (closes #1989)"
45
+ },
46
+ {
47
+ "name": "prefer-map-over-conditional-dispatch",
48
+ "changeType": "fix",
49
+ "issues": [
50
+ 1990
51
+ ],
52
+ "summary": "decline the hoist across a guard (closes #1990)"
53
+ }
54
+ ]
55
+ },
2
56
  {
3
57
  "version": "1.20.147",
4
58
  "date": "2026-08-13T06:24:35.769Z",