From eeea0201b9542b14f6e868f5535e795e70f9b435 Mon Sep 17 00:00:00 2001 From: minseong Date: Fri, 17 Apr 2026 03:57:24 +0900 Subject: [PATCH 1/3] fix(lint): fix false positive in noMisleadingReturnType for nested object widening --- .../lint/nursery/no_misleading_return_type.rs | 184 ++++++++++-------- .../nursery/noMisleadingReturnType/valid.ts | 4 + .../noMisleadingReturnType/valid.ts.snap | 4 + 3 files changed, 109 insertions(+), 83 deletions(-) diff --git a/crates/biome_js_analyze/src/lint/nursery/no_misleading_return_type.rs b/crates/biome_js_analyze/src/lint/nursery/no_misleading_return_type.rs index c779a54f3f94..efb6ddd5c42e 100644 --- a/crates/biome_js_analyze/src/lint/nursery/no_misleading_return_type.rs +++ b/crates/biome_js_analyze/src/lint/nursery/no_misleading_return_type.rs @@ -412,110 +412,128 @@ fn is_literal_of_primitive(ty: &Type) -> bool { /// Checks whether annotation differs from returns only by property-level /// literal widening that contextual typing would handle. fn is_only_property_literal_widening(annotation: &Type, returns: &[Type]) -> bool { - if let TypeData::Tuple(ann_tuple) = &**annotation { - return returns.iter().all(|inferred| { - let TypeData::Tuple(inf_tuple) = &**inferred else { - return false; - }; - let ann_elems = ann_tuple.elements(); - let inf_elems = inf_tuple.elements(); - if ann_elems.len() != inf_elems.len() || ann_elems.is_empty() { + returns.iter().all(|inferred| { + let mut stack: Vec<(Type, Type)> = vec![(annotation.clone(), inferred.clone())]; + let mut has_widening = false; + let mut iterations = 0usize; + + while let Some((annotated, inferred)) = stack.pop() { + iterations += 1; + if iterations > 50 { return false; } - let mut has_widening = false; - for (ann_elem, inf_elem) in ann_elems.iter().zip(inf_elems.iter()) { - let ann_ty = annotation.resolve(&ann_elem.ty); - let inf_ty = inferred.resolve(&inf_elem.ty); - match (ann_ty, inf_ty) { - (Some(a), Some(b)) => { - if types_match(&a, &b) { - continue; - } - if is_base_type_of_literal(&a, &b) { - has_widening = true; - } else { - return false; + + if let TypeData::Tuple(annotated_tuple) = &*annotated { + let TypeData::Tuple(inferred_tuple) = &*inferred else { + return false; + }; + let annotated_elements = annotated_tuple.elements(); + let inferred_elements = inferred_tuple.elements(); + if annotated_elements.len() != inferred_elements.len() + || annotated_elements.is_empty() + { + return false; + } + for (annotated_element, inferred_element) in + annotated_elements.iter().zip(inferred_elements.iter()) + { + match ( + annotated.resolve(&annotated_element.ty), + inferred.resolve(&inferred_element.ty), + ) { + (Some(annotated_type), Some(inferred_type)) => { + if types_match(&annotated_type, &inferred_type) { + continue; + } + if is_base_type_of_literal(&annotated_type, &inferred_type) { + has_widening = true; + } else { + stack.push((annotated_type, inferred_type)); + } } + _ => return false, } - _ => return false, } + continue; } - has_widening - }); - } - let (TypeData::Object(ann_obj), _) = (&**annotation, ()) else { - return false; - }; - - if ann_obj.members.is_empty() { - return false; - } + let TypeData::Object(annotated_object) = &*annotated else { + return false; + }; + if annotated_object.members.is_empty() { + return false; + } - returns.iter().all(|inferred| { - let inf_members = match &**inferred { - TypeData::Object(obj) => &obj.members, - TypeData::Literal(lit) => match lit.as_ref() { - Literal::Object(obj_lit) => obj_lit.members(), + let inferred_members = match &*inferred { + TypeData::Object(object) => &object.members, + TypeData::Literal(literal) => match literal.as_ref() { + Literal::Object(object_literal) => object_literal.members(), + _ => return false, + }, _ => return false, - }, - _ => return false, - }; - - if inf_members.is_empty() { - return false; - } - - let mut has_widening = false; + }; + if inferred_members.is_empty() { + return false; + } - let ann_index_sig = ann_obj.members.iter().find(|m| { - matches!(m.kind, biome_js_type_info::TypeMemberKind::IndexSignature(_)) - }); - if let Some(sig_member) = ann_index_sig - && let Some(sig_value_ty) = annotation.resolve(&sig_member.ty) { - let mut sig_has_widening = false; - let all_ok = inf_members.iter().all(|inf_m| { - if let Some(inf_ty) = annotation.resolve(&inf_m.ty) { - if types_match(&sig_value_ty, &inf_ty) { + let annotated_index_signature = annotated_object.members.iter().find(|member| { + matches!( + member.kind, + biome_js_type_info::TypeMemberKind::IndexSignature(_) + ) + }); + if let Some(index_signature_member) = annotated_index_signature + && let Some(index_signature_value_type) = + annotated.resolve(&index_signature_member.ty) + { + let mut index_signature_has_widening = false; + let all_inferred_covered = inferred_members.iter().all(|inferred_member| { + if let Some(inferred_type) = inferred.resolve(&inferred_member.ty) { + if types_match(&index_signature_value_type, &inferred_type) { return true; } - if is_base_type_of_literal(&sig_value_ty, &inf_ty) { - sig_has_widening = true; + if is_base_type_of_literal(&index_signature_value_type, &inferred_type) { + index_signature_has_widening = true; return true; } } false }); - return all_ok && sig_has_widening; + if !(all_inferred_covered && index_signature_has_widening) { + return false; + } + has_widening = true; + continue; } - for ann_member in ann_obj.members.iter() { - let ann_name = match &ann_member.kind { - biome_js_type_info::TypeMemberKind::Named(name) - | biome_js_type_info::TypeMemberKind::NamedOptional(name) => name, - _ => continue, - }; - - let inf_member = inf_members.iter().find(|m| m.kind.has_name(ann_name)); - let Some(inf_member) = inf_member else { - return false; - }; - - let ann_ty = annotation.resolve(&ann_member.ty); - let inf_ty = annotation.resolve(&inf_member.ty); - - match (ann_ty, inf_ty) { - (Some(a), Some(b)) => { - if types_match(&a, &b) { - continue; - } - if is_base_type_of_literal(&a, &b) { - has_widening = true; - } else { - return false; + for annotated_member in annotated_object.members.iter() { + let annotated_name = match &annotated_member.kind { + biome_js_type_info::TypeMemberKind::Named(name) + | biome_js_type_info::TypeMemberKind::NamedOptional(name) => name, + _ => continue, + }; + let Some(inferred_member) = inferred_members + .iter() + .find(|member| member.kind.has_name(annotated_name)) + else { + return false; + }; + match ( + annotated.resolve(&annotated_member.ty), + inferred.resolve(&inferred_member.ty), + ) { + (Some(annotated_type), Some(inferred_type)) => { + if types_match(&annotated_type, &inferred_type) { + continue; + } + if is_base_type_of_literal(&annotated_type, &inferred_type) { + has_widening = true; + } else { + stack.push((annotated_type, inferred_type)); + } } + _ => return false, } - _ => return false, } } diff --git a/crates/biome_js_analyze/tests/specs/nursery/noMisleadingReturnType/valid.ts b/crates/biome_js_analyze/tests/specs/nursery/noMisleadingReturnType/valid.ts index 4b819307b1c0..286c251903ca 100644 --- a/crates/biome_js_analyze/tests/specs/nursery/noMisleadingReturnType/valid.ts +++ b/crates/biome_js_analyze/tests/specs/nursery/noMisleadingReturnType/valid.ts @@ -149,3 +149,7 @@ declare function getStr(): string; function letReassignStr(): string { let s = "default"; if (Math.random() > 0.5) s = getStr(); return s; } declare function getNum(): number; const arrowLetReassign = (): number => { let n = 0; if (Math.random() > 0.5) n = getNum(); return n; }; + +// Nested object widening without as const — tsc widens `true` to `boolean` at any depth +function nestedObj(): { inner: { flag: boolean } } { return { inner: { flag: true } }; } +function nestedTuple(): [{ ok: boolean }] { return [{ ok: true }]; } diff --git a/crates/biome_js_analyze/tests/specs/nursery/noMisleadingReturnType/valid.ts.snap b/crates/biome_js_analyze/tests/specs/nursery/noMisleadingReturnType/valid.ts.snap index d7cf0edde203..9f0add3ca43e 100644 --- a/crates/biome_js_analyze/tests/specs/nursery/noMisleadingReturnType/valid.ts.snap +++ b/crates/biome_js_analyze/tests/specs/nursery/noMisleadingReturnType/valid.ts.snap @@ -156,4 +156,8 @@ function letReassignStr(): string { let s = "default"; if (Math.random() > 0.5) declare function getNum(): number; const arrowLetReassign = (): number => { let n = 0; if (Math.random() > 0.5) n = getNum(); return n; }; +// Nested object widening without as const — tsc widens `true` to `boolean` at any depth +function nestedObj(): { inner: { flag: boolean } } { return { inner: { flag: true } }; } +function nestedTuple(): [{ ok: boolean }] { return [{ ok: true }]; } + ``` From 38a65263f4411a55a4c5b58432aa19cd41dcd817 Mon Sep 17 00:00:00 2001 From: minseong Date: Sat, 18 Apr 2026 18:28:15 +0900 Subject: [PATCH 2/3] chore: add changeset --- .changeset/tidy-planes-stay.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tidy-planes-stay.md diff --git a/.changeset/tidy-planes-stay.md b/.changeset/tidy-planes-stay.md new file mode 100644 index 000000000000..f54d7c1b15eb --- /dev/null +++ b/.changeset/tidy-planes-stay.md @@ -0,0 +1,5 @@ +--- +"@biomejs/biome": patch +--- + +Fixed [#9810](https://github.com/biomejs/biome/issues/9810): `noMisleadingReturnType` incorrectly flagging nested object literals with widened properties. From 952eef296501a4ececac5ff2a217bac1813c0145 Mon Sep 17 00:00:00 2001 From: minseong Date: Sat, 18 Apr 2026 20:48:02 +0900 Subject: [PATCH 3/3] refactor(lint): extract iteration cap constant --- .../src/lint/nursery/no_misleading_return_type.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/biome_js_analyze/src/lint/nursery/no_misleading_return_type.rs b/crates/biome_js_analyze/src/lint/nursery/no_misleading_return_type.rs index efb6ddd5c42e..84a74f3557e8 100644 --- a/crates/biome_js_analyze/src/lint/nursery/no_misleading_return_type.rs +++ b/crates/biome_js_analyze/src/lint/nursery/no_misleading_return_type.rs @@ -77,6 +77,9 @@ pub struct RuleState { returns: Vec, } +/// Maximum iterations for type graph traversal to guard against infinite loops on cyclic types. +const MAX_TYPE_TRAVERSAL_ITERATIONS: usize = 50; + impl Rule for NoMisleadingReturnType { type Query = Typed; type State = RuleState; @@ -419,7 +422,7 @@ fn is_only_property_literal_widening(annotation: &Type, returns: &[Type]) -> boo while let Some((annotated, inferred)) = stack.pop() { iterations += 1; - if iterations > 50 { + if iterations > MAX_TYPE_TRAVERSAL_ITERATIONS { return false; } @@ -906,7 +909,7 @@ fn is_nonunion_wider(annotated: &Type, inferred: &Type) -> bool { while let Some((ann, inf)) = stack.pop() { iterations += 1; - if iterations > 50 { + if iterations > MAX_TYPE_TRAVERSAL_ITERATIONS { return false; }