From a72a7788effbfb0c949816569b9fc22a76d6811f Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Sun, 3 May 2026 18:10:54 +0000 Subject: [PATCH 1/2] Augment `BooleanAnd` falsey and `BooleanOr` truthy type narrowing when left and right conditions narrow different expression keys - In TypeSpecifier::specifyTypesInCondition for BooleanAnd falsey context, the intersection of normalized left and right SpecifiedTypes drops all narrowing when the two sides operate on different expression keys (e.g. $test vs $test['hi']). Add augmentDisjunctionTypes() to recover narrowing by computing both disjunction-path scopes and unioning the narrowed types for any expression narrowed in both paths. - Apply the same augmentation for BooleanOr truthy context, which is the De Morgan dual and has the same information loss. - Update four existing tests whose assertions reflected the old incomplete narrowing: type-specifying-extensions-2-{false,null}.php (extension fires in all contexts, both paths now narrow correctly), bug-3632 (additional true-positive instanceof error detected), bug-11903 (PHPDoc tip removed because narrowing is now from control flow). - Add regression tests covering the reported case (isset && is_string on array union), the || dual, and parallel type checks (is_array, is_int, is_float, is_bool, array_key_exists). --- src/Analyser/TypeSpecifier.php | 74 +++++++++++++- .../type-specifying-extensions-2-false.php | 2 +- .../type-specifying-extensions-2-null.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-14566.php | 98 +++++++++++++++++++ .../Classes/ImpossibleInstanceOfRuleTest.php | 5 +- .../BooleanNotConstantConditionRuleTest.php | 1 - 6 files changed, 176 insertions(+), 6 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14566.php diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 9f7a86eb4ae..306e5ab95a7 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -734,7 +734,16 @@ 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 { + $leftNorm = $leftTypes->normalize($scope); + $rightNorm = $rightTypes->normalize($rightScope); + $types = $leftNorm->intersectWith($rightNorm); + $leftFalseyScope = $scope->filterByFalseyValue($expr->left); + $rightFalseyScope = $rightScope->filterByFalseyValue($expr->right); + $types = $this->augmentDisjunctionTypes($scope, $leftNorm, $rightNorm, $leftFalseyScope, $rightFalseyScope, $types); + } if ($context->false()) { $leftTypesForHolders = $leftTypes; $rightTypesForHolders = $rightTypes; @@ -788,8 +797,13 @@ public function specifyTypesInCondition( ) { $types = $leftTypes->normalize($scope); } else { - $types = $leftTypes->normalize($scope)->intersectWith($rightTypes->normalize($rightScope)); + $leftNorm = $leftTypes->normalize($scope); + $rightNorm = $rightTypes->normalize($rightScope); + $types = $leftNorm->intersectWith($rightNorm); $types = $this->augmentBooleanOrTruthyWithConditionalHolders($scope, $rightScope, $expr, $types); + $leftTruthyScopeForAugment = $scope->filterByTruthyValue($expr->left); + $rightTruthyScopeForAugment = $rightScope->filterByTruthyValue($expr->right); + $types = $this->augmentDisjunctionTypes($scope, $leftNorm, $rightNorm, $leftTruthyScopeForAugment, $rightTruthyScopeForAugment, $types); } } else { $types = $leftTypes->unionWith($rightTypes); @@ -2076,6 +2090,62 @@ private function augmentBooleanOrTruthyWithConditionalHolders(MutatingScope $sco return $types; } + private function augmentDisjunctionTypes( + MutatingScope $scope, + SpecifiedTypes $leftNorm, + SpecifiedTypes $rightNorm, + MutatingScope $leftFilteredScope, + MutatingScope $rightFilteredScope, + SpecifiedTypes $types, + ): SpecifiedTypes + { + $candidateExprs = []; + foreach ($leftNorm->getSureTypes() as $exprString => [$exprNode, $type]) { + $candidateExprs[$exprString] = $exprNode; + } + foreach ($rightNorm->getSureTypes() as $exprString => [$exprNode, $type]) { + $candidateExprs[$exprString] = $exprNode; + } + + foreach ($candidateExprs as $exprString => $targetExpr) { + if (isset($types->getSureTypes()[$exprString])) { + continue; + } + + if (!$scope->hasExpressionType($targetExpr)->yes()) { + continue; + } + if (!$leftFilteredScope->hasExpressionType($targetExpr)->yes()) { + continue; + } + if (!$rightFilteredScope->hasExpressionType($targetExpr)->yes()) { + continue; + } + + $origType = $scope->getType($targetExpr); + $leftType = $leftFilteredScope->getType($targetExpr); + $rightType = $rightFilteredScope->getType($targetExpr); + + $leftNarrowed = !$leftType->equals($origType) && $origType->isSuperTypeOf($leftType)->yes(); + $rightNarrowed = !$rightType->equals($origType) && $origType->isSuperTypeOf($rightType)->yes(); + + if (!$leftNarrowed || !$rightNarrowed) { + continue; + } + + $unionType = TypeCombinator::union($leftType, $rightType); + if ($unionType->equals($origType)) { + continue; + } + + $types = $types->unionWith( + $this->create($targetExpr, $unionType, TypeSpecifierContext::createTrue(), $scope), + ); + } + + return $types; + } + /** * @return array */ diff --git a/tests/PHPStan/Analyser/data/type-specifying-extensions-2-false.php b/tests/PHPStan/Analyser/data/type-specifying-extensions-2-false.php index 8016cee235b..c33f80b9278 100644 --- a/tests/PHPStan/Analyser/data/type-specifying-extensions-2-false.php +++ b/tests/PHPStan/Analyser/data/type-specifying-extensions-2-false.php @@ -10,5 +10,5 @@ if ((new \PHPStan\Tests\AssertionClass())->assertString($foo) && \PHPStan\Tests\AssertionClass::assertInt($bar)) { } -assertType('string|null', $foo); +assertType('string', $foo); assertType('int|null', $bar); diff --git a/tests/PHPStan/Analyser/data/type-specifying-extensions-2-null.php b/tests/PHPStan/Analyser/data/type-specifying-extensions-2-null.php index 8016cee235b..c33f80b9278 100644 --- a/tests/PHPStan/Analyser/data/type-specifying-extensions-2-null.php +++ b/tests/PHPStan/Analyser/data/type-specifying-extensions-2-null.php @@ -10,5 +10,5 @@ if ((new \PHPStan\Tests\AssertionClass())->assertString($foo) && \PHPStan\Tests\AssertionClass::assertInt($bar)) { } -assertType('string|null', $foo); +assertType('string', $foo); assertType('int|null', $bar); diff --git a/tests/PHPStan/Analyser/nsrt/bug-14566.php b/tests/PHPStan/Analyser/nsrt/bug-14566.php new file mode 100644 index 00000000000..19ca598afed --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14566.php @@ -0,0 +1,98 @@ +treatPhpDocTypesAsCertain: false in your %configurationFile%.', ], ]); } From d2744e3b19b74cb625a68ef14375f3e5197e4303 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 3 May 2026 18:30:04 +0000 Subject: [PATCH 2/2] Address review feedback in augmentDisjunctionTypes - Rename $leftNorm/$rightNorm to $leftNormalized/$rightNormalized - Rename $origType to $originalType - Extract $types->getSureTypes() into $existingSureTypes before the loop - Early-continue on left narrowing check before computing right narrowing Co-Authored-By: Claude Opus 4.6 --- src/Analyser/TypeSpecifier.php | 39 ++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 306e5ab95a7..5701a800165 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -737,12 +737,12 @@ public function specifyTypesInCondition( if ($context->true()) { $types = $leftTypes->unionWith($rightTypes); } else { - $leftNorm = $leftTypes->normalize($scope); - $rightNorm = $rightTypes->normalize($rightScope); - $types = $leftNorm->intersectWith($rightNorm); + $leftNormalized = $leftTypes->normalize($scope); + $rightNormalized = $rightTypes->normalize($rightScope); + $types = $leftNormalized->intersectWith($rightNormalized); $leftFalseyScope = $scope->filterByFalseyValue($expr->left); $rightFalseyScope = $rightScope->filterByFalseyValue($expr->right); - $types = $this->augmentDisjunctionTypes($scope, $leftNorm, $rightNorm, $leftFalseyScope, $rightFalseyScope, $types); + $types = $this->augmentDisjunctionTypes($scope, $leftNormalized, $rightNormalized, $leftFalseyScope, $rightFalseyScope, $types); } if ($context->false()) { $leftTypesForHolders = $leftTypes; @@ -797,13 +797,13 @@ public function specifyTypesInCondition( ) { $types = $leftTypes->normalize($scope); } else { - $leftNorm = $leftTypes->normalize($scope); - $rightNorm = $rightTypes->normalize($rightScope); - $types = $leftNorm->intersectWith($rightNorm); + $leftNormalized = $leftTypes->normalize($scope); + $rightNormalized = $rightTypes->normalize($rightScope); + $types = $leftNormalized->intersectWith($rightNormalized); $types = $this->augmentBooleanOrTruthyWithConditionalHolders($scope, $rightScope, $expr, $types); $leftTruthyScopeForAugment = $scope->filterByTruthyValue($expr->left); $rightTruthyScopeForAugment = $rightScope->filterByTruthyValue($expr->right); - $types = $this->augmentDisjunctionTypes($scope, $leftNorm, $rightNorm, $leftTruthyScopeForAugment, $rightTruthyScopeForAugment, $types); + $types = $this->augmentDisjunctionTypes($scope, $leftNormalized, $rightNormalized, $leftTruthyScopeForAugment, $rightTruthyScopeForAugment, $types); } } else { $types = $leftTypes->unionWith($rightTypes); @@ -2092,23 +2092,25 @@ private function augmentBooleanOrTruthyWithConditionalHolders(MutatingScope $sco private function augmentDisjunctionTypes( MutatingScope $scope, - SpecifiedTypes $leftNorm, - SpecifiedTypes $rightNorm, + SpecifiedTypes $leftNormalized, + SpecifiedTypes $rightNormalized, MutatingScope $leftFilteredScope, MutatingScope $rightFilteredScope, SpecifiedTypes $types, ): SpecifiedTypes { $candidateExprs = []; - foreach ($leftNorm->getSureTypes() as $exprString => [$exprNode, $type]) { + foreach ($leftNormalized->getSureTypes() as $exprString => [$exprNode, $type]) { $candidateExprs[$exprString] = $exprNode; } - foreach ($rightNorm->getSureTypes() as $exprString => [$exprNode, $type]) { + foreach ($rightNormalized->getSureTypes() as $exprString => [$exprNode, $type]) { $candidateExprs[$exprString] = $exprNode; } + $existingSureTypes = $types->getSureTypes(); + foreach ($candidateExprs as $exprString => $targetExpr) { - if (isset($types->getSureTypes()[$exprString])) { + if (isset($existingSureTypes[$exprString])) { continue; } @@ -2122,19 +2124,20 @@ private function augmentDisjunctionTypes( continue; } - $origType = $scope->getType($targetExpr); + $originalType = $scope->getType($targetExpr); $leftType = $leftFilteredScope->getType($targetExpr); $rightType = $rightFilteredScope->getType($targetExpr); - $leftNarrowed = !$leftType->equals($origType) && $origType->isSuperTypeOf($leftType)->yes(); - $rightNarrowed = !$rightType->equals($origType) && $origType->isSuperTypeOf($rightType)->yes(); + if ($leftType->equals($originalType) || !$originalType->isSuperTypeOf($leftType)->yes()) { + continue; + } - if (!$leftNarrowed || !$rightNarrowed) { + if ($rightType->equals($originalType) || !$originalType->isSuperTypeOf($rightType)->yes()) { continue; } $unionType = TypeCombinator::union($leftType, $rightType); - if ($unionType->equals($origType)) { + if ($unionType->equals($originalType)) { continue; }