Fix phpstan/phpstan#11565: False positive: Conditional return type takes the wrong branch#5362
Conversation
VincentLanglet
left a comment
There was a problem hiding this comment.
Is it the only place the removeExpr is needed ?
- Only if context is null ?
- Only for Assign (And not other type of assign like
??=)
|
Pushed. Here's what was done to address the review: Changes made:
Answers to the reviewer's questions:
|
|
@staabm I remembered you said something about |
I can remember that too much expression printing can be slow. |
|
|
||
| if ($context->null()) { | ||
| $specifiedTypes = $this->specifyTypesInCondition($scope->exitFirstLevelStatements(), $expr->expr, $context)->setRootExpr($expr); | ||
| $specifiedTypes = $specifiedTypes->removeExpr($this->exprPrinter->printExpr($expr->var)); |
There was a problem hiding this comment.
why do we need this fix only for $context->null()?
shouldn't a assignment always override pre-existing types?
There was a problem hiding this comment.
Pushed. Here's the summary:
Changes made:
Added code comments in src/Analyser/TypeSpecifier.php explaining why removeExpr is only needed in the $context->null() branch, and cleaned up unnecessary comments in the test file.
Answer to the reviewer's question:
The removeExpr is only needed for $context->null() because the two branches recurse into different sub-expressions:
- Null context (statement like
$items = iteratorToList($items);): recurses into$expr->expr(the RHS). The RHS function call's conditional return type may produce specifiedTypes targeting the LHS variable, causing circular evaluation.removeExprprevents this. - Non-null context (condition like
if ($items = iteratorToList($items))): recurses into$expr->var(the LHS variable) to narrow based on truthiness. It does not recurse into the RHS, so conditional return types are never re-evaluated and the bug cannot occur. ApplyingremoveExprhere would be wrong — it would remove the truthiness narrowing for the assigned variable.
All 11,708 tests pass and make phpstan reports no errors.
| if ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { | ||
| $specifiedTypes = $specifiedTypes->removeExpr('$' . $expr->var->name); | ||
| } | ||
| $specifiedTypes = $specifiedTypes->removeExpr($this->exprPrinter->printExpr($expr->var)); |
There was a problem hiding this comment.
this fix also affects assignments to expressions different than Variable. add a test which is affected by this change
There was a problem hiding this comment.
Pushed. Added two test cases to tests/PHPStan/Analyser/nsrt/bug-11565.php that exercise the generalized removeExpr for non-Variable LHS expressions:
- Property fetch (
$holder->items = iteratorToList($holder->items)) — verifies the conditional return type resolves correctly when the LHS is a property fetch - Array dim fetch (
$data['items'] = iteratorToList($data['items'])) — verifies the same for array dimension access
Both make phpstan and make tests pass with all 11708 tests green.
… when variable is reassigned - When processing `$items = iteratorToList($items)` as a statement, the TypeSpecifier evaluated the conditional return type using the post-assignment scope where `$items` was already `list<string>`, causing the condition `$iterable is list` to be true and the return type to resolve to `never` - Added `removeExpr` method to SpecifiedTypes to filter out entries by expression key - In TypeSpecifier, when processing an Assign in null context, remove specifiedTypes that target the assigned variable to prevent the post-assignment type from being incorrectly narrowed by the conditional return type evaluation - New regression test in tests/PHPStan/Analyser/nsrt/bug-11565.php
…t test Address review feedback: use exprPrinter->printExpr($expr->var) instead of only handling simple variables. This covers property fetches, array dim fetches, and other LHS expressions that could theoretically trigger the same circular evaluation bug. The fix is only needed in null context (standalone expression statements) because non-null context recurses into $expr->var, not $expr->expr, so the RHS conditional return type is never re-evaluated. AssignOp\Coalesce (??=) is not handled in specifyTypesInCondition and falls through to empty SpecifiedTypes, so it cannot trigger this bug. Added test case for truthy context (inside if) to verify it works. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
In null context, we recurse into $expr->expr (the RHS), which may produce specifiedTypes for the LHS variable via conditional return types — causing circular evaluation. In non-null context, we recurse into $expr->var (the LHS) instead, so conditional return types are never re-evaluated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Test that the generalized removeExpr (using exprPrinter instead of variable-only check) correctly handles non-Variable LHS expressions like property fetches and array dim fetches in assignments. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
f33028b to
c8b3293
Compare
… context" This reverts commit 8e99862.
Summary
When a variable is reassigned from a function with a conditional return type (e.g.,
$items = iteratorToList($items)), PHPStan incorrectly resolved the conditional return type toneverinstead oflist<T>. This happened because the TypeSpecifier used the post-assignment scope to evaluate the conditional, causing the variable's new type (the function's return type) to be used as the input, creating a circular evaluation.Changes
src/Analyser/TypeSpecifier.php: In the null-context handling ofAssignexpressions, remove any specifiedTypes that target the assigned variable. This prevents the post-assignment type from incorrectly influencing the conditional return type evaluation.src/Analyser/SpecifiedTypes.php: AddedremoveExpr(string $exprString)method to allow filtering out entries from bothsureTypesandsureNotTypesby expression key.tests/PHPStan/Analyser/nsrt/bug-11565.php: Regression test covering both the buggy case (same variable) and the working case (different variable).Root cause
After processing
$items = iteratorToList($items), the scope has$itemstyped aslist<string>. The statement processing inNodeScopeResolverthen callsspecifyTypesInConditionwith this post-assignment scope to extract additional type narrowing. For an Assign in null context, the TypeSpecifier recurses into the RHS function call.ParametersAcceptorSelector::selectFromArgsevaluates the argument$itemsusing the post-assignment scope, gettinglist<string>instead of the originaliterable<string, string>. Sincelist<string>IS a list, the conditional($iterable is list ? never : list<T>)resolves tonever, and the TypeSpecifier creates a "not list" narrowing for$items. When applied tolist<string>, this produces*NEVER*.The fix removes specifiedTypes targeting the assigned variable in null context, since after reassignment the variable no longer represents the function's input.
Test
Added
tests/PHPStan/Analyser/nsrt/bug-11565.phpwhich tests that$items = iteratorToList($items)correctly resolves tolist<string>when$itemsstarts asiterable<string, string>. Also verifies the existing working case with different variables.Fixes phpstan/phpstan#11565