oxlint-plugin-react-doctor 0.5.5 → 0.5.6-dev.8908f98

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 (3) hide show
  1. package/dist/index.d.ts +1293 -0
  2. package/dist/index.js +993 -144
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -323,9 +323,18 @@ const BROWSER_ARTIFACT_PATH_PATTERNS = [
323
323
  const AGENT_TOOL_DANGEROUS_CAPABILITY_PATTERN = /\b(?:exec|execSync|spawn|child_process|eval|new Function|vm\.run|readFile|writeFile|fs\.read|fs\.write|fetch|axios|http\.request|sandbox|runCode|executeCode)\b/;
324
324
  //#endregion
325
325
  //#region src/plugin/rules/security-scan/utils/is-browser-artifact-path.ts
326
- const isServerOnlyBuildArtifactPath = (relativePath) => /(?:^|\/)(?:\.next\/server|\.output\/server)\//.test(relativePath);
326
+ const SERVER_BUILD_ROOT_SEGMENTS = new Set([".next", ".output"]);
327
+ const isNonShippedBuildArtifactPath = (relativePath) => {
328
+ const segments = relativePath.split("/");
329
+ for (let index = 0; index < segments.length; index += 1) {
330
+ if (!SERVER_BUILD_ROOT_SEGMENTS.has(segments[index])) continue;
331
+ if (segments[index] === ".next" && segments[index + 1] === "dev") return true;
332
+ if (segments[index + 1] === "server" && index + 2 < segments.length) return true;
333
+ }
334
+ return false;
335
+ };
327
336
  const isBrowserArtifactPath = (relativePath, isGeneratedBundle) => {
328
- if (isServerOnlyBuildArtifactPath(relativePath)) return false;
337
+ if (isNonShippedBuildArtifactPath(relativePath)) return false;
329
338
  if (isGeneratedBundle) return true;
330
339
  if (relativePath.endsWith(".map")) return true;
331
340
  return BROWSER_ARTIFACT_PATH_PATTERNS.some((pattern) => pattern.test(relativePath));
@@ -1108,6 +1117,11 @@ const getImportedNameFromModule = (contextNode, localIdentifierName, moduleSourc
1108
1117
  if (info.source !== moduleSource) return null;
1109
1118
  return info.imported;
1110
1119
  };
1120
+ const getImportSourceForName = (contextNode, localIdentifierName) => {
1121
+ const lookup = getImportLookup(contextNode);
1122
+ if (!lookup) return null;
1123
+ return lookup.get(localIdentifierName)?.source ?? null;
1124
+ };
1111
1125
  //#endregion
1112
1126
  //#region src/plugin/utils/find-variable-initializer.ts
1113
1127
  const FUNCTION_LIKE_TYPES$1 = new Set([
@@ -1847,7 +1861,7 @@ const anchorAmbiguousText = defineRule({
1847
1861
  });
1848
1862
  //#endregion
1849
1863
  //#region src/plugin/rules/a11y/anchor-has-content.ts
1850
- const MESSAGE$51 = "Blind users can't follow this link because screen readers announce nothing, so add visible text, `aria-label`, or `aria-labelledby`.";
1864
+ const MESSAGE$57 = "Blind users can't follow this link because screen readers announce nothing, so add visible text, `aria-label`, or `aria-labelledby`.";
1851
1865
  const anchorHasContent = defineRule({
1852
1866
  id: "anchor-has-content",
1853
1867
  title: "Anchor has no content",
@@ -1863,7 +1877,7 @@ const anchorHasContent = defineRule({
1863
1877
  for (const attribute of ["title", "aria-label"]) if (hasJsxPropIgnoreCase(opening.attributes, attribute)) return;
1864
1878
  context.report({
1865
1879
  node: opening.name,
1866
- message: MESSAGE$51
1880
+ message: MESSAGE$57
1867
1881
  });
1868
1882
  } })
1869
1883
  });
@@ -2257,7 +2271,7 @@ const parseJsxValue = (value) => {
2257
2271
  };
2258
2272
  //#endregion
2259
2273
  //#region src/plugin/rules/a11y/aria-activedescendant-has-tabindex.ts
2260
- const MESSAGE$50 = "Keyboard users can't focus this element with `aria-activedescendant` because it isn't tabbable, so add `tabIndex={0}`.";
2274
+ const MESSAGE$56 = "Keyboard users can't focus this element with `aria-activedescendant` because it isn't tabbable, so add `tabIndex={0}`.";
2261
2275
  const ariaActivedescendantHasTabindex = defineRule({
2262
2276
  id: "aria-activedescendant-has-tabindex",
2263
2277
  title: "aria-activedescendant missing tabindex",
@@ -2275,14 +2289,14 @@ const ariaActivedescendantHasTabindex = defineRule({
2275
2289
  if (tabIndexValue === null || tabIndexValue >= -1) return;
2276
2290
  context.report({
2277
2291
  node: node.name,
2278
- message: MESSAGE$50
2292
+ message: MESSAGE$56
2279
2293
  });
2280
2294
  return;
2281
2295
  }
2282
2296
  if (isInteractiveElement(tag, node)) return;
2283
2297
  context.report({
2284
2298
  node: node.name,
2285
- message: MESSAGE$50
2299
+ message: MESSAGE$56
2286
2300
  });
2287
2301
  } })
2288
2302
  });
@@ -4187,6 +4201,58 @@ const asyncParallel = defineRule({
4187
4201
  }
4188
4202
  });
4189
4203
  //#endregion
4204
+ //#region src/plugin/rules/security/auth-token-in-web-storage.ts
4205
+ const MESSAGE$55 = "Storing an auth token in `localStorage`/`sessionStorage` exposes it to any XSS on the page: JavaScript can read web storage and exfiltrate the token. Keep tokens in an `HttpOnly`, `Secure`, `SameSite` cookie instead.";
4206
+ const STORAGE_NAMES = new Set(["localStorage", "sessionStorage"]);
4207
+ const STORAGE_GLOBALS = new Set([
4208
+ "window",
4209
+ "globalThis",
4210
+ "self"
4211
+ ]);
4212
+ const SENSITIVE_KEY_PATTERN = /token|jwt|secret|password|passwd|credential|api[-_]?key|bearer|private[-_]?key/i;
4213
+ const isWebStorageObject = (node) => {
4214
+ if (isNodeOfType(node, "Identifier")) return STORAGE_NAMES.has(node.name);
4215
+ if (isNodeOfType(node, "MemberExpression") && !node.computed && isNodeOfType(node.object, "Identifier") && STORAGE_GLOBALS.has(node.object.name) && isNodeOfType(node.property, "Identifier")) return STORAGE_NAMES.has(node.property.name);
4216
+ return false;
4217
+ };
4218
+ const staticMemberName = (member) => {
4219
+ if (!member.computed && isNodeOfType(member.property, "Identifier")) return member.property.name;
4220
+ if (member.computed && isNodeOfType(member.property, "Literal") && typeof member.property.value === "string") return member.property.value;
4221
+ return null;
4222
+ };
4223
+ const authTokenInWebStorage = defineRule({
4224
+ id: "auth-token-in-web-storage",
4225
+ title: "Auth token in web storage",
4226
+ severity: "warn",
4227
+ recommendation: "Don't persist auth tokens (JWTs, access/refresh tokens, secrets) in `localStorage`/`sessionStorage`; they're readable by any XSS. Use an `HttpOnly` cookie set by the server.",
4228
+ create: (context) => ({
4229
+ CallExpression(node) {
4230
+ const callee = node.callee;
4231
+ if (!isNodeOfType(callee, "MemberExpression") || callee.computed) return;
4232
+ if (!isNodeOfType(callee.property, "Identifier") || callee.property.name !== "setItem") return;
4233
+ if (!isWebStorageObject(callee.object)) return;
4234
+ const keyArgument = node.arguments?.[0];
4235
+ if (!keyArgument || !isNodeOfType(keyArgument, "Literal") || typeof keyArgument.value !== "string") return;
4236
+ if (!SENSITIVE_KEY_PATTERN.test(keyArgument.value)) return;
4237
+ context.report({
4238
+ node,
4239
+ message: MESSAGE$55
4240
+ });
4241
+ },
4242
+ AssignmentExpression(node) {
4243
+ const target = node.left;
4244
+ if (!isNodeOfType(target, "MemberExpression")) return;
4245
+ if (!isWebStorageObject(target.object)) return;
4246
+ const propertyName = staticMemberName(target);
4247
+ if (!propertyName || !SENSITIVE_KEY_PATTERN.test(propertyName)) return;
4248
+ context.report({
4249
+ node: target,
4250
+ message: MESSAGE$55
4251
+ });
4252
+ }
4253
+ })
4254
+ });
4255
+ //#endregion
4190
4256
  //#region src/plugin/rules/a11y/autocomplete-valid.ts
4191
4257
  const buildMessage$25 = (value) => `Users who rely on autofill can't fill this field because \`${value}\` isn't a known token, so use a valid \`autoComplete\` token.`;
4192
4258
  const AUTOFILL_TOKENS = new Set([
@@ -4558,7 +4624,7 @@ const isPureEventBlockerHandler = (attribute) => {
4558
4624
  //#endregion
4559
4625
  //#region src/plugin/rules/a11y/click-events-have-key-events.ts
4560
4626
  const PRESENTATION_ROLES$1 = new Set(["presentation", "none"]);
4561
- const MESSAGE$49 = "Keyboard users can't trigger this click handler because there's no keyboard one, so add `onKeyUp`, `onKeyDown`, or `onKeyPress`.";
4627
+ const MESSAGE$54 = "Keyboard users can't trigger this click handler because there's no keyboard one, so add `onKeyUp`, `onKeyDown`, or `onKeyPress`.";
4562
4628
  const KEY_HANDLERS = [
4563
4629
  "onKeyUp",
4564
4630
  "onKeyDown",
@@ -4590,7 +4656,7 @@ const clickEventsHaveKeyEvents = defineRule({
4590
4656
  if (KEY_HANDLERS.some((handler) => hasJsxPropIgnoreCase(node.attributes, handler))) return;
4591
4657
  context.report({
4592
4658
  node: node.name,
4593
- message: MESSAGE$49
4659
+ message: MESSAGE$54
4594
4660
  });
4595
4661
  } };
4596
4662
  }
@@ -4705,7 +4771,7 @@ const isReactComponentName = (name) => {
4705
4771
  };
4706
4772
  //#endregion
4707
4773
  //#region src/plugin/rules/a11y/control-has-associated-label.ts
4708
- const MESSAGE$48 = "Blind users can't tell what this control does because screen readers find no label, so add visible text, `aria-label`, or `aria-labelledby`.";
4774
+ const MESSAGE$53 = "Blind users can't tell what this control does because screen readers find no label, so add visible text, `aria-label`, or `aria-labelledby`.";
4709
4775
  const DEFAULT_IGNORE_ELEMENTS = ["link", "canvas"];
4710
4776
  const DEFAULT_LABELLING_PROPS = [
4711
4777
  "alt",
@@ -4866,7 +4932,7 @@ const controlHasAssociatedLabel = defineRule({
4866
4932
  for (const child of node.children) if (checkChildForLabel(child, 1, checkContext)) return;
4867
4933
  context.report({
4868
4934
  node: opening,
4869
- message: MESSAGE$48
4935
+ message: MESSAGE$53
4870
4936
  });
4871
4937
  } };
4872
4938
  }
@@ -5292,6 +5358,38 @@ const noVagueButtonLabel = defineRule({
5292
5358
  } })
5293
5359
  });
5294
5360
  //#endregion
5361
+ //#region src/plugin/utils/has-jsx-spread-attribute.ts
5362
+ const hasJsxSpreadAttribute$1 = (attributes) => attributes.some((attribute) => isNodeOfType(attribute, "JSXSpreadAttribute"));
5363
+ //#endregion
5364
+ //#region src/plugin/rules/a11y/dialog-has-accessible-name.ts
5365
+ const MESSAGE$52 = "This dialog has no accessible name, so screen readers announce it as just “dialog.” Add `aria-label` or point `aria-labelledby` at its heading.";
5366
+ const DIALOG_ROLES = new Set(["dialog", "alertdialog"]);
5367
+ const NAME_PROVIDING_ATTRIBUTES = [
5368
+ "aria-label",
5369
+ "aria-labelledby",
5370
+ "title"
5371
+ ];
5372
+ const dialogHasAccessibleName = defineRule({
5373
+ id: "dialog-has-accessible-name",
5374
+ title: "Dialog without accessible name",
5375
+ severity: "warn",
5376
+ recommendation: "Give every `<dialog>` / `role=\"dialog\"` an accessible name with `aria-label` or `aria-labelledby` (referencing the dialog's title element).",
5377
+ create: (context) => ({ JSXOpeningElement(node) {
5378
+ if (!isNodeOfType(node.name, "JSXIdentifier")) return;
5379
+ const tagName = node.name.name;
5380
+ if (tagName[0] !== tagName[0]?.toLowerCase()) return;
5381
+ const roleAttribute = hasJsxPropIgnoreCase(node.attributes, "role");
5382
+ const roleValue = roleAttribute ? getJsxPropStringValue(roleAttribute) : null;
5383
+ if (!(tagName === "dialog" || roleValue !== null && DIALOG_ROLES.has(roleValue))) return;
5384
+ if (hasJsxSpreadAttribute$1(node.attributes)) return;
5385
+ if (NAME_PROVIDING_ATTRIBUTES.some((attribute) => hasJsxPropIgnoreCase(node.attributes, attribute))) return;
5386
+ context.report({
5387
+ node: node.name,
5388
+ message: MESSAGE$52
5389
+ });
5390
+ } })
5391
+ });
5392
+ //#endregion
5295
5393
  //#region src/plugin/utils/is-es5-component.ts
5296
5394
  const PRAGMA$2 = "React";
5297
5395
  const CREATE_CLASS = "createReactClass";
@@ -5326,7 +5424,7 @@ const isEs6Component = (node) => {
5326
5424
  };
5327
5425
  //#endregion
5328
5426
  //#region src/plugin/rules/react-builtins/display-name.ts
