react-doctor 0.0.34 → 0.0.36

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.
@@ -169,6 +169,56 @@ const SECRET_FALSE_POSITIVE_SUFFIXES = new Set([
169
169
  "schema",
170
170
  "constant"
171
171
  ]);
172
+ const TANSTACK_ROUTE_FILE_PATTERN = /\/routes\//;
173
+ const TANSTACK_ROOT_ROUTE_FILE_PATTERN = /__root\.(tsx?|jsx?)$/;
174
+ const TANSTACK_ROUTE_PROPERTY_ORDER = [
175
+ "params",
176
+ "validateSearch",
177
+ "loaderDeps",
178
+ "search.middlewares",
179
+ "ssr",
180
+ "context",
181
+ "beforeLoad",
182
+ "loader",
183
+ "onEnter",
184
+ "onStay",
185
+ "onLeave",
186
+ "head",
187
+ "scripts",
188
+ "headers",
189
+ "remountDeps"
190
+ ];
191
+ const TANSTACK_ROUTE_CREATION_FUNCTIONS = new Set([
192
+ "createFileRoute",
193
+ "createRoute",
194
+ "createRootRoute",
195
+ "createRootRouteWithContext"
196
+ ]);
197
+ const TANSTACK_SERVER_FN_NAMES = new Set(["createServerFn"]);
198
+ const TANSTACK_MIDDLEWARE_METHOD_ORDER = [
199
+ "middleware",
200
+ "inputValidator",
201
+ "client",
202
+ "server",
203
+ "handler"
204
+ ];
205
+ const TANSTACK_REDIRECT_FUNCTIONS = new Set(["redirect", "notFound"]);
206
+ const TANSTACK_SERVER_FN_FILE_PATTERN = /\.functions(\.[jt]sx?)?$/;
207
+ const SEQUENTIAL_AWAIT_THRESHOLD_FOR_LOADER = 2;
208
+ const TANSTACK_QUERY_HOOKS = new Set([
209
+ "useQuery",
210
+ "useInfiniteQuery",
211
+ "useSuspenseQuery",
212
+ "useSuspenseInfiniteQuery"
213
+ ]);
214
+ const TANSTACK_MUTATION_HOOKS = new Set(["useMutation"]);
215
+ const TANSTACK_QUERY_CLIENT_CLASS = "QueryClient";
216
+ const STABLE_HOOK_WRAPPERS = new Set([
217
+ "useState",
218
+ "useMemo",
219
+ "useRef"
220
+ ]);
221
+ const SCRIPT_LOADING_ATTRIBUTES = new Set(["defer", "async"]);
172
222
  const TRIVIAL_INITIALIZER_NAMES = new Set([
173
223
  "Boolean",
174
224
  "String",
@@ -312,6 +362,24 @@ const LEGACY_SHADOW_STYLE_PROPERTIES = new Set([
312
362
  "shadowRadius",
313
363
  "elevation"
314
364
  ]);
365
+ const BOUNCE_ANIMATION_NAMES = new Set([
366
+ "bounce",
367
+ "elastic",
368
+ "wobble",
369
+ "jiggle",
370
+ "spring"
371
+ ]);
372
+ const Z_INDEX_ABSURD_THRESHOLD = 100;
373
+ const INLINE_STYLE_PROPERTY_THRESHOLD = 8;
374
+ const SIDE_TAB_BORDER_WIDTH_WITHOUT_RADIUS_PX = 3;
375
+ const SIDE_TAB_BORDER_WIDTH_WITH_RADIUS_PX = 1;
376
+ const SIDE_TAB_TAILWIND_WIDTH_WITHOUT_RADIUS = 4;
377
+ const DARK_GLOW_BLUR_THRESHOLD_PX = 4;
378
+ const DARK_BACKGROUND_CHANNEL_MAX = 35;
379
+ const COLOR_CHROMA_THRESHOLD = 30;
380
+ const TINY_TEXT_THRESHOLD_PX = 12;
381
+ const WIDE_TRACKING_THRESHOLD_EM = .05;
382
+ const LONG_TRANSITION_DURATION_THRESHOLD_MS = 1e3;
315
383
 
316
384
  //#endregion
317
385
  //#region src/plugin/helpers.ts
@@ -355,7 +423,7 @@ const isSimpleExpression = (node) => {
355
423
  case "TemplateLiteral": return true;
356
424
  case "BinaryExpression": return isSimpleExpression(node.left) && isSimpleExpression(node.right);
357
425
  case "UnaryExpression": return isSimpleExpression(node.argument);
358
- case "MemberExpression": return !node.computed;
426
+ case "MemberExpression": return !node.computed && isSimpleExpression(node.object);
359
427
  case "ConditionalExpression": return isSimpleExpression(node.test) && isSimpleExpression(node.consequent) && isSimpleExpression(node.alternate);
360
428
  default: return false;
361
429
  }
@@ -588,6 +656,521 @@ const clientPassiveEventListeners = { create: (context) => ({ CallExpression(nod
588
656
  });
589
657
  } }) };
590
658
 
