Gather all conjuncts of a compound condition side into BooleanAnd/BooleanOr conditional holders#5810
Conversation
…BooleanOr` conditional holders - `processBooleanConditionalTypes()` now derives the condition from both the sure (via `remove`) and sureNot (via `intersect`) narrowings of the condition side, instead of only one list. When that side is a compound boolean (e.g. `!$a && $b`), the narrowings that make it true are split across the two lists; picking only one dropped a conjunct, so a holder fired on a strict subset of its real precondition and chained into a too-narrow type. - The `conditionsFromSureTypes` parameter is removed; the eight per-direction calls collapse to four (condition-side × holder direction). - Replace the outer `array_merge()` of per-call holder maps with a new `mergeConditionalHolders()` that unions holders per target expression by their key. `array_merge()` is keyed by target expression string and silently let a later call overwrite an earlier holder for the same expression (and skipped expressions leaked empty `[]` entries), so a valid holder could be dropped depending on call order. - Drop the eager `$holders[$exprString] = []` pre-initialization so skipped expressions no longer emit empty entries. - Symmetric fix applies to both the `BooleanAnd` (false context) and `BooleanOr` (true context) holder paths. Closes phpstan/phpstan#14780
ondrejmirtes
left a comment
There was a problem hiding this comment.
Please also add test for https://phpstan.org/r/64d44954-b95e-48f9-af7d-3ee8b42da962 and fix it in case it’s still failing.
…ssion holders When a `BooleanAnd` guard side is itself a plain conjunction (e.g. `$a === null && $b === null`), the false-context holder narrowings are re-derived by swapping the truthy sure/sureNot lists, landing in the sureNot list. The negation those holders must encode is a disjunction (`$a !== null || $b !== null`), which cannot be represented as independent per-expression narrowings. Splitting it let each holder fire on its own and over-narrow, dropping a reachable value. Suppress holder creation only for that swapped sureNot multi-target case. Genuine negated conjunctions (`!($x instanceof X && ...)`) assert every conjunct true through the sure list and the `BooleanOr` true context remain sound to split, so they are untouched. Closes phpstan/phpstan#14780 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Done. Here's a summary of what I did to address the review. What the reviewer askedondrejmirtes asked to add a test for the playground at function f(?int $a, ?object $b, bool $mock): void {
if ($a === null && $b === null && !$mock) { throw new \LogicException(); }
if ($mock) { return; }
\PHPStan\dumpType($a); // wanted int|null, got int
\PHPStan\dumpType($b); // wanted object|null, got object
}It was still failing on the PR branch ( Root cause (a sibling of the PR's original bug)The PR fixed the case where the condition side of a For The fixIn The narrow gating matters: my first attempt skipped all multi-target holder sides and broke PHPStan's own foreach analysis ( Verification
Committed as |
Summary
Two interacting early-return/throw guards could make PHPStan infer a type that is narrower than reality, dropping a reachable value. For the reported snippet,
$autowas inferred asnullinsideif ($forAll)when it should have beenint|null.The root cause is in how
BooleanAnd/BooleanOrdecomposition builds conditional expression holders (the machinery introduced/extended by #5760). A holder learned from an&&guard kept only one conjunct of its precondition and then chained that incomplete implication with a fact learned from the other guard, concluding a wrong type.Changes
src/Analyser/TypeSpecifier.phpprocessBooleanConditionalTypes()now builds the holder's condition from both the sure narrowings (negated viaTypeCombinator::remove) and the sureNot narrowings (negated viaTypeCombinator::intersect) of the condition side, gathering every conjunct of "this side is true" into a single condition set. Previously it read only one of the two lists per call (selected by theconditionsFromSureTypesflag), so a compound condition side lost a conjunct.conditionsFromSureTypesparameter is removed and the eight per-direction calls in each branch collapse to four.mergeConditionalHolders()replaces the outerarray_merge()used to combine the per-call holder maps.array_merge()is keyed by the target expression string, so a later call's entry overwrote an earlier holder for the same expression — and the old$holders[$exprString] = []pre-initialization leaked empty entries that overwrote real holders depending on call order. The new helper unions holders per target expression by theirgetKey().$holders[$exprString] = []pre-initialization; entries are created lazily only when a holder is actually added.BooleanAnd(false context) andBooleanOr(true context) holder paths receive the same fix, since they share the helper.Root cause
For a guard like
if (!$forAll && $expiration === null && $auto !== null) throw;, the negated fact is!(!$forAll && $expiration === null && $auto !== null). The valid implication concluding$autois!$forAll && $expiration === null⟹$auto === null— it needs both conjuncts. But the condition side!$forAll && $expiration === nullis itself aBooleanAnd, and its narrowings are spread across the sure list ($forAll) and the sureNot list ($expiration). BecauseprocessBooleanConditionalTypes()only consumed one list per call, it produced single-conjunct holders ($auto === null WHEN $expiration === nulland$auto === null WHEN $forAll === false). The first guard separately taught$forAll ⟹ $expiration === null, so enteringif ($forAll)established$expiration === null, which fired the truncated holder and collapsed$autotonull.A second, independent defect amplified this: even when the correct multi-conjunct holder was produced, the per-call results were combined with
array_merge(), which is keyed by the target expression and dropped holders for the same expression depending on call order (this is what caused thebug-6202style isset narrowing to regress in early iterations of the fix).mergeConditionalHolders()fixes that by unioning per target expression.Test
tests/PHPStan/Analyser/nsrt/bug-14780.php:withBothGuards()— the verbatim reproducer from the issue playground; assertsint|nullfor$auto.singleGuard()— the same guard in isolation, asserting the truncated holder does not over-narrow.fourConditions()— an analogous case with four conjuncts across two guards (broken before the fix,nullinstead ofint|null), exercising a deeper compound condition side.I also probed the parallel
BooleanOrtruthy path and deeper/right-associativeBooleanAndnestings with throwaway reproducers; theBooleanOrpath is fixed symmetrically and remains covered by the existingnegated-boolean-and-conditional-holders.php,boolean-and-conditional-holders-mixed-context.phpandconditional-holders-or-assert-isset.phptests, which continue to pass.Fixes phpstan/phpstan#14780