@blumintinc/eslint-plugin-blumint 1.20.145 → 1.20.147

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -223,7 +223,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
223
223
  module.exports = {
224
224
  meta: {
225
225
  name: '@blumintinc/eslint-plugin-blumint',
226
- version: '1.20.145',
226
+ version: '1.20.147',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -196,6 +196,246 @@ function declaresFunctionParameter(params) {
196
196
  }
197
197
  return parameterAnnotation(params[0])?.type === utils_1.AST_NODE_TYPES.TSFunctionType;
198
198
  }
199
+ /**
200
+ * A database transaction handle is valid only for the attempt that created it,
201
+ * and a transaction body is re-run whenever the driver retries — Firestore
202
+ * retries an attempt whose reads a concurrent write invalidated. A memoized
203
+ * body hands the retry the first attempt's cached promise, so the retry queues
204
+ * no writes on its own handle, commits empty, and the caller reads the first
205
+ * attempt's return value and reports success. Memoizing the method that OWNS
206
+ * the transaction is the same defect one level up: the whole transaction, writes
207
+ * included, then runs once per instance.
208
+ */
209
+ const TRANSACTION_TYPE_NAME = 'Transaction';
210
+ const RUN_TRANSACTION_NAME = 'runTransaction';
211
+ /**
212
+ * Keys that hold source positions or the tree's only back-edge rather than
213
+ * child nodes; `parent` would make a subtree walk non-terminating.
214
+ */
215
+ const NON_TRAVERSABLE_KEYS = new Set(['parent', 'range', 'loc', 'type']);
216
+ /** Every node of `root`'s subtree, `root` included. */
217
+ function* subtreeOf(root) {
218
+ yield root;
219
+ for (const [key, value] of Object.entries(root)) {
220
+ if (NON_TRAVERSABLE_KEYS.has(key)) {
221
+ continue;
222
+ }
223
+ if (Array.isArray(value)) {
224
+ for (const element of value) {
225
+ if (ASTHelpers_1.ASTHelpers.isNode(element)) {
226
+ yield* subtreeOf(element);
227
+ }
228
+ }
229
+ }
230
+ else if (ASTHelpers_1.ASTHelpers.isNode(value)) {
231
+ yield* subtreeOf(value);
232
+ }
233
+ }
234
+ }
235
+ /** The name a call invokes, for `f()` and for `o.f()` alike. */
236
+ function calleeName(node) {
237
+ const callee = withoutChain(node.callee);
238
+ if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
239
+ return callee.name;
240
+ }
241
+ if (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
242
+ !callee.computed &&
243
+ callee.property.type === utils_1.AST_NODE_TYPES.Identifier) {
244
+ return callee.property.name;
245
+ }
246
+ return undefined;
247
+ }
248
+ /**
249
+ * Whether the node is a `runTransaction(…)` call, under any receiver:
250
+ * `db.runTransaction`, `firestore.runTransaction` and a bare imported
251
+ * `runTransaction` all open a retryable transaction.
252
+ */
253
+ function isRunTransactionCall(node) {
254
+ return (node.type === utils_1.AST_NODE_TYPES.CallExpression &&
255
+ calleeName(node) === RUN_TRANSACTION_NAME);
256
+ }
257
+ /** Whether the method opens a transaction anywhere in its own body. */
258
+ function ownsTransaction(fn) {
259
+ for (const node of subtreeOf(fn)) {
260
+ if (isRunTransactionCall(node)) {
261
+ return true;
262
+ }
263
+ }
264
+ return false;
265
+ }
266
+ /**
267
+ * Whether a type name denotes the transaction handle. The rightmost segment is
268
+ * the type's own name, so the qualified spellings — `FirebaseFirestore.
269
+ * Transaction`, `admin.firestore.Transaction` — answer alongside the bare one;
270
+ * a locally aliased import (`import { Transaction as Txn }`) answers through
271
+ * the alias set the file's imports define.
272
+ */
273
+ function namesTransaction(typeName, aliases) {
274
+ if (typeName.type === utils_1.AST_NODE_TYPES.Identifier) {
275
+ return (typeName.name === TRANSACTION_TYPE_NAME || aliases.has(typeName.name));
276
+ }
277
+ if (typeName.type === utils_1.AST_NODE_TYPES.TSQualifiedName) {
278
+ return typeName.right.name === TRANSACTION_TYPE_NAME;
279
+ }
280
+ return false;
281
+ }
282
+ /**
283
+ * Whether a declared type hands the method a transaction handle: written
284
+ * directly, as one arm of a union or intersection, or as a property of an
285
+ * object type — the shape a destructured `{ transaction }: { transaction:
286
+ * Transaction }` parameter carries.
287
+ *
288
+ * Type ARGUMENTS are deliberately not entered: `Map<string, Transaction>` or
289
+ * `Promise<Transaction>` describes a collection of handles or a handle yet to
290
+ * exist, neither of which is the attempt-scoped handle this carve-out is about.
291
+ */
292
+ function declaresTransactionType(annotation, aliases) {
293
+ if (!annotation) {
294
+ return false;
295
+ }
296
+ if (annotation.type === utils_1.AST_NODE_TYPES.TSTypeReference) {
297
+ return namesTransaction(annotation.typeName, aliases);
298
+ }
299
+ if (annotation.type === utils_1.AST_NODE_TYPES.TSUnionType ||
300
+ annotation.type === utils_1.AST_NODE_TYPES.TSIntersectionType) {
301
+ return annotation.types.some((member) => declaresTransactionType(member, aliases));
302
+ }
303
+ if (annotation.type === utils_1.AST_NODE_TYPES.TSTypeLiteral) {
304
+ return annotation.members.some((member) => member.type === utils_1.AST_NODE_TYPES.TSPropertySignature &&
305
+ declaresTransactionType(member.typeAnnotation?.typeAnnotation, aliases));
306
+ }
307
+ return false;
308
+ }
309
+ /**
310
+ * The type annotation a parameter declares, including the destructuring shapes
311
+ * `parameterAnnotation` does not reach: an object or array pattern carries its
312
+ * annotation on the pattern itself.
313
+ */
314
+ function bindingAnnotation(param) {
315
+ if (param.type === utils_1.AST_NODE_TYPES.ObjectPattern ||
316
+ param.type === utils_1.AST_NODE_TYPES.ArrayPattern) {
317
+ return param.typeAnnotation?.typeAnnotation;
318
+ }
319
+ return parameterAnnotation(param);
320
+ }
321
+ /**
322
+ * Whether the method declares a parameter typed as a transaction handle.
323
+ *
324
+ * The test reads the ANNOTATION, not the parameter's name: a parameter merely
325
+ * named `transaction` is as likely to hold a payment or a ledger entry, and the
326
+ * rule's other carve-outs (void result, callback parameter) are annotation-driven
327
+ * for the same reason. A bare `async apply(transaction)` therefore keeps
328
+ * reporting — it declares nothing to honour, and under the `noImplicitAny` its
329
+ * consumers compile with it does not type-check anyway. Where the handle arrives
330
+ * through an unresolvable alias (`args: MembershipArgs`), the call-site test
331
+ * below is what recognises it.
332
+ */
333
+ function declaresTransactionParameter(params, aliases) {
334
+ return params.some((param) => declaresTransactionType(bindingAnnotation(param), aliases));
335
+ }
336
+ /**
337
+ * The expression itself, with any `ChainExpression` wrapper removed. ESTree
338
+ * wraps a whole optional chain in that node, so `this?.body` and
339
+ * `this.body.bind?.(this)` reach a bare member/call test as something else
340
+ * entirely. Nullish spellings carry the transaction handle exactly as the plain
341
+ * ones do, and reading through the wrapper is what keeps the carve-out from
342
+ * lapsing on them — a lapse that would restore the empty-commit autofix.
343
+ */
344
+ function withoutChain(node) {
345
+ return node.type === utils_1.AST_NODE_TYPES.ChainExpression
346
+ ? withoutChain(node.expression)
347
+ : node;
348
+ }
349
+ /** The own-method name a `this.foo` reference reads, if it reads one. */
350
+ function thisMemberName(node) {
351
+ const expression = withoutChain(node);
352
+ if (expression.type === utils_1.AST_NODE_TYPES.MemberExpression &&
353
+ !expression.computed &&
354
+ withoutChain(expression.object).type === utils_1.AST_NODE_TYPES.ThisExpression &&
355
+ expression.property.type === utils_1.AST_NODE_TYPES.Identifier) {
356
+ return expression.property.name;
357
+ }
358
+ // `this.body.bind(this)` passes the same method, one wrapper out.
359
+ if (expression.type === utils_1.AST_NODE_TYPES.CallExpression &&
360
+ expression.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
361
+ !expression.callee.computed &&
362
+ expression.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
363
+ expression.callee.property.name === 'bind') {
364
+ return thisMemberName(expression.callee.object);
365
+ }
366
+ return undefined;
367
+ }
368
+ /** Whether the subtree mentions the binding, under any nesting. */
369
+ function mentionsBinding(node, name) {
370
+ for (const descendant of subtreeOf(node)) {
371
+ if (descendant.type === utils_1.AST_NODE_TYPES.Identifier &&
372
+ descendant.name === name) {
373
+ return true;
374
+ }
375
+ }
376
+ return false;
377
+ }
378
+ /**
379
+ * Own methods a `runTransaction` argument hands the attempt to: the method
380
+ * passed as the callback itself, and every `this.method(…)` the callback body
381
+ * invokes with the attempt's handle among its arguments.
382
+ *
383
+ * Passing the handle on is what makes a method part of the attempt, so a
384
+ * helper the callback calls WITHOUT it — a config read, a lookup that takes no
385
+ * transaction — is untouched and keeps reporting.
386
+ */
387
+ function collectTransactionParticipants(argument, participants) {
388
+ const passed = thisMemberName(argument);
389
+ if (passed) {
390
+ participants.add(passed);
391
+ return;
392
+ }
393
+ if (argument.type !== utils_1.AST_NODE_TYPES.ArrowFunctionExpression &&
394
+ argument.type !== utils_1.AST_NODE_TYPES.FunctionExpression) {
395
+ return;
396
+ }
397
+ const handle = argument.params[0];
398
+ if (handle?.type !== utils_1.AST_NODE_TYPES.Identifier) {
399
+ return;
400
+ }
401
+ for (const node of subtreeOf(argument.body)) {
402
+ if (node.type !== utils_1.AST_NODE_TYPES.CallExpression) {
403
+ continue;
404
+ }
405
+ const method = thisMemberName(node.callee);
406
+ if (method &&
407
+ node.arguments.some((arg) => mentionsBinding(arg, handle.name))) {
408
+ participants.add(method);
409
+ }
410
+ }
411
+ }
412
+ /** Every own method the class hands a transaction handle to. */
413
+ function transactionParticipantsOf(body) {
414
+ const participants = new Set();
415
+ for (const node of subtreeOf(body)) {
416
+ if (!isRunTransactionCall(node)) {
417
+ continue;
418
+ }
419
+ for (const argument of node.arguments) {
420
+ collectTransactionParticipants(argument, participants);
421
+ }
422
+ }
423
+ return participants;
424
+ }
425
+ /** The statically known name of a method, for matching call sites against it. */
426
+ function methodName(node) {
427
+ if (node.computed) {
428
+ return undefined;
429
+ }
430
+ const { key } = node;
431
+ if (key.type === utils_1.AST_NODE_TYPES.Identifier) {
432
+ return key.name;
433
+ }
434
+ if (key.type === utils_1.AST_NODE_TYPES.Literal && typeof key.value === 'string') {
435
+ return key.value;
436
+ }
437
+ return undefined;
438
+ }
199
439
  /**
200
440
  * Matches a memoize decorator in supported syntaxes:
201
441
  * - @Alias()
@@ -290,6 +530,66 @@ exports.enforceMemoizeAsync = (0, createRule_1.createRule)({
290
530
  }
291
531
  return memoizeImportCache;
292
532
  };
533
+ /**
534
+ * Local names bound to an imported `Transaction` type, so that an aliased
535
+ * import (`import { Transaction as Txn } from 'firebase-admin/firestore'`)
536
+ * is read as the handle it is. The module is not constrained: a handle is
537
+ * re-exported through as many paths as a codebase has layers, and the
538
+ * imported NAME already carries the signal.
539
+ */
540
+ let transactionAliasCache = null;
541
+ const transactionAliases = () => {
542
+ if (!transactionAliasCache) {
543
+ transactionAliasCache = new Set();
544
+ for (const statement of context.sourceCode.ast.body) {
545
+ if (statement.type !== utils_1.AST_NODE_TYPES.ImportDeclaration) {
546
+ continue;
547
+ }
548
+ for (const spec of statement.specifiers) {
549
+ if (spec.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
550
+ spec.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
551
+ spec.imported.name === TRANSACTION_TYPE_NAME) {
552
+ transactionAliasCache.add(spec.local.name);
553
+ }
554
+ }
555
+ }
556
+ }
557
+ return transactionAliasCache;
558
+ };
559
+ /**
560
+ * The class-level scan is shared by every method of the class, so it runs
561
+ * once per class body rather than once per candidate method.
562
+ */
563
+ const participantCache = new WeakMap();
564
+ const transactionParticipants = (body) => {
565
+ let participants = participantCache.get(body);
566
+ if (!participants) {
567
+ participants = transactionParticipantsOf(body);
568
+ participantCache.set(body, participants);
569
+ }
570
+ return participants;
571
+ };
572
+ /**
573
+ * Whether the method takes part in a database transaction attempt, either
574
+ * by opening one or by being handed the attempt's handle. Caching such a
575
+ * method is never an optimisation: a retried attempt would replay the first
576
+ * attempt's promise, writing nothing on its own handle while the caller
577
+ * reads a success it did not get.
578
+ */
579
+ const participatesInTransaction = (node, fn) => {
580
+ if (declaresTransactionParameter(fn.params, transactionAliases())) {
581
+ return true;
582
+ }
583
+ if (ownsTransaction(fn)) {
584
+ return true;
585
+ }
586
+ const body = node.parent;
587
+ if (body?.type !== utils_1.AST_NODE_TYPES.ClassBody) {
588
+ return false;
589
+ }
590
+ const name = methodName(node);
591
+ return name !== undefined && transactionParticipants(body).has(name);
592
+ };
293
593
  return {
294
594
  MethodDefinition(node) {
295
595
  // Only process async instance methods (skip static methods)
@@ -341,6 +641,17 @@ exports.enforceMemoizeAsync = (0, createRule_1.createRule)({
341
641
  if (declaresVoidResult(node.value.returnType)) {
342
642
  return;
343
643
  }
644
+ // A transaction handle is valid only for the attempt that created it,
645
+ // so a result derived from one must not outlive that attempt. Caching
646
+ // the body of a `runTransaction` callback — or the method that owns the
647
+ // call — turns the retry a concurrent write provokes into a silent
648
+ // no-op: the retry replays the first attempt's promise, queues nothing
649
+ // on its own handle, commits empty, and reports the first attempt's
650
+ // value as success. The fixer would apply that unattended under
651
+ // `--fix`, so both report and fix are withheld.
652
+ if (participatesInTransaction(node, node.value)) {
653
+ return;
654
+ }
344
655
  const { aliases: memoizeAliases, namespaces: memoizeNamespaces } = memoizeImports();
345
656
  const hasMemoizeImport = memoizeAliases.size > 0 || memoizeNamespaces.size > 0;
346
657
  // Check if method already has @Memoize or @Memoize() decorator
@@ -194,6 +194,184 @@ function unwrapExpression(expr) {
194
194
  }
195
195
  return current;
196
196
  }
197
+ /** Applies `visitChild` to every AST child of `node`, skipping `parent` links. */
198
+ function forEachChildNode(node, visitChild) {
199
+ for (const key in node) {
200
+ if (key === 'parent')
201
+ continue;
202
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
203
+ const child = node[key];
204
+ if (!child || typeof child !== 'object')
205
+ continue;
206
+ if (Array.isArray(child)) {
207
+ for (const item of child) {
208
+ if (item && typeof item === 'object' && 'type' in item) {
209
+ visitChild(item);
210
+ }
211
+ }
212
+ }
213
+ else if ('type' in child) {
214
+ visitChild(child);
215
+ }
216
+ }
217
+ }
218
+ /**
219
+ * Whether `node` is the callee of a call — `u.date.toISOString` in
220
+ * `u.date.toISOString()` — looking through the wrappers that can sit between
221
+ * the member expression and its call (`?.` chains, `!`, `as T`).
222
+ */
223
+ function isCallCallee(node) {
224
+ let current = node;
225
+ let parent = node.parent;
226
+ while (parent &&
227
+ (parent.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
228
+ parent.type === utils_1.AST_NODE_TYPES.TSTypeAssertion ||
229
+ parent.type === utils_1.AST_NODE_TYPES.ChainExpression ||
230
+ parent.type === utils_1.AST_NODE_TYPES.TSNonNullExpression)) {
231
+ current = parent;
232
+ parent = parent.parent;
233
+ }
234
+ return (!!parent &&
235
+ parent.type === utils_1.AST_NODE_TYPES.CallExpression &&
236
+ parent.callee === current);
237
+ }
238
+ /**
239
+ * Renders `node`'s access path when the chain is rooted at `objectName`, or
240
+ * null when it is rooted elsewhere or holds a link with no stable rendering.
241
+ *
242
+ * why: guard collection asks a different question than `buildAccessPath` —
243
+ * "which value did this condition establish something about?" rather than
244
+ * "which dependency should replace the object?" — so it must not apply that
245
+ * function's narrowing policy (method carve-outs, whole-object escalation) nor
246
+ * its side effects on the usage set.
247
+ */
248
+ function renderMemberPathIfRootedAt(node, objectName) {
249
+ const segments = [];
250
+ let current = node;
251
+ while (current.type === utils_1.AST_NODE_TYPES.MemberExpression) {
252
+ const memberExpr = current;
253
+ if (memberExpr.computed) {
254
+ const literalValue = memberExpr.property.type === utils_1.AST_NODE_TYPES.Literal
255
+ ? memberExpr.property.value
256
+ : undefined;
257
+ if (typeof literalValue !== 'number' &&
258
+ typeof literalValue !== 'string') {
259
+ return null;
260
+ }
261
+ segments.unshift({
262
+ text: typeof literalValue === 'number'
263
+ ? `[${literalValue}]`
264
+ : `[${JSON.stringify(literalValue)}]`,
265
+ computed: true,
266
+ optional: memberExpr.optional,
267
+ });
268
+ }
269
+ else {
270
+ if (memberExpr.property.type !== utils_1.AST_NODE_TYPES.Identifier) {
271
+ return null;
272
+ }
273
+ segments.unshift({
274
+ text: memberExpr.property.name,
275
+ computed: false,
276
+ optional: memberExpr.optional,
277
+ });
278
+ }
279
+ current = unwrapExpression(memberExpr.object);
280
+ }
281
+ const base = unwrapExpression(current);
282
+ if (segments.length === 0 ||
283
+ base.type !== utils_1.AST_NODE_TYPES.Identifier ||
284
+ base.name !== objectName) {
285
+ return null;
286
+ }
287
+ return renderPathSegments(objectName, segments);
288
+ }
289
+ /**
290
+ * Every path of `objectName` whose dereferenceability the hook body establishes
291
+ * with a guard rather than with the plain shape of the source.
292
+ *
293
+ * why: a dependency array is an array literal, so every element is evaluated
294
+ * eagerly on every render — outside the `if`, the `&&`, the ternary and the
295
+ * `!` assertion that made the access safe inside the body. Extending a
296
+ * dependency path *through* such a link therefore turns guarded code into an
297
+ * unconditional `TypeError`. The paths collected here mark where a path must
298
+ * stop; see `safePrefixOf`.
299
+ *
300
+ * The collection is deliberately over-broad — it accepts any member path
301
+ * appearing anywhere in a condition, not only one that provably governs the
302
+ * access. Over-collecting costs a coarser dependency (the memo recomputes more
303
+ * often than strictly needed); under-collecting is the crash.
304
+ */
305
+ function collectGuardedPaths(hookBody, objectName) {
306
+ const guarded = new Set();
307
+ const visited = new Set();
308
+ function markConditionPaths(node) {
309
+ if (!node)
310
+ return;
311
+ if (node.type === utils_1.AST_NODE_TYPES.MemberExpression) {
312
+ const path = renderMemberPathIfRootedAt(node, objectName);
313
+ if (path) {
314
+ guarded.add(path);
315
+ // why: only the outermost link of the chain is what the condition
316
+ // established. `if (a.b.c)` dereferences `a.b` unconditionally, so it
317
+ // proves nothing about `a.b` and must not truncate paths there. A
318
+ // computed key can still hold a condition-worthy read of its own.
319
+ if (node.computed) {
320
+ markConditionPaths(node.property);
321
+ }
322
+ return;
323
+ }
324
+ }
325
+ forEachChildNode(node, markConditionPaths);
326
+ }
327
+ function walk(node) {
328
+ if (!node || visited.has(node))
329
+ return;
330
+ visited.add(node);
331
+ if (node.type === utils_1.AST_NODE_TYPES.IfStatement ||
332
+ node.type === utils_1.AST_NODE_TYPES.ConditionalExpression ||
333
+ node.type === utils_1.AST_NODE_TYPES.WhileStatement ||
334
+ node.type === utils_1.AST_NODE_TYPES.DoWhileStatement ||
335
+ node.type === utils_1.AST_NODE_TYPES.ForStatement) {
336
+ // Covers `if (a.b)`, `a.b ? x : y`, and the early-return form
337
+ // `if (!a.b) return;` — the `!` is reached by walking the test.
338
+ markConditionPaths(node.test);
339
+ }
340
+ else if (node.type === utils_1.AST_NODE_TYPES.LogicalExpression) {
341
+ // `a.b && a.b.c`, `a.b || fallback`, `a.b ?? fallback`: the left operand
342
+ // decides whether the right one runs at all.
343
+ markConditionPaths(node.left);
344
+ }
345
+ else if (node.type === utils_1.AST_NODE_TYPES.UnaryExpression) {
346
+ if (node.operator === '!' || node.operator === 'typeof') {
347
+ markConditionPaths(node.argument);
348
+ }
349
+ }
350
+ else if (node.type === utils_1.AST_NODE_TYPES.BinaryExpression) {
351
+ if (node.operator === 'instanceof') {
352
+ markConditionPaths(node.left);
353
+ }
354
+ else if (node.operator === 'in') {
355
+ markConditionPaths(node.right);
356
+ }
357
+ }
358
+ else if (node.type === utils_1.AST_NODE_TYPES.TSNonNullExpression) {
359
+ // `a.b!.c` — the assertion is the author's guard, and it exists only in
360
+ // the type system. The emitted dependency has to stop at `a.b`.
361
+ //
362
+ // Unlike a condition, an assertion speaks about exactly one value, so
363
+ // this does NOT descend: in `load(a.b)!` the `!` covers the call's
364
+ // result and says nothing about `a.b`.
365
+ const asserted = unwrapExpression(node.expression);
366
+ if (asserted.type === utils_1.AST_NODE_TYPES.MemberExpression) {
367
+ markConditionPaths(asserted);
368
+ }
369
+ }
370
+ forEachChildNode(node, walk);
371
+ }
372
+ walk(hookBody);
373
+ return guarded;
374
+ }
197
375
  /**
198
376
  * Whether the hook body anywhere calls the state setter that corresponds to
199
377
  * `dependencyName` (dep `count` -> `setCount(...)`).
@@ -250,8 +428,46 @@ function getObjectUsagesInHook(hookBody, objectName, typeInfo) {
250
428
  // rendered path cannot place `?.` markers correctly.
251
429
  const pathSegments = new Map();
252
430
  const visited = new Set();
431
+ const guardedPaths = collectGuardedPaths(hookBody, objectName);
253
432
  let needsEntireObject = false;
254
433
  let isUsed = false;
434
+ /**
435
+ * The longest prefix of `segments` that a dependency array may evaluate
436
+ * unconditionally.
437
+ *
438
+ * why: the hook body reaches a deep path under guards the array cannot
439
+ * carry, so the deepest path the body reads is not always a legal dependency.
440
+ * Truncating at the first unsafe link yields a coarser dependency — the memo
441
+ * recomputes whenever the parent object's identity changes rather than only
442
+ * when the leaf does — which can only cost precision, never correctness, and
443
+ * still delivers the narrowing this rule exists for.
444
+ */
445
+ function safePrefixOf(segments) {
446
+ let limit = segments.length;
447
+ // A link the source reached with `?.` is one the author expects to be
448
+ // nullish. When the very next link is spelled *without* `?.`, the source
449
+ // only survives because something outside the expression established the
450
+ // value — a guard, a narrowing assertion, an invariant. The dependency
451
+ // array inherits none of it, so the path stops at the optional link.
452
+ // A trailing optional link (`a.b?.[0]`, `state?.[0]`) is safe as written
453
+ // and keeps its full rendering.
454
+ for (let index = 0; index < segments.length - 1; index += 1) {
455
+ if (segments[index].optional && !segments[index + 1].optional) {
456
+ limit = index + 1;
457
+ break;
458
+ }
459
+ }
460
+ // A prefix whose dereferenceability a condition established is unusable in
461
+ // the array for the same reason. The shortest such prefix wins, since it is
462
+ // the most conservative stopping point.
463
+ for (let index = 1; index < limit; index += 1) {
464
+ if (guardedPaths.has(renderPathSegments(objectName, segments.slice(0, index)))) {
465
+ limit = index;
466
+ break;
467
+ }
468
+ }
469
+ return segments.slice(0, limit);
470
+ }
255
471
  // Built-in array methods that indicate usage of the entire array
256
472
  const ARRAY_METHODS = new Set([
257
473
  'map',
@@ -314,6 +530,36 @@ function getObjectUsagesInHook(hookBody, objectName, typeInfo) {
314
530
  // Collect all links from leaf to root
315
531
  while (current.type === utils_1.AST_NODE_TYPES.MemberExpression) {
316
532
  const memberExpr = current;
533
+ // A member called through a chain never terminates a dependency path:
534
+ // depend on the receiver instead.
535
+ //
536
+ // why: two reasons converge. A method reached through a chain
537
+ // (`u.date.toISOString`) is a prototype-shared reference — the same value
538
+ // for every receiver of that type — so pinning it in the array makes the
539
+ // hook stop invalidating and serve a stale value forever. And it is the
540
+ // link that dereferences the receiver, so a receiver whose safety came
541
+ // from a guard (`if (u.date)`, `u.date!`) throws when the array is
542
+ // evaluated on a render that never entered the guard.
543
+ //
544
+ // Two conditions bound this. The link must be spelled without `?.`: a
545
+ // fully optional chain (`userData?.date?.toISOString`) short-circuits
546
+ // instead of throwing, which is exactly the per-link rendering this rule
547
+ // already gets right. And the receiver must itself be a member path —
548
+ // where it is the dependency object (`userData?.getName?.()`), falling
549
+ // back to it would surrender the narrowing entirely, and a function held
550
+ // directly on a plain dependency object is per-instance state whose
551
+ // identity legitimately changes; `isMethodMember` decides that case with
552
+ // the type checker.
553
+ if (!memberExpr.optional && isCallCallee(memberExpr)) {
554
+ const receiver = unwrapExpression(memberExpr.object);
555
+ if (receiver.type === utils_1.AST_NODE_TYPES.MemberExpression) {
556
+ const receiverPath = buildAccessPath(receiver);
557
+ if (receiverPath) {
558
+ usages.set(receiverPath, memberExpr.range?.[0] || 0);
559
+ }
560
+ return null;
561
+ }
562
+ }
317
563
  // Handle computed properties (like array indices)
318
564
  if (memberExpr.computed) {
319
565
  // why: only a *literal* string/number computed key (obj[0],
@@ -613,8 +859,25 @@ function getObjectUsagesInHook(hookBody, objectName, typeInfo) {
613
859
  }
614
860
  }
615
861
  visit(hookBody);
862
+ // Replace every collected path with the prefix that survives evaluation
863
+ // outside the hook body. Distinct reads can collapse onto one dependency
864
+ // (`a.b` and a truncated `a.b.c` are the same entry), so the earliest source
865
+ // position is kept for the ordering below.
866
+ const safeUsages = new Map();
867
+ usages.forEach((position, rawPath) => {
868
+ const segments = pathSegments.get(rawPath);
869
+ const safeSegments = segments ? safePrefixOf(segments) : undefined;
870
+ const safePath = safeSegments
871
+ ? renderPathSegments(objectName, safeSegments)
872
+ : rawPath;
873
+ if (safeSegments) {
874
+ pathSegments.set(safePath, safeSegments);
875
+ }
876
+ const recorded = safeUsages.get(safePath);
877
+ safeUsages.set(safePath, recorded === undefined ? position : Math.min(recorded, position));
878
+ });
616
879
  // Process paths and determine which ones to include
617
- const paths = Array.from(usages.keys());
880
+ const paths = Array.from(safeUsages.keys());
618
881
  const finalPaths = new Set();
619
882
  paths.forEach((path) => {
620
883
  // Always include the main path
@@ -658,8 +921,8 @@ function getObjectUsagesInHook(hookBody, objectName, typeInfo) {
658
921
  });
659
922
  // Sort paths: longer/more specific paths first, then by optional chaining preference
660
923
  const sortedPaths = filteredPaths.sort((a, b) => {
661
- const posA = usages.get(a) || 0;
662
- const posB = usages.get(b) || 0;
924
+ const posA = safeUsages.get(a) || 0;
925
+ const posB = safeUsages.get(b) || 0;
663
926
  // For paths with the same base, put longer ones first
664
927
  const aDepth = a.split('.').length + (a.includes('[') ? 1 : 0);
665
928
  const bDepth = b.split('.').length + (b.includes('[') ? 1 : 0);