From cbd408fea572d01d71383ba8d4ed76e0bee32f52 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:36:44 +0000 Subject: [PATCH] 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` 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 --- src/Analyser/MutatingScope.php | 21 +++- tests/PHPStan/Analyser/nsrt/bug-15404.php | 106 ++++++++++++++++++ ...onCallRuleWithUncertainPhpDocTypesTest.php | 5 + .../Rules/Comparison/data/bug-15404.php | 17 +++ turbo-ext/src/MutatingScope.cpp | 23 ++++ 5 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15404.php create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-15404.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 85867200a20..7b25ba91b18 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -4091,11 +4091,22 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $trackedType = $currentTypes[0]; $trackedNativeType ??= $currentTypes[1]; } - } elseif (!$specifiedTypes->shouldOverwrite() && $scope->isComplexUnionType($trackedType)) { - // mirrors addTypeToExpression()/removeTypeFromExpression(): narrowing - // a combinatorially-grown offset union doubles it with every isset()- - // style check and gets skipped (overwrites assign, they never narrow) - continue; + } else { + if (!$specifiedTypes->shouldOverwrite() && $scope->isComplexUnionType($trackedType)) { + // mirrors addTypeToExpression()/removeTypeFromExpression(): narrowing + // a combinatorially-grown offset union doubles it with every isset()- + // style check and gets skipped (overwrites assign, they never narrow) + continue; + } + if ($trackedNativeType === null) { + // a phpdoc-only holder (e.g. one a conditional expression put in + // place) - the native base comes from the native state, not from + // the phpdoc-derived specified type + $currentTypes = $scope->getCurrentTypesOfSpecifiedExpr($expr); + if ($currentTypes !== null) { + $trackedNativeType = $currentTypes[1]; + } + } } if (isset($typeSpecification['terms'])) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-15404.php b/tests/PHPStan/Analyser/nsrt/bug-15404.php new file mode 100644 index 00000000000..f25c306779e --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15404.php @@ -0,0 +1,106 @@ +} $s */ +function a(array $s): void +{ + $has = isset($s['projects']); + if (!$has) { + return; + } + foreach ($s['projects'] as $p) { + assertType('string', $p); + assertNativeType('mixed', $p); + if (!is_string($p)) { + throw new \InvalidArgumentException(); + } + } +} + +/** @param array{projects?: list} $s */ +function inlineIsset(array $s): void +{ + if (!isset($s['projects'])) { + return; + } + foreach ($s['projects'] as $p) { + assertType('string', $p); + assertNativeType('mixed', $p); + } +} + +/** @param array{projects?: list} $s */ +function arrayKeyExists(array $s): void +{ + $has = array_key_exists('projects', $s); + if (!$has) { + return; + } + foreach ($s['projects'] as $p) { + assertType('string', $p); + assertNativeType('mixed', $p); + } +} + +/** @param array{projects?: list} $s */ +function notEmptyArray(array $s): void +{ + $has = isset($s['projects']); + if (!$has) { + return; + } + if ($s['projects'] !== []) { + assertType('non-empty-list', $s['projects']); + assertNativeType('mixed~array{}', $s['projects']); + } +} + +/** @param array{projects?: list} $s */ +function isArrayCheck(array $s): void +{ + $has = isset($s['projects']); + if (!$has) { + return; + } + if (is_array($s['projects'])) { + assertType('list', $s['projects']); + assertNativeType('array', $s['projects']); + } +} + +/** @param array{projects?: list} $s */ +function notIsArrayCheck(array $s): void +{ + $has = isset($s['projects']); + if (!$has) { + return; + } + if (!is_int($s['projects'])) { + assertType('list', $s['projects']); + assertNativeType('mixed~int', $s['projects']); + } +} + +class Foo +{ + + /** @var list|null */ + public $projects; + + public function doFoo(): void + { + $has = $this->projects !== null; + if (!$has) { + return; + } + foreach ($this->projects as $p) { + assertType('string', $p); + assertNativeType('mixed', $p); + } + } + +} diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleWithUncertainPhpDocTypesTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleWithUncertainPhpDocTypesTest.php index 3fff9077bce..8d59006a411 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleWithUncertainPhpDocTypesTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleWithUncertainPhpDocTypesTest.php @@ -271,4 +271,9 @@ public function testBug15250(): void $this->analyse([__DIR__ . '/data/bug-15250.php'], []); } + public function testBug15404(): void + { + $this->analyse([__DIR__ . '/data/bug-15404.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-15404.php b/tests/PHPStan/Rules/Comparison/data/bug-15404.php new file mode 100644 index 00000000000..1dd5291a8cb --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-15404.php @@ -0,0 +1,17 @@ +} $s */ +function a(array $s): void +{ + $has = isset($s['projects']); + if (!$has) { + return; + } + foreach ($s['projects'] as $p) { + if (!is_string($p)) { + throw new \InvalidArgumentException(); + } + } +} diff --git a/turbo-ext/src/MutatingScope.cpp b/turbo-ext/src/MutatingScope.cpp index 1138b46ed9a..01d45327736 100644 --- a/turbo-ext/src/MutatingScope.cpp +++ b/turbo-ext/src/MutatingScope.cpp @@ -7826,6 +7826,29 @@ class MutatingScope skipSpecification = true; } } + if (!skipSpecification && !hasTrackedNative) { + /* a phpdoc-only holder - the native base comes from the + * native state, not from the phpdoc-derived specified type */ + zval exprZv; + ZVAL_OBJ(&exprZv, expr); + zv::Val currentTypes = otherPrivate(scopeObject, PT_LC("getcurrenttypesofspecifiedexpr"), 1, &exprZv, [&](MutatingScope &other) { + return other.getCurrentTypesOfSpecifiedExpr(expr); + }); + if (UNEXPECTED(currentTypes.isUndef())) return zv::Val(); + if (Z_TYPE_P(currentTypes.raw()) != IS_NULL) { + if (UNEXPECTED(Z_TYPE_P(currentTypes.raw()) != IS_ARRAY)) { + zend_throw_error(NULL, "phpstan_turbo: getCurrentTypesOfSpecifiedExpr() answered an unexpected shape"); + return zv::Val(); + } + zval *native = zend_hash_index_find(Z_ARRVAL_P(currentTypes.raw()), 1); + if (UNEXPECTED(native == NULL)) { + zend_throw_error(NULL, "phpstan_turbo: getCurrentTypesOfSpecifiedExpr() answered an unexpected shape"); + return zv::Val(); + } + trackedNativeType = zv::Val::copyOf(zv::Ref(native)); + hasTrackedNative = true; + } + } } if (skipSpecification) continue;