Skip to content

Commit cbd408f

Browse files
phpstan-botclaude
authored andcommitted
Read the native base type from native state when narrowing an expression that only has a PHPDoc holder in MutatingScope::applySpecifiedTypes()
- When an expression had a Yes-certainty holder in expressionTypes but none in nativeExpressionTypes (e.g. `$s['projects']` placed only on the phpdoc side by the conditional expression of `$has = isset($s['projects'])`), the native result of a narrowing fell back to the phpdoc-derived specified type. That leaked `list<string>` into the native type through the foreach `iteratee !== []` narrowing, and caused a false function.alreadyNarrowedType with treatPhpDocTypesAsCertain: false. - Now the native base comes from getCurrentTypesOfSpecifiedExpr() in that case. This covers all three branches: sure narrowing, sure-not removal, and alternative-form terms. - Ported the same change to the native mirror in turbo-ext/src/MutatingScope.cpp. - Probed the analogous entry points (array_key_exists() stored in a variable, `!== []`, `!is_int()`, `is_array()`, property fetch `!== null` stored in a variable). They all went through the same code path and are covered by the new NSRT test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 4ef9ab7 commit cbd408f

5 files changed

Lines changed: 167 additions & 5 deletions

File tree

‎src/Analyser/MutatingScope.php‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4091,11 +4091,22 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self
40914091
$trackedType = $currentTypes[0];
40924092
$trackedNativeType ??= $currentTypes[1];
40934093
}
4094-
} elseif (!$specifiedTypes->shouldOverwrite() && $scope->isComplexUnionType($trackedType)) {
4095-
// mirrors addTypeToExpression()/removeTypeFromExpression(): narrowing
4096-
// a combinatorially-grown offset union doubles it with every isset()-
4097-
// style check and gets skipped (overwrites assign, they never narrow)
4098-
continue;
4094+
} else {
4095+
if (!$specifiedTypes->shouldOverwrite() && $scope->isComplexUnionType($trackedType)) {
4096+
// mirrors addTypeToExpression()/removeTypeFromExpression(): narrowing
4097+
// a combinatorially-grown offset union doubles it with every isset()-
4098+
// style check and gets skipped (overwrites assign, they never narrow)
4099+
continue;
4100+
}
4101+
if ($trackedNativeType === null) {
4102+
// a phpdoc-only holder (e.g. one a conditional expression put in
4103+
// place) - the native base comes from the native state, not from
4104+
// the phpdoc-derived specified type
4105+
$currentTypes = $scope->getCurrentTypesOfSpecifiedExpr($expr);
4106+
if ($currentTypes !== null) {
4107+
$trackedNativeType = $currentTypes[1];
4108+
}
4109+
}
40994110
}
41004111

