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