@blumintinc/eslint-plugin-blumint 1.20.147 → 1.20.149
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 +1 -1
- package/lib/rules/class-methods-read-top-to-bottom.js +74 -5
- package/lib/rules/enforce-early-destructuring.js +136 -1
- package/lib/rules/no-array-length-in-deps.js +129 -1
- package/lib/rules/no-entire-object-hook-deps.js +108 -8
- package/lib/rules/parallelize-async-operations.js +315 -3
- package/lib/rules/prefer-map-over-conditional-dispatch.js +70 -0
- package/lib/utils/ASTHelpers.d.ts +11 -0
- package/lib/utils/ASTHelpers.js +20 -9
- package/lib/utils/hungarianNaming.d.ts +22 -0
- package/lib/utils/hungarianNaming.js +130 -0
- package/package.json +1 -1
- package/release-manifest.json +68 -0
package/lib/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
29
|
-
.filter((name) =>
|
|
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
|
|
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);
|
|
@@ -5,6 +5,7 @@ const utils_1 = require("@typescript-eslint/utils");
|
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
6
|
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
7
7
|
const disableDirectives_1 = require("../utils/disableDirectives");
|
|
8
|
+
const hungarianNaming_1 = require("../utils/hungarianNaming");
|
|
8
9
|
const importInsertion_1 = require("../utils/importInsertion");
|
|
9
10
|
// React hooks to check
|
|
10
11
|
const HOOK_NAMES = new Set(['useEffect', 'useCallback', 'useMemo']);
|
|
@@ -62,10 +63,45 @@ function getLastPropertyName(expr) {
|
|
|
62
63
|
}
|
|
63
64
|
return null;
|
|
64
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* The name used when the base contributes nothing spellable. Free of any type
|
|
68
|
+
* marker by construction, and the concept the memo actually stands for — the
|
|
69
|
+
* hash of the dependency's CONTENT, which is what depending on `.length`
|
|
70
|
+
* failed to track.
|
|
71
|
+
*/
|
|
72
|
+
const BASE_FREE_HASH_NAME = 'contentHash';
|
|
73
|
+
function capitalize(name) {
|
|
74
|
+
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Names for the memo binding, in descending order of how much of the base they
|
|
78
|
+
* keep. `no-hungarian` ships as an error and is NOT fixable, so a name it
|
|
79
|
+
* rejects turns this fixer's work into a manual rename in a file that was clean
|
|
80
|
+
* beforehand (#1997) — every candidate is therefore checked against the same
|
|
81
|
+
* predicate that rule applies, and the first acceptable one wins.
|
|
82
|
+
*
|
|
83
|
+
* Which candidate that is follows from what `no-hungarian` rejects:
|
|
84
|
+
*
|
|
85
|
+
* - `<base>Hash` reads as the domain concept for almost every base and is the
|
|
86
|
+
* preferred spelling.
|
|
87
|
+
* - A single-letter `b`/`i` base makes `bHash`/`iHash` look like a Hungarian
|
|
88
|
+
* type prefix (b=boolean, i=integer) glued to a capital. `hashOf<Base>` says
|
|
89
|
+
* the same thing with the base out of the leading position.
|
|
90
|
+
* - A base that IS a type word or its abbreviation (`obj`, `arr`, `str`,
|
|
91
|
+
* `array`, `number`, ...) taints every name that carries it as a segment, so
|
|
92
|
+
* no base-preserving candidate can succeed. Dropping the type-coded base is
|
|
93
|
+
* exactly the rename `no-hungarian` asks for.
|
|
94
|
+
*/
|
|
95
|
+
function baseDerivedNames(base) {
|
|
96
|
+
return [`${base}Hash`, `hashOf${capitalize(base)}`];
|
|
97
|
+
}
|
|
65
98
|
function generateUniqueName(base, taken) {
|
|
66
|
-
const candidate =
|
|
99
|
+
const candidate = baseDerivedNames(base).find((name) => !(0, hungarianNaming_1.encodesTypeMarker)(name)) ??
|
|
100
|
+
BASE_FREE_HASH_NAME;
|
|
67
101
|
if (!taken.has(candidate))
|
|
68
102
|
return candidate;
|
|
103
|
+
// A numeric suffix disambiguates without reopening the naming question: it
|
|
104
|
+
// adds no word boundary, so it cannot turn an accepted name into a marker.
|
|
69
105
|
let i = 2;
|
|
70
106
|
while (taken.has(`${candidate}${i}`)) {
|
|
71
107
|
i++;
|
|
@@ -126,6 +162,96 @@ function findInsertionPoint(node) {
|
|
|
126
162
|
}
|
|
127
163
|
return null;
|
|
128
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* Whether an optional link sits at or below `expr` on its member/call spine,
|
|
167
|
+
* making everything further right in the chain unreachable when that link is
|
|
168
|
+
* nullish. `a?.b.run(x)` short-circuits at `a?.b`, so the optional flag the
|
|
169
|
+
* check needs is not the outer call's own.
|
|
170
|
+
*/
|
|
171
|
+
function hasOptionalLink(expr) {
|
|
172
|
+
let current = expr;
|
|
173
|
+
while (current) {
|
|
174
|
+
switch (current.type) {
|
|
175
|
+
case utils_1.AST_NODE_TYPES.MemberExpression:
|
|
176
|
+
if (current.optional)
|
|
177
|
+
return true;
|
|
178
|
+
current = current.object;
|
|
179
|
+
break;
|
|
180
|
+
case utils_1.AST_NODE_TYPES.CallExpression:
|
|
181
|
+
if (current.optional)
|
|
182
|
+
return true;
|
|
183
|
+
current = current.callee;
|
|
184
|
+
break;
|
|
185
|
+
case utils_1.AST_NODE_TYPES.ChainExpression:
|
|
186
|
+
case utils_1.AST_NODE_TYPES.TSNonNullExpression:
|
|
187
|
+
current = current.expression;
|
|
188
|
+
break;
|
|
189
|
+
default:
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Whether `child` sits in a position of `parent` that control flow may skip.
|
|
197
|
+
* Positions evaluated regardless of the branch taken — an `if` test, a `&&`
|
|
198
|
+
* left operand, a `switch` discriminant, a `do` body, an optional chain's
|
|
199
|
+
* object spine — are excluded, since hoisting above them preserves evaluation.
|
|
200
|
+
*/
|
|
201
|
+
function isSkippableBranch(child, parent) {
|
|
202
|
+
switch (parent.type) {
|
|
203
|
+
case utils_1.AST_NODE_TYPES.IfStatement:
|
|
204
|
+
case utils_1.AST_NODE_TYPES.ConditionalExpression:
|
|
205
|
+
return parent.consequent === child || parent.alternate === child;
|
|
206
|
+
case utils_1.AST_NODE_TYPES.LogicalExpression:
|
|
207
|
+
return parent.right === child;
|
|
208
|
+
case utils_1.AST_NODE_TYPES.SwitchStatement:
|
|
209
|
+
return parent.discriminant !== child;
|
|
210
|
+
// A case's test and body are both reached only once the switch selects it.
|
|
211
|
+
case utils_1.AST_NODE_TYPES.SwitchCase:
|
|
212
|
+
return true;
|
|
213
|
+
// A loop body may run zero times; a `do` body always runs once, so it is
|
|
214
|
+
// not listed here.
|
|
215
|
+
case utils_1.AST_NODE_TYPES.WhileStatement:
|
|
216
|
+
case utils_1.AST_NODE_TYPES.ForStatement:
|
|
217
|
+
case utils_1.AST_NODE_TYPES.ForInStatement:
|
|
218
|
+
case utils_1.AST_NODE_TYPES.ForOfStatement:
|
|
219
|
+
return parent.body === child;
|
|
220
|
+
// Arguments and computed keys of an optional chain go unevaluated when the
|
|
221
|
+
// chain short-circuits, so they guard their subtree exactly as an `if` does.
|
|
222
|
+
case utils_1.AST_NODE_TYPES.MemberExpression:
|
|
223
|
+
return parent.object !== child && hasOptionalLink(parent);
|
|
224
|
+
case utils_1.AST_NODE_TYPES.CallExpression:
|
|
225
|
+
return parent.callee !== child && hasOptionalLink(parent);
|
|
226
|
+
default:
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Whether the climb from the hook call to the insertion statement escapes a
|
|
232
|
+
* position that control flow may skip. A guard that wraps the hook without a
|
|
233
|
+
* block of its own gives the climb no in-branch statement position, so the memo
|
|
234
|
+
* lands above the guard while its dependency array `[<base>]` dereferences the
|
|
235
|
+
* guarded value on every render — turning code that does not throw into code
|
|
236
|
+
* that does. The dereference was safe only because of the surrounding
|
|
237
|
+
* narrowing, which the hoisted position no longer enjoys, so the fix is
|
|
238
|
+
* withheld and the report stands alone. A braced guard needs no bail: the
|
|
239
|
+
* insertion block is then the guarded block itself.
|
|
240
|
+
*/
|
|
241
|
+
function crossesConditionalGuard(node, insertion) {
|
|
242
|
+
let current = node;
|
|
243
|
+
while (current !== insertion.statement) {
|
|
244
|
+
const parent = current.parent;
|
|
245
|
+
// An insertion statement absent from the hook's ancestor chain contradicts
|
|
246
|
+
// how it was derived; decline rather than guess at the geometry.
|
|
247
|
+
if (!parent)
|
|
248
|
+
return true;
|
|
249
|
+
if (isSkippableBranch(current, parent))
|
|
250
|
+
return true;
|
|
251
|
+
current = parent;
|
|
252
|
+
}
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
129
255
|
/**
|
|
130
256
|
* Collects the identifiers a hoisted `stableHash(<base>)` expression would
|
|
131
257
|
* read: the root object of the member chain plus any computed keys. Property
|
|
@@ -496,6 +622,8 @@ exports.noArrayLengthInDeps = (0, createRule_1.createRule)({
|
|
|
496
622
|
const insertion = findInsertionPoint(node);
|
|
497
623
|
if (!insertion)
|
|
498
624
|
return null;
|
|
625
|
+
if (crossesConditionalGuard(node, insertion))
|
|
626
|
+
return null;
|
|
499
627
|
for (const { member } of lengthDeps) {
|
|
500
628
|
if (!isBaseSafeToHoist(context, getBaseExpression(member), insertion)) {
|
|
501
629
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|