@blumintinc/eslint-plugin-blumint 1.20.146 → 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.146',
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;