oxlint-plugin-react-doctor 0.7.5-dev.3075e10 → 0.7.5-dev.3afd146

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 +235 -15
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -397,6 +397,7 @@ const isProductionFilePath = (relativePath, sourceFilePattern) => {
397
397
  //#endregion
398
398
  //#region src/plugin/rules/security-scan/utils/is-production-source-path.ts
399
399
  const isProductionSourcePath = (relativePath) => {
400
+ if (/\.d\.[cm]?[jt]s$/i.test(relativePath)) return false;
400
401
  return isProductionFilePath(relativePath, SOURCE_FILE_PATTERN);
401
402
  };
402
403
  //#endregion
@@ -5178,8 +5179,10 @@ const STORAGE_GLOBALS = new Set([
5178
5179
  const SENSITIVE_KEY_PATTERN = /token|jwt|secret|password|passwd|credential|api[-_]?key|bearer|private[-_]?key/i;
5179
5180
  const NON_AUTH_TOKEN_PATTERN = /csrf|xsrf|device|fcm|apns|push|design|tokeniz|syntax|css|theme|color/i;
5180
5181
  const STRONG_AUTH_KEY_PATTERN = /jwt|secret|password|passwd|credential|private[-_]?key|api[-_]?key|bearer|access[-_]?token|refresh[-_]?token|auth[-_]?token|id[-_]?token|session/i;
5182
+ const PRODUCT_API_KEY_RECORDS_PATTERN = /(?:^|[._:-])(?:created|saved|integration|mailing)[-_]?api[-_]?keys$/i;
5181
5183
  const PRODUCT_API_KEY_COLLECTION_PATTERN = /[._:-](?:created|generated|saved)[-_]?api[-_]?keys$/i;
5182
5184
  const isAuthCredentialKey = (key) => {
5185
+ if (PRODUCT_API_KEY_RECORDS_PATTERN.test(key)) return false;
5183
5186
  if (!SENSITIVE_KEY_PATTERN.test(key)) return false;
5184
5187
  if (PRODUCT_API_KEY_COLLECTION_PATTERN.test(key)) return false;
5185
5188
  if (NON_AUTH_TOKEN_PATTERN.test(key) && !STRONG_AUTH_KEY_PATTERN.test(key)) return false;
@@ -6868,11 +6871,10 @@ const getTemplateInterpolations = (valueTail) => {
6868
6871
  const interpolations = valueTail.slice(1, closingBacktickIndex).match(/\$\{[^}]*\}/g);
6869
6872
  return interpolations === null ? "" : interpolations.join(" ");
6870
6873
  };
6871
- const getIdentifierDeclarationInitializer = (identifier, fileContent) => {
6872
- const declarationMatch = new RegExp(`(?:const|let|var)\\s+${escapeRegExp(identifier)}\\s*(?::[^=\\n]{0,120})?=\\s*`).exec(fileContent);
6873
- if (declarationMatch === null) return null;
6874
- const initializerStartIndex = declarationMatch.index + declarationMatch[0].length;
6875
- return fileContent.slice(initializerStartIndex, initializerStartIndex + STATIC_TEMPLATE_MAX_CHARS);
6874
+ const getIdentifierDeclarationInitializer = (identifier, sinkIndex, fileContent) => {
6875
+ const declaration = findVisibleIdentifierDeclaration(identifier, sinkIndex, fileContent);
6876
+ if (declaration === null) return null;
6877
+ return fileContent.slice(declaration.initializerStartIndex, declaration.initializerStartIndex + STATIC_TEMPLATE_MAX_CHARS);
6876
6878
  };
6877
6879
  const isPureStringLiteralConcat = (initializerText) => {
6878
6880
  if (!/^["']/.test(initializerText)) return false;
@@ -6942,6 +6944,153 @@ const isAllOperandsDomContentConcat = (valueExpression) => {
6942
6944
  return !HTML_TAINT_PATTERN.test(withoutCast.replace(DOM_CONTENT_SOURCE_VALUE_PATTERN, ""));
6943
6945
  });
6944
6946
  };
6947
+ const findMatchingBraceIndex = (fileContent, openingBraceIndex) => {
6948
+ let depth = 0;
6949
+ let quote = null;
6950
+ for (let index = openingBraceIndex; index < fileContent.length; index += 1) {
6951
+ const character = fileContent[index];
6952
+ if (quote !== null) {
6953
+ if (character === quote && fileContent[index - 1] !== "\\") quote = null;
6954
+ continue;
6955
+ }
6956
+ if (character === "\"" || character === "'" || character === "`") {
6957
+ quote = character;
6958
+ continue;
6959
+ }
6960
+ if (character === "{") depth += 1;
6961
+ if (character === "}") {
6962
+ depth -= 1;
6963
+ if (depth === 0) return index;
6964
+ }
6965
+ }
6966
+ return fileContent.length;
6967
+ };
6968
+ const findContainingBlockEndIndex = (fileContent, targetIndex) => {
6969
+ const openingBraceIndexes = [];
6970
+ let quote = null;
6971
+ let isLineComment = false;
6972
+ let isBlockComment = false;
6973
+ for (let index = 0; index < targetIndex; index += 1) {
6974
+ const character = fileContent[index];
6975
+ const nextCharacter = fileContent[index + 1];
6976
+ if (isLineComment) {
6977
+ if (character === "\n") isLineComment = false;
6978
+ continue;
6979
+ }
6980
+ if (isBlockComment) {
6981
+ if (character === "*" && nextCharacter === "/") {
6982
+ isBlockComment = false;
6983
+ index += 1;
6984
+ }
6985
+ continue;
6986
+ }
6987
+ if (quote !== null) {
6988
+ if (character === quote && fileContent[index - 1] !== "\\") quote = null;
6989
+ continue;
6990
+ }
6991
+ if (character === "/" && nextCharacter === "/") {
6992
+ isLineComment = true;
6993
+ index += 1;
6994
+ continue;
6995
+ }
6996
+ if (character === "/" && nextCharacter === "*") {
6997
+ isBlockComment = true;
6998
+ index += 1;
6999
+ continue;
7000
+ }
7001
+ if (character === "\"" || character === "'" || character === "`") {
7002
+ quote = character;
7003
+ continue;
7004
+ }
7005
+ if (character === "{") openingBraceIndexes.push(index);
7006
+ if (character === "}") openingBraceIndexes.pop();
7007
+ }
7008
+ const openingBraceIndex = openingBraceIndexes.at(-1);
7009
+ return openingBraceIndex === void 0 ? fileContent.length : findMatchingBraceIndex(fileContent, openingBraceIndex);
7010
+ };
7011
+ const findVisibleIdentifierDeclaration = (identifier, sinkIndex, fileContent) => {
7012
+ const initializerPattern = new RegExp(`(?:const|let|var)\\s+${escapeRegExp(identifier)}\\s*(?::[^=\\n]*)?=\\s*([^;\\n]+)`, "g");
7013
+ let nearestDeclaration = null;
7014
+ let nearestDeclarationIndex = -1;
7015
+ for (const match of fileContent.matchAll(initializerPattern)) {
7016
+ const declarationIndex = match.index;
7017
+ if (declarationIndex === void 0 || declarationIndex >= sinkIndex || declarationIndex <= nearestDeclarationIndex || findContainingBlockEndIndex(fileContent, declarationIndex) < sinkIndex) continue;
7018
+ nearestDeclarationIndex = declarationIndex;
7019
+ const initializer = match[1];
7020
+ if (initializer === void 0) continue;
7021
+ nearestDeclaration = {
7022
+ initializer,
7023
+ initializerStartIndex: declarationIndex + match[0].length - initializer.length
7024
+ };
7025
+ }
7026
+ return nearestDeclaration;
7027
+ };
7028
+ const findContainingFunctionParameterSource = (identifier, sinkIndex, fileContent) => {
7029
+ const patterns = [/function\s+([\w$]+)\s*\(([^)]*)\)\s*\{/g, /(?:const|let|var)\s+([\w$]+)\s*=\s*(?:async\s*)?\(([^)]*)\)\s*=>\s*\{/g];
7030
+ let closestSource = null;
7031
+ let closestStartIndex = -1;
7032
+ for (const pattern of patterns) for (const match of fileContent.matchAll(pattern)) {
7033
+ const matchIndex = match.index;
7034
+ if (matchIndex === void 0 || matchIndex >= sinkIndex || matchIndex < closestStartIndex) continue;
7035
+ if (findMatchingBraceIndex(fileContent, matchIndex + match[0].lastIndexOf("{")) < sinkIndex) continue;
7036
+ const parameterIndex = (match[2] ?? "").split(",").findIndex((parameter) => parameter.trim().match(/^(?:\.\.\.)?([\w$]+)/)?.[1] === identifier);
7037
+ if (parameterIndex < 0) continue;
7038
+ closestStartIndex = matchIndex;
7039
+ closestSource = {
7040
+ functionName: match[1] ?? "",
7041
+ parameterIndex
7042
+ };
7043
+ }
7044
+ return closestSource;
7045
+ };
7046
+ const splitTopLevelArguments = (argumentText) => {
7047
+ const argumentsList = [];
7048
+ let startIndex = 0;
7049
+ let depth = 0;
7050
+ let quote = null;
7051
+ for (let index = 0; index < argumentText.length; index += 1) {
7052
+ const character = argumentText[index];
7053
+ if (quote !== null) {
7054
+ if (character === quote && argumentText[index - 1] !== "\\") quote = null;
7055
+ continue;
7056
+ }
7057
+ if (character === "\"" || character === "'" || character === "`") {
7058
+ quote = character;
7059
+ continue;
7060
+ }
7061
+ if (character === "(" || character === "[" || character === "{") depth += 1;
7062
+ if (character === ")" || character === "]" || character === "}") depth -= 1;
7063
+ if (character === "," && depth === 0) {
7064
+ argumentsList.push(argumentText.slice(startIndex, index).trim());
7065
+ startIndex = index + 1;
7066
+ }
7067
+ }
7068
+ argumentsList.push(argumentText.slice(startIndex).trim());
7069
+ return argumentsList;
7070
+ };
7071
+ const isHtmlTainted = (expression, fileContent, sinkIndex, visitedIdentifiers) => {
7072
+ const trimmedExpression = expression.trim();
7073
+ if (STRING_LITERAL_VALUE_PATTERN.test(`${trimmedExpression};`)) return false;
7074
+ if (MODULE_CONSTANT_VALUE_PATTERN.test(`${trimmedExpression};`)) return false;
7075
+ if (SANITIZER_PATTERN.test(trimmedExpression)) return false;
7076
+ if (ENV_CONFIG_VALUE_PATTERN.test(trimmedExpression)) return false;
7077
+ if (I18N_VALUE_PATTERN.test(trimmedExpression)) return false;
7078
+ if (ESCAPING_SERIALIZER_CALL_PATTERN.test(trimmedExpression)) return false;
7079
+ if (HTML_TAINT_PATTERN.test(trimmedExpression)) return true;
7080
+ const identifier = trimmedExpression.match(/^([\w$]+)\s*(?:[;,})\n]|$)/)?.[1];
7081
+ if (identifier === void 0 || visitedIdentifiers.has(identifier)) return false;
7082
+ visitedIdentifiers.add(identifier);
7083
+ const declaration = findVisibleIdentifierDeclaration(identifier, sinkIndex, fileContent);
7084
+ if (declaration !== null && isHtmlTainted(declaration.initializer, fileContent, sinkIndex, visitedIdentifiers)) return true;
7085
+ const parameterSource = findContainingFunctionParameterSource(identifier, sinkIndex, fileContent);
7086
+ if (parameterSource === null || parameterSource.functionName.length === 0) return false;
7087
+ const callPattern = new RegExp(`\\b${escapeRegExp(parameterSource.functionName)}\\s*\\(([^)]*)\\)`, "g");
7088
+ for (const callMatch of fileContent.matchAll(callPattern)) {
7089
+ const argument = splitTopLevelArguments(callMatch[1] ?? "")[parameterSource.parameterIndex];
7090
+ if (argument !== void 0 && isHtmlTainted(argument, fileContent, sinkIndex, new Set(visitedIdentifiers))) return true;
7091
+ }
7092
+ return false;
7093
+ };
6945
7094
  const dangerousHtmlSink = defineRule({
6946
7095
  id: "dangerous-html-sink",
6947
7096
  title: "HTML injection sink with dynamic content",
@@ -6968,6 +7117,7 @@ const dangerousHtmlSink = defineRule({
6968
7117
  const valueTail = fullValueTail.slice(0, VALUE_EXPRESSION_MAX_CHARS);
6969
7118
  const terminatorIndex = valueTail.search(/[;}]/);
6970
7119
  const valueExpression = terminatorIndex >= 0 ? valueTail.slice(0, terminatorIndex + 1) : valueTail;
7120
+ const sinkIndex = lines.slice(0, lineIndex).join("\n").length + (lineIndex > 0 ? 1 : 0) + line.search(DANGEROUS_HTML_PATTERN);
6971
7121
  if (STRING_LITERAL_VALUE_PATTERN.test(valueExpression)) continue;
6972
7122
  if (MODULE_CONSTANT_VALUE_PATTERN.test(valueExpression)) continue;
6973
7123
  if (DOM_CONTENT_SOURCE_VALUE_PATTERN.test(valueExpression) && !valueExpression.includes("+")) {
@@ -6979,7 +7129,7 @@ const dangerousHtmlSink = defineRule({
6979
7129
  let templateInterpolations = getTemplateInterpolations(longValueTail ?? fullValueTail);
6980
7130
  const valueIdentifier = BARE_IDENTIFIER_VALUE_PATTERN.test(valueExpression) || MEMBER_OR_INDEX_ACCESS_VALUE_PATTERN.test(valueExpression) || IDENTIFIER_WITH_LITERAL_FALLBACK_PATTERN.test(valueExpression) ? valueExpression.match(/^[\w$]+/)?.[0] : void 0;
6981
7131
  if (templateInterpolations === null && valueIdentifier !== void 0 && BARE_IDENTIFIER_VALUE_PATTERN.test(valueExpression)) {
6982
- const declarationInitializer = getIdentifierDeclarationInitializer(valueIdentifier, file.content);
7132
+ const declarationInitializer = getIdentifierDeclarationInitializer(valueIdentifier, sinkIndex, file.content);
6983
7133
  if (declarationInitializer !== null) {
6984
7134
  if (isPureStringLiteralConcat(declarationInitializer)) continue;
6985
7135
  templateInterpolations = getTemplateInterpolations(declarationInitializer);
@@ -6990,7 +7140,7 @@ const dangerousHtmlSink = defineRule({
6990
7140
  if (SANITIZER_PATTERN.test(judgedExpression)) continue;
6991
7141
  if (ENV_CONFIG_VALUE_PATTERN.test(judgedExpression)) continue;
6992
7142
  if (I18N_VALUE_PATTERN.test(judgedExpression)) continue;
6993
- if (!HTML_TAINT_PATTERN.test(judgedExpression)) continue;
7143
+ if (!isHtmlTainted(judgedExpression, file.content, sinkIndex, /* @__PURE__ */ new Set())) continue;
6994
7144
  if (ESCAPING_SERIALIZER_CALL_PATTERN.test(valueExpression)) continue;
6995
7145
  if (/highlighted/i.test(valueExpression)) continue;
6996
7146
  if (/highlight/i.test(valueExpression) && HIGHLIGHTER_LIBRARY_PATTERN.test(file.content)) continue;
@@ -7737,9 +7887,9 @@ const isImportedFromReact = (symbol) => {
7737
7887
  const importDeclaration = symbol.declarationNode.parent;
7738
7888
  return Boolean(importDeclaration && isNodeOfType(importDeclaration, "ImportDeclaration") && typeof importDeclaration.source.value === "string" && REACT_RUNTIME_MODULE_SOURCES.has(importDeclaration.source.value));
7739
7889
  };
7740
- const isNamedReactApiImport = (identifier, apiNames, scopes) => {
7890
+ const isNamedReactApiImport = (identifier, apiNames, scopes, resolveAliases) => {
7741
7891
  if (!isNodeOfType(identifier, "Identifier")) return false;
7742
- const symbol = scopes.symbolFor(identifier);
7892
+ const symbol = resolveAliases ? resolveConstIdentifierAlias(identifier, scopes) : scopes.symbolFor(identifier);
7743
7893
  if (!symbol || !isImportedFromReact(symbol)) return false;
7744
7894
  const importedName = getImportedName(symbol.declarationNode);
7745
7895
  return Boolean(importedName && includesApiName(apiNames, importedName));
@@ -7753,7 +7903,7 @@ const isReactApiCall = (node, apiNames, scopes, options = {}) => {
7753
7903
  if (!isNodeOfType(node, "CallExpression")) return false;
7754
7904
  const callee = stripParenExpression(node.callee);
7755
7905
  if (isNodeOfType(callee, "Identifier")) {
7756
- if (isNamedReactApiImport(callee, apiNames, scopes)) return true;
7906
+ if (isNamedReactApiImport(callee, apiNames, scopes, Boolean(options.resolveNamedAliases))) return true;
7757
7907
  return Boolean(options.allowUnboundBareCalls && includesApiName(apiNames, callee.name) && scopes.isGlobalReference(callee));
7758
7908
  }
7759
7909
  if (!isNodeOfType(callee, "MemberExpression") || callee.computed || !isNodeOfType(callee.property, "Identifier") || !includesApiName(apiNames, callee.property.name)) return false;
@@ -10962,6 +11112,11 @@ If the missing value is recreated every render, move it inside the hook or stabi
10962
11112
  return { CallExpression(node) {
10963
11113
  const hookName = getHookName(node.callee, context.scopes);
10964
11114
  if (!hookName || !isHookOfInterest(hookName, node.callee)) return;
11115
+ if (HOOKS_REQUIRING_DEPS_MATCH.has(hookName) && !isReactApiCall(node, HOOKS_REQUIRING_DEPS_MATCH, context.scopes, {
11116
+ allowGlobalReactNamespace: true,
11117
+ allowUnboundBareCalls: true,
11118
+ resolveNamedAliases: true
11119
+ })) return;
10965
11120
  const callbackArgumentIndex = getCallbackArgumentIndex(hookName);
10966
11121
  const depsArgumentIndex = getDepsArgumentIndex(hookName);
10967
11122
  const callbackArgument = node.arguments[callbackArgumentIndex];
@@ -23998,6 +24153,26 @@ const STATEFUL_HTML_DESCENDANT_TAGS = new Set([
23998
24153
  "dialog",
23999
24154
  "canvas"
24000
24155
  ]);
24156
+ const STATEFUL_HTML_ATTRIBUTE_NAMES = new Set([
24157
+ "autofocus",
24158
+ "contenteditable",
24159
+ "draggable",
24160
+ "tabindex"
24161
+ ]);
24162
+ const isStaticallyFalseAttributeValue = (attribute) => {
24163
+ if (!isNodeOfType(attribute, "JSXAttribute") || !attribute.value) return false;
24164
+ const value = isNodeOfType(attribute.value, "JSXExpressionContainer") ? attribute.value.expression : attribute.value;
24165
+ return isNodeOfType(value, "Literal") && (value.value === false || value.value === "false");
24166
+ };
24167
+ const hasStatefulHtmlAttribute = (openingElement) => {
24168
+ if (!isNodeOfType(openingElement, "JSXOpeningElement")) return false;
24169
+ return openingElement.attributes.some((attribute) => {
24170
+ if (!isNodeOfType(attribute, "JSXAttribute") || !isNodeOfType(attribute.name, "JSXIdentifier")) return false;
24171
+ const attributeName = attribute.name.name.toLowerCase();
24172
+ if (!STATEFUL_HTML_ATTRIBUTE_NAMES.has(attributeName)) return false;
24173
+ return attributeName === "tabindex" || !isStaticallyFalseAttributeValue(attribute);
24174
+ });
24175
+ };
24001
24176
  const STATEFUL_DESCENDANT_SCAN_BUDGET = 200;
24002
24177
  const rootIdentifierNameOf = (expression) => {
24003
24178
  let object = expression;
@@ -24015,7 +24190,8 @@ const containsStatefulDescendant = (jsxElement, options = {}) => {
24015
24190
  budget -= 1;
24016
24191
  const node = stack.pop();
24017
24192
  if (isNodeOfType(node, "JSXElement")) {
24018
- const name = node.openingElement.name;
24193
+ const opening = node.openingElement;
24194
+ const name = opening.name;
24019
24195
  if (name && isNodeOfType(name, "JSXIdentifier")) {
24020
24196
  const tagName = name.name;
24021
24197
  const firstChar = tagName.charCodeAt(0);
@@ -24023,6 +24199,7 @@ const containsStatefulDescendant = (jsxElement, options = {}) => {
24023
24199
  if (STATEFUL_HTML_DESCENDANT_TAGS.has(tagName)) return true;
24024
24200
  }
24025
24201
  if (name && isNodeOfType(name, "JSXMemberExpression")) return true;
24202
+ if (hasStatefulHtmlAttribute(opening)) return true;
24026
24203
  const children = node.children ?? [];
24027
24204
  for (const child of children) stack.push(child);
24028
24205
  continue;
@@ -24582,6 +24759,14 @@ const templateHasOuterMemberIdentity = (template, bindingFunction) => {
24582
24759
  }
24583
24760
  return false;
24584
24761
  };
24762
+ const findBareItemNamesReferencedByTemplate = (template, itemNames) => {
24763
+ const referencedItemNames = /* @__PURE__ */ new Set();
24764
+ for (const expression of template.expressions ?? []) {
24765
+ const unwrappedExpression = stripParenExpression(expression);
24766
+ if (isNodeOfType(unwrappedExpression, "Identifier") && itemNames.has(unwrappedExpression.name)) referencedItemNames.add(unwrappedExpression.name);
24767
+ }
24768
+ return referencedItemNames;
24769
+ };
24585
24770
  const forLoopTestReadsDataLength = (test) => {
24586
24771
  let didFindLengthRead = false;
24587
24772
  walkAst(test, (child) => {
@@ -24718,9 +24903,11 @@ const noArrayIndexAsKey = defineRule({
24718
24903
  } else if (STATELESS_HTML_LEAF_TAGS.has(elementName.name)) {
24719
24904
  const jsxElement = openingElement.parent;
24720
24905
  if (jsxElement && isNodeOfType(jsxElement, "JSXElement")) {
24906
+ const isInlineTextRun = INLINE_TEXT_LEAF_TAGS.has(elementName.name);
24907
+ const primitiveItemNames = keyTemplate ? findBareItemNamesReferencedByTemplate(keyTemplate, itemNames) : EMPTY_NAME_SET;
24721
24908
  if (!containsStatefulDescendant(jsxElement, {
24722
- memberRootNames: INLINE_TEXT_LEAF_TAGS.has(elementName.name) ? itemNames : EMPTY_NAME_SET,
24723
- bareIdentifierNames: derivedNames
24909
+ memberRootNames: isInlineTextRun ? itemNames : EMPTY_NAME_SET,
24910
+ bareIdentifierNames: primitiveItemNames.size > 0 ? new Set([...derivedNames, ...primitiveItemNames]) : derivedNames
24724
24911
  })) return;
24725
24912
  }
24726
24913
  }
@@ -24888,7 +25075,11 @@ const noAsyncEffectCallback = defineRule({
24888
25075
  severity: "warn",
24889
25076
  recommendation: "Don't make the effect callback `async`. Define an async function inside the effect and call it, then return a real cleanup function if you need one.",
24890
25077
  create: (context) => ({ CallExpression(node) {
24891
- if (!isHookCall$2(node, EFFECT_HOOK_NAMES$1)) return;
25078
+ if (!isReactApiCall(node, EFFECT_HOOK_NAMES$1, context.scopes, {
25079
+ allowGlobalReactNamespace: true,
25080
+ allowUnboundBareCalls: true,
25081
+ resolveNamedAliases: true
25082
+ })) return;
24892
25083
  const callback = getEffectCallback(node);
24893
25084
  if (!callback) return;
24894
25085
  if (!isNodeOfType(callback, "ArrowFunctionExpression") && !isNodeOfType(callback, "FunctionExpression")) return;
@@ -30169,7 +30360,11 @@ const noFetchInEffect = defineRule({
30169
30360
  severity: "warn",
30170
30361
  recommendation: "Use a data-fetching layer or Server Component so fetches do not race, double-fire, or leak from `useEffect`.",
30171
30362
  create: (context) => ({ CallExpression(node) {
30172
- if (!isHookCall$2(node, EFFECT_HOOK_NAMES$1)) return;
30363
+ if (!isReactApiCall(node, EFFECT_HOOK_NAMES$1, context.scopes, {
30364
+ allowGlobalReactNamespace: true,
30365
+ allowUnboundBareCalls: true,
30366
+ resolveNamedAliases: true
30367
+ })) return;
30173
30368
  const callback = getEffectCallback(node);
30174
30369
  if (!callback) return;
30175
30370
  const analysisFunctions = collectEffectAnalysisFunctions(callback, context);
@@ -35017,6 +35212,30 @@ const guardsRenderShape = (comparison) => {
35017
35212
  }
35018
35213
  return false;
35019
35214
  };
35215
+ const isPropsChildrenLength = (node, scopes) => {
35216
+ const unwrappedNode = stripParenExpression(node);
35217
+ return isNodeOfType(unwrappedNode, "MemberExpression") && !unwrappedNode.computed && isNodeOfType(unwrappedNode.property, "Identifier") && unwrappedNode.property.name === "length" && resolvesToPropsChildren(stripParenExpression(unwrappedNode.object), scopes);
35218
+ };
35219
+ const isLargeTextLengthComparison = (node, scopes) => {
35220
+ const unwrappedNode = stripParenExpression(node);
35221
+ if (!isNodeOfType(unwrappedNode, "BinaryExpression")) return false;
35222
+ const leftIsLength = isPropsChildrenLength(unwrappedNode.left, scopes);
35223
+ const rightIsLength = isPropsChildrenLength(unwrappedNode.right, scopes);
35224
+ const thresholdNode = leftIsLength ? unwrappedNode.right : unwrappedNode.left;
35225
+ if (!leftIsLength && !rightIsLength || !isNodeOfType(thresholdNode, "Literal")) return false;
35226
+ if (typeof thresholdNode.value !== "number" || thresholdNode.value < 1e3) return false;
35227
+ return leftIsLength ? unwrappedNode.operator === ">" || unwrappedNode.operator === ">=" : unwrappedNode.operator === "<" || unwrappedNode.operator === "<=";
35228
+ };
35229
+ const isLargeStringOptimizationGuard = (comparison, scopes) => {
35230
+ let current = findTransparentExpressionRoot(comparison);
35231
+ while (current.parent) {
35232
+ const parent = current.parent;
35233
+ if (!isNodeOfType(parent, "LogicalExpression") || parent.operator !== "&&") return false;
35234
+ if (isLargeTextLengthComparison(parent.left === current ? parent.right : parent.left, scopes)) return true;
35235
+ current = findTransparentExpressionRoot(parent);
35236
+ }
35237
+ return false;
35238
+ };
35020
35239
  const noPolymorphicChildren = defineRule({
35021
35240
  id: "no-polymorphic-children",
35022
35241
  title: "Children type checked at runtime",
@@ -35030,6 +35249,7 @@ const noPolymorphicChildren = defineRule({
35030
35249
  const isStringLiteral = (operand) => isNodeOfType(operand, "Literal") && operand.value === "string";
35031
35250
  if (!isStringLiteral(node.left) && !isStringLiteral(node.right)) return;
35032
35251
  if (!guardsRenderShape(node)) return;
35252
+ if (isLargeStringOptimizationGuard(node, context.scopes)) return;
35033
35253
  context.report({
35034
35254
  node,
35035
35255
  message: "Your users hit inconsistent behavior because `typeof children === \"string\"` makes this component switch on what callers pass, so add clear subcomponents like `<Button.Text>` instead."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxlint-plugin-react-doctor",
3
- "version": "0.7.5-dev.3075e10",
3
+ "version": "0.7.5-dev.3afd146",
4
4
  "description": "React Doctor rules for oxlint.",
5
5
  "keywords": [
6
6
  "accessibility",