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;