Skip to content

Commit 01ba8ae

Browse files
staabmphpstan-bot
authored andcommitted
Fix type narrowing persisting across impure function calls
- TypeSpecifier::createForExpr() checked if the top-level function call was pure before remembering its type, but did not check whether arguments contained impure sub-expressions - Added containsImpureCall() helper to recursively detect impure function/method/static calls within expression trees - Applied the check to FuncCall, MethodCall, and StaticCall branches in createForExpr() - Added regression test in tests/PHPStan/Analyser/nsrt/bug-13416.php Closes phpstan/phpstan#13416
1 parent 1340aeb commit 01ba8ae

File tree

2 files changed

+149
-0
lines changed

2 files changed

+149
-0
lines changed

src/Analyser/TypeSpecifier.php

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
use function array_shift;
8888
use function count;
8989
use function in_array;
90+
use function is_array;
9091
use function is_string;
9192
use function strtolower;
9293
use function substr;
@@ -1972,6 +1973,12 @@ private function createForExpr(
19721973
if (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no()) {
19731974
return new SpecifiedTypes([], []);
19741975
}
1976+
1977+
foreach ($expr->getArgs() as $arg) {
1978+
if ($this->containsImpureCall($arg->value, $scope)) {
1979+
return new SpecifiedTypes([], []);
1980+
}
1981+
}
19751982
}
19761983

19771984
if (
@@ -1992,6 +1999,24 @@ private function createForExpr(
19921999

19932000
return new SpecifiedTypes([], []);
19942001
}
2002+
2003+
if ($this->containsImpureCall($expr->var, $scope)) {
2004+
if (isset($containsNull) && !$containsNull) {
2005+
return $this->createNullsafeTypes($originalExpr, $scope, $context, $type);
2006+
}
2007+
2008+
return new SpecifiedTypes([], []);
2009+
}
2010+
2011+
foreach ($expr->getArgs() as $arg) {
2012+
if ($this->containsImpureCall($arg->value, $scope)) {
2013+
if (isset($containsNull) && !$containsNull) {
2014+
return $this->createNullsafeTypes($originalExpr, $scope, $context, $type);
2015+
}
2016+
2017+
return new SpecifiedTypes([], []);
2018+
}
2019+
}
19952020
}
19962021

19972022
if (
@@ -2017,6 +2042,16 @@ private function createForExpr(
20172042

20182043
return new SpecifiedTypes([], []);
20192044
}
2045+
2046+
foreach ($expr->getArgs() as $arg) {
2047+
if ($this->containsImpureCall($arg->value, $scope)) {
2048+
if (isset($containsNull) && !$containsNull) {
2049+
return $this->createNullsafeTypes($originalExpr, $scope, $context, $type);
2050+
}
2051+
2052+
return new SpecifiedTypes([], []);
2053+
}
2054+
}
20202055
}
20212056

20222057
$sureTypes = [];
@@ -2047,6 +2082,74 @@ private function createForExpr(
20472082
return $types;
20482083
}
20492084

2085+
private function containsImpureCall(Expr $expr, Scope $scope): bool
2086+
{
2087+
if ($expr instanceof FuncCall && $expr->name instanceof Name) {
2088+
if (!$this->reflectionProvider->hasFunction($expr->name, $scope)) {
2089+
return true;
2090+
}
2091+
$functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope);
2092+
$hasSideEffects = $functionReflection->hasSideEffects();
2093+
if ($hasSideEffects->yes()) {
2094+
return true;
2095+
}
2096+
if (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no()) {
2097+
return true;
2098+
}
2099+
}
2100+
2101+
if ($expr instanceof MethodCall && $expr->name instanceof Node\Identifier) {
2102+
$calledOnType = $scope->getType($expr->var);
2103+
$methodReflection = $scope->getMethodReflection($calledOnType, $expr->name->toString());
2104+
if (
2105+
$methodReflection === null
2106+
|| $methodReflection->hasSideEffects()->yes()
2107+
|| (!$this->rememberPossiblyImpureFunctionValues && !$methodReflection->hasSideEffects()->no())
2108+
) {
2109+
return true;
2110+
}
2111+
}
2112+
2113+
if ($expr instanceof StaticCall && $expr->name instanceof Node\Identifier) {
2114+
if ($expr->class instanceof Name) {
2115+
$calledOnType = $scope->resolveTypeByName($expr->class);
2116+
} else {
2117+
$calledOnType = $scope->getType($expr->class);
2118+
}
2119+
$methodReflection = $scope->getMethodReflection($calledOnType, $expr->name->toString());
2120+
if (
2121+
$methodReflection === null
2122+
|| $methodReflection->hasSideEffects()->yes()
2123+
|| (!$this->rememberPossiblyImpureFunctionValues && !$methodReflection->hasSideEffects()->no())
2124+
) {
2125+
return true;
2126+
}
2127+
}
2128+
2129+
foreach ($expr->getSubNodeNames() as $name) {
2130+
$subNode = $expr->{$name};
2131+
if ($subNode instanceof Expr) {
2132+
if ($this->containsImpureCall($subNode, $scope)) {
2133+
return true;
2134+
}
2135+
}
2136+
if (!is_array($subNode)) {
2137+
continue;
2138+
}
2139+
2140+
foreach ($subNode as $item) {
2141+
if ($item instanceof Node\Arg && $this->containsImpureCall($item->value, $scope)) {
2142+
return true;
2143+
}
2144+
if ($item instanceof Expr && $this->containsImpureCall($item, $scope)) {
2145+
return true;
2146+
}
2147+
}
2148+
}
2149+
2150+
return false;
2151+
}
2152+
20502153
private function createNullsafeTypes(Expr $expr, Scope $scope, TypeSpecifierContext $context, ?Type $type): SpecifiedTypes
20512154
{
20522155
if ($expr instanceof Expr\NullsafePropertyFetch) {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug13416;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
class MyRecord {
8+
/** @var array<int, self> */
9+
private static array $storage = [];
10+
11+
/** @phpstan-impure */
12+
public function insert(): void {
13+
self::$storage[] = $this;
14+
}
15+
16+
/**
17+
* @return array<int, self>
18+
* @phpstan-impure
19+
*/
20+
public static function find(): array {
21+
return self::$storage;
22+
}
23+
}
24+
25+
class TestCase {
26+
public function testMinimalBug(): void {
27+
$msg1 = new MyRecord();
28+
$msg1->insert();
29+
30+
assert(
31+
count(MyRecord::find()) === 1,
32+
'should have 1 record initially'
33+
);
34+
35+
$msg2 = new MyRecord();
36+
$msg2->insert();
37+
38+
assertType('array<int, Bug13416\MyRecord>', MyRecord::find());
39+
assertType('int<0, max>', count(MyRecord::find()));
40+
41+
assert(
42+
count(MyRecord::find()) === 2,
43+
'should have 2 messages after adding one'
44+
);
45+
}
46+
}

0 commit comments

Comments
 (0)