/*
* Copyright 2025 the original author or authors.
*
* Licensed under the Moderne Source Available License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://docs.moderne.io/licensing/moderne-source-available-license
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {Recipe} from "../../recipe";
import {ExecutionContext} from "../../execution";
import {TreeVisitor} from "../../visitor";
import {JavaScriptVisitor} from "../visitor";
import {J} from "../../java";
import {JS} from "../tree";
import {Type} from "../../java";
import {markupWarn} from "../../markers";
/**
* Array methods that don't await async callbacks.
* When an async function is passed as a callback to these methods,
* the returned Promise is not awaited, leading to bugs.
*/
const SYNC_ARRAY_METHODS = new Set([
'some', // Returns first truthy value, but Promise is always truthy
'every', // Returns first falsy value, but Promise is always truthy
'find', // Returns first truthy value, but Promise is always truthy
'findIndex', // Returns first truthy value, but Promise is always truthy
'filter', // Filters based on truthy values, but Promise is always truthy
'forEach', // Ignores return values entirely, async callbacks won't be awaited
]);
/**
* Check if a type is a Promise type.
* Looks for types like Promise, PromiseLike, or the Promise class itself.
*/
function isPromiseType(type?: Type): boolean {
if (!type) return false;
// Check for Class type with Promise name
if (Type.isClass(type)) {
const fqn = type.fullyQualifiedName;
return fqn === 'Promise' ||
fqn === 'PromiseLike' ||
fqn.endsWith('.Promise') ||
fqn.endsWith('.PromiseLike');
}
// Check for Parameterized type (e.g., Promise)
if (Type.isParameterized(type)) {
return isPromiseType(type.type);
}
// Check for Union type (e.g., Promise | undefined)
if (Type.isUnion(type)) {
return type.bounds.some(b => isPromiseType(b));
}
return false;
}
/**
* Check if an arrow function has an async modifier.
* In JavaScript, async is represented as a LanguageExtension modifier with keyword="async"
*/
function hasAsyncModifier(arrowFunc: JS.ArrowFunction): boolean {
return arrowFunc.modifiers.some(m =>
m.type === J.ModifierType.Async ||
(m.type === J.ModifierType.LanguageExtension && m.keyword === 'async')
);
}
/**
* Check if a function type returns a Promise.
*/
function functionTypeReturnsPromise(funcType: Type): boolean {
if (!Type.isFunctionType(funcType)) return false;
const clazz = funcType as Type.Class;
if (clazz.typeParameters && clazz.typeParameters.length > 0) {
// First type parameter is typically R (return type) in TypeScript function types
// It's a GenericTypeVariable with bounds containing the actual type
const returnTypeParam = clazz.typeParameters[0];
// Check if it's directly a Promise type
if (isPromiseType(returnTypeParam)) {
return true;
}
// Check if it's a GenericTypeVariable with bounds
if (Type.isGenericTypeVariable(returnTypeParam)) {
const bounds = returnTypeParam.bounds;
if (bounds && bounds.some(b => isPromiseType(b))) {
return true;
}
}
}
return false;
}
/**
* Check if a callback argument returns a Promise.
* This checks:
* 1. Explicit async modifier on arrow functions
* 2. Type annotation indicating Promise return type
* 3. Function references whose type indicates Promise return
*/
function callbackReturnsPromise(arg: any): boolean {
// Handle RightPadded wrapper
const element = arg?.element ?? arg;
if (!element) return false;
// Check if it's an arrow function
if (element.kind === JS.Kind.ArrowFunction) {
const arrowFunc = element as JS.ArrowFunction;
// Check for async modifier
if (hasAsyncModifier(arrowFunc)) {
return true;
}
// Check if return type expression indicates Promise
const returnType = arrowFunc.returnTypeExpression;
if (returnType) {
// Check if the return type expression is an identifier named Promise
if (returnType.kind === J.Kind.Identifier) {
const id = returnType as J.Identifier;
if (id.simpleName === 'Promise' || id.simpleName === 'PromiseLike') {
return true;
}
}
// Check if it's a parameterized type like Promise
if (returnType.kind === J.Kind.ParameterizedType) {
const pt = returnType as J.ParameterizedType;
// ParameterizedType has 'clazz' property which is the base type
const baseType = (pt as any).clazz;
if (baseType?.kind === J.Kind.Identifier) {
const clazz = baseType as J.Identifier;
if (clazz.simpleName === 'Promise' || clazz.simpleName === 'PromiseLike') {
return true;
}
}
}
}
// Check type attribution if available
const funcType = (arrowFunc as any).type;
if (funcType && functionTypeReturnsPromise(funcType)) {
return true;
}
}
// Check if it's a function reference (Identifier) with type attribution
if (element.kind === J.Kind.Identifier) {
const identifier = element as J.Identifier;
const funcType = identifier.type;
if (funcType && functionTypeReturnsPromise(funcType)) {
return true;
}
}
return false;
}
/**
* Detects async callbacks passed to synchronous array methods.
*
* This is a common bug pattern in JavaScript/TypeScript where async functions
* are passed to array methods like `.some()`, `.every()`, `.find()`, etc.
* These methods don't await the promises returned by async callbacks, leading
* to bugs where Promise objects are treated as truthy values.
*
* Example of buggy code:
* ```typescript
* // BUG: .some() doesn't await, so any Promise is truthy
* const hasAdmin = users.some(async user => {
* return await checkPermission(user, 'admin');
* });
* // hasAdmin is ALWAYS true because Promise objects are truthy!
*
* // CORRECT: Use a for loop with await
* let hasAdmin = false;
* for (const user of users) {
* if (await checkPermission(user, 'admin')) {
* hasAdmin = true;
* break;
* }
* }
* ```
*
* This recipe reports occurrences but doesn't auto-fix because the correct
* fix depends on the context (could use for...of loop, Promise.all, etc.).
*/
export class AsyncCallbackInSyncArrayMethod extends Recipe {
readonly name = "org.openrewrite.javascript.cleanup.async-callback-in-sync-array-method";
readonly displayName: string = "Detect async callbacks in synchronous array methods";
readonly description: string = "Detects async callbacks passed to array methods like .some(), .every(), .filter() which don't await promises. This is a common bug where Promise objects are always truthy.";
readonly tags = ["javascript", "typescript", "async", "bug", "cleanup"];
async editor(): Promise> {
return new class extends JavaScriptVisitor {
override async visitMethodInvocation(method: J.MethodInvocation, ctx: ExecutionContext): Promise {
let m = await super.visitMethodInvocation(method, ctx) as J.MethodInvocation;
const methodName = m.name?.simpleName;
if (!methodName || !SYNC_ARRAY_METHODS.has(methodName)) {
return m;
}
// Check the arguments for async callbacks
const args = m.arguments?.elements;
if (!args || args.length === 0) {
return m;
}
const firstArg = args[0];
if (callbackReturnsPromise(firstArg)) {
return markupWarn(
m,
`Async callback passed to .${methodName}()`,
`Array methods like .${methodName}() don't await async callbacks, so Promises are treated as truthy values. Consider using a for...of loop with await instead.`
);
}
return m;
}
}();
}
}