From 0386f3a3a44457dcab78df3f6583e8c3aa6194c4 Mon Sep 17 00:00:00 2001 From: RebeccaStevens Date: Sun, 23 Feb 2025 23:32:19 +0000 Subject: [PATCH] feat(immutable-data): add support for detecting map and set mutations (#935) BREAKING CHANGE: map and set mutations will now be reported fix #934 --- docs/rules/immutable-data.md | 6 + src/options/ignore.ts | 16 ++ src/rules/immutable-data.ts | 135 +++++++++++-- src/utils/misc.ts | 7 - src/utils/type-guards.ts | 16 ++ .../__snapshots__/map.test.ts.snap | 115 +++++++++++ .../__snapshots__/set.test.ts.snap | 115 +++++++++++ tests/rules/immutable-data/map.test.ts | 128 +++++++++++++ tests/rules/immutable-data/set.test.ts | 178 ++++++++++++++++++ 9 files changed, 697 insertions(+), 19 deletions(-) create mode 100644 tests/rules/immutable-data/__snapshots__/map.test.ts.snap create mode 100644 tests/rules/immutable-data/__snapshots__/set.test.ts.snap create mode 100644 tests/rules/immutable-data/map.test.ts create mode 100644 tests/rules/immutable-data/set.test.ts diff --git a/docs/rules/immutable-data.md b/docs/rules/immutable-data.md index 490e40d39..8fde328b6 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 54a066d07..3279b140f 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 635712ad6..0675a1e8d 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([]); + +/** + * 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 75dc75fdc..6cb8cdc6e 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(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 a33df2a61..12c7f476b 100644 --- a/src/utils/type-guards.ts +++ b/src/utils/type-guards.ts @@ -324,6 +324,14 @@ export function isArrayType(context: RuleContext> return typeMatches(context, "Array", type); } +export function isMapType(context: RuleContext>, type: Type | null): boolean { + return typeMatches(context, "Map", type); +} + +export function isSetType(context: RuleContext>, type: Type | null): boolean { + return typeMatches(context, "Set", type); +} + export function isArrayConstructorType( context: RuleContext>, type: Type | null, @@ -331,6 +339,14 @@ export function isArrayConstructorType( return typeMatches(context, "ArrayConstructor", type); } +export function isMapConstructorType(context: RuleContext>, type: Type | null): boolean { + return typeMatches(context, "MapConstructor", type); +} + +export function isSetConstructorType(context: RuleContext>, type: Type | null): boolean { + return typeMatches(context, "SetConstructor", type); +} + export function isObjectConstructorType( context: RuleContext>, type: Type | null, 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 000000000..448ebc273 --- /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 000000000..9767b2d7d --- /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 000000000..dd7f42519 --- /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 000000000..6cda1bfa7 --- /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(); + }); + }); + }); + }); +});