Skip to content

Commit 54760ef

Browse files
ondrejmirtesphpstan-bot
authored andcommitted
Fix #4865
1 parent 6b63691 commit 54760ef

4 files changed

Lines changed: 41 additions & 1 deletion

File tree

‎CLAUDE.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,10 @@ When considering a bug fix that involves checking "is this type a Foo?", first c
232232

233233
When two scopes are merged (e.g. after if/else branches), `MutatingScope::generalizeWith()` must invalidate dependent expressions. If variable `$i` changes, then `$locations[$i]` must be invalidated too. Bugs arise when stale `ExpressionTypeHolder` entries survive scope merges. Fix pattern: in `MutatingScope`, when a root expression changes, skip/invalidate all deep expressions that depend on it.
234234

235+
### Closure::bind() scope leaking into argument evaluation
236+
237+
`NodeScopeResolver::processArgs()` has special handling for `Closure::bind()` and `Closure::bindTo()` calls. When the first argument is a closure/arrow function literal, a `$closureBindScope` is created with `$this` rebound to the second argument's type, and this scope is used to process the closure body. However, this `$closureBindScope` must ONLY be applied when the first argument is actually an `Expr\Closure` or `Expr\ArrowFunction`. If the first argument is a general expression that returns a closure (e.g. `$this->hydrate()`), the expression itself must be evaluated in the original scope — otherwise `$this` in the expression gets incorrectly resolved as the bound object type instead of the current class. The condition at the `$scopeToPass` assignment must check the argument node type.
238+
235239
### Array type tracking: SetExistingOffsetValueTypeExpr vs SetOffsetValueTypeExpr
236240

237241
When assigning to an array offset, NodeScopeResolver must distinguish:

‎src/Analyser/NodeScopeResolver.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5628,7 +5628,7 @@ private function processArgs(
56285628

56295629
$originalScope = $scope;
56305630
$scopeToPass = $scope;
5631-
if ($i === 0 && $closureBindScope !== null) {
5631+
if ($i === 0 && $closureBindScope !== null && ($arg->value instanceof Expr\Closure || $arg->value instanceof Expr\ArrowFunction)) {
56325632
$scopeToPass = $closureBindScope;
56335633
}
56345634

‎tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -974,6 +974,14 @@ public function testClosureBind(): void
974974
]);
975975
}
976976

977+
public function testBug4865(): void
978+
{
979+
$this->checkThisOnly = false;
980+
$this->checkNullables = true;
981+
$this->checkUnionTypes = true;
982+
$this->analyse([__DIR__ . '/data/bug-4865.php'], []);
983+
}
984+
977985
public function testArrowFunctionClosureBind(): void
978986
{
979987
$this->checkThisOnly = false;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
namespace Bug4865;
4+
5+
use Closure;
6+
7+
class HelloWorld
8+
{
9+
/**
10+
* @param object $instance
11+
* @param array<string,mixed> $fieldToValue
12+
*
13+
* @return mixed
14+
*/
15+
public function create($instance, array $fieldToValue) {
16+
(Closure::bind($this->hydrate(), $instance, get_class($instance)))($fieldToValue);
17+
18+
return $instance;
19+
}
20+
21+
public function hydrate(): Closure {
22+
return function ($map) {
23+
foreach ($map as $field => $value) {
24+
$this->$field = $value;
25+
}
26+
};
27+
}
28+
}

0 commit comments

Comments
 (0)