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

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 +586 -141
  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;
@@ -5931,6 +5934,21 @@ const checkedRequiresOnchangeOrReadonly = defineRule({
5931
5934
  }
5932
5935
  });
5933
5936
  //#endregion
5937
+ //#region src/plugin/utils/get-static-property-key-name.ts
5938
+ const getStaticPropertyKeyName = (node, options = {}) => {
5939
+ if (!isNodeOfType(node, "Property")) return null;
5940
+ if (node.computed) {
5941
+ if (options.allowComputedString && isNodeOfType(node.key, "Literal") && typeof node.key.value === "string") return node.key.value;
5942
+ return null;
5943
+ }
5944
+ if (isNodeOfType(node.key, "Identifier")) return node.key.name;
5945
+ if (isNodeOfType(node.key, "Literal")) {
5946
+ if (typeof node.key.value === "string") return node.key.value;
5947
+ if (options.stringifyNonStringLiterals) return String(node.key.value);
5948
+ }
5949
+ return null;
5950
+ };
5951
+ //#endregion
5934
5952
  //#region src/plugin/utils/is-presentation-role.ts
5935
5953
  const isPresentationRole = (openingElement) => {
5936
5954
  const roleAttribute = hasJsxPropIgnoreCase(openingElement.attributes, "role");
@@ -5975,6 +5993,21 @@ const isPureEventBlockerHandler = (attribute) => {
5975
5993
  return false;
5976
5994
  };
5977
5995
  //#endregion
5996
+ //#region src/plugin/utils/resolve-const-identifier-alias.ts
5997
+ const resolveConstIdentifierAlias = (identifier, scopes) => {
5998
+ if (!isNodeOfType(identifier, "Identifier")) return null;
5999
+ const visitedSymbolIds = /* @__PURE__ */ new Set();
6000
+ let symbol = scopes.symbolFor(identifier);
6001
+ while (symbol?.kind === "const") {
6002
+ if (visitedSymbolIds.has(symbol.id) || !symbol.initializer || !isNodeOfType(symbol.declarationNode, "VariableDeclarator") || symbol.declarationNode.id !== symbol.bindingIdentifier) return null;
6003
+ visitedSymbolIds.add(symbol.id);
6004
+ const initializer = stripParenExpression(symbol.initializer);
6005
+ if (!isNodeOfType(initializer, "Identifier")) return symbol;
6006
+ symbol = scopes.symbolFor(initializer);
6007
+ }
6008
+ return symbol;
6009
+ };
6010
+ //#endregion
5978
6011
  //#region src/plugin/rules/a11y/click-events-have-key-events.ts
5979
6012
  const MESSAGE$57 = "Keyboard users can't trigger this click handler because there's no keyboard one, so add `onKeyUp`, `onKeyDown`, or `onKeyPress`.";
5980
6013
  const KEY_HANDLERS = [
@@ -5985,6 +6018,63 @@ const KEY_HANDLERS = [
5985
6018
  "onKeyDownCapture",
5986
6019
  "onKeyPressCapture"
5987
6020
  ];
6021
+ const CLICK_HANDLERS = ["onClick", "onClickCapture"];
6022
+ const TRANSPARENT_SPREAD_EVENT_NAMES = new Set([...CLICK_HANDLERS, ...KEY_HANDLERS].map((eventName) => eventName.toLowerCase()));
6023
+ const CONSERVATIVE_SPREAD_PROP_NAMES = new Set([
6024
+ "aria-hidden",
6025
+ "onmouseenter",
6026
+ "onmouseover",
6027
+ "role"
6028
+ ]);
6029
+ const resolveSpreadObjectExpression = (expression, scopes) => {
6030
+ const innerExpression = stripParenExpression(expression);
6031
+ if (isNodeOfType(innerExpression, "ObjectExpression")) return innerExpression;
6032
+ if (!isNodeOfType(innerExpression, "Identifier")) return null;
6033
+ const symbol = resolveConstIdentifierAlias(innerExpression, scopes);
6034
+ if (symbol?.kind !== "const" || !symbol.initializer) return null;
6035
+ const initializer = stripParenExpression(symbol.initializer);
6036
+ return isNodeOfType(initializer, "ObjectExpression") ? initializer : null;
6037
+ };
6038
+ const collectTransparentSpreadEventNames = (expression, scopes, eventValues, visitedObjectExpressions) => {
6039
+ const objectExpression = resolveSpreadObjectExpression(expression, scopes);
6040
+ if (!objectExpression || visitedObjectExpressions.has(objectExpression)) return false;
6041
+ visitedObjectExpressions.add(objectExpression);
6042
+ let isTransparent = true;
6043
+ for (const property of objectExpression.properties) {
6044
+ if (isNodeOfType(property, "SpreadElement")) {
6045
+ if (!collectTransparentSpreadEventNames(property.argument, scopes, eventValues, visitedObjectExpressions)) {
6046
+ isTransparent = false;
6047
+ break;
6048
+ }
6049
+ continue;
6050
+ }
6051
+ if (!isNodeOfType(property, "Property")) {
6052
+ isTransparent = false;
6053
+ break;
6054
+ }
6055
+ const propertyName = getStaticPropertyKeyName(property, { allowComputedString: true });
6056
+ if (!propertyName) {
6057
+ isTransparent = false;
6058
+ break;
6059
+ }
6060
+ const normalizedPropertyName = propertyName.toLowerCase();
6061
+ if (CONSERVATIVE_SPREAD_PROP_NAMES.has(normalizedPropertyName)) {
6062
+ isTransparent = false;
6063
+ break;
6064
+ }
6065
+ if (TRANSPARENT_SPREAD_EVENT_NAMES.has(normalizedPropertyName)) eventValues.set(normalizedPropertyName, property.value);
6066
+ }
6067
+ visitedObjectExpressions.delete(objectExpression);
6068
+ return isTransparent;
6069
+ };
6070
+ const getTransparentSpreadEventValues = (attributes, scopes) => {
6071
+ const eventValues = /* @__PURE__ */ new Map();
6072
+ for (const attribute of attributes) {
6073
+ if (!isNodeOfType(attribute, "JSXSpreadAttribute")) continue;
6074
+ if (!collectTransparentSpreadEventNames(attribute.argument, scopes, eventValues, /* @__PURE__ */ new Set())) return null;
6075
+ }
6076
+ return eventValues;
6077
+ };
5988
6078
  const FOCUSLESS_CONTAINER_TAGS = new Set([
5989
6079
  "tr",
5990
6080
  "td",
@@ -6032,7 +6122,10 @@ const isFocusForwardingFunctionBody = (body) => {
6032
6122
  };
6033
6123
  const resolveHandlerFunction = (attribute) => {
6034
6124
  if (!attribute.value || !isNodeOfType(attribute.value, "JSXExpressionContainer")) return null;
6035
- let expression = attribute.value.expression;
6125
+ return resolveHandlerFunctionExpression(attribute.value.expression);
6126
+ };
6127
+ const resolveHandlerFunctionExpression = (handlerExpression) => {
6128
+ let expression = handlerExpression;
6036
6129
  if (isNodeOfType(expression, "Identifier")) {
6037
6130
  const binding = findVariableInitializer(expression, expression.name);
6038
6131
  if (!binding?.initializer) return null;
@@ -6131,18 +6224,22 @@ const clickEventsHaveKeyEvents = defineRule({
6131
6224
  if (!HTML_TAGS.has(tag)) return;
6132
6225
  if (tag === "label") return;
6133
6226
  if (!FOCUSLESS_CONTAINER_TAGS.has(tag) && isInteractiveElement(tag, node)) return;
6227
+ const spreadEventValues = getTransparentSpreadEventValues(node.attributes, context.scopes);
6228
+ if (!spreadEventValues) return;
6134
6229
  const onClick = hasJsxPropIgnoreCase(node.attributes, "onClick") ?? hasJsxPropIgnoreCase(node.attributes, "onClickCapture");
6135
- if (!onClick) return;
6136
- if (isPureEventBlockerHandler(onClick)) return;
6137
- if (isFocusForwardingHandler(onClick)) return;
6138
- if (hasJsxSpreadAttribute$1(node.attributes)) return;
6230
+ const spreadOnClickExpression = CLICK_HANDLERS.map((name) => spreadEventValues.get(name.toLowerCase())).find((expression) => expression !== void 0);
6231
+ if (!onClick && !spreadOnClickExpression) return;
6232
+ if (onClick && isPureEventBlockerHandler(onClick)) return;
6233
+ if (onClick && isFocusForwardingHandler(onClick)) return;
6234
+ const spreadHandlerFunction = spreadOnClickExpression ? resolveHandlerFunctionExpression(spreadOnClickExpression) : null;
6235
+ if (spreadHandlerFunction && (isFocusForwardingFunctionBody(spreadHandlerFunction.body ?? null) || containsBackdropDismissComparison(spreadHandlerFunction.body ?? null))) return;
6139
6236
  if (hasCompositeItemRole(node)) return;
6140
6237
  if (isHoverSelectionListItem(tag, node)) return;
6141
- if (isBackdropDismissHandler(onClick)) return;
6238
+ if (onClick && isBackdropDismissHandler(onClick)) return;
6142
6239
  if (containsKeyboardActivatableDescendant(node.parent)) return;
6143
6240
  if (isHiddenFromScreenReader(node, context.settings)) return;
6144
6241
  if (isPresentationRole(node)) return;
6145
- if (KEY_HANDLERS.some((handler) => hasJsxPropIgnoreCase(node.attributes, handler))) return;
6242
+ if (KEY_HANDLERS.some((handler) => hasJsxPropIgnoreCase(node.attributes, handler) || spreadEventValues.has(handler.toLowerCase()))) return;
6146
6243
  context.report({
6147
6244
  node: node.name,
6148
6245
  message: MESSAGE$57
@@ -6868,11 +6965,10 @@ const getTemplateInterpolations = (valueTail) => {
6868
6965
  const interpolations = valueTail.slice(1, closingBacktickIndex).match(/\$\{[^}]*\}/g);
6869
6966
  return interpolations === null ? "" : interpolations.join(" ");
6870
6967
  };
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);
6968
+ const getIdentifierDeclarationInitializer = (identifier, sinkIndex, fileContent) => {
6969
+ const declaration = findVisibleIdentifierDeclaration(identifier, sinkIndex, fileContent);
6970
+ if (declaration === null) return null;
6971
+ return fileContent.slice(declaration.initializerStartIndex, declaration.initializerStartIndex + STATIC_TEMPLATE_MAX_CHARS);
6876
6972
  };
6877
6973
  const isPureStringLiteralConcat = (initializerText) => {
6878
6974
  if (!/^["']/.test(initializerText)) return false;
@@ -6942,6 +7038,153 @@ const isAllOperandsDomContentConcat = (valueExpression) => {
6942
7038
  return !HTML_TAINT_PATTERN.test(withoutCast.replace(DOM_CONTENT_SOURCE_VALUE_PATTERN, ""));
6943
7039
  });
6944
7040
  };
7041
+ const findMatchingBraceIndex = (fileContent, openingBraceIndex) => {
7042
+ let depth = 0;
7043
+ let quote = null;
7044
+ for (let index = openingBraceIndex; index < fileContent.length; index += 1) {
7045
+ const character = fileContent[index];
7046
+ if (quote !== null) {
7047
+ if (character === quote && fileContent[index - 1] !== "\\") quote = null;
7048
+ continue;
7049
+ }
7050
+ if (character === "\"" || character === "'" || character === "`") {
7051
+ quote = character;
7052
+ continue;
7053
+ }
7054
+ if (character === "{") depth += 1;
7055
+ if (character === "}") {
7056
+ depth -= 1;
7057
+ if (depth === 0) return index;
7058
+ }
7059
+ }
7060
+ return fileContent.length;
7061
+ };
7062
+ const findContainingBlockEndIndex = (fileContent, targetIndex) => {
7063
+ const openingBraceIndexes = [];
7064
+ let quote = null;
7065
+ let isLineComment = false;
7066
+ let isBlockComment = false;
7067
+ for (let index = 0; index < targetIndex; index += 1) {
7068
+ const character = fileContent[index];
7069
+ const nextCharacter = fileContent[index + 1];
7070
+ if (isLineComment) {
7071
+ if (character === "\n") isLineComment = false;
7072
+ continue;
7073
+ }
7074
+ if (isBlockComment) {
7075
+ if (character === "*" && nextCharacter === "/") {
7076
+ isBlockComment = false;
7077
+ index += 1;
7078
+ }
7079
+ continue;
7080
+ }
7081
+ if (quote !== null) {
7082
+ if (character === quote && fileContent[index - 1] !== "\\") quote = null;
7083
+ continue;
7084
+ }
7085
+ if (character === "/" && nextCharacter === "/") {
7086
+ isLineComment = true;
7087
+ index += 1;
7088
+ continue;
7089
+ }
7090
+ if (character === "/" && nextCharacter === "*") {
7091
+ isBlockComment = true;
7092
+ index += 1;
7093
+ continue;
7094
+ }
7095
+ if (character === "\"" || character === "'" || character === "`") {
7096
+ quote = character;
7097
+ continue;
7098
+ }
7099
+ if (character === "{") openingBraceIndexes.push(index);
7100
+ if (character === "}") openingBraceIndexes.pop();
7101
+ }
7102
+ const openingBraceIndex = openingBraceIndexes.at(-1);
7103
+ return openingBraceIndex === void 0 ? fileContent.length : findMatchingBraceIndex(fileContent, openingBraceIndex);
7104
+ };
7105
+ const findVisibleIdentifierDeclaration = (identifier, sinkIndex, fileContent) => {
7106
+ const initializerPattern = new RegExp(`(?:const|let|var)\\s+${escapeRegExp(identifier)}\\s*(?::[^=\\n]*)?=\\s*([^;\\n]+)`, "g");
7107
+ let nearestDeclaration = null;
7108
+ let nearestDeclarationIndex = -1;
7109
+ for (const match of fileContent.matchAll(initializerPattern)) {
7110
+ const declarationIndex = match.index;
7111
+ if (declarationIndex === void 0 || declarationIndex >= sinkIndex || declarationIndex <= nearestDeclarationIndex || findContainingBlockEndIndex(fileContent, declarationIndex) < sinkIndex) continue;
7112
+ nearestDeclarationIndex = declarationIndex;
7113
+ const initializer = match[1];
7114
+ if (initializer === void 0) continue;
7115
+ nearestDeclaration = {
7116
+ initializer,
7117
+ initializerStartIndex: declarationIndex + match[0].length - initializer.length
7118
+ };
7119
+ }
7120
+ return nearestDeclaration;
7121
+ };
7122
+ const findContainingFunctionParameterSource = (identifier, sinkIndex, fileContent) => {
7123
+ const patterns = [/function\s+([\w$]+)\s*\(([^)]*)\)\s*\{/g, /(?:const|let|var)\s+([\w$]+)\s*=\s*(?:async\s*)?\(([^)]*)\)\s*=>\s*\{/g];
7124
+ let closestSource = null;
7125
+ let closestStartIndex = -1;
7126
+ for (const pattern of patterns) for (const match of fileContent.matchAll(pattern)) {
7127
+ const matchIndex = match.index;
7128
+ if (matchIndex === void 0 || matchIndex >= sinkIndex || matchIndex < closestStartIndex) continue;
7129
+ if (findMatchingBraceIndex(fileContent, matchIndex + match[0].lastIndexOf("{")) < sinkIndex) continue;
7130
+ const parameterIndex = (match[2] ?? "").split(",").findIndex((parameter) => parameter.trim().match(/^(?:\.\.\.)?([\w$]+)/)?.[1] === identifier);
7131
+ if (parameterIndex < 0) continue;
7132
+ closestStartIndex = matchIndex;
7133
+ closestSource = {
7134
+ functionName: match[1] ?? "",
7135
+ parameterIndex
7136
+ };
7137
+ }
7138
+ return closestSource;
7139
+ };
7140
+ const splitTopLevelArguments = (argumentText) => {
7141
+ const argumentsList = [];
7142
+ let startIndex = 0;
7143
+ let depth = 0;
7144
+ let quote = null;
7145
+ for (let index = 0; index < argumentText.length; index += 1) {
7146
+ const character = argumentText[index];
7147
+ if (quote !== null) {
7148
+ if (character === quote && argumentText[index - 1] !== "\\") quote = null;
7149
+ continue;
7150
+ }
7151
+ if (character === "\"" || character === "'" || character === "`") {
7152
+ quote = character;
7153
+ continue;
7154
+ }
7155
+ if (character === "(" || character === "[" || character === "{") depth += 1;
7156
+ if (character === ")" || character === "]" || character === "}") depth -= 1;
7157
+ if (character === "," && depth === 0) {
7158
+ argumentsList.push(argumentText.slice(startIndex, index).trim());
7159
+ startIndex = index + 1;
7160
+ }
7161
+ }
7162
+ argumentsList.push(argumentText.slice(startIndex).trim());
7163
+ return argumentsList;
7164
+ };
7165
+ const isHtmlTainted = (expression, fileContent, sinkIndex, visitedIdentifiers) => {
7166
+ const trimmedExpression = expression.trim();
7167
+ if (STRING_LITERAL_VALUE_PATTERN.test(`${trimmedExpression};`)) return false;
7168
+ if (MODULE_CONSTANT_VALUE_PATTERN.test(`${trimmedExpression};`)) return false;
7169
+ if (SANITIZER_PATTERN.test(trimmedExpression)) return false;
7170
+ if (ENV_CONFIG_VALUE_PATTERN.test(trimmedExpression)) return false;
7171
+ if (I18N_VALUE_PATTERN.test(trimmedExpression)) return false;
7172
+ if (ESCAPING_SERIALIZER_CALL_PATTERN.test(trimmedExpression)) return false;
7173
+ if (HTML_TAINT_PATTERN.test(trimmedExpression)) return true;
7174
+ const identifier = trimmedExpression.match(/^([\w$]+)\s*(?:[;,})\n]|$)/)?.[1];
7175
+ if (identifier === void 0 || visitedIdentifiers.has(identifier)) return false;
7176
+ visitedIdentifiers.add(identifier);
7177
+ const declaration = findVisibleIdentifierDeclaration(identifier, sinkIndex, fileContent);
7178
+ if (declaration !== null && isHtmlTainted(declaration.initializer, fileContent, sinkIndex, visitedIdentifiers)) return true;
7179
+ const parameterSource = findContainingFunctionParameterSource(identifier, sinkIndex, fileContent);
7180
+ if (parameterSource === null || parameterSource.functionName.length === 0) return false;
7181
+ const callPattern = new RegExp(`\\b${escapeRegExp(parameterSource.functionName)}\\s*\\(([^)]*)\\)`, "g");
7182
+ for (const callMatch of fileContent.matchAll(callPattern)) {
7183
+ const argument = splitTopLevelArguments(callMatch[1] ?? "")[parameterSource.parameterIndex];
7184
+ if (argument !== void 0 && isHtmlTainted(argument, fileContent, sinkIndex, new Set(visitedIdentifiers))) return true;
7185
+ }
7186
+ return false;
7187
+ };
6945
7188
  const dangerousHtmlSink = defineRule({
6946
7189
  id: "dangerous-html-sink",
6947
7190
  title: "HTML injection sink with dynamic content",
@@ -6968,6 +7211,7 @@ const dangerousHtmlSink = defineRule({
6968
7211
  const valueTail = fullValueTail.slice(0, VALUE_EXPRESSION_MAX_CHARS);
6969
7212
  const terminatorIndex = valueTail.search(/[;}]/);
6970
7213
  const valueExpression = terminatorIndex >= 0 ? valueTail.slice(0, terminatorIndex + 1) : valueTail;
7214
+ const sinkIndex = lines.slice(0, lineIndex).join("\n").length + (lineIndex > 0 ? 1 : 0) + line.search(DANGEROUS_HTML_PATTERN);
6971
7215
  if (STRING_LITERAL_VALUE_PATTERN.test(valueExpression)) continue;
6972
7216
  if (MODULE_CONSTANT_VALUE_PATTERN.test(valueExpression)) continue;
6973
7217
  if (DOM_CONTENT_SOURCE_VALUE_PATTERN.test(valueExpression) && !valueExpression.includes("+")) {
@@ -6979,7 +7223,7 @@ const dangerousHtmlSink = defineRule({
6979
7223
  let templateInterpolations = getTemplateInterpolations(longValueTail ?? fullValueTail);
6980
7224
  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
7225
  if (templateInterpolations === null && valueIdentifier !== void 0 && BARE_IDENTIFIER_VALUE_PATTERN.test(valueExpression)) {
6982
- const declarationInitializer = getIdentifierDeclarationInitializer(valueIdentifier, file.content);
7226
+ const declarationInitializer = getIdentifierDeclarationInitializer(valueIdentifier, sinkIndex, file.content);
6983
7227
  if (declarationInitializer !== null) {
6984
7228
  if (isPureStringLiteralConcat(declarationInitializer)) continue;
6985
7229
  templateInterpolations = getTemplateInterpolations(declarationInitializer);
@@ -6990,7 +7234,7 @@ const dangerousHtmlSink = defineRule({
6990
7234
  if (SANITIZER_PATTERN.test(judgedExpression)) continue;
6991
7235
  if (ENV_CONFIG_VALUE_PATTERN.test(judgedExpression)) continue;
6992
7236
  if (I18N_VALUE_PATTERN.test(judgedExpression)) continue;
6993
- if (!HTML_TAINT_PATTERN.test(judgedExpression)) continue;
7237
+ if (!isHtmlTainted(judgedExpression, file.content, sinkIndex, /* @__PURE__ */ new Set())) continue;
6994
7238
  if (ESCAPING_SERIALIZER_CALL_PATTERN.test(valueExpression)) continue;
6995
7239
  if (/highlighted/i.test(valueExpression)) continue;
6996
7240
  if (/highlight/i.test(valueExpression) && HIGHLIGHTER_LIBRARY_PATTERN.test(file.content)) continue;
@@ -7468,7 +7712,7 @@ const isCreateClassLikeCall = (node) => {
7468
7712
  if (isNodeOfType(callee, "MemberExpression")) return getStaticMemberName$2(callee) === "createClass";
7469
7713
  return false;
7470
7714
  };
7471
- const isCreateContextCall = (node) => {
7715
+ const isCreateContextCall$1 = (node) => {
7472
7716
  if (!isNodeOfType(node, "CallExpression")) return false;
7473
7717
  const callee = node.callee;
7474
7718
  if (isNodeOfType(callee, "Identifier")) return callee.name === "createContext";
@@ -7645,7 +7889,7 @@ const displayName = defineRule({
7645
7889
  }
7646
7890
  },
7647
7891
  CallExpression(node) {
7648
- if (settings.checkContextObjects && isReactVersionAtLeast$1(settings.reactVersion, 16, 3) && isCreateContextCall(node)) {
7892
+ if (settings.checkContextObjects && isReactVersionAtLeast$1(settings.reactVersion, 16, 3) && isCreateContextCall$1(node)) {
7649
7893
  const assignedName = getAssignedName(node);
7650
7894
  if (assignedName) {
7651
7895
  const programRoot = findProgramRoot(node);
@@ -7692,21 +7936,6 @@ const displayName = defineRule({
7692
7936
  }
7693
7937
  });
7694
7938
  //#endregion
7695
- //#region src/plugin/utils/get-static-property-key-name.ts
7696
- const getStaticPropertyKeyName = (node, options = {}) => {
7697
- if (!isNodeOfType(node, "Property")) return null;
7698
- if (node.computed) {
7699
- if (options.allowComputedString && isNodeOfType(node.key, "Literal") && typeof node.key.value === "string") return node.key.value;
7700
- return null;
7701
- }
7702
- if (isNodeOfType(node.key, "Identifier")) return node.key.name;
7703
- if (isNodeOfType(node.key, "Literal")) {
7704
- if (typeof node.key.value === "string") return node.key.value;
7705
- if (options.stringifyNonStringLiterals) return String(node.key.value);
7706
- }
7707
- return null;
7708
- };
7709
- //#endregion
7710
7939
  //#region src/plugin/utils/read-static-boolean.ts
7711
7940
  const readStaticBoolean = (node) => {
7712
7941
  if (!node) return null;
@@ -7715,21 +7944,6 @@ const readStaticBoolean = (node) => {
7715
7944
  return unwrappedNode.value;
7716
7945
  };
7717
7946
  //#endregion
7718
- //#region src/plugin/utils/resolve-const-identifier-alias.ts
7719
- const resolveConstIdentifierAlias = (identifier, scopes) => {
7720
- if (!isNodeOfType(identifier, "Identifier")) return null;
7721
- const visitedSymbolIds = /* @__PURE__ */ new Set();
7722
- let symbol = scopes.symbolFor(identifier);
7723
- while (symbol?.kind === "const") {
7724
- if (visitedSymbolIds.has(symbol.id) || !symbol.initializer || !isNodeOfType(symbol.declarationNode, "VariableDeclarator") || symbol.declarationNode.id !== symbol.bindingIdentifier) return null;
7725
- visitedSymbolIds.add(symbol.id);
7726
- const initializer = stripParenExpression(symbol.initializer);
7727
- if (!isNodeOfType(initializer, "Identifier")) return symbol;
7728
- symbol = scopes.symbolFor(initializer);
7729
- }
7730
- return symbol;
7731
- };
7732
- //#endregion
7733
7947
  //#region src/plugin/utils/is-react-api-call.ts
7734
7948
  const includesApiName = (apiNames, apiName) => typeof apiNames === "string" ? apiNames === apiName : apiNames.has(apiName);
7735
7949
  const isImportedFromReact = (symbol) => {
@@ -7737,9 +7951,9 @@ const isImportedFromReact = (symbol) => {
7737
7951
  const importDeclaration = symbol.declarationNode.parent;
7738
7952
  return Boolean(importDeclaration && isNodeOfType(importDeclaration, "ImportDeclaration") && typeof importDeclaration.source.value === "string" && REACT_RUNTIME_MODULE_SOURCES.has(importDeclaration.source.value));
7739
7953
  };
7740
- const isNamedReactApiImport = (identifier, apiNames, scopes) => {
7954
+ const isNamedReactApiImport = (identifier, apiNames, scopes, resolveAliases) => {
7741
7955
  if (!isNodeOfType(identifier, "Identifier")) return false;
7742
- const symbol = scopes.symbolFor(identifier);
7956
+ const symbol = resolveAliases ? resolveConstIdentifierAlias(identifier, scopes) : scopes.symbolFor(identifier);
7743
7957
  if (!symbol || !isImportedFromReact(symbol)) return false;
7744
7958
  const importedName = getImportedName(symbol.declarationNode);
7745
7959
  return Boolean(importedName && includesApiName(apiNames, importedName));
@@ -7753,7 +7967,7 @@ const isReactApiCall = (node, apiNames, scopes, options = {}) => {
7753
7967
  if (!isNodeOfType(node, "CallExpression")) return false;
7754
7968
  const callee = stripParenExpression(node.callee);
7755
7969
  if (isNodeOfType(callee, "Identifier")) {
7756
- if (isNamedReactApiImport(callee, apiNames, scopes)) return true;
7970
+ if (isNamedReactApiImport(callee, apiNames, scopes, Boolean(options.resolveNamedAliases))) return true;
7757
7971
  return Boolean(options.allowUnboundBareCalls && includesApiName(apiNames, callee.name) && scopes.isGlobalReference(callee));
7758
7972
  }
7759
7973
  if (!isNodeOfType(callee, "MemberExpression") || callee.computed || !isNodeOfType(callee.property, "Identifier") || !includesApiName(apiNames, callee.property.name)) return false;
@@ -8856,6 +9070,30 @@ const doMatchingNodesCoverEveryPathAfterUsage = (usageNode, matchingNodes, conte
8856
9070
  }
8857
9071
  return matchingBlocks.size > 0;
8858
9072
  };
9073
+ const doMatchingNodesCoverEveryPathFromFunctionEntry = (owner, matchingNodes, context) => {
9074
+ const functionCfg = context.cfg.cfgFor(owner);
9075
+ if (!functionCfg) return false;
9076
+ const matchingBlocks = new Set(matchingNodes.flatMap((matchingNode) => {
9077
+ if (context.cfg.enclosingFunction(matchingNode) !== owner) return [];
9078
+ const matchingBlock = functionCfg.blockOf(matchingNode);
9079
+ return matchingBlock ? [matchingBlock] : [];
9080
+ }));
9081
+ if (matchingBlocks.size === 0) return false;
9082
+ const visitedBlocks = new Set([functionCfg.entry]);
9083
+ const pendingBlocks = [functionCfg.entry];
9084
+ while (pendingBlocks.length > 0) {
9085
+ const currentBlock = pendingBlocks.pop();
9086
+ if (!currentBlock) break;
9087
+ if (matchingBlocks.has(currentBlock)) continue;
9088
+ for (const edge of currentBlock.successors) {
9089
+ if (edge.to === functionCfg.exit) return false;
9090
+ if (visitedBlocks.has(edge.to)) continue;
9091
+ visitedBlocks.add(edge.to);
9092
+ pendingBlocks.push(edge.to);
9093
+ }
9094
+ }
9095
+ return true;
9096
+ };
8859
9097
  const removeSynchronouslyReleasedUsages = (callback, usages, context) => {
8860
9098
  if (!isNodeOfType(callback, "ArrowFunctionExpression") && !isNodeOfType(callback, "FunctionExpression")) return usages;
8861
9099
  if (!isNodeOfType(callback.body, "BlockStatement")) return usages;
@@ -9056,6 +9294,83 @@ const doesCleanupFunctionReleaseUsage = (cleanupFunction, usage, context, visite
9056
9294
  });
9057
9295
  return didCleanupFunctionMatch;
9058
9296
  };
9297
+ const doesBoundCleanupReleaseUsage = (expression, usage, context) => {
9298
+ const callExpression = stripParenExpression(expression);
9299
+ if (!isNodeOfType(callExpression, "CallExpression")) return false;
9300
+ const bindCallee = stripParenExpression(callExpression.callee);
9301
+ if (!isNodeOfType(bindCallee, "MemberExpression") || bindCallee.computed || !isNodeOfType(bindCallee.property, "Identifier") || bindCallee.property.name !== "bind") return false;
9302
+ const releaseMember = stripParenExpression(bindCallee.object);
9303
+ if (!isNodeOfType(releaseMember, "MemberExpression") || releaseMember.computed || !isNodeOfType(releaseMember.property, "Identifier")) return false;
9304
+ const releaseReceiverKey = resolveExpressionKey$1(releaseMember.object, context);
9305
+ if (releaseReceiverKey === null || releaseReceiverKey !== resolveExpressionKey$1(callExpression.arguments?.[0], context)) return false;
9306
+ const releaseVerbName = releaseMember.property.name;
9307
+ if (usage.kind === "socket") return usage.handleKey === releaseReceiverKey && (SOCKET_RELEASE_VERB_NAMES.has(releaseVerbName) || UNIVERSAL_RELEASE_VERB_NAMES.has(releaseVerbName));
9308
+ return usage.kind === "subscribe" && usage.handleKey === releaseReceiverKey && (releaseVerbName === "unsubscribe" || releaseVerbName === "unsub" || releaseVerbName === "close" || releaseVerbName === "unwatch" || releaseVerbName === "unlisten" || BOUND_RESOURCE_RELEASE_METHOD_NAMES.has(releaseVerbName));
9309
+ };
9310
+ const callbackReturnsCleanupForUsage = (callback, usage, context) => {
9311
+ if (!isNodeOfType(callback, "ArrowFunctionExpression") && !isNodeOfType(callback, "FunctionExpression")) return false;
9312
+ const doesReturnedValueReleaseUsage = (returnedValue) => {
9313
+ if (doesBoundCleanupReleaseUsage(returnedValue, usage, context)) return true;
9314
+ const cleanupFunction = resolveStableValue(returnedValue, context);
9315
+ if (cleanupFunction && doesBoundCleanupReleaseUsage(cleanupFunction, usage, context)) return true;
9316
+ return Boolean(cleanupFunction && isFunctionLike$1(cleanupFunction) && doesCleanupFunctionReleaseUsage(cleanupFunction, usage, context));
9317
+ };
9318
+ if (!isNodeOfType(callback.body, "BlockStatement")) return doesReturnedValueReleaseUsage(stripParenExpression(callback.body));
9319
+ const matchingCleanupReturns = [];
9320
+ walkInsideStatementBlocks(callback.body, (child) => {
9321
+ if (isNodeOfType(child, "ReturnStatement") && child.argument && doesReturnedValueReleaseUsage(stripParenExpression(child.argument))) matchingCleanupReturns.push(child);
9322
+ });
9323
+ return doMatchingNodesCoverEveryPathFromFunctionEntry(callback, matchingCleanupReturns, context);
9324
+ };
9325
+ const hasRerunReleaseBeforeUsage = (callback, usage, context) => {
9326
+ if (!isNodeOfType(callback, "ArrowFunctionExpression") && !isNodeOfType(callback, "FunctionExpression") || !isNodeOfType(callback.body, "BlockStatement")) return false;
9327
+ const functionCfg = context.cfg.cfgFor(callback);
9328
+ const usageBlock = functionCfg?.blockOf(usage.node);
9329
+ const usageStart = getRangeStart(usage.node);
9330
+ if (!functionCfg || !usageBlock || usageStart === null) return false;
9331
+ const findHandleGuard = (releaseCall) => {
9332
+ if (usage.handleKey === null) return null;
9333
+ let ancestor = releaseCall.parent;
9334
+ while (ancestor && ancestor !== callback.body) {
9335
+ if (isNodeOfType(ancestor, "IfStatement")) return ancestor.alternate === null && resolveExpressionKey$1(ancestor.test, context) === usage.handleKey && getRangeStart(ancestor) !== null && (getRangeStart(ancestor) ?? usageStart) < usageStart ? ancestor : null;
9336
+ ancestor = ancestor.parent;
9337
+ }
9338
+ return null;
9339
+ };
9340
+ const matchingReleaseAnchors = [];
9341
+ walkInsideStatementBlocks(callback.body, (child) => {
9342
+ if (!isNodeOfType(child, "CallExpression")) return;
9343
+ const releaseStart = getRangeStart(child);
9344
+ const handleGuard = findHandleGuard(child);
9345
+ if (releaseStart === null || releaseStart >= usageStart || functionCfg.blockOf(child) !== usageBlock && !handleGuard) return;
9346
+ if (doesReleaseCallMatchUsage(child, usage, context)) {
9347
+ matchingReleaseAnchors.push(handleGuard ?? child);
9348
+ return;
9349
+ }
9350
+ const helperFunction = resolveStableValue(child.callee, context);
9351
+ if (helperFunction && isFunctionLike$1(helperFunction) && doesCleanupFunctionReleaseUsage(helperFunction, usage, context)) matchingReleaseAnchors.push(handleGuard ?? child);
9352
+ });
9353
+ return doMatchingNodesCoverEveryPathFromFunctionEntry(callback, matchingReleaseAnchors, context);
9354
+ };
9355
+ const hasStableUnmountCleanupForUsage = (callback, usage, context) => {
9356
+ const componentFunction = findEnclosingFunction(callback);
9357
+ if (!componentFunction || !isNodeOfType(componentFunction, "ArrowFunctionExpression") && !isNodeOfType(componentFunction, "FunctionExpression") && !isNodeOfType(componentFunction, "FunctionDeclaration")) return false;
9358
+ let didFindUnmountCleanup = false;
9359
+ walkAst(componentFunction.body, (child) => {
9360
+ if (didFindUnmountCleanup) return false;
9361
+ if (!isNodeOfType(child, "CallExpression") || findEnclosingFunction(child) !== componentFunction) return;
9362
+ if (!isHookCall$2(child, CLEANUP_EFFECT_HOOK_NAMES)) return;
9363
+ const dependencyList = child.arguments?.[1];
9364
+ if (!isNodeOfType(dependencyList, "ArrayExpression") || dependencyList.elements.length > 0) return;
9365
+ const cleanupCallback = getEffectCallback(child);
9366
+ if (cleanupCallback && cleanupCallback !== callback && callbackReturnsCleanupForUsage(cleanupCallback, usage, context)) {
9367
+ didFindUnmountCleanup = true;
9368
+ return false;
9369
+ }
9370
+ });
9371
+ return didFindUnmountCleanup;
9372
+ };
9373
+ const hasSplitLifecycleCleanup = (callback, usage, context) => usage.handleKey !== null && hasRerunReleaseBeforeUsage(callback, usage, context) && hasStableUnmountCleanupForUsage(callback, usage, context);
9059
9374
  const effectHasCleanupForUsage = (callback, usage, context) => {
9060
9375
  if (!isNodeOfType(callback, "ArrowFunctionExpression") && !isNodeOfType(callback, "FunctionExpression")) return false;
9061
9376
  if (usage.kind === "subscribe" && findEnclosingFunction(usage.node) === callback && doesResourceResultEscape(usage.node, true) && isCleanupReturningSubscribeLikeCallExpression(usage.node)) return true;
@@ -9068,6 +9383,10 @@ const effectHasCleanupForUsage = (callback, usage, context) => {
9068
9383
  if (returnStart !== null && usageStart !== null && returnStart < usageStart) return;
9069
9384
  const returnedValue = child.argument ? stripParenExpression(child.argument) : null;
9070
9385
  if (!returnedValue) return;
9386
+ if (doesBoundCleanupReleaseUsage(returnedValue, usage, context)) {
9387
+ matchingCleanupReturns.push(child);
9388
+ return;
9389
+ }
9071
9390
  if (usage.kind === "subscribe" && (returnedValue === usage.node || getRangeStart(returnedValue) !== null && getRangeStart(returnedValue) === getRangeStart(usage.node)) && isCleanupReturningSubscribeLikeCallExpression(returnedValue)) {
9072
9391
  matchingCleanupReturns.push(child);
9073
9392
  return;
@@ -9083,13 +9402,17 @@ const effectHasCleanupForUsage = (callback, usage, context) => {
9083
9402
  if (!context.scopes.symbolFor(returnedValue)?.initializer) return;
9084
9403
  }
9085
9404
  const cleanupFunction = resolveStableValue(returnedValue, context);
9405
+ if (cleanupFunction && doesBoundCleanupReleaseUsage(cleanupFunction, usage, context)) {
9406
+ matchingCleanupReturns.push(child);
9407
+ return;
9408
+ }
9086
9409
  if (!cleanupFunction || !isFunctionLike$1(cleanupFunction)) return;
9087
9410
  if (doesCleanupFunctionReleaseUsage(cleanupFunction, usage, context)) matchingCleanupReturns.push(child);
9088
9411
  });
9089
9412
  return doMatchingNodesCoverEveryPathAfterUsage(resolveCleanupPathAnchor(usage.node, callback, context), matchingCleanupReturns, context);
9090
9413
  };
9091
9414
  const findFirstUsageWithoutCleanup = (callback, usages, context) => {
9092
- for (const usage of usages) if (!effectHasCleanupForUsage(callback, usage, context)) return usage;
9415
+ for (const usage of usages) if (!effectHasCleanupForUsage(callback, usage, context) && !hasSplitLifecycleCleanup(callback, usage, context)) return usage;
9093
9416
  return null;
9094
9417
  };
9095
9418
  const isLocalAbortControllerExpression = (expression, context, visitedSymbolIds = /* @__PURE__ */ new Set()) => {
@@ -10962,6 +11285,11 @@ If the missing value is recreated every render, move it inside the hook or stabi
10962
11285
  return { CallExpression(node) {
10963
11286
  const hookName = getHookName(node.callee, context.scopes);
10964
11287
  if (!hookName || !isHookOfInterest(hookName, node.callee)) return;
11288
+ if (HOOKS_REQUIRING_DEPS_MATCH.has(hookName) && !isReactApiCall(node, HOOKS_REQUIRING_DEPS_MATCH, context.scopes, {
11289
+ allowGlobalReactNamespace: true,
11290
+ allowUnboundBareCalls: true,
11291
+ resolveNamedAliases: true
11292
+ })) return;
10965
11293
  const callbackArgumentIndex = getCallbackArgumentIndex(hookName);
10966
11294
  const depsArgumentIndex = getDepsArgumentIndex(hookName);
10967
11295
  const callbackArgument = node.arguments[callbackArgumentIndex];
@@ -22889,61 +23217,73 @@ const PURE_GLOBAL_CALLEE_NAMES = new Set([
22889
23217
  "Array",
22890
23218
  "BigInt",
22891
23219
  "Boolean",
23220
+ "encodeURIComponent",
22892
23221
  "Number",
22893
23222
  "Object",
22894
23223
  "String",
22895
23224
  "parseFloat",
22896
- "parseInt"
23225
+ "parseInt",
23226
+ "structuredClone"
23227
+ ]);
23228
+ const PURE_GLOBAL_CONSTRUCTOR_NAMES = new Set(["Date", "Set"]);
23229
+ const PURE_HELPER_NAMESPACE_MEMBER_NAMES = new Map([
23230
+ ["Array", new Set(["from"])],
23231
+ ["JSON", new Set([
23232
+ "isRawJSON",
23233
+ "parse",
23234
+ "rawJSON",
23235
+ "stringify"
23236
+ ])],
23237
+ ["Math", new Set([
23238
+ "abs",
23239
+ "acos",
23240
+ "acosh",
23241
+ "asin",
23242
+ "asinh",
23243
+ "atan",
23244
+ "atan2",
23245
+ "atanh",
23246
+ "cbrt",
23247
+ "ceil",
23248
+ "clz32",
23249
+ "cos",
23250
+ "cosh",
23251
+ "exp",
23252
+ "floor",
23253
+ "fround",
23254
+ "hypot",
23255
+ "imul",
23256
+ "log",
23257
+ "log10",
23258
+ "log1p",
23259
+ "log2",
23260
+ "max",
23261
+ "min",
23262
+ "pow",
23263
+ "round",
23264
+ "sign",
23265
+ "sin",
23266
+ "sinh",
23267
+ "sqrt",
23268
+ "tan",
23269
+ "tanh",
23270
+ "trunc"
23271
+ ])],
23272
+ ["Object", new Set(["assign"])]
22897
23273
  ]);
22898
- const PURE_GLOBAL_NAMESPACE_NAMES = new Set(["JSON", "Math"]);
22899
- const PURE_HELPER_NAMESPACE_MEMBER_NAMES = new Map([["JSON", new Set([
22900
- "isRawJSON",
22901
- "parse",
22902
- "rawJSON",
22903
- "stringify"
22904
- ])], ["Math", new Set([
22905
- "abs",
22906
- "acos",
22907
- "acosh",
22908
- "asin",
22909
- "asinh",
22910
- "atan",
22911
- "atan2",
22912
- "atanh",
22913
- "cbrt",
22914
- "ceil",
22915
- "clz32",
22916
- "cos",
22917
- "cosh",
22918
- "exp",
22919
- "floor",
22920
- "fround",
22921
- "hypot",
22922
- "imul",
22923
- "log",
22924
- "log10",
22925
- "log1p",
22926
- "log2",
22927
- "max",
22928
- "min",
22929
- "pow",
22930
- "round",
22931
- "sign",
22932
- "sin",
22933
- "sinh",
22934
- "sqrt",
22935
- "tan",
22936
- "tanh",
22937
- "trunc"
22938
- ])]]);
22939
23274
  const PURE_MEMBER_TRANSFORM_NAMES = new Set([
22940
23275
  "concat",
22941
23276
  "filter",
23277
+ "flatMap",
22942
23278
  "join",
22943
23279
  "map",
23280
+ "reduce",
23281
+ "replace",
23282
+ "slice",
22944
23283
  "split",
22945
23284
  "toLowerCase",
22946
23285
  "toString",
23286
+ "toSorted",
22947
23287
  "toUpperCase",
22948
23288
  "trim"
22949
23289
  ]);
@@ -23325,6 +23665,11 @@ const analyzeHelperExpression = (expression, environment, usedParameterIndices)
23325
23665
  const callbackSummary = analyzeHelperStatements(node.body.body ?? [], callbackEnvironment, usedParameterIndices, analyzeHelperExpression);
23326
23666
  return callbackSummary.isValid && !callbackSummary.canContinue;
23327
23667
  }
23668
+ if (isNodeOfType(node, "NewExpression")) {
23669
+ const callee = stripParenExpression(node.callee);
23670
+ if (!isNodeOfType(callee, "Identifier") || !PURE_GLOBAL_CONSTRUCTOR_NAMES.has(callee.name) || environment.parameterIndices.has(callee.name) || environment.recursiveNames.has(callee.name) || environment.shadowedGlobalNames.has(callee.name)) return false;
23671
+ return (node.arguments ?? []).every((argument) => analyzeHelperExpression(argument, environment, usedParameterIndices));
23672
+ }
23328
23673
  if (isNodeOfType(node, "CallExpression")) {
23329
23674
  const callee = stripParenExpression(node.callee);
23330
23675
  const calleeRoot = getMemberRoot(callee);
@@ -23527,7 +23872,7 @@ const collectValueEvidence = (analysis, expression, frame, remainingCallFrames,
23527
23872
  }
23528
23873
  const callee = stripParenExpression(node.callee);
23529
23874
  const calleeRoot = getMemberRoot(callee);
23530
- const isPureGlobalCall = isNodeOfType(callee, "Identifier") && PURE_GLOBAL_CALLEE_NAMES.has(callee.name) && getIdentifierBindingIdentity(analysis, callee) === null || isNodeOfType(calleeRoot, "Identifier") && PURE_GLOBAL_NAMESPACE_NAMES.has(calleeRoot.name) && getIdentifierBindingIdentity(analysis, calleeRoot) === null;
23875
+ const isPureGlobalCall = isNodeOfType(callee, "Identifier") && PURE_GLOBAL_CALLEE_NAMES.has(callee.name) && getIdentifierBindingIdentity(analysis, callee) === null || isNodeOfType(callee, "MemberExpression") && isNodeOfType(calleeRoot, "Identifier") && getIdentifierBindingIdentity(analysis, calleeRoot) === null && PURE_HELPER_NAMESPACE_MEMBER_NAMES.get(calleeRoot.name)?.has(getStaticMemberName$1(callee) ?? "") === true;
23531
23876
  const isPureMemberTransform = isNodeOfType(callee, "MemberExpression") && PURE_MEMBER_TRANSFORM_NAMES.has(getStaticMemberName$1(callee) ?? "");
23532
23877
  if (isPureGlobalCall || isPureMemberTransform) {
23533
23878
  if (isPureMemberTransform && isNodeOfType(callee, "MemberExpression")) mergeEvidence(evidence, collectValueEvidence(analysis, callee.object, frame, remainingCallFrames, new Set(visitedBindings)));
@@ -23580,6 +23925,15 @@ const collectValueEvidence = (analysis, expression, frame, remainingCallFrames,
23580
23925
  }
23581
23926
  return evidence;
23582
23927
  }
23928
+ if (isNodeOfType(node, "NewExpression")) {
23929
+ const callee = stripParenExpression(node.callee);
23930
+ if (!isNodeOfType(callee, "Identifier") || !PURE_GLOBAL_CONSTRUCTOR_NAMES.has(callee.name) || getIdentifierBindingIdentity(analysis, callee) !== null) {
23931
+ evidence.hasUnknownSource = true;
23932
+ return evidence;
23933
+ }
23934
+ for (const argument of node.arguments ?? []) mergeEvidence(evidence, collectValueEvidence(analysis, argument, frame, remainingCallFrames, new Set(visitedBindings)));
23935
+ return evidence;
23936
+ }
23583
23937
  if (isFunctionLike$1(node) || isNodeOfType(node, "AwaitExpression")) {
23584
23938
  evidence.hasUnknownSource = true;
23585
23939
  return evidence;
@@ -23998,6 +24352,26 @@ const STATEFUL_HTML_DESCENDANT_TAGS = new Set([
23998
24352
  "dialog",
23999
24353
  "canvas"
24000
24354
  ]);
24355
+ const STATEFUL_HTML_ATTRIBUTE_NAMES = new Set([
24356
+ "autofocus",
24357
+ "contenteditable",
24358
+ "draggable",
24359
+ "tabindex"
24360
+ ]);
24361
+ const isStaticallyFalseAttributeValue = (attribute) => {
24362
+ if (!isNodeOfType(attribute, "JSXAttribute") || !attribute.value) return false;
24363
+ const value = isNodeOfType(attribute.value, "JSXExpressionContainer") ? attribute.value.expression : attribute.value;
24364
+ return isNodeOfType(value, "Literal") && (value.value === false || value.value === "false");
24365
+ };
24366
+ const hasStatefulHtmlAttribute = (openingElement) => {
24367
+ if (!isNodeOfType(openingElement, "JSXOpeningElement")) return false;
24368
+ return openingElement.attributes.some((attribute) => {
24369
+ if (!isNodeOfType(attribute, "JSXAttribute") || !isNodeOfType(attribute.name, "JSXIdentifier")) return false;
24370
+ const attributeName = attribute.name.name.toLowerCase();
24371
+ if (!STATEFUL_HTML_ATTRIBUTE_NAMES.has(attributeName)) return false;
24372
+ return attributeName === "tabindex" || !isStaticallyFalseAttributeValue(attribute);
24373
+ });
24374
+ };
24001
24375
  const STATEFUL_DESCENDANT_SCAN_BUDGET = 200;
24002
24376
  const rootIdentifierNameOf = (expression) => {
24003
24377
  let object = expression;
@@ -24015,7 +24389,8 @@ const containsStatefulDescendant = (jsxElement, options = {}) => {
24015
24389
  budget -= 1;
24016
24390
  const node = stack.pop();
24017
24391
  if (isNodeOfType(node, "JSXElement")) {
24018
- const name = node.openingElement.name;
24392
+ const opening = node.openingElement;
24393
+ const name = opening.name;
24019
24394
  if (name && isNodeOfType(name, "JSXIdentifier")) {
24020
24395
  const tagName = name.name;
24021
24396
  const firstChar = tagName.charCodeAt(0);
@@ -24023,6 +24398,7 @@ const containsStatefulDescendant = (jsxElement, options = {}) => {
24023
24398
  if (STATEFUL_HTML_DESCENDANT_TAGS.has(tagName)) return true;
24024
24399
  }
24025
24400
  if (name && isNodeOfType(name, "JSXMemberExpression")) return true;
24401
+ if (hasStatefulHtmlAttribute(opening)) return true;
24026
24402
  const children = node.children ?? [];
24027
24403
  for (const child of children) stack.push(child);
24028
24404
  continue;
@@ -24582,6 +24958,14 @@ const templateHasOuterMemberIdentity = (template, bindingFunction) => {
24582
24958
  }
24583
24959
  return false;
24584
24960
  };
24961
+ const findBareItemNamesReferencedByTemplate = (template, itemNames) => {
24962
+ const referencedItemNames = /* @__PURE__ */ new Set();
24963
+ for (const expression of template.expressions ?? []) {
24964
+ const unwrappedExpression = stripParenExpression(expression);
24965
+ if (isNodeOfType(unwrappedExpression, "Identifier") && itemNames.has(unwrappedExpression.name)) referencedItemNames.add(unwrappedExpression.name);
24966
+ }
24967
+ return referencedItemNames;
24968
+ };
24585
24969
  const forLoopTestReadsDataLength = (test) => {
24586
24970
  let didFindLengthRead = false;
24587
24971
  walkAst(test, (child) => {
@@ -24718,9 +25102,11 @@ const noArrayIndexAsKey = defineRule({
24718
25102
  } else if (STATELESS_HTML_LEAF_TAGS.has(elementName.name)) {
24719
25103
  const jsxElement = openingElement.parent;
24720
25104
  if (jsxElement && isNodeOfType(jsxElement, "JSXElement")) {
25105
+ const isInlineTextRun = INLINE_TEXT_LEAF_TAGS.has(elementName.name);
25106
+ const primitiveItemNames = keyTemplate ? findBareItemNamesReferencedByTemplate(keyTemplate, itemNames) : EMPTY_NAME_SET;
24721
25107
  if (!containsStatefulDescendant(jsxElement, {
24722
- memberRootNames: INLINE_TEXT_LEAF_TAGS.has(elementName.name) ? itemNames : EMPTY_NAME_SET,
24723
- bareIdentifierNames: derivedNames
25108
+ memberRootNames: isInlineTextRun ? itemNames : EMPTY_NAME_SET,
25109
+ bareIdentifierNames: primitiveItemNames.size > 0 ? new Set([...derivedNames, ...primitiveItemNames]) : derivedNames
24724
25110
  })) return;
24725
25111
  }
24726
25112
  }
@@ -24888,7 +25274,11 @@ const noAsyncEffectCallback = defineRule({
24888
25274
  severity: "warn",
24889
25275
  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
25276
  create: (context) => ({ CallExpression(node) {
24891
- if (!isHookCall$2(node, EFFECT_HOOK_NAMES$1)) return;
25277
+ if (!isReactApiCall(node, EFFECT_HOOK_NAMES$1, context.scopes, {
25278
+ allowGlobalReactNamespace: true,
25279
+ allowUnboundBareCalls: true,
25280
+ resolveNamedAliases: true
25281
+ })) return;
24892
25282
  const callback = getEffectCallback(node);
24893
25283
  if (!callback) return;
24894
25284
  if (!isNodeOfType(callback, "ArrowFunctionExpression") && !isNodeOfType(callback, "FunctionExpression")) return;
@@ -26086,6 +26476,19 @@ const noCloneElement = defineRule({
26086
26476
  } })
26087
26477
  });
26088
26478
  //#endregion
26479
+ //#region src/plugin/utils/get-call-method-name.ts
26480
+ /**
26481
+ * Returns the static method name of a call's callee when it's a
26482
+ * non-computed MemberExpression (`obj.method` → `"method"`), or
26483
+ * `null` otherwise. Used by `no-pass-data-to-parent` and
26484
+ * `no-pass-live-state-to-parent` to match the receiver-bound
26485
+ * method-call shape against an allow/block list.
26486
+ */
26487
+ const getCallMethodName = (callee) => {
26488
+ if (isNodeOfType(callee, "MemberExpression") && !callee.computed && isNodeOfType(callee.property, "Identifier")) return callee.property.name;
26489
+ return null;
26490
+ };
26491
+ //#endregion
26089
26492
  //#region src/plugin/rules/state-and-effects/no-create-context-in-render.ts
26090
26493
  const MESSAGE$31 = "createContext() builds a new context every render, so every consumer gets cut off & resets.";
26091
26494
  const CONTEXT_MODULES = [
@@ -26093,23 +26496,35 @@ const CONTEXT_MODULES = [
26093
26496
  "use-context-selector",
26094
26497
  "react-tracked"
26095
26498
  ];
26096
- const isCreateContextCallee = (callee) => {
26499
+ const getSupportedContextImportSource = (symbol) => {
26500
+ if (symbol?.kind !== "import") return null;
26501
+ const importDeclaration = symbol.declarationNode.parent;
26502
+ if (!importDeclaration || !isNodeOfType(importDeclaration, "ImportDeclaration") || typeof importDeclaration.source.value !== "string" || !CONTEXT_MODULES.includes(importDeclaration.source.value)) return null;
26503
+ return importDeclaration.source.value;
26504
+ };
26505
+ const isSupportedNamespaceSymbol = (symbol) => getSupportedContextImportSource(symbol) !== null && Boolean(symbol && (isNodeOfType(symbol.declarationNode, "ImportDefaultSpecifier") || isNodeOfType(symbol.declarationNode, "ImportNamespaceSpecifier")));
26506
+ const isDestructuredCreateContextBinding = (identifier, scopes) => {
26507
+ if (!isNodeOfType(identifier, "Identifier")) return false;
26508
+ const symbol = scopes.symbolFor(identifier);
26509
+ if (symbol?.kind !== "const" || !symbol.initializer || !isNodeOfType(symbol.declarationNode, "VariableDeclarator") || !isNodeOfType(symbol.declarationNode.id, "ObjectPattern")) return false;
26510
+ const property = symbol.bindingIdentifier.parent;
26511
+ if (!property || !isNodeOfType(property, "Property") || getStaticPropertyKeyName(property, { allowComputedString: true }) !== "createContext") return false;
26512
+ const initializer = stripParenExpression(symbol.initializer);
26513
+ return isNodeOfType(initializer, "Identifier") && isSupportedNamespaceSymbol(resolveConstIdentifierAlias(initializer, scopes));
26514
+ };
26515
+ const isCreateContextCall = (node, scopes) => {
26516
+ const callee = stripParenExpression(node.callee);
26097
26517
  if (isNodeOfType(callee, "Identifier")) {
26098
- const binding = getImportBindingForName(callee, callee.name);
26099
- return binding !== null && binding.exportedName === "createContext" && CONTEXT_MODULES.includes(binding.source);
26518
+ if (isDestructuredCreateContextBinding(callee, scopes)) return true;
26519
+ const symbol = resolveConstIdentifierAlias(callee, scopes);
26520
+ return getSupportedContextImportSource(symbol) !== null && Boolean(symbol && getImportedName(symbol.declarationNode) === "createContext");
26100
26521
  }
26101
- if (isNodeOfType(callee, "MemberExpression") && !callee.computed) {
26102
- const namespaceIdentifier = callee.object;
26103
- const propertyIdentifier = callee.property;
26104
- if (!isNodeOfType(namespaceIdentifier, "Identifier")) return false;
26105
- if (!isNodeOfType(propertyIdentifier, "Identifier")) return false;
26106
- if (propertyIdentifier.name !== "createContext") return false;
26107
- const namespaceName = namespaceIdentifier.name;
26108
- if (isCanonicalReactNamespaceName(namespaceName)) return true;
26109
- const importSource = getImportSourceForName(namespaceIdentifier, namespaceName);
26110
- return importSource !== null && CONTEXT_MODULES.includes(importSource);
26111
- }
26112
- return false;
26522
+ if (!isNodeOfType(callee, "MemberExpression")) return false;
26523
+ if ((getCallMethodName(callee) ?? (callee.computed && isNodeOfType(callee.property, "Literal") && typeof callee.property.value === "string" ? callee.property.value : null)) !== "createContext") return false;
26524
+ const receiver = stripParenExpression(callee.object);
26525
+ if (!isNodeOfType(receiver, "Identifier")) return false;
26526
+ if (receiver.name === "React" && scopes.isGlobalReference(receiver)) return true;
26527
+ return isSupportedNamespaceSymbol(resolveConstIdentifierAlias(receiver, scopes));
26113
26528
  };
26114
26529
  const noCreateContextInRender = defineRule({
26115
26530
  id: "no-create-context-in-render",
@@ -26118,7 +26533,7 @@ const noCreateContextInRender = defineRule({
26118
26533
  category: "Correctness",
26119
26534
  recommendation: "Move `createContext(...)` outside the component, to the top level of the file, so it stays the same on every render.",
26120
26535
  create: (context) => ({ CallExpression(node) {
26121
- if (!isCreateContextCallee(node.callee)) return;
26536
+ if (!isCreateContextCall(node, context.scopes)) return;
26122
26537
  const componentOrHookName = enclosingComponentOrHookName(node);
26123
26538
  if (!componentOrHookName) return;
26124
26539
  context.report({
@@ -28068,20 +28483,31 @@ const noDocumentStartViewTransition = defineRule({
28068
28483
  } })
28069
28484
  });
28070
28485
  //#endregion
28486
+ //#region src/plugin/utils/get-static-property-name.ts
28487
+ const getStaticPropertyName = (memberExpression) => {
28488
+ const property = memberExpression.property;
28489
+ if (!memberExpression.computed && isNodeOfType(property, "Identifier")) return property.name;
28490
+ if (memberExpression.computed && isNodeOfType(property, "Literal")) return typeof property.value === "string" ? property.value : null;
28491
+ if (memberExpression.computed && isNodeOfType(property, "TemplateLiteral") && property.expressions.length === 0) return property.quasis[0]?.value.cooked ?? property.quasis[0]?.value.raw ?? null;
28492
+ return null;
28493
+ };
28494
+ //#endregion
28071
28495
  //#region src/plugin/rules/js-performance/no-document-write.ts
28072
28496
  const MESSAGE$24 = "`document.write()` blocks parsing, is ignored (or wipes the page) after load, and is flagged by browsers as a performance anti-pattern. Build DOM nodes or set `innerHTML`/`textContent` on a target element instead.";
28073
28497
  const WRITE_METHODS = new Set(["write", "writeln"]);
28498
+ const isGlobalDocumentReference = (node, context) => node.name === "document" && context.scopes.isGlobalReference(node);
28074
28499
  const noDocumentWrite = defineRule({
28075
28500
  id: "no-document-write",
28076
28501
  title: "document.write/writeln",
28077
28502
  severity: "warn",
28078
28503
  recommendation: "Don't use `document.write()`/`document.writeln()`. Append DOM nodes or set `innerHTML`/`textContent` on a specific element instead.",
28079
28504
  create: (context) => ({ CallExpression(node) {
28080
- const callee = node.callee;
28081
- if (!isNodeOfType(callee, "MemberExpression") || callee.computed) return;
28505
+ const callee = stripParenExpression(node.callee);
28506
+ if (!isNodeOfType(callee, "MemberExpression")) return;
28082
28507
  const receiver = stripParenExpression(callee.object);
28083
- if (!isNodeOfType(receiver, "Identifier") || receiver.name !== "document") return;
28084
- if (!isNodeOfType(callee.property, "Identifier") || !WRITE_METHODS.has(callee.property.name)) return;
28508
+ if (!isNodeOfType(receiver, "Identifier") || !isGlobalDocumentReference(receiver, context)) return;
28509
+ const methodName = getStaticPropertyName(callee);
28510
+ if (methodName === null || !WRITE_METHODS.has(methodName)) return;
28085
28511
  context.report({
28086
28512
  node,
28087
28513
  message: MESSAGE$24
@@ -28888,6 +29314,14 @@ const noEffectWithFreshDeps = defineRule({
28888
29314
  //#endregion
28889
29315
  //#region src/plugin/rules/security/no-eval.ts
28890
29316
  const SANDBOX_SURFACE_PATH_PATTERN = /(?:^|[/-])sandbox(?:$|[/-])|(?:^|\/)[\w.]+-sandbox(?:\/|\.[cm]?[jt]sx?$)/i;
29317
+ const getExecutableGlobalName = (node, context) => {
29318
+ const expression = stripParenExpression(node);
29319
+ if (isNodeOfType(expression, "Identifier")) return context.scopes.isGlobalReference(expression) ? expression.name : null;
29320
+ if (!isNodeOfType(expression, "MemberExpression")) return null;
29321
+ const receiver = stripParenExpression(expression.object);
29322
+ if (!isNodeOfType(receiver, "Identifier") || receiver.name !== "globalThis" || !context.scopes.isGlobalReference(receiver)) return null;
29323
+ return getStaticPropertyName(expression);
29324
+ };
28891
29325
  const noEval = defineRule({
28892
29326
  id: "no-eval",
28893
29327
  title: "eval() runs untrusted code strings",
@@ -28897,20 +29331,21 @@ const noEval = defineRule({
28897
29331
  if (SANDBOX_SURFACE_PATH_PATTERN.test(normalizeFilename(context.filename ?? ""))) return {};
28898
29332
  return {
28899
29333
  CallExpression(node) {
28900
- if (isNodeOfType(node.callee, "Identifier") && node.callee.name === "eval") {
29334
+ const executableGlobalName = getExecutableGlobalName(node.callee, context);
29335
+ if (executableGlobalName === "eval") {
28901
29336
  context.report({
28902
29337
  node,
28903
29338
  message: "eval() is a code-injection vulnerability: it runs any string as code."
28904
29339
  });
28905
29340
  return;
28906
29341
  }
28907
- if (isNodeOfType(node.callee, "Identifier") && (node.callee.name === "setTimeout" || node.callee.name === "setInterval") && isNodeOfType(node.arguments?.[0], "Literal") && typeof node.arguments[0].value === "string") context.report({
29342
+ if ((executableGlobalName === "setTimeout" || executableGlobalName === "setInterval") && isNodeOfType(node.arguments?.[0], "Literal") && typeof node.arguments[0].value === "string") context.report({
28908
29343
  node,
28909
- message: `Passing a string to ${node.callee.name}() is a code-injection vulnerability, since it runs that string as code.`
29344
+ message: `Passing a string to ${executableGlobalName}() is a code-injection vulnerability, since it runs that string as code.`
28910
29345
  });
28911
29346
  },
28912
29347
  NewExpression(node) {
28913
- if (isNodeOfType(node.callee, "Identifier") && node.callee.name === "Function") {
29348
+ if (getExecutableGlobalName(node.callee, context) === "Function") {
28914
29349
  const onlyArgument = node.arguments.length === 1 ? node.arguments[0] : void 0;
28915
29350
  if (isNodeOfType(onlyArgument, "Literal") && typeof onlyArgument.value === "string" && onlyArgument.value.trim() === "return this") return;
28916
29351
  context.report({
@@ -30169,7 +30604,11 @@ const noFetchInEffect = defineRule({
30169
30604
  severity: "warn",
30170
30605
  recommendation: "Use a data-fetching layer or Server Component so fetches do not race, double-fire, or leak from `useEffect`.",
30171
30606
  create: (context) => ({ CallExpression(node) {
30172
- if (!isHookCall$2(node, EFFECT_HOOK_NAMES$1)) return;
30607
+ if (!isReactApiCall(node, EFFECT_HOOK_NAMES$1, context.scopes, {
30608
+ allowGlobalReactNamespace: true,
30609
+ allowUnboundBareCalls: true,
30610
+ resolveNamedAliases: true
30611
+ })) return;
30173
30612
  const callback = getEffectCallback(node);
30174
30613
  if (!callback) return;
30175
30614
  const analysisFunctions = collectEffectAnalysisFunctions(callback, context);
@@ -34379,19 +34818,6 @@ const DATA_SINK_METHOD_NAMES = new Set([
34379
34818
  "deserialize"
34380
34819
  ]);
34381
34820
  //#endregion
34382
- //#region src/plugin/utils/get-call-method-name.ts
34383
- /**
34384
- * Returns the static method name of a call's callee when it's a
34385
- * non-computed MemberExpression (`obj.method` → `"method"`), or
34386
- * `null` otherwise. Used by `no-pass-data-to-parent` and
34387
- * `no-pass-live-state-to-parent` to match the receiver-bound
34388
- * method-call shape against an allow/block list.
34389
- */
34390
- const getCallMethodName = (callee) => {
34391
- if (isNodeOfType(callee, "MemberExpression") && !callee.computed && isNodeOfType(callee.property, "Identifier")) return callee.property.name;
34392
- return null;
34393
- };
34394
- //#endregion
34395
34821
  //#region src/plugin/rules/state-and-effects/no-pass-data-to-parent.ts
34396
34822
  const isUseStateIdentifier = (identifier) => {
34397
34823
  if (!isNodeOfType(identifier, "Identifier")) return false;
@@ -35017,6 +35443,30 @@ const guardsRenderShape = (comparison) => {
35017
35443
  }
35018
35444
  return false;
35019
35445
  };
35446
+ const isPropsChildrenLength = (node, scopes) => {
35447
+ const unwrappedNode = stripParenExpression(node);
35448
+ return isNodeOfType(unwrappedNode, "MemberExpression") && !unwrappedNode.computed && isNodeOfType(unwrappedNode.property, "Identifier") && unwrappedNode.property.name === "length" && resolvesToPropsChildren(stripParenExpression(unwrappedNode.object), scopes);
35449
+ };
35450
+ const isLargeTextLengthComparison = (node, scopes) => {
35451
+ const unwrappedNode = stripParenExpression(node);
35452
+ if (!isNodeOfType(unwrappedNode, "BinaryExpression")) return false;
35453
+ const leftIsLength = isPropsChildrenLength(unwrappedNode.left, scopes);
35454
+ const rightIsLength = isPropsChildrenLength(unwrappedNode.right, scopes);
35455
+ const thresholdNode = leftIsLength ? unwrappedNode.right : unwrappedNode.left;
35456
+ if (!leftIsLength && !rightIsLength || !isNodeOfType(thresholdNode, "Literal")) return false;
35457
+ if (typeof thresholdNode.value !== "number" || thresholdNode.value < 1e3) return false;
35458
+ return leftIsLength ? unwrappedNode.operator === ">" || unwrappedNode.operator === ">=" : unwrappedNode.operator === "<" || unwrappedNode.operator === "<=";
35459
+ };
35460
+ const isLargeStringOptimizationGuard = (comparison, scopes) => {
35461
+ let current = findTransparentExpressionRoot(comparison);
35462
+ while (current.parent) {
35463
+ const parent = current.parent;
35464
+ if (!isNodeOfType(parent, "LogicalExpression") || parent.operator !== "&&") return false;
35465
+ if (isLargeTextLengthComparison(parent.left === current ? parent.right : parent.left, scopes)) return true;
35466
+ current = findTransparentExpressionRoot(parent);
35467
+ }
35468
+ return false;
35469
+ };
35020
35470
  const noPolymorphicChildren = defineRule({
35021
35471
  id: "no-polymorphic-children",
35022
35472
  title: "Children type checked at runtime",
@@ -35030,6 +35480,7 @@ const noPolymorphicChildren = defineRule({
35030
35480
  const isStringLiteral = (operand) => isNodeOfType(operand, "Literal") && operand.value === "string";
35031
35481
  if (!isStringLiteral(node.left) && !isStringLiteral(node.right)) return;
35032
35482
  if (!guardsRenderShape(node)) return;
35483
+ if (isLargeStringOptimizationGuard(node, context.scopes)) return;
35033
35484
  context.report({
35034
35485
  node,
35035
35486
  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."
@@ -54455,12 +54906,6 @@ const webhookSignatureRisk = defineRule({
54455
54906
  //#region src/plugin/rules/zod/utils/zod-ast.ts
54456
54907
  const ZOD_MODULE = "zod";
54457
54908
  const ZOD_MODULE_SOURCES = [ZOD_MODULE];
54458
- const getStaticPropertyName = (member) => {
54459
- const property = member.property;
54460
- if (!member.computed && isNodeOfType(property, "Identifier")) return property.name;
54461
- if (member.computed && isNodeOfType(property, "Literal") && typeof property.value === "string") return property.value;
54462
- return null;
54463
- };
54464
54909
  const importInfoCache = /* @__PURE__ */ new WeakMap();
54465
54910
  const getImportInfoForIdentifier = (identifier) => {
54466
54911
  if (importInfoCache.has(identifier)) return importInfoCache.get(identifier) ?? null;
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.3aef25a",
4
4
  "description": "React Doctor rules for oxlint.",
5
5
  "keywords": [
6
6
  "accessibility",