From 23b2685aa1710440f3b5f3dae4baf9f1accac2dc Mon Sep 17 00:00:00 2001 From: Jeffrey Angenent <1571879+devfrey@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:19:03 +0100 Subject: [PATCH 1/3] Skip properties with default in `EnforceReadonlyPublicPropertyRule` --- src/Rule/EnforceReadonlyPublicPropertyRule.php | 10 +++++++++- .../data/EnforceReadonlyPublicPropertyRule/code-81.php | 4 ++++ .../data/EnforceReadonlyPublicPropertyRule/code-84.php | 4 ++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/Rule/EnforceReadonlyPublicPropertyRule.php b/src/Rule/EnforceReadonlyPublicPropertyRule.php index b6b77dc..bdc5a3d 100644 --- a/src/Rule/EnforceReadonlyPublicPropertyRule.php +++ b/src/Rule/EnforceReadonlyPublicPropertyRule.php @@ -41,7 +41,15 @@ public function processNode( return []; } - if (!$node->isPublic() || $node->isReadOnly() || $node->hasHooks() || $node->isPrivateSet() || $node->isProtectedSet() || $node->isStatic()) { + if ( + !$node->isPublic() + || $node->isReadOnly() + || $node->hasHooks() + || $node->isPrivateSet() + || $node->isProtectedSet() + || $node->isStatic() + || $node->getDefault() !== null + ) { return []; } diff --git a/tests/Rule/data/EnforceReadonlyPublicPropertyRule/code-81.php b/tests/Rule/data/EnforceReadonlyPublicPropertyRule/code-81.php index 23cde24..da02523 100644 --- a/tests/Rule/data/EnforceReadonlyPublicPropertyRule/code-81.php +++ b/tests/Rule/data/EnforceReadonlyPublicPropertyRule/code-81.php @@ -14,6 +14,8 @@ trait MyTrait { public static string $static; + public int $default = 42; + } class MyClass { @@ -30,6 +32,8 @@ class MyClass { public static string $static; + public int $quux = 7; + } readonly class MyReadonlyClass { diff --git a/tests/Rule/data/EnforceReadonlyPublicPropertyRule/code-84.php b/tests/Rule/data/EnforceReadonlyPublicPropertyRule/code-84.php index 43d4e27..d360eb0 100644 --- a/tests/Rule/data/EnforceReadonlyPublicPropertyRule/code-84.php +++ b/tests/Rule/data/EnforceReadonlyPublicPropertyRule/code-84.php @@ -16,6 +16,8 @@ trait MyTrait { public static string $static; + public int $default = 42; + } class MyClass { @@ -34,6 +36,8 @@ class MyClass { public static string $static; + public int $quux = 7; + } readonly class MyReadonlyClass { From ee3c32b5157d2177e5db477b245def7e65d7f39c Mon Sep 17 00:00:00 2001 From: Jeffrey Angenent <1571879+devfrey@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:26:04 +0100 Subject: [PATCH 2/3] Skip untyped properties in `EnforceReadonlyPublicPropertyRule` --- src/Rule/EnforceReadonlyPublicPropertyRule.php | 1 + tests/Rule/data/EnforceReadonlyPublicPropertyRule/code-81.php | 4 ++++ tests/Rule/data/EnforceReadonlyPublicPropertyRule/code-84.php | 4 ++++ 3 files changed, 9 insertions(+) diff --git a/src/Rule/EnforceReadonlyPublicPropertyRule.php b/src/Rule/EnforceReadonlyPublicPropertyRule.php index bdc5a3d..4c0110d 100644 --- a/src/Rule/EnforceReadonlyPublicPropertyRule.php +++ b/src/Rule/EnforceReadonlyPublicPropertyRule.php @@ -49,6 +49,7 @@ public function processNode( || $node->isProtectedSet() || $node->isStatic() || $node->getDefault() !== null + || $node->getNativeType() === null ) { return []; } diff --git a/tests/Rule/data/EnforceReadonlyPublicPropertyRule/code-81.php b/tests/Rule/data/EnforceReadonlyPublicPropertyRule/code-81.php index da02523..023b9d4 100644 --- a/tests/Rule/data/EnforceReadonlyPublicPropertyRule/code-81.php +++ b/tests/Rule/data/EnforceReadonlyPublicPropertyRule/code-81.php @@ -16,6 +16,8 @@ trait MyTrait { public int $default = 42; + public $untyped; + } class MyClass { @@ -34,6 +36,8 @@ class MyClass { public int $quux = 7; + public $quuz; + } readonly class MyReadonlyClass { diff --git a/tests/Rule/data/EnforceReadonlyPublicPropertyRule/code-84.php b/tests/Rule/data/EnforceReadonlyPublicPropertyRule/code-84.php index d360eb0..88f3010 100644 --- a/tests/Rule/data/EnforceReadonlyPublicPropertyRule/code-84.php +++ b/tests/Rule/data/EnforceReadonlyPublicPropertyRule/code-84.php @@ -18,6 +18,8 @@ trait MyTrait { public int $default = 42; + public $untyped; + } class MyClass { @@ -38,6 +40,8 @@ class MyClass { public int $quux = 7; + public $quuz; + } readonly class MyReadonlyClass { From f27c3b97169ec2162c935d5f53f43d9902ed5911 Mon Sep 17 00:00:00 2001 From: Jeffrey Angenent <1571879+devfrey@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:30:38 +0100 Subject: [PATCH 3/3] Add `excludePropertyWithDefaultValue` option --- README.md | 7 +++++ rules.neon | 4 +++ .../EnforceReadonlyPublicPropertyRule.php | 10 +++++-- .../EnforceReadonlyPublicPropertyRuleTest.php | 27 +++++++++++++++++-- .../code-81.php | 4 +-- .../code-84.php | 4 +-- .../exclude-property-with-default-value.php | 22 +++++++++++++++ 7 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 tests/Rule/data/EnforceReadonlyPublicPropertyRule/exclude-property-with-default-value.php diff --git a/README.md b/README.md index 0a035f4..b36b377 100644 --- a/README.md +++ b/README.md @@ -219,12 +219,19 @@ class NoNativeReturnTypehint { - Ensures immutability of all public properties by enforcing `readonly` modifier - No modifier needed for readonly classes in PHP 8.2 - Does nothing if PHP version does not support readonly properties (PHP 8.0 and below) +- Can be configured to exclude properties with a default value ```php class EnforceReadonlyPublicPropertyRule { public int $foo; // fails, no readonly modifier public readonly int $bar; } ``` +```neon +parameters: + shipmonkRules: + enforceReadonlyPublicProperty: + excludePropertyWithDefaultValue: true # defaults to false +``` ### forbidArithmeticOperationOnNonNumber - Disallows using [arithmetic operators](https://www.php.net/manual/en/language.operators.arithmetic.php) with non-numeric types (only `float`, `int` and `BcMath\Number` is allowed) diff --git a/rules.neon b/rules.neon index 687dbf3..c23929d 100644 --- a/rules.neon +++ b/rules.neon @@ -22,6 +22,7 @@ parameters: enabled: %shipmonkRules.enableAllRules% enforceReadonlyPublicProperty: enabled: %shipmonkRules.enableAllRules% + excludePropertyWithDefaultValue: false forbidArithmeticOperationOnNonNumber: enabled: %shipmonkRules.enableAllRules% allowNumericString: false @@ -121,6 +122,7 @@ parametersSchema: ]) enforceReadonlyPublicProperty: structure([ enabled: bool() + excludePropertyWithDefaultValue: bool() ]) forbidArithmeticOperationOnNonNumber: structure([ enabled: bool() @@ -325,6 +327,8 @@ services: treatPhpDocTypesAsCertain: %treatPhpDocTypesAsCertain% - class: ShipMonk\PHPStan\Rule\EnforceReadonlyPublicPropertyRule + arguments: + excludePropertyWithDefaultValue: %shipmonkRules.enforceReadonlyPublicProperty.excludePropertyWithDefaultValue% - class: ShipMonk\PHPStan\Rule\ForbidArithmeticOperationOnNonNumberRule arguments: diff --git a/src/Rule/EnforceReadonlyPublicPropertyRule.php b/src/Rule/EnforceReadonlyPublicPropertyRule.php index 4c0110d..daf0aa3 100644 --- a/src/Rule/EnforceReadonlyPublicPropertyRule.php +++ b/src/Rule/EnforceReadonlyPublicPropertyRule.php @@ -16,10 +16,16 @@ class EnforceReadonlyPublicPropertyRule implements Rule { + private bool $excludePropertyWithDefaultValue; + private PhpVersion $phpVersion; - public function __construct(PhpVersion $phpVersion) + public function __construct( + bool $excludePropertyWithDefaultValue, + PhpVersion $phpVersion + ) { + $this->excludePropertyWithDefaultValue = $excludePropertyWithDefaultValue; $this->phpVersion = $phpVersion; } @@ -48,8 +54,8 @@ public function processNode( || $node->isPrivateSet() || $node->isProtectedSet() || $node->isStatic() - || $node->getDefault() !== null || $node->getNativeType() === null + || ($this->excludePropertyWithDefaultValue && $node->getDefault() !== null) ) { return []; } diff --git a/tests/Rule/EnforceReadonlyPublicPropertyRuleTest.php b/tests/Rule/EnforceReadonlyPublicPropertyRuleTest.php index 6b21839..c4f9b2b 100644 --- a/tests/Rule/EnforceReadonlyPublicPropertyRuleTest.php +++ b/tests/Rule/EnforceReadonlyPublicPropertyRuleTest.php @@ -2,6 +2,7 @@ namespace ShipMonk\PHPStan\Rule; +use LogicException; use PHPStan\Php\PhpVersion; use PHPStan\Rules\Rule; use ShipMonk\PHPStan\RuleTestCase; @@ -13,12 +14,24 @@ class EnforceReadonlyPublicPropertyRuleTest extends RuleTestCase { + private ?bool $excludePropertyWithDefaultValue = null; + private ?PhpVersion $phpVersion = null; protected function getRule(): Rule { - self::assertNotNull($this->phpVersion); - return new EnforceReadonlyPublicPropertyRule($this->phpVersion); + if ($this->excludePropertyWithDefaultValue === null) { + throw new LogicException('excludePropertyWithDefaultValue must be set'); + } + + if ($this->phpVersion === null) { + throw new LogicException('phpVersion must be set'); + } + + return new EnforceReadonlyPublicPropertyRule( + $this->excludePropertyWithDefaultValue, + $this->phpVersion, + ); } public function testPhp84(): void @@ -27,22 +40,32 @@ public function testPhp84(): void self::markTestSkipped('PHP7 parser fails with property hooks'); } + $this->excludePropertyWithDefaultValue = false; $this->phpVersion = $this->createPhpVersion(80_400); $this->analyseFile(__DIR__ . '/data/EnforceReadonlyPublicPropertyRule/code-84.php'); } public function testPhp81(): void { + $this->excludePropertyWithDefaultValue = false; $this->phpVersion = $this->createPhpVersion(80_100); $this->analyseFile(__DIR__ . '/data/EnforceReadonlyPublicPropertyRule/code-81.php'); } public function testPhp80(): void { + $this->excludePropertyWithDefaultValue = false; $this->phpVersion = $this->createPhpVersion(80_000); $this->analyseFile(__DIR__ . '/data/EnforceReadonlyPublicPropertyRule/code-80.php'); } + public function testExcludePropertyWithDefaultValue(): void + { + $this->excludePropertyWithDefaultValue = true; + $this->phpVersion = $this->createPhpVersion(80_100); + $this->analyseFile(__DIR__ . '/data/EnforceReadonlyPublicPropertyRule/exclude-property-with-default-value.php'); + } + private function createPhpVersion(int $version): PhpVersion { return new PhpVersion($version); diff --git a/tests/Rule/data/EnforceReadonlyPublicPropertyRule/code-81.php b/tests/Rule/data/EnforceReadonlyPublicPropertyRule/code-81.php index 023b9d4..a3f326c 100644 --- a/tests/Rule/data/EnforceReadonlyPublicPropertyRule/code-81.php +++ b/tests/Rule/data/EnforceReadonlyPublicPropertyRule/code-81.php @@ -14,7 +14,7 @@ trait MyTrait { public static string $static; - public int $default = 42; + public int $default = 42; // error: Public property `default` not marked as readonly. public $untyped; @@ -34,7 +34,7 @@ class MyClass { public static string $static; - public int $quux = 7; + public int $quux = 7; // error: Public property `quux` not marked as readonly. public $quuz; diff --git a/tests/Rule/data/EnforceReadonlyPublicPropertyRule/code-84.php b/tests/Rule/data/EnforceReadonlyPublicPropertyRule/code-84.php index 88f3010..c6bf9c9 100644 --- a/tests/Rule/data/EnforceReadonlyPublicPropertyRule/code-84.php +++ b/tests/Rule/data/EnforceReadonlyPublicPropertyRule/code-84.php @@ -16,7 +16,7 @@ trait MyTrait { public static string $static; - public int $default = 42; + public int $default = 42; // error: Public property `default` not marked as readonly. public $untyped; @@ -38,7 +38,7 @@ class MyClass { public static string $static; - public int $quux = 7; + public int $quux = 7; // error: Public property `quux` not marked as readonly. public $quuz; diff --git a/tests/Rule/data/EnforceReadonlyPublicPropertyRule/exclude-property-with-default-value.php b/tests/Rule/data/EnforceReadonlyPublicPropertyRule/exclude-property-with-default-value.php new file mode 100644 index 0000000..06c4749 --- /dev/null +++ b/tests/Rule/data/EnforceReadonlyPublicPropertyRule/exclude-property-with-default-value.php @@ -0,0 +1,22 @@ +