react-doctor 0.0.41 → 0.0.44

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.
@@ -1,12 +1,4 @@
1
1
  //#region src/plugin/constants.ts
2
- const GIANT_COMPONENT_LINE_THRESHOLD = 300;
3
- const CASCADING_SET_STATE_THRESHOLD = 3;
4
- const RELATED_USE_STATE_THRESHOLD = 5;
5
- const DEEP_NESTING_THRESHOLD = 3;
6
- const DUPLICATE_STORAGE_READ_THRESHOLD = 2;
7
- const SEQUENTIAL_AWAIT_THRESHOLD = 3;
8
- const SECRET_MIN_LENGTH_CHARS = 8;
9
- const AUTH_CHECK_LOOKAHEAD_STATEMENTS = 3;
10
2
  const LAYOUT_PROPERTIES = new Set([
11
3
  "width",
12
4
  "height",
@@ -52,11 +44,20 @@ const HEAVY_LIBRARIES = new Set([
52
44
  "@toast-ui/editor",
53
45
  "draft-js"
54
46
  ]);
55
- 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
+ ]);
56
54
  const FETCH_MEMBER_OBJECTS = new Set([
57
55
  "axios",
58
56
  "ky",
59
- "got"
57
+ "got",
58
+ "ofetch",
59
+ "wretch",
60
+ "request"
60
61
  ]);
