@tanstack/eslint-plugin-query 5.94.5 → 5.95.0

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.
@@ -130,21 +130,6 @@ var ASTUtils = {
130
130
  }
131
131
  return identifiers;
132
132
  },
133
- isAncestorIsCallee(identifier) {
134
- let previousNode = identifier;
135
- let currentNode = identifier.parent;
136
- while (currentNode !== void 0) {
137
- if (currentNode.type === import_utils.AST_NODE_TYPES.CallExpression && currentNode.callee === previousNode) {
138
- return true;
139
- }
140
- if (currentNode.type !== import_utils.AST_NODE_TYPES.MemberExpression) {
141
- return false;
142
- }
143
- previousNode = currentNode;
144
- currentNode = currentNode.parent;
145
- }
146
- return false;
147
- },
148
133
  traverseUpOnly(identifier, allowedNodeTypes) {
149
134
  const parent = identifier.parent;
150
135
  if (parent !== void 0 && allowedNodeTypes.includes(parent.type)) {
@@ -365,7 +350,7 @@ var ExhaustiveDepsUtils = {
365
350
  const { sourceCode, reference, scopeManager, node, filename } = params;
366
351
  const component = ASTUtils.getFunctionAncestor(sourceCode, node);
367
352
  const queryFnScope = scopeManager.acquire(node);
368
- if (queryFnScope === null) {
353
+ if (queryFnScope === null || reference.isValueReference === false) {
369
354
  return false;
370
355
  }
371
356
  let currentScope = reference.resolved?.scope ?? null;
@@ -397,15 +382,57 @@ var ExhaustiveDepsUtils = {
397
382
  }
398
383
  return reference.identifier.name !== "undefined" && reference.identifier.parent.type !== import_utils3.AST_NODE_TYPES.NewExpression && !ExhaustiveDepsUtils.isInstanceOfKind(reference.identifier.parent);
399
384
  },
400
- isInstanceOfKind(node) {
401
- return node.type === import_utils3.AST_NODE_TYPES.BinaryExpression && node.operator === "instanceof";
385
+ /**
386
+ * Given required refs and existing queryKey entries, compute missing dependency paths
387
+ * respecting allowlisted variables and types.
388
+ */
389
+ computeFilteredMissingPaths(params) {
390
+ const {
391
+ requiredRefs,
392
+ allowlistedVariables,
393
+ existingRootIdentifiers,
394
+ existingFullPaths
395
+ } = params;
396
+ const missingPaths = /* @__PURE__ */ new Set();
397
+ for (const { root, path, allowlistedByType } of requiredRefs) {
398
+ if (existingRootIdentifiers.has(root)) continue;
399
+ if (allowlistedVariables.has(root)) continue;
400
+ if (existingFullPaths.has(path)) continue;
401
+ if (allowlistedByType) continue;
402
+ missingPaths.add(path);
403
+ }
404
+ for (const path of missingPaths) {
405
+ const root = path.split(".")[0];
406
+ if (root !== path && root !== void 0 && missingPaths.has(root)) {
407
+ missingPaths.delete(path);
408
+ }
409
+ }
410
+ return Array.from(missingPaths);
402
411
  },
412
+ /**
413
+ * Extract existing queryKey deps as root identifiers and full member paths.
414
+ */
403
415
  collectQueryKeyDeps(params) {
404
416
  const { sourceCode, scopeManager, queryKeyNode } = params;
405
- const deps = /* @__PURE__ */ new Set();
417
+ const roots = /* @__PURE__ */ new Set();
418
+ const paths = /* @__PURE__ */ new Set();
406
419
  const visitorKeys = sourceCode.visitorKeys;
407
- function add(identifier) {
408
- deps.add(ASTUtils.mapKeyNodeToBaseText(identifier, sourceCode));
420
+ function addRoot(name8) {
421
+ const cleaned = ExhaustiveDepsUtils.normalizeChain(name8);
422
+ roots.add(cleaned);
423
+ paths.add(cleaned);
424
+ }
425
+ function addFull(text) {
426
+ const cleaned = ExhaustiveDepsUtils.normalizeChain(text);
427
+ paths.add(cleaned);
428
+ }
429
+ function addRefPath(refPath) {
430
+ if (!refPath) return;
431
+ if (refPath.coversRootMembers) {
432
+ addRoot(refPath.root);
433
+ return;
434
+ }
435
+ addFull(refPath.path);
409
436
  }
410
437
  function visitChildren(node) {
411
438
  const keys = visitorKeys[node.type] ?? [];
@@ -427,9 +454,15 @@ var ExhaustiveDepsUtils = {
427
454
  function visit(node) {
428
455
  if (!node) return;
429
456
  switch (node.type) {
430
- case import_utils3.AST_NODE_TYPES.Identifier:
431
- add(node);
457
+ case import_utils3.AST_NODE_TYPES.Identifier: {
458
+ addRefPath(
459
+ ExhaustiveDepsUtils.computeRefPath({
460
+ identifier: node,
461
+ sourceCode
462
+ })
463
+ );
432
464
  return;
465
+ }
433
466
  case import_utils3.AST_NODE_TYPES.ArrowFunctionExpression:
434
467
  case import_utils3.AST_NODE_TYPES.FunctionExpression:
435
468
  for (const reference of ExhaustiveDepsUtils.collectExternalRefsInFunction(
@@ -438,9 +471,15 @@ var ExhaustiveDepsUtils = {
438
471
  scopeManager
439
472
  }
440
473
  )) {
441
- if (reference.identifier.type === import_utils3.AST_NODE_TYPES.Identifier) {
442
- add(reference.identifier);
474
+ if (reference.identifier.type !== import_utils3.AST_NODE_TYPES.Identifier) {
475
+ continue;
443
476
  }
477
+ addRefPath(
478
+ ExhaustiveDepsUtils.computeRefPath({
479
+ identifier: reference.identifier,
480
+ sourceCode
481
+ })
482
+ );
444
483
  }
445
484
  return;
446
485
  case import_utils3.AST_NODE_TYPES.Property:
@@ -448,26 +487,96 @@ var ExhaustiveDepsUtils = {
448
487
  return;
449
488
  case import_utils3.AST_NODE_TYPES.MemberExpression:
450
489
  if (node.parent.type === import_utils3.AST_NODE_TYPES.CallExpression && node.parent.callee === node && node.object.type === import_utils3.AST_NODE_TYPES.Identifier) {
451
- deps.add(node.object.name);
490
+ addRoot(node.object.name);
452
491
  } else {
453
492
  visit(node.object);
454
493
  }
455
494
  return;
456
495
  case import_utils3.AST_NODE_TYPES.CallExpression:
457
496
  node.arguments.forEach((argument) => visit(argument));
458
- if (node.callee.type === import_utils3.AST_NODE_TYPES.MemberExpression || node.callee.type === import_utils3.AST_NODE_TYPES.ChainExpression || node.callee.type === import_utils3.AST_NODE_TYPES.TSNonNullExpression) {
459
- visit(node.callee);
497
+ switch (node.callee.type) {
498
+ case import_utils3.AST_NODE_TYPES.Identifier:
499
+ case import_utils3.AST_NODE_TYPES.MemberExpression:
500
+ case import_utils3.AST_NODE_TYPES.ChainExpression:
501
+ case import_utils3.AST_NODE_TYPES.TSNonNullExpression:
502
+ visit(node.callee);
503
+ break;
460
504
  }
461
505
  return;
462
506
  }
463
507
  visitChildren(node);
464
508
  }
465
509
  visit(queryKeyNode);
466
- return deps;
510
+ return { roots, paths };
467
511
  },
468
512
  isNode(value) {
469
513
  return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
470
514
  },
515
+ /**
516
+ * Checks whether the resolved variable is allowlisted by its type annotation
517
+ */
518
+ variableIsAllowlistedByType(params) {
519
+ const { allowlistedTypes, variable } = params;
520
+ if (allowlistedTypes.size === 0) return false;
521
+ if (!variable) return false;
522
+ for (const id of variable.identifiers) {
523
+ if (id.typeAnnotation) {
524
+ const typeIdentifiers = /* @__PURE__ */ new Set();
525
+ ExhaustiveDepsUtils.collectTypeIdentifiers(
526
+ id.typeAnnotation.typeAnnotation,
527
+ typeIdentifiers
528
+ );
529
+ for (const typeIdentifier of typeIdentifiers) {
530
+ if (allowlistedTypes.has(typeIdentifier)) return true;
531
+ }
532
+ }
533
+ }
534
+ return false;
535
+ },
536
+ isInstanceOfKind(node) {
537
+ return node.type === import_utils3.AST_NODE_TYPES.BinaryExpression && node.operator === "instanceof";
538
+ },
539
+ /**
540
+ * Normalizes a chain by removing optional chaining operators
541
+ *
542
+ * Example: `a?.b.c!` -> `a.b.c`
543
+ */
544
+ normalizeChain(text) {
545
+ return text.replace(/(?:\?(\.)|!)/g, "$1");
546
+ },
547
+ /**
548
+ * Computes the reference path for an identifier
549
+ *
550
+ * Example: `a.b.c!` -> `{ path: 'a.b.c', root: 'a' }`
551
+ */
552
+ computeRefPath(params) {
553
+ const { identifier, sourceCode } = params;
554
+ const fullChainNode = ASTUtils.traverseUpOnly(identifier, [
555
+ import_utils3.AST_NODE_TYPES.MemberExpression,
556
+ import_utils3.AST_NODE_TYPES.TSNonNullExpression,
557
+ import_utils3.AST_NODE_TYPES.Identifier
558
+ ]);
559
+ const fullText = ExhaustiveDepsUtils.normalizeChain(
560
+ sourceCode.getText(fullChainNode)
561
+ );
562
+ const parent = fullChainNode.parent;
563
+ let dependencyPath = fullText;
564
+ let coversRootMembers = fullText === identifier.name;
565
+ if (parent && parent.type === import_utils3.AST_NODE_TYPES.CallExpression && parent.callee === fullChainNode) {
566
+ const segments = fullText.split(".");
567
+ if (segments.length > 1) {
568
+ dependencyPath = segments.slice(0, -1).join(".");
569
+ }
570
+ coversRootMembers = false;
571
+ }
572
+ dependencyPath = dependencyPath.split(".")[0] === "" ? identifier.name : dependencyPath;
573
+ const root = dependencyPath.split(".")[0];
574
+ return {
575
+ path: dependencyPath,
576
+ root: root ?? identifier.name,
577
+ coversRootMembers: coversRootMembers && dependencyPath === root
578
+ };
579
+ },
471
580
  collectExternalRefsInFunction(params) {
472
581
  const { functionNode, scopeManager } = params;
473
582
  const functionScope = scopeManager.acquire(functionNode);
@@ -499,6 +608,52 @@ var ExhaustiveDepsUtils = {
499
608
  }
500
609
  collect(functionScope);
501
610
  return externalRefs;
611
+ },
612
+ /**
613
+ * Recursively collects type identifiers from a type annotation
614
+ */
615
+ collectTypeIdentifiers(typeNode, out) {
616
+ switch (typeNode.type) {
617
+ case import_utils3.AST_NODE_TYPES.TSTypeReference: {
618
+ if (typeNode.typeName.type === import_utils3.AST_NODE_TYPES.Identifier) {
619
+ out.add(typeNode.typeName.name);
620
+ }
621
+ break;
622
+ }
623
+ case import_utils3.AST_NODE_TYPES.TSUnionType:
624
+ case import_utils3.AST_NODE_TYPES.TSIntersectionType: {
625
+ typeNode.types.forEach(
626
+ (t) => ExhaustiveDepsUtils.collectTypeIdentifiers(t, out)
627
+ );
628
+ break;
629
+ }
630
+ case import_utils3.AST_NODE_TYPES.TSArrayType: {
631
+ ExhaustiveDepsUtils.collectTypeIdentifiers(typeNode.elementType, out);
632
+ break;
633
+ }
634
+ case import_utils3.AST_NODE_TYPES.TSTupleType: {
635
+ typeNode.elementTypes.forEach(
636
+ (et) => ExhaustiveDepsUtils.collectTypeIdentifiers(et, out)
637
+ );
638
+ break;
639
+ }
640
+ }
641
+ },
642
+ /**
643
+ * Gets the function expression nodes from a queryFn property, handling conditional expressions.
644
+ * When neither branch is skipToken, returns both branches so all deps are scanned.
645
+ */
646
+ getQueryFnNodes(queryFn) {
647
+ if (queryFn.value.type !== import_utils3.AST_NODE_TYPES.ConditionalExpression) {
648
+ return [queryFn.value];
649
+ }
650
+ if (queryFn.value.consequent.type === import_utils3.AST_NODE_TYPES.Identifier && queryFn.value.consequent.name === "skipToken") {
651
+ return [queryFn.value.alternate];
652
+ }
653
+ if (queryFn.value.alternate.type === import_utils3.AST_NODE_TYPES.Identifier && queryFn.value.alternate.name === "skipToken") {
654
+ return [queryFn.value.consequent];
655
+ }
656
+ return [queryFn.value.consequent, queryFn.value.alternate];
502
657
  }
503
658
  };
504
659
 
@@ -521,22 +676,34 @@ var rule = createRule({
521
676
  },
522
677
  hasSuggestions: true,
523
678
  fixable: "code",
524
- schema: []
679
+ schema: [
680
+ {
681
+ type: "object",
682
+ properties: {
683
+ allowlist: {
684
+ type: "object",
685
+ properties: {
686
+ variables: { type: "array", items: { type: "string" } },
687
+ types: { type: "array", items: { type: "string" } }
688
+ },
689
+ additionalProperties: false
690
+ }
691
+ },
692
+ additionalProperties: false
693
+ }
694
+ ]
525
695
  },
526
696
  defaultOptions: [],
527
697
  create: detectTanstackQueryImports((context) => {
528
698
  return {
529
- Property: (node) => {
530
- if (!ASTUtils.isObjectExpression(node.parent) || !ASTUtils.isIdentifierWithName(node.key, QUERY_KEY)) {
531
- return;
532
- }
699
+ ObjectExpression: (node) => {
533
700
  const scopeManager = context.sourceCode.scopeManager;
534
701
  const queryKey = ASTUtils.findPropertyWithIdentifierKey(
535
- node.parent.properties,
702
+ node.properties,
536
703
  QUERY_KEY
537
704
  );
538
705
  const queryFn = ASTUtils.findPropertyWithIdentifierKey(
539
- node.parent.properties,
706
+ node.properties,
540
707
  QUERY_FN
541
708
  );
542
709
  if (scopeManager === null || queryKey === void 0 || queryFn === void 0 || !ASTUtils.isNodeOfOneOf(queryFn.value, [
@@ -550,73 +717,104 @@ var rule = createRule({
550
717
  queryKey.value,
551
718
  context
552
719
  );
553
- const externalRefs = ASTUtils.getExternalRefs({
554
- scopeManager,
555
- sourceCode: context.sourceCode,
556
- node: getQueryFnRelevantNode(queryFn)
557
- });
558
- const relevantRefs = externalRefs.filter(
559
- (reference) => ExhaustiveDepsUtils.isRelevantReference({
560
- sourceCode: context.sourceCode,
561
- reference,
720
+ const queryFnNodes = ExhaustiveDepsUtils.getQueryFnNodes(queryFn);
721
+ const externalRefs = queryFnNodes.flatMap(
722
+ (fnNode) => ASTUtils.getExternalRefs({
562
723
  scopeManager,
563
- node: getQueryFnRelevantNode(queryFn),
564
- filename: context.filename
724
+ sourceCode: context.sourceCode,
725
+ node: fnNode
565
726
  })
566
727
  );
728
+ const relevantRefs = externalRefs.filter(
729
+ (reference) => queryFnNodes.some(
730
+ (fnNode) => ExhaustiveDepsUtils.isRelevantReference({
731
+ sourceCode: context.sourceCode,
732
+ reference,
733
+ scopeManager,
734
+ node: fnNode,
735
+ filename: context.filename
736
+ })
737
+ )
738
+ );
739
+ const ruleOptions = context.options.at(0);
740
+ const allowlistedVariables = new Set(
741
+ ruleOptions?.allowlist?.variables ?? []
742
+ );
743
+ const allowlistedTypes = new Set(ruleOptions?.allowlist?.types ?? []);
744
+ const requiredRefs = relevantRefs.flatMap((ref) => {
745
+ if (ref.identifier.type !== import_utils4.AST_NODE_TYPES.Identifier) return [];
746
+ const refPath = ExhaustiveDepsUtils.computeRefPath({
747
+ identifier: ref.identifier,
748
+ sourceCode: context.sourceCode
749
+ });
750
+ if (refPath === null) return [];
751
+ return [
752
+ {
753
+ ...refPath,
754
+ allowlistedByType: ExhaustiveDepsUtils.variableIsAllowlistedByType({
755
+ allowlistedTypes,
756
+ variable: ref.resolved ?? null
757
+ })
758
+ }
759
+ ];
760
+ });
761
+ if (requiredRefs.length === 0) return;
567
762
  const queryKeyDeps = ExhaustiveDepsUtils.collectQueryKeyDeps({
568
763
  sourceCode: context.sourceCode,
569
764
  scopeManager,
570
765
  queryKeyNode
571
766
  });
572
- const missingRefs = relevantRefs.map((ref) => ({
573
- ref,
574
- text: ASTUtils.isAncestorIsCallee(ref.identifier) ? ref.identifier.name : ASTUtils.mapKeyNodeToBaseText(
575
- ref.identifier,
576
- context.sourceCode
577
- )
578
- })).filter(({ ref, text }) => {
579
- return !ref.isTypeReference && !queryKeyDeps.has(text) && !queryKeyDeps.has(text.split(/[?.]/)[0] ?? "");
580
- }).map(({ ref, text }) => ({
581
- identifier: ref.identifier,
582
- text
583
- }));
584
- const uniqueMissingRefs = uniqueBy(missingRefs, (x) => x.text);
585
- if (uniqueMissingRefs.length > 0) {
586
- const missingAsText = uniqueMissingRefs.map((ref) => ref.text).join(", ");
587
- const queryKeyValue = context.sourceCode.getText(queryKeyNode);
588
- const existingWithMissing = queryKeyValue === "[]" ? `[${missingAsText}]` : queryKeyValue.replace(/\]$/, `, ${missingAsText}]`);
589
- const suggestions = [];
590
- if (queryKeyNode.type === import_utils4.AST_NODE_TYPES.ArrayExpression) {
591
- suggestions.push({
592
- messageId: "fixTo",
593
- data: { result: existingWithMissing },
594
- fix(fixer) {
595
- return fixer.replaceText(queryKeyNode, existingWithMissing);
596
- }
597
- });
598
- }
599
- context.report({
600
- node,
601
- messageId: "missingDeps",
602
- data: {
603
- deps: uniqueMissingRefs.map((ref) => ref.text).join(", ")
604
- },
605
- suggest: suggestions
606
- });
607
- }
767
+ const missingPaths = ExhaustiveDepsUtils.computeFilteredMissingPaths({
768
+ requiredRefs,
769
+ allowlistedVariables,
770
+ existingRootIdentifiers: queryKeyDeps.roots,
771
+ existingFullPaths: queryKeyDeps.paths
772
+ });
773
+ if (missingPaths.length === 0) return;
774
+ const missingAsText = missingPaths.join(", ");
775
+ const suggestions = buildSuggestions({
776
+ queryKeyNode,
777
+ missingPaths,
778
+ missingAsText,
779
+ sourceCode: context.sourceCode
780
+ });
781
+ context.report({
782
+ node,
783
+ messageId: "missingDeps",
784
+ data: { deps: missingAsText },
785
+ suggest: suggestions
786
+ });
608
787
  }
609
788
  };
610
789
  })
611
790
  });
612
- function getQueryFnRelevantNode(queryFn) {
613
- if (queryFn.value.type !== import_utils4.AST_NODE_TYPES.ConditionalExpression) {
614
- return queryFn.value;
791
+ function buildSuggestions(params) {
792
+ const { queryKeyNode, missingPaths, missingAsText, sourceCode } = params;
793
+ if (queryKeyNode.type !== import_utils4.AST_NODE_TYPES.ArrayExpression) {
794
+ return [];
615
795
  }
616
- if (queryFn.value.consequent.type === import_utils4.AST_NODE_TYPES.Identifier && queryFn.value.consequent.name === "skipToken") {
617
- return queryFn.value.alternate;
796
+ const closingBracket = sourceCode.getLastToken(queryKeyNode);
797
+ if (!closingBracket) return [];
798
+ const existingElements = queryKeyNode.elements.filter((el) => el !== null).map((el) => sourceCode.getText(el));
799
+ const resultText = `[${[...existingElements, ...missingPaths].join(", ")}]`;
800
+ if (queryKeyNode.elements.length === 0) {
801
+ return [
802
+ {
803
+ messageId: "fixTo",
804
+ data: { result: resultText },
805
+ fix: (fixer) => fixer.replaceText(queryKeyNode, resultText)
806
+ }
807
+ ];
618
808
  }
619
- return queryFn.value.consequent;
809
+ const tokenBefore = sourceCode.getTokenBefore(closingBracket);
810
+ const separator = tokenBefore?.value === "," ? " " : ", ";
811
+ return [
812
+ {
813
+ messageId: "fixTo",
814
+ data: { result: resultText },
815
+ fix: (fixer) => fixer.insertTextBefore(closingBracket, `${separator}${missingAsText}`)
816
+ }
817
+ ];
620
818
  }
621
819
  function dereferenceVariablesAndTypeAssertions(queryKeyNode, context) {
622
820
  const visitedNodes = /* @__PURE__ */ new Set();