/** * Rule: no-multiple-resolved * Disallow creating new promises with paths that resolve multiple times */ 'use strict' const { getScope } = require('./lib/eslint-compat') const getDocsUrl = require('./lib/get-docs-url') const { isPromiseConstructorWithInlineExecutor, } = require('./lib/is-promise-constructor') /** * @typedef {import('estree').Node} Node * @typedef {import('estree').Expression} Expression * @typedef {import('estree').Identifier} Identifier * @typedef {import('estree').FunctionExpression} FunctionExpression * @typedef {import('estree').ArrowFunctionExpression} ArrowFunctionExpression * @typedef {import('estree').SimpleCallExpression} CallExpression * @typedef {import('estree').MemberExpression} MemberExpression * @typedef {import('estree').NewExpression} NewExpression * @typedef {import('estree').ImportExpression} ImportExpression * @typedef {import('estree').YieldExpression} YieldExpression * @typedef {import('eslint').Rule.CodePath} CodePath * @typedef {import('eslint').Rule.CodePathSegment} CodePathSegment */ /** * An expression that can throw an error. * see https://github.com/eslint/eslint/blob/e940be7a83d0caea15b64c1e1c2785a6540e2641/lib/linter/code-path-analysis/code-path-analyzer.js#L639-L643 * @typedef {CallExpression | MemberExpression | NewExpression | ImportExpression | YieldExpression} ThrowableExpression */ /** * Iterate all previous path segments. * @param {CodePathSegment} segment * @returns {Iterable} */ function* iterateAllPrevPathSegments(segment) { yield* iterate(segment, []) /** * @param {CodePathSegment} segment * @param {CodePathSegment[]} processed */ function* iterate(segment, processed) { if (processed.includes(segment)) { return } const nextProcessed = [segment, ...processed] for (const prev of segment.prevSegments) { if (prev.prevSegments.length === 0) { yield [prev] } else { for (const segments of iterate(prev, nextProcessed)) { yield [prev, ...segments] } } } } } /** * Iterate all next path segments. * @param {CodePathSegment} segment * @returns {Iterable} */ function* iterateAllNextPathSegments(segment) { yield* iterate(segment, []) /** * @param {CodePathSegment} segment * @param {CodePathSegment[]} processed */ function* iterate(segment, processed) { if (processed.includes(segment)) { return } const nextProcessed = [segment, ...processed] for (const next of segment.nextSegments) { if (next.nextSegments.length === 0) { yield [next] } else { for (const segments of iterate(next, nextProcessed)) { yield [next, ...segments] } } } } } /** * Finds the same route path from the given path following previous path segments. * @param {CodePathSegment} segment * @returns {CodePathSegment | null} */ function findSameRoutePathSegment(segment) { /** @type {Set} */ const routeSegments = new Set() for (const route of iterateAllPrevPathSegments(segment)) { if (routeSegments.size === 0) { // First for (const seg of route) { routeSegments.add(seg) } continue } for (const seg of routeSegments) { if (!route.includes(seg)) { routeSegments.delete(seg) } } } for (const routeSegment of routeSegments) { let hasUnreached = false for (const segments of iterateAllNextPathSegments(routeSegment)) { if (!segments.includes(segment)) { // It has a route that does not reach the given path. hasUnreached = true break } } if (!hasUnreached) { return routeSegment } } return null } class CodePathInfo { /** * @param {CodePath} path */ constructor(path) { this.path = path /** @type {Map} */ this.segmentInfos = new Map() this.resolvedCount = 0 /** @type {CodePathSegment[]} */ this.allSegments = [] } getCurrentSegmentInfos() { return this.path.currentSegments.map((segment) => { const info = this.segmentInfos.get(segment) if (info) { return info } const newInfo = new CodePathSegmentInfo(this, segment) this.segmentInfos.set(segment, newInfo) return newInfo }) } /** * @typedef {object} AlreadyResolvedData * @property {Identifier} resolved * @property {'certain' | 'potential'} kind */ /** * Check all paths and return paths resolved multiple times. * @param {PromiseCodePathContext} promiseCodePathContext * @returns {Iterable} */ *iterateReports(promiseCodePathContext) { const targets = [...this.segmentInfos.values()].filter( (info) => info.resolved ) for (const segmentInfo of targets) { const result = this._getAlreadyResolvedData( segmentInfo.segment, promiseCodePathContext ) if (result) { yield { node: segmentInfo.resolved, resolved: result.resolved, kind: result.kind, } } } } /** * Compute the previously resolved path. * @param {CodePathSegment} segment * @param {PromiseCodePathContext} promiseCodePathContext * @returns {AlreadyResolvedData | null} */ _getAlreadyResolvedData(segment, promiseCodePathContext) { const prevSegments = segment.prevSegments.filter( (prev) => !promiseCodePathContext.isResolvedTryBlockCodePathSegment(prev) ) if (prevSegments.length === 0) { return null } const prevSegmentInfos = prevSegments.map((prev) => this._getProcessedSegmentInfo(prev, promiseCodePathContext) ) if (prevSegmentInfos.every((info) => info.resolved)) { // If the previous paths are all resolved, the next path is also resolved. return { resolved: prevSegmentInfos[0].resolved, kind: 'certain', } } for (const prevSegmentInfo of prevSegmentInfos) { if (prevSegmentInfo.resolved) { // If the previous path is partially resolved, // then the next path is potentially resolved. return { resolved: prevSegmentInfo.resolved, kind: 'potential', } } if (prevSegmentInfo.potentiallyResolved) { let potential = false if (prevSegmentInfo.segment.nextSegments.length === 1) { // If the previous path is potentially resolved and there is one next path, // then the next path is potentially resolved. potential = true } else { // This is necessary, for example, if `resolve()` in the finally section. const segmentInfo = this.segmentInfos.get(segment) if (segmentInfo && segmentInfo.resolved) { if ( prevSegmentInfo.segment.nextSegments.every((next) => { const nextSegmentInfo = this.segmentInfos.get(next) return ( nextSegmentInfo && nextSegmentInfo.resolved === segmentInfo.resolved ) }) ) { // If the previous path is potentially resolved and // the next paths all point to the same resolved node, // then the next path is potentially resolved. potential = true } } } if (potential) { return { resolved: prevSegmentInfo.potentiallyResolved, kind: 'potential', } } } } const sameRoute = findSameRoutePathSegment(segment) if (sameRoute) { const sameRouteSegmentInfo = this._getProcessedSegmentInfo(sameRoute) if (sameRouteSegmentInfo.potentiallyResolved) { return { resolved: sameRouteSegmentInfo.potentiallyResolved, kind: 'potential', } } } return null } /** * @param {CodePathSegment} segment * @param {PromiseCodePathContext} promiseCodePathContext */ _getProcessedSegmentInfo(segment, promiseCodePathContext) { const segmentInfo = this.segmentInfos.get(segment) if (segmentInfo) { return segmentInfo } const newInfo = new CodePathSegmentInfo(this, segment) this.segmentInfos.set(segment, newInfo) const alreadyResolvedData = this._getAlreadyResolvedData( segment, promiseCodePathContext ) if (alreadyResolvedData) { if (alreadyResolvedData.kind === 'certain') { newInfo.resolved = alreadyResolvedData.resolved } else { newInfo.potentiallyResolved = alreadyResolvedData.resolved } } return newInfo } } class CodePathSegmentInfo { /** * @param {CodePathInfo} pathInfo * @param {CodePathSegment} segment */ constructor(pathInfo, segment) { this.pathInfo = pathInfo this.segment = segment /** @type {Identifier | null} */ this._resolved = null /** @type {Identifier | null} */ this.potentiallyResolved = null } get resolved() { return this._resolved } /** @type {Identifier} */ set resolved(identifier) { this._resolved = identifier this.pathInfo.resolvedCount++ } } class PromiseCodePathContext { constructor() { /** @type {Set} */ this.resolvedSegmentIds = new Set() } /** @param {CodePathSegment} */ addResolvedTryBlockCodePathSegment(segment) { this.resolvedSegmentIds.add(segment.id) } /** @param {CodePathSegment} */ isResolvedTryBlockCodePathSegment(segment) { return this.resolvedSegmentIds.has(segment.id) } } module.exports = { meta: { type: 'problem', docs: { description: 'Disallow creating new promises with paths that resolve multiple times.', url: getDocsUrl('no-multiple-resolved'), }, messages: { alreadyResolved: 'Promise should not be resolved multiple times. Promise is already resolved on line {{line}}.', potentiallyAlreadyResolved: 'Promise should not be resolved multiple times. Promise is potentially resolved on line {{line}}.', }, schema: [], }, /** @param {import('eslint').Rule.RuleContext} context */ create(context) { const reported = new Set() const promiseCodePathContext = new PromiseCodePathContext() /** * @param {Identifier} node * @param {Identifier} resolved * @param {'certain' | 'potential'} kind */ function report(node, resolved, kind) { if (reported.has(node)) { return } reported.add(node) context.report({ node: node.parent, messageId: kind === 'certain' ? 'alreadyResolved' : 'potentiallyAlreadyResolved', data: { line: resolved.loc.start.line, }, }) } /** * @param {CodePathInfo} codePathInfo * @param {PromiseCodePathContext} promiseCodePathContext */ function verifyMultipleResolvedPath(codePathInfo, promiseCodePathContext) { for (const { node, resolved, kind } of codePathInfo.iterateReports( promiseCodePathContext )) { report(node, resolved, kind) } } /** @type {CodePathInfo[]} */ const codePathInfoStack = [] /** @type {Set[]} */ const resolverReferencesStack = [new Set()] /** @type {ThrowableExpression | null} */ let lastThrowableExpression = null return { /** @param {FunctionExpression | ArrowFunctionExpression} node */ 'FunctionExpression, ArrowFunctionExpression'(node) { if (!isPromiseConstructorWithInlineExecutor(node.parent)) { return } // Collect and stack `resolve` and `reject` references. /** @type {Set} */ const resolverReferences = new Set() const resolvers = node.params.filter( /** @returns {node is Identifier} */ (node) => node && node.type === 'Identifier' ) for (const resolver of resolvers) { const variable = getScope(context, node).set.get(resolver.name) // istanbul ignore next -- Usually always present. if (!variable) continue for (const reference of variable.references) { resolverReferences.add(reference.identifier) } } resolverReferencesStack.unshift(resolverReferences) }, /** @param {FunctionExpression | ArrowFunctionExpression} node */ 'FunctionExpression, ArrowFunctionExpression:exit'(node) { if (!isPromiseConstructorWithInlineExecutor(node.parent)) { return } resolverReferencesStack.shift() }, /** @param {CodePath} path */ onCodePathStart(path) { codePathInfoStack.unshift(new CodePathInfo(path)) }, onCodePathEnd() { const codePathInfo = codePathInfoStack.shift() if (codePathInfo.resolvedCount > 1) { verifyMultipleResolvedPath(codePathInfo, promiseCodePathContext) } }, /** @param {ThrowableExpression} node */ 'CallExpression, MemberExpression, NewExpression, ImportExpression, YieldExpression:exit'( node ) { lastThrowableExpression = node }, /** * @param {CodePathSegment} segment * @param {Node} node */ onCodePathSegmentEnd(segment, node) { if ( node.type === 'CatchClause' && lastThrowableExpression && lastThrowableExpression.type === 'CallExpression' && node.parent.type === 'TryStatement' && node.parent.range[0] <= lastThrowableExpression.range[0] && lastThrowableExpression.range[1] <= node.parent.range[1] ) { const resolverReferences = resolverReferencesStack[0] if (resolverReferences.has(lastThrowableExpression.callee)) { // Mark a segment if the last expression in the try block is a call to resolve. promiseCodePathContext.addResolvedTryBlockCodePathSegment(segment) } } }, /** @type {Identifier} */ 'CallExpression > Identifier.callee'(node) { const codePathInfo = codePathInfoStack[0] const resolverReferences = resolverReferencesStack[0] if (!resolverReferences.has(node)) { return } for (const segmentInfo of codePathInfo.getCurrentSegmentInfos()) { // If a resolving path is found, report if the path is already resolved. // Store the information if it is not already resolved. if (segmentInfo.resolved) { report(node, segmentInfo.resolved, 'certain') continue } segmentInfo.resolved = node } }, } }, }