Repository: eslint-functional/eslint-plugin-functional (ts)

Issue:
`immutable-data`: Map Support
## Suggestion
Thank you for this amazing linter, this has helped us keep our codebase maintainable and our data flows easy to understand! I have a request: currently `immutable-data` supports array and disallows modifications. When it comes to other similar datatypes like Maps, the linter doesn't  seem to have rules that prevent modifications. 

Example with `immutable-data` set to `error`:
```ts
export const value = new Map();
value.set('foo', 'bar'); // no errors.
```

If I missed it, can you please point me in the right direction? Thank you!

Pull request description:
feat(immutable-data): add support for detecting map and set mutations
BREAKING CHANGE: map and set mutations will now be reported

fix #934

Patch:
diff --git a/docs/rules/immutable-data.md b/docs/rules/immutable-data.md
index 490e40d..8fde328 100644
--- a/docs/rules/immutable-data.md
+++ b/docs/rules/immutable-data.md
@@ -67,6 +67,7 @@ This rule accepts an options object of the following type:
 ```ts
 type Options = {
   ignoreClasses: boolean | "fieldsOnly";
+  ignoreMapsAndSets: boolean;
   ignoreImmediateMutation: boolean;
   ignoreNonConstDeclarations:
     | boolean
@@ -113,6 +114,7 @@ type Options = {
 ```ts
 type Options = {
   ignoreClasses: false;
+  ignoreMapsAndSets: false;
   ignoreImmediateMutation: true;
   ignoreNonConstDeclarations: false;
 };
@@ -159,6 +161,10 @@ Ignore mutations inside classes.
 
 Classes already aren't functional so ignore mutations going on inside them.
 
+### `ignoreMapsAndSets`
+
+Ignore mutations of builtin `Map`s and `Set`s.
+
 ### `ignoreIdentifierPattern`
 
 This option takes a RegExp string or an array of RegExp strings.
diff --git a/src/options/ignore.ts b/src/options/ignore.ts
index 54a066d..3279b14 100644
--- a/src/options/ignore.ts
+++ b/src/options/ignore.ts
@@ -95,6 +95,22 @@ export const ignoreClassesOptionSchema: JSONSchema4ObjectSchema["properties"] =
   },
 };
 
+/**
+ * The option to ignore mapsAndSets.
+ */
+export type IgnoreMapsAndSetsOption = Readonly<{
+  ignoreMapsAndSets: boolean;
+}>;
+
+/**
+ * The schema for the option to ignore maps and sets.
+ */
+export const ignoreMapsAndSetsOptionSchema: JSONSchema4ObjectSchema["properties"] = {
+  ignoreMapsAndSets: {
+    type: "boolean",
+  },
+};
+
 /**
  * The option to ignore prefix selector.
  */
diff --git a/src/rules/immutable-data.ts b/src/rules/immutable-data.ts
index 635712a..0675a1e 100644
--- a/src/rules/immutable-data.ts
+++ b/src/rules/immutable-data.ts
@@ -7,17 +7,19 @@ import {
   type IgnoreAccessorPatternOption,
   type IgnoreClassesOption,
   type IgnoreIdentifierPatternOption,
+  type IgnoreMapsAndSetsOption,
   type OverridableOptions,
   type RawOverridableOptions,
   getCoreOptions,
   ignoreAccessorPatternOptionSchema,
   ignoreClassesOptionSchema,
   ignoreIdentifierPatternOptionSchema,
+  ignoreMapsAndSetsOptionSchema,
   shouldIgnoreClasses,
   shouldIgnorePattern,
   upgradeRawOverridableOptions,
 } from "#/options";
-import { isExpected, ruleNameScope } from "#/utils/misc";
+import { ruleNameScope } from "#/utils/misc";
 import { type NamedCreateRuleCustomMeta, type Rule, type RuleResult, createRule, getTypeOfNode } from "#/utils/rule";
 import { overridableOptionsSchema } from "#/utils/schemas";
 import { findRootIdentifier, isDefinedByMutableVariable, isInConstructor } from "#/utils/tree";
