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.
Files changed (2) hide show
  1. package/dist/index.js +352 -131
  2. 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.18.10";
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
- * Collect every write target in an assignment, including destructuring
1179
- * patterns such as `[local, globalValue] = source`.
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 getAssignmentTargets(node) {
1182
- const target = Extract.unwrap(node);
1183
- switch (target.type) {
1184
- case AST_NODE_TYPES.Identifier:
1185
- case AST_NODE_TYPES.MemberExpression: return [target];
1186
- case AST_NODE_TYPES.ArrayPattern: return target.elements.flatMap((element) => element == null ? [] : getAssignmentTargets(element));
1187
- case AST_NODE_TYPES.AssignmentPattern: return getAssignmentTargets(target.left);
1188
- case AST_NODE_TYPES.ObjectPattern: return target.properties.flatMap((property) => {
1189
- if (property.type === AST_NODE_TYPES.RestElement) return getAssignmentTargets(property.argument);
1190
- return getAssignmentTargets(property.value);
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 a direct call target, following simple function aliases. */
1197
- function resolveToFunction(context, node, seen = /* @__PURE__ */ new Set()) {
1198
- const expression = Extract.unwrap(node);
1199
- if (Check.isFunction(expression)) return expression;
1200
- if (!Check.isIdentifier(expression) || seen.has(expression)) return null;
1201
- seen.add(expression);
1202
- const resolved = resolve(context, expression);
1203
- if (resolved == null) return null;
1204
- return resolveToFunction(context, resolved, seen);
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 hc = core.getHookCollector(context);
1227
- const fc = core.getFunctionComponentCollector(context);
1228
- const directEffects = /* @__PURE__ */ new Map();
1229
- const callGraph = /* @__PURE__ */ new Map();
1230
- function getEnclosingFunction(node) {
1231
- return Traverse.findParent(node, Check.isFunction);
1232
- }
1233
- function recordEffect(node, messageId, data) {
1234
- const enclosing = getEnclosingFunction(node);
1235
- if (enclosing == null) return;
1236
- const effects = directEffects.get(enclosing) ?? [];
1237
- effects.push({
1238
- data,
1239
- messageId,
1240
- node
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 MUTATING_METHODS = /* @__PURE__ */ new Set([
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 navigation hooks.
1413
+ * Known mutating hooks.
1334
1414
  */
1335
- const NAVIGATION_HOOKS = /* @__PURE__ */ new Set([
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 NAVIGATION_HOOKS.values().some((hook) => core.isAPICall(hook)(context, init));
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 && MUTATING_METHODS.has(method)) {
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 passing functions that mutate captured local variables into frozen contexts such as JSX props, hook arguments, and hook return values." },
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 collector = createImmutabilityCollector();
1529
- return merge(hooks.visitor, collector.visitor, { "Program:exit"(program) {
1530
- for (const hook of hooks.api.getAllHooks(program)) for (const expression of hook.rets) if (expression != null) collector.facts.sinks.push({
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 mutableFunctions = inferMutableFunctions(context, collector.facts.mutations);
1535
- if (mutableFunctions.size === 0) return;
1536
- const reported = /* @__PURE__ */ new Set();
1537
- for (const sink of collector.facts.sinks) {
1538
- const expression = sink.expression;
1539
- if (reported.has(expression)) continue;
1540
- const fn = resolveToFunctionNode(context, expression);
1541
- if (fn == null) continue;
1542
- const mutation = mutableFunctions.get(fn);
1543
- if (mutation == null) continue;
1544
- reported.add(expression);
1545
- context.report({
1546
- data: { name: mutation.name },
1547
- messageId: "default",
1548
- node: expression
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: { name: mutation.name },
1552
- messageId: "mutates",
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.18.10",
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.18.10",
40
- "@eslint-react/core": "5.18.10",
41
- "@eslint-react/eslint": "5.18.10",
42
- "@eslint-react/jsx": "5.18.10",
43
- "@eslint-react/shared": "5.18.10",
44
- "@eslint-react/var": "5.18.10",
45
- "@typescript-eslint/scope-manager": "^8.69.0",
46
- "@typescript-eslint/type-utils": "^8.69.0",
47
- "@typescript-eslint/types": "^8.69.0",
48
- "@typescript-eslint/typescript-estree": "^8.69.0",
49
- "@typescript-eslint/utils": "^8.69.0",
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.2.18",
59
- "@types/react-dom": "^19.2.7",
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.2.8",
63
- "react-dom": "^19.2.8",
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",