From e810c664dff25639d802a7970395d4db2916f3ac Mon Sep 17 00:00:00 2001 From: Caleb White Date: Fri, 17 Jul 2026 16:23:40 -0500 Subject: [PATCH] fix: resolve static/self/parent relative to @param-closure-this bound class When a closure or arrow function has its $this type overridden via @param-closure-this, the static/self/parent keywords in return type declarations were still resolving to the enclosing class instead of the bound class. This also meant the closure scope was not set, preventing access to protected/private members of the bound class. Two fixes were needed: 1. Set inClosureBindScopeClasses when applying @param-closure-this so that resolveTypeByName, resolveName, and canAccessClassMember use the bound class for static/self resolution and visibility checks. 2. Override the InitializerExprContext in getFunctionType when inClosureBindScopeClasses is set, so that static/self/parent in return type declarations (processed via ParserNodeTypeToPHPStanType) also resolve to the bound class instead of the enclosing class. Closes https://github.com/phpstan/phpstan/issues/11010 --- src/Analyser/MutatingScope.php | 44 +++++++++++- src/Analyser/NodeScopeResolver.php | 6 +- .../Analyser/nsrt/param-closure-this.php | 72 +++++++++++++++++++ .../Rules/Methods/CallMethodsRuleTest.php | 9 +++ .../PHPStan/Rules/Methods/data/bug-11010.php | 28 ++++++++ 5 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 tests/PHPStan/Rules/Methods/data/bug-11010.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 1f843048b25..b4da779f335 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2020,7 +2020,7 @@ public function restoreThis(self $restoreThisScope): self $expressionTypes, $nativeExpressionTypes, $this->conditionalExpressions, - $this->inClosureBindScopeClasses, + $restoreThisScope->inClosureBindScopeClasses, $this->anonymousFunctionReflection, $this->inFirstLevelStatement, [], @@ -2059,6 +2059,31 @@ public function isInClosureBind(): bool return $this->inClosureBindScopeClasses !== []; } + /** + * @param list $scopeClasses + */ + public function withClosureBindScopeClasses(array $scopeClasses): self + { + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $this->expressionTypes, + $this->nativeExpressionTypes, + $this->conditionalExpressions, + $scopeClasses, + $this->anonymousFunctionReflection, + $this->isInFirstLevelStatement(), + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + $this->inFunctionCallsStack, + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + /** * @api * @param ParameterReflection[]|null $callableParameters @@ -2397,6 +2422,23 @@ public function getFunctionType($type, bool $isNullable, bool $isVariadic): Type false, )), new AccessoryArrayListType()]); } + if ( + $type instanceof Name + && $this->inClosureBindScopeClasses !== [] + && $this->inClosureBindScopeClasses !== ['static'] + && in_array($type->toLowerString(), ['static', 'self', 'parent'], true) + && $this->reflectionProvider->hasClass($this->inClosureBindScopeClasses[0]) + ) { + return $this->initializerExprTypeResolver->getFunctionType( + $type, + $isNullable, + false, + InitializerExprContext::fromClassReflection( + $this->reflectionProvider->getClass($this->inClosureBindScopeClasses[0]), + ), + ); + } + return $this->initializerExprTypeResolver->getFunctionType($type, $isNullable, false, InitializerExprContext::fromScope($this)); } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 05566f1d6f7..72f087b400d 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3656,7 +3656,8 @@ public function processArgs( $closureThisType = $this->resolveClosureThisType($callLike, $calleeReflection, $parameter, $scopeToPass); if ($closureThisType !== null) { $restoreThisScope = $scopeToPass; - $scopeToPass = $scopeToPass->assignVariable('this', $closureThisType, new ObjectWithoutClassType(), TrinaryLogic::createYes()); + $scopeToPass = $scopeToPass->assignVariable('this', $closureThisType, new ObjectWithoutClassType(), TrinaryLogic::createYes()) + ->withClosureBindScopeClasses($closureThisType->getObjectClassNames()); } } @@ -3722,7 +3723,8 @@ public function processArgs( ) { $closureThisType = $this->resolveClosureThisType($callLike, $calleeReflection, $parameter, $scopeToPass); if ($closureThisType !== null) { - $scopeToPass = $scopeToPass->assignVariable('this', $closureThisType, new ObjectWithoutClassType(), TrinaryLogic::createYes()); + $scopeToPass = $scopeToPass->assignVariable('this', $closureThisType, new ObjectWithoutClassType(), TrinaryLogic::createYes()) + ->withClosureBindScopeClasses($closureThisType->getObjectClassNames()); } } diff --git a/tests/PHPStan/Analyser/nsrt/param-closure-this.php b/tests/PHPStan/Analyser/nsrt/param-closure-this.php index 96f9fb8f159..6486cf0952a 100644 --- a/tests/PHPStan/Analyser/nsrt/param-closure-this.php +++ b/tests/PHPStan/Analyser/nsrt/param-closure-this.php @@ -316,3 +316,75 @@ public function doFoo(): void } } + +class FooParent +{ + +} + +class FooChild extends FooParent +{ + + private string $secret = 'secret'; + +} + +class StaticReturnClosureThis +{ + + /** + * @param-closure-this FooChild $cb + */ + public function withFooChild(callable $cb): void + { + + } + + /** + * @param-closure-this Foo $cb + */ + public function withFoo(callable $cb): void + { + + } + +} + +class TestStaticResolutionInParamClosureThis +{ + + public function testStaticReturnType(StaticReturnClosureThis $o): void + { + $o->withFoo(function (): static { + assertType('ParamClosureThis\Foo', $this); + return $this; + }); + + $o->withFoo(fn (): static => $this); + } + + public function testSelfReturnType(StaticReturnClosureThis $o): void + { + $o->withFooChild(function (): self { + assertType('ParamClosureThis\FooChild', $this); + return $this; + }); + } + + public function testParentReturnType(StaticReturnClosureThis $o): void + { + $o->withFooChild(function (): parent { + assertType('ParamClosureThis\FooChild', $this); + return $this; + }); + } + + public function testNullableStaticReturnType(StaticReturnClosureThis $o): void + { + $o->withFoo(function (): ?static { + assertType('ParamClosureThis\Foo', $this); + return rand() ? $this : null; + }); + } + +} diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index eda813a24b2..4bb5333aef5 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -3394,6 +3394,15 @@ public function testClosureBindToParamClosureThis(): void ]); } + public function testBug11010(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->checkExplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-11010.php'], []); + } + public function testPureCallable(): void { $this->checkThisOnly = false; diff --git a/tests/PHPStan/Rules/Methods/data/bug-11010.php b/tests/PHPStan/Rules/Methods/data/bug-11010.php new file mode 100644 index 00000000000..07c621cc96c --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-11010.php @@ -0,0 +1,28 @@ +bindTo(new self, self::class); + if ($cb) { + $cb(); + } + } +} + +function (): void { + HelloWorld::cb(function () { + $this->sayHello(); + }); +};