eslint-plugin-react-x 5.18.10 → 5.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +352 -131
- package/package.json +16 -16
package/dist/index.js
CHANGED
|
@@ -4,12 +4,12 @@ import { Check, Compare, Extract, Traverse } from "@eslint-react/ast";
|
|
|
4
4
|
import * as core from "@eslint-react/core";
|
|
5
5
|
import { merge } from "@eslint-react/eslint";
|
|
6
6
|
import { AST_NODE_TYPES } from "@typescript-eslint/types";
|
|
7
|
+
import { P, isMatching, match } from "ts-pattern";
|
|
7
8
|
import { isAssignmentTargetEqual, isInitializedFromReact, resolve, resolveEnclosingAssignmentTarget, resolveObjectType } from "@eslint-react/var";
|
|
8
9
|
import { DefinitionType, ScopeType } from "@typescript-eslint/scope-manager";
|
|
9
10
|
import { findVariable, getStaticValue } from "@typescript-eslint/utils/ast-utils";
|
|
10
11
|
import { findParentAttribute, getElementFullType, hasAttribute } from "@eslint-react/jsx";
|
|
11
12
|
import { compare } from "compare-versions";
|
|
12
|
-
import { P, isMatching, match } from "ts-pattern";
|
|
13
13
|
import { getConstrainedTypeAtLocation } from "@typescript-eslint/type-utils";
|
|
14
14
|
import { unionConstituents } from "ts-api-utils";
|
|
15
15
|
import "typescript";
|
|
@@ -145,7 +145,7 @@ const rules$6 = {
|
|
|
145
145
|
//#endregion
|
|
146
146
|
//#region package.json
|
|
147
147
|
var name$6 = "eslint-plugin-react-x";
|
|
148
|
-
var version = "5.
|
|
148
|
+
var version = "5.19.1";
|
|
149
149
|
|
|
150
150
|
//#endregion
|
|
151
151
|
//#region src/utils/create-rule.ts
|
|
@@ -1143,6 +1143,97 @@ const MUTATING_ARRAY_METHODS = /* @__PURE__ */ new Set([
|
|
|
1143
1143
|
"unshift"
|
|
1144
1144
|
]);
|
|
1145
1145
|
/**
|
|
1146
|
+
* Collect every write target in an assignment, including destructuring
|
|
1147
|
+
* patterns such as `[local, globalValue] = source`.
|
|
1148
|
+
*/
|
|
1149
|
+
function getAssignmentTargets(node) {
|
|
1150
|
+
const target = Extract.unwrap(node);
|
|
1151
|
+
switch (target.type) {
|
|
1152
|
+
case AST_NODE_TYPES.Identifier:
|
|
1153
|
+
case AST_NODE_TYPES.MemberExpression: return [target];
|
|
1154
|
+
case AST_NODE_TYPES.ArrayPattern: return target.elements.flatMap((element) => element == null ? [] : getAssignmentTargets(element));
|
|
1155
|
+
case AST_NODE_TYPES.AssignmentPattern: return getAssignmentTargets(target.left);
|
|
1156
|
+
case AST_NODE_TYPES.ObjectPattern: return target.properties.flatMap((property) => {
|
|
1157
|
+
if (property.type === AST_NODE_TYPES.RestElement) return getAssignmentTargets(property.argument);
|
|
1158
|
+
return getAssignmentTargets(property.value);
|
|
1159
|
+
});
|
|
1160
|
+
case AST_NODE_TYPES.RestElement: return getAssignmentTargets(target.argument);
|
|
1161
|
+
default: return [];
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
/** Resolve a direct call target, following simple function aliases. */
|
|
1165
|
+
function resolveToFunction(context, node, seen = /* @__PURE__ */ new Set()) {
|
|
1166
|
+
const expression = Extract.unwrap(node);
|
|
1167
|
+
if (Check.isFunction(expression)) return expression;
|
|
1168
|
+
if (!Check.isIdentifier(expression) || seen.has(expression)) return null;
|
|
1169
|
+
seen.add(expression);
|
|
1170
|
+
const resolved = resolve(context, expression);
|
|
1171
|
+
if (resolved == null) return null;
|
|
1172
|
+
return resolveToFunction(context, resolved, seen);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
//#endregion
|
|
1176
|
+
//#region src/rules/globals/collect.ts
|
|
1177
|
+
function createGlobalsCollector() {
|
|
1178
|
+
const facts = {
|
|
1179
|
+
callEdges: [],
|
|
1180
|
+
methodCalls: [],
|
|
1181
|
+
writes: []
|
|
1182
|
+
};
|
|
1183
|
+
function getEnclosingFunction(node) {
|
|
1184
|
+
return Traverse.findParent(node, Check.isFunction);
|
|
1185
|
+
}
|
|
1186
|
+
function pushWrite(node, target) {
|
|
1187
|
+
const enclosingFunction = getEnclosingFunction(node);
|
|
1188
|
+
if (enclosingFunction == null) return;
|
|
1189
|
+
facts.writes.push({
|
|
1190
|
+
enclosingFunction,
|
|
1191
|
+
node,
|
|
1192
|
+
target
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
return {
|
|
1196
|
+
facts,
|
|
1197
|
+
visitor: {
|
|
1198
|
+
AssignmentExpression(node) {
|
|
1199
|
+
for (const target of getAssignmentTargets(node.left)) pushWrite(node, target);
|
|
1200
|
+
},
|
|
1201
|
+
CallExpression(node) {
|
|
1202
|
+
const caller = getEnclosingFunction(node);
|
|
1203
|
+
if (caller != null) facts.callEdges.push({
|
|
1204
|
+
callee: node.callee,
|
|
1205
|
+
caller
|
|
1206
|
+
});
|
|
1207
|
+
const callee = Extract.unwrap(node.callee);
|
|
1208
|
+
if (callee.type !== AST_NODE_TYPES.MemberExpression) return;
|
|
1209
|
+
const method = Extract.getCalleeName(node);
|
|
1210
|
+
if (method == null || !MUTATING_ARRAY_METHODS.has(method)) return;
|
|
1211
|
+
if (caller == null) return;
|
|
1212
|
+
facts.methodCalls.push({
|
|
1213
|
+
enclosingFunction: caller,
|
|
1214
|
+
method,
|
|
1215
|
+
node,
|
|
1216
|
+
receiver: callee.object
|
|
1217
|
+
});
|
|
1218
|
+
},
|
|
1219
|
+
UnaryExpression(node) {
|
|
1220
|
+
if (node.operator !== "delete") return;
|
|
1221
|
+
const argument = Extract.unwrap(node.argument);
|
|
1222
|
+
if (argument.type !== AST_NODE_TYPES.MemberExpression) return;
|
|
1223
|
+
pushWrite(node, argument);
|
|
1224
|
+
},
|
|
1225
|
+
UpdateExpression(node) {
|
|
1226
|
+
const argument = Extract.unwrap(node.argument);
|
|
1227
|
+
if (argument.type !== AST_NODE_TYPES.Identifier && argument.type !== AST_NODE_TYPES.MemberExpression) return;
|
|
1228
|
+
pushWrite(node, argument);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
//#endregion
|
|
1235
|
+
//#region src/rules/globals/origins.ts
|
|
1236
|
+
/**
|
|
1146
1237
|
* Return whether an identifier is an unresolved global or is declared in the
|
|
1147
1238
|
* global/module scope.
|
|
1148
1239
|
*/
|
|
@@ -1174,34 +1265,85 @@ function resolveGlobalOrigin(context, node, seen = /* @__PURE__ */ new Set()) {
|
|
|
1174
1265
|
if (initializer.type !== AST_NODE_TYPES.Identifier && initializer.type !== AST_NODE_TYPES.MemberExpression) return null;
|
|
1175
1266
|
return resolveGlobalOrigin(context, initializer, seen);
|
|
1176
1267
|
}
|
|
1268
|
+
|
|
1269
|
+
//#endregion
|
|
1270
|
+
//#region src/rules/globals/effects.ts
|
|
1177
1271
|
/**
|
|
1178
|
-
*
|
|
1179
|
-
*
|
|
1272
|
+
* Classify the collected writes and mutating method calls, keeping only the
|
|
1273
|
+
* ones that reach a global/module binding, grouped by the function that
|
|
1274
|
+
* performs them.
|
|
1180
1275
|
*/
|
|
1181
|
-
function
|
|
1182
|
-
const
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1276
|
+
function inferGlobalMutations(context, facts) {
|
|
1277
|
+
const directEffects = /* @__PURE__ */ new Map();
|
|
1278
|
+
function pushEffect(enclosingFunction, effect) {
|
|
1279
|
+
const effects = directEffects.get(enclosingFunction) ?? [];
|
|
1280
|
+
effects.push(effect);
|
|
1281
|
+
directEffects.set(enclosingFunction, effects);
|
|
1282
|
+
}
|
|
1283
|
+
for (const write of facts.writes) {
|
|
1284
|
+
if (Check.isIdentifier(write.target)) {
|
|
1285
|
+
if (!isGlobalVariable(context, write.target)) continue;
|
|
1286
|
+
pushEffect(write.enclosingFunction, {
|
|
1287
|
+
kind: "global",
|
|
1288
|
+
name: write.target.name,
|
|
1289
|
+
method: null,
|
|
1290
|
+
node: write.node
|
|
1291
|
+
});
|
|
1292
|
+
continue;
|
|
1293
|
+
}
|
|
1294
|
+
if (resolveGlobalOrigin(context, write.target.object) == null) continue;
|
|
1295
|
+
pushEffect(write.enclosingFunction, {
|
|
1296
|
+
kind: "property",
|
|
1297
|
+
name: context.sourceCode.getText(write.target),
|
|
1298
|
+
method: null,
|
|
1299
|
+
node: write.node
|
|
1191
1300
|
});
|
|
1192
|
-
case AST_NODE_TYPES.RestElement: return getAssignmentTargets(target.argument);
|
|
1193
|
-
default: return [];
|
|
1194
1301
|
}
|
|
1302
|
+
for (const call of facts.methodCalls) {
|
|
1303
|
+
const origin = resolveGlobalOrigin(context, call.receiver);
|
|
1304
|
+
if (origin == null) continue;
|
|
1305
|
+
pushEffect(call.enclosingFunction, {
|
|
1306
|
+
kind: "method",
|
|
1307
|
+
name: origin.name,
|
|
1308
|
+
method: call.method,
|
|
1309
|
+
node: call.node
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
return directEffects;
|
|
1195
1313
|
}
|
|
1196
|
-
/** Resolve
|
|
1197
|
-
function
|
|
1198
|
-
const
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1314
|
+
/** Resolve the collected call edges into a function-to-function call graph. */
|
|
1315
|
+
function inferCallGraph(context, callEdges) {
|
|
1316
|
+
const callGraph = /* @__PURE__ */ new Map();
|
|
1317
|
+
for (const edge of callEdges) {
|
|
1318
|
+
const callee = resolveToFunction(context, edge.callee);
|
|
1319
|
+
if (callee == null) continue;
|
|
1320
|
+
const callees = callGraph.get(edge.caller) ?? /* @__PURE__ */ new Set();
|
|
1321
|
+
callees.add(callee);
|
|
1322
|
+
callGraph.set(edge.caller, callees);
|
|
1323
|
+
}
|
|
1324
|
+
return callGraph;
|
|
1325
|
+
}
|
|
1326
|
+
/**
|
|
1327
|
+
* Like the SPEC's function signatures, these summaries keep creation of an
|
|
1328
|
+
* effect separate from applying it in a component or hook render: walk the
|
|
1329
|
+
* call graph from each render function and gather every reachable effect once.
|
|
1330
|
+
*/
|
|
1331
|
+
function collectReachableEffects(renderFunctions, directEffects, callGraph) {
|
|
1332
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1333
|
+
const reported = /* @__PURE__ */ new Set();
|
|
1334
|
+
const reachable = [];
|
|
1335
|
+
function applyFunctionEffects(func) {
|
|
1336
|
+
if (visited.has(func)) return;
|
|
1337
|
+
visited.add(func);
|
|
1338
|
+
for (const effect of directEffects.get(func) ?? []) {
|
|
1339
|
+
if (reported.has(effect)) continue;
|
|
1340
|
+
reported.add(effect);
|
|
1341
|
+
reachable.push(effect);
|
|
1342
|
+
}
|
|
1343
|
+
for (const callee of callGraph.get(func) ?? []) applyFunctionEffects(callee);
|
|
1344
|
+
}
|
|
1345
|
+
for (const func of renderFunctions) applyFunctionEffects(func);
|
|
1346
|
+
return reachable;
|
|
1205
1347
|
}
|
|
1206
1348
|
|
|
1207
1349
|
//#endregion
|
|
@@ -1223,87 +1365,25 @@ var globals_default = createRule({
|
|
|
1223
1365
|
defaultOptions: []
|
|
1224
1366
|
});
|
|
1225
1367
|
function create$49(context) {
|
|
1226
|
-
const
|
|
1227
|
-
const
|
|
1228
|
-
const
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
directEffects.set(enclosing, effects);
|
|
1243
|
-
}
|
|
1244
|
-
function recordWrite(node, target) {
|
|
1245
|
-
if (Check.isIdentifier(target)) {
|
|
1246
|
-
if (!isGlobalVariable(context, target)) return;
|
|
1247
|
-
recordEffect(node, "mutatingGlobal", { name: target.name });
|
|
1248
|
-
return;
|
|
1249
|
-
}
|
|
1250
|
-
if (resolveGlobalOrigin(context, target.object) == null) return;
|
|
1251
|
-
recordEffect(node, "mutatingGlobalProperty", { name: context.sourceCode.getText(target) });
|
|
1252
|
-
}
|
|
1253
|
-
function recordCallEdge(node) {
|
|
1254
|
-
const caller = getEnclosingFunction(node);
|
|
1255
|
-
if (caller == null) return;
|
|
1256
|
-
const callee = resolveToFunction(context, node.callee);
|
|
1257
|
-
if (callee == null) return;
|
|
1258
|
-
const callees = callGraph.get(caller) ?? /* @__PURE__ */ new Set();
|
|
1259
|
-
callees.add(callee);
|
|
1260
|
-
callGraph.set(caller, callees);
|
|
1261
|
-
}
|
|
1262
|
-
return merge(hc.visitor, fc.visitor, {
|
|
1263
|
-
AssignmentExpression(node) {
|
|
1264
|
-
for (const target of getAssignmentTargets(node.left)) recordWrite(node, target);
|
|
1265
|
-
},
|
|
1266
|
-
CallExpression(node) {
|
|
1267
|
-
recordCallEdge(node);
|
|
1268
|
-
const callee = Extract.unwrap(node.callee);
|
|
1269
|
-
if (callee.type !== AST_NODE_TYPES.MemberExpression) return;
|
|
1270
|
-
const method = Extract.getCalleeName(node);
|
|
1271
|
-
if (method == null || !MUTATING_ARRAY_METHODS.has(method)) return;
|
|
1272
|
-
const origin = resolveGlobalOrigin(context, callee.object);
|
|
1273
|
-
if (origin == null) return;
|
|
1274
|
-
recordEffect(node, "mutatingGlobalArrayMethod", {
|
|
1275
|
-
name: origin.name,
|
|
1276
|
-
method
|
|
1368
|
+
const hooks = core.getHookCollector(context);
|
|
1369
|
+
const comps = core.getFunctionComponentCollector(context);
|
|
1370
|
+
const globs = createGlobalsCollector();
|
|
1371
|
+
return merge(hooks.visitor, comps.visitor, globs.visitor, { "Program:exit"(program) {
|
|
1372
|
+
const renderFunctions = [...comps.api.getAllComponents(program), ...hooks.api.getAllHooks(program)].map(({ node }) => node);
|
|
1373
|
+
const directEffects = inferGlobalMutations(context, globs.facts);
|
|
1374
|
+
const callGraph = inferCallGraph(context, globs.facts.callEdges);
|
|
1375
|
+
for (const effect of collectReachableEffects(renderFunctions, directEffects, callGraph)) {
|
|
1376
|
+
const data = effect.method == null ? { name: effect.name } : {
|
|
1377
|
+
name: effect.name,
|
|
1378
|
+
method: effect.method
|
|
1379
|
+
};
|
|
1380
|
+
context.report({
|
|
1381
|
+
data,
|
|
1382
|
+
messageId: match(effect).with({ kind: "global" }, () => "mutatingGlobal").with({ kind: "method" }, () => "mutatingGlobalArrayMethod").with({ kind: "property" }, () => "mutatingGlobalProperty").exhaustive(),
|
|
1383
|
+
node: effect.node
|
|
1277
1384
|
});
|
|
1278
|
-
},
|
|
1279
|
-
"Program:exit"(program) {
|
|
1280
|
-
const renderFunctions = [...fc.api.getAllComponents(program), ...hc.api.getAllHooks(program)];
|
|
1281
|
-
const visited = /* @__PURE__ */ new Set();
|
|
1282
|
-
const reported = /* @__PURE__ */ new Set();
|
|
1283
|
-
function applyFunctionEffects(func) {
|
|
1284
|
-
if (visited.has(func)) return;
|
|
1285
|
-
visited.add(func);
|
|
1286
|
-
for (const effect of directEffects.get(func) ?? []) {
|
|
1287
|
-
if (reported.has(effect)) continue;
|
|
1288
|
-
reported.add(effect);
|
|
1289
|
-
context.report(effect);
|
|
1290
|
-
}
|
|
1291
|
-
for (const callee of callGraph.get(func) ?? []) applyFunctionEffects(callee);
|
|
1292
|
-
}
|
|
1293
|
-
for (const { node } of renderFunctions) applyFunctionEffects(node);
|
|
1294
|
-
},
|
|
1295
|
-
UnaryExpression(node) {
|
|
1296
|
-
if (node.operator !== "delete") return;
|
|
1297
|
-
const argument = Extract.unwrap(node.argument);
|
|
1298
|
-
if (argument.type !== AST_NODE_TYPES.MemberExpression) return;
|
|
1299
|
-
recordWrite(node, argument);
|
|
1300
|
-
},
|
|
1301
|
-
UpdateExpression(node) {
|
|
1302
|
-
const argument = Extract.unwrap(node.argument);
|
|
1303
|
-
if (argument.type !== AST_NODE_TYPES.Identifier && argument.type !== AST_NODE_TYPES.MemberExpression) return;
|
|
1304
|
-
recordWrite(node, argument);
|
|
1305
1385
|
}
|
|
1306
|
-
});
|
|
1386
|
+
} });
|
|
1307
1387
|
}
|
|
1308
1388
|
|
|
1309
1389
|
//#endregion
|
|
@@ -1314,7 +1394,7 @@ function create$49(context) {
|
|
|
1314
1394
|
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map
|
|
1315
1395
|
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set
|
|
1316
1396
|
*/
|
|
1317
|
-
const
|
|
1397
|
+
const KNOWN_MUTATING_METHODS = /* @__PURE__ */ new Set([
|
|
1318
1398
|
"add",
|
|
1319
1399
|
"clear",
|
|
1320
1400
|
"copyWithin",
|
|
@@ -1330,14 +1410,36 @@ const MUTATING_METHODS = /* @__PURE__ */ new Set([
|
|
|
1330
1410
|
"unshift"
|
|
1331
1411
|
]);
|
|
1332
1412
|
/**
|
|
1333
|
-
* Known
|
|
1413
|
+
* Known mutating hooks.
|
|
1334
1414
|
*/
|
|
1335
|
-
const
|
|
1415
|
+
const KNOWN_MUTATING_HOOKS = /* @__PURE__ */ new Set([
|
|
1336
1416
|
"useHistory",
|
|
1337
1417
|
"useNavigate",
|
|
1338
1418
|
"useNavigation",
|
|
1339
1419
|
"useRouter"
|
|
1340
1420
|
]);
|
|
1421
|
+
function isNodeWithin(node, ancestor) {
|
|
1422
|
+
let current = node;
|
|
1423
|
+
while (current != null) {
|
|
1424
|
+
if (current === ancestor) return true;
|
|
1425
|
+
current = current.parent;
|
|
1426
|
+
}
|
|
1427
|
+
return false;
|
|
1428
|
+
}
|
|
1429
|
+
function isComponentPropsDefinition(def, components) {
|
|
1430
|
+
if (def.type !== DefinitionType.Parameter) return false;
|
|
1431
|
+
const fn = def.node;
|
|
1432
|
+
if (!Check.isFunction(fn)) return false;
|
|
1433
|
+
const firstParam = fn.params.at(0);
|
|
1434
|
+
if (firstParam == null || !isNodeWithin(def.name, firstParam)) return false;
|
|
1435
|
+
return components.includes(fn);
|
|
1436
|
+
}
|
|
1437
|
+
function getStateHookName(context, init) {
|
|
1438
|
+
const { additionalStateHooks } = getSettingsFromContext(context);
|
|
1439
|
+
if (core.isUseStateLikeCall(init, additionalStateHooks)) return Extract.getCalleeName(init) ?? "useState";
|
|
1440
|
+
if (core.isUseReducerCall(context, init)) return "useReducer";
|
|
1441
|
+
return null;
|
|
1442
|
+
}
|
|
1341
1443
|
function resolveToFunctionNode(context, node, seen = /* @__PURE__ */ new Set()) {
|
|
1342
1444
|
const expr = Extract.unwrap(node);
|
|
1343
1445
|
if (Check.isFunction(expr)) return expr;
|
|
@@ -1382,7 +1484,7 @@ function isInitializedFromUseRef(context, node) {
|
|
|
1382
1484
|
function isKnownNonMutatingMethodCall(context, node) {
|
|
1383
1485
|
const callee = Extract.unwrap(node.callee);
|
|
1384
1486
|
return Check.isExpression(callee) && isInitializedFromCall(context, callee, (init) => {
|
|
1385
|
-
return
|
|
1487
|
+
return KNOWN_MUTATING_HOOKS.values().some((hook) => core.isAPICall(hook)(context, init));
|
|
1386
1488
|
});
|
|
1387
1489
|
}
|
|
1388
1490
|
function isRefLikeChain(context, node) {
|
|
@@ -1427,7 +1529,7 @@ function createImmutabilityCollector() {
|
|
|
1427
1529
|
const callee = Extract.unwrap(node.callee);
|
|
1428
1530
|
if (callee.type === AST_NODE_TYPES.MemberExpression) {
|
|
1429
1531
|
const method = Extract.getCalleeName(node);
|
|
1430
|
-
if (method != null &&
|
|
1532
|
+
if (method != null && KNOWN_MUTATING_METHODS.has(method)) {
|
|
1431
1533
|
const root = Extract.getIdentifierAt(callee.object, 0);
|
|
1432
1534
|
if (root != null) pushMutation("value", node, callee.object, root);
|
|
1433
1535
|
}
|
|
@@ -1474,6 +1576,66 @@ function createImmutabilityCollector() {
|
|
|
1474
1576
|
};
|
|
1475
1577
|
}
|
|
1476
1578
|
|
|
1579
|
+
//#endregion
|
|
1580
|
+
//#region src/rules/immutability/origins.ts
|
|
1581
|
+
/**
|
|
1582
|
+
* Classify whether a variable ultimately holds a value that must be treated as
|
|
1583
|
+
* immutable: a component's props, a state value returned from `useState`-like or
|
|
1584
|
+
* `useReducer` calls, or a shallow copy (spread literal) of either.
|
|
1585
|
+
* @param context The rule context.
|
|
1586
|
+
* @param variable The variable to classify.
|
|
1587
|
+
* @param components The confirmed function component nodes in the file.
|
|
1588
|
+
* @param seen Variables already visited during spread recursion.
|
|
1589
|
+
* @returns The frozen origin, or `null` when the variable is not derived from one.
|
|
1590
|
+
*/
|
|
1591
|
+
function classifyFrozenOrigin(context, variable, components, seen = /* @__PURE__ */ new Set()) {
|
|
1592
|
+
if (seen.has(variable)) return null;
|
|
1593
|
+
seen.add(variable);
|
|
1594
|
+
const origin = resolveVariableOrigin(context, variable);
|
|
1595
|
+
const def = origin.defs.length === 1 ? origin.defs[0] : null;
|
|
1596
|
+
if (def == null) return null;
|
|
1597
|
+
if (isComponentPropsDefinition(def, components)) return {
|
|
1598
|
+
kind: "props",
|
|
1599
|
+
name: origin.name
|
|
1600
|
+
};
|
|
1601
|
+
if (def.type !== DefinitionType.Variable) return null;
|
|
1602
|
+
const init = def.node.init == null ? null : Extract.unwrap(def.node.init);
|
|
1603
|
+
if (init == null) return null;
|
|
1604
|
+
switch (init.type) {
|
|
1605
|
+
case AST_NODE_TYPES.CallExpression: {
|
|
1606
|
+
const hook = getStateHookName(context, init);
|
|
1607
|
+
if (hook == null) return null;
|
|
1608
|
+
if (def.node.id.type !== AST_NODE_TYPES.ArrayPattern) return null;
|
|
1609
|
+
const first = def.node.id.elements.at(0);
|
|
1610
|
+
if (first == null || !isNodeWithin(def.name, first)) return null;
|
|
1611
|
+
return {
|
|
1612
|
+
kind: "state",
|
|
1613
|
+
name: origin.name,
|
|
1614
|
+
hook
|
|
1615
|
+
};
|
|
1616
|
+
}
|
|
1617
|
+
case AST_NODE_TYPES.ArrayExpression:
|
|
1618
|
+
case AST_NODE_TYPES.ObjectExpression: {
|
|
1619
|
+
const elements = init.type === AST_NODE_TYPES.ObjectExpression ? init.properties : init.elements;
|
|
1620
|
+
for (const element of elements) {
|
|
1621
|
+
if (element?.type !== AST_NODE_TYPES.SpreadElement) continue;
|
|
1622
|
+
const argument = Extract.unwrap(element.argument);
|
|
1623
|
+
if (!Check.isIdentifier(argument)) continue;
|
|
1624
|
+
const source = findVariable(context.sourceCode.getScope(argument), argument);
|
|
1625
|
+
if (source == null) continue;
|
|
1626
|
+
const inner = classifyFrozenOrigin(context, source, components, seen);
|
|
1627
|
+
if (inner != null) return {
|
|
1628
|
+
kind: "shallow-copy",
|
|
1629
|
+
name: origin.name,
|
|
1630
|
+
original: inner.name
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1633
|
+
return null;
|
|
1634
|
+
}
|
|
1635
|
+
default: return null;
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1477
1639
|
//#endregion
|
|
1478
1640
|
//#region src/rules/immutability/effects.ts
|
|
1479
1641
|
function isGlobalOrModuleVariable(variable) {
|
|
@@ -1505,6 +1667,47 @@ function inferMutableFunctions(context, mutations) {
|
|
|
1505
1667
|
}
|
|
1506
1668
|
return mutableFunctions;
|
|
1507
1669
|
}
|
|
1670
|
+
function getMutatedObject(mutation) {
|
|
1671
|
+
const target = Extract.unwrap(mutation.target);
|
|
1672
|
+
if (mutation.node.type === AST_NODE_TYPES.CallExpression) return target;
|
|
1673
|
+
return target.type === AST_NODE_TYPES.MemberExpression ? Extract.unwrap(target.object) : target;
|
|
1674
|
+
}
|
|
1675
|
+
function inferDirectMutations(context, mutations, components) {
|
|
1676
|
+
const directMutations = [];
|
|
1677
|
+
for (const mutation of mutations) {
|
|
1678
|
+
if (mutation.kind !== "value") continue;
|
|
1679
|
+
if (mutation.node.type === AST_NODE_TYPES.CallExpression && isKnownNonMutatingMethodCall(context, mutation.node)) continue;
|
|
1680
|
+
if (isRefMutation(context, mutation)) continue;
|
|
1681
|
+
const variable = findVariable(context.sourceCode.getScope(mutation.root), mutation.root);
|
|
1682
|
+
if (variable == null) continue;
|
|
1683
|
+
const origin = classifyFrozenOrigin(context, variable, components);
|
|
1684
|
+
if (origin == null) continue;
|
|
1685
|
+
switch (origin.kind) {
|
|
1686
|
+
case "props":
|
|
1687
|
+
directMutations.push({
|
|
1688
|
+
name: origin.name,
|
|
1689
|
+
detail: "It is a prop of this component and must be treated as immutable.",
|
|
1690
|
+
node: mutation.node
|
|
1691
|
+
});
|
|
1692
|
+
break;
|
|
1693
|
+
case "state":
|
|
1694
|
+
directMutations.push({
|
|
1695
|
+
name: origin.name,
|
|
1696
|
+
detail: `It is a state value returned from '${origin.hook}' and must be treated as immutable.`,
|
|
1697
|
+
node: mutation.node
|
|
1698
|
+
});
|
|
1699
|
+
break;
|
|
1700
|
+
case "shallow-copy":
|
|
1701
|
+
if (getMutatedObject(mutation).type !== AST_NODE_TYPES.MemberExpression) continue;
|
|
1702
|
+
directMutations.push({
|
|
1703
|
+
name: origin.name,
|
|
1704
|
+
detail: `It is a shallow copy of '${origin.original}'; mutating nested values through it mutates '${origin.original}' in place.`,
|
|
1705
|
+
node: mutation.node
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
return directMutations;
|
|
1710
|
+
}
|
|
1508
1711
|
|
|
1509
1712
|
//#endregion
|
|
1510
1713
|
//#region src/rules/immutability/immutability.ts
|
|
@@ -1512,9 +1715,10 @@ const RULE_NAME$48 = "immutability";
|
|
|
1512
1715
|
var immutability_default = createRule({
|
|
1513
1716
|
meta: {
|
|
1514
1717
|
type: "problem",
|
|
1515
|
-
docs: { description: "Validates against
|
|
1718
|
+
docs: { description: "Validates against mutating props, state, and other immutable values, including through functions passed into frozen contexts such as JSX props, hook arguments, and hook return values." },
|
|
1516
1719
|
messages: {
|
|
1517
1720
|
default: "This function may (indirectly) reassign or modify '{{name}}' after render, which can cause inconsistent behavior on subsequent renders. Consider using state instead.",
|
|
1721
|
+
direct: "Do not mutate '{{name}}' directly. {{detail}}",
|
|
1518
1722
|
mutates: "This modifies '{{name}}'."
|
|
1519
1723
|
},
|
|
1520
1724
|
schema: []
|
|
@@ -1525,31 +1729,48 @@ var immutability_default = createRule({
|
|
|
1525
1729
|
});
|
|
1526
1730
|
function create$48(context) {
|
|
1527
1731
|
const hooks = core.getHookCollector(context);
|
|
1528
|
-
const
|
|
1529
|
-
|
|
1530
|
-
|
|
1732
|
+
const comps = core.getFunctionComponentCollector(context);
|
|
1733
|
+
const immut = createImmutabilityCollector();
|
|
1734
|
+
return merge(hooks.visitor, comps.visitor, immut.visitor, { "Program:exit"(program) {
|
|
1735
|
+
for (const hook of hooks.api.getAllHooks(program)) for (const expression of hook.rets) if (expression != null) immut.facts.sinks.push({
|
|
1531
1736
|
kind: "hook-return",
|
|
1532
1737
|
expression
|
|
1533
1738
|
});
|
|
1534
|
-
const
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
const
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1739
|
+
const reportedMutations = /* @__PURE__ */ new Set();
|
|
1740
|
+
const mutableFunctions = inferMutableFunctions(context, immut.facts.mutations);
|
|
1741
|
+
if (mutableFunctions.size > 0) {
|
|
1742
|
+
const reportedSinks = /* @__PURE__ */ new Set();
|
|
1743
|
+
for (const sink of immut.facts.sinks) {
|
|
1744
|
+
const expression = sink.expression;
|
|
1745
|
+
if (reportedSinks.has(expression)) continue;
|
|
1746
|
+
const fn = resolveToFunctionNode(context, expression);
|
|
1747
|
+
if (fn == null) continue;
|
|
1748
|
+
const mutation = mutableFunctions.get(fn);
|
|
1749
|
+
if (mutation == null) continue;
|
|
1750
|
+
reportedSinks.add(expression);
|
|
1751
|
+
reportedMutations.add(mutation.node);
|
|
1752
|
+
context.report({
|
|
1753
|
+
data: { name: mutation.name },
|
|
1754
|
+
messageId: "default",
|
|
1755
|
+
node: expression
|
|
1756
|
+
});
|
|
1757
|
+
context.report({
|
|
1758
|
+
data: { name: mutation.name },
|
|
1759
|
+
messageId: "mutates",
|
|
1760
|
+
node: mutation.node
|
|
1761
|
+
});
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
const funcs = comps.api.getAllComponents(program).map((comp) => comp.node);
|
|
1765
|
+
for (const mutation of inferDirectMutations(context, immut.facts.mutations, funcs)) {
|
|
1766
|
+
if (reportedMutations.has(mutation.node)) continue;
|
|
1767
|
+
reportedMutations.add(mutation.node);
|
|
1550
1768
|
context.report({
|
|
1551
|
-
data: {
|
|
1552
|
-
|
|
1769
|
+
data: {
|
|
1770
|
+
name: mutation.name,
|
|
1771
|
+
detail: mutation.detail
|
|
1772
|
+
},
|
|
1773
|
+
messageId: "direct",
|
|
1553
1774
|
node: mutation.node
|
|
1554
1775
|
});
|
|
1555
1776
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eslint-plugin-react-x",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.19.1",
|
|
4
4
|
"description": "A set of composable ESLint rules for libraries and frameworks that use React as a UI runtime.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
@@ -36,17 +36,17 @@
|
|
|
36
36
|
"dist"
|
|
37
37
|
],
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@eslint-react/ast": "5.
|
|
40
|
-
"@eslint-react/core": "5.
|
|
41
|
-
"@eslint-react/eslint": "5.
|
|
42
|
-
"@eslint-react/jsx": "5.
|
|
43
|
-
"@eslint-react/shared": "5.
|
|
44
|
-
"@eslint-react/var": "5.
|
|
45
|
-
"@typescript-eslint/scope-manager": "^8.
|
|
46
|
-
"@typescript-eslint/type-utils": "^8.
|
|
47
|
-
"@typescript-eslint/types": "^8.
|
|
48
|
-
"@typescript-eslint/typescript-estree": "^8.
|
|
49
|
-
"@typescript-eslint/utils": "^8.
|
|
39
|
+
"@eslint-react/ast": "5.19.1",
|
|
40
|
+
"@eslint-react/core": "5.19.1",
|
|
41
|
+
"@eslint-react/eslint": "5.19.1",
|
|
42
|
+
"@eslint-react/jsx": "5.19.1",
|
|
43
|
+
"@eslint-react/shared": "5.19.1",
|
|
44
|
+
"@eslint-react/var": "5.19.1",
|
|
45
|
+
"@typescript-eslint/scope-manager": "^8.70.0",
|
|
46
|
+
"@typescript-eslint/type-utils": "^8.70.0",
|
|
47
|
+
"@typescript-eslint/types": "^8.70.0",
|
|
48
|
+
"@typescript-eslint/typescript-estree": "^8.70.0",
|
|
49
|
+
"@typescript-eslint/utils": "^8.70.0",
|
|
50
50
|
"compare-versions": "^6.1.1",
|
|
51
51
|
"string-ts": "^2.3.1",
|
|
52
52
|
"ts-api-utils": "^2.5.0",
|
|
@@ -55,12 +55,12 @@
|
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@local/configs": "0.0.0",
|
|
57
57
|
"@local/eff": "0.0.0",
|
|
58
|
-
"@types/react": "^19.
|
|
59
|
-
"@types/react-dom": "^19.
|
|
58
|
+
"@types/react": "^19.3.0",
|
|
59
|
+
"@types/react-dom": "^19.3.0",
|
|
60
60
|
"dedent": "^1.7.2",
|
|
61
61
|
"eslint": "^10.10.0",
|
|
62
|
-
"react": "^19.
|
|
63
|
-
"react-dom": "^19.
|
|
62
|
+
"react": "^19.3.0",
|
|
63
|
+
"react-dom": "^19.3.0",
|
|
64
64
|
"tsdown": "^0.23.0",
|
|
65
65
|
"tsl": "^1.0.30",
|
|
66
66
|
"tsl-dx": "^0.13.3",
|