61
62
  const INDEX_PARAMETER_NAMES = new Set([
62
63
  "index",
@@ -169,6 +170,7 @@ const SECRET_FALSE_POSITIVE_SUFFIXES = new Set([
169
170
  "schema",
170
171
  "constant"
171
172
  ]);
173
+ const LOADING_STATE_PATTERN = /^(?:isLoading|isPending)$/;
172
174
  const TANSTACK_ROUTE_FILE_PATTERN = /\/routes\//;
173
175
  const TANSTACK_ROOT_ROUTE_FILE_PATTERN = /__root\.(tsx?|jsx?)$/;
174
176
  const TANSTACK_ROUTE_PROPERTY_ORDER = [
@@ -204,7 +206,6 @@ const TANSTACK_MIDDLEWARE_METHOD_ORDER = [
204
206
  ];
205
207
  const TANSTACK_REDIRECT_FUNCTIONS = new Set(["redirect", "notFound"]);
206
208
  const TANSTACK_SERVER_FN_FILE_PATTERN = /\.functions(\.[jt]sx?)?$/;
207
- const SEQUENTIAL_AWAIT_THRESHOLD_FOR_LOADER = 2;
208
209
  const TANSTACK_QUERY_HOOKS = new Set([
209
210
  "useQuery",
210
211
  "useInfiniteQuery",
@@ -212,13 +213,29 @@ const TANSTACK_QUERY_HOOKS = new Set([
212
213
  "useSuspenseInfiniteQuery"
213
214
  ]);
214
215
  const TANSTACK_MUTATION_HOOKS = new Set(["useMutation"]);
215
- const TANSTACK_QUERY_CLIENT_CLASS = "QueryClient";
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
+ ]);
216
226
  const STABLE_HOOK_WRAPPERS = new Set([
217
227
  "useState",
218
228
  "useMemo",
219
229
  "useRef"
220
230
  ]);
221
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
+ ]);
222
239
  const TRIVIAL_INITIALIZER_NAMES = new Set([
223
240
  "Boolean",
224
241
  "String",
@@ -296,11 +313,9 @@ const CHAINABLE_ITERATION_METHODS = new Set([
296
313
  "forEach",
297
314
  "flatMap"
298
315
  ]);
299
- const STORAGE_OBJECTS = new Set(["localStorage", "sessionStorage"]);
300
- const LARGE_BLUR_THRESHOLD_PX = 10;
316
+ const STORAGE_OBJECTS$1 = new Set(["localStorage", "sessionStorage"]);
301
317
  const BLUR_VALUE_PATTERN = /blur\((\d+(?:\.\d+)?)px\)/;
302
318
  const ANIMATION_CALLBACK_NAMES = new Set(["requestAnimationFrame", "setInterval"]);
303
- const RAW_TEXT_PREVIEW_MAX_CHARS = 30;
304
319
  const REACT_NATIVE_TEXT_COMPONENTS = new Set([
305
320
  "Text",
306
321
  "TextInput",
@@ -369,23 +384,12 @@ const BOUNCE_ANIMATION_NAMES = new Set([
369
384
  "jiggle",
370
385
  "spring"
371
386
  ]);
372
- const Z_INDEX_ABSURD_THRESHOLD = 100;
373
- const INLINE_STYLE_PROPERTY_THRESHOLD = 8;
374
- const SIDE_TAB_BORDER_WIDTH_WITHOUT_RADIUS_PX = 3;
375
- const SIDE_TAB_BORDER_WIDTH_WITH_RADIUS_PX = 1;
376
- const SIDE_TAB_TAILWIND_WIDTH_WITHOUT_RADIUS = 4;
377
- const DARK_GLOW_BLUR_THRESHOLD_PX = 4;
378
- const DARK_BACKGROUND_CHANNEL_MAX = 35;
379
- const COLOR_CHROMA_THRESHOLD = 30;
380
- const TINY_TEXT_THRESHOLD_PX = 12;
381
- const WIDE_TRACKING_THRESHOLD_EM = .05;
382
387
  const LONG_TRANSITION_DURATION_THRESHOLD_MS = 1e3;
383
-
384
388
  //#endregion
385
389
  //#region src/plugin/helpers.ts
386
390
  const walkAst = (node, visitor) => {
387
391
  if (!node || typeof node !== "object") return;
388
- visitor(node);
392
+ if (visitor(node) === false) return;
389
393
  for (const key of Object.keys(node)) {
390
394
  if (key === "parent") continue;
391
395
  const child = node[key];
@@ -487,12 +491,13 @@ const isMutatingDbCall = (node) => {
487
491
  const { property } = node.callee;
488
492
  return property?.type === "Identifier" && MUTATION_METHOD_NAMES.has(property.name);
489
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());
490
495
  const isMutatingFetchCall = (node) => {
491
496
  if (node.type !== "CallExpression") return false;
492
497
  if (node.callee?.type !== "Identifier" || node.callee.name !== "fetch") return false;
493
498
  const optionsArgument = node.arguments?.[1];
494
499
  if (!optionsArgument || optionsArgument.type !== "ObjectExpression") return false;
495
- 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));
496
501
  };
497
502
  const findSideEffect = (node) => {
498
503
  let sideEffectDescription = null;
@@ -500,7 +505,7 @@ const findSideEffect = (node) => {
500
505
  if (sideEffectDescription) return;
501
506
  if (isCookiesOrHeadersCall(child, "cookies")) sideEffectDescription = `cookies().${child.callee.property.name}()`;
502
507
  else if (isCookiesOrHeadersCall(child, "headers")) sideEffectDescription = `headers().${child.callee.property.name}()`;
503
- 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}`;
504
509
  else if (isMutatingDbCall(child)) {
505
510
  const methodName = child.callee.property.name;
506
511
  const objectName = child.callee.object?.type === "Identifier" ? child.callee.object.name : null;
@@ -509,21 +514,56 @@ const findSideEffect = (node) => {
509
514
  });
510
515
  return sideEffectDescription;
511
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
+ };
512
543
  const extractDestructuredPropNames = (params) => {
513
544
  const propNames = /* @__PURE__ */ new Set();
514
- for (const param of params) if (param.type === "ObjectPattern") {
515
- for (const property of param.properties ?? []) if (property.type === "Property" && property.key?.type === "Identifier") propNames.add(property.key.name);
516
- } else if (param.type === "Identifier") propNames.add(param.name);
545
+ for (const param of params) collectPatternNames(param, propNames);
517
546
  return propNames;
518
547
  };
519
-
520
548
  //#endregion
521
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
+ } }) };
522
562
  const noGiantComponent = { create: (context) => {
523
563
  const reportOversizedComponent = (nameNode, componentName, bodyNode) => {
524
564
  if (!bodyNode.loc) return;
525
565
  const lineCount = bodyNode.loc.end.line - bodyNode.loc.start.line + 1;
526
- if (lineCount > GIANT_COMPONENT_LINE_THRESHOLD) context.report({
566
+ if (lineCount > 300) context.report({
527
567
  node: nameNode,
528
568
  message: `Component "${componentName}" is ${lineCount} lines — consider breaking it into smaller focused components`
529
569
  });
@@ -577,7 +617,193 @@ const noNestedComponentDefinition = { create: (context) => {
577
617
  }
578
618
  };
579
619
  } };
580
-
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
+ } };
581
807
  //#endregion
582
808
  //#region src/plugin/rules/bundle-size.ts
583
809
  const noBarrelImport = { create: (context) => {
@@ -623,6 +849,38 @@ const useLazyMotion = { create: (context) => ({ ImportDeclaration(node) {
623
849
  message: "Import \"m\" with LazyMotion instead of \"motion\" — saves ~30kb in bundle size"
624
850
  });
625
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
+ }) };
626
884
  const noUndeferredThirdParty = { create: (context) => ({ JSXOpeningElement(node) {
627
885
  if (node.name?.type !== "JSXIdentifier" || node.name.name !== "script") return;
628
886
  const attributes = node.attributes ?? [];
@@ -632,12 +890,11 @@ const noUndeferredThirdParty = { create: (context) => ({ JSXOpeningElement(node)
632
890
  message: "Synchronous <script> with src — add defer or async to avoid blocking first paint"
633
891
  });
634
892
  } }) };
635
-
636
893
  //#endregion
637
894
  //#region src/plugin/rules/client.ts
638
895
  const clientPassiveEventListeners = { create: (context) => ({ CallExpression(node) {
639
896
  if (!isMemberProperty(node.callee, "addEventListener")) return;
640
- if (node.arguments?.length < 2) return;
897
+ if ((node.arguments?.length ?? 0) < 2) return;
641
898
  const eventNameNode = node.arguments[0];
642
899
  if (eventNameNode.type !== "Literal" || !PASSIVE_EVENT_NAMES.has(eventNameNode.value)) return;
643
900
  const eventName = eventNameNode.value;
@@ -645,17 +902,45 @@ const clientPassiveEventListeners = { create: (context) => ({ CallExpression(nod
645
902
  if (!optionsArgument) {
646
903
  context.report({
647
904
  node,
648
- 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())`
649
906
  });
650
907
  return;
651
908
  }
652
909
  if (optionsArgument.type !== "ObjectExpression") return;
653
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({
654
911
  node,
655
- 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`
656
942
  });
657
943
  } }) };
658
-
659
944
  //#endregion
660
945
  //#region src/plugin/rules/design.ts
661
946
  const isOvershootCubicBezier = (value) => {
@@ -702,12 +987,24 @@ const getStylePropertyKey = (property) => {
702
987
  };
703
988
  const parseColorToRgb = (value) => {
704
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
+ };
705
996
  const hex6Match = trimmed.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
706
997
  if (hex6Match) return {
707
998
  red: parseInt(hex6Match[1], 16),
708
999
  green: parseInt(hex6Match[2], 16),
709
1000
  blue: parseInt(hex6Match[3], 16)
710
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
+ };
711
1008
  const hex3Match = trimmed.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
712
1009
  if (hex3Match) return {
713
1010
  red: parseInt(hex3Match[1] + hex3Match[1], 16),
@@ -722,7 +1019,7 @@ const parseColorToRgb = (value) => {
722
1019
  };
723
1020
  return null;
724
1021
  };
725
- const hasColorChroma = (parsed) => Math.max(parsed.red, parsed.green, parsed.blue) - Math.min(parsed.red, parsed.green, parsed.blue) >= COLOR_CHROMA_THRESHOLD;
1022
+ const hasColorChroma = (parsed) => Math.max(parsed.red, parsed.green, parsed.blue) - Math.min(parsed.red, parsed.green, parsed.blue) >= 30;
726
1023
  const isNeutralBorderColor = (value) => {
727
1024
  const trimmed = value.trim().toLowerCase();
728
1025
  if ([
@@ -768,7 +1065,7 @@ const parseShadowLayerBlur = (layer) => {
768
1065
  const hasColoredGlowShadow = (shadowValue) => {
769
1066
  for (const layer of splitShadowLayers(shadowValue)) {
770
1067
  const color = extractColorFromShadowLayer(layer);
771
- if (color && hasColorChroma(color) && parseShadowLayerBlur(layer) > DARK_GLOW_BLUR_THRESHOLD_PX) return true;
1068
+ if (color && hasColorChroma(color) && parseShadowLayerBlur(layer) > 4) return true;
772
1069
  }
773
1070
  return false;
774
1071
  };
@@ -777,7 +1074,7 @@ const isBackgroundDark = (bgValue) => {
777
1074
  if (isPureBlackColor(trimmed)) return true;
778
1075
  const parsed = parseColorToRgb(trimmed);
779
1076
  if (!parsed) return false;
780
- return parsed.red <= DARK_BACKGROUND_CHANNEL_MAX && parsed.green <= DARK_BACKGROUND_CHANNEL_MAX && parsed.blue <= DARK_BACKGROUND_CHANNEL_MAX;
1077
+ return parsed.red <= 35 && parsed.green <= 35 && parsed.blue <= 35;
781
1078
  };
782
1079
  const BORDER_SIDE_KEYS = {
783
1080
  borderLeft: "left",
@@ -826,7 +1123,7 @@ const noZIndex9999 = { create: (context) => ({
826
1123
  for (const property of expression.properties ?? []) {
827
1124
  if (getStylePropertyKey(property) !== "zIndex") continue;
828
1125
  const zValue = getStylePropertyNumberValue(property);
829
- if (zValue !== null && Math.abs(zValue) >= Z_INDEX_ABSURD_THRESHOLD) context.report({
1126
+ if (zValue !== null && Math.abs(zValue) >= 100) context.report({
830
1127
  node: property,
831
1128
  message: `z-index: ${zValue} is arbitrarily high — use a deliberate z-index scale (1–50). Extreme values signal a stacking context problem, not a fix`
832
1129
  });
@@ -843,7 +1140,7 @@ const noZIndex9999 = { create: (context) => ({
843
1140
  if (getStylePropertyKey(child) !== "zIndex") return;
844
1141
  if (child.value?.type === "Literal" && typeof child.value.value === "number") {
845
1142
  const zValue = child.value.value;
846
- if (Math.abs(zValue) >= Z_INDEX_ABSURD_THRESHOLD) context.report({
1143
+ if (Math.abs(zValue) >= 100) context.report({
847
1144
  node: child,
848
1145
  message: `z-index: ${zValue} is arbitrarily high — use a deliberate z-index scale (1–50). Extreme values signal a stacking context problem, not a fix`
849
1146
  });
@@ -855,7 +1152,7 @@ const noInlineExhaustiveStyle = { create: (context) => ({ JSXAttribute(node) {
855
1152
  const expression = getInlineStyleExpression(node);
856
1153
  if (!expression) return;
857
1154
  const propertyCount = expression.properties?.filter((property) => property.type === "Property").length ?? 0;
858
- if (propertyCount >= INLINE_STYLE_PROPERTY_THRESHOLD) context.report({
1155
+ if (propertyCount >= 8) context.report({
859
1156
  node: expression,
860
1157
  message: `${propertyCount} inline style properties — extract to a CSS class, CSS module, or styled component for maintainability and reuse`
861
1158
  });
@@ -870,7 +1167,7 @@ const noSideTabBorder = { create: (context) => ({
870
1167
  const strValue = getStylePropertyStringValue(property);
871
1168
  if (numValue !== null && numValue > 0 || strValue !== null && parseFloat(strValue) > 0) hasBorderRadius = true;
872
1169
  }
873
- const threshold = hasBorderRadius ? SIDE_TAB_BORDER_WIDTH_WITH_RADIUS_PX : SIDE_TAB_BORDER_WIDTH_WITHOUT_RADIUS_PX;
1170
+ const threshold = hasBorderRadius ? 1 : 3;
874
1171
  for (const property of expression.properties ?? []) {
875
1172
  const key = getStylePropertyKey(property);
876
1173
  if (!key) continue;
@@ -911,7 +1208,7 @@ const noSideTabBorder = { create: (context) => ({
911
1208
  const sideMatch = classStr.match(/\bborder-[lrse]-(\d+)\b/);
912
1209
  if (!sideMatch) return;
913
1210
  if (/\bborder-(?:(?:gray|slate|zinc|neutral|stone)-\d+|white|black|transparent)\b/.test(classStr)) return;
914
- if (parseInt(sideMatch[1], 10) >= (/\brounded(?:-(?!none\b)\w+)?\b/.test(classStr) && !/\brounded-none\b/.test(classStr) ? SIDE_TAB_BORDER_WIDTH_WITH_RADIUS_PX : SIDE_TAB_TAILWIND_WIDTH_WITHOUT_RADIUS)) context.report({
1211
+ if (parseInt(sideMatch[1], 10) >= (/\brounded(?:-(?!none\b)\w+)?\b/.test(classStr) && !/\brounded-none\b/.test(classStr) ? 1 : 4)) context.report({
915
1212
  node,
916
1213
  message: `Thick one-sided border (${sideMatch[0]}) — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it`
917
1214
  });
@@ -1023,9 +1320,9 @@ const noTinyText = { create: (context) => ({ JSXAttribute(node) {
1023
1320
  const remMatch = strValue.match(/^([\d.]+)rem$/);
1024
1321
  if (remMatch) pxValue = parseFloat(remMatch[1]) * 16;
1025
1322
  }
1026
- if (pxValue !== null && pxValue > 0 && pxValue < TINY_TEXT_THRESHOLD_PX) context.report({
1323
+ if (pxValue !== null && pxValue > 0 && pxValue < 12) context.report({
1027
1324
  node: property,
1028
- message: `Font size ${pxValue}px is too small — body text should be at least ${TINY_TEXT_THRESHOLD_PX}px for readability, 16px is ideal`
1325
+ message: `Font size ${pxValue}px is too small — body text should be at least 12px for readability, 16px is ideal`
1029
1326
  });
1030
1327
  }
1031
1328
  } }) };
@@ -1054,7 +1351,7 @@ const noWideLetterSpacing = { create: (context) => ({ JSXAttribute(node) {
1054
1351
  if (numValue !== null && numValue > 0) letterSpacingEm = numValue / 16;
1055
1352
  }
1056
1353
  }
1057
- if (!isUppercase && letterSpacingProperty && letterSpacingEm !== null && letterSpacingEm > WIDE_TRACKING_THRESHOLD_EM) context.report({
1354
+ if (!isUppercase && letterSpacingProperty && letterSpacingEm !== null && letterSpacingEm > .05) context.report({
1058
1355
  node: letterSpacingProperty,
1059
1356
  message: `Letter spacing ${letterSpacingEm.toFixed(2)}em on body text disrupts natural character groupings. Reserve wide tracking for short uppercase labels only`
1060
1357
  });
@@ -1164,15 +1461,15 @@ const noLongTransitionDuration = { create: (context) => ({ JSXAttribute(node) {
1164
1461
  }
1165
1462
  if (longestDurationMs > 0) durationMs = longestDurationMs;
1166
1463
  }
1167
- if (durationMs !== null && durationMs > LONG_TRANSITION_DURATION_THRESHOLD_MS) context.report({
1464
+ if (durationMs !== null && durationMs > 1e3) context.report({
1168
1465
  node: property,
1169
1466
  message: `${durationMs}ms transition is too slow for UI feedback — keep transitions under ${LONG_TRANSITION_DURATION_THRESHOLD_MS}ms. Use longer durations only for page-load hero animations`
1170
1467
  });
1171
1468
  }
1172
1469
  } }) };
1173
-
1174
1470
  //#endregion
1175
1471
  //#region src/plugin/rules/correctness.ts
1472
+ const STRING_COERCION_FUNCTIONS = new Set(["String", "Number"]);
1176
1473
  const extractIndexName = (node) => {
1177
1474
  if (node.type === "Identifier" && INDEX_PARAMETER_NAMES.has(node.name)) return node.name;
1178
1475
  if (node.type === "TemplateLiteral") {
@@ -1180,7 +1477,8 @@ const extractIndexName = (node) => {
1180
1477
  if (indexExpression) return indexExpression.name;
1181
1478
  }
1182
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;
1183
- 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;
1184
1482
  return null;
1185
1483
  };
1186
1484
  const isInsideStaticPlaceholderMap = (node) => {
@@ -1210,8 +1508,8 @@ const noArrayIndexAsKey = { create: (context) => ({ JSXAttribute(node) {
1210
1508
  });
1211
1509
  } }) };
1212
1510
  const PREVENT_DEFAULT_ELEMENTS = {
1213
- form: "onSubmit",
1214
- a: "onClick"
1511
+ form: ["onSubmit"],
1512
+ a: ["onClick"]
1215
1513
  };
1216
1514
  const containsPreventDefaultCall = (node) => {
1217
1515
  let didFindPreventDefault = false;
@@ -1221,31 +1519,86 @@ const containsPreventDefaultCall = (node) => {
1221
1519
  });
1222
1520
  return didFindPreventDefault;
1223
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
+ };
1224
1526
  const noPreventDefault = { create: (context) => ({ JSXOpeningElement(node) {
1225
1527
  const elementName = node.name?.type === "JSXIdentifier" ? node.name.name : null;
1226
1528
  if (!elementName) return;
1227
- const targetEventProp = PREVENT_DEFAULT_ELEMENTS[elementName];
1228
- if (!targetEventProp) return;
1229
- const eventAttribute = findJsxAttribute(node.attributes ?? [], targetEventProp);
1230
- if (!eventAttribute?.value || eventAttribute.value.type !== "JSXExpressionContainer") return;
1231
- const expression = eventAttribute.value.expression;
1232
- if (expression?.type !== "ArrowFunctionExpression" && expression?.type !== "FunctionExpression") return;
1233
- if (!containsPreventDefaultCall(expression)) return;
1234
- 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";
1235
- context.report({
1236
- node,
1237
- message
1238
- });
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
+ }
1239
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
+ };
1240
1561
  const renderingConditionalRender = { create: (context) => ({ LogicalExpression(node) {
1241
1562
  if (node.operator !== "&&") return;
1242
1563
  if (!(node.right?.type === "JSXElement" || node.right?.type === "JSXFragment")) return;
1243
- 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({
1244
1569
  node,
1245
- message: "Conditional rendering with .length can render '0' — use .length > 0 or Boolean(.length)"
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({
1598
+ node,
1599
+ message: `SVG ${node.name.name} attribute uses 4+ decimal precision — truncate to 1–2 decimals to shrink markup with no visible difference`
1246
1600
  });
1247
1601
  } }) };
1248
-
1249
1602
  //#endregion
1250
1603
  //#region src/plugin/rules/js-performance.ts
1251
1604
  const jsCombineIterations = { create: (context) => ({ CallExpression(node) {
@@ -1323,12 +1676,12 @@ const jsCacheStorage = { create: (context) => {
1323
1676
  const storageReadCounts = /* @__PURE__ */ new Map();
1324
1677
  return { CallExpression(node) {
1325
1678
  if (!isMemberProperty(node.callee, "getItem")) return;
1326
- 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;
1327
1680
  if (node.arguments?.[0]?.type !== "Literal") return;
1328
1681
  const storageKey = String(node.arguments[0].value);
1329
1682
  const readCount = (storageReadCounts.get(storageKey) ?? 0) + 1;
1330
1683
  storageReadCounts.set(storageKey, readCount);
1331
- if (readCount === DUPLICATE_STORAGE_READ_THRESHOLD) {
1684
+ if (readCount === 2) {
1332
1685
  const storageName = node.callee.object.name;
1333
1686
  context.report({
1334
1687
  node,
@@ -1347,7 +1700,7 @@ const jsEarlyExit = { create: (context) => ({ IfStatement(node) {
1347
1700
  nestingDepth++;
1348
1701
  currentBlock = innerStatement.consequent;
1349
1702
  }
1350
- if (nestingDepth >= DEEP_NESTING_THRESHOLD) context.report({
1703
+ if (nestingDepth >= 3) context.report({
1351
1704
  node,
1352
1705
  message: `${nestingDepth + 1} levels of nested if statements — use early returns to flatten`
1353
1706
  });
@@ -1359,7 +1712,7 @@ const asyncParallel = { create: (context) => {
1359
1712
  if (isTestFile) return;
1360
1713
  const consecutiveAwaitStatements = [];
1361
1714
  const flushConsecutiveAwaits = () => {
1362
- if (consecutiveAwaitStatements.length >= SEQUENTIAL_AWAIT_THRESHOLD) reportIfIndependent(consecutiveAwaitStatements, context);
1715
+ if (consecutiveAwaitStatements.length >= 3) reportIfIndependent(consecutiveAwaitStatements, context);
1363
1716
  consecutiveAwaitStatements.length = 0;
1364
1717
  };
1365
1718
  for (const statement of node.body ?? []) if (statement.type === "VariableDeclaration" && statement.declarations?.length === 1 && statement.declarations[0].init?.type === "AwaitExpression" || statement.type === "ExpressionStatement" && statement.expression?.type === "AwaitExpression") consecutiveAwaitStatements.push(statement);
@@ -1400,7 +1753,193 @@ const jsFlatmapFilter = { create: (context) => ({ CallExpression(node) {
1400
1753
  message: ".map().filter(Boolean) iterates twice — use .flatMap() to transform and filter in a single pass"
1401
1754
  });
1402
1755
  } }) };
1403
-
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
+ } };
1404
1943
  //#endregion
1405
1944
  //#region src/plugin/rules/nextjs.ts
1406
1945
  const nextjsNoImgElement = { create: (context) => {
@@ -1450,13 +1989,39 @@ const nextjsNoAElement = { create: (context) => ({ JSXOpeningElement(node) {
1450
1989
  message: "Use next/link instead of <a> for internal links — enables client-side navigation and prefetching"
1451
1990
  });
1452
1991
  } }) };
1453
- const nextjsNoUseSearchParamsWithoutSuspense = { create: (context) => ({ CallExpression(node) {
1454
- if (!isHookCall(node, "useSearchParams")) return;
1455
- context.report({
1456
- node,
1457
- 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
+ }
1458
2006
  });
1459
- } }) };
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
+ } };
1460
2025
  const nextjsNoClientFetchForServerData = { create: (context) => {
1461
2026
  let fileHasUseClient = false;
1462
2027
  return {
@@ -1630,8 +2195,7 @@ const getExportedGetHandlerBody = (node) => {
1630
2195
  if (!declaration) return null;
1631
2196
  if (declaration.type === "FunctionDeclaration" && declaration.id?.name === "GET") return declaration.body;
1632
2197
  if (declaration.type === "VariableDeclaration") {
1633
- const declarator = declaration.declarations?.[0];
1634
- 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;
1635
2199
  }
1636
2200
  return null;
1637
2201
  };
@@ -1654,7 +2218,6 @@ const nextjsNoSideEffectInGetHandler = { create: (context) => ({ ExportNamedDecl
1654
2218
  message: `GET handler has side effects (${sideEffect}) — use POST to prevent CSRF and unintended prefetch triggers`
1655
2219
  });
1656
2220
  } }) };
1657
-
1658
2221
  //#endregion
1659
2222
  //#region src/plugin/rules/performance.ts
1660
2223
  const isMemoCall = (node) => {
@@ -1698,6 +2261,13 @@ const noInlinePropOnMemoComponent = { create: (context) => {
1698
2261
  }
1699
2262
  };
1700
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
+ };
1701
2271
  const noUsememoSimpleExpression = { create: (context) => ({ CallExpression(node) {
1702
2272
  if (!isHookCall(node, "useMemo")) return;
1703
2273
  const callback = node.arguments?.[0];
@@ -1706,7 +2276,7 @@ const noUsememoSimpleExpression = { create: (context) => ({ CallExpression(node)
1706
2276
  let returnExpression = null;
1707
2277
  if (callback.body?.type !== "BlockStatement") returnExpression = callback.body;
1708
2278
  else if (callback.body.body?.length === 1 && callback.body.body[0].type === "ReturnStatement") returnExpression = callback.body.body[0].argument;
1709
- if (returnExpression && isSimpleExpression(returnExpression)) context.report({
2279
+ if (returnExpression && isTriviallyCheapExpression(returnExpression)) context.report({
1710
2280
  node,
1711
2281
  message: "useMemo wrapping a trivially cheap expression — memo overhead exceeds the computation"
1712
2282
  });
@@ -1782,7 +2352,7 @@ const noLargeAnimatedBlur = { create: (context) => ({ JSXAttribute(node) {
1782
2352
  const match = BLUR_VALUE_PATTERN.exec(property.value.value);
1783
2353
  if (!match) continue;
1784
2354
  const blurRadius = Number.parseFloat(match[1]);
1785
- if (blurRadius > LARGE_BLUR_THRESHOLD_PX) context.report({
2355
+ if (blurRadius > 10) context.report({
1786
2356
  node: property,
1787
2357
  message: `blur(${blurRadius}px) is expensive — cost escalates with radius and layer size, can exceed GPU memory on mobile`
1788
2358
  });
@@ -1853,8 +2423,21 @@ const renderingAnimateSvgWrapper = { create: (context) => ({ JSXOpeningElement(n
1853
2423
  message: "Animation props directly on <svg> — wrap in a <div> or <motion.div> for better rendering performance"
1854
2424
  });
1855
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
+ } }) };
1856
2439
  const renderingHydrationNoFlicker = { create: (context) => ({ CallExpression(node) {
1857
- if (!isHookCall(node, EFFECT_HOOK_NAMES) || node.arguments?.length < 2) return;
2440
+ if (!isHookCall(node, EFFECT_HOOK_NAMES) || (node.arguments?.length ?? 0) < 2) return;
1858
2441
  const depsNode = node.arguments[1];
1859
2442
  if (depsNode.type !== "ArrayExpression" || depsNode.elements?.length !== 0) return;
1860
2443
  const callback = getEffectCallback(node);
@@ -1880,38 +2463,365 @@ const renderingScriptDeferAsync = { create: (context) => ({ JSXOpeningElement(no
1880
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"
1881
2464
  });
1882
2465
  } }) };
1883
-
1884
- //#endregion
1885
- //#region src/plugin/rules/react-native.ts
1886
- const resolveJsxElementName = (openingElement) => {
1887
- const elementName = openingElement?.name;
1888
- if (!elementName) return null;
1889
- if (elementName.type === "JSXIdentifier") return elementName.name;
1890
- if (elementName.type === "JSXMemberExpression") return elementName.property?.name ?? null;
1891
- return null;
1892
- };
1893
- const truncateText = (text) => text.length > RAW_TEXT_PREVIEW_MAX_CHARS ? `${text.slice(0, RAW_TEXT_PREVIEW_MAX_CHARS)}...` : text;
1894
- const isRawTextContent = (child) => {
1895
- if (child.type === "JSXText") return Boolean(child.value?.trim());
1896
- if (child.type !== "JSXExpressionContainer" || !child.expression) return false;
1897
- const expression = child.expression;
1898
- return expression.type === "Literal" && (typeof expression.value === "string" || typeof expression.value === "number") || expression.type === "TemplateLiteral";
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;
1899
2474
  };
1900
- const getRawTextDescription = (child) => {
1901
- if (child.type === "JSXText") return `"${truncateText(child.value.trim())}"`;
1902
- if (child.type === "JSXExpressionContainer" && child.expression) {
1903
- const expression = child.expression;
1904
- if (expression.type === "Literal" && typeof expression.value === "string") return `"${truncateText(expression.value)}"`;
1905
- if (expression.type === "Literal" && typeof expression.value === "number") return `{${expression.value}}`;
1906
- if (expression.type === "TemplateLiteral") return "template literal";
1907
- }
1908
- return "text content";
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
+ };
2488
+ return {
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>";
2502
+ context.report({
2503
+ node: declarator,
2504
+ message: `Static JSX "${name}" inside a component — hoist to module scope so it isn't recreated each render`
2505
+ });
2506
+ }
2507
+ }
2508
+ };
2509
+ } };
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;
1909
2518
  };
