oxlint-plugin-react-doctor 0.5.0 → 0.5.1-dev.19d99ee

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.
Files changed (2) hide show
  1. package/dist/index.js +416 -105
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -432,6 +432,7 @@ const SETTER_PATTERN = /^set[A-Z]/;
432
432
  const RENDER_FUNCTION_PATTERN = /^render[A-Z]/;
433
433
  const UPPERCASE_PATTERN = /^[A-Z]/;
434
434
  const REACT_HANDLER_PROP_PATTERN = /^on[A-Z]/;
435
+ const HANDLER_FUNCTION_NAME_PATTERN = /^(?:on|handle)[A-Z]/;
435
436
  const EFFECT_HOOK_NAMES$1 = new Set(["useEffect", "useLayoutEffect"]);
436
437
  const HOOKS_WITH_DEPS = new Set([
437
438
  "useEffect",
@@ -1131,6 +1132,15 @@ const isMemberProperty = (node, propertyName) => Boolean(node && isNodeOfType(no
1131
1132
  const NEXTJS_SOURCE_FILE_EXTENSION_GROUP = "(?:tsx?|jsx?|mts|mjs)";
1132
1133
  const PAGE_FILE_PATTERN = new RegExp(`/page\\.${NEXTJS_SOURCE_FILE_EXTENSION_GROUP}$`);
1133
1134
  const PAGE_OR_LAYOUT_FILE_PATTERN = new RegExp(`/(page|layout)\\.${NEXTJS_SOURCE_FILE_EXTENSION_GROUP}$`);
1135
+ const LAYOUT_FILE_NAMES = [
1136
+ "layout.tsx",
1137
+ "layout.jsx",
1138
+ "layout.ts",
1139
+ "layout.js",
1140
+ "layout.mts",
1141
+ "layout.mjs"
1142
+ ];
1143
+ const METADATA_EXPORT_NAMES = ["metadata", "generateMetadata"];
1134
1144
  const INTERNAL_PAGE_PATH_PATTERN = /\/(?:(?:\((?:dashboard|admin|settings|account|internal|manage|console|portal|auth|onboarding|app|ee|protected)\))|(?:dashboard|admin|settings|account|internal|manage|console|portal))\//i;
1135
1145
  const PAGES_DIRECTORY_PATTERN = /\/pages\//;
1136
1146
  const NEXTJS_NAVIGATION_FUNCTIONS = new Set([
@@ -12542,6 +12552,64 @@ const nextjsInlineScriptMissingId = defineRule({
12542
12552
  } })
12543
12553
  });
12544
12554
  //#endregion
12555
+ //#region src/plugin/utils/parse-export-specifiers.ts
12556
+ const getSpecifierName = (rawName) => rawName.replace(/^type\s+/, "").trim();
12557
+ const parseExportSpecifiers = (specifiersText, declarationIsTypeOnly) => specifiersText.split(",").map((specifierText) => specifierText.trim()).filter(Boolean).map((specifierText) => {
12558
+ const isTypeOnly = declarationIsTypeOnly || specifierText.startsWith("type ");
12559
+ const [rawLocalName, rawExportedName] = specifierText.split(/\s+as\s+/);
12560
+ const localName = getSpecifierName(rawLocalName ?? "");
12561
+ return {
12562
+ localName,
12563
+ exportedName: getSpecifierName(rawExportedName ?? localName),
12564
+ isTypeOnly
12565
+ };
12566
+ });
12567
+ //#endregion
12568
+ //#region src/plugin/utils/strip-js-comments.ts
12569
+ const BLOCK_COMMENT_PATTERN = /\/\*[\s\S]*?\*\//g;
12570
+ const LINE_COMMENT_PATTERN = /^\s*\/\/.*$/gm;
12571
+ const stripJsComments = (sourceText) => sourceText.replace(BLOCK_COMMENT_PATTERN, "").replace(LINE_COMMENT_PATTERN, "");
12572
+ //#endregion
12573
+ //#region src/plugin/utils/does-module-export-name.ts
12574
+ const DEFAULT_EXPORT_DECLARATION_PATTERN = /^\s*export\s+default\b/m;
12575
+ const NAMED_EXPORT_DECLARATION_PATTERN = /^\s*export\s+(?:declare\s+)?(?:(?:async\s+)?function|(?:abstract\s+)?class|const|let|var|enum|interface|type)\s+([\w$]+)/gm;
12576
+ const LOCAL_EXPORT_SPECIFIER_DECLARATION_PATTERN$1 = /^\s*export\s+(?:type\s+)?\{([\s\S]*?)\}(?:\s+from\s+["'][^"']+["'])?\s*;?\s*(?:(?:\/\/[^\n]*)?\s*)/gm;
12577
+ const doesSourceTextExportName = (sourceText, exportedName) => {
12578
+ const strippedSource = stripJsComments(sourceText);
12579
+ if (exportedName === "default" && DEFAULT_EXPORT_DECLARATION_PATTERN.test(strippedSource)) return true;
12580
+ for (const match of strippedSource.matchAll(NAMED_EXPORT_DECLARATION_PATTERN)) if (match[1] === exportedName) return true;
12581
+ for (const match of strippedSource.matchAll(LOCAL_EXPORT_SPECIFIER_DECLARATION_PATTERN$1)) if (parseExportSpecifiers(match[1] ?? "", false).map((specifier) => specifier.exportedName).includes(exportedName)) return true;
12582
+ return false;
12583
+ };
12584
+ const doesModuleExportName = (filePath, exportedName) => {
12585
+ try {
12586
+ return doesSourceTextExportName(fs.readFileSync(filePath, "utf8"), exportedName);
12587
+ } catch {
12588
+ return false;
12589
+ }
12590
+ };
12591
+ //#endregion
12592
+ //#region src/plugin/utils/has-ancestor-layout-matching.ts
12593
+ const hasAncestorLayoutMatching = (pageFilename, matchesLayout) => {
12594
+ const normalizedPage = pageFilename.replaceAll("\\", "/");
12595
+ let currentDirectory = path.dirname(normalizedPage);
12596
+ for (let level = 0; level < 30; level++) {
12597
+ for (const layoutFileName of LAYOUT_FILE_NAMES) {
12598
+ const layoutPath = path.join(currentDirectory, layoutFileName);
12599
+ if (layoutPath.replaceAll("\\", "/") === normalizedPage) continue;
12600
+ if (matchesLayout(layoutPath)) return true;
12601
+ }
12602
+ const parentDirectory = path.dirname(currentDirectory);
12603
+ if (path.basename(currentDirectory) === "app" && path.basename(parentDirectory) !== "app") break;
12604
+ if (parentDirectory === currentDirectory) break;
12605
+ currentDirectory = parentDirectory;
12606
+ }
12607
+ return false;
12608
+ };
12609
+ //#endregion
12610
+ //#region src/plugin/utils/find-ancestor-metadata-layout.ts
12611
+ const hasAncestorMetadataLayout = (pageFilename) => hasAncestorLayoutMatching(pageFilename, (layoutPath) => METADATA_EXPORT_NAMES.some((exportName) => doesModuleExportName(layoutPath, exportName)));
12612
+ //#endregion
12545
12613
  //#region src/plugin/rules/nextjs/nextjs-missing-metadata.ts
12546
12614
  const nextjsMissingMetadata = defineRule({
12547
12615
  id: "nextjs-missing-metadata",
@@ -12554,13 +12622,15 @@ const nextjsMissingMetadata = defineRule({
12554
12622
  const filename = normalizeFilename$1(context.filename ?? "");
12555
12623
  if (!PAGE_FILE_PATTERN.test(filename)) return;
12556
12624
  if (INTERNAL_PAGE_PATH_PATTERN.test(filename)) return;
12557
- if (!programNode.body?.some((statement) => {
12625
+ if (programNode.body?.some((statement) => {
12558
12626
  if (!isNodeOfType(statement, "ExportNamedDeclaration")) return false;
12559
12627
  const declaration = statement.declaration;
12560
- if (isNodeOfType(declaration, "VariableDeclaration")) return declaration.declarations?.some((declarator) => isNodeOfType(declarator.id, "Identifier") && (declarator.id.name === "metadata" || declarator.id.name === "generateMetadata"));
12628
+ if (isNodeOfType(declaration, "VariableDeclaration")) return declaration.declarations?.some((declarator) => isNodeOfType(declarator.id, "Identifier") && METADATA_EXPORT_NAMES.includes(declarator.id.name));
12561
12629
  if (isNodeOfType(declaration, "FunctionDeclaration")) return declaration.id?.name === "generateMetadata";
12562
- return false;
12563
- })) context.report({
12630
+ return (statement.specifiers ?? []).some((specifier) => isNodeOfType(specifier, "ExportSpecifier") && isNodeOfType(specifier.exported, "Identifier") && METADATA_EXPORT_NAMES.includes(specifier.exported.name));
12631
+ })) return;
12632
+ if (hasAncestorMetadataLayout(context.filename ?? "")) return;
12633
+ context.report({
12564
12634
  node: programNode,
12565
12635
  message: "This page has no metadata, so search engines and social previews get no title or description."
12566
12636
  });
@@ -13451,43 +13521,6 @@ const parseSourceFile = (absoluteFilePath) => {
13451
13521
  return parsedProgram;
13452
13522
  };
13453
13523
  //#endregion
13454
- //#region src/plugin/utils/parse-export-specifiers.ts
13455
- const getSpecifierName = (rawName) => rawName.replace(/^type\s+/, "").trim();
13456
- const parseExportSpecifiers = (specifiersText, declarationIsTypeOnly) => specifiersText.split(",").map((specifierText) => specifierText.trim()).filter(Boolean).map((specifierText) => {
13457
- const isTypeOnly = declarationIsTypeOnly || specifierText.startsWith("type ");
13458
- const [rawLocalName, rawExportedName] = specifierText.split(/\s+as\s+/);
13459
- const localName = getSpecifierName(rawLocalName ?? "");
13460
- return {
13461
- localName,
13462
- exportedName: getSpecifierName(rawExportedName ?? localName),
13463
- isTypeOnly
13464
- };
13465
- });
13466
- //#endregion
13467
- //#region src/plugin/utils/strip-js-comments.ts
13468
- const BLOCK_COMMENT_PATTERN = /\/\*[\s\S]*?\*\//g;
13469
- const LINE_COMMENT_PATTERN = /^\s*\/\/.*$/gm;
13470
- const stripJsComments = (sourceText) => sourceText.replace(BLOCK_COMMENT_PATTERN, "").replace(LINE_COMMENT_PATTERN, "");
13471
- //#endregion
13472
- //#region src/plugin/utils/does-module-export-name.ts
13473
- const DEFAULT_EXPORT_DECLARATION_PATTERN = /^\s*export\s+default\b/m;
13474
- const NAMED_EXPORT_DECLARATION_PATTERN = /^\s*export\s+(?:declare\s+)?(?:(?:async\s+)?function|(?:abstract\s+)?class|const|let|var|enum|interface|type)\s+([\w$]+)/gm;
13475
- const LOCAL_EXPORT_SPECIFIER_DECLARATION_PATTERN$1 = /^\s*export\s+(?:type\s+)?\{([\s\S]*?)\}(?:\s+from\s+["'][^"']+["'])?\s*;?\s*(?:(?:\/\/[^\n]*)?\s*)/gm;
13476
- const doesSourceTextExportName = (sourceText, exportedName) => {
13477
- const strippedSource = stripJsComments(sourceText);
13478
- if (exportedName === "default" && DEFAULT_EXPORT_DECLARATION_PATTERN.test(strippedSource)) return true;
13479
- for (const match of strippedSource.matchAll(NAMED_EXPORT_DECLARATION_PATTERN)) if (match[1] === exportedName) return true;
13480
- for (const match of strippedSource.matchAll(LOCAL_EXPORT_SPECIFIER_DECLARATION_PATTERN$1)) if (parseExportSpecifiers(match[1] ?? "", false).map((specifier) => specifier.exportedName).includes(exportedName)) return true;
13481
- return false;
13482
- };
13483
- const doesModuleExportName = (filePath, exportedName) => {
13484
- try {
13485
- return doesSourceTextExportName(fs.readFileSync(filePath, "utf8"), exportedName);
13486
- } catch {
13487
- return false;
13488
- }
13489
- };
13490
- //#endregion
13491
13524
  //#region src/plugin/utils/is-barrel-index-module.ts
13492
13525
  const INDEX_MODULE_FILE_PATTERN = /^index\.(?:[cm]?[jt]sx?|mjs)$/;
13493
13526
  const BINDING_IMPORT_DECLARATION_PATTERN = /^\s*import\s+(type\s+)?(?!["'])([^;]*?)\s+from\s+["']([^"']+)["']\s*;?\s*(?:(?:\/\/[^\n]*)?\s*)/gm;
@@ -13971,29 +14004,10 @@ const astMentionsSuspense = (programNode) => {
13971
14004
  };
13972
14005
  //#endregion
13973
14006
  //#region src/plugin/utils/find-ancestor-suspense-layout.ts
13974
- const LAYOUT_FILE_NAMES = [
13975
- "layout.tsx",
13976
- "layout.jsx",
13977
- "layout.ts",
13978
- "layout.js"
13979
- ];
13980
- const hasAncestorSuspenseLayout = (pageFilename) => {
13981
- const normalizedPage = pageFilename.replaceAll("\\", "/");
13982
- let currentDirectory = path.dirname(normalizedPage);
13983
- for (let level = 0; level < 30; level++) {
13984
- for (const layoutFileName of LAYOUT_FILE_NAMES) {
13985
- const layoutPath = path.join(currentDirectory, layoutFileName);
13986
- if (layoutPath.replaceAll("\\", "/") === normalizedPage) continue;
13987
- const programRoot = parseSourceFile(layoutPath);
13988
- if (programRoot && astMentionsSuspense(programRoot)) return true;
13989
- }
13990
- if (path.basename(currentDirectory) === "app") break;
13991
- const parentDirectory = path.dirname(currentDirectory);
13992
- if (parentDirectory === currentDirectory) break;
13993
- currentDirectory = parentDirectory;
13994
- }
13995
- return false;
13996
- };
14007
+ const hasAncestorSuspenseLayout = (pageFilename) => hasAncestorLayoutMatching(pageFilename, (layoutPath) => {
14008
+ const programRoot = parseSourceFile(layoutPath);
14009
+ return Boolean(programRoot && astMentionsSuspense(programRoot));
14010
+ });
13997
14011
  //#endregion
13998
14012
  //#region src/plugin/rules/nextjs/nextjs-no-use-search-params-without-suspense.ts
13999
14013
  const astContainsUseSearchParams = (root) => {
@@ -24343,6 +24357,21 @@ const noZIndex9999 = defineRule({
24343
24357
  })
24344
24358
  });
24345
24359
  //#endregion
24360
+ //#region src/plugin/utils/is-framework-route-or-special-filename.ts
24361
+ const sourceFileExtensionGroup = NEXTJS_SOURCE_FILE_EXTENSION_GROUP;
24362
+ const FRAMEWORK_ROUTE_FILE_PATTERNS = [
24363
+ new RegExp(`^(page|layout|loading|error|not-found|template|default|global-error|route|_app|_document|_error)\\.${sourceFileExtensionGroup}$`),
24364
+ new RegExp(`^(_layout|\\+html|\\+not-found|\\+native-intent)\\.${sourceFileExtensionGroup}$`),
24365
+ new RegExp(`(?:^__root|\\.lazy)\\.${sourceFileExtensionGroup}$`),
24366
+ new RegExp(`^(root|entry\\.client|entry\\.server)\\.${sourceFileExtensionGroup}$`)
24367
+ ];
24368
+ const isFrameworkRouteOrSpecialFilename = (rawFilename) => {
24369
+ if (!rawFilename) return false;
24370
+ if (isNextjsMetadataImageRouteFilename(rawFilename)) return true;
24371
+ const basename = path.basename(rawFilename);
24372
+ return FRAMEWORK_ROUTE_FILE_PATTERNS.some((pattern) => pattern.test(basename));
24373
+ };
24374
+ //#endregion
24346
24375
  //#region src/plugin/rules/react-builtins/only-export-components-tables.ts
24347
24376
  const NOT_REACT_COMPONENT_EXPRESSION_TYPES = new Set([
24348
24377
  "ArrayExpression",
@@ -24394,32 +24423,6 @@ const ENTRY_POINT_BASENAMES = new Set([
24394
24423
  "client.jsx",
24395
24424
  "server.tsx",
24396
24425
  "server.jsx",
24397
- "page.tsx",
24398
- "page.jsx",
24399
- "layout.tsx",
24400
- "layout.jsx",
24401
- "loading.tsx",
24402
- "loading.jsx",
24403
- "error.tsx",
24404
- "error.jsx",
24405
- "not-found.tsx",
24406
- "not-found.jsx",
24407
- "template.tsx",
24408
- "template.jsx",
24409
- "default.tsx",
24410
- "default.jsx",
24411
- "global-error.tsx",
24412
- "global-error.jsx",
24413
- "route.tsx",
24414
- "route.jsx",
24415
- "_layout.tsx",
24416
- "_layout.jsx",
24417
- "_app.tsx",
24418
- "_app.jsx",
24419
- "_document.tsx",
24420
- "_document.jsx",
24421
- "_error.tsx",
24422
- "_error.jsx",
24423
24426
  "app.tsx",
24424
24427
  "app.jsx",
24425
24428
  "App.tsx",
@@ -24499,6 +24502,54 @@ const UTILITY_FILE_BASENAMES = new Set([
24499
24502
  "defaults.tsx",
24500
24503
  "defaults.jsx"
24501
24504
  ]);
24505
+ const ROUTE_FACTORY_CALLEE_NAMES = new Set([
24506
+ ...TANSTACK_ROUTE_CREATION_FUNCTIONS,
24507
+ "createLazyFileRoute",
24508
+ "createLazyRoute",
24509
+ "createAPIFileRoute",
24510
+ "createServerFileRoute",
24511
+ "createServerRootRoute",
24512
+ "createServerRoute",
24513
+ "createBrowserRouter",
24514
+ "createHashRouter",
24515
+ "createMemoryRouter",
24516
+ "createStaticRouter",
24517
+ "createRouter"
24518
+ ]);
24519
+ const ROUTE_MODULE_ALLOWED_EXPORT_NAMES = new Set([
24520
+ "loader",
24521
+ "clientLoader",
24522
+ "action",
24523
+ "clientAction",
24524
+ "headers",
24525
+ "meta",
24526
+ "links",
24527
+ "handle",
24528
+ "shouldRevalidate",
24529
+ "middleware",
24530
+ "unstable_middleware",
24531
+ "getServerSideProps",
24532
+ "getStaticProps",
24533
+ "getStaticPaths",
24534
+ "getInitialProps",
24535
+ "reportWebVitals",
24536
+ "metadata",
24537
+ "generateMetadata",
24538
+ "generateStaticParams",
24539
+ "generateImageMetadata",
24540
+ "generateSitemaps",
24541
+ "viewport",
24542
+ "generateViewport",
24543
+ "revalidate",
24544
+ "dynamic",
24545
+ "dynamicParams",
24546
+ "fetchCache",
24547
+ "runtime",
24548
+ "preferredRegion",
24549
+ "maxDuration",
24550
+ "experimental_ppr",
24551
+ "unstable_settings"
24552
+ ]);
24502
24553
  //#endregion
24503
24554
  //#region src/plugin/rules/react-builtins/only-export-components.ts
24504
24555
  const NAMED_EXPORT_MESSAGE = "This file exports non-components, so Fast Refresh can't safely preserve component state.";
@@ -24542,6 +24593,18 @@ const isReactCreateContext = (initializer) => {
24542
24593
  if (isNodeOfType(callee, "MemberExpression") && isNodeOfType(callee.property, "Identifier") && callee.property.name === "createContext") return true;
24543
24594
  return false;
24544
24595
  };
24596
+ const isRouteFactoryName = (name) => ROUTE_FACTORY_CALLEE_NAMES.has(name);
24597
+ const isRouteFactoryCall = (expression) => {
24598
+ let currentCall = expression;
24599
+ while (isNodeOfType(currentCall, "CallExpression")) {
24600
+ const callee = currentCall.callee;
24601
+ if (isNodeOfType(callee, "Identifier") && isRouteFactoryName(callee.name)) return true;
24602
+ if (isNodeOfType(callee, "MemberExpression") && isNodeOfType(callee.property, "Identifier") && isRouteFactoryName(callee.property.name)) return true;
24603
+ if (!isNodeOfType(callee, "CallExpression")) return false;
24604
+ currentCall = callee;
24605
+ }
24606
+ return false;
24607
+ };
24545
24608
  const isReactHocName = (name, state) => state.customHocs.has(name);
24546
24609
  const isHocCallee = (callee, state) => {
24547
24610
  if (isNodeOfType(callee, "Identifier")) return isReactHocName(callee.name, state);
@@ -24575,10 +24638,12 @@ const isReactComponentInitializer = (expression, state) => {
24575
24638
  const classifyExport = (name, reportNode, isFunction, initializer, state) => {
24576
24639
  if (initializer) {
24577
24640
  const expression = skipTsExpression(initializer);
24641
+ if (isRouteFactoryCall(expression)) return { kind: "react-component" };
24578
24642
  if (isNodeOfType(expression, "CallExpression") && isHocCallee(expression.callee, state) && expression.arguments.length > 0 && isReactComponentName(name)) return { kind: "react-component" };
24579
24643
  if (isNodeOfType(expression, "ConditionalExpression") && isReactComponentName(name) && isReactComponentInitializer(expression.consequent, state) && isReactComponentInitializer(expression.alternate, state)) return { kind: "react-component" };
24580
24644
  }
24581
24645
  if (state.allowExportNames.has(name)) return { kind: "allowed" };
24646
+ if (ROUTE_MODULE_ALLOWED_EXPORT_NAMES.has(name)) return { kind: "allowed" };
24582
24647
  if (/^use[A-Z]/.test(name)) return { kind: "allowed" };
24583
24648
  if (state.allowConstantExport && initializer) {
24584
24649
  const expression = skipTsExpression(initializer);
@@ -24650,6 +24715,7 @@ const isFileNameAllowed = (filename, checkJS) => {
24650
24715
  if (filename.includes(".test.") || filename.includes(".spec.") || filename.includes(".cy.") || filename.includes(".stories.")) return false;
24651
24716
  for (const segment of NON_FAST_REFRESH_PATH_SEGMENTS) if (filename.includes(segment)) return false;
24652
24717
  if (isEntryPointFile(filename)) return false;
24718
+ if (isFrameworkRouteOrSpecialFilename(filename)) return false;
24653
24719
  if (isAssetOrUtilityFile(filename)) return false;
24654
24720
  if (filename.endsWith(".tsx") || filename.endsWith(".jsx")) return true;
24655
24721
  if (checkJS && filename.endsWith(".js")) return true;
@@ -24724,6 +24790,10 @@ const onlyExportComponents = defineRule({
24724
24790
  continue;
24725
24791
  }
24726
24792
  if (isNodeOfType(stripped, "CallExpression")) {
24793
+ if (isRouteFactoryCall(stripped)) {
24794
+ hasReactExport = true;
24795
+ continue;
24796
+ }
24727
24797
  const isHoc = isHocCallee(stripped.callee, state);
24728
24798
  const firstArg = stripped.arguments[0];
24729
24799
  const firstArgIsValid = Boolean(firstArg) && (() => {
@@ -28518,29 +28588,236 @@ const isInsidePlatformOsWebBranch = (node) => {
28518
28588
  //#endregion
28519
28589
  //#region src/plugin/rules/react-native/utils/collect-text-wrapper-components.ts
28520
28590
  const isFunctionNode = (node) => isNodeOfType(node, "ArrowFunctionExpression") || isNodeOfType(node, "FunctionExpression") || isNodeOfType(node, "FunctionDeclaration");
28521
- const resolveReturnedRootElementName = (functionNode) => {
28591
+ const COMPONENT_WRAPPER_CALLEE_NAMES = new Set(["memo", "forwardRef"]);
28592
+ const resolveCalleeName = (callee) => {
28593
+ if (isNodeOfType(callee, "Identifier")) return callee.name;
28594
+ if (isNodeOfType(callee, "MemberExpression") && isNodeOfType(callee.property, "Identifier")) return callee.property.name;
28595
+ return null;
28596
+ };
28597
+ const unwrapComponentDefinition = (node) => {
28598
+ let current = stripParenExpression(node);
28599
+ while (isNodeOfType(current, "CallExpression")) {
28600
+ const calleeName = resolveCalleeName(current.callee);
28601
+ const firstArgument = current.arguments?.[0];
28602
+ if (!calleeName || !COMPONENT_WRAPPER_CALLEE_NAMES.has(calleeName) || !firstArgument) break;
28603
+ current = stripParenExpression(firstArgument);
28604
+ }
28605
+ return current;
28606
+ };
28607
+ const resolveChildrenPropertyLocalName = (property) => {
28608
+ if (!isNodeOfType(property, "Property")) return null;
28609
+ if (!isNodeOfType(property.key, "Identifier") || property.key.name !== "children") return null;
28610
+ const value = property.value;
28611
+ if (isNodeOfType(value, "Identifier")) return value.name;
28612
+ if (isNodeOfType(value, "AssignmentPattern") && isNodeOfType(value.left, "Identifier")) return value.left.name;
28613
+ return null;
28614
+ };
28615
+ const resolveParamChildrenBindings = (functionNode) => {
28616
+ const bindings = {
28617
+ childrenNames: /* @__PURE__ */ new Set(),
28618
+ propsObjectNames: /* @__PURE__ */ new Set()
28619
+ };
28620
+ const firstParam = functionNode.params?.[0];
28621
+ if (!firstParam) return bindings;
28622
+ if (isNodeOfType(firstParam, "Identifier")) {
28623
+ bindings.propsObjectNames.add(firstParam.name);
28624
+ return bindings;
28625
+ }
28626
+ if (!isNodeOfType(firstParam, "ObjectPattern")) return bindings;
28627
+ let didDestructureChildren = false;
28628
+ let restName = null;
28629
+ for (const property of firstParam.properties ?? []) {
28630
+ if (isNodeOfType(property, "RestElement") && isNodeOfType(property.argument, "Identifier")) {
28631
+ restName = property.argument.name;
28632
+ continue;
28633
+ }
28634
+ const localName = resolveChildrenPropertyLocalName(property);
28635
+ if (localName) {
28636
+ didDestructureChildren = true;
28637
+ bindings.childrenNames.add(localName);
28638
+ }
28639
+ }
28640
+ if (restName && !didDestructureChildren) bindings.propsObjectNames.add(restName);
28641
+ return bindings;
28642
+ };
28643
+ const MAX_CHILDREN_ALIAS_PASSES = 3;
28644
+ const isPropsObjectExpression = (expression, bindings) => {
28645
+ if (!expression) return false;
28646
+ const value = stripParenExpression(expression);
28647
+ if (isNodeOfType(value, "Identifier")) return bindings.propsObjectNames.has(value.name);
28648
+ return isNodeOfType(value, "MemberExpression") && isNodeOfType(value.object, "ThisExpression") && isNodeOfType(value.property, "Identifier") && value.property.name === "props";
28649
+ };
28650
+ const isChildrenValueExpression = (expression, bindings) => {
28651
+ if (!expression) return false;
28652
+ const value = stripParenExpression(expression);
28653
+ if (isNodeOfType(value, "Identifier")) return bindings.childrenNames.has(value.name);
28654
+ return isNodeOfType(value, "MemberExpression") && isNodeOfType(value.property, "Identifier") && value.property.name === "children" && isPropsObjectExpression(value.object, bindings);
28655
+ };
28656
+ const collectChildrenAliases = (functionNode, bindings) => {
28522
28657
  const { body } = functionNode;
28523
- if (!body) return null;
28524
- if (!isNodeOfType(body, "BlockStatement")) return isNodeOfType(body, "JSXElement") ? resolveJsxElementName(body.openingElement) : null;
28525
- for (const statement of body.body) {
28526
- if (!isNodeOfType(statement, "ReturnStatement")) continue;
28527
- const argument = statement.argument;
28528
- if (argument && isNodeOfType(argument, "JSXElement")) return resolveJsxElementName(argument.openingElement);
28658
+ if (!body || !isNodeOfType(body, "BlockStatement")) return;
28659
+ for (let pass = 0; pass < MAX_CHILDREN_ALIAS_PASSES; pass += 1) {
28660
+ const sizeBeforePass = bindings.childrenNames.size;
28661
+ walkAst(body, (node) => {
28662
+ if (isFunctionNode(node)) return false;
28663
+ if (!isNodeOfType(node, "VariableDeclarator") || !node.init) return void 0;
28664
+ if (isNodeOfType(node.id, "Identifier")) {
28665
+ if (isChildrenValueExpression(node.init, bindings)) bindings.childrenNames.add(node.id.name);
28666
+ return;
28667
+ }
28668
+ if (isNodeOfType(node.id, "ObjectPattern") && isPropsObjectExpression(node.init, bindings)) for (const property of node.id.properties ?? []) {
28669
+ const localName = resolveChildrenPropertyLocalName(property);
28670
+ if (localName) bindings.childrenNames.add(localName);
28671
+ }
28672
+ });
28673
+ if (bindings.childrenNames.size === sizeBeforePass) break;
28674
+ }
28675
+ };
28676
+ const collectJsxRootsFromExpression = (expression, roots) => {
28677
+ const value = stripParenExpression(expression);
28678
+ if (isNodeOfType(value, "JSXElement") || isNodeOfType(value, "JSXFragment")) {
28679
+ roots.push(value);
28680
+ return;
28681
+ }
28682
+ if (isNodeOfType(value, "ConditionalExpression")) {
28683
+ if (value.consequent) collectJsxRootsFromExpression(value.consequent, roots);
28684
+ if (value.alternate) collectJsxRootsFromExpression(value.alternate, roots);
28685
+ return;
28686
+ }
28687
+ if (isNodeOfType(value, "LogicalExpression")) {
28688
+ if (value.left) collectJsxRootsFromExpression(value.left, roots);
28689
+ if (value.right) collectJsxRootsFromExpression(value.right, roots);
28690
+ }
28691
+ };
28692
+ const collectReturnedJsxRoots = (functionNode) => {
28693
+ const roots = [];
28694
+ const { body } = functionNode;
28695
+ if (!body) return roots;
28696
+ if (!isNodeOfType(body, "BlockStatement")) {
28697
+ collectJsxRootsFromExpression(body, roots);
28698
+ return roots;
28699
+ }
28700
+ walkAst(body, (node) => {
28701
+ if (isFunctionNode(node) && node !== functionNode) return false;
28702
+ if (isNodeOfType(node, "ReturnStatement") && node.argument) {
28703
+ collectJsxRootsFromExpression(node.argument, roots);
28704
+ return false;
28705
+ }
28706
+ });
28707
+ return roots;
28708
+ };
28709
+ const isChildrenForwardingJsxChild = (child, bindings) => isNodeOfType(child, "JSXExpressionContainer") && isChildrenValueExpression(child.expression, bindings);
28710
+ const isChildrenForwardingAttribute = (attribute, bindings) => {
28711
+ if (isNodeOfType(attribute, "JSXSpreadAttribute")) return isPropsObjectExpression(attribute.argument, bindings);
28712
+ return isNodeOfType(attribute, "JSXAttribute") && isNodeOfType(attribute.name, "JSXIdentifier") && attribute.name.name === "children" && isNodeOfType(attribute.value, "JSXExpressionContainer") && isChildrenValueExpression(attribute.value.expression, bindings);
28713
+ };
28714
+ const jsxRootForwardsChildrenIntoText = (jsxRoot, bindings, isTextHandlingElement) => {
28715
+ let didForwardIntoText = false;
28716
+ walkAst(jsxRoot, (node) => {
28717
+ if (didForwardIntoText || isFunctionNode(node)) return false;
28718
+ if (!isNodeOfType(node, "JSXElement")) return void 0;
28719
+ const elementName = resolveJsxElementName(node.openingElement);
28720
+ if (!elementName || !isTextHandlingElement(elementName)) return;
28721
+ didForwardIntoText = (node.children ?? []).some((child) => isChildrenForwardingJsxChild(child, bindings)) || (node.openingElement.attributes ?? []).some((attribute) => isChildrenForwardingAttribute(attribute, bindings));
28722
+ });
28723
+ return didForwardIntoText;
28724
+ };
28725
+ const isMeaningfulJsxChild = (child) => !isNodeOfType(child, "JSXText") || Boolean(child.value?.trim());
28726
+ const jsxRootRendersChildrenOutsideText = (jsxRoot, bindings, isTextHandlingElement) => {
28727
+ let didRenderOutsideText = false;
28728
+ walkAst(jsxRoot, (node) => {
28729
+ if (didRenderOutsideText || isFunctionNode(node)) return false;
28730
+ if (!isNodeOfType(node, "JSXElement") && !isNodeOfType(node, "JSXFragment")) return;
28731
+ if (isNodeOfType(node, "JSXElement")) {
28732
+ const elementName = resolveJsxElementName(node.openingElement);
28733
+ if (elementName && isTextHandlingElement(elementName)) return false;
28734
+ if (!(node.children ?? []).some(isMeaningfulJsxChild) && (node.openingElement.attributes ?? []).some((attribute) => isChildrenForwardingAttribute(attribute, bindings))) {
28735
+ didRenderOutsideText = true;
28736
+ return;
28737
+ }
28738
+ }
28739
+ didRenderOutsideText = (node.children ?? []).some((child) => isChildrenForwardingJsxChild(child, bindings));
28740
+ });
28741
+ return didRenderOutsideText;
28742
+ };
28743
+ const resolveStyledFactoryBaseName = (definitionNode) => {
28744
+ let current = stripParenExpression(definitionNode);
28745
+ while (current) {
28746
+ if (isNodeOfType(current, "TaggedTemplateExpression")) {
28747
+ current = stripParenExpression(current.tag);
28748
+ continue;
28749
+ }
28750
+ if (isNodeOfType(current, "CallExpression")) {
28751
+ const callee = stripParenExpression(current.callee);
28752
+ if (isNodeOfType(callee, "Identifier") && callee.name === "styled") {
28753
+ const baseArgument = current.arguments?.[0];
28754
+ if (!baseArgument) return null;
28755
+ const base = stripParenExpression(baseArgument);
28756
+ return isNodeOfType(base, "Identifier") ? base.name : null;
28757
+ }
28758
+ current = callee;
28759
+ continue;
28760
+ }
28761
+ if (isNodeOfType(current, "MemberExpression")) {
28762
+ if (isNodeOfType(current.object, "Identifier") && current.object.name === "styled" && isNodeOfType(current.property, "Identifier")) return current.property.name;
28763
+ current = stripParenExpression(current.object);
28764
+ continue;
28765
+ }
28766
+ return null;
28529
28767
  }
28530
28768
  return null;
28531
28769
  };
28532
- const recordWrapperFromDeclaration = (componentName, functionNode, isTextHandlingRoot, wrappers) => {
28770
+ const resolveClassRenderFunction = (classNode) => {
28771
+ if (!isNodeOfType(classNode, "ClassDeclaration") && !isNodeOfType(classNode, "ClassExpression")) return null;
28772
+ for (const member of classNode.body?.body ?? []) {
28773
+ if (!isNodeOfType(member, "MethodDefinition")) continue;
28774
+ if (!isNodeOfType(member.key, "Identifier") || member.key.name !== "render") continue;
28775
+ return member.value && isFunctionNode(member.value) ? member.value : null;
28776
+ }
28777
+ return null;
28778
+ };
28779
+ const recordWrapperFromDeclaration = (componentName, definitionNode, isTextHandlingElement, wrappers) => {
28533
28780
  if (!componentName || !isReactComponentName(componentName)) return;
28534
- if (!functionNode || !isFunctionNode(functionNode)) return;
28535
- const rootName = resolveReturnedRootElementName(functionNode);
28536
- if (rootName && isTextHandlingRoot(rootName)) wrappers.add(componentName);
28781
+ if (wrappers.has(componentName)) return;
28782
+ if (!definitionNode) return;
28783
+ const unwrapped = unwrapComponentDefinition(definitionNode);
28784
+ const styledBaseName = resolveStyledFactoryBaseName(unwrapped);
28785
+ if (styledBaseName && isTextHandlingElement(styledBaseName)) {
28786
+ wrappers.add(componentName);
28787
+ return;
28788
+ }
28789
+ const functionNode = resolveClassRenderFunction(unwrapped) ?? (isFunctionNode(unwrapped) ? unwrapped : null);
28790
+ if (!functionNode) return;
28791
+ const bindings = resolveParamChildrenBindings(functionNode);
28792
+ collectChildrenAliases(functionNode, bindings);
28793
+ const jsxRoots = collectReturnedJsxRoots(functionNode);
28794
+ if (jsxRoots.some((jsxRoot) => jsxRootRendersChildrenOutsideText(jsxRoot, bindings, isTextHandlingElement))) return;
28795
+ for (const jsxRoot of jsxRoots) {
28796
+ if (isNodeOfType(jsxRoot, "JSXElement")) {
28797
+ const rootName = resolveJsxElementName(jsxRoot.openingElement);
28798
+ if (rootName && isTextHandlingElement(rootName)) {
28799
+ wrappers.add(componentName);
28800
+ return;
28801
+ }
28802
+ }
28803
+ if (jsxRootForwardsChildrenIntoText(jsxRoot, bindings, isTextHandlingElement)) {
28804
+ wrappers.add(componentName);
28805
+ return;
28806
+ }
28807
+ }
28537
28808
  };
28809
+ const MAX_TRANSITIVE_WRAPPER_PASSES = 3;
28538
28810
  const collectTextWrapperComponents = (programNode, isTextHandlingRoot) => {
28539
28811
  const wrappers = /* @__PURE__ */ new Set();
28540
- walkAst(programNode, (node) => {
28541
- if (isNodeOfType(node, "VariableDeclarator")) recordWrapperFromDeclaration(node.id && isNodeOfType(node.id, "Identifier") ? node.id.name : null, node.init, isTextHandlingRoot, wrappers);
28542
- else if (isNodeOfType(node, "FunctionDeclaration")) recordWrapperFromDeclaration(node.id && isNodeOfType(node.id, "Identifier") ? node.id.name : null, node, isTextHandlingRoot, wrappers);
28543
- });
28812
+ const isTextHandlingElement = (elementName) => isTextHandlingRoot(elementName) || wrappers.has(elementName);
28813
+ for (let pass = 0; pass < MAX_TRANSITIVE_WRAPPER_PASSES; pass += 1) {
28814
+ const sizeBeforePass = wrappers.size;
28815
+ walkAst(programNode, (node) => {
28816
+ if (isNodeOfType(node, "VariableDeclarator")) recordWrapperFromDeclaration(node.id && isNodeOfType(node.id, "Identifier") ? node.id.name : null, node.init, isTextHandlingElement, wrappers);
28817
+ else if (isNodeOfType(node, "FunctionDeclaration") || isNodeOfType(node, "ClassDeclaration")) recordWrapperFromDeclaration(node.id && isNodeOfType(node.id, "Identifier") ? node.id.name : null, node, isTextHandlingElement, wrappers);
28818
+ });
28819
+ if (wrappers.size === sizeBeforePass) break;
28820
+ }
28544
28821
  return wrappers;
28545
28822
  };
28546
28823
  //#endregion
@@ -28608,6 +28885,7 @@ const rnNoRawText = defineRule({
28608
28885
  title: "Raw text outside a Text component",
28609
28886
  requires: ["react-native"],
28610
28887
  severity: "error",
28888
+ tags: ["test-noise"],
28611
28889
  recommendation: "Text outside a `<Text>` component crashes on React Native. Wrap it like `<Text>{value}</Text>`.",
28612
28890
  create: (context) => {
28613
28891
  let isDomComponentFile = false;
@@ -34290,6 +34568,9 @@ const tanstackStartNoNavigateInRender = defineRule({
34290
34568
  let eventHandlerDepth = 0;
34291
34569
  const isDeferredHookCall = (node) => isHookCall$1(node, EFFECT_HOOK_NAMES$1) || isHookCall$1(node, "useCallback") || isHookCall$1(node, "useMemo");
34292
34570
  const isEventHandlerAttribute = (node) => isNodeOfType(node, "JSXAttribute") && isNodeOfType(node.name, "JSXIdentifier") && typeof node.name.name === "string" && node.name.name.startsWith("on") && UPPERCASE_PATTERN.test(node.name.name.charAt(2));
34571
+ const isEventHandlerProperty = (node) => isNodeOfType(node, "Property") && isFunctionLike$2(node.value) && (isNodeOfType(node.key, "Identifier") && typeof node.key.name === "string" && REACT_HANDLER_PROP_PATTERN.test(node.key.name) || isNodeOfType(node.key, "Literal") && typeof node.key.value === "string" && REACT_HANDLER_PROP_PATTERN.test(node.key.value));
34572
+ const isHandlerNamedVariableDeclarator = (node) => isNodeOfType(node, "VariableDeclarator") && isNodeOfType(node.id, "Identifier") && typeof node.id.name === "string" && HANDLER_FUNCTION_NAME_PATTERN.test(node.id.name) && isFunctionLike$2(node.init);
34573
+ const isHandlerNamedFunctionDeclaration = (node) => isNodeOfType(node, "FunctionDeclaration") && isNodeOfType(node.id, "Identifier") && typeof node.id.name === "string" && HANDLER_FUNCTION_NAME_PATTERN.test(node.id.name);
34293
34574
  return {
34294
34575
  CallExpression(node) {
34295
34576
  const filename = normalizeFilename$1(context.filename ?? "");
@@ -34315,6 +34596,36 @@ const tanstackStartNoNavigateInRender = defineRule({
34315
34596
  const filename = normalizeFilename$1(context.filename ?? "");
34316
34597
  if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
34317
34598
  if (isEventHandlerAttribute(node)) eventHandlerDepth = Math.max(0, eventHandlerDepth - 1);
34599
+ },
34600
+ Property(node) {
34601
+ const filename = normalizeFilename$1(context.filename ?? "");
34602
+ if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
34603
+ if (isEventHandlerProperty(node)) eventHandlerDepth++;
34604
+ },
34605
+ "Property:exit"(node) {
34606
+ const filename = normalizeFilename$1(context.filename ?? "");
34607
+ if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
34608
+ if (isEventHandlerProperty(node)) eventHandlerDepth = Math.max(0, eventHandlerDepth - 1);
34609
+ },
34610
+ VariableDeclarator(node) {
34611
+ const filename = normalizeFilename$1(context.filename ?? "");
34612
+ if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
34613
+ if (isHandlerNamedVariableDeclarator(node)) eventHandlerDepth++;
34614
+ },
34615
+ "VariableDeclarator:exit"(node) {
34616
+ const filename = normalizeFilename$1(context.filename ?? "");
34617
+ if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
34618
+ if (isHandlerNamedVariableDeclarator(node)) eventHandlerDepth = Math.max(0, eventHandlerDepth - 1);
34619
+ },
34620
+ FunctionDeclaration(node) {
34621
+ const filename = normalizeFilename$1(context.filename ?? "");
34622
+ if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
34623
+ if (isHandlerNamedFunctionDeclaration(node)) eventHandlerDepth++;
34624
+ },
34625
+ "FunctionDeclaration:exit"(node) {
34626
+ const filename = normalizeFilename$1(context.filename ?? "");
34627
+ if (!TANSTACK_ROUTE_FILE_PATTERN.test(filename)) return;
34628
+ if (isHandlerNamedFunctionDeclaration(node)) eventHandlerDepth = Math.max(0, eventHandlerDepth - 1);
34318
34629
  }
34319
34630
  };
34320
34631
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxlint-plugin-react-doctor",
3
- "version": "0.5.0",
3
+ "version": "0.5.1-dev.19d99ee",
4
4
  "description": "oxlint plugin for React Doctor: diagnose React codebases for security, performance, correctness, accessibility, bundle-size, and architecture issues",
5
5
  "keywords": [
6
6
  "accessibility",
@@ -50,7 +50,7 @@
50
50
  "@types/node": "^25.6.0"
51
51
  },
52
52
  "engines": {
53
- "node": "^20.19.0 || >=22.12.0"
53
+ "node": "^20.19.0 || >=22.13.0"
54
54
  },
55
55
  "scripts": {
56
56
  "dev": "vp pack --watch",