From d2257549b9b5c292a08b576e1a2bc360252eb98c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Kr=C3=A4mer?= Date: Mon, 9 Feb 2026 23:08:59 +0100 Subject: [PATCH] Add ForbiddenStaticMethods rule and related classes - Introduced the `ForbiddenStaticMethodsRule` to enforce restrictions on static method calls based on defined regex patterns. - Created multiple service classes demonstrating both allowed and forbidden static calls, including `AllowedStaticCall`, `ForbiddenClassStaticCall`, and `ForbiddenMethodStaticCall`. - Implemented utility classes like `StaticHelper` and `LegacyHelper` to serve as targets for forbidden calls. - Added comprehensive tests to validate the behavior of the new rule and ensure correct identification of allowed and forbidden static method calls. This enhancement aims to improve code quality by preventing misuse of static methods in the codebase. --- .../AllowedMethodOnForbiddenClass.php | 20 +++ .../AllowedStaticCall.php | 19 +++ .../ForbiddenClassStaticCall.php | 19 +++ .../ForbiddenMethodStaticCall.php | 19 +++ .../ForbiddenNamespaceStaticCall.php | 19 +++ data/ForbiddenStaticMethods/LegacyHelper.php | 17 +++ data/ForbiddenStaticMethods/StaticHelper.php | 22 +++ data/ForbiddenStaticMethods/UserFactory.php | 17 +++ .../ForbiddenStaticMethodsRule.php | 141 ++++++++++++++++++ .../ForbiddenStaticMethodsRuleTest.php | 68 +++++++++ 10 files changed, 361 insertions(+) create mode 100644 data/ForbiddenStaticMethods/AllowedMethodOnForbiddenClass.php create mode 100644 data/ForbiddenStaticMethods/AllowedStaticCall.php create mode 100644 data/ForbiddenStaticMethods/ForbiddenClassStaticCall.php create mode 100644 data/ForbiddenStaticMethods/ForbiddenMethodStaticCall.php create mode 100644 data/ForbiddenStaticMethods/ForbiddenNamespaceStaticCall.php create mode 100644 data/ForbiddenStaticMethods/LegacyHelper.php create mode 100644 data/ForbiddenStaticMethods/StaticHelper.php create mode 100644 data/ForbiddenStaticMethods/UserFactory.php create mode 100644 src/Architecture/ForbiddenStaticMethodsRule.php create mode 100644 tests/TestCases/Architecture/ForbiddenStaticMethodsRuleTest.php diff --git a/data/ForbiddenStaticMethods/AllowedMethodOnForbiddenClass.php b/data/ForbiddenStaticMethods/AllowedMethodOnForbiddenClass.php new file mode 100644 index 0000000..b0a5219 --- /dev/null +++ b/data/ForbiddenStaticMethods/AllowedMethodOnForbiddenClass.php @@ -0,0 +1,20 @@ + + */ +class ForbiddenStaticMethodsRule implements Rule +{ + private const ERROR_MESSAGE = 'Static method call "%s" is forbidden.'; + + private const IDENTIFIER = 'phauthentic.architecture.forbiddenStaticMethods'; + + /** + * An array of regex patterns for forbidden static method calls. + * Patterns match against FQCN::methodName format. + * + * @var array + */ + private array $forbiddenStaticMethods; + + /** + * @param array $forbiddenStaticMethods + */ + public function __construct(array $forbiddenStaticMethods) + { + $this->forbiddenStaticMethods = $forbiddenStaticMethods; + } + + public function getNodeType(): string + { + return StaticCall::class; + } + + /** + * @param StaticCall $node + */ + public function processNode(Node $node, Scope $scope): array + { + // Skip dynamic method names (e.g., DateTime::$method()) + if (!$node->name instanceof Identifier) { + return []; + } + + $className = $this->resolveClassName($node, $scope); + if ($className === null) { + return []; + } + + $methodName = $node->name->toString(); + $fullName = $className . '::' . $methodName; + + foreach ($this->forbiddenStaticMethods as $forbiddenPattern) { + if (preg_match($forbiddenPattern, $fullName)) { + return [ + RuleErrorBuilder::message(sprintf( + self::ERROR_MESSAGE, + $fullName + )) + ->identifier(self::IDENTIFIER) + ->line($node->getLine()) + ->build() + ]; + } + } + + return []; + } + + /** + * Resolves the class name from a static call node. + * Handles Name nodes and self/static/parent keywords. + */ + private function resolveClassName(StaticCall $node, Scope $scope): ?string + { + $class = $node->class; + + // Skip dynamic class names (e.g., $class::method()) + if (!$class instanceof Name) { + return null; + } + + $className = $class->toString(); + + // Handle self, static, parent keywords + if (in_array($className, ['self', 'static', 'parent'], true)) { + $classReflection = $scope->getClassReflection(); + if ($classReflection === null) { + return null; + } + + if ($className === 'parent') { + $parentClass = $classReflection->getParentClass(); + if ($parentClass === null) { + return null; + } + return $parentClass->getName(); + } + + return $classReflection->getName(); + } + + // For fully qualified names, return as-is + if ($class instanceof Name\FullyQualified) { + return $className; + } + + // For non-fully-qualified names, we need to resolve them + // PHPStan's scope can help us resolve the actual class name + return $className; + } +} diff --git a/tests/TestCases/Architecture/ForbiddenStaticMethodsRuleTest.php b/tests/TestCases/Architecture/ForbiddenStaticMethodsRuleTest.php new file mode 100644 index 0000000..0648b67 --- /dev/null +++ b/tests/TestCases/Architecture/ForbiddenStaticMethodsRuleTest.php @@ -0,0 +1,68 @@ + + */ +class ForbiddenStaticMethodsRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new ForbiddenStaticMethodsRule([ + // Namespace-level: forbid all static calls to classes in App\Legacy + '/^App\\\\Legacy\\\\.*::.*/', + // Class-level: forbid all static calls on App\Utils\StaticHelper + '/^App\\\\Utils\\\\StaticHelper::.*/', + // Method-level: forbid only DateTime::createFromFormat + '/^DateTime::createFromFormat$/', + ]); + } + + public function testForbiddenNamespaceStaticCall(): void + { + $this->analyse([__DIR__ . '/../../../data/ForbiddenStaticMethods/ForbiddenNamespaceStaticCall.php'], [ + [ + 'Static method call "App\Legacy\LegacyHelper::doSomething" is forbidden.', + 17, + ], + ]); + } + + public function testForbiddenClassStaticCall(): void + { + $this->analyse([__DIR__ . '/../../../data/ForbiddenStaticMethods/ForbiddenClassStaticCall.php'], [ + [ + 'Static method call "App\Utils\StaticHelper::calculate" is forbidden.', + 17, + ], + ]); + } + + public function testForbiddenMethodStaticCall(): void + { + $this->analyse([__DIR__ . '/../../../data/ForbiddenStaticMethods/ForbiddenMethodStaticCall.php'], [ + [ + 'Static method call "DateTime::createFromFormat" is forbidden.', + 17, + ], + ]); + } + + public function testAllowedStaticCall(): void + { + $this->analyse([__DIR__ . '/../../../data/ForbiddenStaticMethods/AllowedStaticCall.php'], []); + } + + public function testAllowedMethodOnPartiallyForbiddenClass(): void + { + // DateTime::getLastErrors is allowed, only createFromFormat is forbidden + $this->analyse([__DIR__ . '/../../../data/ForbiddenStaticMethods/AllowedMethodOnForbiddenClass.php'], []); + } +}