react-doctor 0.0.42 → 0.0.45

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.
@@ -44,11 +44,20 @@ const HEAVY_LIBRARIES = new Set([
44
44
  "@toast-ui/editor",
45
45
  "draft-js"
46
46
  ]);
47
- const FETCH_CALLEE_NAMES = new Set(["fetch"]);
47
+ const FETCH_CALLEE_NAMES = new Set([
48
+ "fetch",
49
+ "ky",
50
+ "got",
51
+ "wretch",
52
+ "ofetch"
53
+ ]);
48
54
  const FETCH_MEMBER_OBJECTS = new Set([
49
55
  "axios",
50
56
  "ky",
51
- "got"
57
+ "got",
58
+ "ofetch",
59
+ "wretch",
60
+ "request"
52
61
  ]);
53
62
  const INDEX_PARAMETER_NAMES = new Set([
54
63
  "index",
@@ -161,6 +170,7 @@ const SECRET_FALSE_POSITIVE_SUFFIXES = new Set([
161
170
  "schema",
162
171
  "constant"
163
172
  ]);
173
+ const LOADING_STATE_PATTERN = /^(?:isLoading|isPending)$/;
164
174
  const TANSTACK_ROUTE_FILE_PATTERN = /\/routes\//;
165
175
  const TANSTACK_ROOT_ROUTE_FILE_PATTERN = /__root\.(tsx?|jsx?)$/;
166
176
  const TANSTACK_ROUTE_PROPERTY_ORDER = [
@@ -203,12 +213,29 @@ const TANSTACK_QUERY_HOOKS = new Set([
203
213
  "useSuspenseInfiniteQuery"
204
214
  ]);
205
215
  const TANSTACK_MUTATION_HOOKS = new Set(["useMutation"]);
216
+ const QUERY_CACHE_UPDATE_METHODS = new Set([
217
+ "invalidateQueries",
218
+ "setQueryData",
219
+ "setQueriesData",
220
+ "resetQueries",
221
+ "refetchQueries",
222
+ "removeQueries",
223
+ "cancelQueries",
224
+ "clear"
225
+ ]);
206
226
  const STABLE_HOOK_WRAPPERS = new Set([
207
227
  "useState",
208
228
  "useMemo",
209
229
  "useRef"
210
230
  ]);
211
231
  const SCRIPT_LOADING_ATTRIBUTES = new Set(["defer", "async"]);
232
+ const GENERIC_EVENT_SUFFIXES = new Set([
233
+ "Click",
234
+ "Change",
235
+ "Input",
236
+ "Blur",
237
+ "Focus"
238
+ ]);
212
239
  const TRIVIAL_INITIALIZER_NAMES = new Set([
213
240
  "Boolean",
214
241
  "String",
@@ -286,7 +313,7 @@ const CHAINABLE_ITERATION_METHODS = new Set([
286
313
  "forEach",
287
314
  "flatMap"
288
315
  ]);
289
- const STORAGE_OBJECTS = new Set(["localStorage", "sessionStorage"]);
316
+ const STORAGE_OBJECTS$1 = new Set(["localStorage", "sessionStorage"]);
290
317
  const BLUR_VALUE_PATTERN = /blur\((\d+(?:\.\d+)?)px\)/;
291
318
  const ANIMATION_CALLBACK_NAMES = new Set(["requestAnimationFrame", "setInterval"]);
292
319
  const REACT_NATIVE_TEXT_COMPONENTS = new Set([
@@ -362,7 +389,7 @@ const LONG_TRANSITION_DURATION_THRESHOLD_MS = 1e3;
362
389
  //#region src/plugin/helpers.ts
363
390
  const walkAst = (node, visitor) => {
364
391
  if (!node || typeof node !== "object") return;
365
- visitor(node);
392
+ if (visitor(node) === false) return;
366
393
  for (const key of Object.keys(node)) {
367
394
  if (key === "parent") continue;
368
395
  const child = node[key];
@@ -464,12 +491,13 @@ const isMutatingDbCall = (node) => {
464
491
  const { property } = node.callee;
465
492
  return property?.type === "Identifier" && MUTATION_METHOD_NAMES.has(property.name);
466
493
  };
494
+ const isMutatingMethodProperty = (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());
467
495
  const isMutatingFetchCall = (node) => {
468
496
  if (node.type !== "CallExpression") return false;
469
497
  if (node.callee?.type !== "Identifier" || node.callee.name !== "fetch") return false;
470
498
  const optionsArgument = node.arguments?.[1];
471
499
  if (!optionsArgument || optionsArgument.type !== "ObjectExpression") return false;
472
- return optionsArgument.properties?.some((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()));
500
+ return Boolean(optionsArgument.properties?.some(isMutatingMethodProperty));
473
501
  };
474
502
  const findSideEffect = (node) => {
475
503
  let sideEffectDescription = null;
@@ -477,7 +505,7 @@ const findSideEffect = (node) => {
477
505
  if (sideEffectDescription) return;
478
506
  if (isCookiesOrHeadersCall(child, "cookies")) sideEffectDescription = `cookies().${child.callee.property.name}()`;
479
507
  else if (isCookiesOrHeadersCall(child, "headers")) sideEffectDescription = `headers().${child.callee.property.name}()`;
480
- else if (isMutatingFetchCall(child)) sideEffectDescription = `fetch() with method ${child.arguments[1].properties.find((property) => property.key?.type === "Identifier" && property.key.name === "method").value.value}`;
508
+ else if (isMutatingFetchCall(child)) sideEffectDescription = `fetch() with method ${child.arguments[1].properties.find(isMutatingMethodProperty).value.value}`;
481
509
  else if (isMutatingDbCall(child)) {
482
510
  const methodName = child.callee.property.name;
483
511
  const objectName = child.callee.object?.type === "Identifier" ? child.callee.object.name : null;
@@ -486,15 +514,51 @@ const findSideEffect = (node) => {
486
514
  });
487
515
  return sideEffectDescription;
488
516
  };
517
+ const collectPatternNames = (pattern, into) => {
518
+ if (!pattern) return;
519
+ if (pattern.type === "Identifier") {
520
+ into.add(pattern.name);
521
+ return;
522
+ }
523
+ if (pattern.type === "AssignmentPattern") {
524
+ collectPatternNames(pattern.left, into);
525
+ return;
526
+ }
527
+ if (pattern.type === "RestElement") {
528
+ collectPatternNames(pattern.argument, into);
529
+ return;
530
+ }
531
+ if (pattern.type === "ArrayPattern") {
532
+ for (const element of pattern.elements ?? []) collectPatternNames(element, into);
533
+ return;
534
+ }
535
+ if (pattern.type === "ObjectPattern") for (const property of pattern.properties ?? []) {
536
+ if (property.type === "RestElement") {
537
+ collectPatternNames(property.argument, into);
538
+ continue;
539
+ }
540
+ if (property.type === "Property") collectPatternNames(property.value, into);
541
+ }
542
+ };
489
543
  const extractDestructuredPropNames = (params) => {
490
544
  const propNames = /* @__PURE__ */ new Set();
491
- for (const param of params) if (param.type === "ObjectPattern") {
492
- for (const property of param.properties ?? []) if (property.type === "Property" && property.key?.type === "Identifier") propNames.add(property.key.name);
493
- } else if (param.type === "Identifier") propNames.add(param.name);
545
+ for (const param of params) collectPatternNames(param, propNames);
494
546
  return propNames;
495
547
  };
496
548
  //#endregion
497
549
  //#region src/plugin/rules/architecture.ts
550
+ const noGenericHandlerNames = { create: (context) => ({ JSXAttribute(node) {
551
+ if (node.name?.type !== "JSXIdentifier" || !node.name.name.startsWith("on")) return;
552
+ if (!node.value || node.value.type !== "JSXExpressionContainer") return;
553
+ const eventSuffix = node.name.name.slice(2);
554
+ if (!GENERIC_EVENT_SUFFIXES.has(eventSuffix)) return;
555
+ const mirroredHandlerName = `handle${eventSuffix}`;
556
+ const expression = node.value.expression;
557
+ if (expression?.type === "Identifier" && expression.name === mirroredHandlerName) context.report({
558
+ node,
559
+ message: `Non-descriptive handler name "${expression.name}" — name should describe what it does, not when it runs`
560
+ });
561
+ } }) };
498
562
  const noGiantComponent = { create: (context) => {
499
563
  const reportOversizedComponent = (nameNode, componentName, bodyNode) => {
500
564
  if (!bodyNode.loc) return;
@@ -553,6 +617,193 @@ const noNestedComponentDefinition = { create: (context) => {
553
617
  }
554
618
  };
555
619
  } };
620
+ const BOOLEAN_PROP_PREFIX_PATTERN = /^(?:is|has|should|can|show|hide|enable|disable|with)[A-Z]/;
621
+ const collectBooleanLikePropsFromBody = (componentBody, propsParamName) => {
622
+ const found = /* @__PURE__ */ new Set();
623
+ if (!componentBody) return found;
624
+ walkAst(componentBody, (child) => {
625
+ if (child.type !== "MemberExpression") return;
626
+ if (child.computed) return;
627
+ if (child.object?.type !== "Identifier") return;
628
+ if (child.object.name !== propsParamName) return;
629
+ if (child.property?.type !== "Identifier") return;
630
+ if (!BOOLEAN_PROP_PREFIX_PATTERN.test(child.property.name)) return;
631
+ found.add(child.property.name);
632
+ });
633
+ return found;
634
+ };
635
+ const noManyBooleanProps = { create: (context) => {
636
+ const reportIfMany = (booleanLikePropNames, componentName, reportNode) => {
637
+ if (booleanLikePropNames.length >= 4) context.report({
638
+ node: reportNode,
639
+ message: `Component "${componentName}" takes ${booleanLikePropNames.length} boolean-like props (${booleanLikePropNames.slice(0, 3).join(", ")}…) — consider compound components or explicit variants instead of stacking flags`
640
+ });
641
+ };
642
+ const checkComponent = (param, body, componentName, reportNode) => {
643
+ if (!param) return;
644
+ if (param.type === "ObjectPattern") {
645
+ const booleanLikePropNames = [];
646
+ for (const property of param.properties ?? []) {
647
+ if (property.type !== "Property") continue;
648
+ const keyName = property.key?.type === "Identifier" ? property.key.name : null;
649
+ if (!keyName) continue;
650
+ if (BOOLEAN_PROP_PREFIX_PATTERN.test(keyName)) booleanLikePropNames.push(keyName);
651
+ }
652
+ reportIfMany(booleanLikePropNames, componentName, reportNode);
653
+ return;
654
+ }
655
+ if (param.type === "Identifier") reportIfMany([...collectBooleanLikePropsFromBody(body, param.name)], componentName, reportNode);
656
+ };
657
+ return {
658
+ FunctionDeclaration(node) {
659
+ if (!isComponentDeclaration(node)) return;
660
+ checkComponent(node.params?.[0], node.body, node.id.name, node.id);
661
+ },
662
+ VariableDeclarator(node) {
663
+ if (!isComponentAssignment(node)) return;
664
+ checkComponent(node.init?.params?.[0], node.init?.body, node.id.name, node.id);
665
+ }
666
+ };
667
+ } };
668
+ const REACT_19_DEPRECATED_MESSAGES = {
669
+ forwardRef: "forwardRef is no longer needed on React 19+ — refs are regular props on function components; remove forwardRef and pass ref directly",
670
+ useContext: "useContext is superseded by `use()` on React 19+ — `use()` reads context conditionally inside hooks, branches, and loops; switch to `import { use } from 'react'`"
671
+ };
672
+ const noReact19DeprecatedApis = { create: (context) => {
673
+ const reactNamespaceBindings = /* @__PURE__ */ new Set();
674
+ return {
675
+ ImportDeclaration(node) {
676
+ if (node.source?.value !== "react") return;
677
+ for (const specifier of node.specifiers ?? []) {
678
+ if (specifier.type === "ImportSpecifier") {
679
+ const importedName = specifier.imported?.name;
680
+ if (!importedName) continue;
681
+ const message = REACT_19_DEPRECATED_MESSAGES[importedName];
682
+ if (message) context.report({
683
+ node: specifier,
684
+ message
685
+ });
686
+ continue;
687
+ }
688
+ if (specifier.type === "ImportDefaultSpecifier" || specifier.type === "ImportNamespaceSpecifier") {
689
+ const localName = specifier.local?.name;
690
+ if (localName) reactNamespaceBindings.add(localName);
691
+ }
692
+ }
693
+ },
694
+ MemberExpression(node) {
695
+ if (reactNamespaceBindings.size === 0) return;
696
+ if (node.computed) return;
697
+ if (node.object?.type !== "Identifier") return;
698
+ if (!reactNamespaceBindings.has(node.object.name)) return;
699
+ if (node.property?.type !== "Identifier") return;
700
+ const message = REACT_19_DEPRECATED_MESSAGES[node.property.name];
701
+ if (message) context.report({
702
+ node,
703
+ message
704
+ });
705
+ }
706
+ };
707
+ } };
708
+ const RENDER_PROP_PATTERN = /^render[A-Z]/;
709
+ const noRenderPropChildren = { create: (context) => ({ JSXOpeningElement(node) {
710
+ const renderPropAttrs = [];
711
+ for (const attr of node.attributes ?? []) {
712
+ if (attr.type !== "JSXAttribute") continue;
713
+ if (attr.name?.type !== "JSXIdentifier") continue;
714
+ const name = attr.name.name;
715
+ if (!RENDER_PROP_PATTERN.test(name)) continue;
716
+ renderPropAttrs.push({
717
+ name,
718
+ node: attr
719
+ });
720
+ }
721
+ if (renderPropAttrs.length < 3) return;
722
+ const propList = renderPropAttrs.slice(0, 3).map((entry) => entry.name).join(", ");
723
+ context.report({
724
+ node: renderPropAttrs[0].node,
725
+ message: `${renderPropAttrs.length} render-prop slots on the same element (${propList}…) — collapse into compound subcomponents or \`children\` so consumers don't need to know about every customization point`
726
+ });
727
+ } }) };
728
+ const HOOK_OBJECTS_WITH_METHODS = new Map([
729
+ ["useRouter", new Set([
730
+ "push",
731
+ "replace",
732
+ "back",
733
+ "forward",
734
+ "refresh",
735
+ "prefetch"
736
+ ])],
737
+ ["useNavigation", new Set([
738
+ "navigate",
739
+ "push",
740
+ "goBack",
741
+ "popToTop",
742
+ "reset",
743
+ "replace",
744
+ "dispatch"
745
+ ])],
746
+ ["useSearchParams", new Set([
747
+ "get",
748
+ "getAll",
749
+ "has",
750
+ "set"
751
+ ])]
752
+ ]);
753
+ const buildHookBindingMap = (componentBody) => {
754
+ const result = /* @__PURE__ */ new Map();
755
+ if (componentBody?.type !== "BlockStatement") return result;
756
+ for (const statement of componentBody.body ?? []) {
757
+ if (statement.type !== "VariableDeclaration") continue;
758
+ for (const declarator of statement.declarations ?? []) {
759
+ if (declarator.id?.type !== "Identifier") continue;
760
+ if (declarator.init?.type !== "CallExpression") continue;
761
+ const callee = declarator.init.callee;
762
+ if (callee?.type !== "Identifier") continue;
763
+ result.set(declarator.id.name, callee.name);
764
+ }
765
+ }
766
+ return result;
767
+ };
768
+ const reactCompilerDestructureMethod = { create: (context) => {
769
+ const hookBindingMapStack = [];
770
+ const isComponent = (node) => {
771
+ if (node.type === "FunctionDeclaration") return Boolean(node.id?.name && isUppercaseName(node.id.name));
772
+ if (node.type === "VariableDeclarator") return isComponentAssignment(node);
773
+ return false;
774
+ };
775
+ const enter = (node) => {
776
+ if (!isComponent(node)) return;
777
+ const body = node.type === "FunctionDeclaration" ? node.body : node.init?.body;
778
+ hookBindingMapStack.push(buildHookBindingMap(body));
779
+ };
780
+ const exit = (node) => {
781
+ if (isComponent(node)) hookBindingMapStack.pop();
782
+ };
783
+ return {
784
+ FunctionDeclaration: enter,
785
+ "FunctionDeclaration:exit": exit,
786
+ VariableDeclarator: enter,
787
+ "VariableDeclarator:exit": exit,
788
+ MemberExpression(node) {
789
+ if (hookBindingMapStack.length === 0) return;
790
+ if (node.computed) return;
791
+ if (node.object?.type !== "Identifier") return;
792
+ if (node.property?.type !== "Identifier") return;
793
+ const bindingName = node.object.name;
794
+ const methodName = node.property.name;
795
+ const hookSource = hookBindingMapStack[hookBindingMapStack.length - 1].get(bindingName);
796
+ if (!hookSource) return;
797
+ const allowedMethods = HOOK_OBJECTS_WITH_METHODS.get(hookSource);
798
+ if (!allowedMethods || !allowedMethods.has(methodName)) return;
799
+ if (node.parent?.type !== "CallExpression" || node.parent.callee !== node) return;
800
+ context.report({
801
+ node,
802
+ message: `Destructure for clarity: \`const { ${methodName} } = ${hookSource}()\` then call \`${methodName}(...)\` directly — easier for React Compiler to memoize and clearer about which methods this component depends on`
803
+ });
804
+ }
805
+ };
806
+ } };
556
807
  //#endregion
557
808
  //#region src/plugin/rules/bundle-size.ts
558
809
  const noBarrelImport = { create: (context) => {
@@ -598,6 +849,38 @@ const useLazyMotion = { create: (context) => ({ ImportDeclaration(node) {
598
849
  message: "Import \"m\" with LazyMotion instead of \"motion\" — saves ~30kb in bundle size"
599
850
  });
600
851
  } }) };
852
+ const noDynamicImportPath = { create: (context) => ({
853
+ ImportExpression(node) {
854
+ const source = node.source;
855
+ if (source && source.type !== "Literal" && source.type !== "TemplateLiteral") {
856
+ context.report({
857
+ node,
858
+ message: "Dynamic import path is not statically analyzable — use a string literal so the bundler can split this chunk"
859
+ });
860
+ return;
861
+ }
862
+ if (source?.type === "TemplateLiteral" && (source.expressions?.length ?? 0) > 0) context.report({
863
+ node,
864
+ message: "Template literal with interpolation in dynamic import — use a string literal so the bundler can split this chunk"
865
+ });
866
+ },
867
+ CallExpression(node) {
868
+ if (node.callee?.type !== "Identifier" || node.callee.name !== "require") return;
869
+ const arg = node.arguments?.[0];
870
+ if (!arg) return;
871
+ if (arg.type !== "Literal" && arg.type !== "TemplateLiteral") {
872
+ context.report({
873
+ node,
874
+ message: "Dynamic require() path is not statically analyzable — use a string literal so the bundler can trace this dependency"
875
+ });
876
+ return;
877
+ }
878
+ if (arg.type === "TemplateLiteral" && (arg.expressions?.length ?? 0) > 0) context.report({
879
+ node,
880
+ message: "Template literal with interpolation in require() — use a string literal so the bundler can trace this dependency"
881
+ });
882
+ }
883
+ }) };
601
884
  const noUndeferredThirdParty = { create: (context) => ({ JSXOpeningElement(node) {
602
885
  if (node.name?.type !== "JSXIdentifier" || node.name.name !== "script") return;
603
886
  const attributes = node.attributes ?? [];
@@ -611,7 +894,7 @@ const noUndeferredThirdParty = { create: (context) => ({ JSXOpeningElement(node)
611
894
  //#region src/plugin/rules/client.ts
612
895
  const clientPassiveEventListeners = { create: (context) => ({ CallExpression(node) {
613
896
  if (!isMemberProperty(node.callee, "addEventListener")) return;
614
- if (node.arguments?.length < 2) return;
897
+ if ((node.arguments?.length ?? 0) < 2) return;
615
898
  const eventNameNode = node.arguments[0];
616
899
  if (eventNameNode.type !== "Literal" || !PASSIVE_EVENT_NAMES.has(eventNameNode.value)) return;
617
900
  const eventName = eventNameNode.value;
@@ -619,14 +902,43 @@ const clientPassiveEventListeners = { create: (context) => ({ CallExpression(nod
619
902
  if (!optionsArgument) {
620
903
  context.report({
621
904
  node,
622
- message: `"${eventName}" listener without { passive: true } — blocks scrolling performance`
905
+ message: `"${eventName}" listener without { passive: true } — blocks scrolling performance. Only add { passive: true } if the handler does NOT call event.preventDefault() (passive listeners silently ignore preventDefault())`
623
906
  });
624
907
  return;
625
908
  }
626
909
  if (optionsArgument.type !== "ObjectExpression") return;
627
910
  if (!optionsArgument.properties?.some((property) => property.type === "Property" && property.key?.type === "Identifier" && property.key.name === "passive" && property.value?.type === "Literal" && property.value.value === true)) context.report({
628
911
  node,
629
- message: `"${eventName}" listener without { passive: true } — blocks scrolling performance`
912
+ message: `"${eventName}" listener without { passive: true } — blocks scrolling performance. Only add { passive: true } if the handler does NOT call event.preventDefault() (passive listeners silently ignore preventDefault())`
913
+ });
914
+ } }) };
915
+ const VERSIONED_KEY_PATTERN = /(?:[._:-]v\d+|@\d+|\bv\d+\b)/i;
916
+ const STORAGE_OBJECTS = new Set(["localStorage", "sessionStorage"]);
917
+ const isJsonStringifyCall = (node) => {
918
+ if (node.type !== "CallExpression") return false;
919
+ if (node.callee?.type !== "MemberExpression") return false;
920
+ if (node.callee.object?.type !== "Identifier") return false;
921
+ if (node.callee.object.name !== "JSON") return false;
922
+ if (node.callee.property?.type !== "Identifier") return false;
923
+ return node.callee.property.name === "stringify";
924
+ };
925
+ const clientLocalstorageNoVersion = { create: (context) => ({ CallExpression(node) {
926
+ if (node.callee?.type !== "MemberExpression") return;
927
+ if (node.callee.object?.type !== "Identifier") return;
928
+ if (!STORAGE_OBJECTS.has(node.callee.object.name)) return;
929
+ if (node.callee.property?.type !== "Identifier") return;
930
+ if (node.callee.property.name !== "setItem") return;
931
+ const keyArg = node.arguments?.[0];
932
+ if (!keyArg) return;
933
+ if (keyArg.type !== "Literal") return;
934
+ if (typeof keyArg.value !== "string") return;
935
+ if (VERSIONED_KEY_PATTERN.test(keyArg.value)) return;
936
+ const valueArg = node.arguments?.[1];
937
+ if (!valueArg) return;
938
+ if (!isJsonStringifyCall(valueArg)) return;
939
+ context.report({
940
+ node: keyArg,
941
+ message: `${node.callee.object.name}.setItem("${keyArg.value}", JSON.stringify(...)) — bake a version into the key (e.g. "${keyArg.value}:v1") so a future schema change can ignore old data instead of crashing on it`
630
942
  });
631
943
  } }) };
632
944
  //#endregion
@@ -675,12 +987,24 @@ const getStylePropertyKey = (property) => {
675
987
  };
676
988
  const parseColorToRgb = (value) => {
677
989
  const trimmed = value.trim().toLowerCase();
990
+ const hex8Match = trimmed.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})[0-9a-f]{2}$/);
991
+ if (hex8Match) return {
992
+ red: parseInt(hex8Match[1], 16),
993
+ green: parseInt(hex8Match[2], 16),
994
+ blue: parseInt(hex8Match[3], 16)
995
+ };
678
996
  const hex6Match = trimmed.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
679
997
  if (hex6Match) return {
680
998
  red: parseInt(hex6Match[1], 16),
681
999
  green: parseInt(hex6Match[2], 16),
682
1000
  blue: parseInt(hex6Match[3], 16)
683
1001
  };
1002
+ const hex4Match = trimmed.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])[0-9a-f]$/);
1003
+ if (hex4Match) return {
1004
+ red: parseInt(hex4Match[1] + hex4Match[1], 16),
1005
+ green: parseInt(hex4Match[2] + hex4Match[2], 16),
1006
+ blue: parseInt(hex4Match[3] + hex4Match[3], 16)
1007
+ };
684
1008
  const hex3Match = trimmed.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
685
1009
  if (hex3Match) return {
686
1010
  red: parseInt(hex3Match[1] + hex3Match[1], 16),
@@ -1145,6 +1469,7 @@ const noLongTransitionDuration = { create: (context) => ({ JSXAttribute(node) {
1145
1469
  } }) };
1146
1470
  //#endregion
1147
1471
  //#region src/plugin/rules/correctness.ts
1472
+ const STRING_COERCION_FUNCTIONS = new Set(["String", "Number"]);
1148
1473
  const extractIndexName = (node) => {
1149
1474
  if (node.type === "Identifier" && INDEX_PARAMETER_NAMES.has(node.name)) return node.name;
1150
1475
  if (node.type === "TemplateLiteral") {
@@ -1152,7 +1477,8 @@ const extractIndexName = (node) => {
1152
1477
  if (indexExpression) return indexExpression.name;
1153
1478
  }
1154
1479
  if (node.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.object?.type === "Identifier" && INDEX_PARAMETER_NAMES.has(node.callee.object.name) && node.callee.property?.type === "Identifier" && node.callee.property.name === "toString") return node.callee.object.name;
1155
- if (node.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "String" && node.arguments?.[0]?.type === "Identifier" && INDEX_PARAMETER_NAMES.has(node.arguments[0].name)) return node.arguments[0].name;
1480
+ if (node.type === "CallExpression" && node.callee?.type === "Identifier" && STRING_COERCION_FUNCTIONS.has(node.callee.name) && node.arguments?.[0]?.type === "Identifier" && INDEX_PARAMETER_NAMES.has(node.arguments[0].name)) return node.arguments[0].name;
1481
+ if (node.type === "BinaryExpression" && node.operator === "+" && (node.left?.type === "Identifier" && INDEX_PARAMETER_NAMES.has(node.left.name) && node.right?.type === "Literal" && node.right.value === "" || node.right?.type === "Identifier" && INDEX_PARAMETER_NAMES.has(node.right.name) && node.left?.type === "Literal" && node.left.value === "")) return node.left?.type === "Identifier" ? node.left.name : node.right.name;
1156
1482
  return null;
1157
1483
  };
1158
1484
  const isInsideStaticPlaceholderMap = (node) => {
@@ -1182,8 +1508,8 @@ const noArrayIndexAsKey = { create: (context) => ({ JSXAttribute(node) {
1182
1508
  });
1183
1509
  } }) };
1184
1510
  const PREVENT_DEFAULT_ELEMENTS = {
1185
- form: "onSubmit",
1186
- a: "onClick"
1511
+ form: ["onSubmit"],
1512
+ a: ["onClick"]
1187
1513
  };
1188
1514
  const containsPreventDefaultCall = (node) => {
1189
1515
  let didFindPreventDefault = false;
@@ -1193,28 +1519,84 @@ const containsPreventDefaultCall = (node) => {
1193
1519
  });
1194
1520
  return didFindPreventDefault;
1195
1521
  };
1522
+ const buildPreventDefaultMessage = (elementName) => {
1523
+ if (elementName === "form") return "preventDefault() on <form> onSubmit — form won't work without JavaScript. Consider using a server action for progressive enhancement";
1524
+ return "preventDefault() on <a> onClick — use a <button> or routing component instead";
1525
+ };
1196
1526
  const noPreventDefault = { create: (context) => ({ JSXOpeningElement(node) {
1197
1527
  const elementName = node.name?.type === "JSXIdentifier" ? node.name.name : null;
1198
1528
  if (!elementName) return;
1199
- const targetEventProp = PREVENT_DEFAULT_ELEMENTS[elementName];
1200
- if (!targetEventProp) return;
1201
- const eventAttribute = findJsxAttribute(node.attributes ?? [], targetEventProp);
1202
- if (!eventAttribute?.value || eventAttribute.value.type !== "JSXExpressionContainer") return;
1203
- const expression = eventAttribute.value.expression;
1204
- if (expression?.type !== "ArrowFunctionExpression" && expression?.type !== "FunctionExpression") return;
1205
- if (!containsPreventDefaultCall(expression)) return;
1206
- const message = elementName === "form" ? "preventDefault() on <form> onSubmit — form won't work without JavaScript. Consider using a server action for progressive enhancement" : "preventDefault() on <a> onClick — use a <button> or routing component instead";
1207
- context.report({
1208
- node,
1209
- message
1210
- });
1529
+ const targetEventProps = PREVENT_DEFAULT_ELEMENTS[elementName];
1530
+ if (!targetEventProps) return;
1531
+ for (const targetEventProp of targetEventProps) {
1532
+ const eventAttribute = findJsxAttribute(node.attributes ?? [], targetEventProp);
1533
+ if (!eventAttribute?.value || eventAttribute.value.type !== "JSXExpressionContainer") continue;
1534
+ const expression = eventAttribute.value.expression;
1535
+ if (expression?.type !== "ArrowFunctionExpression" && expression?.type !== "FunctionExpression") continue;
1536
+ if (!containsPreventDefaultCall(expression)) continue;
1537
+ context.report({
1538
+ node,
1539
+ message: buildPreventDefaultMessage(elementName)
1540
+ });
1541
+ return;
1542
+ }
1211
1543
  } }) };
1544
+ const NUMERIC_NAME_HINTS = [
1545
+ "count",
1546
+ "length",
1547
+ "total",
1548
+ "size",
1549
+ "num"
1550
+ ];
1551
+ const isNumericName = (name) => {
1552
+ for (const hint of NUMERIC_NAME_HINTS) {
1553
+ if (name === hint) return true;
1554
+ const camelSuffix = hint.charAt(0).toUpperCase() + hint.slice(1);
1555
+ if (name.endsWith(camelSuffix)) return true;
1556
+ if (name.endsWith(`_${hint}`)) return true;
1557
+ if (name.endsWith(`_${hint.toUpperCase()}`)) return true;
1558
+ }
1559
+ return false;
1560
+ };
1212
1561
  const renderingConditionalRender = { create: (context) => ({ LogicalExpression(node) {
1213
1562
  if (node.operator !== "&&") return;
1214
1563
  if (!(node.right?.type === "JSXElement" || node.right?.type === "JSXFragment")) return;
1215
- if (node.left?.type === "MemberExpression" && node.left.property?.type === "Identifier" && node.left.property.name === "length") context.report({
1564
+ const left = node.left;
1565
+ if (!left) return;
1566
+ const isLengthMemberAccess = left.type === "MemberExpression" && left.property?.type === "Identifier" && left.property.name === "length";
1567
+ const isNumericIdentifier = left.type === "Identifier" && isNumericName(left.name);
1568
+ if (isLengthMemberAccess || isNumericIdentifier) context.report({
1569
+ node,
1570
+ message: "Conditional rendering with a numeric value can render '0' — use `value > 0`, `Boolean(value)`, or a ternary"
1571
+ });
1572
+ } }) };
1573
+ const noPolymorphicChildren = { create: (context) => ({ BinaryExpression(node) {
1574
+ if (node.operator !== "===" && node.operator !== "==") return;
1575
+ const isTypeofChildren = (operand) => operand?.type === "UnaryExpression" && operand.operator === "typeof" && operand.argument?.type === "Identifier" && operand.argument.name === "children";
1576
+ if (!isTypeofChildren(node.left) && !isTypeofChildren(node.right)) return;
1577
+ const isStringLiteral = (operand) => operand?.type === "Literal" && operand.value === "string";
1578
+ if (!isStringLiteral(node.left) && !isStringLiteral(node.right)) return;
1579
+ context.report({
1580
+ node,
1581
+ message: "Polymorphic `typeof children === \"string\"` check — expose explicit subcomponents (e.g. `<Button.Text>`) instead of branching on what the consumer passed"
1582
+ });
1583
+ } }) };
1584
+ const SVG_PATH_HIGH_PRECISION_PATTERN = /\d+\.\d{4,}/;
1585
+ const SVG_PATH_ATTRIBUTES = new Set([
1586
+ "d",
1587
+ "points",
1588
+ "transform"
1589
+ ]);
1590
+ const renderingSvgPrecision = { create: (context) => ({ JSXAttribute(node) {
1591
+ if (node.name?.type !== "JSXIdentifier") return;
1592
+ if (!SVG_PATH_ATTRIBUTES.has(node.name.name)) return;
1593
+ if (node.value?.type !== "Literal") return;
1594
+ const value = node.value.value;
1595
+ if (typeof value !== "string") return;
1596
+ if (!SVG_PATH_HIGH_PRECISION_PATTERN.test(value)) return;
1597
+ context.report({
1216
1598
  node,
1217
- message: "Conditional rendering with .length can render '0'use .length > 0 or Boolean(.length)"
1599
+ message: `SVG ${node.name.name} attribute uses 4+ decimal precisiontruncate to 1–2 decimals to shrink markup with no visible difference`
1218
1600
  });
1219
1601
  } }) };
1220
1602
  //#endregion
@@ -1294,7 +1676,7 @@ const jsCacheStorage = { create: (context) => {
1294
1676
  const storageReadCounts = /* @__PURE__ */ new Map();
1295
1677
  return { CallExpression(node) {
1296
1678
  if (!isMemberProperty(node.callee, "getItem")) return;
1297
- if (node.callee.object?.type !== "Identifier" || !STORAGE_OBJECTS.has(node.callee.object.name)) return;
1679
+ if (node.callee.object?.type !== "Identifier" || !STORAGE_OBJECTS$1.has(node.callee.object.name)) return;
1298
1680
  if (node.arguments?.[0]?.type !== "Literal") return;
1299
1681
  const storageKey = String(node.arguments[0].value);
1300
1682
  const readCount = (storageReadCounts.get(storageKey) ?? 0) + 1;
@@ -1371,6 +1753,193 @@ const jsFlatmapFilter = { create: (context) => ({ CallExpression(node) {
1371
1753
  message: ".map().filter(Boolean) iterates twice — use .flatMap() to transform and filter in a single pass"
1372
1754
  });
1373
1755
  } }) };
1756
+ const buildMemberAccessKey = (node) => {
1757
+ if (node.type === "Identifier") return node.name;
1758
+ if (node.type === "ThisExpression") return "this";
1759
+ if (node.type !== "MemberExpression" || node.computed) return null;
1760
+ const objectKey = buildMemberAccessKey(node.object);
1761
+ if (!objectKey) return null;
1762
+ if (node.property?.type !== "Identifier") return null;
1763
+ return `${objectKey}.${node.property.name}`;
1764
+ };
1765
+ const jsCachePropertyAccess = { create: (context) => {
1766
+ const inspectLoopBody = (loopBody) => {
1767
+ const counts = /* @__PURE__ */ new Map();
1768
+ walkAst(loopBody, (child) => {
1769
+ if (child.type !== "MemberExpression") return;
1770
+ if (child.computed) return;
1771
+ if (child.parent?.type === "MemberExpression" && child.parent.object === child) return;
1772
+ const key = buildMemberAccessKey(child);
1773
+ if (!key) return;
1774
+ if (key.split(".").length < 3) return;
1775
+ const existing = counts.get(key);
1776
+ if (existing) existing.count++;
1777
+ else counts.set(key, {
1778
+ count: 1,
1779
+ firstNode: child
1780
+ });
1781
+ });
1782
+ for (const [key, { count, firstNode }] of counts) if (count >= 3) context.report({
1783
+ node: firstNode,
1784
+ message: `${key} is read ${count} times inside this loop — hoist into a const at the top of the loop body`
1785
+ });
1786
+ };
1787
+ const handleLoop = (node) => {
1788
+ if (node.body) inspectLoopBody(node.body);
1789
+ };
1790
+ return {
1791
+ ForStatement: handleLoop,
1792
+ ForInStatement: handleLoop,
1793
+ ForOfStatement: handleLoop,
1794
+ WhileStatement: handleLoop,
1795
+ DoWhileStatement: handleLoop
1796
+ };
1797
+ } };
1798
+ const jsLengthCheckFirst = { create: (context) => ({ CallExpression(node) {
1799
+ if (node.callee?.type !== "MemberExpression") return;
1800
+ if (node.callee.property?.type !== "Identifier") return;
1801
+ if (node.callee.property.name !== "every") return;
1802
+ const callback = node.arguments?.[0];
1803
+ if (callback?.type !== "ArrowFunctionExpression" && callback?.type !== "FunctionExpression") return;
1804
+ const params = callback.params ?? [];
1805
+ if (params.length < 2) return;
1806
+ let referencesOtherArrayByIndex = false;
1807
+ walkAst(callback.body, (child) => {
1808
+ if (referencesOtherArrayByIndex) return;
1809
+ if (child.type === "MemberExpression" && child.computed && child.property?.type === "Identifier" && params[1]?.type === "Identifier" && child.property.name === params[1].name) referencesOtherArrayByIndex = true;
1810
+ });
1811
+ if (!referencesOtherArrayByIndex) return;
1812
+ let guard = node.parent ?? null;
1813
+ while (guard && guard.type !== "LogicalExpression" && guard.type !== "IfStatement") guard = guard.parent ?? null;
1814
+ if (guard?.type === "LogicalExpression" && guard.operator === "&&") {
1815
+ const left = guard.left;
1816
+ if (left?.type === "BinaryExpression" && left.operator === "===" && (isMemberProperty(left.left, "length") || isMemberProperty(left.right, "length"))) return;
1817
+ }
1818
+ context.report({
1819
+ node,
1820
+ message: ".every() over an array compared to another array — short-circuit with `a.length === b.length && a.every(...)` so unequal-length arrays exit immediately"
1821
+ });
1822
+ } }) };
1823
+ const INTL_CLASSES = new Set([
1824
+ "NumberFormat",
1825
+ "DateTimeFormat",
1826
+ "Collator",
1827
+ "RelativeTimeFormat",
1828
+ "ListFormat",
1829
+ "PluralRules",
1830
+ "Segmenter",
1831
+ "DisplayNames"
1832
+ ]);
1833
+ const isIntlNewExpression = (node) => {
1834
+ if (node.type !== "NewExpression") return false;
1835
+ const callee = node.callee;
1836
+ if (callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && callee.object.name === "Intl" && callee.property?.type === "Identifier" && INTL_CLASSES.has(callee.property.name)) return true;
1837
+ return false;
1838
+ };
1839
+ const jsHoistIntl = { create: (context) => ({ NewExpression(node) {
1840
+ if (!isIntlNewExpression(node)) return;
1841
+ let cursor = node.parent ?? null;
1842
+ let inFunctionBody = false;
1843
+ while (cursor) {
1844
+ if (cursor.type === "FunctionDeclaration" || cursor.type === "FunctionExpression" || cursor.type === "ArrowFunctionExpression") {
1845
+ inFunctionBody = true;
1846
+ break;
1847
+ }
1848
+ cursor = cursor.parent ?? null;
1849
+ }
1850
+ if (!inFunctionBody) return;
1851
+ const className = node.callee.property?.name ?? "Intl";
1852
+ context.report({
1853
+ node,
1854
+ message: `new Intl.${className}() inside a function — hoist to module scope or wrap in useMemo so it isn't recreated each call`
1855
+ });
1856
+ } }) };
1857
+ const findFirstAwaitOutsideNestedFunctions = (block) => {
1858
+ let firstAwait = null;
1859
+ walkAst(block, (child) => {
1860
+ if (firstAwait) return false;
1861
+ if (child !== block && (child.type === "FunctionDeclaration" || child.type === "FunctionExpression" || child.type === "ArrowFunctionExpression")) return false;
1862
+ if (child.type === "AwaitExpression") firstAwait = child;
1863
+ });
1864
+ return firstAwait;
1865
+ };
1866
+ const isFunctionishExpression = (node) => node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression";
1867
+ const ITERATION_METHOD_NAMES_WITH_CALLBACK = new Set([
1868
+ "forEach",
1869
+ "map",
1870
+ "filter",
1871
+ "reduce",
1872
+ "reduceRight",
1873
+ "find",
1874
+ "findIndex",
1875
+ "some",
1876
+ "every",
1877
+ "flatMap"
1878
+ ]);
1879
+ const PROMISE_CONCURRENCY_METHODS = new Set([
1880
+ "all",
1881
+ "allSettled",
1882
+ "race",
1883
+ "any"
1884
+ ]);
1885
+ const isWrappedInPromiseConcurrency = (mapCall) => {
1886
+ const parent = mapCall.parent;
1887
+ if (parent?.type !== "CallExpression") return false;
1888
+ if (parent.arguments?.[0] !== mapCall) return false;
1889
+ const callee = parent.callee;
1890
+ if (callee?.type !== "MemberExpression" || callee.computed) return false;
1891
+ if (callee.object?.type !== "Identifier" || callee.object.name !== "Promise") return false;
1892
+ if (callee.property?.type !== "Identifier") return false;
1893
+ return PROMISE_CONCURRENCY_METHODS.has(callee.property.name);
1894
+ };
1895
+ const asyncAwaitInLoop = { create: (context) => {
1896
+ const inspectLoopBody = (loopBody, label) => {
1897
+ if (!loopBody) return;
1898
+ const firstAwait = findFirstAwaitOutsideNestedFunctions(loopBody);
1899
+ if (firstAwait) context.report({
1900
+ node: firstAwait,
1901
+ message: `await inside a ${label} runs the calls sequentially — for independent operations, collect them and use \`await Promise.all(items.map(...))\` to run them concurrently`
1902
+ });
1903
+ };
1904
+ return {
1905
+ ForStatement(node) {
1906
+ inspectLoopBody(node.body, "for-loop");
1907
+ },
1908
+ ForInStatement(node) {
1909
+ inspectLoopBody(node.body, "for…in loop");
1910
+ },
1911
+ ForOfStatement(node) {
1912
+ if (node.await) return;
1913
+ inspectLoopBody(node.body, "for…of loop");
1914
+ },
1915
+ WhileStatement(node) {
1916
+ inspectLoopBody(node.body, "while-loop");
1917
+ },
1918
+ DoWhileStatement(node) {
1919
+ inspectLoopBody(node.body, "do-while loop");
1920
+ },
1921
+ CallExpression(node) {
1922
+ if (node.callee?.type !== "MemberExpression") return;
1923
+ if (node.callee.property?.type !== "Identifier") return;
1924
+ const methodName = node.callee.property.name;
1925
+ if (!ITERATION_METHOD_NAMES_WITH_CALLBACK.has(methodName)) return;
1926
+ const callback = node.arguments?.[0];
1927
+ if (!callback || !isFunctionishExpression(callback)) return;
1928
+ if (!callback.async) return;
1929
+ const body = callback.body;
1930
+ if (!body) return;
1931
+ if ((methodName === "map" || methodName === "flatMap") && isWrappedInPromiseConcurrency(node)) return;
1932
+ const firstAwait = findFirstAwaitOutsideNestedFunctions(body);
1933
+ if (firstAwait) {
1934
+ const message = methodName === "forEach" ? "Async callback in .forEach — return values are dropped, so awaits don't actually wait. Use a `for…of` loop or `await Promise.all(items.map(async (item) => {...}))`" : `Async callback in .${methodName} — sequential awaits inside the callback waterfall. Use \`await Promise.all(items.map(async (item) => {...}))\` to run them concurrently`;
1935
+ context.report({
1936
+ node: firstAwait,
1937
+ message
1938
+ });
1939
+ }
1940
+ }
1941
+ };
1942
+ } };
1374
1943
  //#endregion
1375
1944
  //#region src/plugin/rules/nextjs.ts
1376
1945
  const nextjsNoImgElement = { create: (context) => {
@@ -1420,13 +1989,39 @@ const nextjsNoAElement = { create: (context) => ({ JSXOpeningElement(node) {
1420
1989
  message: "Use next/link instead of <a> for internal links — enables client-side navigation and prefetching"
1421
1990
  });
1422
1991
  } }) };
1423
- const nextjsNoUseSearchParamsWithoutSuspense = { create: (context) => ({ CallExpression(node) {
1424
- if (!isHookCall(node, "useSearchParams")) return;
1425
- context.report({
1426
- node,
1427
- message: "useSearchParams() requires a <Suspense> boundary without one, the entire page bails out to client-side rendering"
1992
+ const fileMentionsSuspense = (programNode) => {
1993
+ let didSee = false;
1994
+ walkAst(programNode, (child) => {
1995
+ if (didSee) return false;
1996
+ if (child.type === "JSXOpeningElement" && child.name?.type === "JSXIdentifier" && child.name.name === "Suspense") {
1997
+ didSee = true;
1998
+ return false;
1999
+ }
2000
+ if (child.type === "ImportDeclaration" && child.source?.value === "react") {
2001
+ if ((child.specifiers ?? []).some((specifier) => specifier.type === "ImportSpecifier" && specifier.imported?.name === "Suspense")) {
2002
+ didSee = true;
2003
+ return false;
2004
+ }
2005
+ }
1428
2006
  });
1429
- } }) };
2007
+ return didSee;
2008
+ };
2009
+ const nextjsNoUseSearchParamsWithoutSuspense = { create: (context) => {
2010
+ let hasSuspenseInFile = false;
2011
+ return {
2012
+ Program(programNode) {
2013
+ hasSuspenseInFile = fileMentionsSuspense(programNode);
2014
+ },
2015
+ CallExpression(node) {
2016
+ if (hasSuspenseInFile) return;
2017
+ if (!isHookCall(node, "useSearchParams")) return;
2018
+ context.report({
2019
+ node,
2020
+ message: "useSearchParams() requires a <Suspense> boundary — without one, the entire page bails out to client-side rendering"
2021
+ });
2022
+ }
2023
+ };
2024
+ } };
1430
2025
  const nextjsNoClientFetchForServerData = { create: (context) => {
1431
2026
  let fileHasUseClient = false;
1432
2027
  return {
@@ -1600,8 +2195,7 @@ const getExportedGetHandlerBody = (node) => {
1600
2195
  if (!declaration) return null;
1601
2196
  if (declaration.type === "FunctionDeclaration" && declaration.id?.name === "GET") return declaration.body;
1602
2197
  if (declaration.type === "VariableDeclaration") {
1603
- const declarator = declaration.declarations?.[0];
1604
- if (declarator?.id?.type === "Identifier" && declarator.id.name === "GET" && declarator.init && (declarator.init.type === "ArrowFunctionExpression" || declarator.init.type === "FunctionExpression")) return declarator.init.body;
2198
+ for (const declarator of declaration.declarations ?? []) if (declarator?.id?.type === "Identifier" && declarator.id.name === "GET" && declarator.init && (declarator.init.type === "ArrowFunctionExpression" || declarator.init.type === "FunctionExpression")) return declarator.init.body;
1605
2199
  }
1606
2200
  return null;
1607
2201
  };
@@ -1667,6 +2261,13 @@ const noInlinePropOnMemoComponent = { create: (context) => {
1667
2261
  }
1668
2262
  };
1669
2263
  } };
2264
+ const isTriviallyCheapExpression = (node) => {
2265
+ if (!node) return false;
2266
+ if (!isSimpleExpression(node)) return false;
2267
+ if (node.type === "Identifier") return false;
2268
+ if (node.type === "MemberExpression") return false;
2269
+ return true;
2270
+ };
1670
2271
  const noUsememoSimpleExpression = { create: (context) => ({ CallExpression(node) {
1671
2272
  if (!isHookCall(node, "useMemo")) return;
1672
2273
  const callback = node.arguments?.[0];
@@ -1675,7 +2276,7 @@ const noUsememoSimpleExpression = { create: (context) => ({ CallExpression(node)
1675
2276
  let returnExpression = null;
1676
2277
  if (callback.body?.type !== "BlockStatement") returnExpression = callback.body;
1677
2278
  else if (callback.body.body?.length === 1 && callback.body.body[0].type === "ReturnStatement") returnExpression = callback.body.body[0].argument;
1678
- if (returnExpression && isSimpleExpression(returnExpression)) context.report({
2279
+ if (returnExpression && isTriviallyCheapExpression(returnExpression)) context.report({
1679
2280
  node,
1680
2281
  message: "useMemo wrapping a trivially cheap expression — memo overhead exceeds the computation"
1681
2282
  });
@@ -1822,8 +2423,21 @@ const renderingAnimateSvgWrapper = { create: (context) => ({ JSXOpeningElement(n
1822
2423
  message: "Animation props directly on <svg> — wrap in a <div> or <motion.div> for better rendering performance"
1823
2424
  });
1824
2425
  } }) };
2426
+ const renderingUsetransitionLoading = { create: (context) => ({ VariableDeclarator(node) {
2427
+ if (node.id?.type !== "ArrayPattern" || !node.id.elements?.length) return;
2428
+ if (!node.init || !isHookCall(node.init, "useState")) return;
2429
+ if (!node.init.arguments?.length) return;
2430
+ const initializer = node.init.arguments[0];
2431
+ if (initializer.type !== "Literal" || initializer.value !== false) return;
2432
+ const stateVariableName = node.id.elements[0]?.name;
2433
+ if (!stateVariableName || !LOADING_STATE_PATTERN.test(stateVariableName)) return;
2434
+ context.report({
2435
+ node: node.init,
2436
+ message: `useState for "${stateVariableName}" — if this guards a state transition (not an async fetch), consider useTransition instead`
2437
+ });
2438
+ } }) };
1825
2439
  const renderingHydrationNoFlicker = { create: (context) => ({ CallExpression(node) {
1826
- if (!isHookCall(node, EFFECT_HOOK_NAMES) || node.arguments?.length < 2) return;
2440
+ if (!isHookCall(node, EFFECT_HOOK_NAMES) || (node.arguments?.length ?? 0) < 2) return;
1827
2441
  const depsNode = node.arguments[1];
1828
2442
  if (depsNode.type !== "ArrayExpression" || depsNode.elements?.length !== 0) return;
1829
2443
  const callback = getEffectCallback(node);
@@ -1849,60 +2463,388 @@ const renderingScriptDeferAsync = { create: (context) => ({ JSXOpeningElement(no
1849
2463
  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"
1850
2464
  });
1851
2465
  } }) };
1852
- //#endregion
1853
- //#region src/plugin/rules/react-native.ts
1854
- const resolveJsxElementName = (openingElement) => {
1855
- const elementName = openingElement?.name;
1856
- if (!elementName) return null;
1857
- if (elementName.type === "JSXIdentifier") return elementName.name;
1858
- if (elementName.type === "JSXMemberExpression") return elementName.property?.name ?? null;
1859
- return null;
1860
- };
1861
- const truncateText = (text) => text.length > 30 ? `${text.slice(0, 30)}...` : text;
1862
- const isRawTextContent = (child) => {
1863
- if (child.type === "JSXText") return Boolean(child.value?.trim());
1864
- if (child.type !== "JSXExpressionContainer" || !child.expression) return false;
1865
- const expression = child.expression;
1866
- return expression.type === "Literal" && (typeof expression.value === "string" || typeof expression.value === "number") || expression.type === "TemplateLiteral";
1867
- };
1868
- const getRawTextDescription = (child) => {
1869
- if (child.type === "JSXText") return `"${truncateText(child.value.trim())}"`;
1870
- if (child.type === "JSXExpressionContainer" && child.expression) {
1871
- const expression = child.expression;
1872
- if (expression.type === "Literal" && typeof expression.value === "string") return `"${truncateText(expression.value)}"`;
1873
- if (expression.type === "Literal" && typeof expression.value === "number") return `{${expression.value}}`;
1874
- if (expression.type === "TemplateLiteral") return "template literal";
1875
- }
1876
- return "text content";
1877
- };
1878
- const isTextHandlingComponent = (elementName) => {
1879
- if (REACT_NATIVE_TEXT_COMPONENTS.has(elementName)) return true;
1880
- return [...REACT_NATIVE_TEXT_COMPONENT_KEYWORDS].some((keyword) => elementName.includes(keyword));
2466
+ const jsxReferencesLocalScope = (jsxNode) => {
2467
+ let referencesScope = false;
2468
+ walkAst(jsxNode, (child) => {
2469
+ if (referencesScope) return;
2470
+ if (child.type === "JSXExpressionContainer" && child.expression?.type !== "JSXEmptyExpression") referencesScope = true;
2471
+ if (child.type === "JSXSpreadAttribute") referencesScope = true;
2472
+ });
2473
+ return referencesScope;
1881
2474
  };
1882
- const rnNoRawText = { create: (context) => {
1883
- let isDomComponentFile = false;
2475
+ const renderingHoistJsx = { create: (context) => {
2476
+ let componentDepth = 0;
2477
+ const isComponentLike = (node) => {
2478
+ if (node.type === "FunctionDeclaration" && node.id?.name && isUppercaseName(node.id.name)) return true;
2479
+ if (node.type === "VariableDeclarator" && isComponentAssignment(node)) return true;
2480
+ return false;
2481
+ };
2482
+ const enter = (node) => {
2483
+ if (isComponentLike(node)) componentDepth++;
2484
+ };
2485
+ const exit = (node) => {
2486
+ if (isComponentLike(node)) componentDepth = Math.max(0, componentDepth - 1);
2487
+ };
1884
2488
  return {
1885
- Program(programNode) {
1886
- isDomComponentFile = hasDirective(programNode, "use dom");
1887
- },
1888
- JSXElement(node) {
1889
- if (isDomComponentFile) return;
1890
- const elementName = resolveJsxElementName(node.openingElement);
1891
- if (elementName && isTextHandlingComponent(elementName)) return;
1892
- for (const child of node.children ?? []) {
1893
- if (!isRawTextContent(child)) continue;
2489
+ FunctionDeclaration: enter,
2490
+ "FunctionDeclaration:exit": exit,
2491
+ VariableDeclarator: enter,
2492
+ "VariableDeclarator:exit": exit,
2493
+ VariableDeclaration(node) {
2494
+ if (componentDepth === 0) return;
2495
+ if (node.kind !== "const") return;
2496
+ for (const declarator of node.declarations ?? []) {
2497
+ const init = declarator.init;
2498
+ if (!init) continue;
2499
+ if (init.type !== "JSXElement" && init.type !== "JSXFragment") continue;
2500
+ if (jsxReferencesLocalScope(init)) continue;
2501
+ const name = declarator.id?.type === "Identifier" ? declarator.id.name : "<unnamed>";
1894
2502
  context.report({
1895
- node: child,
1896
- message: `Raw ${getRawTextDescription(child)} outside a <Text> component — this will crash on React Native`
2503
+ node: declarator,
2504
+ message: `Static JSX "${name}" inside a component — hoist to module scope so it isn't recreated each render`
1897
2505
  });
1898
2506
  }
1899
2507
  }
1900
2508
  };
1901
2509
  } };
1902
- const rnNoDeprecatedModules = { create: (context) => ({ ImportDeclaration(node) {
1903
- if (node.source?.value !== "react-native") return;
1904
- for (const specifier of node.specifiers ?? []) {
1905
- if (specifier.type !== "ImportSpecifier") continue;
2510
+ const callbackReturnsJsx = (callback) => {
2511
+ if (!callback) return false;
2512
+ if (callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression") return false;
2513
+ const body = callback.body;
2514
+ if (body?.type === "JSXElement" || body?.type === "JSXFragment") return true;
2515
+ if (body?.type !== "BlockStatement") return false;
2516
+ for (const stmt of body.body ?? []) if (stmt.type === "ReturnStatement" && (stmt.argument?.type === "JSXElement" || stmt.argument?.type === "JSXFragment")) return true;
2517
+ return false;
2518
+ };
2519
+ const containsEarlyReturn = (ifStatement) => {
2520
+ const consequent = ifStatement.consequent;
2521
+ if (!consequent) return false;
2522
+ if (consequent.type === "ReturnStatement") return true;
2523
+ if (consequent.type !== "BlockStatement") return false;
2524
+ for (const stmt of consequent.body ?? []) if (stmt.type === "ReturnStatement") return true;
2525
+ return false;
2526
+ };
2527
+ const rerenderMemoBeforeEarlyReturn = { create: (context) => {
2528
+ const inspectFunctionBody = (statements) => {
2529
+ let memoNode = null;
2530
+ for (const stmt of statements) {
2531
+ if (!memoNode) {
2532
+ if (stmt.type !== "VariableDeclaration") continue;
2533
+ for (const declarator of stmt.declarations ?? []) {
2534
+ const init = declarator.init;
2535
+ if (init?.type === "CallExpression" && isHookCall(init, "useMemo") && callbackReturnsJsx(init.arguments?.[0])) {
2536
+ memoNode = declarator;
2537
+ break;
2538
+ }
2539
+ }
2540
+ continue;
2541
+ }
2542
+ if (stmt.type === "IfStatement" && containsEarlyReturn(stmt)) {
2543
+ context.report({
2544
+ node: memoNode,
2545
+ message: "useMemo returning JSX runs before an early return — extract the JSX into a memoized child component so the parent bails out before the subtree renders"
2546
+ });
2547
+ return;
2548
+ }
2549
+ }
2550
+ };
2551
+ return {
2552
+ FunctionDeclaration(node) {
2553
+ if (!isUppercaseName(node.id?.name ?? "")) return;
2554
+ if (node.body?.type !== "BlockStatement") return;
2555
+ inspectFunctionBody(node.body.body ?? []);
2556
+ },
2557
+ VariableDeclarator(node) {
2558
+ if (!isComponentAssignment(node)) return;
2559
+ const body = node.init?.body;
2560
+ if (body?.type !== "BlockStatement") return;
2561
+ inspectFunctionBody(body.body ?? []);
2562
+ }
2563
+ };
2564
+ } };
2565
+ const NONDETERMINISTIC_RENDER_PATTERNS = [
2566
+ {
2567
+ display: "new Date()",
2568
+ matches: (n) => n.type === "NewExpression" && n.callee?.type === "Identifier" && n.callee.name === "Date"
2569
+ },
2570
+ {
2571
+ display: "Date.now()",
2572
+ matches: (n) => n.type === "CallExpression" && n.callee?.type === "MemberExpression" && n.callee.object?.type === "Identifier" && n.callee.object.name === "Date" && n.callee.property?.type === "Identifier" && n.callee.property.name === "now"
2573
+ },
2574
+ {
2575
+ display: "Math.random()",
2576
+ matches: (n) => n.type === "CallExpression" && n.callee?.type === "MemberExpression" && n.callee.object?.type === "Identifier" && n.callee.object.name === "Math" && n.callee.property?.type === "Identifier" && n.callee.property.name === "random"
2577
+ },
2578
+ {
2579
+ display: "performance.now()",
2580
+ matches: (n) => n.type === "CallExpression" && n.callee?.type === "MemberExpression" && n.callee.object?.type === "Identifier" && n.callee.object.name === "performance" && n.callee.property?.type === "Identifier" && n.callee.property.name === "now"
2581
+ },
2582
+ {
2583
+ display: "crypto.randomUUID()",
2584
+ matches: (n) => n.type === "CallExpression" && n.callee?.type === "MemberExpression" && n.callee.object?.type === "Identifier" && n.callee.object.name === "crypto" && n.callee.property?.type === "Identifier" && n.callee.property.name === "randomUUID"
2585
+ }
2586
+ ];
2587
+ const findOpeningElementOfChild = (jsxNode) => {
2588
+ let cursor = jsxNode.parent ?? null;
2589
+ while (cursor) {
2590
+ if (cursor.type === "JSXElement") return cursor.openingElement;
2591
+ if (cursor.type === "JSXFragment") return null;
2592
+ cursor = cursor.parent ?? null;
2593
+ }
2594
+ return null;
2595
+ };
2596
+ const hasSuppressHydrationWarningAttribute = (openingElement) => {
2597
+ if (!openingElement) return false;
2598
+ for (const attr of openingElement.attributes ?? []) if (attr.type === "JSXAttribute" && attr.name?.type === "JSXIdentifier" && attr.name.name === "suppressHydrationWarning") return true;
2599
+ return false;
2600
+ };
2601
+ const HIGH_FREQUENCY_DOM_EVENTS = new Set([
2602
+ "scroll",
2603
+ "mousemove",
2604
+ "wheel",
2605
+ "pointermove",
2606
+ "touchmove",
2607
+ "drag"
2608
+ ]);
2609
+ const isAddEventListenerCall = (node) => {
2610
+ if (node.type !== "CallExpression") return false;
2611
+ if (node.callee?.type !== "MemberExpression") return false;
2612
+ if (node.callee.property?.type !== "Identifier") return false;
2613
+ if (node.callee.property.name !== "addEventListener") return false;
2614
+ return true;
2615
+ };
2616
+ const handlerCallsSetState = (handler) => {
2617
+ if (handler.type !== "ArrowFunctionExpression" && handler.type !== "FunctionExpression") return null;
2618
+ let setStateCall = null;
2619
+ walkAst(handler.body, (child) => {
2620
+ if (setStateCall) return;
2621
+ if (child.type === "CallExpression" && child.callee?.type === "Identifier" && /^set[A-Z]/.test(child.callee.name)) setStateCall = child;
2622
+ });
2623
+ return setStateCall;
2624
+ };
2625
+ const rerenderTransitionsScroll = { create: (context) => ({ CallExpression(node) {
2626
+ if (!isAddEventListenerCall(node)) return;
2627
+ const eventArg = node.arguments?.[0];
2628
+ if (eventArg?.type !== "Literal") return;
2629
+ const eventName = eventArg.value;
2630
+ if (typeof eventName !== "string" || !HIGH_FREQUENCY_DOM_EVENTS.has(eventName)) return;
2631
+ const handler = node.arguments?.[1];
2632
+ if (!handler) return;
2633
+ const setStateCall = handlerCallsSetState(handler);
2634
+ if (!setStateCall) return;
2635
+ let cursor = setStateCall.parent ?? null;
2636
+ while (cursor && cursor !== handler) {
2637
+ if (cursor.type === "CallExpression" && cursor.callee?.type === "Identifier" && (cursor.callee.name === "startTransition" || cursor.callee.name === "requestAnimationFrame" || cursor.callee.name === "requestIdleCallback")) return;
2638
+ cursor = cursor.parent ?? null;
2639
+ }
2640
+ context.report({
2641
+ node: setStateCall,
2642
+ message: `setState in a "${eventName}" handler triggers re-renders at scroll/pointer frequency — wrap in startTransition (mark as non-urgent), use useDeferredValue, or stash in a ref + rAF throttle`
2643
+ });
2644
+ } }) };
2645
+ const renderingHydrationMismatchTime = { create: (context) => ({ JSXExpressionContainer(node) {
2646
+ if (!node.expression) return;
2647
+ const matched = NONDETERMINISTIC_RENDER_PATTERNS.find((pattern) => pattern.matches(node.expression));
2648
+ if (matched) {
2649
+ if (hasSuppressHydrationWarningAttribute(findOpeningElementOfChild(node))) return;
2650
+ context.report({
2651
+ node,
2652
+ message: `${matched.display} in JSX renders differently on server vs client — wrap in useEffect+useState (client-only) or add suppressHydrationWarning to the parent if intentional`
2653
+ });
2654
+ return;
2655
+ }
2656
+ walkAst(node.expression, (child) => {
2657
+ for (const pattern of NONDETERMINISTIC_RENDER_PATTERNS) if (pattern.matches(child)) {
2658
+ if (hasSuppressHydrationWarningAttribute(findOpeningElementOfChild(node))) return;
2659
+ context.report({
2660
+ node: child,
2661
+ message: `${pattern.display} reachable from JSX renders differently on server vs client — wrap in useEffect+useState (client-only) or add suppressHydrationWarning to the parent if intentional`
2662
+ });
2663
+ return;
2664
+ }
2665
+ });
2666
+ } }) };
2667
+ const collectIdentifierNames = (node, into) => {
2668
+ if (!node) return;
2669
+ walkAst(node, (child) => {
2670
+ if (child.type === "Identifier") into.add(child.name);
2671
+ });
2672
+ };
2673
+ const isEarlyReturnIfStatement = (statement) => {
2674
+ if (statement.type !== "IfStatement") return false;
2675
+ const consequent = statement.consequent;
2676
+ if (!consequent) return false;
2677
+ if (consequent.type === "ReturnStatement") return true;
2678
+ if (consequent.type !== "BlockStatement") return false;
2679
+ for (const inner of consequent.body ?? []) if (inner.type === "ReturnStatement") return true;
2680
+ return false;
2681
+ };
2682
+ const asyncDeferAwait = { create: (context) => {
2683
+ const inspectStatements = (statements) => {
2684
+ for (let statementIndex = 0; statementIndex < statements.length - 1; statementIndex++) {
2685
+ const currentStatement = statements[statementIndex];
2686
+ if (currentStatement.type !== "VariableDeclaration") continue;
2687
+ const awaitedBindingNames = /* @__PURE__ */ new Set();
2688
+ let didAwait = false;
2689
+ for (const declarator of currentStatement.declarations ?? []) if (declarator.init?.type === "AwaitExpression") {
2690
+ didAwait = true;
2691
+ if (declarator.id?.type === "Identifier") awaitedBindingNames.add(declarator.id.name);
2692
+ else if (declarator.id?.type === "ObjectPattern") {
2693
+ for (const property of declarator.id.properties ?? []) if (property.type === "Property" && property.value?.type === "Identifier") awaitedBindingNames.add(property.value.name);
2694
+ }
2695
+ }
2696
+ if (!didAwait) continue;
2697
+ const nextStatement = statements[statementIndex + 1];
2698
+ if (!isEarlyReturnIfStatement(nextStatement)) continue;
2699
+ const testIdentifiers = /* @__PURE__ */ new Set();
2700
+ collectIdentifierNames(nextStatement.test, testIdentifiers);
2701
+ if ([...awaitedBindingNames].some((name) => testIdentifiers.has(name))) continue;
2702
+ const consequentIdentifiers = /* @__PURE__ */ new Set();
2703
+ collectIdentifierNames(nextStatement.consequent, consequentIdentifiers);
2704
+ if ([...awaitedBindingNames].some((name) => consequentIdentifiers.has(name))) continue;
2705
+ context.report({
2706
+ node: currentStatement,
2707
+ message: "await blocks the function before an early-return that doesn't use the awaited value — move the await after the synchronous guard so the skip path stays fast"
2708
+ });
2709
+ }
2710
+ };
2711
+ const enterFunction = (node) => {
2712
+ if (!node.async) return;
2713
+ if (node.body?.type !== "BlockStatement") return;
2714
+ inspectStatements(node.body.body ?? []);
2715
+ };
2716
+ return {
2717
+ FunctionDeclaration: enterFunction,
2718
+ FunctionExpression: enterFunction,
2719
+ ArrowFunctionExpression: enterFunction
2720
+ };
2721
+ } };
2722
+ const CONTINUOUS_VALUE_HOOK_PATTERN = /^use(?:Window(?:Width|Height|Dimensions)|Scroll(?:Position|Y|X)|MousePosition|ResizeObserver|IntersectionObserver)/;
2723
+ const isThresholdComparison = (node, valueName) => {
2724
+ if (node.type !== "BinaryExpression") return false;
2725
+ if (![
2726
+ "<",
2727
+ "<=",
2728
+ ">",
2729
+ ">=",
2730
+ "===",
2731
+ "!==",
2732
+ "==",
2733
+ "!="
2734
+ ].includes(node.operator)) return false;
2735
+ if (!(node.left?.type === "Identifier" && node.left.name === valueName || node.right?.type === "Identifier" && node.right.name === valueName)) return false;
2736
+ return node.left?.type === "Literal" || node.right?.type === "Literal";
2737
+ };
2738
+ const findThresholdDerivedBindings = (componentBody) => {
2739
+ const out = [];
2740
+ if (componentBody?.type !== "BlockStatement") return out;
2741
+ const statements = componentBody.body ?? [];
2742
+ for (let outerIndex = 0; outerIndex < statements.length; outerIndex++) {
2743
+ const outerStatement = statements[outerIndex];
2744
+ if (outerStatement.type !== "VariableDeclaration") continue;
2745
+ for (const declarator of outerStatement.declarations ?? []) {
2746
+ if (declarator.id?.type !== "Identifier") continue;
2747
+ const init = declarator.init;
2748
+ if (init?.type !== "CallExpression") continue;
2749
+ if (init.callee?.type !== "Identifier") continue;
2750
+ if (!CONTINUOUS_VALUE_HOOK_PATTERN.test(init.callee.name)) continue;
2751
+ const continuousName = declarator.id.name;
2752
+ const hookName = init.callee.name;
2753
+ for (let innerIndex = outerIndex + 1; innerIndex < statements.length; innerIndex++) {
2754
+ const innerStatement = statements[innerIndex];
2755
+ if (innerStatement.type !== "VariableDeclaration") break;
2756
+ let foundThreshold = false;
2757
+ for (const innerDecl of innerStatement.declarations ?? []) if (innerDecl.init && isThresholdComparison(innerDecl.init, continuousName)) {
2758
+ foundThreshold = true;
2759
+ break;
2760
+ }
2761
+ if (foundThreshold) {
2762
+ out.push({
2763
+ continuousName,
2764
+ hookName,
2765
+ declarator
2766
+ });
2767
+ break;
2768
+ }
2769
+ }
2770
+ }
2771
+ }
2772
+ return out;
2773
+ };
2774
+ const rerenderDerivedStateFromHook = { create: (context) => {
2775
+ const checkComponent = (componentBody) => {
2776
+ if (!componentBody || componentBody.type !== "BlockStatement") return;
2777
+ const bindings = findThresholdDerivedBindings(componentBody);
2778
+ for (const binding of bindings) context.report({
2779
+ node: binding.declarator,
2780
+ message: `${binding.hookName}() returns a continuously-changing value but you only compare it to a threshold — use a media-query / threshold hook (e.g. \`useMediaQuery("(max-width: 767px)")\`) so the component re-renders only when the threshold flips`
2781
+ });
2782
+ };
2783
+ return {
2784
+ FunctionDeclaration(node) {
2785
+ if (!node.id?.name || !isUppercaseName(node.id.name)) return;
2786
+ checkComponent(node.body);
2787
+ },
2788
+ VariableDeclarator(node) {
2789
+ if (!isComponentAssignment(node)) return;
2790
+ checkComponent(node.init?.body);
2791
+ }
2792
+ };
2793
+ } };
2794
+ //#endregion
2795
+ //#region src/plugin/rules/react-native.ts
2796
+ const resolveJsxElementName = (openingElement) => {
2797
+ const elementName = openingElement?.name;
2798
+ if (!elementName) return null;
2799
+ if (elementName.type === "JSXIdentifier") return elementName.name;
2800
+ if (elementName.type === "JSXMemberExpression") return elementName.property?.name ?? null;
2801
+ return null;
2802
+ };
2803
+ const truncateText = (text) => text.length > 30 ? `${text.slice(0, 30)}...` : text;
2804
+ const isRawTextContent = (child) => {
2805
+ if (child.type === "JSXText") return Boolean(child.value?.trim());
2806
+ if (child.type !== "JSXExpressionContainer" || !child.expression) return false;
2807
+ const expression = child.expression;
2808
+ return expression.type === "Literal" && (typeof expression.value === "string" || typeof expression.value === "number") || expression.type === "TemplateLiteral";
2809
+ };
2810
+ const getRawTextDescription = (child) => {
2811
+ if (child.type === "JSXText") return `"${truncateText(child.value.trim())}"`;
2812
+ if (child.type === "JSXExpressionContainer" && child.expression) {
2813
+ const expression = child.expression;
2814
+ if (expression.type === "Literal" && typeof expression.value === "string") return `"${truncateText(expression.value)}"`;
2815
+ if (expression.type === "Literal" && typeof expression.value === "number") return `{${expression.value}}`;
2816
+ if (expression.type === "TemplateLiteral") return "template literal";
2817
+ }
2818
+ return "text content";
2819
+ };
2820
+ const isTextHandlingComponent = (elementName) => {
2821
+ if (REACT_NATIVE_TEXT_COMPONENTS.has(elementName)) return true;
2822
+ return [...REACT_NATIVE_TEXT_COMPONENT_KEYWORDS].some((keyword) => elementName.includes(keyword));
2823
+ };
2824
+ const rnNoRawText = { create: (context) => {
2825
+ let isDomComponentFile = false;
2826
+ return {
2827
+ Program(programNode) {
2828
+ isDomComponentFile = hasDirective(programNode, "use dom");
2829
+ },
2830
+ JSXElement(node) {
2831
+ if (isDomComponentFile) return;
2832
+ const elementName = resolveJsxElementName(node.openingElement);
2833
+ if (elementName && isTextHandlingComponent(elementName)) return;
2834
+ for (const child of node.children ?? []) {
2835
+ if (!isRawTextContent(child)) continue;
2836
+ context.report({
2837
+ node: child,
2838
+ message: `Raw ${getRawTextDescription(child)} outside a <Text> component — this will crash on React Native`
2839
+ });
2840
+ }
2841
+ }
2842
+ };
2843
+ } };
2844
+ const rnNoDeprecatedModules = { create: (context) => ({ ImportDeclaration(node) {
2845
+ if (node.source?.value !== "react-native") return;
2846
+ for (const specifier of node.specifiers ?? []) {
2847
+ if (specifier.type !== "ImportSpecifier") continue;
1906
2848
  const importedName = specifier.imported?.name;
1907
2849
  if (!importedName) continue;
1908
2850
  const replacement = DEPRECATED_RN_MODULE_REPLACEMENTS[importedName];
@@ -2011,6 +2953,449 @@ const rnNoSingleElementStyleArray = { create: (context) => ({ JSXAttribute(node)
2011
2953
  message: `Single-element style array on "${propName}" — use ${propName}={value} instead of ${propName}={[value]} to avoid unnecessary array allocation`
2012
2954
  });
2013
2955
  } }) };
2956
+ const TOUCHABLE_COMPONENTS = new Set([
2957
+ "TouchableOpacity",
2958
+ "TouchableHighlight",
2959
+ "TouchableWithoutFeedback",
2960
+ "TouchableNativeFeedback"
2961
+ ]);
2962
+ const rnPreferPressable = { create: (context) => ({ ImportDeclaration(node) {
2963
+ if (node.source?.value !== "react-native") return;
2964
+ for (const specifier of node.specifiers ?? []) {
2965
+ if (specifier.type !== "ImportSpecifier") continue;
2966
+ const importedName = specifier.imported?.name;
2967
+ if (!importedName || !TOUCHABLE_COMPONENTS.has(importedName)) continue;
2968
+ context.report({
2969
+ node: specifier,
2970
+ message: `${importedName} is legacy — use <Pressable> from react-native (or react-native-gesture-handler) for modern press handling`
2971
+ });
2972
+ }
2973
+ } }) };
2974
+ const rnPreferExpoImage = { create: (context) => ({ ImportDeclaration(node) {
2975
+ if (node.source?.value !== "react-native") return;
2976
+ for (const specifier of node.specifiers ?? []) {
2977
+ if (specifier.type !== "ImportSpecifier") continue;
2978
+ if (specifier.imported?.name !== "Image") continue;
2979
+ context.report({
2980
+ node: specifier,
2981
+ message: "Importing Image from react-native — prefer expo-image for caching, placeholders, and progressive loading (drop-in API)"
2982
+ });
2983
+ }
2984
+ } }) };
2985
+ const NON_NATIVE_NAVIGATOR_PACKAGES = new Set(["@react-navigation/stack", "@react-navigation/drawer"]);
2986
+ const rnNoNonNativeNavigator = { create: (context) => ({ ImportDeclaration(node) {
2987
+ const source = node.source?.value;
2988
+ if (typeof source !== "string" || !NON_NATIVE_NAVIGATOR_PACKAGES.has(source)) return;
2989
+ const replacement = source.replace("@react-navigation/", "@react-navigation/native-");
2990
+ context.report({
2991
+ node,
2992
+ message: `${source} uses a JS-implemented navigator — use ${replacement} for native iOS/Android transitions and gestures`
2993
+ });
2994
+ } }) };
2995
+ const rnNoScrollState = { create: (context) => ({ JSXAttribute(node) {
2996
+ if (node.name?.type !== "JSXIdentifier") return;
2997
+ if (node.name.name !== "onScroll") return;
2998
+ if (node.value?.type !== "JSXExpressionContainer") return;
2999
+ const expression = node.value.expression;
3000
+ if (expression?.type !== "ArrowFunctionExpression" && expression?.type !== "FunctionExpression") return;
3001
+ let setStateCallNode = null;
3002
+ walkAst(expression.body, (child) => {
3003
+ if (setStateCallNode) return;
3004
+ if (child.type === "CallExpression" && child.callee?.type === "Identifier" && /^set[A-Z]/.test(child.callee.name)) setStateCallNode = child;
3005
+ });
3006
+ if (setStateCallNode) context.report({
3007
+ node: setStateCallNode,
3008
+ message: "setState in onScroll triggers re-renders on every scroll event — use a Reanimated shared value (useAnimatedScrollHandler) or a ref to track scroll position"
3009
+ });
3010
+ } }) };
3011
+ const SCROLLVIEW_NAMES = new Set(["ScrollView"]);
3012
+ const rnNoScrollviewMappedList = { create: (context) => ({ JSXElement(node) {
3013
+ const elementName = resolveJsxElementName(node.openingElement);
3014
+ if (!elementName || !SCROLLVIEW_NAMES.has(elementName)) return;
3015
+ for (const child of node.children ?? []) {
3016
+ if (child.type !== "JSXExpressionContainer") continue;
3017
+ const expression = child.expression;
3018
+ if (expression?.type === "CallExpression" && expression.callee?.type === "MemberExpression" && expression.callee.property?.type === "Identifier" && expression.callee.property.name === "map") {
3019
+ context.report({
3020
+ node: child,
3021
+ message: `<${elementName}> rendering items.map(...) — use FlashList, LegendList, or FlatList so only visible rows mount`
3022
+ });
3023
+ return;
3024
+ }
3025
+ }
3026
+ } }) };
3027
+ const RENDER_ITEM_PROP_NAMES = new Set([
3028
+ "renderItem",
3029
+ "renderSectionHeader",
3030
+ "renderSectionFooter"
3031
+ ]);
3032
+ const rnNoInlineObjectInListItem = { create: (context) => {
3033
+ let renderItemDepth = 0;
3034
+ const isRenderItemAttribute = (parent) => {
3035
+ if (parent?.type !== "JSXAttribute") return false;
3036
+ const attrName = parent.name?.type === "JSXIdentifier" ? parent.name.name : null;
3037
+ return attrName ? RENDER_ITEM_PROP_NAMES.has(attrName) : false;
3038
+ };
3039
+ const isRenderItemFunction = (node) => {
3040
+ if (node.type !== "ArrowFunctionExpression" && node.type !== "FunctionExpression") return false;
3041
+ const expressionContainer = node.parent;
3042
+ if (expressionContainer?.type !== "JSXExpressionContainer") return false;
3043
+ return isRenderItemAttribute(expressionContainer.parent);
3044
+ };
3045
+ const enter = (node) => {
3046
+ if (isRenderItemFunction(node)) renderItemDepth++;
3047
+ };
3048
+ const exit = (node) => {
3049
+ if (isRenderItemFunction(node)) renderItemDepth = Math.max(0, renderItemDepth - 1);
3050
+ };
3051
+ return {
3052
+ ArrowFunctionExpression: enter,
3053
+ "ArrowFunctionExpression:exit": exit,
3054
+ FunctionExpression: enter,
3055
+ "FunctionExpression:exit": exit,
3056
+ JSXAttribute(node) {
3057
+ if (renderItemDepth === 0) return;
3058
+ if (node.value?.type !== "JSXExpressionContainer") return;
3059
+ if (node.value.expression?.type !== "ObjectExpression") return;
3060
+ const propName = node.name?.type === "JSXIdentifier" ? node.name.name : "<unknown>";
3061
+ context.report({
3062
+ node,
3063
+ message: `Inline object literal on "${propName}" inside renderItem — allocates a fresh reference per row and breaks memo() on the row component. Hoist outside renderItem or pass primitives`
3064
+ });
3065
+ }
3066
+ };
3067
+ } };
3068
+ const REANIMATED_LAYOUT_KEYS = new Set([
3069
+ "width",
3070
+ "height",
3071
+ "top",
3072
+ "left",
3073
+ "right",
3074
+ "bottom",
3075
+ "minWidth",
3076
+ "minHeight",
3077
+ "maxWidth",
3078
+ "maxHeight",
3079
+ "marginTop",
3080
+ "marginBottom",
3081
+ "marginLeft",
3082
+ "marginRight",
3083
+ "paddingTop",
3084
+ "paddingBottom",
3085
+ "paddingLeft",
3086
+ "paddingRight",
3087
+ "flex",
3088
+ "flexBasis",
3089
+ "flexGrow",
3090
+ "flexShrink"
3091
+ ]);
3092
+ const findReturnedObject = (callback) => {
3093
+ if (callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression") return null;
3094
+ const body = callback.body;
3095
+ if (body?.type === "ObjectExpression") return body;
3096
+ if (body?.type !== "BlockStatement") return null;
3097
+ for (const stmt of body.body ?? []) if (stmt.type === "ReturnStatement" && stmt.argument?.type === "ObjectExpression") return stmt.argument;
3098
+ return null;
3099
+ };
3100
+ const rnAnimateLayoutProperty = { create: (context) => ({ CallExpression(node) {
3101
+ if (node.callee?.type !== "Identifier" || node.callee.name !== "useAnimatedStyle") return;
3102
+ const callback = node.arguments?.[0];
3103
+ if (!callback) return;
3104
+ const returnedObject = findReturnedObject(callback);
3105
+ if (!returnedObject) return;
3106
+ for (const property of returnedObject.properties ?? []) {
3107
+ if (property.type !== "Property") continue;
3108
+ if (property.key?.type !== "Identifier") continue;
3109
+ if (!REANIMATED_LAYOUT_KEYS.has(property.key.name)) continue;
3110
+ context.report({
3111
+ node: property,
3112
+ message: `useAnimatedStyle animating "${property.key.name}" — layout properties run on the layout thread; use transform: [{ translateX/Y }, { scale }] or opacity for GPU-accelerated animation`
3113
+ });
3114
+ }
3115
+ } }) };
3116
+ const rnPreferContentInsetAdjustment = { create: (context) => ({ JSXElement(node) {
3117
+ if (resolveJsxElementName(node.openingElement) !== "SafeAreaView") return;
3118
+ for (const child of node.children ?? []) {
3119
+ if (child.type !== "JSXElement") continue;
3120
+ const childName = resolveJsxElementName(child.openingElement);
3121
+ if (!childName || !SCROLLVIEW_NAMES.has(childName)) continue;
3122
+ context.report({
3123
+ node,
3124
+ message: "<SafeAreaView> wrapping <ScrollView> — set `contentInsetAdjustmentBehavior=\"automatic\"` on the ScrollView and drop the SafeAreaView wrapper for native safe-area handling"
3125
+ });
3126
+ return;
3127
+ }
3128
+ } }) };
3129
+ const PRESS_HANDLER_PROP_NAMES = new Set(["onPressIn", "onPressOut"]);
3130
+ const handlerMutatesIdentifier = (handler, sharedValueBindings) => {
3131
+ if (handler.type !== "ArrowFunctionExpression" && handler.type !== "FunctionExpression") return false;
3132
+ if (sharedValueBindings.size === 0) return false;
3133
+ let didMutate = false;
3134
+ walkAst(handler.body, (child) => {
3135
+ if (didMutate) return;
3136
+ if (child.type === "AssignmentExpression" && child.left?.type === "MemberExpression" && child.left.object?.type === "Identifier" && sharedValueBindings.has(child.left.object.name) && child.left.property?.type === "Identifier" && child.left.property.name === "value") didMutate = true;
3137
+ if (child.type === "CallExpression" && child.callee?.type === "MemberExpression" && child.callee.object?.type === "Identifier" && sharedValueBindings.has(child.callee.object.name) && child.callee.property?.type === "Identifier" && (child.callee.property.name === "set" || child.callee.property.name === "value")) didMutate = true;
3138
+ });
3139
+ return didMutate;
3140
+ };
3141
+ const rnPressableSharedValueMutation = { create: (context) => {
3142
+ const sharedValueBindingsByComponent = [];
3143
+ const enterScope = () => {
3144
+ sharedValueBindingsByComponent.push(/* @__PURE__ */ new Set());
3145
+ };
3146
+ const exitScope = () => {
3147
+ sharedValueBindingsByComponent.pop();
3148
+ };
3149
+ const trackSharedValueBinding = (declarator) => {
3150
+ if (sharedValueBindingsByComponent.length === 0) return;
3151
+ if (declarator.id?.type !== "Identifier") return;
3152
+ if (declarator.init?.type !== "CallExpression") return;
3153
+ const callee = declarator.init.callee;
3154
+ if (callee?.type !== "Identifier") return;
3155
+ if (callee.name !== "useSharedValue") return;
3156
+ sharedValueBindingsByComponent[sharedValueBindingsByComponent.length - 1].add(declarator.id.name);
3157
+ };
3158
+ return {
3159
+ FunctionDeclaration: enterScope,
3160
+ "FunctionDeclaration:exit": exitScope,
3161
+ FunctionExpression: enterScope,
3162
+ "FunctionExpression:exit": exitScope,
3163
+ ArrowFunctionExpression: enterScope,
3164
+ "ArrowFunctionExpression:exit": exitScope,
3165
+ VariableDeclarator(node) {
3166
+ trackSharedValueBinding(node);
3167
+ },
3168
+ JSXOpeningElement(node) {
3169
+ if (resolveJsxElementName(node) !== "Pressable") return;
3170
+ if (sharedValueBindingsByComponent.length === 0) return;
3171
+ const activeBindings = /* @__PURE__ */ new Set();
3172
+ for (const frame of sharedValueBindingsByComponent) for (const binding of frame) activeBindings.add(binding);
3173
+ if (activeBindings.size === 0) return;
3174
+ for (const attr of node.attributes ?? []) {
3175
+ if (attr.type !== "JSXAttribute") continue;
3176
+ if (attr.name?.type !== "JSXIdentifier") continue;
3177
+ if (!PRESS_HANDLER_PROP_NAMES.has(attr.name.name)) continue;
3178
+ if (attr.value?.type !== "JSXExpressionContainer") continue;
3179
+ const handler = attr.value.expression;
3180
+ if (!handler) continue;
3181
+ if (!handlerMutatesIdentifier(handler, activeBindings)) continue;
3182
+ context.report({
3183
+ node: attr,
3184
+ message: `<Pressable> ${attr.name.name} mutates a Reanimated shared value — use a Gesture.Tap() inside <GestureDetector> for press animations that stay on the UI thread`
3185
+ });
3186
+ }
3187
+ }
3188
+ };
3189
+ } };
3190
+ const VIRTUALIZED_LIST_NAMES = new Set([
3191
+ "FlatList",
3192
+ "FlashList",
3193
+ "LegendList",
3194
+ "SectionList",
3195
+ "VirtualizedList"
3196
+ ]);
3197
+ const rnListDataMapped = { create: (context) => ({ JSXOpeningElement(node) {
3198
+ const elementName = resolveJsxElementName(node);
3199
+ if (!elementName || !VIRTUALIZED_LIST_NAMES.has(elementName)) return;
3200
+ for (const attr of node.attributes ?? []) {
3201
+ if (attr.type !== "JSXAttribute") continue;
3202
+ if (attr.name?.type !== "JSXIdentifier" || attr.name.name !== "data") continue;
3203
+ if (attr.value?.type !== "JSXExpressionContainer") continue;
3204
+ const expression = attr.value.expression;
3205
+ if (expression?.type !== "CallExpression") continue;
3206
+ if (expression.callee?.type !== "MemberExpression") continue;
3207
+ if (expression.callee.property?.type !== "Identifier") continue;
3208
+ const methodName = expression.callee.property.name;
3209
+ if (methodName !== "map" && methodName !== "filter") continue;
3210
+ context.report({
3211
+ node: attr,
3212
+ message: `<${elementName} data={items.${methodName}(...)}> allocates a fresh array per render — wrap in useMemo at list scope so the data reference stays stable across parent renders`
3213
+ });
3214
+ return;
3215
+ }
3216
+ } }) };
3217
+ const rnAnimationReactionAsDerived = { create: (context) => ({ CallExpression(node) {
3218
+ if (node.callee?.type !== "Identifier" || node.callee.name !== "useAnimatedReaction") return;
3219
+ const reactionFn = node.arguments?.[1];
3220
+ if (!reactionFn) return;
3221
+ if (reactionFn.type !== "ArrowFunctionExpression" && reactionFn.type !== "FunctionExpression") return;
3222
+ const body = reactionFn.body;
3223
+ let singleAssignment = null;
3224
+ if (body?.type === "BlockStatement") {
3225
+ const statements = body.body ?? [];
3226
+ if (statements.length !== 1) return;
3227
+ const onlyStatement = statements[0];
3228
+ if (onlyStatement.type !== "ExpressionStatement") return;
3229
+ singleAssignment = onlyStatement.expression;
3230
+ } else if (body) singleAssignment = body;
3231
+ if (!singleAssignment) return;
3232
+ if (singleAssignment.type !== "AssignmentExpression") return;
3233
+ if (singleAssignment.left?.type !== "MemberExpression") return;
3234
+ if (singleAssignment.left.property?.type !== "Identifier") return;
3235
+ if (singleAssignment.left.property.name !== "value") return;
3236
+ context.report({
3237
+ node,
3238
+ message: "useAnimatedReaction body is a single shared-value assignment — useDerivedValue is shorter and tracks dependencies natively"
3239
+ });
3240
+ } }) };
3241
+ const JS_BOTTOM_SHEET_PACKAGES = new Set([
3242
+ "@gorhom/bottom-sheet",
3243
+ "react-native-bottom-sheet",
3244
+ "react-native-modal-bottom-sheet",
3245
+ "react-native-raw-bottom-sheet"
3246
+ ]);
3247
+ const rnBottomSheetPreferNative = { create: (context) => ({ ImportDeclaration(node) {
3248
+ const source = node.source?.value;
3249
+ if (typeof source !== "string" || !JS_BOTTOM_SHEET_PACKAGES.has(source)) return;
3250
+ context.report({
3251
+ node,
3252
+ message: `${source} is a JS-implemented bottom sheet — for v7+ RN, prefer <Modal presentationStyle="formSheet"> for native gesture handling and snap points`
3253
+ });
3254
+ } }) };
3255
+ const rnScrollviewDynamicPadding = { create: (context) => ({ JSXOpeningElement(node) {
3256
+ const elementName = resolveJsxElementName(node);
3257
+ if (!elementName) return;
3258
+ if (!SCROLLVIEW_NAMES.has(elementName) && elementName !== "FlatList" && elementName !== "FlashList") return;
3259
+ for (const attr of node.attributes ?? []) {
3260
+ if (attr.type !== "JSXAttribute") continue;
3261
+ if (attr.name?.type !== "JSXIdentifier" || attr.name.name !== "contentContainerStyle") continue;
3262
+ if (attr.value?.type !== "JSXExpressionContainer") continue;
3263
+ const expression = attr.value.expression;
3264
+ if (expression?.type !== "ObjectExpression") continue;
3265
+ for (const property of expression.properties ?? []) {
3266
+ if (property.type !== "Property") continue;
3267
+ if (property.key?.type !== "Identifier") continue;
3268
+ const key = property.key.name;
3269
+ if (key !== "paddingBottom" && key !== "paddingTop") continue;
3270
+ const value = property.value;
3271
+ if (!value) continue;
3272
+ if (value.type === "Literal") continue;
3273
+ context.report({
3274
+ node: property,
3275
+ message: `Dynamic ${key} on contentContainerStyle reflows the scroll content — use \`contentInset\` (OS-level offset, no relayout) instead`
3276
+ });
3277
+ return;
3278
+ }
3279
+ }
3280
+ } }) };
3281
+ const LIST_ROW_PRESS_HANDLER_PROPS = new Set([
3282
+ "onPress",
3283
+ "onLongPress",
3284
+ "onPressIn",
3285
+ "onPressOut",
3286
+ "onSelect",
3287
+ "onClick"
3288
+ ]);
3289
+ const detectInlineRowHandlers = (renderItemFn) => {
3290
+ const inlineHandlers = [];
3291
+ walkAst(renderItemFn.body, (child) => {
3292
+ if (child.type !== "JSXAttribute") return;
3293
+ if (child.name?.type !== "JSXIdentifier") return;
3294
+ if (!LIST_ROW_PRESS_HANDLER_PROPS.has(child.name.name)) return;
3295
+ if (child.value?.type !== "JSXExpressionContainer") return;
3296
+ const expression = child.value.expression;
3297
+ if (expression?.type === "ArrowFunctionExpression" || expression?.type === "FunctionExpression") inlineHandlers.push(child);
3298
+ });
3299
+ return inlineHandlers;
3300
+ };
3301
+ const isRenderItemJsxAttribute = (parent) => {
3302
+ if (parent?.type !== "JSXAttribute") return false;
3303
+ return (parent.name?.type === "JSXIdentifier" ? parent.name.name : null) === "renderItem";
3304
+ };
3305
+ const isRenderItemFunction = (node) => {
3306
+ const parent = node.parent;
3307
+ if (parent?.type !== "JSXExpressionContainer") return false;
3308
+ return isRenderItemJsxAttribute(parent.parent);
3309
+ };
3310
+ const rnListCallbackPerRow = { create: (context) => {
3311
+ const inspect = (node) => {
3312
+ if (!isRenderItemFunction(node)) return;
3313
+ const inlineHandlers = detectInlineRowHandlers(node);
3314
+ for (const handler of inlineHandlers) {
3315
+ const handlerName = handler.name?.type === "JSXIdentifier" ? handler.name.name : "<handler>";
3316
+ context.report({
3317
+ node: handler,
3318
+ message: `Inline ${handlerName} arrow inside renderItem creates a fresh closure per row — hoist with useCallback at list scope and pass the row id as a primitive prop`
3319
+ });
3320
+ }
3321
+ };
3322
+ return {
3323
+ ArrowFunctionExpression: inspect,
3324
+ FunctionExpression: inspect
3325
+ };
3326
+ } };
3327
+ const LEGACY_SHADOW_KEYS = new Set([
3328
+ "shadowColor",
3329
+ "shadowOffset",
3330
+ "shadowOpacity",
3331
+ "shadowRadius",
3332
+ "elevation"
3333
+ ]);
3334
+ const findLegacyShadowProperty = (objectExpression) => {
3335
+ for (const property of objectExpression.properties ?? []) {
3336
+ if (property.type !== "Property") continue;
3337
+ if (property.key?.type !== "Identifier") continue;
3338
+ if (LEGACY_SHADOW_KEYS.has(property.key.name)) return {
3339
+ keyName: property.key.name,
3340
+ node: property
3341
+ };
3342
+ }
3343
+ return null;
3344
+ };
3345
+ const rnStylePreferBoxShadow = { create: (context) => ({
3346
+ JSXAttribute(node) {
3347
+ if (node.name?.type !== "JSXIdentifier") return;
3348
+ const attrName = node.name.name;
3349
+ if (attrName !== "style" && !attrName.endsWith("Style")) return;
3350
+ if (node.value?.type !== "JSXExpressionContainer") return;
3351
+ const expression = node.value.expression;
3352
+ if (expression?.type !== "ObjectExpression") return;
3353
+ const match = findLegacyShadowProperty(expression);
3354
+ if (!match) return;
3355
+ context.report({
3356
+ node: match.node,
3357
+ message: `${match.keyName} is iOS/Android-platform-specific — use the cross-platform CSS \`boxShadow\` string (e.g. \`boxShadow: "0 2px 8px rgba(0,0,0,0.1)"\`) on RN v7+`
3358
+ });
3359
+ },
3360
+ CallExpression(node) {
3361
+ if (node.callee?.type !== "MemberExpression") return;
3362
+ if (node.callee.object?.type !== "Identifier") return;
3363
+ if (node.callee.object.name !== "StyleSheet") return;
3364
+ if (node.callee.property?.type !== "Identifier") return;
3365
+ if (node.callee.property.name !== "create") return;
3366
+ const arg = node.arguments?.[0];
3367
+ if (arg?.type !== "ObjectExpression") return;
3368
+ for (const property of arg.properties ?? []) {
3369
+ if (property.type !== "Property") continue;
3370
+ if (property.value?.type !== "ObjectExpression") continue;
3371
+ const match = findLegacyShadowProperty(property.value);
3372
+ if (!match) continue;
3373
+ context.report({
3374
+ node: match.node,
3375
+ message: `${match.keyName} is iOS/Android-platform-specific — use the cross-platform CSS \`boxShadow\` string on RN v7+`
3376
+ });
3377
+ }
3378
+ }
3379
+ }) };
3380
+ const RECYCLABLE_LIST_NAMES = new Set(["FlashList", "LegendList"]);
3381
+ const rnListRecyclableWithoutTypes = { create: (context) => ({ JSXOpeningElement(node) {
3382
+ const elementName = resolveJsxElementName(node);
3383
+ if (!elementName || !RECYCLABLE_LIST_NAMES.has(elementName)) return;
3384
+ let hasRecycleItemsEnabled = false;
3385
+ let hasGetItemType = false;
3386
+ for (const attr of node.attributes ?? []) {
3387
+ if (attr.type !== "JSXAttribute") continue;
3388
+ if (attr.name?.type !== "JSXIdentifier") continue;
3389
+ if (attr.name.name === "recycleItems") if (!attr.value) hasRecycleItemsEnabled = true;
3390
+ else if (attr.value.type === "JSXExpressionContainer" && attr.value.expression?.type === "Literal") hasRecycleItemsEnabled = attr.value.expression.value === true;
3391
+ else hasRecycleItemsEnabled = true;
3392
+ if (attr.name.name === "getItemType") hasGetItemType = true;
3393
+ }
3394
+ if (hasRecycleItemsEnabled && !hasGetItemType) context.report({
3395
+ node,
3396
+ message: `<${elementName} recycleItems> without \`getItemType\` — heterogeneous rows mount into the wrong recycled cells. Add \`getItemType={item => item.kind}\` so FlashList keeps separate recycle pools per type`
3397
+ });
3398
+ } }) };
2014
3399
  //#endregion
2015
3400
  //#region src/plugin/rules/tanstack-query.ts
2016
3401
  const queryStableQueryClient = { create: (context) => {
@@ -2030,10 +3415,10 @@ const queryStableQueryClient = { create: (context) => {
2030
3415
  if (node.id?.type === "Identifier" && UPPERCASE_PATTERN.test(node.id.name) && (node.init?.type === "ArrowFunctionExpression" || node.init?.type === "FunctionExpression")) componentDepth--;
2031
3416
  },
2032
3417
  CallExpression(node) {
2033
- if (node.callee?.type === "Identifier" && STABLE_HOOK_WRAPPERS.has(node.callee.name)) stableHookDepth++;
3418
+ if (isHookCall(node, STABLE_HOOK_WRAPPERS)) stableHookDepth++;
2034
3419
  },
2035
3420
  "CallExpression:exit"(node) {
2036
- if (node.callee?.type === "Identifier" && STABLE_HOOK_WRAPPERS.has(node.callee.name)) stableHookDepth--;
3421
+ if (isHookCall(node, STABLE_HOOK_WRAPPERS)) stableHookDepth = Math.max(0, stableHookDepth - 1);
2037
3422
  },
2038
3423
  NewExpression(node) {
2039
3424
  if (componentDepth <= 0) return;
@@ -2092,14 +3477,17 @@ const queryMutationMissingInvalidation = { create: (context) => ({ CallExpressio
2092
3477
  const optionsArgument = node.arguments?.[0];
2093
3478
  if (!optionsArgument || optionsArgument.type !== "ObjectExpression") return;
2094
3479
  if (!optionsArgument.properties?.some((property) => property.type === "Property" && property.key?.type === "Identifier" && property.key.name === "mutationFn")) return;
2095
- let hasInvalidation = false;
3480
+ let hasCacheUpdate = false;
2096
3481
  walkAst(optionsArgument, (child) => {
2097
- if (hasInvalidation) return;
2098
- if (child.type === "CallExpression" && child.callee?.type === "MemberExpression" && child.callee.property?.type === "Identifier" && child.callee.property.name === "invalidateQueries") hasInvalidation = true;
3482
+ if (hasCacheUpdate) return false;
3483
+ if (child.type === "CallExpression" && child.callee?.type === "MemberExpression" && child.callee.property?.type === "Identifier" && QUERY_CACHE_UPDATE_METHODS.has(child.callee.property.name)) {
3484
+ hasCacheUpdate = true;
3485
+ return false;
3486
+ }
2099
3487
  });
2100
- if (!hasInvalidation) context.report({
3488
+ if (!hasCacheUpdate) context.report({
2101
3489
  node,
2102
- message: "useMutation without invalidateQueries — stale data may remain cached after the mutation. Add onSuccess with queryClient.invalidateQueries()"
3490
+ message: "useMutation without a cache update — stale data may remain after the mutation. Call queryClient.invalidateQueries / setQueryData / resetQueries / refetchQueries inside onSuccess (or trigger a router refresh)"
2103
3491
  });
2104
3492
  } }) };
2105
3493
  const queryNoUseQueryForMutation = { create: (context) => ({ CallExpression(node) {
@@ -2153,7 +3541,7 @@ const noSecretsInClientCode = { create: (context) => ({ VariableDeclarator(node)
2153
3541
  const literalValue = node.init.value;
2154
3542
  const trailingSuffix = variableName.split("_").pop()?.toLowerCase() ?? "";
2155
3543
  const isUiConstant = SECRET_FALSE_POSITIVE_SUFFIXES.has(trailingSuffix);
2156
- if (SECRET_VARIABLE_PATTERN.test(variableName) && !isUiConstant && literalValue.length > 8) {
3544
+ if (SECRET_VARIABLE_PATTERN.test(variableName) && !isUiConstant && literalValue.length > 24) {
2157
3545
  context.report({
2158
3546
  node,
2159
3547
  message: `Possible hardcoded secret in "${variableName}" — use environment variables instead`
@@ -2188,7 +3576,7 @@ const serverAuthActions = { create: (context) => {
2188
3576
  const declaration = node.declaration;
2189
3577
  if (declaration?.type !== "FunctionDeclaration" || !declaration?.async) return;
2190
3578
  if (!(fileHasUseServerDirective || hasUseServerDirective(declaration))) return;
2191
- if (!containsAuthCheck((declaration.body?.body ?? []).slice(0, 3))) {
3579
+ if (!containsAuthCheck((declaration.body?.body ?? []).slice(0, 10))) {
2192
3580
  const functionName = declaration.id?.name ?? "anonymous";
2193
3581
  context.report({
2194
3582
  node: declaration.id ?? node,
@@ -2198,23 +3586,380 @@ const serverAuthActions = { create: (context) => {
2198
3586
  }
2199
3587
  };
2200
3588
  } };
3589
+ const MUTABLE_CONTAINER_CONSTRUCTORS = new Set([
3590
+ "Map",
3591
+ "Set",
3592
+ "WeakMap",
3593
+ "WeakSet"
3594
+ ]);
3595
+ const isMutableConstInitializer = (init) => {
3596
+ if (!init) return null;
3597
+ if (init.type === "ArrayExpression") return "[]";
3598
+ if (init.type === "ObjectExpression") return "{}";
3599
+ if (init.type === "NewExpression" && init.callee?.type === "Identifier" && MUTABLE_CONTAINER_CONSTRUCTORS.has(init.callee.name)) return `new ${init.callee.name}()`;
3600
+ return null;
3601
+ };
3602
+ const serverNoMutableModuleState = { create: (context) => {
3603
+ let fileHasUseServerDirective = false;
3604
+ return {
3605
+ Program(programNode) {
3606
+ fileHasUseServerDirective = hasDirective(programNode, "use server");
3607
+ },
3608
+ VariableDeclaration(node) {
3609
+ if (!fileHasUseServerDirective) return;
3610
+ if (node.parent?.type !== "Program") return;
3611
+ for (const declarator of node.declarations ?? []) {
3612
+ const variableName = declarator.id?.type === "Identifier" ? declarator.id.name : "<unnamed>";
3613
+ if (node.kind === "let" || node.kind === "var") {
3614
+ context.report({
3615
+ node: declarator,
3616
+ message: `Module-scoped ${node.kind} "${variableName}" in a "use server" file — this is shared across requests; move per-request data into the action body`
3617
+ });
3618
+ continue;
3619
+ }
3620
+ const containerKind = isMutableConstInitializer(declarator.init);
3621
+ if (containerKind) context.report({
3622
+ node: declarator,
3623
+ message: `Module-scoped const "${variableName} = ${containerKind}" in a "use server" file — the container itself is shared across requests; move per-request data into the action body`
3624
+ });
3625
+ }
3626
+ }
3627
+ };
3628
+ } };
3629
+ const serverCacheWithObjectLiteral = { create: (context) => {
3630
+ const cachedFunctionNames = /* @__PURE__ */ new Set();
3631
+ return {
3632
+ VariableDeclarator(node) {
3633
+ if (node.id?.type !== "Identifier") return;
3634
+ const init = node.init;
3635
+ if (init?.type !== "CallExpression") return;
3636
+ const callee = init.callee;
3637
+ if (!(callee?.type === "Identifier" && callee.name === "cache" || callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && callee.object.name === "React" && callee.property?.type === "Identifier" && callee.property.name === "cache")) return;
3638
+ cachedFunctionNames.add(node.id.name);
3639
+ },
3640
+ CallExpression(node) {
3641
+ if (node.callee?.type !== "Identifier") return;
3642
+ if (!cachedFunctionNames.has(node.callee.name)) return;
3643
+ if ((node.arguments?.[0])?.type !== "ObjectExpression") return;
3644
+ context.report({
3645
+ node,
3646
+ message: `${node.callee.name} is React.cache()-wrapped, but you're passing an object literal — the cache keys on argument identity, so a fresh {} per render bypasses dedup. Pass primitives or hoist the object`
3647
+ });
3648
+ }
3649
+ };
3650
+ } };
3651
+ const CONSOLE_DEFERRABLE_METHODS = new Set([
3652
+ "log",
3653
+ "info",
3654
+ "warn"
3655
+ ]);
3656
+ const ANALYTICS_DEFERRABLE_OBJECTS = new Set([
3657
+ "analytics",
3658
+ "posthog",
3659
+ "mixpanel",
3660
+ "segment",
3661
+ "amplitude",
3662
+ "datadog",
3663
+ "sentry"
3664
+ ]);
3665
+ const ANALYTICS_DEFERRABLE_METHODS = new Set([
3666
+ "track",
3667
+ "identify",
3668
+ "page",
3669
+ "capture",
3670
+ "captureMessage",
3671
+ "captureException",
3672
+ "log"
3673
+ ]);
3674
+ const isDeferrableSideEffectCall = (objectName, methodName) => {
3675
+ if (objectName === "console") return CONSOLE_DEFERRABLE_METHODS.has(methodName);
3676
+ if (ANALYTICS_DEFERRABLE_OBJECTS.has(objectName)) return ANALYTICS_DEFERRABLE_METHODS.has(methodName);
3677
+ return false;
3678
+ };
2201
3679
  const serverAfterNonblocking = { create: (context) => {
2202
3680
  let fileHasUseServerDirective = false;
3681
+ let serverFunctionDepth = 0;
3682
+ const enterIfServerFunction = (node) => {
3683
+ if (hasUseServerDirective(node)) serverFunctionDepth++;
3684
+ };
3685
+ const leaveIfServerFunction = (node) => {
3686
+ if (hasUseServerDirective(node)) serverFunctionDepth = Math.max(0, serverFunctionDepth - 1);
3687
+ };
2203
3688
  return {
2204
3689
  Program(programNode) {
2205
3690
  fileHasUseServerDirective = hasDirective(programNode, "use server");
2206
3691
  },
3692
+ FunctionDeclaration: enterIfServerFunction,
3693
+ "FunctionDeclaration:exit": leaveIfServerFunction,
3694
+ FunctionExpression: enterIfServerFunction,
3695
+ "FunctionExpression:exit": leaveIfServerFunction,
3696
+ ArrowFunctionExpression: enterIfServerFunction,
3697
+ "ArrowFunctionExpression:exit": leaveIfServerFunction,
2207
3698
  CallExpression(node) {
2208
- if (!fileHasUseServerDirective) return;
3699
+ if (!fileHasUseServerDirective && serverFunctionDepth === 0) return;
2209
3700
  if (node.callee?.type !== "MemberExpression") return;
2210
3701
  if (node.callee.property?.type !== "Identifier") return;
2211
3702
  const objectName = node.callee.object?.type === "Identifier" ? node.callee.object.name : null;
2212
3703
  if (!objectName) return;
2213
3704
  const methodName = node.callee.property.name;
2214
- if (!(objectName === "console" && (methodName === "log" || methodName === "info" || methodName === "warn") || objectName === "analytics" && (methodName === "track" || methodName === "identify" || methodName === "page"))) return;
3705
+ if (!isDeferrableSideEffectCall(objectName, methodName)) return;
3706
+ context.report({
3707
+ node,
3708
+ message: `${objectName}.${methodName}() in server action — wrap in \`after(() => ${objectName}.${methodName}(...))\` so it doesn't delay the user-visible response`
3709
+ });
3710
+ }
3711
+ };
3712
+ } };
3713
+ const ROUTE_HANDLER_HTTP_METHODS = new Set([
3714
+ "GET",
3715
+ "POST",
3716
+ "PUT",
3717
+ "PATCH",
3718
+ "DELETE",
3719
+ "OPTIONS",
3720
+ "HEAD"
3721
+ ]);
3722
+ const STATIC_IO_FUNCTIONS = new Set([
3723
+ "readFileSync",
3724
+ "readFile",
3725
+ "readdir",
3726
+ "readdirSync",
3727
+ "stat",
3728
+ "statSync",
3729
+ "access",
3730
+ "accessSync"
3731
+ ]);
3732
+ const isStaticIoCall = (call) => {
3733
+ if (call.type !== "CallExpression") return false;
3734
+ const callee = call.callee;
3735
+ if (callee?.type === "Identifier" && STATIC_IO_FUNCTIONS.has(callee.name)) return true;
3736
+ if (callee?.type !== "MemberExpression") return false;
3737
+ const propertyName = callee.property?.type === "Identifier" ? callee.property.name : null;
3738
+ if (!propertyName || !STATIC_IO_FUNCTIONS.has(propertyName)) return false;
3739
+ return true;
3740
+ };
3741
+ const isFetchOfImportMetaUrl = (call) => {
3742
+ if (call.type !== "CallExpression") return false;
3743
+ if (call.callee?.type !== "Identifier" || call.callee.name !== "fetch") return false;
3744
+ const arg = call.arguments?.[0];
3745
+ if (!arg) return false;
3746
+ if (arg.type !== "NewExpression") return false;
3747
+ if (arg.callee?.type !== "Identifier" || arg.callee.name !== "URL") return false;
3748
+ const secondArg = arg.arguments?.[1];
3749
+ if (!secondArg) return false;
3750
+ return secondArg.type === "MemberExpression" && secondArg.object?.type === "MetaProperty" && secondArg.property?.type === "Identifier" && secondArg.property.name === "url";
3751
+ };
3752
+ const callReadsHandlerArgs = (call, handlerParamNames) => {
3753
+ if (handlerParamNames.size === 0) return false;
3754
+ let referencesArg = false;
3755
+ walkAst(call, (child) => {
3756
+ if (referencesArg) return;
3757
+ if (child.type === "Identifier" && handlerParamNames.has(child.name)) referencesArg = true;
3758
+ });
3759
+ return referencesArg;
3760
+ };
3761
+ const DERIVING_ARRAY_METHODS = new Set([
3762
+ "toSorted",
3763
+ "toReversed",
3764
+ "filter",
3765
+ "map",
3766
+ "slice"
3767
+ ]);
3768
+ const getRootIdentifierName = (node) => {
3769
+ let cursor = node;
3770
+ while (cursor && (cursor.type === "MemberExpression" || cursor.type === "CallExpression")) if (cursor.type === "MemberExpression") cursor = cursor.object;
3771
+ else if (cursor.type === "CallExpression") {
3772
+ const callee = cursor.callee;
3773
+ if (callee?.type === "MemberExpression") cursor = callee.object;
3774
+ else return null;
3775
+ }
3776
+ return cursor?.type === "Identifier" ? cursor.name : null;
3777
+ };
3778
+ const expressionDerivesFromIdentifier = (node, identifierName) => {
3779
+ if (node.type !== "CallExpression") return false;
3780
+ const callee = node.callee;
3781
+ if (callee?.type !== "MemberExpression") return false;
3782
+ if (callee.property?.type !== "Identifier") return false;
3783
+ if (!DERIVING_ARRAY_METHODS.has(callee.property.name)) return false;
3784
+ return getRootIdentifierName(callee) === identifierName;
3785
+ };
3786
+ const serverDedupProps = { create: (context) => ({ JSXOpeningElement(node) {
3787
+ const identifierAttributes = /* @__PURE__ */ new Map();
3788
+ const derivedAttributes = [];
3789
+ for (const attr of node.attributes ?? []) {
3790
+ if (attr.type !== "JSXAttribute") continue;
3791
+ if (attr.name?.type !== "JSXIdentifier") continue;
3792
+ if (attr.value?.type !== "JSXExpressionContainer") continue;
3793
+ const expression = attr.value.expression;
3794
+ if (!expression) continue;
3795
+ if (expression.type === "Identifier") identifierAttributes.set(expression.name, attr.name.name);
3796
+ else if (expression.type === "CallExpression") {
3797
+ const root = getRootIdentifierName(expression);
3798
+ if (root && DERIVING_ARRAY_METHODS.has(getDerivingMethodName(expression) ?? "")) {
3799
+ if (expressionDerivesFromIdentifier(expression, root)) derivedAttributes.push({
3800
+ propName: attr.name.name,
3801
+ rootName: root,
3802
+ node: attr
3803
+ });
3804
+ }
3805
+ }
3806
+ }
3807
+ for (const derived of derivedAttributes) {
3808
+ const sourcePropName = identifierAttributes.get(derived.rootName);
3809
+ if (sourcePropName) context.report({
3810
+ node: derived.node,
3811
+ message: `"${derived.propName}" is derived from "${sourcePropName}" (same source: ${derived.rootName}) — passing both doubles RSC serialization. Pass the source once and derive on the client`
3812
+ });
3813
+ }
3814
+ } }) };
3815
+ const getDerivingMethodName = (node) => {
3816
+ if (node.type !== "CallExpression") return null;
3817
+ if (node.callee?.type !== "MemberExpression") return null;
3818
+ if (node.callee.property?.type !== "Identifier") return null;
3819
+ return node.callee.property.name;
3820
+ };
3821
+ const PAGES_ROUTER_API_PATH_PATTERN = /\/pages\/api\//;
3822
+ const inspectHandlerBody = (context, handlerBody, handlerLabel, handlerParamNames) => {
3823
+ walkAst(handlerBody, (child) => {
3824
+ let staticCall = null;
3825
+ if (isStaticIoCall(child)) staticCall = child;
3826
+ else if (isFetchOfImportMetaUrl(child)) staticCall = child;
3827
+ else if (child.type === "AwaitExpression" && child.argument && (isStaticIoCall(child.argument) || isFetchOfImportMetaUrl(child.argument))) staticCall = child.argument;
3828
+ if (!staticCall) return;
3829
+ if (callReadsHandlerArgs(staticCall, handlerParamNames)) return;
3830
+ const calleeText = staticCall.callee?.type === "MemberExpression" && staticCall.callee.property?.type === "Identifier" ? `${staticCall.callee.object?.type === "Identifier" ? staticCall.callee.object.name : "?"}.${staticCall.callee.property.name}` : staticCall.callee?.type === "Identifier" ? staticCall.callee.name : "io";
3831
+ context.report({
3832
+ node: staticCall,
3833
+ message: `${calleeText}() in ${handlerLabel} reads the same static asset every request — hoist to module scope so the read happens once at module load`
3834
+ });
3835
+ });
3836
+ };
3837
+ const collectIdentifierParams = (params) => {
3838
+ const names = /* @__PURE__ */ new Set();
3839
+ for (const param of params) if (param.type === "Identifier") names.add(param.name);
3840
+ return names;
3841
+ };
3842
+ const serverHoistStaticIo = { create: (context) => ({
3843
+ ExportNamedDeclaration(node) {
3844
+ const declaration = node.declaration;
3845
+ if (declaration?.type !== "FunctionDeclaration") return;
3846
+ const handlerName = declaration.id?.name;
3847
+ if (!handlerName || !ROUTE_HANDLER_HTTP_METHODS.has(handlerName)) return;
3848
+ if (declaration.body?.type !== "BlockStatement") return;
3849
+ inspectHandlerBody(context, declaration.body, `${handlerName} route handler`, collectIdentifierParams(declaration.params ?? []));
3850
+ },
3851
+ ExportDefaultDeclaration(node) {
3852
+ const filename = context.getFilename?.() ?? "";
3853
+ if (!PAGES_ROUTER_API_PATH_PATTERN.test(filename)) return;
3854
+ const declaration = node.declaration;
3855
+ if (!declaration || declaration.type !== "FunctionDeclaration" && declaration.type !== "FunctionExpression" && declaration.type !== "ArrowFunctionExpression") return;
3856
+ if (!declaration.async) return;
3857
+ const body = declaration.body;
3858
+ if (body?.type !== "BlockStatement") return;
3859
+ inspectHandlerBody(context, body, "pages/api handler", collectIdentifierParams(declaration.params ?? []));
3860
+ }
3861
+ }) };
3862
+ const collectDeclaredNames = (declaration) => {
3863
+ const names = /* @__PURE__ */ new Set();
3864
+ for (const declarator of declaration.declarations ?? []) if (declarator.id?.type === "Identifier") names.add(declarator.id.name);
3865
+ else if (declarator.id?.type === "ObjectPattern") {
3866
+ for (const property of declarator.id.properties ?? []) if (property.type === "Property" && property.value?.type === "Identifier") names.add(property.value.name);
3867
+ else if (property.type === "RestElement" && property.argument?.type === "Identifier") names.add(property.argument.name);
3868
+ } else if (declarator.id?.type === "ArrayPattern") {
3869
+ for (const element of declarator.id.elements ?? []) if (element?.type === "Identifier") names.add(element.name);
3870
+ }
3871
+ return names;
3872
+ };
3873
+ const declarationStartsWithAwait = (declaration) => {
3874
+ for (const declarator of declaration.declarations ?? []) if (declarator.init?.type === "AwaitExpression") return true;
3875
+ return false;
3876
+ };
3877
+ const declarationReadsAnyName = (declaration, names) => {
3878
+ if (names.size === 0) return false;
3879
+ let didRead = false;
3880
+ walkAst(declaration, (child) => {
3881
+ if (didRead) return;
3882
+ if (child.type === "Identifier" && names.has(child.name)) didRead = true;
3883
+ });
3884
+ return didRead;
3885
+ };
3886
+ const serverSequentialIndependentAwait = { create: (context) => {
3887
+ const inspectStatements = (statements) => {
3888
+ for (let statementIndex = 0; statementIndex < statements.length - 1; statementIndex++) {
3889
+ const currentStatement = statements[statementIndex];
3890
+ if (currentStatement.type !== "VariableDeclaration") continue;
3891
+ if (!declarationStartsWithAwait(currentStatement)) continue;
3892
+ const declaredNames = collectDeclaredNames(currentStatement);
3893
+ const nextStatement = statements[statementIndex + 1];
3894
+ if (nextStatement.type !== "VariableDeclaration") continue;
3895
+ if (!declarationStartsWithAwait(nextStatement)) continue;
3896
+ if (declarationReadsAnyName(nextStatement, declaredNames)) continue;
3897
+ context.report({
3898
+ node: nextStatement,
3899
+ message: "Sequential `await` without a data dependency on the previous result — wrap the independent calls in `Promise.all([...])` so they race instead of waterfalling"
3900
+ });
3901
+ statementIndex++;
3902
+ }
3903
+ };
3904
+ const visitFunctionBody = (node) => {
3905
+ if (!node.async) return;
3906
+ if (node.body?.type !== "BlockStatement") return;
3907
+ inspectStatements(node.body.body ?? []);
3908
+ };
3909
+ return {
3910
+ FunctionDeclaration: visitFunctionBody,
3911
+ FunctionExpression: visitFunctionBody,
3912
+ ArrowFunctionExpression: visitFunctionBody
3913
+ };
3914
+ } };
3915
+ const isFetchCall = (node) => {
3916
+ if (node.type !== "CallExpression") return false;
3917
+ return node.callee?.type === "Identifier" && node.callee.name === "fetch";
3918
+ };
3919
+ const objectExpressionHasNextRevalidate = (objectExpression) => {
3920
+ if (objectExpression.type !== "ObjectExpression") return false;
3921
+ for (const property of objectExpression.properties ?? []) {
3922
+ if (property.type !== "Property") continue;
3923
+ if (property.key?.type !== "Identifier") continue;
3924
+ if (property.key.name === "cache") return true;
3925
+ if (property.key.name !== "next") continue;
3926
+ if (property.value?.type !== "ObjectExpression") return true;
3927
+ for (const innerProperty of property.value.properties ?? []) {
3928
+ if (innerProperty.type !== "Property") continue;
3929
+ if (innerProperty.key?.type !== "Identifier") continue;
3930
+ if (innerProperty.key.name === "revalidate" || innerProperty.key.name === "tags") return true;
3931
+ }
3932
+ return true;
3933
+ }
3934
+ return false;
3935
+ };
3936
+ const APP_ROUTER_FILE_PATTERN = /\/app\/(?:[^/]+\/)*(?:route|page|layout|template|loading|error|default)\.(?:tsx?|jsx?)$/;
3937
+ const NON_PROJECT_PATH_PATTERN = /\/(?:node_modules|dist|build|\.next)\//;
3938
+ const serverFetchWithoutRevalidate = { create: (context) => {
3939
+ let isServerSideFile = false;
3940
+ return {
3941
+ Program(node) {
3942
+ const filename = context.getFilename?.() ?? "";
3943
+ if (!APP_ROUTER_FILE_PATTERN.test(filename)) {
3944
+ isServerSideFile = false;
3945
+ return;
3946
+ }
3947
+ if (NON_PROJECT_PATH_PATTERN.test(filename)) {
3948
+ isServerSideFile = false;
3949
+ return;
3950
+ }
3951
+ isServerSideFile = !(node.body ?? []).some((statement) => statement.type === "ExpressionStatement" && statement.expression?.type === "Literal" && statement.expression.value === "use client");
3952
+ },
3953
+ CallExpression(node) {
3954
+ if (!isServerSideFile) return;
3955
+ if (!isFetchCall(node)) return;
3956
+ const optionsArg = node.arguments?.[1];
3957
+ if (optionsArg && objectExpressionHasNextRevalidate(optionsArg)) return;
3958
+ const urlArg = node.arguments?.[0];
3959
+ const urlText = urlArg?.type === "Literal" && typeof urlArg.value === "string" ? `"${urlArg.value}"` : "url";
2215
3960
  context.report({
2216
3961
  node,
2217
- message: `${objectName}.${methodName}() in server actionuse after() for non-blocking logging/analytics`
3962
+ message: `fetch(${urlText}) in a Server Component / route handler defaults to forever-caching pass { next: { revalidate: <seconds> } } / { next: { tags: [...] } } / { cache: "no-store" } so stale data doesn't quietly persist`
2218
3963
  });
2219
3964
  }
2220
3965
  };
@@ -2326,8 +4071,7 @@ const tanstackStartServerFnValidateInput = { create: (context) => ({ CallExpress
2326
4071
  const tanstackStartNoUseEffectFetch = { create: (context) => ({ CallExpression(node) {
2327
4072
  const filename = context.getFilename?.() ?? "";
2328
4073
  if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
2329
- if (node.callee?.type !== "Identifier") return;
2330
- if (node.callee.name !== "useEffect" && node.callee.name !== "useLayoutEffect") return;
4074
+ if (!isHookCall(node, EFFECT_HOOK_NAMES)) return;
2331
4075
  const callback = node.arguments?.[0];
2332
4076
  if (!callback) return;
2333
4077
  let hasFetchCall = false;
@@ -2402,15 +4146,17 @@ const tanstackStartServerFnMethodOrder = { create: (context) => ({ CallExpressio
2402
4146
  }
2403
4147
  } }) };
2404
4148
  const tanstackStartNoNavigateInRender = { create: (context) => {
2405
- let effectDepth = 0;
4149
+ let deferredCallbackDepth = 0;
2406
4150
  let eventHandlerDepth = 0;
4151
+ const isDeferredHookCall = (node) => isHookCall(node, EFFECT_HOOK_NAMES) || isHookCall(node, "useCallback") || isHookCall(node, "useMemo");
4152
+ const isEventHandlerAttribute = (node) => node.name?.type === "JSXIdentifier" && typeof node.name.name === "string" && node.name.name.startsWith("on") && UPPERCASE_PATTERN.test(node.name.name.charAt(2));
2407
4153
  return {
2408
4154
  CallExpression(node) {
2409
4155
  const filename = context.getFilename?.() ?? "";
2410
4156
  if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
2411
- if (node.callee?.type === "Identifier" && (node.callee.name === "useEffect" || node.callee.name === "useLayoutEffect")) effectDepth++;
2412
- if (effectDepth > 0 || eventHandlerDepth > 0) return;
2413
- if (node.callee?.type === "Identifier" && node.callee.name === "navigate" && node.arguments?.length > 0) context.report({
4157
+ if (isDeferredHookCall(node)) deferredCallbackDepth++;
4158
+ if (deferredCallbackDepth > 0 || eventHandlerDepth > 0) return;
4159
+ if (node.callee?.type === "Identifier" && node.callee.name === "navigate" && (node.arguments?.length ?? 0) > 0) context.report({
2414
4160
  node,
2415
4161
  message: "navigate() called during render — use redirect() in beforeLoad/loader for route-level redirects"
2416
4162
  });
@@ -2418,17 +4164,17 @@ const tanstackStartNoNavigateInRender = { create: (context) => {
2418
4164
  "CallExpression:exit"(node) {
2419
4165
  const filename = context.getFilename?.() ?? "";
2420
4166
  if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
2421
- if (node.callee?.type === "Identifier" && (node.callee.name === "useEffect" || node.callee.name === "useLayoutEffect")) effectDepth--;
4167
+ if (isDeferredHookCall(node)) deferredCallbackDepth = Math.max(0, deferredCallbackDepth - 1);
2422
4168
  },
2423
4169
  JSXAttribute(node) {
2424
4170
  const filename = context.getFilename?.() ?? "";
2425
4171
  if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
2426
- if (node.name?.type === "JSXIdentifier" && node.name.name.startsWith("on") && UPPERCASE_PATTERN.test(node.name.name.charAt(2))) eventHandlerDepth++;
4172
+ if (isEventHandlerAttribute(node)) eventHandlerDepth++;
2427
4173
  },
2428
4174
  "JSXAttribute:exit"(node) {
2429
4175
  const filename = context.getFilename?.() ?? "";
2430
4176
  if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
2431
- if (node.name?.type === "JSXIdentifier" && node.name.name.startsWith("on") && UPPERCASE_PATTERN.test(node.name.name.charAt(2))) eventHandlerDepth--;
4177
+ if (isEventHandlerAttribute(node)) eventHandlerDepth = Math.max(0, eventHandlerDepth - 1);
2432
4178
  }
2433
4179
  };
2434
4180
  } };
@@ -2455,6 +4201,17 @@ const tanstackStartNoUseServerInHandler = { create: (context) => ({ CallExpressi
2455
4201
  message: "\"use server\" inside createServerFn handler — TanStack Start handles this automatically, remove the directive"
2456
4202
  });
2457
4203
  } }) };
4204
+ const SAFE_BUILD_ENV_VARS = new Set([
4205
+ "NODE_ENV",
4206
+ "MODE",
4207
+ "DEV",
4208
+ "PROD"
4209
+ ]);
4210
+ const SECRET_KEYWORD_PATTERN = /(?:secret|token|api[_]?key|password|private)/i;
4211
+ const isLikelySecret = (envVarName) => {
4212
+ if (SAFE_BUILD_ENV_VARS.has(envVarName)) return false;
4213
+ return SECRET_KEYWORD_PATTERN.test(envVarName);
4214
+ };
2458
4215
  const tanstackStartNoSecretsInLoader = { create: (context) => ({ CallExpression(node) {
2459
4216
  const optionsObject = getRouteOptionsObject(node);
2460
4217
  if (!optionsObject) return;
@@ -2464,11 +4221,15 @@ const tanstackStartNoSecretsInLoader = { create: (context) => ({ CallExpression(
2464
4221
  if (keyName !== "loader" && keyName !== "beforeLoad") continue;
2465
4222
  walkAst(property.value ?? property, (child) => {
2466
4223
  if (child.type !== "MemberExpression") return;
2467
- 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") {
2468
- const envVarName = child.property?.type === "Identifier" ? child.property.name : null;
2469
- if (envVarName && !envVarName.startsWith("VITE_")) context.report({
4224
+ const isProcessEnvAccess = child.object?.type === "MemberExpression" && child.object.object?.type === "Identifier" && child.object.object.name === "process" && child.object.property?.type === "Identifier" && child.object.property.name === "env";
4225
+ const isImportMetaEnvAccess = child.object?.type === "MemberExpression" && child.object.object?.type === "MetaProperty" && child.object.property?.type === "Identifier" && child.object.property.name === "env";
4226
+ if (!isProcessEnvAccess && !isImportMetaEnvAccess) return;
4227
+ const envVarName = child.property?.type === "Identifier" ? child.property.name : null;
4228
+ if (envVarName && isLikelySecret(envVarName)) {
4229
+ const envSource = isImportMetaEnvAccess ? "import.meta.env" : "process.env";
4230
+ context.report({
2470
4231
  node: child,
2471
- message: `process.env.${envVarName} in ${keyName} — loaders are isomorphic and may leak secrets to the client. Move to a createServerFn()`
4232
+ message: `${envSource}.${envVarName} in ${keyName} — loaders are isomorphic and may leak secrets to the client. Move to a createServerFn()`
2472
4233
  });
2473
4234
  }
2474
4235
  });
@@ -2522,6 +4283,7 @@ const hasTopLevelAwait = (statement) => {
2522
4283
  if (statement.type === "VariableDeclaration") return statement.declarations?.some((declarator) => declarator.init?.type === "AwaitExpression");
2523
4284
  if (statement.type === "ExpressionStatement") return statement.expression?.type === "AwaitExpression" || statement.expression?.type === "AssignmentExpression" && statement.expression.right?.type === "AwaitExpression";
2524
4285
  if (statement.type === "ReturnStatement") return statement.argument?.type === "AwaitExpression";
4286
+ if (statement.type === "ForOfStatement" && statement.await) return true;
2525
4287
  return false;
2526
4288
  };
2527
4289
  const tanstackStartLoaderParallelFetch = { create: (context) => ({ CallExpression(node) {
@@ -2550,7 +4312,7 @@ const tanstackStartLoaderParallelFetch = { create: (context) => ({ CallExpressio
2550
4312
  //#endregion
2551
4313
  //#region src/plugin/rules/state-and-effects.ts
2552
4314
  const noDerivedStateEffect = { create: (context) => ({ CallExpression(node) {
2553
- if (!isHookCall(node, EFFECT_HOOK_NAMES) || node.arguments.length < 2) return;
4315
+ if (!isHookCall(node, EFFECT_HOOK_NAMES) || (node.arguments?.length ?? 0) < 2) return;
2554
4316
  const callback = getEffectCallback(node);
2555
4317
  if (!callback) return;
2556
4318
  const depsNode = node.arguments[1];
@@ -2604,7 +4366,7 @@ const noCascadingSetState = { create: (context) => ({ CallExpression(node) {
2604
4366
  });
2605
4367
  } }) };
2606
4368
  const noEffectEventHandler = { create: (context) => ({ CallExpression(node) {
2607
- if (!isHookCall(node, EFFECT_HOOK_NAMES) || node.arguments.length < 2) return;
4369
+ if (!isHookCall(node, EFFECT_HOOK_NAMES) || (node.arguments?.length ?? 0) < 2) return;
2608
4370
  const callback = getEffectCallback(node);
2609
4371
  if (!callback) return;
2610
4372
  const depsNode = node.arguments[1];
@@ -2619,24 +4381,57 @@ const noEffectEventHandler = { create: (context) => ({ CallExpression(node) {
2619
4381
  });
2620
4382
  } }) };
2621
4383
  const noDerivedUseState = { create: (context) => {
2622
- const componentPropNames = /* @__PURE__ */ new Set();
4384
+ const componentPropStack = [];
4385
+ const isPropName = (name) => {
4386
+ for (let stackIndex = componentPropStack.length - 1; stackIndex >= 0; stackIndex--) if (componentPropStack[stackIndex].has(name)) return true;
4387
+ return false;
4388
+ };
4389
+ const isFunctionLikeVariableDeclarator = (node) => {
4390
+ if (node.type !== "VariableDeclarator") return false;
4391
+ return node.init?.type === "ArrowFunctionExpression" || node.init?.type === "FunctionExpression";
4392
+ };
2623
4393
  return {
2624
4394
  FunctionDeclaration(node) {
2625
- if (!node.id?.name || !isUppercaseName(node.id.name)) return;
2626
- for (const name of extractDestructuredPropNames(node.params ?? [])) componentPropNames.add(name);
4395
+ if (!node.id?.name || !isUppercaseName(node.id.name)) {
4396
+ componentPropStack.push(/* @__PURE__ */ new Set());
4397
+ return;
4398
+ }
4399
+ componentPropStack.push(extractDestructuredPropNames(node.params ?? []));
4400
+ },
4401
+ "FunctionDeclaration:exit"() {
4402
+ componentPropStack.pop();
2627
4403
  },
2628
4404
  VariableDeclarator(node) {
2629
- if (!isComponentAssignment(node)) return;
2630
- for (const name of extractDestructuredPropNames(node.init?.params ?? [])) componentPropNames.add(name);
4405
+ if (isComponentAssignment(node)) {
4406
+ componentPropStack.push(extractDestructuredPropNames(node.init?.params ?? []));
4407
+ return;
4408
+ }
4409
+ if (isFunctionLikeVariableDeclarator(node)) componentPropStack.push(/* @__PURE__ */ new Set());
4410
+ },
4411
+ "VariableDeclarator:exit"(node) {
4412
+ if (isComponentAssignment(node) || isFunctionLikeVariableDeclarator(node)) componentPropStack.pop();
2631
4413
  },
2632
4414
  CallExpression(node) {
2633
4415
  if (!isHookCall(node, "useState") || !node.arguments?.length) return;
4416
+ if (componentPropStack.length === 0) return;
2634
4417
  const initializer = node.arguments[0];
2635
- if (initializer.type !== "Identifier") return;
2636
- if (componentPropNames.has(initializer.name)) context.report({
2637
- node,
2638
- message: `useState initialized from prop "${initializer.name}" — if this value should stay in sync with the prop, derive it during render instead`
2639
- });
4418
+ if (initializer.type === "Identifier" && isPropName(initializer.name)) {
4419
+ context.report({
4420
+ node,
4421
+ message: `useState initialized from prop "${initializer.name}" — if this value should stay in sync with the prop, derive it during render instead`
4422
+ });
4423
+ return;
4424
+ }
4425
+ if (initializer.type === "MemberExpression" && !initializer.computed) {
4426
+ let rootIdentifierName = null;
4427
+ let cursor = initializer;
4428
+ while (cursor?.type === "MemberExpression") cursor = cursor.object;
4429
+ if (cursor?.type === "Identifier") rootIdentifierName = cursor.name;
4430
+ if (rootIdentifierName && isPropName(rootIdentifierName)) context.report({
4431
+ node,
4432
+ message: `useState initialized from prop "${rootIdentifierName}" — if this value should stay in sync with the prop, derive it during render instead`
4433
+ });
4434
+ }
2640
4435
  }
2641
4436
  };
2642
4437
  } };
@@ -2675,15 +4470,42 @@ const rerenderLazyStateInit = { create: (context) => ({ CallExpression(node) {
2675
4470
  message: `useState(${calleeName}()) calls initializer on every render — use useState(() => ${calleeName}()) for lazy initialization`
2676
4471
  });
2677
4472
  } }) };
4473
+ const STATE_ARITHMETIC_OPERATORS = new Set([
4474
+ "+",
4475
+ "-",
4476
+ "*",
4477
+ "/",
4478
+ "%",
4479
+ "**"
4480
+ ]);
4481
+ const deriveStateVariableName = (setterName) => {
4482
+ if (!setterName.startsWith("set") || setterName.length < 4) return null;
4483
+ return setterName.charAt(3).toLowerCase() + setterName.slice(4);
4484
+ };
2678
4485
  const rerenderFunctionalSetstate = { create: (context) => ({ CallExpression(node) {
2679
4486
  if (!isSetterCall(node)) return;
2680
4487
  if (!node.arguments?.length) return;
2681
4488
  const calleeName = node.callee.name;
2682
4489
  const argument = node.arguments[0];
2683
- if (argument.type === "BinaryExpression" && (argument.operator === "+" || argument.operator === "-") && argument.left?.type === "Identifier") context.report({
2684
- node,
2685
- message: `${calleeName}(${argument.left.name} ${argument.operator} ...) use functional update to avoid stale closures`
2686
- });
4490
+ const expectedStateName = deriveStateVariableName(calleeName);
4491
+ if (argument.type === "BinaryExpression" && STATE_ARITHMETIC_OPERATORS.has(argument.operator) && expectedStateName) {
4492
+ const matchesExpected = (operand) => operand?.type === "Identifier" && operand.name === expectedStateName;
4493
+ const stateIdentifier = matchesExpected(argument.left) ? argument.left : matchesExpected(argument.right) ? argument.right : null;
4494
+ if (stateIdentifier) {
4495
+ context.report({
4496
+ node,
4497
+ message: `${calleeName}(${stateIdentifier.name} ${argument.operator} ...) — use functional update to avoid stale closures`
4498
+ });
4499
+ return;
4500
+ }
4501
+ }
4502
+ if (argument.type === "UpdateExpression" && (argument.operator === "++" || argument.operator === "--") && argument.argument?.type === "Identifier" && argument.argument.name === expectedStateName) {
4503
+ const display = argument.prefix ? `${argument.operator}${argument.argument.name}` : `${argument.argument.name}${argument.operator}`;
4504
+ context.report({
4505
+ node,
4506
+ message: `${calleeName}(${display}) — use functional update to avoid stale closures (and reading the post-increment value bug)`
4507
+ });
4508
+ }
2687
4509
  } }) };
2688
4510
  const rerenderDependencies = { create: (context) => ({ CallExpression(node) {
2689
4511
  if (!isHookCall(node, HOOKS_WITH_DEPS) || node.arguments.length < 2) return;
@@ -2701,6 +4523,295 @@ const rerenderDependencies = { create: (context) => ({ CallExpression(node) {
2701
4523
  });
2702
4524
  }
2703
4525
  } }) };
4526
+ const noPropCallbackInEffect = { create: (context) => {
4527
+ const componentPropParamStack = [];
4528
+ const enterComponentParams = (params) => {
4529
+ const propNames = extractDestructuredPropNames(params ?? []);
4530
+ componentPropParamStack.push(propNames);
4531
+ };
4532
+ const isPropName = (name) => {
4533
+ for (let stackIndex = componentPropParamStack.length - 1; stackIndex >= 0; stackIndex--) if (componentPropParamStack[stackIndex].has(name)) return true;
4534
+ return false;
4535
+ };
4536
+ const isFunctionLikeVariableDeclarator = (node) => {
4537
+ if (node.type !== "VariableDeclarator") return false;
4538
+ return node.init?.type === "ArrowFunctionExpression" || node.init?.type === "FunctionExpression";
4539
+ };
4540
+ return {
4541
+ FunctionDeclaration(node) {
4542
+ if (!node.id?.name || !isUppercaseName(node.id.name)) {
4543
+ componentPropParamStack.push(/* @__PURE__ */ new Set());
4544
+ return;
4545
+ }
4546
+ enterComponentParams(node.params);
4547
+ },
4548
+ "FunctionDeclaration:exit"() {
4549
+ componentPropParamStack.pop();
4550
+ },
4551
+ VariableDeclarator(node) {
4552
+ if (isComponentAssignment(node)) {
4553
+ enterComponentParams(node.init?.params);
4554
+ return;
4555
+ }
4556
+ if (isFunctionLikeVariableDeclarator(node)) componentPropParamStack.push(/* @__PURE__ */ new Set());
4557
+ },
4558
+ "VariableDeclarator:exit"(node) {
4559
+ if (isComponentAssignment(node) || isFunctionLikeVariableDeclarator(node)) componentPropParamStack.pop();
4560
+ },
4561
+ CallExpression(node) {
4562
+ if (!isHookCall(node, EFFECT_HOOK_NAMES) || (node.arguments?.length ?? 0) < 2) return;
4563
+ if (componentPropParamStack.length === 0) return;
4564
+ const callback = getEffectCallback(node);
4565
+ if (!callback) return;
4566
+ const depsNode = node.arguments[1];
4567
+ if (depsNode.type !== "ArrayExpression" || !depsNode.elements?.length) return;
4568
+ const bodyStatements = getCallbackStatements(callback);
4569
+ for (const stmt of bodyStatements) {
4570
+ let invokedPropName = null;
4571
+ if (stmt.type === "ExpressionStatement" && stmt.expression?.type === "CallExpression" && stmt.expression.callee?.type === "Identifier" && isPropName(stmt.expression.callee.name)) invokedPropName = stmt.expression.callee.name;
4572
+ if (!invokedPropName) continue;
4573
+ if (!depsNode.elements.some((element) => element?.type === "Identifier" && !isPropName(element.name))) continue;
4574
+ context.report({
4575
+ node: stmt,
4576
+ message: `useEffect calls prop callback "${invokedPropName}" with local state in deps — this is the "lift state via callback" anti-pattern; lift state into a shared Provider so both sides read the same source`
4577
+ });
4578
+ }
4579
+ }
4580
+ };
4581
+ } };
4582
+ const noEffectEventInDeps = { create: (context) => {
4583
+ const componentBindingStack = [];
4584
+ const isEffectEventBinding = (name) => {
4585
+ for (let stackIndex = componentBindingStack.length - 1; stackIndex >= 0; stackIndex--) if (componentBindingStack[stackIndex].has(name)) return true;
4586
+ return false;
4587
+ };
4588
+ const enterComponent = () => {
4589
+ componentBindingStack.push(/* @__PURE__ */ new Set());
4590
+ };
4591
+ const exitComponent = () => {
4592
+ componentBindingStack.pop();
4593
+ };
4594
+ return {
4595
+ FunctionDeclaration(node) {
4596
+ if (!node.id?.name || !isUppercaseName(node.id.name)) return;
4597
+ enterComponent();
4598
+ },
4599
+ "FunctionDeclaration:exit"(node) {
4600
+ if (!node.id?.name || !isUppercaseName(node.id.name)) return;
4601
+ exitComponent();
4602
+ },
4603
+ VariableDeclarator(node) {
4604
+ if (isComponentAssignment(node)) {
4605
+ enterComponent();
4606
+ return;
4607
+ }
4608
+ if (componentBindingStack.length === 0) return;
4609
+ if (node.id?.type !== "Identifier") return;
4610
+ const init = node.init;
4611
+ if (!init || init.type !== "CallExpression") return;
4612
+ if (!isHookCall(init, "useEffectEvent")) return;
4613
+ componentBindingStack[componentBindingStack.length - 1].add(node.id.name);
4614
+ },
4615
+ "VariableDeclarator:exit"(node) {
4616
+ if (isComponentAssignment(node)) exitComponent();
4617
+ },
4618
+ CallExpression(node) {
4619
+ if (!isHookCall(node, HOOKS_WITH_DEPS) || node.arguments.length < 2) return;
4620
+ if (componentBindingStack.length === 0) return;
4621
+ const depsNode = node.arguments[1];
4622
+ if (depsNode.type !== "ArrayExpression") return;
4623
+ for (const element of depsNode.elements ?? []) {
4624
+ if (element?.type !== "Identifier") continue;
4625
+ if (isEffectEventBinding(element.name)) context.report({
4626
+ node: element,
4627
+ message: `"${element.name}" is from useEffectEvent and must not be in the deps array — its identity is intentionally unstable; call it inside the effect without listing it`
4628
+ });
4629
+ }
4630
+ }
4631
+ };
4632
+ } };
4633
+ const collectUseStateBindings = (componentBody) => {
4634
+ const bindings = [];
4635
+ if (componentBody?.type !== "BlockStatement") return bindings;
4636
+ for (const statement of componentBody.body ?? []) {
4637
+ if (statement.type !== "VariableDeclaration") continue;
4638
+ for (const declarator of statement.declarations ?? []) {
4639
+ if (declarator.id?.type !== "ArrayPattern") continue;
4640
+ const elements = declarator.id.elements ?? [];
4641
+ if (elements.length < 2) continue;
4642
+ const valueElement = elements[0];
4643
+ const setterElement = elements[1];
4644
+ if (valueElement?.type !== "Identifier" || setterElement?.type !== "Identifier" || !isSetterIdentifier(setterElement.name)) continue;
4645
+ if (declarator.init?.type !== "CallExpression") continue;
4646
+ if (!isHookCall(declarator.init, "useState")) continue;
4647
+ bindings.push({
4648
+ valueName: valueElement.name,
4649
+ setterName: setterElement.name,
4650
+ declarator
4651
+ });
4652
+ }
4653
+ }
4654
+ return bindings;
4655
+ };
4656
+ const collectReturnExpressions = (componentBody) => {
4657
+ if (componentBody?.type !== "BlockStatement") return [];
4658
+ const returns = [];
4659
+ for (const statement of componentBody.body ?? []) {
4660
+ if (statement.type === "ReturnStatement" && statement.argument) {
4661
+ returns.push(statement.argument);
4662
+ continue;
4663
+ }
4664
+ walkInsideStatementBlocks(statement, (child) => {
4665
+ if (child.type === "ReturnStatement" && child.argument) returns.push(child.argument);
4666
+ });
4667
+ }
4668
+ return returns;
4669
+ };
4670
+ const walkInsideStatementBlocks = (node, visitor) => {
4671
+ if (!node || typeof node !== "object") return;
4672
+ if (node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression") return;
4673
+ visitor(node);
4674
+ for (const key of Object.keys(node)) {
4675
+ if (key === "parent") continue;
4676
+ const child = node[key];
4677
+ if (Array.isArray(child)) {
4678
+ for (const item of child) if (item && typeof item === "object" && item.type) walkInsideStatementBlocks(item, visitor);
4679
+ } else if (child && typeof child === "object" && child.type) walkInsideStatementBlocks(child, visitor);
4680
+ }
4681
+ };
4682
+ const expressionReadsName = (expression, name) => {
4683
+ let didRead = false;
4684
+ walkAst(expression, (child) => {
4685
+ if (didRead) return;
4686
+ if (child.type === "Identifier" && child.name === name) didRead = true;
4687
+ });
4688
+ return didRead;
4689
+ };
4690
+ const rerenderStateOnlyInHandlers = { create: (context) => {
4691
+ const checkComponent = (componentBody) => {
4692
+ if (!componentBody || componentBody.type !== "BlockStatement") return;
4693
+ const bindings = collectUseStateBindings(componentBody);
4694
+ if (bindings.length === 0) return;
4695
+ const returnExpressions = collectReturnExpressions(componentBody);
4696
+ if (returnExpressions.length === 0) return;
4697
+ for (const binding of bindings) {
4698
+ if (returnExpressions.some((expression) => expressionReadsName(expression, binding.valueName))) continue;
4699
+ let setterCalled = false;
4700
+ walkAst(componentBody, (child) => {
4701
+ if (setterCalled) return;
4702
+ if (child.type === "CallExpression" && child.callee?.type === "Identifier" && child.callee.name === binding.setterName) setterCalled = true;
4703
+ });
4704
+ if (!setterCalled) continue;
4705
+ context.report({
4706
+ node: binding.declarator,
4707
+ message: `useState "${binding.valueName}" is updated but never read in the component's return — use useRef so updates don't trigger re-renders`
4708
+ });
4709
+ }
4710
+ };
4711
+ return {
4712
+ FunctionDeclaration(node) {
4713
+ if (!node.id?.name || !isUppercaseName(node.id.name)) return;
4714
+ checkComponent(node.body);
4715
+ },
4716
+ VariableDeclarator(node) {
4717
+ if (!isComponentAssignment(node)) return;
4718
+ checkComponent(node.init?.body);
4719
+ }
4720
+ };
4721
+ } };
4722
+ const SUBSCRIPTION_METHOD_NAMES = new Set([
4723
+ "addEventListener",
4724
+ "subscribe",
4725
+ "on",
4726
+ "addListener"
4727
+ ]);
4728
+ const advancedEventHandlerRefs = { create: (context) => ({ CallExpression(node) {
4729
+ if (!isHookCall(node, EFFECT_HOOK_NAMES)) return;
4730
+ if ((node.arguments?.length ?? 0) < 2) return;
4731
+ const callback = getEffectCallback(node);
4732
+ if (!callback) return;
4733
+ const depsNode = node.arguments[1];
4734
+ if (depsNode.type !== "ArrayExpression" || !depsNode.elements?.length) return;
4735
+ const depIdentifierNames = /* @__PURE__ */ new Set();
4736
+ for (const element of depsNode.elements) if (element?.type === "Identifier") depIdentifierNames.add(element.name);
4737
+ if (depIdentifierNames.size === 0) return;
4738
+ let registeredHandlerName = null;
4739
+ walkAst(callback.body, (child) => {
4740
+ if (registeredHandlerName) return;
4741
+ if (child.type !== "CallExpression") return;
4742
+ if (child.callee?.type !== "MemberExpression") return;
4743
+ if (child.callee.property?.type !== "Identifier") return;
4744
+ if (!SUBSCRIPTION_METHOD_NAMES.has(child.callee.property.name)) return;
4745
+ const handlerArg = child.arguments?.[1];
4746
+ if (handlerArg?.type !== "Identifier") return;
4747
+ if (depIdentifierNames.has(handlerArg.name)) registeredHandlerName = handlerArg.name;
4748
+ });
4749
+ if (registeredHandlerName) context.report({
4750
+ node,
4751
+ message: `useEffect re-subscribes a "${registeredHandlerName}" listener every time the handler identity changes — store the handler in a ref and have the listener read \`handlerRef.current()\`, then drop it from the deps`
4752
+ });
4753
+ } }) };
4754
+ const DEFERRABLE_HOOK_NAMES = new Set([
4755
+ "useSearchParams",
4756
+ "useParams",
4757
+ "usePathname"
4758
+ ]);
4759
+ const findHookCallBindings = (componentBody) => {
4760
+ const bindings = [];
4761
+ if (componentBody?.type !== "BlockStatement") return bindings;
4762
+ for (const statement of componentBody.body ?? []) {
4763
+ if (statement.type !== "VariableDeclaration") continue;
4764
+ for (const declarator of statement.declarations ?? []) {
4765
+ if (declarator.id?.type !== "Identifier") continue;
4766
+ if (declarator.init?.type !== "CallExpression") continue;
4767
+ const callee = declarator.init.callee;
4768
+ if (callee?.type !== "Identifier") continue;
4769
+ if (!DEFERRABLE_HOOK_NAMES.has(callee.name)) continue;
4770
+ bindings.push({
4771
+ valueName: declarator.id.name,
4772
+ hookName: callee.name,
4773
+ declarator
4774
+ });
4775
+ }
4776
+ }
4777
+ return bindings;
4778
+ };
4779
+ const collectHandlerBindingNames = (componentBody) => {
4780
+ const handlerNames = /* @__PURE__ */ new Set();
4781
+ walkAst(componentBody, (child) => {
4782
+ if (child.type !== "JSXAttribute") return;
4783
+ if (child.name?.type !== "JSXIdentifier") return;
4784
+ if (!/^on[A-Z]/.test(child.name.name)) return;
4785
+ if (child.value?.type !== "JSXExpressionContainer") return;
4786
+ const expression = child.value.expression;
4787
+ if (expression?.type === "Identifier") handlerNames.add(expression.name);
4788
+ });
4789
+ return handlerNames;
4790
+ };
4791
+ const isInsideEventHandler = (node, handlerBindingNames) => {
4792
+ let cursor = node.parent ?? null;
4793
+ while (cursor) {
4794
+ if (cursor.type === "ArrowFunctionExpression" || cursor.type === "FunctionExpression" || cursor.type === "FunctionDeclaration") {
4795
+ let outer = cursor.parent ?? null;
4796
+ while (outer) {
4797
+ if (outer.type === "JSXAttribute") {
4798
+ const attrName = outer.name?.type === "JSXIdentifier" ? outer.name.name : null;
4799
+ if (attrName && /^on[A-Z]/.test(attrName)) return true;
4800
+ return false;
4801
+ }
4802
+ if (outer.type === "VariableDeclarator") {
4803
+ const declaredName = outer.id?.type === "Identifier" ? outer.id.name : null;
4804
+ return Boolean(declaredName && handlerBindingNames.has(declaredName));
4805
+ }
4806
+ if (outer.type === "Program") return false;
4807
+ outer = outer.parent ?? null;
4808
+ }
4809
+ return false;
4810
+ }
4811
+ cursor = cursor.parent ?? null;
4812
+ }
4813
+ return false;
4814
+ };
2704
4815
  //#endregion
2705
4816
  //#region src/plugin/index.ts
2706
4817
  const plugin = {
@@ -2710,21 +4821,69 @@ const plugin = {
2710
4821
  "no-fetch-in-effect": noFetchInEffect,
2711
4822
  "no-cascading-set-state": noCascadingSetState,
2712
4823
  "no-effect-event-handler": noEffectEventHandler,
4824
+ "no-effect-event-in-deps": noEffectEventInDeps,
4825
+ "no-prop-callback-in-effect": noPropCallbackInEffect,
2713
4826
  "no-derived-useState": noDerivedUseState,
2714
4827
  "prefer-useReducer": preferUseReducer,
2715
4828
  "rerender-lazy-state-init": rerenderLazyStateInit,
2716
4829
  "rerender-functional-setstate": rerenderFunctionalSetstate,
2717
4830
  "rerender-dependencies": rerenderDependencies,
4831
+ "rerender-state-only-in-handlers": rerenderStateOnlyInHandlers,
4832
+ "rerender-defer-reads-hook": { create: (context) => {
4833
+ const checkComponent = (componentBody) => {
4834
+ if (!componentBody || componentBody.type !== "BlockStatement") return;
4835
+ const bindings = findHookCallBindings(componentBody);
4836
+ if (bindings.length === 0) return;
4837
+ const handlerBindingNames = collectHandlerBindingNames(componentBody);
4838
+ for (const binding of bindings) {
4839
+ const referenceLocations = [];
4840
+ walkAst(componentBody, (child) => {
4841
+ if (child === binding.declarator.id) return;
4842
+ if (child.type === "Identifier" && child.name === binding.valueName) referenceLocations.push(child);
4843
+ });
4844
+ if (referenceLocations.length === 0) continue;
4845
+ if (!referenceLocations.every((ref) => isInsideEventHandler(ref, handlerBindingNames))) continue;
4846
+ context.report({
4847
+ node: binding.declarator,
4848
+ message: `${binding.hookName}() return is only read inside event handlers — defer the read into the handler (e.g. \`new URL(window.location.href).searchParams\`) so the component doesn't re-render on every URL change`
4849
+ });
4850
+ }
4851
+ };
4852
+ return {
4853
+ FunctionDeclaration(node) {
4854
+ if (!node.id?.name || !isUppercaseName(node.id.name)) return;
4855
+ checkComponent(node.body);
4856
+ },
4857
+ VariableDeclarator(node) {
4858
+ if (!isComponentAssignment(node)) return;
4859
+ checkComponent(node.init?.body);
4860
+ }
4861
+ };
4862
+ } },
4863
+ "advanced-event-handler-refs": advancedEventHandlerRefs,
4864
+ "no-generic-handler-names": noGenericHandlerNames,
2718
4865
  "no-giant-component": noGiantComponent,
4866
+ "no-many-boolean-props": noManyBooleanProps,
4867
+ "no-react19-deprecated-apis": noReact19DeprecatedApis,
4868
+ "no-render-prop-children": noRenderPropChildren,
2719
4869
  "no-render-in-render": noRenderInRender,
2720
4870
  "no-nested-component-definition": noNestedComponentDefinition,
4871
+ "react-compiler-destructure-method": reactCompilerDestructureMethod,
2721
4872
  "no-usememo-simple-expression": noUsememoSimpleExpression,
2722
4873
  "no-layout-property-animation": noLayoutPropertyAnimation,
2723
4874
  "rerender-memo-with-default-value": rerenderMemoWithDefaultValue,
4875
+ "rerender-memo-before-early-return": rerenderMemoBeforeEarlyReturn,
4876
+ "rerender-transitions-scroll": rerenderTransitionsScroll,
4877
+ "rerender-derived-state-from-hook": rerenderDerivedStateFromHook,
4878
+ "async-defer-await": asyncDeferAwait,
4879
+ "async-await-in-loop": asyncAwaitInLoop,
2724
4880
  "rendering-animate-svg-wrapper": renderingAnimateSvgWrapper,
4881
+ "rendering-hoist-jsx": renderingHoistJsx,
4882
+ "rendering-hydration-mismatch-time": renderingHydrationMismatchTime,
2725
4883
  "no-inline-prop-on-memo-component": noInlinePropOnMemoComponent,
2726
4884
  "rendering-hydration-no-flicker": renderingHydrationNoFlicker,
2727
4885
  "rendering-script-defer-async": renderingScriptDeferAsync,
4886
+ "rendering-usetransition-loading": renderingUsetransitionLoading,
2728
4887
  "no-transition-all": noTransitionAll,
2729
4888
  "no-global-css-variable-animation": noGlobalCssVariableAnimation,
2730
4889
  "no-large-animated-blur": noLargeAnimatedBlur,
@@ -2733,14 +4892,37 @@ const plugin = {
2733
4892
  "no-eval": noEval,
2734
4893
  "no-secrets-in-client-code": noSecretsInClientCode,
2735
4894
  "no-barrel-import": noBarrelImport,
4895
+ "no-dynamic-import-path": noDynamicImportPath,
2736
4896
  "no-full-lodash-import": noFullLodashImport,
2737
4897
  "no-moment": noMoment,
2738
4898
  "prefer-dynamic-import": preferDynamicImport,
2739
4899
  "use-lazy-motion": useLazyMotion,
2740
4900
  "no-undeferred-third-party": noUndeferredThirdParty,
2741
4901
  "no-array-index-as-key": noArrayIndexAsKey,
4902
+ "no-polymorphic-children": noPolymorphicChildren,
2742
4903
  "rendering-conditional-render": renderingConditionalRender,
4904
+ "rendering-svg-precision": renderingSvgPrecision,
2743
4905
  "no-prevent-default": noPreventDefault,
4906
+ "no-document-start-view-transition": { create: (context) => ({ CallExpression(node) {
4907
+ const callee = node.callee;
4908
+ if (callee?.type !== "MemberExpression") return;
4909
+ if (callee.object?.type !== "Identifier" || callee.object.name !== "document") return;
4910
+ if (callee.property?.type !== "Identifier" || callee.property.name !== "startViewTransition") return;
4911
+ context.report({
4912
+ node,
4913
+ message: "document.startViewTransition() bypasses React's <ViewTransition> integration — render a <ViewTransition> component and let React drive the transition (around startTransition / useDeferredValue / Suspense)"
4914
+ });
4915
+ } }) },
4916
+ "no-flush-sync": { create: (context) => ({ ImportDeclaration(node) {
4917
+ if (node.source?.value !== "react-dom") return;
4918
+ for (const specifier of node.specifiers ?? []) {
4919
+ if (specifier.type !== "ImportSpecifier") continue;
4920
+ if (specifier.imported?.name === "flushSync") context.report({
4921
+ node: specifier,
4922
+ message: "flushSync from react-dom skips View Transition snapshots and concurrent rendering — prefer startTransition for non-urgent updates"
4923
+ });
4924
+ }
4925
+ } }) },
2744
4926
  "nextjs-no-img-element": nextjsNoImgElement,
2745
4927
  "nextjs-async-client-component": nextjsAsyncClientComponent,
2746
4928
  "nextjs-no-a-element": nextjsNoAElement,
@@ -2759,10 +4941,20 @@ const plugin = {
2759
4941
  "nextjs-no-side-effect-in-get-handler": nextjsNoSideEffectInGetHandler,
2760
4942
  "server-auth-actions": serverAuthActions,
2761
4943
  "server-after-nonblocking": serverAfterNonblocking,
4944
+ "server-no-mutable-module-state": serverNoMutableModuleState,
4945
+ "server-cache-with-object-literal": serverCacheWithObjectLiteral,
4946
+ "server-hoist-static-io": serverHoistStaticIo,
4947
+ "server-dedup-props": serverDedupProps,
4948
+ "server-sequential-independent-await": serverSequentialIndependentAwait,
4949
+ "server-fetch-without-revalidate": serverFetchWithoutRevalidate,
2762
4950
  "client-passive-event-listeners": clientPassiveEventListeners,
4951
+ "client-localstorage-no-version": clientLocalstorageNoVersion,
2763
4952
  "js-combine-iterations": jsCombineIterations,
2764
4953
  "js-tosorted-immutable": jsTosortedImmutable,
2765
4954
  "js-hoist-regexp": jsHoistRegexp,
4955
+ "js-hoist-intl": jsHoistIntl,
4956
+ "js-cache-property-access": jsCachePropertyAccess,
4957
+ "js-length-check-first": jsLengthCheckFirst,
2766
4958
  "js-min-max-loop": jsMinMaxLoop,
2767
4959
  "js-set-map-lookups": jsSetMapLookups,
2768
4960
  "js-batch-dom-css": jsBatchDomCss,
@@ -2779,6 +4971,22 @@ const plugin = {
2779
4971
  "rn-no-legacy-shadow-styles": rnNoLegacyShadowStyles,
2780
4972
  "rn-prefer-reanimated": rnPreferReanimated,
2781
4973
  "rn-no-single-element-style-array": rnNoSingleElementStyleArray,
4974
+ "rn-prefer-pressable": rnPreferPressable,
4975
+ "rn-prefer-expo-image": rnPreferExpoImage,
4976
+ "rn-no-non-native-navigator": rnNoNonNativeNavigator,
4977
+ "rn-no-scroll-state": rnNoScrollState,
4978
+ "rn-no-scrollview-mapped-list": rnNoScrollviewMappedList,
4979
+ "rn-no-inline-object-in-list-item": rnNoInlineObjectInListItem,
4980
+ "rn-animate-layout-property": rnAnimateLayoutProperty,
4981
+ "rn-prefer-content-inset-adjustment": rnPreferContentInsetAdjustment,
4982
+ "rn-pressable-shared-value-mutation": rnPressableSharedValueMutation,
4983
+ "rn-list-data-mapped": rnListDataMapped,
4984
+ "rn-list-callback-per-row": rnListCallbackPerRow,
4985
+ "rn-list-recyclable-without-types": rnListRecyclableWithoutTypes,
4986
+ "rn-animation-reaction-as-derived": rnAnimationReactionAsDerived,
4987
+ "rn-bottom-sheet-prefer-native": rnBottomSheetPreferNative,
4988
+ "rn-scrollview-dynamic-padding": rnScrollviewDynamicPadding,
4989
+ "rn-style-prefer-boxshadow": rnStylePreferBoxShadow,
2782
4990
  "tanstack-start-route-property-order": tanstackStartRoutePropertyOrder,
2783
4991
  "tanstack-start-no-direct-fetch-in-loader": tanstackStartNoDirectFetchInLoader,
2784
4992
  "tanstack-start-server-fn-validate-input": tanstackStartServerFnValidateInput,