From af67d361f206e448fdbb46683b0efa7b2ad25892 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:25:47 +0000 Subject: [PATCH] Fix arrow functions inheriting property type narrowings from parent scope - Arrow functions inherited all expression types from the parent scope, including property narrowings from assignments (e.g. $this->prop = []) - Closures correctly reset property types by building a fresh scope, but arrow functions did not - Filter out non-readonly PropertyFetch narrowings in enterArrowFunctionWithoutReflection, matching closure behavior - Fix TemplateTypeTrait to capture $this->default in a local variable before passing to arrow function - New regression test in tests/PHPStan/Analyser/nsrt/bug-13563.php --- src/Analyser/MutatingScope.php | 30 ++++++++++++- src/Type/Generic/TemplateTypeTrait.php | 3 +- tests/PHPStan/Analyser/nsrt/bug-13563.php | 55 +++++++++++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13563.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index f1dfb1f0da..608de72386 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2321,13 +2321,39 @@ public function enterArrowFunctionWithoutReflection(Expr\ArrowFunction $arrowFun $arrowFunctionScope = $arrowFunctionScope->invalidateExpression(new Variable('this')); } + $filteredExpressionTypes = $this->invalidateStaticExpressions($arrowFunctionScope->expressionTypes); + $filteredNativeExpressionTypes = $arrowFunctionScope->nativeExpressionTypes; + + if (!$arrowFunction->static && $this->hasVariableType('this')->yes()) { + foreach ($filteredExpressionTypes as $exprString => $typeHolder) { + $expr = $typeHolder->getExpr(); + if (!$expr instanceof PropertyFetch) { + continue; + } + if ($this->isReadonlyPropertyFetch($expr, true)) { + continue; + } + unset($filteredExpressionTypes[$exprString]); + } + foreach ($filteredNativeExpressionTypes as $exprString => $typeHolder) { + $expr = $typeHolder->getExpr(); + if (!$expr instanceof PropertyFetch) { + continue; + } + if ($this->isReadonlyPropertyFetch($expr, true)) { + continue; + } + unset($filteredNativeExpressionTypes[$exprString]); + } + } + return $this->scopeFactory->create( $arrowFunctionScope->context, $this->isDeclareStrictTypes(), $arrowFunctionScope->getFunction(), $arrowFunctionScope->getNamespace(), - $this->invalidateStaticExpressions($arrowFunctionScope->expressionTypes), - $arrowFunctionScope->nativeExpressionTypes, + $filteredExpressionTypes, + $filteredNativeExpressionTypes, $arrowFunctionScope->conditionalExpressions, $arrowFunctionScope->inClosureBindScopeClasses, new ClosureType(), diff --git a/src/Type/Generic/TemplateTypeTrait.php b/src/Type/Generic/TemplateTypeTrait.php index d823f9b20d..afba955ad2 100644 --- a/src/Type/Generic/TemplateTypeTrait.php +++ b/src/Type/Generic/TemplateTypeTrait.php @@ -74,7 +74,8 @@ public function describe(VerbosityLevel $level): string } $defaultDescription = ''; if ($this->default !== null) { - $recursionGuard = RecursionGuard::runOnObjectIdentity($this->default, fn () => $this->default->describe($level)); + $default = $this->default; + $recursionGuard = RecursionGuard::runOnObjectIdentity($default, static fn () => $default->describe($level)); if (!$recursionGuard instanceof ErrorType) { $defaultDescription .= sprintf(' = %s', $recursionGuard); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-13563.php b/tests/PHPStan/Analyser/nsrt/bug-13563.php new file mode 100644 index 0000000000..cf6d850900 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13563.php @@ -0,0 +1,55 @@ + + */ + private array $callbacks = []; + + public function willReturnCallback(string $method, callable $callback): void + { + $this->callbacks[$method] = \Closure::fromCallable($callback); + } +} + +class MyTest +{ + /** + * @var array + */ + private array $dates = []; + + /** + * @var array + */ + private array $propNotCleared = []; + + public function setUp(): void + { + $invoker = new Invoker(); + $this->dates = []; + + assertType('array{}', $this->dates); + + // Arrow function should see the declared property type, not the narrowed array{} type + $invoker->willReturnCallback('get', fn (int $id) => assertType('array', $this->dates)); + + // Closure correctly sees the declared property type + $invoker->willReturnCallback('get', function (int $id) { + assertType('array', $this->dates); + }); + + // Property not cleared - both should see the declared type + assertType('array', $this->propNotCleared); + $invoker->willReturnCallback('get', fn (int $id) => assertType('array', $this->propNotCleared)); + $invoker->willReturnCallback('get', function (int $id) { + assertType('array', $this->propNotCleared); + }); + } +}