Project tagged-union narrowings through OR and foreach destructure#5531
Merged
Conversation
`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) <[email protected]>
`foreach ($a as [$x, $y, …])` over an iterable whose value type is a
tagged union of constant arrays (e.g. `array<array{null, int}|array{int,
null}>`) 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) <[email protected]>
3316920 to
79a31cf
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two related precision improvements that build on the existing
conditional-expression-holder machinery. They share a worktree but are
independent — the second commit doesn't depend on the first.
1. Project stored-boolean narrowings through
||truthyif (A || B)truthy whereAandBare stored booleans(
\$isA = \$obj instanceof ClassA;/\$isB = \$obj instanceof ClassB;)left the held narrowing of
\$objinvisible inside the OR — each arm'sspecifyTypesInConditiononly narrows the boolean variable itself, so\$objstayedmixeduntil a nested check pinned one of the booleansdown.
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.
Gated so that certainty is preserved (e.g.
if (empty(\$a['bar']))correctly leaves
\$aMaybe-defined).2. Track destructure relationships across tagged-union foreach
foreach (\$a as [\$x, \$y, …])over an iterable whose value type is atagged union of constant arrays —
array<array{null, int}|array{int, null}>,array<array{tag: 'a', data: int}|array{tag: 'b', data: string}>, etc. — used to lose the per-variant link between thedestructured variables: each one became the position-wise union
(`int|null`) and
if (\$x === null)no longer narrowed\$y.In
enterForeachwe now register conditional-expression holders oneach destructured variable: per 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 fires the
matching holder. Handles flat positional and string/int-keyed
List_patterns; nested destructure falls back to per-variable tracking. The
holders integrate with the existing reassignment invalidation, so
\$x = null;inside the body severs the binding for\$xonly.Test plan
or-of-stored-booleans-narrowing.phpcovers two-/three-wayinstanceof OR, plus a mixed-narrowing-kind variant where only one
arm narrows each target.
foreach-destructure-tagged-union.phpcovers two- andthree-variant unions with positional, named-key (
'tag'/'data'), and constant-int discriminators, plus a single-variantno-op case and a reassignment-invalidation case.
tests/PHPStan/Analyser/NodeScopeResolverTest.php: 1542/1542.tests/PHPStan/Analyser/+tests/PHPStan/Rules/+tests/PHPStan/Type/: 8231 tests, 59 pre-existing skips, 0 failures.make phpstan: green.additive.
🤖 Generated with Claude Code
Closes phpstan/phpstan#9519