UNPKG

eslint-plugin-sonarjs

Version:
390 lines (389 loc) • 15.5 kB
"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; }