UNPKG

eslint-plugin-sonarjs

Version:
294 lines (293 loc) 13.4 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/S5914/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 assertions_js_1 = require("../helpers/assertions.js"); const generate_meta_js_1 = require("../helpers/generate-meta.js"); const constant_evaluation_js_1 = require("./constant-evaluation.js"); const meta = __importStar(require("./generated-meta.js")); const messages = { issue: 'Replace this assertion; it always succeeds.', // hint targeted at identity comparisons against a freshly-created value: name the deep equality // matcher for the assertion's own style, since it's almost always what was meant freshIdentity: 'Use `{{matcher}}` instead; freshly-created values are never identical to other values.', // hint for predicate assertions on a freshly-created value, where the truthiness/nullishness is statically known freshPredicate: 'Replace this assertion; the value is freshly created here, so the result is independent of the code under test.', suggestDeepEquality: 'Replace with `{{matcher}}`.', }; exports.rule = { meta: (0, generate_meta_js_1.generateMeta)(meta, { messages, hasSuggestions: true }), create(context) { function checkAssertion(node) { const assertion = (0, assertions_js_1.extractTestAssertion)(context, node); const trivial = assertion ? getTrivialAssertion(context, assertion) : null; if (trivial) { context.report({ node: trivial.reportNode, messageId: trivial.messageId, data: trivial.data, suggest: trivial.suggest, }); } } return { CallExpression(node) { checkAssertion(node); }, MemberExpression(node) { checkAssertion(node); }, }; }, }; /** * Classifies the assertion as trivially true (always guaranteed to succeed) and picks the most * informative message for the underlying cause. An assertion that is guaranteed to always *fail* * is not reported here: it's self-correcting, since it makes the test suite fail immediately. * The one exception is an identity comparison against a freshly-created value, which is reported * regardless of which way it resolves, since it usually signals a toBe/toEqual mixup. * @returns the node to report and the corresponding message id, or null if the assertion isn't trivially true. */ function getTrivialAssertion(context, assertion) { switch (assertion.kind) { case 'predicate': return resolvePredicateAssertion(context, assertion); case 'comparison': return resolveComparisonAssertion(context, assertion); default: return null; } } function resolvePredicateAssertion(context, assertion) { const actual = (0, constant_evaluation_js_1.resolveConstantPrimitiveValue)(context, assertion.actual); if (actual && (0, constant_evaluation_js_1.predicateHolds)(assertion.predicate, actual.value) !== assertion.negated) { return { reportNode: assertion.actual, messageId: 'issue' }; } if (isFreshReferenceExpression(assertion.actual) && (0, constant_evaluation_js_1.freshReferencePredicateHolds)(assertion.predicate) !== assertion.negated) { return { reportNode: assertion.actual, messageId: 'freshPredicate' }; } return null; } function resolveComparisonAssertion(context, assertion) { if (assertion.comparison === 'strict') { // a freshly-created value on either side makes strict equality trivially fail (or // trivially succeed when negated), and is flagged either way: it's a common // toBe/toEqual mixup. Loose equality is excluded because object-to-primitive coercion // can make comparisons like `[] == false` pass. if (isFreshReferenceExpression(assertion.actual)) { return { reportNode: assertion.actual, messageId: 'freshIdentity', data: { matcher: getDeepEqualityMatcher(assertion.style, assertion.negated) }, suggest: toSuggestions(getFreshIdentitySuggestion(context, assertion)), }; } if (isFreshReferenceExpression(assertion.expected)) { return { reportNode: assertion.expected, messageId: 'freshIdentity', data: { matcher: getDeepEqualityMatcher(assertion.style, assertion.negated) }, suggest: toSuggestions(getFreshIdentitySuggestion(context, assertion)), }; } } // both sides are constant (syntactically or via a resolved binding): for strict or loose // equality the comparison result is statically determined. Deep equality is intentionally // skipped via an explicit whitelist to avoid widening this rule when assertion helper // comparison kinds evolve. if (assertion.comparison === 'strict' || assertion.comparison === 'loose') { const actual = (0, constant_evaluation_js_1.resolveConstantPrimitiveValue)(context, assertion.actual); const expected = (0, constant_evaluation_js_1.resolveConstantPrimitiveValue)(context, assertion.expected); if (actual && expected) { const equal = assertion.comparison === 'strict' ? (0, constant_evaluation_js_1.strictEqualityHolds)(assertion.style, actual.value, expected.value) : // eslint-disable-next-line eqeqeq actual.value == expected.value; if (equal !== assertion.negated) { return { reportNode: assertion.actual, messageId: 'issue' }; } } } return null; } /** * Names the deep equality matcher that most likely fixes a `freshIdentity` finding, for the * assertion's own style and negation, so the suggestion reads naturally regardless of which * library the offending assertion came from. */ function getDeepEqualityMatcher(style, negated) { switch (style) { case 'jest-like': case 'jasmine': case 'playwright': return negated ? 'not.toEqual' : 'toEqual'; case 'chai-bdd': case 'cypress': return negated ? 'not.deep.equal' : 'deep.equal'; case 'chai-assert': return negated ? 'notDeepEqual' : 'deepEqual'; case 'node-assert': return negated ? 'notDeepStrictEqual' : 'deepStrictEqual'; default: { const exhaustiveCheck = style; return exhaustiveCheck; } } } function toSuggestions(suggestion) { return suggestion ? [suggestion] : undefined; } /** * Builds the suggestion that swaps a `freshIdentity` assertion's matcher for its deep equality * counterpart, for the assertion's own style and negation. Returns null when the assertion's * shape doesn't map cleanly onto a single text edit (e.g. an unexpected AST shape). */ function getFreshIdentitySuggestion(context, assertion) { const matcher = getDeepEqualityMatcher(assertion.style, assertion.negated); switch (assertion.style) { case 'jest-like': case 'jasmine': case 'playwright': // reportNode is always the matcher property identifier itself (e.g. `toBe` in // `expect(x).toBe(y)`), so it's always safe to rename in place return replacePropertyIdentifier(assertion.reportNode, 'toEqual', matcher); case 'chai-bdd': // `eql` is chai's built-in alias for `.deep.equal`, so no chain restructuring is needed; // reportNode is always the matcher property identifier (e.g. `equal` in `.to.equal(y)`). // Report the inserted name (`eql`, with `not` left untouched when already present) so the // suggestion label matches the applied edit. return replacePropertyIdentifier(assertion.reportNode, 'eql', assertion.negated ? 'not.eql' : 'eql'); case 'chai-assert': return replaceCalleeMethodName(assertion.node, assertion.negated ? 'notDeepEqual' : 'deepEqual', matcher); case 'node-assert': return replaceCalleeMethodName(assertion.node, assertion.negated ? 'notDeepStrictEqual' : 'deepStrictEqual', matcher); case 'cypress': return replaceCypressPredicateString(context, assertion, matcher); default: { const exhaustiveCheck = assertion.style; return exhaustiveCheck; } } } function replacePropertyIdentifier(reportNode, newName, matcher) { if (reportNode.type !== 'Identifier') { return null; } return { messageId: 'suggestDeepEquality', data: { matcher }, fix: fixer => fixer.replaceText(reportNode, newName), }; } /** * Replaces the method name in an `obj.method(...)` call with `newName`. Only fires when the * call is written as a member access: destructured/aliased imports like * `import { strictEqual as same } from 'node:assert'; same(x, y)` make the callee a bare * identifier bound to a specific function, so renaming its call site would reference an * undefined binding instead of fixing the assertion. */ function replaceCalleeMethodName(assertionNode, newName, matcher) { if (assertionNode.type !== 'CallExpression' || assertionNode.callee.type !== 'MemberExpression' || assertionNode.callee.computed || assertionNode.callee.property.type !== 'Identifier') { return null; } const identifier = assertionNode.callee.property; return { messageId: 'suggestDeepEquality', data: { matcher }, fix: fixer => fixer.replaceText(identifier, newName), }; } /** * Cypress expresses the comparison as a Chai assertion string, e.g. `cy.wrap(x).should('equal', y)`, * so the fix rewrites the string content instead of an identifier. */ function replaceCypressPredicateString(context, assertion, matcher) { if (assertion.node.type !== 'CallExpression') { return null; } const predicateArg = assertion.node.arguments[0]; if (predicateArg?.type !== 'Literal' || typeof predicateArg.value !== 'string') { return null; } const quote = context.sourceCode.getText(predicateArg)[0]; const newValue = assertion.negated ? 'not.deep.equal' : 'deep.equal'; return { messageId: 'suggestDeepEquality', data: { matcher }, fix: fixer => fixer.replaceText(predicateArg, `${quote}${newValue}${quote}`), }; } /** * These expression types create a new reference on each evaluation, so they are always different from any other value */ const FRESH_REFERENCE_EXPRESSIONS = new Set([ 'ArrayExpression', 'ObjectExpression', 'FunctionExpression', 'ArrowFunctionExpression', 'ClassExpression', 'NewExpression', ]); /** * Checks if the given node is an expression that creates a new reference on each evaluation, and thus is always different from any other value. */ function isFreshReferenceExpression(node) { // regex literals create a fresh RegExp object on each evaluation if (node.type === 'Literal' && 'regex' in node) { return true; } return FRESH_REFERENCE_EXPRESSIONS.has(node.type); }