eslint-plugin-sonarjs
Version:
390 lines (389 loc) • 15.5 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/S9072/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;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.rule = void 0;
const typescript_1 = __importDefault(require("typescript"));
const ast_js_1 = require("../helpers/ast.js");
const expect_call_chain_js_1 = require("../helpers/expect-call-chain.js");
const generate_meta_js_1 = require("../helpers/generate-meta.js");
const module_js_1 = require("../helpers/module.js");
const parser_services_js_1 = require("../helpers/parser-services.js");
const type_js_1 = require("../helpers/type.js");
const mocha_style_test_frameworks_js_1 = require("../helpers/testing/mocha-style-test-frameworks.js");
const meta = __importStar(require("./generated-meta.js"));
const messages = {
nonCallable: '`{{matcher}}` can only test exceptions thrown by a function it invokes. Wrap the code that may throw in a function.',
asyncCallback: 'This exception assertion only catches synchronous throws; use a rejection assertion for the async callback.',
wrapInCallback: 'Wrap the asserted operation in a synchronous callback.',
useRejectionAssertion: 'Use a rejection assertion for the async callback.',
};
const EXPECT_FQNS = new Set(['@jest.globals.expect', 'vitest.expect', 'bun:test.expect']);
const SUPPORTED_MODULES = new Set(['@jest/globals', 'vitest', 'bun:test']);
const EXCEPTION_MATCHERS = new Set(['toThrow', 'toThrowError']);
const TEST_CONSTRUCTS = new Set([
...mocha_style_test_frameworks_js_1.TEST_FUNCTION_NAMES,
'before',
'after',
'beforeEach',
'afterEach',
'beforeAll',
'afterAll',
]);
const TEST_MODIFIERS = new Set(['only', 'concurrent', 'failing', 'sequential', 'skip', 'todo']);
const SECOND_TO_LAST_INDEX = -2;
function getRequiredServices(context) {
const services = context.sourceCode.parserServices;
return (0, parser_services_js_1.isRequiredParserServices)(services) ? services : null;
}
exports.rule = {
meta: (0, generate_meta_js_1.generateMeta)(meta, { messages, hasSuggestions: true }),
create(context) {
const services = getRequiredServices(context);
if (!services) {
return {};
}
return {
CallExpression(node) {
const assertion = getAssertion(context, node);
if (!assertion) {
return;
}
if (isExplicitAsyncCallback(assertion.actual)) {
context.report({
node: assertion.actual,
messageId: 'asyncCallback',
suggest: getAsyncCallbackSuggestion(context, assertion),
});
return;
}
if (!isDefinitelyNonCallable(assertion.actual, services)) {
return;
}
context.report({
node: assertion.actual,
messageId: 'nonCallable',
data: { matcher: assertion.matcher },
suggest: getNonCallableSuggestion(context, assertion.actual, services),
});
},
};
},
};
function getAssertion(context, node) {
if (node.type !== 'CallExpression' ||
node.callee.type !== 'MemberExpression' ||
node.callee.computed ||
!(0, ast_js_1.isIdentifier)(node.callee.property) ||
!EXCEPTION_MATCHERS.has(node.callee.property.name)) {
return null;
}
const parent = context.sourceCode.getAncestors(node).at(-1);
if ((parent?.type === 'MemberExpression' && parent.object === node) ||
(parent?.type === 'CallExpression' && parent.callee === node)) {
return null;
}
const chain = (0, expect_call_chain_js_1.collectCallChain)(node);
if (!chain.complete) {
return null;
}
const root = getExpectCall(context, node);
if (!root || root.arguments.length === 0) {
return null;
}
const rootMemberNames = getMemberNames(root.callee);
const matcherSegments = chain.segments.slice(0, -rootMemberNames.length || undefined);
if (matcherSegments.length !== 1 && matcherSegments.length !== 2) {
return null;
}
const not = matcherSegments[1]?.name === 'not' ? matcherSegments[1].node : null;
if (matcherSegments.length === 2 && not === null) {
return null;
}
const notAncestors = not ? context.sourceCode.getAncestors(not) : [];
const notMember = notAncestors.at(-1);
const notCall = notAncestors.at(SECOND_TO_LAST_INDEX);
if (notMember?.type === 'MemberExpression' &&
notCall?.type === 'CallExpression' &&
notCall.callee === notMember) {
return null;
}
const [actual] = root.arguments;
if (!actual || actual.type === 'SpreadElement') {
return null;
}
return { call: node, root, actual, not, matcher: node.callee.property.name };
}
function isExpectCall(context, callee) {
const fqn = (0, module_js_1.getFullyQualifiedName)(context, callee);
if (fqn && EXPECT_FQNS.has(fqn)) {
const runtime = isRuntimeBinding(context, callee);
return runtime;
}
return isRequireExpectCall(callee) || isRequireExpectBinding(context, callee);
}
function getExpectCall(context, call) {
let current = call;
while (current.type === 'CallExpression' ||
current.type === 'MemberExpression' ||
current.type === 'ChainExpression') {
if (current.type === 'CallExpression') {
if (isExpectCall(context, current.callee)) {
return current;
}
current = (0, expect_call_chain_js_1.unwrapChainExpression)(current.callee);
}
else if (current.type === 'MemberExpression') {
current = (0, expect_call_chain_js_1.unwrapChainExpression)(current.object);
}
else {
current = current.expression;
}
}
return null;
}
function isRuntimeBinding(context, node) {
const identifier = getRootIdentifier(node);
if (!identifier) {
// Inline `require('module').expect(...)` has no binding that can be type-only.
return true;
}
const variable = (0, ast_js_1.getVariableFromScope)(context.sourceCode.getScope(identifier), identifier.name);
const importDefinition = variable?.defs.find(def => def.type === 'ImportBinding');
if (importDefinition?.type === 'ImportBinding') {
const declaration = importDefinition.parent;
const specifier = importDefinition.node;
return !(0, ast_js_1.isTypeOnlyImport)(declaration) && specifier.importKind !== 'type';
}
return variable?.defs.some(def => def.type === 'Variable') ?? false;
}
function isRequireExpectCall(node) {
return (node.type === 'MemberExpression' &&
!node.computed &&
(0, ast_js_1.isIdentifier)(node.property, 'expect') &&
isSupportedRequire(node.object));
}
function isRequireExpectBinding(context, node) {
if (node.type !== 'Identifier') {
return false;
}
const variable = (0, ast_js_1.getVariableFromScope)(context.sourceCode.getScope(node), node.name);
for (const definition of variable?.defs ?? []) {
if (definition.type !== 'Variable') {
continue;
}
const declarator = definition.node;
const initializer = declarator.init;
if (!initializer || !isSupportedRequire(initializer)) {
continue;
}
if (initializer.type === 'MemberExpression' &&
!initializer.computed &&
(0, ast_js_1.isIdentifier)(initializer.property, 'expect')) {
return true;
}
if (declarator.id.type === 'ObjectPattern') {
return declarator.id.properties.some(property => {
if (property.type !== 'Property' ||
property.computed ||
property.key.type !== 'Identifier') {
return false;
}
return (property.key.name === 'expect' &&
property.value.type === 'Identifier' &&
property.value.name === node.name);
});
}
}
return false;
}
function isSupportedRequire(node) {
const requireCall = getRequireCall(node);
const moduleName = requireCall?.arguments[0];
return (moduleName?.type === 'Literal' &&
typeof moduleName.value === 'string' &&
SUPPORTED_MODULES.has(moduleName.value));
}
function getRequireCall(node) {
if (node?.type === 'CallExpression' &&
(0, ast_js_1.isIdentifier)(node.callee, 'require') &&
node.arguments.length === 1) {
return node;
}
if (node?.type === 'MemberExpression') {
return getRequireCall(node.object);
}
return null;
}
function getRootIdentifier(node) {
let current = node;
while (current.type === 'MemberExpression' || current.type === 'ChainExpression') {
current = current.type === 'MemberExpression' ? current.object : current.expression;
}
return current.type === 'Identifier' ? current : null;
}
function getMemberNames(node) {
const names = [];
let current = node;
while (current.type === 'MemberExpression' &&
!current.computed &&
(0, ast_js_1.isIdentifier)(current.property)) {
names.unshift(current.property.name);
current = current.object;
}
return names;
}
function isExplicitAsyncCallback(node) {
return ((node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression') &&
node.async === true &&
!node.generator);
}
function isDefinitelyNonCallable(node, services) {
const type = (0, type_js_1.getTypeFromTreeNode)(node, services);
if ((0, type_js_1.isAnyOrUnknownType)(type) || isUncertainType(type)) {
return false;
}
const types = type.isUnion() ? type.types : [type];
return types.every(constituent => {
if ((0, type_js_1.isAnyOrUnknownType)(constituent) || isUncertainType(constituent)) {
return false;
}
return constituent.getCallSignatures().length === 0;
});
}
function isUncertainType(type) {
const flags = type.getFlags();
return ((flags &
(typescript_1.default.TypeFlags.Any |
typescript_1.default.TypeFlags.Unknown |
typescript_1.default.TypeFlags.TypeParameter |
typescript_1.default.TypeFlags.IndexedAccess |
typescript_1.default.TypeFlags.Conditional |
typescript_1.default.TypeFlags.Substitution)) !==
0);
}
function getNonCallableSuggestion(context, actual, services) {
if (actual.type !== 'CallExpression' && actual.type !== 'NewExpression') {
return undefined;
}
if ((0, type_js_1.isThenableOrThenableUnion)(actual, services)) {
return undefined;
}
return [
{
messageId: 'wrapInCallback',
fix: fixer => fixer.replaceText(actual, `() => ${context.sourceCode.getText(actual)}`),
},
];
}
function getAsyncCallbackSuggestion(context, assertion) {
const callback = getEnclosingAsyncTestCallback(context, assertion.call);
if (!callback || !isStandaloneExpression(context, assertion.call)) {
return undefined;
}
const target = assertion.not ?? assertion.call.callee.property;
return [
{
messageId: 'useRejectionAssertion',
fix: fixer => [
fixer.insertTextBefore(assertion.root, 'await '),
fixer.replaceText(assertion.actual, `(${context.sourceCode.getText(assertion.actual)})()`),
fixer.insertTextBefore(target, 'rejects.'),
],
},
];
}
function getEnclosingAsyncTestCallback(context, node) {
const ancestors = context.sourceCode.getAncestors(node);
for (let index = ancestors.length - 1; index >= 0; index--) {
const ancestor = ancestors[index];
if (!(0, ast_js_1.isFunctionNode)(ancestor)) {
continue;
}
const parent = ancestors[index - 1];
if (ancestor.type === 'FunctionDeclaration') {
return null;
}
const callback = ancestor;
if (callback.async !== true ||
callback.params[0]?.type === 'Identifier' ||
parent?.type !== 'CallExpression' ||
!parent.arguments.includes(callback) ||
!isRecognizedTestOrHook(context, parent)) {
return null;
}
return callback;
}
return null;
}
function isStandaloneExpression(context, node) {
const parent = context.sourceCode.getAncestors(node).at(-1);
return parent?.type === 'ExpressionStatement';
}
function isRecognizedTestOrHook(context, call) {
const fqn = (0, module_js_1.getFullyQualifiedName)(context, call.callee);
if (fqn) {
for (const moduleName of SUPPORTED_MODULES) {
const prefix = `${moduleName.replaceAll('/', '.')}.`;
if (!fqn.startsWith(prefix)) {
continue;
}
const parts = fqn.slice(prefix.length).split('.');
return (TEST_CONSTRUCTS.has(parts[0]) &&
parts.slice(1).every(modifier => TEST_MODIFIERS.has(modifier)) &&
isRuntimeBinding(context, call.callee));
}
}
return false;
}