From be1a3699cf71a8d0726d2007cbf79fe89568b5f3 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Sun, 3 May 2026 12:28:42 +0000 Subject: [PATCH 1/5] Propagate array dim fetch narrowings to parent variable in `BooleanAnd` falsey and `BooleanOr` truthy `intersectWith` - Add `propagateArrayDimFetchNarrowingsToParent()` to `TypeSpecifier` that enriches normalized `SpecifiedTypes` by computing a parent array variable's narrowed type from its child `ArrayDimFetch` sureType using `HasOffsetValueType`, mirroring what `MutatingScope::specifyExpressionType` already does when applying narrowings - Call the new method in the `BooleanAnd` falsey branch and the `BooleanOr` truthy branch before `intersectWith`, so that both sides have a sureType for the parent variable and the intersection correctly produces the union - Skip propagation for nested `ArrayDimFetch` parents to avoid over-narrowing in cases where the intermediate offset may not exist - The fix handles all type-check functions (`is_string`, `is_int`, `is_array`, etc.) and `instanceof` uniformly since they all go through the same conditional return type / type specifier machinery --- src/Analyser/TypeSpecifier.php | 64 ++++++++++++++++++++++- tests/PHPStan/Analyser/nsrt/bug-14566.php | 59 +++++++++++++++++++++ 2 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14566.php diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 9f7a86eb4a..5830a7d98e 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -734,7 +734,15 @@ public function specifyTypesInCondition( $leftTypes = $this->specifyTypesInCondition($scope, $expr->left, $context)->setRootExpr($expr); $rightScope = $scope->filterByTruthyValue($expr->left); $rightTypes = $this->specifyTypesInCondition($rightScope, $expr->right, $context)->setRootExpr($expr); - $types = $context->true() ? $leftTypes->unionWith($rightTypes) : $leftTypes->normalize($scope)->intersectWith($rightTypes->normalize($rightScope)); + if ($context->true()) { + $types = $leftTypes->unionWith($rightTypes); + } else { + $normalizedLeft = $leftTypes->normalize($scope); + $normalizedRight = $rightTypes->normalize($rightScope); + $normalizedLeft = $this->propagateArrayDimFetchNarrowingsToParent($normalizedLeft, $scope); + $normalizedRight = $this->propagateArrayDimFetchNarrowingsToParent($normalizedRight, $rightScope); + $types = $normalizedLeft->intersectWith($normalizedRight); + } if ($context->false()) { $leftTypesForHolders = $leftTypes; $rightTypesForHolders = $rightTypes; @@ -788,7 +796,11 @@ public function specifyTypesInCondition( ) { $types = $leftTypes->normalize($scope); } else { - $types = $leftTypes->normalize($scope)->intersectWith($rightTypes->normalize($rightScope)); + $normalizedLeft = $leftTypes->normalize($scope); + $normalizedRight = $rightTypes->normalize($rightScope); + $normalizedLeft = $this->propagateArrayDimFetchNarrowingsToParent($normalizedLeft, $scope); + $normalizedRight = $this->propagateArrayDimFetchNarrowingsToParent($normalizedRight, $rightScope); + $types = $normalizedLeft->intersectWith($normalizedRight); $types = $this->augmentBooleanOrTruthyWithConditionalHolders($scope, $rightScope, $expr, $types); } } else { @@ -2076,6 +2088,54 @@ private function augmentBooleanOrTruthyWithConditionalHolders(MutatingScope $sco return $types; } + private function propagateArrayDimFetchNarrowingsToParent(SpecifiedTypes $normalizedTypes, Scope $scope): SpecifiedTypes + { + $additionalSureTypes = []; + foreach ($normalizedTypes->getSureTypes() as $exprString => [$exprNode, $type]) { + if ( + !$exprNode instanceof ArrayDimFetch + || $exprNode->dim === null + || $exprNode->var instanceof ArrayDimFetch + ) { + continue; + } + + $dimType = $scope->getType($exprNode->dim)->toArrayKey(); + if (!$dimType instanceof ConstantIntegerType && !$dimType instanceof ConstantStringType) { // @phpstan-ignore phpstanApi.instanceofType + continue; + } + + $parentExprString = $this->exprPrinter->printExpr($exprNode->var); + if (isset($normalizedTypes->getSureTypes()[$parentExprString]) || isset($additionalSureTypes[$parentExprString])) { + continue; + } + + $parentType = $scope->getType($exprNode->var); + if ($parentType instanceof MixedType || !$parentType->isArray()->yes()) { + continue; + } + + $narrowedParentType = TypeCombinator::intersect( + $parentType, + new HasOffsetValueType($dimType, $type), + ); + if ($narrowedParentType instanceof NeverType) { + continue; + } + + $additionalSureTypes[$parentExprString] = [$exprNode->var, $narrowedParentType]; + } + + if ($additionalSureTypes === []) { + return $normalizedTypes; + } + + return new SpecifiedTypes( + $normalizedTypes->getSureTypes() + $additionalSureTypes, + [], + ); + } + /** * @return array */ diff --git a/tests/PHPStan/Analyser/nsrt/bug-14566.php b/tests/PHPStan/Analyser/nsrt/bug-14566.php new file mode 100644 index 0000000000..7d0e15d5e1 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14566.php @@ -0,0 +1,59 @@ + Date: Sun, 3 May 2026 17:30:11 +0200 Subject: [PATCH 2/5] Try other strategy --- src/Analyser/TypeSpecifier.php | 100 +++++++++++++-------------------- 1 file changed, 38 insertions(+), 62 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 5830a7d98e..4928d8757b 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -734,15 +734,7 @@ public function specifyTypesInCondition( $leftTypes = $this->specifyTypesInCondition($scope, $expr->left, $context)->setRootExpr($expr); $rightScope = $scope->filterByTruthyValue($expr->left); $rightTypes = $this->specifyTypesInCondition($rightScope, $expr->right, $context)->setRootExpr($expr); - if ($context->true()) { - $types = $leftTypes->unionWith($rightTypes); - } else { - $normalizedLeft = $leftTypes->normalize($scope); - $normalizedRight = $rightTypes->normalize($rightScope); - $normalizedLeft = $this->propagateArrayDimFetchNarrowingsToParent($normalizedLeft, $scope); - $normalizedRight = $this->propagateArrayDimFetchNarrowingsToParent($normalizedRight, $rightScope); - $types = $normalizedLeft->intersectWith($normalizedRight); - } + $types = $context->true() ? $leftTypes->unionWith($rightTypes) : $leftTypes->normalize($scope)->intersectWith($rightTypes->normalize($rightScope)); if ($context->false()) { $leftTypesForHolders = $leftTypes; $rightTypesForHolders = $rightTypes; @@ -796,11 +788,7 @@ public function specifyTypesInCondition( ) { $types = $leftTypes->normalize($scope); } else { - $normalizedLeft = $leftTypes->normalize($scope); - $normalizedRight = $rightTypes->normalize($rightScope); - $normalizedLeft = $this->propagateArrayDimFetchNarrowingsToParent($normalizedLeft, $scope); - $normalizedRight = $this->propagateArrayDimFetchNarrowingsToParent($normalizedRight, $rightScope); - $types = $normalizedLeft->intersectWith($normalizedRight); + $types = $leftTypes->normalize($scope)->intersectWith($rightTypes->normalize($rightScope)); $types = $this->augmentBooleanOrTruthyWithConditionalHolders($scope, $rightScope, $expr, $types); } } else { @@ -2088,54 +2076,6 @@ private function augmentBooleanOrTruthyWithConditionalHolders(MutatingScope $sco return $types; } - private function propagateArrayDimFetchNarrowingsToParent(SpecifiedTypes $normalizedTypes, Scope $scope): SpecifiedTypes - { - $additionalSureTypes = []; - foreach ($normalizedTypes->getSureTypes() as $exprString => [$exprNode, $type]) { - if ( - !$exprNode instanceof ArrayDimFetch - || $exprNode->dim === null - || $exprNode->var instanceof ArrayDimFetch - ) { - continue; - } - - $dimType = $scope->getType($exprNode->dim)->toArrayKey(); - if (!$dimType instanceof ConstantIntegerType && !$dimType instanceof ConstantStringType) { // @phpstan-ignore phpstanApi.instanceofType - continue; - } - - $parentExprString = $this->exprPrinter->printExpr($exprNode->var); - if (isset($normalizedTypes->getSureTypes()[$parentExprString]) || isset($additionalSureTypes[$parentExprString])) { - continue; - } - - $parentType = $scope->getType($exprNode->var); - if ($parentType instanceof MixedType || !$parentType->isArray()->yes()) { - continue; - } - - $narrowedParentType = TypeCombinator::intersect( - $parentType, - new HasOffsetValueType($dimType, $type), - ); - if ($narrowedParentType instanceof NeverType) { - continue; - } - - $additionalSureTypes[$parentExprString] = [$exprNode->var, $narrowedParentType]; - } - - if ($additionalSureTypes === []) { - return $normalizedTypes; - } - - return new SpecifiedTypes( - $normalizedTypes->getSureTypes() + $additionalSureTypes, - [], - ); - } - /** * @return array */ @@ -2646,6 +2586,42 @@ private function createForExpr( } } + if ( + $expr instanceof ArrayDimFetch + && $expr->dim !== null + && !$context->null() + ) { + $dimType = $scope->getType($expr->dim); + if ($dimType instanceof ConstantIntegerType || $dimType->getConstantStrings() !== []) { + $varType = $scope->getType($expr->var); + $constantArrays = $varType->getConstantArrays(); + if ($constantArrays !== []) { + $typesToRemove = []; + foreach ($constantArrays as $constantArray) { + if (!$constantArray->hasOffsetValueType($dimType)->yes()) { + continue; + } + $offsetValueType = $constantArray->getOffsetValueType($dimType); + if ($context->false()) { + if ($type->isSuperTypeOf($offsetValueType)->yes()) { + $typesToRemove[] = $constantArray; + } + } elseif ($context->true()) { + if ($type->isSuperTypeOf($offsetValueType)->no()) { + $typesToRemove[] = $constantArray; + } + } + } + + if ($typesToRemove !== [] && count($typesToRemove) < count($constantArrays)) { + $typeToRemove = TypeCombinator::union(...$typesToRemove); + $varExprString = $this->exprPrinter->printExpr($expr->var); + $sureNotTypes[$varExprString] = [$expr->var, $typeToRemove]; + } + } + } + } + $types = new SpecifiedTypes($sureTypes, $sureNotTypes); if (isset($containsNull) && !$containsNull) { return $this->createNullsafeTypes($originalExpr, $scope, $context, $type)->unionWith($types); From dd2f739975b0d6e32c2c6762aa891c5309fff64c Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 3 May 2026 16:34:56 +0000 Subject: [PATCH 3/5] Use HasOffsetValueType refinement instead of constant-array elimination Instead of removing entire constant array variants via sureNotTypes when the type fully covers the offset value type, compute the narrowed offset value type using TypeCombinator::remove (false context) or TypeCombinator::intersect (true context) and rebuild the constant array with the refined value. This handles partial overlap cases that the previous approach could not: e.g. array{hi: string|int} after `is_string($arr['hi'])` in false context correctly narrows to array{hi: int}, whereas the old approach left it unchanged because string does not fully cover string|int. Also applies toArrayKey() to the dim type (matching MutatingScope's pattern) and avoids instanceof ConstantStringType. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/TypeSpecifier.php | 31 ++++++++++++++--------- tests/PHPStan/Analyser/nsrt/bug-14566.php | 21 +++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-7716.php | 8 +++--- 3 files changed, 44 insertions(+), 16 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 4928d8757b..88d56076b6 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -2591,32 +2591,39 @@ private function createForExpr( && $expr->dim !== null && !$context->null() ) { - $dimType = $scope->getType($expr->dim); + $dimType = $scope->getType($expr->dim)->toArrayKey(); if ($dimType instanceof ConstantIntegerType || $dimType->getConstantStrings() !== []) { $varType = $scope->getType($expr->var); $constantArrays = $varType->getConstantArrays(); if ($constantArrays !== []) { - $typesToRemove = []; + $refinedArrays = []; + $changed = false; foreach ($constantArrays as $constantArray) { if (!$constantArray->hasOffsetValueType($dimType)->yes()) { + $refinedArrays[] = $constantArray; continue; } $offsetValueType = $constantArray->getOffsetValueType($dimType); if ($context->false()) { - if ($type->isSuperTypeOf($offsetValueType)->yes()) { - $typesToRemove[] = $constantArray; - } - } elseif ($context->true()) { - if ($type->isSuperTypeOf($offsetValueType)->no()) { - $typesToRemove[] = $constantArray; - } + $narrowedValueType = TypeCombinator::remove($offsetValueType, $type); + } else { + $narrowedValueType = TypeCombinator::intersect($offsetValueType, $type); + } + if ($narrowedValueType instanceof NeverType) { + $changed = true; + continue; + } + if (!$narrowedValueType->equals($offsetValueType)) { + $changed = true; + $refinedArrays[] = $constantArray->setExistingOffsetValueType($dimType, $narrowedValueType); + } else { + $refinedArrays[] = $constantArray; } } - if ($typesToRemove !== [] && count($typesToRemove) < count($constantArrays)) { - $typeToRemove = TypeCombinator::union(...$typesToRemove); + if ($changed && $refinedArrays !== []) { $varExprString = $this->exprPrinter->printExpr($expr->var); - $sureNotTypes[$varExprString] = [$expr->var, $typeToRemove]; + $sureTypes[$varExprString] = [$expr->var, TypeCombinator::union(...$refinedArrays)]; } } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14566.php b/tests/PHPStan/Analyser/nsrt/bug-14566.php index 7d0e15d5e1..6bc3a8f6cb 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14566.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14566.php @@ -57,3 +57,24 @@ function fooInstanceof(array $test): void { } assertType("array{}|array{hi: 'hello'}", $test); } + +/** + * @param array{}|array{hi: string|int}|array{hi: float} $test + */ +function fooPartialOverlap(array $test): void { + if (isset($test['hi']) && is_string($test['hi'])) { + return; + } + assertType("array{}|array{hi: float}|array{hi: int}", $test); +} + +/** + * @param array{}|array{hi: string|int}|array{hi: float} $test + */ +function fooPartialOverlapOr(array $test): void { + if (!isset($test['hi']) || !is_string($test['hi'])) { + assertType("array{}|array{hi: float}|array{hi: int}", $test); + return; + } + assertType("array{hi: string}", $test); +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-7716.php b/tests/PHPStan/Analyser/nsrt/bug-7716.php index 63280c3b46..bd4d38f66e 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-7716.php +++ b/tests/PHPStan/Analyser/nsrt/bug-7716.php @@ -15,13 +15,13 @@ public function sayHello(array $array): int $hasBar = isset($array['bar']) && $array['bar'] > 1; if ($hasFoo) { - assertType('array{foo: int, bar?: int}', $array); + assertType('array{foo: int<2, max>, bar?: int}', $array); assertType('int<2, max>', $array['foo']); return $array['foo']; } if ($hasBar) { - assertType('array{foo?: int, bar: int}', $array); + assertType('array{foo?: int, bar: int<2, max>}', $array); assertType('int<2, max>', $array['bar']); return $array['bar']; } @@ -38,13 +38,13 @@ public function sayHello2(array $array): int $hasFoo = isset($array['foo']) && $array['foo'] > 1; if ($hasFoo) { - assertType('array{foo: int, bar?: int}', $array); + assertType('array{foo: int<2, max>, bar?: int}', $array); assertType('int<2, max>', $array['foo']); return $array['foo']; } if ($hasBar) { - assertType('array{foo?: int, bar: int}', $array); + assertType('array{foo?: int, bar: int<2, max>}', $array); assertType('int<2, max>', $array['bar']); return $array['bar']; } From a4d71d1e3f37428f4d449909ed48789bd15c331a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 3 May 2026 17:27:44 +0000 Subject: [PATCH 4/5] Extend conditional expression holders to non-Variable expressions The conditional expression holder mechanism in BooleanAnd falsey and BooleanOr truthy paths previously only created holders for Expr\Variable nodes. This meant that when two non-variable expressions (e.g. property fetches) were combined with &&, follow-up narrowing was lost: if ($c->x instanceof Foo && $c->y instanceof Foo) { return; } if ($c->x instanceof Foo) { /* $c->y was Foo|Baz, not Bar */ } Extend processBooleanSureConditionalTypes and processBooleanNotSureConditionalTypes to accept PropertyFetch, ArrayDimFetch, and StaticPropertyFetch as conditions and targets. Replace the Variable-name-based self-reference check with an expression-string comparison that works for any expression type. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/NodeScopeResolver.php | 4 +- src/Analyser/TypeSpecifier.php | 57 +++++++------------ .../Constant/ConstantArrayTypeBuilder.php | 4 -- tests/PHPStan/Analyser/nsrt/bug-14566.php | 27 +++++++++ 4 files changed, 48 insertions(+), 44 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index cbed5105ae..abd0066be9 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3917,7 +3917,7 @@ private function tryProcessUnrolledConstantArrayForeach( $matchedNativeArrays = count($nativeConstantArrays) === count($constantArrays) ? $nativeConstantArrays : null; $valueVarName = $stmt->valueVar->name; - $keyVarName = $stmt->keyVar instanceof Variable && is_string($stmt->keyVar->name) ? $stmt->keyVar->name : null; + $keyVarName = $stmt->keyVar instanceof Variable ? $stmt->keyVar->name : null; $allBodyScopes = []; $allChainScopes = []; @@ -4052,7 +4052,7 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto && ($stmt->keyVar === null || ($stmt->keyVar instanceof Variable && is_string($stmt->keyVar->name))) ) { $keyVarName = null; - if ($stmt->keyVar instanceof Variable && is_string($stmt->keyVar->name)) { + if ($stmt->keyVar instanceof Variable) { $keyVarName = $stmt->keyVar->name; } $scope = $scope->enterForeach( diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 88d56076b6..7b0fc8a292 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -2083,10 +2083,7 @@ private function processBooleanSureConditionalTypes(Scope $scope, SpecifiedTypes { $conditionExpressionTypes = []; foreach ($leftTypes->getSureTypes() as $exprString => [$expr, $type]) { - if (!$expr instanceof Expr\Variable) { - continue; - } - if (!is_string($expr->name)) { + if (!$this->isTrackableExpression($expr)) { continue; } @@ -2105,10 +2102,7 @@ private function processBooleanSureConditionalTypes(Scope $scope, SpecifiedTypes if (count($conditionExpressionTypes) > 0) { $holders = []; foreach ($rightTypes->getSureTypes() as $exprString => [$expr, $type]) { - if (!$expr instanceof Expr\Variable) { - continue; - } - if (!is_string($expr->name)) { + if (!$this->isTrackableExpression($expr)) { continue; } @@ -2118,18 +2112,9 @@ private function processBooleanSureConditionalTypes(Scope $scope, SpecifiedTypes $conditions = $conditionExpressionTypes; foreach ($conditions as $conditionExprString => $conditionExprTypeHolder) { - $conditionExpr = $conditionExprTypeHolder->getExpr(); - if (!$conditionExpr instanceof Expr\Variable) { - continue; - } - if (!is_string($conditionExpr->name)) { - continue; + if ($conditionExprString === $exprString) { + unset($conditions[$conditionExprString]); } - if ($conditionExpr->name !== $expr->name) { - continue; - } - - unset($conditions[$conditionExprString]); } if (count($conditions) === 0) { @@ -2149,6 +2134,17 @@ private function processBooleanSureConditionalTypes(Scope $scope, SpecifiedTypes return []; } + private function isTrackableExpression(Expr $expr): bool + { + if ($expr instanceof Expr\Variable) { + return is_string($expr->name); + } + + return $expr instanceof Expr\PropertyFetch + || $expr instanceof Expr\ArrayDimFetch + || $expr instanceof Expr\StaticPropertyFetch; + } + /** * Flatten a deep BooleanOr chain into leaf expressions and process them * without recursive filterByFalseyValue calls. This reduces O(n^2) to O(n) @@ -2283,10 +2279,7 @@ private function processBooleanNotSureConditionalTypes(Scope $scope, SpecifiedTy { $conditionExpressionTypes = []; foreach ($leftTypes->getSureNotTypes() as $exprString => [$expr, $type]) { - if (!$expr instanceof Expr\Variable) { - continue; - } - if (!is_string($expr->name)) { + if (!$this->isTrackableExpression($expr)) { continue; } @@ -2299,10 +2292,7 @@ private function processBooleanNotSureConditionalTypes(Scope $scope, SpecifiedTy if (count($conditionExpressionTypes) > 0) { $holders = []; foreach ($rightTypes->getSureNotTypes() as $exprString => [$expr, $type]) { - if (!$expr instanceof Expr\Variable) { - continue; - } - if (!is_string($expr->name)) { + if (!$this->isTrackableExpression($expr)) { continue; } @@ -2312,18 +2302,9 @@ private function processBooleanNotSureConditionalTypes(Scope $scope, SpecifiedTy $conditions = $conditionExpressionTypes; foreach ($conditions as $conditionExprString => $conditionExprTypeHolder) { - $conditionExpr = $conditionExprTypeHolder->getExpr(); - if (!$conditionExpr instanceof Expr\Variable) { - continue; - } - if (!is_string($conditionExpr->name)) { - continue; + if ($conditionExprString === $exprString) { + unset($conditions[$conditionExprString]); } - if ($conditionExpr->name !== $expr->name) { - continue; - } - - unset($conditions[$conditionExprString]); } if (count($conditions) === 0) { diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index 894fbfc4f1..cd7f5aa026 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -120,10 +120,6 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $opt } if ($offsetType === null) { - if (count($this->nextAutoIndexes) === 0) { - return; - } - $newAutoIndexes = $optional ? $this->nextAutoIndexes : []; $hasOptional = false; foreach ($this->keyTypes as $i => $keyType) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-14566.php b/tests/PHPStan/Analyser/nsrt/bug-14566.php index 6bc3a8f6cb..e9804212e1 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14566.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14566.php @@ -78,3 +78,30 @@ function fooPartialOverlapOr(array $test): void { } assertType("array{hi: string}", $test); } + +class FooContainer { + /** @var \stdClass|string */ + public $x; + /** @var \stdClass|int */ + public $y; +} + +function fooPropertyFetchInstanceof(FooContainer $c): void { + if ($c->x instanceof \stdClass && $c->y instanceof \stdClass) { + return; + } + if ($c->x instanceof \stdClass) { + assertType('int', $c->y); + } +} + +function fooPropertyFetchInstanceofOr(FooContainer $c): void { + if (!$c->x instanceof \stdClass || !$c->y instanceof \stdClass) { + if ($c->x instanceof \stdClass) { + assertType('int', $c->y); + } + return; + } + assertType('stdClass', $c->x); + assertType('stdClass', $c->y); +} From 2d6b57107e50dc11b60e8866266556afe57733cd Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 4 May 2026 09:22:39 +0000 Subject: [PATCH 5/5] Use right-side scope only for non-Variable expressions in conditional holders For property fetches and array dim fetches, the target type in conditional expression holders must be computed from the right-side scope where the base object is already narrowed. For example, `$node->name` needs `$node` narrowed to `FuncCall` (not `CallLike`) to resolve the property type correctly. Variables keep using the original scope to avoid breaking multi-var isset() where the right scope has already removed null from the other variable. Also removes a redundant `!($expr->name instanceof Name)` check that PHPStan correctly identified as always-true after the preceding elseif branch. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/TypeSpecifier.php | 28 ++++++++++++----------- tests/PHPStan/Analyser/nsrt/bug-14566.php | 26 +++++++++++++++++++++ 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 7b0fc8a292..0649379fdf 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -596,7 +596,7 @@ public function specifyTypesInCondition( } return $this->handleDefaultTruthyOrFalseyContext($context, $expr, $scope); - } elseif ($expr instanceof FuncCall && !($expr->name instanceof Name)) { + } elseif ($expr instanceof FuncCall) { $specifiedTypes = $this->specifyTypesFromCallableCall($context, $expr, $scope); if ($specifiedTypes !== null) { return $specifiedTypes; @@ -754,10 +754,10 @@ public function specifyTypesInCondition( $result = $result->setAlwaysOverwriteTypes(); } return $result->setNewConditionalExpressionHolders(array_merge( - $this->processBooleanNotSureConditionalTypes($scope, $leftTypesForHolders, $rightTypesForHolders), - $this->processBooleanNotSureConditionalTypes($scope, $rightTypesForHolders, $leftTypesForHolders), - $this->processBooleanSureConditionalTypes($scope, $leftTypesForHolders, $rightTypesForHolders), - $this->processBooleanSureConditionalTypes($scope, $rightTypesForHolders, $leftTypesForHolders), + $this->processBooleanNotSureConditionalTypes($scope, $leftTypesForHolders, $rightTypesForHolders, $rightScope), + $this->processBooleanNotSureConditionalTypes($scope, $rightTypesForHolders, $leftTypesForHolders, $scope), + $this->processBooleanSureConditionalTypes($scope, $leftTypesForHolders, $rightTypesForHolders, $rightScope), + $this->processBooleanSureConditionalTypes($scope, $rightTypesForHolders, $leftTypesForHolders, $scope), ))->setRootExpr($expr); } @@ -804,10 +804,10 @@ public function specifyTypesInCondition( $result = $result->setAlwaysOverwriteTypes(); } return $result->setNewConditionalExpressionHolders(array_merge( - $this->processBooleanNotSureConditionalTypes($scope, $leftTypes, $rightTypes), - $this->processBooleanNotSureConditionalTypes($scope, $rightTypes, $leftTypes), - $this->processBooleanSureConditionalTypes($scope, $leftTypes, $rightTypes), - $this->processBooleanSureConditionalTypes($scope, $rightTypes, $leftTypes), + $this->processBooleanNotSureConditionalTypes($scope, $leftTypes, $rightTypes, $rightScope), + $this->processBooleanNotSureConditionalTypes($scope, $rightTypes, $leftTypes, $scope), + $this->processBooleanSureConditionalTypes($scope, $leftTypes, $rightTypes, $rightScope), + $this->processBooleanSureConditionalTypes($scope, $rightTypes, $leftTypes, $scope), ))->setRootExpr($expr); } @@ -2079,7 +2079,7 @@ private function augmentBooleanOrTruthyWithConditionalHolders(MutatingScope $sco /** * @return array */ - private function processBooleanSureConditionalTypes(Scope $scope, SpecifiedTypes $leftTypes, SpecifiedTypes $rightTypes): array + private function processBooleanSureConditionalTypes(Scope $scope, SpecifiedTypes $leftTypes, SpecifiedTypes $rightTypes, Scope $rightScope): array { $conditionExpressionTypes = []; foreach ($leftTypes->getSureTypes() as $exprString => [$expr, $type]) { @@ -2121,9 +2121,10 @@ private function processBooleanSureConditionalTypes(Scope $scope, SpecifiedTypes continue; } + $targetScope = $expr instanceof Expr\Variable ? $scope : $rightScope; $holder = new ConditionalExpressionHolder( $conditions, - ExpressionTypeHolder::createYes($expr, TypeCombinator::intersect($scope->getType($expr), $type)), + ExpressionTypeHolder::createYes($expr, TypeCombinator::intersect($targetScope->getType($expr), $type)), ); $holders[$exprString][$holder->getKey()] = $holder; } @@ -2275,7 +2276,7 @@ private function specifyTypesForFlattenedBooleanAnd( /** * @return array */ - private function processBooleanNotSureConditionalTypes(Scope $scope, SpecifiedTypes $leftTypes, SpecifiedTypes $rightTypes): array + private function processBooleanNotSureConditionalTypes(Scope $scope, SpecifiedTypes $leftTypes, SpecifiedTypes $rightTypes, Scope $rightScope): array { $conditionExpressionTypes = []; foreach ($leftTypes->getSureNotTypes() as $exprString => [$expr, $type]) { @@ -2311,9 +2312,10 @@ private function processBooleanNotSureConditionalTypes(Scope $scope, SpecifiedTy continue; } + $targetScope = $expr instanceof Expr\Variable ? $scope : $rightScope; $holder = new ConditionalExpressionHolder( $conditions, - ExpressionTypeHolder::createYes($expr, TypeCombinator::remove($scope->getType($expr), $type)), + ExpressionTypeHolder::createYes($expr, TypeCombinator::remove($targetScope->getType($expr), $type)), ); $holders[$exprString][$holder->getKey()] = $holder; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14566.php b/tests/PHPStan/Analyser/nsrt/bug-14566.php index e9804212e1..ec6005247c 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14566.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14566.php @@ -79,6 +79,32 @@ function fooPartialOverlapOr(array $test): void { assertType("array{hi: string}", $test); } +/** + * Regression: conditional holders for property fetches must use the right-side + * scope (where the base object is narrowed) to precompute the target type. + * Otherwise, accessing $node->name when $node is CallLike (which has no $name + * property) produces ErrorType. + */ +function fooElseifPropertyNarrowing(\PhpParser\Node\Expr\CallLike $node, \PHPStan\Analyser\Scope $scope): void { + if ($node instanceof \PhpParser\Node\Expr\MethodCall && $node->name instanceof \PhpParser\Node\Identifier) { + assertType('PhpParser\Node\Expr\MethodCall', $node); + assertType('PhpParser\Node\Identifier', $node->name); + } elseif ($node instanceof \PhpParser\Node\Expr\StaticCall && $node->name instanceof \PhpParser\Node\Identifier && $node->class instanceof \PhpParser\Node\Name) { + assertType('PhpParser\Node\Expr\StaticCall', $node); + assertType('PhpParser\Node\Identifier', $node->name); + assertType('PhpParser\Node\Name', $node->class); + } elseif ($node instanceof \PhpParser\Node\Expr\New_ && $node->class instanceof \PhpParser\Node\Name) { + assertType('PhpParser\Node\Expr\New_', $node); + assertType('PhpParser\Node\Name', $node->class); + } elseif ($node instanceof \PhpParser\Node\Expr\FuncCall && $node->name instanceof \PhpParser\Node\Name) { + assertType('PhpParser\Node\Expr\FuncCall', $node); + assertType('PhpParser\Node\Name', $node->name); + } elseif ($node instanceof \PhpParser\Node\Expr\FuncCall) { + assertType('PhpParser\Node\Expr\FuncCall', $node); + assertType('PhpParser\Node\Expr', $node->name); + } +} + class FooContainer { /** @var \stdClass|string */ public $x;