diff --git a/README.md b/README.md index d1ee3193..ce76738d 100644 --- a/README.md +++ b/README.md @@ -1513,6 +1513,58 @@ public function run(): array ## 2. Doctrine-specific Rules +### NoStringTargetEntityRule + +A Doctrine association attribute must reference its target entity as a class constant, not a string. + +```yaml +rules: + - Symplify\PHPStanRules\Rules\Doctrine\NoStringTargetEntityRule +``` + +```php +#[ManyToOne(targetEntity: 'App\Entity\Category')] +``` + +:x: + +
+ +```php +#[ManyToOne(targetEntity: Category::class)] +``` + +:+1: + +
+ +### NoReadonlyEntityClassRule + +A Doctrine entity is hydrated via reflection without the constructor, so it must not be a `readonly` class. An entity is recognized by an `#[ORM\Entity]` attribute or a public static `loadMetadata()` method. + +```yaml +rules: + - Symplify\PHPStanRules\Rules\Doctrine\NoReadonlyEntityClassRule +``` + +```php +#[ORM\Entity] +final readonly class Product {} +``` + +:x: + +
+ +```php +#[ORM\Entity] +final class Product {} +``` + +:+1: + +
+ ### RequireQueryBuilderOnRepositoryRule Prevents using `$entityManager->createQueryBuilder('...')`, use `$repository->createQueryBuilder()` as safer. @@ -2920,6 +2972,95 @@ $services->set(SomeConsumer::class)
+### PreferClassInDefinitionFetchRule + +A container definition fetch that names a class by a plain string should use the class constant instead. The string is only flagged when it is a real class name; a service id string is left alone. + +```yaml +rules: + - Symplify\PHPStanRules\Rules\Symfony\PreferClassInDefinitionFetchRule +``` + +```php +$container->getDefinition('App\Helper\ColumnSchemaHelper'); +``` + +:x: + +
+ +```php +$container->getDefinition(ColumnSchemaHelper::class); +``` + +:+1: + +
+ +### NoServiceSetterCallRule + +In a PHP config closure, a setter injection wired by hand with `->call('setX', [service(...)])` should be a `#[Required]` attribute on the setter, so autowiring calls it. Only a `setXxx()` method fed a `service()` is reported; a container parameter or a non-setter call stays. + +```yaml +rules: + - Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\NoServiceSetterCallRule +``` + +```php +$services->set(SomeService::class) + ->call('setRepository', [service(SomeRepository::class)]); +``` + +:x: + +
+ +```php +#[Required] +public function setRepository(SomeRepository $someRepository): void +{ + $this->someRepository = $someRepository; +} +``` + +:+1: + +
+ +### NoAutoconfiguredServiceTagRule + +When a PHP config closure's `defaults()` uses `autoconfigure()`, a `->tag()` that autoconfiguration already adds by interface (`console.command`, `form.type`, `kernel.event_subscriber`, `security.voter`, `twig.extension`, `validator.constraint_validator`) is redundant. Only a tag with no attributes of its own is reported. + +```yaml +rules: + - Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\NoAutoconfiguredServiceTagRule +``` + +```php +$services->defaults()->autoconfigure(); + +$services->set(SomeSubscriber::class) + ->tag('kernel.event_subscriber'); +``` + +:x: + +
+ +```php +$services->defaults()->autoconfigure(); + +$services->set(SomeSubscriber::class); +``` + +:+1: + +
+ +--- + +
+ ## 4. PHPUnit-specific Rules ### NoAssertFuncCallInTestsRule diff --git a/config/doctrine-rules.neon b/config/doctrine-rules.neon index ec4c4961..6f524bef 100644 --- a/config/doctrine-rules.neon +++ b/config/doctrine-rules.neon @@ -8,3 +8,7 @@ rules: # test fixtures - Symplify\PHPStanRules\Rules\Doctrine\RequireQueryBuilderOnRepositoryRule + + # entity mapping + - Symplify\PHPStanRules\Rules\Doctrine\NoStringTargetEntityRule + - Symplify\PHPStanRules\Rules\Doctrine\NoReadonlyEntityClassRule diff --git a/config/symfony-config-rules.neon b/config/symfony-config-rules.neon index 2a9af87a..6156bf52 100644 --- a/config/symfony-config-rules.neon +++ b/config/symfony-config-rules.neon @@ -26,6 +26,12 @@ rules: # service('id') where an alias points 'id' at a class - Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\PreferClassServiceReferenceRule + # redundant ->tag() that autoconfigure() already adds + - Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\NoAutoconfiguredServiceTagRule + + # ->call('setX', [service()]) that should be a #[Required] setter + - Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\NoServiceSetterCallRule + services: - class: Symplify\PHPStanRules\Collector\ClassTargetServiceAliasCollector diff --git a/config/symfony-rules.neon b/config/symfony-rules.neon index 7e1ce0b8..e7138dac 100644 --- a/config/symfony-rules.neon +++ b/config/symfony-rules.neon @@ -18,6 +18,7 @@ rules: # dependency injection - Symplify\PHPStanRules\Rules\Symfony\NoServiceJugglingRule + - Symplify\PHPStanRules\Rules\Symfony\PreferClassInDefinitionFetchRule - Symplify\PHPStanRules\Rules\Symfony\NoGetInControllerRule - Symplify\PHPStanRules\Rules\Symfony\NoGetInCommandRule - Symplify\PHPStanRules\Rules\Symfony\NoGetDoctrineInControllerRule diff --git a/src/Enum/RuleIdentifier/DoctrineRuleIdentifier.php b/src/Enum/RuleIdentifier/DoctrineRuleIdentifier.php index 3a928b52..ca9a9486 100644 --- a/src/Enum/RuleIdentifier/DoctrineRuleIdentifier.php +++ b/src/Enum/RuleIdentifier/DoctrineRuleIdentifier.php @@ -21,4 +21,8 @@ final class DoctrineRuleIdentifier public const string NO_LISTENER_WITHOUT_CONTRACT = 'doctrine.noListenerWithoutContract'; public const string REQUIRE_SERVICE_PARENT_REPOSITORY = 'doctrine.requireServiceParentRepository'; + + public const string NO_STRING_TARGET_ENTITY = 'doctrine.noStringTargetEntity'; + + public const string NO_READONLY_ENTITY_CLASS = 'doctrine.noReadonlyEntityClass'; } diff --git a/src/Enum/RuleIdentifier/SymfonyRuleIdentifier.php b/src/Enum/RuleIdentifier/SymfonyRuleIdentifier.php index f24f685b..bf6ba302 100644 --- a/src/Enum/RuleIdentifier/SymfonyRuleIdentifier.php +++ b/src/Enum/RuleIdentifier/SymfonyRuleIdentifier.php @@ -67,4 +67,10 @@ final class SymfonyRuleIdentifier public const string FILE_NAME_MATCHES_EXTENSION = 'symfony.fileNameMatchesExtension'; public const string PREFER_CLASS_SERVICE_REFERENCE = 'symfony.preferClassServiceReference'; + + public const string NO_AUTOCONFIGURED_SERVICE_TAG = 'symfony.noAutoconfiguredServiceTag'; + + public const string NO_SERVICE_SETTER_CALL = 'symfony.noServiceSetterCall'; + + public const string PREFER_CLASS_IN_DEFINITION_FETCH = 'symfony.preferClassInDefinitionFetch'; } diff --git a/src/Rules/Doctrine/NoReadonlyEntityClassRule.php b/src/Rules/Doctrine/NoReadonlyEntityClassRule.php new file mode 100644 index 00000000..717872b3 --- /dev/null +++ b/src/Rules/Doctrine/NoReadonlyEntityClassRule.php @@ -0,0 +1,79 @@ + + */ +final class NoReadonlyEntityClassRule implements Rule +{ + public const string ERROR_MESSAGE = 'Entity class "%s" must not be readonly. Doctrine hydrates entities via reflection without the constructor, which a readonly class forbids. Remove the readonly modifier from the class'; + + private const string ENTITY_ATTRIBUTE = Entity::class; + + public function getNodeType(): string + { + return Class_::class; + } + + /** + * @param Class_ $node + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if (! $node->isReadonly()) { + return []; + } + + if (! $node->name instanceof Identifier) { + return []; + } + + if (! $this->isEntity($node)) { + return []; + } + + $identifierRuleError = RuleErrorBuilder::message( + sprintf(self::ERROR_MESSAGE, (string) $node->namespacedName) + ) + ->identifier(DoctrineRuleIdentifier::NO_READONLY_ENTITY_CLASS) + ->build(); + + return [$identifierRuleError]; + } + + private function isEntity(Class_ $class): bool + { + foreach ($class->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + if ($attr->name->toString() === self::ENTITY_ATTRIBUTE) { + return true; + } + } + } + + $loadMetadataMethod = $class->getMethod('loadMetadata'); + + return $loadMetadataMethod instanceof ClassMethod && $loadMetadataMethod->isPublic(); + } +} diff --git a/src/Rules/Doctrine/NoStringTargetEntityRule.php b/src/Rules/Doctrine/NoStringTargetEntityRule.php new file mode 100644 index 00000000..851a2cbf --- /dev/null +++ b/src/Rules/Doctrine/NoStringTargetEntityRule.php @@ -0,0 +1,75 @@ + + */ +final class NoStringTargetEntityRule implements Rule +{ + public const string ERROR_MESSAGE = 'Doctrine association #[%s] uses a string targetEntity "%s"; use %s::class instead'; + + /** + * @var string[] + */ + private const array ASSOCIATION_ATTRIBUTES = [ + 'Doctrine\ORM\Mapping\ManyToOne', + 'Doctrine\ORM\Mapping\OneToMany', + 'Doctrine\ORM\Mapping\OneToOne', + 'Doctrine\ORM\Mapping\ManyToMany', + ]; + + public function getNodeType(): string + { + return Attribute::class; + } + + /** + * @param Attribute $node + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if (! in_array($node->name->toString(), self::ASSOCIATION_ATTRIBUTES, true)) { + return []; + } + + foreach ($node->args as $arg) { + if (! $arg->name instanceof Identifier || $arg->name->toString() !== 'targetEntity') { + continue; + } + + if (! $arg->value instanceof String_) { + return []; + } + + return [ + RuleErrorBuilder::message( + sprintf(self::ERROR_MESSAGE, $node->name->getLast(), $arg->value->value, $arg->value->value) + ) + ->identifier(DoctrineRuleIdentifier::NO_STRING_TARGET_ENTITY) + ->build(), + ]; + } + + return []; + } +} diff --git a/src/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule.php b/src/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule.php new file mode 100644 index 00000000..2e86cd9f --- /dev/null +++ b/src/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule.php @@ -0,0 +1,193 @@ +set(SomeValidator::class)->tag('validator.constraint_validator'). + * + * When the closure's defaults() opens with autoconfigure(), a service implementing ConstraintValidatorInterface is + * tagged either way, so the tag repeats what the interface already says. Only a tag with no attributes of its own is + * reported - an attribute (a priority, a validator alias, ...) says more than autoconfigure() does and keeps the tag. + * A file with no autoconfigure() is left alone, there the tag is the only thing registering the service. + * + * @see \Symplify\PHPStanRules\Tests\Rules\Symfony\ConfigClosure\NoAutoconfiguredServiceTagRule\NoAutoconfiguredServiceTagRuleTest + * + * @implements Rule + */ +final readonly class NoAutoconfiguredServiceTagRule implements Rule +{ + public const string ERROR_MESSAGE = 'Tag "%s" is added by autoconfigure() on its own, as "%s" is a %s - remove the ->tag() call'; + + private const string SERVICES_VARIABLE_NAME = 'services'; + + /** + * The tags Symfony adds on its own to a service of the given type. + * + * @var array + */ + private const array AUTOCONFIGURED_TAGS = [ + 'console.command' => Command::class, + 'form.type' => 'Symfony\Component\Form\FormTypeInterface', + 'kernel.event_subscriber' => EventSubscriberInterface::class, + 'security.voter' => 'Symfony\Component\Security\Core\Authorization\Voter\VoterInterface', + 'twig.extension' => ExtensionInterface::class, + 'validator.constraint_validator' => 'Symfony\Component\Validator\ConstraintValidatorInterface', + ]; + + public function __construct( + private ReflectionProvider $reflectionProvider, + ) { + } + + public function getNodeType(): string + { + return FileNode::class; + } + + /** + * @param FileNode $node + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $nodeFinder = new NodeFinder(); + + /** @var MethodCall[] $methodCalls */ + $methodCalls = $nodeFinder->findInstanceOf($node->getNodes(), MethodCall::class); + + if (! $this->hasAutoconfigureCall($methodCalls)) { + return []; + } + + $ruleErrors = []; + + foreach ($methodCalls as $methodCall) { + $tagName = $this->matchTagNameWithoutAttributes($methodCall); + if ($tagName === null) { + continue; + } + + $autoconfiguredType = self::AUTOCONFIGURED_TAGS[$tagName] ?? null; + if ($autoconfiguredType === null) { + continue; + } + + $className = $this->resolveTaggedClassName($methodCall); + if ($className === null) { + continue; + } + + if (! $this->isSubclassOf($className, $autoconfiguredType)) { + continue; + } + + $ruleErrors[] = RuleErrorBuilder::message( + sprintf(self::ERROR_MESSAGE, $tagName, $className, $autoconfiguredType) + ) + ->identifier(SymfonyRuleIdentifier::NO_AUTOCONFIGURED_SERVICE_TAG) + // the name, not the call - a chained call starts on the line of the $services->set() above it + ->line($methodCall->name->getStartLine()) + ->build(); + } + + return $ruleErrors; + } + + /** + * @param MethodCall[] $methodCalls + */ + private function hasAutoconfigureCall(array $methodCalls): bool + { + return array_any( + $methodCalls, + static fn (MethodCall $methodCall): bool => $methodCall->name instanceof Identifier && $methodCall->name->toString() === 'autoconfigure' + ); + } + + /** + * @return string|null the tag name of a ->tag() call that adds no attributes of its own + */ + private function matchTagNameWithoutAttributes(MethodCall $methodCall): ?string + { + if (! $methodCall->name instanceof Identifier || $methodCall->name->toString() !== 'tag') { + return null; + } + + $args = $methodCall->getArgs(); + if (count($args) !== 1) { + return null; + } + + return $args[0]->value instanceof String_ ? $args[0]->value->value : null; + } + + /** + * Walks the call chain down to the $services->set() or $services->get() call the tag belongs to. + */ + private function resolveTaggedClassName(MethodCall $methodCall): ?string + { + $currentCall = $methodCall->var; + + while ($currentCall instanceof MethodCall) { + if ($currentCall->var instanceof Variable && $currentCall->var->name === self::SERVICES_VARIABLE_NAME) { + if (! $currentCall->name instanceof Identifier || ! in_array($currentCall->name->toString(), ['set', 'get'], true)) { + return null; + } + + $args = $currentCall->getArgs(); + + return $args === [] ? null : $this->matchClassName($args[0]->value); + } + + $currentCall = $currentCall->var; + } + + return null; + } + + private function matchClassName(Node $classValue): ?string + { + if (! $classValue instanceof ClassConstFetch || ! $classValue->class instanceof Name) { + return null; + } + + if (! $classValue->name instanceof Identifier || $classValue->name->toLowerString() !== 'class') { + return null; + } + + return $classValue->class->toString(); + } + + private function isSubclassOf(string $className, string $parentClassName): bool + { + if (! $this->reflectionProvider->hasClass($className)) { + return false; + } + + return $this->reflectionProvider->getClass($className) + ->is($parentClassName); + } +} diff --git a/src/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule.php b/src/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule.php new file mode 100644 index 00000000..c3b4cffb --- /dev/null +++ b/src/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule.php @@ -0,0 +1,110 @@ +get(X::class)->call('setFoo', [service(Foo::class)]) - + * should be a #[Required] attribute on the setter instead, so autowiring calls it and the config no longer names the + * method by a loose string. + * + * Only a call() naming a setXxx() method that a service() feeds is reported. A call() to another method runs logic + * the container cannot infer, and a setter fed a container parameter has no type to autowire, so both stay a manual call. + * + * @see \Symplify\PHPStanRules\Tests\Rules\Symfony\ConfigClosure\NoServiceSetterCallRule\NoServiceSetterCallRuleTest + * + * @implements Rule + */ +final class NoServiceSetterCallRule implements Rule +{ + public const string ERROR_MESSAGE = 'Setter call() to "%s()" wires the dependency by hand, mark the method #[Required] and let autowiring call it instead'; + + /** + * A setter is a setXxx() method, e.g. setListLeadRepository(). A "setup" or "settle" method is no setter. + */ + private const string SETTER_METHOD_PATTERN = '#^set\p{Lu}#u'; + + private const string SERVICE_FUNCTION = 'Symfony\Component\DependencyInjection\Loader\Configurator\service'; + + public function getNodeType(): string + { + return MethodCall::class; + } + + /** + * @param MethodCall $node + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if (! $node->name instanceof Identifier || $node->name->toString() !== 'call') { + return []; + } + + $firstArg = $node->getArgs()[0] ?? null; + if ($firstArg === null || ! $firstArg->value instanceof String_) { + return []; + } + + $methodName = $firstArg->value->value; + if (preg_match(self::SETTER_METHOD_PATTERN, $methodName) !== 1) { + return []; + } + + // only a setter fed a service() can move to #[Required]; a container parameter is not resolved by type + if (! $this->hasServiceArgument($node)) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf(self::ERROR_MESSAGE, $methodName)) + ->identifier(SymfonyRuleIdentifier::NO_SERVICE_SETTER_CALL) + ->line($node->getStartLine()) + ->build(), + ]; + } + + /** + * The arguments of a call() travel in an array, e.g. ->call('setFieldModel', [service(FieldModel::class)]). + * A service() reference among them is the dependency #[Required] autowiring resolves by type. + */ + private function hasServiceArgument(MethodCall $methodCall): bool + { + $secondArg = $methodCall->getArgs()[1] ?? null; + if ($secondArg === null || ! $secondArg->value instanceof Array_) { + return false; + } + + return array_any( + $secondArg->value->items, + fn (ArrayItem $arrayItem): bool => $arrayItem->value instanceof FuncCall && $this->isServiceFunction($arrayItem->value) + ); + } + + private function isServiceFunction(FuncCall $funcCall): bool + { + if (! $funcCall->name instanceof Name) { + return false; + } + + $functionName = $funcCall->name->toString(); + + return $functionName === 'service' || $functionName === self::SERVICE_FUNCTION; + } +} diff --git a/src/Rules/Symfony/PreferClassInDefinitionFetchRule.php b/src/Rules/Symfony/PreferClassInDefinitionFetchRule.php new file mode 100644 index 00000000..ac80f0d8 --- /dev/null +++ b/src/Rules/Symfony/PreferClassInDefinitionFetchRule.php @@ -0,0 +1,77 @@ +getDefinition('App\Helper\ColumnSchemaHelper') should pass ColumnSchemaHelper::class. The string is + * only flagged when it is a real class name, a service id string such as 'app.helper.core' is left alone. + * + * @see \Symplify\PHPStanRules\Tests\Rules\Symfony\PreferClassInDefinitionFetchRule\PreferClassInDefinitionFetchRuleTest + * + * @implements Rule + */ +final readonly class PreferClassInDefinitionFetchRule implements Rule +{ + public const string ERROR_MESSAGE = 'Fetch the definition by class constant, %s::class, rather than the string "%s"'; + + /** + * @var list + */ + private const array DEFINITION_METHOD_NAMES = ['getDefinition', 'hasDefinition', 'findDefinition', 'removeDefinition']; + + public function __construct( + private ReflectionProvider $reflectionProvider, + ) { + } + + public function getNodeType(): string + { + return MethodCall::class; + } + + /** + * @param MethodCall $node + * + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if (! $node->name instanceof Identifier || ! in_array($node->name->toString(), self::DEFINITION_METHOD_NAMES, true)) { + return []; + } + + $firstArg = $node->getArgs()[0] ?? null; + if (! $firstArg instanceof Arg || ! $firstArg->value instanceof String_) { + return []; + } + + $className = $firstArg->value->value; + if (! $this->reflectionProvider->hasClass($className)) { + return []; + } + + $ruleError = RuleErrorBuilder::message( + sprintf(self::ERROR_MESSAGE, $className, $className) + ) + ->identifier(SymfonyRuleIdentifier::PREFER_CLASS_IN_DEFINITION_FETCH) + ->line($firstArg->getStartLine()) + ->build(); + + return [$ruleError]; + } +} diff --git a/stubs/Doctrine/ORM/Mapping/ManyToOne.php b/stubs/Doctrine/ORM/Mapping/ManyToOne.php new file mode 100644 index 00000000..b60449bb --- /dev/null +++ b/stubs/Doctrine/ORM/Mapping/ManyToOne.php @@ -0,0 +1,15 @@ + $expectedErrorsWithLines + */ + #[DataProvider('provideData')] + public function testRule(string $filePath, array $expectedErrorsWithLines): void + { + $this->analyse([$filePath], $expectedErrorsWithLines); + } + + /** + * @return Iterator, mixed>> + */ + public static function provideData(): Iterator + { + $loadMetadataError = sprintf( + NoReadonlyEntityClassRule::ERROR_MESSAGE, + ReportReadonlyLoadMetadataEntity::class + ); + yield [__DIR__ . '/Fixture/ReportReadonlyLoadMetadataEntity.php', [[$loadMetadataError, 7]]]; + + $attributeError = sprintf( + NoReadonlyEntityClassRule::ERROR_MESSAGE, + ReportReadonlyAttributeEntity::class + ); + yield [__DIR__ . '/Fixture/ReportReadonlyAttributeEntity.php', [[$attributeError, 9]]]; + + yield [__DIR__ . '/Fixture/SkipReadonlyPlainClass.php', []]; + yield [__DIR__ . '/Fixture/SkipNonReadonlyEntity.php', []]; + } + + protected function getRule(): Rule + { + return new NoReadonlyEntityClassRule(); + } +} diff --git a/tests/Rules/Doctrine/NoStringTargetEntityRule/Fixture/ReportStringTargetEntity.php b/tests/Rules/Doctrine/NoStringTargetEntityRule/Fixture/ReportStringTargetEntity.php new file mode 100644 index 00000000..99d0d8e7 --- /dev/null +++ b/tests/Rules/Doctrine/NoStringTargetEntityRule/Fixture/ReportStringTargetEntity.php @@ -0,0 +1,13 @@ + $expectedErrorsWithLines + */ + #[DataProvider('provideData')] + public function testRule(string $filePath, array $expectedErrorsWithLines): void + { + $this->analyse([$filePath], $expectedErrorsWithLines); + } + + /** + * @return Iterator, mixed>> + */ + public static function provideData(): Iterator + { + $errorMessage = sprintf( + NoStringTargetEntityRule::ERROR_MESSAGE, + 'ManyToOne', + 'App\Entity\Category', + 'App\Entity\Category' + ); + yield [__DIR__ . '/Fixture/ReportStringTargetEntity.php', [[$errorMessage, 11]]]; + + yield [__DIR__ . '/Fixture/SkipClassConstTargetEntity.php', []]; + } + + protected function getRule(): Rule + { + return new NoStringTargetEntityRule(); + } +} diff --git a/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/Fixture/RedundantTagConfig.php b/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/Fixture/RedundantTagConfig.php new file mode 100644 index 00000000..7d9e8fc7 --- /dev/null +++ b/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/Fixture/RedundantTagConfig.php @@ -0,0 +1,16 @@ +services(); + + $services->defaults() + ->autoconfigure(); + + $services->set(SomeSubscriber::class) + ->tag('kernel.event_subscriber'); +}; diff --git a/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/Fixture/SkipNoAutoconfigureConfig.php b/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/Fixture/SkipNoAutoconfigureConfig.php new file mode 100644 index 00000000..53989efb --- /dev/null +++ b/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/Fixture/SkipNoAutoconfigureConfig.php @@ -0,0 +1,13 @@ +services(); + + $services->set(SomeSubscriber::class) + ->tag('kernel.event_subscriber'); +}; diff --git a/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/Fixture/SkipTagWithAttributesConfig.php b/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/Fixture/SkipTagWithAttributesConfig.php new file mode 100644 index 00000000..d743f8a0 --- /dev/null +++ b/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/Fixture/SkipTagWithAttributesConfig.php @@ -0,0 +1,16 @@ +services(); + + $services->defaults() + ->autoconfigure(); + + $services->set(SomeSubscriber::class) + ->tag('kernel.event_subscriber', ['priority' => 10]); +}; diff --git a/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/NoAutoconfiguredServiceTagRuleTest.php b/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/NoAutoconfiguredServiceTagRuleTest.php new file mode 100644 index 00000000..c4af6e66 --- /dev/null +++ b/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/NoAutoconfiguredServiceTagRuleTest.php @@ -0,0 +1,57 @@ + $expectedErrorsWithLines + */ + #[DataProvider('provideData')] + public function testRule(string $filePath, array $expectedErrorsWithLines): void + { + $this->analyse([$filePath], $expectedErrorsWithLines); + } + + /** + * @return Iterator, mixed>> + */ + public static function provideData(): Iterator + { + $errorMessage = sprintf( + NoAutoconfiguredServiceTagRule::ERROR_MESSAGE, + 'kernel.event_subscriber', + SomeSubscriber::class, + EventSubscriberInterface::class + ); + yield [__DIR__ . '/Fixture/RedundantTagConfig.php', [[$errorMessage, 15]]]; + + yield [__DIR__ . '/Fixture/SkipNoAutoconfigureConfig.php', []]; + yield [__DIR__ . '/Fixture/SkipTagWithAttributesConfig.php', []]; + } + + /** + * @return array + */ + #[Override] + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/config/configured_rule.neon']; + } + + protected function getRule(): Rule + { + return self::getContainer()->getByType(NoAutoconfiguredServiceTagRule::class); + } +} diff --git a/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/Source/SomeSubscriber.php b/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/Source/SomeSubscriber.php new file mode 100644 index 00000000..16a67c76 --- /dev/null +++ b/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/Source/SomeSubscriber.php @@ -0,0 +1,18 @@ + + */ + public static function getSubscribedEvents(): array + { + return []; + } +} diff --git a/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/config/configured_rule.neon b/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/config/configured_rule.neon new file mode 100644 index 00000000..710d4704 --- /dev/null +++ b/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/config/configured_rule.neon @@ -0,0 +1,2 @@ +rules: + - Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\NoAutoconfiguredServiceTagRule diff --git a/tests/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule/Fixture/SetterCallConfig.php b/tests/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule/Fixture/SetterCallConfig.php new file mode 100644 index 00000000..9154f387 --- /dev/null +++ b/tests/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule/Fixture/SetterCallConfig.php @@ -0,0 +1,15 @@ +services(); + + $services->set(SomeConsumer::class) + ->call('setSomeService', [service(SomeService::class)]); +}; diff --git a/tests/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule/Fixture/SkipNonSetterAndParamConfig.php b/tests/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule/Fixture/SkipNonSetterAndParamConfig.php new file mode 100644 index 00000000..0229759d --- /dev/null +++ b/tests/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule/Fixture/SkipNonSetterAndParamConfig.php @@ -0,0 +1,16 @@ +services(); + + $services->set(SomeConsumer::class) + ->call('configure', [service(SomeService::class)]) + ->call('setSomeService', ['%some.parameter%']); +}; diff --git a/tests/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule/NoServiceSetterCallRuleTest.php b/tests/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule/NoServiceSetterCallRuleTest.php new file mode 100644 index 00000000..57fe38a0 --- /dev/null +++ b/tests/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule/NoServiceSetterCallRuleTest.php @@ -0,0 +1,39 @@ + $expectedErrorsWithLines + */ + #[DataProvider('provideData')] + public function testRule(string $filePath, array $expectedErrorsWithLines): void + { + $this->analyse([$filePath], $expectedErrorsWithLines); + } + + /** + * @return Iterator, mixed>> + */ + public static function provideData(): Iterator + { + $errorMessage = sprintf(NoServiceSetterCallRule::ERROR_MESSAGE, 'setSomeService'); + yield [__DIR__ . '/Fixture/SetterCallConfig.php', [[$errorMessage, 13]]]; + + yield [__DIR__ . '/Fixture/SkipNonSetterAndParamConfig.php', []]; + } + + protected function getRule(): Rule + { + return new NoServiceSetterCallRule(); + } +} diff --git a/tests/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule/Source/SomeConsumer.php b/tests/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule/Source/SomeConsumer.php new file mode 100644 index 00000000..63995782 --- /dev/null +++ b/tests/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule/Source/SomeConsumer.php @@ -0,0 +1,16 @@ +getDefinition(SomeHelper::class); + + $container->getDefinition('Symplify\PHPStanRules\Tests\Rules\Symfony\PreferClassInDefinitionFetchRule\Source\SomeHelper'); + + $container->getDefinition('some.service.id'); + } +} diff --git a/tests/Rules/Symfony/PreferClassInDefinitionFetchRule/PreferClassInDefinitionFetchRuleTest.php b/tests/Rules/Symfony/PreferClassInDefinitionFetchRule/PreferClassInDefinitionFetchRuleTest.php new file mode 100644 index 00000000..3c51e956 --- /dev/null +++ b/tests/Rules/Symfony/PreferClassInDefinitionFetchRule/PreferClassInDefinitionFetchRuleTest.php @@ -0,0 +1,49 @@ + $expectedErrorsWithLines + */ + #[DataProvider('provideData')] + public function testRule(string $filePath, array $expectedErrorsWithLines): void + { + $this->analyse([$filePath], $expectedErrorsWithLines); + } + + /** + * @return Iterator, mixed>> + */ + public static function provideData(): Iterator + { + $className = SomeHelper::class; + $errorMessage = sprintf(PreferClassInDefinitionFetchRule::ERROR_MESSAGE, $className, $className); + yield [__DIR__ . '/Fixture/SomeCompilerPass.php', [[$errorMessage, 16]]]; + } + + /** + * @return array + */ + #[Override] + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/config/configured_rule.neon']; + } + + protected function getRule(): Rule + { + return self::getContainer()->getByType(PreferClassInDefinitionFetchRule::class); + } +} diff --git a/tests/Rules/Symfony/PreferClassInDefinitionFetchRule/Source/SomeHelper.php b/tests/Rules/Symfony/PreferClassInDefinitionFetchRule/Source/SomeHelper.php new file mode 100644 index 00000000..085158dc --- /dev/null +++ b/tests/Rules/Symfony/PreferClassInDefinitionFetchRule/Source/SomeHelper.php @@ -0,0 +1,9 @@ +