Pin the boolean value of a whole &&/|| condition into its truthy/falsey scopes#5937
Pin the boolean value of a whole &&/|| condition into its truthy/falsey scopes#5937phpstan-bot wants to merge 3 commits into
&&/|| condition into its truthy/falsey scopes#5937Conversation
…falsey scopes
- Add `ConditionalExpressionHolderHelper::specifyWholeExpressionType()`, which
pins a compound condition's boolean value (`true` in the truthy branch,
`false` in the falsey branch) into a scope and re-runs the conditional
expression resolution, without re-narrowing the operands.
- Wrap the truthy/falsey scope callbacks of `BooleanOrHandler` and
`BooleanAndHandler` with that helper so the whole `||`/`&&` expression is
stored in both branch scopes.
- This makes the merge after an `if` create a conditional-expression guard keyed
on the whole condition (the single-variable case `if ($cond) { $x = 1; }`
already worked because the narrowed variable became the guard; a compound
condition narrowed no single variable, so no guard existed). A later identical
`if ($cond || $f)` then re-derives the same narrowing and variable definedness.
- The operand-narrowed scope is reused rather than re-filtering the whole
expression, so side effects such as `($foo = intval($foo)) < 1` inside the
condition are preserved.
- Covered analogous cases: `&&`, word operators (`and`/`or`), nested compound
conditions `($a && $b) || $c`, comparison operands `$a > 0 || $b > 0`, and
carrying actual type narrowing (not just definedness) across the second `if`.
- Negated conditions (`!($a || $b)`) and elseif-chain definedness are left as
separate pre-existing limitations: they route through `BooleanNot` /
incremental branch merges rather than the operator's `processExpr` callbacks,
and they already fail for single-variable conditions.
There was a problem hiding this comment.
the snippet https://phpstan.org/r/aab74e73-2bfe-432b-8bcd-f9b939d2eaab still produces errors with this PR, while it should not contain any "Variable ... might not be defined." error
Repeating an identical `if`/`elseif`/`else` chain only re-derived definedness for the first branch; later branches (`elseif`, `else`) still reported the variables assigned there as possibly undefined. This held for both compound (`$a || $b`) and single-variable conditions. A non-first branch is selected by a path condition spanning several conditions (`!prevCond && thisCond`). The scope merge only recorded single-guard conditional holders, and a single guard of that path also matches a sibling branch, so it was dropped when that sibling merged in. Record a multi-guard holder keyed on the whole path condition (kept alive by the vacuous-conditional preservation because one of its guards contradicts the sibling branch), and let such a holder take guards that are already definite in scope but not re-specified by the condition currently being applied, as long as one guard is actively specified. Single-guard holders keep their existing behaviour. Multi-guard targets are restricted to plain variables whose definedness the branch establishes, so condition variables and the narrowing recorded for compound boolean operands are left untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Done. Here's a summary of how I addressed the review. The reviewstaabb pointed out that the issue's "another example" (playground snippet) still produced if ($rel || $overwrite) { $vvv = 1; }
elseif ($document) { $aaa = 2; }
else { $eee = 3; }
if ($rel || $overwrite) { var_dump($vvv); } // worked already (the original PR)
elseif ($document) { var_dump($aaa); } // $aaa "might not be defined"
else { var_dump($eee); } // $eee "might not be defined"Root causeThe original PR fixed only the first branch. I traced the failure: a non-first branch is selected by a path condition ( The fix (
|
Summary
PHPStan failed to carry information across two identical compound conditions. Given
it reported
$x might not be defined, even though the secondifhas the exactsame condition as the first and therefore guarantees
$xwas assigned.The single-variable form (
if ($cond) { $x = 1; } if ($cond) { echo $x; })already worked. This change extends the same reasoning to compound
&&/||conditions.
Changes
src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php: newspecifyWholeExpressionType()method that pins a compound condition's booleanvalue (
truein the truthy branch,falsein the falsey branch) into a scopeand re-runs the conditional-expression resolution, without re-narrowing the
operands.
src/Analyser/ExprHandler/BooleanOrHandler.phpandsrc/Analyser/ExprHandler/BooleanAndHandler.php: wrap thetruthyScopeCallback/
falseyScopeCallbackreturned fromprocessExpr()with that helper so thewhole
||/&&expression's boolean value is stored in both branch scopes.Root cause
The conditional-expression machinery records guards from variables whose type
differs between the two merged branches of an
if. Forif ($cond) { $x = 1; },$condis narrowed totruein the body andfalseafterwards, so it becomes aguard and the merge records "when
$condis true,$xis1". A laterif ($cond)specifies$condtotrue, fires the guard, and$xis knowndefined.
A compound condition like
$cond || $fnarrows no single variable to a definitevalue (either operand could be the truthy one), so the merge had nothing to guard
on and produced no conditional expression at all. The fix stores the whole
condition expression's boolean value in the truthy and falsey scopes, giving the
merge a guard keyed on the entire condition. Because the guard's boolean value is
also re-specified through
filterBySpecifiedTypes(), the resolution runs and thelater identical
ifre-derives both the variable definedness and any typenarrowing.
Storing the value via
filterBySpecifiedTypes()on the already operand-narrowedscope (instead of re-filtering the whole expression) is what keeps condition side
effects intact — e.g.
if (!ctype_digit($foo) || ($foo = intval($foo)) < 1)muststill see
$fooasint<1, max>afterwards.The two analogous constructs that are not fixed here, and why:
!($a || $b)):BooleanNotcomputes its scopes viaspecifyTypesInConditionon the whole negated expression rather than throughthe inner operator's
processExprcallbacks, so the pin never runs.elseifchains: this depends on capturing the negation ofprior branches as multi-guards through incremental branch merges.
Both already fail for the single-variable case, so they are pre-existing
limitations independent of compound conditions and out of scope for this change.
Test
tests/PHPStan/Analyser/nsrt/bug-14871.phpasserts (withassertVariableCertaintyand
assertType):||case and the&&mirror;$cond or $f);($a && $b) || $c;$a > 0 || $b > 0;'set') across the second identicalif;if (!ctype_digit($foo) || ($foo = intval($foo)) < 1)still narrows
$footoint<1, max>), proving the pin does not re-run theassignment.
The test fails before the fix (the variables are
Maybe-defined) and passes after.The full test suite, PHPStan self-analysis (
make phpstan), and theTypeSpecifierTest/NodeScopeResolverTestsuites pass.Fixes phpstan/phpstan#14871