diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b04fa3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/vendor +/phpunit.xml +/.php_cs.cache +composer.lock +.php-cs-fixer.cache +.phpunit.result.cache diff --git a/extension.neon b/extension.neon index 80cf7ed..075005a 100644 --- a/extension.neon +++ b/extension.neon @@ -7,3 +7,4 @@ parameters: - stubs/Money/MoneyParser.stub rules: - Ibexa\PHPStan\Rules\NoConfigResolverParametersInConstructorRule + - Ibexa\PHPStan\Rules\RequireAbstractionInDependenciesRule diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..e69de29 diff --git a/phpstan.neon b/phpstan.neon index ab3d432..0e28375 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,6 +1,14 @@ +includes: + - phpstan-baseline.neon + parameters: level: 8 paths: - rules - tests checkMissingCallableSignature: true + ignoreErrors: + # Test fixture properties are intentionally write-only for testing purposes + - + message: "#^Property .* is never read, only written\\.$#" + path: tests/rules/Fixtures/RequireAbstractionInDependenciesFixture.php diff --git a/rules/RequireAbstractionInDependenciesRule.php b/rules/RequireAbstractionInDependenciesRule.php new file mode 100644 index 0000000..255b882 --- /dev/null +++ b/rules/RequireAbstractionInDependenciesRule.php @@ -0,0 +1,116 @@ + + */ +final class RequireAbstractionInDependenciesRule implements Rule +{ + private ReflectionProvider $reflectionProvider; + + public function __construct( + ReflectionProvider $reflectionProvider + ) { + $this->reflectionProvider = $reflectionProvider; + } + + public function getNodeType(): string + { + return Node\Stmt\ClassMethod::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->params) { + return []; + } + + $errors = []; + + foreach ($node->params as $param) { + $error = $this->validateParameter($param); + if ($error !== null) { + $errors[] = $error; + } + } + + return $errors; + } + + private function validateParameter(Node\Param $param): ?RuleError + { + if (!$param->type instanceof Node\Name) { + return null; + } + + if ($param->var instanceof Error) { + return null; + } + + $typeName = $param->type->toString(); + + // Skip if the type doesn't exist in reflection + if (!$this->reflectionProvider->hasClass($typeName)) { + return null; + } + + $classReflection = $this->reflectionProvider->getClass($typeName); + + // Skip interfaces - they are always acceptable + if ($classReflection->isInterface()) { + return null; + } + + // Skip abstract classes - they are acceptable + if ($classReflection->isAbstract()) { + return null; + } + + $reflection = $classReflection->getNativeReflection(); + + // This is a concrete class - check if it has interfaces or extends an abstract class + $interfaces = class_implements($typeName); + $parentClass = $reflection->getParentClass(); + $hasAbstractParent = $parentClass && $parentClass->isAbstract(); + + // If there are no interfaces and no abstract parent, it's acceptable (no violation) + if (empty($interfaces) && !$hasAbstractParent) { + return null; + } + + // Build error with suggestions + $suggestions = []; + + if (!empty($interfaces)) { + $suggestions[] = 'Available interfaces: ' . implode(', ', $interfaces); + } + + if ($hasAbstractParent) { + $suggestions[] = 'Abstract parent: ' . $parentClass->getName(); + } + + return RuleErrorBuilder::message( + sprintf( + 'Parameter $%s uses concrete class %s instead of an interface or abstract class. %s', + is_string($param->var->name) ? $param->var->name : $param->var->name->getType(), + $typeName, + implode('. ', $suggestions) + ) + )->build(); + } +} diff --git a/tests/rules/Fixtures/RequireAbstractionInDependencies/AbstractClass.php b/tests/rules/Fixtures/RequireAbstractionInDependencies/AbstractClass.php new file mode 100644 index 0000000..7eb1f38 --- /dev/null +++ b/tests/rules/Fixtures/RequireAbstractionInDependencies/AbstractClass.php @@ -0,0 +1,14 @@ +concreteClass = $concreteClass; + $this->testInterface = $testInterface; + $this->classWithoutInterface = $classWithoutInterface; + $this->abstractClass = $abstractClass; + $this->concreteExtendingAbstract = $concreteExtendingAbstract; + } + + public function methodWithConcreteClass(ConcreteClass $class): void + { + } + + public function methodWithInterface(TestInterface $interface): void + { + } + + public function methodWithAbstractClass(AbstractClass $abstract): void + { + } + + public function methodWithConcreteExtendingAbstract(ConcreteClassExtendingAbstract $concreteExtendingAbstract): void + { + } + + public function methodWithoutInterface(ClassWithoutInterface $class): void + { + } + + public function methodWithBuiltInTypes(string $str, int $num): void + { + } +} diff --git a/tests/rules/RequireAbstractionInDependenciesRuleTest.php b/tests/rules/RequireAbstractionInDependenciesRuleTest.php new file mode 100644 index 0000000..3365496 --- /dev/null +++ b/tests/rules/RequireAbstractionInDependenciesRuleTest.php @@ -0,0 +1,53 @@ + + */ +final class RequireAbstractionInDependenciesRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new RequireAbstractionInDependenciesRule( + $this->createReflectionProvider() + ); + } + + public function testRule(): void + { + $this->analyse( + [ + __DIR__ . '/Fixtures/RequireAbstractionInDependenciesFixture.php', + ], + [ + [ + 'Parameter $concreteClass uses concrete class Ibexa\Tests\PHPStan\Rules\Fixtures\RequireAbstractionInDependencies\ConcreteClass instead of an interface or abstract class. Available interfaces: Ibexa\Tests\PHPStan\Rules\Fixtures\RequireAbstractionInDependencies\TestInterface', + 29, + ], + [ + 'Parameter $concreteExtendingAbstract uses concrete class Ibexa\Tests\PHPStan\Rules\Fixtures\RequireAbstractionInDependencies\ConcreteClassExtendingAbstract instead of an interface or abstract class. Abstract parent: Ibexa\Tests\PHPStan\Rules\Fixtures\RequireAbstractionInDependencies\AbstractClass', + 29, + ], + [ + 'Parameter $class uses concrete class Ibexa\Tests\PHPStan\Rules\Fixtures\RequireAbstractionInDependencies\ConcreteClass instead of an interface or abstract class. Available interfaces: Ibexa\Tests\PHPStan\Rules\Fixtures\RequireAbstractionInDependencies\TestInterface', + 43, + ], + [ + 'Parameter $concreteExtendingAbstract uses concrete class Ibexa\Tests\PHPStan\Rules\Fixtures\RequireAbstractionInDependencies\ConcreteClassExtendingAbstract instead of an interface or abstract class. Abstract parent: Ibexa\Tests\PHPStan\Rules\Fixtures\RequireAbstractionInDependencies\AbstractClass', + 55, + ], + ] + ); + } +}