@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 +1 -1
- package/lib/rules/enforce-memoize-async.js +311 -0
- package/lib/rules/no-entire-object-hook-deps.js +266 -3
- package/lib/rules/prefer-use-deep-compare-memo.js +307 -19
- package/lib/rules/prevent-children-clobber.js +297 -35
- package/lib/rules/vertically-group-related-functions.js +154 -0
- package/package.json +1 -1
- package/release-manifest.json +52 -0
package/lib/index.js
CHANGED
|
@@ -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(
|
|
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);
|