eslint-plugin-sonarjs
Version:
294 lines (293 loc) • 13.4 kB
JavaScript
;
/*
* 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);
}