Skip to content

Commit ecde24a

Browse files
feat(immutable-data): add new option ignoreNonConstDeclarations
fix #691
1 parent 180e191 commit ecde24a

File tree

7 files changed

+227
-11
lines changed

7 files changed

+227
-11
lines changed

docs/rules/immutable-data.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ This rule accepts an options object of the following type:
6161
type Options = {
6262
ignoreClasses: boolean | "fieldsOnly";
6363
ignoreImmediateMutation: boolean;
64+
ignoreNonConstDeclarations: boolean;
6465
ignoreIdentifierPattern?: string[] | string;
6566
ignoreAccessorPattern?: string[] | string;
6667
};
@@ -72,6 +73,7 @@ type Options = {
7273
type Options = {
7374
ignoreClasses: false;
7475
ignoreImmediateMutation: true;
76+
ignoreNonConstDeclarations: false;
7577
};
7678
```
7779

@@ -97,6 +99,11 @@ const original = ["foo", "bar", "baz"];
9799
const sorted = [...original].sort((a, b) => a.localeCompare(b)); // This is OK with ignoreImmediateMutation.
98100
```
99101

102+
### `ignoreNonConstDeclarations`
103+
104+
If true, this rule will ignore any mutations that happen on non-const variables.
105+
This allow for more easily using mutable data by simply using the `let` keyword instead of `const`.
106+
100107
### `ignoreClasses`
101108

102109
Ignore mutations inside classes.

src/rules/immutable-data.ts

Lines changed: 106 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import {
55
} from "@typescript-eslint/utils/json-schema";
66
import { type RuleContext } from "@typescript-eslint/utils/ts-eslint";
77
import { deepmerge } from "deepmerge-ts";
8+
import { isNodeFlagSet } from "ts-api-utils";
9+
import type ts from "typescript";
810

11+
import typescript from "#eslint-plugin-functional/conditional-imports/typescript";
912
import {
1013
type IgnoreAccessorPatternOption,
1114
type IgnoreIdentifierPatternOption,
@@ -52,6 +55,7 @@ type Options = [
5255
IgnoreClassesOption &
5356
IgnoreIdentifierPatternOption & {
5457
ignoreImmediateMutation: boolean;
58+
ignoreNonConstDeclarations: boolean;
5559
},
5660
];
5761

@@ -69,6 +73,9 @@ const schema: JSONSchema4[] = [
6973
ignoreImmediateMutation: {
7074
type: "boolean",
7175
},
76+
ignoreNonConstDeclarations: {
77+
type: "boolean",
78+
},
7279
} satisfies JSONSchema4ObjectSchema["properties"],
7380
),
7481
additionalProperties: false,
@@ -82,6 +89,7 @@ const defaultOptions: Options = [
8289
{
8390
ignoreClasses: false,
8491
ignoreImmediateMutation: true,
92+
ignoreNonConstDeclarations: false,
8593
},
8694
];
8795

@@ -158,6 +166,39 @@ const objectConstructorMutatorFunctions = new Set([
158166
"setPrototypeOf",
159167
]);
160168

169+
/**
170+
* Is the given identifier defined by a mutable variable (let or var)?
171+
*/
172+
function isDefinedByMutableVaraible(
173+
node: TSESTree.Identifier,
174+
context: Readonly<RuleContext<keyof typeof errorMessages, Options>>,
175+
) {
176+
if (typescript === undefined) {
177+
return true;
178+
}
179+
180+
const tsNode = context.parserServices?.esTreeNodeToTSNodeMap.get(node);
181+
const variableDeclaration =
182+
tsNode !== undefined &&
183+
"flowNode" in tsNode &&
184+
typeof tsNode.flowNode === "object" &&
185+
tsNode.flowNode !== null &&
186+
"node" in tsNode.flowNode &&
187+
typeof tsNode.flowNode.node === "object" &&
188+
tsNode.flowNode.node !== null &&
189+
typescript.isVariableDeclaration(tsNode.flowNode.node as ts.Node)
190+
? (tsNode.flowNode.node as ts.VariableDeclaration)
191+
: undefined;
192+
193+
const variableDeclarationList = variableDeclaration?.parent;
194+
195+
return (
196+
variableDeclarationList === undefined ||
197+
!typescript.isVariableDeclarationList(variableDeclarationList) ||
198+
!isNodeFlagSet(variableDeclarationList, typescript.NodeFlags.Const)
199+
);
200+
}
201+
161202
/**
162203
* Check if the given assignment expression violates this rule.
163204
*/
@@ -167,8 +208,12 @@ function checkAssignmentExpression(
167208
options: Readonly<Options>,
168209
): RuleResult<keyof typeof errorMessages, Options> {
169210
const [optionsObject] = options;
170-
const { ignoreIdentifierPattern, ignoreAccessorPattern, ignoreClasses } =
171-
optionsObject;
211+
const {
212+
ignoreIdentifierPattern,
213+
ignoreAccessorPattern,
214+
ignoreNonConstDeclarations,
215+
ignoreClasses,
216+
} = optionsObject;
172217

173218
if (
174219
!isMemberExpression(node.left) ||
@@ -186,6 +231,17 @@ function checkAssignmentExpression(
186231
};
187232
}
188233

234+
if (
235+
ignoreNonConstDeclarations &&
236+
isIdentifier(node.left.object) &&
237+
isDefinedByMutableVaraible(node.left.object, context)
238+
) {
239+
return {
240+
context,
241+
descriptors: [],
242+
};
243+
}
244+
189245
return {
190246
context,
191247
descriptors:
@@ -203,8 +259,12 @@ function checkUnaryExpression(
203259
options: Readonly<Options>,
204260
): RuleResult<keyof typeof errorMessages, Options> {
205261
const [optionsObject] = options;
206-
const { ignoreIdentifierPattern, ignoreAccessorPattern, ignoreClasses } =
207-
optionsObject;
262+
const {
263+
ignoreIdentifierPattern,
264+
ignoreAccessorPattern,
265+
ignoreNonConstDeclarations,
266+
ignoreClasses,
267+
} = optionsObject;
208268

209269
if (
210270
!isMemberExpression(node.argument) ||
@@ -222,6 +282,17 @@ function checkUnaryExpression(
222282
};
223283
}
224284

285+
if (
286+
ignoreNonConstDeclarations &&
287+
isIdentifier(node.argument.object) &&
288+
isDefinedByMutableVaraible(node.argument.object, context)
289+
) {
290+
return {
291+
context,
292+
descriptors: [],
293+
};
294+
}
295+
225296
return {
226297
context,
227298
descriptors:
@@ -238,8 +309,12 @@ function checkUpdateExpression(
238309
options: Readonly<Options>,
239310
): RuleResult<keyof typeof errorMessages, Options> {
240311
const [optionsObject] = options;
241-
const { ignoreIdentifierPattern, ignoreAccessorPattern, ignoreClasses } =
242-
optionsObject;
312+
const {
313+
ignoreIdentifierPattern,
314+
ignoreAccessorPattern,
315+
ignoreNonConstDeclarations,
316+
ignoreClasses,
317+
} = optionsObject;
243318

244319
if (
245320
!isMemberExpression(node.argument) ||
@@ -257,6 +332,17 @@ function checkUpdateExpression(
257332
};
258333
}
259334

335+
if (
336+
ignoreNonConstDeclarations &&
337+
isIdentifier(node.argument.object) &&
338+
isDefinedByMutableVaraible(node.argument.object, context)
339+
) {
340+
return {
341+
context,
342+
descriptors: [],
343+
};
344+
}
345+
260346
return {
261347
context,
262348
descriptors: [{ node, messageId: "generic" }],
@@ -306,8 +392,12 @@ function checkCallExpression(
306392
options: Readonly<Options>,
307393
): RuleResult<keyof typeof errorMessages, Options> {
308394
const [optionsObject] = options;
309-
const { ignoreIdentifierPattern, ignoreAccessorPattern, ignoreClasses } =
310-
optionsObject;
395+
const {
396+
ignoreIdentifierPattern,
397+
ignoreAccessorPattern,
398+
ignoreNonConstDeclarations,
399+
ignoreClasses,
400+
} = optionsObject;
311401

312402
// Not potential object mutation?
313403
if (
@@ -334,7 +424,10 @@ function checkCallExpression(
334424
arrayMutatorMethods.has(node.callee.property.name) &&
335425
(!ignoreImmediateMutation ||
336426
!isInChainCallAndFollowsNew(node.callee, context)) &&
337-
isArrayType(getTypeOfNode(node.callee.object, context))
427+
isArrayType(getTypeOfNode(node.callee.object, context)) &&
428+
(!ignoreNonConstDeclarations ||
429+
!isIdentifier(node.callee.object) ||
430+
!isDefinedByMutableVaraible(node.callee.object, context))
338431
) {
339432
return {
340433
context,
@@ -355,7 +448,10 @@ function checkCallExpression(
355448
ignoreIdentifierPattern,
356449
ignoreAccessorPattern,
357450
) &&
358-
isObjectConstructorType(getTypeOfNode(node.callee.object, context))
451+
isObjectConstructorType(getTypeOfNode(node.callee.object, context)) &&
452+
(!ignoreNonConstDeclarations ||
453+
!isIdentifier(node.callee.object) ||
454+
!isDefinedByMutableVaraible(node.callee.object, context))
359455
) {
360456
return {
361457
context,

src/utils/rule.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ export function getESTreeNode<
310310
Context extends Readonly<RuleContext<string, BaseOptions>>,
311311
>(node: TSNode, context: Context): TSESTree.Node | null {
312312
const parserServices = getParserServices(context, true);
313-
return parserServices.tsNodeToESTreeNodeMap.get(node);
313+
return parserServices.tsNodeToESTreeNodeMap.get(node) ?? null;
314314
}
315315

316316
/**

tests/rules/immutable-data/ts/array/invalid.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,52 @@ const tests: Array<
702702
},
703703
],
704704
},
705+
// ignoreNonConstDeclarations.
706+
{
707+
code: dedent`
708+
const foo = [0, 1];
709+
foo[0] += 1;
710+
`,
711+
optionsSet: [[{ ignoreNonConstDeclarations: true }]],
712+
errors: [
713+
{
714+
messageId: "generic",
715+
type: AST_NODE_TYPES.AssignmentExpression,
716+
line: 2,
717+
column: 1,
718+
},
719+
],
720+
},
721+
{
722+
code: dedent`
723+
const foo = [0, 1];
724+
foo[1]++;
725+
`,
726+
optionsSet: [[{ ignoreNonConstDeclarations: true }]],
727+
errors: [
728+
{
729+
messageId: "generic",
730+
type: AST_NODE_TYPES.UpdateExpression,
731+
line: 2,
732+
column: 1,
733+
},
734+
],
735+
},
736+
{
737+
code: dedent`
738+
const foo = [0, 1];
739+
foo.pop();
740+
`,
741+
optionsSet: [[{ ignoreNonConstDeclarations: true }]],
742+
errors: [
743+
{
744+
messageId: "array",
745+
type: AST_NODE_TYPES.CallExpression,
746+
line: 2,
747+
column: 1,
748+
},
749+
],
750+
},
705751
];
706752

707753
export default tests;

tests/rules/immutable-data/ts/array/valid.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,25 @@ const tests: Array<ValidTestCaseSet<OptionsOf<typeof rule>>> = [
230230
`,
231231
optionsSet: [[]],
232232
},
233+
// ignoreNonConstDeclarations.
234+
{
235+
code: dedent`
236+
var mutableVar = [0, 1];
237+
mutableVar[0] += 1;
238+
mutableVar[1]++;
239+
mutableVar.pop();
240+
`,
241+
optionsSet: [[{ ignoreNonConstDeclarations: true }]],
242+
},
243+
{
244+
code: dedent`
245+
let mutableVar = [0, 1];
246+
mutableVar[0] += 1;
247+
mutableVar[1]++;
248+
mutableVar.pop();
249+
`,
250+
optionsSet: [[{ ignoreNonConstDeclarations: true }]],
251+
},
233252
];
234253

235254
export default tests;

tests/rules/immutable-data/ts/object/invalid.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,37 @@ const tests: Array<
320320
},
321321
],
322322
},
323+
// ignoreNonConstDeclarations.
324+
{
325+
code: dedent`
326+
const mutableVar = { a: 1 };
327+
mutableVar.a++;
328+
`,
329+
optionsSet: [[{ ignoreNonConstDeclarations: true }]],
330+
errors: [
331+
{
332+
messageId: "generic",
333+
type: AST_NODE_TYPES.UpdateExpression,
334+
line: 2,
335+
column: 1,
336+
},
337+
],
338+
},
339+
{
340+
code: dedent`
341+
const mutableVar = { a: 1 };
342+
mutableVar.a = 0;
343+
`,
344+
optionsSet: [[{ ignoreNonConstDeclarations: true }]],
345+
errors: [
346+
{
347+
messageId: "generic",
348+
type: AST_NODE_TYPES.AssignmentExpression,
349+
line: 2,
350+
column: 1,
351+
},
352+
],
353+
},
323354
];
324355

325356
export default tests;

tests/rules/immutable-data/ts/object/valid.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,23 @@ const tests: Array<ValidTestCaseSet<OptionsOf<typeof rule>>> = [
5959
[{ ignoreAccessorPattern: ["**.mutable*.**"] }],
6060
],
6161
},
62+
// ignoreNonConstDeclarations.
63+
{
64+
code: dedent`
65+
var mutableVar = { a: 1 };
66+
mutableVar.a = 0;
67+
mutableVar.a++;
68+
`,
69+
optionsSet: [[{ ignoreNonConstDeclarations: true }]],
70+
},
71+
{
72+
code: dedent`
73+
let mutableVar = { a: 1 };
74+
mutableVar.a = 0;
75+
mutableVar.a++;
76+
`,
77+
optionsSet: [[{ ignoreNonConstDeclarations: true }]],
78+
},
6279
// Allow initialization of class members in constructor
6380
{
6481
code: dedent`

0 commit comments

Comments
 (0)