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