From f13ee0844ea30ec6583e07f80634619a40dce4ea Mon Sep 17 00:00:00 2001 From: HawkForge <75696486+HawkForge@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:45:04 +0600 Subject: [PATCH 1/2] Read the native narrowing base from the scope when only the PHPDoc type is tracked MutatingScope::applySpecifiedTypes() takes the base type of a narrowing from the tracked expression holders. An expression can be tracked with its PHPDoc type but not with a native one, for example `$s['projects']` after `$has = isset($s['projects']); if (!$has) return;`, where the conditional holder of `$has` only carries the PHPDoc type. The native result of a sure-not narrowing then fell back to the PHPDoc result, so `$s['projects'] !== []` (which foreach applies to its iteratee) made the native type `non-empty-list` and `$p` natively `string`. With treatPhpDocTypesAsCertain: false, is_string($p) was then reported as always true. When only the PHPDoc type is tracked, the native base is now read from the scope, as it already is for untracked expressions. 2.2.17 read it through getNativeType() in removeTypeFromExpression(). Closes https://github.com/phpstan/phpstan/issues/15404 --- src/Analyser/MutatingScope.php | 5 ++++ ...onCallRuleWithUncertainPhpDocTypesTest.php | 5 ++++ .../Rules/Comparison/data/bug-15404.php | 17 +++++++++++++ turbo-ext/src/MutatingScope.cpp | 24 +++++++++++++++++++ 4 files changed, 51 insertions(+) create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-15404.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index b31ccecf985..51d0915650d 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -4238,6 +4238,11 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self // a combinatorially-grown offset union doubles it with every isset()- // style check and gets skipped (overwrites assign, they never narrow) continue; + } elseif ($trackedNativeType === null) { + // tracked with its PHPDoc type only (e.g. by a conditional holder) - + // the native base is still read from the scope, not taken from + // the PHPDoc type + $trackedNativeType = $scope->getCurrentTypesOfSpecifiedExpr($expr)[1] ?? null; } if (isset($typeSpecification['terms'])) { diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleWithUncertainPhpDocTypesTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleWithUncertainPhpDocTypesTest.php index 635d4eff976..3f0606e1e1f 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleWithUncertainPhpDocTypesTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleWithUncertainPhpDocTypesTest.php @@ -272,4 +272,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..490ad1d2896 --- /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 66e26133896..df008a07b10 100644 --- a/turbo-ext/src/MutatingScope.cpp +++ b/turbo-ext/src/MutatingScope.cpp @@ -7999,6 +7999,30 @@ class MutatingScope skipSpecification = true; } } + if (!skipSpecification && !hasTrackedNative) { + /* tracked with its PHPDoc type only (e.g. by a conditional + * holder) - the native base is still read from the scope, + * not taken from the PHPDoc 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; From 1dcbe297047deb7e10acb2223a51a2a54d0e36dc Mon Sep 17 00:00:00 2001 From: HawkForge <75696486+HawkForge@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:45:09 +0600 Subject: [PATCH 2/2] Bump expected turbo version --- src/Turbo/TurboExtensionEnabler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turbo/TurboExtensionEnabler.php b/src/Turbo/TurboExtensionEnabler.php index 374d29043e6..b868c5d001c 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -33,7 +33,7 @@ final class TurboExtensionEnabler { - public const EXPECTED_EXTENSION_VERSION = 'a0f7086'; + public const EXPECTED_EXTENSION_VERSION = 'f13ee08'; private static bool $active = false;