Create cross-kind conditional expression holders in BooleanAnd/BooleanOr type specifier with truthy fallback for isset()#5760
Conversation
VincentLanglet
left a comment
There was a problem hiding this comment.
Is there some refacto with processBooleanNotSureConditionalTypes and processBooleanSureConditionalTypes methods ?
|
Pushed. The refactoring unifies the four methods ( |
6de7bd1 to
ed816ea
Compare
VincentLanglet
left a comment
There was a problem hiding this comment.
Add non regression test for
…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
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
4902674 to
5a09a66
Compare
…holders The truthy-narrowing fallback for empty falsey SpecifiedTypes (added for isset() on non-constant array dim fetches) previously ran unconditionally inside the `$context->false()` block. In a mixed truthy-and-false context (produced by negation) it would run in addition to the existing falsey re-derivation, supplementing holders the truthy branch had already filled. Make it an explicit `else` branch so the two re-derivation strategies are mutually exclusive: the mixed truthy-and-false context re-derives from the falsey narrowing, while the pure false context falls back to the truthy narrowing. Add comments documenting both branches. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
VincentLanglet
left a comment
There was a problem hiding this comment.
No test was added for the latest commit
…ed context Negating a `&&` condition specifies the inner BooleanAnd in a mixed truthy-and-false context, which is the path refined by the previous commit (the isset() truthy fallback must not fire there; holders are re-derived from the falsey narrowing instead). These cases produced `mixed` before the PR and now narrow correctly through that path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Done. I addressed VincentLanglet's review ("No test was added for the latest commit"). What the latest commit changedCommit What I addedA new nsrt test file
I verified these are meaningful regression tests:
Verification
Committed ( One note: the latest commit produces no observable output difference on these (or other patterns I tried) — old and new both narrow correctly here. The commit was a defensive correctness change ensuring the two re-derivation strategies are mutually exclusive, so this test locks in the mixed-context behavior rather than demonstrating a before/after divergence at the commit boundary. |
… too The truthy-narrowing fallback (added for isset()/array_key_exists() on array dim fetches, whose falsey narrowing is empty) was gated behind an `else` so it only ran in a pure false context. But the mixed truthy-and-false context — reached by negating a `=== true` comparison, e.g. `(isset($data[$key]) && !is_string($data[$key])) === true` — also leaves those arms empty after the falsey re-derivation, so gating the fallback there dropped the narrowing and the `?? 'fallback'` expression widened back to `mixed~null` instead of `string`. Run the fallback unconditionally after the falsey re-derivation. It only fills arms the re-derivation left empty, so it never overrides a non-empty falsey result — which addresses the original concern about the two blocks conflicting. Add boolean-and-conditional-holders-mixed-context.php covering the `=== true` mixed-context path (which fails when the fallback is gated to pure-false only), and correct the misleading header of negated-boolean-and-conditional-holders.php (`!(...)` specifies a pure falsey context, not a mixed one). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Pushed as The review concern
This was correct, and investigating why revealed something more important: InvestigationI reverted Digging into the context bitmask explained why. Constructing the real mixed-context case (
The Fix
Verification
|
|
I think we could keep it like this (and maybe remove some comments ?) @staabm
We can still open a new issue and run the bot on it when a regression will be found |
|
@VincentLanglet agree. and yes, please less verbose comments ;) |
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DOne |
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
Fixes phpstan/phpstan#11918
Fixes phpstan/phpstan#3385
Fixes phpstan/phpstan#6202
Fixes phpstan/phpstan#14455