eslint-plugin-sonarjs
Version:
376 lines (375 loc) • 15.2 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/S5976/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 ancestor_js_1 = require("../helpers/ancestor.js");
const ast_js_1 = require("../helpers/ast.js");
const equivalence_js_1 = require("../helpers/equivalence.js");
const generate_meta_js_1 = require("../helpers/generate-meta.js");
const location_js_1 = require("../helpers/location.js");
const module_js_1 = require("../helpers/module.js");
const mocha_style_test_frameworks_js_1 = require("../helpers/mocha-style-test-frameworks.js");
const meta = __importStar(require("./generated-meta.js"));
const messages = {
replaceWithParameterizedTest: 'Replace these {{count}} tests with a single Parameterized one.',
};
const JEST_MODULES = ['jest', '@jest/globals'];
const PLAYWRIGHT_MODULES = ['@playwright/test'];
const VITEST_MODULES = ['vitest'];
const SUPPORTED_MODULE_FQNS = ['jest', '@jest.globals', 'vitest', '@playwright.test'];
const UNSUPPORTED_TEST_MODULES = [
'cypress',
'jasmine',
'jasmine-core',
'jasmine-node',
'karma-jasmine',
'mocha',
];
const TEST_FUNCTION_NAMES = new Set(['it', 'test']);
const COMMON_TEST_MODIFIERS = new Set(['only']);
const JEST_TEST_MODIFIERS = new Set(['concurrent', 'failing']);
const PLAYWRIGHT_TEST_MODIFIERS = new Set(['fail', 'slow']);
const VITEST_TEST_MODIFIERS = new Set(['concurrent', 'fails', 'sequential']);
const NON_CONCRETE_TEST_MODIFIERS = new Set(['each', 'fixme', 'skip', 'todo']);
const MIN_SIMILAR_TESTS = 3;
const MIN_STATEMENTS = 2;
const MAX_PARAMETERS = 3;
exports.rule = {
meta: (0, generate_meta_js_1.generateMeta)(meta, { messages }),
create(context) {
const activeFrameworks = {
jest: (0, module_js_1.importsOrDependsOnModule)(context, JEST_MODULES, JEST_MODULES),
playwright: (0, module_js_1.importsOrDependsOnModule)(context, PLAYWRIGHT_MODULES, PLAYWRIGHT_MODULES),
vitest: (0, module_js_1.importsOrDependsOnModule)(context, VITEST_MODULES, VITEST_MODULES),
};
if (!Object.values(activeFrameworks).some(Boolean)) {
return {};
}
const allowGlobalTestFunctions = !importsUnsupportedFrameworkForGlobals(context);
const scopeStack = [];
const functionStack = [];
const testCallbacks = new Set();
function pushFrame(disabled) {
scopeStack.push({ disabled, tests: [] });
}
function currentFrame() {
return scopeStack.at(-1);
}
return {
Program() {
pushFrame(false);
},
'Program:exit'() {
const frame = scopeStack.pop();
if (frame !== undefined) {
checkSimilarTests(context, frame.tests);
}
},
BlockStatement(node) {
const currentFunction = functionStack.at(-1);
const isTestCallbackBody = currentFunction !== undefined &&
testCallbacks.has(currentFunction) &&
currentFunction.body === node;
pushFrame(Boolean(currentFrame()?.disabled || isTestCallbackBody));
},
'BlockStatement:exit'() {
const frame = scopeStack.pop();
if (frame !== undefined && !frame.disabled) {
checkSimilarTests(context, frame.tests);
}
},
':function'(node) {
if (isCallbackFunctionNode(node)) {
functionStack.push(node);
}
},
':function:exit'(node) {
if (isCallbackFunctionNode(node)) {
testCallbacks.delete(node);
functionStack.pop();
}
},
CallExpression(node) {
const testCall = getTestCall(context, node, activeFrameworks, allowGlobalTestFunctions);
if (testCall === undefined) {
return;
}
testCallbacks.add(testCall.callback);
const candidate = toTestCandidate(node, testCall);
const frame = currentFrame();
if (candidate !== undefined && frame !== undefined && !frame.disabled) {
frame.tests.push(candidate);
}
},
};
},
};
function importsUnsupportedFrameworkForGlobals(context) {
return (0, module_js_1.importsModule)(context, [...UNSUPPORTED_TEST_MODULES]);
}
function getTestCall(context, node, activeFrameworks, allowGlobalTestFunctions) {
const classification = classifyTestCall(context, node, activeFrameworks, allowGlobalTestFunctions);
if (classification === undefined) {
return undefined;
}
const callback = node.arguments.find(isCallbackFunctionNode);
if (callback === undefined) {
return undefined;
}
return { callback, concrete: classification === 'concrete' };
}
function classifyTestCall(context, node, activeFrameworks, allowGlobalTestFunctions) {
const calleeParts = (0, mocha_style_test_frameworks_js_1.getMochaCalleeParts)(node.callee);
if (calleeParts === undefined) {
return undefined;
}
const testConstruct = getSupportedTestConstruct(context, calleeParts.base, calleeParts.modifiers, activeFrameworks, allowGlobalTestFunctions);
if (testConstruct === undefined) {
return undefined;
}
if (testConstruct.modifiers.every(modifier => isConcreteModifier(modifier, activeFrameworks))) {
return 'concrete';
}
if (testConstruct.modifiers.some(modifier => NON_CONCRETE_TEST_MODIFIERS.has(modifier))) {
return 'ignored';
}
return 'ignored';
}
function getSupportedTestConstruct(context, base, modifiers, activeFrameworks, allowGlobalTestFunctions) {
const fqn = (0, module_js_1.getFullyQualifiedName)(context, base);
const importedName = getTestNameFromFullyQualifiedName(fqn);
if (importedName !== undefined) {
return { name: importedName, modifiers };
}
if (modifiers.length > 0 && TEST_FUNCTION_NAMES.has(modifiers[0]) && isSupportedModule(fqn)) {
return { name: modifiers[0], modifiers: modifiers.slice(1) };
}
const variable = (0, ast_js_1.getVariableFromScope)(context.sourceCode.getScope(base), base.name);
if (allowGlobalTestFunctions &&
(variable === undefined || variable.defs.length === 0) &&
TEST_FUNCTION_NAMES.has(base.name) &&
(activeFrameworks.jest || activeFrameworks.vitest)) {
return { name: base.name, modifiers };
}
return undefined;
}
function getTestNameFromFullyQualifiedName(fqn) {
if (fqn === null) {
return undefined;
}
const moduleName = SUPPORTED_MODULE_FQNS.find(moduleName => fqn.startsWith(`${moduleName}.`));
const testName = moduleName === undefined ? undefined : fqn.slice(moduleName.length + 1);
return testName !== undefined && TEST_FUNCTION_NAMES.has(testName) ? testName : undefined;
}
function isSupportedModule(fqn) {
return fqn !== null && SUPPORTED_MODULE_FQNS.includes(fqn);
}
function isConcreteModifier(modifier, activeFrameworks) {
return (COMMON_TEST_MODIFIERS.has(modifier) ||
(activeFrameworks.jest && JEST_TEST_MODIFIERS.has(modifier)) ||
(activeFrameworks.playwright && PLAYWRIGHT_TEST_MODIFIERS.has(modifier)) ||
(activeFrameworks.vitest && VITEST_TEST_MODIFIERS.has(modifier)));
}
function toTestCandidate(node, testCall) {
const titleNode = node.arguments[0];
if (!testCall.concrete || titleNode === undefined || (0, mocha_style_test_frameworks_js_1.getStaticTitle)(titleNode) === undefined) {
return undefined;
}
const { callback } = testCall;
if (callback.body.type !== 'BlockStatement' || callback.body.body.length < MIN_STATEMENTS) {
return undefined;
}
return {
...testCall,
body: callback.body.body,
titleNode,
};
}
function checkSimilarTests(context, tests) {
if (tests.length < MIN_SIMILAR_TESTS) {
return;
}
const handled = new Set();
for (const [index, test] of tests.entries()) {
if (handled.has(test)) {
continue;
}
const literalCollector = new LiteralDifferenceCollector(context, test.body);
const equivalentTests = [];
for (const otherTest of tests.slice(index + 1)) {
if (handled.has(otherTest)) {
continue;
}
if (literalCollector.matches(otherTest.body) &&
literalCollector.acceptCurrentWithinLimits(MAX_PARAMETERS)) {
equivalentTests.push(otherTest);
}
literalCollector.clearCurrent();
}
reportIfIssue(context, test, literalCollector, equivalentTests, handled);
}
}
function reportIfIssue(context, test, literalCollector, equivalentTests, handled) {
if (equivalentTests.length + 1 < MIN_SIMILAR_TESTS) {
return;
}
const parameterNodes = literalCollector.nodesToParameterize;
if (parameterNodes.size === 0) {
return;
}
handled.add(test);
for (const equivalentTest of equivalentTests) {
handled.add(equivalentTest);
}
(0, location_js_1.report)(context, {
node: test.titleNode,
messageId: 'replaceWithParameterizedTest',
message: messages.replaceWithParameterizedTest,
data: { count: String(equivalentTests.length + 1) },
}, [
...[...parameterNodes].map(node => (0, location_js_1.toSecondaryLocation)(node, 'Value to parameterize')),
...equivalentTests.map(({ titleNode }) => (0, location_js_1.toSecondaryLocation)(titleNode, 'Related test')),
]);
}
class LiteralDifferenceCollector {
constructor(context, baseBody) {
this.context = context;
this.baseBody = baseBody;
this.nodesToParameterize = new Set();
this.currentNodesToParameterize = new Set();
this.literalNodesByTokenRange = collectLiteralNodes(context, baseBody);
}
matches(otherBody) {
const otherLiteralNodesByTokenRange = collectLiteralNodes(this.context, otherBody);
return (0, equivalence_js_1.areEquivalent)(this.baseBody, otherBody, this.context.sourceCode, (left, right) => {
if (left.value === right.value) {
return true;
}
const leftLiteral = this.literalNodesByTokenRange.get(tokenRangeKey(left));
const rightLiteral = otherLiteralNodesByTokenRange.get(tokenRangeKey(right));
if (leftLiteral === undefined || rightLiteral === undefined) {
return false;
}
const leftCategory = literalCategory(leftLiteral);
if (leftCategory === undefined || leftCategory !== literalCategory(rightLiteral)) {
return false;
}
if (Object.is(leftLiteral.value, rightLiteral.value)) {
return true;
}
this.currentNodesToParameterize.add(leftLiteral);
return true;
});
}
acceptCurrentWithinLimits(maxParameters) {
const parameterCount = new Set([
...this.nodesToParameterize,
...this.currentNodesToParameterize,
]).size;
if (parameterCount > maxParameters || this.baseBody.length <= parameterCount) {
return false;
}
for (const node of this.currentNodesToParameterize) {
this.nodesToParameterize.add(node);
}
return true;
}
clearCurrent() {
this.currentNodesToParameterize.clear();
}
}
function collectLiteralNodes(context, nodes) {
const result = new Map();
const nodeList = Array.isArray(nodes) ? nodes : [nodes];
for (const node of nodeList) {
collectLiteralNode(context, node, result);
}
return result;
}
function collectLiteralNode(context, node, result) {
if (node.type === 'Literal' && literalCategory(node) !== undefined) {
const firstToken = context.sourceCode.getFirstToken(node);
if (firstToken !== null) {
result.set(tokenRangeKey(firstToken), node);
}
return;
}
for (const child of (0, ancestor_js_1.childrenOf)(node, context.sourceCode.visitorKeys)) {
collectLiteralNode(context, child, result);
}
}
function tokenRangeKey(token) {
return token.range?.join(':') ?? '';
}
function literalCategory(node) {
if ('regex' in node && node.regex !== undefined) {
return undefined;
}
if (node.value === null) {
return 'null';
}
switch (typeof node.value) {
case 'bigint':
return 'bigint';
case 'boolean':
return 'boolean';
case 'number':
return 'number';
case 'string':
return 'string';
default:
return undefined;
}
}
function isCallbackFunctionNode(node) {
return node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression';
}