From ec6051b66021a679275ebcb27595c39baf73cce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Tue, 13 Jan 2026 23:49:33 +0100 Subject: [PATCH 01/16] Adding Property related Rules --- README.md | 2 + .../EntityWithAccessors.php | 65 ++++ .../ServiceWithAccessors.php | 18 + data/PropertyMustMatch/TestClass.php | 99 +++++ docs/Rules.md | 46 +++ docs/rules/Forbidden-Accessors-Rule.md | 67 ++++ docs/rules/Property-Must-Match-Rule.md | 122 +++++++ src/Architecture/ForbiddenAccessorsRule.php | 158 ++++++++ src/Architecture/PropertyMustMatchRule.php | 342 ++++++++++++++++++ .../ForbiddenAccessorsRuleGettersOnlyTest.php | 38 ++ ...enAccessorsRuleProtectedVisibilityTest.php | 54 +++ .../ForbiddenAccessorsRuleSettersOnlyTest.php | 38 ++ .../ForbiddenAccessorsRuleTest.php | 51 +++ .../PropertyMustMatchRuleNullableTest.php | 43 +++ .../PropertyMustMatchRuleTest.php | 105 ++++++ 15 files changed, 1248 insertions(+) create mode 100644 data/ForbiddenAccessors/EntityWithAccessors.php create mode 100644 data/ForbiddenAccessors/ServiceWithAccessors.php create mode 100644 data/PropertyMustMatch/TestClass.php create mode 100644 docs/rules/Forbidden-Accessors-Rule.md create mode 100644 docs/rules/Property-Must-Match-Rule.md create mode 100644 src/Architecture/ForbiddenAccessorsRule.php create mode 100644 src/Architecture/PropertyMustMatchRule.php create mode 100644 tests/TestCases/Architecture/ForbiddenAccessorsRuleGettersOnlyTest.php create mode 100644 tests/TestCases/Architecture/ForbiddenAccessorsRuleProtectedVisibilityTest.php create mode 100644 tests/TestCases/Architecture/ForbiddenAccessorsRuleSettersOnlyTest.php create mode 100644 tests/TestCases/Architecture/ForbiddenAccessorsRuleTest.php create mode 100644 tests/TestCases/Architecture/PropertyMustMatchRuleNullableTest.php create mode 100644 tests/TestCases/Architecture/PropertyMustMatchRuleTest.php diff --git a/README.md b/README.md index aaa47ef..41e8b51 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ See individual rule documentation for detailed configuration examples. A [full c - [Methods Returning Bool Must Follow Naming Convention Rule](docs/rules/Methods-Returning-Bool-Must-Follow-Naming-Convention-Rule.md) - [Method Signature Must Match Rule](docs/rules/Method-Signature-Must-Match-Rule.md) - [Method Must Return Type Rule](docs/rules/Method-Must-Return-Type-Rule.md) +- [Property Must Match Rule](docs/rules/Property-Must-Match-Rule.md) +- [Forbidden Accessors Rule](docs/rules/Forbidden-Accessors-Rule.md) ### Clean Code Rules diff --git a/data/ForbiddenAccessors/EntityWithAccessors.php b/data/ForbiddenAccessors/EntityWithAccessors.php new file mode 100644 index 0000000..011d28e --- /dev/null +++ b/data/ForbiddenAccessors/EntityWithAccessors.php @@ -0,0 +1,65 @@ +name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getAge(): int + { + return $this->age; + } + + public function setAge(int $age): void + { + $this->age = $age; + } + + protected function getActive(): bool + { + return $this->active; + } + + protected function setActive(bool $active): void + { + $this->active = $active; + } + + private function getPrivateValue(): string + { + return 'private'; + } + + private function setPrivateValue(string $value): void + { + // This should not trigger an error (private) + } + + public function doSomething(): void + { + // Regular method, should not trigger + } + + public function get(): void + { + // Should not trigger - no uppercase letter after 'get' + } + + public function set(): void + { + // Should not trigger - no uppercase letter after 'set' + } +} diff --git a/data/ForbiddenAccessors/ServiceWithAccessors.php b/data/ForbiddenAccessors/ServiceWithAccessors.php new file mode 100644 index 0000000..817e89b --- /dev/null +++ b/data/ForbiddenAccessors/ServiceWithAccessors.php @@ -0,0 +1,18 @@ +config; + } + + public function setConfig(string $config): void + { + $this->config = $config; + } +} diff --git a/data/PropertyMustMatch/TestClass.php b/data/PropertyMustMatch/TestClass.php new file mode 100644 index 0000000..5dfc2b4 --- /dev/null +++ b/data/PropertyMustMatch/TestClass.php @@ -0,0 +1,99 @@ + + */ +class ForbiddenAccessorsRule implements Rule +{ + private const ERROR_MESSAGE_GETTER = 'Class %s must not have a %s getter method %s().'; + private const ERROR_MESSAGE_SETTER = 'Class %s must not have a %s setter method %s().'; + private const IDENTIFIER = 'phauthentic.architecture.forbiddenAccessors'; + + private const GETTER_PATTERN = '/^get[A-Z]/'; + private const SETTER_PATTERN = '/^set[A-Z]/'; + + /** + * @param array $classPatterns Regex patterns to match against class FQCNs. + * @param bool $forbidGetters Whether to forbid getXxx() methods. + * @param bool $forbidSetters Whether to forbid setXxx() methods. + * @param array $visibility Array of visibilities to check ('public', 'protected'). + */ + public function __construct( + protected array $classPatterns, + protected bool $forbidGetters = true, + protected bool $forbidSetters = true, + protected array $visibility = ['public'] + ) { + } + + public function getNodeType(): string + { + return Class_::class; + } + + /** + * @param Class_ $node + * @param Scope $scope + * @return array<\PHPStan\Rules\RuleError> + */ + public function processNode(Node $node, Scope $scope): array + { + if (!isset($node->name)) { + return []; + } + + $className = $node->name->toString(); + $namespaceName = $scope->getNamespace() ?? ''; + $fullClassName = $namespaceName !== '' ? $namespaceName . '\\' . $className : $className; + + if (!$this->matchesClassPatterns($fullClassName)) { + return []; + } + + $errors = []; + + foreach ($node->getMethods() as $method) { + $methodName = $method->name->toString(); + $methodVisibility = $this->getMethodVisibility($method); + + if (!in_array($methodVisibility, $this->visibility, true)) { + continue; + } + + if ($this->forbidGetters && preg_match(self::GETTER_PATTERN, $methodName)) { + $errors[] = $this->buildGetterError($fullClassName, $methodVisibility, $methodName, $method->getLine()); + } + + if ($this->forbidSetters && preg_match(self::SETTER_PATTERN, $methodName)) { + $errors[] = $this->buildSetterError($fullClassName, $methodVisibility, $methodName, $method->getLine()); + } + } + + return $errors; + } + + /** + * Check if the class FQCN matches any of the configured patterns. + */ + private function matchesClassPatterns(string $fullClassName): bool + { + foreach ($this->classPatterns as $pattern) { + if (preg_match($pattern, $fullClassName)) { + return true; + } + } + + return false; + } + + /** + * Get the visibility of a method as a string. + */ + private function getMethodVisibility(Node\Stmt\ClassMethod $method): string + { + if ($method->isPublic()) { + return 'public'; + } + + if ($method->isProtected()) { + return 'protected'; + } + + return 'private'; + } + + /** + * @return \PHPStan\Rules\RuleError + */ + private function buildGetterError(string $fullClassName, string $visibility, string $methodName, int $line) + { + return RuleErrorBuilder::message( + sprintf(self::ERROR_MESSAGE_GETTER, $fullClassName, $visibility, $methodName) + ) + ->identifier(self::IDENTIFIER) + ->line($line) + ->build(); + } + + /** + * @return \PHPStan\Rules\RuleError + */ + private function buildSetterError(string $fullClassName, string $visibility, string $methodName, int $line) + { + return RuleErrorBuilder::message( + sprintf(self::ERROR_MESSAGE_SETTER, $fullClassName, $visibility, $methodName) + ) + ->identifier(self::IDENTIFIER) + ->line($line) + ->build(); + } +} diff --git a/src/Architecture/PropertyMustMatchRule.php b/src/Architecture/PropertyMustMatchRule.php new file mode 100644 index 0000000..794f128 --- /dev/null +++ b/src/Architecture/PropertyMustMatchRule.php @@ -0,0 +1,342 @@ + + * }> $propertyPatterns + */ + public function __construct( + protected array $propertyPatterns + ) { + } + + public function getNodeType(): string + { + return Class_::class; + } + + /** + * @param Class_ $node + * @param Scope $scope + * @return array + */ + public function processNode(Node $node, Scope $scope): array + { + $className = $node->name?->toString() ?? ''; + + if ($className === '') { + return []; + } + + $classProperties = $this->getClassProperties($node); + $matchingPatterns = $this->getMatchingPatterns($className); + + $errors = []; + foreach ($matchingPatterns as $patternConfig) { + $errors = array_merge( + $errors, + $this->validatePatternProperties($patternConfig, $classProperties, $className, $node->getLine()) + ); + } + + return $errors; + } + + /** + * @return array + */ + private function getMatchingPatterns(string $className): array + { + return array_filter( + $this->propertyPatterns, + fn(array $config): bool => (bool) preg_match($config['classPattern'], $className) + ); + } + + /** + * @param array{classPattern: string, properties: array} $patternConfig + * @param array $classProperties + * @return array<\PHPStan\Rules\RuleError> + */ + private function validatePatternProperties( + array $patternConfig, + array $classProperties, + string $className, + int $classLine + ): array { + $errors = []; + + foreach ($patternConfig['properties'] as $propertyRule) { + $errors = array_merge( + $errors, + $this->validatePropertyRule($propertyRule, $classProperties, $className, $classLine) + ); + } + + return $errors; + } + + /** + * @param array $classProperties + * @return array<\PHPStan\Rules\RuleError> + */ + private function validatePropertyRule( + array $propertyRule, + array $classProperties, + string $className, + int $classLine + ): array { + $propertyName = $propertyRule['name']; + + if (!isset($classProperties[$propertyName])) { + return $this->handleMissingProperty($propertyRule, $className, $propertyName, $classLine); + } + + return $this->validateExistingProperty($propertyRule, $classProperties[$propertyName], $className, $propertyName); + } + + /** + * @return array<\PHPStan\Rules\RuleError> + */ + private function handleMissingProperty( + array $propertyRule, + string $className, + string $propertyName, + int $classLine + ): array { + $isRequired = $propertyRule['required'] ?? false; + + if (!$isRequired) { + return []; + } + + return [ + RuleErrorBuilder::message( + sprintf(self::ERROR_MESSAGE_MISSING_PROPERTY, $className, $propertyName) + ) + ->identifier(self::IDENTIFIER) + ->line($classLine) + ->build() + ]; + } + + /** + * @return array<\PHPStan\Rules\RuleError> + */ + private function validateExistingProperty( + array $propertyRule, + Property $property, + string $className, + string $propertyName + ): array { + return array_filter([ + $this->validatePropertyType($propertyRule, $property, $className, $propertyName), + $this->validateVisibilityScope($propertyRule, $property, $className, $propertyName), + ]); + } + + /** + * Get all properties from a class indexed by name. + * + * @param Class_ $node + * @return array + */ + private function getClassProperties(Class_ $node): array + { + $properties = []; + + foreach ($node->getProperties() as $property) { + foreach ($property->props as $prop) { + $properties[$prop->name->toString()] = $property; + } + } + + return $properties; + } + + /** + * Validate property type against expected type. + * + * @param array $propertyRule + * @param Property $property + * @param string $className + * @param string $propertyName + * @return \PHPStan\Rules\RuleError|null + */ + private function validatePropertyType( + array $propertyRule, + Property $property, + string $className, + string $propertyName + ): ?\PHPStan\Rules\RuleError { + if (!isset($propertyRule['type']) || $propertyRule['type'] === null) { + return null; + } + + $expectedType = $propertyRule['type']; + $actualType = $this->getTypeAsString($property->type); + $nullable = $propertyRule['nullable'] ?? false; + + if ($this->typeMatches($actualType, $expectedType, $nullable)) { + return null; + } + + return $this->buildTypeError( + $className, + $propertyName, + $this->formatExpectedType($expectedType, $nullable), + $actualType ?? 'none', + $property->getLine() + ); + } + + private function typeMatches(?string $actualType, string $expectedType, bool $nullable): bool + { + if ($actualType === $expectedType) { + return true; + } + + return $nullable && $actualType === '?' . $expectedType; + } + + private function formatExpectedType(string $expectedType, bool $nullable): string + { + if (!$nullable) { + return $expectedType; + } + + return $expectedType . ' or ?' . $expectedType; + } + + private function buildTypeError( + string $className, + string $propertyName, + string $expectedType, + string $actualType, + int $line + ): \PHPStan\Rules\RuleError { + return RuleErrorBuilder::message( + sprintf( + self::ERROR_MESSAGE_WRONG_TYPE, + $className, + $propertyName, + $expectedType, + $actualType + ) + ) + ->identifier(self::IDENTIFIER) + ->line($line) + ->build(); + } + + /** + * Validate property visibility scope. + * + * @param array $propertyRule + * @param Property $property + * @param string $className + * @param string $propertyName + * @return \PHPStan\Rules\RuleError|null + */ + private function validateVisibilityScope( + array $propertyRule, + Property $property, + string $className, + string $propertyName + ): ?\PHPStan\Rules\RuleError { + if (!isset($propertyRule['visibilityScope']) || $propertyRule['visibilityScope'] === null) { + return null; + } + + $expectedVisibility = $propertyRule['visibilityScope']; + $isValid = match ($expectedVisibility) { + 'public' => $property->isPublic(), + 'protected' => $property->isProtected(), + 'private' => $property->isPrivate(), + default => true, + }; + + if (!$isValid) { + return RuleErrorBuilder::message( + sprintf( + self::ERROR_MESSAGE_VISIBILITY_SCOPE, + $className, + $propertyName, + $expectedVisibility + ) + ) + ->identifier(self::IDENTIFIER) + ->line($property->getLine()) + ->build(); + } + + return null; + } + + /** + * Convert a type node to string representation. + * + * @param mixed $type + * @return string|null + */ + private function getTypeAsString(mixed $type): ?string + { + return match (true) { + $type === null => null, + $type instanceof Identifier => $type->name, + $type instanceof Name => $type->toString(), + $type instanceof NullableType => + ($inner = $this->getTypeAsString($type->type)) !== null ? '?' . $inner : null, + default => null, + }; + } +} diff --git a/tests/TestCases/Architecture/ForbiddenAccessorsRuleGettersOnlyTest.php b/tests/TestCases/Architecture/ForbiddenAccessorsRuleGettersOnlyTest.php new file mode 100644 index 0000000..722291e --- /dev/null +++ b/tests/TestCases/Architecture/ForbiddenAccessorsRuleGettersOnlyTest.php @@ -0,0 +1,38 @@ + + */ +class ForbiddenAccessorsRuleGettersOnlyTest extends RuleTestCase +{ + protected function getRule(): ForbiddenAccessorsRule + { + return new ForbiddenAccessorsRule( + classPatterns: ['/\\\\Domain\\\\.*Entity$/'], + forbidGetters: true, + forbidSetters: false, + visibility: ['public'] + ); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/../../../data/ForbiddenAccessors/EntityWithAccessors.php'], [ + [ + 'Class App\Domain\UserEntity must not have a public getter method getName().', + 11, + ], + [ + 'Class App\Domain\UserEntity must not have a public getter method getAge().', + 21, + ], + ]); + } +} diff --git a/tests/TestCases/Architecture/ForbiddenAccessorsRuleProtectedVisibilityTest.php b/tests/TestCases/Architecture/ForbiddenAccessorsRuleProtectedVisibilityTest.php new file mode 100644 index 0000000..65d1525 --- /dev/null +++ b/tests/TestCases/Architecture/ForbiddenAccessorsRuleProtectedVisibilityTest.php @@ -0,0 +1,54 @@ + + */ +class ForbiddenAccessorsRuleProtectedVisibilityTest extends RuleTestCase +{ + protected function getRule(): ForbiddenAccessorsRule + { + return new ForbiddenAccessorsRule( + classPatterns: ['/\\\\Domain\\\\.*Entity$/'], + forbidGetters: true, + forbidSetters: true, + visibility: ['public', 'protected'] + ); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/../../../data/ForbiddenAccessors/EntityWithAccessors.php'], [ + [ + 'Class App\Domain\UserEntity must not have a public getter method getName().', + 11, + ], + [ + 'Class App\Domain\UserEntity must not have a public setter method setName().', + 16, + ], + [ + 'Class App\Domain\UserEntity must not have a public getter method getAge().', + 21, + ], + [ + 'Class App\Domain\UserEntity must not have a public setter method setAge().', + 26, + ], + [ + 'Class App\Domain\UserEntity must not have a protected getter method getActive().', + 31, + ], + [ + 'Class App\Domain\UserEntity must not have a protected setter method setActive().', + 36, + ], + ]); + } +} diff --git a/tests/TestCases/Architecture/ForbiddenAccessorsRuleSettersOnlyTest.php b/tests/TestCases/Architecture/ForbiddenAccessorsRuleSettersOnlyTest.php new file mode 100644 index 0000000..2f3dce2 --- /dev/null +++ b/tests/TestCases/Architecture/ForbiddenAccessorsRuleSettersOnlyTest.php @@ -0,0 +1,38 @@ + + */ +class ForbiddenAccessorsRuleSettersOnlyTest extends RuleTestCase +{ + protected function getRule(): ForbiddenAccessorsRule + { + return new ForbiddenAccessorsRule( + classPatterns: ['/\\\\Domain\\\\.*Entity$/'], + forbidGetters: false, + forbidSetters: true, + visibility: ['public'] + ); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/../../../data/ForbiddenAccessors/EntityWithAccessors.php'], [ + [ + 'Class App\Domain\UserEntity must not have a public setter method setName().', + 16, + ], + [ + 'Class App\Domain\UserEntity must not have a public setter method setAge().', + 26, + ], + ]); + } +} diff --git a/tests/TestCases/Architecture/ForbiddenAccessorsRuleTest.php b/tests/TestCases/Architecture/ForbiddenAccessorsRuleTest.php new file mode 100644 index 0000000..31cb50c --- /dev/null +++ b/tests/TestCases/Architecture/ForbiddenAccessorsRuleTest.php @@ -0,0 +1,51 @@ + + */ +class ForbiddenAccessorsRuleTest extends RuleTestCase +{ + protected function getRule(): ForbiddenAccessorsRule + { + return new ForbiddenAccessorsRule( + classPatterns: ['/\\\\Domain\\\\.*Entity$/'], + forbidGetters: true, + forbidSetters: true, + visibility: ['public'] + ); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/../../../data/ForbiddenAccessors/EntityWithAccessors.php'], [ + [ + 'Class App\Domain\UserEntity must not have a public getter method getName().', + 11, + ], + [ + 'Class App\Domain\UserEntity must not have a public setter method setName().', + 16, + ], + [ + 'Class App\Domain\UserEntity must not have a public getter method getAge().', + 21, + ], + [ + 'Class App\Domain\UserEntity must not have a public setter method setAge().', + 26, + ], + ]); + } + + public function testRuleDoesNotMatchNonEntityClasses(): void + { + $this->analyse([__DIR__ . '/../../../data/ForbiddenAccessors/ServiceWithAccessors.php'], []); + } +} diff --git a/tests/TestCases/Architecture/PropertyMustMatchRuleNullableTest.php b/tests/TestCases/Architecture/PropertyMustMatchRuleNullableTest.php new file mode 100644 index 0000000..46c9a14 --- /dev/null +++ b/tests/TestCases/Architecture/PropertyMustMatchRuleNullableTest.php @@ -0,0 +1,43 @@ + + */ +class PropertyMustMatchRuleNullableTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new PropertyMustMatchRule([ + [ + 'classPattern' => '/^.*Handler$/', + 'properties' => [ + [ + 'name' => 'id', + 'type' => 'int', + 'visibilityScope' => 'private', + 'nullable' => true, + ], + ], + ], + ]); + } + + public function testNullableFlag(): void + { + $this->analyse([__DIR__ . '/../../../data/PropertyMustMatch/TestClass.php'], [ + // WrongTypeAllowedHandler - wrong type entirely (string instead of int or ?int) + [ + 'Property WrongTypeAllowedHandler::$id should be of type int or ?int, string given.', + 98, + ], + ]); + } +} diff --git a/tests/TestCases/Architecture/PropertyMustMatchRuleTest.php b/tests/TestCases/Architecture/PropertyMustMatchRuleTest.php new file mode 100644 index 0000000..a87accf --- /dev/null +++ b/tests/TestCases/Architecture/PropertyMustMatchRuleTest.php @@ -0,0 +1,105 @@ + + */ +class PropertyMustMatchRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new PropertyMustMatchRule([ + [ + 'classPattern' => '/^.*Controller$/', + 'properties' => [ + [ + 'name' => 'id', + 'type' => 'int', + 'visibilityScope' => 'private', + 'required' => true, + ], + [ + 'name' => 'repository', + 'type' => 'DummyRepository', + 'visibilityScope' => 'private', + 'required' => true, + ], + ], + ], + [ + 'classPattern' => '/^.*Service$/', + 'properties' => [ + [ + 'name' => 'logger', + 'type' => 'LoggerInterface', + 'visibilityScope' => 'private', + 'required' => false, + ], + ], + ], + ]); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/../../../data/PropertyMustMatch/TestClass.php'], [ + // MissingPropertyController - missing required property 'id' + [ + 'Class MissingPropertyController must have property $id.', + 17, + ], + + // WrongTypeController - wrong type for 'id' (string instead of int) + [ + 'Property WrongTypeController::$id should be of type int, string given.', + 25, + ], + + // WrongVisibilityController - wrong visibility for 'id' (public instead of private) + [ + 'Property WrongVisibilityController::$id must be private.', + 32, + ], + + // MultipleErrorsController - wrong type and wrong visibility for 'id' + [ + 'Property MultipleErrorsController::$id should be of type int, string given.', + 39, + ], + [ + 'Property MultipleErrorsController::$id must be private.', + 39, + ], + // MultipleErrorsController - wrong visibility for 'repository' (protected instead of private) + [ + 'Property MultipleErrorsController::$repository must be private.', + 40, + ], + + // NoTypeController - missing type on 'id' property + [ + 'Property NoTypeController::$id should be of type int, none given.', + 46, + ], + + // NullableTypeController - nullable type doesn't match expected 'int' + [ + 'Property NullableTypeController::$id should be of type int, ?int given.', + 53, + ], + + // WrongLoggerTypeService - wrong type for optional 'logger' property + [ + 'Property WrongLoggerTypeService::$logger should be of type LoggerInterface, string given.', + 72, + ], + ]); + } +} From 80f42bfcda84a3530c7bfd3762699b43c663a02f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Wed, 14 Jan 2026 00:12:30 +0100 Subject: [PATCH 02/16] Adding a rule config builder to help creating rule configs --- rule-builder.html | 1815 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1815 insertions(+) create mode 100644 rule-builder.html diff --git a/rule-builder.html b/rule-builder.html new file mode 100644 index 0000000..4cf19bc --- /dev/null +++ b/rule-builder.html @@ -0,0 +1,1815 @@ + + + + + + PHPStan Rule Builder + + + + + +
+
+

PHPStan Rule Builder

+

Configure PHPStan architecture rules with a visual form builder

+
+
+ +
+
+
+
+
+ + +
+
+
+
+ +
+
+
+
Configuration
+
+
+ Select a rule above to start configuring +
+
+
+
+
+
+
+
+ YAML Preview + +
+
+
+ YAML output will appear here +
+
+
+
+
+
+
+ + + + From dea429c54c6ca8d90b073e8a1fe76ecfc58132c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Mon, 9 Feb 2026 23:21:29 +0100 Subject: [PATCH 03/16] Fix PropertyMustMatchRule to match against FQCN instead of short class name The rule was using only the short class name for pattern matching and error messages, inconsistent with all other class-based rules in the codebase. Now uses $scope->getNamespace() to resolve the full class name, matching the approach used by ForbiddenAccessorsRule and other rules. Co-authored-by: Cursor --- data/PropertyMustMatch/TestClass.php | 2 + src/Architecture/PropertyMustMatchRule.php | 13 +++--- .../PropertyMustMatchRuleNullableTest.php | 4 +- .../PropertyMustMatchRuleTest.php | 40 +++++++++---------- 4 files changed, 32 insertions(+), 27 deletions(-) diff --git a/data/PropertyMustMatch/TestClass.php b/data/PropertyMustMatch/TestClass.php index 5dfc2b4..0a557a6 100644 --- a/data/PropertyMustMatch/TestClass.php +++ b/data/PropertyMustMatch/TestClass.php @@ -2,6 +2,8 @@ declare(strict_types=1); +namespace App\PropertyMustMatch; + class DummyRepository { } diff --git a/src/Architecture/PropertyMustMatchRule.php b/src/Architecture/PropertyMustMatchRule.php index 794f128..dec3aeb 100644 --- a/src/Architecture/PropertyMustMatchRule.php +++ b/src/Architecture/PropertyMustMatchRule.php @@ -67,24 +67,27 @@ public function getNodeType(): string /** * @param Class_ $node * @param Scope $scope - * @return array + * @return array<\PHPStan\Rules\RuleError> */ public function processNode(Node $node, Scope $scope): array { - $className = $node->name?->toString() ?? ''; + $shortClassName = $node->name?->toString() ?? ''; - if ($className === '') { + if ($shortClassName === '') { return []; } + $namespaceName = $scope->getNamespace() ?? ''; + $fullClassName = $namespaceName !== '' ? $namespaceName . '\\' . $shortClassName : $shortClassName; + $classProperties = $this->getClassProperties($node); - $matchingPatterns = $this->getMatchingPatterns($className); + $matchingPatterns = $this->getMatchingPatterns($fullClassName); $errors = []; foreach ($matchingPatterns as $patternConfig) { $errors = array_merge( $errors, - $this->validatePatternProperties($patternConfig, $classProperties, $className, $node->getLine()) + $this->validatePatternProperties($patternConfig, $classProperties, $fullClassName, $node->getLine()) ); } diff --git a/tests/TestCases/Architecture/PropertyMustMatchRuleNullableTest.php b/tests/TestCases/Architecture/PropertyMustMatchRuleNullableTest.php index 46c9a14..1aff146 100644 --- a/tests/TestCases/Architecture/PropertyMustMatchRuleNullableTest.php +++ b/tests/TestCases/Architecture/PropertyMustMatchRuleNullableTest.php @@ -35,8 +35,8 @@ public function testNullableFlag(): void $this->analyse([__DIR__ . '/../../../data/PropertyMustMatch/TestClass.php'], [ // WrongTypeAllowedHandler - wrong type entirely (string instead of int or ?int) [ - 'Property WrongTypeAllowedHandler::$id should be of type int or ?int, string given.', - 98, + 'Property App\PropertyMustMatch\WrongTypeAllowedHandler::$id should be of type int or ?int, string given.', + 100, ], ]); } diff --git a/tests/TestCases/Architecture/PropertyMustMatchRuleTest.php b/tests/TestCases/Architecture/PropertyMustMatchRuleTest.php index a87accf..192371d 100644 --- a/tests/TestCases/Architecture/PropertyMustMatchRuleTest.php +++ b/tests/TestCases/Architecture/PropertyMustMatchRuleTest.php @@ -27,7 +27,7 @@ protected function getRule(): Rule ], [ 'name' => 'repository', - 'type' => 'DummyRepository', + 'type' => 'App\PropertyMustMatch\DummyRepository', 'visibilityScope' => 'private', 'required' => true, ], @@ -38,7 +38,7 @@ protected function getRule(): Rule 'properties' => [ [ 'name' => 'logger', - 'type' => 'LoggerInterface', + 'type' => 'App\PropertyMustMatch\LoggerInterface', 'visibilityScope' => 'private', 'required' => false, ], @@ -52,53 +52,53 @@ public function testRule(): void $this->analyse([__DIR__ . '/../../../data/PropertyMustMatch/TestClass.php'], [ // MissingPropertyController - missing required property 'id' [ - 'Class MissingPropertyController must have property $id.', - 17, + 'Class App\PropertyMustMatch\MissingPropertyController must have property $id.', + 19, ], // WrongTypeController - wrong type for 'id' (string instead of int) [ - 'Property WrongTypeController::$id should be of type int, string given.', - 25, + 'Property App\PropertyMustMatch\WrongTypeController::$id should be of type int, string given.', + 27, ], // WrongVisibilityController - wrong visibility for 'id' (public instead of private) [ - 'Property WrongVisibilityController::$id must be private.', - 32, + 'Property App\PropertyMustMatch\WrongVisibilityController::$id must be private.', + 34, ], // MultipleErrorsController - wrong type and wrong visibility for 'id' [ - 'Property MultipleErrorsController::$id should be of type int, string given.', - 39, + 'Property App\PropertyMustMatch\MultipleErrorsController::$id should be of type int, string given.', + 41, ], [ - 'Property MultipleErrorsController::$id must be private.', - 39, + 'Property App\PropertyMustMatch\MultipleErrorsController::$id must be private.', + 41, ], // MultipleErrorsController - wrong visibility for 'repository' (protected instead of private) [ - 'Property MultipleErrorsController::$repository must be private.', - 40, + 'Property App\PropertyMustMatch\MultipleErrorsController::$repository must be private.', + 42, ], // NoTypeController - missing type on 'id' property [ - 'Property NoTypeController::$id should be of type int, none given.', - 46, + 'Property App\PropertyMustMatch\NoTypeController::$id should be of type int, none given.', + 48, ], // NullableTypeController - nullable type doesn't match expected 'int' [ - 'Property NullableTypeController::$id should be of type int, ?int given.', - 53, + 'Property App\PropertyMustMatch\NullableTypeController::$id should be of type int, ?int given.', + 55, ], // WrongLoggerTypeService - wrong type for optional 'logger' property [ - 'Property WrongLoggerTypeService::$logger should be of type LoggerInterface, string given.', - 72, + 'Property App\PropertyMustMatch\WrongLoggerTypeService::$logger should be of type App\PropertyMustMatch\LoggerInterface, string given.', + 74, ], ]); } From 54a1792aad7bea6c87a9025eefb028096a44397f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Mon, 9 Feb 2026 23:23:04 +0100 Subject: [PATCH 04/16] Add @implements Rule annotation and fix all PHPStan level 8 errors Introduce @phpstan-type aliases (PropertyRule, PatternConfig) for the property rule array shapes, replacing untyped array parameters throughout. Remove redundant null checks already handled by isset(). Resolves all 11 pre-existing PHPStan errors in PropertyMustMatchRule. Co-authored-by: Cursor --- ...ningBoolMustFollowNamingConventionRule.php | 1 + src/Architecture/ModularArchitectureRule.php | 1 + src/Architecture/PropertyMustMatchRule.php | 45 ++++++++++--------- 3 files changed, 25 insertions(+), 22 deletions(-) diff --git a/src/Architecture/MethodsReturningBoolMustFollowNamingConventionRule.php b/src/Architecture/MethodsReturningBoolMustFollowNamingConventionRule.php index af3b0eb..fca7508 100644 --- a/src/Architecture/MethodsReturningBoolMustFollowNamingConventionRule.php +++ b/src/Architecture/MethodsReturningBoolMustFollowNamingConventionRule.php @@ -24,6 +24,7 @@ /** * Specification: + * * - Any class method that returns a boolean must follow the naming convention provided by the regex. * * @implements Rule diff --git a/src/Architecture/ModularArchitectureRule.php b/src/Architecture/ModularArchitectureRule.php index bd7f33d..6a80e62 100644 --- a/src/Architecture/ModularArchitectureRule.php +++ b/src/Architecture/ModularArchitectureRule.php @@ -32,6 +32,7 @@ * 2. Cross-module dependencies (only facades and DTOs allowed) * * Specification: + * * - Domain layer cannot import from Application, Infrastructure, or Presentation * - Application layer can import Domain; cannot import Infrastructure or Presentation * - Infrastructure layer can import Domain and Application; cannot import Presentation diff --git a/src/Architecture/PropertyMustMatchRule.php b/src/Architecture/PropertyMustMatchRule.php index dec3aeb..18d4118 100644 --- a/src/Architecture/PropertyMustMatchRule.php +++ b/src/Architecture/PropertyMustMatchRule.php @@ -33,6 +33,19 @@ * - Checks if the property type matches the expected type. * - Checks if the property has the required visibility scope (public, protected, private). * - When required is set to true, enforces that matching classes must have the property. + * + * @phpstan-type PropertyRule array{ + * name: string, + * type?: string|null, + * visibilityScope?: string|null, + * required?: bool|null, + * nullable?: bool|null + * } + * @phpstan-type PatternConfig array{ + * classPattern: string, + * properties: array + * } + * @implements Rule */ class PropertyMustMatchRule implements Rule { @@ -43,16 +56,7 @@ class PropertyMustMatchRule implements Rule private const ERROR_MESSAGE_VISIBILITY_SCOPE = 'Property %s::$%s must be %s.'; /** - * @param array - * }> $propertyPatterns + * @param array $propertyPatterns */ public function __construct( protected array $propertyPatterns @@ -95,7 +99,7 @@ public function processNode(Node $node, Scope $scope): array } /** - * @return array + * @return array */ private function getMatchingPatterns(string $className): array { @@ -106,7 +110,7 @@ private function getMatchingPatterns(string $className): array } /** - * @param array{classPattern: string, properties: array} $patternConfig + * @param PatternConfig $patternConfig * @param array $classProperties * @return array<\PHPStan\Rules\RuleError> */ @@ -129,6 +133,7 @@ private function validatePatternProperties( } /** + * @param PropertyRule $propertyRule * @param array $classProperties * @return array<\PHPStan\Rules\RuleError> */ @@ -148,6 +153,7 @@ private function validatePropertyRule( } /** + * @param PropertyRule $propertyRule * @return array<\PHPStan\Rules\RuleError> */ private function handleMissingProperty( @@ -173,6 +179,7 @@ private function handleMissingProperty( } /** + * @param PropertyRule $propertyRule * @return array<\PHPStan\Rules\RuleError> */ private function validateExistingProperty( @@ -209,10 +216,7 @@ private function getClassProperties(Class_ $node): array /** * Validate property type against expected type. * - * @param array $propertyRule - * @param Property $property - * @param string $className - * @param string $propertyName + * @param PropertyRule $propertyRule * @return \PHPStan\Rules\RuleError|null */ private function validatePropertyType( @@ -221,7 +225,7 @@ private function validatePropertyType( string $className, string $propertyName ): ?\PHPStan\Rules\RuleError { - if (!isset($propertyRule['type']) || $propertyRule['type'] === null) { + if (!isset($propertyRule['type'])) { return null; } @@ -284,10 +288,7 @@ private function buildTypeError( /** * Validate property visibility scope. * - * @param array $propertyRule - * @param Property $property - * @param string $className - * @param string $propertyName + * @param PropertyRule $propertyRule * @return \PHPStan\Rules\RuleError|null */ private function validateVisibilityScope( @@ -296,7 +297,7 @@ private function validateVisibilityScope( string $className, string $propertyName ): ?\PHPStan\Rules\RuleError { - if (!isset($propertyRule['visibilityScope']) || $propertyRule['visibilityScope'] === null) { + if (!isset($propertyRule['visibilityScope'])) { return null; } From c3e1fb4771de770ca6daceb2a1afe694a723fca5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Mon, 9 Feb 2026 23:23:29 +0100 Subject: [PATCH 05/16] Throw InvalidArgumentException for invalid visibilityScope values The default case in the visibility match expression silently accepted invalid values like typos, making the visibility check a no-op. Now throws an InvalidArgumentException with a helpful message listing the valid options. Co-authored-by: Cursor --- src/Architecture/PropertyMustMatchRule.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Architecture/PropertyMustMatchRule.php b/src/Architecture/PropertyMustMatchRule.php index 18d4118..f9e066e 100644 --- a/src/Architecture/PropertyMustMatchRule.php +++ b/src/Architecture/PropertyMustMatchRule.php @@ -306,7 +306,9 @@ private function validateVisibilityScope( 'public' => $property->isPublic(), 'protected' => $property->isProtected(), 'private' => $property->isPrivate(), - default => true, + default => throw new \InvalidArgumentException( + sprintf('Invalid visibilityScope "%s". Must be one of: public, protected, private.', $expectedVisibility) + ), }; if (!$isValid) { From a62ed6b7be2a187495cd33e69012fef5b69d6104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Mon, 9 Feb 2026 23:24:45 +0100 Subject: [PATCH 06/16] Add UnionType and IntersectionType support to PropertyMustMatchRule The getTypeAsString method now handles union types (string|int) and intersection types (Countable&Iterator) instead of returning null and reporting the actual type as "none". Aligns with how other rules in the codebase handle complex types. Co-authored-by: Cursor --- data/PropertyMustMatch/TestClass.php | 15 +++++++++++++++ src/Architecture/PropertyMustMatchRule.php | 14 ++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/data/PropertyMustMatch/TestClass.php b/data/PropertyMustMatch/TestClass.php index 0a557a6..fc2c81d 100644 --- a/data/PropertyMustMatch/TestClass.php +++ b/data/PropertyMustMatch/TestClass.php @@ -99,3 +99,18 @@ class WrongTypeAllowedHandler { private string $id; } + +// Union type property - used to test union type support +class UnionTypeCommand +{ + private string|int $value; +} + +// Intersection type property - used to test intersection type support +interface Loggable {} +interface Serializable {} + +class IntersectionTypeCommand +{ + private \Countable&\Iterator $collection; +} diff --git a/src/Architecture/PropertyMustMatchRule.php b/src/Architecture/PropertyMustMatchRule.php index f9e066e..ca74487 100644 --- a/src/Architecture/PropertyMustMatchRule.php +++ b/src/Architecture/PropertyMustMatchRule.php @@ -17,11 +17,14 @@ namespace Phauthentic\PHPStanRules\Architecture; use PhpParser\Node; +use PhpParser\Node\ComplexType; use PhpParser\Node\Identifier; +use PhpParser\Node\IntersectionType; use PhpParser\Node\Name; use PhpParser\Node\NullableType; use PhpParser\Node\Stmt\Class_; use PhpParser\Node\Stmt\Property; +use PhpParser\Node\UnionType; use PHPStan\Analyser\Scope; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; @@ -330,11 +333,8 @@ private function validateVisibilityScope( /** * Convert a type node to string representation. - * - * @param mixed $type - * @return string|null */ - private function getTypeAsString(mixed $type): ?string + private function getTypeAsString(ComplexType|Identifier|Name|null $type): ?string { return match (true) { $type === null => null, @@ -342,6 +342,12 @@ private function getTypeAsString(mixed $type): ?string $type instanceof Name => $type->toString(), $type instanceof NullableType => ($inner = $this->getTypeAsString($type->type)) !== null ? '?' . $inner : null, + $type instanceof UnionType => implode('|', array_filter( + array_map(fn ($t) => $this->getTypeAsString($t), $type->types) + )), + $type instanceof IntersectionType => implode('&', array_filter( + array_map(fn ($t) => $this->getTypeAsString($t), $type->types) + )), default => null, }; } From 67cc236b6e9950deab300f45d42244c247947a2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Mon, 9 Feb 2026 23:25:27 +0100 Subject: [PATCH 07/16] Handle preg_match errors in PropertyMustMatchRule and ForbiddenAccessorsRule Both rules silently swallowed preg_match errors from invalid regex patterns, treating them the same as "no match". Now throws InvalidArgumentException with the pattern and error message so configuration mistakes are surfaced immediately. Co-authored-by: Cursor --- src/Architecture/ForbiddenAccessorsRule.php | 8 +++++++- src/Architecture/PropertyMustMatchRule.php | 11 ++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/Architecture/ForbiddenAccessorsRule.php b/src/Architecture/ForbiddenAccessorsRule.php index 3a350ea..d1e7b51 100644 --- a/src/Architecture/ForbiddenAccessorsRule.php +++ b/src/Architecture/ForbiddenAccessorsRule.php @@ -106,7 +106,13 @@ public function processNode(Node $node, Scope $scope): array private function matchesClassPatterns(string $fullClassName): bool { foreach ($this->classPatterns as $pattern) { - if (preg_match($pattern, $fullClassName)) { + $result = @preg_match($pattern, $fullClassName); + if ($result === false) { + throw new \InvalidArgumentException( + sprintf('Invalid regex pattern "%s": %s', $pattern, preg_last_error_msg()) + ); + } + if ($result === 1) { return true; } } diff --git a/src/Architecture/PropertyMustMatchRule.php b/src/Architecture/PropertyMustMatchRule.php index ca74487..0232fa7 100644 --- a/src/Architecture/PropertyMustMatchRule.php +++ b/src/Architecture/PropertyMustMatchRule.php @@ -108,7 +108,16 @@ private function getMatchingPatterns(string $className): array { return array_filter( $this->propertyPatterns, - fn(array $config): bool => (bool) preg_match($config['classPattern'], $className) + function (array $config) use ($className): bool { + $result = @preg_match($config['classPattern'], $className); + if ($result === false) { + throw new \InvalidArgumentException( + sprintf('Invalid regex pattern "%s": %s', $config['classPattern'], preg_last_error_msg()) + ); + } + + return $result === 1; + } ); } From a6d35461488a21979848ef2f105e0b92c1e78e4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Mon, 9 Feb 2026 23:25:54 +0100 Subject: [PATCH 08/16] Add missing return type declarations to ForbiddenAccessorsRule buildGetterError and buildSetterError had @return docblocks but lacked actual PHP return type declarations. Co-authored-by: Cursor --- src/Architecture/ForbiddenAccessorsRule.php | 10 ++-------- src/Architecture/PropertyMustMatchRule.php | 2 +- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/Architecture/ForbiddenAccessorsRule.php b/src/Architecture/ForbiddenAccessorsRule.php index d1e7b51..401da1b 100644 --- a/src/Architecture/ForbiddenAccessorsRule.php +++ b/src/Architecture/ForbiddenAccessorsRule.php @@ -136,10 +136,7 @@ private function getMethodVisibility(Node\Stmt\ClassMethod $method): string return 'private'; } - /** - * @return \PHPStan\Rules\RuleError - */ - private function buildGetterError(string $fullClassName, string $visibility, string $methodName, int $line) + private function buildGetterError(string $fullClassName, string $visibility, string $methodName, int $line): \PHPStan\Rules\RuleError { return RuleErrorBuilder::message( sprintf(self::ERROR_MESSAGE_GETTER, $fullClassName, $visibility, $methodName) @@ -149,10 +146,7 @@ private function buildGetterError(string $fullClassName, string $visibility, str ->build(); } - /** - * @return \PHPStan\Rules\RuleError - */ - private function buildSetterError(string $fullClassName, string $visibility, string $methodName, int $line) + private function buildSetterError(string $fullClassName, string $visibility, string $methodName, int $line): \PHPStan\Rules\RuleError { return RuleErrorBuilder::message( sprintf(self::ERROR_MESSAGE_SETTER, $fullClassName, $visibility, $methodName) diff --git a/src/Architecture/PropertyMustMatchRule.php b/src/Architecture/PropertyMustMatchRule.php index 0232fa7..1515159 100644 --- a/src/Architecture/PropertyMustMatchRule.php +++ b/src/Architecture/PropertyMustMatchRule.php @@ -342,7 +342,7 @@ private function validateVisibilityScope( /** * Convert a type node to string representation. - */ +git s */ private function getTypeAsString(ComplexType|Identifier|Name|null $type): ?string { return match (true) { From c551f84b4aec450ec65560f864a6d8f9c88c00c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Mon, 9 Feb 2026 23:26:15 +0100 Subject: [PATCH 09/16] Fix misleading $visibility docblock in ForbiddenAccessorsRule The docblock listed only 'public' and 'protected' as valid visibility values, but 'private' is also supported and works correctly. Co-authored-by: Cursor --- src/Architecture/ForbiddenAccessorsRule.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Architecture/ForbiddenAccessorsRule.php b/src/Architecture/ForbiddenAccessorsRule.php index 401da1b..a3e614d 100644 --- a/src/Architecture/ForbiddenAccessorsRule.php +++ b/src/Architecture/ForbiddenAccessorsRule.php @@ -44,7 +44,7 @@ class ForbiddenAccessorsRule implements Rule * @param array $classPatterns Regex patterns to match against class FQCNs. * @param bool $forbidGetters Whether to forbid getXxx() methods. * @param bool $forbidSetters Whether to forbid setXxx() methods. - * @param array $visibility Array of visibilities to check ('public', 'protected'). + * @param array $visibility Array of visibilities to check ('public', 'protected', 'private'). */ public function __construct( protected array $classPatterns, From e2fc8024e526106861f3eda78a0e12e3f28bae83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Mon, 9 Feb 2026 23:27:23 +0100 Subject: [PATCH 10/16] Add constructor parameter validation to both rules PropertyMustMatchRule now validates that propertyPatterns is non-empty. ForbiddenAccessorsRule now validates that classPatterns is non-empty and that visibility values are valid (public, protected, private). Invalid configurations now throw InvalidArgumentException immediately instead of silently producing no results. Co-authored-by: Cursor --- src/Architecture/ForbiddenAccessorsRule.php | 16 ++++++++++++++++ src/Architecture/PropertyMustMatchRule.php | 3 +++ 2 files changed, 19 insertions(+) diff --git a/src/Architecture/ForbiddenAccessorsRule.php b/src/Architecture/ForbiddenAccessorsRule.php index a3e614d..27137fe 100644 --- a/src/Architecture/ForbiddenAccessorsRule.php +++ b/src/Architecture/ForbiddenAccessorsRule.php @@ -40,6 +40,8 @@ class ForbiddenAccessorsRule implements Rule private const GETTER_PATTERN = '/^get[A-Z]/'; private const SETTER_PATTERN = '/^set[A-Z]/'; + private const VALID_VISIBILITIES = ['public', 'protected', 'private']; + /** * @param array $classPatterns Regex patterns to match against class FQCNs. * @param bool $forbidGetters Whether to forbid getXxx() methods. @@ -52,6 +54,20 @@ public function __construct( protected bool $forbidSetters = true, protected array $visibility = ['public'] ) { + if ($classPatterns === []) { + throw new \InvalidArgumentException('At least one class pattern must be provided.'); + } + + $invalidVisibilities = array_diff($this->visibility, self::VALID_VISIBILITIES); + if ($invalidVisibilities !== []) { + throw new \InvalidArgumentException( + sprintf( + 'Invalid visibility value(s): %s. Must be one of: %s.', + implode(', ', $invalidVisibilities), + implode(', ', self::VALID_VISIBILITIES) + ) + ); + } } public function getNodeType(): string diff --git a/src/Architecture/PropertyMustMatchRule.php b/src/Architecture/PropertyMustMatchRule.php index 1515159..5993f14 100644 --- a/src/Architecture/PropertyMustMatchRule.php +++ b/src/Architecture/PropertyMustMatchRule.php @@ -64,6 +64,9 @@ class PropertyMustMatchRule implements Rule public function __construct( protected array $propertyPatterns ) { + if ($propertyPatterns === []) { + throw new \InvalidArgumentException('At least one property pattern must be provided.'); + } } public function getNodeType(): string From 7c93ac78f9f81d45bd92b2252d1b67a17db5c0a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Mon, 9 Feb 2026 23:28:33 +0100 Subject: [PATCH 11/16] Update dependencies in composer.json and composer.lock - Bump phpunit/phpunit from ^12.0 to ^12.5.8 - Update myclabs/deep-copy from 1.13.0 to 1.13.4 - Upgrade nikic/php-parser from v5.4.0 to v5.7.0 - Upgrade phpstan/phpstan from 2.1.11 to 2.1.38 - Upgrade phpunit/php-code-coverage from 12.1.0 to 12.5.3 - Update various references and URLs in composer.lock These changes ensure compatibility with the latest versions of the dependencies. --- composer.json | 2 +- composer.lock | 343 +++++++++++++++++++++++++++++++++----------------- 2 files changed, 226 insertions(+), 119 deletions(-) diff --git a/composer.json b/composer.json index cf053ed..a6397fe 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "type": "library", "require-dev": { "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^12.0", + "phpunit/phpunit": "^12.5.8", "squizlabs/php_codesniffer": "^3.12" }, "autoload": { diff --git a/composer.lock b/composer.lock index 56a4269..bc05ff6 100644 --- a/composer.lock +++ b/composer.lock @@ -4,21 +4,21 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4dbefe67316486c425350e64485ab275", + "content-hash": "ae7582a75038775f4b8e01ad21426704", "packages": [], "packages-dev": [ { "name": "myclabs/deep-copy", - "version": "1.13.0", + "version": "1.13.4", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "024473a478be9df5fdaca2c793f2232fe788e414" + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/024473a478be9df5fdaca2c793f2232fe788e414", - "reference": "024473a478be9df5fdaca2c793f2232fe788e414", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", "shasum": "" }, "require": { @@ -57,7 +57,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.0" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" }, "funding": [ { @@ -65,20 +65,20 @@ "type": "tidelift" } ], - "time": "2025-02-12T12:17:51+00:00" + "time": "2025-08-01T08:46:24+00:00" }, { "name": "nikic/php-parser", - "version": "v5.4.0", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494" + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/447a020a1f875a434d62f2a401f53b82a396e494", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { @@ -97,7 +97,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-master": "5.x-dev" } }, "autoload": { @@ -121,9 +121,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.4.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2024-12-30T11:07:19+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { "name": "phar-io/manifest", @@ -245,16 +245,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.11", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpstan.git", - "reference": "8ca5f79a8f63c49b2359065832a654e1ec70ac30" - }, + "version": "2.1.38", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8ca5f79a8f63c49b2359065832a654e1ec70ac30", - "reference": "8ca5f79a8f63c49b2359065832a654e1ec70ac30", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dfaf1f530e1663aa167bc3e52197adb221582629", + "reference": "dfaf1f530e1663aa167bc3e52197adb221582629", "shasum": "" }, "require": { @@ -299,38 +294,38 @@ "type": "github" } ], - "time": "2025-03-24T13:45:00+00:00" + "time": "2026-01-30T17:12:46+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "12.1.0", + "version": "12.5.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "d331a5ced3d9a2b917baa9841b2211e72f9e780d" + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/d331a5ced3d9a2b917baa9841b2211e72f9e780d", - "reference": "d331a5ced3d9a2b917baa9841b2211e72f9e780d", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d", + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^5.4.0", + "nikic/php-parser": "^5.7.0", "php": ">=8.3", "phpunit/php-file-iterator": "^6.0", "phpunit/php-text-template": "^5.0", "sebastian/complexity": "^5.0", - "sebastian/environment": "^8.0", + "sebastian/environment": "^8.0.3", "sebastian/lines-of-code": "^4.0", "sebastian/version": "^6.0", - "theseer/tokenizer": "^1.2.3" + "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.1" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -339,7 +334,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.1.x-dev" + "dev-main": "12.5.x-dev" } }, "autoload": { @@ -368,28 +363,40 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.1.0" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" } ], - "time": "2025-03-17T13:56:07+00:00" + "time": "2026-02-06T06:01:44+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "6.0.0", + "version": "6.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "961bc913d42fe24a257bfff826a5068079ac7782" + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/961bc913d42fe24a257bfff826a5068079ac7782", - "reference": "961bc913d42fe24a257bfff826a5068079ac7782", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", "shasum": "" }, "require": { @@ -429,15 +436,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2025-02-07T04:58:37+00:00" + "time": "2026-02-02T14:04:18+00:00" }, { "name": "phpunit/php-invoker", @@ -625,16 +644,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.0.10", + "version": "12.5.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "6075843014de23bcd6992842d69ca99d25d6a433" + "reference": "1686e30f6b32d35592f878a7f56fd0421d7d56c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/6075843014de23bcd6992842d69ca99d25d6a433", - "reference": "6075843014de23bcd6992842d69ca99d25d6a433", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/1686e30f6b32d35592f878a7f56fd0421d7d56c5", + "reference": "1686e30f6b32d35592f878a7f56fd0421d7d56c5", "shasum": "" }, "require": { @@ -644,23 +663,24 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.13.0", + "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.1.0", - "phpunit/php-file-iterator": "^6.0.0", + "phpunit/php-code-coverage": "^12.5.3", + "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", - "sebastian/cli-parser": "^4.0.0", - "sebastian/comparator": "^7.0.1", + "sebastian/cli-parser": "^4.2.0", + "sebastian/comparator": "^7.1.4", "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.0.0", - "sebastian/exporter": "^7.0.0", - "sebastian/global-state": "^8.0.0", + "sebastian/environment": "^8.0.3", + "sebastian/exporter": "^7.0.2", + "sebastian/global-state": "^8.0.2", "sebastian/object-enumerator": "^7.0.0", - "sebastian/type": "^6.0.2", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.3", "sebastian/version": "^6.0.0", "staabm/side-effects-detector": "^1.0.5" }, @@ -670,7 +690,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.0-dev" + "dev-main": "12.5-dev" } }, "autoload": { @@ -702,7 +722,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.0.10" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.10" }, "funding": [ { @@ -713,25 +733,33 @@ "url": "https://github.com/sebastianbergmann", "type": "github" }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, { "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", "type": "tidelift" } ], - "time": "2025-03-23T16:03:59+00:00" + "time": "2026-02-08T07:06:48+00:00" }, { "name": "sebastian/cli-parser", - "version": "4.0.0", + "version": "4.2.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "6d584c727d9114bcdc14c86711cd1cad51778e7c" + "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/6d584c727d9114bcdc14c86711cd1cad51778e7c", - "reference": "6d584c727d9114bcdc14c86711cd1cad51778e7c", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04", "shasum": "" }, "require": { @@ -743,7 +771,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "4.2-dev" } }, "autoload": { @@ -767,28 +795,40 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.0.0" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" } ], - "time": "2025-02-07T04:53:50+00:00" + "time": "2025-09-14T09:36:45+00:00" }, { "name": "sebastian/comparator", - "version": "7.0.1", + "version": "7.1.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "b478f34614f934e0291598d0c08cbaba9644bee5" + "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/b478f34614f934e0291598d0c08cbaba9644bee5", - "reference": "b478f34614f934e0291598d0c08cbaba9644bee5", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/6a7de5df2e094f9a80b40a522391a7e6022df5f6", + "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6", "shasum": "" }, "require": { @@ -799,7 +839,7 @@ "sebastian/exporter": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.2" }, "suggest": { "ext-bcmath": "For comparing BcMath\\Number objects" @@ -807,7 +847,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "7.1-dev" } }, "autoload": { @@ -847,15 +887,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.0.1" + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.4" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" } ], - "time": "2025-03-07T07:00:32+00:00" + "time": "2026-01-24T09:28:48+00:00" }, { "name": "sebastian/complexity", @@ -984,16 +1036,16 @@ }, { "name": "sebastian/environment", - "version": "8.0.0", + "version": "8.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "8afe311eca49171bf95405cc0078be9a3821f9f2" + "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8afe311eca49171bf95405cc0078be9a3821f9f2", - "reference": "8afe311eca49171bf95405cc0078be9a3821f9f2", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/24a711b5c916efc6d6e62aa65aa2ec98fef77f68", + "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68", "shasum": "" }, "require": { @@ -1036,28 +1088,40 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/8.0.0" + "source": "https://github.com/sebastianbergmann/environment/tree/8.0.3" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" } ], - "time": "2025-02-07T04:56:08+00:00" + "time": "2025-08-12T14:11:56+00:00" }, { "name": "sebastian/exporter", - "version": "7.0.0", + "version": "7.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "76432aafc58d50691a00d86d0632f1217a47b688" + "reference": "016951ae10980765e4e7aee491eb288c64e505b7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/76432aafc58d50691a00d86d0632f1217a47b688", - "reference": "76432aafc58d50691a00d86d0632f1217a47b688", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7", + "reference": "016951ae10980765e4e7aee491eb288c64e505b7", "shasum": "" }, "require": { @@ -1114,28 +1178,40 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" } ], - "time": "2025-02-07T04:56:42+00:00" + "time": "2025-09-24T06:16:11+00:00" }, { "name": "sebastian/global-state", - "version": "8.0.0", + "version": "8.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "570a2aeb26d40f057af686d63c4e99b075fb6cbc" + "reference": "ef1377171613d09edd25b7816f05be8313f9115d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/570a2aeb26d40f057af686d63c4e99b075fb6cbc", - "reference": "570a2aeb26d40f057af686d63c4e99b075fb6cbc", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d", + "reference": "ef1377171613d09edd25b7816f05be8313f9115d", "shasum": "" }, "require": { @@ -1176,15 +1252,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.0" + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" } ], - "time": "2025-02-07T04:56:59+00:00" + "time": "2025-08-29T11:29:25+00:00" }, { "name": "sebastian/lines-of-code", @@ -1360,16 +1448,16 @@ }, { "name": "sebastian/recursion-context", - "version": "7.0.0", + "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "c405ae3a63e01b32eb71577f8ec1604e39858a7c" + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/c405ae3a63e01b32eb71577f8ec1604e39858a7c", - "reference": "c405ae3a63e01b32eb71577f8ec1604e39858a7c", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", "shasum": "" }, "require": { @@ -1412,28 +1500,40 @@ "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" } ], - "time": "2025-02-07T05:00:01+00:00" + "time": "2025-08-13T04:44:59+00:00" }, { "name": "sebastian/type", - "version": "6.0.2", + "version": "6.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "1d7cd6e514384c36d7a390347f57c385d4be6069" + "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/1d7cd6e514384c36d7a390347f57c385d4be6069", - "reference": "1d7cd6e514384c36d7a390347f57c385d4be6069", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d", + "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d", "shasum": "" }, "require": { @@ -1469,15 +1569,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/6.0.2" + "source": "https://github.com/sebastianbergmann/type/tree/6.0.3" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" } ], - "time": "2025-03-18T13:37:31+00:00" + "time": "2025-08-09T06:57:12+00:00" }, { "name": "sebastian/version", @@ -1535,16 +1647,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.12.0", + "version": "3.13.5", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "2d1b63db139c3c6ea0c927698e5160f8b3b8d630" + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/2d1b63db139c3c6ea0c927698e5160f8b3b8d630", - "reference": "2d1b63db139c3c6ea0c927698e5160f8b3b8d630", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4", "shasum": "" }, "require": { @@ -1561,11 +1673,6 @@ "bin/phpcs" ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" @@ -1615,7 +1722,7 @@ "type": "thanks_dev" } ], - "time": "2025-03-18T05:04:51+00:00" + "time": "2025-11-04T16:30:35+00:00" }, { "name": "staabm/side-effects-detector", @@ -1671,23 +1778,23 @@ }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", "shasum": "" }, "require": { "ext-dom": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.1" }, "type": "library", "autoload": { @@ -1709,7 +1816,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" }, "funding": [ { @@ -1717,7 +1824,7 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" + "time": "2025-12-08T11:19:18+00:00" } ], "aliases": [], From e1f0a15e78520512b09deacf6d64f886c0741ae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Mon, 9 Feb 2026 23:29:58 +0100 Subject: [PATCH 12/16] Fix typo in docblock for getTypeAsString method in PropertyMustMatchRule --- src/Architecture/PropertyMustMatchRule.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Architecture/PropertyMustMatchRule.php b/src/Architecture/PropertyMustMatchRule.php index 5993f14..acebd59 100644 --- a/src/Architecture/PropertyMustMatchRule.php +++ b/src/Architecture/PropertyMustMatchRule.php @@ -345,7 +345,7 @@ private function validateVisibilityScope( /** * Convert a type node to string representation. -git s */ + */ private function getTypeAsString(ComplexType|Identifier|Name|null $type): ?string { return match (true) { From e6eb523c89c6ecd6f0e8e1561bcfd8dce3ab75ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Mon, 9 Feb 2026 23:42:36 +0100 Subject: [PATCH 13/16] Refactor rules to utilize ClassNameResolver trait for full class name resolution - Added ClassNameResolver trait to multiple rules (AttributeRule, CatchExceptionOfTypeNotAllowedRule, ClassMustBeFinalRule, ClassMustBeReadonlyRule, ClassMustHaveSpecificationDocblockRule, ForbiddenAccessorsRule, MethodMustReturnTypeRule, MethodSignatureMustMatchRule, PropertyMustMatchRule). - Updated processNode methods to resolve full class names using the new trait, improving consistency and reducing code duplication. - Removed redundant code related to manual full class name construction and unnecessary private methods. This refactor enhances maintainability and aligns the rules with a unified approach for class name resolution. --- src/Architecture/AttributeRule.php | 9 +- .../CatchExceptionOfTypeNotAllowedRule.php | 17 ++- src/Architecture/ClassMustBeFinalRule.php | 15 +-- src/Architecture/ClassMustBeReadonlyRule.php | 23 ++-- ...ClassMustHaveSpecificationDocblockRule.php | 26 +---- src/Architecture/ClassNameResolver.php | 109 ++++++++++++++++++ src/Architecture/ForbiddenAccessorsRule.php | 31 +---- src/Architecture/MethodMustReturnTypeRule.php | 26 ++--- .../MethodSignatureMustMatchRule.php | 26 ++--- src/Architecture/PropertyMustMatchRule.php | 38 +----- ...thodSignatureMustMatchRuleRequiredTest.php | 4 +- .../Architecture/RegexAllOfRuleTest.php | 15 +-- 12 files changed, 180 insertions(+), 159 deletions(-) create mode 100644 src/Architecture/ClassNameResolver.php diff --git a/src/Architecture/AttributeRule.php b/src/Architecture/AttributeRule.php index 12fe46f..9133866 100644 --- a/src/Architecture/AttributeRule.php +++ b/src/Architecture/AttributeRule.php @@ -54,6 +54,8 @@ */ class AttributeRule implements Rule { + use ClassNameResolver; + private const ERROR_FORBIDDEN = 'Attribute %s is forbidden on %s %s.'; private const ERROR_NOT_ALLOWED = 'Attribute %s is not in the allowed list for %s %s. Allowed patterns: %s'; @@ -104,14 +106,11 @@ public function getNodeType(): string */ public function processNode(Node $node, Scope $scope): array { - if (!isset($node->name)) { + $fullClassName = $this->resolveFullClassName($node, $scope); + if ($fullClassName === null) { return []; } - $className = $node->name->toString(); - $namespaceName = $scope->getNamespace() ?? ''; - $fullClassName = $namespaceName !== '' ? $namespaceName . '\\' . $className : $className; - /** @var list $errors */ $errors = []; diff --git a/src/Architecture/CatchExceptionOfTypeNotAllowedRule.php b/src/Architecture/CatchExceptionOfTypeNotAllowedRule.php index 5175bdd..3fec9d9 100644 --- a/src/Architecture/CatchExceptionOfTypeNotAllowedRule.php +++ b/src/Architecture/CatchExceptionOfTypeNotAllowedRule.php @@ -32,22 +32,27 @@ */ class CatchExceptionOfTypeNotAllowedRule implements Rule { + use ClassNameResolver; + private const ERROR_MESSAGE = 'Catching exception of type %s is not allowed.'; private const IDENTIFIER = 'phauthentic.architecture.catchExceptionOfTypeNotAllowed'; /** - * @var array An array of exception class names that are not allowed to be caught. - * e.g., ['Exception', 'Error', 'Throwable'] + * @var array Normalized forbidden exception types (without leading backslash) */ - private array $forbiddenExceptionTypes; + private array $normalizedForbiddenTypes; /** * @param array $forbiddenExceptionTypes An array of exception class names that are not allowed to be caught. */ public function __construct(array $forbiddenExceptionTypes) { - $this->forbiddenExceptionTypes = $forbiddenExceptionTypes; + // Normalize all forbidden types by removing leading backslash + $this->normalizedForbiddenTypes = array_map( + fn(string $type): string => $this->normalizeClassName($type), + $forbiddenExceptionTypes + ); } public function getNodeType(): string @@ -64,9 +69,11 @@ public function processNode(Node $node, Scope $scope): array foreach ($node->types as $type) { $exceptionType = $type->toString(); + // Normalize the caught exception type for comparison + $normalizedType = $this->normalizeClassName($exceptionType); // Check if the caught exception type is in the forbidden list - if (in_array($exceptionType, $this->forbiddenExceptionTypes, true)) { + if (in_array($normalizedType, $this->normalizedForbiddenTypes, true)) { $errors[] = RuleErrorBuilder::message(sprintf(self::ERROR_MESSAGE, $exceptionType)) ->line($node->getLine()) ->identifier(self::IDENTIFIER) diff --git a/src/Architecture/ClassMustBeFinalRule.php b/src/Architecture/ClassMustBeFinalRule.php index 8d5b720..1cba0e6 100644 --- a/src/Architecture/ClassMustBeFinalRule.php +++ b/src/Architecture/ClassMustBeFinalRule.php @@ -34,6 +34,8 @@ */ class ClassMustBeFinalRule implements Rule { + use ClassNameResolver; + private const ERROR_MESSAGE = 'Class %s must be final.'; private const IDENTIFIER = 'phauthentic.architecture.classMustBeFinal'; @@ -59,7 +61,8 @@ public function getNodeType(): string */ public function processNode(Node $node, Scope $scope): array { - if (!isset($node->name)) { + $fullClassName = $this->resolveFullClassName($node, $scope); + if ($fullClassName === null) { return []; } @@ -68,14 +71,8 @@ public function processNode(Node $node, Scope $scope): array return []; } - $className = $node->name->toString(); - $namespaceName = $scope->getNamespace() ?? ''; - $fullClassName = $namespaceName . '\\' . $className; - - foreach ($this->patterns as $pattern) { - if (preg_match($pattern, $fullClassName) && !$node->isFinal()) { - return [$this->buildRuleError($fullClassName)]; - } + if ($this->matchesAnyPattern($fullClassName, $this->patterns) && !$node->isFinal()) { + return [$this->buildRuleError($fullClassName)]; } return []; diff --git a/src/Architecture/ClassMustBeReadonlyRule.php b/src/Architecture/ClassMustBeReadonlyRule.php index d85aa0c..5a315ec 100644 --- a/src/Architecture/ClassMustBeReadonlyRule.php +++ b/src/Architecture/ClassMustBeReadonlyRule.php @@ -32,6 +32,8 @@ */ class ClassMustBeReadonlyRule implements Rule { + use ClassNameResolver; + private const ERROR_MESSAGE = 'Class %s must be readonly.'; private const IDENTIFIER = 'phauthentic.architecture.classMustBeReadonly'; @@ -59,22 +61,17 @@ public function getNodeType(): string */ public function processNode(Node $node, Scope $scope): array { - if (!isset($node->name)) { + $fullClassName = $this->resolveFullClassName($node, $scope); + if ($fullClassName === null) { return []; } - $className = $node->name->toString(); - $namespaceName = $scope->getNamespace() ?? ''; - $fullClassName = $namespaceName . '\\' . $className; - - foreach ($this->patterns as $pattern) { - if (preg_match($pattern, $fullClassName) && !$node->isReadonly()) { - return [ - RuleErrorBuilder::message(sprintf(self::ERROR_MESSAGE, $fullClassName)) - ->identifier(self::IDENTIFIER) - ->build(), - ]; - } + if ($this->matchesAnyPattern($fullClassName, $this->patterns) && !$node->isReadonly()) { + return [ + RuleErrorBuilder::message(sprintf(self::ERROR_MESSAGE, $fullClassName)) + ->identifier(self::IDENTIFIER) + ->build(), + ]; } return []; diff --git a/src/Architecture/ClassMustHaveSpecificationDocblockRule.php b/src/Architecture/ClassMustHaveSpecificationDocblockRule.php index 0c37473..0aa32c3 100644 --- a/src/Architecture/ClassMustHaveSpecificationDocblockRule.php +++ b/src/Architecture/ClassMustHaveSpecificationDocblockRule.php @@ -39,6 +39,8 @@ */ class ClassMustHaveSpecificationDocblockRule implements Rule { + use ClassNameResolver; + private const ERROR_MESSAGE_MISSING = '%s %s must have a docblock with a "%s" section.'; private const ERROR_MESSAGE_INVALID = '%s %s has an invalid specification docblock. %s'; private const IDENTIFIER = 'phauthentic.architecture.classMustHaveSpecificationDocblock'; @@ -96,20 +98,18 @@ public function processNode(Node $node, Scope $scope): array return []; } - if (!isset($node->name)) { + $fullClassName = $this->resolveFullClassName($node, $scope); + if ($fullClassName === null) { return []; } $errors = []; - $className = $node->name->toString(); - $namespaceName = $scope->getNamespace() ?? ''; - $fullClassName = $namespaceName . '\\' . $className; // Determine the type for error messages $type = $node instanceof Interface_ ? 'Interface' : 'Class'; // Check class/interface docblock - if ($this->matchesPatterns($fullClassName, $this->classPatterns)) { + if ($this->matchesAnyPattern($fullClassName, $this->classPatterns)) { $docComment = $node->getDocComment(); if ($docComment === null) { $errors[] = $this->buildMissingDocblockError($type, $fullClassName, $node); @@ -123,7 +123,7 @@ public function processNode(Node $node, Scope $scope): array $methodName = $method->name->toString(); $fullMethodName = $fullClassName . '::' . $methodName; - if ($this->matchesPatterns($fullMethodName, $this->methodPatterns)) { + if ($this->matchesAnyPattern($fullMethodName, $this->methodPatterns)) { $docComment = $method->getDocComment(); if ($docComment === null) { $errors[] = $this->buildMissingDocblockError('Method', $fullMethodName, $method); @@ -136,20 +136,6 @@ public function processNode(Node $node, Scope $scope): array return $errors; } - /** - * @param array $patterns - */ - private function matchesPatterns(string $target, array $patterns): bool - { - foreach ($patterns as $pattern) { - if (preg_match($pattern, $target)) { - return true; - } - } - - return false; - } - private function isValidSpecificationDocblock(Doc $docComment): bool { $text = $docComment->getText(); diff --git a/src/Architecture/ClassNameResolver.php b/src/Architecture/ClassNameResolver.php new file mode 100644 index 0000000..994ecc6 --- /dev/null +++ b/src/Architecture/ClassNameResolver.php @@ -0,0 +1,109 @@ +name)) { + return null; + } + + $className = $node->name->toString(); + $namespaceName = $scope->getNamespace() ?? ''; + + return $namespaceName !== '' ? $namespaceName . '\\' . $className : $className; + } + + /** + * Convert a type node to its string representation. + * + * Handles Identifier, Name, NullableType, UnionType, and IntersectionType. + * + * @param ComplexType|Identifier|Name|null $type + * @return string|null + */ + protected function getTypeAsString(ComplexType|Identifier|Name|null $type): ?string + { + return match (true) { + $type === null => null, + $type instanceof Identifier => $type->name, + $type instanceof Name => $type->toString(), + $type instanceof NullableType => + ($inner = $this->getTypeAsString($type->type)) !== null + ? '?' . $inner + : null, + $type instanceof UnionType => implode('|', array_filter( + array_map(fn($t) => $this->getTypeAsString($t), $type->types) + )), + $type instanceof IntersectionType => implode('&', array_filter( + array_map(fn($t) => $this->getTypeAsString($t), $type->types) + )), + default => null, + }; + } + + /** + * Check if a subject string matches any of the given regex patterns. + * + * @param string $subject The string to test + * @param array $patterns Array of regex patterns + * @return bool True if any pattern matches + */ + protected function matchesAnyPattern(string $subject, array $patterns): bool + { + foreach ($patterns as $pattern) { + if (preg_match($pattern, $subject) === 1) { + return true; + } + } + + return false; + } + + /** + * Normalize a class name by removing leading backslash. + * + * @param string $className + * @return string + */ + protected function normalizeClassName(string $className): string + { + return ltrim($className, '\\'); + } +} diff --git a/src/Architecture/ForbiddenAccessorsRule.php b/src/Architecture/ForbiddenAccessorsRule.php index 27137fe..1b5bf1b 100644 --- a/src/Architecture/ForbiddenAccessorsRule.php +++ b/src/Architecture/ForbiddenAccessorsRule.php @@ -33,6 +33,8 @@ */ class ForbiddenAccessorsRule implements Rule { + use ClassNameResolver; + private const ERROR_MESSAGE_GETTER = 'Class %s must not have a %s getter method %s().'; private const ERROR_MESSAGE_SETTER = 'Class %s must not have a %s setter method %s().'; private const IDENTIFIER = 'phauthentic.architecture.forbiddenAccessors'; @@ -82,15 +84,12 @@ public function getNodeType(): string */ public function processNode(Node $node, Scope $scope): array { - if (!isset($node->name)) { + $fullClassName = $this->resolveFullClassName($node, $scope); + if ($fullClassName === null) { return []; } - $className = $node->name->toString(); - $namespaceName = $scope->getNamespace() ?? ''; - $fullClassName = $namespaceName !== '' ? $namespaceName . '\\' . $className : $className; - - if (!$this->matchesClassPatterns($fullClassName)) { + if (!$this->matchesAnyPattern($fullClassName, $this->classPatterns)) { return []; } @@ -116,26 +115,6 @@ public function processNode(Node $node, Scope $scope): array return $errors; } - /** - * Check if the class FQCN matches any of the configured patterns. - */ - private function matchesClassPatterns(string $fullClassName): bool - { - foreach ($this->classPatterns as $pattern) { - $result = @preg_match($pattern, $fullClassName); - if ($result === false) { - throw new \InvalidArgumentException( - sprintf('Invalid regex pattern "%s": %s', $pattern, preg_last_error_msg()) - ); - } - if ($result === 1) { - return true; - } - } - - return false; - } - /** * Get the visibility of a method as a string. */ diff --git a/src/Architecture/MethodMustReturnTypeRule.php b/src/Architecture/MethodMustReturnTypeRule.php index 2d5caae..50fe17f 100644 --- a/src/Architecture/MethodMustReturnTypeRule.php +++ b/src/Architecture/MethodMustReturnTypeRule.php @@ -39,6 +39,8 @@ */ class MethodMustReturnTypeRule implements Rule { + use ClassNameResolver; + private const IDENTIFIER = 'phauthentic.architecture.methodMustReturnType'; private const ERROR_MESSAGE_VOID = 'Method %s must have a void return type.'; @@ -78,12 +80,16 @@ public function getNodeType(): string */ public function processNode(Node $node, Scope $scope): array { + $fullClassName = $this->resolveFullClassName($node, $scope); + if ($fullClassName === null) { + return []; + } + $errors = []; - $className = $node->name ? $node->name->toString() : ''; foreach ($node->getMethods() as $method) { $methodName = $method->name->toString(); - $fullName = $className . '::' . $methodName; + $fullName = $fullClassName . '::' . $methodName; foreach ($this->returnTypePatterns as $patternConfig) { if (!preg_match($patternConfig['pattern'], $fullName)) { @@ -449,22 +455,6 @@ private function buildTypeMismatchError(string $fullName, string $expectedType, ->build(); } - private function getTypeAsString(mixed $type): ?string - { - $nullableInner = null; - if ($type instanceof NullableType) { - $nullableInner = $this->getTypeAsString($type->type); - } - - return match (true) { - $type === null => null, - $type instanceof Identifier => $type->name, - $type instanceof Name => $type->toString(), - $type instanceof NullableType => $nullableInner !== null ? '?' . $nullableInner : null, - default => null, - }; - } - private function isNullableType(mixed $type): bool { return $type instanceof NullableType; diff --git a/src/Architecture/MethodSignatureMustMatchRule.php b/src/Architecture/MethodSignatureMustMatchRule.php index d7bcaa1..467bad1 100644 --- a/src/Architecture/MethodSignatureMustMatchRule.php +++ b/src/Architecture/MethodSignatureMustMatchRule.php @@ -18,9 +18,6 @@ use PhpParser\Node; use PhpParser\Node\Expr\Variable; -use PhpParser\Node\Identifier; -use PhpParser\Node\Name; -use PhpParser\Node\NullableType; use PhpParser\Node\Param; use PhpParser\Node\Stmt\Class_; use PhpParser\Node\Stmt\ClassMethod; @@ -42,6 +39,8 @@ */ class MethodSignatureMustMatchRule implements Rule { + use ClassNameResolver; + private const IDENTIFIER = 'phauthentic.architecture.methodSignatureMustMatch'; private const ERROR_MESSAGE_MISSING_PARAMETER = 'Method %s is missing parameter #%d of type %s.'; @@ -81,11 +80,14 @@ public function getNodeType(): string */ public function processNode(Node $node, Scope $scope): array { - $className = $node->name?->toString() ?? ''; + $fullClassName = $this->resolveFullClassName($node, $scope); + if ($fullClassName === null) { + return []; + } return [ - ...$this->checkRequiredMethods($node, $className), - ...$this->validateMethods($node->getMethods(), $className), + ...$this->checkRequiredMethods($node, $fullClassName), + ...$this->validateMethods($node->getMethods(), $fullClassName), ]; } @@ -290,18 +292,6 @@ private function getParamName(Param $param): ?string return null; } - private function getTypeAsString(mixed $type): ?string - { - return match (true) { - $type === null => null, - $type instanceof Identifier => $type->name, - $type instanceof Name => $type->toString(), - $type instanceof NullableType => - ($inner = $this->getTypeAsString($type->type)) !== null ? '?' . $inner : null, - default => null, - }; - } - /** * Extract class name pattern and method name from a regex pattern. * Expected pattern format: '/^ClassName::methodName$/' or '/ClassName::methodName$/' diff --git a/src/Architecture/PropertyMustMatchRule.php b/src/Architecture/PropertyMustMatchRule.php index acebd59..d6bff83 100644 --- a/src/Architecture/PropertyMustMatchRule.php +++ b/src/Architecture/PropertyMustMatchRule.php @@ -17,14 +17,8 @@ namespace Phauthentic\PHPStanRules\Architecture; use PhpParser\Node; -use PhpParser\Node\ComplexType; -use PhpParser\Node\Identifier; -use PhpParser\Node\IntersectionType; -use PhpParser\Node\Name; -use PhpParser\Node\NullableType; use PhpParser\Node\Stmt\Class_; use PhpParser\Node\Stmt\Property; -use PhpParser\Node\UnionType; use PHPStan\Analyser\Scope; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; @@ -52,6 +46,8 @@ */ class PropertyMustMatchRule implements Rule { + use ClassNameResolver; + private const IDENTIFIER = 'phauthentic.architecture.propertyMustMatch'; private const ERROR_MESSAGE_MISSING_PROPERTY = 'Class %s must have property $%s.'; @@ -80,16 +76,13 @@ public function getNodeType(): string * @return array<\PHPStan\Rules\RuleError> */ public function processNode(Node $node, Scope $scope): array - { - $shortClassName = $node->name?->toString() ?? ''; - if ($shortClassName === '') { + { + $fullClassName = $this->resolveFullClassName($node, $scope); + if ($fullClassName === null) { return []; } - $namespaceName = $scope->getNamespace() ?? ''; - $fullClassName = $namespaceName !== '' ? $namespaceName . '\\' . $shortClassName : $shortClassName; - $classProperties = $this->getClassProperties($node); $matchingPatterns = $this->getMatchingPatterns($fullClassName); @@ -342,25 +335,4 @@ private function validateVisibilityScope( return null; } - - /** - * Convert a type node to string representation. - */ - private function getTypeAsString(ComplexType|Identifier|Name|null $type): ?string - { - return match (true) { - $type === null => null, - $type instanceof Identifier => $type->name, - $type instanceof Name => $type->toString(), - $type instanceof NullableType => - ($inner = $this->getTypeAsString($type->type)) !== null ? '?' . $inner : null, - $type instanceof UnionType => implode('|', array_filter( - array_map(fn ($t) => $this->getTypeAsString($t), $type->types) - )), - $type instanceof IntersectionType => implode('&', array_filter( - array_map(fn ($t) => $this->getTypeAsString($t), $type->types) - )), - default => null, - }; - } } diff --git a/tests/TestCases/Architecture/MethodSignatureMustMatchRuleRequiredTest.php b/tests/TestCases/Architecture/MethodSignatureMustMatchRuleRequiredTest.php index bd33e69..1b99fbb 100644 --- a/tests/TestCases/Architecture/MethodSignatureMustMatchRuleRequiredTest.php +++ b/tests/TestCases/Architecture/MethodSignatureMustMatchRuleRequiredTest.php @@ -34,14 +34,14 @@ public function testRequiredMethodRule(): void $this->analyse([__DIR__ . '/../../../data/MethodSignatureMustMatch/RequiredMethodTestClass.php'], [ // MyTestController is missing the required execute method [ - 'Class MyTestController must implement method execute with signature: public function execute(int $param1).', + 'Class Phauthentic\PHPStanRules\Tests\Data\MethodSignatureMustMatch\MyTestController must implement method execute with signature: public function execute(int $param1).', 8, ], // AnotherTestController implements the method correctly - no error expected // YetAnotherTestController is missing the required execute method [ - 'Class YetAnotherTestController must implement method execute with signature: public function execute(int $param1).', + 'Class Phauthentic\PHPStanRules\Tests\Data\MethodSignatureMustMatch\YetAnotherTestController must implement method execute with signature: public function execute(int $param1).', 24, ], // NotAController doesn't match the pattern - no error expected diff --git a/tests/TestCases/Architecture/RegexAllOfRuleTest.php b/tests/TestCases/Architecture/RegexAllOfRuleTest.php index 8ca1a0a..91bd315 100644 --- a/tests/TestCases/Architecture/RegexAllOfRuleTest.php +++ b/tests/TestCases/Architecture/RegexAllOfRuleTest.php @@ -37,21 +37,16 @@ protected function getRule(): Rule public function testRule(): void { + // With the improved getTypeAsString() from ClassNameResolver trait, + // union types are now properly parsed, so the valid cases pass. + // Only the invalid cases (missing required types) should report errors. $this->analyse([__DIR__ . '/../../../data/MethodMustReturnType/RegexAllOfTestClass.php'], [ [ - 'Method RegexAllOfTestClass::validUnionWithUser must have a return type of all of: regex:/^UserEntity$/, int.', - 6, - ], - [ - 'Method RegexAllOfTestClass::validUnionWithProduct must have a return type of all of: regex:/^ProductEntity$/, string.', - 7, - ], - [ - 'Method RegexAllOfTestClass::invalidUnionMissingUser must have a return type of all of: regex:/^UserEntity$/, int.', + 'Method RegexAllOfTestClass::invalidUnionMissingUser must have all of the return types: regex:/^UserEntity$/, int, OtherClass|int given.', 10, ], [ - 'Method RegexAllOfTestClass::invalidUnionMissingProduct must have a return type of all of: regex:/^ProductEntity$/, string.', + 'Method RegexAllOfTestClass::invalidUnionMissingProduct must have all of the return types: regex:/^ProductEntity$/, string, UserEntity|OtherClass given.', 11, ], ]); From 8213006ff0742046a72ff2d8dbae8a0d421015d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Mon, 9 Feb 2026 23:47:04 +0100 Subject: [PATCH 14/16] Remove unnecessary blank line in processNode method of PropertyMustMatchRule for improved code clarity. --- src/Architecture/PropertyMustMatchRule.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Architecture/PropertyMustMatchRule.php b/src/Architecture/PropertyMustMatchRule.php index d6bff83..85bfac9 100644 --- a/src/Architecture/PropertyMustMatchRule.php +++ b/src/Architecture/PropertyMustMatchRule.php @@ -76,7 +76,6 @@ public function getNodeType(): string * @return array<\PHPStan\Rules\RuleError> */ public function processNode(Node $node, Scope $scope): array - { $fullClassName = $this->resolveFullClassName($node, $scope); if ($fullClassName === null) { From 390c96adde3d9263d063f509fd0c60db3258e989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Tue, 10 Feb 2026 00:01:32 +0100 Subject: [PATCH 15/16] Add tests to skip non-modular and anonymous classes in architecture rules - Introduced tests in CircularModuleDependencyRuleTest to skip non-modular namespaces and same module imports. - Added tests in ClassMustBeFinalRuleTest to ensure final classes and anonymous classes are correctly handled. - Updated ForbiddenAccessorsRuleTest to skip anonymous classes. - Enhanced ForbiddenStaticMethodsRuleTest to skip dynamic method and class names. These additions improve the robustness of the architecture rules by ensuring that specific cases are properly ignored during analysis. --- data/BoolNaming/EdgeCaseMethodBoolClass.php | 30 +++++ data/Forbidden/ChildService.php | 13 +++ data/Forbidden/ForbiddenService.php | 23 ++++ .../AnonymousClassWithAccessors.php | 14 +++ data/ForbiddenStaticMethods/DynamicCall.php | 20 ++++ .../EdgeCaseTestClass.php | 28 +++++ .../Application/NonModularImport.php | 15 +++ .../Application/SameModuleImport.php | 15 +++ .../NonModular/OutsideClass.php | 15 +++ data/Service/AnonymousServiceClass.php | 9 ++ data/Service/FinalRuleService.php | 7 ++ .../CircularModuleDependencyRuleTest.php | 27 +++++ .../Architecture/ClassMustBeFinalRuleTest.php | 12 ++ .../ForbiddenAccessorsRuleExceptionsTest.php | 25 +++++ ...ddenAccessorsRulePrivateVisibilityTest.php | 38 +++++++ .../ForbiddenAccessorsRuleTest.php | 6 + ...nStaticMethodsRuleSelfStaticParentTest.php | 46 ++++++++ .../ForbiddenStaticMethodsRuleTest.php | 6 + .../MethodMustReturnTypeRuleEdgeCasesTest.php | 106 ++++++++++++++++++ ...ustFollowNamingConventionEdgeCasesTest.php | 30 +++++ .../PropertyMustMatchRuleExceptionsTest.php | 18 +++ ...pertyMustMatchRuleIntersectionTypeTest.php | 36 ++++++ .../PropertyMustMatchRuleInvalidRegexTest.php | 33 ++++++ ...ertyMustMatchRuleInvalidVisibilityTest.php | 37 ++++++ 24 files changed, 609 insertions(+) create mode 100644 data/BoolNaming/EdgeCaseMethodBoolClass.php create mode 100644 data/Forbidden/ChildService.php create mode 100644 data/Forbidden/ForbiddenService.php create mode 100644 data/ForbiddenAccessors/AnonymousClassWithAccessors.php create mode 100644 data/ForbiddenStaticMethods/DynamicCall.php create mode 100644 data/MethodMustReturnType/EdgeCaseTestClass.php create mode 100644 data/ModularArchitectureTest/Capability/ProductCatalog/Application/NonModularImport.php create mode 100644 data/ModularArchitectureTest/Capability/UserManagement/Application/SameModuleImport.php create mode 100644 data/ModularArchitectureTest/NonModular/OutsideClass.php create mode 100644 data/Service/AnonymousServiceClass.php create mode 100644 data/Service/FinalRuleService.php create mode 100644 tests/TestCases/Architecture/ForbiddenAccessorsRuleExceptionsTest.php create mode 100644 tests/TestCases/Architecture/ForbiddenAccessorsRulePrivateVisibilityTest.php create mode 100644 tests/TestCases/Architecture/ForbiddenStaticMethodsRuleSelfStaticParentTest.php create mode 100644 tests/TestCases/Architecture/MethodMustReturnTypeRuleEdgeCasesTest.php create mode 100644 tests/TestCases/Architecture/MethodsReturningBoolMustFollowNamingConventionEdgeCasesTest.php create mode 100644 tests/TestCases/Architecture/PropertyMustMatchRuleExceptionsTest.php create mode 100644 tests/TestCases/Architecture/PropertyMustMatchRuleIntersectionTypeTest.php create mode 100644 tests/TestCases/Architecture/PropertyMustMatchRuleInvalidRegexTest.php create mode 100644 tests/TestCases/Architecture/PropertyMustMatchRuleInvalidVisibilityTest.php diff --git a/data/BoolNaming/EdgeCaseMethodBoolClass.php b/data/BoolNaming/EdgeCaseMethodBoolClass.php new file mode 100644 index 0000000..a776278 --- /dev/null +++ b/data/BoolNaming/EdgeCaseMethodBoolClass.php @@ -0,0 +1,30 @@ +analyse( + [__DIR__ . '/../../../data/ModularArchitectureTest/NonModular/OutsideClass.php'], + [] + ); + } + + public function testSameModuleImportIsSkipped(): void + { + // Importing from the same module should not trigger any errors + $this->analyse( + [__DIR__ . '/../../../data/ModularArchitectureTest/Capability/UserManagement/Application/SameModuleImport.php'], + [] + ); + } + + public function testModularFileImportingNonModularClassIsSkipped(): void + { + // A modular file importing a non-modular class (e.g., DateTime) should be skipped + $this->analyse( + [__DIR__ . '/../../../data/ModularArchitectureTest/Capability/ProductCatalog/Application/NonModularImport.php'], + [] + ); + } + public function testCircularDependencyDetection(): void { // Reset to ensure clean state diff --git a/tests/TestCases/Architecture/ClassMustBeFinalRuleTest.php b/tests/TestCases/Architecture/ClassMustBeFinalRuleTest.php index cb1b127..de4929c 100644 --- a/tests/TestCases/Architecture/ClassMustBeFinalRuleTest.php +++ b/tests/TestCases/Architecture/ClassMustBeFinalRuleTest.php @@ -33,4 +33,16 @@ public function testRuleIgnoresAbstractClassesByDefault(): void { $this->analyse([__DIR__ . '/../../../data/Service/AbstractServiceClass.php'], []); } + + public function testFinalClassMatchingPatternPassesRule(): void + { + // A class that matches the pattern but is already final should produce no errors + $this->analyse([__DIR__ . '/../../../data/Service/FinalRuleService.php'], []); + } + + public function testAnonymousClassIsSkipped(): void + { + // Anonymous classes should be skipped (no class name to match) + $this->analyse([__DIR__ . '/../../../data/Service/AnonymousServiceClass.php'], []); + } } diff --git a/tests/TestCases/Architecture/ForbiddenAccessorsRuleExceptionsTest.php b/tests/TestCases/Architecture/ForbiddenAccessorsRuleExceptionsTest.php new file mode 100644 index 0000000..be7e2f7 --- /dev/null +++ b/tests/TestCases/Architecture/ForbiddenAccessorsRuleExceptionsTest.php @@ -0,0 +1,25 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('At least one class pattern must be provided.'); + new ForbiddenAccessorsRule(classPatterns: []); + } + + public function testInvalidVisibilityThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid visibility value(s): invalid'); + new ForbiddenAccessorsRule(classPatterns: ['/./'], visibility: ['invalid']); + } +} diff --git a/tests/TestCases/Architecture/ForbiddenAccessorsRulePrivateVisibilityTest.php b/tests/TestCases/Architecture/ForbiddenAccessorsRulePrivateVisibilityTest.php new file mode 100644 index 0000000..e64539c --- /dev/null +++ b/tests/TestCases/Architecture/ForbiddenAccessorsRulePrivateVisibilityTest.php @@ -0,0 +1,38 @@ + + */ +class ForbiddenAccessorsRulePrivateVisibilityTest extends RuleTestCase +{ + protected function getRule(): ForbiddenAccessorsRule + { + return new ForbiddenAccessorsRule( + classPatterns: ['/\\\\Domain\\\\.*Entity$/'], + forbidGetters: true, + forbidSetters: true, + visibility: ['private'] + ); + } + + public function testPrivateAccessorsAreDetected(): void + { + $this->analyse([__DIR__ . '/../../../data/ForbiddenAccessors/EntityWithAccessors.php'], [ + [ + 'Class App\Domain\UserEntity must not have a private getter method getPrivateValue().', + 41, + ], + [ + 'Class App\Domain\UserEntity must not have a private setter method setPrivateValue().', + 46, + ], + ]); + } +} diff --git a/tests/TestCases/Architecture/ForbiddenAccessorsRuleTest.php b/tests/TestCases/Architecture/ForbiddenAccessorsRuleTest.php index 31cb50c..84e5514 100644 --- a/tests/TestCases/Architecture/ForbiddenAccessorsRuleTest.php +++ b/tests/TestCases/Architecture/ForbiddenAccessorsRuleTest.php @@ -48,4 +48,10 @@ public function testRuleDoesNotMatchNonEntityClasses(): void { $this->analyse([__DIR__ . '/../../../data/ForbiddenAccessors/ServiceWithAccessors.php'], []); } + + public function testAnonymousClassIsSkipped(): void + { + // Anonymous classes should be skipped (resolveFullClassName returns null) + $this->analyse([__DIR__ . '/../../../data/ForbiddenAccessors/AnonymousClassWithAccessors.php'], []); + } } diff --git a/tests/TestCases/Architecture/ForbiddenStaticMethodsRuleSelfStaticParentTest.php b/tests/TestCases/Architecture/ForbiddenStaticMethodsRuleSelfStaticParentTest.php new file mode 100644 index 0000000..842a33f --- /dev/null +++ b/tests/TestCases/Architecture/ForbiddenStaticMethodsRuleSelfStaticParentTest.php @@ -0,0 +1,46 @@ + + */ +class ForbiddenStaticMethodsRuleSelfStaticParentTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new ForbiddenStaticMethodsRule([ + '/^App\\\\Forbidden\\\\ForbiddenService::.*/', + ]); + } + + public function testSelfAndStaticCalls(): void + { + $this->analyse([__DIR__ . '/../../../data/Forbidden/ForbiddenService.php'], [ + [ + 'Static method call "App\Forbidden\ForbiddenService::create" is forbidden.', + 16, + ], + [ + 'Static method call "App\Forbidden\ForbiddenService::create" is forbidden.', + 21, + ], + ]); + } + + public function testParentCall(): void + { + $this->analyse([__DIR__ . '/../../../data/Forbidden/ChildService.php'], [ + [ + 'Static method call "App\Forbidden\ForbiddenService::create" is forbidden.', + 11, + ], + ]); + } +} diff --git a/tests/TestCases/Architecture/ForbiddenStaticMethodsRuleTest.php b/tests/TestCases/Architecture/ForbiddenStaticMethodsRuleTest.php index 0648b67..20e4cbf 100644 --- a/tests/TestCases/Architecture/ForbiddenStaticMethodsRuleTest.php +++ b/tests/TestCases/Architecture/ForbiddenStaticMethodsRuleTest.php @@ -65,4 +65,10 @@ public function testAllowedMethodOnPartiallyForbiddenClass(): void // DateTime::getLastErrors is allowed, only createFromFormat is forbidden $this->analyse([__DIR__ . '/../../../data/ForbiddenStaticMethods/AllowedMethodOnForbiddenClass.php'], []); } + + public function testDynamicCallsAreSkipped(): void + { + // Dynamic method names ($method) and dynamic class names ($class) should be skipped + $this->analyse([__DIR__ . '/../../../data/ForbiddenStaticMethods/DynamicCall.php'], []); + } } diff --git a/tests/TestCases/Architecture/MethodMustReturnTypeRuleEdgeCasesTest.php b/tests/TestCases/Architecture/MethodMustReturnTypeRuleEdgeCasesTest.php new file mode 100644 index 0000000..7a04cd9 --- /dev/null +++ b/tests/TestCases/Architecture/MethodMustReturnTypeRuleEdgeCasesTest.php @@ -0,0 +1,106 @@ + + */ +class MethodMustReturnTypeRuleEdgeCasesTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new MethodMustReturnTypeRule([ + [ + 'pattern' => '/^EdgeCaseTestClass::noReturnTypeWithType$/', + 'type' => 'int', + ], + [ + 'pattern' => '/^EdgeCaseTestClass::noReturnTypeWithOneOf$/', + 'oneOf' => ['int', 'string'], + ], + [ + 'pattern' => '/^EdgeCaseTestClass::noReturnTypeWithAllOf$/', + 'allOf' => ['int', 'string'], + ], + [ + 'pattern' => '/^EdgeCaseTestClass::objectReturnsInt$/', + 'type' => 'object', + ], + [ + 'pattern' => '/^EdgeCaseTestClass::anyOfInvalid$/', + 'anyOf' => ['int', 'string'], + ], + [ + 'pattern' => '/^EdgeCaseTestClass::anyOfValid$/', + 'anyOf' => ['int', 'string'], + ], + [ + 'pattern' => '/^EdgeCaseTestClass::regexTypeValid$/', + 'oneOf' => ['regex:/^Some.*Object$/', 'int'], + ], + [ + 'pattern' => '/^EdgeCaseTestClass::regexTypeInvalid$/', + 'oneOf' => ['regex:/^Some.*Object$/', 'int'], + ], + [ + 'pattern' => '/^EdgeCaseTestClass::validInt$/', + 'type' => 'int', + ], + [ + 'pattern' => '/^EdgeCaseTestClass::validNullableString$/', + 'type' => 'string', + 'nullable' => true, + ], + [ + 'pattern' => '/^EdgeCaseTestClass::validVoid$/', + 'void' => true, + ], + [ + 'pattern' => '/^EdgeCaseTestClass::validObject$/', + 'type' => 'object', + 'objectTypePattern' => '/^SomeEdgeCaseObject$/', + ], + [ + 'pattern' => '/^EdgeCaseTestClass::validNullableObject$/', + 'type' => 'object', + 'nullable' => true, + ], + ]); + } + + public function testEdgeCases(): void + { + $this->analyse([__DIR__ . '/../../../data/MethodMustReturnType/EdgeCaseTestClass.php'], [ + [ + 'Method EdgeCaseTestClass::noReturnTypeWithType must have a return type of int.', + 5, + ], + [ + 'Method EdgeCaseTestClass::noReturnTypeWithOneOf must have a return type of one of: int, string.', + 7, + ], + [ + 'Method EdgeCaseTestClass::noReturnTypeWithAllOf must have a return type of all of: int, string.', + 9, + ], + [ + 'Method EdgeCaseTestClass::objectReturnsInt must return an object type.', + 11, + ], + [ + 'Method EdgeCaseTestClass::anyOfInvalid must have one of the return types: int, string, float given.', + 13, + ], + [ + 'Method EdgeCaseTestClass::regexTypeInvalid must have one of the return types: regex:/^Some.*Object$/, int, float given.', + 19, + ], + ]); + } +} diff --git a/tests/TestCases/Architecture/MethodsReturningBoolMustFollowNamingConventionEdgeCasesTest.php b/tests/TestCases/Architecture/MethodsReturningBoolMustFollowNamingConventionEdgeCasesTest.php new file mode 100644 index 0000000..3778b31 --- /dev/null +++ b/tests/TestCases/Architecture/MethodsReturningBoolMustFollowNamingConventionEdgeCasesTest.php @@ -0,0 +1,30 @@ + + */ +class MethodsReturningBoolMustFollowNamingConventionEdgeCasesTest extends RuleTestCase +{ + protected function getRule(): MethodsReturningBoolMustFollowNamingConventionRule + { + return new MethodsReturningBoolMustFollowNamingConventionRule(); + } + + public function testMagicMethodsAndNoReturnTypeAreSkipped(): void + { + $this->analyse([__DIR__ . '/../../../data/BoolNaming/EdgeCaseMethodBoolClass.php'], [ + [ + 'Method App\BoolNaming\EdgeCaseMethodBoolClass::check() returns boolean but does not follow naming convention (regex: /^(is|has|can|should|was|will)[A-Z_]/).', + 26, + ], + ]); + } +} diff --git a/tests/TestCases/Architecture/PropertyMustMatchRuleExceptionsTest.php b/tests/TestCases/Architecture/PropertyMustMatchRuleExceptionsTest.php new file mode 100644 index 0000000..3d44f34 --- /dev/null +++ b/tests/TestCases/Architecture/PropertyMustMatchRuleExceptionsTest.php @@ -0,0 +1,18 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('At least one property pattern must be provided.'); + new PropertyMustMatchRule([]); + } +} diff --git a/tests/TestCases/Architecture/PropertyMustMatchRuleIntersectionTypeTest.php b/tests/TestCases/Architecture/PropertyMustMatchRuleIntersectionTypeTest.php new file mode 100644 index 0000000..c8807ac --- /dev/null +++ b/tests/TestCases/Architecture/PropertyMustMatchRuleIntersectionTypeTest.php @@ -0,0 +1,36 @@ + + */ +class PropertyMustMatchRuleIntersectionTypeTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new PropertyMustMatchRule([ + [ + 'classPattern' => '/^.*Command$/', + 'properties' => [ + [ + 'name' => 'collection', + 'type' => 'Countable&Iterator', + 'visibilityScope' => 'private', + ], + ], + ], + ]); + } + + public function testIntersectionTypePropertyMatches(): void + { + $this->analyse([__DIR__ . '/../../../data/PropertyMustMatch/TestClass.php'], []); + } +} diff --git a/tests/TestCases/Architecture/PropertyMustMatchRuleInvalidRegexTest.php b/tests/TestCases/Architecture/PropertyMustMatchRuleInvalidRegexTest.php new file mode 100644 index 0000000..de6cd39 --- /dev/null +++ b/tests/TestCases/Architecture/PropertyMustMatchRuleInvalidRegexTest.php @@ -0,0 +1,33 @@ + + */ +class PropertyMustMatchRuleInvalidRegexTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new PropertyMustMatchRule([ + [ + 'classPattern' => '/[invalid/', + 'properties' => [ + ['name' => 'id', 'type' => 'int'], + ], + ], + ]); + } + + public function testInvalidRegexThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->analyse([__DIR__ . '/../../../data/PropertyMustMatch/TestClass.php'], []); + } +} diff --git a/tests/TestCases/Architecture/PropertyMustMatchRuleInvalidVisibilityTest.php b/tests/TestCases/Architecture/PropertyMustMatchRuleInvalidVisibilityTest.php new file mode 100644 index 0000000..50ce555 --- /dev/null +++ b/tests/TestCases/Architecture/PropertyMustMatchRuleInvalidVisibilityTest.php @@ -0,0 +1,37 @@ + + */ +class PropertyMustMatchRuleInvalidVisibilityTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new PropertyMustMatchRule([ + [ + 'classPattern' => '/^.*Controller$/', + 'properties' => [ + [ + 'name' => 'id', + 'visibilityScope' => 'invalid', + ], + ], + ], + ]); + } + + public function testInvalidVisibilityScopeThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid visibilityScope "invalid"'); + $this->analyse([__DIR__ . '/../../../data/PropertyMustMatch/TestClass.php'], []); + } +} From 732423122c1af9f9f8b4ff8c98fbf40b53975cb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Tue, 10 Feb 2026 00:07:44 +0100 Subject: [PATCH 16/16] Enhance documentation and introduce new rules for architectural constraints - Updated README.md to mark the Dependency Constraints Rule as deprecated and introduced the new Forbidden Dependencies Rule, which enforces dependency constraints between namespaces. - Added detailed documentation for the Forbidden Dependencies Rule and the Forbidden Static Methods Rule, outlining their configurations and use cases. - Updated Rules.md to include descriptions and examples for the new rules, ensuring clarity on their functionalities. These changes improve the clarity of the rules and provide guidance for users on enforcing architectural boundaries in their code. --- README.md | 4 +- docs/Rules.md | 22 +++ docs/rules/Forbidden-Dependencies-Rule.md | 173 ++++++++++++++++++++ docs/rules/Forbidden-Static-Methods-Rule.md | 116 +++++++++++++ 4 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 docs/rules/Forbidden-Dependencies-Rule.md create mode 100644 docs/rules/Forbidden-Static-Methods-Rule.md diff --git a/README.md b/README.md index 7e29ba7..4cb8756 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,11 @@ See individual rule documentation for detailed configuration examples. A [full c - [Class Must Be Readonly Rule](docs/rules/Class-Must-Be-Readonly-Rule.md) - [Class Must Have Specification Docblock Rule](docs/rules/Class-Must-Have-Specification-Docblock-Rule.md) - [Classname Must Match Pattern Rule](docs/rules/Classname-Must-Match-Pattern-Rule.md) -- [Dependency Constraints Rule](docs/rules/Dependency-Constraints-Rule.md) +- [Dependency Constraints Rule](docs/rules/Dependency-Constraints-Rule.md) *(deprecated, use Forbidden Dependencies Rule)* - [Forbidden Accessors Rule](docs/rules/Forbidden-Accessors-Rule.md) +- [Forbidden Dependencies Rule](docs/rules/Forbidden-Dependencies-Rule.md) - [Forbidden Namespaces Rule](docs/rules/Forbidden-Namespaces-Rule.md) +- [Forbidden Static Methods Rule](docs/rules/Forbidden-Static-Methods-Rule.md) - [Method Must Return Type Rule](docs/rules/Method-Must-Return-Type-Rule.md) - [Method Signature Must Match Rule](docs/rules/Method-Signature-Must-Match-Rule.md) - [Methods Returning Bool Must Follow Naming Convention Rule](docs/rules/Methods-Returning-Bool-Must-Follow-Naming-Convention-Rule.md) diff --git a/docs/Rules.md b/docs/Rules.md index b53df61..7e0d626 100644 --- a/docs/Rules.md +++ b/docs/Rules.md @@ -22,6 +22,18 @@ Forbids public and/or protected getters and setters on classes matching specifie See [Forbidden Accessors Rule documentation](rules/Forbidden-Accessors-Rule.md) for detailed information. +## Forbidden Dependencies Rule + +Enforces dependency constraints between namespaces by checking `use` statements and optionally fully qualified class names (FQCNs). This rule prevents classes in one namespace from depending on classes in another, helping enforce architectural boundaries like layer separation. + +See [Forbidden Dependencies Rule documentation](rules/Forbidden-Dependencies-Rule.md) for detailed information. + +## Forbidden Static Methods Rule + +Forbids specific static method calls matching regex patterns. Supports namespace-level, class-level, and method-level granularity. The rule resolves `self`, `static`, and `parent` keywords to actual class names. + +See [Forbidden Static Methods Rule documentation](rules/Forbidden-Static-Methods-Rule.md) for detailed information. + ## Property Must Match Rule Ensures that classes matching specified patterns have properties with expected names, types, and visibility scopes. Can optionally enforce that matching classes must have certain properties. @@ -224,6 +236,16 @@ services: tags: - phpstan.rules.rule + # Forbid specific static method calls + - + class: Phauthentic\PHPStanRules\Architecture\ForbiddenStaticMethodsRule + arguments: + forbiddenStaticMethods: + - '/^App\\Legacy\\.*::.*/' + - '/^DateTime::createFromFormat$/' + tags: + - phpstan.rules.rule + # Forbid accessors on domain entities - class: Phauthentic\PHPStanRules\Architecture\ForbiddenAccessorsRule diff --git a/docs/rules/Forbidden-Dependencies-Rule.md b/docs/rules/Forbidden-Dependencies-Rule.md new file mode 100644 index 0000000..43a5343 --- /dev/null +++ b/docs/rules/Forbidden-Dependencies-Rule.md @@ -0,0 +1,173 @@ +# Forbidden Dependencies Rule + +Enforces dependency constraints between namespaces by checking `use` statements and optionally fully qualified class names (FQCNs). This rule prevents classes in one namespace from depending on classes in another, helping enforce architectural boundaries like layer separation. + +> **Note:** This rule replaces the deprecated `DependencyConstraintsRule`. See [Dependency Constraints Rule](Dependency-Constraints-Rule.md) for migration details. + +## Configuration Example + +### Basic Usage (Use Statements Only) + +```neon + - + class: Phauthentic\PHPStanRules\Architecture\ForbiddenDependenciesRule + arguments: + forbiddenDependencies: [ + '/^App\\Domain(?:\\\w+)*$/': ['/^App\\Controller\\/'] + ] + tags: + - phpstan.rules.rule +``` + +### With FQCN Checking Enabled + +```neon + - + class: Phauthentic\PHPStanRules\Architecture\ForbiddenDependenciesRule + arguments: + forbiddenDependencies: [ + '/^App\\Capability(?:\\\w+)*$/': [ + '/^DateTime$/', + '/^DateTimeImmutable$/' + ] + ] + checkFqcn: true + tags: + - phpstan.rules.rule +``` + +### With Selective Reference Types + +```neon + - + class: Phauthentic\PHPStanRules\Architecture\ForbiddenDependenciesRule + arguments: + forbiddenDependencies: [ + '/^App\\Capability(?:\\\w+)*$/': [ + '/^DateTime$/', + '/^DateTimeImmutable$/' + ] + ] + checkFqcn: true + fqcnReferenceTypes: ['new', 'param', 'return', 'property'] + tags: + - phpstan.rules.rule +``` + +## Parameters + +- `forbiddenDependencies`: Array where keys are regex patterns for source namespaces and values are arrays of regex patterns for disallowed dependency namespaces. +- `checkFqcn` (optional, default: `false`): Enable checking of fully qualified class names in addition to use statements. +- `fqcnReferenceTypes` (optional, default: all types): Array of reference types to check when `checkFqcn` is enabled. +- `allowedDependencies` (optional, default: `[]`): Whitelist that overrides forbidden dependencies. If a dependency matches both a forbidden pattern and an allowed pattern, it will be allowed. + +## FQCN Reference Types + +When `checkFqcn` is enabled, the following reference types can be checked: + +- `new` - Class instantiations (e.g., `new \DateTime()`) +- `param` - Parameter type hints (e.g., `function foo(\DateTime $date)`) +- `return` - Return type hints (e.g., `function foo(): \DateTime`) +- `property` - Property type hints (e.g., `private \DateTime $date`) +- `static_call` - Static method calls (e.g., `\DateTime::createFromFormat()`) +- `static_property` - Static property access (e.g., `\DateTime::ATOM`) +- `class_const` - Class constant (e.g., `\DateTime::class`) +- `instanceof` - instanceof checks (e.g., `$x instanceof \DateTime`) +- `catch` - catch blocks (e.g., `catch (\Exception $e)`) +- `extends` - class inheritance (e.g., `class Foo extends \DateTime`) +- `implements` - interface implementation (e.g., `class Foo implements \DateTimeInterface`) + +## Use Cases + +### Enforcing Layer Boundaries + +Prevent domain classes from depending on infrastructure or presentation layers: + +```neon + - + class: Phauthentic\PHPStanRules\Architecture\ForbiddenDependenciesRule + arguments: + forbiddenDependencies: + '/^App\\Capability\\.*\\Domain/': + - '/^App\\Capability\\.*\\Application/' + - '/^App\\Capability\\.*\\Infrastructure/' + - '/^App\\Capability\\.*\\Presentation/' + '/^App\\Capability\\.*\\Application/': + - '/^App\\Capability\\.*\\Infrastructure/' + - '/^App\\Capability\\.*\\Presentation/' + tags: + - phpstan.rules.rule +``` + +### Preventing DateTime Usage in Domain Layer + +Encourage the use of domain-specific date/time objects instead of PHP's built-in classes: + +```neon + - + class: Phauthentic\PHPStanRules\Architecture\ForbiddenDependenciesRule + arguments: + forbiddenDependencies: [ + '/^App\\Capability(?:\\\w+)*$/': [ + '/^DateTime$/', + '/^DateTimeImmutable$/' + ] + ] + checkFqcn: true + tags: + - phpstan.rules.rule +``` + +This will catch: + +- `use DateTime;` (use statement) +- `new \DateTime()` (instantiation) +- `function foo(\DateTime $date)` (parameter type) +- `function bar(): \DateTime` (return type) +- `private \DateTime $date` (property type) +- And all other reference types listed above + +### Whitelist with allowedDependencies + +The `allowedDependencies` parameter lets you create a "forbid everything except X" pattern. Dependencies matching both forbidden and allowed patterns will be allowed. + +```neon + - + class: Phauthentic\PHPStanRules\Architecture\ForbiddenDependenciesRule + arguments: + forbiddenDependencies: [ + '/^App\\Capability\\.*\\Domain$/': [ + '/.*\\\\.*/' + ] + ] + checkFqcn: true + allowedDependencies: [ + '/^App\\Capability\\.*\\Domain$/': [ + '/^App\\Shared\\/', + '/^App\\Capability\\/', + '/^Psr\\/' + ] + ] + tags: + - phpstan.rules.rule +``` + +This will: + +- **Allow**: `App\Shared\ValueObject\Money`, `App\Capability\Billing\Invoice`, `Psr\Log\LoggerInterface` +- **Forbid**: `Doctrine\ORM\EntityManager`, `Symfony\Component\HttpFoundation\Request` + +## Diagram + +```mermaid +flowchart TD + A[Check Dependency] --> B{Matches forbidden pattern?} + B -->|No| C[Allow] + B -->|Yes| D{Matches allowed pattern?} + D -->|Yes| E[Allow - Override] + D -->|No| F[Report Error] +``` + +## Backward Compatibility + +By default, `checkFqcn` is `false` and `allowedDependencies` is empty, so existing configurations will continue to work exactly as before, checking only `use` statements. The new FQCN checking and allowedDependencies features must be explicitly enabled. diff --git a/docs/rules/Forbidden-Static-Methods-Rule.md b/docs/rules/Forbidden-Static-Methods-Rule.md new file mode 100644 index 0000000..41acce5 --- /dev/null +++ b/docs/rules/Forbidden-Static-Methods-Rule.md @@ -0,0 +1,116 @@ +# Forbidden Static Methods Rule + +Forbids specific static method calls matching regex patterns. This rule checks static method calls against a configurable list of forbidden patterns and supports namespace-level, class-level, and method-level granularity. + +The rule resolves `self`, `static`, and `parent` keywords to the actual class name before matching, so forbidden patterns work correctly even when these keywords are used. + +## Configuration Example + +```neon + - + class: Phauthentic\PHPStanRules\Architecture\ForbiddenStaticMethodsRule + arguments: + forbiddenStaticMethods: + - '/^App\\Legacy\\.*::.*/' + - '/^App\\Utils\\StaticHelper::.*/' + - '/^DateTime::createFromFormat$/' + tags: + - phpstan.rules.rule +``` + +## Parameters + +- `forbiddenStaticMethods`: Array of regex patterns to match against static method calls. Patterns are matched against the format `FQCN::methodName`. + +## Pattern Granularity + +Patterns match against the fully qualified class name followed by `::` and the method name. This allows you to forbid static calls at different levels of granularity: + +### Namespace-level + +Forbid all static calls to any class in a namespace: + +```neon + forbiddenStaticMethods: + - '/^App\\Legacy\\.*::.*/' +``` + +This forbids calls like `App\Legacy\LegacyHelper::doSomething()` and `App\Legacy\OldService::run()`. + +### Class-level + +Forbid all static calls on a specific class: + +```neon + forbiddenStaticMethods: + - '/^App\\Utils\\StaticHelper::.*/' +``` + +This forbids all static method calls on `App\Utils\StaticHelper`, regardless of the method name. + +### Method-level + +Forbid a specific static method on a specific class: + +```neon + forbiddenStaticMethods: + - '/^DateTime::createFromFormat$/' +``` + +This forbids only `DateTime::createFromFormat()` while allowing other static methods like `DateTime::getLastErrors()`. + +## Use Cases + +### Forbid Legacy Static Helpers + +Prevent usage of legacy static helper classes to encourage dependency injection: + +```neon + - + class: Phauthentic\PHPStanRules\Architecture\ForbiddenStaticMethodsRule + arguments: + forbiddenStaticMethods: + - '/^App\\Legacy\\.*::.*/' + - '/^App\\Helpers\\.*::.*/' + tags: + - phpstan.rules.rule +``` + +### Forbid Specific Factory Methods + +Forbid using static factory methods on certain classes while allowing other static methods: + +```neon + - + class: Phauthentic\PHPStanRules\Architecture\ForbiddenStaticMethodsRule + arguments: + forbiddenStaticMethods: + - '/^DateTime::createFromFormat$/' + - '/^DateTime::createFromTimestamp$/' + tags: + - phpstan.rules.rule +``` + +### Forbid All Static Calls in Domain Layer + +Combine with a broad pattern to forbid all static calls from specific namespaces: + +```neon + - + class: Phauthentic\PHPStanRules\Architecture\ForbiddenStaticMethodsRule + arguments: + forbiddenStaticMethods: + - '/^Illuminate\\Support\\Facades\\.*::.*/' + tags: + - phpstan.rules.rule +``` + +## Handling of self, static, and parent + +The rule resolves the keywords `self`, `static`, and `parent` to the actual fully qualified class name before matching against the forbidden patterns. This means: + +- `self::create()` inside `App\Service\MyService` is matched as `App\Service\MyService::create` +- `static::create()` inside `App\Service\MyService` is matched as `App\Service\MyService::create` +- `parent::create()` inside a child class is matched against the parent class name + +Dynamic class names (e.g., `$class::method()`) and dynamic method names (e.g., `DateTime::$method()`) are skipped.