Skip to content

Commit 0dad714

Browse files
feat(immutable-data): add support for detecting map and set mutations
BREAKING CHANGE: map and set mutations will now be reported fix #934
1 parent b00d98c commit 0dad714

File tree

9 files changed

+697
-19
lines changed

9 files changed

+697
-19
lines changed

docs/rules/immutable-data.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ This rule accepts an options object of the following type:
6767
```ts
6868
type Options = {
6969
ignoreClasses: boolean | "fieldsOnly";
70+
ignoreMapsAndSets: boolean;
7071
ignoreImmediateMutation: boolean;
7172
ignoreNonConstDeclarations:
7273
| boolean
@@ -113,6 +114,7 @@ type Options = {
113114
```ts
114115
type Options = {
115116
ignoreClasses: false;
117+
ignoreMapsAndSets: false;
116118
ignoreImmediateMutation: true;
117119
ignoreNonConstDeclarations: false;
118120
};
@@ -159,6 +161,10 @@ Ignore mutations inside classes.
159161

160162
Classes already aren't functional so ignore mutations going on inside them.
161163

164+
### `ignoreMapsAndSets`
165+
166+
Ignore mutations of builtin `Map`s and `Set`s.
167+
162168
### `ignoreIdentifierPattern`
163169

164170
This option takes a RegExp string or an array of RegExp strings.

src/options/ignore.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,22 @@ export const ignoreClassesOptionSchema: JSONSchema4ObjectSchema["properties"] =
9595
},
9696
};
9797

98+
/**
99+
* The option to ignore mapsAndSets.
100+
*/
101+
export type IgnoreMapsAndSetsOption = Readonly<{
102+
ignoreMapsAndSets: boolean;
103+
}>;
104+
105+
/**
106+
* The schema for the option to ignore maps and sets.
107+
*/
108+
export const ignoreMapsAndSetsOptionSchema: JSONSchema4ObjectSchema["properties"] = {
109+
ignoreMapsAndSets: {
110+
type: "boolean",
111+
},
112+
};
113+
98114
/**
99115
* The option to ignore prefix selector.
100116
*/

src/rules/immutable-data.ts

Lines changed: 123 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,19 @@ import {
77
type IgnoreAccessorPatternOption,
88
type IgnoreClassesOption,
99
type IgnoreIdentifierPatternOption,
10+
type IgnoreMapsAndSetsOption,
1011
type OverridableOptions,
1112
type RawOverridableOptions,
1213
getCoreOptions,
1314
ignoreAccessorPatternOptionSchema,
1415
ignoreClassesOptionSchema,
1516
ignoreIdentifierPatternOptionSchema,
17+
ignoreMapsAndSetsOptionSchema,
1618
shouldIgnoreClasses,
1719
shouldIgnorePattern,
1820
upgradeRawOverridableOptions,
1921
} from "#/options";
20-
import { isExpected, ruleNameScope } from "#/utils/misc";
22+
import { ruleNameScope } from "#/utils/misc";
2123
import { type NamedCreateRuleCustomMeta, type Rule, type RuleResult, createRule, getTypeOfNode } from "#/utils/rule";
2224
import { overridableOptionsSchema } from "#/utils/schemas";
2325
import { findRootIdentifier, isDefinedByMutableVariable, isInConstructor } from "#/utils/tree";
@@ -27,9 +29,13 @@ import {
2729
isArrayType,
2830
isCallExpression,
2931
isIdentifier,
32+
isMapConstructorType,
33+
isMapType,
3034
isMemberExpression,
3135
isNewExpression,
3236
isObjectConstructorType,
37+
isSetConstructorType,
38+
isSetType,
3339
isTSAsExpression,
3440
} from "#/utils/type-guards";
3541

