eslint-plugin-sonarjs
Version:
424 lines (423 loc) • 19.8 kB
JavaScript
"use strict";
/*
* SonarQube JavaScript Plugin
* Copyright (C) SonarSource Sàrl
* mailto:info AT sonarsource DOT com
*
* You can redistribute and/or modify this program under the terms of
* the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the Sonar Source-Available License for more details.
*
* You should have received a copy of the Sonar Source-Available License
* along with this program; if not, see https://sonarsource.com/license/ssal/
*/
// https://sonarsource.github.io/rspec/#/rspec/S2234/javascript
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.rule = void 0;
const ast_js_1 = require("../helpers/ast.js");
const generate_meta_js_1 = require("../helpers/generate-meta.js");
const type_js_1 = require("../helpers/type.js");
const parser_services_js_1 = require("../helpers/parser-services.js");
const location_js_1 = require("../helpers/location.js");
const meta = __importStar(require("./generated-meta.js"));
exports.rule = {
meta: (0, generate_meta_js_1.generateMeta)(meta),
create(context) {
const services = context.sourceCode.parserServices;
const canResolveType = (0, parser_services_js_1.isRequiredParserServices)(services);
const COMPARISON_OPERATORS = ['==', '!=', '===', '!==', '<', '<=', '>', '>='];
function checkArguments(functionCall) {
// Extract argument names first (cheap operation)
const argumentNames = functionCall.arguments.map(arg => {
const argument = arg;
return argument.type === 'Identifier' ? argument.name : undefined;
});
// Early exit: detecting swapped arguments requires at least 2 identifier arguments.
// Skip expensive type resolution if there aren't enough identifiers to check.
// This avoids a TypeScript performance issue where type inference for complex
// destructuring patterns with nested defaults can have exponential complexity.
const identifierCount = argumentNames.filter(name => name !== undefined).length;
if (identifierCount < 2) {
return;
}
const resolvedFunction = resolveFunctionDeclaration(functionCall);
if (!resolvedFunction) {
return;
}
const { params: functionParameters, declaration: functionDeclaration } = resolvedFunction;
if (isCryptoCyclicRotation(functionCall, functionParameters)) {
return;
}
for (let argumentIndex = 0; argumentIndex < argumentNames.length; argumentIndex++) {
const argumentName = argumentNames[argumentIndex];
if (argumentName) {
const swappedArgumentName = getSwappedArgumentName(argumentNames, functionParameters, argumentName, argumentIndex, functionCall);
if (swappedArgumentName &&
!areComparedArguments([argumentName, swappedArgumentName], functionCall) &&
!isIntentionalComparatorReversal(functionCall, argumentName, swappedArgumentName) &&
!isInDirectionalContext(functionCall) &&
!isIntentionalTernarySwap(functionCall, argumentName, swappedArgumentName)) {
raiseIssue(argumentName, swappedArgumentName, functionDeclaration, functionCall);
return;
}
}
}
}
function areComparedArguments(argumentNames, node) {
function getName(node) {
switch (node.type) {
case 'Identifier':
return node.name;
case 'CallExpression':
return getName(node.callee);
case 'MemberExpression':
return getName(node.object);
default:
return undefined;
}
}
function checkComparedArguments(lhs, rhs) {
return ([lhs, rhs].map(getName).filter(name => name && argumentNames.includes(name)).length ===
argumentNames.length);
}
const maybeIfStmt = context.sourceCode
.getAncestors(node)
.reverse()
.find(ancestor => ancestor.type === 'IfStatement');
if (maybeIfStmt) {
const { test } = maybeIfStmt;
switch (test.type) {
case 'BinaryExpression': {
const binExpr = test;
if (COMPARISON_OPERATORS.includes(binExpr.operator)) {
const { left: lhs, right: rhs } = binExpr;
return checkComparedArguments(lhs, rhs);
}
break;
}
case 'CallExpression': {
const callExpr = test;
if (callExpr.arguments.length === 1 && callExpr.callee.type === 'MemberExpression') {
const [lhs, rhs] = [callExpr.callee.object, callExpr.arguments[0]];
return checkComparedArguments(lhs, rhs);
}
break;
}
}
}
return false;
}
/**
* Returns true when the detected argument swap is an intentional reversal wrapper.
*
* A reversal wrapper is an ArrowFunctionExpression or FunctionExpression with exactly
* 2 single-character identifier parameters (e.g. `a`, `b`, `x`, `y`) whose sole body
* is a single call that passes those same 2 parameters in swapped order,
* e.g. `(a, b) => compare(b, a)`.
*
* Requiring single-character parameter names ensures that only placeholder-style names
* are treated as intentional reversals. Meaningful names like `year` or `month` indicate
* that the swap is likely a bug rather than a deliberate comparator reversal.
*/
function isIntentionalComparatorReversal(functionCall, arg1Name, arg2Name) {
const enclosingFunc = context.sourceCode
.getAncestors(functionCall)
.reverse()
.find(ancestor => ancestor.type === 'ArrowFunctionExpression' || ancestor.type === 'FunctionExpression');
if (enclosingFunc?.params.length !== 2) {
return false;
}
const [param0, param1] = enclosingFunc.params;
if (param0.type !== 'Identifier' || param1.type !== 'Identifier') {
return false;
}
// Only suppress when parameter names are single-character placeholders like 'a', 'b', 'x', 'y'.
// Meaningful names (e.g. 'year', 'month') suggest the swap is a real bug, not an intentional reversal.
if (param0.name.length > 1 || param1.name.length > 1) {
return false;
}
const paramNames = new Set([param0.name, param1.name]);
if (!paramNames.has(arg1Name) || !paramNames.has(arg2Name)) {
return false;
}
const body = enclosingFunc.body;
if (body === functionCall) {
return true;
}
if (body.type === 'BlockStatement' &&
body.body.length === 1 &&
body.body[0].type === 'ReturnStatement' &&
body.body[0].argument === functionCall) {
return true;
}
return false;
}
// Returns true if the node is nested inside an object property whose key is a
// directional keyword (e.g. 'rtl', 'ltr', 'reverse'). In such cases, parameter
// swapping is intentional — it represents the opposite ordering for bidirectional
// text handling or conditional reversal logic.
function isInDirectionalContext(node) {
const ancestors = context.sourceCode.getAncestors(node);
for (const ancestor of ancestors) {
if (ancestor.type === 'Property') {
const { key } = ancestor;
if (key.type === 'Identifier' && DIRECTIONAL_KEYWORD_PATTERN.test(key.name)) {
return true;
}
if (key.type === 'Literal' &&
typeof key.value === 'string' &&
DIRECTIONAL_KEYWORD_PATTERN.test(key.value)) {
return true;
}
}
}
return false;
}
/**
* Returns true when the detected argument swap is in one branch of a ConditionalExpression,
* the other branch calls the same function with those arguments in the opposite (normal) order,
* AND the ternary condition itself is a comparison that involves both of those same arguments.
*
* This ensures the condition is actually selecting the correct ordering rather than being an
* unrelated boolean. For example:
* `start < stop ? fn(start, stop) : fn(stop, start)` — suppressed (condition compares the pair)
* `legacy ? fn(stop, start) : fn(start, stop)` — reported (condition unrelated to arg order)
*/
function isIntentionalTernarySwap(functionCall, arg1Name, arg2Name) {
const ancestors = context.sourceCode.getAncestors(functionCall);
const parent = ancestors.at(-1);
if (parent?.type !== 'ConditionalExpression') {
return false;
}
const conditional = parent;
// Determine the "other" branch of the ternary
let otherBranch = null;
if (conditional.consequent === functionCall) {
otherBranch = conditional.alternate;
}
else if (conditional.alternate === functionCall) {
otherBranch = conditional.consequent;
}
if (otherBranch?.type !== 'CallExpression') {
return false;
}
const otherCall = otherBranch;
// Both calls must target the same callee (by source text)
if (context.sourceCode.getText(functionCall.callee) !==
context.sourceCode.getText(otherCall.callee)) {
return false;
}
if (otherCall.arguments.length !== functionCall.arguments.length) {
return false;
}
// Find positions of arg1 and arg2 in the flagged call
const args = functionCall.arguments;
const idx1 = args.findIndex(a => a.type === 'Identifier' && a.name === arg1Name);
const idx2 = args.findIndex(a => a.type === 'Identifier' && a.name === arg2Name);
if (idx1 < 0 || idx2 < 0) {
return false;
}
// In the other branch, those same positions must carry arg2 and arg1 (reversed)
const otherArgs = otherCall.arguments;
const otherAtIdx1 = otherArgs[idx1];
const otherAtIdx2 = otherArgs[idx2];
if (!(otherAtIdx1?.type === 'Identifier' &&
otherAtIdx1.name === arg2Name &&
otherAtIdx2?.type === 'Identifier' &&
otherAtIdx2.name === arg1Name)) {
return false;
}
// The ternary condition must itself compare the same argument pair. This ties the
// suppression to condition-controlled ordering (e.g. `a < b ? fn(a, b) : fn(b, a)`)
// rather than an arbitrary boolean selector (e.g. `flag ? fn(b, a) : fn(a, b)`).
const test = conditional.test;
if (test.type !== 'BinaryExpression') {
return false;
}
if (!COMPARISON_OPERATORS.includes(test.operator)) {
return false;
}
const leftName = test.left.type === 'Identifier' ? test.left.name : undefined;
const rightName = test.right.type === 'Identifier' ? test.right.name : undefined;
if (!leftName || !rightName) {
return false;
}
const conditionNames = new Set([leftName, rightName]);
return conditionNames.has(arg1Name) && conditionNames.has(arg2Name);
}
function resolveFunctionDeclaration(node) {
if (canResolveType) {
return resolveFromTSSignature(node);
}
let functionDeclaration = null;
if ((0, ast_js_1.isFunctionNode)(node.callee)) {
functionDeclaration = node.callee;
}
else if (node.callee.type === 'Identifier') {
functionDeclaration = (0, ast_js_1.resolveFromFunctionReference)(context, node.callee);
}
if (!functionDeclaration) {
return null;
}
return {
params: extractFunctionParameters(functionDeclaration),
declaration: functionDeclaration,
};
}
function resolveFromTSSignature(node) {
const signature = (0, type_js_1.getSignatureFromCallee)(node, services);
if (signature?.declaration) {
return {
params: signature.parameters.map(param => param.name),
declaration: services.tsNodeToESTreeNodeMap.get(signature.declaration),
};
}
return null;
}
function getSwappedArgumentName(argumentNames, functionParameters, argumentName, argumentIndex, node) {
const indexInFunctionDeclaration = functionParameters.indexOf(argumentName);
if (indexInFunctionDeclaration >= 0 && indexInFunctionDeclaration !== argumentIndex) {
const potentiallySwappedArgument = argumentNames[indexInFunctionDeclaration];
if (potentiallySwappedArgument &&
potentiallySwappedArgument === functionParameters[argumentIndex] &&
haveCompatibleTypes(node.arguments[argumentIndex], node.arguments[indexInFunctionDeclaration])) {
return potentiallySwappedArgument;
}
}
return null;
}
function haveCompatibleTypes(arg1, arg2) {
if (canResolveType) {
const type1 = normalizeType((0, type_js_1.getTypeAsString)(arg1, services));
const type2 = normalizeType((0, type_js_1.getTypeAsString)(arg2, services));
return type1 === type2;
}
return true;
}
function raiseIssue(arg1, arg2, functionDeclaration, node) {
(0, location_js_1.report)(context, {
message: `Arguments '${arg1}' and '${arg2}' have the same names but not the same order as the function parameters.`,
loc: getParametersClauseLocation(node.arguments),
}, getSecondaryLocations(functionDeclaration));
}
return {
NewExpression: (node) => {
checkArguments(node);
},
CallExpression: (node) => {
checkArguments(node);
},
};
},
};
const DIRECTIONAL_KEYWORD_PATTERN = /\b(rtl|ltr|reverse|flip|swap|forward|backward)\b/i;
const CRYPTO_FUNCTION_PATTERN = /^(md[45]_?)?(ff|gg|hh|ii)$/i;
const CRYPTO_STATE_PARAM_COUNT = 4;
function isCryptoCyclicRotation(functionCall, functionParameters) {
const callee = functionCall.callee;
let calleeName = null;
if (callee.type === 'Identifier') {
calleeName = callee.name;
}
else if (callee.type === 'MemberExpression' && callee.property.type === 'Identifier') {
calleeName = callee.property.name;
}
if (!calleeName || !CRYPTO_FUNCTION_PATTERN.test(calleeName)) {
return false;
}
if (functionParameters.length < CRYPTO_STATE_PARAM_COUNT ||
functionCall.arguments.length < CRYPTO_STATE_PARAM_COUNT) {
return false;
}
// First 4 arguments must all be identifiers
const argNames = [];
for (let i = 0; i < CRYPTO_STATE_PARAM_COUNT; i++) {
const arg = functionCall.arguments[i];
if (arg.type !== 'Identifier') {
return false;
}
argNames.push(arg.name);
}
// First 4 parameters must all be defined
const paramNames = functionParameters.slice(0, CRYPTO_STATE_PARAM_COUNT);
if (paramNames.includes(undefined)) {
return false;
}
// Check if args[0..3] are a cyclic rotation of params[0..3]
for (let k = 1; k <= CRYPTO_STATE_PARAM_COUNT - 1; k++) {
if (argNames.every((arg, i) => arg === paramNames[(i + k) % CRYPTO_STATE_PARAM_COUNT])) {
return true;
}
}
return false;
}
function extractFunctionParameters(functionDeclaration) {
return functionDeclaration.params.map(param => {
const identifiers = (0, ast_js_1.resolveIdentifiers)(param);
if (identifiers.length === 1 && identifiers[0]) {
return identifiers[0].name;
}
return undefined;
});
}
function getSecondaryLocations(functionDeclaration) {
if (functionDeclaration?.params && functionDeclaration.params.length > 0) {
const { start, end } = getParametersClauseLocation(functionDeclaration.params);
return [(0, location_js_1.toSecondaryLocation)({ loc: { start, end } }, 'Formal parameters')];
}
return [];
}
function getParametersClauseLocation(parameters) {
const firstParam = parameters[0];
const lastParam = parameters.at(-1);
return { start: firstParam.loc.start, end: lastParam.loc.end };
}
function normalizeType(typeAsString) {
switch (typeAsString) {
case 'String':
return 'string';
case 'Boolean':
return 'boolean';
case 'Number':
return 'number';
default:
return typeAsString;
}
}