659
+ //#endregion
660
+ //#region src/plugin/rules/design.ts
661
+ const isOvershootCubicBezier = (value) => {
662
+ const match = value.match(/cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/);
663
+ if (!match) return false;
664
+ const controlY1 = parseFloat(match[2]);
665
+ const controlY2 = parseFloat(match[4]);
666
+ return controlY1 < -.1 || controlY1 > 1.1 || controlY2 < -.1 || controlY2 > 1.1;
667
+ };
668
+ const hasBounceAnimationName = (value) => {
669
+ const lowerValue = value.toLowerCase();
670
+ for (const name of BOUNCE_ANIMATION_NAMES) if (lowerValue.includes(name)) return true;
671
+ return false;
672
+ };
673
+ const getStringFromClassNameAttr = (node) => {
674
+ const classAttr = findJsxAttribute(node.attributes ?? [], "className");
675
+ if (!classAttr?.value) return null;
676
+ if (classAttr.value.type === "Literal" && typeof classAttr.value.value === "string") return classAttr.value.value;
677
+ if (classAttr.value.type === "JSXExpressionContainer" && classAttr.value.expression?.type === "Literal" && typeof classAttr.value.expression.value === "string") return classAttr.value.expression.value;
678
+ if (classAttr.value.type === "JSXExpressionContainer" && classAttr.value.expression?.type === "TemplateLiteral" && classAttr.value.expression.quasis?.length === 1) return classAttr.value.expression.quasis[0].value?.raw ?? null;
679
+ return null;
680
+ };
681
+ const getInlineStyleExpression = (node) => {
682
+ if (node.name?.type !== "JSXIdentifier" || node.name.name !== "style") return null;
683
+ if (node.value?.type !== "JSXExpressionContainer") return null;
684
+ const expression = node.value.expression;
685
+ if (expression?.type !== "ObjectExpression") return null;
686
+ return expression;
687
+ };
688
+ const getStylePropertyStringValue = (property) => {
689
+ if (property.value?.type === "Literal" && typeof property.value.value === "string") return property.value.value;
690
+ return null;
691
+ };
692
+ const getStylePropertyNumberValue = (property) => {
693
+ if (property.value?.type === "Literal" && typeof property.value.value === "number") return property.value.value;
694
+ if (property.value?.type === "UnaryExpression" && property.value.operator === "-" && property.value.argument?.type === "Literal" && typeof property.value.argument.value === "number") return -property.value.argument.value;
695
+ return null;
696
+ };
697
+ const getStylePropertyKey = (property) => {
698
+ if (property.type !== "Property") return null;
699
+ if (property.key?.type === "Identifier") return property.key.name;
700
+ if (property.key?.type === "Literal" && typeof property.key.value === "string") return property.key.value;
701
+ return null;
702
+ };
703
+ const parseColorToRgb = (value) => {
704
+ const trimmed = value.trim().toLowerCase();
705
+ const hex6Match = trimmed.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
706
+ if (hex6Match) return {
707
+ red: parseInt(hex6Match[1], 16),
708
+ green: parseInt(hex6Match[2], 16),
709
+ blue: parseInt(hex6Match[3], 16)
710
+ };
711
+ const hex3Match = trimmed.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
712
+ if (hex3Match) return {
713
+ red: parseInt(hex3Match[1] + hex3Match[1], 16),
714
+ green: parseInt(hex3Match[2] + hex3Match[2], 16),
715
+ blue: parseInt(hex3Match[3] + hex3Match[3], 16)
716
+ };
717
+ const rgbMatch = trimmed.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
718
+ if (rgbMatch) return {
719
+ red: parseInt(rgbMatch[1], 10),
720
+ green: parseInt(rgbMatch[2], 10),
721
+ blue: parseInt(rgbMatch[3], 10)
722
+ };
723
+ return null;
724
+ };
725
+ const hasColorChroma = (parsed) => Math.max(parsed.red, parsed.green, parsed.blue) - Math.min(parsed.red, parsed.green, parsed.blue) >= COLOR_CHROMA_THRESHOLD;
726
+ const isNeutralBorderColor = (value) => {
727
+ const trimmed = value.trim().toLowerCase();
728
+ if ([
729
+ "gray",
730
+ "grey",
731
+ "silver",
732
+ "white",
733
+ "black",
734
+ "transparent",
735
+ "currentcolor"
736
+ ].includes(trimmed)) return true;
737
+ const parsed = parseColorToRgb(trimmed);
738
+ if (parsed) return !hasColorChroma(parsed);
739
+ return false;
740
+ };
741
+ const extractBorderColorFromShorthand = (shorthandValue) => {
742
+ const afterSolid = shorthandValue.match(/solid\s+(.+)$/i);
743
+ if (!afterSolid) return null;
744
+ return afterSolid[1].trim();
745
+ };
746
+ const isPureBlackColor = (value) => {
747
+ const trimmed = value.trim().toLowerCase();
748
+ if (trimmed === "#000" || trimmed === "#000000") return true;
749
+ if (/^rgb\(\s*0\s*,\s*0\s*,\s*0\s*\)$/.test(trimmed)) return true;
750
+ return false;
751
+ };
752
+ const splitShadowLayers = (shadowValue) => shadowValue.split(/,(?![^(]*\))/);
753
+ const extractColorFromShadowLayer = (layer) => {
754
+ const rgbMatch = layer.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
755
+ if (rgbMatch) return {
756
+ red: parseInt(rgbMatch[1], 10),
757
+ green: parseInt(rgbMatch[2], 10),
758
+ blue: parseInt(rgbMatch[3], 10)
759
+ };
760
+ const hexMatch = layer.match(/#([0-9a-f]{3,6})\b/i);
761
+ if (hexMatch) return parseColorToRgb(`#${hexMatch[1]}`);
762
+ return null;
763
+ };
764
+ const parseShadowLayerBlur = (layer) => {
765
+ const numericTokens = [...layer.replace(/rgba?\([^)]*\)/g, "").replace(/#[0-9a-f]{3,8}\b/gi, "").matchAll(/(\d+(?:\.\d+)?)(px)?/g)].map((match) => parseFloat(match[1]));
766
+ return numericTokens.length >= 3 ? numericTokens[2] : 0;
767
+ };
768
+ const hasColoredGlowShadow = (shadowValue) => {
769
+ for (const layer of splitShadowLayers(shadowValue)) {
770
+ const color = extractColorFromShadowLayer(layer);
771
+ if (color && hasColorChroma(color) && parseShadowLayerBlur(layer) > DARK_GLOW_BLUR_THRESHOLD_PX) return true;
772
+ }
773
+ return false;
774
+ };
775
+ const isBackgroundDark = (bgValue) => {
776
+ const trimmed = bgValue.trim().toLowerCase();
777
+ if (isPureBlackColor(trimmed)) return true;
778
+ const parsed = parseColorToRgb(trimmed);
779
+ if (!parsed) return false;
780
+ return parsed.red <= DARK_BACKGROUND_CHANNEL_MAX && parsed.green <= DARK_BACKGROUND_CHANNEL_MAX && parsed.blue <= DARK_BACKGROUND_CHANNEL_MAX;
781
+ };
782
+ const BORDER_SIDE_KEYS = {
783
+ borderLeft: "left",
784
+ borderRight: "right",
785
+ borderInlineStart: "left",
786
+ borderInlineEnd: "right"
787
+ };
788
+ const BORDER_SIDE_WIDTH_KEYS = new Set([
789
+ "borderLeftWidth",
790
+ "borderRightWidth",
791
+ "borderInlineStartWidth",
792
+ "borderInlineEndWidth"
793
+ ]);
794
+ const noInlineBounceEasing = { create: (context) => ({
795
+ JSXAttribute(node) {
796
+ const expression = getInlineStyleExpression(node);
797
+ if (!expression) return;
798
+ for (const property of expression.properties ?? []) {
799
+ const key = getStylePropertyKey(property);
800
+ if (!key) continue;
801
+ const value = getStylePropertyStringValue(property);
802
+ if (!value) continue;
803
+ if ((key === "transition" || key === "transitionTimingFunction" || key === "animation" || key === "animationTimingFunction") && isOvershootCubicBezier(value)) context.report({
804
+ node: property,
805
+ message: "Bounce/elastic easing feels dated — real objects decelerate smoothly. Use ease-out or cubic-bezier(0.16, 1, 0.3, 1) instead"
806
+ });
807
+ if ((key === "animation" || key === "animationName") && hasBounceAnimationName(value)) context.report({
808
+ node: property,
809
+ message: "Bounce/elastic animation name detected — these feel tacky. Use exponential easing (ease-out-quart/expo) for natural deceleration"
810
+ });
811
+ }
812
+ },
813
+ JSXOpeningElement(node) {
814
+ const classStr = getStringFromClassNameAttr(node);
815
+ if (!classStr) return;
816
+ if (/\banimate-bounce\b/.test(classStr)) context.report({
817
+ node,
818
+ message: "animate-bounce feels dated and tacky — use a subtle ease-out transform for natural deceleration"
819
+ });
820
+ }
821
+ }) };
822
+ const noZIndex9999 = { create: (context) => ({
823
+ JSXAttribute(node) {
824
+ const expression = getInlineStyleExpression(node);
825
+ if (!expression) return;
826
+ for (const property of expression.properties ?? []) {
827
+ if (getStylePropertyKey(property) !== "zIndex") continue;
828
+ const zValue = getStylePropertyNumberValue(property);
829
+ if (zValue !== null && Math.abs(zValue) >= Z_INDEX_ABSURD_THRESHOLD) context.report({
830
+ node: property,
831
+ message: `z-index: ${zValue} is arbitrarily high — use a deliberate z-index scale (1–50). Extreme values signal a stacking context problem, not a fix`
832
+ });
833
+ }
834
+ },
835
+ CallExpression(node) {
836
+ if (node.callee?.type !== "MemberExpression") return;
837
+ if (node.callee.property?.type !== "Identifier" || node.callee.property.name !== "create") return;
838
+ if (node.callee.object?.type !== "Identifier" || node.callee.object.name !== "StyleSheet") return;
839
+ const argument = node.arguments?.[0];
840
+ if (!argument || argument.type !== "ObjectExpression") return;
841
+ walkAst(argument, (child) => {
842
+ if (child.type !== "Property") return;
843
+ if (getStylePropertyKey(child) !== "zIndex") return;
844
+ if (child.value?.type === "Literal" && typeof child.value.value === "number") {
845
+ const zValue = child.value.value;
846
+ if (Math.abs(zValue) >= Z_INDEX_ABSURD_THRESHOLD) context.report({
847
+ node: child,
848
+ message: `z-index: ${zValue} is arbitrarily high — use a deliberate z-index scale (1–50). Extreme values signal a stacking context problem, not a fix`
849
+ });
850
+ }
851
+ });
852
+ }
853
+ }) };
854
+ const noInlineExhaustiveStyle = { create: (context) => ({ JSXAttribute(node) {
855
+ const expression = getInlineStyleExpression(node);
856
+ if (!expression) return;
857
+ const propertyCount = expression.properties?.filter((property) => property.type === "Property").length ?? 0;
858
+ if (propertyCount >= INLINE_STYLE_PROPERTY_THRESHOLD) context.report({
859
+ node: expression,
860
+ message: `${propertyCount} inline style properties — extract to a CSS class, CSS module, or styled component for maintainability and reuse`
861
+ });
862
+ } }) };
863
+ const noSideTabBorder = { create: (context) => ({
864
+ JSXAttribute(node) {
865
+ const expression = getInlineStyleExpression(node);
866
+ if (!expression) return;
867
+ let hasBorderRadius = false;
868
+ for (const property of expression.properties ?? []) if (getStylePropertyKey(property) === "borderRadius") {
869
+ const numValue = getStylePropertyNumberValue(property);
870
+ const strValue = getStylePropertyStringValue(property);
871
+ if (numValue !== null && numValue > 0 || strValue !== null && parseFloat(strValue) > 0) hasBorderRadius = true;
872
+ }
873
+ const threshold = hasBorderRadius ? SIDE_TAB_BORDER_WIDTH_WITH_RADIUS_PX : SIDE_TAB_BORDER_WIDTH_WITHOUT_RADIUS_PX;
874
+ for (const property of expression.properties ?? []) {
875
+ const key = getStylePropertyKey(property);
876
+ if (!key) continue;
877
+ if (key in BORDER_SIDE_KEYS) {
878
+ const value = getStylePropertyStringValue(property);
879
+ if (!value) continue;
880
+ const widthMatch = value.match(/^(\d+)px\s+solid/);
881
+ if (!widthMatch) continue;
882
+ const borderColor = extractBorderColorFromShorthand(value);
883
+ if (borderColor && isNeutralBorderColor(borderColor)) continue;
884
+ const width = parseInt(widthMatch[1], 10);
885
+ if (width >= threshold) context.report({
886
+ node: property,
887
+ message: `Thick one-sided border (${BORDER_SIDE_KEYS[key]}: ${width}px) — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it`
888
+ });
889
+ }
890
+ if (BORDER_SIDE_WIDTH_KEYS.has(key)) {
891
+ const numValue = getStylePropertyNumberValue(property);
892
+ const strValue = getStylePropertyStringValue(property);
893
+ const width = numValue ?? (strValue !== null ? parseFloat(strValue) : NaN);
894
+ if (isNaN(width)) continue;
895
+ const colorKey = key.replace("Width", "Color");
896
+ if (!expression.properties?.some((colorProperty) => {
897
+ if (getStylePropertyKey(colorProperty) !== colorKey) return false;
898
+ const colorValue = getStylePropertyStringValue(colorProperty);
899
+ return colorValue !== null && !isNeutralBorderColor(colorValue);
900
+ })) continue;
901
+ if (width >= threshold) context.report({
902
+ node: property,
903
+ message: `Thick one-sided border (${width}px) — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it`
904
+ });
905
+ }
906
+ }
907
+ },
908
+ JSXOpeningElement(node) {
909
+ const classStr = getStringFromClassNameAttr(node);
910
+ if (!classStr) return;
911
+ const sideMatch = classStr.match(/\bborder-[lrse]-(\d+)\b/);
912
+ if (!sideMatch) return;
913
+ if (/\bborder-(?:(?:gray|slate|zinc|neutral|stone)-\d+|white|black|transparent)\b/.test(classStr)) return;
914
+ if (parseInt(sideMatch[1], 10) >= (/\brounded(?:-(?!none\b)\w+)?\b/.test(classStr) && !/\brounded-none\b/.test(classStr) ? SIDE_TAB_BORDER_WIDTH_WITH_RADIUS_PX : SIDE_TAB_TAILWIND_WIDTH_WITHOUT_RADIUS)) context.report({
915
+ node,
916
+ message: `Thick one-sided border (${sideMatch[0]}) — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it`
917
+ });
918
+ }
919
+ }) };
920
+ const noPureBlackBackground = { create: (context) => ({
921
+ JSXAttribute(node) {
922
+ const expression = getInlineStyleExpression(node);
923
+ if (!expression) return;
924
+ for (const property of expression.properties ?? []) {
925
+ const key = getStylePropertyKey(property);
926
+ if (key !== "backgroundColor" && key !== "background") continue;
927
+ const value = getStylePropertyStringValue(property);
928
+ if (value && isPureBlackColor(value)) context.report({
929
+ node: property,
930
+ message: "Pure #000 background looks harsh — tint slightly toward your brand hue for a more refined feel (e.g. #0a0a0f)"
931
+ });
932
+ }
933
+ },
934
+ JSXOpeningElement(node) {
935
+ const classStr = getStringFromClassNameAttr(node);
936
+ if (!classStr) return;
937
+ if (/\bbg-black\b(?!\/)/.test(classStr)) context.report({
938
+ node,
939
+ message: "Pure black background (bg-black) looks harsh — use a near-black tinted toward your brand hue (e.g. bg-gray-950)"
940
+ });
941
+ }
942
+ }) };
943
+ const noGradientText = { create: (context) => ({
944
+ JSXAttribute(node) {
945
+ const expression = getInlineStyleExpression(node);
946
+ if (!expression) return;
947
+ let hasBackgroundClipText = false;
948
+ let hasGradientBackground = false;
949
+ for (const property of expression.properties ?? []) {
950
+ const key = getStylePropertyKey(property);
951
+ const value = getStylePropertyStringValue(property);
952
+ if (!key || !value) continue;
953
+ if ((key === "backgroundClip" || key === "WebkitBackgroundClip") && value === "text") hasBackgroundClipText = true;
954
+ if ((key === "backgroundImage" || key === "background") && value.includes("gradient")) hasGradientBackground = true;
955
+ }
956
+ if (hasBackgroundClipText && hasGradientBackground) context.report({
957
+ node,
958
+ message: "Gradient text (background-clip: text) is decorative rather than meaningful — a common AI tell. Use solid colors for text"
959
+ });
960
+ },
961
+ JSXOpeningElement(node) {
962
+ const classStr = getStringFromClassNameAttr(node);
963
+ if (!classStr) return;
964
+ if (/\bbg-clip-text\b/.test(classStr) && /\bbg-gradient-to-/.test(classStr)) context.report({
965
+ node,
966
+ message: "Gradient text (bg-clip-text + bg-gradient) is decorative rather than meaningful — a common AI tell. Use solid colors for text"
967
+ });
968
+ }
969
+ }) };
970
+ const noDarkModeGlow = { create: (context) => ({ JSXAttribute(node) {
971
+ const expression = getInlineStyleExpression(node);
972
+ if (!expression) return;
973
+ let hasDarkBackground = false;
974
+ let shadowProperty = null;
975
+ let shadowValue = null;
976
+ for (const property of expression.properties ?? []) {
977
+ const key = getStylePropertyKey(property);
978
+ if (!key) continue;
979
+ if (key === "backgroundColor" || key === "background") {
980
+ const value = getStylePropertyStringValue(property);
981
+ if (value && isBackgroundDark(value)) hasDarkBackground = true;
982
+ }
983
+ if (key === "boxShadow") {
984
+ shadowProperty = property;
985
+ shadowValue = getStylePropertyStringValue(property);
986
+ }
987
+ }
988
+ if (!hasDarkBackground || !shadowValue || !shadowProperty) return;
989
+ if (hasColoredGlowShadow(shadowValue)) context.report({
990
+ node: shadowProperty,
991
+ message: "Colored glow on dark background — the default AI-generated 'cool' look. Use subtle, purposeful lighting instead"
992
+ });
993
+ } }) };
994
+ const noJustifiedText = { create: (context) => ({ JSXAttribute(node) {
995
+ const expression = getInlineStyleExpression(node);
996
+ if (!expression) return;
997
+ let isJustified = false;
998
+ let hasHyphens = false;
999
+ for (const property of expression.properties ?? []) {
1000
+ const key = getStylePropertyKey(property);
1001
+ const value = getStylePropertyStringValue(property);
1002
+ if (!key || !value) continue;
1003
+ if (key === "textAlign" && value === "justify") isJustified = true;
1004
+ if ((key === "hyphens" || key === "WebkitHyphens") && value === "auto") hasHyphens = true;
1005
+ }
1006
+ if (isJustified && !hasHyphens) context.report({
1007
+ node,
1008
+ message: "Justified text without hyphens creates uneven word spacing (\"rivers of white\"). Use text-align: left, or add hyphens: auto"
1009
+ });
1010
+ } }) };
1011
+ const noTinyText = { create: (context) => ({ JSXAttribute(node) {
1012
+ const expression = getInlineStyleExpression(node);
1013
+ if (!expression) return;
1014
+ for (const property of expression.properties ?? []) {
1015
+ if (getStylePropertyKey(property) !== "fontSize") continue;
1016
+ let pxValue = null;
1017
+ const numValue = getStylePropertyNumberValue(property);
1018
+ const strValue = getStylePropertyStringValue(property);
1019
+ if (numValue !== null) pxValue = numValue;
1020
+ else if (strValue !== null) {
1021
+ const pxMatch = strValue.match(/^([\d.]+)px$/);
1022
+ if (pxMatch) pxValue = parseFloat(pxMatch[1]);
1023
+ const remMatch = strValue.match(/^([\d.]+)rem$/);
1024
+ if (remMatch) pxValue = parseFloat(remMatch[1]) * 16;
1025
+ }
1026
+ if (pxValue !== null && pxValue > 0 && pxValue < TINY_TEXT_THRESHOLD_PX) context.report({
1027
+ node: property,
1028
+ message: `Font size ${pxValue}px is too small — body text should be at least ${TINY_TEXT_THRESHOLD_PX}px for readability, 16px is ideal`
1029
+ });
1030
+ }
1031
+ } }) };
1032
+ const noWideLetterSpacing = { create: (context) => ({ JSXAttribute(node) {
1033
+ const expression = getInlineStyleExpression(node);
1034
+ if (!expression) return;
1035
+ let isUppercase = false;
1036
+ let letterSpacingProperty = null;
1037
+ let letterSpacingEm = null;
1038
+ for (const property of expression.properties ?? []) {
1039
+ const key = getStylePropertyKey(property);
1040
+ if (!key) continue;
1041
+ if (key === "textTransform") {
1042
+ if (getStylePropertyStringValue(property) === "uppercase") isUppercase = true;
1043
+ }
1044
+ if (key === "letterSpacing") {
1045
+ letterSpacingProperty = property;
1046
+ const strValue = getStylePropertyStringValue(property);
1047
+ const numValue = getStylePropertyNumberValue(property);
1048
+ if (strValue) {
1049
+ const emMatch = strValue.match(/^([\d.]+)em$/);
1050
+ if (emMatch) letterSpacingEm = parseFloat(emMatch[1]);
1051
+ const pxMatch = strValue.match(/^([\d.]+)px$/);
1052
+ if (pxMatch) letterSpacingEm = parseFloat(pxMatch[1]) / 16;
1053
+ }
1054
+ if (numValue !== null && numValue > 0) letterSpacingEm = numValue / 16;
1055
+ }
1056
+ }
1057
+ if (!isUppercase && letterSpacingProperty && letterSpacingEm !== null && letterSpacingEm > WIDE_TRACKING_THRESHOLD_EM) context.report({
1058
+ node: letterSpacingProperty,
1059
+ message: `Letter spacing ${letterSpacingEm.toFixed(2)}em on body text disrupts natural character groupings. Reserve wide tracking for short uppercase labels only`
1060
+ });
1061
+ } }) };
1062
+ const noGrayOnColoredBackground = { create: (context) => ({ JSXOpeningElement(node) {
1063
+ const classStr = getStringFromClassNameAttr(node);
1064
+ if (!classStr) return;
1065
+ const grayTextMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
1066
+ const coloredBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
1067
+ if (grayTextMatch && coloredBgMatch) context.report({
1068
+ node,
1069
+ message: `Gray text (${grayTextMatch[0]}) on colored background (${coloredBgMatch[0]}) looks washed out — use a darker shade of the background color or white`
1070
+ });
1071
+ } }) };
1072
+ const noLayoutTransitionInline = { create: (context) => ({ JSXAttribute(node) {
1073
+ const expression = getInlineStyleExpression(node);
1074
+ if (!expression) return;
1075
+ for (const property of expression.properties ?? []) {
1076
+ const key = getStylePropertyKey(property);
1077
+ if (key !== "transition" && key !== "transitionProperty") continue;
1078
+ const value = getStylePropertyStringValue(property);
1079
+ if (!value) continue;
1080
+ const lower = value.toLowerCase();
1081
+ if (/\ball\b/.test(lower)) continue;
1082
+ const layoutMatch = lower.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/);
1083
+ if (layoutMatch) context.report({
1084
+ node: property,
1085
+ message: `Transitioning layout property "${layoutMatch[0]}" causes layout thrash every frame — use transform and opacity instead`
1086
+ });
1087
+ }
1088
+ } }) };
1089
+ const noDisabledZoom = { create: (context) => ({ JSXOpeningElement(node) {
1090
+ if (node.name?.type !== "JSXIdentifier" || node.name.name !== "meta") return;
1091
+ const nameAttr = findJsxAttribute(node.attributes ?? [], "name");
1092
+ if (!nameAttr?.value) return;
1093
+ if ((nameAttr.value.type === "Literal" ? nameAttr.value.value : null) !== "viewport") return;
1094
+ const contentAttr = findJsxAttribute(node.attributes ?? [], "content");
1095
+ if (!contentAttr?.value) return;
1096
+ const contentValue = contentAttr.value.type === "Literal" && typeof contentAttr.value.value === "string" ? contentAttr.value.value : null;
1097
+ if (!contentValue) return;
1098
+ const hasUserScalableNo = /user-scalable\s*=\s*no/i.test(contentValue);
1099
+ const maxScaleMatch = contentValue.match(/maximum-scale\s*=\s*([\d.]+)/i);
1100
+ const hasRestrictiveMaxScale = maxScaleMatch !== null && parseFloat(maxScaleMatch[1]) < 2;
1101
+ if (hasUserScalableNo && hasRestrictiveMaxScale) context.report({
1102
+ node,
1103
+ message: `user-scalable=no and maximum-scale=${maxScaleMatch[1]} disable pinch-to-zoom — this is an accessibility violation (WCAG 1.4.4). Remove both and fix layout if it breaks at 200% zoom`
1104
+ });
1105
+ else if (hasUserScalableNo) context.report({
1106
+ node,
1107
+ message: "user-scalable=no disables pinch-to-zoom — this is an accessibility violation (WCAG 1.4.4). Remove it and fix layout if it breaks at 200% zoom"
1108
+ });
1109
+ else if (hasRestrictiveMaxScale) context.report({
1110
+ node,
1111
+ message: `maximum-scale=${maxScaleMatch[1]} restricts zoom below 200% — this is an accessibility violation (WCAG 1.4.4). Use maximum-scale=5 or remove it`
1112
+ });
1113
+ } }) };
1114
+ const noOutlineNone = { create: (context) => ({ JSXAttribute(node) {
1115
+ const expression = getInlineStyleExpression(node);
1116
+ if (!expression) return;
1117
+ let hasOutlineNone = false;
1118
+ let outlineProperty = null;
1119
+ for (const property of expression.properties ?? []) {
1120
+ if (getStylePropertyKey(property) !== "outline") continue;
1121
+ const strValue = getStylePropertyStringValue(property);
1122
+ const numValue = getStylePropertyNumberValue(property);
1123
+ if (strValue === "none" || strValue === "0" || numValue === 0) {
1124
+ hasOutlineNone = true;
1125
+ outlineProperty = property;
1126
+ }
1127
+ }
1128
+ if (!hasOutlineNone || !outlineProperty) return;
1129
+ if (!expression.properties?.some((property) => {
1130
+ return getStylePropertyKey(property) === "boxShadow";
1131
+ })) context.report({
1132
+ node: outlineProperty,
1133
+ message: "outline: none removes keyboard focus visibility — use :focus-visible styling instead, or provide a box-shadow focus ring"
1134
+ });
1135
+ } }) };
1136
+ const noLongTransitionDuration = { create: (context) => ({ JSXAttribute(node) {
1137
+ const expression = getInlineStyleExpression(node);
1138
+ if (!expression) return;
1139
+ for (const property of expression.properties ?? []) {
1140
+ const key = getStylePropertyKey(property);
1141
+ if (!key) continue;
1142
+ const value = getStylePropertyStringValue(property);
1143
+ if (!value) continue;
1144
+ let durationMs = null;
1145
+ if (key === "transitionDuration" || key === "animationDuration") {
1146
+ let longestDurationPropertyMs = 0;
1147
+ for (const segment of value.split(",")) {
1148
+ const trimmedSegment = segment.trim();
1149
+ const msMatch = trimmedSegment.match(/^([\d.]+)ms$/);
1150
+ const secondsMatch = trimmedSegment.match(/^([\d.]+)s$/);
1151
+ if (msMatch) longestDurationPropertyMs = Math.max(longestDurationPropertyMs, parseFloat(msMatch[1]));
1152
+ else if (secondsMatch) longestDurationPropertyMs = Math.max(longestDurationPropertyMs, parseFloat(secondsMatch[1]) * 1e3);
1153
+ }
1154
+ if (longestDurationPropertyMs > 0) durationMs = longestDurationPropertyMs;
1155
+ }
1156
+ if (key === "transition" || key === "animation") {
1157
+ let longestDurationMs = 0;
1158
+ const segments = value.split(",");
1159
+ for (const segment of segments) {
1160
+ const firstTimeMatch = segment.match(/(?<![a-zA-Z\d])([\d.]+)(m?s)(?![a-zA-Z\d-])/);
1161
+ if (!firstTimeMatch) continue;
1162
+ const segmentDurationMs = firstTimeMatch[2] === "ms" ? parseFloat(firstTimeMatch[1]) : parseFloat(firstTimeMatch[1]) * 1e3;
1163
+ longestDurationMs = Math.max(longestDurationMs, segmentDurationMs);
1164
+ }
1165
+ if (longestDurationMs > 0) durationMs = longestDurationMs;
1166
+ }
1167
+ if (durationMs !== null && durationMs > LONG_TRANSITION_DURATION_THRESHOLD_MS) context.report({
1168
+ node: property,
1169
+ message: `${durationMs}ms transition is too slow for UI feedback — keep transitions under ${LONG_TRANSITION_DURATION_THRESHOLD_MS}ms. Use longer durations only for page-load hero animations`
1170
+ });
1171
+ }
1172
+ } }) };
1173
+
591
1174
  //#endregion
592
1175
  //#region src/plugin/rules/correctness.ts
593
1176
  const extractIndexName = (node) => {
@@ -673,6 +1256,10 @@ const jsCombineIterations = { create: (context) => ({ CallExpression(node) {
673
1256
  if (innerCall?.type !== "CallExpression" || innerCall.callee?.type !== "MemberExpression" || innerCall.callee.property?.type !== "Identifier") return;
674
1257
  const innerMethod = innerCall.callee.property.name;
675
1258
  if (!CHAINABLE_ITERATION_METHODS.has(innerMethod)) return;
1259
+ if (innerMethod === "map" && outerMethod === "filter") {
1260
+ const filterArgument = node.arguments?.[0];
1261
+ if (filterArgument?.type === "Identifier" && filterArgument.name === "Boolean" || filterArgument?.type === "ArrowFunctionExpression" && filterArgument.params?.length === 1 && filterArgument.body?.type === "Identifier" && filterArgument.params[0]?.type === "Identifier" && filterArgument.body.name === filterArgument.params[0].name) return;
1262
+ }
676
1263
  context.report({
677
1264
  node,
678
1265
  message: `.${innerMethod}().${outerMethod}() iterates the array twice — combine into a single loop with .reduce() or for...of`
@@ -798,6 +1385,21 @@ const reportIfIndependent = (statements, context) => {
798
1385
  message: `${statements.length} sequential await statements that appear independent — use Promise.all() for parallel execution`
799
1386
  });
800
1387
  };
1388
+ const jsFlatmapFilter = { create: (context) => ({ CallExpression(node) {
1389
+ if (node.callee?.type !== "MemberExpression" || node.callee.property?.type !== "Identifier") return;
1390
+ if (node.callee.property.name !== "filter") return;
1391
+ const filterArgument = node.arguments?.[0];
1392
+ if (!filterArgument) return;
1393
+ const isIdentityArrow = filterArgument.type === "ArrowFunctionExpression" && filterArgument.params?.length === 1 && filterArgument.body?.type === "Identifier" && filterArgument.params[0]?.type === "Identifier" && filterArgument.body.name === filterArgument.params[0].name;
1394
+ if (!(filterArgument.type === "Identifier" && filterArgument.name === "Boolean" || isIdentityArrow)) return;
1395
+ const innerCall = node.callee.object;
1396
+ if (innerCall?.type !== "CallExpression" || innerCall.callee?.type !== "MemberExpression" || innerCall.callee.property?.type !== "Identifier") return;
1397
+ if (innerCall.callee.property.name !== "map") return;
1398
+ context.report({
1399
+ node,
1400
+ message: ".map().filter(Boolean) iterates twice — use .flatMap() to transform and filter in a single pass"
1401
+ });
1402
+ } }) };
801
1403
 
802
1404
  //#endregion
803
1405
  //#region src/plugin/rules/nextjs.ts
@@ -888,32 +1490,37 @@ const nextjsMissingMetadata = { create: (context) => ({ Program(programNode) {
888
1490
  message: "Page without metadata or generateMetadata export — hurts SEO"
889
1491
  });
890
1492
  } }) };
891
- const describeClientSideNavigation = (node) => {
1493
+ const describeClientSideNavigation = (node, isPagesRouterFile) => {
1494
+ const redirectGuidance = isPagesRouterFile ? "handle navigation in an event handler, getServerSideProps redirect, or middleware" : "use redirect() from next/navigation or handle navigation in an event handler";
892
1495
  if (node.type === "CallExpression" && node.callee?.type === "MemberExpression") {
893
1496
  const objectName = node.callee.object?.type === "Identifier" ? node.callee.object.name : null;
894
1497
  const methodName = node.callee.property?.type === "Identifier" ? node.callee.property.name : null;
895
- if (objectName === "router" && (methodName === "push" || methodName === "replace")) return `router.${methodName}() in useEffect — use redirect() from next/navigation or handle navigation in an event handler`;
1498
+ if (objectName === "router" && (methodName === "push" || methodName === "replace")) return `router.${methodName}() in useEffect — ${redirectGuidance}`;
896
1499
  }
897
1500
  if (node.type === "AssignmentExpression" && node.left?.type === "MemberExpression") {
898
1501
  const objectName = node.left.object?.type === "Identifier" ? node.left.object.name : null;
899
1502
  const propertyName = node.left.property?.type === "Identifier" ? node.left.property.name : null;
900
- if (objectName === "window" && propertyName === "location") return "window.location assignment in useEffect — use redirect() from next/navigation or handle in middleware instead";
901
- if (objectName === "location" && propertyName === "href") return "location.href assignment in useEffect — use redirect() from next/navigation or handle in middleware instead";
1503
+ if (objectName === "window" && propertyName === "location") return `window.location assignment in useEffect — ${redirectGuidance}`;
1504
+ if (objectName === "location" && propertyName === "href") return `location.href assignment in useEffect — ${redirectGuidance}`;
902
1505
  }
903
1506
  return null;
904
1507
  };
905
- const nextjsNoClientSideRedirect = { create: (context) => ({ CallExpression(node) {
906
- if (!isHookCall(node, EFFECT_HOOK_NAMES)) return;
907
- const callback = getEffectCallback(node);
908
- if (!callback) return;
909
- walkAst(callback, (child) => {
910
- const navigationDescription = describeClientSideNavigation(child);
911
- if (navigationDescription) context.report({
912
- node: child,
913
- message: navigationDescription
1508
+ const nextjsNoClientSideRedirect = { create: (context) => {
1509
+ const filename = context.getFilename?.() ?? "";
1510
+ const isPagesRouterFile = PAGES_DIRECTORY_PATTERN.test(filename);
1511
+ return { CallExpression(node) {
1512
+ if (!isHookCall(node, EFFECT_HOOK_NAMES)) return;
1513
+ const callback = getEffectCallback(node);
1514
+ if (!callback) return;
1515
+ walkAst(callback, (child) => {
1516
+ const navigationDescription = describeClientSideNavigation(child, isPagesRouterFile);
1517
+ if (navigationDescription) context.report({
1518
+ node: child,
1519
+ message: navigationDescription
1520
+ });
914
1521
  });
915
- });
916
- } }) };
1522
+ } };
1523
+ } };
917
1524
  const nextjsNoRedirectInTryCatch = { create: (context) => {
918
1525
  let tryCatchDepth = 0;
919
1526
  return {
@@ -1260,6 +1867,19 @@ const renderingHydrationNoFlicker = { create: (context) => ({ CallExpression(nod
1260
1867
  message: "useEffect(setState, []) on mount causes a flash — consider useSyncExternalStore or suppressHydrationWarning"
1261
1868
  });
1262
1869
  } }) };
1870
+ const renderingScriptDeferAsync = { create: (context) => ({ JSXOpeningElement(node) {
1871
+ if (node.name?.type !== "JSXIdentifier" || node.name.name !== "script") return;
1872
+ const attributes = node.attributes ?? [];
1873
+ if (!attributes.some((attr) => attr.type === "JSXAttribute" && attr.name?.type === "JSXIdentifier" && attr.name.name === "src")) return;
1874
+ const typeAttribute = attributes.find((attr) => attr.type === "JSXAttribute" && attr.name?.type === "JSXIdentifier" && attr.name.name === "type");
1875
+ const typeValue = typeAttribute?.value?.type === "Literal" ? typeAttribute.value.value : null;
1876
+ if (typeof typeValue === "string" && !EXECUTABLE_SCRIPT_TYPES.has(typeValue)) return;
1877
+ if (typeValue === "module") return;
1878
+ if (!attributes.some((attr) => attr.type === "JSXAttribute" && attr.name?.type === "JSXIdentifier" && SCRIPT_LOADING_ATTRIBUTES.has(attr.name.name))) context.report({
1879
+ node,
1880
+ message: "<script src> without defer or async — blocks HTML parsing and delays First Contentful Paint. Add defer for DOM-dependent scripts or async for independent ones"
1881
+ });
1882
+ } }) };
1263
1883
 
1264
1884
  //#endregion
1265
1885
  //#region src/plugin/rules/react-native.ts
@@ -1424,6 +2044,119 @@ const rnNoSingleElementStyleArray = { create: (context) => ({ JSXAttribute(node)
1424
2044
  });
1425
2045
  } }) };
1426
2046
 
2047
+ //#endregion
2048
+ //#region src/plugin/rules/tanstack-query.ts
2049
+ const queryStableQueryClient = { create: (context) => {
2050
+ let componentDepth = 0;
2051
+ let stableHookDepth = 0;
2052
+ return {
2053
+ FunctionDeclaration(node) {
2054
+ if (node.id?.name && UPPERCASE_PATTERN.test(node.id.name)) componentDepth++;
2055
+ },
2056
+ "FunctionDeclaration:exit"(node) {
2057
+ if (node.id?.name && UPPERCASE_PATTERN.test(node.id.name)) componentDepth--;
2058
+ },
2059
+ VariableDeclarator(node) {
2060
+ if (node.id?.type === "Identifier" && UPPERCASE_PATTERN.test(node.id.name) && (node.init?.type === "ArrowFunctionExpression" || node.init?.type === "FunctionExpression")) componentDepth++;
2061
+ },
2062
+ "VariableDeclarator:exit"(node) {
2063
+ if (node.id?.type === "Identifier" && UPPERCASE_PATTERN.test(node.id.name) && (node.init?.type === "ArrowFunctionExpression" || node.init?.type === "FunctionExpression")) componentDepth--;
2064
+ },
2065
+ CallExpression(node) {
2066
+ if (node.callee?.type === "Identifier" && STABLE_HOOK_WRAPPERS.has(node.callee.name)) stableHookDepth++;
2067
+ },
2068
+ "CallExpression:exit"(node) {
2069
+ if (node.callee?.type === "Identifier" && STABLE_HOOK_WRAPPERS.has(node.callee.name)) stableHookDepth--;
2070
+ },
2071
+ NewExpression(node) {
2072
+ if (componentDepth <= 0) return;
2073
+ if (stableHookDepth > 0) return;
2074
+ if (node.callee?.type !== "Identifier" || node.callee.name !== TANSTACK_QUERY_CLIENT_CLASS) return;
2075
+ context.report({
2076
+ node,
2077
+ message: "new QueryClient() inside a component — creates a new cache on every render. Move to module scope or wrap in useState(() => new QueryClient())"
2078
+ });
2079
+ }
2080
+ };
2081
+ } };
2082
+ const queryNoRestDestructuring = { create: (context) => ({ VariableDeclarator(node) {
2083
+ if (node.id?.type !== "ObjectPattern") return;
2084
+ if (!node.init || node.init.type !== "CallExpression") return;
2085
+ const calleeName = node.init.callee?.type === "Identifier" ? node.init.callee.name : null;
2086
+ if (!calleeName || !TANSTACK_QUERY_HOOKS.has(calleeName)) return;
2087
+ if (node.id.properties?.some((property) => property.type === "RestElement")) context.report({
2088
+ node: node.id,
2089
+ message: `Rest destructuring on ${calleeName}() result — subscribes to all fields and causes unnecessary re-renders. Destructure only the fields you need`
2090
+ });
2091
+ } }) };
2092
+ const queryNoVoidQueryFn = { create: (context) => ({ CallExpression(node) {
2093
+ const calleeName = node.callee?.type === "Identifier" ? node.callee.name : null;
2094
+ if (!calleeName || !TANSTACK_QUERY_HOOKS.has(calleeName)) return;
2095
+ const optionsArgument = node.arguments?.[0];
2096
+ if (!optionsArgument || optionsArgument.type !== "ObjectExpression") return;
2097
+ const queryFnProperty = optionsArgument.properties?.find((property) => property.type === "Property" && property.key?.type === "Identifier" && property.key.name === "queryFn");
2098
+ if (!queryFnProperty?.value) return;
2099
+ const queryFnValue = queryFnProperty.value;
2100
+ if (queryFnValue.type === "ArrowFunctionExpression" && queryFnValue.body?.type !== "BlockStatement") return;
2101
+ if (queryFnValue.type === "ArrowFunctionExpression" || queryFnValue.type === "FunctionExpression") {
2102
+ const body = queryFnValue.body;
2103
+ if (body?.type !== "BlockStatement") return;
2104
+ if ((body.body ?? []).length === 0) context.report({
2105
+ node: queryFnProperty,
2106
+ message: "Empty queryFn — query functions must return a value. Use the enabled option to conditionally disable the query instead"
2107
+ });
2108
+ }
2109
+ } }) };
2110
+ const queryNoQueryInEffect = { create: (context) => ({ CallExpression(node) {
2111
+ if (!isHookCall(node, EFFECT_HOOK_NAMES)) return;
2112
+ const callback = getEffectCallback(node);
2113
+ if (!callback) return;
2114
+ walkAst(callback, (child) => {
2115
+ if (child.type !== "CallExpression") return;
2116
+ if ((child.callee?.type === "Identifier" ? child.callee.name : null) === "refetch") context.report({
2117
+ node: child,
2118
+ message: "refetch() inside useEffect — React Query manages refetching automatically. Use queryKey dependencies or the enabled option instead"
2119
+ });
2120
+ });
2121
+ } }) };
2122
+ const queryMutationMissingInvalidation = { create: (context) => ({ CallExpression(node) {
2123
+ const calleeName = node.callee?.type === "Identifier" ? node.callee.name : null;
2124
+ if (!calleeName || !TANSTACK_MUTATION_HOOKS.has(calleeName)) return;
2125
+ const optionsArgument = node.arguments?.[0];
2126
+ if (!optionsArgument || optionsArgument.type !== "ObjectExpression") return;
2127
+ if (!optionsArgument.properties?.some((property) => property.type === "Property" && property.key?.type === "Identifier" && property.key.name === "mutationFn")) return;
2128
+ let hasInvalidation = false;
2129
+ walkAst(optionsArgument, (child) => {
2130
+ if (hasInvalidation) return;
2131
+ if (child.type === "CallExpression" && child.callee?.type === "MemberExpression" && child.callee.property?.type === "Identifier" && child.callee.property.name === "invalidateQueries") hasInvalidation = true;
2132
+ });
2133
+ if (!hasInvalidation) context.report({
2134
+ node,
2135
+ message: "useMutation without invalidateQueries — stale data may remain cached after the mutation. Add onSuccess with queryClient.invalidateQueries()"
2136
+ });
2137
+ } }) };
2138
+ const queryNoUseQueryForMutation = { create: (context) => ({ CallExpression(node) {
2139
+ const calleeName = node.callee?.type === "Identifier" ? node.callee.name : null;
2140
+ if (!calleeName || !TANSTACK_QUERY_HOOKS.has(calleeName)) return;
2141
+ const optionsArgument = node.arguments?.[0];
2142
+ if (!optionsArgument || optionsArgument.type !== "ObjectExpression") return;
2143
+ const queryFnProperty = optionsArgument.properties?.find((property) => property.type === "Property" && property.key?.type === "Identifier" && property.key.name === "queryFn");
2144
+ if (!queryFnProperty?.value) return;
2145
+ let hasMutatingFetch = false;
2146
+ walkAst(queryFnProperty.value, (child) => {
2147
+ if (hasMutatingFetch) return;
2148
+ if (child.type !== "CallExpression") return;
2149
+ if (child.callee?.type !== "Identifier" || child.callee.name !== "fetch") return;
2150
+ const optionsArg = child.arguments?.[1];
2151
+ if (!optionsArg || optionsArg.type !== "ObjectExpression") return;
2152
+ if (optionsArg.properties?.find((property) => property.type === "Property" && property.key?.type === "Identifier" && property.key.name === "method" && property.value?.type === "Literal" && typeof property.value.value === "string" && MUTATING_HTTP_METHODS.has(property.value.value.toUpperCase()))) hasMutatingFetch = true;
2153
+ });
2154
+ if (hasMutatingFetch) context.report({
2155
+ node,
2156
+ message: `${calleeName}() with a mutating fetch (POST/PUT/DELETE) — use useMutation() instead, which provides onSuccess/onError callbacks and doesn't auto-refetch`
2157
+ });
2158
+ } }) };
2159
+
1427
2160
  //#endregion
1428
2161
  //#region src/plugin/rules/security.ts
1429
2162
  const noEval = { create: (context) => ({
@@ -1522,6 +2255,335 @@ const serverAfterNonblocking = { create: (context) => {
1522
2255
  };
1523
2256
  } };
1524
2257
 
2258
+ //#endregion
2259
+ //#region src/plugin/rules/tanstack-start.ts
2260
+ const getRouteOptionsObject = (node) => {
2261
+ if (node.type !== "CallExpression") return null;
2262
+ const callee = node.callee;
2263
+ if (callee?.type === "CallExpression" && callee.callee?.type === "Identifier") {
2264
+ if (!TANSTACK_ROUTE_CREATION_FUNCTIONS.has(callee.callee.name)) return null;
2265
+ const optionsArgument = node.arguments?.[0];
2266
+ if (optionsArgument?.type === "ObjectExpression") return optionsArgument;
2267
+ return null;
2268
+ }
2269
+ if (callee?.type === "Identifier") {
2270
+ if (!TANSTACK_ROUTE_CREATION_FUNCTIONS.has(callee.name)) return null;
2271
+ const optionsArgument = node.arguments?.[0];
2272
+ if (optionsArgument?.type === "ObjectExpression") return optionsArgument;
2273
+ return null;
2274
+ }
2275
+ return null;
2276
+ };
2277
+ const getPropertyKeyName = (property) => {
2278
+ if (property.type !== "Property" && property.type !== "MethodDefinition") return null;
2279
+ if (property.key?.type === "Identifier") return property.key.name;
2280
+ if (property.key?.type === "Literal") return String(property.key.value);
2281
+ return null;
2282
+ };
2283
+ const walkServerFnChain = (outerNode) => {
2284
+ const result = {
2285
+ isServerFnChain: false,
2286
+ specifiedMethod: null,
2287
+ hasInputValidator: false
2288
+ };
2289
+ let currentNode = outerNode.callee?.object;
2290
+ while (currentNode?.type === "CallExpression") {
2291
+ const calleeName = getCalleeName(currentNode);
2292
+ if (calleeName && TANSTACK_SERVER_FN_NAMES.has(calleeName)) {
2293
+ result.isServerFnChain = true;
2294
+ const optionsArgument = currentNode.arguments?.[0];
2295
+ if (optionsArgument?.type === "ObjectExpression") {
2296
+ for (const property of optionsArgument.properties ?? []) if (property.key?.type === "Identifier" && property.key.name === "method" && property.value?.type === "Literal" && typeof property.value.value === "string") result.specifiedMethod = property.value.value;
2297
+ }
2298
+ }
2299
+ if (calleeName === "inputValidator") result.hasInputValidator = true;
2300
+ if (currentNode.callee?.type === "MemberExpression") currentNode = currentNode.callee.object;
2301
+ else break;
2302
+ }
2303
+ return result;
2304
+ };
2305
+ const tanstackStartRoutePropertyOrder = { create: (context) => ({ CallExpression(node) {
2306
+ const optionsObject = getRouteOptionsObject(node);
2307
+ if (!optionsObject) return;
2308
+ const properties = optionsObject.properties ?? [];
2309
+ const orderedPropertyNames = [];
2310
+ for (const property of properties) {
2311
+ const propertyName = getPropertyKeyName(property);
2312
+ if (propertyName !== null) orderedPropertyNames.push(propertyName);
2313
+ }
2314
+ const sensitiveProperties = orderedPropertyNames.filter((propertyName) => TANSTACK_ROUTE_PROPERTY_ORDER.includes(propertyName));
2315
+ let lastIndex = -1;
2316
+ for (const propertyName of sensitiveProperties) {
2317
+ const currentIndex = TANSTACK_ROUTE_PROPERTY_ORDER.indexOf(propertyName);
2318
+ if (currentIndex < lastIndex) {
2319
+ const expectedBefore = TANSTACK_ROUTE_PROPERTY_ORDER[lastIndex];
2320
+ context.report({
2321
+ node: optionsObject,
2322
+ message: `Route property "${propertyName}" must come before "${expectedBefore}" — wrong order breaks TypeScript type inference`
2323
+ });
2324
+ return;
2325
+ }
2326
+ lastIndex = currentIndex;
2327
+ }
2328
+ } }) };
2329
+ const tanstackStartNoDirectFetchInLoader = { create: (context) => ({ CallExpression(node) {
2330
+ const optionsObject = getRouteOptionsObject(node);
2331
+ if (!optionsObject) return;
2332
+ const properties = optionsObject.properties ?? [];
2333
+ for (const property of properties) {
2334
+ if (getPropertyKeyName(property) !== "loader") continue;
2335
+ walkAst(property.value ?? property, (child) => {
2336
+ if (child.type !== "CallExpression") return;
2337
+ if (child.callee?.type === "Identifier" && child.callee.name === "fetch") context.report({
2338
+ node: child,
2339
+ message: "Direct fetch() in route loader — use createServerFn() for type-safe server logic with automatic RPC"
2340
+ });
2341
+ });
2342
+ }
2343
+ } }) };
2344
+ const tanstackStartServerFnValidateInput = { create: (context) => ({ CallExpression(node) {
2345
+ if (node.callee?.type !== "MemberExpression") return;
2346
+ if (node.callee.property?.type !== "Identifier") return;
2347
+ if (node.callee.property.name !== "handler") return;
2348
+ const chainInfo = walkServerFnChain(node);
2349
+ if (!chainInfo.isServerFnChain) return;
2350
+ const handlerFunction = node.arguments?.[0];
2351
+ if (!handlerFunction) return;
2352
+ let accessesData = false;
2353
+ walkAst(handlerFunction, (child) => {
2354
+ if (child.type === "MemberExpression" && child.property?.type === "Identifier" && child.property.name === "data") accessesData = true;
2355
+ if (child.type === "ObjectPattern" && child.properties?.some((property) => property.key?.type === "Identifier" && property.key.name === "data")) accessesData = true;
2356
+ });
2357
+ if (accessesData && !chainInfo.hasInputValidator) context.report({
2358
+ node,
2359
+ message: "Server function handler accesses data without inputValidator() — validate inputs crossing the network boundary"
2360
+ });
2361
+ } }) };
2362
+ const tanstackStartNoUseEffectFetch = { create: (context) => ({ CallExpression(node) {
2363
+ const filename = context.getFilename?.() ?? "";
2364
+ if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
2365
+ if (node.callee?.type !== "Identifier") return;
2366
+ if (node.callee.name !== "useEffect" && node.callee.name !== "useLayoutEffect") return;
2367
+ const callback = node.arguments?.[0];
2368
+ if (!callback) return;
2369
+ let hasFetchCall = false;
2370
+ walkAst(callback, (child) => {
2371
+ if (hasFetchCall) return;
2372
+ if (child.type === "CallExpression" && child.callee?.type === "Identifier" && child.callee.name === "fetch") hasFetchCall = true;
2373
+ });
2374
+ if (hasFetchCall) context.report({
2375
+ node,
2376
+ message: "fetch() inside useEffect in a route file — use the route loader or createServerFn() instead"
2377
+ });
2378
+ } }) };
2379
+ const tanstackStartMissingHeadContent = { create: (context) => {
2380
+ let hasHeadContentElement = false;
2381
+ return {
2382
+ JSXOpeningElement(node) {
2383
+ const filename = context.getFilename?.() ?? "";
2384
+ if (!TANSTACK_ROOT_ROUTE_FILE_PATTERN.test(filename)) return;
2385
+ if (node.name?.type === "JSXIdentifier" && node.name.name === "HeadContent") hasHeadContentElement = true;
2386
+ },
2387
+ "Program:exit"(programNode) {
2388
+ const filename = context.getFilename?.() ?? "";
2389
+ if (!TANSTACK_ROOT_ROUTE_FILE_PATTERN.test(filename)) return;
2390
+ if (!hasHeadContentElement) context.report({
2391
+ node: programNode,
2392
+ message: "Root route (__root) without <HeadContent /> — route head() meta tags won't render"
2393
+ });
2394
+ }
2395
+ };
2396
+ } };
2397
+ const tanstackStartNoAnchorElement = { create: (context) => ({ JSXOpeningElement(node) {
2398
+ const filename = context.getFilename?.() ?? "";
2399
+ if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
2400
+ if (node.name?.type !== "JSXIdentifier" || node.name.name !== "a") return;
2401
+ const hrefAttribute = (node.attributes ?? []).find((attribute) => attribute.type === "JSXAttribute" && attribute.name?.type === "JSXIdentifier" && attribute.name.name === "href");
2402
+ if (!hrefAttribute?.value) return;
2403
+ let hrefValue = null;
2404
+ if (hrefAttribute.value.type === "Literal") hrefValue = hrefAttribute.value.value;
2405
+ else if (hrefAttribute.value.type === "JSXExpressionContainer" && hrefAttribute.value.expression?.type === "Literal") hrefValue = hrefAttribute.value.expression.value;
2406
+ if (typeof hrefValue === "string" && hrefValue.startsWith("/")) context.report({
2407
+ node,
2408
+ message: "Use <Link> from @tanstack/react-router instead of <a> for internal navigation — enables type-safe routing and preloading"
2409
+ });
2410
+ } }) };
2411
+ const tanstackStartServerFnMethodOrder = { create: (context) => ({ CallExpression(node) {
2412
+ if (node.callee?.type !== "MemberExpression") return;
2413
+ const methodNames = [];
2414
+ let currentNode = node;
2415
+ while (currentNode?.type === "CallExpression" && currentNode.callee?.type === "MemberExpression") {
2416
+ const methodName = currentNode.callee.property?.type === "Identifier" ? currentNode.callee.property.name : null;
2417
+ if (methodName) methodNames.unshift(methodName);
2418
+ currentNode = currentNode.callee.object;
2419
+ }
2420
+ if (currentNode?.type === "CallExpression" && currentNode.callee?.type === "Identifier") {
2421
+ if (!TANSTACK_SERVER_FN_NAMES.has(currentNode.callee.name)) return;
2422
+ } else return;
2423
+ const ownMethodName = node.callee.property?.type === "Identifier" ? node.callee.property.name : null;
2424
+ if (methodNames[methodNames.length - 1] !== ownMethodName) return;
2425
+ const orderSensitiveMethods = methodNames.filter((name) => TANSTACK_MIDDLEWARE_METHOD_ORDER.includes(name));
2426
+ let lastIndex = -1;
2427
+ for (const methodName of orderSensitiveMethods) {
2428
+ const currentIndex = TANSTACK_MIDDLEWARE_METHOD_ORDER.indexOf(methodName);
2429
+ if (currentIndex < lastIndex) {
2430
+ const expectedBefore = TANSTACK_MIDDLEWARE_METHOD_ORDER[lastIndex];
2431
+ context.report({
2432
+ node,
2433
+ message: `Server function method .${methodName}() must come before .${expectedBefore}() — wrong order breaks type inference`
2434
+ });
2435
+ return;
2436
+ }
2437
+ lastIndex = currentIndex;
2438
+ }
2439
+ } }) };
2440
+ const tanstackStartNoNavigateInRender = { create: (context) => {
2441
+ let effectDepth = 0;
2442
+ let eventHandlerDepth = 0;
2443
+ return {
2444
+ CallExpression(node) {
2445
+ const filename = context.getFilename?.() ?? "";
2446
+ if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
2447
+ if (node.callee?.type === "Identifier" && (node.callee.name === "useEffect" || node.callee.name === "useLayoutEffect")) effectDepth++;
2448
+ if (effectDepth > 0 || eventHandlerDepth > 0) return;
2449
+ if (node.callee?.type === "Identifier" && node.callee.name === "navigate" && node.arguments?.length > 0) context.report({
2450
+ node,
2451
+ message: "navigate() called during render — use redirect() in beforeLoad/loader for route-level redirects"
2452
+ });
2453
+ },
2454
+ "CallExpression:exit"(node) {
2455
+ const filename = context.getFilename?.() ?? "";
2456
+ if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
2457
+ if (node.callee?.type === "Identifier" && (node.callee.name === "useEffect" || node.callee.name === "useLayoutEffect")) effectDepth--;
2458
+ },
2459
+ JSXAttribute(node) {
2460
+ const filename = context.getFilename?.() ?? "";
2461
+ if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
2462
+ if (node.name?.type === "JSXIdentifier" && node.name.name.startsWith("on") && UPPERCASE_PATTERN.test(node.name.name.charAt(2))) eventHandlerDepth++;
2463
+ },
2464
+ "JSXAttribute:exit"(node) {
2465
+ const filename = context.getFilename?.() ?? "";
2466
+ if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
2467
+ if (node.name?.type === "JSXIdentifier" && node.name.name.startsWith("on") && UPPERCASE_PATTERN.test(node.name.name.charAt(2))) eventHandlerDepth--;
2468
+ }
2469
+ };
2470
+ } };
2471
+ const tanstackStartNoDynamicServerFnImport = { create: (context) => ({ ImportExpression(node) {
2472
+ const source = node.source;
2473
+ if (!source) return;
2474
+ let importPath = null;
2475
+ if (source.type === "Literal" && typeof source.value === "string") importPath = source.value;
2476
+ else if (source.type === "TemplateLiteral" && source.quasis?.length === 1) importPath = source.quasis[0].value?.raw ?? null;
2477
+ if (importPath && TANSTACK_SERVER_FN_FILE_PATTERN.test(importPath)) context.report({
2478
+ node,
2479
+ message: "Dynamic import of server functions file — use static imports so the bundler can replace server code with RPC stubs"
2480
+ });
2481
+ } }) };
2482
+ const tanstackStartNoUseServerInHandler = { create: (context) => ({ CallExpression(node) {
2483
+ if (node.callee?.type !== "MemberExpression") return;
2484
+ if (node.callee.property?.type !== "Identifier" || node.callee.property.name !== "handler") return;
2485
+ const handlerFunction = node.arguments?.[0];
2486
+ if (!handlerFunction || handlerFunction.type !== "ArrowFunctionExpression" && handlerFunction.type !== "FunctionExpression") return;
2487
+ const body = handlerFunction.body;
2488
+ if (body?.type !== "BlockStatement") return;
2489
+ if (body.body?.some((statement) => statement.type === "ExpressionStatement" && (statement.directive === "use server" || statement.expression?.type === "Literal" && statement.expression.value === "use server"))) context.report({
2490
+ node: handlerFunction,
2491
+ message: "\"use server\" inside createServerFn handler — TanStack Start handles this automatically, remove the directive"
2492
+ });
2493
+ } }) };
2494
+ const tanstackStartNoSecretsInLoader = { create: (context) => ({ CallExpression(node) {
2495
+ const optionsObject = getRouteOptionsObject(node);
2496
+ if (!optionsObject) return;
2497
+ const properties = optionsObject.properties ?? [];
2498
+ for (const property of properties) {
2499
+ const keyName = getPropertyKeyName(property);
2500
+ if (keyName !== "loader" && keyName !== "beforeLoad") continue;
2501
+ walkAst(property.value ?? property, (child) => {
2502
+ if (child.type !== "MemberExpression") return;
2503
+ if (child.object?.type === "MemberExpression" && child.object.object?.type === "Identifier" && child.object.object.name === "process" && child.object.property?.type === "Identifier" && child.object.property.name === "env") {
2504
+ const envVarName = child.property?.type === "Identifier" ? child.property.name : null;
2505
+ if (envVarName && !envVarName.startsWith("VITE_")) context.report({
2506
+ node: child,
2507
+ message: `process.env.${envVarName} in ${keyName} — loaders are isomorphic and may leak secrets to the client. Move to a createServerFn()`
2508
+ });
2509
+ }
2510
+ });
2511
+ }
2512
+ } }) };
2513
+ const tanstackStartGetMutation = { create: (context) => ({ CallExpression(node) {
2514
+ if (node.callee?.type !== "MemberExpression") return;
2515
+ if (node.callee.property?.type !== "Identifier" || node.callee.property.name !== "handler") return;
2516
+ const chainInfo = walkServerFnChain(node);
2517
+ if (!chainInfo.isServerFnChain) return;
2518
+ if (chainInfo.specifiedMethod && MUTATING_HTTP_METHODS.has(chainInfo.specifiedMethod.toUpperCase())) return;
2519
+ const handlerFunction = node.arguments?.[0];
2520
+ if (!handlerFunction) return;
2521
+ const sideEffect = findSideEffect(handlerFunction);
2522
+ if (sideEffect) context.report({
2523
+ node,
2524
+ message: `GET server function has side effects (${sideEffect}) — use createServerFn({ method: 'POST' }) for mutations`
2525
+ });
2526
+ } }) };
2527
+ const tanstackStartRedirectInTryCatch = { create: (context) => {
2528
+ let tryBlockDepth = 0;
2529
+ let catchClauseDepth = 0;
2530
+ return {
2531
+ TryStatement() {
2532
+ tryBlockDepth++;
2533
+ },
2534
+ "TryStatement:exit"() {
2535
+ tryBlockDepth--;
2536
+ },
2537
+ CatchClause() {
2538
+ catchClauseDepth++;
2539
+ },
2540
+ "CatchClause:exit"() {
2541
+ catchClauseDepth--;
2542
+ },
2543
+ ThrowStatement(node) {
2544
+ if (tryBlockDepth === 0) return;
2545
+ if (catchClauseDepth > 0) return;
2546
+ const argument = node.argument;
2547
+ if (argument?.type !== "CallExpression") return;
2548
+ if (argument.callee?.type !== "Identifier") return;
2549
+ if (!TANSTACK_REDIRECT_FUNCTIONS.has(argument.callee.name)) return;
2550
+ context.report({
2551
+ node,
2552
+ message: `throw ${argument.callee.name}() inside try block — the router catches this internally. Move it outside the try block or re-throw in the catch`
2553
+ });
2554
+ }
2555
+ };
2556
+ } };
2557
+ const hasTopLevelAwait = (statement) => {
2558
+ if (statement.type === "VariableDeclaration") return statement.declarations?.some((declarator) => declarator.init?.type === "AwaitExpression");
2559
+ if (statement.type === "ExpressionStatement") return statement.expression?.type === "AwaitExpression" || statement.expression?.type === "AssignmentExpression" && statement.expression.right?.type === "AwaitExpression";
2560
+ if (statement.type === "ReturnStatement") return statement.argument?.type === "AwaitExpression";
2561
+ return false;
2562
+ };
2563
+ const tanstackStartLoaderParallelFetch = { create: (context) => ({ CallExpression(node) {
2564
+ const optionsObject = getRouteOptionsObject(node);
2565
+ if (!optionsObject) return;
2566
+ const properties = optionsObject.properties ?? [];
2567
+ for (const property of properties) {
2568
+ if (getPropertyKeyName(property) !== "loader") continue;
2569
+ const loaderValue = property.value;
2570
+ if (!loaderValue || loaderValue.type !== "ArrowFunctionExpression" && loaderValue.type !== "FunctionExpression") continue;
2571
+ const functionBody = loaderValue.body;
2572
+ if (!functionBody || functionBody.type !== "BlockStatement") continue;
2573
+ let sequentialAwaitCount = 0;
2574
+ for (const statement of functionBody.body ?? []) {
2575
+ if (hasTopLevelAwait(statement)) sequentialAwaitCount++;
2576
+ if (sequentialAwaitCount >= SEQUENTIAL_AWAIT_THRESHOLD_FOR_LOADER) {
2577
+ context.report({
2578
+ node: property,
2579
+ message: "Multiple sequential awaits in loader — use Promise.all() to fetch data in parallel and avoid waterfalls"
2580
+ });
2581
+ break;
2582
+ }
2583
+ }
2584
+ }
2585
+ } }) };
2586
+
1525
2587
  //#endregion
1526
2588
  //#region src/plugin/rules/state-and-effects.ts
1527
2589
  const noDerivedStateEffect = { create: (context) => ({ CallExpression(node) {
@@ -1700,6 +2762,7 @@ const plugin = {
1700
2762
  "rendering-animate-svg-wrapper": renderingAnimateSvgWrapper,
1701
2763
  "no-inline-prop-on-memo-component": noInlinePropOnMemoComponent,
1702
2764
  "rendering-hydration-no-flicker": renderingHydrationNoFlicker,
2765
+ "rendering-script-defer-async": renderingScriptDeferAsync,
1703
2766
  "no-transition-all": noTransitionAll,
1704
2767
  "no-global-css-variable-animation": noGlobalCssVariableAnimation,
1705
2768
  "no-large-animated-blur": noLargeAnimatedBlur,
@@ -1744,6 +2807,7 @@ const plugin = {
1744
2807
  "js-index-maps": jsIndexMaps,
1745
2808
  "js-cache-storage": jsCacheStorage,
1746
2809
  "js-early-exit": jsEarlyExit,
2810
+ "js-flatmap-filter": jsFlatmapFilter,
1747
2811
  "async-parallel": asyncParallel,
1748
2812
  "rn-no-raw-text": rnNoRawText,
1749
2813
  "rn-no-deprecated-modules": rnNoDeprecatedModules,
@@ -1752,7 +2816,42 @@ const plugin = {
1752
2816
  "rn-no-inline-flatlist-renderitem": rnNoInlineFlatlistRenderitem,
1753
2817
  "rn-no-legacy-shadow-styles": rnNoLegacyShadowStyles,
1754
2818
  "rn-prefer-reanimated": rnPreferReanimated,
1755
- "rn-no-single-element-style-array": rnNoSingleElementStyleArray
2819
+ "rn-no-single-element-style-array": rnNoSingleElementStyleArray,
2820
+ "tanstack-start-route-property-order": tanstackStartRoutePropertyOrder,
2821
+ "tanstack-start-no-direct-fetch-in-loader": tanstackStartNoDirectFetchInLoader,
2822
+ "tanstack-start-server-fn-validate-input": tanstackStartServerFnValidateInput,
2823
+ "tanstack-start-no-useeffect-fetch": tanstackStartNoUseEffectFetch,
2824
+ "tanstack-start-missing-head-content": tanstackStartMissingHeadContent,
2825
+ "tanstack-start-no-anchor-element": tanstackStartNoAnchorElement,
2826
+ "tanstack-start-server-fn-method-order": tanstackStartServerFnMethodOrder,
2827
+ "tanstack-start-no-navigate-in-render": tanstackStartNoNavigateInRender,
2828
+ "tanstack-start-no-dynamic-server-fn-import": tanstackStartNoDynamicServerFnImport,
2829
+ "tanstack-start-no-use-server-in-handler": tanstackStartNoUseServerInHandler,
2830
+ "tanstack-start-no-secrets-in-loader": tanstackStartNoSecretsInLoader,
2831
+ "tanstack-start-get-mutation": tanstackStartGetMutation,
2832
+ "tanstack-start-redirect-in-try-catch": tanstackStartRedirectInTryCatch,
2833
+ "tanstack-start-loader-parallel-fetch": tanstackStartLoaderParallelFetch,
2834
+ "query-stable-query-client": queryStableQueryClient,
2835
+ "query-no-rest-destructuring": queryNoRestDestructuring,
2836
+ "query-no-void-query-fn": queryNoVoidQueryFn,
2837
+ "query-no-query-in-effect": queryNoQueryInEffect,
2838
+ "query-mutation-missing-invalidation": queryMutationMissingInvalidation,
2839
+ "query-no-usequery-for-mutation": queryNoUseQueryForMutation,
2840
+ "no-inline-bounce-easing": noInlineBounceEasing,
2841
+ "no-z-index-9999": noZIndex9999,
2842
+ "no-inline-exhaustive-style": noInlineExhaustiveStyle,
2843
+ "no-side-tab-border": noSideTabBorder,
2844
+ "no-pure-black-background": noPureBlackBackground,
2845
+ "no-gradient-text": noGradientText,
2846
+ "no-dark-mode-glow": noDarkModeGlow,
2847
+ "no-justified-text": noJustifiedText,
2848
+ "no-tiny-text": noTinyText,
2849
+ "no-wide-letter-spacing": noWideLetterSpacing,
2850
+ "no-gray-on-colored-background": noGrayOnColoredBackground,
2851
+ "no-layout-transition-inline": noLayoutTransitionInline,
2852
+ "no-disabled-zoom": noDisabledZoom,
2853
+ "no-outline-none": noOutlineNone,
2854
+ "no-long-transition-duration": noLongTransitionDuration
1756
2855
  }
1757
2856
  };
1758
2857