From 9be41b18f031e655ab9d04d83c912b99b64f5793 Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Sun, 5 Jul 2026 10:14:12 +0000 Subject: [PATCH] Refine remembered method calls and property fetches when their receiver is narrowed - Add `MutatingScope::refreshDependentCallsAndFetches()`, called at the end of `filterBySpecifiedTypes()`, which re-derives every remembered `MethodCall`, `NullsafeMethodCall`, `PropertyFetch` and `NullsafePropertyFetch` whose receiver was just narrowed, and intersects the remembered (control-flow narrowed) type with the type derived from the now-narrower receiver. - This fixes the case where an earlier control-flow narrowing (e.g. removing `null` after `=== null`) recorded a call/fetch type against a broader receiver, and a later `instanceof` (or any condition narrowing) made the receiver more specific without refreshing the dependent call/fetch. - Refresh both PHPDoc and native expression types. - The refresh is driven by receiver narrowing (only from `filterBySpecifiedTypes`), so it never touches call/fetch types recorded by direct assignment to a property (which may legitimately hold a covariant subtype not compatible with the declared property type). - Covers the property-fetch analogue of the reported method-call bug. --- src/Analyser/MutatingScope.php | 102 ++++++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-10050.php | 53 +++++++++++ 2 files changed, 155 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-10050.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 33e32539ff..ca9aded25d 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -105,6 +105,7 @@ use PHPStan\Type\VoidType; use Throwable; use function abs; +use function array_fill_keys; use function array_filter; use function array_key_exists; use function array_key_first; @@ -3529,6 +3530,8 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self } } + $scope = $scope->refreshDependentCallsAndFetches(array_keys($specifiedExpressions)); + /** @var static */ return $scope->scopeFactory->create( $scope->context, @@ -3550,6 +3553,105 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self ); } + /** + * When a receiver expression is narrowed, method calls and property fetches + * on it that were narrowed by earlier control flow must be refined too, so + * their remembered type combines with the one derived from the now-narrower + * receiver. + * + * @param string[] $narrowedExprStrings + */ + private function refreshDependentCallsAndFetches(array $narrowedExprStrings): self + { + if (count($narrowedExprStrings) === 0) { + return $this; + } + + $narrowedExprStringsMap = array_fill_keys($narrowedExprStrings, true); + + $expressionTypes = $this->expressionTypes; + $nativeExpressionTypes = $this->nativeExpressionTypes; + $changed = false; + + foreach ($this->expressionTypes as $exprString => $holder) { + $expr = $holder->getExpr(); + if ( + !$expr instanceof MethodCall + && !$expr instanceof Expr\NullsafeMethodCall + && !$expr instanceof PropertyFetch + && !$expr instanceof Expr\NullsafePropertyFetch + ) { + continue; + } + + if (($expr instanceof MethodCall || $expr instanceof Expr\NullsafeMethodCall) && $expr->isFirstClassCallable()) { + continue; + } + + if (!array_key_exists($this->getNodeKey($expr->var), $narrowedExprStringsMap)) { + continue; + } + + $freshType = $this->resolveExprHandlerType($this, $expr); + $newType = TypeCombinator::intersect($holder->getType(), $freshType); + if (!$newType->equals($holder->getType())) { + $expressionTypes[$exprString] = new ExpressionTypeHolder($expr, $newType, $holder->getCertainty()); + $changed = true; + } + + if (!array_key_exists($exprString, $nativeExpressionTypes)) { + continue; + } + + $nativeHolder = $nativeExpressionTypes[$exprString]; + $freshNativeType = $this->resolveExprHandlerType($this->promoteNativeTypes(), $expr); + $newNativeType = TypeCombinator::intersect($nativeHolder->getType(), $freshNativeType); + if ($newNativeType->equals($nativeHolder->getType())) { + continue; + } + + $nativeExpressionTypes[$exprString] = new ExpressionTypeHolder($expr, $newNativeType, $nativeHolder->getCertainty()); + $changed = true; + } + + if (!$changed) { + return $this; + } + + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $expressionTypes, + $nativeExpressionTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->inFirstLevelStatement, + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + $this->inFunctionCallsStack, + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + + private function resolveExprHandlerType(self $scope, Expr $node): Type + { + /** @var ExprHandler $exprHandler */ + foreach ($scope->container->getServicesByTag(ExprHandler::EXTENSION_TAG) as $exprHandler) { + if (!$exprHandler->supports($node)) { + continue; + } + + return $exprHandler->resolveType($scope, $node); + } + + return new MixedType(); + } + /** * @return array */ diff --git a/tests/PHPStan/Analyser/nsrt/bug-10050.php b/tests/PHPStan/Analyser/nsrt/bug-10050.php new file mode 100644 index 0000000000..54b57cf265 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-10050.php @@ -0,0 +1,53 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug10050; + +use function PHPStan\Testing\assertType; + +interface Value +{ + public function get(): int|string|null; +} + +interface StringValue extends Value +{ + public function get(): string; +} + +function testMethod(Value $v): void +{ + assertType('int|string|null', $v->get()); + if ($v->get() === null) { + return; + } + assertType('int|string', $v->get()); + if ($v instanceof StringValue) { + assertType('string', $v->get()); + } + assertType('int|string', $v->get()); +} + +class A +{ + public int|null $p; +} + +class B +{ + public string|null $p; +} + +function testProperty(A|B $x): void +{ + assertType('int|string|null', $x->p); + if ($x->p === null) { + return; + } + assertType('int|string', $x->p); + if ($x instanceof A) { + assertType('int', $x->p); + } + assertType('int|string', $x->p); +}