From 1200b1fc77258d4282f5e87ca9571fb753e701d8 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Wed, 6 May 2026 19:24:25 +0000 Subject: [PATCH 1/6] Widen empty `ConstantArrayType` to `array` when resolving dynamic constant types - `ConstantArrayType::generalize(lessSpecific)` returns `$this` for empty arrays (0 keys), so `resolveConstantType()` and `resolveClassConstantType()` were returning `array{}` for dynamic constants defined as `[]` - Add `generalizeDynamicConstantType()` helper in `ConstantResolver` that falls back to `ArrayType(MixedType, MixedType)` when `generalize()` returns a type that is still a constant value and is an array - Fixes false positive "Offset X on array{} in isset() does not exist" for dynamic array constants - Covers both global constants (`define('FOO', [])`) and class constants (`const FOO = []`) listed in `dynamicConstantNames` --- src/Analyser/ConstantResolver.php | 15 +++++++++++++-- tests/PHPStan/Analyser/data/dynamic-constant.php | 5 +++++ tests/PHPStan/Analyser/dynamic-constants.neon | 2 ++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ConstantResolver.php b/src/Analyser/ConstantResolver.php index 52f3b515e3f..6e6a1685783 100644 --- a/src/Analyser/ConstantResolver.php +++ b/src/Analyser/ConstantResolver.php @@ -13,6 +13,7 @@ use PHPStan\Reflection\ReflectionProvider\ReflectionProviderProvider; use PHPStan\ShouldNotHappenException; use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; +use PHPStan\Type\ArrayType; use PHPStan\Type\Constant\ConstantFloatType; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; @@ -424,7 +425,7 @@ public function resolveConstantType(string $constantName, Type $constantType): T return $constantType; } if (in_array($constantName, $this->dynamicConstantNames, true)) { - return $constantType->generalize(GeneralizePrecision::lessSpecific()); + return $this->generalizeDynamicConstantType($constantType); } } @@ -459,13 +460,23 @@ public function resolveClassConstantType(string $className, string $constantName } if ($constantType->isConstantValue()->yes()) { - return $constantType->generalize(GeneralizePrecision::lessSpecific()); + return $this->generalizeDynamicConstantType($constantType); } } return $constantType; } + private function generalizeDynamicConstantType(Type $constantType): Type + { + $generalized = $constantType->generalize(GeneralizePrecision::lessSpecific()); + if ($generalized->isConstantValue()->yes() && $generalized->isArray()->yes()) { + return new ArrayType(new MixedType(), new MixedType()); + } + + return $generalized; + } + private function createInteger(?int $min, ?int $max): Type { if ($min !== null && $min === $max) { diff --git a/tests/PHPStan/Analyser/data/dynamic-constant.php b/tests/PHPStan/Analyser/data/dynamic-constant.php index 7509236df61..af9be058fe1 100644 --- a/tests/PHPStan/Analyser/data/dynamic-constant.php +++ b/tests/PHPStan/Analyser/data/dynamic-constant.php @@ -7,6 +7,7 @@ define('GLOBAL_PURE_CONSTANT', 123); define('GLOBAL_DYNAMIC_CONSTANT', false); define('GLOBAL_DYNAMIC_CONSTANT_WITH_EXPLICIT_TYPES', null); +define('GLOBAL_DYNAMIC_EMPTY_ARRAY', []); class DynamicConstantClass { @@ -22,6 +23,8 @@ class DynamicConstantClass /** @var int */ const DYNAMIC_INCOMPATIBLE_PHPDOC_CONSTANT = null; + + const DYNAMIC_EMPTY_ARRAY_NO_PHPDOC = []; } class NoDynamicConstantClass @@ -41,5 +44,7 @@ private function rip() assertType('string|null', DynamicConstantClass::DYNAMIC_NULL_WITH_PHPDOC_CONSTANT); assertType('list', DynamicConstantClass::DYNAMIC_EMPTY_ARRAY_WITH_PHPDOC_CONSTANT); assertType('int', DynamicConstantClass::DYNAMIC_INCOMPATIBLE_PHPDOC_CONSTANT); + assertType('array', DynamicConstantClass::DYNAMIC_EMPTY_ARRAY_NO_PHPDOC); + assertType('array', GLOBAL_DYNAMIC_EMPTY_ARRAY); } } diff --git a/tests/PHPStan/Analyser/dynamic-constants.neon b/tests/PHPStan/Analyser/dynamic-constants.neon index 2dfb2167aff..324a24b4a8f 100644 --- a/tests/PHPStan/Analyser/dynamic-constants.neon +++ b/tests/PHPStan/Analyser/dynamic-constants.neon @@ -7,6 +7,8 @@ parameters: - DynamicConstants\DynamicConstantClass::DYNAMIC_NULL_WITH_PHPDOC_CONSTANT - DynamicConstants\DynamicConstantClass::DYNAMIC_EMPTY_ARRAY_WITH_PHPDOC_CONSTANT - DynamicConstants\DynamicConstantClass::DYNAMIC_INCOMPATIBLE_PHPDOC_CONSTANT + - DynamicConstants\DynamicConstantClass::DYNAMIC_EMPTY_ARRAY_NO_PHPDOC - GLOBAL_DYNAMIC_CONSTANT + - GLOBAL_DYNAMIC_EMPTY_ARRAY DynamicConstants\DynamicConstantClass::DYNAMIC_CONSTANT_WITH_EXPLICIT_TYPES_IN_CLASS: 'string|null' GLOBAL_DYNAMIC_CONSTANT_WITH_EXPLICIT_TYPES: 'string|null' From fa0540a8ebfba5fa40558c86e44b7aa9d11dcd66 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 6 May 2026 20:35:12 +0000 Subject: [PATCH 2/6] Add integration test for bug 8526 verifying no false positives with dynamic constants Tests the actual user scenario: define('FOO', true) and define('DYNAMICARRAY', []) with dynamicConstantNames should not report "Offset on array{} in isset() does not exist" or "If condition is always true". Co-Authored-By: Claude Opus 4.6 --- .../Analyser/Bug8526IntegrationTest.php | 53 +++++++++++++++++++ tests/PHPStan/Analyser/bug-8526.neon | 7 +++ tests/PHPStan/Analyser/data/bug-8526.php | 16 ++++++ 3 files changed, 76 insertions(+) create mode 100644 tests/PHPStan/Analyser/Bug8526IntegrationTest.php create mode 100644 tests/PHPStan/Analyser/bug-8526.neon create mode 100644 tests/PHPStan/Analyser/data/bug-8526.php diff --git a/tests/PHPStan/Analyser/Bug8526IntegrationTest.php b/tests/PHPStan/Analyser/Bug8526IntegrationTest.php new file mode 100644 index 00000000000..2600ccd28af --- /dev/null +++ b/tests/PHPStan/Analyser/Bug8526IntegrationTest.php @@ -0,0 +1,53 @@ +runAnalyse(__DIR__ . '/data/bug-8526.php'); + $this->assertNoErrors($errors); + } + + /** + * @return list + */ + private function runAnalyse(string $file): array + { + $file = $this->getFileHelper()->normalizePath($file); + + $analyser = self::getContainer()->getByType(Analyser::class); + $finalizer = self::getContainer()->getByType(AnalyserResultFinalizer::class); + $errors = $finalizer->finalize( + $analyser->analyse([$file], null, null, true), + false, + true, + )->getErrors(); + foreach ($errors as $error) { + $this->assertSame($file, $error->getFilePath()); + } + + return $errors; + } + + public static function getAdditionalConfigFiles(): array + { + return array_unique( + array_merge( + parent::getAdditionalConfigFiles(), + [ + __DIR__ . '/bug-8526.neon', + ], + ), + ); + } + +} diff --git a/tests/PHPStan/Analyser/bug-8526.neon b/tests/PHPStan/Analyser/bug-8526.neon new file mode 100644 index 00000000000..a3e89eab620 --- /dev/null +++ b/tests/PHPStan/Analyser/bug-8526.neon @@ -0,0 +1,7 @@ +includes: + - ../../../conf/bleedingEdge.neon + +parameters: + dynamicConstantNames: + - FOO + - DYNAMICARRAY diff --git a/tests/PHPStan/Analyser/data/bug-8526.php b/tests/PHPStan/Analyser/data/bug-8526.php new file mode 100644 index 00000000000..ba035357a06 --- /dev/null +++ b/tests/PHPStan/Analyser/data/bug-8526.php @@ -0,0 +1,16 @@ + Date: Wed, 6 May 2026 20:50:54 +0000 Subject: [PATCH 3/6] Rename generalizeDynamicConstantType to generalizeConstantType The method operates on any constant value type, not specifically dynamic constants. The previous name was misleading about its scope. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ConstantResolver.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Analyser/ConstantResolver.php b/src/Analyser/ConstantResolver.php index 6e6a1685783..a010960137f 100644 --- a/src/Analyser/ConstantResolver.php +++ b/src/Analyser/ConstantResolver.php @@ -425,7 +425,7 @@ public function resolveConstantType(string $constantName, Type $constantType): T return $constantType; } if (in_array($constantName, $this->dynamicConstantNames, true)) { - return $this->generalizeDynamicConstantType($constantType); + return $this->generalizeConstantType($constantType); } } @@ -460,14 +460,14 @@ public function resolveClassConstantType(string $className, string $constantName } if ($constantType->isConstantValue()->yes()) { - return $this->generalizeDynamicConstantType($constantType); + return $this->generalizeConstantType($constantType); } } return $constantType; } - private function generalizeDynamicConstantType(Type $constantType): Type + private function generalizeConstantType(Type $constantType): Type { $generalized = $constantType->generalize(GeneralizePrecision::lessSpecific()); if ($generalized->isConstantValue()->yes() && $generalized->isArray()->yes()) { From c1366e167d93d3cb2018a80508efa00384fae693 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Wed, 6 May 2026 22:56:17 +0200 Subject: [PATCH 4/6] Rename --- src/Analyser/ConstantResolver.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Analyser/ConstantResolver.php b/src/Analyser/ConstantResolver.php index a010960137f..6e6a1685783 100644 --- a/src/Analyser/ConstantResolver.php +++ b/src/Analyser/ConstantResolver.php @@ -425,7 +425,7 @@ public function resolveConstantType(string $constantName, Type $constantType): T return $constantType; } if (in_array($constantName, $this->dynamicConstantNames, true)) { - return $this->generalizeConstantType($constantType); + return $this->generalizeDynamicConstantType($constantType); } } @@ -460,14 +460,14 @@ public function resolveClassConstantType(string $className, string $constantName } if ($constantType->isConstantValue()->yes()) { - return $this->generalizeConstantType($constantType); + return $this->generalizeDynamicConstantType($constantType); } } return $constantType; } - private function generalizeConstantType(Type $constantType): Type + private function generalizeDynamicConstantType(Type $constantType): Type { $generalized = $constantType->generalize(GeneralizePrecision::lessSpecific()); if ($generalized->isConstantValue()->yes() && $generalized->isArray()->yes()) { From f79e4b827ec7defe3c4682715590f57ad2df2c2e Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 6 May 2026 21:08:14 +0000 Subject: [PATCH 5/6] Check specifically for empty ConstantArrayType after generalization Narrow the fallback check from any constant array value to specifically an empty ConstantArrayType using equals(). This is safer against future changes to ConstantArrayType::generalize() and documents the exact edge case being handled. Also restores the generalizeConstantType name since the method generalizes any constant type, not just dynamic ones. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ConstantResolver.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Analyser/ConstantResolver.php b/src/Analyser/ConstantResolver.php index 6e6a1685783..ee340ea1a3a 100644 --- a/src/Analyser/ConstantResolver.php +++ b/src/Analyser/ConstantResolver.php @@ -14,6 +14,7 @@ use PHPStan\ShouldNotHappenException; use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; use PHPStan\Type\ArrayType; +use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantFloatType; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; @@ -425,7 +426,7 @@ public function resolveConstantType(string $constantName, Type $constantType): T return $constantType; } if (in_array($constantName, $this->dynamicConstantNames, true)) { - return $this->generalizeDynamicConstantType($constantType); + return $this->generalizeConstantType($constantType); } } @@ -460,17 +461,17 @@ public function resolveClassConstantType(string $className, string $constantName } if ($constantType->isConstantValue()->yes()) { - return $this->generalizeDynamicConstantType($constantType); + return $this->generalizeConstantType($constantType); } } return $constantType; } - private function generalizeDynamicConstantType(Type $constantType): Type + private function generalizeConstantType(Type $constantType): Type { $generalized = $constantType->generalize(GeneralizePrecision::lessSpecific()); - if ($generalized->isConstantValue()->yes() && $generalized->isArray()->yes()) { + if ($generalized->equals(new ConstantArrayType([], []))) { return new ArrayType(new MixedType(), new MixedType()); } From 5cddc725a5b52a8ab4170e05108619276f6130c9 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 6 May 2026 21:26:13 +0000 Subject: [PATCH 6/6] Rename generalizeConstantType back to generalizeDynamicConstantType The method is only called in contexts where the constant is in dynamicConstantNames, so the "Dynamic" qualifier is appropriate. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ConstantResolver.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Analyser/ConstantResolver.php b/src/Analyser/ConstantResolver.php index ee340ea1a3a..d304ef5a64d 100644 --- a/src/Analyser/ConstantResolver.php +++ b/src/Analyser/ConstantResolver.php @@ -426,7 +426,7 @@ public function resolveConstantType(string $constantName, Type $constantType): T return $constantType; } if (in_array($constantName, $this->dynamicConstantNames, true)) { - return $this->generalizeConstantType($constantType); + return $this->generalizeDynamicConstantType($constantType); } } @@ -461,14 +461,14 @@ public function resolveClassConstantType(string $className, string $constantName } if ($constantType->isConstantValue()->yes()) { - return $this->generalizeConstantType($constantType); + return $this->generalizeDynamicConstantType($constantType); } } return $constantType; } - private function generalizeConstantType(Type $constantType): Type + private function generalizeDynamicConstantType(Type $constantType): Type { $generalized = $constantType->generalize(GeneralizePrecision::lessSpecific()); if ($generalized->equals(new ConstantArrayType([], []))) {