oxlint-plugin-react-doctor 0.5.8-dev.80e3093 → 0.5.8-dev.8232e96
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.
- package/dist/index.js +288 -64
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1160,6 +1160,61 @@ const isFirebaseRulesPath = (relativePath) => /(?:^|\/)(?:firestore\.rules|stora
|
|
|
1160
1160
|
//#endregion
|
|
1161
1161
|
//#region src/plugin/rules/security-scan/utils/strip-comments-preserving-positions.ts
|
|
1162
1162
|
const WHITESPACE_PATTERN = /\s/;
|
|
1163
|
+
const IDENTIFIER_CHARACTER_PATTERN = /[\p{ID_Continue}$]/u;
|
|
1164
|
+
const REGEX_PRECEDING_KEYWORDS = new Set([
|
|
1165
|
+
"return",
|
|
1166
|
+
"typeof",
|
|
1167
|
+
"case",
|
|
1168
|
+
"in",
|
|
1169
|
+
"of",
|
|
1170
|
+
"new",
|
|
1171
|
+
"delete",
|
|
1172
|
+
"void",
|
|
1173
|
+
"instanceof",
|
|
1174
|
+
"do",
|
|
1175
|
+
"else",
|
|
1176
|
+
"yield",
|
|
1177
|
+
"await",
|
|
1178
|
+
"throw"
|
|
1179
|
+
]);
|
|
1180
|
+
const isRegexLiteralStart = (characters, slashIndex) => {
|
|
1181
|
+
let cursor = slashIndex - 1;
|
|
1182
|
+
while (cursor >= 0 && WHITESPACE_PATTERN.test(characters[cursor])) cursor -= 1;
|
|
1183
|
+
if (cursor < 0) return true;
|
|
1184
|
+
const previousCharacter = characters[cursor];
|
|
1185
|
+
const characterBefore = cursor > 0 ? characters[cursor - 1] : "";
|
|
1186
|
+
if (IDENTIFIER_CHARACTER_PATTERN.test(previousCharacter)) {
|
|
1187
|
+
let wordStartIndex = cursor;
|
|
1188
|
+
while (wordStartIndex > 0 && IDENTIFIER_CHARACTER_PATTERN.test(characters[wordStartIndex - 1])) wordStartIndex -= 1;
|
|
1189
|
+
if (wordStartIndex > 0 && characters[wordStartIndex - 1] === ".") return false;
|
|
1190
|
+
return REGEX_PRECEDING_KEYWORDS.has(characters.slice(wordStartIndex, cursor + 1).join(""));
|
|
1191
|
+
}
|
|
1192
|
+
if (previousCharacter === "<") return false;
|
|
1193
|
+
if (previousCharacter === ">") return characterBefore === "=";
|
|
1194
|
+
if (previousCharacter === "+" || previousCharacter === "-") return characterBefore !== previousCharacter;
|
|
1195
|
+
if (previousCharacter === "!") return !(IDENTIFIER_CHARACTER_PATTERN.test(characterBefore) || characterBefore === ")" || characterBefore === "]");
|
|
1196
|
+
return !(previousCharacter === ")" || previousCharacter === "]" || previousCharacter === "}" || previousCharacter === "\"" || previousCharacter === "'" || previousCharacter === "`");
|
|
1197
|
+
};
|
|
1198
|
+
const findRegexLiteralEnd = (content, slashIndex) => {
|
|
1199
|
+
let cursor = slashIndex + 1;
|
|
1200
|
+
let isInsideCharacterClass = false;
|
|
1201
|
+
while (cursor < content.length) {
|
|
1202
|
+
const character = content[cursor];
|
|
1203
|
+
if (character === "\\") {
|
|
1204
|
+
cursor += 2;
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1207
|
+
if (character === "\n") return null;
|
|
1208
|
+
if (character === "[") isInsideCharacterClass = true;
|
|
1209
|
+
else if (character === "]") isInsideCharacterClass = false;
|
|
1210
|
+
else if (character === "/" && !isInsideCharacterClass) {
|
|
1211
|
+
if (content[cursor + 1] === "/") return null;
|
|
1212
|
+
return cursor + 1;
|
|
1213
|
+
}
|
|
1214
|
+
cursor += 1;
|
|
1215
|
+
}
|
|
1216
|
+
return null;
|
|
1217
|
+
};
|
|
1163
1218
|
const quotedLiteralHasWhitespace = (content, openQuoteIndex, delimiter) => {
|
|
1164
1219
|
for (let cursor = openQuoteIndex + 1; cursor < content.length; cursor += 1) {
|
|
1165
1220
|
const character = content[cursor];
|
|
@@ -1193,6 +1248,11 @@ const blankNonCodePreservingPositions = (content, blankStringContents) => {
|
|
|
1193
1248
|
index += 2;
|
|
1194
1249
|
continue;
|
|
1195
1250
|
}
|
|
1251
|
+
if (character === "\n" && stringDelimiter !== "`") {
|
|
1252
|
+
stringDelimiter = null;
|
|
1253
|
+
index += 1;
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1196
1256
|
if (character === stringDelimiter) {
|
|
1197
1257
|
stringDelimiter = null;
|
|
1198
1258
|
index += 1;
|
|
@@ -1240,6 +1300,14 @@ const blankNonCodePreservingPositions = (content, blankStringContents) => {
|
|
|
1240
1300
|
}
|
|
1241
1301
|
continue;
|
|
1242
1302
|
}
|
|
1303
|
+
if (character === "/") {
|
|
1304
|
+
const regexEndIndex = isRegexLiteralStart(characters, index) ? findRegexLiteralEnd(content, index) : null;
|
|
1305
|
+
if (regexEndIndex !== null) {
|
|
1306
|
+
if (blankStringContents) for (let interiorIndex = index + 1; interiorIndex < regexEndIndex - 1; interiorIndex += 1) blankUnlessNewline(interiorIndex);
|
|
1307
|
+
index = regexEndIndex;
|
|
1308
|
+
continue;
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1243
1311
|
if (templateExpressionDepths.length > 0) {
|
|
1244
1312
|
const innermost = templateExpressionDepths.length - 1;
|
|
1245
1313
|
if (character === "{") templateExpressionDepths[innermost] += 1;
|
|
@@ -2018,7 +2086,7 @@ const anchorAmbiguousText = defineRule({
|
|
|
2018
2086
|
});
|
|
2019
2087
|
//#endregion
|
|
2020
2088
|
//#region src/plugin/rules/a11y/anchor-has-content.ts
|
|
2021
|
-
const MESSAGE$
|
|
2089
|
+
const MESSAGE$58 = "Blind users can't follow this link because screen readers announce nothing, so add visible text, `aria-label`, or `aria-labelledby`.";
|
|
2022
2090
|
const anchorHasContent = defineRule({
|
|
2023
2091
|
id: "anchor-has-content",
|
|
2024
2092
|
title: "Anchor has no content",
|
|
@@ -2041,7 +2109,7 @@ const anchorHasContent = defineRule({
|
|
|
2041
2109
|
]) if (hasJsxPropIgnoreCase(opening.attributes, attribute)) return;
|
|
2042
2110
|
context.report({
|
|
2043
2111
|
node: opening.name,
|
|
2044
|
-
message: MESSAGE$
|
|
2112
|
+
message: MESSAGE$58
|
|
2045
2113
|
});
|
|
2046
2114
|
} };
|
|
2047
2115
|
}
|
|
@@ -2427,7 +2495,7 @@ const parseJsxValue = (value) => {
|
|
|
2427
2495
|
};
|
|
2428
2496
|
//#endregion
|
|
2429
2497
|
//#region src/plugin/rules/a11y/aria-activedescendant-has-tabindex.ts
|
|
2430
|
-
const MESSAGE$
|
|
2498
|
+
const MESSAGE$57 = "Keyboard users can't focus this element with `aria-activedescendant` because it isn't tabbable, so add `tabIndex={0}`.";
|
|
2431
2499
|
const ariaActivedescendantHasTabindex = defineRule({
|
|
2432
2500
|
id: "aria-activedescendant-has-tabindex",
|
|
2433
2501
|
title: "aria-activedescendant missing tabindex",
|
|
@@ -2445,14 +2513,14 @@ const ariaActivedescendantHasTabindex = defineRule({
|
|
|
2445
2513
|
if (tabIndexValue === null || tabIndexValue >= -1) return;
|
|
2446
2514
|
context.report({
|
|
2447
2515
|
node: node.name,
|
|
2448
|
-
message: MESSAGE$
|
|
2516
|
+
message: MESSAGE$57
|
|
2449
2517
|
});
|
|
2450
2518
|
return;
|
|
2451
2519
|
}
|
|
2452
2520
|
if (isInteractiveElement(tag, node)) return;
|
|
2453
2521
|
context.report({
|
|
2454
2522
|
node: node.name,
|
|
2455
|
-
message: MESSAGE$
|
|
2523
|
+
message: MESSAGE$57
|
|
2456
2524
|
});
|
|
2457
2525
|
} })
|
|
2458
2526
|
});
|
|
@@ -4557,7 +4625,7 @@ const asyncParallel = defineRule({
|
|
|
4557
4625
|
});
|
|
4558
4626
|
//#endregion
|
|
4559
4627
|
//#region src/plugin/rules/security/auth-token-in-web-storage.ts
|
|
4560
|
-
const MESSAGE$
|
|
4628
|
+
const MESSAGE$56 = "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.";
|
|
4561
4629
|
const STORAGE_NAMES = new Set(["localStorage", "sessionStorage"]);
|
|
4562
4630
|
const STORAGE_GLOBALS = new Set([
|
|
4563
4631
|
"window",
|
|
@@ -4598,7 +4666,7 @@ const authTokenInWebStorage = defineRule({
|
|
|
4598
4666
|
if (!isAuthCredentialKey(keyArgument.value)) return;
|
|
4599
4667
|
context.report({
|
|
4600
4668
|
node,
|
|
4601
|
-
message: MESSAGE$
|
|
4669
|
+
message: MESSAGE$56
|
|
4602
4670
|
});
|
|
4603
4671
|
},
|
|
4604
4672
|
AssignmentExpression(node) {
|
|
@@ -4609,7 +4677,7 @@ const authTokenInWebStorage = defineRule({
|
|
|
4609
4677
|
if (!propertyName || !isAuthCredentialKey(propertyName)) return;
|
|
4610
4678
|
context.report({
|
|
4611
4679
|
node: target,
|
|
4612
|
-
message: MESSAGE$
|
|
4680
|
+
message: MESSAGE$56
|
|
4613
4681
|
});
|
|
4614
4682
|
}
|
|
4615
4683
|
}))
|
|
@@ -5099,7 +5167,7 @@ const isPureEventBlockerHandler = (attribute) => {
|
|
|
5099
5167
|
};
|
|
5100
5168
|
//#endregion
|
|
5101
5169
|
//#region src/plugin/rules/a11y/click-events-have-key-events.ts
|
|
5102
|
-
const MESSAGE$
|
|
5170
|
+
const MESSAGE$55 = "Keyboard users can't trigger this click handler because there's no keyboard one, so add `onKeyUp`, `onKeyDown`, or `onKeyPress`.";
|
|
5103
5171
|
const KEY_HANDLERS = [
|
|
5104
5172
|
"onKeyUp",
|
|
5105
5173
|
"onKeyDown",
|
|
@@ -5127,7 +5195,7 @@ const clickEventsHaveKeyEvents = defineRule({
|
|
|
5127
5195
|
if (KEY_HANDLERS.some((handler) => hasJsxPropIgnoreCase(node.attributes, handler))) return;
|
|
5128
5196
|
context.report({
|
|
5129
5197
|
node: node.name,
|
|
5130
|
-
message: MESSAGE$
|
|
5198
|
+
message: MESSAGE$55
|
|
5131
5199
|
});
|
|
5132
5200
|
} };
|
|
5133
5201
|
}
|
|
@@ -5344,7 +5412,7 @@ const getClassNameLiteral = (classAttribute) => {
|
|
|
5344
5412
|
};
|
|
5345
5413
|
//#endregion
|
|
5346
5414
|
//#region src/plugin/rules/a11y/control-has-associated-label.ts
|
|
5347
|
-
const MESSAGE$
|
|
5415
|
+
const MESSAGE$54 = "Blind users can't tell what this control does because screen readers find no label, so add visible text, `aria-label`, or `aria-labelledby`.";
|
|
5348
5416
|
const DISPLAY_NONE_CLASS_TOKEN = "hidden";
|
|
5349
5417
|
const isDisplayNoneClassToken = (token) => token.toLowerCase() === DISPLAY_NONE_CLASS_TOKEN;
|
|
5350
5418
|
const collectStaticTemplateClassTokens = (templateLiteral) => {
|
|
@@ -5539,7 +5607,7 @@ const controlHasAssociatedLabel = defineRule({
|
|
|
5539
5607
|
for (const child of node.children) if (checkChildForLabel(child, 1, checkContext)) return;
|
|
5540
5608
|
context.report({
|
|
5541
5609
|
node: opening,
|
|
5542
|
-
message: MESSAGE$
|
|
5610
|
+
message: MESSAGE$54
|
|
5543
5611
|
});
|
|
5544
5612
|
} };
|
|
5545
5613
|
}
|
|
@@ -5562,7 +5630,7 @@ const corsCookieTrustRisk = defineRule({
|
|
|
5562
5630
|
const DANGEROUS_HTML_PATTERN = /dangerouslySetInnerHTML|\.(?:inner|outer)HTML\s*[+]?=(?!=)|\.insertAdjacentHTML\s*\(|\bdocument\.write(?:ln)?\s*\(|\.(?:createContextualFragment|setHTMLUnsafe)\s*\(/;
|
|
5563
5631
|
const HTML_VALUE_START_PATTERN = /(?:__html\s*:|\.(?:inner|outer)HTML\s*[+]?=(?!=)|\.insertAdjacentHTML\s*\(\s*[^,]*,|\bdocument\.write(?:ln)?\s*\(|\.(?:createContextualFragment|setHTMLUnsafe)\s*\()\s*([\s\S]*)/;
|
|
5564
5632
|
const HTML_TAINT_PATTERN = /searchParams|query|params|request|req\.|response\.|result\.|data\.|await|fetch|props\.|children|content|html|body|text|message|\blocation\b|document\.cookie|\breferrer\b|\blocalStorage\b|\bsessionStorage\b|URLSearchParams|window\.name/i;
|
|
5565
|
-
const STRING_LITERAL_VALUE_PATTERN = /^(?:["'
|
|
5633
|
+
const STRING_LITERAL_VALUE_PATTERN = /^(?:"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`[^`$]*`)\s*(?:\/\/[^\n]*)?\s*(?:[;,})\n]|$)/;
|
|
5566
5634
|
const MODULE_CONSTANT_VALUE_PATTERN = /^[A-Z][A-Z0-9_]*\s*(?:\/\/[^\n]*)?\s*(?:[;,})\n]|$)/;
|
|
5567
5635
|
const DOM_CONTENT_SOURCE_VALUE_PATTERN = /^[\w$]+(?:\??\.[\w$]+)*\??\.(?:inner|outer)HTML\b/;
|
|
5568
5636
|
const SANITIZER_PATTERN = /\b(?:DOMPurify|sanitize\w*|purify|(?:escape|encode)[A-Za-z]*(?:Html|HTML|Entit\w*)|insane|xss)\b|(?<!un)safe|(?<!un)saniti[sz]/i;
|
|
@@ -5994,7 +6062,7 @@ const noVagueButtonLabel = defineRule({
|
|
|
5994
6062
|
});
|
|
5995
6063
|
//#endregion
|
|
5996
6064
|
//#region src/plugin/rules/a11y/dialog-has-accessible-name.ts
|
|
5997
|
-
const MESSAGE$
|
|
6065
|
+
const MESSAGE$53 = "This dialog has no accessible name, so screen readers announce it as just “dialog.” Add `aria-label` or point `aria-labelledby` at its heading.";
|
|
5998
6066
|
const DIALOG_ROLES = new Set(["dialog", "alertdialog"]);
|
|
5999
6067
|
const NAME_PROVIDING_ATTRIBUTES = [
|
|
6000
6068
|
"aria-label",
|
|
@@ -6017,7 +6085,7 @@ const dialogHasAccessibleName = defineRule({
|
|
|
6017
6085
|
if (NAME_PROVIDING_ATTRIBUTES.some((attribute) => hasJsxPropIgnoreCase(node.attributes, attribute))) return;
|
|
6018
6086
|
context.report({
|
|
6019
6087
|
node: node.name,
|
|
6020
|
-
message: MESSAGE$
|
|
6088
|
+
message: MESSAGE$53
|
|
6021
6089
|
});
|
|
6022
6090
|
} })
|
|
6023
6091
|
});
|
|
@@ -6056,7 +6124,7 @@ const isEs6Component = (node) => {
|
|
|
6056
6124
|
};
|
|
6057
6125
|
//#endregion
|
|
6058
6126
|
//#region src/plugin/rules/react-builtins/display-name.ts
|
|
6059
|
-
const MESSAGE$
|
|
6127
|
+
const MESSAGE$52 = "This component shows up as Anonymous in React DevTools because it has no `displayName`.";
|
|
6060
6128
|
const DEFAULT_ADDITIONAL_HOCS = [
|
|
6061
6129
|
"observer",
|
|
6062
6130
|
"lazy",
|
|
@@ -6259,7 +6327,7 @@ const displayName = defineRule({
|
|
|
6259
6327
|
const reportAt = (node) => {
|
|
6260
6328
|
context.report({
|
|
6261
6329
|
node,
|
|
6262
|
-
message: MESSAGE$
|
|
6330
|
+
message: MESSAGE$52
|
|
6263
6331
|
});
|
|
6264
6332
|
};
|
|
6265
6333
|
return {
|
|
@@ -8423,7 +8491,7 @@ const forbidElements = defineRule({
|
|
|
8423
8491
|
});
|
|
8424
8492
|
//#endregion
|
|
8425
8493
|
//#region src/plugin/rules/react-builtins/forward-ref-uses-ref.ts
|
|
8426
|
-
const MESSAGE$
|
|
8494
|
+
const MESSAGE$51 = "The parent can't reach this component's node because the `forwardRef` wrapper ignores `ref`.";
|
|
8427
8495
|
const forwardRefUsesRef = defineRule({
|
|
8428
8496
|
id: "forward-ref-uses-ref",
|
|
8429
8497
|
title: "forwardRef without ref parameter",
|
|
@@ -8443,7 +8511,7 @@ const forwardRefUsesRef = defineRule({
|
|
|
8443
8511
|
if (isNodeOfType(onlyParam, "RestElement")) return;
|
|
8444
8512
|
context.report({
|
|
8445
8513
|
node: inner,
|
|
8446
|
-
message: MESSAGE$
|
|
8514
|
+
message: MESSAGE$51
|
|
8447
8515
|
});
|
|
8448
8516
|
} })
|
|
8449
8517
|
});
|
|
@@ -8480,7 +8548,7 @@ const gitProviderUrlInjectionRisk = defineRule({
|
|
|
8480
8548
|
});
|
|
8481
8549
|
//#endregion
|
|
8482
8550
|
//#region src/plugin/rules/a11y/heading-has-content.ts
|
|
8483
|
-
const MESSAGE$
|
|
8551
|
+
const MESSAGE$50 = "Blind users can't use this heading to navigate because screen readers skip it empty, so add text, `aria-label`, or `aria-labelledby`.";
|
|
8484
8552
|
const DEFAULT_HEADING_TAGS = [
|
|
8485
8553
|
"h1",
|
|
8486
8554
|
"h2",
|
|
@@ -8514,7 +8582,7 @@ const headingHasContent = defineRule({
|
|
|
8514
8582
|
for (const attribute of ["aria-label", "aria-labelledby"]) if (hasJsxPropIgnoreCase(node.attributes, attribute)) return;
|
|
8515
8583
|
context.report({
|
|
8516
8584
|
node,
|
|
8517
|
-
message: MESSAGE$
|
|
8585
|
+
message: MESSAGE$50
|
|
8518
8586
|
});
|
|
8519
8587
|
} };
|
|
8520
8588
|
}
|
|
@@ -8652,7 +8720,7 @@ const hooksNoNanInDeps = defineRule({
|
|
|
8652
8720
|
});
|
|
8653
8721
|
//#endregion
|
|
8654
8722
|
//#region src/plugin/rules/a11y/html-has-lang.ts
|
|
8655
|
-
const MESSAGE$
|
|
8723
|
+
const MESSAGE$49 = "Screen readers may mispronounce this page because it doesn't declare a language, so add a `lang` attribute like `en`.";
|
|
8656
8724
|
const resolveSettings$38 = (settings) => {
|
|
8657
8725
|
const reactDoctor = settings?.["react-doctor"];
|
|
8658
8726
|
return { htmlTags: (typeof reactDoctor === "object" && reactDoctor !== null ? reactDoctor.htmlHasLang ?? {} : {}).htmlTags ?? ["html"] };
|
|
@@ -8699,13 +8767,13 @@ const htmlHasLang = defineRule({
|
|
|
8699
8767
|
if (!lang) {
|
|
8700
8768
|
context.report({
|
|
8701
8769
|
node: node.name,
|
|
8702
|
-
message: MESSAGE$
|
|
8770
|
+
message: MESSAGE$49
|
|
8703
8771
|
});
|
|
8704
8772
|
return;
|
|
8705
8773
|
}
|
|
8706
8774
|
if (evaluateLang(lang.value) === "empty") context.report({
|
|
8707
8775
|
node: lang,
|
|
8708
|
-
message: MESSAGE$
|
|
8776
|
+
message: MESSAGE$49
|
|
8709
8777
|
});
|
|
8710
8778
|
} };
|
|
8711
8779
|
}
|
|
@@ -8925,7 +8993,7 @@ const htmlNoNestedInteractive = defineRule({
|
|
|
8925
8993
|
});
|
|
8926
8994
|
//#endregion
|
|
8927
8995
|
//#region src/plugin/rules/a11y/iframe-has-title.ts
|
|
8928
|
-
const MESSAGE$
|
|
8996
|
+
const MESSAGE$48 = "Screen reader users cannot identify this `<iframe>` because it has no title. Add a `title` that describes its content.";
|
|
8929
8997
|
const evaluateTitleValue = (value) => {
|
|
8930
8998
|
if (!value) return "missing";
|
|
8931
8999
|
if (isNodeOfType(value, "Literal")) {
|
|
@@ -8965,14 +9033,14 @@ const iframeHasTitle = defineRule({
|
|
|
8965
9033
|
if (!titleAttr) {
|
|
8966
9034
|
if (hasSpread || tag === "iframe") context.report({
|
|
8967
9035
|
node: node.name,
|
|
8968
|
-
message: MESSAGE$
|
|
9036
|
+
message: MESSAGE$48
|
|
8969
9037
|
});
|
|
8970
9038
|
return;
|
|
8971
9039
|
}
|
|
8972
9040
|
const verdict = evaluateTitleValue(titleAttr.value);
|
|
8973
9041
|
if (verdict === "missing" || verdict === "empty") context.report({
|
|
8974
9042
|
node: titleAttr,
|
|
8975
|
-
message: MESSAGE$
|
|
9043
|
+
message: MESSAGE$48
|
|
8976
9044
|
});
|
|
8977
9045
|
} })
|
|
8978
9046
|
});
|
|
@@ -9087,7 +9155,7 @@ const iframeMissingSandbox = defineRule({
|
|
|
9087
9155
|
});
|
|
9088
9156
|
//#endregion
|
|
9089
9157
|
//#region src/plugin/rules/a11y/img-redundant-alt.ts
|
|
9090
|
-
const MESSAGE$
|
|
9158
|
+
const MESSAGE$47 = "Screen reader users hear \"image\" or \"photo\" twice because they already announce it, so describe what the image shows instead.";
|
|
9091
9159
|
const DEFAULT_COMPONENTS = ["img"];
|
|
9092
9160
|
const DEFAULT_REDUNDANT_WORDS = [
|
|
9093
9161
|
"image",
|
|
@@ -9152,7 +9220,7 @@ const imgRedundantAlt = defineRule({
|
|
|
9152
9220
|
if (!altAttribute) return;
|
|
9153
9221
|
if (altValueRedundant(altAttribute, settings.words)) context.report({
|
|
9154
9222
|
node: altAttribute,
|
|
9155
|
-
message: MESSAGE$
|
|
9223
|
+
message: MESSAGE$47
|
|
9156
9224
|
});
|
|
9157
9225
|
} };
|
|
9158
9226
|
}
|
|
@@ -9191,7 +9259,7 @@ const DEPRECATED_CIPHER_API_PATTERN = /(?<!cipher\.)\bcreate(?:Cipher|Decipher)\
|
|
|
9191
9259
|
const WEAK_CIPHER_ALGORITHM_PATTERN = /\bcreate(?:Cipher|Decipher)iv\s*\(\s*["'](?:des|des3|des-?ede3?|rc4|rc2|bf|blowfish)\b/i;
|
|
9192
9260
|
const WEAK_CIPHER_NAME_PATTERN = /\b(?:DES|RC4|Blowfish)\b/;
|
|
9193
9261
|
const CIPHER_CONTEXT_PATTERN = /\b(?:cipher|decipher|encrypt|decrypt|crypto)\b/i;
|
|
9194
|
-
const UNSAFE_SIGNATURE_COMPARISON_PATTERN = /[A-Za-z_$][\w$.]
|
|
9262
|
+
const UNSAFE_SIGNATURE_COMPARISON_PATTERN = /[A-Za-z_$][\w$.]{0,100}signature[\w$]*(?:\([^)]*\))?\s*(?:===?|!==?)\s*[A-Za-z_$][\w$.]*(?:\([^)]*\))?|[A-Za-z_$][\w$.]{0,100}(?:\([^)]*\))?\s*(?:===?|!==?)\s*[A-Za-z_$][\w$.]{0,100}signature[\w$]*(?:\([^)]*\))?/i;
|
|
9195
9263
|
const ENUM_MEMBER_COMPARAND_PATTERN = /(?:===?|!==?)\s*[A-Z](?:[a-z]|[A-Z0-9_]*\b(?!\s*[.(]))|^[A-Z](?:[a-z]|[A-Z0-9_]*\b(?!\s*[.(]))[\w$.]*(?:\([^)]*\))?\s*(?:===?|!==?)/;
|
|
9196
9264
|
const SIGNATURE_METADATA_IDENTIFIER_PATTERN = /signature(?:Method|Type|Status|Algorithm|Kind|Mode|Version)\b/i;
|
|
9197
9265
|
const BOOLEAN_COMPARAND_PATTERN = /(?:===?|!==?)\s*(?:true|false|null|undefined)\b/;
|
|
@@ -9231,17 +9299,18 @@ const insecureCryptoRisk = defineRule({
|
|
|
9231
9299
|
if (!isProductionSourcePath(file.relativePath)) return [];
|
|
9232
9300
|
if (DEMO_CONTEXT_PATTERN.test(file.relativePath)) return [];
|
|
9233
9301
|
if (PROTOCOL_MANDATED_HASH_CONTEXT_PATTERN.test(file.relativePath)) return [];
|
|
9234
|
-
|
|
9235
|
-
|
|
9236
|
-
if (matchIndex < 0) matchIndex =
|
|
9237
|
-
if (matchIndex < 0
|
|
9238
|
-
if (matchIndex < 0 &&
|
|
9239
|
-
|
|
9302
|
+
const content = getScannableContent(file);
|
|
9303
|
+
let matchIndex = findMatchIndexNearContext(content, WEAK_HASH_PATTERN, SECURITY_CONTEXT_PATTERN, PROTOCOL_MANDATED_HASH_CONTEXT_PATTERN);
|
|
9304
|
+
if (matchIndex < 0) matchIndex = content.search(WEAK_CIPHER_ALGORITHM_PATTERN);
|
|
9305
|
+
if (matchIndex < 0) matchIndex = content.search(DEPRECATED_CIPHER_API_PATTERN);
|
|
9306
|
+
if (matchIndex < 0 && CIPHER_CONTEXT_PATTERN.test(content)) matchIndex = content.search(WEAK_CIPHER_NAME_PATTERN);
|
|
9307
|
+
if (matchIndex < 0 && !TIMING_SAFE_COMPARISON_PATTERN.test(content) && !CLIENT_COMPONENT_FILE_PATTERN.test(file.relativePath)) {
|
|
9308
|
+
const comparisonMatch = UNSAFE_SIGNATURE_COMPARISON_PATTERN.exec(content);
|
|
9240
9309
|
if (comparisonMatch !== null && !ENUM_MEMBER_COMPARAND_PATTERN.test(comparisonMatch[0]) && !SIGNATURE_METADATA_IDENTIFIER_PATTERN.test(comparisonMatch[0]) && !BOOLEAN_COMPARAND_PATTERN.test(comparisonMatch[0])) matchIndex = comparisonMatch.index;
|
|
9241
9310
|
}
|
|
9242
|
-
if (matchIndex < 0) matchIndex = findRandomCallIndexWithSameLineContext(
|
|
9311
|
+
if (matchIndex < 0) matchIndex = findRandomCallIndexWithSameLineContext(content, MATH_RANDOM_CALL_PATTERN, SECURITY_RANDOM_CONTEXT_PATTERN, UI_NONCE_CONTEXT_PATTERN);
|
|
9243
9312
|
if (matchIndex < 0) return [];
|
|
9244
|
-
const location = getLocationAtIndex(
|
|
9313
|
+
const location = getLocationAtIndex(content, matchIndex);
|
|
9245
9314
|
return [{
|
|
9246
9315
|
message: "Code uses weak hashes, deprecated ciphers, timing-unsafe comparisons, or Math.random in a security-shaped context.",
|
|
9247
9316
|
line: location.line,
|
|
@@ -12131,7 +12200,7 @@ const jsxMaxDepth = defineRule({
|
|
|
12131
12200
|
});
|
|
12132
12201
|
//#endregion
|
|
12133
12202
|
//#region src/plugin/rules/react-builtins/jsx-no-comment-textnodes.ts
|
|
12134
|
-
const MESSAGE$
|
|
12203
|
+
const MESSAGE$46 = "Your users see this comment as text on the page because `//` & `/*` aren't hidden in JSX.";
|
|
12135
12204
|
const LITERAL_TEXT_TAGS = new Set([
|
|
12136
12205
|
"code",
|
|
12137
12206
|
"pre",
|
|
@@ -12179,7 +12248,7 @@ const jsxNoCommentTextnodes = defineRule({
|
|
|
12179
12248
|
if (isInsideLiteralTextTag(node)) return;
|
|
12180
12249
|
context.report({
|
|
12181
12250
|
node,
|
|
12182
|
-
message: MESSAGE$
|
|
12251
|
+
message: MESSAGE$46
|
|
12183
12252
|
});
|
|
12184
12253
|
} })
|
|
12185
12254
|
});
|
|
@@ -12210,7 +12279,7 @@ const isInsideFunctionScope = (node) => {
|
|
|
12210
12279
|
};
|
|
12211
12280
|
//#endregion
|
|
12212
12281
|
//#region src/plugin/rules/react-builtins/jsx-no-constructed-context-values.ts
|
|
12213
|
-
const MESSAGE$
|
|
12282
|
+
const MESSAGE$45 = "Every reader of this context redraws on each render because you build its `value` inline.";
|
|
12214
12283
|
const CONTEXT_MODULES$1 = [
|
|
12215
12284
|
"react",
|
|
12216
12285
|
"use-context-selector",
|
|
@@ -12308,7 +12377,7 @@ const jsxNoConstructedContextValues = defineRule({
|
|
|
12308
12377
|
if (!isConstructedValue(innerExpression)) continue;
|
|
12309
12378
|
context.report({
|
|
12310
12379
|
node: attribute,
|
|
12311
|
-
message: MESSAGE$
|
|
12380
|
+
message: MESSAGE$45
|
|
12312
12381
|
});
|
|
12313
12382
|
}
|
|
12314
12383
|
}
|
|
@@ -12392,7 +12461,8 @@ const isJsxAttributeOnIntrinsicHtmlElement = (attribute) => {
|
|
|
12392
12461
|
};
|
|
12393
12462
|
//#endregion
|
|
12394
12463
|
//#region src/plugin/rules/react-builtins/jsx-no-jsx-as-prop.ts
|
|
12395
|
-
const
|
|
12464
|
+
const MESSAGE_MEMOISED = "This child redraws every render because the prop gets brand new JSX each time.";
|
|
12465
|
+
const MESSAGE_UNKNOWN = "If this child is memoized, it still redraws every render because the prop gets brand new JSX each time.";
|
|
12396
12466
|
const KNOWN_SLOT_PROP_NAMES = new Set([
|
|
12397
12467
|
"icon",
|
|
12398
12468
|
"Icon",
|
|
@@ -12655,7 +12725,8 @@ const jsxNoJsxAsProp = defineRule({
|
|
|
12655
12725
|
if (isJsxAttributeOnIntrinsicHtmlElement(node)) return;
|
|
12656
12726
|
const parentJsxOpening = node.parent;
|
|
12657
12727
|
const openingName = parentJsxOpening && isNodeOfType(parentJsxOpening, "JSXOpeningElement") ? parentJsxOpening.name : null;
|
|
12658
|
-
|
|
12728
|
+
const memoStatus = memoStatusForJsxOpeningName(memoRegistry, openingName);
|
|
12729
|
+
if (memoStatus === "not-memoised") return;
|
|
12659
12730
|
if (isNodeOfType(node.name, "JSXIdentifier") && isSlotPropName(node.name.name)) return;
|
|
12660
12731
|
if (!isInsideFunctionScope(node)) return;
|
|
12661
12732
|
const value = node.value;
|
|
@@ -12666,7 +12737,7 @@ const jsxNoJsxAsProp = defineRule({
|
|
|
12666
12737
|
if (!isJsxProducingExpression(expressionNode) && !followsRenderLocalJsxBinding(expressionNode, node)) return;
|
|
12667
12738
|
context.report({
|
|
12668
12739
|
node,
|
|
12669
|
-
message:
|
|
12740
|
+
message: memoStatus === "memoised" ? MESSAGE_MEMOISED : MESSAGE_UNKNOWN
|
|
12670
12741
|
});
|
|
12671
12742
|
}
|
|
12672
12743
|
};
|
|
@@ -17946,19 +18017,108 @@ const isArrayFromLengthObjectCall = (node) => {
|
|
|
17946
18017
|
}
|
|
17947
18018
|
return false;
|
|
17948
18019
|
};
|
|
17949
|
-
const
|
|
18020
|
+
const TYPE_RESOLUTION_DEPTH_LIMIT = 4;
|
|
18021
|
+
const isStringKeywordAnnotation = (typeAnnotation) => Boolean(typeAnnotation && isNodeOfType(typeAnnotation, "TSTypeAnnotation") && isNodeOfType(typeAnnotation.typeAnnotation, "TSStringKeyword"));
|
|
18022
|
+
const findSameFileTypeDeclaration = (referenceNode, typeName) => {
|
|
18023
|
+
const programRoot = findProgramRoot(referenceNode);
|
|
18024
|
+
if (!programRoot || !isNodeOfType(programRoot, "Program")) return null;
|
|
18025
|
+
for (const statement of programRoot.body) {
|
|
18026
|
+
const declaration = isNodeOfType(statement, "ExportNamedDeclaration") ? statement.declaration : statement;
|
|
18027
|
+
if (!declaration) continue;
|
|
18028
|
+
if ((isNodeOfType(declaration, "TSInterfaceDeclaration") || isNodeOfType(declaration, "TSTypeAliasDeclaration")) && isNodeOfType(declaration.id, "Identifier") && declaration.id.name === typeName) return declaration;
|
|
18029
|
+
}
|
|
18030
|
+
return null;
|
|
18031
|
+
};
|
|
18032
|
+
const typeDeclaresStringProperty = (typeNode, propertyName, referenceNode, depth) => {
|
|
18033
|
+
if (depth > TYPE_RESOLUTION_DEPTH_LIMIT) return false;
|
|
18034
|
+
let members = null;
|
|
18035
|
+
if (isNodeOfType(typeNode, "TSTypeLiteral")) members = typeNode.members;
|
|
18036
|
+
else if (isNodeOfType(typeNode, "TSInterfaceDeclaration")) members = typeNode.body.body;
|
|
18037
|
+
if (members) {
|
|
18038
|
+
for (const member of members) {
|
|
18039
|
+
if (!isNodeOfType(member, "TSPropertySignature")) continue;
|
|
18040
|
+
if (!isNodeOfType(member.key, "Identifier") || member.key.name !== propertyName) continue;
|
|
18041
|
+
return isStringKeywordAnnotation(member.typeAnnotation);
|
|
18042
|
+
}
|
|
18043
|
+
return false;
|
|
18044
|
+
}
|
|
18045
|
+
if (isNodeOfType(typeNode, "TSTypeAliasDeclaration")) return typeDeclaresStringProperty(typeNode.typeAnnotation, propertyName, referenceNode, depth + 1);
|
|
18046
|
+
if (isNodeOfType(typeNode, "TSTypeReference") && isNodeOfType(typeNode.typeName, "Identifier")) {
|
|
18047
|
+
const declaration = findSameFileTypeDeclaration(referenceNode, typeNode.typeName.name);
|
|
18048
|
+
if (!declaration) return false;
|
|
18049
|
+
return typeDeclaresStringProperty(declaration, propertyName, referenceNode, depth + 1);
|
|
18050
|
+
}
|
|
18051
|
+
return false;
|
|
18052
|
+
};
|
|
18053
|
+
const isDestructuredFromStringTypedPattern = (bindingIdentifier) => {
|
|
18054
|
+
const property = bindingIdentifier.parent;
|
|
18055
|
+
if (!property || !isNodeOfType(property, "Property")) return false;
|
|
18056
|
+
if (!isNodeOfType(property.key, "Identifier")) return false;
|
|
18057
|
+
const pattern = property.parent;
|
|
18058
|
+
if (!pattern || !isNodeOfType(pattern, "ObjectPattern")) return false;
|
|
18059
|
+
const typeAnnotation = pattern.typeAnnotation;
|
|
18060
|
+
if (!typeAnnotation || !isNodeOfType(typeAnnotation, "TSTypeAnnotation")) return false;
|
|
18061
|
+
return typeDeclaresStringProperty(typeAnnotation.typeAnnotation, property.key.name, bindingIdentifier, 0);
|
|
18062
|
+
};
|
|
18063
|
+
const isProvablyStringValued = (expression, depth) => {
|
|
18064
|
+
if (depth > TYPE_RESOLUTION_DEPTH_LIMIT) return false;
|
|
18065
|
+
const node = stripParenExpression(expression);
|
|
18066
|
+
if (isNodeOfType(node, "Literal")) return typeof node.value === "string";
|
|
18067
|
+
if (isNodeOfType(node, "TemplateLiteral")) return true;
|
|
18068
|
+
if (isNodeOfType(node, "CallExpression") && isNodeOfType(node.callee, "Identifier") && node.callee.name === "String") return true;
|
|
18069
|
+
if (isNodeOfType(node, "Identifier")) {
|
|
18070
|
+
const binding = findVariableInitializer(node, node.name);
|
|
18071
|
+
if (!binding) return false;
|
|
18072
|
+
if (binding.initializer && isProvablyStringValued(binding.initializer, depth + 1)) return true;
|
|
18073
|
+
if (isNodeOfType(binding.bindingIdentifier, "Identifier") && isStringKeywordAnnotation(binding.bindingIdentifier.typeAnnotation)) return true;
|
|
18074
|
+
return isDestructuredFromStringTypedPattern(binding.bindingIdentifier);
|
|
18075
|
+
}
|
|
18076
|
+
return false;
|
|
18077
|
+
};
|
|
18078
|
+
const hasProvablyStringFirstArgument = (callNode) => {
|
|
18079
|
+
if (!isNodeOfType(callNode, "CallExpression")) return false;
|
|
18080
|
+
const source = callNode.arguments?.[0];
|
|
18081
|
+
return Boolean(source && isProvablyStringValued(source, 0));
|
|
18082
|
+
};
|
|
18083
|
+
/**
|
|
18084
|
+
* `[...str]`, `str.split(...)`, and `Array.from(str)` slice ONE string
|
|
18085
|
+
* into positional fragments (characters, lines, tokens). Fragment
|
|
18086
|
+
* position IS the entry's stable identity — nothing reorders, filters,
|
|
18087
|
+
* or carries per-item state — so an index key is correct there.
|
|
18088
|
+
* `.split(...)` needs no string proof (only strings have `.split`);
|
|
18089
|
+
* the spread / `Array.from` forms do, because both are equally common
|
|
18090
|
+
* on arrays.
|
|
18091
|
+
*/
|
|
18092
|
+
const isStringDerivedReceiver = (receiver, depth = 0) => {
|
|
18093
|
+
const node = stripParenExpression(receiver);
|
|
18094
|
+
if (isNodeOfType(node, "Identifier")) {
|
|
18095
|
+
if (depth >= TYPE_RESOLUTION_DEPTH_LIMIT) return false;
|
|
18096
|
+
const binding = findVariableInitializer(node, node.name);
|
|
18097
|
+
if (!binding?.initializer) return false;
|
|
18098
|
+
return isStringDerivedReceiver(binding.initializer, depth + 1);
|
|
18099
|
+
}
|
|
18100
|
+
if (isNodeOfType(node, "ArrayExpression") && node.elements?.length === 1) {
|
|
18101
|
+
const only = node.elements[0];
|
|
18102
|
+
if (only && isNodeOfType(only, "SpreadElement")) return isProvablyStringValued(only.argument, 0);
|
|
18103
|
+
}
|
|
18104
|
+
if (isNodeOfType(node, "CallExpression") && isNodeOfType(node.callee, "MemberExpression") && isNodeOfType(node.callee.property, "Identifier") && node.callee.property.name === "split") return true;
|
|
18105
|
+
return isArrayFromCall(node) && hasProvablyStringFirstArgument(node);
|
|
18106
|
+
};
|
|
18107
|
+
const isInsideIteratorMapMatching = (node, matchesMethodReceiver, matchesArrayFromCall) => {
|
|
17950
18108
|
let current = node;
|
|
17951
18109
|
while (current.parent) {
|
|
17952
18110
|
const parent = current.parent;
|
|
17953
18111
|
if (isFunctionLike$1(current) && isNodeOfType(parent, "CallExpression") && parent.arguments.includes(current)) {
|
|
17954
18112
|
const callee = parent.callee;
|
|
17955
|
-
if (isNodeOfType(callee, "MemberExpression") && isNodeOfType(callee.property, "Identifier") && (callee.property.name === "map" || callee.property.name === "flatMap" || callee.property.name === "forEach")) return
|
|
17956
|
-
if (isArrayFromCall(parent) && parent.arguments.length >= 2 && parent.arguments[1] === current) return
|
|
18113
|
+
if (isNodeOfType(callee, "MemberExpression") && isNodeOfType(callee.property, "Identifier") && (callee.property.name === "map" || callee.property.name === "flatMap" || callee.property.name === "forEach")) return matchesMethodReceiver(callee.object);
|
|
18114
|
+
if (isArrayFromCall(parent) && parent.arguments.length >= 2 && parent.arguments[1] === current) return matchesArrayFromCall(parent);
|
|
17957
18115
|
}
|
|
17958
18116
|
current = parent;
|
|
17959
18117
|
}
|
|
17960
18118
|
return false;
|
|
17961
18119
|
};
|
|
18120
|
+
const isInsideStringDerivedMap = (node) => isInsideIteratorMapMatching(node, isStringDerivedReceiver, hasProvablyStringFirstArgument);
|
|
18121
|
+
const isInsideStaticPlaceholderMap = (node) => isInsideIteratorMapMatching(node, isStaticPlaceholderReceiver, isArrayFromLengthObjectCall);
|
|
17962
18122
|
/**
|
|
17963
18123
|
* Walk up from a JSXAttribute node looking for the enclosing iterator
|
|
17964
18124
|
* callback (`.map(cb)`, `.flatMap(cb)`, `.forEach(cb)`, `Array.from(_, cb)`)
|
|
@@ -18014,6 +18174,7 @@ const noArrayIndexAsKey = defineRule({
|
|
|
18014
18174
|
const indexName = extractIndexName(node.value.expression);
|
|
18015
18175
|
if (!indexName) return;
|
|
18016
18176
|
if (isInsideStaticPlaceholderMap(node)) return;
|
|
18177
|
+
if (isInsideStringDerivedMap(node)) return;
|
|
18017
18178
|
if (isCompositeKeyWithIteratorIdentity(node.value.expression, node)) return;
|
|
18018
18179
|
const openingElement = node.parent;
|
|
18019
18180
|
if (openingElement && isNodeOfType(openingElement, "JSXOpeningElement")) {
|
|
@@ -19851,6 +20012,25 @@ const countSetterCallSites = (ref) => {
|
|
|
19851
20012
|
}
|
|
19852
20013
|
return count;
|
|
19853
20014
|
};
|
|
20015
|
+
const collectUpdaterParameterReads = (analysis, updaterFn) => {
|
|
20016
|
+
const reads = [];
|
|
20017
|
+
for (const ref of getDownstreamRefs(analysis, updaterFn.body)) if (ref.resolved?.defs.some((def) => def.type === "Parameter" && def.node === updaterFn)) reads.push(ref.identifier);
|
|
20018
|
+
return reads;
|
|
20019
|
+
};
|
|
20020
|
+
const readsParameterOnlyViaObjectSpread = (parameterReads) => parameterReads.every((read) => {
|
|
20021
|
+
const spread = read.parent;
|
|
20022
|
+
if (!spread || !isNodeOfType(spread, "SpreadElement")) return false;
|
|
20023
|
+
const container = spread.parent;
|
|
20024
|
+
return Boolean(container && isNodeOfType(container, "ObjectExpression"));
|
|
20025
|
+
});
|
|
20026
|
+
const isAccumulatingFunctionalUpdater = (analysis, callExpr) => {
|
|
20027
|
+
if (!isNodeOfType(callExpr, "CallExpression")) return false;
|
|
20028
|
+
const firstArgument = callExpr.arguments?.[0];
|
|
20029
|
+
if (!firstArgument || !isFunctionLike$1(firstArgument)) return false;
|
|
20030
|
+
const parameterReads = collectUpdaterParameterReads(analysis, firstArgument);
|
|
20031
|
+
if (parameterReads.length === 0) return false;
|
|
20032
|
+
return !readsParameterOnlyViaObjectSpread(parameterReads);
|
|
20033
|
+
};
|
|
19854
20034
|
const getStateNameForUseStateDecl = (useStateNode) => {
|
|
19855
20035
|
if (!useStateNode || !isNodeOfType(useStateNode, "VariableDeclarator")) return null;
|
|
19856
20036
|
if (!isNodeOfType(useStateNode.id, "ArrayPattern")) return null;
|
|
@@ -19885,6 +20065,7 @@ const noDerivedState = defineRule({
|
|
|
19885
20065
|
const depsUpstreamRefs = depsRefs.flatMap((depRef) => getUpstreamRefs(analysis, depRef));
|
|
19886
20066
|
if (isInitialOnlySetterCall(callExpr)) continue;
|
|
19887
20067
|
if (isControlledPropMirror(node, callExpr)) continue;
|
|
20068
|
+
if (isAccumulatingFunctionalUpdater(analysis, callExpr)) continue;
|
|
19888
20069
|
const isSomeArgsInternal = argsUpstreamRefs.some((argRef) => isState(analysis, argRef) || isProp(analysis, argRef));
|
|
19889
20070
|
const isValueAlwaysInSync = argsUpstreamRefs.length > 0 && argsUpstreamRefs.every((argRef) => depsUpstreamRefs.some((depRef) => argRef.resolved === depRef.resolved)) && countSetterCallSites(ref) === 1;
|
|
19890
20071
|
if (isSomeArgsInternal) context.report({
|
|
@@ -30874,35 +31055,67 @@ const preferUseSyncExternalStore = defineRule({
|
|
|
30874
31055
|
});
|
|
30875
31056
|
//#endregion
|
|
30876
31057
|
//#region src/plugin/rules/state-and-effects/prefer-use-reducer.ts
|
|
31058
|
+
const getSetterNameFromStatement = (statement, setterNames) => {
|
|
31059
|
+
let expression = null;
|
|
31060
|
+
if (isNodeOfType(statement, "ExpressionStatement")) expression = statement.expression;
|
|
31061
|
+
else if (isNodeOfType(statement, "ReturnStatement")) expression = statement.argument;
|
|
31062
|
+
if (!expression) return null;
|
|
31063
|
+
const call = stripParenExpression(expression);
|
|
31064
|
+
if (!isNodeOfType(call, "CallExpression")) return null;
|
|
31065
|
+
if (!isNodeOfType(call.callee, "Identifier")) return null;
|
|
31066
|
+
return setterNames.has(call.callee.name) ? call.callee.name : null;
|
|
31067
|
+
};
|
|
31068
|
+
const findLargestCoUpdatedSetterGroup = (componentBody, setterNames) => {
|
|
31069
|
+
let largestGroupSize = 0;
|
|
31070
|
+
const visit = (node, isInsideNestedFunction) => {
|
|
31071
|
+
if (isNodeOfType(node, "BlockStatement") && isInsideNestedFunction) {
|
|
31072
|
+
const groupSetterNames = /* @__PURE__ */ new Set();
|
|
31073
|
+
for (const statement of node.body ?? []) {
|
|
31074
|
+
const setterName = getSetterNameFromStatement(statement, setterNames);
|
|
31075
|
+
if (setterName) groupSetterNames.add(setterName);
|
|
31076
|
+
}
|
|
31077
|
+
largestGroupSize = Math.max(largestGroupSize, groupSetterNames.size);
|
|
31078
|
+
}
|
|
31079
|
+
const enteringNestedFunction = isInsideNestedFunction || isFunctionLike$1(node);
|
|
31080
|
+
const record = node;
|
|
31081
|
+
for (const key of Object.keys(record)) {
|
|
31082
|
+
if (key === "parent" || key === "type") continue;
|
|
31083
|
+
const child = record[key];
|
|
31084
|
+
if (Array.isArray(child)) {
|
|
31085
|
+
for (const item of child) if (isAstNode(item)) visit(item, enteringNestedFunction);
|
|
31086
|
+
} else if (isAstNode(child)) visit(child, enteringNestedFunction);
|
|
31087
|
+
}
|
|
31088
|
+
};
|
|
31089
|
+
visit(componentBody, false);
|
|
31090
|
+
return largestGroupSize;
|
|
31091
|
+
};
|
|
30877
31092
|
const preferUseReducer = defineRule({
|
|
30878
31093
|
id: "prefer-useReducer",
|
|
30879
31094
|
title: "Many related useState calls",
|
|
30880
31095
|
tags: ["test-noise"],
|
|
30881
31096
|
severity: "warn",
|
|
30882
|
-
recommendation: "Group
|
|
31097
|
+
recommendation: "Group state that always changes together into `useReducer` so one dispatched action describes the whole transition instead of a list of setter calls.",
|
|
30883
31098
|
create: (context) => {
|
|
30884
|
-
const
|
|
31099
|
+
const reportCoUpdatedUseState = (body, componentName) => {
|
|
30885
31100
|
if (!isNodeOfType(body, "BlockStatement")) return;
|
|
30886
|
-
|
|
30887
|
-
|
|
30888
|
-
|
|
30889
|
-
|
|
30890
|
-
}
|
|
30891
|
-
if (useStateCount >= 5) context.report({
|
|
31101
|
+
const setterNames = new Set(collectUseStateBindings(body).map((binding) => binding.setterName));
|
|
31102
|
+
if (setterNames.size < 5) return;
|
|
31103
|
+
const coUpdatedCount = findLargestCoUpdatedSetterGroup(body, setterNames);
|
|
31104
|
+
if (coUpdatedCount >= 5) context.report({
|
|
30892
31105
|
node: body,
|
|
30893
|
-
message:
|
|
31106
|
+
message: `"${componentName}" updates ${coUpdatedCount} separate useState values in one place — state that changes together is easier to keep consistent as a single useReducer action.`
|
|
30894
31107
|
});
|
|
30895
31108
|
};
|
|
30896
31109
|
return {
|
|
30897
31110
|
FunctionDeclaration(node) {
|
|
30898
31111
|
if (!node.id?.name || !isUppercaseName(node.id.name)) return;
|
|
30899
|
-
|
|
31112
|
+
reportCoUpdatedUseState(node.body, node.id.name);
|
|
30900
31113
|
},
|
|
30901
31114
|
VariableDeclarator(node) {
|
|
30902
31115
|
if (!isComponentAssignment(node)) return;
|
|
30903
31116
|
if (!isNodeOfType(node.init, "ArrowFunctionExpression") && !isNodeOfType(node.init, "FunctionExpression")) return;
|
|
30904
31117
|
if (!isNodeOfType(node.id, "Identifier")) return;
|
|
30905
|
-
|
|
31118
|
+
reportCoUpdatedUseState(node.init.body, node.id.name);
|
|
30906
31119
|
}
|
|
30907
31120
|
};
|
|
30908
31121
|
}
|
|
@@ -34169,6 +34382,17 @@ const collectReturnedJsxRoots = (functionNode) => {
|
|
|
34169
34382
|
return roots;
|
|
34170
34383
|
};
|
|
34171
34384
|
const isChildrenForwardingJsxChild = (child, bindings) => isNodeOfType(child, "JSXExpressionContainer") && isChildrenValueExpression(child.expression, bindings);
|
|
34385
|
+
const fragmentChildrenOrNull = (child) => {
|
|
34386
|
+
if (isNodeOfType(child, "JSXFragment")) return child.children ?? [];
|
|
34387
|
+
if (isNodeOfType(child, "JSXElement") && isJsxFragmentElement(child.openingElement)) return child.children ?? [];
|
|
34388
|
+
return null;
|
|
34389
|
+
};
|
|
34390
|
+
const isChildrenForwardingJsxChildThroughFragments = (child, bindings) => {
|
|
34391
|
+
if (isChildrenForwardingJsxChild(child, bindings)) return true;
|
|
34392
|
+
const fragmentChildren = fragmentChildrenOrNull(child);
|
|
34393
|
+
if (fragmentChildren === null) return false;
|
|
34394
|
+
return fragmentChildren.some((innerChild) => isChildrenForwardingJsxChildThroughFragments(innerChild, bindings));
|
|
34395
|
+
};
|
|
34172
34396
|
const isChildrenForwardingAttribute = (attribute, bindings) => {
|
|
34173
34397
|
if (isNodeOfType(attribute, "JSXSpreadAttribute")) return isPropsObjectExpression(attribute.argument, bindings);
|
|
34174
34398
|
return isNodeOfType(attribute, "JSXAttribute") && isNodeOfType(attribute.name, "JSXIdentifier") && attribute.name.name === "children" && isNodeOfType(attribute.value, "JSXExpressionContainer") && isChildrenValueExpression(attribute.value.expression, bindings);
|
|
@@ -34180,7 +34404,7 @@ const jsxRootForwardsChildrenIntoText = (jsxRoot, bindings, isTextHandlingElemen
|
|
|
34180
34404
|
if (!isNodeOfType(node, "JSXElement")) return void 0;
|
|
34181
34405
|
const elementName = resolveJsxElementName(node.openingElement);
|
|
34182
34406
|
if (!elementName || !isTextHandlingElement(elementName)) return;
|
|
34183
|
-
didForwardIntoText = (node.children ?? []).some((child) =>
|
|
34407
|
+
didForwardIntoText = (node.children ?? []).some((child) => isChildrenForwardingJsxChildThroughFragments(child, bindings)) || (node.openingElement.attributes ?? []).some((attribute) => isChildrenForwardingAttribute(attribute, bindings));
|
|
34184
34408
|
});
|
|
34185
34409
|
return didForwardIntoText;
|
|
34186
34410
|
};
|
|
@@ -34198,7 +34422,7 @@ const jsxRootForwardsChildren = (jsxRoot, bindings, isTextHandlingElement, count
|
|
|
34198
34422
|
return;
|
|
34199
34423
|
}
|
|
34200
34424
|
}
|
|
34201
|
-
if (countsAsForwardTarget(node) && (node.children ?? []).some((child) =>
|
|
34425
|
+
if (countsAsForwardTarget(node) && (node.children ?? []).some((child) => isChildrenForwardingJsxChildThroughFragments(child, bindings))) didForward = true;
|
|
34202
34426
|
});
|
|
34203
34427
|
return didForward;
|
|
34204
34428
|
};
|
|
@@ -34276,14 +34500,13 @@ const recordWrapperFromDeclaration = (componentName, definitionNode, isTextHandl
|
|
|
34276
34500
|
if (kind === "text") wrappers.add(componentName);
|
|
34277
34501
|
else if (kind === "nonText") nonTextWrappers.add(componentName);
|
|
34278
34502
|
};
|
|
34279
|
-
const MAX_TRANSITIVE_WRAPPER_PASSES = 3;
|
|
34280
34503
|
const collectTextWrapperComponents = (programNode, isTextHandlingRoot, isNonTextHostRoot) => {
|
|
34281
34504
|
const wrappers = /* @__PURE__ */ new Set();
|
|
34282
34505
|
const nonTextWrappers = /* @__PURE__ */ new Set();
|
|
34283
34506
|
const isTextHandlingElement = (elementName) => isTextHandlingRoot(elementName) || wrappers.has(elementName);
|
|
34284
34507
|
const isNonTextHostElement = (elementName) => isNonTextHostRoot(elementName) || nonTextWrappers.has(elementName);
|
|
34285
34508
|
const recordDeclaration = (componentName, definitionNode) => recordWrapperFromDeclaration(componentName, definitionNode, isTextHandlingElement, isNonTextHostElement, wrappers, nonTextWrappers);
|
|
34286
|
-
|
|
34509
|
+
while (true) {
|
|
34287
34510
|
const wrappersSizeBeforePass = wrappers.size;
|
|
34288
34511
|
const nonTextSizeBeforePass = nonTextWrappers.size;
|
|
34289
34512
|
walkAst(programNode, (node) => {
|
|
@@ -38965,7 +39188,7 @@ const isAuthGuardName = (calleeName) => {
|
|
|
38965
39188
|
//#endregion
|
|
38966
39189
|
//#region src/plugin/utils/is-non-privileged-server-action.ts
|
|
38967
39190
|
const NON_DATA_EFFECT_FUNCTION_NAMES = new Set([...CACHE_REVALIDATION_FUNCTION_NAMES, ...NEXTJS_NAVIGATION_FUNCTIONS]);
|
|
38968
|
-
const isCacheOrNavigationCall = (node) => isNodeOfType(node, "CallExpression") && isNodeOfType(node.callee, "Identifier") && NON_DATA_EFFECT_FUNCTION_NAMES.has(node.callee.name);
|
|
39191
|
+
const isCacheOrNavigationCall = (node) => isNodeOfType(node, "CallExpression") && isNodeOfType(node.callee, "Identifier") && NON_DATA_EFFECT_FUNCTION_NAMES.has(node.callee.name) && getImportSourceForName(node, node.callee.name) !== null;
|
|
38969
39192
|
const unwrapExpression = (node) => {
|
|
38970
39193
|
let current = node;
|
|
38971
39194
|
while (current) {
|
|
@@ -38990,6 +39213,7 @@ const isDataExposingValue = (node) => {
|
|
|
38990
39213
|
const isLiteralOnlyExpression = (node) => {
|
|
38991
39214
|
if (!node) return false;
|
|
38992
39215
|
if (isNodeOfType(node, "Literal")) return true;
|
|
39216
|
+
if (isNodeOfType(node, "Identifier")) return node.name === "undefined";
|
|
38993
39217
|
if (isNodeOfType(node, "TemplateLiteral")) return (node.expressions ?? []).every(isLiteralOnlyExpression);
|
|
38994
39218
|
if (isNodeOfType(node, "UnaryExpression")) return isLiteralOnlyExpression(node.argument);
|
|
38995
39219
|
if (isNodeOfType(node, "ArrayExpression")) return (node.elements ?? []).every((element) => element === null || !isNodeOfType(element, "SpreadElement") && isLiteralOnlyExpression(element));
|