eslint-plugin-promise
Version:
Enforce best practices for JavaScript promises
502 lines (475 loc) • 15.7 kB
JavaScript
/**
* 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<CodePathSegment[]>}
*/
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<CodePathSegment[]>}
*/
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<CodePathSegment>} */
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<CodePathSegment, CodePathSegmentInfo>} */
this.segmentInfos = new Map()
this.resolvedCount = 0
/** @type {Set<CodePathSegment>} */
this.currentSegments = new Set()
}
/** @param {CodePathSegment} segment */
onSegmentEnter(segment) {
this.currentSegments.add(segment)
}
/** @param {CodePathSegment} segment */
onSegmentExit(segment) {
this.currentSegments.delete(segment)
}
getCurrentSegmentInfos() {
return [...this.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<AlreadyResolvedData & { node: Identifier }>}
*/
*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<string>} */
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<Identifier>[]} */
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<Identifier>} */
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 */
onCodePathSegmentStart(segment) {
codePathInfoStack[0].onSegmentEnter(segment)
},
/** @param {CodePathSegment} segment */
/* istanbul ignore next */ // It is not called in ESLint v7.
onUnreachableCodePathSegmentStart(segment) {
codePathInfoStack[0].onSegmentEnter(segment)
},
/**
* @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)
}
}
codePathInfoStack[0].onSegmentExit(segment)
},
/** @param {CodePathSegment} segment */
/* istanbul ignore next */ // It is not called in ESLint v7.
onUnreachableCodePathSegmentEnd(segment) {
codePathInfoStack[0].onSegmentExit(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
}
},
}
},
}