Skip to content

Commit 98dad9b

Browse files
github-actions[bot]phpstan-bot
authored andcommitted
Intersect stored FuncCall expression types with dynamically computed types in MutatingScope::resolveType()
- When a FuncCall expression (e.g. `count($arr)`) has a stored expression type from a prior condition, `resolveType()` now also computes the dynamic type via the ExprHandler and returns the intersection of both. - This prevents stale stored types from overriding the correct type when function arguments have been narrowed after scope merging. - The bug occurred when `count($arr)` was used in a comparison (e.g. `count($arr) > 2`), storing `count($arr) = int<0, max>` after merge, and then `$arr` was narrowed to non-empty by a different condition. - The fix also covers analogous cases: `sizeof()`, `strlen()`, `mb_strlen()`, and any other FuncCall with stored expression types.
1 parent 03834ec commit 98dad9b

File tree

2 files changed

+71
-1
lines changed

2 files changed

+71
-1
lines changed

src/Analyser/MutatingScope.php

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -973,7 +973,25 @@ private function resolveType(string $exprString, Expr $node): Type
973973
&& !$node instanceof Expr\ArrowFunction
974974
&& $this->hasExpressionType($node)->yes()
975975
) {
976-
return $this->expressionTypes[$exprString]->getType();
976+
$storedType = $this->expressionTypes[$exprString]->getType();
977+
978+
if ($node instanceof FuncCall) {
979+
// Stored expression types for function calls can become stale
980+
// when arguments are narrowed after scope merging.
981+
// Intersect with the dynamically computed type to stay correct.
982+
$this->resolvedTypes[$exprString] = $storedType;
983+
foreach ($this->container->getServicesByTag(ExprHandler::EXTENSION_TAG) as $exprHandler) {
984+
if (!$exprHandler->supports($node)) {
985+
continue;
986+
}
987+
$dynamicType = $exprHandler->resolveType($this, $node);
988+
unset($this->resolvedTypes[$exprString]);
989+
return TypeCombinator::intersect($storedType, $dynamicType);
990+
}
991+
unset($this->resolvedTypes[$exprString]);
992+
}
993+
994+
return $storedType;
977995
}
978996

979997
/** @var ExprHandler<Expr> $exprHandler */
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
namespace Bug13750;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
class HelloWorld
8+
{
9+
/** @param array<mixed> $arr */
10+
public function forgetCount(array $arr): void
11+
{
12+
if (count($arr) > 2) {
13+
assertType('non-empty-array<mixed>', $arr);
14+
assertType('int<3, max>', count($arr));
15+
}
16+
assertType('array<mixed>', $arr);
17+
assertType('int<0, max>', count($arr));
18+
if (count($arr, COUNT_RECURSIVE) > 2) {
19+
assertType('non-empty-array<mixed>', $arr);
20+
assertType('int<1, max>', count($arr));
21+
}
22+
}
23+
24+
/** @param array<mixed> $arr */
25+
public function forgetCountDifferentNarrowing(array $arr): void
26+
{
27+
if (count($arr) > 2) {
28+
}
29+
if ($arr !== []) {
30+
assertType('int<1, max>', count($arr));
31+
}
32+
}
33+
34+
/** @param array<mixed> $arr */
35+
public function sizeofAfterSizeof(array $arr): void
36+
{
37+
if (sizeof($arr) > 2) {
38+
}
39+
if ($arr !== []) {
40+
assertType('int<1, max>', sizeof($arr));
41+
}
42+
}
43+
44+
public function strlenAfterStrlen(string $str): void
45+
{
46+
if (strlen($str) > 5) {
47+
}
48+
if ($str !== '') {
49+
assertType('int<1, max>', strlen($str));
50+
}
51+
}
52+
}

0 commit comments

Comments
 (0)