diff --git a/.changeset/contributor-14-no-floating-promises-overloads.md b/.changeset/contributor-14-no-floating-promises-overloads.md new file mode 100644 index 000000000000..cafde90b1cb7 --- /dev/null +++ b/.changeset/contributor-14-no-floating-promises-overloads.md @@ -0,0 +1,19 @@ +--- +"@biomejs/biome": patch +--- + +Fixed [#9568](https://github.com/biomejs/biome/issues/9568): `noFloatingPromises` now respects adjacent TypeScript overload signatures when matching callback-based calls. + +Previously, overloaded helpers could be reported as floating promises whenever any overload returned a `Promise`, even if the selected overload for the current callback returned a synchronous value: + +```ts +export function bestEffort(cb: () => Promise): Promise; +export function bestEffort(cb: () => T): T | undefined; +export function bestEffort(cb: (() => T) | (() => Promise)) { + return cb(); +} + +bestEffort(() => 1); +``` + +That synchronous call is no longer flagged, while the async callback overload still reports correctly. diff --git a/crates/biome_js_analyze/src/lint/nursery/no_floating_promises.rs b/crates/biome_js_analyze/src/lint/nursery/no_floating_promises.rs index f04ef9b5ad05..7ac135c26bed 100644 --- a/crates/biome_js_analyze/src/lint/nursery/no_floating_promises.rs +++ b/crates/biome_js_analyze/src/lint/nursery/no_floating_promises.rs @@ -3,10 +3,20 @@ use biome_analyze::{ }; use biome_console::markup; use biome_js_factory::make; +use biome_js_semantic::SemanticModel; use biome_js_syntax::{ - AnyJsCallArgument, AnyJsExpression, AnyJsName, JsExpressionStatement, JsSyntaxKind, T, + AnyJsCallArgument, AnyJsDeclarationClause, AnyJsExportClause, AnyJsExportDefaultDeclaration, + AnyJsExpression, AnyJsName, AnyTsName, AnyTsReturnType, AnyTsType, JsExport, + JsExpressionStatement, JsFunctionDeclaration, JsFunctionExportDefaultDeclaration, JsSyntaxKind, + JsSyntaxNode, T, TsDeclareFunctionDeclaration, TsDeclareFunctionExportDefaultDeclaration, + TsDeclareStatement, TsIdentifierBinding, TsNumberLiteralType, TsTypeAliasDeclaration, + binding_ext::AnyJsBindingDeclaration, + parameter_ext::{AnyJsParameterList, AnyParameter}, +}; +use biome_js_type_info::{Type, TypeData}; +use biome_rowan::{ + AstNode, AstSeparatedList, BatchMutationExt, TriviaPieceKind, declare_node_union, }; -use biome_rowan::{AstNode, AstSeparatedList, BatchMutationExt, TriviaPieceKind}; use biome_rule_options::no_floating_promises::NoFloatingPromisesOptions; use crate::{JsRuleAction, ast_utils::is_in_async_function, services::typed::Typed}; @@ -178,8 +188,6 @@ impl Rule for NoFloatingPromises { let expression = node.expression().ok()?; let ty = ctx.type_of_expression(&expression); - // Uncomment the following line for debugging convenience: - //let printed = format!("type of {expression:?} = {ty:?}"); if ty.is_array_of(|ty| ty.is_promise_instance()) { return Some(NoFloatingPromisesState::ArrayOfPromises); } @@ -190,6 +198,14 @@ impl Rule for NoFloatingPromises { return None; } + if expression + .as_js_call_expression() + .and_then(|call| matching_overload_returns_promise_like(ctx, call)) + .is_some_and(|returns_promise| !returns_promise) + { + return None; + } + if is_handled_promise(expression).unwrap_or_default() { return None; } @@ -364,3 +380,595 @@ fn is_handled_promise(expression: AnyJsExpression) -> Option { Some(false) } + +fn matching_overload_returns_promise_like( + ctx: &RuleContext, + call: &biome_js_syntax::JsCallExpression, +) -> Option { + let model = ctx.get_service::()?; + let reference = call + .callee() + .ok()? + .omit_parentheses() + .as_js_identifier_expression()? + .name() + .ok()?; + let declaration = model.binding(&reference)?.tree().declaration()?; + let overloads = collect_adjacent_function_overloads(&declaration)?; + + let matching_overloads: Vec<_> = overloads + .into_iter() + .filter(|overload| !overload.has_body()) + .filter_map(|overload| { + let matched = function_overload_matches_call(ctx, call, &overload)?; + let returns_promise = overload + .return_type_annotation() + .and_then(|return_type| return_type.as_any_ts_type().cloned()) + .map(ts_type_is_promise_like)?; + Some((matched, returns_promise)) + }) + .collect(); + + let best_specificity = matching_overloads + .iter() + .map(|(matched, _)| matched.specificity()) + .max()?; + + let mut best_returns = matching_overloads + .into_iter() + .filter(|(matched, _)| matched.specificity() == best_specificity) + .map(|(_, returns_promise)| returns_promise); + + let returns_promise = best_returns.next()?; + best_returns + .all(|best_return| best_return == returns_promise) + .then_some(returns_promise) +} + +fn collect_adjacent_function_overloads( + declaration: &AnyJsBindingDeclaration, +) -> Option> { + let declaration = AnyPotentialFunctionOverloadSignature::from_binding_declaration(declaration)?; + let name = declaration.name()?; + + let mut first = declaration.clone(); + while let Some(previous) = first + .prev_sibling() + .and_then(AnyPotentialFunctionOverloadSignature::cast) + { + if previous.name().as_deref() != Some(name.as_str()) { + break; + } + first = previous; + } + + let mut overloads = vec![first.clone()]; + let mut next_sibling = first.next_sibling(); + while let Some(next) = next_sibling { + let Some(overload) = AnyPotentialFunctionOverloadSignature::cast(next.clone()) else { + break; + }; + if overload.name().as_deref() != Some(name.as_str()) { + break; + } + next_sibling = overload.next_sibling(); + overloads.push(overload); + } + + (overloads.len() > 1 && overloads.iter().any(|overload| !overload.has_body())) + .then_some(overloads) +} + +fn function_overload_matches_call( + ctx: &RuleContext, + call: &biome_js_syntax::JsCallExpression, + overload: &AnyPotentialFunctionOverloadSignature, +) -> Option { + let Ok(arguments) = call.arguments() else { + return None; + }; + let parameters = overload.parameters()?; + + let parameters: Vec<_> = parameters + .iter() + .filter_map(|parameter| parameter.ok()) + .filter(|parameter| { + !matches!( + parameter, + AnyParameter::AnyJsParameter(biome_js_syntax::AnyJsParameter::TsThisParameter(_)) + ) + }) + .collect(); + + let required_parameter_count = parameters + .iter() + .filter(|parameter| !parameter_is_optional(parameter) && !parameter_is_rest(parameter)) + .count(); + if arguments.args().len() < required_parameter_count { + return None; + } + + let rest_parameter = parameters + .last() + .filter(|parameter| parameter_is_rest(parameter)); + if rest_parameter.is_none() && arguments.args().len() > parameters.len() { + return None; + } + + let mut matched_callback_parameters = 0; + let mut matched_non_callback_parameters = 0; + let mut unknown_non_callback_parameters = 0; + + for (index, argument) in arguments.args().iter().enumerate() { + let Ok(argument) = argument else { + return None; + }; + let parameter = parameters.get(index).or(rest_parameter)?; + + match argument_matches_parameter(ctx, &argument, parameter)? { + ParameterMatch::Callback => matched_callback_parameters += 1, + ParameterMatch::NonCallback => matched_non_callback_parameters += 1, + ParameterMatch::Unknown => unknown_non_callback_parameters += 1, + } + } + + (matched_callback_parameters > 0 || matched_non_callback_parameters > 0).then_some( + OverloadMatch { + matched_callback_parameters, + matched_non_callback_parameters, + unknown_non_callback_parameters, + }, + ) +} + +fn parameter_is_optional(parameter: &AnyParameter) -> bool { + match parameter { + AnyParameter::AnyJsConstructorParameter(parameter) => match parameter { + biome_js_syntax::AnyJsConstructorParameter::AnyJsFormalParameter(parameter) => { + parameter.as_js_formal_parameter().is_some_and(|parameter| { + parameter.question_mark_token().is_some() || parameter.initializer().is_some() + }) + } + biome_js_syntax::AnyJsConstructorParameter::JsRestParameter(_) => false, + biome_js_syntax::AnyJsConstructorParameter::TsPropertyParameter(parameter) => parameter + .formal_parameter() + .ok() + .and_then(|parameter| parameter.as_js_formal_parameter().cloned()) + .is_some_and(|parameter| { + parameter.question_mark_token().is_some() || parameter.initializer().is_some() + }), + }, + AnyParameter::AnyJsParameter(parameter) => match parameter { + biome_js_syntax::AnyJsParameter::AnyJsFormalParameter(parameter) => { + parameter.as_js_formal_parameter().is_some_and(|parameter| { + parameter.question_mark_token().is_some() || parameter.initializer().is_some() + }) + } + biome_js_syntax::AnyJsParameter::JsRestParameter(_) => false, + biome_js_syntax::AnyJsParameter::TsThisParameter(_) => false, + }, + } +} + +fn parameter_is_rest(parameter: &AnyParameter) -> bool { + matches!( + parameter, + AnyParameter::AnyJsConstructorParameter( + biome_js_syntax::AnyJsConstructorParameter::JsRestParameter(_) + ) | AnyParameter::AnyJsParameter(biome_js_syntax::AnyJsParameter::JsRestParameter(_)) + ) +} + +fn argument_matches_parameter( + ctx: &RuleContext, + argument: &AnyJsCallArgument, + parameter: &AnyParameter, +) -> Option { + let Some(parameter_type) = parameter_type_annotation(parameter) else { + return Some(ParameterMatch::Unknown); + }; + + let Some(expected_callback_returns_promise) = parameter_callback_returns_promise(parameter) + else { + let AnyJsCallArgument::AnyJsExpression(argument) = argument else { + return None; + }; + let model = ctx.get_service::()?; + + return match ts_type_matches_argument_type( + &ctx.type_of_expression(argument), + parameter_type, + model, + 0, + ) { + Some(true) => Some(ParameterMatch::NonCallback), + Some(false) => None, + None => Some(ParameterMatch::Unknown), + }; + }; + let AnyJsCallArgument::AnyJsExpression(argument) = argument else { + return None; + }; + let actual_callback_returns_promise = expression_function_returns_promise_like(ctx, argument)?; + + (expected_callback_returns_promise == actual_callback_returns_promise) + .then_some(ParameterMatch::Callback) +} + +fn expression_function_returns_promise_like( + ctx: &RuleContext, + expression: &AnyJsExpression, +) -> Option { + let expression_type = ctx.type_of_expression(expression); + let function = expression_type.as_function()?; + let return_type = function.return_type.as_type()?; + let return_type = expression_type.resolve(return_type)?; + + Some( + return_type.is_promise_instance() || return_type.has_variant(|ty| ty.is_promise_instance()), + ) +} + +fn parameter_callback_returns_promise(parameter: &AnyParameter) -> Option { + callback_type_returns_promise(parameter_type_annotation(parameter)?) +} + +fn callback_type_returns_promise(ty: AnyTsType) -> Option { + let ty = ty.omit_parentheses(); + let function = ty.as_ts_function_type()?; + let return_type = function.return_type().ok()?; + let return_type = return_type.as_any_ts_type()?.clone(); + Some(ts_type_is_promise_like(return_type)) +} + +fn ts_type_is_promise_like(ty: AnyTsType) -> bool { + match ty.omit_parentheses() { + AnyTsType::TsReferenceType(reference) => reference + .name() + .ok() + .is_some_and(|name| ts_name_is_promise(&name)), + AnyTsType::TsUnionType(union) => union + .types() + .into_iter() + .filter_map(|ty| ty.ok()) + .any(ts_type_is_promise_like), + _ => false, + } +} + +fn parameter_type_annotation(parameter: &AnyParameter) -> Option { + parameter.type_annotation()?.ty().ok() +} + +fn ts_type_matches_argument_type( + argument_ty: &Type, + parameter_ty: AnyTsType, + model: &SemanticModel, + depth: usize, +) -> Option { + if depth > 8 { + return None; + } + + match parameter_ty.omit_parentheses() { + AnyTsType::TsUnionType(union) => { + let mut saw_unknown_branch = false; + + for parameter_ty in union.types().into_iter().filter_map(|ty| ty.ok()) { + match ts_type_matches_argument_type(argument_ty, parameter_ty, model, depth + 1) { + Some(true) => return Some(true), + Some(false) => {} + None => saw_unknown_branch = true, + } + } + + if saw_unknown_branch { + None + } else { + Some(false) + } + } + AnyTsType::TsStringType(_) => Some(type_matches_variant(argument_ty, |ty| { + ty.is_string_or_string_literal() + })), + AnyTsType::TsStringLiteralType(literal) => Some(type_matches_variant(argument_ty, |ty| { + literal + .inner_string_text() + .ok() + .is_some_and(|text| ty.is_string_literal(text.text())) + })), + AnyTsType::TsNumberType(_) => Some(type_matches_variant(argument_ty, |ty| { + ty.is_number_or_number_literal() + })), + AnyTsType::TsNumberLiteralType(literal) => ts_number_literal_type_value(&literal) + .map(|value| type_matches_variant(argument_ty, |ty| ty.is_number_literal(value))), + AnyTsType::TsBooleanType(_) => Some(type_matches_variant(argument_ty, type_is_booleanish)), + AnyTsType::TsBooleanLiteralType(literal) => { + let value = match literal.literal().ok()?.text_trimmed() { + "true" => true, + "false" => false, + _ => return None, + }; + + Some(type_matches_variant(argument_ty, |ty| { + ty.is_boolean_literal(value) + })) + } + AnyTsType::TsNullLiteralType(_) => Some(type_matches_variant(argument_ty, |ty| { + matches!(&**ty, TypeData::Null) + })), + AnyTsType::TsUndefinedType(_) => Some(type_matches_variant(argument_ty, |ty| { + matches!(&**ty, TypeData::Undefined) + })), + AnyTsType::TsReferenceType(reference) => { + let aliased_type = resolve_reference_type_alias(&reference, model)?; + ts_type_matches_argument_type(argument_ty, aliased_type, model, depth + 1) + } + _ => None, + } +} + +fn resolve_reference_type_alias( + reference: &biome_js_syntax::TsReferenceType, + model: &SemanticModel, +) -> Option { + let name = reference.name().ok()?; + let reference_identifier = name.as_js_reference_identifier()?; + let binding = model.binding(reference_identifier)?; + let identifier_binding = TsIdentifierBinding::cast_ref(&binding.syntax())?; + let type_alias = identifier_binding.parent::()?; + + if reference.type_arguments().is_some() || type_alias.type_parameters().is_some() { + return None; + } + + type_alias.ty().ok() +} + +fn ts_number_literal_type_value(literal: &TsNumberLiteralType) -> Option { + let magnitude = literal + .literal_token() + .ok()? + .text_trimmed() + .parse::() + .ok()?; + Some(if literal.minus_token().is_some() { + -magnitude + } else { + magnitude + }) +} + +fn type_matches_variant(argument_ty: &Type, predicate: impl Fn(&Type) -> bool + Copy) -> bool { + predicate(argument_ty) + || argument_ty + .flattened_union_variants() + .any(|ty| predicate(&ty)) +} + +fn type_is_booleanish(ty: &Type) -> bool { + matches!(&**ty, TypeData::Boolean) + || ty.is_boolean_literal(true) + || ty.is_boolean_literal(false) +} + +fn ts_name_is_promise(name: &AnyTsName) -> bool { + name.as_js_reference_identifier().is_some_and(|name| { + name.value_token() + .ok() + .is_some_and(|token| token.text_trimmed() == "Promise") + }) +} + +#[derive(Clone, Copy, Debug)] +struct OverloadMatch { + matched_callback_parameters: usize, + matched_non_callback_parameters: usize, + unknown_non_callback_parameters: usize, +} + +impl OverloadMatch { + fn specificity(self) -> (usize, usize, usize) { + ( + self.matched_callback_parameters, + self.matched_non_callback_parameters, + usize::MAX - self.unknown_non_callback_parameters, + ) + } +} + +#[derive(Clone, Copy, Debug)] +enum ParameterMatch { + Callback, + NonCallback, + Unknown, +} + +declare_node_union! { + AnyPotentialFunctionOverloadSignature = + JsFunctionDeclaration + | JsFunctionExportDefaultDeclaration + | TsDeclareFunctionDeclaration + | TsDeclareFunctionExportDefaultDeclaration +} + +impl AnyPotentialFunctionOverloadSignature { + fn from_binding_declaration(declaration: &AnyJsBindingDeclaration) -> Option { + match declaration { + AnyJsBindingDeclaration::JsFunctionDeclaration(declaration) => { + Some(Self::JsFunctionDeclaration(declaration.clone())) + } + AnyJsBindingDeclaration::JsFunctionExportDefaultDeclaration(declaration) => Some( + Self::JsFunctionExportDefaultDeclaration(declaration.clone()), + ), + AnyJsBindingDeclaration::TsDeclareFunctionDeclaration(declaration) => { + Some(Self::TsDeclareFunctionDeclaration(declaration.clone())) + } + AnyJsBindingDeclaration::TsDeclareFunctionExportDefaultDeclaration(declaration) => { + Some(Self::TsDeclareFunctionExportDefaultDeclaration( + declaration.clone(), + )) + } + _ => None, + } + } + + fn name(&self) -> Option { + match self { + Self::JsFunctionDeclaration(declaration) => { + function_binding_name(&declaration.id().ok()?) + } + Self::JsFunctionExportDefaultDeclaration(declaration) => { + function_binding_name(&declaration.id()?) + } + Self::TsDeclareFunctionDeclaration(declaration) => { + function_binding_name(&declaration.id().ok()?) + } + Self::TsDeclareFunctionExportDefaultDeclaration(declaration) => { + function_binding_name(&declaration.id()?) + } + } + } + + fn parameters(&self) -> Option { + Some(match self { + Self::JsFunctionDeclaration(function) => function.parameters().ok()?.items().into(), + Self::JsFunctionExportDefaultDeclaration(function) => { + function.parameters().ok()?.items().into() + } + Self::TsDeclareFunctionDeclaration(function) => { + function.parameters().ok()?.items().into() + } + Self::TsDeclareFunctionExportDefaultDeclaration(function) => { + function.parameters().ok()?.items().into() + } + }) + } + + fn return_type_annotation(&self) -> Option { + match self { + Self::JsFunctionDeclaration(function) => function + .return_type_annotation() + .and_then(|annotation| annotation.ty().ok()), + Self::JsFunctionExportDefaultDeclaration(function) => function + .return_type_annotation() + .and_then(|annotation| annotation.ty().ok()), + Self::TsDeclareFunctionDeclaration(function) => function + .return_type_annotation() + .and_then(|annotation| annotation.ty().ok()), + Self::TsDeclareFunctionExportDefaultDeclaration(function) => function + .return_type_annotation() + .and_then(|annotation| annotation.ty().ok()), + } + } + + fn has_body(&self) -> bool { + match self { + Self::JsFunctionDeclaration(function) => function.body().ok().is_some(), + Self::JsFunctionExportDefaultDeclaration(function) => function.body().ok().is_some(), + Self::TsDeclareFunctionDeclaration(_) + | Self::TsDeclareFunctionExportDefaultDeclaration(_) => false, + } + } + + fn wrapper_syntax(&self) -> JsSyntaxNode { + match self { + Self::JsFunctionDeclaration(function) => { + if let Some(parent) = function.syntax().parent() + && matches!(parent.kind(), JsSyntaxKind::JS_EXPORT) + { + return parent; + } + } + Self::JsFunctionExportDefaultDeclaration(function) => { + if let Some(parent) = function.syntax().parent() + && matches!( + parent.kind(), + JsSyntaxKind::JS_EXPORT_DEFAULT_DECLARATION_CLAUSE + ) + && let Some(export) = parent.parent() + { + return export; + } + } + Self::TsDeclareFunctionDeclaration(function) => { + if let Some(parent) = function.syntax().parent() + && matches!( + parent.kind(), + JsSyntaxKind::JS_EXPORT | JsSyntaxKind::TS_DECLARE_STATEMENT + ) + { + return parent; + } + } + Self::TsDeclareFunctionExportDefaultDeclaration(function) => { + if let Some(parent) = function.syntax().parent() + && matches!( + parent.kind(), + JsSyntaxKind::JS_EXPORT_DEFAULT_DECLARATION_CLAUSE + ) + && let Some(export) = parent.parent() + { + return export; + } + } + } + + self.syntax().clone() + } + + fn get_sibling_syntax(syntax: JsSyntaxNode) -> Option { + if let Some(export) = JsExport::cast_ref(&syntax) { + return match export.export_clause().ok()? { + AnyJsExportClause::AnyJsDeclarationClause(declaration) => match declaration { + AnyJsDeclarationClause::JsFunctionDeclaration(function) => { + Some(function.syntax().clone()) + } + AnyJsDeclarationClause::TsDeclareFunctionDeclaration(function) => { + Some(function.syntax().clone()) + } + _ => None, + }, + AnyJsExportClause::JsExportDefaultDeclarationClause(default_clause) => { + match default_clause.declaration().ok()? { + AnyJsExportDefaultDeclaration::JsFunctionExportDefaultDeclaration( + function, + ) => Some(function.syntax().clone()), + AnyJsExportDefaultDeclaration::TsDeclareFunctionExportDefaultDeclaration( + function, + ) => Some(function.syntax().clone()), + _ => None, + } + } + _ => None, + }; + } + + if let Some(declare_statement) = TsDeclareStatement::cast_ref(&syntax) { + return match declare_statement.declaration().ok()? { + AnyJsDeclarationClause::TsDeclareFunctionDeclaration(function) => { + Some(function.syntax().clone()) + } + _ => None, + }; + } + + Some(syntax) + } + + fn prev_sibling(&self) -> Option { + Self::get_sibling_syntax(self.wrapper_syntax().prev_sibling()?) + } + + fn next_sibling(&self) -> Option { + Self::get_sibling_syntax(self.wrapper_syntax().next_sibling()?) + } +} + +fn function_binding_name(binding: &biome_js_syntax::AnyJsBinding) -> Option { + binding + .as_js_identifier_binding()? + .name_token() + .ok() + .map(|token| token.text_trimmed().to_string()) +} diff --git a/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_alias_invalid.ts b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_alias_invalid.ts new file mode 100644 index 000000000000..1c0300e01362 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_alias_invalid.ts @@ -0,0 +1,14 @@ +/* should generate diagnostics */ + +type SyncMode = "sync"; +type AsyncMode = "async"; + +export function schedule(mode: SyncMode, cb: () => Promise): void; +export function schedule(mode: AsyncMode, cb: () => Promise): Promise; +export function schedule(mode: SyncMode | AsyncMode, cb: () => Promise) { + return cb(); +} + +schedule("async", async () => { + await Promise.resolve(); +}); diff --git a/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_alias_invalid.ts.snap b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_alias_invalid.ts.snap new file mode 100644 index 000000000000..5dc7686c3563 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_alias_invalid.ts.snap @@ -0,0 +1,44 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: issue9568_alias_invalid.ts +--- +# Input +```ts +/* should generate diagnostics */ + +type SyncMode = "sync"; +type AsyncMode = "async"; + +export function schedule(mode: SyncMode, cb: () => Promise): void; +export function schedule(mode: AsyncMode, cb: () => Promise): Promise; +export function schedule(mode: SyncMode | AsyncMode, cb: () => Promise) { + return cb(); +} + +schedule("async", async () => { + await Promise.resolve(); +}); + +``` + +# Diagnostics +``` +issue9568_alias_invalid.ts:12:1 lint/nursery/noFloatingPromises ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i A "floating" Promise was found, meaning it is not properly handled and could lead to ignored errors or unexpected behavior. + + 10 │ } + 11 │ + > 12 │ schedule("async", async () => { + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + > 13 │ await Promise.resolve(); + > 14 │ }); + │ ^^^ + 15 │ + + i This happens when a Promise is not awaited, lacks a `.catch` or `.then` rejection handler, or is not explicitly ignored using the `void` operator. + + i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information. + + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_alias_valid.ts b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_alias_valid.ts new file mode 100644 index 000000000000..b68f63f20798 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_alias_valid.ts @@ -0,0 +1,14 @@ +/* should not generate diagnostics */ + +type SyncMode = "sync"; +type AsyncMode = "async"; + +export function schedule(mode: SyncMode, cb: () => Promise): void; +export function schedule(mode: AsyncMode, cb: () => Promise): Promise; +export function schedule(mode: SyncMode | AsyncMode, cb: () => Promise) { + return cb(); +} + +schedule("sync", async () => { + await Promise.resolve(); +}); diff --git a/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_alias_valid.ts.snap b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_alias_valid.ts.snap new file mode 100644 index 000000000000..a323292e5cd9 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_alias_valid.ts.snap @@ -0,0 +1,22 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: issue9568_alias_valid.ts +--- +# Input +```ts +/* should not generate diagnostics */ + +type SyncMode = "sync"; +type AsyncMode = "async"; + +export function schedule(mode: SyncMode, cb: () => Promise): void; +export function schedule(mode: AsyncMode, cb: () => Promise): Promise; +export function schedule(mode: SyncMode | AsyncMode, cb: () => Promise) { + return cb(); +} + +schedule("sync", async () => { + await Promise.resolve(); +}); + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_invalid.ts b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_invalid.ts new file mode 100644 index 000000000000..32d507fa0993 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_invalid.ts @@ -0,0 +1,11 @@ +/* should generate diagnostics */ + +export function bestEffort(cb: () => Promise): Promise; +export function bestEffort(cb: () => T): T | undefined; +export function bestEffort(cb: (() => T) | (() => Promise)) { + return cb(); +} + +bestEffort(async () => { + await Promise.resolve(); +}); diff --git a/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_invalid.ts.snap b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_invalid.ts.snap new file mode 100644 index 000000000000..15deccccf1ed --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_invalid.ts.snap @@ -0,0 +1,42 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +assertion_line: 149 +expression: issue9568_invalid.ts +--- +# Input +```ts +/* should generate diagnostics */ + +export function bestEffort(cb: () => Promise): Promise; +export function bestEffort(cb: () => T): T | undefined; +export function bestEffort(cb: (() => T) | (() => Promise)) { + return cb(); +} + +bestEffort(async () => { + await Promise.resolve(); +}); + +``` + +# Diagnostics +``` +issue9568_invalid.ts:9:1 lint/nursery/noFloatingPromises ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i A "floating" Promise was found, meaning it is not properly handled and could lead to ignored errors or unexpected behavior. + + 7 │ } + 8 │ + > 9 │ bestEffort(async () => { + │ ^^^^^^^^^^^^^^^^^^^^^^^^ + > 10 │ await Promise.resolve(); + > 11 │ }); + │ ^^^ + 12 │ + + i This happens when a Promise is not awaited, lacks a `.catch` or `.then` rejection handler, or is not explicitly ignored using the `void` operator. + + i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information. + + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_non_callback_invalid.ts b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_non_callback_invalid.ts new file mode 100644 index 000000000000..20e6841500b3 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_non_callback_invalid.ts @@ -0,0 +1,14 @@ +/* should generate diagnostics */ + +export function schedule(mode: "sync", cb: () => Promise): void; +export function schedule(mode: "async", cb: () => Promise): Promise; +export function schedule( + mode: "sync" | "async", + cb: () => Promise, +) { + return cb(); +} + +schedule("async", async () => { + await Promise.resolve(); +}); diff --git a/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_non_callback_invalid.ts.snap b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_non_callback_invalid.ts.snap new file mode 100644 index 000000000000..fcaec595dab7 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_non_callback_invalid.ts.snap @@ -0,0 +1,44 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: issue9568_non_callback_invalid.ts +--- +# Input +```ts +/* should generate diagnostics */ + +export function schedule(mode: "sync", cb: () => Promise): void; +export function schedule(mode: "async", cb: () => Promise): Promise; +export function schedule( + mode: "sync" | "async", + cb: () => Promise, +) { + return cb(); +} + +schedule("async", async () => { + await Promise.resolve(); +}); + +``` + +# Diagnostics +``` +issue9568_non_callback_invalid.ts:12:1 lint/nursery/noFloatingPromises ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + i A "floating" Promise was found, meaning it is not properly handled and could lead to ignored errors or unexpected behavior. + + 10 │ } + 11 │ + > 12 │ schedule("async", async () => { + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + > 13 │ await Promise.resolve(); + > 14 │ }); + │ ^^^ + 15 │ + + i This happens when a Promise is not awaited, lacks a `.catch` or `.then` rejection handler, or is not explicitly ignored using the `void` operator. + + i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information. + + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_non_callback_only_invalid.ts b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_non_callback_only_invalid.ts new file mode 100644 index 000000000000..786d14e9f8c6 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_non_callback_only_invalid.ts @@ -0,0 +1,9 @@ +/* should generate diagnostics */ + +export function schedule(mode: "sync"): void; +export function schedule(mode: "async"): Promise; +export function schedule(mode: "sync" | "async") { + return Promise.resolve(); +} + +schedule("async"); diff --git a/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_non_callback_only_invalid.ts.snap b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_non_callback_only_invalid.ts.snap new file mode 100644 index 000000000000..e22acb5d8071 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_non_callback_only_invalid.ts.snap @@ -0,0 +1,37 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +assertion_line: 149 +expression: issue9568_non_callback_only_invalid.ts +--- +# Input +```ts +/* should generate diagnostics */ + +export function schedule(mode: "sync"): void; +export function schedule(mode: "async"): Promise; +export function schedule(mode: "sync" | "async") { + return Promise.resolve(); +} + +schedule("async"); + +``` + +# Diagnostics +``` +issue9568_non_callback_only_invalid.ts:8:1 lint/nursery/noFloatingPromises ━━━━━━━━━━━━━━━━━━━━━━━━ + + i A "floating" Promise was found, meaning it is not properly handled and could lead to ignored errors or unexpected behavior. + + 6 │ } + 7 │ + > 8 │ schedule("async"); + │ ^^^^^^^^^^^^^^^^^ + 9 │ + + i This happens when a Promise is not awaited, lacks a `.catch` or `.then` rejection handler, or is not explicitly ignored using the `void` operator. + + i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information. + + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_non_callback_only_valid.ts b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_non_callback_only_valid.ts new file mode 100644 index 000000000000..c789b2c7597a --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_non_callback_only_valid.ts @@ -0,0 +1,9 @@ +/* should not generate diagnostics */ + +export function schedule(mode: "sync"): void; +export function schedule(mode: "async"): Promise; +export function schedule(mode: "sync" | "async") { + return Promise.resolve(); +} + +schedule("sync"); diff --git a/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_non_callback_only_valid.ts.snap b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_non_callback_only_valid.ts.snap new file mode 100644 index 000000000000..ae92cdba9926 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_non_callback_only_valid.ts.snap @@ -0,0 +1,18 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +assertion_line: 149 +expression: issue9568_non_callback_only_valid.ts +--- +# Input +```ts +/* should not generate diagnostics */ + +export function schedule(mode: "sync"): void; +export function schedule(mode: "async"): Promise; +export function schedule(mode: "sync" | "async") { + return Promise.resolve(); +} + +schedule("sync"); + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_non_callback_valid.ts b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_non_callback_valid.ts new file mode 100644 index 000000000000..79f86153a57f --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_non_callback_valid.ts @@ -0,0 +1,14 @@ +/* should not generate diagnostics */ + +export function schedule(mode: "sync", cb: () => Promise): void; +export function schedule(mode: "async", cb: () => Promise): Promise; +export function schedule( + mode: "sync" | "async", + cb: () => Promise, +) { + return cb(); +} + +schedule("sync", async () => { + await Promise.resolve(); +}); diff --git a/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_non_callback_valid.ts.snap b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_non_callback_valid.ts.snap new file mode 100644 index 000000000000..f60327f74031 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_non_callback_valid.ts.snap @@ -0,0 +1,22 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: issue9568_non_callback_valid.ts +--- +# Input +```ts +/* should not generate diagnostics */ + +export function schedule(mode: "sync", cb: () => Promise): void; +export function schedule(mode: "async", cb: () => Promise): Promise; +export function schedule( + mode: "sync" | "async", + cb: () => Promise, +) { + return cb(); +} + +schedule("sync", async () => { + await Promise.resolve(); +}); + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_valid.ts b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_valid.ts new file mode 100644 index 000000000000..9f4bb27cffe5 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_valid.ts @@ -0,0 +1,13 @@ +/* should not generate diagnostics */ + +export function bestEffort(cb: () => Promise): Promise; +export function bestEffort(cb: () => T): T | undefined; +export function bestEffort(cb: (() => T) | (() => Promise)) { + return cb(); +} + +bestEffort(() => { + console.log("Hello"); +}); + +bestEffort(() => 1); diff --git a/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_valid.ts.snap b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_valid.ts.snap new file mode 100644 index 000000000000..9d5c09e52df7 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/issue9568_valid.ts.snap @@ -0,0 +1,22 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +assertion_line: 149 +expression: issue9568_valid.ts +--- +# Input +```ts +/* should not generate diagnostics */ + +export function bestEffort(cb: () => Promise): Promise; +export function bestEffort(cb: () => T): T | undefined; +export function bestEffort(cb: (() => T) | (() => Promise)) { + return cb(); +} + +bestEffort(() => { + console.log("Hello"); +}); + +bestEffort(() => 1); + +```