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'], []); + } +}