@@ -45,6 +51,7 @@ export const fullName: `${typeof ruleNameScope}/${typeof name}` = `${ruleNameSco
4551

4652
type CoreOptions = IgnoreAccessorPatternOption &
4753
IgnoreClassesOption &
54+
IgnoreMapsAndSetsOption &
4855
IgnoreIdentifierPatternOption & {
4956
ignoreImmediateMutation: boolean;
5057
ignoreNonConstDeclarations:
@@ -64,6 +71,7 @@ const coreOptionsPropertiesSchema = deepmerge(
6471
ignoreIdentifierPatternOptionSchema,
6572
ignoreAccessorPatternOptionSchema,
6673
ignoreClassesOptionSchema,
74+
ignoreMapsAndSetsOptionSchema,
6775
{
6876
ignoreImmediateMutation: {
6977
type: "boolean",
@@ -98,6 +106,7 @@ const schema: JSONSchema4[] = [overridableOptionsSchema(coreOptionsPropertiesSch
98106
const defaultOptions = [
99107
{
100108
ignoreClasses: false,
109+
ignoreMapsAndSets: false,
101110
ignoreImmediateMutation: true,
102111
ignoreNonConstDeclarations: false,
103112
},
@@ -110,6 +119,8 @@ const errorMessages = {
110119
generic: "Modifying an existing object/array is not allowed.",
111120
object: "Modifying properties of existing object not allowed.",
112121
array: "Modifying an array is not allowed.",
122+
map: "Modifying a map is not allowed.",
123+
set: "Modifying a set is not allowed.",
113124
} as const;
114125

115126
/**
@@ -151,14 +162,38 @@ const arrayMutatorMethods = new Set([
151162
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/prototype#Methods#Accessor_methods
152163
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/prototype#Iteration_methods
153164
*/
154-
const arrayNewObjectReturningMethods = ["concat", "slice", "filter", "map", "reduce", "reduceRight"];
165+
const arrayNewObjectReturningMethods = new Set(["concat", "slice", "filter", "map", "reduce", "reduceRight"]);
155166

156167
/**
157168
* Array constructor functions that create a new array.
158169
*
159170
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array#Methods
160171
*/
161-
const arrayConstructorFunctions = ["from", "of"];
172+
const arrayConstructorFunctions = new Set(["from", "of"]);
173+
174+
/**
175+
* Map methods that mutate an map.
176+
*
177+
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map
178+
*/
179+
const mapMutatorMethods = new Set(["clear", "delete", "set"]);
180+
181+
/**
182+
* Map methods that return a new object without mutating the original.
183+
*/
184+
const mapNewObjectReturningMethods = new Set<string>([]);
185+
186+
/**
187+
* Set methods that mutate an set.
188+
*
189+
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set
190+
*/
191+
const setMutatorMethods = new Set(["add", "clear", "delete"]);
192+
193+
/**
194+
* Set methods that return a new object without mutating the original.
195+
*/
196+
const setNewObjectReturningMethods = new Set(["difference", "intersection", "symmetricDifference", "union"]);
162197

163198
/**
164199
* Object constructor functions that mutate an object.
@@ -170,7 +205,7 @@ const objectConstructorMutatorFunctions = new Set(["assign", "defineProperties",
170205
/**
171206
* Object constructor functions that return new objects.
172207
*/
173-
const objectConstructorNewObjectReturningMethods = [
208+
const objectConstructorNewObjectReturningMethods = new Set([
174209
"create",
175210
"entries",
176211
"fromEntries",
@@ -181,14 +216,14 @@ const objectConstructorNewObjectReturningMethods = [
181216
"groupBy",
182217
"keys",
183218
"values",
184-
];
219+
]);
185220

186221
/**
187222
* String constructor functions that return new objects.
188223
*
189224
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String#Methods
190225
*/
191-
const stringConstructorNewObjectReturningMethods = ["split"];
226+
const stringConstructorNewObjectReturningMethods = new Set(["split"]);
192227

193228
/**
194229
* Check if the given assignment expression violates this rule.
@@ -391,35 +426,49 @@ function isInChainCallAndFollowsNew(
391426
}
392427

393428
// Check for: new Array()
394-
if (isNewExpression(node) && isArrayConstructorType(context, getTypeOfNode(node.callee, context))) {
395-
return true;
429+
if (isNewExpression(node)) {
430+
const type = getTypeOfNode(node.callee, context);
431+
return (
432+
isArrayConstructorType(context, type) ||
433+
isMapConstructorType(context, type) ||
434+
isSetConstructorType(context, type)
435+
);
396436
}
397437

398438
if (isCallExpression(node) && isMemberExpression(node.callee) && isIdentifier(node.callee.property)) {
399439
// Check for: Array.from(iterable)
400440
if (
401-
arrayConstructorFunctions.some(isExpected(node.callee.property.name)) &&
441+
arrayConstructorFunctions.has(node.callee.property.name) &&
402442
isArrayConstructorType(context, getTypeOfNode(node.callee.object, context))
403443
) {
404444
return true;
405445
}
406446

407447
// Check for: array.slice(0)
408-
if (arrayNewObjectReturningMethods.some(isExpected(node.callee.property.name))) {
448+
if (arrayNewObjectReturningMethods.has(node.callee.property.name)) {
449+
return true;
450+
}
451+
452+
if (mapNewObjectReturningMethods.has(node.callee.property.name)) {
453+
return true;
454+
}
455+
456+
// Check for: set.difference(otherSet)
457+
if (setNewObjectReturningMethods.has(node.callee.property.name)) {
409458
return true;
410459
}
411460

412461
// Check for: Object.entries(object)
413462
if (
414-
objectConstructorNewObjectReturningMethods.some(isExpected(node.callee.property.name)) &&
463+
objectConstructorNewObjectReturningMethods.has(node.callee.property.name) &&
415464
isObjectConstructorType(context, getTypeOfNode(node.callee.object, context))
416465
) {
417466
return true;
418467
}
419468

420469
// Check for: "".split("")
421470
if (
422-
stringConstructorNewObjectReturningMethods.some(isExpected(node.callee.property.name)) &&
471+
stringConstructorNewObjectReturningMethods.has(node.callee.property.name) &&
423472
getTypeOfNode(node.callee.object, context).isStringLiteral()
424473
) {
425474
return true;
@@ -510,6 +559,68 @@ function checkCallExpression(
510559
}
511560
}
512561

562+
// Set mutation?
563+
if (
564+
setMutatorMethods.has(node.callee.property.name) &&
565+
(!ignoreImmediateMutation || !isInChainCallAndFollowsNew(node.callee, context)) &&
566+
isSetType(context, getTypeOfNode(node.callee.object, context))
567+
) {
568+
if (ignoreNonConstDeclarations === false) {
569+
return {
570+
context,
571+
descriptors: [{ node, messageId: "set" }],
572+
};
573+
}
574+
const rootIdentifier = findRootIdentifier(node.callee.object);
575+
if (
576+
rootIdentifier === undefined ||
577+
!isDefinedByMutableVariable(
578+
rootIdentifier,
579+
context,
580+
(variableNode) =>
581+
ignoreNonConstDeclarations === true ||
582+
!ignoreNonConstDeclarations.treatParametersAsConst ||
583+
shouldIgnorePattern(variableNode, context, ignoreIdentifierPattern, ignoreAccessorPattern),
584+
)
585+
) {
586+
return {
587+
context,
588+
descriptors: [{ node, messageId: "set" }],
589+
};
590+
}
591+
}
592+
593+
// Map mutation?
594+
if (
595+
mapMutatorMethods.has(node.callee.property.name) &&
596+
(!ignoreImmediateMutation || !isInChainCallAndFollowsNew(node.callee, context)) &&
597+
isMapType(context, getTypeOfNode(node.callee.object, context))
598+
) {
599+
if (ignoreNonConstDeclarations === false) {
600+
return {
601+
context,
602+
descriptors: [{ node, messageId: "map" }],
603+
};
604+
}
605+
const rootIdentifier = findRootIdentifier(node.callee.object);
606+
if (
607+
rootIdentifier === undefined ||
608+
!isDefinedByMutableVariable(
609+
rootIdentifier,
610+
context,
611+
(variableNode) =>
612+
ignoreNonConstDeclarations === true ||
613+
!ignoreNonConstDeclarations.treatParametersAsConst ||
614+
shouldIgnorePattern(variableNode, context, ignoreIdentifierPattern, ignoreAccessorPattern),
615+
)
616+
) {
617+
return {
618+
context,
619+
descriptors: [{ node, messageId: "map" }],
620+
};
621+
}
622+
}
623+
513624
// Non-array object mutation (ex. Object.assign on identifier)?
514625
if (
515626
objectConstructorMutatorFunctions.has(node.callee.property.name) &&

src/utils/misc.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,6 @@ import {
2222

2323
export const ruleNameScope = "functional";
2424

25-
/**
26-
* Higher order function to check if the two given values are the same.
27-
*/
28-
export function isExpected<T>(expected: T): (actual: T) => boolean {
29-
return (actual) => actual === expected;
30-
}
31-
3225
/**
3326
* Does the given ExpressionStatement specify directive prologues.
3427
*/

src/utils/type-guards.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,13 +324,29 @@ export function isArrayType(context: RuleContext<string, ReadonlyArray<unknown>>
324324
return typeMatches(context, "Array", type);
325325
}
326326

327+
export function isMapType(context: RuleContext<string, ReadonlyArray<unknown>>, type: Type | null): boolean {
328+
return typeMatches(context, "Map", type);
329+
}
330+
331+
export function isSetType(context: RuleContext<string, ReadonlyArray<unknown>>, type: Type | null): boolean {
332+
return typeMatches(context, "Set", type);
333+
}
334+
327335
export function isArrayConstructorType(
328336
context: RuleContext<string, ReadonlyArray<unknown>>,
329337
type: Type | null,
330338
): boolean {
331339
return typeMatches(context, "ArrayConstructor", type);
332340
}
333341

342+
export function isMapConstructorType(context: RuleContext<string, ReadonlyArray<unknown>>, type: Type | null): boolean {
343+
return typeMatches(context, "MapConstructor", type);
344+
}
345+
346+
export function isSetConstructorType(context: RuleContext<string, ReadonlyArray<unknown>>, type: Type | null): boolean {
347+
return typeMatches(context, "SetConstructor", type);
348+
}
349+
334350
export function isObjectConstructorType(
335351
context: RuleContext<string, ReadonlyArray<unknown>>,
336352
type: Type | null,

0 commit comments

Comments
 (0)