Add custom eslint rule 'no-array-mutating-method-expressions' (#59526)

This commit is contained in:
Jake Bailey 2024-08-14 09:28:57 -07:00 коммит произвёл GitHub
Родитель 7753487591
Коммит 25e09d9fc3
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
12 изменённых файлов: 153 добавлений и 19 удалений

Просмотреть файл

@ -155,6 +155,7 @@ export default tseslint.config(
"local/no-keywords": "error",
"local/jsdoc-format": "error",
"local/js-extensions": "error",
"local/no-array-mutating-method-expressions": "error",
},
},
{

1
package-lock.json сгенерированный
Просмотреть файл

@ -27,6 +27,7 @@
"@types/source-map-support": "^0.5.10",
"@types/which": "^3.0.4",
"@typescript-eslint/rule-tester": "^8.1.0",
"@typescript-eslint/type-utils": "^8.1.0",
"@typescript-eslint/utils": "^8.1.0",
"azure-devops-node-api": "^14.0.2",
"c8": "^10.1.2",

Просмотреть файл

@ -53,6 +53,7 @@
"@types/source-map-support": "^0.5.10",
"@types/which": "^3.0.4",
"@typescript-eslint/rule-tester": "^8.1.0",
"@typescript-eslint/type-utils": "^8.1.0",
"@typescript-eslint/utils": "^8.1.0",
"azure-devops-node-api": "^14.0.2",
"c8": "^10.1.2",

Просмотреть файл

@ -0,0 +1,126 @@
const { ESLintUtils } = require("@typescript-eslint/utils");
const { createRule } = require("./utils.cjs");
const { getConstrainedTypeAtLocation, isTypeArrayTypeOrUnionOfArrayTypes } = require("@typescript-eslint/type-utils");
/**
* @import { TSESTree } from "@typescript-eslint/utils"
*/
void 0;
module.exports = createRule({
name: "no-array-mutating-method-expressions",
meta: {
docs: {
description: ``,
},
messages: {
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.`,
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}}.`,
},
schema: [],
type: "problem",
},
defaultOptions: [],
create(context) {
const services = ESLintUtils.getParserServices(context, /*allowWithoutFullTypeInformation*/ true);
if (!services.program) {
return {};
}
const checker = services.program.getTypeChecker();
/**
* This is a heuristic to ignore cases where the mutating method appears to be
* operating on a "fresh" array.
*
* @type {(callee: TSESTree.MemberExpression) => boolean}
*/
const isFreshArray = callee => {
const object = callee.object;
if (object.type === "ArrayExpression") {
return true;
}
if (object.type !== "CallExpression") {
return false;
}
if (object.callee.type === "Identifier") {
// TypeScript codebase specific helpers.
// TODO(jakebailey): handle ts.
switch (object.callee.name) {
case "arrayFrom":
case "getOwnKeys":
return true;
}
return false;
}
if (object.callee.type === "MemberExpression" && object.callee.property.type === "Identifier") {
switch (object.callee.property.name) {
case "concat":
case "filter":
case "map":
case "slice":
return true;
}
if (object.callee.object.type === "Identifier") {
if (object.callee.object.name === "Array") {
switch (object.callee.property.name) {
case "from":
case "of":
return true;
}
return false;
}
if (object.callee.object.name === "Object") {
switch (object.callee.property.name) {
case "values":
case "keys":
case "entries":
return true;
}
return false;
}
}
}
return false;
};
/** @type {(callee: TSESTree.MemberExpression & { parent: TSESTree.CallExpression; }, method: string, toMethod: string | undefined) => void} */
const check = (callee, method, toMethod) => {
if (callee.parent.parent.type === "ExpressionStatement") return;
if (isFreshArray(callee)) return;
const calleeObjType = getConstrainedTypeAtLocation(services, callee.object);
if (!isTypeArrayTypeOrUnionOfArrayTypes(calleeObjType, checker)) return;
if (toMethod) {
context.report({ node: callee.property, messageId: "noSideEffectUseToMethod", data: { method, toMethod } });
}
else {
context.report({ node: callee.property, messageId: "noSideEffectUse", data: { method } });
}
};
// Methods with new copying variants.
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array#copying_methods_and_mutating_methods
const mutatingMethods = {
reverse: undefined,
sort: "toSorted", // This exists as `ts.toSorted`, so recommend that.
splice: undefined,
};
return Object.fromEntries(
Object.entries(mutatingMethods).map(([method, toMethod]) => [
`CallExpression > MemberExpression[property.name='${method}'][computed=false]`,
node => check(node, method, toMethod),
]),
);
},
});

Просмотреть файл

@ -963,7 +963,7 @@ export class TestState {
const fileName = this.activeFile.fileName;
const hints = this.languageService.provideInlayHints(fileName, span, preferences);
const annotations = ts.map(hints.sort(sortHints), hint => {
const annotations = ts.map(hints.slice().sort(sortHints), hint => {
if (hint.displayParts) {
hint.displayParts = ts.map(hint.displayParts, part => {
if (part.file && /lib.*\.d\.ts$/.test(part.file)) {
@ -3257,8 +3257,8 @@ export class TestState {
allSpanInsets.push({ text: "|]", pos: span.textSpan.start + span.textSpan.length });
});
const reverseSpans = allSpanInsets.sort((l, r) => r.pos - l.pos);
ts.forEach(reverseSpans, span => {
allSpanInsets.sort((l, r) => r.pos - l.pos);
ts.forEach(allSpanInsets, span => {
annotated = annotated.slice(0, span.pos) + span.text + annotated.slice(span.pos);
});
Harness.IO.log(`\nMockup:\n${annotated}`);
@ -3783,7 +3783,7 @@ export class TestState {
return { baselineContent: baselineContent + activeFile.content + `\n\n--No linked edits found--`, offset };
}
let inlineLinkedEditBaselines: { start: number; end: number; index: number; }[] = [];
const inlineLinkedEditBaselines: { start: number; end: number; index: number; }[] = [];
let linkedEditInfoBaseline = "";
for (const edit of linkedEditsByRange) {
const [linkedEdit, positions] = edit;
@ -3802,7 +3802,7 @@ export class TestState {
offset++;
}
inlineLinkedEditBaselines = inlineLinkedEditBaselines.sort((a, b) => a.start - b.start);
inlineLinkedEditBaselines.sort((a, b) => a.start - b.start);
const fileText = activeFile.content;
baselineContent += fileText.slice(0, inlineLinkedEditBaselines[0].start);
for (let i = 0; i < inlineLinkedEditBaselines.length; i++) {
@ -4058,7 +4058,7 @@ export class TestState {
public verifyRefactorKindsAvailable(kind: string, expected: string[], preferences = ts.emptyOptions) {
const refactors = this.getApplicableRefactorsAtSelection("invoked", kind, preferences);
const availableKinds = ts.flatMap(refactors, refactor => refactor.actions).map(action => action.kind);
assert.deepEqual(availableKinds.sort(), expected.sort(), `Expected kinds to be equal`);
assert.deepEqual(availableKinds.slice().sort(), expected.slice().sort(), `Expected kinds to be equal`);
}
public verifyRefactorsAvailable(names: readonly string[]): void {
@ -4938,7 +4938,7 @@ function parseFileContent(content: string, fileName: string, markerMap: Map<stri
const openRanges: RangeLocationInformation[] = [];
/// A list of ranges we've collected so far */
let localRanges: Range[] = [];
const localRanges: Range[] = [];
/// The latest position of the start of an unflushed plain text area
let lastNormalCharPosition = 0;
@ -5105,7 +5105,7 @@ function parseFileContent(content: string, fileName: string, markerMap: Map<stri
}
// put ranges in the correct order
localRanges = localRanges.sort((a, b) => a.pos < b.pos ? -1 : a.pos === b.pos && a.end > b.end ? -1 : 1);
localRanges.sort((a, b) => a.pos < b.pos ? -1 : a.pos === b.pos && a.end > b.end ? -1 : 1);
localRanges.forEach(r => ranges.push(r));
return {

Просмотреть файл

@ -9,6 +9,7 @@ import {
isString,
noop,
SourceMapper,
toSorted,
} from "./_namespaces/ts.js";
import {
AutoImportProviderProject,
@ -93,7 +94,7 @@ export function patchServiceForStateBaseline(service: ProjectService) {
function sendLogsToLogger(title: string, logs: StateItemLog[] | undefined) {
if (!logs) return;
logger.log(title);
logs.sort((a, b) => compareStringsCaseSensitive(a[0], b[0]))
toSorted(logs, (a, b) => compareStringsCaseSensitive(a[0], b[0]))
.forEach(([title, propertyLogs]) => {
logger.log(title);
propertyLogs.forEach(p => isString(p) ? logger.log(p) : p.forEach(s => logger.log(s)));

Просмотреть файл

@ -113,11 +113,12 @@ function parse(sourceFile: SourceFile, content: string): NodeArray<Node> {
}
}
// Heuristic: fewer errors = more likely to be the right kind.
const { body } = parsedNodes.sort(
parsedNodes.sort(
(a, b) =>
a.sourceFile.parseDiagnostics.length -
b.sourceFile.parseDiagnostics.length,
)[0];
);
const { body } = parsedNodes[0];
return body;
}

Просмотреть файл

@ -164,7 +164,8 @@ function getContainers(declaration: Declaration): readonly string[] {
container = getContainerNode(container);
}
return containers.reverse();
containers.reverse();
return containers;
}
function compareNavigateToItems(i1: RawNavigateToItem, i2: RawNavigateToItem) {

Просмотреть файл

@ -60,7 +60,8 @@ export function collectElements(sourceFile: SourceFile, cancellationToken: Cance
const res: OutliningSpan[] = [];
addNodeOutliningSpans(sourceFile, cancellationToken, res);
addRegionOutliningSpans(sourceFile, res);
return res.sort((span1, span2) => span1.textSpan.start - span2.textSpan.start);
res.sort((span1, span2) => span1.textSpan.start - span2.textSpan.start);
return res;
}
function addNodeOutliningSpans(sourceFile: SourceFile, cancellationToken: CancellationToken, out: OutliningSpan[]): void {

Просмотреть файл

@ -1081,11 +1081,11 @@ function extractFunctionInScope(
});
const typeParametersAndDeclarations = arrayFrom(typeParameterUsages.values(), type => ({ type, declaration: getFirstDeclarationBeforePosition(type, context.startPosition) }));
const sortedTypeParametersAndDeclarations = typeParametersAndDeclarations.sort(compareTypesByDeclarationOrder);
typeParametersAndDeclarations.sort(compareTypesByDeclarationOrder);
const typeParameters: readonly TypeParameterDeclaration[] | undefined = sortedTypeParametersAndDeclarations.length === 0
const typeParameters: readonly TypeParameterDeclaration[] | undefined = typeParametersAndDeclarations.length === 0
? undefined
: mapDefined(sortedTypeParametersAndDeclarations, ({ declaration }) => declaration as TypeParameterDeclaration);
: mapDefined(typeParametersAndDeclarations, ({ declaration }) => declaration as TypeParameterDeclaration);
// Strictly speaking, we should check whether each name actually binds to the appropriate type
// parameter. In cases of shadowing, they may not.

Просмотреть файл

@ -97,7 +97,8 @@ export function computeSuggestionDiagnostics(sourceFile: SourceFile, program: Pr
addRange(diags, sourceFile.bindSuggestionDiagnostics);
addRange(diags, program.getSuggestionDiagnostics(sourceFile, cancellationToken));
return diags.sort((d1, d2) => d1.start - d2.start);
diags.sort((d1, d2) => d1.start - d2.start);
return diags;
function check(node: Node) {
if (isJsFile) {

Просмотреть файл

@ -1,7 +1,7 @@
import * as ts from "../../../_namespaces/ts.js";
import { extractTest } from "./helpers.js";
function testExtractRangeFailed(caption: string, s: string, expectedErrors: string[]) {
function testExtractRangeFailed(caption: string, s: string, expectedErrors: readonly string[]) {
return it(caption, () => {
const t = extractTest(s);
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
const result = ts.refactor.extractSymbol.getRangeToExtract(file, ts.createTextSpanFromRange(selectionRange), /*invoked*/ false);
assert(result.targetRange === undefined, "failure expected");
const sortedErrors = result.errors.map(e => e.messageText as string).sort();
assert.deepEqual(sortedErrors, expectedErrors.sort(), "unexpected errors");
assert.deepEqual(sortedErrors, expectedErrors.slice().sort(), "unexpected errors");
});
}