Skip to content
21 changes: 21 additions & 0 deletions .changeset/cute-moons-obey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@biomejs/biome": patch
---

Fixed [#8004](https://github.com/biomejs/biome/issues/8004): [`noParametersOnlyUsedInRecursion`](https://biomejs.dev/linter/rules/no-parameters-only-used-in-recursion/) now correctly detects recursion by comparing function bindings instead of just names.

Previously, the rule incorrectly flagged parameters when a method had the same name as an outer function but called the outer function (not itself):

```js
function notRecursive(arg) {
return arg;
}

const obj = {
notRecursive(arg) {
return notRecursive(arg); // This calls the outer function, not the method itself
},
};
```

Biome now properly distinguishes between these cases and will not report false positives.
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ impl Rule for NoParametersOnlyUsedInRecursion {
// Get function name for recursion detection
let function_name = get_function_name(&parent_function);

// Get function binding for semantic comparison
let parent_function_binding = get_function_binding(&parent_function, model);

// Get all references to this parameter
let all_refs: Vec<_> = binding.all_references(model).collect();

Expand All @@ -144,6 +147,8 @@ impl Rule for NoParametersOnlyUsedInRecursion {
function_name.as_ref(),
&parent_function,
name_text,
model,
parent_function_binding.as_ref(),
) {
refs_in_recursion += 1;
} else {
Expand Down Expand Up @@ -250,6 +255,55 @@ fn get_function_name(parent_function: &AnyJsParameterParentFunction) -> Option<T
}
}

/// Gets the binding for a function declaration/expression, if available
fn get_function_binding(
parent_function: &AnyJsParameterParentFunction,
model: &biome_js_semantic::SemanticModel,
) -> Option<biome_js_semantic::Binding> {
match parent_function {
AnyJsParameterParentFunction::JsFunctionDeclaration(decl) => decl
.id()
.ok()
.and_then(|any_binding| any_binding.as_js_identifier_binding().cloned())
.map(|id| model.as_binding(&id)),
AnyJsParameterParentFunction::JsFunctionExpression(expr) => expr
.id()
.and_then(|any_binding| any_binding.as_js_identifier_binding().cloned())
.map(|id| model.as_binding(&id)),
AnyJsParameterParentFunction::JsArrowFunctionExpression(arrow) => {
// For arrow functions, find the binding from the surrounding context
let arrow_syntax = arrow.syntax();
for ancestor in arrow_syntax.ancestors().skip(1) {
// Check for variable declarator: const foo = () => ...
if let Some(declarator) = JsVariableDeclarator::cast_ref(&ancestor)
&& let Ok(id) = declarator.id()
&& let Some(any_binding) = id.as_any_js_binding()
&& let Some(js_id_binding) = any_binding.as_js_identifier_binding()
{
return Some(model.as_binding(js_id_binding));
}

// Check for assignment expression: foo = () => ...
if let Some(assignment) = JsAssignmentExpression::cast_ref(&ancestor)
&& let Ok(left) = assignment.left()
&& let Some(id_assignment) = left.as_any_js_assignment()
&& let Some(js_id_assignment) = id_assignment.as_js_identifier_assignment()
{
// Resolve assignment target to its binding
return model.binding(js_id_assignment);
}

if is_function_like(&ancestor) {
break;
}
}
None
}
// Methods are property names, not bindings - use name-based comparison
_ => None,
}
}

/// Extracts the name of an arrow function from its surrounding context.
/// Handles cases like:
/// - `const foo = () => ...` (variable declarator)
Expand All @@ -262,12 +316,7 @@ fn get_arrow_function_name(
let arrow_syntax = arrow_fn.syntax();

// Walk up the syntax tree to find a variable declarator or assignment
for ancestor in arrow_syntax.ancestors() {
// Skip the arrow function node itself
if ancestor == *arrow_syntax {
continue;
}

for ancestor in arrow_syntax.ancestors().skip(1) {
// Check for variable declarator: const foo = () => ...
if let Some(declarator) = JsVariableDeclarator::cast_ref(&ancestor) {
return declarator
Expand Down Expand Up @@ -335,7 +384,12 @@ fn is_function_signature(parent_function: &AnyJsParameterParentFunction) -> bool
)
}

fn is_recursive_call(call: &JsCallExpression, function_name: Option<&TokenText>) -> bool {
fn is_recursive_call(
call: &JsCallExpression,
function_name: Option<&TokenText>,
model: &biome_js_semantic::SemanticModel,
parent_function_binding: Option<&biome_js_semantic::Binding>,
) -> bool {
let Ok(callee) = call.callee() else {
return false;
};
Expand All @@ -348,7 +402,32 @@ fn is_recursive_call(call: &JsCallExpression, function_name: Option<&TokenText>)

// Simple identifier: foo()
if let Some(ref_id) = expr.as_js_reference_identifier() {
return ref_id.name().ok().is_some_and(|n| n.text() == name.text());
let name_matches = ref_id.name().ok().is_some_and(|n| n.text() == name.text());
if !name_matches {
return false;
}

let called_binding = model.binding(&ref_id);

match (parent_function_binding, called_binding) {
// Both have bindings - compare them directly
(Some(parent_binding), Some(called_binding)) => {
return called_binding == *parent_binding;
}
// Parent has no binding (e.g. in the case of a method),
// but call resolves to a binding
(None, Some(_)) => {
return false;
}
// Parent has binding but call doesn't resolve
(Some(_), None) => {
return false;
}
// Neither has a binding. Fall back to name comparison
(None, None) => {
return name_matches;
}
}
}

// Member expression: this.foo() or this?.foo()
Expand Down Expand Up @@ -405,6 +484,8 @@ fn is_reference_in_recursive_call(
function_name: Option<&TokenText>,
parent_function: &AnyJsParameterParentFunction,
param_name: &str,
model: &biome_js_semantic::SemanticModel,
parent_function_binding: Option<&biome_js_semantic::Binding>,
) -> bool {
let ref_node = reference.syntax();

Expand All @@ -414,7 +495,13 @@ fn is_reference_in_recursive_call(
// Check if this is a call expression
if let Some(call_expr) = JsCallExpression::cast_ref(&node) {
// Check if this call is recursive AND uses our parameter
if is_recursive_call_with_param_usage(&call_expr, function_name, param_name) {
if is_recursive_call_with_param_usage(
&call_expr,
function_name,
param_name,
model,
parent_function_binding,
) {
return true;
}
}
Expand Down Expand Up @@ -528,9 +615,11 @@ fn is_recursive_call_with_param_usage(
call: &JsCallExpression,
function_name: Option<&TokenText>,
param_name: &str,
model: &biome_js_semantic::SemanticModel,
parent_function_binding: Option<&biome_js_semantic::Binding>,
) -> bool {
// First check if this is a recursive call at all
if !is_recursive_call(call, function_name) {
if !is_recursive_call(call, function_name, model, parent_function_binding) {
return false;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ foo = (n, acc) => {
return foo(n - 1, acc);
};

// Separate declaration and assignment with arrow function
let bar;
bar = (x, unused) => {
if (x === 0) return 0;
return bar(x - 1, unused);
};

// Logical AND operator
function fnAnd(n, acc) {
if (n === 0) return 0;
Expand Down
Loading