Fix phpstan/phpstan#4090: Array detected as empty while it cannot#5387
Fix phpstan/phpstan#4090: Array detected as empty while it cannot#5387phpstan-bot wants to merge 12 commits intophpstan:2.1.xfrom
Conversation
- When $count = count($a) and later $count > 1 or $count === 1, the array $a was not narrowed to non-empty-array - Root cause: conditional expression matching required exact type equality, but count comparisons produce subtypes (e.g. int<2, max> is a subtype of int<1, max>) - Added conditionalExpressionHolderMatches() that uses isSuperTypeOf for IntegerRangeType conditions, enabling count-based narrowing - New regression test in tests/PHPStan/Analyser/nsrt/bug-4090.php Closes phpstan/phpstan#4090
src/Analyser/MutatingScope.php
Outdated
| } | ||
|
|
||
| $conditionType = $condition->getType(); | ||
| if (!$conditionType instanceof IntegerRangeType) { |
There was a problem hiding this comment.
instanceof *Type checks are not recommended and this one looks wrong. This should rely on another condition ; maybe a Type:: method ?
Think about this: Why would the check isSuperTypeOf should be made for some types and not for some others ?
Or maybe does it means that the fix should be somewhere else ?
There was a problem hiding this comment.
All done — both verification runs confirmed clean. The commit addressing VincentLanglet's review has been pushed to the PR.
…matching Address review feedback: use Type::isInteger() and Type::isConstantScalarValue() instead of instanceof IntegerRangeType check. Also add explicit certainty check before the type comparison. The isSuperTypeOf matching is restricted to non-constant integer types (i.e. integer ranges like int<1, max>) because using it for constant scalar values (like unions of constant integers 0|2) causes cascading over-narrowing when multiple conditional expressions match and their types get intersected. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
src/Analyser/MutatingScope.php
Outdated
| } | ||
|
|
||
| $conditionType = $condition->getType(); | ||
| if (!$conditionType->isInteger()->yes() || $conditionType->isConstantScalarValue()->yes()) { |
There was a problem hiding this comment.
Can't this be extended to more Types ?
You ddin't explain why it would work for some and not some others. Stop focusing on IntegerRange
There was a problem hiding this comment.
All background tasks have finished. The work is complete — commit pushed, all tests and static analysis passing.
…er ranges The conditional expression system intersects results from ALL matching conditions. Using isSuperTypeOf broadly causes types like bool to match both 'false' and 'bool' conditions simultaneously, producing *NEVER* when their results conflict. Integer ranges (from count()) don't have this problem because their conditions are non-overlapping. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
src/Analyser/MutatingScope.php
Outdated
| // For types like bool, isSuperTypeOf would match both a 'false' condition AND | ||
| // a 'bool' condition simultaneously, and intersecting their (potentially conflicting) | ||
| // results produces *NEVER*. Integer ranges don't have this problem because | ||
| // count() creates non-overlapping conditions (e.g. int<1, max> vs 0). |
There was a problem hiding this comment.
Remove the comment, and look for another strategy cause it should also works for UnionType and certainly some others Type.
Maybe only setting $conditions OR $specifiedExpressions in specific case ; but feel free to explore other ideas
There was a problem hiding this comment.
All done. Every background task has completed successfully — tests and static analysis all green. The commit is pushed.
Instead of restricting isSuperTypeOf to integer ranges, use a two-pass strategy: try exact matches first, then fall back to isSuperTypeOf when no exact match exists. The fallback only accepts a single match to avoid intersecting potentially conflicting results from overlapping conditions (e.g. mixed~null, mixed~false, mixed~0 all being supertypes of false). This generalizes the fix beyond IntegerRangeType while keeping existing behavior intact. Updated bug-5051 test expectations to reflect improved precision (PHPStan can now narrow $update to false when $data is known). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
VincentLanglet
left a comment
There was a problem hiding this comment.
This produce a regression on the snippet
class AggregationParser
{
/**
* @param array<string, mixed> $aggregation
*/
private function parseAggregation(array $aggregation)
{
$type = $aggregation['type'] ?? null;
if (!\is_string($type) || empty($type) || is_numeric($type)) {
return null;
}
if (empty($aggregation['field']) && $type !== 'filter') {
return null;
}
$field = '';
if ($type !== 'filter') {
$field = self::buildFieldName();
}
assertType('non-falsy-string', $type);
}
private static function buildFieldName(): string
{
return 'field';
}
}
…onal expressions exist The isSuperTypeOf fallback in Pass 2 incorrectly matched conditional expressions from disjunctive guards (e.g. `empty($x) && $y !== 'foo'`) where subtype matching does not preserve the logical implication. The fix requires at least 2 conditional expressions for the same target before using the isSuperTypeOf fallback, ensuring it only fires for proper partitions (like truthy/falsy from count() assignments). Also stops propagating Pass 2 results into specifiedExpressions to prevent cascading matches across iterations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Pushed. Here's a summary of the fix: Problem: The
Fix: Two changes:
|
VincentLanglet
left a comment
There was a problem hiding this comment.
Can't the two foreach ($conditionalExpressions loop be merged into a single one ?
Addresses review feedback: the exact-match and isSuperTypeOf-fallback passes now happen in a single iteration over conditionalExpressions, collecting exactMatches and superTypeMatches separately, then deciding which to use after the loop. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Done. Merged the two |
VincentLanglet
left a comment
There was a problem hiding this comment.
Remove conditionalExpressionHolderMatches method because you end up duplicating the equals call
… call Remove the separate method and inline the isSuperTypeOf check directly at the call site, since the equals() check is already performed in the surrounding code. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Done. Removed the |
Addresses review feedback: extract the certainty + isSuperTypeOf check into an ExpressionTypeHolder::isSuperTypeOf() method, mirroring the existing equals() method pattern. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… for consistency Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
When the result of
count($array)was stored in a variable (e.g.,$count = count($a)) and then that variable was used in a comparison ($count > 1or$count === 1), PHPStan did not narrow the array tonon-empty-array. This caused false positives when using functions likecurrent()on the array, which returnsfalsefor empty arrays.Direct comparisons like
count($a) > 1already worked correctly.Changes
conditionalExpressionHolderMatches()method tosrc/Analyser/MutatingScope.phpthat relaxes the conditional expression matching forIntegerRangeTypeconditions, usingisSuperTypeOf()instead of exact equalityfilterBySpecifiedTypes()to use the new matching methodtests/PHPStan/Analyser/nsrt/bug-4090.phpcovering all three patterns from the issue (direct count comparison, variable-stored count, switch on count)Root cause
When
$count = count($a)is assigned, theAssignHandlercreates conditional expressions linking$countback to$a:$countisint<1, max>(truthy), then$aisnon-empty-array$countis0(falsey), then$aisarray{}When later
$count > 1narrows$counttoint<2, max>, or$count === 1narrows it to1, the conditional expression matching infilterBySpecifiedTypes()required exact type equality (equals()). Sinceint<2, max>and1are not equal toint<1, max>(even though they are subtypes), the conditional expressions never matched.The fix uses
isSuperTypeOf()specifically forIntegerRangeTypeconditions, which correctly recognizes thatint<2, max>and1are subtypes ofint<1, max>.Test
The regression test
tests/PHPStan/Analyser/nsrt/bug-4090.phpcovers three functions from the original issue:foo(): directcount($a)in conditions (already worked)bar():$count = count($a)with variable-based conditions (was broken)qux():switch(count($a))(already worked)All assert that
$aisnon-empty-array<string>in branches where count is > 0.Fixes phpstan/phpstan#4090