You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
510 lines
20 KiB
510 lines
20 KiB
1 month ago
|
/**
|
||
|
* @fileoverview Rule to flag constant comparisons and logical expressions that always/never short circuit
|
||
|
* @author Jordan Eldredge <https://jordaneldredge.com>
|
||
|
*/
|
||
|
|
||
|
"use strict";
|
||
|
|
||
|
const globals = require("globals");
|
||
|
const { isNullLiteral, isConstant, isReferenceToGlobalVariable, isLogicalAssignmentOperator } = require("./utils/ast-utils");
|
||
|
|
||
|
const NUMERIC_OR_STRING_BINARY_OPERATORS = new Set(["+", "-", "*", "/", "%", "|", "^", "&", "**", "<<", ">>", ">>>"]);
|
||
|
|
||
|
//------------------------------------------------------------------------------
|
||
|
// Helpers
|
||
|
//------------------------------------------------------------------------------
|
||
|
|
||
|
/**
|
||
|
* Checks whether or not a node is `null` or `undefined`. Similar to the one
|
||
|
* found in ast-utils.js, but this one correctly handles the edge case that
|
||
|
* `undefined` has been redefined.
|
||
|
* @param {Scope} scope Scope in which the expression was found.
|
||
|
* @param {ASTNode} node A node to check.
|
||
|
* @returns {boolean} Whether or not the node is a `null` or `undefined`.
|
||
|
* @public
|
||
|
*/
|
||
|
function isNullOrUndefined(scope, node) {
|
||
|
return (
|
||
|
isNullLiteral(node) ||
|
||
|
(node.type === "Identifier" && node.name === "undefined" && isReferenceToGlobalVariable(scope, node)) ||
|
||
|
(node.type === "UnaryExpression" && node.operator === "void")
|
||
|
);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Test if an AST node has a statically knowable constant nullishness. Meaning,
|
||
|
* it will always resolve to a constant value of either: `null`, `undefined`
|
||
|
* or not `null` _or_ `undefined`. An expression that can vary between those
|
||
|
* three states at runtime would return `false`.
|
||
|
* @param {Scope} scope The scope in which the node was found.
|
||
|
* @param {ASTNode} node The AST node being tested.
|
||
|
* @param {boolean} nonNullish if `true` then nullish values are not considered constant.
|
||
|
* @returns {boolean} Does `node` have constant nullishness?
|
||
|
*/
|
||
|
function hasConstantNullishness(scope, node, nonNullish) {
|
||
|
if (nonNullish && isNullOrUndefined(scope, node)) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
switch (node.type) {
|
||
|
case "ObjectExpression": // Objects are never nullish
|
||
|
case "ArrayExpression": // Arrays are never nullish
|
||
|
case "ArrowFunctionExpression": // Functions never nullish
|
||
|
case "FunctionExpression": // Functions are never nullish
|
||
|
case "ClassExpression": // Classes are never nullish
|
||
|
case "NewExpression": // Objects are never nullish
|
||
|
case "Literal": // Nullish, or non-nullish, literals never change
|
||
|
case "TemplateLiteral": // A string is never nullish
|
||
|
case "UpdateExpression": // Numbers are never nullish
|
||
|
case "BinaryExpression": // Numbers, strings, or booleans are never nullish
|
||
|
return true;
|
||
|
case "CallExpression": {
|
||
|
if (node.callee.type !== "Identifier") {
|
||
|
return false;
|
||
|
}
|
||
|
const functionName = node.callee.name;
|
||
|
|
||
|
return (functionName === "Boolean" || functionName === "String" || functionName === "Number") &&
|
||
|
isReferenceToGlobalVariable(scope, node.callee);
|
||
|
}
|
||
|
case "LogicalExpression": {
|
||
|
return node.operator === "??" && hasConstantNullishness(scope, node.right, true);
|
||
|
}
|
||
|
case "AssignmentExpression":
|
||
|
if (node.operator === "=") {
|
||
|
return hasConstantNullishness(scope, node.right, nonNullish);
|
||
|
}
|
||
|
|
||
|
/*
|
||
|
* Handling short-circuiting assignment operators would require
|
||
|
* walking the scope. We won't attempt that (for now...) /
|
||
|
*/
|
||
|
if (isLogicalAssignmentOperator(node.operator)) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
/*
|
||
|
* The remaining assignment expressions all result in a numeric or
|
||
|
* string (non-nullish) value:
|
||
|
* "+=", "-=", "*=", "/=", "%=", "<<=", ">>=", ">>>=", "|=", "^=", "&="
|
||
|
*/
|
||
|
|
||
|
return true;
|
||
|
case "UnaryExpression":
|
||
|
|
||
|
/*
|
||
|
* "void" Always returns `undefined`
|
||
|
* "typeof" All types are strings, and thus non-nullish
|
||
|
* "!" Boolean is never nullish
|
||
|
* "delete" Returns a boolean, which is never nullish
|
||
|
* Math operators always return numbers or strings, neither of which
|
||
|
* are non-nullish "+", "-", "~"
|
||
|
*/
|
||
|
|
||
|
return true;
|
||
|
case "SequenceExpression": {
|
||
|
const last = node.expressions[node.expressions.length - 1];
|
||
|
|
||
|
return hasConstantNullishness(scope, last, nonNullish);
|
||
|
}
|
||
|
case "Identifier":
|
||
|
return node.name === "undefined" && isReferenceToGlobalVariable(scope, node);
|
||
|
case "JSXElement": // ESLint has a policy of not assuming any specific JSX behavior.
|
||
|
case "JSXFragment":
|
||
|
return false;
|
||
|
default:
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Test if an AST node is a boolean value that never changes. Specifically we
|
||
|
* test for:
|
||
|
* 1. Literal booleans (`true` or `false`)
|
||
|
* 2. Unary `!` expressions with a constant value
|
||
|
* 3. Constant booleans created via the `Boolean` global function
|
||
|
* @param {Scope} scope The scope in which the node was found.
|
||
|
* @param {ASTNode} node The node to test
|
||
|
* @returns {boolean} Is `node` guaranteed to be a boolean?
|
||
|
*/
|
||
|
function isStaticBoolean(scope, node) {
|
||
|
switch (node.type) {
|
||
|
case "Literal":
|
||
|
return typeof node.value === "boolean";
|
||
|
case "CallExpression":
|
||
|
return node.callee.type === "Identifier" && node.callee.name === "Boolean" &&
|
||
|
isReferenceToGlobalVariable(scope, node.callee) &&
|
||
|
(node.arguments.length === 0 || isConstant(scope, node.arguments[0], true));
|
||
|
case "UnaryExpression":
|
||
|
return node.operator === "!" && isConstant(scope, node.argument, true);
|
||
|
default:
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
|
||
|
/**
|
||
|
* Test if an AST node will always give the same result when compared to a
|
||
|
* boolean value. Note that comparison to boolean values is different than
|
||
|
* truthiness.
|
||
|
* https://262.ecma-international.org/5.1/#sec-11.9.3
|
||
|
*
|
||
|
* Javascript `==` operator works by converting the boolean to `1` (true) or
|
||
|
* `+0` (false) and then checks the values `==` equality to that number.
|
||
|
* @param {Scope} scope The scope in which node was found.
|
||
|
* @param {ASTNode} node The node to test.
|
||
|
* @returns {boolean} Will `node` always coerce to the same boolean value?
|
||
|
*/
|
||
|
function hasConstantLooseBooleanComparison(scope, node) {
|
||
|
switch (node.type) {
|
||
|
case "ObjectExpression":
|
||
|
case "ClassExpression":
|
||
|
|
||
|
/**
|
||
|
* In theory objects like:
|
||
|
*
|
||
|
* `{toString: () => a}`
|
||
|
* `{valueOf: () => a}`
|
||
|
*
|
||
|
* Or a classes like:
|
||
|
*
|
||
|
* `class { static toString() { return a } }`
|
||
|
* `class { static valueOf() { return a } }`
|
||
|
*
|
||
|
* Are not constant verifiably when `inBooleanPosition` is
|
||
|
* false, but it's an edge case we've opted not to handle.
|
||
|
*/
|
||
|
return true;
|
||
|
case "ArrayExpression": {
|
||
|
const nonSpreadElements = node.elements.filter(e =>
|
||
|
|
||
|
// Elements can be `null` in sparse arrays: `[,,]`;
|
||
|
e !== null && e.type !== "SpreadElement");
|
||
|
|
||
|
|
||
|
/*
|
||
|
* Possible future direction if needed: We could check if the
|
||
|
* single value would result in variable boolean comparison.
|
||
|
* For now we will err on the side of caution since `[x]` could
|
||
|
* evaluate to `[0]` or `[1]`.
|
||
|
*/
|
||
|
return node.elements.length === 0 || nonSpreadElements.length > 1;
|
||
|
}
|
||
|
case "ArrowFunctionExpression":
|
||
|
case "FunctionExpression":
|
||
|
return true;
|
||
|
case "UnaryExpression":
|
||
|
if (node.operator === "void" || // Always returns `undefined`
|
||
|
node.operator === "typeof" // All `typeof` strings, when coerced to number, are not 0 or 1.
|
||
|
) {
|
||
|
return true;
|
||
|
}
|
||
|
if (node.operator === "!") {
|
||
|
return isConstant(scope, node.argument, true);
|
||
|
}
|
||
|
|
||
|
/*
|
||
|
* We won't try to reason about +, -, ~, or delete
|
||
|
* In theory, for the mathematical operators, we could look at the
|
||
|
* argument and try to determine if it coerces to a constant numeric
|
||
|
* value.
|
||
|
*/
|
||
|
return false;
|
||
|
case "NewExpression": // Objects might have custom `.valueOf` or `.toString`.
|
||
|
return false;
|
||
|
case "CallExpression": {
|
||
|
if (node.callee.type === "Identifier" &&
|
||
|
node.callee.name === "Boolean" &&
|
||
|
isReferenceToGlobalVariable(scope, node.callee)
|
||
|
) {
|
||
|
return node.arguments.length === 0 || isConstant(scope, node.arguments[0], true);
|
||
|
}
|
||
|
return false;
|
||
|
}
|
||
|
case "Literal": // True or false, literals never change
|
||
|
return true;
|
||
|
case "Identifier":
|
||
|
return node.name === "undefined" && isReferenceToGlobalVariable(scope, node);
|
||
|
case "TemplateLiteral":
|
||
|
|
||
|
/*
|
||
|
* In theory we could try to check if the quasi are sufficient to
|
||
|
* prove that the expression will always be true, but it would be
|
||
|
* tricky to get right. For example: `000.${foo}000`
|
||
|
*/
|
||
|
return node.expressions.length === 0;
|
||
|
case "AssignmentExpression":
|
||
|
if (node.operator === "=") {
|
||
|
return hasConstantLooseBooleanComparison(scope, node.right);
|
||
|
}
|
||
|
|
||
|
/*
|
||
|
* Handling short-circuiting assignment operators would require
|
||
|
* walking the scope. We won't attempt that (for now...)
|
||
|
*
|
||
|
* The remaining assignment expressions all result in a numeric or
|
||
|
* string (non-nullish) values which could be truthy or falsy:
|
||
|
* "+=", "-=", "*=", "/=", "%=", "<<=", ">>=", ">>>=", "|=", "^=", "&="
|
||
|
*/
|
||
|
return false;
|
||
|
case "SequenceExpression": {
|
||
|
const last = node.expressions[node.expressions.length - 1];
|
||
|
|
||
|
return hasConstantLooseBooleanComparison(scope, last);
|
||
|
}
|
||
|
case "JSXElement": // ESLint has a policy of not assuming any specific JSX behavior.
|
||
|
case "JSXFragment":
|
||
|
return false;
|
||
|
default:
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
|
||
|
/**
|
||
|
* Test if an AST node will always give the same result when _strictly_ compared
|
||
|
* to a boolean value. This can happen if the expression can never be boolean, or
|
||
|
* if it is always the same boolean value.
|
||
|
* @param {Scope} scope The scope in which the node was found.
|
||
|
* @param {ASTNode} node The node to test
|
||
|
* @returns {boolean} Will `node` always give the same result when compared to a
|
||
|
* static boolean value?
|
||
|
*/
|
||
|
function hasConstantStrictBooleanComparison(scope, node) {
|
||
|
switch (node.type) {
|
||
|
case "ObjectExpression": // Objects are not booleans
|
||
|
case "ArrayExpression": // Arrays are not booleans
|
||
|
case "ArrowFunctionExpression": // Functions are not booleans
|
||
|
case "FunctionExpression":
|
||
|
case "ClassExpression": // Classes are not booleans
|
||
|
case "NewExpression": // Objects are not booleans
|
||
|
case "TemplateLiteral": // Strings are not booleans
|
||
|
case "Literal": // True, false, or not boolean, literals never change.
|
||
|
case "UpdateExpression": // Numbers are not booleans
|
||
|
return true;
|
||
|
case "BinaryExpression":
|
||
|
return NUMERIC_OR_STRING_BINARY_OPERATORS.has(node.operator);
|
||
|
case "UnaryExpression": {
|
||
|
if (node.operator === "delete") {
|
||
|
return false;
|
||
|
}
|
||
|
if (node.operator === "!") {
|
||
|
return isConstant(scope, node.argument, true);
|
||
|
}
|
||
|
|
||
|
/*
|
||
|
* The remaining operators return either strings or numbers, neither
|
||
|
* of which are boolean.
|
||
|
*/
|
||
|
return true;
|
||
|
}
|
||
|
case "SequenceExpression": {
|
||
|
const last = node.expressions[node.expressions.length - 1];
|
||
|
|
||
|
return hasConstantStrictBooleanComparison(scope, last);
|
||
|
}
|
||
|
case "Identifier":
|
||
|
return node.name === "undefined" && isReferenceToGlobalVariable(scope, node);
|
||
|
case "AssignmentExpression":
|
||
|
if (node.operator === "=") {
|
||
|
return hasConstantStrictBooleanComparison(scope, node.right);
|
||
|
}
|
||
|
|
||
|
/*
|
||
|
* Handling short-circuiting assignment operators would require
|
||
|
* walking the scope. We won't attempt that (for now...)
|
||
|
*/
|
||
|
if (isLogicalAssignmentOperator(node.operator)) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
/*
|
||
|
* The remaining assignment expressions all result in either a number
|
||
|
* or a string, neither of which can ever be boolean.
|
||
|
*/
|
||
|
return true;
|
||
|
case "CallExpression": {
|
||
|
if (node.callee.type !== "Identifier") {
|
||
|
return false;
|
||
|
}
|
||
|
const functionName = node.callee.name;
|
||
|
|
||
|
if (
|
||
|
(functionName === "String" || functionName === "Number") &&
|
||
|
isReferenceToGlobalVariable(scope, node.callee)
|
||
|
) {
|
||
|
return true;
|
||
|
}
|
||
|
if (functionName === "Boolean" && isReferenceToGlobalVariable(scope, node.callee)) {
|
||
|
return (
|
||
|
node.arguments.length === 0 || isConstant(scope, node.arguments[0], true));
|
||
|
}
|
||
|
return false;
|
||
|
}
|
||
|
case "JSXElement": // ESLint has a policy of not assuming any specific JSX behavior.
|
||
|
case "JSXFragment":
|
||
|
return false;
|
||
|
default:
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Test if an AST node will always result in a newly constructed object
|
||
|
* @param {Scope} scope The scope in which the node was found.
|
||
|
* @param {ASTNode} node The node to test
|
||
|
* @returns {boolean} Will `node` always be new?
|
||
|
*/
|
||
|
function isAlwaysNew(scope, node) {
|
||
|
switch (node.type) {
|
||
|
case "ObjectExpression":
|
||
|
case "ArrayExpression":
|
||
|
case "ArrowFunctionExpression":
|
||
|
case "FunctionExpression":
|
||
|
case "ClassExpression":
|
||
|
return true;
|
||
|
case "NewExpression": {
|
||
|
if (node.callee.type !== "Identifier") {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
/*
|
||
|
* All the built-in constructors are always new, but
|
||
|
* user-defined constructors could return a sentinel
|
||
|
* object.
|
||
|
*
|
||
|
* Catching these is especially useful for primitive constructors
|
||
|
* which return boxed values, a surprising gotcha' in JavaScript.
|
||
|
*/
|
||
|
return Object.hasOwnProperty.call(globals.builtin, node.callee.name) &&
|
||
|
isReferenceToGlobalVariable(scope, node.callee);
|
||
|
}
|
||
|
case "Literal":
|
||
|
|
||
|
// Regular expressions are objects, and thus always new
|
||
|
return typeof node.regex === "object";
|
||
|
case "SequenceExpression": {
|
||
|
const last = node.expressions[node.expressions.length - 1];
|
||
|
|
||
|
return isAlwaysNew(scope, last);
|
||
|
}
|
||
|
case "AssignmentExpression":
|
||
|
if (node.operator === "=") {
|
||
|
return isAlwaysNew(scope, node.right);
|
||
|
}
|
||
|
return false;
|
||
|
case "ConditionalExpression":
|
||
|
return isAlwaysNew(scope, node.consequent) && isAlwaysNew(scope, node.alternate);
|
||
|
case "JSXElement": // ESLint has a policy of not assuming any specific JSX behavior.
|
||
|
case "JSXFragment":
|
||
|
return false;
|
||
|
default:
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Checks if one operand will cause the result to be constant.
|
||
|
* @param {Scope} scope Scope in which the expression was found.
|
||
|
* @param {ASTNode} a One side of the expression
|
||
|
* @param {ASTNode} b The other side of the expression
|
||
|
* @param {string} operator The binary expression operator
|
||
|
* @returns {ASTNode | null} The node which will cause the expression to have a constant result.
|
||
|
*/
|
||
|
function findBinaryExpressionConstantOperand(scope, a, b, operator) {
|
||
|
if (operator === "==" || operator === "!=") {
|
||
|
if (
|
||
|
(isNullOrUndefined(scope, a) && hasConstantNullishness(scope, b, false)) ||
|
||
|
(isStaticBoolean(scope, a) && hasConstantLooseBooleanComparison(scope, b))
|
||
|
) {
|
||
|
return b;
|
||
|
}
|
||
|
} else if (operator === "===" || operator === "!==") {
|
||
|
if (
|
||
|
(isNullOrUndefined(scope, a) && hasConstantNullishness(scope, b, false)) ||
|
||
|
(isStaticBoolean(scope, a) && hasConstantStrictBooleanComparison(scope, b))
|
||
|
) {
|
||
|
return b;
|
||
|
}
|
||
|
}
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
//------------------------------------------------------------------------------
|
||
|
// Rule Definition
|
||
|
//------------------------------------------------------------------------------
|
||
|
|
||
|
/** @type {import('../shared/types').Rule} */
|
||
|
module.exports = {
|
||
|
meta: {
|
||
|
type: "problem",
|
||
|
docs: {
|
||
|
description: "Disallow expressions where the operation doesn't affect the value",
|
||
|
recommended: false,
|
||
|
url: "https://eslint.org/docs/latest/rules/no-constant-binary-expression"
|
||
|
},
|
||
|
schema: [],
|
||
|
messages: {
|
||
|
constantBinaryOperand: "Unexpected constant binary expression. Compares constantly with the {{otherSide}}-hand side of the `{{operator}}`.",
|
||
|
constantShortCircuit: "Unexpected constant {{property}} on the left-hand side of a `{{operator}}` expression.",
|
||
|
alwaysNew: "Unexpected comparison to newly constructed object. These two values can never be equal.",
|
||
|
bothAlwaysNew: "Unexpected comparison of two newly constructed objects. These two values can never be equal."
|
||
|
}
|
||
|
},
|
||
|
|
||
|
create(context) {
|
||
|
const sourceCode = context.sourceCode;
|
||
|
|
||
|
return {
|
||
|
LogicalExpression(node) {
|
||
|
const { operator, left } = node;
|
||
|
const scope = sourceCode.getScope(node);
|
||
|
|
||
|
if ((operator === "&&" || operator === "||") && isConstant(scope, left, true)) {
|
||
|
context.report({ node: left, messageId: "constantShortCircuit", data: { property: "truthiness", operator } });
|
||
|
} else if (operator === "??" && hasConstantNullishness(scope, left, false)) {
|
||
|
context.report({ node: left, messageId: "constantShortCircuit", data: { property: "nullishness", operator } });
|
||
|
}
|
||
|
},
|
||
|
BinaryExpression(node) {
|
||
|
const scope = sourceCode.getScope(node);
|
||
|
const { right, left, operator } = node;
|
||
|
const rightConstantOperand = findBinaryExpressionConstantOperand(scope, left, right, operator);
|
||
|
const leftConstantOperand = findBinaryExpressionConstantOperand(scope, right, left, operator);
|
||
|
|
||
|
if (rightConstantOperand) {
|
||
|
context.report({ node: rightConstantOperand, messageId: "constantBinaryOperand", data: { operator, otherSide: "left" } });
|
||
|
} else if (leftConstantOperand) {
|
||
|
context.report({ node: leftConstantOperand, messageId: "constantBinaryOperand", data: { operator, otherSide: "right" } });
|
||
|
} else if (operator === "===" || operator === "!==") {
|
||
|
if (isAlwaysNew(scope, left)) {
|
||
|
context.report({ node: left, messageId: "alwaysNew" });
|
||
|
} else if (isAlwaysNew(scope, right)) {
|
||
|
context.report({ node: right, messageId: "alwaysNew" });
|
||
|
}
|
||
|
} else if (operator === "==" || operator === "!=") {
|
||
|
|
||
|
/*
|
||
|
* If both sides are "new", then both sides are objects and
|
||
|
* therefore they will be compared by reference even with `==`
|
||
|
* equality.
|
||
|
*/
|
||
|
if (isAlwaysNew(scope, left) && isAlwaysNew(scope, right)) {
|
||
|
context.report({ node: left, messageId: "bothAlwaysNew" });
|
||
|
}
|
||
|
}
|
||
|
|
||
|
}
|
||
|
|
||
|
/*
|
||
|
* In theory we could handle short-circuiting assignment operators,
|
||
|
* for some constant values, but that would require walking the
|
||
|
* scope to find the value of the variable being assigned. This is
|
||
|
* dependant on https://github.com/eslint/eslint/issues/13776
|
||
|
*
|
||
|
* AssignmentExpression() {},
|
||
|
*/
|
||
|
};
|
||
|
}
|
||
|
};
|