1910
- const isTextHandlingComponent = (elementName) => {
1911
- if (REACT_NATIVE_TEXT_COMPONENTS.has(elementName)) return true;
1912
- return [...REACT_NATIVE_TEXT_COMPONENT_KEYWORDS].some((keyword) => elementName.includes(keyword));
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;
1913
2526
  };
1914
- const rnNoRawText = { create: (context) => {
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) => {
1915
2825
  let isDomComponentFile = false;
1916
2826
  return {
1917
2827
  Program(programNode) {
@@ -2043,7 +2953,449 @@ const rnNoSingleElementStyleArray = { create: (context) => ({ JSXAttribute(node)
2043
2953
  message: `Single-element style array on "${propName}" — use ${propName}={value} instead of ${propName}={[value]} to avoid unnecessary array allocation`
2044
2954
  });
2045
2955
  } }) };
2046
-
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
+ } }) };
2047
3399
  //#endregion
2048
3400
  //#region src/plugin/rules/tanstack-query.ts
2049
3401
  const queryStableQueryClient = { create: (context) => {
@@ -2063,15 +3415,15 @@ const queryStableQueryClient = { create: (context) => {
2063
3415
  if (node.id?.type === "Identifier" && UPPERCASE_PATTERN.test(node.id.name) && (node.init?.type === "ArrowFunctionExpression" || node.init?.type === "FunctionExpression")) componentDepth--;
2064
3416
  },
2065
3417
  CallExpression(node) {
2066
- if (node.callee?.type === "Identifier" && STABLE_HOOK_WRAPPERS.has(node.callee.name)) stableHookDepth++;
3418
+ if (isHookCall(node, STABLE_HOOK_WRAPPERS)) stableHookDepth++;
2067
3419
  },
2068
3420
  "CallExpression:exit"(node) {
2069
- 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);
2070
3422
  },
2071
3423
  NewExpression(node) {
2072
3424
  if (componentDepth <= 0) return;
2073
3425
  if (stableHookDepth > 0) return;
2074
- if (node.callee?.type !== "Identifier" || node.callee.name !== TANSTACK_QUERY_CLIENT_CLASS) return;
3426
+ if (node.callee?.type !== "Identifier" || node.callee.name !== "QueryClient") return;
2075
3427
  context.report({
2076
3428
  node,
2077
3429
  message: "new QueryClient() inside a component — creates a new cache on every render. Move to module scope or wrap in useState(() => new QueryClient())"
@@ -2125,14 +3477,17 @@ const queryMutationMissingInvalidation = { create: (context) => ({ CallExpressio
2125
3477
  const optionsArgument = node.arguments?.[0];
2126
3478
  if (!optionsArgument || optionsArgument.type !== "ObjectExpression") return;
2127
3479
  if (!optionsArgument.properties?.some((property) => property.type === "Property" && property.key?.type === "Identifier" && property.key.name === "mutationFn")) return;
2128
- let hasInvalidation = false;
3480
+ let hasCacheUpdate = false;
2129
3481
  walkAst(optionsArgument, (child) => {
2130
- if (hasInvalidation) return;
2131
- if (child.type === "CallExpression" && child.callee?.type === "MemberExpression" && child.callee.property?.type === "Identifier" && child.callee.property.name === "invalidateQueries") hasInvalidation = true;
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
+ }
2132
3487
  });
2133
- if (!hasInvalidation) context.report({
3488
+ if (!hasCacheUpdate) context.report({
2134
3489
  node,
2135
- 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)"
2136
3491
  });
2137
3492
  } }) };
2138
3493
  const queryNoUseQueryForMutation = { create: (context) => ({ CallExpression(node) {
@@ -2156,7 +3511,6 @@ const queryNoUseQueryForMutation = { create: (context) => ({ CallExpression(node
2156
3511
  message: `${calleeName}() with a mutating fetch (POST/PUT/DELETE) — use useMutation() instead, which provides onSuccess/onError callbacks and doesn't auto-refetch`
2157
3512
  });
2158
3513
  } }) };
2159
-
2160
3514
  //#endregion
2161
3515
  //#region src/plugin/rules/security.ts
2162
3516
  const noEval = { create: (context) => ({
@@ -2187,7 +3541,7 @@ const noSecretsInClientCode = { create: (context) => ({ VariableDeclarator(node)
2187
3541
  const literalValue = node.init.value;
2188
3542
  const trailingSuffix = variableName.split("_").pop()?.toLowerCase() ?? "";
2189
3543
  const isUiConstant = SECRET_FALSE_POSITIVE_SUFFIXES.has(trailingSuffix);
2190
- if (SECRET_VARIABLE_PATTERN.test(variableName) && !isUiConstant && literalValue.length > SECRET_MIN_LENGTH_CHARS) {
3544
+ if (SECRET_VARIABLE_PATTERN.test(variableName) && !isUiConstant && literalValue.length > 24) {
2191
3545
  context.report({
2192
3546
  node,
2193
3547
  message: `Possible hardcoded secret in "${variableName}" — use environment variables instead`
@@ -2199,7 +3553,6 @@ const noSecretsInClientCode = { create: (context) => ({ VariableDeclarator(node)
2199
3553
  message: "Hardcoded secret detected — use environment variables instead"
2200
3554
  });
2201
3555
  } }) };
2202
-
2203
3556
  //#endregion
2204
3557
  //#region src/plugin/rules/server.ts
2205
3558
  const containsAuthCheck = (statements) => {
@@ -2223,7 +3576,7 @@ const serverAuthActions = { create: (context) => {
2223
3576
  const declaration = node.declaration;
2224
3577
  if (declaration?.type !== "FunctionDeclaration" || !declaration?.async) return;
2225
3578
  if (!(fileHasUseServerDirective || hasUseServerDirective(declaration))) return;
2226
- if (!containsAuthCheck((declaration.body?.body ?? []).slice(0, AUTH_CHECK_LOOKAHEAD_STATEMENTS))) {
3579
+ if (!containsAuthCheck((declaration.body?.body ?? []).slice(0, 10))) {
2227
3580
  const functionName = declaration.id?.name ?? "anonymous";
2228
3581
  context.report({
2229
3582
  node: declaration.id ?? node,
@@ -2233,28 +3586,384 @@ const serverAuthActions = { create: (context) => {
2233
3586
  }
2234
3587
  };
2235
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
+ };
2236
3679
  const serverAfterNonblocking = { create: (context) => {
2237
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
+ };
2238
3688
  return {
2239
3689
  Program(programNode) {
2240
3690
  fileHasUseServerDirective = hasDirective(programNode, "use server");
2241
3691
  },
3692
+ FunctionDeclaration: enterIfServerFunction,
3693
+ "FunctionDeclaration:exit": leaveIfServerFunction,
3694
+ FunctionExpression: enterIfServerFunction,
3695
+ "FunctionExpression:exit": leaveIfServerFunction,
3696
+ ArrowFunctionExpression: enterIfServerFunction,
3697
+ "ArrowFunctionExpression:exit": leaveIfServerFunction,
2242
3698
  CallExpression(node) {
2243
- if (!fileHasUseServerDirective) return;
3699
+ if (!fileHasUseServerDirective && serverFunctionDepth === 0) return;
2244
3700
  if (node.callee?.type !== "MemberExpression") return;
2245
3701
  if (node.callee.property?.type !== "Identifier") return;
2246
3702
  const objectName = node.callee.object?.type === "Identifier" ? node.callee.object.name : null;
2247
3703
  if (!objectName) return;
2248
3704
  const methodName = node.callee.property.name;
2249
- if (!(objectName === "console" && (methodName === "log" || methodName === "info" || methodName === "warn") || objectName === "analytics" && (methodName === "track" || methodName === "identify" || methodName === "page"))) return;
3705
+ if (!isDeferrableSideEffectCall(objectName, methodName)) return;
2250
3706
  context.report({
2251
3707
  node,
2252
- message: `${objectName}.${methodName}() in server action — use after() for non-blocking logging/analytics`
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";
3960
+ context.report({
3961
+ node,
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`
2253
3963
  });
2254
3964
  }
2255
3965
  };
2256
3966
  } };
2257
-
2258
3967
  //#endregion
2259
3968
  //#region src/plugin/rules/tanstack-start.ts
2260
3969
  const getRouteOptionsObject = (node) => {
@@ -2362,8 +4071,7 @@ const tanstackStartServerFnValidateInput = { create: (context) => ({ CallExpress
2362
4071
  const tanstackStartNoUseEffectFetch = { create: (context) => ({ CallExpression(node) {
2363
4072
  const filename = context.getFilename?.() ?? "";
2364
4073
  if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
2365
- if (node.callee?.type !== "Identifier") return;
2366
- if (node.callee.name !== "useEffect" && node.callee.name !== "useLayoutEffect") return;
4074
+ if (!isHookCall(node, EFFECT_HOOK_NAMES)) return;
2367
4075
  const callback = node.arguments?.[0];
2368
4076
  if (!callback) return;
2369
4077
  let hasFetchCall = false;
@@ -2438,15 +4146,17 @@ const tanstackStartServerFnMethodOrder = { create: (context) => ({ CallExpressio
2438
4146
  }
2439
4147
  } }) };
2440
4148
  const tanstackStartNoNavigateInRender = { create: (context) => {
2441
- let effectDepth = 0;
4149
+ let deferredCallbackDepth = 0;
2442
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));
2443
4153
  return {
2444
4154
  CallExpression(node) {
2445
4155
  const filename = context.getFilename?.() ?? "";
2446
4156
  if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
2447
- if (node.callee?.type === "Identifier" && (node.callee.name === "useEffect" || node.callee.name === "useLayoutEffect")) effectDepth++;
2448
- if (effectDepth > 0 || eventHandlerDepth > 0) return;
2449
- if (node.callee?.type === "Identifier" && node.callee.name === "navigate" && node.arguments?.length > 0) context.report({
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({
2450
4160
  node,
2451
4161
  message: "navigate() called during render — use redirect() in beforeLoad/loader for route-level redirects"
2452
4162
  });
@@ -2454,17 +4164,17 @@ const tanstackStartNoNavigateInRender = { create: (context) => {
2454
4164
  "CallExpression:exit"(node) {
2455
4165
  const filename = context.getFilename?.() ?? "";
2456
4166
  if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
2457
- if (node.callee?.type === "Identifier" && (node.callee.name === "useEffect" || node.callee.name === "useLayoutEffect")) effectDepth--;
4167
+ if (isDeferredHookCall(node)) deferredCallbackDepth = Math.max(0, deferredCallbackDepth - 1);
2458
4168
  },
2459
4169
  JSXAttribute(node) {
2460
4170
  const filename = context.getFilename?.() ?? "";
2461
4171
  if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
2462
- if (node.name?.type === "JSXIdentifier" && node.name.name.startsWith("on") && UPPERCASE_PATTERN.test(node.name.name.charAt(2))) eventHandlerDepth++;
4172
+ if (isEventHandlerAttribute(node)) eventHandlerDepth++;
2463
4173
  },
2464
4174
  "JSXAttribute:exit"(node) {
2465
4175
  const filename = context.getFilename?.() ?? "";
2466
4176
  if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
2467
- if (node.name?.type === "JSXIdentifier" && node.name.name.startsWith("on") && UPPERCASE_PATTERN.test(node.name.name.charAt(2))) eventHandlerDepth--;
4177
+ if (isEventHandlerAttribute(node)) eventHandlerDepth = Math.max(0, eventHandlerDepth - 1);
2468
4178
  }
2469
4179
  };
2470
4180
  } };
@@ -2491,6 +4201,17 @@ const tanstackStartNoUseServerInHandler = { create: (context) => ({ CallExpressi
2491
4201
  message: "\"use server\" inside createServerFn handler — TanStack Start handles this automatically, remove the directive"
2492
4202
  });
2493
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
+ };
2494
4215
  const tanstackStartNoSecretsInLoader = { create: (context) => ({ CallExpression(node) {
2495
4216
  const optionsObject = getRouteOptionsObject(node);
2496
4217
  if (!optionsObject) return;
@@ -2500,11 +4221,15 @@ const tanstackStartNoSecretsInLoader = { create: (context) => ({ CallExpression(
2500
4221
  if (keyName !== "loader" && keyName !== "beforeLoad") continue;
2501
4222
  walkAst(property.value ?? property, (child) => {
2502
4223
  if (child.type !== "MemberExpression") return;
2503
- if (child.object?.type === "MemberExpression" && child.object.object?.type === "Identifier" && child.object.object.name === "process" && child.object.property?.type === "Identifier" && child.object.property.name === "env") {
2504
- const envVarName = child.property?.type === "Identifier" ? child.property.name : null;
2505
- if (envVarName && !envVarName.startsWith("VITE_")) context.report({
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({
2506
4231
  node: child,
2507
- 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()`
2508
4233
  });
2509
4234
  }
2510
4235
  });
@@ -2558,6 +4283,7 @@ const hasTopLevelAwait = (statement) => {
2558
4283
  if (statement.type === "VariableDeclaration") return statement.declarations?.some((declarator) => declarator.init?.type === "AwaitExpression");
2559
4284
  if (statement.type === "ExpressionStatement") return statement.expression?.type === "AwaitExpression" || statement.expression?.type === "AssignmentExpression" && statement.expression.right?.type === "AwaitExpression";
2560
4285
  if (statement.type === "ReturnStatement") return statement.argument?.type === "AwaitExpression";
4286
+ if (statement.type === "ForOfStatement" && statement.await) return true;
2561
4287
  return false;
2562
4288
  };
2563
4289
  const tanstackStartLoaderParallelFetch = { create: (context) => ({ CallExpression(node) {
@@ -2573,7 +4299,7 @@ const tanstackStartLoaderParallelFetch = { create: (context) => ({ CallExpressio
2573
4299
  let sequentialAwaitCount = 0;
2574
4300
  for (const statement of functionBody.body ?? []) {
2575
4301
  if (hasTopLevelAwait(statement)) sequentialAwaitCount++;
2576
- if (sequentialAwaitCount >= SEQUENTIAL_AWAIT_THRESHOLD_FOR_LOADER) {
4302
+ if (sequentialAwaitCount >= 2) {
2577
4303
  context.report({
2578
4304
  node: property,
2579
4305
  message: "Multiple sequential awaits in loader — use Promise.all() to fetch data in parallel and avoid waterfalls"
@@ -2583,11 +4309,10 @@ const tanstackStartLoaderParallelFetch = { create: (context) => ({ CallExpressio
2583
4309
  }
2584
4310
  }
2585
4311
  } }) };
2586
-
2587
4312
  //#endregion
2588
4313
  //#region src/plugin/rules/state-and-effects.ts
2589
4314
  const noDerivedStateEffect = { create: (context) => ({ CallExpression(node) {
2590
- if (!isHookCall(node, EFFECT_HOOK_NAMES) || node.arguments.length < 2) return;
4315
+ if (!isHookCall(node, EFFECT_HOOK_NAMES) || (node.arguments?.length ?? 0) < 2) return;
2591
4316
  const callback = getEffectCallback(node);
2592
4317
  if (!callback) return;
2593
4318
  const depsNode = node.arguments[1];
@@ -2635,13 +4360,13 @@ const noCascadingSetState = { create: (context) => ({ CallExpression(node) {
2635
4360
  const callback = getEffectCallback(node);
2636
4361
  if (!callback) return;
2637
4362
  const setStateCallCount = countSetStateCalls(callback);
2638
- if (setStateCallCount >= CASCADING_SET_STATE_THRESHOLD) context.report({
4363
+ if (setStateCallCount >= 3) context.report({
2639
4364
  node,
2640
4365
  message: `${setStateCallCount} setState calls in a single useEffect — consider using useReducer or deriving state`
2641
4366
  });
2642
4367
  } }) };
2643
4368
  const noEffectEventHandler = { create: (context) => ({ CallExpression(node) {
2644
- if (!isHookCall(node, EFFECT_HOOK_NAMES) || node.arguments.length < 2) return;
4369
+ if (!isHookCall(node, EFFECT_HOOK_NAMES) || (node.arguments?.length ?? 0) < 2) return;
2645
4370
  const callback = getEffectCallback(node);
2646
4371
  if (!callback) return;
2647
4372
  const depsNode = node.arguments[1];
@@ -2656,24 +4381,57 @@ const noEffectEventHandler = { create: (context) => ({ CallExpression(node) {
2656
4381
  });
2657
4382
  } }) };
2658
4383
  const noDerivedUseState = { create: (context) => {
2659
- 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
+ };
2660
4393
  return {
2661
4394
  FunctionDeclaration(node) {
2662
- if (!node.id?.name || !isUppercaseName(node.id.name)) return;
2663
- 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();
2664
4403
  },
2665
4404
  VariableDeclarator(node) {
2666
- if (!isComponentAssignment(node)) return;
2667
- 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();
2668
4413
  },
2669
4414
  CallExpression(node) {
2670
4415
  if (!isHookCall(node, "useState") || !node.arguments?.length) return;
4416
+ if (componentPropStack.length === 0) return;
2671
4417
  const initializer = node.arguments[0];
2672
- if (initializer.type !== "Identifier") return;
2673
- if (componentPropNames.has(initializer.name)) context.report({
2674
- node,
2675
- message: `useState initialized from prop "${initializer.name}" — if this value should stay in sync with the prop, derive it during render instead`
2676
- });
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
+ }
2677
4435
  }
2678
4436
  };
2679
4437
  } };
@@ -2685,7 +4443,7 @@ const preferUseReducer = { create: (context) => {
2685
4443
  if (statement.type !== "VariableDeclaration") continue;
2686
4444
  for (const declarator of statement.declarations ?? []) if (isHookCall(declarator.init, "useState")) useStateCount++;
2687
4445
  }
2688
- if (useStateCount >= RELATED_USE_STATE_THRESHOLD) context.report({
4446
+ if (useStateCount >= 5) context.report({
2689
4447
  node: body,
2690
4448
  message: `Component "${componentName}" has ${useStateCount} useState calls — consider useReducer for related state`
2691
4449
  });
@@ -2712,15 +4470,42 @@ const rerenderLazyStateInit = { create: (context) => ({ CallExpression(node) {
2712
4470
  message: `useState(${calleeName}()) calls initializer on every render — use useState(() => ${calleeName}()) for lazy initialization`
2713
4471
  });
2714
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
+ };
2715
4485
  const rerenderFunctionalSetstate = { create: (context) => ({ CallExpression(node) {
2716
4486
  if (!isSetterCall(node)) return;
2717
4487
  if (!node.arguments?.length) return;
2718
4488
  const calleeName = node.callee.name;
2719
4489
  const argument = node.arguments[0];
2720
- if (argument.type === "BinaryExpression" && (argument.operator === "+" || argument.operator === "-") && argument.left?.type === "Identifier") context.report({
2721
- node,
2722
- message: `${calleeName}(${argument.left.name} ${argument.operator} ...) use functional update to avoid stale closures`
2723
- });
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
+ }
2724
4509
  } }) };
2725
4510
  const rerenderDependencies = { create: (context) => ({ CallExpression(node) {
2726
4511
  if (!isHookCall(node, HOOKS_WITH_DEPS) || node.arguments.length < 2) return;
@@ -2738,7 +4523,295 @@ const rerenderDependencies = { create: (context) => ({ CallExpression(node) {
2738
4523
  });
2739
4524
  }
2740
4525
  } }) };
2741
-
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
+ };
2742
4815
  //#endregion
2743
4816
  //#region src/plugin/index.ts
2744
4817
  const plugin = {
@@ -2748,21 +4821,69 @@ const plugin = {
2748
4821
  "no-fetch-in-effect": noFetchInEffect,
2749
4822
  "no-cascading-set-state": noCascadingSetState,
2750
4823
  "no-effect-event-handler": noEffectEventHandler,
4824
+ "no-effect-event-in-deps": noEffectEventInDeps,
4825
+ "no-prop-callback-in-effect": noPropCallbackInEffect,
2751
4826
  "no-derived-useState": noDerivedUseState,
2752
4827
  "prefer-useReducer": preferUseReducer,
2753
4828
  "rerender-lazy-state-init": rerenderLazyStateInit,
2754
4829
  "rerender-functional-setstate": rerenderFunctionalSetstate,
2755
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,
2756
4865
  "no-giant-component": noGiantComponent,
4866
+ "no-many-boolean-props": noManyBooleanProps,
4867
+ "no-react19-deprecated-apis": noReact19DeprecatedApis,
4868
+ "no-render-prop-children": noRenderPropChildren,
2757
4869
  "no-render-in-render": noRenderInRender,
2758
4870
  "no-nested-component-definition": noNestedComponentDefinition,
4871
+ "react-compiler-destructure-method": reactCompilerDestructureMethod,
2759
4872
  "no-usememo-simple-expression": noUsememoSimpleExpression,
2760
4873
  "no-layout-property-animation": noLayoutPropertyAnimation,
2761
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,
2762
4880
  "rendering-animate-svg-wrapper": renderingAnimateSvgWrapper,
4881
+ "rendering-hoist-jsx": renderingHoistJsx,
4882
+ "rendering-hydration-mismatch-time": renderingHydrationMismatchTime,
2763
4883
  "no-inline-prop-on-memo-component": noInlinePropOnMemoComponent,
2764
4884
  "rendering-hydration-no-flicker": renderingHydrationNoFlicker,
2765
4885
  "rendering-script-defer-async": renderingScriptDeferAsync,
4886
+ "rendering-usetransition-loading": renderingUsetransitionLoading,
2766
4887
  "no-transition-all": noTransitionAll,
2767
4888
  "no-global-css-variable-animation": noGlobalCssVariableAnimation,
2768
4889
  "no-large-animated-blur": noLargeAnimatedBlur,
@@ -2771,14 +4892,37 @@ const plugin = {
2771
4892
  "no-eval": noEval,
2772
4893
  "no-secrets-in-client-code": noSecretsInClientCode,
2773
4894
  "no-barrel-import": noBarrelImport,
4895
+ "no-dynamic-import-path": noDynamicImportPath,
2774
4896
  "no-full-lodash-import": noFullLodashImport,
2775
4897
  "no-moment": noMoment,
2776
4898
  "prefer-dynamic-import": preferDynamicImport,
2777
4899
  "use-lazy-motion": useLazyMotion,
2778
4900
  "no-undeferred-third-party": noUndeferredThirdParty,
2779
4901
  "no-array-index-as-key": noArrayIndexAsKey,
4902
+ "no-polymorphic-children": noPolymorphicChildren,
2780
4903
  "rendering-conditional-render": renderingConditionalRender,
4904
+ "rendering-svg-precision": renderingSvgPrecision,
2781
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
+ } }) },
2782
4926
  "nextjs-no-img-element": nextjsNoImgElement,
2783
4927
  "nextjs-async-client-component": nextjsAsyncClientComponent,
2784
4928
  "nextjs-no-a-element": nextjsNoAElement,
@@ -2797,10 +4941,20 @@ const plugin = {
2797
4941
  "nextjs-no-side-effect-in-get-handler": nextjsNoSideEffectInGetHandler,
2798
4942
  "server-auth-actions": serverAuthActions,
2799
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,
2800
4950
  "client-passive-event-listeners": clientPassiveEventListeners,
4951
+ "client-localstorage-no-version": clientLocalstorageNoVersion,
2801
4952
  "js-combine-iterations": jsCombineIterations,
2802
4953
  "js-tosorted-immutable": jsTosortedImmutable,
2803
4954
  "js-hoist-regexp": jsHoistRegexp,
4955
+ "js-hoist-intl": jsHoistIntl,
4956
+ "js-cache-property-access": jsCachePropertyAccess,
4957
+ "js-length-check-first": jsLengthCheckFirst,
2804
4958
  "js-min-max-loop": jsMinMaxLoop,
2805
4959
  "js-set-map-lookups": jsSetMapLookups,
2806
4960
  "js-batch-dom-css": jsBatchDomCss,
@@ -2817,6 +4971,22 @@ const plugin = {
2817
4971
  "rn-no-legacy-shadow-styles": rnNoLegacyShadowStyles,
2818
4972
  "rn-prefer-reanimated": rnPreferReanimated,
2819
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,
2820
4990
  "tanstack-start-route-property-order": tanstackStartRoutePropertyOrder,
2821
4991
  "tanstack-start-no-direct-fetch-in-loader": tanstackStartNoDirectFetchInLoader,
2822
4992
  "tanstack-start-server-fn-validate-input": tanstackStartServerFnValidateInput,
@@ -2854,7 +5024,7 @@ const plugin = {
2854
5024
  "no-long-transition-duration": noLongTransitionDuration
2855
5025
  }
2856
5026
  };
2857
-
2858
5027
  //#endregion
2859
5028
  export { plugin as default };
5029
+
2860
5030
  //# sourceMappingURL=react-doctor-plugin.js.map