Create cross-kind conditional expression holders in BooleanAnd/BooleanOr type specifier with truthy fallback for isset()#5760
Conversation
…leanOr` type specifier with truthy fallback for `isset()`
- Add `processBooleanNotSureWithSureConditionalTypes()` which builds conditions
from sureNotTypes and holders from sureTypes (cross-kind pairing)
- Add `processBooleanSureWithNotSureConditionalTypes()` which builds conditions
from sureTypes and holders from sureNotTypes (reverse cross-kind pairing)
- Call both new functions (with both argument orderings) in BooleanAnd falsey
and BooleanOr truthy conditional holder creation
- Add truthy fallback: when left/right falsey types produce empty SpecifiedTypes
(e.g. isset() on non-constant array dim fetch), recompute in truthy context
and swap sureTypes/sureNotTypes to derive conditions for holders
- Guard the truthy fallback with `allExpressionsTrackable()` to prevent
over-narrowing when non-trackable expressions (method calls) are involved
- Fixes patterns like `if ($a && !is_string($y)) { throw; }` followed by
`if ($a) { /* $y is now string */ }` and
`if (isset($data['x']) && !is_string($data['x'])) { throw; }` followed by
`$data['x'] ?? ''` being correctly inferred as string
VincentLanglet
left a comment
There was a problem hiding this comment.
Is there some refacto with processBooleanNotSureConditionalTypes and processBooleanSureConditionalTypes methods ?
Consolidate processBooleanSureConditionalTypes, processBooleanNotSureConditionalTypes, processBooleanNotSureWithSureConditionalTypes, and processBooleanSureWithNotSureConditionalTypes into a single processBooleanConditionalTypes method parameterized by whether conditions/holders come from sure or not-sure types. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Pushed. The refactoring unifies the four methods ( |
| $this->processBooleanNotSureConditionalTypes($scope, $rightTypes, $leftTypes, $scope), | ||
| $this->processBooleanSureConditionalTypes($scope, $leftTypes, $rightTypes, $rightScope), | ||
| $this->processBooleanSureConditionalTypes($scope, $rightTypes, $leftTypes, $scope), | ||
| $this->processBooleanConditionalTypes($scope, $leftTypes->getSureNotTypes(), false, $rightTypes->getSureNotTypes(), false, $rightScope), |
There was a problem hiding this comment.
Seems like everytime we call getSure we pass true and everytime we call getSureNotType we pass false ;
Maybe we could improve the refactor ?
There was a problem hiding this comment.
Pushed. The refactoring moves the getSureTypes()/getSureNotTypes() extraction into processBooleanConditionalTypes itself, so the call sites just pass SpecifiedTypes objects and the boolean flag — the method picks the right getter internally. This eliminates the redundancy VincentLanglet pointed out where every getSureTypes() call was always paired with true and every getSureNotTypes() with false.
…extracted arrays The boolean flags always matched which getter was called (true → getSureTypes, false → getSureNotTypes), so move the extraction into the method itself. This removes the redundancy the reviewer pointed out and makes the call sites shorter and harder to get wrong. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| * @return array<string, ConditionalExpressionHolder[]> | ||
| */ | ||
| private function processBooleanConditionalTypes(Scope $scope, array $conditionTypes, bool $conditionsAreSure, array $holderTypes, bool $holdersAreSure, Scope $rightScope): array | ||
| private function processBooleanConditionalTypes(Scope $scope, SpecifiedTypes $conditionSpecifiedTypes, bool $conditionsAreSure, SpecifiedTypes $holderSpecifiedTypes, bool $holdersAreSure, Scope $rightScope): array |
There was a problem hiding this comment.
is conditionsAreSure and holdersAreSure the right name now ?
There was a problem hiding this comment.
Renamed $conditionsAreSure/$holdersAreSure to $conditionsFromSureTypes/$holdersFromSureTypes in processBooleanConditionalTypes — the new names clarify that the boolean selects which getter (getSureTypes() vs getSureNotTypes()) is called internally. Tests and static analysis both pass.
…ldersFromSureTypes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
After
if (isset($data['key']) && !is_string($data['key'])) { throw ...; }, PHPStan did not narrow$data['key']tostringwhen subsequently accessed viaisset()check or??operator. The root cause was that theBooleanAndfalsey handler only created conditional expression holders when both left and right arms had the same "kind" of type specification (both sureTypes or both sureNotTypes), but common patterns produce mismatched kinds.Changes
processBooleanNotSureWithSureConditionalTypes()insrc/Analyser/TypeSpecifier.php— creates conditional holders using conditions fromsureNotTypes(left arm) and holders fromsureTypes(right arm)processBooleanSureWithNotSureConditionalTypes()insrc/Analyser/TypeSpecifier.php— creates conditional holders using conditions fromsureTypes(left arm) and holders fromsureNotTypes(right arm)BooleanAndfalsey handler and theBooleanOrtruthy handlerSpecifiedTypesin falsey context (asisset()does for non-constant array dim fetch), the code recomputes in truthy context and swaps sureTypes/sureNotTypes to derive usable conditionsallExpressionsTrackable()guard to prevent the truthy fallback from creating incomplete conditions when non-trackable expressions (method calls) are involved!== nullcondition,instanceofcondition,isset()with??coalesce, multiple keys, andarray_key_exists()Root cause
The
TypeSpecifiercreates conditional expression holders in the falsey branch ofBooleanAnd(and truthy branch ofBooleanOr) to encode relationships like "if A is true, then B has type X". These holders are created byprocessBooleanSureConditionalTypes(conditions from sureTypes, holders from sureTypes) andprocessBooleanNotSureConditionalTypes(conditions from sureNotTypes, holders from sureNotTypes).For
$a && !is_string($y)in falsey context:$afalsey): produces sureNotTypes{$a → truthy()}!is_string($y)falsey =is_string($y)truthy): produces sureTypes{$y → StringType}Since left has sureNotTypes and right has sureTypes, neither existing function could create the holder "if $a is true, then $y is string". The new cross-kind functions handle this mismatch.
For
isset($data['x'])specifically, the falsey context returns empty SpecifiedTypes for non-constant arrays, so even the cross-kind functions had nothing to work with. The truthy fallback addresses this by deriving conditions from the truthy narrowing ofisset()(which includesHasOffsetTypeon the array variable).Test
tests/PHPStan/Analyser/nsrt/bug-10644.phpcovers:isset($data['subtitle']) && !is_string(...)with??coalesce andisset()recheckis_string,is_int,is_array!== nullcondition withis_stringinstanceofcondition withis_intarray_key_exists()withis_stringFixes phpstan/phpstan#10644