oxlint-plugin-react-doctor 0.5.8-dev.ea00b1b → 0.5.8-dev.f028d8b
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/LICENSE +14 -1
- package/dist/index.js +321 -126
- package/package.json +5 -4
package/LICENSE
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
MIT License
|
|
1
|
+
Modified MIT License
|
|
2
2
|
|
|
3
3
|
Copyright (c) 2026 Million Software, Inc.
|
|
4
4
|
|
|
@@ -19,3 +19,16 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
|
19
19
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
20
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
21
|
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
Our only modification is that the following uses require prior written
|
|
24
|
+
permission from the copyright holder. To request permission, contact
|
|
25
|
+
founders@million.dev.
|
|
26
|
+
|
|
27
|
+
1. Using the Software, its source code, or any derivative works thereof, in
|
|
28
|
+
whole or in part, as training, fine-tuning, or evaluation data, or as input
|
|
29
|
+
to any automated pipeline for training or improving any machine learning
|
|
30
|
+
model or AI system.
|
|
31
|
+
|
|
32
|
+
2. Selling the Software, or offering it to third parties as a paid, hosted, or
|
|
33
|
+
managed product or service (including any commercial API or SaaS offering)
|
|
34
|
+
whose value derives entirely or substantially from the Software.
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,48 @@ import * as fs from "node:fs";
|
|
|
3
3
|
import { parseSync } from "oxc-parser";
|
|
4
4
|
import { analyze } from "eslint-scope";
|
|
5
5
|
import * as eslintVisitorKeys from "eslint-visitor-keys";
|
|
6
|
+
//#region src/plugin/utils/has-type-property.ts
|
|
7
|
+
const hasTypeProperty = (value) => Boolean(value && typeof value === "object" && "type" in value);
|
|
8
|
+
//#endregion
|
|
9
|
+
//#region src/plugin/utils/is-node-of-type.ts
|
|
10
|
+
const isNodeOfType = (node, type) => Boolean(hasTypeProperty(node) && node.type === type);
|
|
11
|
+
//#endregion
|
|
12
|
+
//#region src/plugin/utils/non-react-jsx-dialect.ts
|
|
13
|
+
const NON_REACT_JSX_DIALECT_PACKAGES = new Set([
|
|
14
|
+
"solid-js",
|
|
15
|
+
"solid-js/web",
|
|
16
|
+
"solid-js/store",
|
|
17
|
+
"solid-js/h",
|
|
18
|
+
"solid-js/html",
|
|
19
|
+
"@builder.io/qwik",
|
|
20
|
+
"@builder.io/qwik-city",
|
|
21
|
+
"@builder.io/qwik-react",
|
|
22
|
+
"voby",
|
|
23
|
+
"vidode"
|
|
24
|
+
]);
|
|
25
|
+
const NON_REACT_JSX_DIALECT_PACKAGE_PREFIXES = ["solid-js", "@builder.io/qwik"];
|
|
26
|
+
const startsWithAny = (source, prefixes) => prefixes.some((prefix) => source === prefix || source.startsWith(`${prefix}/`));
|
|
27
|
+
const fileImportsNonReactJsxDialect = (program) => {
|
|
28
|
+
for (const statement of program.body) {
|
|
29
|
+
if (!isNodeOfType(statement, "ImportDeclaration")) continue;
|
|
30
|
+
const source = statement.source;
|
|
31
|
+
const value = source && typeof source.value === "string" ? source.value : null;
|
|
32
|
+
if (!value) continue;
|
|
33
|
+
if (NON_REACT_JSX_DIALECT_PACKAGES.has(value)) return true;
|
|
34
|
+
if (startsWithAny(value, NON_REACT_JSX_DIALECT_PACKAGE_PREFIXES)) return true;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
};
|
|
38
|
+
const jsxAttributeIsNonReactDialectMarker = (openingNode) => {
|
|
39
|
+
for (const attribute of openingNode.attributes) {
|
|
40
|
+
if (!isNodeOfType(attribute, "JSXAttribute")) continue;
|
|
41
|
+
if (!isNodeOfType(attribute.name, "JSXIdentifier")) continue;
|
|
42
|
+
if (attribute.name.name === "classList") return true;
|
|
43
|
+
if (attribute.name.name.startsWith("class:") || attribute.name.name.startsWith("bind:")) return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
};
|
|
47
|
+
//#endregion
|
|
6
48
|
//#region src/plugin/utils/is-testlike-filename.ts
|
|
7
49
|
const NON_PRODUCTION_PATH_SEGMENTS = [
|
|
8
50
|
"/test/",
|
|
@@ -180,53 +222,10 @@ const isTestlikeFilename = (rawFilename) => {
|
|
|
180
222
|
return false;
|
|
181
223
|
};
|
|
182
224
|
//#endregion
|
|
183
|
-
//#region src/plugin/utils/
|
|
184
|
-
const
|
|
185
|
-
//#endregion
|
|
186
|
-
//#region src/plugin/utils/is-node-of-type.ts
|
|
187
|
-
const isNodeOfType = (node, type) => Boolean(hasTypeProperty(node) && node.type === type);
|
|
188
|
-
//#endregion
|
|
189
|
-
//#region src/plugin/utils/non-react-jsx-dialect.ts
|
|
190
|
-
const NON_REACT_JSX_DIALECT_PACKAGES = new Set([
|
|
191
|
-
"solid-js",
|
|
192
|
-
"solid-js/web",
|
|
193
|
-
"solid-js/store",
|
|
194
|
-
"solid-js/h",
|
|
195
|
-
"solid-js/html",
|
|
196
|
-
"@builder.io/qwik",
|
|
197
|
-
"@builder.io/qwik-city",
|
|
198
|
-
"@builder.io/qwik-react",
|
|
199
|
-
"voby",
|
|
200
|
-
"vidode"
|
|
201
|
-
]);
|
|
202
|
-
const NON_REACT_JSX_DIALECT_PACKAGE_PREFIXES = ["solid-js", "@builder.io/qwik"];
|
|
203
|
-
const startsWithAny = (source, prefixes) => prefixes.some((prefix) => source === prefix || source.startsWith(`${prefix}/`));
|
|
204
|
-
const fileImportsNonReactJsxDialect = (program) => {
|
|
205
|
-
for (const statement of program.body) {
|
|
206
|
-
if (!isNodeOfType(statement, "ImportDeclaration")) continue;
|
|
207
|
-
const source = statement.source;
|
|
208
|
-
const value = source && typeof source.value === "string" ? source.value : null;
|
|
209
|
-
if (!value) continue;
|
|
210
|
-
if (NON_REACT_JSX_DIALECT_PACKAGES.has(value)) return true;
|
|
211
|
-
if (startsWithAny(value, NON_REACT_JSX_DIALECT_PACKAGE_PREFIXES)) return true;
|
|
212
|
-
}
|
|
213
|
-
return false;
|
|
214
|
-
};
|
|
215
|
-
const jsxAttributeIsNonReactDialectMarker = (openingNode) => {
|
|
216
|
-
for (const attribute of openingNode.attributes) {
|
|
217
|
-
if (!isNodeOfType(attribute, "JSXAttribute")) continue;
|
|
218
|
-
if (!isNodeOfType(attribute.name, "JSXIdentifier")) continue;
|
|
219
|
-
if (attribute.name.name === "classList") return true;
|
|
220
|
-
if (attribute.name.name.startsWith("class:") || attribute.name.name.startsWith("bind:")) return true;
|
|
221
|
-
}
|
|
222
|
-
return false;
|
|
223
|
-
};
|
|
225
|
+
//#region src/plugin/utils/skip-non-production-files.ts
|
|
226
|
+
const skipNonProductionFiles = (create) => (context) => isTestlikeFilename(context.filename) ? {} : create(context);
|
|
224
227
|
//#endregion
|
|
225
228
|
//#region src/plugin/utils/define-rule.ts
|
|
226
|
-
const wrapCreateForTestNoise = (create) => ((context) => {
|
|
227
|
-
if (isTestlikeFilename(context.filename)) return {};
|
|
228
|
-
return create(context);
|
|
229
|
-
});
|
|
230
229
|
const VISITOR_NODE_NAME_PATTERN = /^[A-Z]/;
|
|
231
230
|
const wrapCreateForReactJsxOnly = (create) => ((context) => {
|
|
232
231
|
const innerVisitors = create(context);
|
|
@@ -273,7 +272,7 @@ const defineRule = (rule) => {
|
|
|
273
272
|
};
|
|
274
273
|
const tags = rule.tags;
|
|
275
274
|
let wrappedCreate = rule.create;
|
|
276
|
-
if (tags?.includes("test-noise") && !tags?.includes("migration-hint")) wrappedCreate =
|
|
275
|
+
if (tags?.includes("test-noise") && !tags?.includes("migration-hint")) wrappedCreate = skipNonProductionFiles(wrappedCreate);
|
|
277
276
|
if (tags?.includes("react-jsx-only")) wrappedCreate = wrapCreateForReactJsxOnly(wrappedCreate);
|
|
278
277
|
if (wrappedCreate === rule.create) return rule;
|
|
279
278
|
return {
|
|
@@ -1176,6 +1175,20 @@ const getImportSourceForName = (contextNode, localIdentifierName) => {
|
|
|
1176
1175
|
if (!lookup) return null;
|
|
1177
1176
|
return lookup.get(localIdentifierName)?.source ?? null;
|
|
1178
1177
|
};
|
|
1178
|
+
const getImportBindingForName = (contextNode, localIdentifierName) => {
|
|
1179
|
+
const info = getImportLookup(contextNode)?.get(localIdentifierName);
|
|
1180
|
+
if (!info) return null;
|
|
1181
|
+
if (info.isNamespace) return {
|
|
1182
|
+
source: info.source,
|
|
1183
|
+
exportedName: null,
|
|
1184
|
+
isNamespace: true
|
|
1185
|
+
};
|
|
1186
|
+
return {
|
|
1187
|
+
source: info.source,
|
|
1188
|
+
exportedName: info.isDefault ? "default" : info.imported,
|
|
1189
|
+
isNamespace: false
|
|
1190
|
+
};
|
|
1191
|
+
};
|
|
1179
1192
|
//#endregion
|
|
1180
1193
|
//#region src/plugin/utils/find-variable-initializer.ts
|
|
1181
1194
|
const FUNCTION_LIKE_TYPES$1 = new Set([
|
|
@@ -1414,6 +1427,14 @@ const NEXTJS_NAVIGATION_FUNCTIONS = new Set([
|
|
|
1414
1427
|
"forbidden",
|
|
1415
1428
|
"unauthorized"
|
|
1416
1429
|
]);
|
|
1430
|
+
const CACHE_REVALIDATION_FUNCTION_NAMES = new Set([
|
|
1431
|
+
"revalidateTag",
|
|
1432
|
+
"revalidatePath",
|
|
1433
|
+
"expireTag",
|
|
1434
|
+
"expirePath",
|
|
1435
|
+
"unstable_expireTag",
|
|
1436
|
+
"unstable_expirePath"
|
|
1437
|
+
]);
|
|
1417
1438
|
const GOOGLE_FONTS_PATTERN = /fonts\.googleapis\.com/;
|
|
1418
1439
|
const POLYFILL_SCRIPT_PATTERN = /polyfill\.io|polyfill\.min\.js|cdn\.polyfill/;
|
|
1419
1440
|
const APP_DIRECTORY_PATTERN = /\/app\//;
|
|
@@ -4349,7 +4370,7 @@ const authTokenInWebStorage = defineRule({
|
|
|
4349
4370
|
title: "Auth token in web storage",
|
|
4350
4371
|
severity: "warn",
|
|
4351
4372
|
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.",
|
|
4352
|
-
create: (context) => ({
|
|
4373
|
+
create: skipNonProductionFiles((context) => ({
|
|
4353
4374
|
CallExpression(node) {
|
|
4354
4375
|
const callee = node.callee;
|
|
4355
4376
|
if (!isNodeOfType(callee, "MemberExpression") || callee.computed) return;
|
|
@@ -4374,7 +4395,7 @@ const authTokenInWebStorage = defineRule({
|
|
|
4374
4395
|
message: MESSAGE$57
|
|
4375
4396
|
});
|
|
4376
4397
|
}
|
|
4377
|
-
})
|
|
4398
|
+
}))
|
|
4378
4399
|
});
|
|
4379
4400
|
//#endregion
|
|
4380
4401
|
//#region src/plugin/rules/a11y/autocomplete-valid.ts
|
|
@@ -6721,7 +6742,7 @@ const TRANSPARENT_WRAPPER_TYPES = new Set([
|
|
|
6721
6742
|
"ParenthesizedExpression",
|
|
6722
6743
|
"ChainExpression"
|
|
6723
6744
|
]);
|
|
6724
|
-
const unwrapExpression$
|
|
6745
|
+
const unwrapExpression$2 = (node) => {
|
|
6725
6746
|
let current = node;
|
|
6726
6747
|
while (TRANSPARENT_WRAPPER_TYPES.has(current.type)) {
|
|
6727
6748
|
const inner = current.expression;
|
|
@@ -6828,7 +6849,7 @@ const symbolHasStableHookOrigin = (symbol) => {
|
|
|
6828
6849
|
if (!declarator || !isNodeOfType(declarator, "VariableDeclarator")) return false;
|
|
6829
6850
|
const initializerRaw = declarator.init;
|
|
6830
6851
|
if (!initializerRaw) return false;
|
|
6831
|
-
const initializer = unwrapExpression$
|
|
6852
|
+
const initializer = unwrapExpression$2(initializerRaw);
|
|
6832
6853
|
if (symbol.kind === "const") {
|
|
6833
6854
|
if (isNodeOfType(initializer, "Literal") && (initializer.value === null || typeof initializer.value === "number" || typeof initializer.value === "string" || typeof initializer.value === "boolean")) return true;
|
|
6834
6855
|
if (isNodeOfType(initializer, "TemplateLiteral") && getStaticTemplateLiteralValue(initializer) !== null) return true;
|
|
@@ -6848,13 +6869,13 @@ const symbolHasStableHookOrigin = (symbol) => {
|
|
|
6848
6869
|
return false;
|
|
6849
6870
|
};
|
|
6850
6871
|
const symbolHasUseEffectEventOrigin = (symbol) => {
|
|
6851
|
-
const initializer = symbol.initializer ? unwrapExpression$
|
|
6872
|
+
const initializer = symbol.initializer ? unwrapExpression$2(symbol.initializer) : null;
|
|
6852
6873
|
if (!initializer || !isNodeOfType(initializer, "CallExpression")) return false;
|
|
6853
6874
|
return getHookName(initializer.callee) === "useEffectEvent";
|
|
6854
6875
|
};
|
|
6855
6876
|
const getFunctionValueNode = (symbol) => {
|
|
6856
6877
|
if (symbol.kind === "function" && isNodeOfType(symbol.declarationNode, "FunctionDeclaration")) return symbol.declarationNode;
|
|
6857
|
-
const initializer = symbol.initializer ? unwrapExpression$
|
|
6878
|
+
const initializer = symbol.initializer ? unwrapExpression$2(symbol.initializer) : null;
|
|
6858
6879
|
if (initializer && (isNodeOfType(initializer, "FunctionExpression") || isNodeOfType(initializer, "ArrowFunctionExpression"))) return initializer;
|
|
6859
6880
|
return null;
|
|
6860
6881
|
};
|
|
@@ -6990,23 +7011,23 @@ const computeDepKey = (reference) => {
|
|
|
6990
7011
|
return fullName;
|
|
6991
7012
|
};
|
|
6992
7013
|
const computeDeclaredDepKey = (entry) => {
|
|
6993
|
-
const stripped = unwrapExpression$
|
|
7014
|
+
const stripped = unwrapExpression$2(entry);
|
|
6994
7015
|
if (isNodeOfType(stripped, "Identifier")) return stripped.name;
|
|
6995
7016
|
if (isNodeOfType(stripped, "MemberExpression")) return stringifyMemberChain(stripped);
|
|
6996
7017
|
return null;
|
|
6997
7018
|
};
|
|
6998
7019
|
const depsArrayContainsIdentifier = (depsArgument, identifierName) => {
|
|
6999
7020
|
if (!depsArgument) return false;
|
|
7000
|
-
const strippedDepsArgument = unwrapExpression$
|
|
7021
|
+
const strippedDepsArgument = unwrapExpression$2(depsArgument);
|
|
7001
7022
|
if (!isNodeOfType(strippedDepsArgument, "ArrayExpression")) return false;
|
|
7002
7023
|
return strippedDepsArgument.elements.some((element) => {
|
|
7003
7024
|
if (!element) return false;
|
|
7004
|
-
const strippedElement = unwrapExpression$
|
|
7025
|
+
const strippedElement = unwrapExpression$2(element);
|
|
7005
7026
|
return isNodeOfType(strippedElement, "Identifier") && strippedElement.name === identifierName;
|
|
7006
7027
|
});
|
|
7007
7028
|
};
|
|
7008
7029
|
const stringifyMemberChain = (node) => {
|
|
7009
|
-
const stripped = unwrapExpression$
|
|
7030
|
+
const stripped = unwrapExpression$2(node);
|
|
7010
7031
|
if (isNodeOfType(stripped, "Identifier")) return stripped.name;
|
|
7011
7032
|
if (isNodeOfType(stripped, "ThisExpression")) return "this";
|
|
7012
7033
|
if (isNodeOfType(stripped, "MemberExpression")) {
|
|
@@ -7048,13 +7069,13 @@ const hasBroaderDeclaredDependency = (declaredKey, declaredKeys) => {
|
|
|
7048
7069
|
return false;
|
|
7049
7070
|
};
|
|
7050
7071
|
const getMemberRootIdentifier = (node) => {
|
|
7051
|
-
const stripped = unwrapExpression$
|
|
7072
|
+
const stripped = unwrapExpression$2(node);
|
|
7052
7073
|
if (isNodeOfType(stripped, "Identifier")) return stripped;
|
|
7053
7074
|
if (isNodeOfType(stripped, "MemberExpression")) return getMemberRootIdentifier(stripped.object);
|
|
7054
7075
|
return null;
|
|
7055
7076
|
};
|
|
7056
7077
|
const hasComputedMemberExpression = (node) => {
|
|
7057
|
-
const stripped = unwrapExpression$
|
|
7078
|
+
const stripped = unwrapExpression$2(node);
|
|
7058
7079
|
if (!isNodeOfType(stripped, "MemberExpression")) return false;
|
|
7059
7080
|
if (stripped.computed) return true;
|
|
7060
7081
|
return hasComputedMemberExpression(stripped.object);
|
|
@@ -7075,7 +7096,7 @@ const isRegExpLiteral = (node) => {
|
|
|
7075
7096
|
};
|
|
7076
7097
|
const isUnstableInitializer = (node) => {
|
|
7077
7098
|
if (!node) return false;
|
|
7078
|
-
const stripped = unwrapExpression$
|
|
7099
|
+
const stripped = unwrapExpression$2(node);
|
|
7079
7100
|
if (isRegExpLiteral(stripped)) return true;
|
|
7080
7101
|
if (isNodeOfType(stripped, "ConditionalExpression")) return isUnstableInitializer(stripped.consequent) || isUnstableInitializer(stripped.alternate);
|
|
7081
7102
|
if (isNodeOfType(stripped, "LogicalExpression")) return isUnstableInitializer(stripped.left) || isUnstableInitializer(stripped.right);
|
|
@@ -7231,7 +7252,7 @@ const hasMemberCallForRoot = (node, rootName) => {
|
|
|
7231
7252
|
const visit = (current) => {
|
|
7232
7253
|
if (didFindMemberCall) return;
|
|
7233
7254
|
if (isNodeOfType(current, "CallExpression")) {
|
|
7234
|
-
if (getMemberRootIdentifier(unwrapExpression$
|
|
7255
|
+
if (getMemberRootIdentifier(unwrapExpression$2(current.callee))?.name === rootName) {
|
|
7235
7256
|
didFindMemberCall = true;
|
|
7236
7257
|
return;
|
|
7237
7258
|
}
|
|
@@ -7294,7 +7315,7 @@ If the missing value is recreated every render, move it inside the hook or stabi
|
|
|
7294
7315
|
return;
|
|
7295
7316
|
}
|
|
7296
7317
|
const depsArgumentRaw = node.arguments[depsArgumentIndex];
|
|
7297
|
-
const callbackExpression = unwrapExpression$
|
|
7318
|
+
const callbackExpression = unwrapExpression$2(callbackArgument);
|
|
7298
7319
|
let callbackToAnalyze = null;
|
|
7299
7320
|
const forcedCaptureKeys = /* @__PURE__ */ new Set();
|
|
7300
7321
|
if (isNodeOfType(callbackExpression, "ArrowFunctionExpression") || isNodeOfType(callbackExpression, "FunctionExpression")) callbackToAnalyze = callbackExpression;
|
|
@@ -7302,7 +7323,7 @@ If the missing value is recreated every render, move it inside the hook or stabi
|
|
|
7302
7323
|
const callbackSymbol = context.scopes.symbolFor(callbackExpression);
|
|
7303
7324
|
const functionValueNode = callbackSymbol ? getFunctionValueNode(callbackSymbol) : null;
|
|
7304
7325
|
if (functionValueNode) callbackToAnalyze = functionValueNode;
|
|
7305
|
-
else if (callbackSymbol?.initializer && isNodeOfType(unwrapExpression$
|
|
7326
|
+
else if (callbackSymbol?.initializer && isNodeOfType(unwrapExpression$2(callbackSymbol.initializer), "CallExpression")) forcedCaptureKeys.add(callbackExpression.name);
|
|
7306
7327
|
else if (depsArgumentRaw) {
|
|
7307
7328
|
if (depsArrayContainsIdentifier(depsArgumentRaw, callbackExpression.name)) return;
|
|
7308
7329
|
context.report({
|
|
@@ -7359,7 +7380,7 @@ If the missing value is recreated every render, move it inside the hook or stabi
|
|
|
7359
7380
|
});
|
|
7360
7381
|
return;
|
|
7361
7382
|
}
|
|
7362
|
-
const depsArgument = unwrapExpression$
|
|
7383
|
+
const depsArgument = unwrapExpression$2(depsArgumentRaw);
|
|
7363
7384
|
if (isNodeOfType(depsArgument, "Literal") && depsArgument.value === null || isNodeOfType(depsArgument, "Identifier") && depsArgument.name === "undefined") {
|
|
7364
7385
|
if (isAutoDependenciesHook(hookName)) return;
|
|
7365
7386
|
if (HOOKS_REQUIRING_DEPS_ARRAY.has(hookName)) {
|
|
@@ -7400,11 +7421,11 @@ If the missing value is recreated every render, move it inside the hook or stabi
|
|
|
7400
7421
|
for (const forcedCaptureKey of forcedCaptureKeys) captureKeys.add(forcedCaptureKey);
|
|
7401
7422
|
const hasLiteralDepElement = depsArgument.elements.some((element) => {
|
|
7402
7423
|
if (!element) return false;
|
|
7403
|
-
return isLiteralOrEmptyTemplate(unwrapExpression$
|
|
7424
|
+
return isLiteralOrEmptyTemplate(unwrapExpression$2(element));
|
|
7404
7425
|
});
|
|
7405
7426
|
const hasNonStringLiteralDep = depsArgument.elements.some((element) => {
|
|
7406
7427
|
if (!element) return false;
|
|
7407
|
-
return isNonStringLiteral(unwrapExpression$
|
|
7428
|
+
return isNonStringLiteral(unwrapExpression$2(element));
|
|
7408
7429
|
});
|
|
7409
7430
|
if (hasNonStringLiteralDep) context.report({
|
|
7410
7431
|
node: depsArgument,
|
|
@@ -7425,7 +7446,7 @@ If the missing value is recreated every render, move it inside the hook or stabi
|
|
|
7425
7446
|
});
|
|
7426
7447
|
continue;
|
|
7427
7448
|
}
|
|
7428
|
-
const stripped = unwrapExpression$
|
|
7449
|
+
const stripped = unwrapExpression$2(elementNode);
|
|
7429
7450
|
if (isLiteralOrEmptyTemplate(stripped)) continue;
|
|
7430
7451
|
if (isNodeOfType(stripped, "Identifier")) {
|
|
7431
7452
|
const depSymbol = context.scopes.symbolFor(stripped);
|
|
@@ -10789,10 +10810,22 @@ const isWithinChildrenToArray = (jsxNode) => {
|
|
|
10789
10810
|
}
|
|
10790
10811
|
return false;
|
|
10791
10812
|
};
|
|
10813
|
+
const spreadCanOverwriteKey = (spreadAttribute) => {
|
|
10814
|
+
const argument = spreadAttribute.argument;
|
|
10815
|
+
if (!isNodeOfType(argument, "ObjectExpression")) return true;
|
|
10816
|
+
for (const property of argument.properties) {
|
|
10817
|
+
if (!isNodeOfType(property, "Property")) return true;
|
|
10818
|
+
if (property.computed) return true;
|
|
10819
|
+
const propertyKey = property.key;
|
|
10820
|
+
if (isNodeOfType(propertyKey, "Identifier") && propertyKey.name === "key") return true;
|
|
10821
|
+
if (isNodeOfType(propertyKey, "Literal") && String(propertyKey.value) === "key") return true;
|
|
10822
|
+
}
|
|
10823
|
+
return false;
|
|
10824
|
+
};
|
|
10792
10825
|
const checkKeyBeforeSpread = (context, openingElement) => {
|
|
10793
10826
|
let keyIndex = null;
|
|
10794
10827
|
let keyAttribute = null;
|
|
10795
|
-
let
|
|
10828
|
+
let lastOverwritingSpreadIndex = null;
|
|
10796
10829
|
for (let attributeIndex = 0; attributeIndex < openingElement.attributes.length; attributeIndex++) {
|
|
10797
10830
|
const attribute = openingElement.attributes[attributeIndex];
|
|
10798
10831
|
if (isNodeOfType(attribute, "JSXAttribute")) {
|
|
@@ -10800,11 +10833,9 @@ const checkKeyBeforeSpread = (context, openingElement) => {
|
|
|
10800
10833
|
keyIndex = attributeIndex;
|
|
10801
10834
|
keyAttribute = attribute;
|
|
10802
10835
|
}
|
|
10803
|
-
} else if (isNodeOfType(attribute, "JSXSpreadAttribute"))
|
|
10804
|
-
if (firstSpreadIndex === null) firstSpreadIndex = attributeIndex;
|
|
10805
|
-
}
|
|
10836
|
+
} else if (isNodeOfType(attribute, "JSXSpreadAttribute") && spreadCanOverwriteKey(attribute)) lastOverwritingSpreadIndex = attributeIndex;
|
|
10806
10837
|
}
|
|
10807
|
-
if (keyIndex !== null &&
|
|
10838
|
+
if (keyIndex !== null && lastOverwritingSpreadIndex !== null && lastOverwritingSpreadIndex > keyIndex && keyAttribute) context.report({
|
|
10808
10839
|
node: keyAttribute,
|
|
10809
10840
|
message: KEY_BEFORE_SPREAD
|
|
10810
10841
|
});
|
|
@@ -18936,7 +18967,7 @@ const noEval = defineRule({
|
|
|
18936
18967
|
title: "eval() runs untrusted code strings",
|
|
18937
18968
|
severity: "error",
|
|
18938
18969
|
recommendation: "Use `JSON.parse` for data, or rewrite the code so it doesn't build and run code from strings.",
|
|
18939
|
-
create: (context) => ({
|
|
18970
|
+
create: skipNonProductionFiles((context) => ({
|
|
18940
18971
|
CallExpression(node) {
|
|
18941
18972
|
if (isNodeOfType(node.callee, "Identifier") && node.callee.name === "eval") {
|
|
18942
18973
|
context.report({
|
|
@@ -18956,7 +18987,7 @@ const noEval = defineRule({
|
|
|
18956
18987
|
message: "new Function() is a code-injection vulnerability: it builds & runs code from a string."
|
|
18957
18988
|
});
|
|
18958
18989
|
}
|
|
18959
|
-
})
|
|
18990
|
+
}))
|
|
18960
18991
|
});
|
|
18961
18992
|
//#endregion
|
|
18962
18993
|
//#region src/plugin/rules/state-and-effects/no-event-handler.ts
|
|
@@ -27010,7 +27041,7 @@ const preferHtmlDialog = defineRule({
|
|
|
27010
27041
|
});
|
|
27011
27042
|
//#endregion
|
|
27012
27043
|
//#region src/plugin/utils/function-returns-object-literal.ts
|
|
27013
|
-
const unwrapExpression = (node) => {
|
|
27044
|
+
const unwrapExpression$1 = (node) => {
|
|
27014
27045
|
let current = node;
|
|
27015
27046
|
for (;;) {
|
|
27016
27047
|
if ((current.type === "TSAsExpression" || current.type === "TSSatisfiesExpression" || current.type === "TSNonNullExpression") && "expression" in current && isAstNode(current.expression)) {
|
|
@@ -27023,7 +27054,7 @@ const unwrapExpression = (node) => {
|
|
|
27023
27054
|
const doesFunctionReturnsObjectLiteral = (functionNode) => {
|
|
27024
27055
|
if (functionNode.type === "ArrowFunctionExpression" && "body" in functionNode) {
|
|
27025
27056
|
const body = functionNode.body;
|
|
27026
|
-
if (body && body.type !== "BlockStatement") return unwrapExpression(body).type === "ObjectExpression";
|
|
27057
|
+
if (body && body.type !== "BlockStatement") return unwrapExpression$1(body).type === "ObjectExpression";
|
|
27027
27058
|
}
|
|
27028
27059
|
const body = functionNode.body;
|
|
27029
27060
|
if (!body || body.type !== "BlockStatement") return false;
|
|
@@ -27031,7 +27062,7 @@ const doesFunctionReturnsObjectLiteral = (functionNode) => {
|
|
|
27031
27062
|
const visit = (node) => {
|
|
27032
27063
|
if (returnsObject) return;
|
|
27033
27064
|
if (node.type === "ReturnStatement" && "argument" in node && node.argument != null) {
|
|
27034
|
-
if (unwrapExpression(node.argument).type === "ObjectExpression") returnsObject = true;
|
|
27065
|
+
if (unwrapExpression$1(node.argument).type === "ObjectExpression") returnsObject = true;
|
|
27035
27066
|
return;
|
|
27036
27067
|
}
|
|
27037
27068
|
const nodeRecord = node;
|
|
@@ -29463,6 +29494,19 @@ const REACT_NATIVE_TEXT_COMPONENTS = new Set([
|
|
|
29463
29494
|
"H5",
|
|
29464
29495
|
"H6"
|
|
29465
29496
|
]);
|
|
29497
|
+
const REACT_NATIVE_RAW_TEXT_HOST_COMPONENTS = new Set([
|
|
29498
|
+
"View",
|
|
29499
|
+
"ScrollView",
|
|
29500
|
+
"SafeAreaView",
|
|
29501
|
+
"KeyboardAvoidingView",
|
|
29502
|
+
"ImageBackground",
|
|
29503
|
+
"Modal",
|
|
29504
|
+
"Pressable",
|
|
29505
|
+
"TouchableOpacity",
|
|
29506
|
+
"TouchableHighlight",
|
|
29507
|
+
"TouchableWithoutFeedback",
|
|
29508
|
+
"TouchableNativeFeedback"
|
|
29509
|
+
]);
|
|
29466
29510
|
const REACT_NATIVE_TEXT_COMPONENT_KEYWORDS = new Set([
|
|
29467
29511
|
"Text",
|
|
29468
29512
|
"Title",
|
|
@@ -29475,7 +29519,11 @@ const REACT_NATIVE_TEXT_COMPONENT_KEYWORDS = new Set([
|
|
|
29475
29519
|
"Description",
|
|
29476
29520
|
"Body"
|
|
29477
29521
|
]);
|
|
29478
|
-
const REACT_NATIVE_TEXT_TRANSPARENT_COMPONENTS = new Set([
|
|
29522
|
+
const REACT_NATIVE_TEXT_TRANSPARENT_COMPONENTS = new Set([
|
|
29523
|
+
"Fragment",
|
|
29524
|
+
"fbt",
|
|
29525
|
+
"fbs"
|
|
29526
|
+
]);
|
|
29479
29527
|
const DEPRECATED_RN_MODULE_REPLACEMENTS = new Map([
|
|
29480
29528
|
["AsyncStorage", "@react-native-async-storage/async-storage"],
|
|
29481
29529
|
["Picker", "@react-native-picker/picker"],
|
|
@@ -30433,23 +30481,29 @@ const jsxRootForwardsChildrenIntoText = (jsxRoot, bindings, isTextHandlingElemen
|
|
|
30433
30481
|
return didForwardIntoText;
|
|
30434
30482
|
};
|
|
30435
30483
|
const isMeaningfulJsxChild = (child) => !isNodeOfType(child, "JSXText") || Boolean(child.value?.trim());
|
|
30436
|
-
const
|
|
30437
|
-
let
|
|
30484
|
+
const jsxRootForwardsChildren = (jsxRoot, bindings, isTextHandlingElement, countsAsForwardTarget) => {
|
|
30485
|
+
let didForward = false;
|
|
30438
30486
|
walkAst(jsxRoot, (node) => {
|
|
30439
|
-
if (
|
|
30487
|
+
if (didForward || isFunctionNode(node)) return false;
|
|
30440
30488
|
if (!isNodeOfType(node, "JSXElement") && !isNodeOfType(node, "JSXFragment")) return;
|
|
30441
30489
|
if (isNodeOfType(node, "JSXElement")) {
|
|
30442
30490
|
const elementName = resolveJsxElementName(node.openingElement);
|
|
30443
30491
|
if (elementName && isTextHandlingElement(elementName)) return false;
|
|
30444
|
-
if (!(node.children ?? []).some(isMeaningfulJsxChild) && (node.openingElement.attributes ?? []).some((attribute) => isChildrenForwardingAttribute(attribute, bindings))) {
|
|
30445
|
-
|
|
30492
|
+
if (!(node.children ?? []).some(isMeaningfulJsxChild) && countsAsForwardTarget(node) && (node.openingElement.attributes ?? []).some((attribute) => isChildrenForwardingAttribute(attribute, bindings))) {
|
|
30493
|
+
didForward = true;
|
|
30446
30494
|
return;
|
|
30447
30495
|
}
|
|
30448
30496
|
}
|
|
30449
|
-
|
|
30497
|
+
if (countsAsForwardTarget(node) && (node.children ?? []).some((child) => isChildrenForwardingJsxChild(child, bindings))) didForward = true;
|
|
30450
30498
|
});
|
|
30451
|
-
return
|
|
30499
|
+
return didForward;
|
|
30452
30500
|
};
|
|
30501
|
+
const jsxRootRendersChildrenOutsideText = (jsxRoot, bindings, isTextHandlingElement) => jsxRootForwardsChildren(jsxRoot, bindings, isTextHandlingElement, () => true);
|
|
30502
|
+
const jsxRootRendersChildrenIntoNonTextHost = (jsxRoot, bindings, isTextHandlingElement, isNonTextHostElement) => jsxRootForwardsChildren(jsxRoot, bindings, isTextHandlingElement, (node) => {
|
|
30503
|
+
if (!isNodeOfType(node, "JSXElement")) return false;
|
|
30504
|
+
const elementName = resolveJsxElementName(node.openingElement);
|
|
30505
|
+
return elementName !== null && isNonTextHostElement(elementName);
|
|
30506
|
+
});
|
|
30453
30507
|
const resolveStyledFactoryBaseName = (definitionNode) => {
|
|
30454
30508
|
let current = stripParenExpression(definitionNode);
|
|
30455
30509
|
while (current) {
|
|
@@ -30486,49 +30540,71 @@ const resolveClassRenderFunction = (classNode) => {
|
|
|
30486
30540
|
}
|
|
30487
30541
|
return null;
|
|
30488
30542
|
};
|
|
30489
|
-
const
|
|
30490
|
-
if (!componentName || !isReactComponentName(componentName)) return;
|
|
30491
|
-
if (wrappers.has(componentName)) return;
|
|
30492
|
-
if (!definitionNode) return;
|
|
30543
|
+
const classifyChildrenForwarding = (definitionNode, isTextHandlingElement, isNonTextHostElement) => {
|
|
30493
30544
|
const unwrapped = unwrapComponentDefinition(definitionNode);
|
|
30494
30545
|
const styledBaseName = resolveStyledFactoryBaseName(unwrapped);
|
|
30495
|
-
if (styledBaseName
|
|
30496
|
-
|
|
30497
|
-
return;
|
|
30546
|
+
if (styledBaseName) {
|
|
30547
|
+
if (isTextHandlingElement(styledBaseName)) return "text";
|
|
30548
|
+
if (isNonTextHostElement(styledBaseName)) return "nonText";
|
|
30549
|
+
return "unknown";
|
|
30498
30550
|
}
|
|
30499
30551
|
const functionNode = resolveClassRenderFunction(unwrapped) ?? (isFunctionNode(unwrapped) ? unwrapped : null);
|
|
30500
|
-
if (!functionNode) return;
|
|
30552
|
+
if (!functionNode) return "unknown";
|
|
30501
30553
|
const bindings = resolveParamChildrenBindings(functionNode);
|
|
30502
30554
|
collectChildrenAliases(functionNode, bindings);
|
|
30503
30555
|
const jsxRoots = collectReturnedJsxRoots(functionNode);
|
|
30504
|
-
if (jsxRoots.some((jsxRoot) =>
|
|
30556
|
+
if (jsxRoots.some((jsxRoot) => jsxRootRendersChildrenIntoNonTextHost(jsxRoot, bindings, isTextHandlingElement, isNonTextHostElement))) return "nonText";
|
|
30557
|
+
if (jsxRoots.some((jsxRoot) => jsxRootRendersChildrenOutsideText(jsxRoot, bindings, isTextHandlingElement))) return "unknown";
|
|
30505
30558
|
for (const jsxRoot of jsxRoots) {
|
|
30506
30559
|
if (isNodeOfType(jsxRoot, "JSXElement")) {
|
|
30507
30560
|
const rootName = resolveJsxElementName(jsxRoot.openingElement);
|
|
30508
|
-
if (rootName && isTextHandlingElement(rootName))
|
|
30509
|
-
wrappers.add(componentName);
|
|
30510
|
-
return;
|
|
30511
|
-
}
|
|
30512
|
-
}
|
|
30513
|
-
if (jsxRootForwardsChildrenIntoText(jsxRoot, bindings, isTextHandlingElement)) {
|
|
30514
|
-
wrappers.add(componentName);
|
|
30515
|
-
return;
|
|
30561
|
+
if (rootName && isTextHandlingElement(rootName)) return "text";
|
|
30516
30562
|
}
|
|
30563
|
+
if (jsxRootForwardsChildrenIntoText(jsxRoot, bindings, isTextHandlingElement)) return "text";
|
|
30517
30564
|
}
|
|
30565
|
+
return "unknown";
|
|
30566
|
+
};
|
|
30567
|
+
const recordWrapperFromDeclaration = (componentName, definitionNode, isTextHandlingElement, isNonTextHostElement, wrappers, nonTextWrappers) => {
|
|
30568
|
+
if (!componentName || !isReactComponentName(componentName)) return;
|
|
30569
|
+
if (wrappers.has(componentName)) return;
|
|
30570
|
+
if (!definitionNode) return;
|
|
30571
|
+
const kind = classifyChildrenForwarding(definitionNode, isTextHandlingElement, isNonTextHostElement);
|
|
30572
|
+
if (kind === "text") wrappers.add(componentName);
|
|
30573
|
+
else if (kind === "nonText") nonTextWrappers.add(componentName);
|
|
30518
30574
|
};
|
|
30519
30575
|
const MAX_TRANSITIVE_WRAPPER_PASSES = 3;
|
|
30520
|
-
const collectTextWrapperComponents = (programNode, isTextHandlingRoot) => {
|
|
30576
|
+
const collectTextWrapperComponents = (programNode, isTextHandlingRoot, isNonTextHostRoot) => {
|
|
30521
30577
|
const wrappers = /* @__PURE__ */ new Set();
|
|
30578
|
+
const nonTextWrappers = /* @__PURE__ */ new Set();
|
|
30522
30579
|
const isTextHandlingElement = (elementName) => isTextHandlingRoot(elementName) || wrappers.has(elementName);
|
|
30580
|
+
const isNonTextHostElement = (elementName) => isNonTextHostRoot(elementName) || nonTextWrappers.has(elementName);
|
|
30581
|
+
const recordDeclaration = (componentName, definitionNode) => recordWrapperFromDeclaration(componentName, definitionNode, isTextHandlingElement, isNonTextHostElement, wrappers, nonTextWrappers);
|
|
30523
30582
|
for (let pass = 0; pass < MAX_TRANSITIVE_WRAPPER_PASSES; pass += 1) {
|
|
30524
|
-
const
|
|
30583
|
+
const wrappersSizeBeforePass = wrappers.size;
|
|
30584
|
+
const nonTextSizeBeforePass = nonTextWrappers.size;
|
|
30525
30585
|
walkAst(programNode, (node) => {
|
|
30526
|
-
if (isNodeOfType(node, "VariableDeclarator"))
|
|
30527
|
-
else if (isNodeOfType(node, "FunctionDeclaration") || isNodeOfType(node, "ClassDeclaration"))
|
|
30586
|
+
if (isNodeOfType(node, "VariableDeclarator")) recordDeclaration(node.id && isNodeOfType(node.id, "Identifier") ? node.id.name : null, node.init ?? null);
|
|
30587
|
+
else if (isNodeOfType(node, "FunctionDeclaration") || isNodeOfType(node, "ClassDeclaration")) recordDeclaration(node.id && isNodeOfType(node.id, "Identifier") ? node.id.name : null, node);
|
|
30528
30588
|
});
|
|
30529
|
-
if (wrappers.size ===
|
|
30589
|
+
if (wrappers.size === wrappersSizeBeforePass && nonTextWrappers.size === nonTextSizeBeforePass) break;
|
|
30530
30590
|
}
|
|
30531
|
-
|
|
30591
|
+
for (const wrapperName of wrappers) nonTextWrappers.delete(wrapperName);
|
|
30592
|
+
return {
|
|
30593
|
+
textWrappers: wrappers,
|
|
30594
|
+
nonTextWrappers
|
|
30595
|
+
};
|
|
30596
|
+
};
|
|
30597
|
+
//#endregion
|
|
30598
|
+
//#region src/plugin/rules/react-native/utils/resolve-imported-component-forwarding.ts
|
|
30599
|
+
const resolveImportedComponentForwarding = (contextNode, fromFilename, localName, isTextHandlingRoot, isNonTextHostRoot) => {
|
|
30600
|
+
const binding = getImportBindingForName(contextNode, localName);
|
|
30601
|
+
if (!binding || binding.isNamespace || binding.exportedName === null) return null;
|
|
30602
|
+
const resolvedNode = resolveCrossFileFunctionExport(fromFilename, binding.source, binding.exportedName);
|
|
30603
|
+
if (!resolvedNode) return null;
|
|
30604
|
+
const moduleProgram = findProgramRoot(resolvedNode);
|
|
30605
|
+
if (moduleProgram === null) return classifyChildrenForwarding(resolvedNode, isTextHandlingRoot, isNonTextHostRoot);
|
|
30606
|
+
const { textWrappers, nonTextWrappers } = collectTextWrapperComponents(moduleProgram, isTextHandlingRoot, isNonTextHostRoot);
|
|
30607
|
+
return classifyChildrenForwarding(resolvedNode, (elementName) => isTextHandlingRoot(elementName) || textWrappers.has(elementName), (elementName) => isNonTextHostRoot(elementName) || nonTextWrappers.has(elementName));
|
|
30532
30608
|
};
|
|
30533
30609
|
//#endregion
|
|
30534
30610
|
//#region src/plugin/rules/react-native/utils/is-expo-ui-component-element.ts
|
|
@@ -30599,19 +30675,37 @@ const rnNoRawText = defineRule({
|
|
|
30599
30675
|
recommendation: "Text outside a `<Text>` component crashes on React Native. Wrap it like `<Text>{value}</Text>`.",
|
|
30600
30676
|
create: (context) => {
|
|
30601
30677
|
let isDomComponentFile = false;
|
|
30602
|
-
let
|
|
30678
|
+
let autoDetectedTextWrappers = /* @__PURE__ */ new Set();
|
|
30679
|
+
let autoDetectedNonTextWrappers = /* @__PURE__ */ new Set();
|
|
30680
|
+
const isNonTextHostName = (elementName) => !isReactComponentName(elementName) || REACT_NATIVE_RAW_TEXT_HOST_COMPONENTS.has(elementName);
|
|
30681
|
+
const isRawTextReportTarget = (elementName) => elementName !== null && (isNonTextHostName(elementName) || autoDetectedNonTextWrappers.has(elementName));
|
|
30682
|
+
const importedNonTextWrapperCache = /* @__PURE__ */ new Map();
|
|
30683
|
+
const isImportedNonTextWrapper = (elementName, contextNode) => {
|
|
30684
|
+
if (elementName === null || !isReactComponentName(elementName)) return false;
|
|
30685
|
+
const { filename } = context;
|
|
30686
|
+
if (filename === void 0) return false;
|
|
30687
|
+
const cached = importedNonTextWrapperCache.get(elementName);
|
|
30688
|
+
if (cached !== void 0) return cached;
|
|
30689
|
+
const isNonTextWrapper = resolveImportedComponentForwarding(contextNode, filename, elementName, isTextHandlingComponent, isNonTextHostName) === "nonText";
|
|
30690
|
+
importedNonTextWrapperCache.set(elementName, isNonTextWrapper);
|
|
30691
|
+
return isNonTextWrapper;
|
|
30692
|
+
};
|
|
30603
30693
|
return {
|
|
30604
30694
|
Program(programNode) {
|
|
30605
30695
|
isDomComponentFile = hasDirective(programNode, "use dom");
|
|
30606
|
-
|
|
30696
|
+
const childrenForwarding = collectTextWrapperComponents(programNode, isTextHandlingComponent, isNonTextHostName);
|
|
30697
|
+
autoDetectedTextWrappers = childrenForwarding.textWrappers;
|
|
30698
|
+
autoDetectedNonTextWrappers = childrenForwarding.nonTextWrappers;
|
|
30607
30699
|
},
|
|
30608
30700
|
JSXElement(node) {
|
|
30609
30701
|
if (isDomComponentFile) return;
|
|
30610
30702
|
const elementName = resolveTextBoundaryName(node.openingElement);
|
|
30611
|
-
if (elementName && (isTextHandlingComponent(elementName) ||
|
|
30703
|
+
if (elementName && (isTextHandlingComponent(elementName) || autoDetectedTextWrappers.has(elementName))) return;
|
|
30612
30704
|
if (isExpoUiComponentElement(node.openingElement, node, "ListItem")) return;
|
|
30613
30705
|
if (isInsidePlatformOsWebBranch(node)) return;
|
|
30614
30706
|
if (isTransparentTextWrapper(elementName) && isInsideTextHandlingComponent(node)) return;
|
|
30707
|
+
if (!(node.children ?? []).some(isRawTextContent)) return;
|
|
30708
|
+
if (!isRawTextReportTarget(elementName) && !isImportedNonTextWrapper(elementName, node)) return;
|
|
30615
30709
|
for (const child of node.children ?? []) {
|
|
30616
30710
|
if (!isRawTextContent(child)) continue;
|
|
30617
30711
|
context.report({
|
|
@@ -35155,6 +35249,67 @@ const isAuthGuardName = (calleeName) => {
|
|
|
35155
35249
|
return false;
|
|
35156
35250
|
};
|
|
35157
35251
|
//#endregion
|
|
35252
|
+
//#region src/plugin/utils/is-non-privileged-server-action.ts
|
|
35253
|
+
const NON_DATA_EFFECT_FUNCTION_NAMES = new Set([...CACHE_REVALIDATION_FUNCTION_NAMES, ...NEXTJS_NAVIGATION_FUNCTIONS]);
|
|
35254
|
+
const isCacheOrNavigationCall = (node) => isNodeOfType(node, "CallExpression") && isNodeOfType(node.callee, "Identifier") && NON_DATA_EFFECT_FUNCTION_NAMES.has(node.callee.name);
|
|
35255
|
+
const unwrapExpression = (node) => {
|
|
35256
|
+
let current = node;
|
|
35257
|
+
while (current) {
|
|
35258
|
+
if (isNodeOfType(current, "TSAsExpression") || isNodeOfType(current, "TSNonNullExpression") || isNodeOfType(current, "TSSatisfiesExpression") || isNodeOfType(current, "ChainExpression")) {
|
|
35259
|
+
current = current.expression;
|
|
35260
|
+
continue;
|
|
35261
|
+
}
|
|
35262
|
+
if (isNodeOfType(current, "SequenceExpression")) {
|
|
35263
|
+
current = current.expressions?.[current.expressions.length - 1];
|
|
35264
|
+
continue;
|
|
35265
|
+
}
|
|
35266
|
+
return current;
|
|
35267
|
+
}
|
|
35268
|
+
return null;
|
|
35269
|
+
};
|
|
35270
|
+
const isDataExposingValue = (node) => {
|
|
35271
|
+
const value = unwrapExpression(node);
|
|
35272
|
+
if (!value) return false;
|
|
35273
|
+
if (isCacheOrNavigationCall(value)) return false;
|
|
35274
|
+
return !isLiteralOnlyExpression(value);
|
|
35275
|
+
};
|
|
35276
|
+
const isLiteralOnlyExpression = (node) => {
|
|
35277
|
+
if (!node) return false;
|
|
35278
|
+
if (isNodeOfType(node, "Literal")) return true;
|
|
35279
|
+
if (isNodeOfType(node, "TemplateLiteral")) return (node.expressions ?? []).every(isLiteralOnlyExpression);
|
|
35280
|
+
if (isNodeOfType(node, "UnaryExpression")) return isLiteralOnlyExpression(node.argument);
|
|
35281
|
+
if (isNodeOfType(node, "ArrayExpression")) return (node.elements ?? []).every((element) => element === null || !isNodeOfType(element, "SpreadElement") && isLiteralOnlyExpression(element));
|
|
35282
|
+
if (isNodeOfType(node, "ObjectExpression")) return (node.properties ?? []).every((property) => isNodeOfType(property, "Property") && (!property.computed || isLiteralOnlyExpression(property.key)) && isLiteralOnlyExpression(property.value));
|
|
35283
|
+
return false;
|
|
35284
|
+
};
|
|
35285
|
+
const getReturnedOrThrownArgument = (node) => {
|
|
35286
|
+
if (isNodeOfType(node, "ReturnStatement")) return node.argument ?? null;
|
|
35287
|
+
if (isNodeOfType(node, "ThrowStatement")) return node.argument ?? null;
|
|
35288
|
+
return null;
|
|
35289
|
+
};
|
|
35290
|
+
const isDataExposingReturnOrThrow = (node) => isDataExposingValue(getReturnedOrThrownArgument(node));
|
|
35291
|
+
const isPrivilegedEffect = (node) => isNodeOfType(node, "CallExpression") || isNodeOfType(node, "TaggedTemplateExpression") || isNodeOfType(node, "NewExpression") || isNodeOfType(node, "AssignmentExpression") || isNodeOfType(node, "UpdateExpression") || isNodeOfType(node, "UnaryExpression") && node.operator === "delete" || isDataExposingReturnOrThrow(node);
|
|
35292
|
+
const isNonPrivilegedServerAction = (functionNode) => {
|
|
35293
|
+
const functionBody = functionNode.body;
|
|
35294
|
+
if (!functionBody) return false;
|
|
35295
|
+
if (!isNodeOfType(functionBody, "BlockStatement") && isDataExposingValue(functionBody)) return false;
|
|
35296
|
+
let hasNonDataEffectCall = false;
|
|
35297
|
+
let hasPrivilegedEffect = false;
|
|
35298
|
+
walkAst(functionBody, (child) => {
|
|
35299
|
+
if (hasPrivilegedEffect) return false;
|
|
35300
|
+
if (child !== functionBody && isFunctionLike$2(child)) return false;
|
|
35301
|
+
if (isCacheOrNavigationCall(child)) {
|
|
35302
|
+
hasNonDataEffectCall = true;
|
|
35303
|
+
return;
|
|
35304
|
+
}
|
|
35305
|
+
if (isPrivilegedEffect(child)) {
|
|
35306
|
+
hasPrivilegedEffect = true;
|
|
35307
|
+
return false;
|
|
35308
|
+
}
|
|
35309
|
+
});
|
|
35310
|
+
return hasNonDataEffectCall && !hasPrivilegedEffect;
|
|
35311
|
+
};
|
|
35312
|
+
//#endregion
|
|
35158
35313
|
//#region src/plugin/rules/server/server-auth-actions.ts
|
|
35159
35314
|
const isAsyncFunctionLikeNode = (node) => {
|
|
35160
35315
|
if (!node) return false;
|
|
@@ -35229,6 +35384,7 @@ const getAuthScanRoots = (functionNode) => {
|
|
|
35229
35384
|
const inspectServerAction = (candidate, fileHasUseServerDirective, allowedFunctionNames, context) => {
|
|
35230
35385
|
if (!(fileHasUseServerDirective || hasUseServerDirective(candidate.functionNode))) return;
|
|
35231
35386
|
if (containsAuthCheck(getAuthScanRoots(candidate.functionNode), allowedFunctionNames, GENERIC_AUTH_METHOD_NAMES)) return;
|
|
35387
|
+
if (isNonPrivilegedServerAction(candidate.functionNode)) return;
|
|
35232
35388
|
context.report({
|
|
35233
35389
|
node: candidate.reportNode,
|
|
35234
35390
|
message: `Anyone can call server action "${candidate.displayName}" without logging in, since it has no auth check.`
|
|
@@ -35854,22 +36010,6 @@ const isSupabaseMigrationPath = (relativePath) => /(?:^|\/)supabase\/(?:migratio
|
|
|
35854
36010
|
//#endregion
|
|
35855
36011
|
//#region src/plugin/rules/security-scan/utils/is-sql-path.ts
|
|
35856
36012
|
const isSqlPath = (relativePath) => relativePath.endsWith(".sql") || isSupabaseMigrationPath(relativePath);
|
|
35857
|
-
const supabaseRlsPolicyRisk = defineRule({
|
|
35858
|
-
id: "supabase-rls-policy-risk",
|
|
35859
|
-
title: "Permissive Supabase RLS policy",
|
|
35860
|
-
severity: "error",
|
|
35861
|
-
recommendation: "Keep public-read policies explicit, but gate inserts, updates, deletes, and service-role bypasses behind `auth.uid()` plus trusted tenant membership.",
|
|
35862
|
-
scan: scanByPattern({
|
|
35863
|
-
shouldScan: (file) => isSqlPath(file.relativePath),
|
|
35864
|
-
pattern: [
|
|
35865
|
-
/disable\s+row\s+level\s+security/i,
|
|
35866
|
-
/create\s+policy[\s\S]{0,700}auth\.role\(\)\s*=\s*["']service_role["']/i,
|
|
35867
|
-
/create\s+policy[\s\S]{0,700}\bfor\s+(?:all|insert|update|delete)\b[\s\S]{0,500}\b(?:using|with\s+check)\s*\(\s*true\s*\)/i,
|
|
35868
|
-
/create\s+policy(?:(?!\bfor\s+select\b)[\s\S]){0,700}\b(?:using|with\s+check)\s*\(\s*true\s*\)/i
|
|
35869
|
-
],
|
|
35870
|
-
message: "Supabase policy SQL disables RLS, permits writes broadly, or references a service-role bypass."
|
|
35871
|
-
})
|
|
35872
|
-
});
|
|
35873
36013
|
//#endregion
|
|
35874
36014
|
//#region src/plugin/rules/security-scan/utils/sanitize-sql-for-scan.ts
|
|
35875
36015
|
const DOLLAR_QUOTE_TAG_PATTERN = /^\$[A-Za-z_]?\w*\$/;
|
|
@@ -36046,6 +36186,60 @@ const sanitizeSqlForScan = (content) => {
|
|
|
36046
36186
|
return characters.join("");
|
|
36047
36187
|
};
|
|
36048
36188
|
//#endregion
|
|
36189
|
+
//#region src/plugin/rules/security-scan/supabase-rls-policy-risk.ts
|
|
36190
|
+
const DISABLED_RLS_PATTERN = /disable\s+row\s+level\s+security/i;
|
|
36191
|
+
const SERVICE_ROLE_BODY_BYPASS_PATTERN = /auth\.role\(\)\s*=\s*["']service_role["']/i;
|
|
36192
|
+
const CREATE_POLICY_PATTERN = /create\s+policy/gi;
|
|
36193
|
+
const STATEMENT_END_PATTERN = /;|create\s+policy/i;
|
|
36194
|
+
const PERMISSIVE_TRUE_PATTERN = /\b(?:using|with\s+check)\s*\(\s*true\s*\)/i;
|
|
36195
|
+
const FOR_SELECT_PATTERN = /\bfor\s+select\b/i;
|
|
36196
|
+
const TO_CLAUSE_PATTERN = /\bto\s+([\s\S]+?)(?=\s+(?:using|with\s+check|as|for)\b|;|$)/i;
|
|
36197
|
+
const SERVER_ONLY_ROLES = new Set([
|
|
36198
|
+
"service_role",
|
|
36199
|
+
"postgres",
|
|
36200
|
+
"supabase_admin"
|
|
36201
|
+
]);
|
|
36202
|
+
const isServerOnlyScoped = (statement) => {
|
|
36203
|
+
const toClause = TO_CLAUSE_PATTERN.exec(statement);
|
|
36204
|
+
if (toClause === null) return false;
|
|
36205
|
+
const roles = toClause[1].split(",").map((role) => role.trim().replace(/["'`]/g, "").toLowerCase()).filter(Boolean);
|
|
36206
|
+
return roles.length > 0 && roles.every((role) => SERVER_ONLY_ROLES.has(role));
|
|
36207
|
+
};
|
|
36208
|
+
const isRiskyPolicyStatement = (statement, rawStatement) => {
|
|
36209
|
+
if (SERVICE_ROLE_BODY_BYPASS_PATTERN.test(rawStatement)) return true;
|
|
36210
|
+
if (!PERMISSIVE_TRUE_PATTERN.test(statement)) return false;
|
|
36211
|
+
if (FOR_SELECT_PATTERN.test(statement)) return false;
|
|
36212
|
+
return !isServerOnlyScoped(statement);
|
|
36213
|
+
};
|
|
36214
|
+
const POLICY_RISK_MESSAGE = "Supabase policy SQL disables RLS, permits writes broadly, or references a service-role bypass.";
|
|
36215
|
+
const supabaseRlsPolicyRisk = defineRule({
|
|
36216
|
+
id: "supabase-rls-policy-risk",
|
|
36217
|
+
title: "Permissive Supabase RLS policy",
|
|
36218
|
+
severity: "error",
|
|
36219
|
+
recommendation: "Keep public-read policies explicit, but gate inserts, updates, deletes, and service-role bypasses behind `auth.uid()` plus trusted tenant membership.",
|
|
36220
|
+
scan: (file) => {
|
|
36221
|
+
if (!isSqlPath(file.relativePath)) return [];
|
|
36222
|
+
const content = sanitizeSqlForScan(file.content);
|
|
36223
|
+
let earliestRiskIndex = content.search(DISABLED_RLS_PATTERN);
|
|
36224
|
+
CREATE_POLICY_PATTERN.lastIndex = 0;
|
|
36225
|
+
for (let policyMatch = CREATE_POLICY_PATTERN.exec(content); policyMatch !== null; policyMatch = CREATE_POLICY_PATTERN.exec(content)) {
|
|
36226
|
+
const afterKeyword = policyMatch.index + policyMatch[0].length;
|
|
36227
|
+
const terminatorOffset = content.slice(afterKeyword).search(STATEMENT_END_PATTERN);
|
|
36228
|
+
const statementEnd = terminatorOffset < 0 ? content.length : afterKeyword + terminatorOffset;
|
|
36229
|
+
if (!isRiskyPolicyStatement(content.slice(policyMatch.index, statementEnd), file.content.slice(policyMatch.index, statementEnd))) continue;
|
|
36230
|
+
if (earliestRiskIndex < 0 || policyMatch.index < earliestRiskIndex) earliestRiskIndex = policyMatch.index;
|
|
36231
|
+
break;
|
|
36232
|
+
}
|
|
36233
|
+
if (earliestRiskIndex < 0) return [];
|
|
36234
|
+
const { line, column } = getLocationAtIndex(content, earliestRiskIndex);
|
|
36235
|
+
return [{
|
|
36236
|
+
message: POLICY_RISK_MESSAGE,
|
|
36237
|
+
line,
|
|
36238
|
+
column
|
|
36239
|
+
}];
|
|
36240
|
+
}
|
|
36241
|
+
});
|
|
36242
|
+
//#endregion
|
|
36049
36243
|
//#region src/plugin/rules/security-scan/supabase-table-missing-rls.ts
|
|
36050
36244
|
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;
|
|
36051
36245
|
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");
|
|
@@ -42536,6 +42730,7 @@ const CROSS_FILE_RULE_IDS = new Set([
|
|
|
42536
42730
|
"nextjs-missing-metadata",
|
|
42537
42731
|
"nextjs-no-use-search-params-without-suspense",
|
|
42538
42732
|
"no-mutating-reducer-state",
|
|
42733
|
+
"rn-no-raw-text",
|
|
42539
42734
|
"rn-prefer-expo-image"
|
|
42540
42735
|
]);
|
|
42541
42736
|
//#endregion
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oxlint-plugin-react-doctor",
|
|
3
|
-
"version": "0.5.8-dev.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.5.8-dev.f028d8b",
|
|
4
|
+
"description": "React Doctor rules for oxlint.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"accessibility",
|
|
7
7
|
"linter",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"bugs": {
|
|
22
22
|
"url": "https://github.com/millionco/react-doctor/issues"
|
|
23
23
|
},
|
|
24
|
-
"license": "
|
|
24
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
25
25
|
"author": "Aiden Bai",
|
|
26
26
|
"repository": {
|
|
27
27
|
"type": "git",
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
},
|
|
31
31
|
"files": [
|
|
32
32
|
"dist/**/*.js",
|
|
33
|
-
"dist/**/*.d.ts"
|
|
33
|
+
"dist/**/*.d.ts",
|
|
34
|
+
"LICENSE"
|
|
34
35
|
],
|
|
35
36
|
"type": "module",
|
|
36
37
|
"sideEffects": false,
|