From dc73664e8b5e97bdfabf07f13112ab15342374e9 Mon Sep 17 00:00:00 2001 From: HawkForge <75696486+HawkForge@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:28:29 +0600 Subject: [PATCH 1/3] Keep the analysed PHP version as the floor of a PHP_VERSION_ID < X guard When a single PHP version is analysed (a fixed `phpVersion`, or no version range from the config or composer.json), PHP_VERSION_ID is int<50207, max> and Scope::getPhpVersion() treats that as no information and answers with the analysed version. A guard like `if (PHP_VERSION_ID < 80400)` narrows the constant to int<50207, 80399>, and getPhpVersion() returned that range as is, so the version dropped to PHP 5.2.7 inside the branch. Since the "supported only on PHP X" rules read the version from the scope, a class with promoted properties in such a branch got a non-ignorable property.promotedNotSupported error with `phpVersion: 80500`. Only the upper bound of that range comes from the guard. Raise the lower bound to the analysed version, or to the guard's upper bound when the guard excludes the analysed version. Guards with a lower bound (`PHP_VERSION_ID >= 80300`) and configured version ranges are unchanged. The turbo-ext mirror gets the same change. Closes https://github.com/phpstan/phpstan/issues/15425 --- src/Analyser/MutatingScope.php | 54 +++++++-- .../InvalidPromotedPropertiesRuleTest.php | 6 + .../PHPStan/Rules/Classes/data/bug-15425.php | 16 +++ turbo-ext/src/MutatingScope.cpp | 106 ++++++++++++------ turbo-ext/src/generated/MutatingScope.h | 61 +++++----- turbo-ext/tests/scope-family.php | 32 ++++++ 6 files changed, 200 insertions(+), 75 deletions(-) create mode 100644 tests/PHPStan/Rules/Classes/data/bug-15425.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index b31ccecf985..ba9572e4fa5 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -136,6 +136,7 @@ use function is_string; use function ltrim; use function md5; +use function min; use function preg_match; use function preg_match_all; use function spl_object_id; @@ -6020,25 +6021,56 @@ public function getPhpVersion(): PhpVersions { $constType = $this->getGlobalConstantType(new Name('PHP_VERSION_ID')); if ($constType !== null && !$this->isOverallPhpVersionRange($constType)) { + if ( + $constType instanceof IntegerRangeType + && $constType->getMin() === ConstantResolver::PHP_MIN_ANALYZABLE_VERSION_ID + && $constType->getMax() !== null + && $this->getConfiguredPhpVersionRange() === null + ) { + // A guard like `PHP_VERSION_ID < 80400` only gives an upper bound. Without + // a configured version range, the lower bound is merely the lowest version + // PHPStan knows, while outside the guard the single analysed PHP version + // applies. Keep the lower bound at that version, or as close to it as + // the guard allows. + $maxVersionId = $constType->getMax(); + return new PhpVersions(IntegerRangeType::fromInterval( + min($this->phpVersion->getVersionId(), $maxVersionId), + $maxVersionId, + )); + } + return new PhpVersions($constType); } - // The analysed PHP version range comes either from the NEON phpVersion min/max - // config or from the composer.json "require.php" constraint - the very same - // source ConstantResolver narrows PHP_VERSION_ID with, so that - // Scope::getPhpVersion() never contradicts the PHP_VERSION_ID constant. + $configuredRange = $this->getConfiguredPhpVersionRange(); + if ($configuredRange !== null) { + return new PhpVersions($configuredRange); + } + + return new PhpVersions(new ConstantIntegerType($this->phpVersion->getVersionId())); + } + + /** + * The analysed PHP version range comes either from the NEON phpVersion min/max + * config or from the composer.json "require.php" constraint - the very same + * source ConstantResolver narrows PHP_VERSION_ID with, so that + * Scope::getPhpVersion() never contradicts the PHP_VERSION_ID constant. + * Null when neither is set and a single PHP version is analysed. + */ + private function getConfiguredPhpVersionRange(): ?Type + { [$minPhpVersion, $maxPhpVersion] = $this->configuredPhpVersionRangeHelper->getVersionRange(); if ( - $minPhpVersion !== null - || ($maxPhpVersion !== null && $maxPhpVersion->getVersionId() !== PhpVersionFactory::MAX_PHP_VERSION) + $minPhpVersion === null + && ($maxPhpVersion === null || $maxPhpVersion->getVersionId() === PhpVersionFactory::MAX_PHP_VERSION) ) { - return new PhpVersions(IntegerRangeType::fromInterval( - $minPhpVersion !== null ? $minPhpVersion->getVersionId() : ConstantResolver::PHP_MIN_ANALYZABLE_VERSION_ID, - $maxPhpVersion !== null ? $maxPhpVersion->getVersionId() : null, - )); + return null; } - return new PhpVersions(new ConstantIntegerType($this->phpVersion->getVersionId())); + return IntegerRangeType::fromInterval( + $minPhpVersion !== null ? $minPhpVersion->getVersionId() : ConstantResolver::PHP_MIN_ANALYZABLE_VERSION_ID, + $maxPhpVersion !== null ? $maxPhpVersion->getVersionId() : null, + ); } /** diff --git a/tests/PHPStan/Rules/Classes/InvalidPromotedPropertiesRuleTest.php b/tests/PHPStan/Rules/Classes/InvalidPromotedPropertiesRuleTest.php index e856be21354..4c109e94659 100644 --- a/tests/PHPStan/Rules/Classes/InvalidPromotedPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Classes/InvalidPromotedPropertiesRuleTest.php @@ -141,4 +141,10 @@ public function testConditionallyDeclaredClass(): void $this->analyse([__DIR__ . '/data/promoted-properties-php-versions.php'], $errors); } + #[RequiresPhp('>= 8.0.0')] + public function testBug15425(): void + { + $this->analyse([__DIR__ . '/data/bug-15425.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Classes/data/bug-15425.php b/tests/PHPStan/Rules/Classes/data/bug-15425.php new file mode 100644 index 00000000000..80a74c039bc --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/bug-15425.php @@ -0,0 +1,16 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug15425; + +if (PHP_VERSION_ID < 80400) { + \PHPStan\dumpType(PHP_VERSION_ID); + + final class Foo + { + public function __construct(public ?string $message = null) + { + } + } +} diff --git a/turbo-ext/src/MutatingScope.cpp b/turbo-ext/src/MutatingScope.cpp index 66e26133896..4a810496c9f 100644 --- a/turbo-ext/src/MutatingScope.cpp +++ b/turbo-ext/src/MutatingScope.cpp @@ -3908,11 +3908,14 @@ class MutatingScope if (UNEXPECTED(constType.isUndef())) return zv::Val(); bool isOverallPhpVersionRange = false; + bool hasMinAnalyzableVersion = false; + zv::Val max; if (constType.ref().isObject() && constType.ref().instanceOf(pt_ce_integer_range_type)) { zv::Val min = pt_type_call(constType.ref().asObject(), PT_LC("getmin"), 0, NULL); if (UNEXPECTED(min.isUndef())) return zv::Val(); if (min.ref().isLong() && min.ref().asLong() == PT_MS_PHP_MIN_ANALYZABLE_VERSION_ID) { - zv::Val max = pt_type_call(constType.ref().asObject(), PT_LC("getmax"), 0, NULL); + hasMinAnalyzableVersion = true; + max = pt_type_call(constType.ref().asObject(), PT_LC("getmax"), 0, NULL); if (UNEXPECTED(max.isUndef())) return zv::Val(); if (max.isNull() || (max.ref().isLong() && max.ref().asLong() == PT_MS_MAX_PHP_VERSION)) { isOverallPhpVersionRange = true; @@ -3920,12 +3923,56 @@ class MutatingScope } } - if (!constType.isNull() && !isOverallPhpVersionRange) return pt_type_new(PT_CLASS_PHP_VERSIONS, 1, constType.raw()); + if (!constType.isNull() && !isOverallPhpVersionRange) { + if (hasMinAnalyzableVersion && max.ref().isLong()) { + zv::Val configuredRange = getConfiguredPhpVersionRange(); + if (UNEXPECTED(configuredRange.isUndef())) return zv::Val(); + if (configuredRange.isNull()) { + // A guard like `PHP_VERSION_ID < 80400` only gives an upper bound. Without + // a configured version range, the lower bound is merely the lowest version + // PHPStan knows, while outside the guard the single analysed PHP version + // applies. Keep the lower bound at that version, or as close to it as + // the guard allows. + zv::Ref phpVersion = slot(PT_MS_PROP_PHP_VERSION); + if (UNEXPECTED(!phpVersion.isObject())) return uninitializedProperty("phpVersion"); + zv::Val versionId = pt_php_version_get_version_id(phpVersion.raw()); + if (UNEXPECTED(versionId.isUndef())) return zv::Val(); + zend_long maxVersionId = max.ref().asLong(); + zend_long minVersionId = zval_get_long(versionId.raw()); + zval interval[2]; + ZVAL_LONG(&interval[0], minVersionId < maxVersionId ? minVersionId : maxVersionId); + ZVAL_LONG(&interval[1], maxVersionId); + zv::Val versionRange = pt_type_call_static_ce(pt_ce_integer_range_type, PT_LC("frominterval"), 2, interval); + if (UNEXPECTED(versionRange.isUndef())) return zv::Val(); + return pt_type_new(PT_CLASS_PHP_VERSIONS, 1, versionRange.raw()); + } + } + + return pt_type_new(PT_CLASS_PHP_VERSIONS, 1, constType.raw()); + } + + zv::Val configuredRange = getConfiguredPhpVersionRange(); + if (UNEXPECTED(configuredRange.isUndef())) return zv::Val(); + if (!configuredRange.isNull()) return pt_type_new(PT_CLASS_PHP_VERSIONS, 1, configuredRange.raw()); + + zv::Ref phpVersion = slot(PT_MS_PROP_PHP_VERSION); + if (UNEXPECTED(!phpVersion.isObject())) return uninitializedProperty("phpVersion"); + zv::Val versionId = pt_php_version_get_version_id(phpVersion.raw()); + if (UNEXPECTED(versionId.isUndef())) return zv::Val(); + zval constantInteger; + if (UNEXPECTED(!pt_constant_integer_type_new(&constantInteger, zval_get_long(versionId.raw())))) return zv::Val(); + zv::Val constantIntegerType = zv::Val::adopt(constantInteger); + return pt_type_new(PT_CLASS_PHP_VERSIONS, 1, constantIntegerType.raw()); + } - // The analysed PHP version range comes either from the NEON phpVersion min/max - // config or from the composer.json "require.php" constraint - the very same - // source ConstantResolver narrows PHP_VERSION_ID with, so that - // Scope::getPhpVersion() never contradicts the PHP_VERSION_ID constant. + /* The analysed PHP version range comes either from the NEON phpVersion min/max + * config or from the composer.json "require.php" constraint - the very same + * source ConstantResolver narrows PHP_VERSION_ID with, so that + * Scope::getPhpVersion() never contradicts the PHP_VERSION_ID constant. + * NULL when neither is set and a single PHP version is analysed; UNDEF = + * pending exception */ + zv::Val getConfiguredPhpVersionRange() + { if (UNEXPECTED(!requireSlot(PT_MS_PROP_CONFIGURED_PHP_VERSION_RANGE_HELPER, "configuredPhpVersionRangeHelper"))) return zv::Val(); zv::Ref rangeHelper = slot(PT_MS_PROP_CONFIGURED_PHP_VERSION_RANGE_HELPER); zv::Val range = pt_type_call(rangeHelper.asObject(), PT_LC("getversionrange"), 0, NULL); @@ -3952,36 +3999,25 @@ class MutatingScope if (UNEXPECTED(maxVersionId.isUndef())) return zv::Val(); narrowed = !(maxVersionId.ref().isLong() && maxVersionId.ref().asLong() == PT_MS_MAX_PHP_VERSION); } - if (narrowed) { - zval interval[2]; - zv::Val minVersionId, maxVersionId; - if (minPhpVersion != NULL) { - minVersionId = pt_php_version_get_version_id(minPhpVersion); - if (UNEXPECTED(minVersionId.isUndef())) return zv::Val(); - ZVAL_COPY_VALUE(&interval[0], minVersionId.raw()); - } else { - ZVAL_LONG(&interval[0], PT_MS_PHP_MIN_ANALYZABLE_VERSION_ID); - } - if (maxPhpVersion != NULL) { - maxVersionId = pt_php_version_get_version_id(maxPhpVersion); - if (UNEXPECTED(maxVersionId.isUndef())) return zv::Val(); - ZVAL_COPY_VALUE(&interval[1], maxVersionId.raw()); - } else { - ZVAL_NULL(&interval[1]); - } - zv::Val versionRange = pt_type_call_static_ce(pt_ce_integer_range_type, PT_LC("frominterval"), 2, interval); - if (UNEXPECTED(versionRange.isUndef())) return zv::Val(); - return pt_type_new(PT_CLASS_PHP_VERSIONS, 1, versionRange.raw()); - } + if (!narrowed) return zv::Val::null(); - zv::Ref phpVersion = slot(PT_MS_PROP_PHP_VERSION); - if (UNEXPECTED(!phpVersion.isObject())) return uninitializedProperty("phpVersion"); - zv::Val versionId = pt_php_version_get_version_id(phpVersion.raw()); - if (UNEXPECTED(versionId.isUndef())) return zv::Val(); - zval constantInteger; - if (UNEXPECTED(!pt_constant_integer_type_new(&constantInteger, zval_get_long(versionId.raw())))) return zv::Val(); - zv::Val constantIntegerType = zv::Val::adopt(constantInteger); - return pt_type_new(PT_CLASS_PHP_VERSIONS, 1, constantIntegerType.raw()); + zval interval[2]; + zv::Val minVersionId, maxVersionId; + if (minPhpVersion != NULL) { + minVersionId = pt_php_version_get_version_id(minPhpVersion); + if (UNEXPECTED(minVersionId.isUndef())) return zv::Val(); + ZVAL_COPY_VALUE(&interval[0], minVersionId.raw()); + } else { + ZVAL_LONG(&interval[0], PT_MS_PHP_MIN_ANALYZABLE_VERSION_ID); + } + if (maxPhpVersion != NULL) { + maxVersionId = pt_php_version_get_version_id(maxPhpVersion); + if (UNEXPECTED(maxVersionId.isUndef())) return zv::Val(); + ZVAL_COPY_VALUE(&interval[1], maxVersionId.raw()); + } else { + ZVAL_NULL(&interval[1]); + } + return pt_type_call_static_ce(pt_ce_integer_range_type, PT_LC("frominterval"), 2, interval); } /* $this->getPhpVersion()->supportsNamedArguments()->no() negated — the diff --git a/turbo-ext/src/generated/MutatingScope.h b/turbo-ext/src/generated/MutatingScope.h index 832eb9a9226..4e3ae75c523 100644 --- a/turbo-ext/src/generated/MutatingScope.h +++ b/turbo-ext/src/generated/MutatingScope.h @@ -528,21 +528,22 @@ inline constexpr char strings[] = "getIterableValueType\0" /* 9182 */ "PHPStan\\Php\\PhpVersions\0" /* 9203 */ "getPhpVersion\0" /* 9227 */ - "isOverallPhpVersionRange\0" /* 9241 */ - "PhpParser\\Node\0" /* 9266 */ - "invokeNodeCallback\0" /* 9281 */ - "collectorType\0" /* 9300 */ - "data\0" /* 9314 */ - "emitCollectedData\0" /* 9319 */ - "extensionClass\0" /* 9337 */ - "trackValueDependency\0" /* 9352 */ - "file\0" /* 9373 */ - "trackFileDependency\0" /* 9378 */ - "directory\0" /* 9398 */ - "pattern\0" /* 9408 */ - "'*'\0" /* 9416 */ - "trackDirectoryDependency\0" /* 9420 */ - "trackClassDependency"; /* 9445 */ + "getConfiguredPhpVersionRange\0" /* 9241 */ + "isOverallPhpVersionRange\0" /* 9270 */ + "PhpParser\\Node\0" /* 9295 */ + "invokeNodeCallback\0" /* 9310 */ + "collectorType\0" /* 9329 */ + "data\0" /* 9343 */ + "emitCollectedData\0" /* 9348 */ + "extensionClass\0" /* 9366 */ + "trackValueDependency\0" /* 9381 */ + "file\0" /* 9402 */ + "trackFileDependency\0" /* 9407 */ + "directory\0" /* 9427 */ + "pattern\0" /* 9437 */ + "'*'\0" /* 9445 */ + "trackDirectoryDependency\0" /* 9449 */ + "trackClassDependency"; /* 9474 */ inline constexpr reg::PackedArg args[] = { reg::packed(0, 0, 10), /* __construct $container */ reg::packed(48, 0, 61), /* __construct $scopeFactory */ @@ -1070,20 +1071,21 @@ inline constexpr reg::PackedArg args[] = { reg::packed(5878, 0, 2162), /* getIterableValueType $iteratee */ reg::packed(1476, 0, 2162), /* getIterableValueType return */ reg::packed(1476, 0, 9203), /* getPhpVersion return */ + reg::packed(1476, MAY_BE_NULL, 2162), /* getConfiguredPhpVersionRange return */ reg::packed(4614, 0, 2162), /* isOverallPhpVersionRange $type */ reg::packed(1476, MAY_BE_BOOL), /* isOverallPhpVersionRange return */ - reg::packed(2500, 0, 9266), /* invokeNodeCallback $node */ + reg::packed(2500, 0, 9295), /* invokeNodeCallback $node */ reg::packed(1476, MAY_BE_VOID), /* invokeNodeCallback return */ - reg::packed(9300, MAY_BE_STRING), /* emitCollectedData $collectorType */ - reg::packed(9314, MAY_BE_ANY), /* emitCollectedData $data */ + reg::packed(9329, MAY_BE_STRING), /* emitCollectedData $collectorType */ + reg::packed(9343, MAY_BE_ANY), /* emitCollectedData $data */ reg::packed(1476, MAY_BE_VOID), /* emitCollectedData return */ - reg::packed(9337, MAY_BE_STRING), /* trackValueDependency $extensionClass */ + reg::packed(9366, MAY_BE_STRING), /* trackValueDependency $extensionClass */ reg::packed(3280, MAY_BE_STRING), /* trackValueDependency $key */ reg::packed(1476, MAY_BE_VOID), /* trackValueDependency return */ - reg::packed(9373, MAY_BE_STRING), /* trackFileDependency $file */ + reg::packed(9402, MAY_BE_STRING), /* trackFileDependency $file */ reg::packed(1476, MAY_BE_VOID), /* trackFileDependency return */ - reg::packed(9398, MAY_BE_STRING), /* trackDirectoryDependency $directory */ - reg::packed(9408, MAY_BE_STRING, reg::NoString, false, false, 9416), /* trackDirectoryDependency $pattern */ + reg::packed(9427, MAY_BE_STRING), /* trackDirectoryDependency $directory */ + reg::packed(9437, MAY_BE_STRING, reg::NoString, false, false, 9445), /* trackDirectoryDependency $pattern */ reg::packed(1476, MAY_BE_VOID), /* trackDirectoryDependency return */ reg::packed(3717, MAY_BE_STRING), /* trackClassDependency $className */ reg::packed(1476, MAY_BE_VOID), /* trackClassDependency return */ @@ -1285,13 +1287,14 @@ inline constexpr sigtab::Sig getNativeConstantTypes = { { 9140 /* getNativeConst inline constexpr sigtab::Sig getIterableKeyType = { { 9163 /* getIterableKeyType */, 1, 521, 1, 522, ZEND_ACC_PUBLIC } }; inline constexpr sigtab::Sig getIterableValueType = { { 9182 /* getIterableValueType */, 1, 523, 1, 524, ZEND_ACC_PUBLIC } }; inline constexpr sigtab::Sig getPhpVersion = { { 9227 /* getPhpVersion */, 0, 525, 0, 525, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig isOverallPhpVersionRange = { { 9241 /* isOverallPhpVersionRange */, 1, 526, 1, 527, ZEND_ACC_PRIVATE } }; -inline constexpr sigtab::Sig invokeNodeCallback = { { 9281 /* invokeNodeCallback */, 1, 528, 1, 529, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig emitCollectedData = { { 9319 /* emitCollectedData */, 2, 530, 2, 532, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig trackValueDependency = { { 9352 /* trackValueDependency */, 2, 533, 2, 535, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig trackFileDependency = { { 9378 /* trackFileDependency */, 1, 536, 1, 537, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig trackDirectoryDependency = { { 9420 /* trackDirectoryDependency */, 1, 538, 2, 540, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig trackClassDependency = { { 9445 /* trackClassDependency */, 1, 541, 1, 542, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig getConfiguredPhpVersionRange = { { 9241 /* getConfiguredPhpVersionRange */, 0, 526, 0, 526, ZEND_ACC_PRIVATE } }; +inline constexpr sigtab::Sig isOverallPhpVersionRange = { { 9270 /* isOverallPhpVersionRange */, 1, 527, 1, 528, ZEND_ACC_PRIVATE } }; +inline constexpr sigtab::Sig invokeNodeCallback = { { 9310 /* invokeNodeCallback */, 1, 529, 1, 530, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig emitCollectedData = { { 9348 /* emitCollectedData */, 2, 531, 2, 533, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig trackValueDependency = { { 9381 /* trackValueDependency */, 2, 534, 2, 536, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig trackFileDependency = { { 9407 /* trackFileDependency */, 1, 537, 1, 538, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig trackDirectoryDependency = { { 9449 /* trackDirectoryDependency */, 1, 539, 2, 541, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig trackClassDependency = { { 9474 /* trackClassDependency */, 1, 542, 1, 543, ZEND_ACC_PUBLIC } }; } // namespace sig } // namespace ptdecl::MutatingScope diff --git a/turbo-ext/tests/scope-family.php b/turbo-ext/tests/scope-family.php index a0fb591f0d5..4d085699dcc 100644 --- a/turbo-ext/tests/scope-family.php +++ b/turbo-ext/tests/scope-family.php @@ -643,6 +643,35 @@ public function exprRef(Node\Expr $expr): mixed return [$table, $table, []]; }; +// PHP_VERSION_ID under a guard that only gives an upper bound (`PHP_VERSION_ID < X`), +// with a single analysed version pinned to phpVersion: 80500 whatever PHP runs +// this: getPhpVersion() raises the lower bound to the analysed version, or as +// close to it as the guard allows - once with the analysed version above the +// guard's bound and once below it +$sfGuardedVersionScopes = []; +foreach ([80399, 80599] as $sfGuardedMax) { + $sfGuardedVersionFetch = new \PhpParser\Node\Expr\ConstFetch(new \PhpParser\Node\Name('PHP_VERSION_ID')); + $sfGuardedVersionArgs = $sfHarness->constructorArgs( + $sfScopeFactory->create(\PHPStan\Analyser\ScopeContext::create($sfFile)) + ->assignExpression($sfGuardedVersionFetch, \PHPStan\Type\IntegerRangeType::fromInterval(\PHPStan\Analyser\ConstantResolver::PHP_MIN_ANALYZABLE_VERSION_ID, $sfGuardedMax), new \PHPStan\Type\IntegerType()), + ); + $sfGuardedVersionArgs['phpVersion'] = new \PHPStan\Php\PhpVersion(80500); + $sfGuardedVersionArgs['configuredPhpVersionRangeHelper'] = new \PHPStan\Php\ConfiguredPhpVersionRangeHelper(80500, $scContainer->getByType(\PHPStan\Php\ComposerPhpVersionFactory::class)); + $sfGuardedVersionScope = new \PHPStan\Analyser\MutatingScope(...array_values($sfGuardedVersionArgs)); + $sfScopes[spl_object_id($sfGuardedVersionScope)] = [$sfGuardedVersionScope, [$sfGuardedVersionFetch], null]; + $sfGuardedVersionScopes[] = [$sfGuardedVersionScope, static function (string $side) use ($sfHarness, $sfGuardedVersionFetch, $sfGuardedMax): array { + $native = $side === 'native'; + $range = $native + ? \PHPStanTurbo\IntegerRangeType::fromInterval(\PHPStan\Analyser\ConstantResolver::PHP_MIN_ANALYZABLE_VERSION_ID, $sfGuardedMax) + : \PHPStan\Type\IntegerRangeType::fromInterval(\PHPStan\Analyser\ConstantResolver::PHP_MIN_ANALYZABLE_VERSION_ID, $sfGuardedMax); + $holder = $native + ? new \PHPStanTurbo\ExpressionTypeHolder($sfGuardedVersionFetch, $range, \PHPStanTurbo\TrinaryLogic::createYes()) + : new \PHPStan\Analyser\ExpressionTypeHolder($sfGuardedVersionFetch, $range, \PHPStan\TrinaryLogic::createYes()); + $table = [$sfHarness->key($sfGuardedVersionFetch) => $holder]; + + return [$table, $table, []]; + }]; +} foreach ([80500, 70400] as $sfVersionId) { $sfVersionScope = $sfScopeFactory->create(\PHPStan\Analyser\ScopeContext::create($sfFile)->enterClass($sfReflectionProvider->getClass(\ScopeFamilyFixture\Holder::class))) ->assignExpression(new \PhpParser\Node\Expr\ConstFetch(new \PhpParser\Node\Name('PHP_VERSION_ID')), new \PHPStan\Type\Constant\ConstantIntegerType($sfVersionId), new \PHPStan\Type\IntegerType()); @@ -759,6 +788,9 @@ public function exprRef(Node\Expr $expr): mixed // the scopes whose tables are built per side (each side's own Type classes) $sfScopes[spl_object_id($sfSyntheticScope)][3] = $sfSyntheticTables; $sfScopes[spl_object_id($sfOverallVersionScope)][3] = $sfOverallVersionTables; +foreach ($sfGuardedVersionScopes as [$sfGuardedVersionScope, $sfGuardedVersionTables]) { + $sfScopes[spl_object_id($sfGuardedVersionScope)][3] = $sfGuardedVersionTables; +} $sfPromotedArgs = $sfHarness->constructorArgs($sfSyntheticScope); $sfPromotedArgs['nativeTypesPromoted'] = true; $sfPromotedScope = new \PHPStan\Analyser\MutatingScope(...array_values($sfPromotedArgs)); From 6bc2941151e9e98752b197b1b792fc35679f43b8 Mon Sep 17 00:00:00 2001 From: HawkForge <75696486+HawkForge@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:46:59 +0600 Subject: [PATCH 2/3] Cover the configured range check and the analysed-version floor for PHP_VERSION_ID < X guards A Composer constraint without a lower bound keeps the guard's full range, and an analysed version below the guard stays the floor. --- ...motedPropertiesRulePhpVersionRangeTest.php | 38 +++++++++++++++++++ .../InvalidPromotedPropertiesRuleTest.php | 7 +++- .../Classes/ReadOnlyClassRulePhp81Test.php | 38 +++++++++++++++++++ .../data/bug-15425-composer/composer.json | 5 +++ .../Rules/Classes/data/bug-15425-range.php | 14 +++++++ .../Rules/Classes/data/bug-15425-readonly.php | 11 ++++++ .../PHPStan/Rules/Classes/data/bug-15425.php | 11 +++++- ...alidPromotedPropertiesPhpVersionRange.neon | 4 ++ .../Rules/Classes/readOnlyClassPhp81.neon | 2 + 9 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 tests/PHPStan/Rules/Classes/InvalidPromotedPropertiesRulePhpVersionRangeTest.php create mode 100644 tests/PHPStan/Rules/Classes/ReadOnlyClassRulePhp81Test.php create mode 100644 tests/PHPStan/Rules/Classes/data/bug-15425-composer/composer.json create mode 100644 tests/PHPStan/Rules/Classes/data/bug-15425-range.php create mode 100644 tests/PHPStan/Rules/Classes/data/bug-15425-readonly.php create mode 100644 tests/PHPStan/Rules/Classes/invalidPromotedPropertiesPhpVersionRange.neon create mode 100644 tests/PHPStan/Rules/Classes/readOnlyClassPhp81.neon diff --git a/tests/PHPStan/Rules/Classes/InvalidPromotedPropertiesRulePhpVersionRangeTest.php b/tests/PHPStan/Rules/Classes/InvalidPromotedPropertiesRulePhpVersionRangeTest.php new file mode 100644 index 00000000000..a08a70b713b --- /dev/null +++ b/tests/PHPStan/Rules/Classes/InvalidPromotedPropertiesRulePhpVersionRangeTest.php @@ -0,0 +1,38 @@ + + */ +class InvalidPromotedPropertiesRulePhpVersionRangeTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new InvalidPromotedPropertiesRule(); + } + + #[RequiresPhp('>= 8.0.0')] + public function testBug15425(): void + { + $this->analyse([__DIR__ . '/data/bug-15425-range.php'], [ + [ + 'Promoted properties are supported only on PHP 8.0 and later.', + 10, + ], + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/invalidPromotedPropertiesPhpVersionRange.neon', + ]; + } + +} diff --git a/tests/PHPStan/Rules/Classes/InvalidPromotedPropertiesRuleTest.php b/tests/PHPStan/Rules/Classes/InvalidPromotedPropertiesRuleTest.php index 4c109e94659..5692d594e11 100644 --- a/tests/PHPStan/Rules/Classes/InvalidPromotedPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Classes/InvalidPromotedPropertiesRuleTest.php @@ -144,7 +144,12 @@ public function testConditionallyDeclaredClass(): void #[RequiresPhp('>= 8.0.0')] public function testBug15425(): void { - $this->analyse([__DIR__ . '/data/bug-15425.php'], []); + $this->analyse([__DIR__ . '/data/bug-15425.php'], [ + [ + 'Promoted properties are supported only on PHP 8.0 and later.', + 19, + ], + ]); } } diff --git a/tests/PHPStan/Rules/Classes/ReadOnlyClassRulePhp81Test.php b/tests/PHPStan/Rules/Classes/ReadOnlyClassRulePhp81Test.php new file mode 100644 index 00000000000..d1ac1f2b520 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/ReadOnlyClassRulePhp81Test.php @@ -0,0 +1,38 @@ + + */ +class ReadOnlyClassRulePhp81Test extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new ReadOnlyClassRule(); + } + + #[RequiresPhp('>= 8.2.0')] + public function testBug15425(): void + { + $this->analyse([__DIR__ . '/data/bug-15425-readonly.php'], [ + [ + 'Readonly classes are supported only on PHP 8.2 and later.', + 8, + ], + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/readOnlyClassPhp81.neon', + ]; + } + +} diff --git a/tests/PHPStan/Rules/Classes/data/bug-15425-composer/composer.json b/tests/PHPStan/Rules/Classes/data/bug-15425-composer/composer.json new file mode 100644 index 00000000000..04cb27ac932 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/bug-15425-composer/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "php": "<8.4" + } +} diff --git a/tests/PHPStan/Rules/Classes/data/bug-15425-range.php b/tests/PHPStan/Rules/Classes/data/bug-15425-range.php new file mode 100644 index 00000000000..bd9fb78b2d6 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/bug-15425-range.php @@ -0,0 +1,14 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug15425Range; + +if (PHP_VERSION_ID < 80300) { + final class Foo + { + public function __construct(public ?string $message = null) + { + } + } +} diff --git a/tests/PHPStan/Rules/Classes/data/bug-15425-readonly.php b/tests/PHPStan/Rules/Classes/data/bug-15425-readonly.php new file mode 100644 index 00000000000..3f5a34cb1c7 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/bug-15425-readonly.php @@ -0,0 +1,11 @@ += 8.2 + +declare(strict_types = 1); + +namespace Bug15425Readonly; + +if (PHP_VERSION_ID < 80400) { + final readonly class Foo + { + } +} diff --git a/tests/PHPStan/Rules/Classes/data/bug-15425.php b/tests/PHPStan/Rules/Classes/data/bug-15425.php index 80a74c039bc..cc579e4ac1a 100644 --- a/tests/PHPStan/Rules/Classes/data/bug-15425.php +++ b/tests/PHPStan/Rules/Classes/data/bug-15425.php @@ -5,8 +5,6 @@ namespace Bug15425; if (PHP_VERSION_ID < 80400) { - \PHPStan\dumpType(PHP_VERSION_ID); - final class Foo { public function __construct(public ?string $message = null) @@ -14,3 +12,12 @@ public function __construct(public ?string $message = null) } } } + +if (PHP_VERSION_ID < 80000) { + final class Bar + { + public function __construct(public ?string $message = null) + { + } + } +} diff --git a/tests/PHPStan/Rules/Classes/invalidPromotedPropertiesPhpVersionRange.neon b/tests/PHPStan/Rules/Classes/invalidPromotedPropertiesPhpVersionRange.neon new file mode 100644 index 00000000000..c8ede5a79bc --- /dev/null +++ b/tests/PHPStan/Rules/Classes/invalidPromotedPropertiesPhpVersionRange.neon @@ -0,0 +1,4 @@ +parameters: + # composer.json "php": "<8.4" gives a version range without a lower bound + composerAutoloaderProjectPaths: + - %rootDir%/tests/PHPStan/Rules/Classes/data/bug-15425-composer diff --git a/tests/PHPStan/Rules/Classes/readOnlyClassPhp81.neon b/tests/PHPStan/Rules/Classes/readOnlyClassPhp81.neon new file mode 100644 index 00000000000..181c74319a3 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/readOnlyClassPhp81.neon @@ -0,0 +1,2 @@ +parameters: + phpVersion: 80100 From aa91ba8134f86dad0ce6fd2779946347cff6ed89 Mon Sep 17 00:00:00 2001 From: HawkForge <75696486+HawkForge@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:47:00 +0600 Subject: [PATCH 3/3] 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 c2200f4bdd1..df0f42d5be0 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -34,7 +34,7 @@ final class TurboExtensionEnabler { - public const EXPECTED_EXTENSION_VERSION = '1cb5faf'; + public const EXPECTED_EXTENSION_VERSION = 'dc73664'; private static bool $active = false;