Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 43 additions & 11 deletions src/Analyser/MutatingScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
);
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/Turbo/TurboExtensionEnabler.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
final class TurboExtensionEnabler
{

public const EXPECTED_EXTENSION_VERSION = '1cb5faf';
public const EXPECTED_EXTENSION_VERSION = 'dc73664';

private static bool $active = false;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Classes;

use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;
use PHPUnit\Framework\Attributes\RequiresPhp;

/**
* @extends RuleTestCase<InvalidPromotedPropertiesRule>
*/
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',
];
}

}
11 changes: 11 additions & 0 deletions tests/PHPStan/Rules/Classes/InvalidPromotedPropertiesRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,4 +141,15 @@ 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'], [
[
'Promoted properties are supported only on PHP 8.0 and later.',
19,
],
]);
}

}
38 changes: 38 additions & 0 deletions tests/PHPStan/Rules/Classes/ReadOnlyClassRulePhp81Test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Classes;

use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;
use PHPUnit\Framework\Attributes\RequiresPhp;

/**
* @extends RuleTestCase<ReadOnlyClassRule>
*/
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',
];
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{

@staabm staabm Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need the composer.json files? I think the PHP_VERSION_ID < 80400 should be enough.

does the expected behaviour only reproduce with a composer.json?

"require": {
"php": "<8.4"
}
}
14 changes: 14 additions & 0 deletions tests/PHPStan/Rules/Classes/data/bug-15425-range.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php // lint >= 8.0

declare(strict_types = 1);

namespace Bug15425Range;

if (PHP_VERSION_ID < 80300) {
final class Foo
{
public function __construct(public ?string $message = null)
{
}
}
}
11 changes: 11 additions & 0 deletions tests/PHPStan/Rules/Classes/data/bug-15425-readonly.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php // lint >= 8.2

declare(strict_types = 1);

namespace Bug15425Readonly;

if (PHP_VERSION_ID < 80400) {
final readonly class Foo
{
}
}
23 changes: 23 additions & 0 deletions tests/PHPStan/Rules/Classes/data/bug-15425.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php // lint >= 8.0

declare(strict_types = 1);

namespace Bug15425;

if (PHP_VERSION_ID < 80400) {

@staabm staabm Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please add another variant of this condition in the test in which the previously reported false-positive-error would be valid

final class Foo
{
public function __construct(public ?string $message = null)
{
}
}
}

if (PHP_VERSION_ID < 80000) {
final class Bar
{
public function __construct(public ?string $message = null)
{
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
parameters:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this config?

# composer.json "php": "<8.4" gives a version range without a lower bound
composerAutoloaderProjectPaths:
- %rootDir%/tests/PHPStan/Rules/Classes/data/bug-15425-composer
2 changes: 2 additions & 0 deletions tests/PHPStan/Rules/Classes/readOnlyClassPhp81.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
parameters:
phpVersion: 80100
106 changes: 71 additions & 35 deletions turbo-ext/src/MutatingScope.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3908,24 +3908,71 @@ 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;
}
}
}

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);
Expand All @@ -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
Expand Down
Loading
Loading