Skip to content

Commit 25e09d9

Browse files
authored
Add custom eslint rule 'no-array-mutating-method-expressions' (microsoft#59526)
1 parent 7753487 commit 25e09d9

File tree

12 files changed

+153
-19
lines changed

12 files changed

+153
-19
lines changed

eslint.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ export default tseslint.config(
155155
"local/no-keywords": "error",
156156
"local/jsdoc-format": "error",
157157
"local/js-extensions": "error",
158+
"local/no-array-mutating-method-expressions": "error",
158159
},
159160
},
160161
{

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"@types/source-map-support": "^0.5.10",
5454
"@types/which": "^3.0.4",
5555
"@typescript-eslint/rule-tester": "^8.1.0",
56+
"@typescript-eslint/type-utils": "^8.1.0",
5657
"@typescript-eslint/utils": "^8.1.0",
5758
"azure-devops-node-api": "^14.0.2",
5859
"c8": "^10.1.2",
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
const { ESLintUtils } = require("@typescript-eslint/utils");
2+
const { createRule } = require("./utils.cjs");
3+
const { getConstrainedTypeAtLocation, isTypeArrayTypeOrUnionOfArrayTypes } = require("@typescript-eslint/type-utils");
4+
5+
/**
6+
* @import { TSESTree } from "@typescript-eslint/utils"
7+
*/
8+
void 0;
9+
10+
module.exports = createRule({
11+
name: "no-array-mutating-method-expressions",
12+
meta: {
13+
docs: {
14+
description: ``,
15+
},
16+
messages: {
17+
noSideEffectUse: `This call to {{method}} appears to be unintentional as it appears in an expression position. Sort the array in a separate statement or explicitly copy the array with slice.`,
18+
noSideEffectUseToMethod: `This call to {{method}} appears to be unintentional as it appears in an expression position. Sort the array in a separate statement or explicitly copy and slice the array with slice/{{toMethod}}.`,
19+
},
20+
schema: [],
21+
type: "problem",
22+
},
23+
defaultOptions: [],
24+
25+
create(context) {
26+
const services = ESLintUtils.getParserServices(context, /*allowWithoutFullTypeInformation*/ true);
27+
if (!services.program) {
28+
return {};
29+
}
30+
31+
const checker = services.program.getTypeChecker();
32+
33+
/**
34+
* This is a heuristic to ignore cases where the mutating method appears to be
35+
* operating on a "fresh" array.
36+
*
37+
* @type {(callee: TSESTree.MemberExpression) => boolean}
38+
*/
39+
const isFreshArray = callee => {
40+
const object = callee.object;
41+
42+
if (object.type === "ArrayExpression") {
43+
return true;
44+
}
45+
46+
if (object.type !== "CallExpression") {
47+
return false;
48+
}
49+
50+
if (object.callee.type === "Identifier") {
51+
// TypeScript codebase specific helpers.
52+
// TODO(jakebailey): handle ts.
53+
switch (object.callee.name) {
54+
case "arrayFrom":
55+
case "getOwnKeys":
56+
return true;
57+
}
58+
return false;
59+
}
60+
61+
if (object.callee.type === "MemberExpression" && object.callee.property.type === "Identifier") {
62+
switch (object.callee.property.name) {
63+
case "concat":
64+
case "filter":
65+
case "map":
66+
case "slice":
67+
return true;
68+
}
69+
70+
if (object.callee.object.type === "Identifier") {
71+
if (object.callee.object.name === "Array") {
72+
switch (object.callee.property.name) {
73+
case "from":
74+
case "of":
75+
return true;
76+
}
77+
return false;
78+
}
79+
80+
if (object.callee.object.name === "Object") {
81+
switch (object.callee.property.name) {
82+
case "values":
83+
case "keys":
84+
case "entries":
85+
return true;
86+
}
87+
return false;
88+
}
89+
}
90+
}
91+
92+
return false;
93+
};
94+
95+
/** @type {(callee: TSESTree.MemberExpression & { parent: TSESTree.CallExpression; }, method: string, toMethod: string | undefined) => void} */
96+
const check = (callee, method, toMethod) => {
97+
if (callee.parent.parent.type === "ExpressionStatement") return;
98+
if (isFreshArray(callee)) return;
99+
100+
const calleeObjType = getConstrainedTypeAtLocation(services, callee.object);
101+
if (!isTypeArrayTypeOrUnionOfArrayTypes(calleeObjType, checker)) return;
102+
103+
if (toMethod) {
104+
context.report({ node: callee.property, messageId: "noSideEffectUseToMethod", data: { method, toMethod } });
105+
}
106+
else {
107+
context.report({ node: callee.property, messageId: "noSideEffectUse", data: { method } });
108+
}
109+
};
110+
111+
// Methods with new copying variants.
112+
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array#copying_methods_and_mutating_methods
113+
const mutatingMethods = {
114+
reverse: undefined,
115+
sort: "toSorted", // This exists as `ts.toSorted`, so recommend that.
116+
splice: undefined,
117+
};
118+
119+
return Object.fromEntries(
120+
Object.entries(mutatingMethods).map(([method, toMethod]) => [
121+
`CallExpression > MemberExpression[property.name='${method}'][computed=false]`,
122+
node => check(node, method, toMethod),
123+
]),
124+
);
125+
},
126+
});

src/harness/fourslashImpl.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -963,7 +963,7 @@ export class TestState {
963963

964964
const fileName = this.activeFile.fileName;
965965
const hints = this.languageService.provideInlayHints(fileName, span, preferences);
966-
const annotations = ts.map(hints.sort(sortHints), hint => {
966+
const annotations = ts.map(hints.slice().sort(sortHints), hint => {
967967
if (hint.displayParts) {
968968
hint.displayParts = ts.map(hint.displayParts, part => {
969969
if (part.file && /lib.*\.d\.ts$/.test(part.file)) {
@@ -3257,8 +3257,8 @@ export class TestState {
32573257
allSpanInsets.push({ text: "|]", pos: span.textSpan.start + span.textSpan.length });
32583258
});
32593259

3260-
const reverseSpans = allSpanInsets.sort((l, r) => r.pos - l.pos);
3261-
ts.forEach(reverseSpans, span => {
3260+
allSpanInsets.sort((l, r) => r.pos - l.pos);
3261+
ts.forEach(allSpanInsets, span => {
32623262
annotated = annotated.slice(0, span.pos) + span.text + annotated.slice(span.pos);
32633263
});
32643264
Harness.IO.log(`\nMockup:\n${annotated}`);
@@ -3783,7 +3783,7 @@ export class TestState {
37833783
return { baselineContent: baselineContent + activeFile.content + `\n\n--No linked edits found--`, offset };
37843784
}
37853785

3786-
let inlineLinkedEditBaselines: { start: number; end: number; index: number; }[] = [];
3786+
const inlineLinkedEditBaselines: { start: number; end: number; index: number; }[] = [];
37873787
let linkedEditInfoBaseline = "";
37883788
for (const edit of linkedEditsByRange) {
37893789
const [linkedEdit, positions] = edit;
@@ -3802,7 +3802,7 @@ export class TestState {
38023802
offset++;
38033803
}
38043804

3805-
inlineLinkedEditBaselines = inlineLinkedEditBaselines.sort((a, b) => a.start - b.start);
3805+
inlineLinkedEditBaselines.sort((a, b) => a.start - b.start);
38063806
const fileText = activeFile.content;
38073807
baselineContent += fileText.slice(0, inlineLinkedEditBaselines[0].start);
38083808
for (let i = 0; i < inlineLinkedEditBaselines.length; i++) {
@@ -4058,7 +4058,7 @@ export class TestState {
40584058
public verifyRefactorKindsAvailable(kind: string, expected: string[], preferences = ts.emptyOptions) {
40594059
const refactors = this.getApplicableRefactorsAtSelection("invoked", kind, preferences);
40604060
const availableKinds = ts.flatMap(refactors, refactor => refactor.actions).map(action => action.kind);
4061-
assert.deepEqual(availableKinds.sort(), expected.sort(), `Expected kinds to be equal`);
4061+
assert.deepEqual(availableKinds.slice().sort(), expected.slice().sort(), `Expected kinds to be equal`);
40624062
}
40634063

40644064
public verifyRefactorsAvailable(names: readonly string[]): void {
@@ -4938,7 +4938,7 @@ function parseFileContent(content: string, fileName: string, markerMap: Map<stri
49384938
const openRanges: RangeLocationInformation[] = [];
49394939

49404940
/// A list of ranges we've collected so far */
4941-
let localRanges: Range[] = [];
4941+
const localRanges: Range[] = [];
49424942

49434943
/// The latest position of the start of an unflushed plain text area
49444944
let lastNormalCharPosition = 0;
@@ -5105,7 +5105,7 @@ function parseFileContent(content: string, fileName: string, markerMap: Map<stri
51055105
}
51065106

51075107
// put ranges in the correct order
5108-
localRanges = localRanges.sort((a, b) => a.pos < b.pos ? -1 : a.pos === b.pos && a.end > b.end ? -1 : 1);
5108+
localRanges.sort((a, b) => a.pos < b.pos ? -1 : a.pos === b.pos && a.end > b.end ? -1 : 1);
51095109
localRanges.forEach(r => ranges.push(r));
51105110

51115111
return {

src/harness/projectServiceStateLogger.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
isString,
1010
noop,
1111
SourceMapper,
12+
toSorted,
1213
} from "./_namespaces/ts.js";
1314
import {
1415
AutoImportProviderProject,
@@ -93,7 +94,7 @@ export function patchServiceForStateBaseline(service: ProjectService) {
9394
function sendLogsToLogger(title: string, logs: StateItemLog[] | undefined) {
9495
if (!logs) return;
9596
logger.log(title);
96-
logs.sort((a, b) => compareStringsCaseSensitive(a[0], b[0]))
97+
toSorted(logs, (a, b) => compareStringsCaseSensitive(a[0], b[0]))
9798
.forEach(([title, propertyLogs]) => {
9899
logger.log(title);
99100
propertyLogs.forEach(p => isString(p) ? logger.log(p) : p.forEach(s => logger.log(s)));

src/services/mapCode.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,11 +113,12 @@ function parse(sourceFile: SourceFile, content: string): NodeArray<Node> {
113113
}
114114
}
115115
// Heuristic: fewer errors = more likely to be the right kind.
116-
const { body } = parsedNodes.sort(
116+
parsedNodes.sort(
117117
(a, b) =>
118118
a.sourceFile.parseDiagnostics.length -
119119
b.sourceFile.parseDiagnostics.length,
120-
)[0];
120+
);
121+
const { body } = parsedNodes[0];
121122
return body;
122123
}
123124

src/services/navigateTo.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,8 @@ function getContainers(declaration: Declaration): readonly string[] {
164164
container = getContainerNode(container);
165165
}
166166

167-
return containers.reverse();
167+
containers.reverse();
168+
return containers;
168169
}
169170

170171
function compareNavigateToItems(i1: RawNavigateToItem, i2: RawNavigateToItem) {

src/services/outliningElementsCollector.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ export function collectElements(sourceFile: SourceFile, cancellationToken: Cance
6060
const res: OutliningSpan[] = [];
6161
addNodeOutliningSpans(sourceFile, cancellationToken, res);
6262
addRegionOutliningSpans(sourceFile, res);
63-
return res.sort((span1, span2) => span1.textSpan.start - span2.textSpan.start);
63+
res.sort((span1, span2) => span1.textSpan.start - span2.textSpan.start);
64+
return res;
6465
}
6566

6667
function addNodeOutliningSpans(sourceFile: SourceFile, cancellationToken: CancellationToken, out: OutliningSpan[]): void {

src/services/refactors/extractSymbol.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,11 +1081,11 @@ function extractFunctionInScope(
10811081
});
10821082

10831083
const typeParametersAndDeclarations = arrayFrom(typeParameterUsages.values(), type => ({ type, declaration: getFirstDeclarationBeforePosition(type, context.startPosition) }));
1084-
const sortedTypeParametersAndDeclarations = typeParametersAndDeclarations.sort(compareTypesByDeclarationOrder);
1084+
typeParametersAndDeclarations.sort(compareTypesByDeclarationOrder);
10851085

1086-
const typeParameters: readonly TypeParameterDeclaration[] | undefined = sortedTypeParametersAndDeclarations.length === 0
1086+
const typeParameters: readonly TypeParameterDeclaration[] | undefined = typeParametersAndDeclarations.length === 0
10871087
? undefined
1088-
: mapDefined(sortedTypeParametersAndDeclarations, ({ declaration }) => declaration as TypeParameterDeclaration);
1088+
: mapDefined(typeParametersAndDeclarations, ({ declaration }) => declaration as TypeParameterDeclaration);
10891089

10901090
// Strictly speaking, we should check whether each name actually binds to the appropriate type
10911091
// parameter. In cases of shadowing, they may not.

src/services/suggestionDiagnostics.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@ export function computeSuggestionDiagnostics(sourceFile: SourceFile, program: Pr
9797

9898
addRange(diags, sourceFile.bindSuggestionDiagnostics);
9999
addRange(diags, program.getSuggestionDiagnostics(sourceFile, cancellationToken));
100-
return diags.sort((d1, d2) => d1.start - d2.start);
100+
diags.sort((d1, d2) => d1.start - d2.start);
101+
return diags;
101102

102103
function check(node: Node) {
103104
if (isJsFile) {

src/testRunner/unittests/services/extract/ranges.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as ts from "../../../_namespaces/ts.js";
22
import { extractTest } from "./helpers.js";
33

4-
function testExtractRangeFailed(caption: string, s: string, expectedErrors: string[]) {
4+
function testExtractRangeFailed(caption: string, s: string, expectedErrors: readonly string[]) {
55
return it(caption, () => {
66
const t = extractTest(s);
77
const file = ts.createSourceFile("a.ts", t.source, ts.ScriptTarget.Latest, /*setParentNodes*/ true);
@@ -12,7 +12,7 @@ function testExtractRangeFailed(caption: string, s: string, expectedErrors: stri
1212
const result = ts.refactor.extractSymbol.getRangeToExtract(file, ts.createTextSpanFromRange(selectionRange), /*invoked*/ false);
1313
assert(result.targetRange === undefined, "failure expected");
1414
const sortedErrors = result.errors.map(e => e.messageText as string).sort();
15-
assert.deepEqual(sortedErrors, expectedErrors.sort(), "unexpected errors");
15+
assert.deepEqual(sortedErrors, expectedErrors.slice().sort(), "unexpected errors");
1616
});
1717
}
1818

0 commit comments

Comments
 (0)