oxlint-plugin-react-doctor 0.5.5 → 0.5.6-dev.09dde18
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.d.ts +1928 -5
- package/dist/index.js +1984 -210
- package/package.json +2 -2
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
|
|
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 (
|
|
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$
|
|
1864
|
+
const MESSAGE$64 = "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$
|
|
1880
|
+
message: MESSAGE$64
|
|
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$
|
|
2274
|
+
const MESSAGE$63 = "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$
|
|
2292
|
+
message: MESSAGE$63
|
|
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$
|
|
2299
|
+
message: MESSAGE$63
|
|
2286
2300
|
});
|
|
2287
2301
|
} })
|
|
2288
2302
|
});
|
|
@@ -3090,6 +3104,76 @@ const AUTH_FUNCTION_NAMES = new Set([
|
|
|
3090
3104
|
"getAuth",
|
|
3091
3105
|
"validateSession"
|
|
3092
3106
|
]);
|
|
3107
|
+
const AUTH_STRONG_TOKEN_PATTERN = /^auth(?:n|z|ed|enticate[ds]?|enticating|entication|orize[ds]?|orizing|orization|orizer)?$/;
|
|
3108
|
+
const AUTH_STANDALONE_NOUN_TOKENS = new Set([
|
|
3109
|
+
"signedin",
|
|
3110
|
+
"loggedin",
|
|
3111
|
+
"signin"
|
|
3112
|
+
]);
|
|
3113
|
+
const AUTH_ASSERTIVE_VERB_TOKENS = new Set([
|
|
3114
|
+
"require",
|
|
3115
|
+
"ensure",
|
|
3116
|
+
"assert",
|
|
3117
|
+
"verify",
|
|
3118
|
+
"validate",
|
|
3119
|
+
"check",
|
|
3120
|
+
"protect",
|
|
3121
|
+
"enforce",
|
|
3122
|
+
"guard",
|
|
3123
|
+
"gate",
|
|
3124
|
+
"restrict",
|
|
3125
|
+
"is",
|
|
3126
|
+
"has",
|
|
3127
|
+
"can",
|
|
3128
|
+
"must"
|
|
3129
|
+
]);
|
|
3130
|
+
const AUTH_GETTER_VERB_TOKENS = new Set([
|
|
3131
|
+
"get",
|
|
3132
|
+
"fetch",
|
|
3133
|
+
"load",
|
|
3134
|
+
"read",
|
|
3135
|
+
"resolve",
|
|
3136
|
+
"retrieve",
|
|
3137
|
+
"use"
|
|
3138
|
+
]);
|
|
3139
|
+
const AUTH_QUALIFIER_TOKENS = new Set([
|
|
3140
|
+
"current",
|
|
3141
|
+
"my",
|
|
3142
|
+
"own"
|
|
3143
|
+
]);
|
|
3144
|
+
const AUTH_STRONG_NOUN_TOKENS = new Set([
|
|
3145
|
+
"session",
|
|
3146
|
+
"sessions",
|
|
3147
|
+
"login",
|
|
3148
|
+
"admin",
|
|
3149
|
+
"admins",
|
|
3150
|
+
"superadmin",
|
|
3151
|
+
"superuser",
|
|
3152
|
+
"role",
|
|
3153
|
+
"roles",
|
|
3154
|
+
"permission",
|
|
3155
|
+
"permissions",
|
|
3156
|
+
"jwt",
|
|
3157
|
+
"identity",
|
|
3158
|
+
"principal",
|
|
3159
|
+
"credential",
|
|
3160
|
+
"credentials"
|
|
3161
|
+
]);
|
|
3162
|
+
const AUTH_WEAK_NOUN_TOKENS = new Set([
|
|
3163
|
+
"user",
|
|
3164
|
+
"users",
|
|
3165
|
+
"account",
|
|
3166
|
+
"accounts",
|
|
3167
|
+
"token",
|
|
3168
|
+
"tokens",
|
|
3169
|
+
"access",
|
|
3170
|
+
"me",
|
|
3171
|
+
"viewer",
|
|
3172
|
+
"caller",
|
|
3173
|
+
"subject",
|
|
3174
|
+
"scope",
|
|
3175
|
+
"scopes"
|
|
3176
|
+
]);
|
|
3093
3177
|
const GENERIC_AUTH_METHOD_NAMES = new Set(["getUser"]);
|
|
3094
3178
|
const AUTH_OBJECT_PATTERN = /(?:^|[._])(?:auth|authn|authz|clerk|session|jwt|firebase|supabase|nextauth|kinde|workos|stytch|descope|cognito|propelauth|lucia)/i;
|
|
3095
3179
|
const SECRET_PATTERNS = [
|
|
@@ -4187,6 +4271,58 @@ const asyncParallel = defineRule({
|
|
|
4187
4271
|
}
|
|
4188
4272
|
});
|
|
4189
4273
|
//#endregion
|
|
4274
|
+
//#region src/plugin/rules/security/auth-token-in-web-storage.ts
|
|
4275
|
+
const MESSAGE$62 = "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.";
|
|
4276
|
+
const STORAGE_NAMES = new Set(["localStorage", "sessionStorage"]);
|
|
4277
|
+
const STORAGE_GLOBALS = new Set([
|
|
4278
|
+
"window",
|
|
4279
|
+
"globalThis",
|
|
4280
|
+
"self"
|
|
4281
|
+
]);
|
|
4282
|
+
const SENSITIVE_KEY_PATTERN = /token|jwt|secret|password|passwd|credential|api[-_]?key|bearer|private[-_]?key/i;
|
|
4283
|
+
const isWebStorageObject = (node) => {
|
|
4284
|
+
if (isNodeOfType(node, "Identifier")) return STORAGE_NAMES.has(node.name);
|
|
4285
|
+
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);
|
|
4286
|
+
return false;
|
|
4287
|
+
};
|
|
4288
|
+
const staticMemberName = (member) => {
|
|
4289
|
+
if (!member.computed && isNodeOfType(member.property, "Identifier")) return member.property.name;
|
|
4290
|
+
if (member.computed && isNodeOfType(member.property, "Literal") && typeof member.property.value === "string") return member.property.value;
|
|
4291
|
+
return null;
|
|
4292
|
+
};
|
|
4293
|
+
const authTokenInWebStorage = defineRule({
|
|
4294
|
+
id: "auth-token-in-web-storage",
|
|
4295
|
+
title: "Auth token in web storage",
|
|
4296
|
+
severity: "warn",
|
|
4297
|
+
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.",
|
|
4298
|
+
create: (context) => ({
|
|
4299
|
+
CallExpression(node) {
|
|
4300
|
+
const callee = node.callee;
|
|
4301
|
+
if (!isNodeOfType(callee, "MemberExpression") || callee.computed) return;
|
|
4302
|
+
if (!isNodeOfType(callee.property, "Identifier") || callee.property.name !== "setItem") return;
|
|
4303
|
+
if (!isWebStorageObject(callee.object)) return;
|
|
4304
|
+
const keyArgument = node.arguments?.[0];
|
|
4305
|
+
if (!keyArgument || !isNodeOfType(keyArgument, "Literal") || typeof keyArgument.value !== "string") return;
|
|
4306
|
+
if (!SENSITIVE_KEY_PATTERN.test(keyArgument.value)) return;
|
|
4307
|
+
context.report({
|
|
4308
|
+
node,
|
|
4309
|
+
message: MESSAGE$62
|
|
4310
|
+
});
|
|
4311
|
+
},
|
|
4312
|
+
AssignmentExpression(node) {
|
|
4313
|
+
const target = node.left;
|
|
4314
|
+
if (!isNodeOfType(target, "MemberExpression")) return;
|
|
4315
|
+
if (!isWebStorageObject(target.object)) return;
|
|
4316
|
+
const propertyName = staticMemberName(target);
|
|
4317
|
+
if (!propertyName || !SENSITIVE_KEY_PATTERN.test(propertyName)) return;
|
|
4318
|
+
context.report({
|
|
4319
|
+
node: target,
|
|
4320
|
+
message: MESSAGE$62
|
|
4321
|
+
});
|
|
4322
|
+
}
|
|
4323
|
+
})
|
|
4324
|
+
});
|
|
4325
|
+
//#endregion
|
|
4190
4326
|
//#region src/plugin/rules/a11y/autocomplete-valid.ts
|
|
4191
4327
|
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
4328
|
const AUTOFILL_TOKENS = new Set([
|
|
@@ -4558,7 +4694,7 @@ const isPureEventBlockerHandler = (attribute) => {
|
|
|
4558
4694
|
//#endregion
|
|
4559
4695
|
//#region src/plugin/rules/a11y/click-events-have-key-events.ts
|
|
4560
4696
|
const PRESENTATION_ROLES$1 = new Set(["presentation", "none"]);
|
|
4561
|
-
const MESSAGE$
|
|
4697
|
+
const MESSAGE$61 = "Keyboard users can't trigger this click handler because there's no keyboard one, so add `onKeyUp`, `onKeyDown`, or `onKeyPress`.";
|
|
4562
4698
|
const KEY_HANDLERS = [
|
|
4563
4699
|
"onKeyUp",
|
|
4564
4700
|
"onKeyDown",
|
|
@@ -4590,7 +4726,7 @@ const clickEventsHaveKeyEvents = defineRule({
|
|
|
4590
4726
|
if (KEY_HANDLERS.some((handler) => hasJsxPropIgnoreCase(node.attributes, handler))) return;
|
|
4591
4727
|
context.report({
|
|
4592
4728
|
node: node.name,
|
|
4593
|
-
message: MESSAGE$
|
|
4729
|
+
message: MESSAGE$61
|
|
4594
4730
|
});
|
|
4595
4731
|
} };
|
|
4596
4732
|
}
|
|
@@ -4705,7 +4841,7 @@ const isReactComponentName = (name) => {
|
|
|
4705
4841
|
};
|
|
4706
4842
|
//#endregion
|
|
4707
4843
|
//#region src/plugin/rules/a11y/control-has-associated-label.ts
|
|
4708
|
-
const MESSAGE$
|
|
4844
|
+
const MESSAGE$60 = "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
4845
|
const DEFAULT_IGNORE_ELEMENTS = ["link", "canvas"];
|
|
4710
4846
|
const DEFAULT_LABELLING_PROPS = [
|
|
4711
4847
|
"alt",
|
|
@@ -4866,7 +5002,7 @@ const controlHasAssociatedLabel = defineRule({
|
|
|
4866
5002
|
for (const child of node.children) if (checkChildForLabel(child, 1, checkContext)) return;
|
|
4867
5003
|
context.report({
|
|
4868
5004
|
node: opening,
|
|
4869
|
-
message: MESSAGE$
|
|
5005
|
+
message: MESSAGE$60
|
|
4870
5006
|
});
|
|
4871
5007
|
} };
|
|
4872
5008
|
}
|
|
@@ -4995,6 +5131,7 @@ const dangerousHtmlSink = defineRule({
|
|
|
4995
5131
|
return findings;
|
|
4996
5132
|
}
|
|
4997
5133
|
});
|
|
5134
|
+
const WCAG_CONTRAST_NORMAL_MIN = 4.5;
|
|
4998
5135
|
const LONG_TRANSITION_DURATION_THRESHOLD_MS = 1e3;
|
|
4999
5136
|
const VAGUE_BUTTON_LABELS = new Set([
|
|
5000
5137
|
"continue",
|
|
@@ -5292,6 +5429,38 @@ const noVagueButtonLabel = defineRule({
|
|
|
5292
5429
|
} })
|
|
5293
5430
|
});
|
|
5294
5431
|
//#endregion
|
|
5432
|
+
//#region src/plugin/utils/has-jsx-spread-attribute.ts
|
|
5433
|
+
const hasJsxSpreadAttribute = (attributes) => attributes.some((attribute) => isNodeOfType(attribute, "JSXSpreadAttribute"));
|
|
5434
|
+
//#endregion
|
|
5435
|
+
//#region src/plugin/rules/a11y/dialog-has-accessible-name.ts
|
|
5436
|
+
const MESSAGE$59 = "This dialog has no accessible name, so screen readers announce it as just “dialog.” Add `aria-label` or point `aria-labelledby` at its heading.";
|
|
5437
|
+
const DIALOG_ROLES = new Set(["dialog", "alertdialog"]);
|
|
5438
|
+
const NAME_PROVIDING_ATTRIBUTES = [
|
|
5439
|
+
"aria-label",
|
|
5440
|
+
"aria-labelledby",
|
|
5441
|
+
"title"
|
|
5442
|
+
];
|
|
5443
|
+
const dialogHasAccessibleName = defineRule({
|
|
5444
|
+
id: "dialog-has-accessible-name",
|
|
5445
|
+
title: "Dialog without accessible name",
|
|
5446
|
+
severity: "warn",
|
|
5447
|
+
recommendation: "Give every `<dialog>` / `role=\"dialog\"` an accessible name with `aria-label` or `aria-labelledby` (referencing the dialog's title element).",
|
|
5448
|
+
create: (context) => ({ JSXOpeningElement(node) {
|
|
5449
|
+
if (!isNodeOfType(node.name, "JSXIdentifier")) return;
|
|
5450
|
+
const tagName = node.name.name;
|
|
5451
|
+
if (tagName[0] !== tagName[0]?.toLowerCase()) return;
|
|
5452
|
+
const roleAttribute = hasJsxPropIgnoreCase(node.attributes, "role");
|
|
5453
|
+
const roleValue = roleAttribute ? getJsxPropStringValue(roleAttribute) : null;
|
|
5454
|
+
if (!(tagName === "dialog" || roleValue !== null && DIALOG_ROLES.has(roleValue))) return;
|
|
5455
|
+
if (hasJsxSpreadAttribute(node.attributes)) return;
|
|
5456
|
+
if (NAME_PROVIDING_ATTRIBUTES.some((attribute) => hasJsxPropIgnoreCase(node.attributes, attribute))) return;
|
|
5457
|
+
context.report({
|
|
5458
|
+
node: node.name,
|
|
5459
|
+
message: MESSAGE$59
|
|
5460
|
+
});
|
|
5461
|
+
} })
|
|
5462
|
+
});
|
|
5463
|
+
//#endregion
|
|
5295
5464
|
//#region src/plugin/utils/is-es5-component.ts
|
|
5296
5465
|
const PRAGMA$2 = "React";
|
|
5297
5466
|
const CREATE_CLASS = "createReactClass";
|
|
@@ -5326,7 +5495,7 @@ const isEs6Component = (node) => {
|
|
|
5326
5495
|
};
|
|
5327
5496
|
//#endregion
|
|
5328
5497
|
//#region src/plugin/rules/react-builtins/display-name.ts
|
|
5329
|
-
const MESSAGE$
|
|
5498
|
+
const MESSAGE$58 = "This component shows up as Anonymous in React DevTools because it has no `displayName`.";
|
|
5330
5499
|
const DEFAULT_ADDITIONAL_HOCS = [
|
|
5331
5500
|
"observer",
|
|
5332
5501
|
"lazy",
|
|
@@ -5529,7 +5698,7 @@ const displayName = defineRule({
|
|
|
5529
5698
|
const reportAt = (node) => {
|
|
5530
5699
|
context.report({
|
|
5531
5700
|
node,
|
|
5532
|
-
message: MESSAGE$
|
|
5701
|
+
message: MESSAGE$58
|
|
5533
5702
|
});
|
|
5534
5703
|
};
|
|
5535
5704
|
return {
|
|
@@ -7677,7 +7846,7 @@ const forbidElements = defineRule({
|
|
|
7677
7846
|
});
|
|
7678
7847
|
//#endregion
|
|
7679
7848
|
//#region src/plugin/rules/react-builtins/forward-ref-uses-ref.ts
|
|
7680
|
-
const MESSAGE$
|
|
7849
|
+
const MESSAGE$57 = "The parent can't reach this component's node because the `forwardRef` wrapper ignores `ref`.";
|
|
7681
7850
|
const forwardRefUsesRef = defineRule({
|
|
7682
7851
|
id: "forward-ref-uses-ref",
|
|
7683
7852
|
title: "forwardRef without ref parameter",
|
|
@@ -7697,7 +7866,7 @@ const forwardRefUsesRef = defineRule({
|
|
|
7697
7866
|
if (isNodeOfType(onlyParam, "RestElement")) return;
|
|
7698
7867
|
context.report({
|
|
7699
7868
|
node: inner,
|
|
7700
|
-
message: MESSAGE$
|
|
7869
|
+
message: MESSAGE$57
|
|
7701
7870
|
});
|
|
7702
7871
|
} })
|
|
7703
7872
|
});
|
|
@@ -7734,7 +7903,7 @@ const gitProviderUrlInjectionRisk = defineRule({
|
|
|
7734
7903
|
});
|
|
7735
7904
|
//#endregion
|
|
7736
7905
|
//#region src/plugin/rules/a11y/heading-has-content.ts
|
|
7737
|
-
const MESSAGE$
|
|
7906
|
+
const MESSAGE$56 = "Blind users can't use this heading to navigate because screen readers skip it empty, so add text, `aria-label`, or `aria-labelledby`.";
|
|
7738
7907
|
const DEFAULT_HEADING_TAGS = [
|
|
7739
7908
|
"h1",
|
|
7740
7909
|
"h2",
|
|
@@ -7767,7 +7936,7 @@ const headingHasContent = defineRule({
|
|
|
7767
7936
|
if (isHiddenFromScreenReader(node, context.settings)) return;
|
|
7768
7937
|
context.report({
|
|
7769
7938
|
node,
|
|
7770
|
-
message: MESSAGE$
|
|
7939
|
+
message: MESSAGE$56
|
|
7771
7940
|
});
|
|
7772
7941
|
} };
|
|
7773
7942
|
}
|
|
@@ -7905,7 +8074,7 @@ const hooksNoNanInDeps = defineRule({
|
|
|
7905
8074
|
});
|
|
7906
8075
|
//#endregion
|
|
7907
8076
|
//#region src/plugin/rules/a11y/html-has-lang.ts
|
|
7908
|
-
const MESSAGE$
|
|
8077
|
+
const MESSAGE$55 = "Screen readers may mispronounce this page because it doesn't declare a language, so add a `lang` attribute like `en`.";
|
|
7909
8078
|
const resolveSettings$38 = (settings) => {
|
|
7910
8079
|
const reactDoctor = settings?.["react-doctor"];
|
|
7911
8080
|
return { htmlTags: (typeof reactDoctor === "object" && reactDoctor !== null ? reactDoctor.htmlHasLang ?? {} : {}).htmlTags ?? ["html"] };
|
|
@@ -7953,7 +8122,7 @@ const htmlHasLang = defineRule({
|
|
|
7953
8122
|
if (!lang) {
|
|
7954
8123
|
context.report({
|
|
7955
8124
|
node: node.name,
|
|
7956
|
-
message: MESSAGE$
|
|
8125
|
+
message: MESSAGE$55
|
|
7957
8126
|
});
|
|
7958
8127
|
return;
|
|
7959
8128
|
}
|
|
@@ -7961,13 +8130,13 @@ const htmlHasLang = defineRule({
|
|
|
7961
8130
|
if (verdict === "missing" || verdict === "empty") {
|
|
7962
8131
|
context.report({
|
|
7963
8132
|
node: lang,
|
|
7964
|
-
message: MESSAGE$
|
|
8133
|
+
message: MESSAGE$55
|
|
7965
8134
|
});
|
|
7966
8135
|
return;
|
|
7967
8136
|
}
|
|
7968
8137
|
if (hasSpread && !lang) context.report({
|
|
7969
8138
|
node: node.name,
|
|
7970
|
-
message: MESSAGE$
|
|
8139
|
+
message: MESSAGE$55
|
|
7971
8140
|
});
|
|
7972
8141
|
} };
|
|
7973
8142
|
}
|
|
@@ -8181,7 +8350,7 @@ const htmlNoNestedInteractive = defineRule({
|
|
|
8181
8350
|
});
|
|
8182
8351
|
//#endregion
|
|
8183
8352
|
//#region src/plugin/rules/a11y/iframe-has-title.ts
|
|
8184
|
-
const MESSAGE$
|
|
8353
|
+
const MESSAGE$54 = "Screen reader users cannot identify this `<iframe>` because it has no title. Add a `title` that describes its content.";
|
|
8185
8354
|
const evaluateTitleValue = (value) => {
|
|
8186
8355
|
if (!value) return "missing";
|
|
8187
8356
|
if (isNodeOfType(value, "Literal")) {
|
|
@@ -8221,14 +8390,14 @@ const iframeHasTitle = defineRule({
|
|
|
8221
8390
|
if (!titleAttr) {
|
|
8222
8391
|
if (hasSpread || tag === "iframe") context.report({
|
|
8223
8392
|
node: node.name,
|
|
8224
|
-
message: MESSAGE$
|
|
8393
|
+
message: MESSAGE$54
|
|
8225
8394
|
});
|
|
8226
8395
|
return;
|
|
8227
8396
|
}
|
|
8228
8397
|
const verdict = evaluateTitleValue(titleAttr.value);
|
|
8229
8398
|
if (verdict === "missing" || verdict === "empty") context.report({
|
|
8230
8399
|
node: titleAttr,
|
|
8231
|
-
message: MESSAGE$
|
|
8400
|
+
message: MESSAGE$54
|
|
8232
8401
|
});
|
|
8233
8402
|
} })
|
|
8234
8403
|
});
|
|
@@ -8332,7 +8501,7 @@ const iframeMissingSandbox = defineRule({
|
|
|
8332
8501
|
});
|
|
8333
8502
|
//#endregion
|
|
8334
8503
|
//#region src/plugin/rules/a11y/img-redundant-alt.ts
|
|
8335
|
-
const MESSAGE$
|
|
8504
|
+
const MESSAGE$53 = "Screen reader users hear \"image\" or \"photo\" twice because they already announce it, so describe what the image shows instead.";
|
|
8336
8505
|
const DEFAULT_COMPONENTS = ["img"];
|
|
8337
8506
|
const DEFAULT_REDUNDANT_WORDS = [
|
|
8338
8507
|
"image",
|
|
@@ -8397,7 +8566,7 @@ const imgRedundantAlt = defineRule({
|
|
|
8397
8566
|
if (!altAttribute) return;
|
|
8398
8567
|
if (altValueRedundant(altAttribute, settings.words)) context.report({
|
|
8399
8568
|
node: altAttribute,
|
|
8400
|
-
message: MESSAGE$
|
|
8569
|
+
message: MESSAGE$53
|
|
8401
8570
|
});
|
|
8402
8571
|
} };
|
|
8403
8572
|
}
|
|
@@ -8495,6 +8664,136 @@ const insecureCryptoRisk = defineRule({
|
|
|
8495
8664
|
}
|
|
8496
8665
|
});
|
|
8497
8666
|
//#endregion
|
|
8667
|
+
//#region src/plugin/rules/security-scan/utils/find-matching-bracket.ts
|
|
8668
|
+
const findMatchingBracket = (content, openIndex) => {
|
|
8669
|
+
const open = content[openIndex];
|
|
8670
|
+
const close = open === "(" ? ")" : open === "{" ? "}" : open === "[" ? "]" : "";
|
|
8671
|
+
if (close === "") return -1;
|
|
8672
|
+
let depth = 0;
|
|
8673
|
+
let stringDelimiter = null;
|
|
8674
|
+
for (let index = openIndex; index < content.length; index += 1) {
|
|
8675
|
+
const character = content[index];
|
|
8676
|
+
if (stringDelimiter !== null) {
|
|
8677
|
+
if (character === "\\") index += 1;
|
|
8678
|
+
else if (character === stringDelimiter) stringDelimiter = null;
|
|
8679
|
+
continue;
|
|
8680
|
+
}
|
|
8681
|
+
if (character === "\"" || character === "'" || character === "`") stringDelimiter = character;
|
|
8682
|
+
else if (character === open) depth += 1;
|
|
8683
|
+
else if (character === close) {
|
|
8684
|
+
depth -= 1;
|
|
8685
|
+
if (depth === 0) return index;
|
|
8686
|
+
}
|
|
8687
|
+
}
|
|
8688
|
+
return -1;
|
|
8689
|
+
};
|
|
8690
|
+
//#endregion
|
|
8691
|
+
//#region src/plugin/rules/security-scan/insecure-session-cookie.ts
|
|
8692
|
+
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])`;
|
|
8693
|
+
const AUTH_COOKIE_NAME_LITERAL = `[\`"'][^\`"']*?${AUTH_COOKIE_NAME_TOKEN}[^\`"']*[\`"']`;
|
|
8694
|
+
const AUTH_COOKIE_SET_CALL_PATTERN = new RegExp(`(?:\\.cookies\\.set|cookies\\(\\s*\\)\\.set|\\.cookie)\\s*\\(\\s*${AUTH_COOKIE_NAME_LITERAL}`, "gi");
|
|
8695
|
+
const HTTP_ONLY_DISABLED_PATTERN = /httpOnly\s*:\s*false\b/i;
|
|
8696
|
+
const STRING_LITERAL_PATTERN = /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`/g;
|
|
8697
|
+
const blankStringContents = (text) => {
|
|
8698
|
+
const characters = text.split("");
|
|
8699
|
+
let index = 0;
|
|
8700
|
+
let stringDelimiter = null;
|
|
8701
|
+
while (index < text.length) {
|
|
8702
|
+
const character = text[index];
|
|
8703
|
+
if (stringDelimiter !== null) {
|
|
8704
|
+
if (character === "\\") {
|
|
8705
|
+
index += 2;
|
|
8706
|
+
continue;
|
|
8707
|
+
}
|
|
8708
|
+
if (character === stringDelimiter) stringDelimiter = null;
|
|
8709
|
+
else if (character !== "\n") characters[index] = " ";
|
|
8710
|
+
index += 1;
|
|
8711
|
+
continue;
|
|
8712
|
+
}
|
|
8713
|
+
if (character === "\"" || character === "'" || character === "`") stringDelimiter = character;
|
|
8714
|
+
index += 1;
|
|
8715
|
+
}
|
|
8716
|
+
return characters.join("");
|
|
8717
|
+
};
|
|
8718
|
+
const COOKIE_CONFIG_OPENER_PATTERN = /cookie\s*:\s*\{/gi;
|
|
8719
|
+
const CLIENT_AUTH_COOKIE_WRITE_PATTERN = new RegExp(`document\\.cookie\\s*=\\s*[\`"'][^\`"'=;]*?${AUTH_COOKIE_NAME_TOKEN}[^\`"'=;]*=`, "gi");
|
|
8720
|
+
const countTopLevelArguments = (argumentsSource) => {
|
|
8721
|
+
if (argumentsSource.trim().length === 0) return 0;
|
|
8722
|
+
let depth = 0;
|
|
8723
|
+
let stringDelimiter = null;
|
|
8724
|
+
let count = 1;
|
|
8725
|
+
for (let index = 0; index < argumentsSource.length; index += 1) {
|
|
8726
|
+
const character = argumentsSource[index];
|
|
8727
|
+
if (stringDelimiter !== null) {
|
|
8728
|
+
if (character === "\\") index += 1;
|
|
8729
|
+
else if (character === stringDelimiter) stringDelimiter = null;
|
|
8730
|
+
continue;
|
|
8731
|
+
}
|
|
8732
|
+
if (character === "\"" || character === "'" || character === "`") stringDelimiter = character;
|
|
8733
|
+
else if (character === "(" || character === "[" || character === "{") depth += 1;
|
|
8734
|
+
else if (character === ")" || character === "]" || character === "}") depth -= 1;
|
|
8735
|
+
else if (character === "," && depth === 0) count += 1;
|
|
8736
|
+
}
|
|
8737
|
+
return count;
|
|
8738
|
+
};
|
|
8739
|
+
const addMatchFindings = (content, pattern, message, isInsecure, findings) => {
|
|
8740
|
+
pattern.lastIndex = 0;
|
|
8741
|
+
for (let match = pattern.exec(content); match !== null; match = pattern.exec(content)) {
|
|
8742
|
+
if (!isInsecure(match.index, match[0])) continue;
|
|
8743
|
+
const location = getLocationAtIndex(content, match.index);
|
|
8744
|
+
findings.push({
|
|
8745
|
+
message,
|
|
8746
|
+
line: location.line,
|
|
8747
|
+
column: location.column
|
|
8748
|
+
});
|
|
8749
|
+
}
|
|
8750
|
+
};
|
|
8751
|
+
const insecureSessionCookie = defineRule({
|
|
8752
|
+
id: "insecure-session-cookie",
|
|
8753
|
+
title: "Auth cookie missing HttpOnly protection",
|
|
8754
|
+
severity: "warn",
|
|
8755
|
+
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.",
|
|
8756
|
+
scan: (file) => {
|
|
8757
|
+
if (!isProductionSourcePath(file.relativePath)) return [];
|
|
8758
|
+
const content = getScannableContent(file);
|
|
8759
|
+
if (!/cookie/i.test(content)) return [];
|
|
8760
|
+
const findings = [];
|
|
8761
|
+
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.";
|
|
8762
|
+
AUTH_COOKIE_SET_CALL_PATTERN.lastIndex = 0;
|
|
8763
|
+
for (let match = AUTH_COOKIE_SET_CALL_PATTERN.exec(content); match !== null; match = AUTH_COOKIE_SET_CALL_PATTERN.exec(content)) {
|
|
8764
|
+
const openParenIndex = match.index + match[0].lastIndexOf("(");
|
|
8765
|
+
const closeParenIndex = findMatchingBracket(content, openParenIndex);
|
|
8766
|
+
if (closeParenIndex < 0) continue;
|
|
8767
|
+
const argumentsSource = content.slice(openParenIndex + 1, closeParenIndex);
|
|
8768
|
+
const hasNoOptions = countTopLevelArguments(argumentsSource) < 3;
|
|
8769
|
+
const argumentsWithoutStrings = argumentsSource.replace(STRING_LITERAL_PATTERN, "");
|
|
8770
|
+
if (!hasNoOptions && !HTTP_ONLY_DISABLED_PATTERN.test(argumentsWithoutStrings)) continue;
|
|
8771
|
+
const location = getLocationAtIndex(content, match.index);
|
|
8772
|
+
findings.push({
|
|
8773
|
+
message,
|
|
8774
|
+
line: location.line,
|
|
8775
|
+
column: location.column
|
|
8776
|
+
});
|
|
8777
|
+
}
|
|
8778
|
+
const blankedContent = blankStringContents(content);
|
|
8779
|
+
COOKIE_CONFIG_OPENER_PATTERN.lastIndex = 0;
|
|
8780
|
+
for (let match = COOKIE_CONFIG_OPENER_PATTERN.exec(blankedContent); match !== null; match = COOKIE_CONFIG_OPENER_PATTERN.exec(blankedContent)) {
|
|
8781
|
+
const braceIndex = match.index + match[0].length - 1;
|
|
8782
|
+
const closeBraceIndex = findMatchingBracket(blankedContent, braceIndex);
|
|
8783
|
+
const block = closeBraceIndex >= 0 ? blankedContent.slice(braceIndex, closeBraceIndex) : blankedContent.slice(braceIndex, braceIndex + 400);
|
|
8784
|
+
if (!HTTP_ONLY_DISABLED_PATTERN.test(block)) continue;
|
|
8785
|
+
const location = getLocationAtIndex(blankedContent, match.index);
|
|
8786
|
+
findings.push({
|
|
8787
|
+
message,
|
|
8788
|
+
line: location.line,
|
|
8789
|
+
column: location.column
|
|
8790
|
+
});
|
|
8791
|
+
}
|
|
8792
|
+
addMatchFindings(content, CLIENT_AUTH_COOKIE_WRITE_PATTERN, message, () => true, findings);
|
|
8793
|
+
return findings;
|
|
8794
|
+
}
|
|
8795
|
+
});
|
|
8796
|
+
//#endregion
|
|
8498
8797
|
//#region src/plugin/constants/event-handlers.ts
|
|
8499
8798
|
const MOUSE_EVENT_HANDLERS = [
|
|
8500
8799
|
"onClick",
|
|
@@ -10624,7 +10923,7 @@ const jsxMaxDepth = defineRule({
|
|
|
10624
10923
|
});
|
|
10625
10924
|
//#endregion
|
|
10626
10925
|
//#region src/plugin/rules/react-builtins/jsx-no-comment-textnodes.ts
|
|
10627
|
-
const MESSAGE$
|
|
10926
|
+
const MESSAGE$52 = "Your users see this comment as text on the page because `//` & `/*` aren't hidden in JSX.";
|
|
10628
10927
|
const LITERAL_TEXT_TAGS = new Set([
|
|
10629
10928
|
"code",
|
|
10630
10929
|
"pre",
|
|
@@ -10660,7 +10959,7 @@ const jsxNoCommentTextnodes = defineRule({
|
|
|
10660
10959
|
if (isInsideLiteralTextTag(node)) return;
|
|
10661
10960
|
context.report({
|
|
10662
10961
|
node,
|
|
10663
|
-
message: MESSAGE$
|
|
10962
|
+
message: MESSAGE$52
|
|
10664
10963
|
});
|
|
10665
10964
|
} })
|
|
10666
10965
|
});
|
|
@@ -10691,7 +10990,7 @@ const isInsideFunctionScope = (node) => {
|
|
|
10691
10990
|
};
|
|
10692
10991
|
//#endregion
|
|
10693
10992
|
//#region src/plugin/rules/react-builtins/jsx-no-constructed-context-values.ts
|
|
10694
|
-
const MESSAGE$
|
|
10993
|
+
const MESSAGE$51 = "Every reader of this context redraws on each render because you build its `value` inline.";
|
|
10695
10994
|
const CONTEXT_MODULES$1 = [
|
|
10696
10995
|
"react",
|
|
10697
10996
|
"use-context-selector",
|
|
@@ -10789,7 +11088,7 @@ const jsxNoConstructedContextValues = defineRule({
|
|
|
10789
11088
|
if (!isConstructedValue(innerExpression)) continue;
|
|
10790
11089
|
context.report({
|
|
10791
11090
|
node: attribute,
|
|
10792
|
-
message: MESSAGE$
|
|
11091
|
+
message: MESSAGE$51
|
|
10793
11092
|
});
|
|
10794
11093
|
}
|
|
10795
11094
|
}
|
|
@@ -10875,7 +11174,7 @@ const isJsxAttributeOnIntrinsicHtmlElement = (attribute) => {
|
|
|
10875
11174
|
};
|
|
10876
11175
|
//#endregion
|
|
10877
11176
|
//#region src/plugin/rules/react-builtins/jsx-no-jsx-as-prop.ts
|
|
10878
|
-
const MESSAGE$
|
|
11177
|
+
const MESSAGE$50 = "This child redraws every render because the prop gets brand new JSX each time.";
|
|
10879
11178
|
const KNOWN_SLOT_PROP_NAMES = new Set([
|
|
10880
11179
|
"icon",
|
|
10881
11180
|
"Icon",
|
|
@@ -11144,7 +11443,7 @@ const jsxNoJsxAsProp = defineRule({
|
|
|
11144
11443
|
if (!isJsxProducingExpression(expressionNode) && !followsRenderLocalJsxBinding(expressionNode, node)) return;
|
|
11145
11444
|
context.report({
|
|
11146
11445
|
node,
|
|
11147
|
-
message: MESSAGE$
|
|
11446
|
+
message: MESSAGE$50
|
|
11148
11447
|
});
|
|
11149
11448
|
}
|
|
11150
11449
|
};
|
|
@@ -11432,7 +11731,7 @@ const DATA_ARRAY_PROP_SUFFIXES = [
|
|
|
11432
11731
|
];
|
|
11433
11732
|
//#endregion
|
|
11434
11733
|
//#region src/plugin/rules/react-builtins/jsx-no-new-array-as-prop.ts
|
|
11435
|
-
const MESSAGE$
|
|
11734
|
+
const MESSAGE$49 = "This child redraws every render because the prop gets a brand new array each time.";
|
|
11436
11735
|
const isDataArrayPropName = (propName) => {
|
|
11437
11736
|
if (DATA_ARRAY_PROP_NAMES.has(propName)) return true;
|
|
11438
11737
|
for (const suffix of DATA_ARRAY_PROP_SUFFIXES) if (propName.length > suffix.length && propName.endsWith(suffix)) return true;
|
|
@@ -11516,7 +11815,7 @@ const jsxNoNewArrayAsProp = defineRule({
|
|
|
11516
11815
|
if (!isArrayProducingExpression(expressionNode) && !followsRenderLocalArrayBinding(expressionNode, node)) return;
|
|
11517
11816
|
context.report({
|
|
11518
11817
|
node,
|
|
11519
|
-
message: MESSAGE$
|
|
11818
|
+
message: MESSAGE$49
|
|
11520
11819
|
});
|
|
11521
11820
|
}
|
|
11522
11821
|
};
|
|
@@ -11774,7 +12073,7 @@ const SAFE_RECEIVER_NAMES = new Set([
|
|
|
11774
12073
|
]);
|
|
11775
12074
|
//#endregion
|
|
11776
12075
|
//#region src/plugin/rules/react-builtins/jsx-no-new-function-as-prop.ts
|
|
11777
|
-
const MESSAGE$
|
|
12076
|
+
const MESSAGE$48 = "This child redraws every render because the prop gets a brand new function each time.";
|
|
11778
12077
|
const isAccessorPredicateName = (propName) => {
|
|
11779
12078
|
for (const prefix of ACCESSOR_PREDICATE_PREFIXES) {
|
|
11780
12079
|
if (propName.length <= prefix.length) continue;
|
|
@@ -11980,7 +12279,7 @@ const jsxNoNewFunctionAsProp = defineRule({
|
|
|
11980
12279
|
if (!isFunctionProducingExpression(expressionNode) && !followsRenderLocalFunctionBinding(expressionNode, node)) return;
|
|
11981
12280
|
context.report({
|
|
11982
12281
|
node,
|
|
11983
|
-
message: MESSAGE$
|
|
12282
|
+
message: MESSAGE$48
|
|
11984
12283
|
});
|
|
11985
12284
|
}
|
|
11986
12285
|
};
|
|
@@ -12200,7 +12499,7 @@ const CONFIG_OBJECT_PROP_SUFFIXES = [
|
|
|
12200
12499
|
];
|
|
12201
12500
|
//#endregion
|
|
12202
12501
|
//#region src/plugin/rules/react-builtins/jsx-no-new-object-as-prop.ts
|
|
12203
|
-
const MESSAGE$
|
|
12502
|
+
const MESSAGE$47 = "This child redraws every render because the prop gets a brand new object each time.";
|
|
12204
12503
|
const isConfigObjectPropName = (propName) => {
|
|
12205
12504
|
if (CONFIG_OBJECT_PROP_NAMES.has(propName)) return true;
|
|
12206
12505
|
for (const suffix of CONFIG_OBJECT_PROP_SUFFIXES) if (propName.length > suffix.length && propName.endsWith(suffix)) return true;
|
|
@@ -12288,7 +12587,7 @@ const jsxNoNewObjectAsProp = defineRule({
|
|
|
12288
12587
|
if (!isObjectProducingExpression(expressionNode) && !followsRenderLocalObjectBinding(expressionNode, node)) return;
|
|
12289
12588
|
context.report({
|
|
12290
12589
|
node,
|
|
12291
|
-
message: MESSAGE$
|
|
12590
|
+
message: MESSAGE$47
|
|
12292
12591
|
});
|
|
12293
12592
|
}
|
|
12294
12593
|
};
|
|
@@ -12296,7 +12595,7 @@ const jsxNoNewObjectAsProp = defineRule({
|
|
|
12296
12595
|
});
|
|
12297
12596
|
//#endregion
|
|
12298
12597
|
//#region src/plugin/rules/react-builtins/jsx-no-script-url.ts
|
|
12299
|
-
const MESSAGE$
|
|
12598
|
+
const MESSAGE$46 = "A `javascript:` URL is an XSS hole that runs injected input as code.";
|
|
12300
12599
|
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
12600
|
const resolveSettings$28 = (settings) => {
|
|
12302
12601
|
const reactDoctor = settings?.["react-doctor"];
|
|
@@ -12337,7 +12636,7 @@ const jsxNoScriptUrl = defineRule({
|
|
|
12337
12636
|
if (!value || !isNodeOfType(value, "Literal") || typeof value.value !== "string") continue;
|
|
12338
12637
|
if (JAVASCRIPT_URL_PATTERN.test(value.value)) context.report({
|
|
12339
12638
|
node: attribute,
|
|
12340
|
-
message: MESSAGE$
|
|
12639
|
+
message: MESSAGE$46
|
|
12341
12640
|
});
|
|
12342
12641
|
}
|
|
12343
12642
|
} };
|
|
@@ -12652,7 +12951,7 @@ const jsxPropsNoSpreadMulti = defineRule({
|
|
|
12652
12951
|
});
|
|
12653
12952
|
//#endregion
|
|
12654
12953
|
//#region src/plugin/rules/react-builtins/jsx-props-no-spreading.ts
|
|
12655
|
-
const MESSAGE$
|
|
12954
|
+
const MESSAGE$45 = "You can't tell what props reach this element when you spread them.";
|
|
12656
12955
|
const resolveSettings$25 = (settings) => {
|
|
12657
12956
|
const reactDoctor = settings?.["react-doctor"];
|
|
12658
12957
|
const ruleSettings = typeof reactDoctor === "object" && reactDoctor !== null ? reactDoctor.jsxPropsNoSpreading ?? {} : {};
|
|
@@ -12693,18 +12992,77 @@ const jsxPropsNoSpreading = defineRule({
|
|
|
12693
12992
|
}
|
|
12694
12993
|
context.report({
|
|
12695
12994
|
node: attribute,
|
|
12696
|
-
message: MESSAGE$
|
|
12995
|
+
message: MESSAGE$45
|
|
12697
12996
|
});
|
|
12698
12997
|
}
|
|
12699
12998
|
} };
|
|
12700
12999
|
}
|
|
12701
13000
|
});
|
|
12702
13001
|
//#endregion
|
|
13002
|
+
//#region src/plugin/rules/security-scan/jwt-insecure-verification.ts
|
|
13003
|
+
const NONE_ALGORITHM_PATTERN = /\b(?:alg|algorithms?)\s*:\s*\[?\s*["'`]none["'`]/gi;
|
|
13004
|
+
const isIndexInsideStringLiteral = (content, index) => {
|
|
13005
|
+
let stringDelimiter = null;
|
|
13006
|
+
const templateExpressionDepths = [];
|
|
13007
|
+
for (let cursor = 0; cursor < index; cursor += 1) {
|
|
13008
|
+
const character = content[cursor];
|
|
13009
|
+
if (stringDelimiter === "`") {
|
|
13010
|
+
if (character === "\\") cursor += 1;
|
|
13011
|
+
else if (character === "`") stringDelimiter = null;
|
|
13012
|
+
else if (character === "$" && content[cursor + 1] === "{") {
|
|
13013
|
+
templateExpressionDepths.push(0);
|
|
13014
|
+
stringDelimiter = null;
|
|
13015
|
+
cursor += 1;
|
|
13016
|
+
}
|
|
13017
|
+
continue;
|
|
13018
|
+
}
|
|
13019
|
+
if (stringDelimiter !== null) {
|
|
13020
|
+
if (character === "\\") cursor += 1;
|
|
13021
|
+
else if (character === stringDelimiter) stringDelimiter = null;
|
|
13022
|
+
continue;
|
|
13023
|
+
}
|
|
13024
|
+
if (character === "\"" || character === "'" || character === "`") stringDelimiter = character;
|
|
13025
|
+
else if (templateExpressionDepths.length > 0) {
|
|
13026
|
+
const top = templateExpressionDepths.length - 1;
|
|
13027
|
+
if (character === "{") templateExpressionDepths[top] += 1;
|
|
13028
|
+
else if (character === "}") if (templateExpressionDepths[top] === 0) {
|
|
13029
|
+
templateExpressionDepths.pop();
|
|
13030
|
+
stringDelimiter = "`";
|
|
13031
|
+
} else templateExpressionDepths[top] -= 1;
|
|
13032
|
+
}
|
|
13033
|
+
}
|
|
13034
|
+
return stringDelimiter !== null;
|
|
13035
|
+
};
|
|
13036
|
+
const jwtInsecureVerification = defineRule({
|
|
13037
|
+
id: "jwt-insecure-verification",
|
|
13038
|
+
title: "JWT verified with the 'none' algorithm",
|
|
13039
|
+
severity: "error",
|
|
13040
|
+
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'] })`).",
|
|
13041
|
+
scan: (file) => {
|
|
13042
|
+
if (!isProductionSourcePath(file.relativePath)) return [];
|
|
13043
|
+
const content = getScannableContent(file);
|
|
13044
|
+
if (!/\bjwt\b|jsonwebtoken|\bjose\b/i.test(content)) return [];
|
|
13045
|
+
const findings = [];
|
|
13046
|
+
NONE_ALGORITHM_PATTERN.lastIndex = 0;
|
|
13047
|
+
for (let noneMatch = NONE_ALGORITHM_PATTERN.exec(content); noneMatch !== null; noneMatch = NONE_ALGORITHM_PATTERN.exec(content)) {
|
|
13048
|
+
if (isIndexInsideStringLiteral(content, noneMatch.index)) continue;
|
|
13049
|
+
const location = getLocationAtIndex(content, noneMatch.index);
|
|
13050
|
+
findings.push({
|
|
13051
|
+
message: "JWT is configured with the 'none' algorithm, which disables signature verification, so any forged token is accepted.",
|
|
13052
|
+
line: location.line,
|
|
13053
|
+
column: location.column
|
|
13054
|
+
});
|
|
13055
|
+
}
|
|
13056
|
+
return findings;
|
|
13057
|
+
}
|
|
13058
|
+
});
|
|
13059
|
+
//#endregion
|
|
12703
13060
|
//#region src/plugin/rules/security-scan/key-lifecycle-risk.ts
|
|
12704
13061
|
const keyLifecycleRisk = defineRule({
|
|
12705
13062
|
id: "key-lifecycle-risk",
|
|
12706
13063
|
title: "Long-lived key material in repository",
|
|
12707
13064
|
severity: "error",
|
|
13065
|
+
committedFilesOnly: true,
|
|
12708
13066
|
recommendation: "Remove private keys from source, rotate exposed credentials, prefer short-lived deploy credentials, and document revocation/expiry for release keys.",
|
|
12709
13067
|
scan: scanByPattern({
|
|
12710
13068
|
shouldScan: (file) => !TEST_CONTEXT_PATTERN.test(file.relativePath) && !DOCUMENTATION_CONTEXT_PATTERN.test(file.relativePath),
|
|
@@ -12862,7 +13220,7 @@ const labelHasAssociatedControl = defineRule({
|
|
|
12862
13220
|
});
|
|
12863
13221
|
//#endregion
|
|
12864
13222
|
//#region src/plugin/rules/a11y/lang.ts
|
|
12865
|
-
const MESSAGE$
|
|
13223
|
+
const MESSAGE$44 = "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
13224
|
const COMMON_LANGUAGE_PRIMARY_TAGS = new Set([
|
|
12867
13225
|
"aa",
|
|
12868
13226
|
"ab",
|
|
@@ -13074,7 +13432,7 @@ const lang = defineRule({
|
|
|
13074
13432
|
if (expression.type === "Identifier" && expression.name === "undefined" || expression.type === "Literal" && expression.value === null) {
|
|
13075
13433
|
context.report({
|
|
13076
13434
|
node: langAttr,
|
|
13077
|
-
message: MESSAGE$
|
|
13435
|
+
message: MESSAGE$44
|
|
13078
13436
|
});
|
|
13079
13437
|
return;
|
|
13080
13438
|
}
|
|
@@ -13083,7 +13441,7 @@ const lang = defineRule({
|
|
|
13083
13441
|
if (value === null) return;
|
|
13084
13442
|
if (!isValidLangTag(value)) context.report({
|
|
13085
13443
|
node: langAttr,
|
|
13086
|
-
message: MESSAGE$
|
|
13444
|
+
message: MESSAGE$44
|
|
13087
13445
|
});
|
|
13088
13446
|
} })
|
|
13089
13447
|
});
|
|
@@ -13127,7 +13485,7 @@ const mdxSsrExecutionRisk = defineRule({
|
|
|
13127
13485
|
});
|
|
13128
13486
|
//#endregion
|
|
13129
13487
|
//#region src/plugin/rules/a11y/media-has-caption.ts
|
|
13130
|
-
const MESSAGE$
|
|
13488
|
+
const MESSAGE$43 = "Deaf and hard-of-hearing users need captions for this media. Add a `<track kind=\"captions\">` inside the `<audio>` or `<video>`.";
|
|
13131
13489
|
const DEFAULT_AUDIO = ["audio"];
|
|
13132
13490
|
const DEFAULT_VIDEO = ["video"];
|
|
13133
13491
|
const DEFAULT_TRACK = ["track"];
|
|
@@ -13168,7 +13526,7 @@ const mediaHasCaption = defineRule({
|
|
|
13168
13526
|
if (!parent || !isNodeOfType(parent, "JSXElement")) {
|
|
13169
13527
|
context.report({
|
|
13170
13528
|
node: node.name,
|
|
13171
|
-
message: MESSAGE$
|
|
13529
|
+
message: MESSAGE$43
|
|
13172
13530
|
});
|
|
13173
13531
|
return;
|
|
13174
13532
|
}
|
|
@@ -13185,7 +13543,7 @@ const mediaHasCaption = defineRule({
|
|
|
13185
13543
|
return kindValue.value.toLowerCase() === "captions";
|
|
13186
13544
|
})) context.report({
|
|
13187
13545
|
node: node.name,
|
|
13188
|
-
message: MESSAGE$
|
|
13546
|
+
message: MESSAGE$43
|
|
13189
13547
|
});
|
|
13190
13548
|
} };
|
|
13191
13549
|
}
|
|
@@ -14986,7 +15344,7 @@ const nextjsNoVercelOgImport = defineRule({
|
|
|
14986
15344
|
});
|
|
14987
15345
|
//#endregion
|
|
14988
15346
|
//#region src/plugin/rules/a11y/no-access-key.ts
|
|
14989
|
-
const MESSAGE$
|
|
15347
|
+
const MESSAGE$42 = "Screen reader users can lose their shortcuts because `accessKey` clashes with them, so remove it.";
|
|
14990
15348
|
const isUndefinedIdentifier = (expression) => isNodeOfType(expression, "Identifier") && expression.name === "undefined";
|
|
14991
15349
|
const noAccessKey = defineRule({
|
|
14992
15350
|
id: "no-access-key",
|
|
@@ -15003,7 +15361,7 @@ const noAccessKey = defineRule({
|
|
|
15003
15361
|
if (isNodeOfType(attributeValue, "Literal") && typeof attributeValue.value === "string") {
|
|
15004
15362
|
context.report({
|
|
15005
15363
|
node: accessKey,
|
|
15006
|
-
message: MESSAGE$
|
|
15364
|
+
message: MESSAGE$42
|
|
15007
15365
|
});
|
|
15008
15366
|
return;
|
|
15009
15367
|
}
|
|
@@ -15013,7 +15371,7 @@ const noAccessKey = defineRule({
|
|
|
15013
15371
|
if (isUndefinedIdentifier(expression)) return;
|
|
15014
15372
|
context.report({
|
|
15015
15373
|
node: accessKey,
|
|
15016
|
-
message: MESSAGE$
|
|
15374
|
+
message: MESSAGE$42
|
|
15017
15375
|
});
|
|
15018
15376
|
}
|
|
15019
15377
|
} })
|
|
@@ -15495,8 +15853,41 @@ const noAdjustStateOnPropChange = defineRule({
|
|
|
15495
15853
|
} })
|
|
15496
15854
|
});
|
|
15497
15855
|
//#endregion
|
|
15856
|
+
//#region src/plugin/rules/design/utils/get-string-from-class-name-attr.ts
|
|
15857
|
+
const getStringFromClassNameAttr = (node) => {
|
|
15858
|
+
if (!isNodeOfType(node, "JSXOpeningElement")) return null;
|
|
15859
|
+
const classAttr = findJsxAttribute(node.attributes ?? [], "className");
|
|
15860
|
+
if (!classAttr?.value) return null;
|
|
15861
|
+
if (isNodeOfType(classAttr.value, "Literal") && typeof classAttr.value.value === "string") return classAttr.value.value;
|
|
15862
|
+
if (isNodeOfType(classAttr.value, "JSXExpressionContainer") && isNodeOfType(classAttr.value.expression, "Literal") && typeof classAttr.value.expression.value === "string") return classAttr.value.expression.value;
|
|
15863
|
+
if (isNodeOfType(classAttr.value, "JSXExpressionContainer") && isNodeOfType(classAttr.value.expression, "TemplateLiteral") && classAttr.value.expression.quasis?.length === 1) return classAttr.value.expression.quasis[0].value?.raw ?? null;
|
|
15864
|
+
return null;
|
|
15865
|
+
};
|
|
15866
|
+
//#endregion
|
|
15867
|
+
//#region src/plugin/rules/design/no-arbitrary-px-font-size.ts
|
|
15868
|
+
const ARBITRARY_PX_FONT_SIZE = /(?:^|\s)(?:\w+:)*text-\[(\d+(?:\.\d+)?)px\]/g;
|
|
15869
|
+
const noArbitraryPxFontSize = defineRule({
|
|
15870
|
+
id: "no-arbitrary-px-font-size",
|
|
15871
|
+
title: "Pixel arbitrary font size",
|
|
15872
|
+
tags: ["design", "test-noise"],
|
|
15873
|
+
severity: "warn",
|
|
15874
|
+
category: "Accessibility",
|
|
15875
|
+
recommendation: "Use `rem` for arbitrary font sizes (`text-[0.8125rem]`, not `text-[13px]`) so text scales with the user's root font-size preference. Pixels stay fine for `border-*` / `outline-*`.",
|
|
15876
|
+
create: (context) => ({ JSXOpeningElement(node) {
|
|
15877
|
+
const classNameValue = getStringFromClassNameAttr(node);
|
|
15878
|
+
if (!classNameValue) return;
|
|
15879
|
+
for (const match of classNameValue.matchAll(ARBITRARY_PX_FONT_SIZE)) {
|
|
15880
|
+
const rem = parseFloat(match[1]) / 16;
|
|
15881
|
+
context.report({
|
|
15882
|
+
node,
|
|
15883
|
+
message: `\`text-[${match[1]}px]\` doesn't scale with the user's font-size preference — use rem, e.g. \`text-[${rem}rem]\`.`
|
|
15884
|
+
});
|
|
15885
|
+
}
|
|
15886
|
+
} })
|
|
15887
|
+
});
|
|
15888
|
+
//#endregion
|
|
15498
15889
|
//#region src/plugin/rules/a11y/no-aria-hidden-on-focusable.ts
|
|
15499
|
-
const MESSAGE$
|
|
15890
|
+
const MESSAGE$41 = "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
15891
|
const noAriaHiddenOnFocusable = defineRule({
|
|
15501
15892
|
id: "no-aria-hidden-on-focusable",
|
|
15502
15893
|
title: "aria-hidden on focusable element",
|
|
@@ -15523,7 +15914,7 @@ const noAriaHiddenOnFocusable = defineRule({
|
|
|
15523
15914
|
const isImplicitlyFocusable = isInteractiveElement(tag, node);
|
|
15524
15915
|
if (isExplicitlyFocusable || isImplicitlyFocusable) context.report({
|
|
15525
15916
|
node: ariaHidden,
|
|
15526
|
-
message: MESSAGE$
|
|
15917
|
+
message: MESSAGE$41
|
|
15527
15918
|
});
|
|
15528
15919
|
} })
|
|
15529
15920
|
});
|
|
@@ -15891,7 +16282,7 @@ const noArrayIndexAsKey = defineRule({
|
|
|
15891
16282
|
});
|
|
15892
16283
|
//#endregion
|
|
15893
16284
|
//#region src/plugin/rules/react-builtins/no-array-index-key.ts
|
|
15894
|
-
const MESSAGE$
|
|
16285
|
+
const MESSAGE$40 = "Your users can see & submit the wrong data when this list reorders.";
|
|
15895
16286
|
const SECOND_INDEX_METHODS = new Set([
|
|
15896
16287
|
"every",
|
|
15897
16288
|
"filter",
|
|
@@ -16095,7 +16486,7 @@ const noArrayIndexKey = defineRule({
|
|
|
16095
16486
|
}
|
|
16096
16487
|
context.report({
|
|
16097
16488
|
node: keyAttribute,
|
|
16098
|
-
message: MESSAGE$
|
|
16489
|
+
message: MESSAGE$40
|
|
16099
16490
|
});
|
|
16100
16491
|
},
|
|
16101
16492
|
CallExpression(node) {
|
|
@@ -16115,15 +16506,35 @@ const noArrayIndexKey = defineRule({
|
|
|
16115
16506
|
if (propName !== "key") continue;
|
|
16116
16507
|
if (expressionUsesIndex(property.value, indexBinding.name)) context.report({
|
|
16117
16508
|
node: property,
|
|
16118
|
-
message: MESSAGE$
|
|
16509
|
+
message: MESSAGE$40
|
|
16119
16510
|
});
|
|
16120
16511
|
}
|
|
16121
16512
|
}
|
|
16122
16513
|
})
|
|
16123
16514
|
});
|
|
16124
16515
|
//#endregion
|
|
16516
|
+
//#region src/plugin/rules/state-and-effects/no-async-effect-callback.ts
|
|
16517
|
+
const MESSAGE$39 = "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.";
|
|
16518
|
+
const noAsyncEffectCallback = defineRule({
|
|
16519
|
+
id: "no-async-effect-callback",
|
|
16520
|
+
title: "Async effect callback",
|
|
16521
|
+
severity: "warn",
|
|
16522
|
+
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.",
|
|
16523
|
+
create: (context) => ({ CallExpression(node) {
|
|
16524
|
+
if (!isHookCall$1(node, EFFECT_HOOK_NAMES$1)) return;
|
|
16525
|
+
const callback = getEffectCallback(node);
|
|
16526
|
+
if (!callback) return;
|
|
16527
|
+
if (!isNodeOfType(callback, "ArrowFunctionExpression") && !isNodeOfType(callback, "FunctionExpression")) return;
|
|
16528
|
+
if (!callback.async) return;
|
|
16529
|
+
context.report({
|
|
16530
|
+
node: callback,
|
|
16531
|
+
message: MESSAGE$39
|
|
16532
|
+
});
|
|
16533
|
+
} })
|
|
16534
|
+
});
|
|
16535
|
+
//#endregion
|
|
16125
16536
|
//#region src/plugin/rules/a11y/no-autofocus.ts
|
|
16126
|
-
const MESSAGE$
|
|
16537
|
+
const MESSAGE$38 = "`autoFocus` moves focus on load, which can disrupt screen reader and keyboard users. Remove it and let users choose where to focus.";
|
|
16127
16538
|
const resolveSettings$21 = (settings) => {
|
|
16128
16539
|
const reactDoctor = settings?.["react-doctor"];
|
|
16129
16540
|
return { ignoreNonDOM: (typeof reactDoctor === "object" && reactDoctor !== null ? reactDoctor.noAutofocus ?? {} : {}).ignoreNonDOM ?? true };
|
|
@@ -16179,12 +16590,45 @@ const noAutofocus = defineRule({
|
|
|
16179
16590
|
}
|
|
16180
16591
|
context.report({
|
|
16181
16592
|
node: autoFocusAttribute,
|
|
16182
|
-
message: MESSAGE$
|
|
16593
|
+
message: MESSAGE$38
|
|
16183
16594
|
});
|
|
16184
16595
|
} };
|
|
16185
16596
|
}
|
|
16186
16597
|
});
|
|
16187
16598
|
//#endregion
|
|
16599
|
+
//#region src/plugin/rules/a11y/no-autoplay-without-muted.ts
|
|
16600
|
+
const MESSAGE$37 = "Autoplaying media with sound is hostile to your users (and browsers block it). Add `muted` (with `playsInline`) to the autoplaying `<video>` / `<audio>`, or drop `autoPlay`.";
|
|
16601
|
+
const resolveStaticBoolean = (attribute) => {
|
|
16602
|
+
const value = attribute.value;
|
|
16603
|
+
if (!value) return true;
|
|
16604
|
+
const literal = isNodeOfType(value, "JSXExpressionContainer") ? value.expression : value;
|
|
16605
|
+
if (isNodeOfType(literal, "Literal")) {
|
|
16606
|
+
if (literal.value === true || literal.value === "true") return true;
|
|
16607
|
+
if (literal.value === false || literal.value === "false") return false;
|
|
16608
|
+
}
|
|
16609
|
+
return null;
|
|
16610
|
+
};
|
|
16611
|
+
const noAutoplayWithoutMuted = defineRule({
|
|
16612
|
+
id: "no-autoplay-without-muted",
|
|
16613
|
+
title: "Autoplaying media without muted",
|
|
16614
|
+
severity: "warn",
|
|
16615
|
+
recommendation: "Always pair `autoPlay` with `muted` (and `playsInline`): `<video autoPlay muted loop playsInline />`. If the sound matters, drop `autoPlay` and let users start it.",
|
|
16616
|
+
create: (context) => ({ JSXOpeningElement(node) {
|
|
16617
|
+
if (!isNodeOfType(node.name, "JSXIdentifier")) return;
|
|
16618
|
+
const tagName = node.name.name;
|
|
16619
|
+
if (tagName !== "video" && tagName !== "audio") return;
|
|
16620
|
+
if (hasJsxSpreadAttribute(node.attributes)) return;
|
|
16621
|
+
const autoPlay = hasJsxPropIgnoreCase(node.attributes, "autoplay");
|
|
16622
|
+
if (!autoPlay || resolveStaticBoolean(autoPlay) !== true) return;
|
|
16623
|
+
const muted = hasJsxPropIgnoreCase(node.attributes, "muted");
|
|
16624
|
+
if (muted && resolveStaticBoolean(muted) !== false) return;
|
|
16625
|
+
context.report({
|
|
16626
|
+
node: node.name,
|
|
16627
|
+
message: MESSAGE$37
|
|
16628
|
+
});
|
|
16629
|
+
} })
|
|
16630
|
+
});
|
|
16631
|
+
//#endregion
|
|
16188
16632
|
//#region src/plugin/utils/create-relative-import-source.ts
|
|
16189
16633
|
const createRelativeImportSource = (filename, targetFilePath) => {
|
|
16190
16634
|
const targetPathWithoutExtension = targetFilePath.slice(0, targetFilePath.length - path.extname(targetFilePath).length);
|
|
@@ -16429,6 +16873,109 @@ const noBarrelImport = defineRule({
|
|
|
16429
16873
|
}
|
|
16430
16874
|
});
|
|
16431
16875
|
//#endregion
|
|
16876
|
+
//#region src/plugin/utils/function-contains-react-render-output.ts
|
|
16877
|
+
const NESTED_RENDER_EVIDENCE_BOUNDARY_TYPES = new Set([
|
|
16878
|
+
"FunctionDeclaration",
|
|
16879
|
+
"FunctionExpression",
|
|
16880
|
+
"ArrowFunctionExpression",
|
|
16881
|
+
"ClassDeclaration",
|
|
16882
|
+
"ClassExpression"
|
|
16883
|
+
]);
|
|
16884
|
+
const isReactImport$1 = (symbol) => {
|
|
16885
|
+
let importDeclaration = symbol.declarationNode?.parent;
|
|
16886
|
+
while (importDeclaration && !isNodeOfType(importDeclaration, "ImportDeclaration")) importDeclaration = importDeclaration.parent ?? null;
|
|
16887
|
+
if (!importDeclaration || !isNodeOfType(importDeclaration, "ImportDeclaration")) return false;
|
|
16888
|
+
return importDeclaration.source.value === "react";
|
|
16889
|
+
};
|
|
16890
|
+
const getImportedName = (symbol) => {
|
|
16891
|
+
if (symbol.kind !== "import") return null;
|
|
16892
|
+
if (!isReactImport$1(symbol)) return null;
|
|
16893
|
+
return getImportedName$1(symbol.declarationNode) ?? null;
|
|
16894
|
+
};
|
|
16895
|
+
const isReactNamespaceImport = (symbol) => {
|
|
16896
|
+
if (symbol.kind !== "import") return false;
|
|
16897
|
+
if (!isReactImport$1(symbol)) return false;
|
|
16898
|
+
return isNodeOfType(symbol.declarationNode, "ImportDefaultSpecifier") || isNodeOfType(symbol.declarationNode, "ImportNamespaceSpecifier");
|
|
16899
|
+
};
|
|
16900
|
+
const isReactCreateElementIdentifierCall = (callee, scopes) => {
|
|
16901
|
+
if (!isNodeOfType(callee, "Identifier")) return false;
|
|
16902
|
+
const symbol = scopes.symbolFor(callee);
|
|
16903
|
+
return Boolean(symbol && getImportedName(symbol) === "createElement");
|
|
16904
|
+
};
|
|
16905
|
+
const isReactCreateElementMemberCall = (callee, scopes) => {
|
|
16906
|
+
if (!isNodeOfType(callee, "MemberExpression")) return false;
|
|
16907
|
+
if (callee.computed) return false;
|
|
16908
|
+
if (!isNodeOfType(callee.object, "Identifier")) return false;
|
|
16909
|
+
if (!isNodeOfType(callee.property, "Identifier")) return false;
|
|
16910
|
+
if (callee.property.name !== "createElement") return false;
|
|
16911
|
+
const symbol = scopes.symbolFor(callee.object);
|
|
16912
|
+
return Boolean(symbol && isReactNamespaceImport(symbol));
|
|
16913
|
+
};
|
|
16914
|
+
const isReactCreateElementCall = (node, scopes) => {
|
|
16915
|
+
if (!isNodeOfType(node, "CallExpression")) return false;
|
|
16916
|
+
return isReactCreateElementIdentifierCall(node.callee, scopes) || isReactCreateElementMemberCall(node.callee, scopes);
|
|
16917
|
+
};
|
|
16918
|
+
const containsRenderOutput = (node, rootNode, scopes) => {
|
|
16919
|
+
if (node !== rootNode && NESTED_RENDER_EVIDENCE_BOUNDARY_TYPES.has(node.type)) return false;
|
|
16920
|
+
if (node.type === "JSXElement" || node.type === "JSXFragment") return true;
|
|
16921
|
+
if (isReactCreateElementCall(node, scopes)) return true;
|
|
16922
|
+
const nodeRecord = node;
|
|
16923
|
+
for (const key of Object.keys(nodeRecord)) {
|
|
16924
|
+
if (key === "parent") continue;
|
|
16925
|
+
const child = nodeRecord[key];
|
|
16926
|
+
if (Array.isArray(child)) {
|
|
16927
|
+
for (const innerChild of child) if (isAstNode(innerChild) && containsRenderOutput(innerChild, rootNode, scopes)) return true;
|
|
16928
|
+
} else if (isAstNode(child) && containsRenderOutput(child, rootNode, scopes)) return true;
|
|
16929
|
+
}
|
|
16930
|
+
return false;
|
|
16931
|
+
};
|
|
16932
|
+
const functionContainsReactRenderOutput = (functionNode, scopes) => containsRenderOutput(functionNode, functionNode, scopes);
|
|
16933
|
+
//#endregion
|
|
16934
|
+
//#region src/plugin/utils/is-component-declaration.ts
|
|
16935
|
+
const isComponentDeclaration = (node) => isNodeOfType(node, "FunctionDeclaration") && node.id !== null && Boolean(node.id?.name) && isUppercaseName(node.id.name);
|
|
16936
|
+
//#endregion
|
|
16937
|
+
//#region src/plugin/rules/react-builtins/no-call-component-as-function.ts
|
|
16938
|
+
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.`;
|
|
16939
|
+
const symbolIsLocalComponent = (symbol, context) => {
|
|
16940
|
+
const declaration = symbol.declarationNode;
|
|
16941
|
+
if (isComponentDeclaration(declaration)) return functionContainsReactRenderOutput(declaration, context.scopes);
|
|
16942
|
+
if (isComponentAssignment(declaration) && symbol.initializer) return functionContainsReactRenderOutput(symbol.initializer, context.scopes);
|
|
16943
|
+
return false;
|
|
16944
|
+
};
|
|
16945
|
+
const noCallComponentAsFunction = defineRule({
|
|
16946
|
+
id: "no-call-component-as-function",
|
|
16947
|
+
title: "Component called as a function",
|
|
16948
|
+
severity: "warn",
|
|
16949
|
+
tags: ["test-noise"],
|
|
16950
|
+
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.",
|
|
16951
|
+
create: (context) => {
|
|
16952
|
+
const renderedJsxNames = /* @__PURE__ */ new Set();
|
|
16953
|
+
const candidateCalls = [];
|
|
16954
|
+
return {
|
|
16955
|
+
JSXOpeningElement(node) {
|
|
16956
|
+
if (isNodeOfType(node.name, "JSXIdentifier") && isUppercaseName(node.name.name)) renderedJsxNames.add(node.name.name);
|
|
16957
|
+
},
|
|
16958
|
+
CallExpression(node) {
|
|
16959
|
+
if (isNodeOfType(node.callee, "Identifier") && isUppercaseName(node.callee.name)) candidateCalls.push({
|
|
16960
|
+
node,
|
|
16961
|
+
callee: node.callee,
|
|
16962
|
+
name: node.callee.name
|
|
16963
|
+
});
|
|
16964
|
+
},
|
|
16965
|
+
"Program:exit"() {
|
|
16966
|
+
for (const candidate of candidateCalls) {
|
|
16967
|
+
const symbol = context.scopes.symbolFor(candidate.callee);
|
|
16968
|
+
if (!symbol) continue;
|
|
16969
|
+
if (symbolIsLocalComponent(symbol, context) || symbol.kind === "import" && renderedJsxNames.has(candidate.name)) context.report({
|
|
16970
|
+
node: candidate.node,
|
|
16971
|
+
message: message(candidate.name)
|
|
16972
|
+
});
|
|
16973
|
+
}
|
|
16974
|
+
}
|
|
16975
|
+
};
|
|
16976
|
+
}
|
|
16977
|
+
});
|
|
16978
|
+
//#endregion
|
|
16432
16979
|
//#region src/plugin/utils/is-setter-identifier.ts
|
|
16433
16980
|
const isSetterIdentifier = (name) => SETTER_PATTERN.test(name);
|
|
16434
16981
|
//#endregion
|
|
@@ -16580,7 +17127,7 @@ const noChainStateUpdates = defineRule({
|
|
|
16580
17127
|
});
|
|
16581
17128
|
//#endregion
|
|
16582
17129
|
//#region src/plugin/rules/react-builtins/no-children-prop.ts
|
|
16583
|
-
const MESSAGE$
|
|
17130
|
+
const MESSAGE$36 = "A `children` prop can override or hide nested children, so the component may render different content than the JSX shows.";
|
|
16584
17131
|
const noChildrenProp = defineRule({
|
|
16585
17132
|
id: "no-children-prop",
|
|
16586
17133
|
title: "Children passed as a prop",
|
|
@@ -16592,7 +17139,7 @@ const noChildrenProp = defineRule({
|
|
|
16592
17139
|
if (node.name.name !== "children") return;
|
|
16593
17140
|
context.report({
|
|
16594
17141
|
node: node.name,
|
|
16595
|
-
message: MESSAGE$
|
|
17142
|
+
message: MESSAGE$36
|
|
16596
17143
|
});
|
|
16597
17144
|
},
|
|
16598
17145
|
CallExpression(node) {
|
|
@@ -16605,7 +17152,7 @@ const noChildrenProp = defineRule({
|
|
|
16605
17152
|
const propertyKey = property.key;
|
|
16606
17153
|
if (isNodeOfType(propertyKey, "Identifier") && propertyKey.name === "children" || isNodeOfType(propertyKey, "Literal") && propertyKey.value === "children") context.report({
|
|
16607
17154
|
node: propertyKey,
|
|
16608
|
-
message: MESSAGE$
|
|
17155
|
+
message: MESSAGE$36
|
|
16609
17156
|
});
|
|
16610
17157
|
}
|
|
16611
17158
|
}
|
|
@@ -16613,7 +17160,7 @@ const noChildrenProp = defineRule({
|
|
|
16613
17160
|
});
|
|
16614
17161
|
//#endregion
|
|
16615
17162
|
//#region src/plugin/rules/react-builtins/no-clone-element.ts
|
|
16616
|
-
const MESSAGE$
|
|
17163
|
+
const MESSAGE$35 = "`React.cloneElement` couples the parent to the child's prop shape, so child prop changes can silently break injected behavior.";
|
|
16617
17164
|
const noCloneElement = defineRule({
|
|
16618
17165
|
id: "no-clone-element",
|
|
16619
17166
|
title: "cloneElement makes child props fragile",
|
|
@@ -16626,7 +17173,7 @@ const noCloneElement = defineRule({
|
|
|
16626
17173
|
if (isNodeOfType(callee, "Identifier") && callee.name === "cloneElement") {
|
|
16627
17174
|
if (isImportedFromModule(node, "cloneElement", "react")) context.report({
|
|
16628
17175
|
node: callee,
|
|
16629
|
-
message: MESSAGE$
|
|
17176
|
+
message: MESSAGE$35
|
|
16630
17177
|
});
|
|
16631
17178
|
return;
|
|
16632
17179
|
}
|
|
@@ -16639,7 +17186,7 @@ const noCloneElement = defineRule({
|
|
|
16639
17186
|
if (!isImportedFromModule(node, callee.object.name, "react")) return;
|
|
16640
17187
|
context.report({
|
|
16641
17188
|
node: callee,
|
|
16642
|
-
message: MESSAGE$
|
|
17189
|
+
message: MESSAGE$35
|
|
16643
17190
|
});
|
|
16644
17191
|
}
|
|
16645
17192
|
} })
|
|
@@ -16688,7 +17235,7 @@ const enclosingComponentOrHookName = (node) => {
|
|
|
16688
17235
|
};
|
|
16689
17236
|
//#endregion
|
|
16690
17237
|
//#region src/plugin/rules/state-and-effects/no-create-context-in-render.ts
|
|
16691
|
-
const MESSAGE$
|
|
17238
|
+
const MESSAGE$34 = "createContext() builds a new context every render, so every consumer gets cut off & resets.";
|
|
16692
17239
|
const CONTEXT_MODULES = [
|
|
16693
17240
|
"react",
|
|
16694
17241
|
"use-context-selector",
|
|
@@ -16724,7 +17271,32 @@ const noCreateContextInRender = defineRule({
|
|
|
16724
17271
|
if (!componentOrHookName) return;
|
|
16725
17272
|
context.report({
|
|
16726
17273
|
node,
|
|
16727
|
-
message: `${MESSAGE$
|
|
17274
|
+
message: `${MESSAGE$34} (called inside "${componentOrHookName}")`
|
|
17275
|
+
});
|
|
17276
|
+
} })
|
|
17277
|
+
});
|
|
17278
|
+
//#endregion
|
|
17279
|
+
//#region src/plugin/rules/react-builtins/no-create-ref-in-function-component.ts
|
|
17280
|
+
const MESSAGE$33 = "`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.";
|
|
17281
|
+
const noCreateRefInFunctionComponent = defineRule({
|
|
17282
|
+
id: "no-create-ref-in-function-component",
|
|
17283
|
+
title: "createRef in function component",
|
|
17284
|
+
severity: "warn",
|
|
17285
|
+
recommendation: "Replace `createRef()` with the `useRef()` hook inside function components and hooks. `createRef` is only for class components.",
|
|
17286
|
+
create: (context) => ({ CallExpression(node) {
|
|
17287
|
+
if (!isReactFunctionCall(node, "createRef")) return;
|
|
17288
|
+
if (isNodeOfType(node.callee, "Identifier")) {
|
|
17289
|
+
const symbol = context.scopes.symbolFor(node.callee);
|
|
17290
|
+
if (symbol && symbol.kind !== "import") return;
|
|
17291
|
+
}
|
|
17292
|
+
const enclosingFunction = nearestEnclosingFunction(node);
|
|
17293
|
+
if (!enclosingFunction) return;
|
|
17294
|
+
const displayName = componentOrHookDisplayNameForFunction(enclosingFunction);
|
|
17295
|
+
if (!displayName) return;
|
|
17296
|
+
if (!(isReactHookName(displayName) || functionContainsReactRenderOutput(enclosingFunction, context.scopes))) return;
|
|
17297
|
+
context.report({
|
|
17298
|
+
node,
|
|
17299
|
+
message: MESSAGE$33
|
|
16728
17300
|
});
|
|
16729
17301
|
} })
|
|
16730
17302
|
});
|
|
@@ -16864,7 +17436,7 @@ const noCreateStoreInRender = defineRule({
|
|
|
16864
17436
|
});
|
|
16865
17437
|
//#endregion
|
|
16866
17438
|
//#region src/plugin/rules/react-builtins/no-danger.ts
|
|
16867
|
-
const MESSAGE$
|
|
17439
|
+
const MESSAGE$32 = "`dangerouslySetInnerHTML` is an XSS hole that runs attacker-controlled HTML in your users' browsers.";
|
|
16868
17440
|
const noDanger = defineRule({
|
|
16869
17441
|
id: "no-danger",
|
|
16870
17442
|
title: "Raw HTML injection can run unsafe markup",
|
|
@@ -16877,7 +17449,7 @@ const noDanger = defineRule({
|
|
|
16877
17449
|
if (!propAttribute) return;
|
|
16878
17450
|
context.report({
|
|
16879
17451
|
node: propAttribute.name,
|
|
16880
|
-
message: MESSAGE$
|
|
17452
|
+
message: MESSAGE$32
|
|
16881
17453
|
});
|
|
16882
17454
|
},
|
|
16883
17455
|
CallExpression(node) {
|
|
@@ -16889,7 +17461,7 @@ const noDanger = defineRule({
|
|
|
16889
17461
|
const propertyKey = property.key;
|
|
16890
17462
|
if (isNodeOfType(propertyKey, "Identifier") && propertyKey.name === "dangerouslySetInnerHTML" || isNodeOfType(propertyKey, "Literal") && propertyKey.value === "dangerouslySetInnerHTML") context.report({
|
|
16891
17463
|
node: propertyKey,
|
|
16892
|
-
message: MESSAGE$
|
|
17464
|
+
message: MESSAGE$32
|
|
16893
17465
|
});
|
|
16894
17466
|
}
|
|
16895
17467
|
}
|
|
@@ -16897,7 +17469,7 @@ const noDanger = defineRule({
|
|
|
16897
17469
|
});
|
|
16898
17470
|
//#endregion
|
|
16899
17471
|
//#region src/plugin/rules/react-builtins/no-danger-with-children.ts
|
|
16900
|
-
const MESSAGE$
|
|
17472
|
+
const MESSAGE$31 = "React throws an error when you set both children & `dangerouslySetInnerHTML`.";
|
|
16901
17473
|
const isLineBreak = (child) => {
|
|
16902
17474
|
if (!isNodeOfType(child, "JSXText")) return false;
|
|
16903
17475
|
return child.value.trim().length === 0 && child.value.includes("\n");
|
|
@@ -16967,7 +17539,7 @@ const noDangerWithChildren = defineRule({
|
|
|
16967
17539
|
if (!hasChildrenProp && !hasNestedChildren) return;
|
|
16968
17540
|
if (hasJsxPropIgnoreCase(opening.attributes, "dangerouslySetInnerHTML") || spreadPropsShape.hasDangerously) context.report({
|
|
16969
17541
|
node: opening,
|
|
16970
|
-
message: MESSAGE$
|
|
17542
|
+
message: MESSAGE$31
|
|
16971
17543
|
});
|
|
16972
17544
|
},
|
|
16973
17545
|
CallExpression(node) {
|
|
@@ -16979,7 +17551,7 @@ const noDangerWithChildren = defineRule({
|
|
|
16979
17551
|
if (!propsShape.hasDangerously) return;
|
|
16980
17552
|
if (node.arguments.length >= 3 || propsShape.hasChildren) context.report({
|
|
16981
17553
|
node,
|
|
16982
|
-
message: MESSAGE$
|
|
17554
|
+
message: MESSAGE$31
|
|
16983
17555
|
});
|
|
16984
17556
|
}
|
|
16985
17557
|
})
|
|
@@ -17144,6 +17716,37 @@ const noDefaultProps = defineRule({
|
|
|
17144
17716
|
} })
|
|
17145
17717
|
});
|
|
17146
17718
|
//#endregion
|
|
17719
|
+
//#region src/plugin/utils/get-class-name-tokens.ts
|
|
17720
|
+
const getClassNameTokens = (classNameValue) => classNameValue.split(/\s+/).filter((token) => token.length > 0).map((token) => token.split(":").pop() ?? token);
|
|
17721
|
+
//#endregion
|
|
17722
|
+
//#region src/plugin/rules/design/no-deprecated-tailwind-class.ts
|
|
17723
|
+
const renameDeprecatedToken = (token) => {
|
|
17724
|
+
if (token === "overflow-ellipsis") return "text-ellipsis";
|
|
17725
|
+
if (token.startsWith("flex-shrink")) return token.replace("flex-shrink", "shrink");
|
|
17726
|
+
if (token.startsWith("flex-grow")) return token.replace("flex-grow", "grow");
|
|
17727
|
+
if (token.startsWith("bg-gradient-to-")) return token.replace("bg-gradient-to-", "bg-linear-to-");
|
|
17728
|
+
return null;
|
|
17729
|
+
};
|
|
17730
|
+
const noDeprecatedTailwindClass = defineRule({
|
|
17731
|
+
id: "no-deprecated-tailwind-class",
|
|
17732
|
+
title: "Deprecated Tailwind v4 utility",
|
|
17733
|
+
tags: ["design", "test-noise"],
|
|
17734
|
+
severity: "warn",
|
|
17735
|
+
requires: ["tailwind:4"],
|
|
17736
|
+
recommendation: "Tailwind v4 renamed these utilities: `bg-gradient-*` → `bg-linear-*`, `flex-shrink-*` → `shrink-*`, `flex-grow-*` → `grow-*`, `overflow-ellipsis` → `text-ellipsis`. Use the new names.",
|
|
17737
|
+
create: (context) => ({ JSXOpeningElement(node) {
|
|
17738
|
+
const classNameValue = getStringFromClassNameAttr(node);
|
|
17739
|
+
if (!classNameValue) return;
|
|
17740
|
+
for (const token of getClassNameTokens(classNameValue)) {
|
|
17741
|
+
const replacement = renameDeprecatedToken(token);
|
|
17742
|
+
if (replacement) context.report({
|
|
17743
|
+
node,
|
|
17744
|
+
message: `\`${token}\` was renamed in Tailwind v4 and no longer applies — use \`${replacement}\`.`
|
|
17745
|
+
});
|
|
17746
|
+
}
|
|
17747
|
+
} })
|
|
17748
|
+
});
|
|
17749
|
+
//#endregion
|
|
17147
17750
|
//#region src/plugin/utils/is-initial-only-prop-name.ts
|
|
17148
17751
|
const isInitialOnlyPropName = (propName) => {
|
|
17149
17752
|
if (propName === "initialValue" || propName === "defaultValue" || propName === "seedValue") return true;
|
|
@@ -17556,7 +18159,7 @@ const isSetStateCallInLifecycle = (setStateCall, lifecycleNames, options = {}) =
|
|
|
17556
18159
|
//#endregion
|
|
17557
18160
|
//#region src/plugin/rules/react-builtins/no-did-mount-set-state.ts
|
|
17558
18161
|
const LIFECYCLE_NAMES$2 = new Set(["componentDidMount"]);
|
|
17559
|
-
const MESSAGE$
|
|
18162
|
+
const MESSAGE$30 = "Your users see an extra render right after mount when you call `setState` in `componentDidMount`.";
|
|
17560
18163
|
const resolveSettings$20 = (settings) => {
|
|
17561
18164
|
const reactDoctor = settings?.["react-doctor"];
|
|
17562
18165
|
return { mode: (typeof reactDoctor === "object" && reactDoctor !== null ? reactDoctor.noDidMountSetState ?? {} : {}).mode ?? "allowed" };
|
|
@@ -17575,7 +18178,7 @@ const noDidMountSetState = defineRule({
|
|
|
17575
18178
|
if (!isSetStateCallInLifecycle(node, LIFECYCLE_NAMES$2, { disallowInNestedFunctions: mode === "disallow-in-func" })) return;
|
|
17576
18179
|
context.report({
|
|
17577
18180
|
node: node.callee,
|
|
17578
|
-
message: MESSAGE$
|
|
18181
|
+
message: MESSAGE$30
|
|
17579
18182
|
});
|
|
17580
18183
|
} };
|
|
17581
18184
|
}
|
|
@@ -17583,7 +18186,7 @@ const noDidMountSetState = defineRule({
|
|
|
17583
18186
|
//#endregion
|
|
17584
18187
|
//#region src/plugin/rules/react-builtins/no-did-update-set-state.ts
|
|
17585
18188
|
const LIFECYCLE_NAMES$1 = new Set(["componentDidUpdate"]);
|
|
17586
|
-
const MESSAGE$
|
|
18189
|
+
const MESSAGE$29 = "Calling setState in componentDidUpdate can trigger another update immediately, loop forever, and freeze the component.";
|
|
17587
18190
|
const resolveSettings$19 = (settings) => {
|
|
17588
18191
|
const reactDoctor = settings?.["react-doctor"];
|
|
17589
18192
|
return { mode: (typeof reactDoctor === "object" && reactDoctor !== null ? reactDoctor.noDidUpdateSetState ?? {} : {}).mode ?? "allowed" };
|
|
@@ -17602,7 +18205,7 @@ const noDidUpdateSetState = defineRule({
|
|
|
17602
18205
|
if (!isSetStateCallInLifecycle(node, LIFECYCLE_NAMES$1, { disallowInNestedFunctions: mode === "disallow-in-func" })) return;
|
|
17603
18206
|
context.report({
|
|
17604
18207
|
node: node.callee,
|
|
17605
|
-
message: MESSAGE$
|
|
18208
|
+
message: MESSAGE$29
|
|
17606
18209
|
});
|
|
17607
18210
|
} };
|
|
17608
18211
|
}
|
|
@@ -17625,7 +18228,7 @@ const isStateMemberExpression = (node) => {
|
|
|
17625
18228
|
};
|
|
17626
18229
|
//#endregion
|
|
17627
18230
|
//#region src/plugin/rules/react-builtins/no-direct-mutation-state.ts
|
|
17628
|
-
const MESSAGE$
|
|
18231
|
+
const MESSAGE$28 = "Your users see stale data because mutating `this.state` by hand never redraws & gets overwritten.";
|
|
17629
18232
|
const shouldIgnoreMutation = (node) => {
|
|
17630
18233
|
let isConstructor = false;
|
|
17631
18234
|
let isInsideCallExpression = false;
|
|
@@ -17647,7 +18250,7 @@ const reportIfStateMutation = (context, reportNode, target) => {
|
|
|
17647
18250
|
if (shouldIgnoreMutation(reportNode)) return;
|
|
17648
18251
|
context.report({
|
|
17649
18252
|
node: reportNode,
|
|
17650
|
-
message: MESSAGE$
|
|
18253
|
+
message: MESSAGE$28
|
|
17651
18254
|
});
|
|
17652
18255
|
};
|
|
17653
18256
|
const noDirectMutationState = defineRule({
|
|
@@ -17857,6 +18460,26 @@ const noDocumentStartViewTransition = defineRule({
|
|
|
17857
18460
|
} })
|
|
17858
18461
|
});
|
|
17859
18462
|
//#endregion
|
|
18463
|
+
//#region src/plugin/rules/js-performance/no-document-write.ts
|
|
18464
|
+
const MESSAGE$27 = "`document.write()` blocks parsing, is ignored (or wipes the page) after load, and is flagged by browsers as a performance anti-pattern. Build DOM nodes or set `innerHTML`/`textContent` on a target element instead.";
|
|
18465
|
+
const WRITE_METHODS = new Set(["write", "writeln"]);
|
|
18466
|
+
const noDocumentWrite = defineRule({
|
|
18467
|
+
id: "no-document-write",
|
|
18468
|
+
title: "document.write/writeln",
|
|
18469
|
+
severity: "warn",
|
|
18470
|
+
recommendation: "Don't use `document.write()`/`document.writeln()`. Append DOM nodes or set `innerHTML`/`textContent` on a specific element instead.",
|
|
18471
|
+
create: (context) => ({ CallExpression(node) {
|
|
18472
|
+
const callee = node.callee;
|
|
18473
|
+
if (!isNodeOfType(callee, "MemberExpression") || callee.computed) return;
|
|
18474
|
+
if (!isNodeOfType(callee.object, "Identifier") || callee.object.name !== "document") return;
|
|
18475
|
+
if (!isNodeOfType(callee.property, "Identifier") || !WRITE_METHODS.has(callee.property.name)) return;
|
|
18476
|
+
context.report({
|
|
18477
|
+
node,
|
|
18478
|
+
message: MESSAGE$27
|
|
18479
|
+
});
|
|
18480
|
+
} })
|
|
18481
|
+
});
|
|
18482
|
+
//#endregion
|
|
17860
18483
|
//#region src/plugin/rules/bundle-size/no-dynamic-import-path.ts
|
|
17861
18484
|
const noDynamicImportPath = defineRule({
|
|
17862
18485
|
id: "no-dynamic-import-path",
|
|
@@ -19235,7 +19858,7 @@ const ALLOWED_NAMESPACES = new Set([
|
|
|
19235
19858
|
"ReactDOM",
|
|
19236
19859
|
"ReactDom"
|
|
19237
19860
|
]);
|
|
19238
|
-
const MESSAGE$
|
|
19861
|
+
const MESSAGE$26 = "`findDOMNode` crashes your app in React 19 because it was removed.";
|
|
19239
19862
|
const noFindDomNode = defineRule({
|
|
19240
19863
|
id: "no-find-dom-node",
|
|
19241
19864
|
title: "findDOMNode breaks component encapsulation",
|
|
@@ -19246,7 +19869,7 @@ const noFindDomNode = defineRule({
|
|
|
19246
19869
|
if (isNodeOfType(callee, "Identifier") && callee.name === "findDOMNode") {
|
|
19247
19870
|
context.report({
|
|
19248
19871
|
node: callee,
|
|
19249
|
-
message: MESSAGE$
|
|
19872
|
+
message: MESSAGE$26
|
|
19250
19873
|
});
|
|
19251
19874
|
return;
|
|
19252
19875
|
}
|
|
@@ -19257,7 +19880,7 @@ const noFindDomNode = defineRule({
|
|
|
19257
19880
|
if (callee.property.name !== "findDOMNode") return;
|
|
19258
19881
|
context.report({
|
|
19259
19882
|
node: callee.property,
|
|
19260
|
-
message: MESSAGE$
|
|
19883
|
+
message: MESSAGE$26
|
|
19261
19884
|
});
|
|
19262
19885
|
}
|
|
19263
19886
|
} })
|
|
@@ -19298,6 +19921,41 @@ const noFullLodashImport = defineRule({
|
|
|
19298
19921
|
} })
|
|
19299
19922
|
});
|
|
19300
19923
|
//#endregion
|
|
19924
|
+
//#region src/plugin/rules/design/no-full-viewport-width.ts
|
|
19925
|
+
const FULL_VIEWPORT_WIDTH_CLASS = /(?:^|\s)(?:min-)?w-(?:screen|\[100vw\])(?:$|\s)/;
|
|
19926
|
+
const WIDTH_KEYS = new Set(["width", "minWidth"]);
|
|
19927
|
+
const MESSAGE$25 = "`100vw` is wider than the viewport whenever a scrollbar is visible, so it triggers horizontal scroll on most desktops. Use `w-full` / `width: 100%` (with the parent's padding) for a full-bleed element.";
|
|
19928
|
+
const noFullViewportWidth = defineRule({
|
|
19929
|
+
id: "no-full-viewport-width",
|
|
19930
|
+
title: "Full viewport width causes overflow",
|
|
19931
|
+
tags: ["design", "test-noise"],
|
|
19932
|
+
severity: "warn",
|
|
19933
|
+
recommendation: "Prefer `w-full` (`width: 100%`) over `w-screen` / `100vw`. `100vw` ignores the scrollbar gutter and overflows horizontally.",
|
|
19934
|
+
create: (context) => ({
|
|
19935
|
+
JSXAttribute(node) {
|
|
19936
|
+
const expression = getInlineStyleExpression(node);
|
|
19937
|
+
if (!expression) return;
|
|
19938
|
+
for (const property of expression.properties ?? []) {
|
|
19939
|
+
const key = getStylePropertyKey(property);
|
|
19940
|
+
if (!key || !WIDTH_KEYS.has(key)) continue;
|
|
19941
|
+
const value = getStylePropertyStringValue(property);
|
|
19942
|
+
if (value && value.trim().toLowerCase() === "100vw") context.report({
|
|
19943
|
+
node: property,
|
|
19944
|
+
message: MESSAGE$25
|
|
19945
|
+
});
|
|
19946
|
+
}
|
|
19947
|
+
},
|
|
19948
|
+
JSXOpeningElement(node) {
|
|
19949
|
+
const classNameValue = getStringFromClassNameAttr(node);
|
|
19950
|
+
if (!classNameValue) return;
|
|
19951
|
+
if (FULL_VIEWPORT_WIDTH_CLASS.test(classNameValue)) context.report({
|
|
19952
|
+
node,
|
|
19953
|
+
message: MESSAGE$25
|
|
19954
|
+
});
|
|
19955
|
+
}
|
|
19956
|
+
})
|
|
19957
|
+
});
|
|
19958
|
+
//#endregion
|
|
19301
19959
|
//#region src/plugin/rules/architecture/no-generic-handler-names.ts
|
|
19302
19960
|
const noGenericHandlerNames = defineRule({
|
|
19303
19961
|
id: "no-generic-handler-names",
|
|
@@ -19320,64 +19978,6 @@ const noGenericHandlerNames = defineRule({
|
|
|
19320
19978
|
} })
|
|
19321
19979
|
});
|
|
19322
19980
|
//#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
19981
|
//#region src/plugin/rules/architecture/no-giant-component.ts
|
|
19382
19982
|
const noGiantComponent = defineRule({
|
|
19383
19983
|
id: "no-giant-component",
|
|
@@ -19418,7 +20018,7 @@ const noGiantComponent = defineRule({
|
|
|
19418
20018
|
});
|
|
19419
20019
|
//#endregion
|
|
19420
20020
|
//#region src/plugin/constants/style.ts
|
|
19421
|
-
const LAYOUT_PROPERTIES = new Set([
|
|
20021
|
+
const LAYOUT_PROPERTIES$1 = new Set([
|
|
19422
20022
|
"width",
|
|
19423
20023
|
"height",
|
|
19424
20024
|
"top",
|
|
@@ -19488,17 +20088,6 @@ const noGlobalCssVariableAnimation = defineRule({
|
|
|
19488
20088
|
} })
|
|
19489
20089
|
});
|
|
19490
20090
|
//#endregion
|
|
19491
|
-
//#region src/plugin/rules/design/utils/get-string-from-class-name-attr.ts
|
|
19492
|
-
const getStringFromClassNameAttr = (node) => {
|
|
19493
|
-
if (!isNodeOfType(node, "JSXOpeningElement")) return null;
|
|
19494
|
-
const classAttr = findJsxAttribute(node.attributes ?? [], "className");
|
|
19495
|
-
if (!classAttr?.value) return null;
|
|
19496
|
-
if (isNodeOfType(classAttr.value, "Literal") && typeof classAttr.value.value === "string") return classAttr.value.value;
|
|
19497
|
-
if (isNodeOfType(classAttr.value, "JSXExpressionContainer") && isNodeOfType(classAttr.value.expression, "Literal") && typeof classAttr.value.expression.value === "string") return classAttr.value.expression.value;
|
|
19498
|
-
if (isNodeOfType(classAttr.value, "JSXExpressionContainer") && isNodeOfType(classAttr.value.expression, "TemplateLiteral") && classAttr.value.expression.quasis?.length === 1) return classAttr.value.expression.quasis[0].value?.raw ?? null;
|
|
19499
|
-
return null;
|
|
19500
|
-
};
|
|
19501
|
-
//#endregion
|
|
19502
20091
|
//#region src/plugin/rules/design/no-gradient-text.ts
|
|
19503
20092
|
const noGradientText = defineRule({
|
|
19504
20093
|
id: "no-gradient-text",
|
|
@@ -19556,6 +20145,26 @@ const noGrayOnColoredBackground = defineRule({
|
|
|
19556
20145
|
} })
|
|
19557
20146
|
});
|
|
19558
20147
|
//#endregion
|
|
20148
|
+
//#region src/plugin/rules/performance/no-img-lazy-with-high-fetchpriority.ts
|
|
20149
|
+
const MESSAGE$24 = "`<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.";
|
|
20150
|
+
const noImgLazyWithHighFetchpriority = defineRule({
|
|
20151
|
+
id: "no-img-lazy-with-high-fetchpriority",
|
|
20152
|
+
title: "Lazy image with high fetchPriority",
|
|
20153
|
+
severity: "warn",
|
|
20154
|
+
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.",
|
|
20155
|
+
create: (context) => ({ JSXOpeningElement(node) {
|
|
20156
|
+
if (!isNodeOfType(node.name, "JSXIdentifier") || node.name.name !== "img") return;
|
|
20157
|
+
const loadingAttribute = hasJsxPropIgnoreCase(node.attributes, "loading");
|
|
20158
|
+
if (!loadingAttribute || getJsxPropStringValue(loadingAttribute)?.toLowerCase() !== "lazy") return;
|
|
20159
|
+
const fetchPriorityAttribute = hasJsxPropIgnoreCase(node.attributes, "fetchPriority");
|
|
20160
|
+
if (!fetchPriorityAttribute || getJsxPropStringValue(fetchPriorityAttribute)?.toLowerCase() !== "high") return;
|
|
20161
|
+
context.report({
|
|
20162
|
+
node: node.name,
|
|
20163
|
+
message: MESSAGE$24
|
|
20164
|
+
});
|
|
20165
|
+
} })
|
|
20166
|
+
});
|
|
20167
|
+
//#endregion
|
|
19559
20168
|
//#region src/plugin/rules/state-and-effects/no-initialize-state.ts
|
|
19560
20169
|
const noInitializeState = defineRule({
|
|
19561
20170
|
id: "no-initialize-state",
|
|
@@ -19785,8 +20394,31 @@ const noIsMounted = defineRule({
|
|
|
19785
20394
|
} })
|
|
19786
20395
|
});
|
|
19787
20396
|
//#endregion
|
|
20397
|
+
//#region src/plugin/rules/js-performance/no-json-parse-stringify-clone.ts
|
|
20398
|
+
const MESSAGE$23 = "`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)`.";
|
|
20399
|
+
const isJsonMethodCall = (node, method) => {
|
|
20400
|
+
if (!isNodeOfType(node, "CallExpression")) return false;
|
|
20401
|
+
const callee = node.callee;
|
|
20402
|
+
return isNodeOfType(callee, "MemberExpression") && !callee.computed && isNodeOfType(callee.object, "Identifier") && callee.object.name === "JSON" && isNodeOfType(callee.property, "Identifier") && callee.property.name === method;
|
|
20403
|
+
};
|
|
20404
|
+
const noJsonParseStringifyClone = defineRule({
|
|
20405
|
+
id: "no-json-parse-stringify-clone",
|
|
20406
|
+
title: "JSON parse/stringify deep clone",
|
|
20407
|
+
severity: "warn",
|
|
20408
|
+
recommendation: "Replace `JSON.parse(JSON.stringify(value))` with `structuredClone(value)`. It is faster and preserves Dates, Maps, Sets, and cyclic references.",
|
|
20409
|
+
create: (context) => ({ CallExpression(node) {
|
|
20410
|
+
if (!isJsonMethodCall(node, "parse")) return;
|
|
20411
|
+
const firstArgument = node.arguments?.[0];
|
|
20412
|
+
if (!firstArgument || !isJsonMethodCall(firstArgument, "stringify")) return;
|
|
20413
|
+
context.report({
|
|
20414
|
+
node,
|
|
20415
|
+
message: MESSAGE$23
|
|
20416
|
+
});
|
|
20417
|
+
} })
|
|
20418
|
+
});
|
|
20419
|
+
//#endregion
|
|
19788
20420
|
//#region src/plugin/rules/correctness/no-jsx-element-type.ts
|
|
19789
|
-
const MESSAGE$
|
|
20421
|
+
const MESSAGE$22 = "`JSX.Element` is too narrow: it excludes `null`, strings, numbers, and fragments that components commonly return. Use `React.ReactNode` instead.";
|
|
19790
20422
|
const isJsxElementTypeReference = (node) => {
|
|
19791
20423
|
if (!isNodeOfType(node, "TSTypeReference")) return false;
|
|
19792
20424
|
const typeName = node.typeName;
|
|
@@ -19803,7 +20435,7 @@ const checkReturnType = (context, returnType) => {
|
|
|
19803
20435
|
if (!typeAnnotation) return;
|
|
19804
20436
|
if (isJsxElementTypeReference(typeAnnotation)) context.report({
|
|
19805
20437
|
node: typeAnnotation,
|
|
19806
|
-
message: MESSAGE$
|
|
20438
|
+
message: MESSAGE$22
|
|
19807
20439
|
});
|
|
19808
20440
|
};
|
|
19809
20441
|
const noJsxElementType = defineRule({
|
|
@@ -19913,7 +20545,7 @@ const noLayoutPropertyAnimation = defineRule({
|
|
|
19913
20545
|
let propertyName = null;
|
|
19914
20546
|
if (isNodeOfType(property.key, "Identifier")) propertyName = property.key.name;
|
|
19915
20547
|
else if (isNodeOfType(property.key, "Literal") && typeof property.key.value === "string") propertyName = property.key.value;
|
|
19916
|
-
if (propertyName && LAYOUT_PROPERTIES.has(propertyName)) context.report({
|
|
20548
|
+
if (propertyName && LAYOUT_PROPERTIES$1.has(propertyName)) context.report({
|
|
19917
20549
|
node: property,
|
|
19918
20550
|
message: `This stutters because animating "${propertyName}" makes the browser redo page layout every frame, so animate transform or scale instead, or use the layout prop`
|
|
19919
20551
|
});
|
|
@@ -20103,13 +20735,138 @@ const noLongTransitionDuration = defineRule({
|
|
|
20103
20735
|
} })
|
|
20104
20736
|
});
|
|
20105
20737
|
//#endregion
|
|
20738
|
+
//#region src/plugin/rules/design/utils/get-style-property-number-value.ts
|
|
20739
|
+
const getStylePropertyNumberValue = (property) => {
|
|
20740
|
+
if (!isNodeOfType(property, "Property")) return null;
|
|
20741
|
+
if (isNodeOfType(property.value, "Literal") && typeof property.value.value === "number") return property.value.value;
|
|
20742
|
+
if (isNodeOfType(property.value, "UnaryExpression") && property.value.operator === "-" && isNodeOfType(property.value.argument, "Literal") && typeof property.value.argument.value === "number") return -property.value.argument.value;
|
|
20743
|
+
return null;
|
|
20744
|
+
};
|
|
20745
|
+
//#endregion
|
|
20746
|
+
//#region src/plugin/rules/design/utils/get-wcag-contrast-ratio.ts
|
|
20747
|
+
const linearizeChannel = (channel) => {
|
|
20748
|
+
const normalized = channel / 255;
|
|
20749
|
+
return normalized <= .03928 ? normalized / 12.92 : Math.pow((normalized + .055) / 1.055, 2.4);
|
|
20750
|
+
};
|
|
20751
|
+
const relativeLuminance = (color) => .2126 * linearizeChannel(color.red) + .7152 * linearizeChannel(color.green) + .0722 * linearizeChannel(color.blue);
|
|
20752
|
+
const getWcagContrastRatio = (foreground, background) => {
|
|
20753
|
+
const foregroundLuminance = relativeLuminance(foreground);
|
|
20754
|
+
const backgroundLuminance = relativeLuminance(background);
|
|
20755
|
+
const lighter = Math.max(foregroundLuminance, backgroundLuminance);
|
|
20756
|
+
const darker = Math.min(foregroundLuminance, backgroundLuminance);
|
|
20757
|
+
return (lighter + .05) / (darker + .05);
|
|
20758
|
+
};
|
|
20759
|
+
//#endregion
|
|
20760
|
+
//#region src/plugin/rules/design/no-low-contrast-inline-style.ts
|
|
20761
|
+
const UNRESOLVABLE = new Set([
|
|
20762
|
+
"transparent",
|
|
20763
|
+
"currentcolor",
|
|
20764
|
+
"inherit",
|
|
20765
|
+
"initial",
|
|
20766
|
+
"unset",
|
|
20767
|
+
"revert",
|
|
20768
|
+
"none"
|
|
20769
|
+
]);
|
|
20770
|
+
const resolveOpaqueColor = (raw) => {
|
|
20771
|
+
const value = raw.trim().toLowerCase();
|
|
20772
|
+
if (UNRESOLVABLE.has(value)) return null;
|
|
20773
|
+
if (value === "white") return {
|
|
20774
|
+
red: 255,
|
|
20775
|
+
green: 255,
|
|
20776
|
+
blue: 255
|
|
20777
|
+
};
|
|
20778
|
+
if (value === "black") return {
|
|
20779
|
+
red: 0,
|
|
20780
|
+
green: 0,
|
|
20781
|
+
blue: 0
|
|
20782
|
+
};
|
|
20783
|
+
if (value.startsWith("var(")) return null;
|
|
20784
|
+
if (/^#(?:[0-9a-f]{4}|[0-9a-f]{8})$/.test(value)) return null;
|
|
20785
|
+
if (value.startsWith("hsl") || value.startsWith("oklch")) return null;
|
|
20786
|
+
if (value.startsWith("rgb")) {
|
|
20787
|
+
const inner = value.slice(value.indexOf("(") + 1, value.lastIndexOf(")"));
|
|
20788
|
+
if (inner.includes("/") || inner.split(",").length >= 4) return null;
|
|
20789
|
+
}
|
|
20790
|
+
return parseColorToRgb(value);
|
|
20791
|
+
};
|
|
20792
|
+
const toPx = (property) => {
|
|
20793
|
+
const numberValue = getStylePropertyNumberValue(property);
|
|
20794
|
+
if (numberValue !== null) return numberValue;
|
|
20795
|
+
const stringValue = getStylePropertyStringValue(property);
|
|
20796
|
+
if (stringValue === null) return null;
|
|
20797
|
+
const pxMatch = stringValue.match(/^([\d.]+)px$/);
|
|
20798
|
+
if (pxMatch) return parseFloat(pxMatch[1]);
|
|
20799
|
+
const remMatch = stringValue.match(/^([\d.]+)rem$/);
|
|
20800
|
+
if (remMatch) return parseFloat(remMatch[1]) * 16;
|
|
20801
|
+
return null;
|
|
20802
|
+
};
|
|
20803
|
+
const isBoldWeight = (property) => {
|
|
20804
|
+
const numberValue = getStylePropertyNumberValue(property);
|
|
20805
|
+
if (numberValue !== null) return numberValue >= 700;
|
|
20806
|
+
const stringValue = getStylePropertyStringValue(property);
|
|
20807
|
+
if (stringValue === null) return false;
|
|
20808
|
+
if (stringValue === "bold" || stringValue === "bolder") return true;
|
|
20809
|
+
const numericWeight = Number(stringValue);
|
|
20810
|
+
return Number.isFinite(numericWeight) && numericWeight >= 700;
|
|
20811
|
+
};
|
|
20812
|
+
const noLowContrastInlineStyle = defineRule({
|
|
20813
|
+
id: "no-low-contrast-inline-style",
|
|
20814
|
+
title: "Low-contrast text in inline style",
|
|
20815
|
+
tags: ["test-noise"],
|
|
20816
|
+
severity: "warn",
|
|
20817
|
+
category: "Accessibility",
|
|
20818
|
+
recommendation: "Text needs a WCAG contrast ratio of at least 4.5:1 (3:1 for large/bold text) against its background. Darken or lighten one of the colors until it passes.",
|
|
20819
|
+
create: (context) => ({ JSXAttribute(node) {
|
|
20820
|
+
const expression = getInlineStyleExpression(node);
|
|
20821
|
+
if (!expression) return;
|
|
20822
|
+
const properties = expression.properties ?? [];
|
|
20823
|
+
if (properties.some((property) => property.type === "SpreadElement")) return;
|
|
20824
|
+
let foreground = null;
|
|
20825
|
+
let backgroundColorRaw = null;
|
|
20826
|
+
let backgroundShorthandRaw = null;
|
|
20827
|
+
let backgroundIsUnknown = false;
|
|
20828
|
+
let fontSizePx = null;
|
|
20829
|
+
let isBold = false;
|
|
20830
|
+
for (const property of properties) {
|
|
20831
|
+
const key = getStylePropertyKey(property);
|
|
20832
|
+
if (!key) continue;
|
|
20833
|
+
if (key === "backgroundImage") {
|
|
20834
|
+
backgroundIsUnknown = true;
|
|
20835
|
+
continue;
|
|
20836
|
+
}
|
|
20837
|
+
if (key === "fontSize" && property.type === "Property") {
|
|
20838
|
+
fontSizePx = toPx(property);
|
|
20839
|
+
continue;
|
|
20840
|
+
}
|
|
20841
|
+
if (key === "fontWeight" && property.type === "Property") {
|
|
20842
|
+
isBold = isBoldWeight(property);
|
|
20843
|
+
continue;
|
|
20844
|
+
}
|
|
20845
|
+
const stringValue = getStylePropertyStringValue(property);
|
|
20846
|
+
if (key === "color") {
|
|
20847
|
+
if (stringValue !== null) foreground = resolveOpaqueColor(stringValue);
|
|
20848
|
+
} else if (key === "backgroundColor") backgroundColorRaw = stringValue;
|
|
20849
|
+
else if (key === "background") if (stringValue === null) backgroundIsUnknown = true;
|
|
20850
|
+
else backgroundShorthandRaw = stringValue;
|
|
20851
|
+
}
|
|
20852
|
+
if (backgroundIsUnknown) return;
|
|
20853
|
+
if (backgroundColorRaw !== null && backgroundShorthandRaw !== null) return;
|
|
20854
|
+
const backgroundRaw = backgroundColorRaw ?? backgroundShorthandRaw;
|
|
20855
|
+
const background = backgroundRaw === null ? null : resolveOpaqueColor(backgroundRaw);
|
|
20856
|
+
if (!foreground || !background) return;
|
|
20857
|
+
const threshold = fontSizePx === null || fontSizePx >= 24 || isBold && fontSizePx >= 18.66 ? 3 : WCAG_CONTRAST_NORMAL_MIN;
|
|
20858
|
+
const ratio = getWcagContrastRatio(foreground, background);
|
|
20859
|
+
if (ratio < threshold) context.report({
|
|
20860
|
+
node,
|
|
20861
|
+
message: `Your users struggle to read this text: its contrast against the background is ${ratio.toFixed(2)}:1, below the ${threshold}:1 WCAG minimum, so darken or lighten one of the colors.`
|
|
20862
|
+
});
|
|
20863
|
+
} })
|
|
20864
|
+
});
|
|
20865
|
+
//#endregion
|
|
20106
20866
|
//#region src/plugin/utils/is-boolean-prefixed-prop-name.ts
|
|
20107
20867
|
const BOOLEAN_PROP_PREFIX_PATTERN = /^(?:is|has|should|can|show|hide|enable|disable|with)[A-Z]/;
|
|
20108
20868
|
const isBooleanPrefixedPropName = (propName) => BOOLEAN_PROP_PREFIX_PATTERN.test(propName);
|
|
20109
20869
|
//#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
20870
|
//#region src/plugin/rules/architecture/no-many-boolean-props.ts
|
|
20114
20871
|
const collectBooleanLikePropsFromBody = (componentBody, propsParamName) => {
|
|
20115
20872
|
const found = /* @__PURE__ */ new Set();
|
|
@@ -20261,7 +21018,7 @@ const noMoment = defineRule({
|
|
|
20261
21018
|
});
|
|
20262
21019
|
//#endregion
|
|
20263
21020
|
//#region src/plugin/rules/react-builtins/no-multi-comp.ts
|
|
20264
|
-
const MESSAGE$
|
|
21021
|
+
const MESSAGE$21 = "This file declares several components, so each component is harder to find, test, and change.";
|
|
20265
21022
|
const resolveSettings$16 = (settings) => {
|
|
20266
21023
|
const reactDoctor = settings?.["react-doctor"];
|
|
20267
21024
|
return { ignoreStateless: (typeof reactDoctor === "object" && reactDoctor !== null ? reactDoctor.noMultiComp ?? {} : {}).ignoreStateless ?? false };
|
|
@@ -20583,7 +21340,7 @@ const noMultiComp = defineRule({
|
|
|
20583
21340
|
if (isSmallFeatureModule || isLargeFeatureModule || isVeryLargeFeatureModule) return;
|
|
20584
21341
|
for (const component of flagged.slice(1)) context.report({
|
|
20585
21342
|
node: component.reportNode,
|
|
20586
|
-
message: MESSAGE$
|
|
21343
|
+
message: MESSAGE$21
|
|
20587
21344
|
});
|
|
20588
21345
|
} };
|
|
20589
21346
|
}
|
|
@@ -20751,7 +21508,7 @@ const resolveReducerFunction = (node, currentFilename) => {
|
|
|
20751
21508
|
};
|
|
20752
21509
|
//#endregion
|
|
20753
21510
|
//#region src/plugin/rules/state-and-effects/no-mutating-reducer-state.ts
|
|
20754
|
-
const MESSAGE$
|
|
21511
|
+
const MESSAGE$20 = "This reducer changes state in place, so your update is silently skipped.";
|
|
20755
21512
|
const SAME_REFERENCE_ARRAY_RETURN_METHODS = new Set([
|
|
20756
21513
|
"copyWithin",
|
|
20757
21514
|
"fill",
|
|
@@ -20961,7 +21718,7 @@ const analyzeReactUseReducerFunctionForStateMutation = (context, functionNode, r
|
|
|
20961
21718
|
reportedNodes.add(options.crossFileConsumerCallSite);
|
|
20962
21719
|
context.report({
|
|
20963
21720
|
node: options.crossFileConsumerCallSite,
|
|
20964
|
-
message: `${MESSAGE$
|
|
21721
|
+
message: `${MESSAGE$20} (mutation in imported reducer at \`${options.crossFileSourceDisplay}\`)`
|
|
20965
21722
|
});
|
|
20966
21723
|
return;
|
|
20967
21724
|
}
|
|
@@ -20970,7 +21727,7 @@ const analyzeReactUseReducerFunctionForStateMutation = (context, functionNode, r
|
|
|
20970
21727
|
reportedNodes.add(mutation.node);
|
|
20971
21728
|
context.report({
|
|
20972
21729
|
node: mutation.node,
|
|
20973
|
-
message: MESSAGE$
|
|
21730
|
+
message: MESSAGE$20
|
|
20974
21731
|
});
|
|
20975
21732
|
}
|
|
20976
21733
|
};
|
|
@@ -21242,7 +21999,7 @@ const noNoninteractiveElementToInteractiveRole = defineRule({
|
|
|
21242
21999
|
});
|
|
21243
22000
|
//#endregion
|
|
21244
22001
|
//#region src/plugin/rules/a11y/no-noninteractive-tabindex.ts
|
|
21245
|
-
const MESSAGE$
|
|
22002
|
+
const MESSAGE$19 = "Keyboard users get stuck focusing this element they can't act on because `tabIndex` makes it tabbable, so remove it.";
|
|
21246
22003
|
const resolveSettings$14 = (settings) => {
|
|
21247
22004
|
const reactDoctor = settings?.["react-doctor"];
|
|
21248
22005
|
const ruleSettings = typeof reactDoctor === "object" && reactDoctor !== null ? reactDoctor.noNoninteractiveTabindex ?? {} : {};
|
|
@@ -21270,7 +22027,7 @@ const noNoninteractiveTabindex = defineRule({
|
|
|
21270
22027
|
if (numeric === null) {
|
|
21271
22028
|
if (isNodeOfType(tabIndexValue, "JSXExpressionContainer") && !settings.allowExpressionValues) context.report({
|
|
21272
22029
|
node: tabIndex,
|
|
21273
|
-
message: MESSAGE$
|
|
22030
|
+
message: MESSAGE$19
|
|
21274
22031
|
});
|
|
21275
22032
|
return;
|
|
21276
22033
|
}
|
|
@@ -21283,7 +22040,7 @@ const noNoninteractiveTabindex = defineRule({
|
|
|
21283
22040
|
if (!roleAttribute) {
|
|
21284
22041
|
context.report({
|
|
21285
22042
|
node: tabIndex,
|
|
21286
|
-
message: MESSAGE$
|
|
22043
|
+
message: MESSAGE$19
|
|
21287
22044
|
});
|
|
21288
22045
|
return;
|
|
21289
22046
|
}
|
|
@@ -21297,20 +22054,12 @@ const noNoninteractiveTabindex = defineRule({
|
|
|
21297
22054
|
}
|
|
21298
22055
|
context.report({
|
|
21299
22056
|
node: tabIndex,
|
|
21300
|
-
message: MESSAGE$
|
|
22057
|
+
message: MESSAGE$19
|
|
21301
22058
|
});
|
|
21302
22059
|
} };
|
|
21303
22060
|
}
|
|
21304
22061
|
});
|
|
21305
22062
|
//#endregion
|
|
21306
|
-
//#region src/plugin/rules/design/utils/get-style-property-number-value.ts
|
|
21307
|
-
const getStylePropertyNumberValue = (property) => {
|
|
21308
|
-
if (!isNodeOfType(property, "Property")) return null;
|
|
21309
|
-
if (isNodeOfType(property.value, "Literal") && typeof property.value.value === "number") return property.value.value;
|
|
21310
|
-
if (isNodeOfType(property.value, "UnaryExpression") && property.value.operator === "-" && isNodeOfType(property.value.argument, "Literal") && typeof property.value.argument.value === "number") return -property.value.argument.value;
|
|
21311
|
-
return null;
|
|
21312
|
-
};
|
|
21313
|
-
//#endregion
|
|
21314
22063
|
//#region src/plugin/rules/design/no-outline-none.ts
|
|
21315
22064
|
const noOutlineNone = defineRule({
|
|
21316
22065
|
id: "no-outline-none",
|
|
@@ -21988,7 +22737,7 @@ const noRandomKey = defineRule({
|
|
|
21988
22737
|
});
|
|
21989
22738
|
//#endregion
|
|
21990
22739
|
//#region src/plugin/rules/react-builtins/no-react-children.ts
|
|
21991
|
-
const MESSAGE$
|
|
22740
|
+
const MESSAGE$18 = "`React.Children` traversal depends on the runtime child shape, so wrapping or unwrapping a child can silently change what gets visited.";
|
|
21992
22741
|
const isChildrenIdentifier = (node, contextNode) => {
|
|
21993
22742
|
if (!isNodeOfType(node, "Identifier") || node.name !== "Children") return false;
|
|
21994
22743
|
return isImportedFromModule(contextNode, "Children", "react");
|
|
@@ -22014,13 +22763,13 @@ const noReactChildren = defineRule({
|
|
|
22014
22763
|
if (isChildrenIdentifier(memberObject, node)) {
|
|
22015
22764
|
context.report({
|
|
22016
22765
|
node: calleeOuter,
|
|
22017
|
-
message: MESSAGE$
|
|
22766
|
+
message: MESSAGE$18
|
|
22018
22767
|
});
|
|
22019
22768
|
return;
|
|
22020
22769
|
}
|
|
22021
22770
|
if (isReactNamespaceMember(memberObject, node)) context.report({
|
|
22022
22771
|
node: calleeOuter,
|
|
22023
|
-
message: MESSAGE$
|
|
22772
|
+
message: MESSAGE$18
|
|
22024
22773
|
});
|
|
22025
22774
|
} })
|
|
22026
22775
|
});
|
|
@@ -22131,6 +22880,86 @@ const noReact19DeprecatedApis = defineRule({
|
|
|
22131
22880
|
})
|
|
22132
22881
|
});
|
|
22133
22882
|
//#endregion
|
|
22883
|
+
//#region src/plugin/rules/design/no-redundant-display-class.ts
|
|
22884
|
+
const BLOCK_DEFAULT_TAGS = new Set([
|
|
22885
|
+
"div",
|
|
22886
|
+
"p",
|
|
22887
|
+
"section",
|
|
22888
|
+
"article",
|
|
22889
|
+
"main",
|
|
22890
|
+
"header",
|
|
22891
|
+
"footer",
|
|
22892
|
+
"nav",
|
|
22893
|
+
"aside",
|
|
22894
|
+
"figure",
|
|
22895
|
+
"figcaption",
|
|
22896
|
+
"blockquote",
|
|
22897
|
+
"form",
|
|
22898
|
+
"fieldset",
|
|
22899
|
+
"address",
|
|
22900
|
+
"pre",
|
|
22901
|
+
"ul",
|
|
22902
|
+
"ol",
|
|
22903
|
+
"dl",
|
|
22904
|
+
"dt",
|
|
22905
|
+
"dd",
|
|
22906
|
+
"h1",
|
|
22907
|
+
"h2",
|
|
22908
|
+
"h3",
|
|
22909
|
+
"h4",
|
|
22910
|
+
"h5",
|
|
22911
|
+
"h6"
|
|
22912
|
+
]);
|
|
22913
|
+
const INLINE_DEFAULT_TAGS = new Set([
|
|
22914
|
+
"span",
|
|
22915
|
+
"a",
|
|
22916
|
+
"b",
|
|
22917
|
+
"i",
|
|
22918
|
+
"em",
|
|
22919
|
+
"strong",
|
|
22920
|
+
"small",
|
|
22921
|
+
"code",
|
|
22922
|
+
"abbr",
|
|
22923
|
+
"cite",
|
|
22924
|
+
"label",
|
|
22925
|
+
"mark",
|
|
22926
|
+
"q",
|
|
22927
|
+
"s",
|
|
22928
|
+
"u",
|
|
22929
|
+
"sub",
|
|
22930
|
+
"sup",
|
|
22931
|
+
"kbd",
|
|
22932
|
+
"samp",
|
|
22933
|
+
"var",
|
|
22934
|
+
"time"
|
|
22935
|
+
]);
|
|
22936
|
+
const STANDALONE_BLOCK = /(?:^|\s)block(?:$|\s)/;
|
|
22937
|
+
const STANDALONE_INLINE = /(?:^|\s)inline(?:$|\s)/;
|
|
22938
|
+
const noRedundantDisplayClass = defineRule({
|
|
22939
|
+
id: "no-redundant-display-class",
|
|
22940
|
+
title: "Redundant display utility",
|
|
22941
|
+
tags: ["design", "test-noise"],
|
|
22942
|
+
severity: "warn",
|
|
22943
|
+
recommendation: "Drop the display class that matches the element's default (`block` on a `<div>`, `inline` on a `<span>`). It is pure noise; keep only display changes like `flex`, `grid`, or `hidden`.",
|
|
22944
|
+
create: (context) => ({ JSXOpeningElement(node) {
|
|
22945
|
+
if (!isNodeOfType(node.name, "JSXIdentifier")) return;
|
|
22946
|
+
const tagName = node.name.name;
|
|
22947
|
+
const classNameValue = getStringFromClassNameAttr(node);
|
|
22948
|
+
if (!classNameValue) return;
|
|
22949
|
+
if (BLOCK_DEFAULT_TAGS.has(tagName) && STANDALONE_BLOCK.test(classNameValue)) {
|
|
22950
|
+
context.report({
|
|
22951
|
+
node,
|
|
22952
|
+
message: `\`block\` is the default display of \`<${tagName}>\`, so the class does nothing — remove it.`
|
|
22953
|
+
});
|
|
22954
|
+
return;
|
|
22955
|
+
}
|
|
22956
|
+
if (INLINE_DEFAULT_TAGS.has(tagName) && STANDALONE_INLINE.test(classNameValue)) context.report({
|
|
22957
|
+
node,
|
|
22958
|
+
message: `\`inline\` is the default display of \`<${tagName}>\`, so the class does nothing — remove it.`
|
|
22959
|
+
});
|
|
22960
|
+
} })
|
|
22961
|
+
});
|
|
22962
|
+
//#endregion
|
|
22134
22963
|
//#region src/plugin/constants/aria-element-roles.ts
|
|
22135
22964
|
const ELEMENT_ROLE_PAIRS = [
|
|
22136
22965
|
["a", "link"],
|
|
@@ -22343,7 +23172,7 @@ const noRenderPropChildren = defineRule({
|
|
|
22343
23172
|
});
|
|
22344
23173
|
//#endregion
|
|
22345
23174
|
//#region src/plugin/rules/react-builtins/no-render-return-value.ts
|
|
22346
|
-
const MESSAGE$
|
|
23175
|
+
const MESSAGE$17 = "Your app breaks in React 19 because `ReactDOM.render` returns nothing there.";
|
|
22347
23176
|
const isReactDomRenderCall = (node) => {
|
|
22348
23177
|
if (!isNodeOfType(node.callee, "MemberExpression")) return false;
|
|
22349
23178
|
if (!isNodeOfType(node.callee.object, "Identifier")) return false;
|
|
@@ -22367,7 +23196,7 @@ const noRenderReturnValue = defineRule({
|
|
|
22367
23196
|
if (!isUsedAsReturnValue(node.parent)) return;
|
|
22368
23197
|
context.report({
|
|
22369
23198
|
node: node.callee,
|
|
22370
|
-
message: MESSAGE$
|
|
23199
|
+
message: MESSAGE$17
|
|
22371
23200
|
});
|
|
22372
23201
|
} })
|
|
22373
23202
|
});
|
|
@@ -22527,11 +23356,17 @@ const classifySecretFileExposure = (filename, options = {}) => {
|
|
|
22527
23356
|
return "unknown";
|
|
22528
23357
|
};
|
|
22529
23358
|
//#endregion
|
|
22530
|
-
//#region src/plugin/utils/
|
|
22531
|
-
const
|
|
22532
|
-
|
|
23359
|
+
//#region src/plugin/utils/tokenize-identifier-words.ts
|
|
23360
|
+
const IDENTIFIER_WORD_PATTERN = /[A-Z]+(?=[A-Z][a-z]|\b)|[A-Z]?[a-z]+|\d+/g;
|
|
23361
|
+
const tokenizeIdentifierWords = (identifierName) => {
|
|
23362
|
+
const words = identifierName.match(IDENTIFIER_WORD_PATTERN);
|
|
23363
|
+
if (!words) return [];
|
|
23364
|
+
return words.map((word) => word.toLowerCase());
|
|
22533
23365
|
};
|
|
22534
23366
|
//#endregion
|
|
23367
|
+
//#region src/plugin/utils/get-identifier-trailing-word.ts
|
|
23368
|
+
const getIdentifierTrailingWord = (identifierName) => tokenizeIdentifierWords(identifierName).at(-1) ?? identifierName.toLowerCase();
|
|
23369
|
+
//#endregion
|
|
22535
23370
|
//#region src/plugin/constants/tanstack.ts
|
|
22536
23371
|
const TANSTACK_ROUTE_FILE_PATTERN = /\/routes\//;
|
|
22537
23372
|
const TANSTACK_ROOT_ROUTE_FILE_PATTERN = /__root\.(tsx?|jsx?)$/;
|
|
@@ -23059,7 +23894,7 @@ const getParentComponent = (node) => {
|
|
|
23059
23894
|
};
|
|
23060
23895
|
//#endregion
|
|
23061
23896
|
//#region src/plugin/rules/react-builtins/no-set-state.ts
|
|
23062
|
-
const MESSAGE$
|
|
23897
|
+
const MESSAGE$16 = "`this.setState` keeps local class state in a project that forbids it, so state ownership becomes harder to reason about.";
|
|
23063
23898
|
const noSetState = defineRule({
|
|
23064
23899
|
id: "no-set-state",
|
|
23065
23900
|
title: "Local class state forbidden",
|
|
@@ -23074,7 +23909,7 @@ const noSetState = defineRule({
|
|
|
23074
23909
|
if (!getParentComponent(node)) return;
|
|
23075
23910
|
context.report({
|
|
23076
23911
|
node: node.callee,
|
|
23077
|
-
message: MESSAGE$
|
|
23912
|
+
message: MESSAGE$16
|
|
23078
23913
|
});
|
|
23079
23914
|
} })
|
|
23080
23915
|
});
|
|
@@ -23236,7 +24071,7 @@ const isAbstractRole = (openingElement, settings) => {
|
|
|
23236
24071
|
};
|
|
23237
24072
|
//#endregion
|
|
23238
24073
|
//#region src/plugin/rules/a11y/no-static-element-interactions.ts
|
|
23239
|
-
const MESSAGE$
|
|
24074
|
+
const MESSAGE$15 = "Screen reader users can't tell this click handler is interactive because it has no `role`, so add a `role` or use a button or link.";
|
|
23240
24075
|
const DEFAULT_HANDLERS = [
|
|
23241
24076
|
"onClick",
|
|
23242
24077
|
"onMouseDown",
|
|
@@ -23296,7 +24131,7 @@ const noStaticElementInteractions = defineRule({
|
|
|
23296
24131
|
if (!roleAttribute || !roleAttribute.value) {
|
|
23297
24132
|
context.report({
|
|
23298
24133
|
node: node.name,
|
|
23299
|
-
message: MESSAGE$
|
|
24134
|
+
message: MESSAGE$15
|
|
23300
24135
|
});
|
|
23301
24136
|
return;
|
|
23302
24137
|
}
|
|
@@ -23306,19 +24141,66 @@ const noStaticElementInteractions = defineRule({
|
|
|
23306
24141
|
if (firstRole && (isInteractiveRole(firstRole) || isNonInteractiveRole(firstRole))) return;
|
|
23307
24142
|
context.report({
|
|
23308
24143
|
node: node.name,
|
|
23309
|
-
message: MESSAGE$
|
|
24144
|
+
message: MESSAGE$15
|
|
23310
24145
|
});
|
|
23311
24146
|
return;
|
|
23312
24147
|
}
|
|
23313
24148
|
if (isNodeOfType(attributeValue, "JSXExpressionContainer") && settings.allowExpressionValues) return;
|
|
23314
24149
|
context.report({
|
|
23315
24150
|
node: node.name,
|
|
23316
|
-
message: MESSAGE$
|
|
24151
|
+
message: MESSAGE$15
|
|
23317
24152
|
});
|
|
23318
24153
|
} };
|
|
23319
24154
|
}
|
|
23320
24155
|
});
|
|
23321
24156
|
//#endregion
|
|
24157
|
+
//#region src/plugin/rules/react-builtins/no-string-false-on-boolean-attribute.ts
|
|
24158
|
+
const BOOLEAN_ATTRIBUTES = new Set([
|
|
24159
|
+
"disabled",
|
|
24160
|
+
"checked",
|
|
24161
|
+
"readonly",
|
|
24162
|
+
"required",
|
|
24163
|
+
"selected",
|
|
24164
|
+
"multiple",
|
|
24165
|
+
"autofocus",
|
|
24166
|
+
"autoplay",
|
|
24167
|
+
"controls",
|
|
24168
|
+
"loop",
|
|
24169
|
+
"muted",
|
|
24170
|
+
"open",
|
|
24171
|
+
"reversed",
|
|
24172
|
+
"default",
|
|
24173
|
+
"novalidate",
|
|
24174
|
+
"formnovalidate",
|
|
24175
|
+
"playsinline",
|
|
24176
|
+
"itemscope",
|
|
24177
|
+
"allowfullscreen"
|
|
24178
|
+
]);
|
|
24179
|
+
const noStringFalseOnBooleanAttribute = defineRule({
|
|
24180
|
+
id: "no-string-false-on-boolean-attribute",
|
|
24181
|
+
title: "String true/false on a boolean attribute",
|
|
24182
|
+
severity: "warn",
|
|
24183
|
+
recommendation: "Use the boolean form on boolean attributes: `disabled` / `disabled={true}` / `disabled={false}`, not `disabled=\"false\"`. A non-empty string is truthy, so `=\"false\"` actually turns the attribute ON.",
|
|
24184
|
+
create: (context) => ({ JSXOpeningElement(node) {
|
|
24185
|
+
if (!isNodeOfType(node.name, "JSXIdentifier")) return;
|
|
24186
|
+
const firstCharacter = node.name.name.charCodeAt(0);
|
|
24187
|
+
if (firstCharacter < 97 || firstCharacter > 122) return;
|
|
24188
|
+
for (const attribute of node.attributes) {
|
|
24189
|
+
if (!isNodeOfType(attribute, "JSXAttribute")) continue;
|
|
24190
|
+
if (!isNodeOfType(attribute.name, "JSXIdentifier")) continue;
|
|
24191
|
+
if (!BOOLEAN_ATTRIBUTES.has(attribute.name.name.toLowerCase())) continue;
|
|
24192
|
+
const value = getJsxPropStringValue(attribute);
|
|
24193
|
+
if (value !== "false" && value !== "true") continue;
|
|
24194
|
+
const attributeName = attribute.name.name;
|
|
24195
|
+
const guidance = value === "false" ? `which React treats as truthy, so the attribute is applied even though you wrote "false". Use \`${attributeName}={false}\` (or omit the attribute) to keep it off` : `but a boolean attribute takes a boolean, not the string "true". Use \`${attributeName}\` or \`${attributeName}={true}\``;
|
|
24196
|
+
context.report({
|
|
24197
|
+
node: attribute,
|
|
24198
|
+
message: `\`${attributeName}="${value}"\` passes the string "${value}", ${guidance}.`
|
|
24199
|
+
});
|
|
24200
|
+
}
|
|
24201
|
+
} })
|
|
24202
|
+
});
|
|
24203
|
+
//#endregion
|
|
23322
24204
|
//#region src/plugin/rules/react-builtins/no-string-refs.ts
|
|
23323
24205
|
const STRING_IN_REF_MESSAGE = "Your component can't reach this node because string refs don't work in modern React.";
|
|
23324
24206
|
const THIS_REFS_MESSAGE = "Your component can't reach its nodes because `this.refs` is empty in modern React.";
|
|
@@ -23369,8 +24251,154 @@ const noStringRefs = defineRule({
|
|
|
23369
24251
|
}
|
|
23370
24252
|
});
|
|
23371
24253
|
//#endregion
|
|
24254
|
+
//#region src/plugin/rules/design/no-svg-currentcolor-with-fill-class.ts
|
|
24255
|
+
const hasColorUtility = (classNameValue, prefix) => classNameValue.split(/\s+/).some((token) => {
|
|
24256
|
+
if (token.includes(":")) return false;
|
|
24257
|
+
if (!token.startsWith(prefix)) return false;
|
|
24258
|
+
const value = token.slice(prefix.length);
|
|
24259
|
+
if (value === "" || value === "current") return false;
|
|
24260
|
+
if (/^\d/.test(value) || /^\[\d/.test(value)) return false;
|
|
24261
|
+
return true;
|
|
24262
|
+
});
|
|
24263
|
+
const isCurrentColor = (attribute) => {
|
|
24264
|
+
const value = getJsxPropStringValue(attribute);
|
|
24265
|
+
return value !== null && value.trim().toLowerCase() === "currentcolor";
|
|
24266
|
+
};
|
|
24267
|
+
const noSvgCurrentcolorWithFillClass = defineRule({
|
|
24268
|
+
id: "no-svg-currentcolor-with-fill-class",
|
|
24269
|
+
title: "currentColor fights a fill/stroke class",
|
|
24270
|
+
tags: ["design", "test-noise"],
|
|
24271
|
+
severity: "warn",
|
|
24272
|
+
recommendation: "Pick one source of truth: drop the `fill=\"currentColor\"` attribute and keep the `fill-*` class, or use `fill-current` to inherit the text color. Having both means the class silently wins.",
|
|
24273
|
+
create: (context) => ({ JSXOpeningElement(node) {
|
|
24274
|
+
const classNameValue = getStringFromClassNameAttr(node);
|
|
24275
|
+
if (!classNameValue) return;
|
|
24276
|
+
for (const paint of ["fill", "stroke"]) {
|
|
24277
|
+
const attribute = findJsxAttribute(node.attributes, paint);
|
|
24278
|
+
if (attribute && isCurrentColor(attribute) && hasColorUtility(classNameValue, `${paint}-`)) {
|
|
24279
|
+
context.report({
|
|
24280
|
+
node: attribute,
|
|
24281
|
+
message: `\`${paint}="currentColor"\` and a \`${paint}-*\` color class on the same element conflict — the class wins. Remove one, or use \`${paint}-current\` to inherit the text color.`
|
|
24282
|
+
});
|
|
24283
|
+
return;
|
|
24284
|
+
}
|
|
24285
|
+
}
|
|
24286
|
+
} })
|
|
24287
|
+
});
|
|
24288
|
+
//#endregion
|
|
24289
|
+
//#region src/plugin/rules/js-performance/no-sync-xhr.ts
|
|
24290
|
+
const MESSAGE$14 = "A synchronous `XMLHttpRequest` (`.open(method, url, false)`) freezes the main thread until the request finishes, blocking all rendering and input. Use `fetch()` or an async XHR (`open(method, url, true)`).";
|
|
24291
|
+
const isFalseLiteral = (node) => isNodeOfType(node, "Literal") && node.value === false;
|
|
24292
|
+
const noSyncXhr = defineRule({
|
|
24293
|
+
id: "no-sync-xhr",
|
|
24294
|
+
title: "Synchronous XMLHttpRequest",
|
|
24295
|
+
severity: "warn",
|
|
24296
|
+
recommendation: "Never open an XMLHttpRequest synchronously (`async` = `false`). It blocks the main thread. Use `fetch()` or pass `true` and handle the response asynchronously.",
|
|
24297
|
+
create: (context) => ({ CallExpression(node) {
|
|
24298
|
+
const callee = node.callee;
|
|
24299
|
+
if (!isNodeOfType(callee, "MemberExpression") || callee.computed) return;
|
|
24300
|
+
if (!isNodeOfType(callee.property, "Identifier") || callee.property.name !== "open") return;
|
|
24301
|
+
const asyncArgument = node.arguments?.[2];
|
|
24302
|
+
if (!asyncArgument || !isFalseLiteral(stripParenExpression(asyncArgument))) return;
|
|
24303
|
+
context.report({
|
|
24304
|
+
node,
|
|
24305
|
+
message: MESSAGE$14
|
|
24306
|
+
});
|
|
24307
|
+
} })
|
|
24308
|
+
});
|
|
24309
|
+
//#endregion
|
|
24310
|
+
//#region src/plugin/rules/design/no-tailwind-layout-transition.ts
|
|
24311
|
+
const ARBITRARY_TRANSITION_PROPERTY = /transition-\[([^\]]+)\]/g;
|
|
24312
|
+
const LAYOUT_PROPERTIES = new Set([
|
|
24313
|
+
"width",
|
|
24314
|
+
"height",
|
|
24315
|
+
"min-width",
|
|
24316
|
+
"max-width",
|
|
24317
|
+
"min-height",
|
|
24318
|
+
"max-height",
|
|
24319
|
+
"top",
|
|
24320
|
+
"left",
|
|
24321
|
+
"right",
|
|
24322
|
+
"bottom",
|
|
24323
|
+
"inset",
|
|
24324
|
+
"inset-block",
|
|
24325
|
+
"inset-inline",
|
|
24326
|
+
"margin",
|
|
24327
|
+
"margin-top",
|
|
24328
|
+
"margin-right",
|
|
24329
|
+
"margin-bottom",
|
|
24330
|
+
"margin-left",
|
|
24331
|
+
"margin-block",
|
|
24332
|
+
"margin-inline",
|
|
24333
|
+
"padding",
|
|
24334
|
+
"padding-top",
|
|
24335
|
+
"padding-right",
|
|
24336
|
+
"padding-bottom",
|
|
24337
|
+
"padding-left",
|
|
24338
|
+
"padding-block",
|
|
24339
|
+
"padding-inline"
|
|
24340
|
+
]);
|
|
24341
|
+
const noTailwindLayoutTransition = defineRule({
|
|
24342
|
+
id: "no-tailwind-layout-transition",
|
|
24343
|
+
title: "Animating a layout property",
|
|
24344
|
+
tags: ["design", "test-noise"],
|
|
24345
|
+
severity: "warn",
|
|
24346
|
+
category: "Performance",
|
|
24347
|
+
recommendation: "Animate `transform` and `opacity` instead, since they skip layout and run on the compositor. For height, animate `grid-template-rows` from `0fr` to `1fr`.",
|
|
24348
|
+
create: (context) => ({ JSXOpeningElement(node) {
|
|
24349
|
+
const classNameValue = getStringFromClassNameAttr(node);
|
|
24350
|
+
if (!classNameValue) return;
|
|
24351
|
+
for (const transitionMatch of classNameValue.matchAll(ARBITRARY_TRANSITION_PROPERTY)) {
|
|
24352
|
+
const animatedProperties = transitionMatch[1];
|
|
24353
|
+
const layoutProperty = animatedProperties.split(",").map((property) => property.trim()).find((property) => LAYOUT_PROPERTIES.has(property));
|
|
24354
|
+
if (layoutProperty) context.report({
|
|
24355
|
+
node,
|
|
24356
|
+
message: `Your users see janky animation because \`transition-[${animatedProperties}]\` animates "${layoutProperty}", a layout property the browser recomputes every frame, so animate transform & opacity instead.`
|
|
24357
|
+
});
|
|
24358
|
+
}
|
|
24359
|
+
} })
|
|
24360
|
+
});
|
|
24361
|
+
//#endregion
|
|
24362
|
+
//#region src/plugin/rules/a11y/no-target-blank-without-rel.ts
|
|
24363
|
+
const MESSAGE$13 = "`<a target=\"_blank\">` without `rel=\"noopener\"` lets the opened page script your tab via `window.opener` (reverse tabnabbing). Add `rel=\"noopener noreferrer\"`.";
|
|
24364
|
+
const targetIsBlank = (attribute) => {
|
|
24365
|
+
const stringValue = getJsxPropStringValue(attribute);
|
|
24366
|
+
if (stringValue !== null) return stringValue === "_blank";
|
|
24367
|
+
const value = attribute.value;
|
|
24368
|
+
if (value && isNodeOfType(value, "JSXExpressionContainer")) {
|
|
24369
|
+
const expression = value.expression;
|
|
24370
|
+
if (isNodeOfType(expression, "Literal") && expression.value === "_blank") return true;
|
|
24371
|
+
}
|
|
24372
|
+
return false;
|
|
24373
|
+
};
|
|
24374
|
+
const noTargetBlankWithoutRel = defineRule({
|
|
24375
|
+
id: "no-target-blank-without-rel",
|
|
24376
|
+
title: "target=_blank without rel=noopener",
|
|
24377
|
+
severity: "warn",
|
|
24378
|
+
recommendation: "Add `rel=\"noopener noreferrer\"` to every `target=\"_blank\"` link. `noopener` blocks reverse tabnabbing; `noreferrer` also strips the `Referer` header.",
|
|
24379
|
+
create: (context) => ({ JSXOpeningElement(node) {
|
|
24380
|
+
if (!isNodeOfType(node.name, "JSXIdentifier")) return;
|
|
24381
|
+
const tagName = node.name.name;
|
|
24382
|
+
if (tagName !== "a" && tagName !== "area") return;
|
|
24383
|
+
if (hasJsxSpreadAttribute(node.attributes)) return;
|
|
24384
|
+
const targetAttribute = findJsxAttribute(node.attributes, "target");
|
|
24385
|
+
if (!targetAttribute || !targetIsBlank(targetAttribute)) return;
|
|
24386
|
+
const relAttribute = findJsxAttribute(node.attributes, "rel");
|
|
24387
|
+
if (relAttribute) {
|
|
24388
|
+
const relValue = getJsxPropStringValue(relAttribute);
|
|
24389
|
+
if (relValue === null) return;
|
|
24390
|
+
const tokens = relValue.toLowerCase().split(/\s+/);
|
|
24391
|
+
if (tokens.includes("noopener") || tokens.includes("noreferrer")) return;
|
|
24392
|
+
}
|
|
24393
|
+
context.report({
|
|
24394
|
+
node: node.name,
|
|
24395
|
+
message: MESSAGE$13
|
|
24396
|
+
});
|
|
24397
|
+
} })
|
|
24398
|
+
});
|
|
24399
|
+
//#endregion
|
|
23372
24400
|
//#region src/plugin/rules/react-builtins/no-this-in-sfc.ts
|
|
23373
|
-
const MESSAGE$
|
|
24401
|
+
const MESSAGE$12 = "This value is `undefined` because function components have no `this`.";
|
|
23374
24402
|
const isInsideClassMethod = (node, customClassFactoryNames) => {
|
|
23375
24403
|
let ancestor = node.parent;
|
|
23376
24404
|
while (ancestor) {
|
|
@@ -23439,7 +24467,7 @@ const noThisInSfc = defineRule({
|
|
|
23439
24467
|
if (!looksLikeFunctionComponent(enclosingFunction)) return;
|
|
23440
24468
|
context.report({
|
|
23441
24469
|
node,
|
|
23442
|
-
message: MESSAGE$
|
|
24470
|
+
message: MESSAGE$12
|
|
23443
24471
|
});
|
|
23444
24472
|
} };
|
|
23445
24473
|
}
|
|
@@ -23477,26 +24505,39 @@ const noTinyText = defineRule({
|
|
|
23477
24505
|
});
|
|
23478
24506
|
//#endregion
|
|
23479
24507
|
//#region src/plugin/rules/performance/no-transition-all.ts
|
|
24508
|
+
const hasTransitionAllClass = (classNameValue) => getClassNameTokens(classNameValue).some((token) => token === "transition-all");
|
|
24509
|
+
const TAILWIND_MESSAGE = "Your users see janky animation because `transition-all` animates every property that changes, including expensive layout ones and instant ones like focus rings. Name the properties: `transition-colors`, `transition-opacity`, or `transition-transform`.";
|
|
23480
24510
|
const noTransitionAll = defineRule({
|
|
23481
24511
|
id: "no-transition-all",
|
|
23482
24512
|
title: "transition: all animates everything",
|
|
23483
24513
|
tags: ["test-noise"],
|
|
23484
24514
|
severity: "warn",
|
|
23485
24515
|
recommendation: "List the specific properties: `transition: \"opacity 200ms, transform 200ms\"`. In Tailwind, use `transition-colors`, `transition-opacity`, or `transition-transform`",
|
|
23486
|
-
create: (context) => ({
|
|
23487
|
-
|
|
23488
|
-
|
|
23489
|
-
|
|
23490
|
-
|
|
23491
|
-
|
|
23492
|
-
|
|
23493
|
-
|
|
23494
|
-
|
|
23495
|
-
|
|
23496
|
-
|
|
24516
|
+
create: (context) => ({
|
|
24517
|
+
JSXAttribute(node) {
|
|
24518
|
+
if (!isNodeOfType(node.name, "JSXIdentifier") || node.name.name !== "style") return;
|
|
24519
|
+
if (!isNodeOfType(node.value, "JSXExpressionContainer")) return;
|
|
24520
|
+
const expression = node.value.expression;
|
|
24521
|
+
if (!isNodeOfType(expression, "ObjectExpression")) return;
|
|
24522
|
+
for (const property of expression.properties ?? []) {
|
|
24523
|
+
if (!isNodeOfType(property, "Property")) continue;
|
|
24524
|
+
const key = isNodeOfType(property.key, "Identifier") ? property.key.name : null;
|
|
24525
|
+
if (key !== "transition" && key !== "transitionProperty") continue;
|
|
24526
|
+
if (isNodeOfType(property.value, "Literal") && typeof property.value.value === "string" && property.value.value.trim().startsWith("all")) context.report({
|
|
24527
|
+
node: property,
|
|
24528
|
+
message: "This can stutter because transition: \"all\" animates every property, even slow layout ones, so list only the properties you actually change"
|
|
24529
|
+
});
|
|
24530
|
+
}
|
|
24531
|
+
},
|
|
24532
|
+
JSXOpeningElement(node) {
|
|
24533
|
+
const classNameValue = getStringFromClassNameAttr(node);
|
|
24534
|
+
if (!classNameValue) return;
|
|
24535
|
+
if (hasTransitionAllClass(classNameValue)) context.report({
|
|
24536
|
+
node,
|
|
24537
|
+
message: TAILWIND_MESSAGE
|
|
23497
24538
|
});
|
|
23498
24539
|
}
|
|
23499
|
-
}
|
|
24540
|
+
})
|
|
23500
24541
|
});
|
|
23501
24542
|
//#endregion
|
|
23502
24543
|
//#region src/plugin/rules/correctness/no-uncontrolled-input.ts
|
|
@@ -23540,7 +24581,6 @@ const collectUndefinedInitialStateNames = (componentBody) => {
|
|
|
23540
24581
|
}
|
|
23541
24582
|
return stateNames;
|
|
23542
24583
|
};
|
|
23543
|
-
const hasJsxSpreadAttribute = (attributes) => attributes.some((attribute) => isNodeOfType(attribute, "JSXSpreadAttribute"));
|
|
23544
24584
|
const noUncontrolledInput = defineRule({
|
|
23545
24585
|
id: "no-uncontrolled-input",
|
|
23546
24586
|
title: "Uncontrolled input value",
|
|
@@ -23644,6 +24684,38 @@ const noUnescapedEntities = defineRule({
|
|
|
23644
24684
|
} })
|
|
23645
24685
|
});
|
|
23646
24686
|
//#endregion
|
|
24687
|
+
//#region src/plugin/rules/a11y/no-uninformative-aria-label.ts
|
|
24688
|
+
const UNINFORMATIVE_LABELS = new Set([
|
|
24689
|
+
"icon",
|
|
24690
|
+
"button",
|
|
24691
|
+
"image",
|
|
24692
|
+
"img",
|
|
24693
|
+
"link",
|
|
24694
|
+
"graphic",
|
|
24695
|
+
"svg",
|
|
24696
|
+
"picture",
|
|
24697
|
+
"element",
|
|
24698
|
+
"field",
|
|
24699
|
+
"input"
|
|
24700
|
+
]);
|
|
24701
|
+
const MESSAGE$11 = "An `aria-label` should name the action or destination, not the element type — this value tells screen-reader users nothing. Use something like `aria-label=\"Search\"` or `aria-label=\"Close dialog\"`.";
|
|
24702
|
+
const noUninformativeAriaLabel = defineRule({
|
|
24703
|
+
id: "no-uninformative-aria-label",
|
|
24704
|
+
title: "Uninformative aria-label",
|
|
24705
|
+
severity: "warn",
|
|
24706
|
+
recommendation: "Name the action, not the element type: `aria-label=\"Search\"`, not `aria-label=\"icon\"` or `aria-label=\"button\"`.",
|
|
24707
|
+
create: (context) => ({ JSXOpeningElement(node) {
|
|
24708
|
+
const ariaLabel = findJsxAttribute(node.attributes, "aria-label");
|
|
24709
|
+
if (!ariaLabel) return;
|
|
24710
|
+
const labelValue = getJsxPropStringValue(ariaLabel);
|
|
24711
|
+
if (labelValue === null) return;
|
|
24712
|
+
if (UNINFORMATIVE_LABELS.has(labelValue.trim().toLowerCase())) context.report({
|
|
24713
|
+
node: ariaLabel,
|
|
24714
|
+
message: MESSAGE$11
|
|
24715
|
+
});
|
|
24716
|
+
} })
|
|
24717
|
+
});
|
|
24718
|
+
//#endregion
|
|
23647
24719
|
//#region src/plugin/constants/dom-aria-properties.ts
|
|
23648
24720
|
const ARIA_PROPERTY_NAMES = new Set([
|
|
23649
24721
|
"activedescendant",
|
|
@@ -25115,7 +26187,7 @@ const noWideLetterSpacing = defineRule({
|
|
|
25115
26187
|
//#endregion
|
|
25116
26188
|
//#region src/plugin/rules/react-builtins/no-will-update-set-state.ts
|
|
25117
26189
|
const LIFECYCLE_NAMES = new Set(["componentWillUpdate", "UNSAFE_componentWillUpdate"]);
|
|
25118
|
-
const MESSAGE$
|
|
26190
|
+
const MESSAGE$10 = "Calling setState in componentWillUpdate can trigger another update immediately, loop forever, and freeze the component.";
|
|
25119
26191
|
const resolveSettings$7 = (settings) => {
|
|
25120
26192
|
const reactDoctor = settings?.["react-doctor"];
|
|
25121
26193
|
return { mode: (typeof reactDoctor === "object" && reactDoctor !== null ? reactDoctor.noWillUpdateSetState ?? {} : {}).mode ?? "allowed" };
|
|
@@ -25149,7 +26221,7 @@ const noWillUpdateSetState = defineRule({
|
|
|
25149
26221
|
if (!isSetStateCallInLifecycle(node, activeLifecycleNames, { disallowInNestedFunctions: mode === "disallow-in-func" })) return;
|
|
25150
26222
|
context.report({
|
|
25151
26223
|
node: node.callee,
|
|
25152
|
-
message: MESSAGE$
|
|
26224
|
+
message: MESSAGE$10
|
|
25153
26225
|
});
|
|
25154
26226
|
} };
|
|
25155
26227
|
}
|
|
@@ -26027,7 +27099,7 @@ const preactNoRenderArguments = defineRule({
|
|
|
26027
27099
|
});
|
|
26028
27100
|
//#endregion
|
|
26029
27101
|
//#region src/plugin/rules/preact/preact-prefer-ondblclick.ts
|
|
26030
|
-
const MESSAGE$
|
|
27102
|
+
const MESSAGE$9 = "Your users get no response from `onDoubleClick` in Preact core, where it never fires, so use `onDblClick` instead, which matches the DOM event name.";
|
|
26031
27103
|
const preactPreferOndblclick = defineRule({
|
|
26032
27104
|
id: "preact-prefer-ondblclick",
|
|
26033
27105
|
title: "onDoubleClick instead of onDblClick",
|
|
@@ -26042,7 +27114,7 @@ const preactPreferOndblclick = defineRule({
|
|
|
26042
27114
|
if (!onDoubleClickAttribute) return;
|
|
26043
27115
|
context.report({
|
|
26044
27116
|
node: onDoubleClickAttribute,
|
|
26045
|
-
message: MESSAGE$
|
|
27117
|
+
message: MESSAGE$9
|
|
26046
27118
|
});
|
|
26047
27119
|
} })
|
|
26048
27120
|
});
|
|
@@ -26082,6 +27154,42 @@ const preactPreferOninput = defineRule({
|
|
|
26082
27154
|
} })
|
|
26083
27155
|
});
|
|
26084
27156
|
//#endregion
|
|
27157
|
+
//#region src/plugin/rules/design/prefer-dvh-over-vh.ts
|
|
27158
|
+
const FULL_VIEWPORT_HEIGHT_CLASS = /(?:^|\s)(?:\w+:)*(?:min-)?h-(?:screen|\[100vh\])(?=$|[\s])/;
|
|
27159
|
+
const HEIGHT_KEYS = new Set(["height", "minHeight"]);
|
|
27160
|
+
const MESSAGE$8 = "`100vh` is taller than the visible viewport on mobile (it ignores the browser's dynamic toolbars), so full-height layouts get clipped. Use the dynamic-viewport unit: `h-dvh` / `min-h-dvh` (or `100dvh`).";
|
|
27161
|
+
const preferDvhOverVh = defineRule({
|
|
27162
|
+
id: "prefer-dvh-over-vh",
|
|
27163
|
+
title: "Use dvh instead of vh for full height",
|
|
27164
|
+
tags: ["design", "test-noise"],
|
|
27165
|
+
severity: "warn",
|
|
27166
|
+
requires: ["tailwind:3.4"],
|
|
27167
|
+
recommendation: "Prefer `dvh` over `vh` for full-height elements. `100vh` overflows under mobile browser chrome; `100dvh` tracks the visible viewport. (`h-dvh`/`min-h-dvh` need Tailwind 3.4+.)",
|
|
27168
|
+
create: (context) => ({
|
|
27169
|
+
JSXAttribute(node) {
|
|
27170
|
+
const expression = getInlineStyleExpression(node);
|
|
27171
|
+
if (!expression) return;
|
|
27172
|
+
for (const property of expression.properties ?? []) {
|
|
27173
|
+
const key = getStylePropertyKey(property);
|
|
27174
|
+
if (!key || !HEIGHT_KEYS.has(key)) continue;
|
|
27175
|
+
const value = getStylePropertyStringValue(property);
|
|
27176
|
+
if (value && value.trim().toLowerCase() === "100vh") context.report({
|
|
27177
|
+
node: property,
|
|
27178
|
+
message: MESSAGE$8
|
|
27179
|
+
});
|
|
27180
|
+
}
|
|
27181
|
+
},
|
|
27182
|
+
JSXOpeningElement(node) {
|
|
27183
|
+
const classNameValue = getStringFromClassNameAttr(node);
|
|
27184
|
+
if (!classNameValue) return;
|
|
27185
|
+
if (FULL_VIEWPORT_HEIGHT_CLASS.test(classNameValue)) context.report({
|
|
27186
|
+
node,
|
|
27187
|
+
message: MESSAGE$8
|
|
27188
|
+
});
|
|
27189
|
+
}
|
|
27190
|
+
})
|
|
27191
|
+
});
|
|
27192
|
+
//#endregion
|
|
26085
27193
|
//#region src/plugin/rules/bundle-size/prefer-dynamic-import.ts
|
|
26086
27194
|
const preferDynamicImport = defineRule({
|
|
26087
27195
|
id: "prefer-dynamic-import",
|
|
@@ -26673,6 +27781,26 @@ const preferTagOverRole = defineRule({
|
|
|
26673
27781
|
} })
|
|
26674
27782
|
});
|
|
26675
27783
|
//#endregion
|
|
27784
|
+
//#region src/plugin/rules/design/prefer-truncate-shorthand.ts
|
|
27785
|
+
const HAS_OVERFLOW_HIDDEN = /(?:^|\s)overflow-hidden(?:$|\s)/;
|
|
27786
|
+
const HAS_TEXT_ELLIPSIS = /(?:^|\s)text-ellipsis(?:$|\s)/;
|
|
27787
|
+
const HAS_WHITESPACE_NOWRAP = /(?:^|\s)whitespace-nowrap(?:$|\s)/;
|
|
27788
|
+
const preferTruncateShorthand = defineRule({
|
|
27789
|
+
id: "prefer-truncate-shorthand",
|
|
27790
|
+
title: "Use truncate shorthand",
|
|
27791
|
+
tags: ["design", "test-noise"],
|
|
27792
|
+
severity: "warn",
|
|
27793
|
+
recommendation: "Replace `overflow-hidden text-ellipsis whitespace-nowrap` with the single Tailwind `truncate` utility, which sets all three.",
|
|
27794
|
+
create: (context) => ({ JSXOpeningElement(node) {
|
|
27795
|
+
const classNameValue = getStringFromClassNameAttr(node);
|
|
27796
|
+
if (!classNameValue) return;
|
|
27797
|
+
if (HAS_OVERFLOW_HIDDEN.test(classNameValue) && HAS_TEXT_ELLIPSIS.test(classNameValue) && HAS_WHITESPACE_NOWRAP.test(classNameValue)) context.report({
|
|
27798
|
+
node,
|
|
27799
|
+
message: "`overflow-hidden text-ellipsis whitespace-nowrap` is exactly what the `truncate` utility does — collapse the three classes into `truncate`."
|
|
27800
|
+
});
|
|
27801
|
+
} })
|
|
27802
|
+
});
|
|
27803
|
+
//#endregion
|
|
26676
27804
|
//#region src/plugin/rules/state-and-effects/prefer-use-effect-event.ts
|
|
26677
27805
|
const collectFunctionTypedLocalBindings = (componentBody) => {
|
|
26678
27806
|
const functionTypedLocals = /* @__PURE__ */ new Set();
|
|
@@ -27035,6 +28163,8 @@ const publicEnvSecretName = defineRule({
|
|
|
27035
28163
|
});
|
|
27036
28164
|
//#endregion
|
|
27037
28165
|
//#region src/plugin/rules/tanstack-query/query-destructure-result.ts
|
|
28166
|
+
const TANSTACK_QUERY_PACKAGE_PATTERN = /^@tanstack\/[\w-]*query[\w-]*$/;
|
|
28167
|
+
const isTanstackQuerySource = (source) => TANSTACK_QUERY_PACKAGE_PATTERN.test(source) || source === "react-query";
|
|
27038
28168
|
const queryDestructureResult = defineRule({
|
|
27039
28169
|
id: "query-destructure-result",
|
|
27040
28170
|
title: "Whole query result subscribes to every field",
|
|
@@ -27047,6 +28177,8 @@ const queryDestructureResult = defineRule({
|
|
|
27047
28177
|
if (!node.init || !isNodeOfType(node.init, "CallExpression")) return;
|
|
27048
28178
|
const calleeName = isNodeOfType(node.init.callee, "Identifier") ? node.init.callee.name : null;
|
|
27049
28179
|
if (!calleeName || !TANSTACK_QUERY_HOOKS.has(calleeName)) return;
|
|
28180
|
+
const importSource = getImportSourceForName(node, calleeName);
|
|
28181
|
+
if (importSource !== null && !isTanstackQuerySource(importSource)) return;
|
|
27050
28182
|
context.report({
|
|
27051
28183
|
node: node.id,
|
|
27052
28184
|
message: `Destructure ${calleeName}() results instead of assigning the whole query object, so TanStack Query only subscribes to the fields you use.`
|
|
@@ -27889,6 +29021,7 @@ const repositorySecretFile = defineRule({
|
|
|
27889
29021
|
id: "repository-secret-file",
|
|
27890
29022
|
title: "Secret file checked into repository",
|
|
27891
29023
|
severity: "error",
|
|
29024
|
+
committedFilesOnly: true,
|
|
27892
29025
|
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
29026
|
scan: (file) => {
|
|
27894
29027
|
if (!isRepositorySecretFilePath(file.relativePath)) return [];
|
|
@@ -27905,6 +29038,20 @@ const repositorySecretFile = defineRule({
|
|
|
27905
29038
|
}
|
|
27906
29039
|
});
|
|
27907
29040
|
//#endregion
|
|
29041
|
+
//#region src/plugin/rules/security-scan/request-body-mass-assignment.ts
|
|
29042
|
+
const REQUEST_INPUT_SOURCE = "(?:req|request|ctx\\.req|ctx\\.request)\\.(?:body|query|params)|await\\s+(?:req|request)\\.json\\(\\s*\\)";
|
|
29043
|
+
const requestBodyMassAssignment = defineRule({
|
|
29044
|
+
id: "request-body-mass-assignment",
|
|
29045
|
+
title: "Request input spread without field allowlist",
|
|
29046
|
+
severity: "warn",
|
|
29047
|
+
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.",
|
|
29048
|
+
scan: scanByPattern({
|
|
29049
|
+
shouldScan: (file) => isProductionSourcePath(file.relativePath),
|
|
29050
|
+
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")],
|
|
29051
|
+
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."
|
|
29052
|
+
})
|
|
29053
|
+
});
|
|
29054
|
+
//#endregion
|
|
27908
29055
|
//#region src/plugin/utils/function-body-has-return-with-value.ts
|
|
27909
29056
|
const functionBodyHasReturnWithValue = (functionNode) => {
|
|
27910
29057
|
if (functionNode.type === "ArrowFunctionExpression" && "body" in functionNode) {
|
|
@@ -34330,6 +35477,17 @@ const scope = defineRule({
|
|
|
34330
35477
|
});
|
|
34331
35478
|
} })
|
|
34332
35479
|
});
|
|
35480
|
+
const secretInFallback = defineRule({
|
|
35481
|
+
id: "secret-in-fallback",
|
|
35482
|
+
title: "Hardcoded secret fallback for env var",
|
|
35483
|
+
severity: "error",
|
|
35484
|
+
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.",
|
|
35485
|
+
scan: scanByPattern({
|
|
35486
|
+
shouldScan: (file) => isProductionSourcePath(file.relativePath),
|
|
35487
|
+
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,
|
|
35488
|
+
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."
|
|
35489
|
+
})
|
|
35490
|
+
});
|
|
34333
35491
|
//#endregion
|
|
34334
35492
|
//#region src/plugin/rules/react-builtins/self-closing-comp.ts
|
|
34335
35493
|
const MESSAGE$2 = "This tag has no children, so the closing tag adds noise without changing output.";
|
|
@@ -34448,6 +35606,47 @@ const serverAfterNonblocking = defineRule({
|
|
|
34448
35606
|
}
|
|
34449
35607
|
});
|
|
34450
35608
|
//#endregion
|
|
35609
|
+
//#region src/plugin/utils/is-auth-guard-name.ts
|
|
35610
|
+
const SIGNED_IN_HEAD_TOKENS = new Set([
|
|
35611
|
+
"signed",
|
|
35612
|
+
"logged",
|
|
35613
|
+
"sign"
|
|
35614
|
+
]);
|
|
35615
|
+
const mergeSignedInTokens = (tokens) => {
|
|
35616
|
+
const mergedTokens = [];
|
|
35617
|
+
for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex += 1) {
|
|
35618
|
+
const currentToken = tokens[tokenIndex];
|
|
35619
|
+
if (SIGNED_IN_HEAD_TOKENS.has(currentToken) && tokens[tokenIndex + 1] === "in") {
|
|
35620
|
+
mergedTokens.push(`${currentToken}in`);
|
|
35621
|
+
tokenIndex += 1;
|
|
35622
|
+
continue;
|
|
35623
|
+
}
|
|
35624
|
+
mergedTokens.push(currentToken);
|
|
35625
|
+
}
|
|
35626
|
+
return mergedTokens;
|
|
35627
|
+
};
|
|
35628
|
+
const isAuthGuardName = (calleeName) => {
|
|
35629
|
+
const tokens = mergeSignedInTokens(tokenizeIdentifierWords(calleeName));
|
|
35630
|
+
if (tokens.length === 0) return false;
|
|
35631
|
+
let hasAssertiveVerb = false;
|
|
35632
|
+
let hasGetterVerb = false;
|
|
35633
|
+
let hasQualifier = false;
|
|
35634
|
+
let hasStrongNoun = false;
|
|
35635
|
+
let hasWeakNoun = false;
|
|
35636
|
+
for (const token of tokens) {
|
|
35637
|
+
if (AUTH_STRONG_TOKEN_PATTERN.test(token) || AUTH_STANDALONE_NOUN_TOKENS.has(token)) return true;
|
|
35638
|
+
if (AUTH_ASSERTIVE_VERB_TOKENS.has(token)) hasAssertiveVerb = true;
|
|
35639
|
+
if (AUTH_GETTER_VERB_TOKENS.has(token)) hasGetterVerb = true;
|
|
35640
|
+
if (AUTH_QUALIFIER_TOKENS.has(token)) hasQualifier = true;
|
|
35641
|
+
if (AUTH_STRONG_NOUN_TOKENS.has(token)) hasStrongNoun = true;
|
|
35642
|
+
if (AUTH_WEAK_NOUN_TOKENS.has(token)) hasWeakNoun = true;
|
|
35643
|
+
}
|
|
35644
|
+
if (hasAssertiveVerb && (hasStrongNoun || hasWeakNoun)) return true;
|
|
35645
|
+
if (hasGetterVerb && hasStrongNoun) return true;
|
|
35646
|
+
if (hasQualifier && hasWeakNoun) return true;
|
|
35647
|
+
return false;
|
|
35648
|
+
};
|
|
35649
|
+
//#endregion
|
|
34451
35650
|
//#region src/plugin/rules/server/server-auth-actions.ts
|
|
34452
35651
|
const isAsyncFunctionLikeNode = (node) => {
|
|
34453
35652
|
if (!node) return false;
|
|
@@ -34490,9 +35689,13 @@ const isMemberCallAuthRelated = (receiverNode, methodName, genericMethodNames) =
|
|
|
34490
35689
|
const getAuthCallName = (callExpression, allowedFunctionNames, genericMethodNames) => {
|
|
34491
35690
|
const calleeNode = unwrapTypeWrappedCallee(callExpression.callee);
|
|
34492
35691
|
if (!calleeNode) return null;
|
|
34493
|
-
if (isNodeOfType(calleeNode, "Identifier"))
|
|
35692
|
+
if (isNodeOfType(calleeNode, "Identifier")) {
|
|
35693
|
+
const calleeName = calleeNode.name;
|
|
35694
|
+
return allowedFunctionNames.has(calleeName) || isAuthGuardName(calleeName) ? calleeName : null;
|
|
35695
|
+
}
|
|
34494
35696
|
if (isNodeOfType(calleeNode, "MemberExpression") && isNodeOfType(calleeNode.property, "Identifier")) {
|
|
34495
35697
|
const methodName = calleeNode.property.name;
|
|
35698
|
+
if (isAuthGuardName(methodName)) return methodName;
|
|
34496
35699
|
if (!allowedFunctionNames.has(methodName)) return null;
|
|
34497
35700
|
if (!isMemberCallAuthRelated(calleeNode.object, methodName, genericMethodNames)) return null;
|
|
34498
35701
|
return methodName;
|
|
@@ -35139,8 +36342,11 @@ const supabaseClientOwnedAuthzField = defineRule({
|
|
|
35139
36342
|
})
|
|
35140
36343
|
});
|
|
35141
36344
|
//#endregion
|
|
36345
|
+
//#region src/plugin/rules/security-scan/utils/is-supabase-migration-path.ts
|
|
36346
|
+
const isSupabaseMigrationPath = (relativePath) => /(?:^|\/)supabase\/(?:migrations|schemas)\//.test(relativePath);
|
|
36347
|
+
//#endregion
|
|
35142
36348
|
//#region src/plugin/rules/security-scan/utils/is-sql-path.ts
|
|
35143
|
-
const isSqlPath = (relativePath) => relativePath.endsWith(".sql") ||
|
|
36349
|
+
const isSqlPath = (relativePath) => relativePath.endsWith(".sql") || isSupabaseMigrationPath(relativePath);
|
|
35144
36350
|
const supabaseRlsPolicyRisk = defineRule({
|
|
35145
36351
|
id: "supabase-rls-policy-risk",
|
|
35146
36352
|
title: "Permissive Supabase RLS policy",
|
|
@@ -35158,6 +36364,210 @@ const supabaseRlsPolicyRisk = defineRule({
|
|
|
35158
36364
|
})
|
|
35159
36365
|
});
|
|
35160
36366
|
//#endregion
|
|
36367
|
+
//#region src/plugin/rules/security-scan/utils/sanitize-sql-for-scan.ts
|
|
36368
|
+
const DOLLAR_QUOTE_TAG_PATTERN = /^\$[A-Za-z_]?\w*\$/;
|
|
36369
|
+
const CODE_BODY_KEYWORDS = new Set([
|
|
36370
|
+
"do",
|
|
36371
|
+
"plpgsql",
|
|
36372
|
+
"sql",
|
|
36373
|
+
"plpython3u",
|
|
36374
|
+
"plpythonu",
|
|
36375
|
+
"plperl",
|
|
36376
|
+
"plperlu",
|
|
36377
|
+
"plv8"
|
|
36378
|
+
]);
|
|
36379
|
+
const precedingKeyword = (content, beforeIndex) => {
|
|
36380
|
+
let lookBack = beforeIndex - 1;
|
|
36381
|
+
while (lookBack >= 0 && /\s/.test(content[lookBack] ?? "")) lookBack -= 1;
|
|
36382
|
+
let wordStart = lookBack;
|
|
36383
|
+
while (wordStart >= 0 && /[A-Za-z0-9_]/.test(content[wordStart] ?? "")) wordStart -= 1;
|
|
36384
|
+
return content.slice(wordStart + 1, lookBack + 1).toLowerCase();
|
|
36385
|
+
};
|
|
36386
|
+
const blankCodeBodyInterior = (content, characters, start, end) => {
|
|
36387
|
+
let index = start;
|
|
36388
|
+
let inExecuteStatement = false;
|
|
36389
|
+
while (index < end) {
|
|
36390
|
+
const character = content[index];
|
|
36391
|
+
if (character === ";") {
|
|
36392
|
+
inExecuteStatement = false;
|
|
36393
|
+
index += 1;
|
|
36394
|
+
continue;
|
|
36395
|
+
}
|
|
36396
|
+
if (/[A-Za-z_]/.test(character)) {
|
|
36397
|
+
let wordEnd = index;
|
|
36398
|
+
while (wordEnd < end && /[A-Za-z0-9_]/.test(content[wordEnd] ?? "")) wordEnd += 1;
|
|
36399
|
+
if (content.slice(index, wordEnd).toLowerCase() === "execute") inExecuteStatement = true;
|
|
36400
|
+
index = wordEnd;
|
|
36401
|
+
continue;
|
|
36402
|
+
}
|
|
36403
|
+
if (character === "'") {
|
|
36404
|
+
const keepVisible = inExecuteStatement;
|
|
36405
|
+
if (!keepVisible) characters[index] = " ";
|
|
36406
|
+
index += 1;
|
|
36407
|
+
while (index < end) {
|
|
36408
|
+
if (content[index] === "'") {
|
|
36409
|
+
if (content[index + 1] === "'") {
|
|
36410
|
+
if (!keepVisible) {
|
|
36411
|
+
characters[index] = " ";
|
|
36412
|
+
characters[index + 1] = " ";
|
|
36413
|
+
}
|
|
36414
|
+
index += 2;
|
|
36415
|
+
continue;
|
|
36416
|
+
}
|
|
36417
|
+
if (!keepVisible) characters[index] = " ";
|
|
36418
|
+
index += 1;
|
|
36419
|
+
break;
|
|
36420
|
+
}
|
|
36421
|
+
if (!keepVisible && content[index] !== "\n") characters[index] = " ";
|
|
36422
|
+
index += 1;
|
|
36423
|
+
}
|
|
36424
|
+
continue;
|
|
36425
|
+
}
|
|
36426
|
+
if (character === "\"") {
|
|
36427
|
+
index += 1;
|
|
36428
|
+
while (index < end) {
|
|
36429
|
+
if (content[index] === "\"") {
|
|
36430
|
+
if (content[index + 1] === "\"") {
|
|
36431
|
+
index += 2;
|
|
36432
|
+
continue;
|
|
36433
|
+
}
|
|
36434
|
+
index += 1;
|
|
36435
|
+
break;
|
|
36436
|
+
}
|
|
36437
|
+
index += 1;
|
|
36438
|
+
}
|
|
36439
|
+
continue;
|
|
36440
|
+
}
|
|
36441
|
+
if (character === "-" && content[index + 1] === "-") {
|
|
36442
|
+
while (index < end && content[index] !== "\n") {
|
|
36443
|
+
characters[index] = " ";
|
|
36444
|
+
index += 1;
|
|
36445
|
+
}
|
|
36446
|
+
continue;
|
|
36447
|
+
}
|
|
36448
|
+
if (character === "/" && content[index + 1] === "*") {
|
|
36449
|
+
while (index < end) {
|
|
36450
|
+
if (content[index] === "*" && content[index + 1] === "/") {
|
|
36451
|
+
characters[index] = " ";
|
|
36452
|
+
characters[index + 1] = " ";
|
|
36453
|
+
index += 2;
|
|
36454
|
+
break;
|
|
36455
|
+
}
|
|
36456
|
+
if (content[index] !== "\n") characters[index] = " ";
|
|
36457
|
+
index += 1;
|
|
36458
|
+
}
|
|
36459
|
+
continue;
|
|
36460
|
+
}
|
|
36461
|
+
index += 1;
|
|
36462
|
+
}
|
|
36463
|
+
};
|
|
36464
|
+
const sanitizeSqlForScan = (content) => {
|
|
36465
|
+
const characters = content.split("");
|
|
36466
|
+
let index = 0;
|
|
36467
|
+
while (index < content.length) {
|
|
36468
|
+
const character = content[index];
|
|
36469
|
+
if (character === "-" && content[index + 1] === "-") {
|
|
36470
|
+
while (index < content.length && content[index] !== "\n") {
|
|
36471
|
+
characters[index] = " ";
|
|
36472
|
+
index += 1;
|
|
36473
|
+
}
|
|
36474
|
+
continue;
|
|
36475
|
+
}
|
|
36476
|
+
if (character === "/" && content[index + 1] === "*") {
|
|
36477
|
+
while (index < content.length) {
|
|
36478
|
+
if (content[index] === "*" && content[index + 1] === "/") {
|
|
36479
|
+
characters[index] = " ";
|
|
36480
|
+
characters[index + 1] = " ";
|
|
36481
|
+
index += 2;
|
|
36482
|
+
break;
|
|
36483
|
+
}
|
|
36484
|
+
if (content[index] !== "\n") characters[index] = " ";
|
|
36485
|
+
index += 1;
|
|
36486
|
+
}
|
|
36487
|
+
continue;
|
|
36488
|
+
}
|
|
36489
|
+
if (character === "'") {
|
|
36490
|
+
characters[index] = " ";
|
|
36491
|
+
index += 1;
|
|
36492
|
+
while (index < content.length) {
|
|
36493
|
+
if (content[index] === "'") {
|
|
36494
|
+
if (content[index + 1] === "'") {
|
|
36495
|
+
characters[index] = " ";
|
|
36496
|
+
characters[index + 1] = " ";
|
|
36497
|
+
index += 2;
|
|
36498
|
+
continue;
|
|
36499
|
+
}
|
|
36500
|
+
characters[index] = " ";
|
|
36501
|
+
index += 1;
|
|
36502
|
+
break;
|
|
36503
|
+
}
|
|
36504
|
+
if (content[index] !== "\n") characters[index] = " ";
|
|
36505
|
+
index += 1;
|
|
36506
|
+
}
|
|
36507
|
+
continue;
|
|
36508
|
+
}
|
|
36509
|
+
if (character === "$") {
|
|
36510
|
+
const tagMatch = DOLLAR_QUOTE_TAG_PATTERN.exec(content.slice(index));
|
|
36511
|
+
if (tagMatch !== null) {
|
|
36512
|
+
const tag = tagMatch[0];
|
|
36513
|
+
const closeIndex = content.indexOf(tag, index + tag.length);
|
|
36514
|
+
const endIndex = closeIndex < 0 ? content.length : closeIndex + tag.length;
|
|
36515
|
+
const keyword = precedingKeyword(content, index);
|
|
36516
|
+
if (CODE_BODY_KEYWORDS.has(keyword)) blankCodeBodyInterior(content, characters, index + tag.length, endIndex);
|
|
36517
|
+
else for (let blankIndex = index; blankIndex < endIndex; blankIndex += 1) if (content[blankIndex] !== "\n") characters[blankIndex] = " ";
|
|
36518
|
+
index = endIndex;
|
|
36519
|
+
continue;
|
|
36520
|
+
}
|
|
36521
|
+
}
|
|
36522
|
+
if (character === "\"") {
|
|
36523
|
+
index += 1;
|
|
36524
|
+
while (index < content.length) {
|
|
36525
|
+
if (content[index] === "\"") {
|
|
36526
|
+
if (content[index + 1] === "\"") {
|
|
36527
|
+
index += 2;
|
|
36528
|
+
continue;
|
|
36529
|
+
}
|
|
36530
|
+
index += 1;
|
|
36531
|
+
break;
|
|
36532
|
+
}
|
|
36533
|
+
index += 1;
|
|
36534
|
+
}
|
|
36535
|
+
continue;
|
|
36536
|
+
}
|
|
36537
|
+
index += 1;
|
|
36538
|
+
}
|
|
36539
|
+
return characters.join("");
|
|
36540
|
+
};
|
|
36541
|
+
//#endregion
|
|
36542
|
+
//#region src/plugin/rules/security-scan/supabase-table-missing-rls.ts
|
|
36543
|
+
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;
|
|
36544
|
+
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");
|
|
36545
|
+
const supabaseTableMissingRls = defineRule({
|
|
36546
|
+
id: "supabase-table-missing-rls",
|
|
36547
|
+
title: "Supabase table created without Row Level Security",
|
|
36548
|
+
severity: "error",
|
|
36549
|
+
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.",
|
|
36550
|
+
scan: (file) => {
|
|
36551
|
+
if (!isSupabaseMigrationPath(file.relativePath)) return [];
|
|
36552
|
+
const content = sanitizeSqlForScan(file.content);
|
|
36553
|
+
if (!/create\s+(?:unlogged\s+)?table/i.test(content)) return [];
|
|
36554
|
+
const findings = [];
|
|
36555
|
+
CREATE_PUBLIC_TABLE_PATTERN.lastIndex = 0;
|
|
36556
|
+
for (let match = CREATE_PUBLIC_TABLE_PATTERN.exec(content); match !== null; match = CREATE_PUBLIC_TABLE_PATTERN.exec(content)) {
|
|
36557
|
+
const tableName = match[1];
|
|
36558
|
+
if (tableName === void 0) continue;
|
|
36559
|
+
if (enableRlsForTablePattern(tableName).test(content.slice(match.index))) continue;
|
|
36560
|
+
const location = getLocationAtIndex(content, match.index);
|
|
36561
|
+
findings.push({
|
|
36562
|
+
message: "Supabase migration creates a public table but never enables Row Level Security, leaving every row exposed to the anon key.",
|
|
36563
|
+
line: location.line,
|
|
36564
|
+
column: location.column
|
|
36565
|
+
});
|
|
36566
|
+
}
|
|
36567
|
+
return findings;
|
|
36568
|
+
}
|
|
36569
|
+
});
|
|
36570
|
+
//#endregion
|
|
35161
36571
|
//#region src/plugin/rules/security-scan/svg-filter-clickjacking-risk.ts
|
|
35162
36572
|
const svgFilterClickjackingRisk = defineRule({
|
|
35163
36573
|
id: "svg-filter-clickjacking-risk",
|
|
@@ -35856,6 +37266,47 @@ const tenantStaticProxyRisk = defineRule({
|
|
|
35856
37266
|
})
|
|
35857
37267
|
});
|
|
35858
37268
|
//#endregion
|
|
37269
|
+
//#region src/plugin/rules/security-scan/unsafe-json-in-html.ts
|
|
37270
|
+
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];
|
|
37271
|
+
const RETURN_ESCAPE_PATTERN = /^[\s)]*\.replace\s*\([^)]*(?:\\u003[cC]|<|<)/;
|
|
37272
|
+
const ESCAPE_WRAPPER_PATTERN = /(?:\b(?:escapeHtml|escapeJSON|escapeJson|htmlEscape|jsesc)|(?<![.\w])(?:serialize|serializeJavascript|devalue|uneval|superjson))\s*\(\s*$/i;
|
|
37273
|
+
const JSON_STRINGIFY_TOKEN_PATTERN = /\bJSON\.stringify\s*\($/i;
|
|
37274
|
+
const RETURN_LOOKAHEAD_CHARS = 160;
|
|
37275
|
+
const unsafeJsonInHtml = defineRule({
|
|
37276
|
+
id: "unsafe-json-in-html",
|
|
37277
|
+
title: "Unescaped JSON in HTML or script sink",
|
|
37278
|
+
severity: "warn",
|
|
37279
|
+
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.",
|
|
37280
|
+
scan: (file) => {
|
|
37281
|
+
if (!isProductionSourcePath(file.relativePath)) return [];
|
|
37282
|
+
const content = getScannableContent(file);
|
|
37283
|
+
if (!content.includes("JSON.stringify")) return [];
|
|
37284
|
+
const findings = [];
|
|
37285
|
+
const seenIndices = /* @__PURE__ */ new Set();
|
|
37286
|
+
for (const pattern of SINK_JSON_STRINGIFY_PATTERNS) {
|
|
37287
|
+
pattern.lastIndex = 0;
|
|
37288
|
+
for (let match = pattern.exec(content); match !== null; match = pattern.exec(content)) {
|
|
37289
|
+
const beforeStringify = match[0].replace(JSON_STRINGIFY_TOKEN_PATTERN, "");
|
|
37290
|
+
if (ESCAPE_WRAPPER_PATTERN.test(beforeStringify)) continue;
|
|
37291
|
+
const closeParenIndex = findMatchingBracket(content, match.index + match[0].length - 1);
|
|
37292
|
+
if (closeParenIndex >= 0) {
|
|
37293
|
+
const afterReturn = content.slice(closeParenIndex + 1, closeParenIndex + 1 + RETURN_LOOKAHEAD_CHARS);
|
|
37294
|
+
if (RETURN_ESCAPE_PATTERN.test(afterReturn)) continue;
|
|
37295
|
+
}
|
|
37296
|
+
if (seenIndices.has(match.index)) continue;
|
|
37297
|
+
seenIndices.add(match.index);
|
|
37298
|
+
const location = getLocationAtIndex(content, match.index);
|
|
37299
|
+
findings.push({
|
|
37300
|
+
message: "JSON.stringify is embedded in HTML/script markup without HTML-escaping; data containing `<\/script>` or `<` breaks out and becomes XSS.",
|
|
37301
|
+
line: location.line,
|
|
37302
|
+
column: location.column
|
|
37303
|
+
});
|
|
37304
|
+
}
|
|
37305
|
+
}
|
|
37306
|
+
return findings;
|
|
37307
|
+
}
|
|
37308
|
+
});
|
|
37309
|
+
//#endregion
|
|
35859
37310
|
//#region src/plugin/rules/security-scan/untrusted-redirect-following.ts
|
|
35860
37311
|
const OUTBOUND_FETCH_CALL_PATTERN = /(?:(?<![.\w$])fetch|\baxios\.\s*(?:get|post|put|delete|head)|\bgot|\bgot\.\s*(?:get|post))\s*\(\s*([^,)]+)/;
|
|
35861
37312
|
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 +37454,7 @@ const voidDomElementsNoChildren = defineRule({
|
|
|
36003
37454
|
//#region src/plugin/rules/security-scan/webhook-signature-risk.ts
|
|
36004
37455
|
const WEBHOOK_HANDLER_PATTERN = /(?:^|\/)[^/]*webhook[^/]*\/|(?:^|\/)[^/]*webhook[^/]*\.[cm]?[jt]s$|\bwebhook\b/i;
|
|
36005
37456
|
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["']
|
|
37457
|
+
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
37458
|
const OUTBOUND_WEBHOOK_URL_MENTION_PATTERN = /webhook[\s_-]?ur[il]\w*/gi;
|
|
36008
37459
|
const OUTBOUND_WEBHOOK_CONFIG_PATTERN = /process\.env\.\w*WEBHOOK_URL|\b(?:send|post|dispatch|publish|notify)\w*Webhook/;
|
|
36009
37460
|
const REQUEST_READ_PATTERN = /\b(?:req|request)\b/;
|
|
@@ -36663,6 +38114,17 @@ const reactDoctorRules = [
|
|
|
36663
38114
|
category: "Performance"
|
|
36664
38115
|
}
|
|
36665
38116
|
},
|
|
38117
|
+
{
|
|
38118
|
+
key: "react-doctor/auth-token-in-web-storage",
|
|
38119
|
+
id: "auth-token-in-web-storage",
|
|
38120
|
+
source: "react-doctor",
|
|
38121
|
+
originallyExternal: false,
|
|
38122
|
+
rule: {
|
|
38123
|
+
...authTokenInWebStorage,
|
|
38124
|
+
framework: "global",
|
|
38125
|
+
category: "Security"
|
|
38126
|
+
}
|
|
38127
|
+
},
|
|
36666
38128
|
{
|
|
36667
38129
|
key: "react-doctor/autocomplete-valid",
|
|
36668
38130
|
id: "autocomplete-valid",
|
|
@@ -36879,6 +38341,18 @@ const reactDoctorRules = [
|
|
|
36879
38341
|
requires: [...new Set(["react", ...noVagueButtonLabel.requires ?? []])]
|
|
36880
38342
|
}
|
|
36881
38343
|
},
|
|
38344
|
+
{
|
|
38345
|
+
key: "react-doctor/dialog-has-accessible-name",
|
|
38346
|
+
id: "dialog-has-accessible-name",
|
|
38347
|
+
source: "react-doctor",
|
|
38348
|
+
originallyExternal: false,
|
|
38349
|
+
rule: {
|
|
38350
|
+
...dialogHasAccessibleName,
|
|
38351
|
+
framework: "global",
|
|
38352
|
+
category: "Accessibility",
|
|
38353
|
+
requires: [...new Set(["react", ...dialogHasAccessibleName.requires ?? []])]
|
|
38354
|
+
}
|
|
38355
|
+
},
|
|
36882
38356
|
{
|
|
36883
38357
|
key: "react-doctor/display-name",
|
|
36884
38358
|
id: "display-name",
|
|
@@ -37164,6 +38638,18 @@ const reactDoctorRules = [
|
|
|
37164
38638
|
tags: [...new Set(["security-scan", ...insecureCryptoRisk.tags ?? []])]
|
|
37165
38639
|
}
|
|
37166
38640
|
},
|
|
38641
|
+
{
|
|
38642
|
+
key: "react-doctor/insecure-session-cookie",
|
|
38643
|
+
id: "insecure-session-cookie",
|
|
38644
|
+
source: "react-doctor",
|
|
38645
|
+
originallyExternal: false,
|
|
38646
|
+
rule: {
|
|
38647
|
+
...insecureSessionCookie,
|
|
38648
|
+
framework: "global",
|
|
38649
|
+
category: "Security",
|
|
38650
|
+
tags: [...new Set(["security-scan", ...insecureSessionCookie.tags ?? []])]
|
|
38651
|
+
}
|
|
38652
|
+
},
|
|
37167
38653
|
{
|
|
37168
38654
|
key: "react-doctor/interactive-supports-focus",
|
|
37169
38655
|
id: "interactive-supports-focus",
|
|
@@ -37606,6 +39092,18 @@ const reactDoctorRules = [
|
|
|
37606
39092
|
requires: [...new Set(["react", ...jsxPropsNoSpreading.requires ?? []])]
|
|
37607
39093
|
}
|
|
37608
39094
|
},
|
|
39095
|
+
{
|
|
39096
|
+
key: "react-doctor/jwt-insecure-verification",
|
|
39097
|
+
id: "jwt-insecure-verification",
|
|
39098
|
+
source: "react-doctor",
|
|
39099
|
+
originallyExternal: false,
|
|
39100
|
+
rule: {
|
|
39101
|
+
...jwtInsecureVerification,
|
|
39102
|
+
framework: "global",
|
|
39103
|
+
category: "Security",
|
|
39104
|
+
tags: [...new Set(["security-scan", ...jwtInsecureVerification.tags ?? []])]
|
|
39105
|
+
}
|
|
39106
|
+
},
|
|
37609
39107
|
{
|
|
37610
39108
|
key: "react-doctor/key-lifecycle-risk",
|
|
37611
39109
|
id: "key-lifecycle-risk",
|
|
@@ -37979,6 +39477,17 @@ const reactDoctorRules = [
|
|
|
37979
39477
|
requires: [...new Set(["react", ...noAdjustStateOnPropChange.requires ?? []])]
|
|
37980
39478
|
}
|
|
37981
39479
|
},
|
|
39480
|
+
{
|
|
39481
|
+
key: "react-doctor/no-arbitrary-px-font-size",
|
|
39482
|
+
id: "no-arbitrary-px-font-size",
|
|
39483
|
+
source: "react-doctor",
|
|
39484
|
+
originallyExternal: false,
|
|
39485
|
+
rule: {
|
|
39486
|
+
...noArbitraryPxFontSize,
|
|
39487
|
+
framework: "global",
|
|
39488
|
+
category: "Accessibility"
|
|
39489
|
+
}
|
|
39490
|
+
},
|
|
37982
39491
|
{
|
|
37983
39492
|
key: "react-doctor/no-aria-hidden-on-focusable",
|
|
37984
39493
|
id: "no-aria-hidden-on-focusable",
|
|
@@ -38014,6 +39523,18 @@ const reactDoctorRules = [
|
|
|
38014
39523
|
requires: [...new Set(["react", ...noArrayIndexKey.requires ?? []])]
|
|
38015
39524
|
}
|
|
38016
39525
|
},
|
|
39526
|
+
{
|
|
39527
|
+
key: "react-doctor/no-async-effect-callback",
|
|
39528
|
+
id: "no-async-effect-callback",
|
|
39529
|
+
source: "react-doctor",
|
|
39530
|
+
originallyExternal: false,
|
|
39531
|
+
rule: {
|
|
39532
|
+
...noAsyncEffectCallback,
|
|
39533
|
+
framework: "global",
|
|
39534
|
+
category: "Bugs",
|
|
39535
|
+
requires: [...new Set(["react", ...noAsyncEffectCallback.requires ?? []])]
|
|
39536
|
+
}
|
|
39537
|
+
},
|
|
38017
39538
|
{
|
|
38018
39539
|
key: "react-doctor/no-autofocus",
|
|
38019
39540
|
id: "no-autofocus",
|
|
@@ -38026,6 +39547,18 @@ const reactDoctorRules = [
|
|
|
38026
39547
|
requires: [...new Set(["react", ...noAutofocus.requires ?? []])]
|
|
38027
39548
|
}
|
|
38028
39549
|
},
|
|
39550
|
+
{
|
|
39551
|
+
key: "react-doctor/no-autoplay-without-muted",
|
|
39552
|
+
id: "no-autoplay-without-muted",
|
|
39553
|
+
source: "react-doctor",
|
|
39554
|
+
originallyExternal: false,
|
|
39555
|
+
rule: {
|
|
39556
|
+
...noAutoplayWithoutMuted,
|
|
39557
|
+
framework: "global",
|
|
39558
|
+
category: "Accessibility",
|
|
39559
|
+
requires: [...new Set(["react", ...noAutoplayWithoutMuted.requires ?? []])]
|
|
39560
|
+
}
|
|
39561
|
+
},
|
|
38029
39562
|
{
|
|
38030
39563
|
key: "react-doctor/no-barrel-import",
|
|
38031
39564
|
id: "no-barrel-import",
|
|
@@ -38037,6 +39570,18 @@ const reactDoctorRules = [
|
|
|
38037
39570
|
category: "Performance"
|
|
38038
39571
|
}
|
|
38039
39572
|
},
|
|
39573
|
+
{
|
|
39574
|
+
key: "react-doctor/no-call-component-as-function",
|
|
39575
|
+
id: "no-call-component-as-function",
|
|
39576
|
+
source: "react-doctor",
|
|
39577
|
+
originallyExternal: false,
|
|
39578
|
+
rule: {
|
|
39579
|
+
...noCallComponentAsFunction,
|
|
39580
|
+
framework: "global",
|
|
39581
|
+
category: "Bugs",
|
|
39582
|
+
requires: [...new Set(["react", ...noCallComponentAsFunction.requires ?? []])]
|
|
39583
|
+
}
|
|
39584
|
+
},
|
|
38040
39585
|
{
|
|
38041
39586
|
key: "react-doctor/no-cascading-set-state",
|
|
38042
39587
|
id: "no-cascading-set-state",
|
|
@@ -38097,6 +39642,18 @@ const reactDoctorRules = [
|
|
|
38097
39642
|
requires: [...new Set(["react", ...noCreateContextInRender.requires ?? []])]
|
|
38098
39643
|
}
|
|
38099
39644
|
},
|
|
39645
|
+
{
|
|
39646
|
+
key: "react-doctor/no-create-ref-in-function-component",
|
|
39647
|
+
id: "no-create-ref-in-function-component",
|
|
39648
|
+
source: "react-doctor",
|
|
39649
|
+
originallyExternal: false,
|
|
39650
|
+
rule: {
|
|
39651
|
+
...noCreateRefInFunctionComponent,
|
|
39652
|
+
framework: "global",
|
|
39653
|
+
category: "Bugs",
|
|
39654
|
+
requires: [...new Set(["react", ...noCreateRefInFunctionComponent.requires ?? []])]
|
|
39655
|
+
}
|
|
39656
|
+
},
|
|
38100
39657
|
{
|
|
38101
39658
|
key: "react-doctor/no-create-store-in-render",
|
|
38102
39659
|
id: "no-create-store-in-render",
|
|
@@ -38155,6 +39712,17 @@ const reactDoctorRules = [
|
|
|
38155
39712
|
category: "Maintainability"
|
|
38156
39713
|
}
|
|
38157
39714
|
},
|
|
39715
|
+
{
|
|
39716
|
+
key: "react-doctor/no-deprecated-tailwind-class",
|
|
39717
|
+
id: "no-deprecated-tailwind-class",
|
|
39718
|
+
source: "react-doctor",
|
|
39719
|
+
originallyExternal: false,
|
|
39720
|
+
rule: {
|
|
39721
|
+
...noDeprecatedTailwindClass,
|
|
39722
|
+
framework: "global",
|
|
39723
|
+
category: "Maintainability"
|
|
39724
|
+
}
|
|
39725
|
+
},
|
|
38158
39726
|
{
|
|
38159
39727
|
key: "react-doctor/no-derived-state",
|
|
38160
39728
|
id: "no-derived-state",
|
|
@@ -38274,6 +39842,17 @@ const reactDoctorRules = [
|
|
|
38274
39842
|
requires: [...new Set(["react", ...noDocumentStartViewTransition.requires ?? []])]
|
|
38275
39843
|
}
|
|
38276
39844
|
},
|
|
39845
|
+
{
|
|
39846
|
+
key: "react-doctor/no-document-write",
|
|
39847
|
+
id: "no-document-write",
|
|
39848
|
+
source: "react-doctor",
|
|
39849
|
+
originallyExternal: false,
|
|
39850
|
+
rule: {
|
|
39851
|
+
...noDocumentWrite,
|
|
39852
|
+
framework: "global",
|
|
39853
|
+
category: "Performance"
|
|
39854
|
+
}
|
|
39855
|
+
},
|
|
38277
39856
|
{
|
|
38278
39857
|
key: "react-doctor/no-dynamic-import-path",
|
|
38279
39858
|
id: "no-dynamic-import-path",
|
|
@@ -38415,6 +39994,17 @@ const reactDoctorRules = [
|
|
|
38415
39994
|
category: "Performance"
|
|
38416
39995
|
}
|
|
38417
39996
|
},
|
|
39997
|
+
{
|
|
39998
|
+
key: "react-doctor/no-full-viewport-width",
|
|
39999
|
+
id: "no-full-viewport-width",
|
|
40000
|
+
source: "react-doctor",
|
|
40001
|
+
originallyExternal: false,
|
|
40002
|
+
rule: {
|
|
40003
|
+
...noFullViewportWidth,
|
|
40004
|
+
framework: "global",
|
|
40005
|
+
category: "Maintainability"
|
|
40006
|
+
}
|
|
40007
|
+
},
|
|
38418
40008
|
{
|
|
38419
40009
|
key: "react-doctor/no-generic-handler-names",
|
|
38420
40010
|
id: "no-generic-handler-names",
|
|
@@ -38471,6 +40061,18 @@ const reactDoctorRules = [
|
|
|
38471
40061
|
category: "Accessibility"
|
|
38472
40062
|
}
|
|
38473
40063
|
},
|
|
40064
|
+
{
|
|
40065
|
+
key: "react-doctor/no-img-lazy-with-high-fetchpriority",
|
|
40066
|
+
id: "no-img-lazy-with-high-fetchpriority",
|
|
40067
|
+
source: "react-doctor",
|
|
40068
|
+
originallyExternal: false,
|
|
40069
|
+
rule: {
|
|
40070
|
+
...noImgLazyWithHighFetchpriority,
|
|
40071
|
+
framework: "global",
|
|
40072
|
+
category: "Performance",
|
|
40073
|
+
requires: [...new Set(["react", ...noImgLazyWithHighFetchpriority.requires ?? []])]
|
|
40074
|
+
}
|
|
40075
|
+
},
|
|
38474
40076
|
{
|
|
38475
40077
|
key: "react-doctor/no-initialize-state",
|
|
38476
40078
|
id: "no-initialize-state",
|
|
@@ -38541,6 +40143,17 @@ const reactDoctorRules = [
|
|
|
38541
40143
|
requires: [...new Set(["react", ...noIsMounted.requires ?? []])]
|
|
38542
40144
|
}
|
|
38543
40145
|
},
|
|
40146
|
+
{
|
|
40147
|
+
key: "react-doctor/no-json-parse-stringify-clone",
|
|
40148
|
+
id: "no-json-parse-stringify-clone",
|
|
40149
|
+
source: "react-doctor",
|
|
40150
|
+
originallyExternal: false,
|
|
40151
|
+
rule: {
|
|
40152
|
+
...noJsonParseStringifyClone,
|
|
40153
|
+
framework: "global",
|
|
40154
|
+
category: "Performance"
|
|
40155
|
+
}
|
|
40156
|
+
},
|
|
38544
40157
|
{
|
|
38545
40158
|
key: "react-doctor/no-jsx-element-type",
|
|
38546
40159
|
id: "no-jsx-element-type",
|
|
@@ -38631,6 +40244,17 @@ const reactDoctorRules = [
|
|
|
38631
40244
|
category: "Performance"
|
|
38632
40245
|
}
|
|
38633
40246
|
},
|
|
40247
|
+
{
|
|
40248
|
+
key: "react-doctor/no-low-contrast-inline-style",
|
|
40249
|
+
id: "no-low-contrast-inline-style",
|
|
40250
|
+
source: "react-doctor",
|
|
40251
|
+
originallyExternal: false,
|
|
40252
|
+
rule: {
|
|
40253
|
+
...noLowContrastInlineStyle,
|
|
40254
|
+
framework: "global",
|
|
40255
|
+
category: "Accessibility"
|
|
40256
|
+
}
|
|
40257
|
+
},
|
|
38634
40258
|
{
|
|
38635
40259
|
key: "react-doctor/no-many-boolean-props",
|
|
38636
40260
|
id: "no-many-boolean-props",
|
|
@@ -38908,6 +40532,17 @@ const reactDoctorRules = [
|
|
|
38908
40532
|
category: "Maintainability"
|
|
38909
40533
|
}
|
|
38910
40534
|
},
|
|
40535
|
+
{
|
|
40536
|
+
key: "react-doctor/no-redundant-display-class",
|
|
40537
|
+
id: "no-redundant-display-class",
|
|
40538
|
+
source: "react-doctor",
|
|
40539
|
+
originallyExternal: false,
|
|
40540
|
+
rule: {
|
|
40541
|
+
...noRedundantDisplayClass,
|
|
40542
|
+
framework: "global",
|
|
40543
|
+
category: "Maintainability"
|
|
40544
|
+
}
|
|
40545
|
+
},
|
|
38911
40546
|
{
|
|
38912
40547
|
key: "react-doctor/no-redundant-roles",
|
|
38913
40548
|
id: "no-redundant-roles",
|
|
@@ -39060,6 +40695,18 @@ const reactDoctorRules = [
|
|
|
39060
40695
|
requires: [...new Set(["react", ...noStaticElementInteractions.requires ?? []])]
|
|
39061
40696
|
}
|
|
39062
40697
|
},
|
|
40698
|
+
{
|
|
40699
|
+
key: "react-doctor/no-string-false-on-boolean-attribute",
|
|
40700
|
+
id: "no-string-false-on-boolean-attribute",
|
|
40701
|
+
source: "react-doctor",
|
|
40702
|
+
originallyExternal: false,
|
|
40703
|
+
rule: {
|
|
40704
|
+
...noStringFalseOnBooleanAttribute,
|
|
40705
|
+
framework: "global",
|
|
40706
|
+
category: "Bugs",
|
|
40707
|
+
requires: [...new Set(["react", ...noStringFalseOnBooleanAttribute.requires ?? []])]
|
|
40708
|
+
}
|
|
40709
|
+
},
|
|
39063
40710
|
{
|
|
39064
40711
|
key: "react-doctor/no-string-refs",
|
|
39065
40712
|
id: "no-string-refs",
|
|
@@ -39072,6 +40719,51 @@ const reactDoctorRules = [
|
|
|
39072
40719
|
requires: [...new Set(["react", ...noStringRefs.requires ?? []])]
|
|
39073
40720
|
}
|
|
39074
40721
|
},
|
|
40722
|
+
{
|
|
40723
|
+
key: "react-doctor/no-svg-currentcolor-with-fill-class",
|
|
40724
|
+
id: "no-svg-currentcolor-with-fill-class",
|
|
40725
|
+
source: "react-doctor",
|
|
40726
|
+
originallyExternal: false,
|
|
40727
|
+
rule: {
|
|
40728
|
+
...noSvgCurrentcolorWithFillClass,
|
|
40729
|
+
framework: "global",
|
|
40730
|
+
category: "Maintainability"
|
|
40731
|
+
}
|
|
40732
|
+
},
|
|
40733
|
+
{
|
|
40734
|
+
key: "react-doctor/no-sync-xhr",
|
|
40735
|
+
id: "no-sync-xhr",
|
|
40736
|
+
source: "react-doctor",
|
|
40737
|
+
originallyExternal: false,
|
|
40738
|
+
rule: {
|
|
40739
|
+
...noSyncXhr,
|
|
40740
|
+
framework: "global",
|
|
40741
|
+
category: "Performance"
|
|
40742
|
+
}
|
|
40743
|
+
},
|
|
40744
|
+
{
|
|
40745
|
+
key: "react-doctor/no-tailwind-layout-transition",
|
|
40746
|
+
id: "no-tailwind-layout-transition",
|
|
40747
|
+
source: "react-doctor",
|
|
40748
|
+
originallyExternal: false,
|
|
40749
|
+
rule: {
|
|
40750
|
+
...noTailwindLayoutTransition,
|
|
40751
|
+
framework: "global",
|
|
40752
|
+
category: "Performance"
|
|
40753
|
+
}
|
|
40754
|
+
},
|
|
40755
|
+
{
|
|
40756
|
+
key: "react-doctor/no-target-blank-without-rel",
|
|
40757
|
+
id: "no-target-blank-without-rel",
|
|
40758
|
+
source: "react-doctor",
|
|
40759
|
+
originallyExternal: false,
|
|
40760
|
+
rule: {
|
|
40761
|
+
...noTargetBlankWithoutRel,
|
|
40762
|
+
framework: "global",
|
|
40763
|
+
category: "Accessibility",
|
|
40764
|
+
requires: [...new Set(["react", ...noTargetBlankWithoutRel.requires ?? []])]
|
|
40765
|
+
}
|
|
40766
|
+
},
|
|
39075
40767
|
{
|
|
39076
40768
|
key: "react-doctor/no-this-in-sfc",
|
|
39077
40769
|
id: "no-this-in-sfc",
|
|
@@ -39141,6 +40833,18 @@ const reactDoctorRules = [
|
|
|
39141
40833
|
requires: [...new Set(["react", ...noUnescapedEntities.requires ?? []])]
|
|
39142
40834
|
}
|
|
39143
40835
|
},
|
|
40836
|
+
{
|
|
40837
|
+
key: "react-doctor/no-uninformative-aria-label",
|
|
40838
|
+
id: "no-uninformative-aria-label",
|
|
40839
|
+
source: "react-doctor",
|
|
40840
|
+
originallyExternal: false,
|
|
40841
|
+
rule: {
|
|
40842
|
+
...noUninformativeAriaLabel,
|
|
40843
|
+
framework: "global",
|
|
40844
|
+
category: "Accessibility",
|
|
40845
|
+
requires: [...new Set(["react", ...noUninformativeAriaLabel.requires ?? []])]
|
|
40846
|
+
}
|
|
40847
|
+
},
|
|
39144
40848
|
{
|
|
39145
40849
|
key: "react-doctor/no-unknown-property",
|
|
39146
40850
|
id: "no-unknown-property",
|
|
@@ -39350,6 +41054,17 @@ const reactDoctorRules = [
|
|
|
39350
41054
|
category: "Bugs"
|
|
39351
41055
|
}
|
|
39352
41056
|
},
|
|
41057
|
+
{
|
|
41058
|
+
key: "react-doctor/prefer-dvh-over-vh",
|
|
41059
|
+
id: "prefer-dvh-over-vh",
|
|
41060
|
+
source: "react-doctor",
|
|
41061
|
+
originallyExternal: false,
|
|
41062
|
+
rule: {
|
|
41063
|
+
...preferDvhOverVh,
|
|
41064
|
+
framework: "global",
|
|
41065
|
+
category: "Maintainability"
|
|
41066
|
+
}
|
|
41067
|
+
},
|
|
39353
41068
|
{
|
|
39354
41069
|
key: "react-doctor/prefer-dynamic-import",
|
|
39355
41070
|
id: "prefer-dynamic-import",
|
|
@@ -39454,6 +41169,17 @@ const reactDoctorRules = [
|
|
|
39454
41169
|
requires: [...new Set(["react", ...preferTagOverRole.requires ?? []])]
|
|
39455
41170
|
}
|
|
39456
41171
|
},
|
|
41172
|
+
{
|
|
41173
|
+
key: "react-doctor/prefer-truncate-shorthand",
|
|
41174
|
+
id: "prefer-truncate-shorthand",
|
|
41175
|
+
source: "react-doctor",
|
|
41176
|
+
originallyExternal: false,
|
|
41177
|
+
rule: {
|
|
41178
|
+
...preferTruncateShorthand,
|
|
41179
|
+
framework: "global",
|
|
41180
|
+
category: "Maintainability"
|
|
41181
|
+
}
|
|
41182
|
+
},
|
|
39457
41183
|
{
|
|
39458
41184
|
key: "react-doctor/prefer-use-effect-event",
|
|
39459
41185
|
id: "prefer-use-effect-event",
|
|
@@ -39756,6 +41482,18 @@ const reactDoctorRules = [
|
|
|
39756
41482
|
tags: [...new Set(["security-scan", ...repositorySecretFile.tags ?? []])]
|
|
39757
41483
|
}
|
|
39758
41484
|
},
|
|
41485
|
+
{
|
|
41486
|
+
key: "react-doctor/request-body-mass-assignment",
|
|
41487
|
+
id: "request-body-mass-assignment",
|
|
41488
|
+
source: "react-doctor",
|
|
41489
|
+
originallyExternal: false,
|
|
41490
|
+
rule: {
|
|
41491
|
+
...requestBodyMassAssignment,
|
|
41492
|
+
framework: "global",
|
|
41493
|
+
category: "Security",
|
|
41494
|
+
tags: [...new Set(["security-scan", ...requestBodyMassAssignment.tags ?? []])]
|
|
41495
|
+
}
|
|
41496
|
+
},
|
|
39759
41497
|
{
|
|
39760
41498
|
key: "react-doctor/require-render-return",
|
|
39761
41499
|
id: "require-render-return",
|
|
@@ -40344,6 +42082,18 @@ const reactDoctorRules = [
|
|
|
40344
42082
|
requires: [...new Set(["react", ...scope.requires ?? []])]
|
|
40345
42083
|
}
|
|
40346
42084
|
},
|
|
42085
|
+
{
|
|
42086
|
+
key: "react-doctor/secret-in-fallback",
|
|
42087
|
+
id: "secret-in-fallback",
|
|
42088
|
+
source: "react-doctor",
|
|
42089
|
+
originallyExternal: false,
|
|
42090
|
+
rule: {
|
|
42091
|
+
...secretInFallback,
|
|
42092
|
+
framework: "global",
|
|
42093
|
+
category: "Security",
|
|
42094
|
+
tags: [...new Set(["security-scan", ...secretInFallback.tags ?? []])]
|
|
42095
|
+
}
|
|
42096
|
+
},
|
|
40347
42097
|
{
|
|
40348
42098
|
key: "react-doctor/self-closing-comp",
|
|
40349
42099
|
id: "self-closing-comp",
|
|
@@ -40500,6 +42250,18 @@ const reactDoctorRules = [
|
|
|
40500
42250
|
tags: [...new Set(["security-scan", ...supabaseRlsPolicyRisk.tags ?? []])]
|
|
40501
42251
|
}
|
|
40502
42252
|
},
|
|
42253
|
+
{
|
|
42254
|
+
key: "react-doctor/supabase-table-missing-rls",
|
|
42255
|
+
id: "supabase-table-missing-rls",
|
|
42256
|
+
source: "react-doctor",
|
|
42257
|
+
originallyExternal: false,
|
|
42258
|
+
rule: {
|
|
42259
|
+
...supabaseTableMissingRls,
|
|
42260
|
+
framework: "global",
|
|
42261
|
+
category: "Security",
|
|
42262
|
+
tags: [...new Set(["security-scan", ...supabaseTableMissingRls.tags ?? []])]
|
|
42263
|
+
}
|
|
42264
|
+
},
|
|
40503
42265
|
{
|
|
40504
42266
|
key: "react-doctor/svg-filter-clickjacking-risk",
|
|
40505
42267
|
id: "svg-filter-clickjacking-risk",
|
|
@@ -40690,6 +42452,18 @@ const reactDoctorRules = [
|
|
|
40690
42452
|
tags: [...new Set(["security-scan", ...tenantStaticProxyRisk.tags ?? []])]
|
|
40691
42453
|
}
|
|
40692
42454
|
},
|
|
42455
|
+
{
|
|
42456
|
+
key: "react-doctor/unsafe-json-in-html",
|
|
42457
|
+
id: "unsafe-json-in-html",
|
|
42458
|
+
source: "react-doctor",
|
|
42459
|
+
originallyExternal: false,
|
|
42460
|
+
rule: {
|
|
42461
|
+
...unsafeJsonInHtml,
|
|
42462
|
+
framework: "global",
|
|
42463
|
+
category: "Security",
|
|
42464
|
+
tags: [...new Set(["security-scan", ...unsafeJsonInHtml.tags ?? []])]
|
|
42465
|
+
}
|
|
42466
|
+
},
|
|
40693
42467
|
{
|
|
40694
42468
|
key: "react-doctor/untrusted-redirect-following",
|
|
40695
42469
|
id: "untrusted-redirect-following",
|