5329
- const MESSAGE$47 = "This component shows up as Anonymous in React DevTools because it has no `displayName`.";
5427
+ const MESSAGE$51 = "This component shows up as Anonymous in React DevTools because it has no `displayName`.";
5330
5428
  const DEFAULT_ADDITIONAL_HOCS = [
5331
5429
  "observer",
5332
5430
  "lazy",
@@ -5529,7 +5627,7 @@ const displayName = defineRule({
5529
5627
  const reportAt = (node) => {
5530
5628
  context.report({
5531
5629
  node,
5532
- message: MESSAGE$47
5630
+ message: MESSAGE$51
5533
5631
  });
5534
5632
  };
5535
5633
  return {
@@ -7677,7 +7775,7 @@ const forbidElements = defineRule({
7677
7775
  });
7678
7776
  //#endregion
7679
7777
  //#region src/plugin/rules/react-builtins/forward-ref-uses-ref.ts
7680
- const MESSAGE$46 = "The parent can't reach this component's node because the `forwardRef` wrapper ignores `ref`.";
7778
+ const MESSAGE$50 = "The parent can't reach this component's node because the `forwardRef` wrapper ignores `ref`.";
7681
7779
  const forwardRefUsesRef = defineRule({
7682
7780
  id: "forward-ref-uses-ref",
7683
7781
  title: "forwardRef without ref parameter",
@@ -7697,7 +7795,7 @@ const forwardRefUsesRef = defineRule({
7697
7795
  if (isNodeOfType(onlyParam, "RestElement")) return;
7698
7796
  context.report({
7699
7797
  node: inner,
7700
- message: MESSAGE$46
7798
+ message: MESSAGE$50
7701
7799
  });
7702
7800
  } })
7703
7801
  });
@@ -7734,7 +7832,7 @@ const gitProviderUrlInjectionRisk = defineRule({
7734
7832
  });
7735
7833
  //#endregion
7736
7834
  //#region src/plugin/rules/a11y/heading-has-content.ts
7737
- const MESSAGE$45 = "Blind users can't use this heading to navigate because screen readers skip it empty, so add text, `aria-label`, or `aria-labelledby`.";
7835
+ const MESSAGE$49 = "Blind users can't use this heading to navigate because screen readers skip it empty, so add text, `aria-label`, or `aria-labelledby`.";
7738
7836
  const DEFAULT_HEADING_TAGS = [
7739
7837
  "h1",
7740
7838
  "h2",
@@ -7767,7 +7865,7 @@ const headingHasContent = defineRule({
7767
7865
  if (isHiddenFromScreenReader(node, context.settings)) return;
7768
7866
  context.report({
7769
7867
  node,
7770
- message: MESSAGE$45
7868
+ message: MESSAGE$49
7771
7869
  });
7772
7870
  } };
7773
7871
  }
@@ -7905,7 +8003,7 @@ const hooksNoNanInDeps = defineRule({
7905
8003
  });
7906
8004
  //#endregion
7907
8005
  //#region src/plugin/rules/a11y/html-has-lang.ts
7908
- const MESSAGE$44 = "Screen readers may mispronounce this page because it doesn't declare a language, so add a `lang` attribute like `en`.";
8006
+ const MESSAGE$48 = "Screen readers may mispronounce this page because it doesn't declare a language, so add a `lang` attribute like `en`.";
7909
8007
  const resolveSettings$38 = (settings) => {
7910
8008
  const reactDoctor = settings?.["react-doctor"];
7911
8009
  return { htmlTags: (typeof reactDoctor === "object" && reactDoctor !== null ? reactDoctor.htmlHasLang ?? {} : {}).htmlTags ?? ["html"] };
@@ -7953,7 +8051,7 @@ const htmlHasLang = defineRule({
7953
8051
  if (!lang) {
7954
8052
  context.report({
7955
8053
  node: node.name,
7956
- message: MESSAGE$44
8054
+ message: MESSAGE$48
7957
8055
  });
7958
8056
  return;
7959
8057
  }
@@ -7961,13 +8059,13 @@ const htmlHasLang = defineRule({
7961
8059
  if (verdict === "missing" || verdict === "empty") {
7962
8060
  context.report({
7963
8061
  node: lang,
7964
- message: MESSAGE$44
8062
+ message: MESSAGE$48
7965
8063
  });
7966
8064
  return;
7967
8065
  }
7968
8066
  if (hasSpread && !lang) context.report({
7969
8067
  node: node.name,
7970
- message: MESSAGE$44
8068
+ message: MESSAGE$48
7971
8069
  });
7972
8070
  } };
7973
8071
  }
@@ -8181,7 +8279,7 @@ const htmlNoNestedInteractive = defineRule({
8181
8279
  });
8182
8280
  //#endregion
8183
8281
  //#region src/plugin/rules/a11y/iframe-has-title.ts
8184
- const MESSAGE$43 = "Screen reader users cannot identify this `<iframe>` because it has no title. Add a `title` that describes its content.";
8282
+ const MESSAGE$47 = "Screen reader users cannot identify this `<iframe>` because it has no title. Add a `title` that describes its content.";
8185
8283
  const evaluateTitleValue = (value) => {
8186
8284
  if (!value) return "missing";
8187
8285
  if (isNodeOfType(value, "Literal")) {
@@ -8221,14 +8319,14 @@ const iframeHasTitle = defineRule({
8221
8319
  if (!titleAttr) {
8222
8320
  if (hasSpread || tag === "iframe") context.report({
8223
8321
  node: node.name,
8224
- message: MESSAGE$43
8322
+ message: MESSAGE$47
8225
8323
  });
8226
8324
  return;
8227
8325
  }
8228
8326
  const verdict = evaluateTitleValue(titleAttr.value);
8229
8327
  if (verdict === "missing" || verdict === "empty") context.report({
8230
8328
  node: titleAttr,
8231
- message: MESSAGE$43
8329
+ message: MESSAGE$47
8232
8330
  });
8233
8331
  } })
8234
8332
  });
@@ -8332,7 +8430,7 @@ const iframeMissingSandbox = defineRule({
8332
8430
  });
8333
8431
  //#endregion
8334
8432
  //#region src/plugin/rules/a11y/img-redundant-alt.ts
8335
- const MESSAGE$42 = "Screen reader users hear \"image\" or \"photo\" twice because they already announce it, so describe what the image shows instead.";
8433
+ const MESSAGE$46 = "Screen reader users hear \"image\" or \"photo\" twice because they already announce it, so describe what the image shows instead.";
8336
8434
  const DEFAULT_COMPONENTS = ["img"];
8337
8435
  const DEFAULT_REDUNDANT_WORDS = [
8338
8436
  "image",
@@ -8397,7 +8495,7 @@ const imgRedundantAlt = defineRule({
8397
8495
  if (!altAttribute) return;
8398
8496
  if (altValueRedundant(altAttribute, settings.words)) context.report({
8399
8497
  node: altAttribute,
8400
- message: MESSAGE$42
8498
+ message: MESSAGE$46
8401
8499
  });
8402
8500
  } };
8403
8501
  }
@@ -8495,6 +8593,136 @@ const insecureCryptoRisk = defineRule({
8495
8593
  }
8496
8594
  });
8497
8595
  //#endregion
8596
+ //#region src/plugin/rules/security-scan/utils/find-matching-bracket.ts
8597
+ const findMatchingBracket = (content, openIndex) => {
8598
+ const open = content[openIndex];
8599
+ const close = open === "(" ? ")" : open === "{" ? "}" : open === "[" ? "]" : "";
8600
+ if (close === "") return -1;
8601
+ let depth = 0;
8602
+ let stringDelimiter = null;
8603
+ for (let index = openIndex; index < content.length; index += 1) {
8604
+ const character = content[index];
8605
+ if (stringDelimiter !== null) {
8606
+ if (character === "\\") index += 1;
8607
+ else if (character === stringDelimiter) stringDelimiter = null;
8608
+ continue;
8609
+ }
8610
+ if (character === "\"" || character === "'" || character === "`") stringDelimiter = character;
8611
+ else if (character === open) depth += 1;
8612
+ else if (character === close) {
8613
+ depth -= 1;
8614
+ if (depth === 0) return index;
8615
+ }
8616
+ }
8617
+ return -1;
8618
+ };
8619
+ //#endregion
8620
+ //#region src/plugin/rules/security-scan/insecure-session-cookie.ts
8621
+ const AUTH_COOKIE_NAME_TOKEN = `(?<![A-Za-z0-9])(?:session|sess|sid|connect\\.sid|auth|jwt|access[_-]?token|refresh[_-]?token|id[_-]?token)(?![A-Za-z0-9])`;
8622
+ const AUTH_COOKIE_NAME_LITERAL = `[\`"'][^\`"']*?${AUTH_COOKIE_NAME_TOKEN}[^\`"']*[\`"']`;
8623
+ const AUTH_COOKIE_SET_CALL_PATTERN = new RegExp(`(?:\\.cookies\\.set|cookies\\(\\s*\\)\\.set|\\.cookie)\\s*\\(\\s*${AUTH_COOKIE_NAME_LITERAL}`, "gi");
8624
+ const HTTP_ONLY_DISABLED_PATTERN = /httpOnly\s*:\s*false\b/i;
8625
+ const STRING_LITERAL_PATTERN = /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`/g;
8626
+ const blankStringContents = (text) => {
8627
+ const characters = text.split("");
8628
+ let index = 0;
8629
+ let stringDelimiter = null;
8630
+ while (index < text.length) {
8631
+ const character = text[index];
8632
+ if (stringDelimiter !== null) {
8633
+ if (character === "\\") {
8634
+ index += 2;
8635
+ continue;
8636
+ }
8637
+ if (character === stringDelimiter) stringDelimiter = null;
8638
+ else if (character !== "\n") characters[index] = " ";
8639
+ index += 1;
8640
+ continue;
8641
+ }
8642
+ if (character === "\"" || character === "'" || character === "`") stringDelimiter = character;
8643
+ index += 1;
8644
+ }
8645
+ return characters.join("");
8646
+ };
8647
+ const COOKIE_CONFIG_OPENER_PATTERN = /cookie\s*:\s*\{/gi;
8648
+ const CLIENT_AUTH_COOKIE_WRITE_PATTERN = new RegExp(`document\\.cookie\\s*=\\s*[\`"'][^\`"'=;]*?${AUTH_COOKIE_NAME_TOKEN}[^\`"'=;]*=`, "gi");
8649
+ const countTopLevelArguments = (argumentsSource) => {
8650
+ if (argumentsSource.trim().length === 0) return 0;
8651
+ let depth = 0;
8652
+ let stringDelimiter = null;
8653
+ let count = 1;
8654
+ for (let index = 0; index < argumentsSource.length; index += 1) {
8655
+ const character = argumentsSource[index];
8656
+ if (stringDelimiter !== null) {
8657
+ if (character === "\\") index += 1;
8658
+ else if (character === stringDelimiter) stringDelimiter = null;
8659
+ continue;
8660
+ }
8661
+ if (character === "\"" || character === "'" || character === "`") stringDelimiter = character;
8662
+ else if (character === "(" || character === "[" || character === "{") depth += 1;
8663
+ else if (character === ")" || character === "]" || character === "}") depth -= 1;
8664
+ else if (character === "," && depth === 0) count += 1;
8665
+ }
8666
+ return count;
8667
+ };
8668
+ const addMatchFindings = (content, pattern, message, isInsecure, findings) => {
8669
+ pattern.lastIndex = 0;
8670
+ for (let match = pattern.exec(content); match !== null; match = pattern.exec(content)) {
8671
+ if (!isInsecure(match.index, match[0])) continue;
8672
+ const location = getLocationAtIndex(content, match.index);
8673
+ findings.push({
8674
+ message,
8675
+ line: location.line,
8676
+ column: location.column
8677
+ });
8678
+ }
8679
+ };
8680
+ const insecureSessionCookie = defineRule({
8681
+ id: "insecure-session-cookie",
8682
+ title: "Auth cookie missing HttpOnly protection",
8683
+ severity: "warn",
8684
+ recommendation: "Set auth/session cookies server-side with `httpOnly: true`, `secure: true`, and `sameSite`. Cookies set via `document.cookie` or with `httpOnly: false` are readable by any XSS payload and can be stolen.",
8685
+ scan: (file) => {
8686
+ if (!isProductionSourcePath(file.relativePath)) return [];
8687
+ const content = getScannableContent(file);
8688
+ if (!/cookie/i.test(content)) return [];
8689
+ const findings = [];
8690
+ const message = "An auth/session cookie is exposed to JavaScript (set via document.cookie, with httpOnly: false, or without cookie options), letting an XSS payload steal it.";
8691
+ AUTH_COOKIE_SET_CALL_PATTERN.lastIndex = 0;
8692
+ for (let match = AUTH_COOKIE_SET_CALL_PATTERN.exec(content); match !== null; match = AUTH_COOKIE_SET_CALL_PATTERN.exec(content)) {
8693
+ const openParenIndex = match.index + match[0].lastIndexOf("(");
8694
+ const closeParenIndex = findMatchingBracket(content, openParenIndex);
8695
+ if (closeParenIndex < 0) continue;
8696
+ const argumentsSource = content.slice(openParenIndex + 1, closeParenIndex);
8697
+ const hasNoOptions = countTopLevelArguments(argumentsSource) < 3;
8698
+ const argumentsWithoutStrings = argumentsSource.replace(STRING_LITERAL_PATTERN, "");
8699
+ if (!hasNoOptions && !HTTP_ONLY_DISABLED_PATTERN.test(argumentsWithoutStrings)) continue;
8700
+ const location = getLocationAtIndex(content, match.index);
8701
+ findings.push({
8702
+ message,
8703
+ line: location.line,
8704
+ column: location.column
8705
+ });
8706
+ }
8707
+ const blankedContent = blankStringContents(content);
8708
+ COOKIE_CONFIG_OPENER_PATTERN.lastIndex = 0;
8709
+ for (let match = COOKIE_CONFIG_OPENER_PATTERN.exec(blankedContent); match !== null; match = COOKIE_CONFIG_OPENER_PATTERN.exec(blankedContent)) {
8710
+ const braceIndex = match.index + match[0].length - 1;
8711
+ const closeBraceIndex = findMatchingBracket(blankedContent, braceIndex);
8712
+ const block = closeBraceIndex >= 0 ? blankedContent.slice(braceIndex, closeBraceIndex) : blankedContent.slice(braceIndex, braceIndex + 400);
8713
+ if (!HTTP_ONLY_DISABLED_PATTERN.test(block)) continue;
8714
+ const location = getLocationAtIndex(blankedContent, match.index);
8715
+ findings.push({
8716
+ message,
8717
+ line: location.line,
8718
+ column: location.column
8719
+ });
8720
+ }
8721
+ addMatchFindings(content, CLIENT_AUTH_COOKIE_WRITE_PATTERN, message, () => true, findings);
8722
+ return findings;
8723
+ }
8724
+ });
8725
+ //#endregion
8498
8726
  //#region src/plugin/constants/event-handlers.ts
8499
8727
  const MOUSE_EVENT_HANDLERS = [
8500
8728
  "onClick",
@@ -10624,7 +10852,7 @@ const jsxMaxDepth = defineRule({
10624
10852
  });
10625
10853
  //#endregion
10626
10854
  //#region src/plugin/rules/react-builtins/jsx-no-comment-textnodes.ts
10627
- const MESSAGE$41 = "Your users see this comment as text on the page because `//` & `/*` aren't hidden in JSX.";
10855
+ const MESSAGE$45 = "Your users see this comment as text on the page because `//` & `/*` aren't hidden in JSX.";
10628
10856
  const LITERAL_TEXT_TAGS = new Set([
10629
10857
  "code",
10630
10858
  "pre",
@@ -10660,7 +10888,7 @@ const jsxNoCommentTextnodes = defineRule({
10660
10888
  if (isInsideLiteralTextTag(node)) return;
10661
10889
  context.report({
10662
10890
  node,
10663
- message: MESSAGE$41
10891
+ message: MESSAGE$45
10664
10892
  });
10665
10893
  } })
10666
10894
  });
@@ -10691,7 +10919,7 @@ const isInsideFunctionScope = (node) => {
10691
10919
  };
10692
10920
  //#endregion
10693
10921
  //#region src/plugin/rules/react-builtins/jsx-no-constructed-context-values.ts
10694
- const MESSAGE$40 = "Every reader of this context redraws on each render because you build its `value` inline.";
10922
+ const MESSAGE$44 = "Every reader of this context redraws on each render because you build its `value` inline.";
10695
10923
  const CONTEXT_MODULES$1 = [
10696
10924
  "react",
10697
10925
  "use-context-selector",
@@ -10789,7 +11017,7 @@ const jsxNoConstructedContextValues = defineRule({
10789
11017
  if (!isConstructedValue(innerExpression)) continue;
10790
11018
  context.report({
10791
11019
  node: attribute,
10792
- message: MESSAGE$40
11020
+ message: MESSAGE$44
10793
11021
  });
10794
11022
  }
10795
11023
  }
@@ -10875,7 +11103,7 @@ const isJsxAttributeOnIntrinsicHtmlElement = (attribute) => {
10875
11103
  };
10876
11104
  //#endregion
10877
11105
  //#region src/plugin/rules/react-builtins/jsx-no-jsx-as-prop.ts
10878
- const MESSAGE$39 = "This child redraws every render because the prop gets brand new JSX each time.";
11106
+ const MESSAGE$43 = "This child redraws every render because the prop gets brand new JSX each time.";
10879
11107
  const KNOWN_SLOT_PROP_NAMES = new Set([
10880
11108
  "icon",
10881
11109
  "Icon",
@@ -11144,7 +11372,7 @@ const jsxNoJsxAsProp = defineRule({
11144
11372
  if (!isJsxProducingExpression(expressionNode) && !followsRenderLocalJsxBinding(expressionNode, node)) return;
11145
11373
  context.report({
11146
11374
  node,
11147
- message: MESSAGE$39
11375
+ message: MESSAGE$43
11148
11376
  });
11149
11377
  }
11150
11378
  };
@@ -11432,7 +11660,7 @@ const DATA_ARRAY_PROP_SUFFIXES = [
11432
11660
  ];
11433
11661
  //#endregion
11434
11662
  //#region src/plugin/rules/react-builtins/jsx-no-new-array-as-prop.ts
11435
- const MESSAGE$38 = "This child redraws every render because the prop gets a brand new array each time.";
11663
+ const MESSAGE$42 = "This child redraws every render because the prop gets a brand new array each time.";
11436
11664
  const isDataArrayPropName = (propName) => {
11437
11665
  if (DATA_ARRAY_PROP_NAMES.has(propName)) return true;
11438
11666
  for (const suffix of DATA_ARRAY_PROP_SUFFIXES) if (propName.length > suffix.length && propName.endsWith(suffix)) return true;
@@ -11516,7 +11744,7 @@ const jsxNoNewArrayAsProp = defineRule({
11516
11744
  if (!isArrayProducingExpression(expressionNode) && !followsRenderLocalArrayBinding(expressionNode, node)) return;
11517
11745
  context.report({
11518
11746
  node,
11519
- message: MESSAGE$38
11747
+ message: MESSAGE$42
11520
11748
  });
11521
11749
  }
11522
11750
  };
@@ -11774,7 +12002,7 @@ const SAFE_RECEIVER_NAMES = new Set([
11774
12002
  ]);
11775
12003
  //#endregion
11776
12004
  //#region src/plugin/rules/react-builtins/jsx-no-new-function-as-prop.ts
11777
- const MESSAGE$37 = "This child redraws every render because the prop gets a brand new function each time.";
12005
+ const MESSAGE$41 = "This child redraws every render because the prop gets a brand new function each time.";
11778
12006
  const isAccessorPredicateName = (propName) => {
11779
12007
  for (const prefix of ACCESSOR_PREDICATE_PREFIXES) {
11780
12008
  if (propName.length <= prefix.length) continue;
@@ -11980,7 +12208,7 @@ const jsxNoNewFunctionAsProp = defineRule({
11980
12208
  if (!isFunctionProducingExpression(expressionNode) && !followsRenderLocalFunctionBinding(expressionNode, node)) return;
11981
12209
  context.report({
11982
12210
  node,
11983
- message: MESSAGE$37
12211
+ message: MESSAGE$41
11984
12212
  });
11985
12213
  }
11986
12214
  };
@@ -12200,7 +12428,7 @@ const CONFIG_OBJECT_PROP_SUFFIXES = [
12200
12428
  ];
12201
12429
  //#endregion
12202
12430
  //#region src/plugin/rules/react-builtins/jsx-no-new-object-as-prop.ts
12203
- const MESSAGE$36 = "This child redraws every render because the prop gets a brand new object each time.";
12431
+ const MESSAGE$40 = "This child redraws every render because the prop gets a brand new object each time.";
12204
12432
  const isConfigObjectPropName = (propName) => {
12205
12433
  if (CONFIG_OBJECT_PROP_NAMES.has(propName)) return true;
12206
12434
  for (const suffix of CONFIG_OBJECT_PROP_SUFFIXES) if (propName.length > suffix.length && propName.endsWith(suffix)) return true;
@@ -12288,7 +12516,7 @@ const jsxNoNewObjectAsProp = defineRule({
12288
12516
  if (!isObjectProducingExpression(expressionNode) && !followsRenderLocalObjectBinding(expressionNode, node)) return;
12289
12517
  context.report({
12290
12518
  node,
12291
- message: MESSAGE$36
12519
+ message: MESSAGE$40
12292
12520
  });
12293
12521
  }
12294
12522
  };
@@ -12296,7 +12524,7 @@ const jsxNoNewObjectAsProp = defineRule({
12296
12524
  });
12297
12525
  //#endregion
12298
12526
  //#region src/plugin/rules/react-builtins/jsx-no-script-url.ts
12299
- const MESSAGE$35 = "A `javascript:` URL is an XSS hole that runs injected input as code.";
12527
+ const MESSAGE$39 = "A `javascript:` URL is an XSS hole that runs injected input as code.";
12300
12528
  const JAVASCRIPT_URL_PATTERN = /j[\r\n\t]*a[\r\n\t]*v[\r\n\t]*a[\r\n\t]*s[\r\n\t]*c[\r\n\t]*r[\r\n\t]*i[\r\n\t]*p[\r\n\t]*t[\r\n\t]*:/i;
12301
12529
  const resolveSettings$28 = (settings) => {
12302
12530
  const reactDoctor = settings?.["react-doctor"];
@@ -12337,7 +12565,7 @@ const jsxNoScriptUrl = defineRule({
12337
12565
  if (!value || !isNodeOfType(value, "Literal") || typeof value.value !== "string") continue;
12338
12566
  if (JAVASCRIPT_URL_PATTERN.test(value.value)) context.report({
12339
12567
  node: attribute,
12340
- message: MESSAGE$35
12568
+ message: MESSAGE$39
12341
12569
  });
12342
12570
  }
12343
12571
  } };
@@ -12652,7 +12880,7 @@ const jsxPropsNoSpreadMulti = defineRule({
12652
12880
  });
12653
12881
  //#endregion
12654
12882
  //#region src/plugin/rules/react-builtins/jsx-props-no-spreading.ts
12655
- const MESSAGE$34 = "You can't tell what props reach this element when you spread them.";
12883
+ const MESSAGE$38 = "You can't tell what props reach this element when you spread them.";
12656
12884
  const resolveSettings$25 = (settings) => {
12657
12885
  const reactDoctor = settings?.["react-doctor"];
12658
12886
  const ruleSettings = typeof reactDoctor === "object" && reactDoctor !== null ? reactDoctor.jsxPropsNoSpreading ?? {} : {};
@@ -12693,18 +12921,77 @@ const jsxPropsNoSpreading = defineRule({
12693
12921
  }
12694
12922
  context.report({
12695
12923
  node: attribute,
12696
- message: MESSAGE$34
12924
+ message: MESSAGE$38
12697
12925
  });
12698
12926
  }
12699
12927
  } };
12700
12928
  }
12701
12929
  });
12702
12930
  //#endregion
12931
+ //#region src/plugin/rules/security-scan/jwt-insecure-verification.ts
12932
+ const NONE_ALGORITHM_PATTERN = /\b(?:alg|algorithms?)\s*:\s*\[?\s*["'`]none["'`]/gi;
12933
+ const isIndexInsideStringLiteral = (content, index) => {
12934
+ let stringDelimiter = null;
12935
+ const templateExpressionDepths = [];
12936
+ for (let cursor = 0; cursor < index; cursor += 1) {
12937
+ const character = content[cursor];
12938
+ if (stringDelimiter === "`") {
12939
+ if (character === "\\") cursor += 1;
12940
+ else if (character === "`") stringDelimiter = null;
12941
+ else if (character === "$" && content[cursor + 1] === "{") {
12942
+ templateExpressionDepths.push(0);
12943
+ stringDelimiter = null;
12944
+ cursor += 1;
12945
+ }
12946
+ continue;
12947
+ }
12948
+ if (stringDelimiter !== null) {
12949
+ if (character === "\\") cursor += 1;
12950
+ else if (character === stringDelimiter) stringDelimiter = null;
12951
+ continue;
12952
+ }
12953
+ if (character === "\"" || character === "'" || character === "`") stringDelimiter = character;
12954
+ else if (templateExpressionDepths.length > 0) {
12955
+ const top = templateExpressionDepths.length - 1;
12956
+ if (character === "{") templateExpressionDepths[top] += 1;
12957
+ else if (character === "}") if (templateExpressionDepths[top] === 0) {
12958
+ templateExpressionDepths.pop();
12959
+ stringDelimiter = "`";
12960
+ } else templateExpressionDepths[top] -= 1;
12961
+ }
12962
+ }
12963
+ return stringDelimiter !== null;
12964
+ };
12965
+ const jwtInsecureVerification = defineRule({
12966
+ id: "jwt-insecure-verification",
12967
+ title: "JWT verified with the 'none' algorithm",
12968
+ severity: "error",
12969
+ recommendation: "Never accept the `none` algorithm; it disables signature verification and lets any forged token through. Pin the real algorithm(s) explicitly (`jwt.verify(token, key, { algorithms: ['RS256'] })`).",
12970
+ scan: (file) => {
12971
+ if (!isProductionSourcePath(file.relativePath)) return [];
12972
+ const content = getScannableContent(file);
12973
+ if (!/\bjwt\b|jsonwebtoken|\bjose\b/i.test(content)) return [];
12974
+ const findings = [];
12975
+ NONE_ALGORITHM_PATTERN.lastIndex = 0;
12976
+ for (let noneMatch = NONE_ALGORITHM_PATTERN.exec(content); noneMatch !== null; noneMatch = NONE_ALGORITHM_PATTERN.exec(content)) {
12977
+ if (isIndexInsideStringLiteral(content, noneMatch.index)) continue;
12978
+ const location = getLocationAtIndex(content, noneMatch.index);
12979
+ findings.push({
12980
+ message: "JWT is configured with the 'none' algorithm, which disables signature verification, so any forged token is accepted.",
12981
+ line: location.line,
12982
+ column: location.column
12983
+ });
12984
+ }
12985
+ return findings;
12986
+ }
12987
+ });
12988
+ //#endregion
12703
12989
  //#region src/plugin/rules/security-scan/key-lifecycle-risk.ts
12704
12990
  const keyLifecycleRisk = defineRule({
12705
12991
  id: "key-lifecycle-risk",
12706
12992
  title: "Long-lived key material in repository",
12707
12993
  severity: "error",
12994
+ committedFilesOnly: true,
12708
12995
  recommendation: "Remove private keys from source, rotate exposed credentials, prefer short-lived deploy credentials, and document revocation/expiry for release keys.",
12709
12996
  scan: scanByPattern({
12710
12997
  shouldScan: (file) => !TEST_CONTEXT_PATTERN.test(file.relativePath) && !DOCUMENTATION_CONTEXT_PATTERN.test(file.relativePath),
@@ -12862,7 +13149,7 @@ const labelHasAssociatedControl = defineRule({
12862
13149
  });
12863
13150
  //#endregion
12864
13151
  //#region src/plugin/rules/a11y/lang.ts
12865
- const MESSAGE$33 = "Screen readers can't pick the right voice because this `lang` isn't a real language code, so use a valid one like `en` or `en-US`.";
13152
+ const MESSAGE$37 = "Screen readers can't pick the right voice because this `lang` isn't a real language code, so use a valid one like `en` or `en-US`.";
12866
13153
  const COMMON_LANGUAGE_PRIMARY_TAGS = new Set([
12867
13154
  "aa",
12868
13155
  "ab",
@@ -13074,7 +13361,7 @@ const lang = defineRule({
13074
13361
  if (expression.type === "Identifier" && expression.name === "undefined" || expression.type === "Literal" && expression.value === null) {
13075
13362
  context.report({
13076
13363
  node: langAttr,
13077
- message: MESSAGE$33
13364
+ message: MESSAGE$37
13078
13365
  });
13079
13366
  return;
13080
13367
  }
@@ -13083,7 +13370,7 @@ const lang = defineRule({
13083
13370
  if (value === null) return;
13084
13371
  if (!isValidLangTag(value)) context.report({
13085
13372
  node: langAttr,
13086
- message: MESSAGE$33
13373
+ message: MESSAGE$37
13087
13374
  });
13088
13375
  } })
13089
13376
  });
@@ -13127,7 +13414,7 @@ const mdxSsrExecutionRisk = defineRule({
13127
13414
  });
13128
13415
  //#endregion
13129
13416
  //#region src/plugin/rules/a11y/media-has-caption.ts
13130
- const MESSAGE$32 = "Deaf and hard-of-hearing users need captions for this media. Add a `<track kind=\"captions\">` inside the `<audio>` or `<video>`.";
13417
+ const MESSAGE$36 = "Deaf and hard-of-hearing users need captions for this media. Add a `<track kind=\"captions\">` inside the `<audio>` or `<video>`.";
13131
13418
  const DEFAULT_AUDIO = ["audio"];
13132
13419
  const DEFAULT_VIDEO = ["video"];
13133
13420
  const DEFAULT_TRACK = ["track"];
@@ -13168,7 +13455,7 @@ const mediaHasCaption = defineRule({
13168
13455
  if (!parent || !isNodeOfType(parent, "JSXElement")) {
13169
13456
  context.report({
13170
13457
  node: node.name,
13171
- message: MESSAGE$32
13458
+ message: MESSAGE$36
13172
13459
  });
13173
13460
  return;
13174
13461
  }
@@ -13185,7 +13472,7 @@ const mediaHasCaption = defineRule({
13185
13472
  return kindValue.value.toLowerCase() === "captions";
13186
13473
  })) context.report({
13187
13474
  node: node.name,
13188
- message: MESSAGE$32
13475
+ message: MESSAGE$36
13189
13476
  });
13190
13477
  } };
13191
13478
  }
@@ -14986,7 +15273,7 @@ const nextjsNoVercelOgImport = defineRule({
14986
15273
  });
14987
15274
  //#endregion
14988
15275
  //#region src/plugin/rules/a11y/no-access-key.ts
14989
- const MESSAGE$31 = "Screen reader users can lose their shortcuts because `accessKey` clashes with them, so remove it.";
15276
+ const MESSAGE$35 = "Screen reader users can lose their shortcuts because `accessKey` clashes with them, so remove it.";
14990
15277
  const isUndefinedIdentifier = (expression) => isNodeOfType(expression, "Identifier") && expression.name === "undefined";
14991
15278
  const noAccessKey = defineRule({
14992
15279
  id: "no-access-key",
@@ -15003,7 +15290,7 @@ const noAccessKey = defineRule({
15003
15290
  if (isNodeOfType(attributeValue, "Literal") && typeof attributeValue.value === "string") {
15004
15291
  context.report({
15005
15292
  node: accessKey,
15006
- message: MESSAGE$31
15293
+ message: MESSAGE$35
15007
15294
  });
15008
15295
  return;
15009
15296
  }
@@ -15013,7 +15300,7 @@ const noAccessKey = defineRule({
15013
15300
  if (isUndefinedIdentifier(expression)) return;
15014
15301
  context.report({
15015
15302
  node: accessKey,
15016
- message: MESSAGE$31
15303
+ message: MESSAGE$35
15017
15304
  });
15018
15305
  }
15019
15306
  } })
@@ -15496,7 +15783,7 @@ const noAdjustStateOnPropChange = defineRule({
15496
15783
  });
15497
15784
  //#endregion
15498
15785
  //#region src/plugin/rules/a11y/no-aria-hidden-on-focusable.ts
15499
- const MESSAGE$30 = "Screen reader users tab to this focusable element but hear nothing because `aria-hidden` skips it, so remove `aria-hidden` or stop it being focusable.";
15786
+ const MESSAGE$34 = "Screen reader users tab to this focusable element but hear nothing because `aria-hidden` skips it, so remove `aria-hidden` or stop it being focusable.";
15500
15787
  const noAriaHiddenOnFocusable = defineRule({
15501
15788
  id: "no-aria-hidden-on-focusable",
15502
15789
  title: "aria-hidden on focusable element",
@@ -15523,7 +15810,7 @@ const noAriaHiddenOnFocusable = defineRule({
15523
15810
  const isImplicitlyFocusable = isInteractiveElement(tag, node);
15524
15811
  if (isExplicitlyFocusable || isImplicitlyFocusable) context.report({
15525
15812
  node: ariaHidden,
15526
- message: MESSAGE$30
15813
+ message: MESSAGE$34
15527
15814
  });
15528
15815
  } })
15529
15816
  });
@@ -15891,7 +16178,7 @@ const noArrayIndexAsKey = defineRule({
15891
16178
  });
15892
16179
  //#endregion
15893
16180
  //#region src/plugin/rules/react-builtins/no-array-index-key.ts
15894
- const MESSAGE$29 = "Your users can see & submit the wrong data when this list reorders.";
16181
+ const MESSAGE$33 = "Your users can see & submit the wrong data when this list reorders.";
15895
16182
  const SECOND_INDEX_METHODS = new Set([
15896
16183
  "every",
15897
16184
  "filter",
@@ -16095,7 +16382,7 @@ const noArrayIndexKey = defineRule({
16095
16382
  }
16096
16383
  context.report({
16097
16384
  node: keyAttribute,
16098
- message: MESSAGE$29
16385
+ message: MESSAGE$33
16099
16386
  });
16100
16387
  },
16101
16388
  CallExpression(node) {
@@ -16115,15 +16402,35 @@ const noArrayIndexKey = defineRule({
16115
16402
  if (propName !== "key") continue;
16116
16403
  if (expressionUsesIndex(property.value, indexBinding.name)) context.report({
16117
16404
  node: property,
16118
- message: MESSAGE$29
16405
+ message: MESSAGE$33
16119
16406
  });
16120
16407
  }
16121
16408
  }
16122
16409
  })
16123
16410
  });
16124
16411
  //#endregion
16412
+ //#region src/plugin/rules/state-and-effects/no-async-effect-callback.ts
16413
+ const MESSAGE$32 = "The `useEffect` callback is `async`, so it returns a Promise instead of a cleanup function. React calls that Promise as cleanup (a no-op) and the effect can race on unmount. Put the async work in an inner function and call it.";
16414
+ const noAsyncEffectCallback = defineRule({
16415
+ id: "no-async-effect-callback",
16416
+ title: "Async effect callback",
16417
+ severity: "warn",
16418
+ 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.",
16419
+ create: (context) => ({ CallExpression(node) {
16420
+ if (!isHookCall$1(node, EFFECT_HOOK_NAMES$1)) return;
16421
+ const callback = getEffectCallback(node);
16422
+ if (!callback) return;
16423
+ if (!isNodeOfType(callback, "ArrowFunctionExpression") && !isNodeOfType(callback, "FunctionExpression")) return;
16424
+ if (!callback.async) return;
16425
+ context.report({
16426
+ node: callback,
16427
+ message: MESSAGE$32
16428
+ });
16429
+ } })
16430
+ });
16431
+ //#endregion
16125
16432
  //#region src/plugin/rules/a11y/no-autofocus.ts
16126
- const MESSAGE$28 = "`autoFocus` moves focus on load, which can disrupt screen reader and keyboard users. Remove it and let users choose where to focus.";
16433
+ const MESSAGE$31 = "`autoFocus` moves focus on load, which can disrupt screen reader and keyboard users. Remove it and let users choose where to focus.";
16127
16434
  const resolveSettings$21 = (settings) => {
16128
16435
  const reactDoctor = settings?.["react-doctor"];
16129
16436
  return { ignoreNonDOM: (typeof reactDoctor === "object" && reactDoctor !== null ? reactDoctor.noAutofocus ?? {} : {}).ignoreNonDOM ?? true };
@@ -16179,7 +16486,7 @@ const noAutofocus = defineRule({
16179
16486
  }
16180
16487
  context.report({
16181
16488
  node: autoFocusAttribute,
16182
- message: MESSAGE$28
16489
+ message: MESSAGE$31
16183
16490
  });
16184
16491
  } };
16185
16492
  }
@@ -16429,6 +16736,109 @@ const noBarrelImport = defineRule({
16429
16736
  }
16430
16737
  });
16431
16738
  //#endregion
16739
+ //#region src/plugin/utils/function-contains-react-render-output.ts
16740
+ const NESTED_RENDER_EVIDENCE_BOUNDARY_TYPES = new Set([
16741
+ "FunctionDeclaration",
16742
+ "FunctionExpression",
16743
+ "ArrowFunctionExpression",
16744
+ "ClassDeclaration",
16745
+ "ClassExpression"
16746
+ ]);
16747
+ const isReactImport$1 = (symbol) => {
16748
+ let importDeclaration = symbol.declarationNode?.parent;
16749
+ while (importDeclaration && !isNodeOfType(importDeclaration, "ImportDeclaration")) importDeclaration = importDeclaration.parent ?? null;
16750
+ if (!importDeclaration || !isNodeOfType(importDeclaration, "ImportDeclaration")) return false;
16751
+ return importDeclaration.source.value === "react";
16752
+ };
16753
+ const getImportedName = (symbol) => {
16754
+ if (symbol.kind !== "import") return null;
16755
+ if (!isReactImport$1(symbol)) return null;
16756
+ return getImportedName$1(symbol.declarationNode) ?? null;
16757
+ };
16758
+ const isReactNamespaceImport = (symbol) => {
16759
+ if (symbol.kind !== "import") return false;
16760
+ if (!isReactImport$1(symbol)) return false;
16761
+ return isNodeOfType(symbol.declarationNode, "ImportDefaultSpecifier") || isNodeOfType(symbol.declarationNode, "ImportNamespaceSpecifier");
16762
+ };
16763
+ const isReactCreateElementIdentifierCall = (callee, scopes) => {
16764
+ if (!isNodeOfType(callee, "Identifier")) return false;
16765
+ const symbol = scopes.symbolFor(callee);
16766
+ return Boolean(symbol && getImportedName(symbol) === "createElement");
16767
+ };
16768
+ const isReactCreateElementMemberCall = (callee, scopes) => {
16769
+ if (!isNodeOfType(callee, "MemberExpression")) return false;
16770
+ if (callee.computed) return false;
16771
+ if (!isNodeOfType(callee.object, "Identifier")) return false;
16772
+ if (!isNodeOfType(callee.property, "Identifier")) return false;
16773
+ if (callee.property.name !== "createElement") return false;
16774
+ const symbol = scopes.symbolFor(callee.object);
16775
+ return Boolean(symbol && isReactNamespaceImport(symbol));
16776
+ };
16777
+ const isReactCreateElementCall = (node, scopes) => {
16778
+ if (!isNodeOfType(node, "CallExpression")) return false;
16779
+ return isReactCreateElementIdentifierCall(node.callee, scopes) || isReactCreateElementMemberCall(node.callee, scopes);
16780
+ };
16781
+ const containsRenderOutput = (node, rootNode, scopes) => {
16782
+ if (node !== rootNode && NESTED_RENDER_EVIDENCE_BOUNDARY_TYPES.has(node.type)) return false;
16783
+ if (node.type === "JSXElement" || node.type === "JSXFragment") return true;
16784
+ if (isReactCreateElementCall(node, scopes)) return true;
16785
+ const nodeRecord = node;
16786
+ for (const key of Object.keys(nodeRecord)) {
16787
+ if (key === "parent") continue;
16788
+ const child = nodeRecord[key];
16789
+ if (Array.isArray(child)) {
16790
+ for (const innerChild of child) if (isAstNode(innerChild) && containsRenderOutput(innerChild, rootNode, scopes)) return true;
16791
+ } else if (isAstNode(child) && containsRenderOutput(child, rootNode, scopes)) return true;
16792
+ }
16793
+ return false;
16794
+ };
16795
+ const functionContainsReactRenderOutput = (functionNode, scopes) => containsRenderOutput(functionNode, functionNode, scopes);
16796
+ //#endregion
16797
+ //#region src/plugin/utils/is-component-declaration.ts
16798
+ const isComponentDeclaration = (node) => isNodeOfType(node, "FunctionDeclaration") && node.id !== null && Boolean(node.id?.name) && isUppercaseName(node.id.name);
16799
+ //#endregion
16800
+ //#region src/plugin/rules/react-builtins/no-call-component-as-function.ts
16801
+ const message = (name) => `\`${name}\` is a component, so calling it as a plain function (\`${name}(...)\`) runs it outside React: its hooks break, it gets no fiber/state, and memoization is lost. Render it as \`<${name} />\` instead.`;
16802
+ const symbolIsLocalComponent = (symbol, context) => {
16803
+ const declaration = symbol.declarationNode;
16804
+ if (isComponentDeclaration(declaration)) return functionContainsReactRenderOutput(declaration, context.scopes);
16805
+ if (isComponentAssignment(declaration) && symbol.initializer) return functionContainsReactRenderOutput(symbol.initializer, context.scopes);
16806
+ return false;
16807
+ };
16808
+ const noCallComponentAsFunction = defineRule({
16809
+ id: "no-call-component-as-function",
16810
+ title: "Component called as a function",
16811
+ severity: "warn",
16812
+ tags: ["test-noise"],
16813
+ recommendation: "Render components as JSX (`<Component />`), never call them like functions (`Component(props)`). A direct call runs the component outside React and breaks hooks, state, and memoization.",
16814
+ create: (context) => {
16815
+ const renderedJsxNames = /* @__PURE__ */ new Set();
16816
+ const candidateCalls = [];
16817
+ return {
16818
+ JSXOpeningElement(node) {
16819
+ if (isNodeOfType(node.name, "JSXIdentifier") && isUppercaseName(node.name.name)) renderedJsxNames.add(node.name.name);
16820
+ },
16821
+ CallExpression(node) {
16822
+ if (isNodeOfType(node.callee, "Identifier") && isUppercaseName(node.callee.name)) candidateCalls.push({
16823
+ node,
16824
+ callee: node.callee,
16825
+ name: node.callee.name
16826
+ });
16827
+ },
16828
+ "Program:exit"() {
16829
+ for (const candidate of candidateCalls) {
16830
+ const symbol = context.scopes.symbolFor(candidate.callee);
16831
+ if (!symbol) continue;
16832
+ if (symbolIsLocalComponent(symbol, context) || symbol.kind === "import" && renderedJsxNames.has(candidate.name)) context.report({
16833
+ node: candidate.node,
16834
+ message: message(candidate.name)
16835
+ });
16836
+ }
16837
+ }
16838
+ };
16839
+ }
16840
+ });
16841
+ //#endregion
16432
16842
  //#region src/plugin/utils/is-setter-identifier.ts
16433
16843
  const isSetterIdentifier = (name) => SETTER_PATTERN.test(name);
16434
16844
  //#endregion
@@ -16580,7 +16990,7 @@ const noChainStateUpdates = defineRule({
16580
16990
  });
16581
16991
  //#endregion
16582
16992
  //#region src/plugin/rules/react-builtins/no-children-prop.ts
16583
- const MESSAGE$27 = "A `children` prop can override or hide nested children, so the component may render different content than the JSX shows.";
16993
+ const MESSAGE$30 = "A `children` prop can override or hide nested children, so the component may render different content than the JSX shows.";
16584
16994
  const noChildrenProp = defineRule({
16585
16995
  id: "no-children-prop",
16586
16996
  title: "Children passed as a prop",
@@ -16592,7 +17002,7 @@ const noChildrenProp = defineRule({
16592
17002
  if (node.name.name !== "children") return;
16593
17003
  context.report({
16594
17004
  node: node.name,
16595
- message: MESSAGE$27
17005
+ message: MESSAGE$30
16596
17006
  });
16597
17007
  },
16598
17008
  CallExpression(node) {
@@ -16605,7 +17015,7 @@ const noChildrenProp = defineRule({
16605
17015
  const propertyKey = property.key;
16606
17016
  if (isNodeOfType(propertyKey, "Identifier") && propertyKey.name === "children" || isNodeOfType(propertyKey, "Literal") && propertyKey.value === "children") context.report({
16607
17017
  node: propertyKey,
16608
- message: MESSAGE$27
17018
+ message: MESSAGE$30
16609
17019
  });
16610
17020
  }
16611
17021
  }
@@ -16613,7 +17023,7 @@ const noChildrenProp = defineRule({
16613
17023
  });
16614
17024
  //#endregion
16615
17025
  //#region src/plugin/rules/react-builtins/no-clone-element.ts
16616
- const MESSAGE$26 = "`React.cloneElement` couples the parent to the child's prop shape, so child prop changes can silently break injected behavior.";
17026
+ const MESSAGE$29 = "`React.cloneElement` couples the parent to the child's prop shape, so child prop changes can silently break injected behavior.";
16617
17027
  const noCloneElement = defineRule({
16618
17028
  id: "no-clone-element",
16619
17029
  title: "cloneElement makes child props fragile",
@@ -16626,7 +17036,7 @@ const noCloneElement = defineRule({
16626
17036
  if (isNodeOfType(callee, "Identifier") && callee.name === "cloneElement") {
16627
17037
  if (isImportedFromModule(node, "cloneElement", "react")) context.report({
16628
17038
  node: callee,
16629
- message: MESSAGE$26
17039
+ message: MESSAGE$29
16630
17040
  });
16631
17041
  return;
16632
17042
  }
@@ -16639,7 +17049,7 @@ const noCloneElement = defineRule({
16639
17049
  if (!isImportedFromModule(node, callee.object.name, "react")) return;
16640
17050
  context.report({
16641
17051
  node: callee,
16642
- message: MESSAGE$26
17052
+ message: MESSAGE$29
16643
17053
  });
16644
17054
  }
16645
17055
  } })
@@ -16688,7 +17098,7 @@ const enclosingComponentOrHookName = (node) => {
16688
17098
  };
16689
17099
  //#endregion
16690
17100
  //#region src/plugin/rules/state-and-effects/no-create-context-in-render.ts
16691
- const MESSAGE$25 = "createContext() builds a new context every render, so every consumer gets cut off & resets.";
17101
+ const MESSAGE$28 = "createContext() builds a new context every render, so every consumer gets cut off & resets.";
16692
17102
  const CONTEXT_MODULES = [
16693
17103
  "react",
16694
17104
  "use-context-selector",
@@ -16724,7 +17134,32 @@ const noCreateContextInRender = defineRule({
16724
17134
  if (!componentOrHookName) return;
16725
17135
  context.report({
16726
17136
  node,
16727
- message: `${MESSAGE$25} (called inside "${componentOrHookName}")`
17137
+ message: `${MESSAGE$28} (called inside "${componentOrHookName}")`
17138
+ });
17139
+ } })
17140
+ });
17141
+ //#endregion
17142
+ //#region src/plugin/rules/react-builtins/no-create-ref-in-function-component.ts
17143
+ const MESSAGE$27 = "`createRef()` in a function component allocates a brand-new ref on every render, so it never holds a value between renders. Use the `useRef()` hook instead.";
17144
+ const noCreateRefInFunctionComponent = defineRule({
17145
+ id: "no-create-ref-in-function-component",
17146
+ title: "createRef in function component",
17147
+ severity: "warn",
17148
+ recommendation: "Replace `createRef()` with the `useRef()` hook inside function components and hooks. `createRef` is only for class components.",
17149
+ create: (context) => ({ CallExpression(node) {
17150
+ if (!isReactFunctionCall(node, "createRef")) return;
17151
+ if (isNodeOfType(node.callee, "Identifier")) {
17152
+ const symbol = context.scopes.symbolFor(node.callee);
17153
+ if (symbol && symbol.kind !== "import") return;
17154
+ }
17155
+ const enclosingFunction = nearestEnclosingFunction(node);
17156
+ if (!enclosingFunction) return;
17157
+ const displayName = componentOrHookDisplayNameForFunction(enclosingFunction);
17158
+ if (!displayName) return;
17159
+ if (!(isReactHookName(displayName) || functionContainsReactRenderOutput(enclosingFunction, context.scopes))) return;
17160
+ context.report({
17161
+ node,
17162
+ message: MESSAGE$27
16728
17163
  });
16729
17164
  } })
16730
17165
  });
@@ -16864,7 +17299,7 @@ const noCreateStoreInRender = defineRule({
16864
17299
  });
16865
17300
  //#endregion
16866
17301
  //#region src/plugin/rules/react-builtins/no-danger.ts
16867
- const MESSAGE$24 = "`dangerouslySetInnerHTML` is an XSS hole that runs attacker-controlled HTML in your users' browsers.";
17302
+ const MESSAGE$26 = "`dangerouslySetInnerHTML` is an XSS hole that runs attacker-controlled HTML in your users' browsers.";
16868
17303
  const noDanger = defineRule({
16869
17304
  id: "no-danger",
16870
17305
  title: "Raw HTML injection can run unsafe markup",
@@ -16877,7 +17312,7 @@ const noDanger = defineRule({
16877
17312
  if (!propAttribute) return;
16878
17313
  context.report({
16879
17314
  node: propAttribute.name,
16880
- message: MESSAGE$24
17315
+ message: MESSAGE$26
16881
17316
  });
16882
17317
  },
16883
17318
  CallExpression(node) {
@@ -16889,7 +17324,7 @@ const noDanger = defineRule({
16889
17324
  const propertyKey = property.key;
16890
17325
  if (isNodeOfType(propertyKey, "Identifier") && propertyKey.name === "dangerouslySetInnerHTML" || isNodeOfType(propertyKey, "Literal") && propertyKey.value === "dangerouslySetInnerHTML") context.report({
16891
17326
  node: propertyKey,
16892
- message: MESSAGE$24
17327
+ message: MESSAGE$26
16893
17328
  });
16894
17329
  }
16895
17330
  }
@@ -16897,7 +17332,7 @@ const noDanger = defineRule({
16897
17332
  });
16898
17333
  //#endregion
16899
17334
  //#region src/plugin/rules/react-builtins/no-danger-with-children.ts
16900
- const MESSAGE$23 = "React throws an error when you set both children & `dangerouslySetInnerHTML`.";
17335
+ const MESSAGE$25 = "React throws an error when you set both children & `dangerouslySetInnerHTML`.";
16901
17336
  const isLineBreak = (child) => {
16902
17337
  if (!isNodeOfType(child, "JSXText")) return false;
16903
17338
  return child.value.trim().length === 0 && child.value.includes("\n");
@@ -16967,7 +17402,7 @@ const noDangerWithChildren = defineRule({
16967
17402
  if (!hasChildrenProp && !hasNestedChildren) return;
16968
17403
  if (hasJsxPropIgnoreCase(opening.attributes, "dangerouslySetInnerHTML") || spreadPropsShape.hasDangerously) context.report({
16969
17404
  node: opening,
16970
- message: MESSAGE$23
17405
+ message: MESSAGE$25
16971
17406
  });
16972
17407
  },
16973
17408
  CallExpression(node) {
@@ -16979,7 +17414,7 @@ const noDangerWithChildren = defineRule({
16979
17414
  if (!propsShape.hasDangerously) return;
16980
17415
  if (node.arguments.length >= 3 || propsShape.hasChildren) context.report({
16981
17416
  node,
16982
- message: MESSAGE$23
17417
+ message: MESSAGE$25
16983
17418
  });
16984
17419
  }
16985
17420
  })
@@ -17556,7 +17991,7 @@ const isSetStateCallInLifecycle = (setStateCall, lifecycleNames, options = {}) =
17556
17991
  //#endregion
17557
17992
  //#region src/plugin/rules/react-builtins/no-did-mount-set-state.ts
17558
17993
  const LIFECYCLE_NAMES$2 = new Set(["componentDidMount"]);
17559
- const MESSAGE$22 = "Your users see an extra render right after mount when you call `setState` in `componentDidMount`.";
17994
+ const MESSAGE$24 = "Your users see an extra render right after mount when you call `setState` in `componentDidMount`.";
17560
17995
  const resolveSettings$20 = (settings) => {
17561
17996
  const reactDoctor = settings?.["react-doctor"];
17562
17997
  return { mode: (typeof reactDoctor === "object" && reactDoctor !== null ? reactDoctor.noDidMountSetState ?? {} : {}).mode ?? "allowed" };
@@ -17575,7 +18010,7 @@ const noDidMountSetState = defineRule({
17575
18010
  if (!isSetStateCallInLifecycle(node, LIFECYCLE_NAMES$2, { disallowInNestedFunctions: mode === "disallow-in-func" })) return;
17576
18011
  context.report({
17577
18012
  node: node.callee,
17578
- message: MESSAGE$22
18013
+ message: MESSAGE$24
17579
18014
  });
17580
18015
  } };
17581
18016
  }
@@ -17583,7 +18018,7 @@ const noDidMountSetState = defineRule({
17583
18018
  //#endregion
17584
18019
  //#region src/plugin/rules/react-builtins/no-did-update-set-state.ts
17585
18020
  const LIFECYCLE_NAMES$1 = new Set(["componentDidUpdate"]);
17586
- const MESSAGE$21 = "Calling setState in componentDidUpdate can trigger another update immediately, loop forever, and freeze the component.";
18021
+ const MESSAGE$23 = "Calling setState in componentDidUpdate can trigger another update immediately, loop forever, and freeze the component.";
17587
18022
  const resolveSettings$19 = (settings) => {
17588
18023
  const reactDoctor = settings?.["react-doctor"];
17589
18024
  return { mode: (typeof reactDoctor === "object" && reactDoctor !== null ? reactDoctor.noDidUpdateSetState ?? {} : {}).mode ?? "allowed" };
@@ -17602,7 +18037,7 @@ const noDidUpdateSetState = defineRule({
17602
18037
  if (!isSetStateCallInLifecycle(node, LIFECYCLE_NAMES$1, { disallowInNestedFunctions: mode === "disallow-in-func" })) return;
17603
18038
  context.report({
17604
18039
  node: node.callee,
17605
- message: MESSAGE$21
18040
+ message: MESSAGE$23
17606
18041
  });
17607
18042
  } };
17608
18043
  }
@@ -17625,7 +18060,7 @@ const isStateMemberExpression = (node) => {
17625
18060
  };
17626
18061
  //#endregion
17627
18062
  //#region src/plugin/rules/react-builtins/no-direct-mutation-state.ts
17628
- const MESSAGE$20 = "Your users see stale data because mutating `this.state` by hand never redraws & gets overwritten.";
18063
+ const MESSAGE$22 = "Your users see stale data because mutating `this.state` by hand never redraws & gets overwritten.";
17629
18064
  const shouldIgnoreMutation = (node) => {
17630
18065
  let isConstructor = false;
17631
18066
  let isInsideCallExpression = false;
@@ -17647,7 +18082,7 @@ const reportIfStateMutation = (context, reportNode, target) => {
17647
18082
  if (shouldIgnoreMutation(reportNode)) return;
17648
18083
  context.report({
17649
18084
  node: reportNode,
17650
- message: MESSAGE$20
18085
+ message: MESSAGE$22
17651
18086
  });
17652
18087
  };
17653
18088
  const noDirectMutationState = defineRule({
@@ -19235,7 +19670,7 @@ const ALLOWED_NAMESPACES = new Set([
19235
19670
  "ReactDOM",
19236
19671
  "ReactDom"
19237
19672
  ]);
19238
- const MESSAGE$19 = "`findDOMNode` crashes your app in React 19 because it was removed.";
19673
+ const MESSAGE$21 = "`findDOMNode` crashes your app in React 19 because it was removed.";
19239
19674
  const noFindDomNode = defineRule({
19240
19675
  id: "no-find-dom-node",
19241
19676
  title: "findDOMNode breaks component encapsulation",
@@ -19246,7 +19681,7 @@ const noFindDomNode = defineRule({
19246
19681
  if (isNodeOfType(callee, "Identifier") && callee.name === "findDOMNode") {
19247
19682
  context.report({
19248
19683
  node: callee,
19249
- message: MESSAGE$19
19684
+ message: MESSAGE$21
19250
19685
  });
19251
19686
  return;
19252
19687
  }
@@ -19257,7 +19692,7 @@ const noFindDomNode = defineRule({
19257
19692
  if (callee.property.name !== "findDOMNode") return;
19258
19693
  context.report({
19259
19694
  node: callee.property,
19260
- message: MESSAGE$19
19695
+ message: MESSAGE$21
19261
19696
  });
19262
19697
  }
19263
19698
  } })
@@ -19320,64 +19755,6 @@ const noGenericHandlerNames = defineRule({
19320
19755
  } })
19321
19756
  });
19322
19757
  //#endregion
19323
- //#region src/plugin/utils/function-contains-react-render-output.ts
19324
- const NESTED_RENDER_EVIDENCE_BOUNDARY_TYPES = new Set([
19325
- "FunctionDeclaration",
19326
- "FunctionExpression",
19327
- "ArrowFunctionExpression",
19328
- "ClassDeclaration",
19329
- "ClassExpression"
19330
- ]);
19331
- const isReactImport$1 = (symbol) => {
19332
- let importDeclaration = symbol.declarationNode?.parent;
19333
- while (importDeclaration && !isNodeOfType(importDeclaration, "ImportDeclaration")) importDeclaration = importDeclaration.parent ?? null;
19334
- if (!importDeclaration || !isNodeOfType(importDeclaration, "ImportDeclaration")) return false;
19335
- return importDeclaration.source.value === "react";
19336
- };
19337
- const getImportedName = (symbol) => {
19338
- if (symbol.kind !== "import") return null;
19339
- if (!isReactImport$1(symbol)) return null;
19340
- return getImportedName$1(symbol.declarationNode) ?? null;
19341
- };
19342
- const isReactNamespaceImport = (symbol) => {
19343
- if (symbol.kind !== "import") return false;
19344
- if (!isReactImport$1(symbol)) return false;
19345
- return isNodeOfType(symbol.declarationNode, "ImportDefaultSpecifier") || isNodeOfType(symbol.declarationNode, "ImportNamespaceSpecifier");
19346
- };
19347
- const isReactCreateElementIdentifierCall = (callee, scopes) => {
19348
- if (!isNodeOfType(callee, "Identifier")) return false;
19349
- const symbol = scopes.symbolFor(callee);
19350
- return Boolean(symbol && getImportedName(symbol) === "createElement");
19351
- };
19352
- const isReactCreateElementMemberCall = (callee, scopes) => {
19353
- if (!isNodeOfType(callee, "MemberExpression")) return false;
19354
- if (callee.computed) return false;
19355
- if (!isNodeOfType(callee.object, "Identifier")) return false;
19356
- if (!isNodeOfType(callee.property, "Identifier")) return false;
19357
- if (callee.property.name !== "createElement") return false;
19358
- const symbol = scopes.symbolFor(callee.object);
19359
- return Boolean(symbol && isReactNamespaceImport(symbol));
19360
- };
19361
- const isReactCreateElementCall = (node, scopes) => {
19362
- if (!isNodeOfType(node, "CallExpression")) return false;
19363
- return isReactCreateElementIdentifierCall(node.callee, scopes) || isReactCreateElementMemberCall(node.callee, scopes);
19364
- };
19365
- const containsRenderOutput = (node, rootNode, scopes) => {
19366
- if (node !== rootNode && NESTED_RENDER_EVIDENCE_BOUNDARY_TYPES.has(node.type)) return false;
19367
- if (node.type === "JSXElement" || node.type === "JSXFragment") return true;
19368
- if (isReactCreateElementCall(node, scopes)) return true;
19369
- const nodeRecord = node;
19370
- for (const key of Object.keys(nodeRecord)) {
19371
- if (key === "parent") continue;
19372
- const child = nodeRecord[key];
19373
- if (Array.isArray(child)) {
19374
- for (const innerChild of child) if (isAstNode(innerChild) && containsRenderOutput(innerChild, rootNode, scopes)) return true;
19375
- } else if (isAstNode(child) && containsRenderOutput(child, rootNode, scopes)) return true;
19376
- }
19377
- return false;
19378
- };
19379
- const functionContainsReactRenderOutput = (functionNode, scopes) => containsRenderOutput(functionNode, functionNode, scopes);
19380
- //#endregion
19381
19758
  //#region src/plugin/rules/architecture/no-giant-component.ts
19382
19759
  const noGiantComponent = defineRule({
19383
19760
  id: "no-giant-component",
@@ -19556,6 +19933,26 @@ const noGrayOnColoredBackground = defineRule({
19556
19933
  } })
19557
19934
  });
19558
19935
  //#endregion
19936
+ //#region src/plugin/rules/performance/no-img-lazy-with-high-fetchpriority.ts
19937
+ const MESSAGE$20 = "`<img loading=\"lazy\">` defers the request while `fetchPriority=\"high\"` asks the browser to rush it, so the two directives contradict each other. Drop one: keep `fetchPriority=\"high\"` (and eager loading) for an LCP image, or `loading=\"lazy\"` for a below-the-fold one.";
19938
+ const noImgLazyWithHighFetchpriority = defineRule({
19939
+ id: "no-img-lazy-with-high-fetchpriority",
19940
+ title: "Lazy image with high fetchPriority",
19941
+ severity: "warn",
19942
+ recommendation: "Don't combine `loading=\"lazy\"` with `fetchPriority=\"high\"`. A high-priority image (usually the LCP) should load eagerly; a lazy image is by definition not high priority.",
19943
+ create: (context) => ({ JSXOpeningElement(node) {
19944
+ if (!isNodeOfType(node.name, "JSXIdentifier") || node.name.name !== "img") return;
19945
+ const loadingAttribute = hasJsxPropIgnoreCase(node.attributes, "loading");
19946
+ if (!loadingAttribute || getJsxPropStringValue(loadingAttribute)?.toLowerCase() !== "lazy") return;
19947
+ const fetchPriorityAttribute = hasJsxPropIgnoreCase(node.attributes, "fetchPriority");
19948
+ if (!fetchPriorityAttribute || getJsxPropStringValue(fetchPriorityAttribute)?.toLowerCase() !== "high") return;
19949
+ context.report({
19950
+ node: node.name,
19951
+ message: MESSAGE$20
19952
+ });
19953
+ } })
19954
+ });
19955
+ //#endregion
19559
19956
  //#region src/plugin/rules/state-and-effects/no-initialize-state.ts
19560
19957
  const noInitializeState = defineRule({
19561
19958
  id: "no-initialize-state",
@@ -19785,6 +20182,29 @@ const noIsMounted = defineRule({
19785
20182
  } })
19786
20183
  });
19787
20184
  //#endregion
20185
+ //#region src/plugin/rules/js-performance/no-json-parse-stringify-clone.ts
20186
+ const MESSAGE$19 = "`JSON.parse(JSON.stringify(x))` deep-clones by re-serializing: it is slow on large objects and silently drops `undefined`, functions, `Date`/`Map`/`Set`, and cyclic references. Use `structuredClone(x)`.";
20187
+ const isJsonMethodCall = (node, method) => {
20188
+ if (!isNodeOfType(node, "CallExpression")) return false;
20189
+ const callee = node.callee;
20190
+ return isNodeOfType(callee, "MemberExpression") && !callee.computed && isNodeOfType(callee.object, "Identifier") && callee.object.name === "JSON" && isNodeOfType(callee.property, "Identifier") && callee.property.name === method;
20191
+ };
20192
+ const noJsonParseStringifyClone = defineRule({
20193
+ id: "no-json-parse-stringify-clone",
20194
+ title: "JSON parse/stringify deep clone",
20195
+ severity: "warn",
20196
+ recommendation: "Replace `JSON.parse(JSON.stringify(value))` with `structuredClone(value)`. It is faster and preserves Dates, Maps, Sets, and cyclic references.",
20197
+ create: (context) => ({ CallExpression(node) {
20198
+ if (!isJsonMethodCall(node, "parse")) return;
20199
+ const firstArgument = node.arguments?.[0];
20200
+ if (!firstArgument || !isJsonMethodCall(firstArgument, "stringify")) return;
20201
+ context.report({
20202
+ node,
20203
+ message: MESSAGE$19
20204
+ });
20205
+ } })
20206
+ });
20207
+ //#endregion
19788
20208
  //#region src/plugin/rules/correctness/no-jsx-element-type.ts
19789
20209
  const MESSAGE$18 = "`JSX.Element` is too narrow: it excludes `null`, strings, numbers, and fragments that components commonly return. Use `React.ReactNode` instead.";
19790
20210
  const isJsxElementTypeReference = (node) => {
@@ -20107,9 +20527,6 @@ const noLongTransitionDuration = defineRule({
20107
20527
  const BOOLEAN_PROP_PREFIX_PATTERN = /^(?:is|has|should|can|show|hide|enable|disable|with)[A-Z]/;
20108
20528
  const isBooleanPrefixedPropName = (propName) => BOOLEAN_PROP_PREFIX_PATTERN.test(propName);
20109
20529
  //#endregion
20110
- //#region src/plugin/utils/is-component-declaration.ts
20111
- const isComponentDeclaration = (node) => isNodeOfType(node, "FunctionDeclaration") && node.id !== null && Boolean(node.id?.name) && isUppercaseName(node.id.name);
20112
- //#endregion
20113
20530
  //#region src/plugin/rules/architecture/no-many-boolean-props.ts
20114
20531
  const collectBooleanLikePropsFromBody = (componentBody, propsParamName) => {
20115
20532
  const found = /* @__PURE__ */ new Set();
@@ -27035,6 +27452,8 @@ const publicEnvSecretName = defineRule({
27035
27452
  });
27036
27453
  //#endregion
27037
27454
  //#region src/plugin/rules/tanstack-query/query-destructure-result.ts
27455
+ const TANSTACK_QUERY_PACKAGE_PATTERN = /^@tanstack\/[\w-]*query[\w-]*$/;
27456
+ const isTanstackQuerySource = (source) => TANSTACK_QUERY_PACKAGE_PATTERN.test(source) || source === "react-query";
27038
27457
  const queryDestructureResult = defineRule({
27039
27458
  id: "query-destructure-result",
27040
27459
  title: "Whole query result subscribes to every field",
@@ -27047,6 +27466,8 @@ const queryDestructureResult = defineRule({
27047
27466
  if (!node.init || !isNodeOfType(node.init, "CallExpression")) return;
27048
27467
  const calleeName = isNodeOfType(node.init.callee, "Identifier") ? node.init.callee.name : null;
27049
27468
  if (!calleeName || !TANSTACK_QUERY_HOOKS.has(calleeName)) return;
27469
+ const importSource = getImportSourceForName(node, calleeName);
27470
+ if (importSource !== null && !isTanstackQuerySource(importSource)) return;
27050
27471
  context.report({
27051
27472
  node: node.id,
27052
27473
  message: `Destructure ${calleeName}() results instead of assigning the whole query object, so TanStack Query only subscribes to the fields you use.`
@@ -27889,6 +28310,7 @@ const repositorySecretFile = defineRule({
27889
28310
  id: "repository-secret-file",
27890
28311
  title: "Secret file checked into repository",
27891
28312
  severity: "error",
28313
+ committedFilesOnly: true,
27892
28314
  recommendation: "Remove committed env files, service-account credentials, npm auth tokens, and webhook URLs; rotate exposed values and keep only redacted examples in source.",
27893
28315
  scan: (file) => {
27894
28316
  if (!isRepositorySecretFilePath(file.relativePath)) return [];
@@ -27905,6 +28327,20 @@ const repositorySecretFile = defineRule({
27905
28327
  }
27906
28328
  });
27907
28329
  //#endregion
28330
+ //#region src/plugin/rules/security-scan/request-body-mass-assignment.ts
28331
+ const REQUEST_INPUT_SOURCE = "(?:req|request|ctx\\.req|ctx\\.request)\\.(?:body|query|params)|await\\s+(?:req|request)\\.json\\(\\s*\\)";
28332
+ const requestBodyMassAssignment = defineRule({
28333
+ id: "request-body-mass-assignment",
28334
+ title: "Request input spread without field allowlist",
28335
+ severity: "warn",
28336
+ recommendation: "Assign explicit, allowlisted fields (or validate with a strict schema and no `.passthrough()`) instead of spreading/merging request input. Otherwise the client can set ownership, role, or price columns (mass assignment) or pollute the prototype.",
28337
+ scan: scanByPattern({
28338
+ shouldScan: (file) => isProductionSourcePath(file.relativePath),
28339
+ pattern: [new RegExp(`\\.\\.\\.\\s*(?:${REQUEST_INPUT_SOURCE})`, "i"), new RegExp(`(?:Object\\.assign\\s*\\(|_\\.(?:merge|mergeWith|defaultsDeep)\\s*\\(|(?:^|[^.\\w])(?:merge|defaultsDeep)\\s*\\()[\\s\\S]{0,80}?(?:${REQUEST_INPUT_SOURCE})`, "i")],
28340
+ message: "Request input is spread or merged into an object without a field allowlist, enabling mass assignment (client-set owner/role/price fields) or prototype pollution."
28341
+ })
28342
+ });
28343
+ //#endregion
27908
28344
  //#region src/plugin/utils/function-body-has-return-with-value.ts
27909
28345
  const functionBodyHasReturnWithValue = (functionNode) => {
27910
28346
  if (functionNode.type === "ArrowFunctionExpression" && "body" in functionNode) {
@@ -34330,6 +34766,17 @@ const scope = defineRule({
34330
34766
  });
34331
34767
  } })
34332
34768
  });
34769
+ const secretInFallback = defineRule({
34770
+ id: "secret-in-fallback",
34771
+ title: "Hardcoded secret fallback for env var",
34772
+ severity: "error",
34773
+ recommendation: "Remove the literal fallback and fail closed (throw when the variable is unset). The hardcoded value is a committed secret, and the `??`/`||` default makes the app run with it in any environment that forgot to set the var.",
34774
+ scan: scanByPattern({
34775
+ shouldScan: (file) => isProductionSourcePath(file.relativePath),
34776
+ pattern: /\bprocess\.env\.(?![A-Z0-9_]*(?:PUBLIC|PUBLISHABLE|ANON)\b)[A-Z][A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|API_?KEY|APIKEY|ACCESS_KEY|CLIENT_SECRET|CREDENTIAL|SIGNING_KEY|ENCRYPTION_KEY|WEBHOOK_SECRET|SERVICE_ROLE)[A-Z0-9_]*(?<!_(?:NAME|HEADER|ENDPOINT|URL|URI|ID|PREFIX|SUFFIX|PARAM|PARAMS|FIELD|ISSUER|AUDIENCE|ALGORITHM|ALG|REGION|BUCKET|HOST|HOSTNAME|PORT|PATH|VERSION|SCOPE|TYPE|FORMAT|EXPIRY|TTL))\s*(?:\?\?|\|\|)\s*(["'`])(?!(?:changeme|change[_-]?me|placeholder|your[_-]|example|sample|dummy|development|local|todo|replace[_-]?me|https?:\/\/|x{3,}|\*{3,}))[^"'`\n]{8,}\1/i,
34777
+ message: "A secret env var has a hardcoded string fallback: the literal is a committed secret and the app fails open (uses it) when the variable is unset."
34778
+ })
34779
+ });
34333
34780
  //#endregion
34334
34781
  //#region src/plugin/rules/react-builtins/self-closing-comp.ts
34335
34782
  const MESSAGE$2 = "This tag has no children, so the closing tag adds noise without changing output.";
@@ -35139,8 +35586,11 @@ const supabaseClientOwnedAuthzField = defineRule({
35139
35586
  })
35140
35587
  });
35141
35588
  //#endregion
35589
+ //#region src/plugin/rules/security-scan/utils/is-supabase-migration-path.ts
35590
+ const isSupabaseMigrationPath = (relativePath) => /(?:^|\/)supabase\/(?:migrations|schemas)\//.test(relativePath);
35591
+ //#endregion
35142
35592
  //#region src/plugin/rules/security-scan/utils/is-sql-path.ts
35143
- const isSqlPath = (relativePath) => relativePath.endsWith(".sql") || /(?:^|\/)supabase\/(?:migrations|schemas)\//.test(relativePath);
35593
+ const isSqlPath = (relativePath) => relativePath.endsWith(".sql") || isSupabaseMigrationPath(relativePath);
35144
35594
  const supabaseRlsPolicyRisk = defineRule({
35145
35595
  id: "supabase-rls-policy-risk",
35146
35596
  title: "Permissive Supabase RLS policy",
@@ -35158,6 +35608,210 @@ const supabaseRlsPolicyRisk = defineRule({
35158
35608
  })
35159
35609
  });
35160
35610
  //#endregion
35611
+ //#region src/plugin/rules/security-scan/utils/sanitize-sql-for-scan.ts
35612
+ const DOLLAR_QUOTE_TAG_PATTERN = /^\$[A-Za-z_]?\w*\$/;
35613
+ const CODE_BODY_KEYWORDS = new Set([
35614
+ "do",
35615
+ "plpgsql",
35616
+ "sql",
35617
+ "plpython3u",
35618
+ "plpythonu",
35619
+ "plperl",
35620
+ "plperlu",
35621
+ "plv8"
35622
+ ]);
35623
+ const precedingKeyword = (content, beforeIndex) => {
35624
+ let lookBack = beforeIndex - 1;
35625
+ while (lookBack >= 0 && /\s/.test(content[lookBack] ?? "")) lookBack -= 1;
35626
+ let wordStart = lookBack;
35627
+ while (wordStart >= 0 && /[A-Za-z0-9_]/.test(content[wordStart] ?? "")) wordStart -= 1;
35628
+ return content.slice(wordStart + 1, lookBack + 1).toLowerCase();
35629
+ };
35630
+ const blankCodeBodyInterior = (content, characters, start, end) => {
35631
+ let index = start;
35632
+ let inExecuteStatement = false;
35633
+ while (index < end) {
35634
+ const character = content[index];
35635
+ if (character === ";") {
35636
+ inExecuteStatement = false;
35637
+ index += 1;
35638
+ continue;
35639
+ }
35640
+ if (/[A-Za-z_]/.test(character)) {
35641
+ let wordEnd = index;
35642
+ while (wordEnd < end && /[A-Za-z0-9_]/.test(content[wordEnd] ?? "")) wordEnd += 1;
35643
+ if (content.slice(index, wordEnd).toLowerCase() === "execute") inExecuteStatement = true;
35644
+ index = wordEnd;
35645
+ continue;
35646
+ }
35647
+ if (character === "'") {
35648
+ const keepVisible = inExecuteStatement;
35649
+ if (!keepVisible) characters[index] = " ";
35650
+ index += 1;
35651
+ while (index < end) {
35652
+ if (content[index] === "'") {
35653
+ if (content[index + 1] === "'") {
35654
+ if (!keepVisible) {
35655
+ characters[index] = " ";
35656
+ characters[index + 1] = " ";
35657
+ }
35658
+ index += 2;
35659
+ continue;
35660
+ }
35661
+ if (!keepVisible) characters[index] = " ";
35662
+ index += 1;
35663
+ break;
35664
+ }
35665
+ if (!keepVisible && content[index] !== "\n") characters[index] = " ";
35666
+ index += 1;
35667
+ }
35668
+ continue;
35669
+ }
35670
+ if (character === "\"") {
35671
+ index += 1;
35672
+ while (index < end) {
35673
+ if (content[index] === "\"") {
35674
+ if (content[index + 1] === "\"") {
35675
+ index += 2;
35676
+ continue;
35677
+ }
35678
+ index += 1;
35679
+ break;
35680
+ }
35681
+ index += 1;
35682
+ }
35683
+ continue;
35684
+ }
35685
+ if (character === "-" && content[index + 1] === "-") {
35686
+ while (index < end && content[index] !== "\n") {
35687
+ characters[index] = " ";
35688
+ index += 1;
35689
+ }
35690
+ continue;
35691
+ }
35692
+ if (character === "/" && content[index + 1] === "*") {
35693
+ while (index < end) {
35694
+ if (content[index] === "*" && content[index + 1] === "/") {
35695
+ characters[index] = " ";
35696
+ characters[index + 1] = " ";
35697
+ index += 2;
35698
+ break;
35699
+ }
35700
+ if (content[index] !== "\n") characters[index] = " ";
35701
+ index += 1;
35702
+ }
35703
+ continue;
35704
+ }
35705
+ index += 1;
35706
+ }
35707
+ };
35708
+ const sanitizeSqlForScan = (content) => {
35709
+ const characters = content.split("");
35710
+ let index = 0;
35711
+ while (index < content.length) {
35712
+ const character = content[index];
35713
+ if (character === "-" && content[index + 1] === "-") {
35714
+ while (index < content.length && content[index] !== "\n") {
35715
+ characters[index] = " ";
35716
+ index += 1;
35717
+ }
35718
+ continue;
35719
+ }
35720
+ if (character === "/" && content[index + 1] === "*") {
35721
+ while (index < content.length) {
35722
+ if (content[index] === "*" && content[index + 1] === "/") {
35723
+ characters[index] = " ";
35724
+ characters[index + 1] = " ";
35725
+ index += 2;
35726
+ break;
35727
+ }
35728
+ if (content[index] !== "\n") characters[index] = " ";
35729
+ index += 1;
35730
+ }
35731
+ continue;
35732
+ }
35733
+ if (character === "'") {
35734
+ characters[index] = " ";
35735
+ index += 1;
35736
+ while (index < content.length) {
35737
+ if (content[index] === "'") {
35738
+ if (content[index + 1] === "'") {
35739
+ characters[index] = " ";
35740
+ characters[index + 1] = " ";
35741
+ index += 2;
35742
+ continue;
35743
+ }
35744
+ characters[index] = " ";
35745
+ index += 1;
35746
+ break;
35747
+ }
35748
+ if (content[index] !== "\n") characters[index] = " ";
35749
+ index += 1;
35750
+ }
35751
+ continue;
35752
+ }
35753
+ if (character === "$") {
35754
+ const tagMatch = DOLLAR_QUOTE_TAG_PATTERN.exec(content.slice(index));
35755
+ if (tagMatch !== null) {
35756
+ const tag = tagMatch[0];
35757
+ const closeIndex = content.indexOf(tag, index + tag.length);
35758
+ const endIndex = closeIndex < 0 ? content.length : closeIndex + tag.length;
35759
+ const keyword = precedingKeyword(content, index);
35760
+ if (CODE_BODY_KEYWORDS.has(keyword)) blankCodeBodyInterior(content, characters, index + tag.length, endIndex);
35761
+ else for (let blankIndex = index; blankIndex < endIndex; blankIndex += 1) if (content[blankIndex] !== "\n") characters[blankIndex] = " ";
35762
+ index = endIndex;
35763
+ continue;
35764
+ }
35765
+ }
35766
+ if (character === "\"") {
35767
+ index += 1;
35768
+ while (index < content.length) {
35769
+ if (content[index] === "\"") {
35770
+ if (content[index + 1] === "\"") {
35771
+ index += 2;
35772
+ continue;
35773
+ }
35774
+ index += 1;
35775
+ break;
35776
+ }
35777
+ index += 1;
35778
+ }
35779
+ continue;
35780
+ }
35781
+ index += 1;
35782
+ }
35783
+ return characters.join("");
35784
+ };
35785
+ //#endregion
35786
+ //#region src/plugin/rules/security-scan/supabase-table-missing-rls.ts
35787
+ const CREATE_PUBLIC_TABLE_PATTERN = /create\s+(?:unlogged\s+)?table\s+(?:if\s+not\s+exists\s+)?(?!(?:auth|storage|realtime|vault|extensions|graphql|graphql_public|pgbouncer|net|supabase_functions|supabase_migrations|cron|pgsodium|pgmq|information_schema|pg_catalog|pg_temp|private|internal)\s*\.)(?:public\s*\.\s*)?["`]?([A-Za-z_][\w$]*)["`]?(?:\s*\(|\s+as\b)/gi;
35788
+ const enableRlsForTablePattern = (tableName) => new RegExp(`alter\\s+table\\s+(?:if\\s+exists\\s+)?(?:only\\s+)?(?:public\\s*\\.\\s*)?["\`]?${escapeRegExp(tableName)}["\`]?\\s+(?:force\\s+)?enable\\s+row\\s+level\\s+security`, "i");
35789
+ const supabaseTableMissingRls = defineRule({
35790
+ id: "supabase-table-missing-rls",
35791
+ title: "Supabase table created without Row Level Security",
35792
+ severity: "error",
35793
+ recommendation: "Enable RLS in the same migration (`alter table <name> enable row level security;`) and add `auth.uid()`-scoped policies for select/insert/update/delete. A public table without RLS is fully readable and writable with the public anon key.",
35794
+ scan: (file) => {
35795
+ if (!isSupabaseMigrationPath(file.relativePath)) return [];
35796
+ const content = sanitizeSqlForScan(file.content);
35797
+ if (!/create\s+(?:unlogged\s+)?table/i.test(content)) return [];
35798
+ const findings = [];
35799
+ CREATE_PUBLIC_TABLE_PATTERN.lastIndex = 0;
35800
+ for (let match = CREATE_PUBLIC_TABLE_PATTERN.exec(content); match !== null; match = CREATE_PUBLIC_TABLE_PATTERN.exec(content)) {
35801
+ const tableName = match[1];
35802
+ if (tableName === void 0) continue;
35803
+ if (enableRlsForTablePattern(tableName).test(content.slice(match.index))) continue;
35804
+ const location = getLocationAtIndex(content, match.index);
35805
+ findings.push({
35806
+ message: "Supabase migration creates a public table but never enables Row Level Security, leaving every row exposed to the anon key.",
35807
+ line: location.line,
35808
+ column: location.column
35809
+ });
35810
+ }
35811
+ return findings;
35812
+ }
35813
+ });
35814
+ //#endregion
35161
35815
  //#region src/plugin/rules/security-scan/svg-filter-clickjacking-risk.ts
35162
35816
  const svgFilterClickjackingRisk = defineRule({
35163
35817
  id: "svg-filter-clickjacking-risk",
@@ -35856,6 +36510,47 @@ const tenantStaticProxyRisk = defineRule({
35856
36510
  })
35857
36511
  });
35858
36512
  //#endregion
36513
+ //#region src/plugin/rules/security-scan/unsafe-json-in-html.ts
36514
+ const SINK_JSON_STRINGIFY_PATTERNS = [/dangerouslySetInnerHTML\s*=\s*\{\{\s*__html\s*:[\s\S]{0,300}?\bJSON\.stringify\s*\(/gi, /<script\b[^>]*>(?:(?!<\/script>)[\s\S]){0,300}?\bJSON\.stringify\s*\(/gi];
36515
+ const RETURN_ESCAPE_PATTERN = /^[\s)]*\.replace\s*\([^)]*(?:\\u003[cC]|&lt;|<)/;
36516
+ const ESCAPE_WRAPPER_PATTERN = /(?:\b(?:escapeHtml|escapeJSON|escapeJson|htmlEscape|jsesc)|(?<![.\w])(?:serialize|serializeJavascript|devalue|uneval|superjson))\s*\(\s*$/i;
36517
+ const JSON_STRINGIFY_TOKEN_PATTERN = /\bJSON\.stringify\s*\($/i;
36518
+ const RETURN_LOOKAHEAD_CHARS = 160;
36519
+ const unsafeJsonInHtml = defineRule({
36520
+ id: "unsafe-json-in-html",
36521
+ title: "Unescaped JSON in HTML or script sink",
36522
+ severity: "warn",
36523
+ recommendation: "JSON.stringify does not HTML-escape, so a `<\/script>` (or `<`) in the data breaks out and becomes XSS. Use an HTML-safe serializer (serialize-javascript, devalue) or escape `<`, `>`, and `&`, or pass data via a JSON `<script type=\"application/json\">` read with JSON.parse.",
36524
+ scan: (file) => {
36525
+ if (!isProductionSourcePath(file.relativePath)) return [];
36526
+ const content = getScannableContent(file);
36527
+ if (!content.includes("JSON.stringify")) return [];
36528
+ const findings = [];
36529
+ const seenIndices = /* @__PURE__ */ new Set();
36530
+ for (const pattern of SINK_JSON_STRINGIFY_PATTERNS) {
36531
+ pattern.lastIndex = 0;
36532
+ for (let match = pattern.exec(content); match !== null; match = pattern.exec(content)) {
36533
+ const beforeStringify = match[0].replace(JSON_STRINGIFY_TOKEN_PATTERN, "");
36534
+ if (ESCAPE_WRAPPER_PATTERN.test(beforeStringify)) continue;
36535
+ const closeParenIndex = findMatchingBracket(content, match.index + match[0].length - 1);
36536
+ if (closeParenIndex >= 0) {
36537
+ const afterReturn = content.slice(closeParenIndex + 1, closeParenIndex + 1 + RETURN_LOOKAHEAD_CHARS);
36538
+ if (RETURN_ESCAPE_PATTERN.test(afterReturn)) continue;
36539
+ }
36540
+ if (seenIndices.has(match.index)) continue;
36541
+ seenIndices.add(match.index);
36542
+ const location = getLocationAtIndex(content, match.index);
36543
+ findings.push({
36544
+ message: "JSON.stringify is embedded in HTML/script markup without HTML-escaping; data containing `<\/script>` or `<` breaks out and becomes XSS.",
36545
+ line: location.line,
36546
+ column: location.column
36547
+ });
36548
+ }
36549
+ }
36550
+ return findings;
36551
+ }
36552
+ });
36553
+ //#endregion
35859
36554
  //#region src/plugin/rules/security-scan/untrusted-redirect-following.ts
35860
36555
  const OUTBOUND_FETCH_CALL_PATTERN = /(?:(?<![.\w$])fetch|\baxios\.\s*(?:get|post|put|delete|head)|\bgot|\bgot\.\s*(?:get|post))\s*\(\s*([^,)]+)/;
35861
36556
  const CALLER_STYLE_URL_NAME_PATTERN = /\b(?:url|targetUrl|callbackUrl|redirectUrl|webhookUrl|companyUrl|websiteUrl|domainUrl|imageUrl|fetchUrl|next|return_to|returnTo|destination|location)\b/i;
@@ -36003,7 +36698,7 @@ const voidDomElementsNoChildren = defineRule({
36003
36698
  //#region src/plugin/rules/security-scan/webhook-signature-risk.ts
36004
36699
  const WEBHOOK_HANDLER_PATTERN = /(?:^|\/)[^/]*webhook[^/]*\/|(?:^|\/)[^/]*webhook[^/]*\.[cm]?[jt]s$|\bwebhook\b/i;
36005
36700
  const WEBHOOK_ENTRYPOINT_PATTERN = /\b(?:export\s+(?:async\s+)?function\s+POST|export\s+const\s+(?:POST|handler|webhook)|webhookHandler|webhookRoute)\b/i;
36006
- const WEBHOOK_SIGNATURE_VERIFICATION_PATTERN = /verifySignature|verify.*signature|verify\w*(?:Webhook|Auth)|constructEvent|createHmac|timingSafeEqual|svix|webhookSecret|stripe\.webhooks|["'][\w-]*signature["']/i;
36701
+ const WEBHOOK_SIGNATURE_VERIFICATION_PATTERN = new RegExp(`${/verifySignature|verify.*signature|verify\w*(?:Webhook|Auth)|constructEvent|createHmac|timingSafeEqual|svix|webhookSecret|stripe\.webhooks|["'][\w-]*signature["']/.source}|${/\b[A-Za-z]{0,40}(?:verif|valid|check|assert|authenticat|compare|guard)[A-Za-z]{0,40}(?:secret|signature|hmac|webhook|digest)[A-Za-z]{0,40}\s*\(/.source}`, "i");
36007
36702
  const OUTBOUND_WEBHOOK_URL_MENTION_PATTERN = /webhook[\s_-]?ur[il]\w*/gi;
36008
36703
  const OUTBOUND_WEBHOOK_CONFIG_PATTERN = /process\.env\.\w*WEBHOOK_URL|\b(?:send|post|dispatch|publish|notify)\w*Webhook/;
36009
36704
  const REQUEST_READ_PATTERN = /\b(?:req|request)\b/;
@@ -36663,6 +37358,17 @@ const reactDoctorRules = [
36663
37358
  category: "Performance"
36664
37359
  }
36665
37360
  },
37361
+ {
37362
+ key: "react-doctor/auth-token-in-web-storage",
37363
+ id: "auth-token-in-web-storage",
37364
+ source: "react-doctor",
37365
+ originallyExternal: false,
37366
+ rule: {
37367
+ ...authTokenInWebStorage,
37368
+ framework: "global",
37369
+ category: "Security"
37370
+ }
37371
+ },
36666
37372
  {
36667
37373
  key: "react-doctor/autocomplete-valid",
36668
37374
  id: "autocomplete-valid",
@@ -36879,6 +37585,18 @@ const reactDoctorRules = [
36879
37585
  requires: [...new Set(["react", ...noVagueButtonLabel.requires ?? []])]
36880
37586
  }
36881
37587
  },
37588
+ {
37589
+ key: "react-doctor/dialog-has-accessible-name",
37590
+ id: "dialog-has-accessible-name",
37591
+ source: "react-doctor",
37592
+ originallyExternal: false,
37593
+ rule: {
37594
+ ...dialogHasAccessibleName,
37595
+ framework: "global",
37596
+ category: "Accessibility",
37597
+ requires: [...new Set(["react", ...dialogHasAccessibleName.requires ?? []])]
37598
+ }
37599
+ },
36882
37600
  {
36883
37601
  key: "react-doctor/display-name",
36884
37602
  id: "display-name",
@@ -37164,6 +37882,18 @@ const reactDoctorRules = [
37164
37882
  tags: [...new Set(["security-scan", ...insecureCryptoRisk.tags ?? []])]
37165
37883
  }
37166
37884
  },
37885
+ {
37886
+ key: "react-doctor/insecure-session-cookie",
37887
+ id: "insecure-session-cookie",
37888
+ source: "react-doctor",
37889
+ originallyExternal: false,
37890
+ rule: {
37891
+ ...insecureSessionCookie,
37892
+ framework: "global",
37893
+ category: "Security",
37894
+ tags: [...new Set(["security-scan", ...insecureSessionCookie.tags ?? []])]
37895
+ }
37896
+ },
37167
37897
  {
37168
37898
  key: "react-doctor/interactive-supports-focus",
37169
37899
  id: "interactive-supports-focus",
@@ -37606,6 +38336,18 @@ const reactDoctorRules = [
37606
38336
  requires: [...new Set(["react", ...jsxPropsNoSpreading.requires ?? []])]
37607
38337
  }
37608
38338
  },
38339
+ {
38340
+ key: "react-doctor/jwt-insecure-verification",
38341
+ id: "jwt-insecure-verification",
38342
+ source: "react-doctor",
38343
+ originallyExternal: false,
38344
+ rule: {
38345
+ ...jwtInsecureVerification,
38346
+ framework: "global",
38347
+ category: "Security",
38348
+ tags: [...new Set(["security-scan", ...jwtInsecureVerification.tags ?? []])]
38349
+ }
38350
+ },
37609
38351
  {
37610
38352
  key: "react-doctor/key-lifecycle-risk",
37611
38353
  id: "key-lifecycle-risk",
@@ -38014,6 +38756,18 @@ const reactDoctorRules = [
38014
38756
  requires: [...new Set(["react", ...noArrayIndexKey.requires ?? []])]
38015
38757
  }
38016
38758
  },
38759
+ {
38760
+ key: "react-doctor/no-async-effect-callback",
38761
+ id: "no-async-effect-callback",
38762
+ source: "react-doctor",
38763
+ originallyExternal: false,
38764
+ rule: {
38765
+ ...noAsyncEffectCallback,
38766
+ framework: "global",
38767
+ category: "Bugs",
38768
+ requires: [...new Set(["react", ...noAsyncEffectCallback.requires ?? []])]
38769
+ }
38770
+ },
38017
38771
  {
38018
38772
  key: "react-doctor/no-autofocus",
38019
38773
  id: "no-autofocus",
@@ -38037,6 +38791,18 @@ const reactDoctorRules = [
38037
38791
  category: "Performance"
38038
38792
  }
38039
38793
  },
38794
+ {
38795
+ key: "react-doctor/no-call-component-as-function",
38796
+ id: "no-call-component-as-function",
38797
+ source: "react-doctor",
38798
+ originallyExternal: false,
38799
+ rule: {
38800
+ ...noCallComponentAsFunction,
38801
+ framework: "global",
38802
+ category: "Bugs",
38803
+ requires: [...new Set(["react", ...noCallComponentAsFunction.requires ?? []])]
38804
+ }
38805
+ },
38040
38806
  {
38041
38807
  key: "react-doctor/no-cascading-set-state",
38042
38808
  id: "no-cascading-set-state",
@@ -38097,6 +38863,18 @@ const reactDoctorRules = [
38097
38863
  requires: [...new Set(["react", ...noCreateContextInRender.requires ?? []])]
38098
38864
  }
38099
38865
  },
38866
+ {
38867
+ key: "react-doctor/no-create-ref-in-function-component",
38868
+ id: "no-create-ref-in-function-component",
38869
+ source: "react-doctor",
38870
+ originallyExternal: false,
38871
+ rule: {
38872
+ ...noCreateRefInFunctionComponent,
38873
+ framework: "global",
38874
+ category: "Bugs",
38875
+ requires: [...new Set(["react", ...noCreateRefInFunctionComponent.requires ?? []])]
38876
+ }
38877
+ },
38100
38878
  {
38101
38879
  key: "react-doctor/no-create-store-in-render",
38102
38880
  id: "no-create-store-in-render",
@@ -38471,6 +39249,18 @@ const reactDoctorRules = [
38471
39249
  category: "Accessibility"
38472
39250
  }
38473
39251
  },
39252
+ {
39253
+ key: "react-doctor/no-img-lazy-with-high-fetchpriority",
39254
+ id: "no-img-lazy-with-high-fetchpriority",
39255
+ source: "react-doctor",
39256
+ originallyExternal: false,
39257
+ rule: {
39258
+ ...noImgLazyWithHighFetchpriority,
39259
+ framework: "global",
39260
+ category: "Performance",
39261
+ requires: [...new Set(["react", ...noImgLazyWithHighFetchpriority.requires ?? []])]
39262
+ }
39263
+ },
38474
39264
  {
38475
39265
  key: "react-doctor/no-initialize-state",
38476
39266
  id: "no-initialize-state",
@@ -38541,6 +39331,17 @@ const reactDoctorRules = [
38541
39331
  requires: [...new Set(["react", ...noIsMounted.requires ?? []])]
38542
39332
  }
38543
39333
  },
39334
+ {
39335
+ key: "react-doctor/no-json-parse-stringify-clone",
39336
+ id: "no-json-parse-stringify-clone",
39337
+ source: "react-doctor",
39338
+ originallyExternal: false,
39339
+ rule: {
39340
+ ...noJsonParseStringifyClone,
39341
+ framework: "global",
39342
+ category: "Performance"
39343
+ }
39344
+ },
38544
39345
  {
38545
39346
  key: "react-doctor/no-jsx-element-type",
38546
39347
  id: "no-jsx-element-type",
@@ -39756,6 +40557,18 @@ const reactDoctorRules = [
39756
40557
  tags: [...new Set(["security-scan", ...repositorySecretFile.tags ?? []])]
39757
40558
  }
39758
40559
  },
40560
+ {
40561
+ key: "react-doctor/request-body-mass-assignment",
40562
+ id: "request-body-mass-assignment",
40563
+ source: "react-doctor",
40564
+ originallyExternal: false,
40565
+ rule: {
40566
+ ...requestBodyMassAssignment,
40567
+ framework: "global",
40568
+ category: "Security",
40569
+ tags: [...new Set(["security-scan", ...requestBodyMassAssignment.tags ?? []])]
40570
+ }
40571
+ },
39759
40572
  {
39760
40573
  key: "react-doctor/require-render-return",
39761
40574
  id: "require-render-return",
@@ -40344,6 +41157,18 @@ const reactDoctorRules = [
40344
41157
  requires: [...new Set(["react", ...scope.requires ?? []])]
40345
41158
  }
40346
41159
  },
41160
+ {
41161
+ key: "react-doctor/secret-in-fallback",
41162
+ id: "secret-in-fallback",
41163
+ source: "react-doctor",
41164
+ originallyExternal: false,
41165
+ rule: {
41166
+ ...secretInFallback,
41167
+ framework: "global",
41168
+ category: "Security",
41169
+ tags: [...new Set(["security-scan", ...secretInFallback.tags ?? []])]
41170
+ }
41171
+ },
40347
41172
  {
40348
41173
  key: "react-doctor/self-closing-comp",
40349
41174
  id: "self-closing-comp",
@@ -40500,6 +41325,18 @@ const reactDoctorRules = [
40500
41325
  tags: [...new Set(["security-scan", ...supabaseRlsPolicyRisk.tags ?? []])]
40501
41326
  }
40502
41327
  },
41328
+ {
41329
+ key: "react-doctor/supabase-table-missing-rls",
41330
+ id: "supabase-table-missing-rls",
41331
+ source: "react-doctor",
41332
+ originallyExternal: false,
41333
+ rule: {
41334
+ ...supabaseTableMissingRls,
41335
+ framework: "global",
41336
+ category: "Security",
41337
+ tags: [...new Set(["security-scan", ...supabaseTableMissingRls.tags ?? []])]
41338
+ }
41339
+ },
40503
41340
  {
40504
41341
  key: "react-doctor/svg-filter-clickjacking-risk",
40505
41342
  id: "svg-filter-clickjacking-risk",
@@ -40690,6 +41527,18 @@ const reactDoctorRules = [
40690
41527
  tags: [...new Set(["security-scan", ...tenantStaticProxyRisk.tags ?? []])]
40691
41528
  }
40692
41529
  },
41530
+ {
41531
+ key: "react-doctor/unsafe-json-in-html",
41532
+ id: "unsafe-json-in-html",
41533
+ source: "react-doctor",
41534
+ originallyExternal: false,
41535
+ rule: {
41536
+ ...unsafeJsonInHtml,
41537
+ framework: "global",
41538
+ category: "Security",
41539
+ tags: [...new Set(["security-scan", ...unsafeJsonInHtml.tags ?? []])]
41540
+ }
41541
+ },
40693
41542
  {
40694
41543
  key: "react-doctor/untrusted-redirect-following",
40695
41544
  id: "untrusted-redirect-following",