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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions data/ForbiddenStaticMethods/AllowedMethodOnForbiddenClass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace App\Service;

use DateTime;

/**
* Service class that makes an allowed static call to a method on DateTime.
* Only DateTime::createFromFormat is forbidden, not other methods.
*/
class AllowedMethodOnForbiddenClass
{
public function execute(): string
{
// This static call should be allowed (only createFromFormat is forbidden)
return DateTime::getLastErrors() !== false ? 'errors' : 'no errors';
}
}
19 changes: 19 additions & 0 deletions data/ForbiddenStaticMethods/AllowedStaticCall.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace App\Service;

use App\Factory\UserFactory;

/**
* Service class that makes an allowed static call.
*/
class AllowedStaticCall
{
public function execute(): object
{
// This static call should be allowed (not matching any forbidden pattern)
return UserFactory::create();
}
}
19 changes: 19 additions & 0 deletions data/ForbiddenStaticMethods/ForbiddenClassStaticCall.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace App\Service;

use App\Utils\StaticHelper;

/**
* Service class that makes a forbidden static call to a forbidden class.
*/
class ForbiddenClassStaticCall
{
public function execute(): int
{
// This static call should be forbidden (class-level pattern)
return StaticHelper::calculate();
}
}
19 changes: 19 additions & 0 deletions data/ForbiddenStaticMethods/ForbiddenMethodStaticCall.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace App\Service;

use DateTime;

/**
* Service class that makes a forbidden static call to a specific method.
*/
class ForbiddenMethodStaticCall
{
public function execute(): DateTime
{
// This static call should be forbidden (method-level pattern)
return DateTime::createFromFormat('Y-m-d', '2024-01-01');
}
}
19 changes: 19 additions & 0 deletions data/ForbiddenStaticMethods/ForbiddenNamespaceStaticCall.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace App\Service;

use App\Legacy\LegacyHelper;

/**
* Service class that makes a forbidden static call to a class in the Legacy namespace.
*/
class ForbiddenNamespaceStaticCall
{
public function execute(): string
{
// This static call should be forbidden (namespace-level pattern)
return LegacyHelper::doSomething();
}
}
17 changes: 17 additions & 0 deletions data/ForbiddenStaticMethods/LegacyHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace App\Legacy;

/**
* A legacy helper class with static methods.
* Used as a target for forbidden namespace-level static calls.
*/
class LegacyHelper
{
public static function doSomething(): string
{
return 'legacy';
}
}
22 changes: 22 additions & 0 deletions data/ForbiddenStaticMethods/StaticHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace App\Utils;

/**
* A utility class with static methods.
* Used as a target for forbidden class-level static calls.
*/
class StaticHelper
{
public static function calculate(): int
{
return 42;
}

public static function format(): string
{
return 'formatted';
}
}
17 changes: 17 additions & 0 deletions data/ForbiddenStaticMethods/UserFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace App\Factory;

/**
* A factory class with static methods.
* Used as an allowed static call target.
*/
class UserFactory
{
public static function create(): object
{
return new \stdClass();
}
}
141 changes: 141 additions & 0 deletions src/Architecture/ForbiddenStaticMethodsRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<?php

/**
* Copyright (c) Florian Krämer (https://florian-kraemer.net)
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE file
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Florian Krämer (https://florian-kraemer.net)
* @author Florian Krämer
* @link https://github.com/Phauthentic
* @license https://opensource.org/licenses/MIT MIT License
*/

declare(strict_types=1);

namespace Phauthentic\PHPStanRules\Architecture;

use PhpParser\Node;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;

/**
* Specification:
*
* - Checks static method calls in PHP code.
* - A static method call matching a given regex pattern (FQCN::methodName) is not allowed.
* - Supports namespace-level, class-level, and method-level granularity.
* - Reports an error if a forbidden static method call is detected.
*
* @implements Rule<StaticCall>
*/
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<string>
*/
private array $forbiddenStaticMethods;

/**
* @param array<string> $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;
}
}
68 changes: 68 additions & 0 deletions tests/TestCases/Architecture/ForbiddenStaticMethodsRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

declare(strict_types=1);

namespace Phauthentic\PHPStanRules\Tests\TestCases\Architecture;

use Phauthentic\PHPStanRules\Architecture\ForbiddenStaticMethodsRule;
use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;

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