From 3c59cc787ca1b606ec72dd314a0d441570505ecb Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 25 Apr 2026 12:44:52 +0200 Subject: [PATCH 1/2] Project stored-boolean narrowings through `||` truthy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `if (A || B)` truthy where `A` and `B` are booleans backed by stored conditional-expression holders (e.g. `$isA = $obj instanceof ClassA; $isB = $obj instanceof ClassB;`) needs to apply the held narrowings of the *target* (`$obj`) to the OR-truthy scope as the union of the per-arm narrowings. specifyTypesInCondition for each arm only sees the boolean variable itself, so the `$obj` narrowing was invisible until a later inner check pinned one of the booleans down. For each conditional-holder target we now resolve its type in the left-truthy and right-truthy filtered scopes, and when both narrow it strictly below the original we add a sure type carrying the union to the OR-truthy result. Gated to keep certainty unchanged: only project when the target stays Yes-defined in all three scopes — `if (empty($a['bar']))` for instance must leave `$a` Maybe-defined because `empty()` tolerates undefined offsets. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Analyser/MutatingScope.php | 8 +++ src/Analyser/TypeSpecifier.php | 77 ++++++++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-9519.php | 64 ++++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-9519.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index a0514c7b206..79d4be017bf 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3348,6 +3348,14 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self ); } + /** + * @return array + */ + public function getConditionalExpressions(): array + { + return $this->conditionalExpressions; + } + /** * @param ConditionalExpressionHolder[] $conditionalExpressionHolders */ diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 31e54152c67..9889f91a046 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -82,6 +82,7 @@ use PHPStan\Type\TypeTraverser; use PHPStan\Type\UnionType; use function array_key_exists; +use function array_key_first; use function array_last; use function array_map; use function array_merge; @@ -783,6 +784,7 @@ public function specifyTypesInCondition( $types = $leftTypes->normalize($scope); } else { $types = $leftTypes->normalize($scope)->intersectWith($rightTypes->normalize($rightScope)); + $types = $this->augmentBooleanOrTruthyWithConditionalHolders($scope, $rightScope, $expr, $types); } } else { $types = $leftTypes->unionWith($rightTypes); @@ -1941,6 +1943,81 @@ private function specifyTypesFromCallableCall(TypeSpecifierContext $context, Fun return $this->specifyTypesFromAsserts($context, $call, $asserts, $parametersAcceptor, $scope); } + /** + * For `if ($a || $b)` truthy, expressions narrowed by stored conditional + * holders (e.g. `$a = $obj instanceof ClassA;` records "when `$a` is + * truthy, `$obj` is `ClassA`") need to be projected into the OR-truthy + * scope as the union of the per-arm narrowings. specifyTypesInCondition + * for each arm only looks at the boolean variable itself, so the held + * narrowing of `$obj` would otherwise be invisible until a later check + * pins one of the booleans down. + * + * For each conditional-holder target $T: + * - resolve $T's type in the left-truthy and right-truthy filtered scopes + * - if both narrow $T strictly below the original, add `$T : leftT|rightT` + * as a sure type to the OR-truthy result + * + * The asymmetric case (one arm narrows, the other doesn't) is intentionally + * skipped: in the OR-truthy scope the arm that didn't narrow could still be + * the truthy one, so the sound result is the original (unnarrowed) type. + */ + private function augmentBooleanOrTruthyWithConditionalHolders(MutatingScope $scope, MutatingScope $rightScope, BooleanOr|LogicalOr $expr, SpecifiedTypes $types): SpecifiedTypes + { + $leftTruthyScope = $scope->filterByTruthyValue($expr->left); + $rightTruthyScope = $rightScope->filterByTruthyValue($expr->right); + + $seen = []; + foreach ([$scope, $rightScope] as $sourceScope) { + foreach ($sourceScope->getConditionalExpressions() as $exprString => $holders) { + if (isset($seen[$exprString])) { + continue; + } + if ($holders === []) { + continue; + } + $seen[$exprString] = true; + $targetExpr = $holders[array_key_first($holders)]->getTypeHolder()->getExpr(); + + // Only project when the target stays Yes-defined in the original + // scope and in both filtered branches. A sure type implicitly + // raises certainty to Yes, which would wrongly upgrade Maybe-defined + // variables — `if (empty($a['bar']))` for instance leaves `$a` + // Maybe-defined because `empty()` tolerates undefined offsets. + if (!$scope->hasExpressionType($targetExpr)->yes()) { + continue; + } + if (!$leftTruthyScope->hasExpressionType($targetExpr)->yes()) { + continue; + } + if (!$rightTruthyScope->hasExpressionType($targetExpr)->yes()) { + continue; + } + + $origType = $scope->getType($targetExpr); + $leftType = $leftTruthyScope->getType($targetExpr); + $rightType = $rightTruthyScope->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/nsrt/bug-9519.php b/tests/PHPStan/Analyser/nsrt/bug-9519.php new file mode 100644 index 00000000000..54b6ad02bb6 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-9519.php @@ -0,0 +1,64 @@ + Date: Sat, 25 Apr 2026 12:45:09 +0200 Subject: [PATCH 2/2] Track destructure relationships across tagged-union foreach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `foreach ($a as [$x, $y, …])` over an iterable whose value type is a tagged union of constant arrays (e.g. `array`) loses the per-variant link between the destructured variables: each one ends up as the position's union (`int|null`) and `if ($x === null)` doesn't narrow `$y` even though every variant pairs the two. Recover the link in `enterForeach` by registering conditional-expression holders on each destructured variable: for every variant, "when this variable matches the variant's value at its position, the *other* variables match the variant's values at their positions". A later narrowing on any one of them then fires the matching holder and pins the others to the corresponding variant's values. Handles flat positional and string-/int-keyed `List_` patterns where each item targets a plain Variable; nested destructure falls back to the existing per-variable type tracking. The held conditions integrate with the existing reassignment invalidation, so `$x = null;` inside the body severs the binding for `$x` only. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Analyser/NodeScopeResolver.php | 119 ++++++++++++++++ .../nsrt/foreach-destructure-tagged-union.php | 133 ++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/foreach-destructure-tagged-union.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index bd17d957c27..89a65fca313 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -141,6 +141,8 @@ use PHPStan\TrinaryLogic; use PHPStan\Type\ArrayType; use PHPStan\Type\ClosureType; +use PHPStan\Type\Constant\ConstantIntegerType; +use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\FileTypeMapper; use PHPStan\Type\Generic\TemplateTypeHelper; use PHPStan\Type\Generic\TemplateTypeMap; @@ -4062,6 +4064,14 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto )->getScope(); $vars = array_merge($vars, $this->getAssignedVariables($stmt->keyVar)); } + + if ($stmt->valueVar instanceof List_) { + $scope = $this->addDestructureTaggedUnionConditionalHolders( + $scope, + $originalScope->getIterableValueType($iterateeType), + $stmt->valueVar, + ); + } } $constantArrays = $iterateeType->getConstantArrays(); @@ -4120,6 +4130,115 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto return $this->processVarAnnotation($scope, $vars, $stmt); } + /** + * When destructuring an iterable whose value type is a tagged union of + * constant arrays — e.g. `array` — the + * variants describe a relationship between the destructured variables that + * a per-variable narrowing would normally lose: knowing `$x === null` should + * imply `$y === int`, but `foreach ($a as [$x, $y])` assigns `$x` and `$y` + * independently, so each ends up as the union (`int|null`) and the link is + * dropped. + * + * Recover the link by storing conditional-expression holders on each + * destructured variable: for every variant, "when this variable matches the + * variant's value at its position, the other variables match the variant's + * values at their positions". A later `if ($x === null)` then fires the + * matching holder and narrows `$y` accordingly. + * + * Only handles flat positional / keyed destructure patterns (List_) where + * each item's target is a plain Variable; nested destructure is left for + * the regular per-variable type tracking. + */ + private function addDestructureTaggedUnionConditionalHolders( + MutatingScope $scope, + Type $iterableValueType, + List_ $list, + ): MutatingScope + { + $constantArrays = $iterableValueType->getConstantArrays(); + if (count($constantArrays) < 2) { + return $scope; + } + + // Collect each list item's array-key value and target variable. + $items = []; + foreach ($list->items as $position => $item) { + if ($item === null) { + continue; + } + if (!$item->value instanceof Variable || !is_string($item->value->name)) { + return $scope; + } + if ($item->key === null) { + $keyValue = $position; + } elseif ($item->key instanceof Node\Scalar\String_) { + $keyValue = $item->key->value; + } elseif ($item->key instanceof Node\Scalar\Int_) { + $keyValue = $item->key->value; + } else { + return $scope; + } + $items[] = ['key' => $keyValue, 'name' => $item->value->name]; + } + + if (count($items) < 2) { + return $scope; + } + + // For every variant, every item must have a matching key with a single + // value type at it; otherwise the variants don't all describe the same + // destructure shape and we can't form a sound holder set. + $variantValuesByItem = []; + foreach ($items as $itemIdx => $itemInfo) { + $variantValuesByItem[$itemIdx] = []; + foreach ($constantArrays as $variantIdx => $variant) { + $keyType = is_int($itemInfo['key']) ? new ConstantIntegerType($itemInfo['key']) : new ConstantStringType($itemInfo['key']); + if (!$variant->hasOffsetValueType($keyType)->yes()) { + return $scope; + } + $variantValuesByItem[$itemIdx][$variantIdx] = $variant->getOffsetValueType($keyType); + } + } + + // For each item × variant, build a holder: "when item is variant's value + // at this position, the *other* items are the variant's values at their + // positions". Skip the variant if the condition value is too wide to be + // a useful discriminator (i.e. equal to the union of all the variant + // values at this position — narrowing it back wouldn't pick a variant). + foreach ($items as $itemIdx => $itemInfo) { + $exprString = '$' . $itemInfo['name']; + $variantConditionTypes = $variantValuesByItem[$itemIdx]; + $itemUnionType = TypeCombinator::union(...array_values($variantConditionTypes)); + $holders = []; + foreach (array_keys($constantArrays) as $variantIdx) { + $conditionType = $variantConditionTypes[$variantIdx]; + if ($conditionType->equals($itemUnionType)) { + continue; + } + $conditions = [ + $exprString => ExpressionTypeHolder::createYes(new Variable($itemInfo['name']), $conditionType), + ]; + foreach ($items as $otherIdx => $otherInfo) { + if ($otherIdx === $itemIdx) { + continue; + } + $otherType = $variantValuesByItem[$otherIdx][$variantIdx]; + $holder = new ConditionalExpressionHolder( + $conditions, + ExpressionTypeHolder::createYes(new Variable($otherInfo['name']), $otherType), + ); + $holders['$' . $otherInfo['name']][$holder->getKey()] = $holder; + } + } + + foreach ($holders as $targetExprString => $targetHolders) { + $scope = $scope->addConditionalExpressions($targetExprString, $targetHolders); + } + } + + return $scope; + } + /** * @param callable(Node $node, Scope $scope): void $nodeCallback */ diff --git a/tests/PHPStan/Analyser/nsrt/foreach-destructure-tagged-union.php b/tests/PHPStan/Analyser/nsrt/foreach-destructure-tagged-union.php new file mode 100644 index 00000000000..db9e7fe806b --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/foreach-destructure-tagged-union.php @@ -0,0 +1,133 @@ + $list + */ +function basicTwoVariants(array $list): void +{ + foreach ($list as [$x, $y]) { + assertType('int|null', $x); + assertType('int|null', $y); + + if ($x === null) { + assertType('null', $x); + assertType('int', $y); + } + if ($x !== null) { + assertType('int', $x); + assertType('null', $y); + } + if ($y === null) { + assertType('int', $x); + assertType('null', $y); + } + if ($y !== null) { + assertType('null', $x); + assertType('int', $y); + } + } +} + +/** + * @param list $list + */ +function classDiscriminator(array $list): void +{ + foreach ($list as [$obj, $value]) { + if ($obj instanceof A) { + assertType('int', $value); + } + if ($obj instanceof B) { + assertType('string', $value); + } + } +} + +/** + * @param list $list + */ +function threeVariants(array $list): void +{ + foreach ($list as [$tag, $value]) { + if ($tag === 0) { + assertType('int', $value); + } + if ($tag === 1) { + assertType('string', $value); + } + if ($tag === 2) { + assertType('bool', $value); + } + } +} + +/** + * @param list $list + */ +function namedKeyDiscriminator(array $list): void +{ + foreach ($list as ['tag' => $tag, 'data' => $data]) { + if ($tag === 'a') { + assertType('int', $data); + } + if ($tag === 'b') { + assertType('string', $data); + } + } +} + +/** + * Single-variant array — no tagged union, the per-variable narrowing applies + * as before and the destructure-aware logic must be a no-op. + * + * @param list $list + */ +function singleVariant(array $list): void +{ + foreach ($list as [$x, $y]) { + assertType('int', $x); + assertType('string', $y); + } +} + +/** + * Reassigning a destructured variable severs the destructure relationship + * for that variable (PHPStan's existing invalidation handles this). + * + * @param list $list + */ +function reassignmentInvalidates(array $list): void +{ + foreach ($list as [$x, $y]) { + $x = null; + // $y is still int|null — the holder for $x was invalidated by reassignment. + assertType('int|null', $y); + } +} + +/** + * Three-position tagged union — narrowing one position should pin the other + * two for the matching variant. + * + * @param list $list + */ +function threePositions(array $list): void +{ + foreach ($list as [$tag, $value, $flag]) { + if ($tag === 'a') { + assertType('int', $value); + assertType('true', $flag); + } + if ($tag === 'b') { + assertType('string', $value); + assertType('false', $flag); + } + } +}