From a216052db5ccdaf0e1942ef19b5a7a73f7da5bcd Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 7 Oct 2025 09:56:52 +0200 Subject: [PATCH 1/8] `!array_key_exists()` should imply `array` for PHP8+ --- ...yExistsFunctionTypeSpecifyingExtension.php | 15 ++++++++++ .../PHPStan/Analyser/nsrt/bug-13270b-php8.php | 30 +++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-13270b.php | 4 ++- .../PHPStan/Analyser/nsrt/bug-13301-php8.php | 15 ++++++++++ tests/PHPStan/Analyser/nsrt/bug-13301.php | 15 ++++++++++ 5 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13270b-php8.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13301-php8.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13301.php diff --git a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php index c9de826666..7edfbe25cf 100644 --- a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php @@ -13,6 +13,7 @@ use PHPStan\Analyser\TypeSpecifierAwareExtension; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Php\PhpVersion; use PHPStan\Reflection\FunctionReflection; use PHPStan\Type\Accessory\HasOffsetType; use PHPStan\Type\Accessory\NonEmptyArrayType; @@ -31,6 +32,12 @@ final class ArrayKeyExistsFunctionTypeSpecifyingExtension implements FunctionTyp private TypeSpecifier $typeSpecifier; + public function __construct( + private PhpVersion $phpVersion, + ) + { + } + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void { $this->typeSpecifier = $typeSpecifier; @@ -110,6 +117,14 @@ public function specifyTypes( new ArrayType(new MixedType(), new MixedType()), new HasOffsetType($keyType), ); + } elseif ( + $this->phpVersion->throwsValueErrorForInternalFunctions() + && $arrayType instanceof MixedType + ) { + $type = TypeCombinator::intersect( + new ArrayType(new MixedType(), new MixedType()), + ); + $context = $context->negate(); } else { $type = new HasOffsetType($keyType); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-13270b-php8.php b/tests/PHPStan/Analyser/nsrt/bug-13270b-php8.php new file mode 100644 index 0000000000..31fe8474c4 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13270b-php8.php @@ -0,0 +1,30 @@ += 8.0 + +declare(strict_types=1); + +namespace Bug13270b; + +use function PHPStan\Testing\assertType; + +class Test +{ + /** + * @param mixed[] $data + * @return mixed[] + */ + public function parseData(array $data): array + { + if (isset($data['price'])) { + assertType('mixed~null', $data['price']); + if (!array_key_exists('priceWithVat', $data['price'])) { + $data['price']['priceWithVat'] = null; + } + assertType("non-empty-array&hasOffsetValue('priceWithVat', mixed)", $data['price']); + if (!array_key_exists('priceWithoutVat', $data['price'])) { + $data['price']['priceWithoutVat'] = null; + } + assertType("non-empty-array&hasOffsetValue('priceWithoutVat', mixed)&hasOffsetValue('priceWithVat', mixed)", $data['price']); + } + return $data; + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-13270b.php b/tests/PHPStan/Analyser/nsrt/bug-13270b.php index a921ed1ddb..ad79c8a880 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13270b.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13270b.php @@ -1,4 +1,6 @@ -= 8.0 + +namespace Bug13301Php8; + +use function PHPStan\Testing\assertType; + +function doFoo($mixed) { + if (array_key_exists('a', $mixed)) { + assertType("non-empty-array&hasOffset('a')", $mixed); + echo "has-a"; + } else { + assertType('array', $mixed); // could be array~hasOffset('a') after arrays got subtractable + echo "NO-a"; + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-13301.php b/tests/PHPStan/Analyser/nsrt/bug-13301.php new file mode 100644 index 0000000000..738195b8f6 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13301.php @@ -0,0 +1,15 @@ + Date: Tue, 7 Oct 2025 09:59:41 +0200 Subject: [PATCH 2/8] Update bug-13301-php8.php --- tests/PHPStan/Analyser/nsrt/bug-13301-php8.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13301-php8.php b/tests/PHPStan/Analyser/nsrt/bug-13301-php8.php index 49bea0e035..4075d937b7 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13301-php8.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13301-php8.php @@ -12,4 +12,16 @@ function doFoo($mixed) { assertType('array', $mixed); // could be array~hasOffset('a') after arrays got subtractable echo "NO-a"; } + assertType('array', $mixed); +} + +function doArray(array $arr) { + if (array_key_exists('a', $arr)) { + assertType("non-empty-array&hasOffset('a')", $arr); + echo "has-a"; + } else { + assertType('array', $arr); + echo "NO-a"; + } + assertType('array', $arr); } From d53baf0a82629f24533944492025b6170633089a Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 7 Oct 2025 10:00:32 +0200 Subject: [PATCH 3/8] Update bug-13270b-php8.php --- tests/PHPStan/Analyser/nsrt/bug-13270b-php8.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13270b-php8.php b/tests/PHPStan/Analyser/nsrt/bug-13270b-php8.php index 31fe8474c4..ecab6997b8 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13270b-php8.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13270b-php8.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Bug13270b; +namespace Bug13270bPhp8; use function PHPStan\Testing\assertType; From ddfe2bcffa62b534b315a49384c7b0a26c3f2052 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 7 Oct 2025 11:20:57 +0200 Subject: [PATCH 4/8] test different context variants --- .../PHPStan/Analyser/nsrt/bug-13301-php8.php | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13301-php8.php b/tests/PHPStan/Analyser/nsrt/bug-13301-php8.php index 4075d937b7..f8b481210f 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13301-php8.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13301-php8.php @@ -15,13 +15,38 @@ function doFoo($mixed) { assertType('array', $mixed); } +function doFooTrue($mixed) { + if (array_key_exists('a', $mixed) === true) { + assertType("non-empty-array&hasOffset('a')", $mixed); + } else { + assertType('array', $mixed); // could be array~hasOffset('a') after arrays got subtractable + } + assertType('array', $mixed); +} + +function doFooTruethy($mixed) { + if (array_key_exists('a', $mixed) == true) { + assertType("non-empty-array&hasOffset('a')", $mixed); + } else { + assertType('array', $mixed); // could be array~hasOffset('a') after arrays got subtractable + } + assertType('array', $mixed); +} + +function doFooFalsey($mixed) { + if (array_key_exists('a', $mixed) == 0) { + assertType("array", $mixed); + } else { + assertType("non-empty-array&hasOffset('a')", $mixed); // could be array~hasOffset('a') after arrays got subtractable + } + assertType('array', $mixed); +} + function doArray(array $arr) { if (array_key_exists('a', $arr)) { assertType("non-empty-array&hasOffset('a')", $arr); - echo "has-a"; } else { assertType('array', $arr); - echo "NO-a"; } assertType('array', $arr); } From 5846f7dc5f47100b261b775ce7ab218f2f9079ff Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 7 Oct 2025 15:17:04 +0200 Subject: [PATCH 5/8] get rid of instanceof Mixed --- .../Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php | 6 ++---- tests/PHPStan/Analyser/nsrt/bug-2001.php | 8 ++++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php index 7edfbe25cf..b8c2baebb4 100644 --- a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php @@ -119,11 +119,9 @@ public function specifyTypes( ); } elseif ( $this->phpVersion->throwsValueErrorForInternalFunctions() - && $arrayType instanceof MixedType + && !$arrayType->isArray()->yes() ) { - $type = TypeCombinator::intersect( - new ArrayType(new MixedType(), new MixedType()), - ); + $type = new ArrayType(new MixedType(), new MixedType()); $context = $context->negate(); } else { $type = new HasOffsetType($keyType); diff --git a/tests/PHPStan/Analyser/nsrt/bug-2001.php b/tests/PHPStan/Analyser/nsrt/bug-2001.php index 69d429d8bd..afc7e9d976 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-2001.php +++ b/tests/PHPStan/Analyser/nsrt/bug-2001.php @@ -16,21 +16,21 @@ public function parseUrl(string $url): string throw new \RuntimeException('Absolute URLs are prohibited for the redirectTo parameter.'); } - assertType('array{scheme?: string, port?: int<0, 65535>, user?: string, pass?: string, path?: string, query?: string, fragment?: string}|false', $parsedUrl); + assertType('array{scheme?: string, host?: string, port?: int<0, 65535>, user?: string, pass?: string, path?: string, query?: string, fragment?: string}', $parsedUrl); $redirectUrl = $parsedUrl['path']; if (array_key_exists('query', $parsedUrl)) { - assertType('array{scheme?: string, port?: int<0, 65535>, user?: string, pass?: string, path?: string, query: string, fragment?: string}', $parsedUrl); + assertType('array{scheme?: string, host?: string, port?: int<0, 65535>, user?: string, pass?: string, path?: string, query: string, fragment?: string}', $parsedUrl); $redirectUrl .= '?' . $parsedUrl['query']; } if (array_key_exists('fragment', $parsedUrl)) { - assertType('array{scheme?: string, port?: int<0, 65535>, user?: string, pass?: string, path?: string, query?: string, fragment: string}', $parsedUrl); + assertType('array{scheme?: string, host?: string, port?: int<0, 65535>, user?: string, pass?: string, path?: string, query?: string, fragment: string}', $parsedUrl); $redirectUrl .= '#' . $parsedUrl['query']; } - assertType('array{scheme?: string, port?: int<0, 65535>, user?: string, pass?: string, path?: string, query?: string, fragment?: string}|false', $parsedUrl); + assertType('array{scheme?: string, host?: string, port?: int<0, 65535>, user?: string, pass?: string, path?: string, query?: string, fragment?: string}', $parsedUrl); return $redirectUrl; } From 3c2a796fc2e0b7bff925841c9156042aa7654bd5 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 7 Oct 2025 15:30:33 +0200 Subject: [PATCH 6/8] fix php7 build --- tests/PHPStan/Analyser/nsrt/bug-2001-php8.php | 51 +++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-2001.php | 10 ++-- 2 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-2001-php8.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-2001-php8.php b/tests/PHPStan/Analyser/nsrt/bug-2001-php8.php new file mode 100644 index 0000000000..f346486d9c --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-2001-php8.php @@ -0,0 +1,51 @@ += 8.0 + +namespace Bug2001Php8; + +use function PHPStan\Testing\assertType; + +class HelloWorld +{ + public function parseUrl(string $url): string + { + $parsedUrl = parse_url(urldecode($url)); + assertType('array{scheme?: string, host?: string, port?: int<0, 65535>, user?: string, pass?: string, path?: string, query?: string, fragment?: string}|false', $parsedUrl); + + if (array_key_exists('host', $parsedUrl)) { + assertType('array{scheme?: string, host: string, port?: int<0, 65535>, user?: string, pass?: string, path?: string, query?: string, fragment?: string}', $parsedUrl); + throw new \RuntimeException('Absolute URLs are prohibited for the redirectTo parameter.'); + } + + assertType('array{scheme?: string, host?: string, port?: int<0, 65535>, user?: string, pass?: string, path?: string, query?: string, fragment?: string}', $parsedUrl); + + $redirectUrl = $parsedUrl['path']; + + if (array_key_exists('query', $parsedUrl)) { + assertType('array{scheme?: string, host?: string, port?: int<0, 65535>, user?: string, pass?: string, path?: string, query: string, fragment?: string}', $parsedUrl); + $redirectUrl .= '?' . $parsedUrl['query']; + } + + if (array_key_exists('fragment', $parsedUrl)) { + assertType('array{scheme?: string, host?: string, port?: int<0, 65535>, user?: string, pass?: string, path?: string, query?: string, fragment: string}', $parsedUrl); + $redirectUrl .= '#' . $parsedUrl['query']; + } + + assertType('array{scheme?: string, host?: string, port?: int<0, 65535>, user?: string, pass?: string, path?: string, query?: string, fragment?: string}', $parsedUrl); + + return $redirectUrl; + } + + public function doFoo(int $i) + { + $a = ['a' => $i]; + if (rand(0, 1)) { + $a['b'] = $i; + } + + if (rand(0,1)) { + $a = ['d' => $i]; + } + + assertType('array{a: int, b?: int}|array{d: int}', $a); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-2001.php b/tests/PHPStan/Analyser/nsrt/bug-2001.php index afc7e9d976..39cc52ff2a 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-2001.php +++ b/tests/PHPStan/Analyser/nsrt/bug-2001.php @@ -1,4 +1,4 @@ -, user?: string, pass?: string, path?: string, query?: string, fragment?: string}', $parsedUrl); + assertType('array{scheme?: string, port?: int<0, 65535>, user?: string, pass?: string, path?: string, query?: string, fragment?: string}|false', $parsedUrl); $redirectUrl = $parsedUrl['path']; if (array_key_exists('query', $parsedUrl)) { - assertType('array{scheme?: string, host?: string, port?: int<0, 65535>, user?: string, pass?: string, path?: string, query: string, fragment?: string}', $parsedUrl); + assertType('array{scheme?: string, port?: int<0, 65535>, user?: string, pass?: string, path?: string, query: string, fragment?: string}', $parsedUrl); $redirectUrl .= '?' . $parsedUrl['query']; } if (array_key_exists('fragment', $parsedUrl)) { - assertType('array{scheme?: string, host?: string, port?: int<0, 65535>, user?: string, pass?: string, path?: string, query?: string, fragment: string}', $parsedUrl); + assertType('array{scheme?: string, port?: int<0, 65535>, user?: string, pass?: string, path?: string, query?: string, fragment: string}', $parsedUrl); $redirectUrl .= '#' . $parsedUrl['query']; } - assertType('array{scheme?: string, host?: string, port?: int<0, 65535>, user?: string, pass?: string, path?: string, query?: string, fragment?: string}', $parsedUrl); + assertType('array{scheme?: string, port?: int<0, 65535>, user?: string, pass?: string, path?: string, query?: string, fragment?: string}|false', $parsedUrl); return $redirectUrl; } From 63ab3174bd2edf3a72b4b3a9755ec2fcb4e84c00 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 7 Oct 2025 15:35:58 +0200 Subject: [PATCH 7/8] fix wrong comment --- tests/PHPStan/Analyser/nsrt/bug-13301-php8.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13301-php8.php b/tests/PHPStan/Analyser/nsrt/bug-13301-php8.php index f8b481210f..ff420724a2 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13301-php8.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13301-php8.php @@ -37,7 +37,7 @@ function doFooFalsey($mixed) { if (array_key_exists('a', $mixed) == 0) { assertType("array", $mixed); } else { - assertType("non-empty-array&hasOffset('a')", $mixed); // could be array~hasOffset('a') after arrays got subtractable + assertType("non-empty-array&hasOffset('a')", $mixed); } assertType('array', $mixed); } From 7458a5352dacc1ab7334ac70d1c30b7a8ff9d0d6 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 8 Oct 2025 06:42:35 +0200 Subject: [PATCH 8/8] fix --- .../Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php | 1 + tests/PHPStan/Analyser/nsrt/bug-13301-php8.php | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php index b8c2baebb4..f5b7b31a3b 100644 --- a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php @@ -122,6 +122,7 @@ public function specifyTypes( && !$arrayType->isArray()->yes() ) { $type = new ArrayType(new MixedType(), new MixedType()); + $type = $type->unsetOffset($keyType); $context = $context->negate(); } else { $type = new HasOffsetType($keyType); diff --git a/tests/PHPStan/Analyser/nsrt/bug-13301-php8.php b/tests/PHPStan/Analyser/nsrt/bug-13301-php8.php index ff420724a2..ee1eb3428e 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13301-php8.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13301-php8.php @@ -9,7 +9,7 @@ function doFoo($mixed) { assertType("non-empty-array&hasOffset('a')", $mixed); echo "has-a"; } else { - assertType('array', $mixed); // could be array~hasOffset('a') after arrays got subtractable + assertType("array", $mixed); echo "NO-a"; } assertType('array', $mixed); @@ -19,7 +19,7 @@ function doFooTrue($mixed) { if (array_key_exists('a', $mixed) === true) { assertType("non-empty-array&hasOffset('a')", $mixed); } else { - assertType('array', $mixed); // could be array~hasOffset('a') after arrays got subtractable + assertType("array", $mixed); } assertType('array', $mixed); } @@ -28,14 +28,14 @@ function doFooTruethy($mixed) { if (array_key_exists('a', $mixed) == true) { assertType("non-empty-array&hasOffset('a')", $mixed); } else { - assertType('array', $mixed); // could be array~hasOffset('a') after arrays got subtractable + assertType("array", $mixed); } assertType('array', $mixed); } function doFooFalsey($mixed) { if (array_key_exists('a', $mixed) == 0) { - assertType("array", $mixed); + assertType("array", $mixed); } else { assertType("non-empty-array&hasOffset('a')", $mixed); }