/** * @fileoverview disallow assignments that can lead to race conditions due to usage of `await` or `yield` * @author Teddy Katz */ "use strict"; const astUtils = require("../util/ast-utils"); //------------------------------------------------------------------------------ // Rule Definition //------------------------------------------------------------------------------ module.exports = { meta: { docs: { description: "disallow assignments that can lead to race conditions due to usage of `await` or `yield`", category: "Possible Errors", recommended: false, url: "https://eslint.org/docs/rules/require-atomic-updates" }, fixable: null, schema: [], messages: { nonAtomicUpdate: "Possible race condition: `{{value}}` might be reassigned based on an outdated value of `{{value}}`." } }, create(context) { const sourceCode = context.getSourceCode(); const identifierToSurroundingFunctionMap = new WeakMap(); const expressionsByCodePathSegment = new Map(); //---------------------------------------------------------------------- // Helpers //---------------------------------------------------------------------- const resolvedVariableCache = new WeakMap(); /** * Gets the variable scope around this variable reference * @param {ASTNode} identifier An `Identifier` AST node * @returns {Scope|null} An escope Scope */ function getScope(identifier) { for (let currentNode = identifier; currentNode; currentNode = currentNode.parent) { const scope = sourceCode.scopeManager.acquire(currentNode, true); if (scope) { return scope; } } return null; } /** * Resolves a given identifier to a given scope * @param {ASTNode} identifier An `Identifier` AST node * @param {Scope} scope An escope Scope * @returns {Variable|null} An escope Variable corresponding to the given identifier */ function resolveVariableInScope(identifier, scope) { return scope.variables.find(variable => variable.name === identifier.name) || (scope.upper ? resolveVariableInScope(identifier, scope.upper) : null); } /** * Resolves an identifier to a variable * @param {ASTNode} identifier An identifier node * @returns {Variable|null} The escope Variable that uses this identifier */ function resolveVariable(identifier) { if (!resolvedVariableCache.has(identifier)) { const surroundingScope = getScope(identifier); if (surroundingScope) { resolvedVariableCache.set(identifier, resolveVariableInScope(identifier, surroundingScope)); } else { resolvedVariableCache.set(identifier, null); } } return resolvedVariableCache.get(identifier); } /** * Checks if an expression is a variable that can only be observed within the given function. * @param {ASTNode} expression The expression to check * @param {ASTNode} surroundingFunction The function node * @returns {boolean} `true` if the expression is a variable which is local to the given function, and is never * referenced in a closure. */ function isLocalVariableWithoutEscape(expression, surroundingFunction) { if (expression.type !== "Identifier") { return false; } const variable = resolveVariable(expression); if (!variable) { return false; } return variable.references.every(reference => identifierToSurroundingFunctionMap.get(reference.identifier) === surroundingFunction) && variable.defs.every(def => identifierToSurroundingFunctionMap.get(def.name) === surroundingFunction); } /** * Reports an AssignmentExpression node that has a non-atomic update * @param {ASTNode} assignmentExpression The assignment that is potentially unsafe * @returns {void} */ function reportAssignment(assignmentExpression) { context.report({ node: assignmentExpression, messageId: "nonAtomicUpdate", data: { value: sourceCode.getText(assignmentExpression.left) } }); } /** * If the control flow graph of a function enters an assignment expression, then does the * both of the following steps in order (possibly with other steps in between) before exiting the * assignment expression, then the assignment might be using an outdated value. * 1. Enters a read of the variable or property assigned in the expression (not necessary if operator assignment is used) * 2. Exits an `await` or `yield` expression * * This function checks for the outdated values and reports them. * @param {CodePathSegment} codePathSegment The current code path segment to traverse * @param {ASTNode} surroundingFunction The function node containing the code path segment * @returns {void} */ function findOutdatedReads( codePathSegment, surroundingFunction, { seenSegments = new Set(), openAssignmentsWithoutReads = new Set(), openAssignmentsWithReads = new Set() } = {} ) { if (seenSegments.has(codePathSegment)) { // An AssignmentExpression can't contain loops, so it's not necessary to reenter them with new state. return; } expressionsByCodePathSegment.get(codePathSegment).forEach(({ entering, node }) => { if (node.type === "AssignmentExpression") { if (entering) { (node.operator === "=" ? openAssignmentsWithoutReads : openAssignmentsWithReads).add(node); } else { openAssignmentsWithoutReads.delete(node); openAssignmentsWithReads.delete(node); } } else if (!entering && (node.type === "AwaitExpression" || node.type === "YieldExpression")) { [...openAssignmentsWithReads] .filter(assignment => !isLocalVariableWithoutEscape(assignment.left, surroundingFunction)) .forEach(reportAssignment); openAssignmentsWithReads.clear(); } else if (!entering && (node.type === "Identifier" || node.type === "MemberExpression")) { [...openAssignmentsWithoutReads] .filter(assignment => ( assignment.left !== node && assignment.left.type === node.type && astUtils.equalTokens(assignment.left, node, sourceCode) )) .forEach(assignment => { openAssignmentsWithoutReads.delete(assignment); openAssignmentsWithReads.add(assignment); }); } }); codePathSegment.nextSegments.forEach(nextSegment => { findOutdatedReads( nextSegment, surroundingFunction, { seenSegments: new Set(seenSegments).add(codePathSegment), openAssignmentsWithoutReads: new Set(openAssignmentsWithoutReads), openAssignmentsWithReads: new Set(openAssignmentsWithReads) } ); }); } //---------------------------------------------------------------------- // Public //---------------------------------------------------------------------- const currentCodePathSegmentStack = []; let currentCodePathSegment = null; const functionStack = []; return { onCodePathStart() { currentCodePathSegmentStack.push(currentCodePathSegment); }, onCodePathEnd(codePath, node) { currentCodePathSegment = currentCodePathSegmentStack.pop(); if (astUtils.isFunction(node) && (node.async || node.generator)) { findOutdatedReads(codePath.initialSegment, node); } }, onCodePathSegmentStart(segment) { currentCodePathSegment = segment; expressionsByCodePathSegment.set(segment, []); }, "AssignmentExpression, Identifier, MemberExpression, AwaitExpression, YieldExpression"(node) { expressionsByCodePathSegment.get(currentCodePathSegment).push({ entering: true, node }); }, "AssignmentExpression, Identifier, MemberExpression, AwaitExpression, YieldExpression:exit"(node) { expressionsByCodePathSegment.get(currentCodePathSegment).push({ entering: false, node }); }, ":function"(node) { functionStack.push(node); }, ":function:exit"() { functionStack.pop(); }, Identifier(node) { if (functionStack.length) { identifierToSurroundingFunctionMap.set(node, functionStack[functionStack.length - 1]); } } }; } };