From a44b629862a727694d9ade92a2cee8863dfb6857 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 28 Jul 2026 17:56:12 +0200 Subject: [PATCH 1/2] Replace SpecifiedTypes::normalize() with symbolic alternative-form entries normalize() eagerly folded sure-not entries into sure entries by subtracting from $scope->getType() at composition time, baking the composition scope's view into the narrowing. intersectWith() now produces symbolic alternative-form entries instead - lists of (sure, subtract) terms read as `(sure ?? current) minus subtract`, united - and filterBySpecifiedTypes() evaluates them against the subject's type at the application point, separately for the PHPDoc and native views. Same-kind constraints still merge exactly; vacuous never-subtractions collapse to no entry. Consumers that need concrete sure types at composition time (conditional-holder building, decided boolean operands) use the new eager ConditionalExpressionHolderHelper::toSureTypes(). Result rebuilds carry the entries via withAlternativeTypesOf(), and the disjunction union recovery treats an alternative entry as already-constrained, as it treated the eager merge entry before. The bug-3991 fixture now infers the native type its own comment always documented as correct. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019wqGgaD7iqL44t1KgpJS7b --- phpstan-baseline.neon | 2 +- .../ExprHandler/BooleanAndHandler.php | 10 +- src/Analyser/ExprHandler/BooleanOrHandler.php | 20 +- .../ConditionalExpressionHolderHelper.php | 34 +++- .../Helper/EqualityTypeSpecifyingHelper.php | 6 +- .../ExprHandler/NullsafeMethodCallHandler.php | 2 +- .../NullsafePropertyFetchHandler.php | 2 +- src/Analyser/MutatingScope.php | 47 ++++- src/Analyser/SpecifiedTypes.php | 181 ++++++++++++++---- ...InArrayFunctionTypeSpecifyingExtension.php | 2 +- tests/PHPStan/Analyser/TypeSpecifierTest.php | 23 ++- tests/PHPStan/Analyser/nsrt/bug-3991.php | 6 +- 12 files changed, 265 insertions(+), 70 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 97a96b4ad61..02b99ed61bb 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -69,7 +69,7 @@ parameters: - rawMessage: Casting to string something that's already string. identifier: cast.useless - count: 1 + count: 2 path: src/Analyser/MutatingScope.php - diff --git a/src/Analyser/ExprHandler/BooleanAndHandler.php b/src/Analyser/ExprHandler/BooleanAndHandler.php index 94140611205..80b7c2672ba 100644 --- a/src/Analyser/ExprHandler/BooleanAndHandler.php +++ b/src/Analyser/ExprHandler/BooleanAndHandler.php @@ -105,9 +105,9 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e if ($context->true()) { $types = $leftTypes->unionWith($rightTypes); } else { - $leftNormalized = $leftTypes->normalize($scope); - $rightNormalized = $rightTypes->normalize($rightScope); - $types = $leftNormalized->intersectWith($rightNormalized); + $leftNormalized = $this->conditionalExpressionHolderHelper->toSureTypes($leftTypes, $scope); + $rightNormalized = $this->conditionalExpressionHolderHelper->toSureTypes($rightTypes, $rightScope); + $types = $leftTypes->intersectWith($rightTypes); $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($scope, $rightScope, $leftNormalized, $rightNormalized, $expr->left, $expr->right, false, $types); } if ($context->false()) { @@ -146,10 +146,10 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e $rightCondTypes = new SpecifiedTypes($truthyRightTypes->getSureNotTypes(), $truthyRightTypes->getSureTypes()); } } - $result = new SpecifiedTypes( + $result = (new SpecifiedTypes( $types->getSureTypes(), $types->getSureNotTypes(), - ); + ))->withAlternativeTypesOf($types); if ($types->shouldOverwrite()) { $result = $result->setAlwaysOverwriteTypes(); } diff --git a/src/Analyser/ExprHandler/BooleanOrHandler.php b/src/Analyser/ExprHandler/BooleanOrHandler.php index f1525a563d6..7ba98d49769 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -148,16 +148,16 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e if ( $scope->getType($expr->left)->toBoolean()->isFalse()->yes() ) { - $types = $rightTypes->normalize($rightScope); + $types = $this->conditionalExpressionHolderHelper->toSureTypes($rightTypes, $rightScope); } elseif ( $scope->getType($expr->left)->toBoolean()->isTrue()->yes() || $scope->getType($expr->right)->toBoolean()->isFalse()->yes() ) { - $types = $leftTypes->normalize($scope); + $types = $this->conditionalExpressionHolderHelper->toSureTypes($leftTypes, $scope); } else { - $leftNormalized = $leftTypes->normalize($scope); - $rightNormalized = $rightTypes->normalize($rightScope); - $types = $leftNormalized->intersectWith($rightNormalized); + $leftNormalized = $this->conditionalExpressionHolderHelper->toSureTypes($leftTypes, $scope); + $rightNormalized = $this->conditionalExpressionHolderHelper->toSureTypes($rightTypes, $rightScope); + $types = $leftTypes->intersectWith($rightTypes); $types = $this->augmentBooleanOrTruthyWithConditionalHolders($typeSpecifier, $scope, $rightScope, $expr, $types); $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($scope, $rightScope, $leftNormalized, $rightNormalized, $expr->left, $expr->right, true, $types); } @@ -166,10 +166,10 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e } if ($context->true()) { - $result = new SpecifiedTypes( + $result = (new SpecifiedTypes( $types->getSureTypes(), $types->getSureNotTypes(), - ); + ))->withAlternativeTypesOf($types); if ($types->shouldOverwrite()) { $result = $result->setAlwaysOverwriteTypes(); } @@ -243,7 +243,7 @@ private function specifyTypesForFlattenedBooleanOr( $armSpecifiedTypes = []; foreach ($arms as $arm) { $armTypes = $typeSpecifier->specifyTypesInCondition($scope, $arm, $context); - $armSpecifiedTypes[] = $armTypes->normalize($scope); + $armSpecifiedTypes[] = $armTypes; } $types = $armSpecifiedTypes[0]; @@ -251,10 +251,10 @@ private function specifyTypesForFlattenedBooleanOr( $types = $types->intersectWith($armSpecifiedTypes[$i]); } - $result = new SpecifiedTypes( + $result = (new SpecifiedTypes( $types->getSureTypes(), $types->getSureNotTypes(), - ); + ))->withAlternativeTypesOf($types); if ($types->shouldOverwrite()) { $result = $result->setAlwaysOverwriteTypes(); } diff --git a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php index 3fb390488a5..f0f6b533e3d 100644 --- a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php +++ b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php @@ -56,10 +56,13 @@ public function augmentDisjunctionTypes( } $existingSureTypes = $types->getSureTypes(); + $existingAlternativeTypes = $types->getAlternativeTypes(); $viableCandidates = []; foreach ($candidateExprs as $exprString => $targetExpr) { - if (isset($existingSureTypes[$exprString])) { + if (isset($existingSureTypes[$exprString]) || isset($existingAlternativeTypes[$exprString])) { + // an alternative-form entry already encodes the either-branch + // union for this expression, deferred to the application point continue; } if (!$scope->hasExpressionType($targetExpr)->yes()) { @@ -298,4 +301,33 @@ private function isTrackableExpression(Expr $expr): bool || $expr instanceof Expr\StaticPropertyFetch; } + /** + * The eager form of the old SpecifiedTypes::normalize(): folds sure-not + * entries into sure entries by subtracting from the expression's type on + * the given scope. Only for consumers that need concrete sure types at + * composition time (conditional-holder building, decided operands) - + * merge paths use SpecifiedTypes::intersectWith() and evaluate at the + * application point instead. + */ + public function toSureTypes(SpecifiedTypes $types, Scope $scope): SpecifiedTypes + { + $sureTypes = $types->getSureTypes(); + + foreach ($types->getSureNotTypes() as $exprString => [$exprNode, $sureNotType]) { + if (!isset($sureTypes[$exprString])) { + $sureTypes[$exprString] = [$exprNode, TypeCombinator::remove($scope->getType($exprNode), $sureNotType)]; + continue; + } + + $sureTypes[$exprString][1] = TypeCombinator::remove($sureTypes[$exprString][1], $sureNotType); + } + + $result = new SpecifiedTypes($sureTypes, []); + if ($types->shouldOverwrite()) { + $result = $result->setAlwaysOverwriteTypes(); + } + + return $result->setRootExpr($types->getRootExpr()); + } + } diff --git a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php b/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php index c5162b0830e..2ea2f176f8e 100644 --- a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php +++ b/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php @@ -246,7 +246,7 @@ public function specifyTypesForEqual(Expr\BinaryOp\Equal $expr, Scope $scope, Ty return $context->true() ? $leftTypes->unionWith($rightTypes) - : $leftTypes->normalize($scope)->intersectWith($rightTypes->normalize($scope)); + : $leftTypes->intersectWith($rightTypes); } public function specifyTypesForIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes @@ -750,8 +750,8 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp } return $leftTypes->unionWith($rightTypes); } elseif ($context->false()) { - return $this->typeSpecifier->create($leftExpr, $leftType, $context, $scope)->setRootExpr($expr)->normalize($scope) - ->intersectWith($this->typeSpecifier->create($rightExpr, $rightType, $context, $scope)->setRootExpr($expr)->normalize($scope)); + return $this->typeSpecifier->create($leftExpr, $leftType, $context, $scope)->setRootExpr($expr) + ->intersectWith($this->typeSpecifier->create($rightExpr, $rightType, $context, $scope)->setRootExpr($expr)); } return (new SpecifiedTypes([], []))->setRootExpr($expr); diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index 215c1d6b497..524cad05a6d 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -81,7 +81,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e )->setRootExpr($expr); $nullSafeTypes = $typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope); - return $context->true() ? $types->unionWith($nullSafeTypes) : $types->normalize($scope)->intersectWith($nullSafeTypes->normalize($scope)); + return $context->true() ? $types->unionWith($nullSafeTypes) : $types->intersectWith($nullSafeTypes); } public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index b5d253da2b0..f2d843625b0 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -81,7 +81,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e )->setRootExpr($expr); $nullSafeTypes = $typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope); - return $context->true() ? $types->unionWith($nullSafeTypes) : $types->normalize($scope)->intersectWith($nullSafeTypes->normalize($scope)); + return $context->true() ? $types->unionWith($nullSafeTypes) : $types->intersectWith($nullSafeTypes); } public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 8cc3329b835..fec12d1d88f 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3308,11 +3308,26 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self { $typeSpecifications = ScopeOps::buildTypeSpecifications($specifiedTypes->getSureTypes(), $specifiedTypes->getSureNotTypes()); + foreach ($specifiedTypes->getAlternativeTypes() as $exprString => [$alternativeExpr, $terms]) { + if ( + $alternativeExpr instanceof Node\Scalar + || $alternativeExpr instanceof Expr\Array_ + || ($alternativeExpr instanceof Expr\UnaryMinus && $alternativeExpr->expr instanceof Node\Scalar) + ) { + continue; + } + $typeSpecifications[] = [ + 'sure' => true, + 'exprString' => (string) $exprString, + 'expr' => $alternativeExpr, + 'terms' => $terms, + ]; + } + $scope = $this; $specifiedExpressions = []; foreach ($typeSpecifications as $typeSpecification) { $expr = $typeSpecification['expr']; - $type = $typeSpecification['type']; if ($expr instanceof IssetExpr) { $issetExpr = $expr; @@ -3341,6 +3356,36 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self continue; } + if (isset($typeSpecification['terms'])) { + // an alternative-form entry: the union over its terms of + // `(sure ?? current) minus subtract`, evaluated here at the + // application point - the deferred descendant of the old + // SpecifiedTypes::normalize() + $evaluate = static function (Type $current) use ($typeSpecification): Type { + $parts = []; + foreach ($typeSpecification['terms'] as [$sure, $subtract]) { + $base = $sure ?? $current; + $parts[] = $subtract !== null ? TypeCombinator::remove($base, $subtract) : $base; + } + + return TypeCombinator::union(...$parts); + }; + $originalExprType = $scope->getType($expr); + if (!$scope->isComplexUnionType($originalExprType)) { + $nativeType = $scope->getNativeType($expr); + $scope = $scope->specifyExpressionType( + $expr, + TypeCombinator::intersect($evaluate($originalExprType), $originalExprType), + TypeCombinator::intersect($evaluate($nativeType), $nativeType), + TrinaryLogic::createYes(), + ); + $specifiedExpressions[$typeSpecification['exprString']] = ExpressionTypeHolder::createYes($expr, $scope->getScopeType($expr)); + } + + continue; + } + + $type = $typeSpecification['type']; if ($typeSpecification['sure']) { if ($specifiedTypes->shouldOverwrite()) { $scope = $scope->assignExpression($expr, $type, $type); diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index 5cfa65dc53b..222020de5c5 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -3,6 +3,7 @@ namespace PHPStan\Analyser; use PhpParser\Node\Expr; +use PHPStan\Type\NeverType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use function array_key_exists; @@ -18,6 +19,19 @@ final class SpecifiedTypes private ?Expr $rootExpr = null; + /** + * Alternative-form entries produced by intersectWith() when the two sides + * constrain the same expression with different kinds (a sure type in one + * branch, a sure-not in the other). Each term (sure, subtract) reads as + * `(sure ?? current type) minus subtract`; the entry's value is the union + * of its terms, evaluated by MutatingScope::applySpecifiedTypes() against + * the subject's type at the application point - the deferred form of what + * the old SpecifiedTypes::normalize() computed eagerly with a scope. + * + * @var array}> + */ + private array $alternativeTypes = []; + /** * @api * @param array $sureTypes @@ -50,6 +64,7 @@ public function __construct( public function setAlwaysOverwriteTypes(): self { $self = new self($this->sureTypes, $this->sureNotTypes); + $self->alternativeTypes = $this->alternativeTypes; $self->overwrite = true; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -63,6 +78,7 @@ public function setAlwaysOverwriteTypes(): self public function setRootExpr(?Expr $rootExpr): self { $self = new self($this->sureTypes, $this->sureNotTypes); + $self->alternativeTypes = $this->alternativeTypes; $self->overwrite = $this->overwrite; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $rootExpr; @@ -76,6 +92,7 @@ public function setRootExpr(?Expr $rootExpr): self public function setNewConditionalExpressionHolders(array $newConditionalExpressionHolders): self { $self = new self($this->sureTypes, $this->sureNotTypes); + $self->alternativeTypes = $this->alternativeTypes; $self->overwrite = $this->overwrite; $self->newConditionalExpressionHolders = $newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -101,6 +118,30 @@ public function getSureNotTypes(): array return $this->sureNotTypes; } + /** + * @return array}> + */ + public function getAlternativeTypes(): array + { + return $this->alternativeTypes; + } + + /** + * A copy of this with the other's alternative-form entries - for the + * composition tails that rebuild a SpecifiedTypes from the sure/sure-not + * slots and must not drop the merged alternatives. + */ + public function withAlternativeTypesOf(self $other): self + { + $self = new self($this->sureTypes, $this->sureNotTypes); + $self->alternativeTypes = $other->alternativeTypes; + $self->overwrite = $this->overwrite; + $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; + $self->rootExpr = $this->rootExpr; + + return $self; + } + public function shouldOverwrite(): bool { return $this->overwrite; @@ -123,10 +164,13 @@ public function removeExpr(string $exprString): self { $sureTypes = $this->sureTypes; $sureNotTypes = $this->sureNotTypes; + $alternativeTypes = $this->alternativeTypes; unset($sureTypes[$exprString]); unset($sureNotTypes[$exprString]); + unset($alternativeTypes[$exprString]); $self = new self($sureTypes, $sureNotTypes); + $self->alternativeTypes = $alternativeTypes; $self->overwrite = $this->overwrite; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -134,36 +178,79 @@ public function removeExpr(string $exprString): self return $self; } - /** @api */ + /** + * The either-branch merge: the result holds when at least one side holds + * (the falsey narrowing of `&&`, the truthy narrowing of `||`). Same-kind + * constraints merge exactly (sure: union of values, sure-not: intersection + * of removed types); an expression constrained with different kinds on the + * two sides becomes an alternative-form entry - `(sure ?? current) minus + * subtract` per side, united at the application point. An expression + * constrained on only one side is unconstrained in the merge. + * + * @api + */ public function intersectWith(SpecifiedTypes $other): self { $sureTypeUnion = []; $sureNotTypeUnion = []; + $alternativeUnion = []; $rootExpr = $this->mergeRootExpr($this->rootExpr, $other->rootExpr); - foreach ($this->sureTypes as $exprString => [$exprNode, $type]) { - if (!isset($other->sureTypes[$exprString])) { - continue; + $keys = []; + foreach ([$this->sureTypes, $this->sureNotTypes, $this->alternativeTypes, $other->sureTypes, $other->sureNotTypes, $other->alternativeTypes] as $map) { + foreach ($map as $exprString => $entry) { + $keys[$exprString] = $entry[0]; } - - $sureTypeUnion[$exprString] = [ - $exprNode, - TypeCombinator::union($type, $other->sureTypes[$exprString][1]), - ]; } - foreach ($this->sureNotTypes as $exprString => [$exprNode, $type]) { - if (!isset($other->sureNotTypes[$exprString])) { + foreach ($keys as $exprString => $exprNode) { + $thisTerms = $this->collectTerms($exprString); + $otherTerms = $other->collectTerms($exprString); + if ($thisTerms === null || $otherTerms === null) { + // unconstrained on one side - unconstrained in the merge continue; } - $sureNotTypeUnion[$exprString] = [ - $exprNode, - TypeCombinator::intersect($type, $other->sureNotTypes[$exprString][1]), - ]; + $terms = array_merge($thisTerms, $otherTerms); + $sures = []; + $subtracts = []; + $pureSure = true; + $pureSureNot = true; + foreach ($terms as [$sure, $subtract]) { + if ($sure === null) { + $pureSure = false; + } else { + $sures[] = $sure; + } + if ($subtract === null) { + $pureSureNot = false; + } else { + $subtracts[] = $subtract; + } + if ($sure === null || $subtract === null) { + continue; + } + + $pureSure = false; + $pureSureNot = false; + } + + if ($pureSure) { + $sureTypeUnion[$exprString] = [$exprNode, TypeCombinator::union(...$sures)]; + } elseif ($pureSureNot) { + $merged = TypeCombinator::intersect(...$subtracts); + if ($merged instanceof NeverType) { + // removing never removes nothing - a vacuous constraint + continue; + } + $sureNotTypeUnion[$exprString] = [$exprNode, $merged]; + } else { + $alternativeUnion[$exprString] = [$exprNode, $terms]; + } } $result = new self($sureTypeUnion, $sureNotTypeUnion); + $result->alternativeTypes = $alternativeUnion; if ($this->overwrite && $other->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } @@ -171,6 +258,48 @@ public function intersectWith(SpecifiedTypes $other): self return $result->setRootExpr($rootExpr); } + /** + * This side's constraint on the expression as alternative-form terms, or + * null when unconstrained. A sure and a sure-not on the same key are one + * term (the sure with the sure-not removed) - both constraints hold here. + * + * @return list|null + */ + private function collectTerms(string|int $exprString): ?array + { + if (isset($this->alternativeTypes[$exprString])) { + $terms = $this->alternativeTypes[$exprString][1]; + // sure/sureNot on the same key as an alternative entry: fold them + // into every term (they hold in addition to the alternatives) + if (isset($this->sureTypes[$exprString]) || isset($this->sureNotTypes[$exprString])) { + $extraSure = $this->sureTypes[$exprString][1] ?? null; + $extraSubtract = $this->sureNotTypes[$exprString][1] ?? null; + $folded = []; + foreach ($terms as [$sure, $subtract]) { + if ($extraSure !== null) { + $sure = $sure === null ? $extraSure : TypeCombinator::intersect($sure, $extraSure); + } + if ($extraSubtract !== null) { + $subtract = $subtract === null ? $extraSubtract : TypeCombinator::union($subtract, $extraSubtract); + } + $folded[] = [$sure, $subtract]; + } + + return $folded; + } + + return $terms; + } + + $sure = $this->sureTypes[$exprString][1] ?? null; + $subtract = $this->sureNotTypes[$exprString][1] ?? null; + if ($sure === null && $subtract === null) { + return null; + } + + return [[$sure, $subtract]]; + } + /** @api */ public function unionWith(SpecifiedTypes $other): self { @@ -201,6 +330,7 @@ public function unionWith(SpecifiedTypes $other): self } $result = new self($sureTypeUnion, $sureNotTypeUnion); + $result->alternativeTypes = $this->alternativeTypes + $other->alternativeTypes; if ($this->overwrite || $other->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } @@ -218,27 +348,6 @@ public function unionWith(SpecifiedTypes $other): self return $result->setRootExpr($rootExpr); } - public function normalize(Scope $scope): self - { - $sureTypes = $this->sureTypes; - - foreach ($this->sureNotTypes as $exprString => [$exprNode, $sureNotType]) { - if (!isset($sureTypes[$exprString])) { - $sureTypes[$exprString] = [$exprNode, TypeCombinator::remove($scope->getType($exprNode), $sureNotType)]; - continue; - } - - $sureTypes[$exprString][1] = TypeCombinator::remove($sureTypes[$exprString][1], $sureNotType); - } - - $result = new self($sureTypes, []); - if ($this->overwrite) { - $result = $result->setAlwaysOverwriteTypes(); - } - - return $result->setRootExpr($this->rootExpr); - } - private function mergeRootExpr(?Expr $rootExprA, ?Expr $rootExprB): ?Expr { if ($rootExprA === $rootExprB) { diff --git a/src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php b/src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php index d6e3f0e3587..ad857c7086f 100644 --- a/src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php @@ -91,7 +91,7 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n } $combinedMultipleItems = true; - $types = $context->true() ? $types->normalize($scope)->intersectWith($itemTypes->normalize($scope)) : $types->unionWith($itemTypes); + $types = $context->true() ? $types->intersectWith($itemTypes) : $types->unionWith($itemTypes); } if ($types !== null) { diff --git a/tests/PHPStan/Analyser/TypeSpecifierTest.php b/tests/PHPStan/Analyser/TypeSpecifierTest.php index 50ac5274f3a..fc7388b3526 100644 --- a/tests/PHPStan/Analyser/TypeSpecifierTest.php +++ b/tests/PHPStan/Analyser/TypeSpecifierTest.php @@ -34,6 +34,7 @@ use PHPStan\Type\ObjectType; use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\StringType; +use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; use PHPUnit\Framework\Attributes\DataProvider; @@ -377,7 +378,7 @@ public static function dataCondition(): iterable self::createFunctionCall('random'), ), [], - ['$foo' => 'mixed'], + [], ], [ new Expr\BinaryOp\BooleanOr( @@ -409,7 +410,7 @@ public static function dataCondition(): iterable ), self::createFunctionCall('random'), ), - ['$foo' => 'mixed'], + [], [], ], @@ -1195,7 +1196,7 @@ public static function dataCondition(): iterable new Identical(new Expr\ConstFetch(new Name('null')), new Variable('a')), ), ['$a' => 'non-empty-string|null'], - ['$a' => 'mixed~non-empty-string & ~null'], + ['$a' => '~null & mixed~non-empty-string'], ], [ new Expr\BinaryOp\BooleanOr( @@ -1209,7 +1210,7 @@ public static function dataCondition(): iterable new Identical(new Expr\ConstFetch(new Name('null')), new Variable('a')), ), ['$a' => 'non-empty-string|null'], - ['$a' => 'mixed~non-empty-string & ~null'], + ['$a' => '~non-empty-string|null'], ], [ new Expr\BinaryOp\BooleanOr( @@ -1223,7 +1224,7 @@ public static function dataCondition(): iterable new Identical(new Expr\ConstFetch(new Name('null')), new Variable('a')), ), ['$a' => 'non-empty-array|null'], - ['$a' => 'mixed~non-empty-array & ~null'], + ['$a' => '~non-empty-array|null'], ], [ new Expr\BinaryOp\BooleanAnd( @@ -1341,6 +1342,18 @@ private function toReadableResult(SpecifiedTypes $specifiedTypes): array $typesDescription[$exprString][] = '~' . $exprType->describe(VerbosityLevel::precise()); } + foreach ($specifiedTypes->getAlternativeTypes() as $exprString => [$exprNode, $terms]) { + // evaluate the alternative-form entry against the test scope, the + // same way filterBySpecifiedTypes() evaluates it at the application + // point - the readable result matches the old eager normalize form + $parts = []; + foreach ($terms as [$sure, $subtract]) { + $base = $sure ?? $this->scope->getType($exprNode); + $parts[] = $subtract !== null ? TypeCombinator::remove($base, $subtract) : $base; + } + $typesDescription[$exprString][] = TypeCombinator::union(...$parts)->describe(VerbosityLevel::precise()); + } + $descriptions = []; foreach ($typesDescription as $exprString => $exprTypes) { $descriptions[$exprString] = implode(' & ', $exprTypes); diff --git a/tests/PHPStan/Analyser/nsrt/bug-3991.php b/tests/PHPStan/Analyser/nsrt/bug-3991.php index e1a79f6937d..e42d47fa024 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-3991.php +++ b/tests/PHPStan/Analyser/nsrt/bug-3991.php @@ -18,11 +18,7 @@ public static function email($config = null) assertType('array|stdClass|null', $config); if (empty($config)) { - // the native type should be `0|0.0|''|'0'|array{}|false|null` - // the problem is that `empty($config)` translates to `!isset($config) || !$config` - // and before specified types of the left and right side are intersected they are "normalized" - // by removing the sureNotType from $scope->getType() which is `stdClass|array|null` here - assertNativeType('array{}|null', $config); + assertNativeType('0|0.0|\'\'|\'0\'|array{}|false|null', $config); assertType('array{}|null', $config); $config = new \stdClass(); } elseif (! (is_array($config) || $config instanceof \stdClass)) { From fe0060d0c2a6b214930ed3dc8bb1d8efa9ceb4b1 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 28 Jul 2026 18:28:02 +0200 Subject: [PATCH 2/2] Cover the true()-gated merge combinations with TypeSpecifierTest rows Infection flagged the $context->true() gates in the in_array item combiner and the nullsafe fallback merge as escaped true()->truthy() mutants. The gates only diverge under negated masks like negate(createTrue()) - the "not exactly true" context produced by !== true comparisons - which no row exercised. New rows cover the plain truthy/falsey narrowing of both shapes and the !== true form. The in_array !== true row kills the mutant: under truthy() the combiner would take the positive either-branch merge for a negative context and lose '$string' => ~'bar'|'foo'. The nullsafe mutant is behaviorally equivalent - both merge arms coincide for every constructible input - so its rows pin the current sound behavior without being able to kill it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019wqGgaD7iqL44t1KgpJS7b --- tests/PHPStan/Analyser/TypeSpecifierTest.php | 47 ++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/PHPStan/Analyser/TypeSpecifierTest.php b/tests/PHPStan/Analyser/TypeSpecifierTest.php index fc7388b3526..0d011b0b0b9 100644 --- a/tests/PHPStan/Analyser/TypeSpecifierTest.php +++ b/tests/PHPStan/Analyser/TypeSpecifierTest.php @@ -3,6 +3,7 @@ namespace PHPStan\Analyser; use Override; +use PhpParser\Node; use PhpParser\Node\Arg; use PhpParser\Node\Expr; use PhpParser\Node\Expr\BinaryOp\Equal; @@ -1324,6 +1325,52 @@ public static function dataCondition(): iterable ], [], ], + [ + new Expr\NullsafeMethodCall(new Variable('fooOrNull'), new Identifier('doFoo')), + ['$fooOrNull' => '~null'], + [], + ], + [ + new FuncCall(new Name('in_array'), [ + new Arg(new Variable('string')), + new Arg(new Expr\Array_([ + new Node\ArrayItem(new String_('foo')), + new Node\ArrayItem(new String_('bar')), + ])), + new Arg(new ConstFetch(new Name('true'))), + ]), + ['$string' => '\'bar\'|\'foo\''], + ['$string' => '~\'bar\'|\'foo\''], + ], + [ + new NotIdentical( + new Expr\NullsafeMethodCall(new Variable('fooOrNull'), new Identifier('doFoo')), + new ConstFetch(new Name('true')), + ), + ['$fooOrNull?->doFoo()' => '~true'], + ['$fooOrNull' => '~null'], + ], + [ + new NotIdentical( + new FuncCall(new Name('in_array'), [ + new Arg(new Variable('string')), + new Arg(new Expr\Array_([ + new Node\ArrayItem(new String_('foo')), + new Node\ArrayItem(new String_('bar')), + ])), + new Arg(new ConstFetch(new Name('true'))), + ]), + new ConstFetch(new Name('true')), + ), + [ + 'in_array($string, [\'foo\', \'bar\'], true)' => '~true', + '$string' => '~\'bar\'|\'foo\'', + ], + [ + 'in_array($string, [\'foo\', \'bar\'], true)' => 'true', + '$string' => '\'bar\'|\'foo\'', + ], + ], ]; }