41014112
if (isset($typeSpecification['terms'])) {
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug15404;
4+
5+
use function PHPStan\Testing\assertNativeType;
6+
use function PHPStan\Testing\assertType;
7+
8+
/** @param array{projects?: list<string>} $s */
9+
function a(array $s): void
10+
{
11+
$has = isset($s['projects']);
12+
if (!$has) {
13+
return;
14+
}
15+
foreach ($s['projects'] as $p) {
16+
assertType('string', $p);
17+
assertNativeType('mixed', $p);
18+
if (!is_string($p)) {
19+
throw new \InvalidArgumentException();
20+
}
21+
}
22+
}
23+
24+
/** @param array{projects?: list<string>} $s */
25+
function inlineIsset(array $s): void
26+
{
27+
if (!isset($s['projects'])) {
28+
return;
29+
}
30+
foreach ($s['projects'] as $p) {
31+
assertType('string', $p);
32+
assertNativeType('mixed', $p);
33+
}
34+
}
35+
36+
/** @param array{projects?: list<string>} $s */
37+
function arrayKeyExists(array $s): void
38+
{
39+
$has = array_key_exists('projects', $s);
40+
if (!$has) {
41+
return;
42+
}
43+
foreach ($s['projects'] as $p) {
44+
assertType('string', $p);
45+
assertNativeType('mixed', $p);
46+
}
47+
}
48+
49+
/** @param array{projects?: list<string>} $s */
50+
function notEmptyArray(array $s): void
51+
{
52+
$has = isset($s['projects']);
53+
if (!$has) {
54+
return;
55+
}
56+
if ($s['projects'] !== []) {
57+
assertType('non-empty-list<string>', $s['projects']);
58+
assertNativeType('mixed~array{}', $s['projects']);
59+
}
60+
}
61+
62+
/** @param array{projects?: list<string>} $s */
63+
function isArrayCheck(array $s): void
64+
{
65+
$has = isset($s['projects']);
66+
if (!$has) {
67+
return;
68+
}
69+
if (is_array($s['projects'])) {
70+
assertType('list<string>', $s['projects']);
71+
assertNativeType('array<mixed, mixed>', $s['projects']);
72+
}
73+
}
74+
75+
/** @param array{projects?: list<string>} $s */
76+
function notIsArrayCheck(array $s): void
77+
{
78+
$has = isset($s['projects']);
79+
if (!$has) {
80+
return;
81+
}
82+
if (!is_int($s['projects'])) {
83+
assertType('list<string>', $s['projects']);
84+
assertNativeType('mixed~int', $s['projects']);
85+
}
86+
}
87+
88+
class Foo
89+
{
90+
91+
/** @var list<string>|null */
92+
public $projects;
93+
94+
public function doFoo(): void
95+
{
96+
$has = $this->projects !== null;
97+
if (!$has) {
98+
return;
99+
}
100+
foreach ($this->projects as $p) {
101+
assertType('string', $p);
102+
assertNativeType('mixed', $p);
103+
}
104+
}
105+
106+
}

‎tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleWithUncertainPhpDocTypesTest.php‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,4 +271,9 @@ public function testBug15250(): void
271271
$this->analyse([__DIR__ . '/data/bug-15250.php'], []);
272272
}
273273

274+
public function testBug15404(): void
275+
{
276+
$this->analyse([__DIR__ . '/data/bug-15404.php'], []);
277+
}
278+
274279
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug15404Rule;
4+
5+
/** @param array{projects?: list<string>} $s */
6+
function a(array $s): void
7+
{
8+
$has = isset($s['projects']);
9+
if (!$has) {
10+
return;
11+
}
12+
foreach ($s['projects'] as $p) {
13+
if (!is_string($p)) {
14+
throw new \InvalidArgumentException();
15+
}
16+
}
17+
}

‎turbo-ext/src/MutatingScope.cpp‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7826,6 +7826,29 @@ class MutatingScope
78267826
skipSpecification = true;
78277827
}
78287828
}
7829+
if (!skipSpecification && !hasTrackedNative) {
7830+
/* a phpdoc-only holder - the native base comes from the
7831+
* native state, not from the phpdoc-derived specified type */
7832+
zval exprZv;
7833+
ZVAL_OBJ(&exprZv, expr);
7834+
zv::Val currentTypes = otherPrivate(scopeObject, PT_LC("getcurrenttypesofspecifiedexpr"), 1, &exprZv, [&](MutatingScope &other) {
7835+
return other.getCurrentTypesOfSpecifiedExpr(expr);
7836+
});
7837+
if (UNEXPECTED(currentTypes.isUndef())) return zv::Val();
7838+
if (Z_TYPE_P(currentTypes.raw()) != IS_NULL) {
7839+
if (UNEXPECTED(Z_TYPE_P(currentTypes.raw()) != IS_ARRAY)) {
7840+
zend_throw_error(NULL, "phpstan_turbo: getCurrentTypesOfSpecifiedExpr() answered an unexpected shape");
7841+
return zv::Val();
7842+
}
7843+
zval *native = zend_hash_index_find(Z_ARRVAL_P(currentTypes.raw()), 1);
7844+
if (UNEXPECTED(native == NULL)) {
7845+
zend_throw_error(NULL, "phpstan_turbo: getCurrentTypesOfSpecifiedExpr() answered an unexpected shape");
7846+
return zv::Val();
7847+
}
7848+
trackedNativeType = zv::Val::copyOf(zv::Ref(native));
7849+
hasTrackedNative = true;
7850+
}
7851+
}
78297852
}
78307853
if (skipSpecification) continue;
78317854

0 commit comments

Comments
 (0)