@@ -27,9 +29,13 @@ import {
   isArrayType,
   isCallExpression,
   isIdentifier,
+  isMapConstructorType,
+  isMapType,
   isMemberExpression,
   isNewExpression,
   isObjectConstructorType,
+  isSetConstructorType,
+  isSetType,
   isTSAsExpression,
 } from "#/utils/type-guards";
 
@@ -45,6 +51,7 @@ export const fullName: `${typeof ruleNameScope}/${typeof name}` = `${ruleNameSco
 
 type CoreOptions = IgnoreAccessorPatternOption &
   IgnoreClassesOption &
+  IgnoreMapsAndSetsOption &
   IgnoreIdentifierPatternOption & {
     ignoreImmediateMutation: boolean;
     ignoreNonConstDeclarations:
@@ -64,6 +71,7 @@ const coreOptionsPropertiesSchema = deepmerge(
   ignoreIdentifierPatternOptionSchema,
   ignoreAccessorPatternOptionSchema,
   ignoreClassesOptionSchema,
+  ignoreMapsAndSetsOptionSchema,
   {
     ignoreImmediateMutation: {
       type: "boolean",
@@ -98,6 +106,7 @@ const schema: JSONSchema4[] = [overridableOptionsSchema(coreOptionsPropertiesSch
 const defaultOptions = [
   {
     ignoreClasses: false,
+    ignoreMapsAndSets: false,
     ignoreImmediateMutation: true,
     ignoreNonConstDeclarations: false,
   },
@@ -110,6 +119,8 @@ const errorMessages = {
   generic: "Modifying an existing object/array is not allowed.",
   object: "Modifying properties of existing object not allowed.",
   array: "Modifying an array is not allowed.",
+  map: "Modifying a map is not allowed.",
+  set: "Modifying a set is not allowed.",
 } as const;
 
 /**
@@ -151,14 +162,38 @@ const arrayMutatorMethods = new Set([
  * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/prototype#Methods#Accessor_methods
  * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/prototype#Iteration_methods
  */
-const arrayNewObjectReturningMethods = ["concat", "slice", "filter", "map", "reduce", "reduceRight"];
+const arrayNewObjectReturningMethods = new Set(["concat", "slice", "filter", "map", "reduce", "reduceRight"]);
 
 /**
  * Array constructor functions that create a new array.
  *
  * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array#Methods
  */
-const arrayConstructorFunctions = ["from", "of"];
+const arrayConstructorFunctions = new Set(["from", "of"]);
+
+/**
+ * Map methods that mutate an map.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map
+ */
+const mapMutatorMethods = new Set(["clear", "delete", "set"]);
+
+/**
+ * Map methods that return a new object without mutating the original.
+ */
+const mapNewObjectReturningMethods = new Set<string>([]);
+
+/**
+ * Set methods that mutate an set.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set
+ */
+const setMutatorMethods = new Set(["add", "clear", "delete"]);
+
+/**
+ * Set methods that return a new object without mutating the original.
+ */
+const setNewObjectReturningMethods = new Set(["difference", "intersection", "symmetricDifference", "union"]);
 
 /**
  * Object constructor functions that mutate an object.
@@ -170,7 +205,7 @@ const objectConstructorMutatorFunctions = new Set(["assign", "defineProperties",
 /**
  * Object constructor functions that return new objects.
  */
-const objectConstructorNewObjectReturningMethods = [
+const objectConstructorNewObjectReturningMethods = new Set([
   "create",
   "entries",
   "fromEntries",
@@ -181,14 +216,14 @@ const objectConstructorNewObjectReturningMethods = [
   "groupBy",
   "keys",
   "values",
-];
+]);
 
 /**
  * String constructor functions that return new objects.
  *
  * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String#Methods
  */
-const stringConstructorNewObjectReturningMethods = ["split"];
+const stringConstructorNewObjectReturningMethods = new Set(["split"]);
 
 /**
  * Check if the given assignment expression violates this rule.
@@ -391,27 +426,41 @@ function isInChainCallAndFollowsNew(
   }
 
   // Check for: new Array()
-  if (isNewExpression(node) && isArrayConstructorType(context, getTypeOfNode(node.callee, context))) {
-    return true;
+  if (isNewExpression(node)) {
+    const type = getTypeOfNode(node.callee, context);
+    return (
+      isArrayConstructorType(context, type) ||
+      isMapConstructorType(context, type) ||
+      isSetConstructorType(context, type)
+    );
   }
 
   if (isCallExpression(node) && isMemberExpression(node.callee) && isIdentifier(node.callee.property)) {
     // Check for: Array.from(iterable)
     if (
-      arrayConstructorFunctions.some(isExpected(node.callee.property.name)) &&
+      arrayConstructorFunctions.has(node.callee.property.name) &&
       isArrayConstructorType(context, getTypeOfNode(node.callee.object, context))
     ) {
       return true;
     }
 
     // Check for: array.slice(0)
-    if (arrayNewObjectReturningMethods.some(isExpected(node.callee.property.name))) {
+    if (arrayNewObjectReturningMethods.has(node.callee.property.name)) {
+      return true;
+    }
+
+    if (mapNewObjectReturningMethods.has(node.callee.property.name)) {
+      return true;
+    }
+
+    // Check for: set.difference(otherSet)
+    if (setNewObjectReturningMethods.has(node.callee.property.name)) {
       return true;
     }
 
     // Check for: Object.entries(object)
     if (
-      objectConstructorNewObjectReturningMethods.some(isExpected(node.callee.property.name)) &&
+      objectConstructorNewObjectReturningMethods.has(node.callee.property.name) &&
       isObjectConstructorType(context, getTypeOfNode(node.callee.object, context))
     ) {
       return true;
@@ -419,7 +468,7 @@ function isInChainCallAndFollowsNew(
 
     // Check for: "".split("")
     if (
-      stringConstructorNewObjectReturningMethods.some(isExpected(node.callee.property.name)) &&
+      stringConstructorNewObjectReturningMethods.has(node.callee.property.name) &&
       getTypeOfNode(node.callee.object, context).isStringLiteral()
     ) {
       return true;
@@ -510,6 +559,68 @@ function checkCallExpression(
     }
   }
 
+  // Set mutation?
+  if (
+    setMutatorMethods.has(node.callee.property.name) &&
+    (!ignoreImmediateMutation || !isInChainCallAndFollowsNew(node.callee, context)) &&
+    isSetType(context, getTypeOfNode(node.callee.object, context))
+  ) {
+    if (ignoreNonConstDeclarations === false) {
+      return {
+        context,
+        descriptors: [{ node, messageId: "set" }],
+      };
+    }
+    const rootIdentifier = findRootIdentifier(node.callee.object);
+    if (
+      rootIdentifier === undefined ||
+      !isDefinedByMutableVariable(
+        rootIdentifier,
+        context,
+        (variableNode) =>
+          ignoreNonConstDeclarations === true ||
+          !ignoreNonConstDeclarations.treatParametersAsConst ||
+          shouldIgnorePattern(variableNode, context, ignoreIdentifierPattern, ignoreAccessorPattern),
+      )
+    ) {
+      return {
+        context,
+        descriptors: [{ node, messageId: "set" }],
+      };
+    }
+  }
+
+  // Map mutation?
+  if (
+    mapMutatorMethods.has(node.callee.property.name) &&
+    (!ignoreImmediateMutation || !isInChainCallAndFollowsNew(node.callee, context)) &&
+    isMapType(context, getTypeOfNode(node.callee.object, context))
+  ) {
+    if (ignoreNonConstDeclarations === false) {
+      return {
+        context,
+        descriptors: [{ node, messageId: "map" }],
+      };
+    }
+    const rootIdentifier = findRootIdentifier(node.callee.object);
+    if (
+      rootIdentifier === undefined ||
+      !isDefinedByMutableVariable(
+        rootIdentifier,
+        context,
+        (variableNode) =>
+          ignoreNonConstDeclarations === true ||
+          !ignoreNonConstDeclarations.treatParametersAsConst ||
+          shouldIgnorePattern(variableNode, context, ignoreIdentifierPattern, ignoreAccessorPattern),
+      )
+    ) {
+      return {
+        context,
+        descriptors: [{ node, messageId: "map" }],
+      };
+    }
+  }
+
   // Non-array object mutation (ex. Object.assign on identifier)?
   if (
     objectConstructorMutatorFunctions.has(node.callee.property.name) &&
diff --git a/src/utils/misc.ts b/src/utils/misc.ts
index 75dc75f..6cb8cdc 100644
--- a/src/utils/misc.ts
+++ b/src/utils/misc.ts
@@ -22,13 +22,6 @@ import {
 
 export const ruleNameScope = "functional";
 
-/**
- * Higher order function to check if the two given values are the same.
- */
-export function isExpected<T>(expected: T): (actual: T) => boolean {
-  return (actual) => actual === expected;
-}
-
 /**
  * Does the given ExpressionStatement specify directive prologues.
  */
diff --git a/src/utils/type-guards.ts b/src/utils/type-guards.ts
index a33df2a..12c7f47 100644
--- a/src/utils/type-guards.ts
+++ b/src/utils/type-guards.ts
@@ -324,6 +324,14 @@ export function isArrayType(context: RuleContext<string, ReadonlyArray<unknown>>
   return typeMatches(context, "Array", type);
 }
 
+export function isMapType(context: RuleContext<string, ReadonlyArray<unknown>>, type: Type | null): boolean {
+  return typeMatches(context, "Map", type);
+}
+
+export function isSetType(context: RuleContext<string, ReadonlyArray<unknown>>, type: Type | null): boolean {
+  return typeMatches(context, "Set", type);
+}
+
 export function isArrayConstructorType(
   context: RuleContext<string, ReadonlyArray<unknown>>,
   type: Type | null,
@@ -331,6 +339,14 @@ export function isArrayConstructorType(
   return typeMatches(context, "ArrayConstructor", type);
 }
 
+export function isMapConstructorType(context: RuleContext<string, ReadonlyArray<unknown>>, type: Type | null): boolean {
+  return typeMatches(context, "MapConstructor", type);
+}
+
+export function isSetConstructorType(context: RuleContext<string, ReadonlyArray<unknown>>, type: Type | null): boolean {
+  return typeMatches(context, "SetConstructor", type);
+}
+
 export function isObjectConstructorType(
   context: RuleContext<string, ReadonlyArray<unknown>>,
   type: Type | null,


Test patch:
diff --git a/tests/rules/immutable-data/__snapshots__/map.test.ts.snap b/tests/rules/immutable-data/__snapshots__/map.test.ts.snap
new file mode 100644
index 0000000..448ebc2
--- /dev/null
+++ b/tests/rules/immutable-data/__snapshots__/map.test.ts.snap
@@ -0,0 +1,115 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`immutable-data > typescript > options > ignoreImmediateMutation > reports immediately mutation when disabled 1`] = `
+[
+  {
+    "column": 1,
+    "endColumn": 28,
+    "endLine": 1,
+    "line": 1,
+    "message": "Modifying a map is not allowed.",
+    "messageId": "map",
+    "nodeType": "CallExpression",
+    "ruleId": "immutable-data",
+    "severity": 2,
+  },
+  {
+    "column": 1,
+    "endColumn": 28,
+    "endLine": 2,
+    "line": 2,
+    "message": "Modifying a map is not allowed.",
+    "messageId": "map",
+    "nodeType": "CallExpression",
+    "ruleId": "immutable-data",
+    "severity": 2,
+  },
+  {
+    "column": 1,
+    "endColumn": 26,
+    "endLine": 3,
+    "line": 3,
+    "message": "Modifying a map is not allowed.",
+    "messageId": "map",
+    "nodeType": "CallExpression",
+    "ruleId": "immutable-data",
+    "severity": 2,
+  },
+]
+`;
+
+exports[`immutable-data > typescript > options > ignoreNonConstDeclarations > reports variables declared as const 1`] = `
+[
+  {
+    "column": 1,
+    "endColumn": 12,
+    "endLine": 2,
+    "line": 2,
+    "message": "Modifying a map is not allowed.",
+    "messageId": "map",
+    "nodeType": "CallExpression",
+    "ruleId": "immutable-data",
+    "severity": 2,
+  },
+  {
+    "column": 1,
+    "endColumn": 12,
+    "endLine": 3,
+    "line": 3,
+    "message": "Modifying a map is not allowed.",
+    "messageId": "map",
+    "nodeType": "CallExpression",
+    "ruleId": "immutable-data",
+    "severity": 2,
+  },
+  {
+    "column": 1,
+    "endColumn": 10,
+    "endLine": 4,
+    "line": 4,
+    "message": "Modifying a map is not allowed.",
+    "messageId": "map",
+    "nodeType": "CallExpression",
+    "ruleId": "immutable-data",
+    "severity": 2,
+  },
+]
+`;
+
+exports[`immutable-data > typescript > report mutating map methods 1`] = `
+[
+  {
+    "column": 1,
+    "endColumn": 12,
+    "endLine": 2,
+    "line": 2,
+    "message": "Modifying a map is not allowed.",
+    "messageId": "map",
+    "nodeType": "CallExpression",
+    "ruleId": "immutable-data",
+    "severity": 2,
+  },
+  {
+    "column": 1,
+    "endColumn": 12,
+    "endLine": 3,
+    "line": 3,
+    "message": "Modifying a map is not allowed.",
+    "messageId": "map",
+    "nodeType": "CallExpression",
+    "ruleId": "immutable-data",
+    "severity": 2,
+  },
+  {
+    "column": 1,
+    "endColumn": 10,
+    "endLine": 4,
+    "line": 4,
+    "message": "Modifying a map is not allowed.",
+    "messageId": "map",
+    "nodeType": "CallExpression",
+    "ruleId": "immutable-data",
+    "severity": 2,
+  },
+]
+`;
diff --git a/tests/rules/immutable-data/__snapshots__/set.test.ts.snap b/tests/rules/immutable-data/__snapshots__/set.test.ts.snap
new file mode 100644
index 0000000..9767b2d
--- /dev/null
+++ b/tests/rules/immutable-data/__snapshots__/set.test.ts.snap
@@ -0,0 +1,115 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`immutable-data > typescript > options > ignoreImmediateMutation > reports immediately mutation when disabled 1`] = `
+[
+  {
+    "column": 1,
+    "endColumn": 23,
+    "endLine": 1,
+    "line": 1,
+    "message": "Modifying a set is not allowed.",
+    "messageId": "set",
+    "nodeType": "CallExpression",
+    "ruleId": "immutable-data",
+    "severity": 2,
+  },
+  {
+    "column": 1,
+    "endColumn": 26,
+    "endLine": 2,
+    "line": 2,
+    "message": "Modifying a set is not allowed.",
+    "messageId": "set",
+    "nodeType": "CallExpression",
+    "ruleId": "immutable-data",
+    "severity": 2,
+  },
+  {
+    "column": 1,
+    "endColumn": 24,
+    "endLine": 3,
+    "line": 3,
+    "message": "Modifying a set is not allowed.",
+    "messageId": "set",
+    "nodeType": "CallExpression",
+    "ruleId": "immutable-data",
+    "severity": 2,
+  },
+]
+`;
+
+exports[`immutable-data > typescript > options > ignoreNonConstDeclarations > reports variables declared as const 1`] = `
+[
+  {
+    "column": 1,
+    "endColumn": 9,
+    "endLine": 2,
+    "line": 2,
+    "message": "Modifying a set is not allowed.",
+    "messageId": "set",
+    "nodeType": "CallExpression",
+    "ruleId": "immutable-data",
+    "severity": 2,
+  },
+  {
+    "column": 1,
+    "endColumn": 12,
+    "endLine": 3,
+    "line": 3,
+    "message": "Modifying a set is not allowed.",
+    "messageId": "set",
+    "nodeType": "CallExpression",
+    "ruleId": "immutable-data",
+    "severity": 2,
+  },
+  {
+    "column": 1,
+    "endColumn": 10,
+    "endLine": 4,
+    "line": 4,
+    "message": "Modifying a set is not allowed.",
+    "messageId": "set",
+    "nodeType": "CallExpression",
+    "ruleId": "immutable-data",
+    "severity": 2,
+  },
+]
+`;
+
+exports[`immutable-data > typescript > report mutating set methods 1`] = `
+[
+  {
+    "column": 1,
+    "endColumn": 9,
+    "endLine": 2,
+    "line": 2,
+    "message": "Modifying a set is not allowed.",
+    "messageId": "set",
+    "nodeType": "CallExpression",
+    "ruleId": "immutable-data",
+    "severity": 2,
+  },
+  {
+    "column": 1,
+    "endColumn": 12,
+    "endLine": 3,
+    "line": 3,
+    "message": "Modifying a set is not allowed.",
+    "messageId": "set",
+    "nodeType": "CallExpression",
+    "ruleId": "immutable-data",
+    "severity": 2,
+  },
+  {
+    "column": 1,
+    "endColumn": 10,
+    "endLine": 4,
+    "line": 4,
+    "message": "Modifying a set is not allowed.",
+    "messageId": "set",
+    "nodeType": "CallExpression",
+    "ruleId": "immutable-data",
+    "severity": 2,
+  },
+]
+`;
diff --git a/tests/rules/immutable-data/map.test.ts b/tests/rules/immutable-data/map.test.ts
new file mode 100644
index 0000000..dd7f425
--- /dev/null
+++ b/tests/rules/immutable-data/map.test.ts
@@ -0,0 +1,128 @@
+import dedent from "dedent";
+import { createRuleTester } from "eslint-vitest-rule-tester";
+import { describe, expect, it } from "vitest";
+
+import { name, rule } from "#/rules/immutable-data";
+
+import { typescriptConfig } from "../../utils/configs";
+
+describe(name, () => {
+  describe("typescript", () => {
+    const { valid, invalid } = createRuleTester({
+      name,
+      rule,
+      configs: typescriptConfig,
+    });
+
+    it("report mutating map methods", () => {
+      const invalidResult = invalid({
+        code: dedent`
+          const x = new Map([[5, 6]]);
+          x.set(4, 8);
+          x.delete(4);
+          x.clear();
+        `,
+        options: [],
+        errors: ["map", "map", "map"],
+      });
+      expect(invalidResult.messages).toMatchSnapshot();
+    });
+
+    it("doesn't report non-mutating map methods", () => {
+      valid(dedent`
+        const x = new Map([[5, 6]]);
+        x.size;
+        x.has(4);
+        x.values();
+        x.entries();
+        x.keys();
+      `);
+    });
+
+    it("doesn't report mutating map methods on non-map objects", () => {
+      valid(dedent`
+        const z = {
+          set: function () {},
+          delete: function () {},
+          clear: function () {},
+        };
+
+        z.set();
+        z.delete();
+        z.clear();
+      `);
+    });
+
+    describe("options", () => {
+      describe("ignoreNonConstDeclarations", () => {
+        it("reports variables declared as const", () => {
+          const invalidResult = invalid({
+            code: dedent`
+              const x = new Map([[5, 6]]);
+              x.set(4, 8);
+              x.delete(4);
+              x.clear();
+            `,
+            options: [
+              {
+                ignoreNonConstDeclarations: true,
+              },
+            ],
+            errors: ["map", "map", "map"],
+          });
+          expect(invalidResult.messages).toMatchSnapshot();
+        });
+
+        it("doesn't report variables not declared as const", () => {
+          valid({
+            code: dedent`
+              let x = new Map([[5, 6]]);
+              x.set(4, 8);
+              x.delete(4);
+              x.clear();
+            `,
+            options: [
+              {
+                ignoreNonConstDeclarations: true,
+              },
+            ],
+          });
+        });
+      });
+
+      describe("ignoreImmediateMutation", () => {
+        it("doesn't report immediately mutation when enabled", () => {
+          valid({
+            code: dedent`
+              new Map([[5, 6]]).set(4, 8);
+              new Map([[5, 6]]).delete(4);
+              new Map([[5, 6]]).clear();
+            `,
+            options: [
+              {
+                ignoreImmediateMutation: true,
+              },
+            ],
+          });
+        });
+
+        it("reports immediately mutation when disabled", () => {
+          const invalidResult = invalid({
+            code: dedent`
+              new Map([[5, 6]]).set(4, 8);
+              new Map([[5, 6]]).delete(4);
+              new Map([[5, 6]]).clear();
+            `,
+            options: [
+              {
+                ignoreImmediateMutation: false,
+              },
+            ],
+            errors: ["map", "map", "map"],
+          });
+          expect(invalidResult.messages).toMatchSnapshot();
+        });
+      });
+    });
+  });
+});
diff --git a/tests/rules/immutable-data/set.test.ts b/tests/rules/immutable-data/set.test.ts
new file mode 100644
index 0000000..6cda1bf
--- /dev/null
+++ b/tests/rules/immutable-data/set.test.ts
@@ -0,0 +1,178 @@
+import dedent from "dedent";
+import { createRuleTester } from "eslint-vitest-rule-tester";
+import { describe, expect, it } from "vitest";
+
+import { name, rule } from "#/rules/immutable-data";
+
+import { typescriptConfig } from "../../utils/configs";
+
+describe(name, () => {
+  describe("typescript", () => {
+    const { valid, invalid } = createRuleTester({
+      name,
+      rule,
+      configs: typescriptConfig,
+    });
+
+    it("report mutating set methods", () => {
+      const invalidResult = invalid({
+        code: dedent`
+          const x = new Set([5, 6]);
+          x.add(4);
+          x.delete(4);
+          x.clear();
+        `,
+        options: [],
+        errors: ["set", "set", "set"],
+      });
+      expect(invalidResult.messages).toMatchSnapshot();
+    });
+
+    it("doesn't report non-mutating set methods", () => {
+      valid(dedent`
+        const x = new Set([5, 6]);
+        x.size;
+        x.has(4);
+        x.values();
+        x.entries();
+        x.keys();
+        x.difference(new Set([1, 2, 3]));
+        x.intersection(new Set([1, 2, 3]));
+        x.symmetricDifference(new Set([1, 2, 3]));
+        x.union(new Set([1, 2, 3]));
+      `);
+    });
+
+    it("allows mutating set methods to be chained to new set methods", () => {
+      valid(dedent`
+        const x = new Set([5, 6]);
+
+        x.difference(new Set([1, 2, 3])).add(4);
+        x.difference(new Set([1, 2, 3])).add(4);
+        x.difference(new Set([1, 2, 3])).add(4);
+        x.difference(new Set([1, 2, 3])).add(4);
+        x.difference(new Set([1, 2, 3])).add(4);
+        x.difference(new Set([1, 2, 3])).add(4);
+        x.difference(new Set([1, 2, 3])).add(4);
+        x.difference(new Set([1, 2, 3])).add(4);
+        x.difference(new Set([1, 2, 3])).add(4);
+
+        x.intersection(new Set([1, 2, 3])).add(4);
+        x.intersection(new Set([1, 2, 3])).add(4);
+        x.intersection(new Set([1, 2, 3])).add(4);
+        x.intersection(new Set([1, 2, 3])).add(4);
+        x.intersection(new Set([1, 2, 3])).add(4);
+        x.intersection(new Set([1, 2, 3])).add(4);
+        x.intersection(new Set([1, 2, 3])).add(4);
+        x.intersection(new Set([1, 2, 3])).add(4);
+        x.intersection(new Set([1, 2, 3])).add(4);
+
+        x.symmetricDifference(new Set([1, 2, 3])).add(4);
+        x.symmetricDifference(new Set([1, 2, 3])).add(4);
+        x.symmetricDifference(new Set([1, 2, 3])).add(4);
+        x.symmetricDifference(new Set([1, 2, 3])).add(4);
+        x.symmetricDifference(new Set([1, 2, 3])).add(4);
+        x.symmetricDifference(new Set([1, 2, 3])).add(4);
+        x.symmetricDifference(new Set([1, 2, 3])).add(4);
+        x.symmetricDifference(new Set([1, 2, 3])).add(4);
+        x.symmetricDifference(new Set([1, 2, 3])).add(4);
+
+        x.union(new Set([1, 2, 3])).add(4);
+        x.union(new Set([1, 2, 3])).add(4);
+        x.union(new Set([1, 2, 3])).add(4);
+        x.union(new Set([1, 2, 3])).add(4);
+        x.union(new Set([1, 2, 3])).add(4);
+        x.union(new Set([1, 2, 3])).add(4);
+        x.union(new Set([1, 2, 3])).add(4);
+        x.union(new Set([1, 2, 3])).add(4);
+        x.union(new Set([1, 2, 3])).add(4);
+      `);
+    });
+
+    it("doesn't report mutating set methods on non-set objects", () => {
+      valid(dedent`
+        const z = {
+          add: function () {},
+          delete: function () {},
+          clear: function () {},
+        };
+
+        z.add();
+        z.delete();
+        z.clear();
+      `);
+    });
+
+    describe("options", () => {
+      describe("ignoreNonConstDeclarations", () => {
+        it("reports variables declared as const", () => {
+          const invalidResult = invalid({
+            code: dedent`
+              const x = new Set([5, 6]);
+              x.add(4);
+              x.delete(4);
+              x.clear();
+            `,
+            options: [
+              {
+                ignoreNonConstDeclarations: true,
+              },
+            ],
+            errors: ["set", "set", "set"],
+          });
+          expect(invalidResult.messages).toMatchSnapshot();
+        });
+
+        it("doesn't report variables not declared as const", () => {
+          valid({
+            code: dedent`
+              let x = new Set([5, 6]);
+              x.add(4);
+              x.delete(4);
+              x.clear();
+            `,
+            options: [
+              {
+                ignoreNonConstDeclarations: true,
+              },
+            ],
+          });
+        });
+      });
+
+      describe("ignoreImmediateMutation", () => {
+        it("doesn't report immediately mutation when enabled", () => {
+          valid({
+            code: dedent`
+              new Set([5, 6]).add(4);
+              new Set([5, 6]).delete(4);
+              new Set([5, 6]).clear();
+            `,
+            options: [
+              {
+                ignoreImmediateMutation: true,
+              },
+            ],
+          });
+        });
+
+        it("reports immediately mutation when disabled", () => {
+          const invalidResult = invalid({
+            code: dedent`
+              new Set([5, 6]).add(4);
+              new Set([5, 6]).delete(4);
+              new Set([5, 6]).clear();
+            `,
+            options: [
+              {
+                ignoreImmediateMutation: false,
+              },
+            ],
+            errors: ["set", "set", "set"],
+          });
+          expect(invalidResult.messages).toMatchSnapshot();
+        });
+      });
+    });
+  });
+});