├── .editorconfig ├── LICENSE ├── build ├── rector-downgrade-php.php └── target-repository │ ├── .github │ ├── FUNDING.yml │ └── workflows │ │ └── standalone_install.yaml │ ├── composer.json │ ├── phpstan-for-tests.neon │ └── tests │ └── SomeClass.php.inc ├── composer.json ├── config ├── code-complexity-rules.neon ├── configurable-rules.neon ├── doctrine-rules.neon ├── naming-rules.neon ├── phpunit-rules.neon ├── rector-rules.neon ├── services │ └── services.neon ├── static-rules.neon ├── symfony-config-rules.neon ├── symfony-rules.neon └── symplify-rules.neon ├── ecs.php ├── phpstan.neon ├── rector.php └── src ├── Contract └── PhpDocParser │ └── PhpDocNodeVisitorInterface.php ├── Doctrine ├── DoctrineEntityDocumentAnalyser.php ├── DoctrineEventSubscriberAnalyzer.php └── RepositoryClassResolver.php ├── Enum ├── ClassName.php ├── DoctrineClass.php ├── DoctrineEvents.php ├── MethodName.php ├── RuleIdentifier.php ├── RuleIdentifier │ ├── DoctrineRuleIdentifier.php │ ├── PHPUnitRuleIdentifier.php │ ├── RectorRuleIdentifier.php │ └── SymfonyRuleIdentifier.php ├── SensioClass.php ├── SymfonyClass.php ├── SymfonyFunctionName.php └── TestClassName.php ├── Exception ├── PhpDocParser │ └── InvalidTraverseException.php └── ShouldNotHappenException.php ├── FileSystem └── FileSystem.php ├── Formatter └── RequiredWithMessageFormatter.php ├── Helper └── NamingHelper.php ├── Matcher └── ArrayStringAndFnMatcher.php ├── Naming └── ClassToSuffixResolver.php ├── NodeAnalyzer ├── AttributeFinder.php ├── EnumAnalyzer.php ├── MethodCallNameAnalyzer.php └── SymfonyRequiredMethodAnalyzer.php ├── NodeFinder └── TypeAwareNodeFinder.php ├── NodeTraverser └── SimpleCallableNodeTraverser.php ├── NodeVisitor ├── CallableNodeVisitor.php └── HasScopedReturnNodeVisitor.php ├── PHPUnit └── DataProviderMethodResolver.php ├── ParentClassMethodNodeResolver.php ├── PhpDoc ├── BarePhpDocParser.php ├── PhpDocResolver.php └── SeePhpDocTagNodesFinder.php ├── PhpDocParser ├── PhpDocNodeTraverser.php └── PhpDocNodeVisitor │ ├── AbstractPhpDocNodeVisitor.php │ └── CallablePhpDocNodeVisitor.php ├── Reflection ├── InvokeClassMethodResolver.php └── ReflectionParser.php ├── ReturnTypeExtension └── NodeGetAttributeTypeExtension.php ├── Rules ├── CheckRequiredInterfaceInContractNamespaceRule.php ├── ClassNameRespectsParentSuffixRule.php ├── Complexity │ ├── ForbiddenArrayMethodCallRule.php │ ├── ForbiddenNewArgumentRule.php │ ├── NoArrayMapWithArrayCallableRule.php │ ├── NoConstructorOverrideRule.php │ └── NoJustPropertyAssignRule.php ├── Convention │ └── ParamNameToTypeConventionRule.php ├── Doctrine │ ├── NoDoctrineListenerWithoutContractRule.php │ ├── NoDocumentMockingRule.php │ ├── NoEntityMockingRule.php │ ├── NoGetRepositoryOnServiceRepositoryEntityRule.php │ ├── NoGetRepositoryOutsideServiceRule.php │ ├── NoParentRepositoryRule.php │ ├── NoRepositoryCallInDataFixtureRule.php │ ├── RequireQueryBuilderOnRepositoryRule.php │ └── RequireServiceRepositoryParentRule.php ├── Domain │ ├── RequireAttributeNamespaceRule.php │ └── RequireExceptionNamespaceRule.php ├── Enum │ └── RequireUniqueEnumConstantRule.php ├── Explicit │ ├── ExplicitClassPrefixSuffixRule.php │ └── NoProtectedClassStmtRule.php ├── ForbiddenExtendOfNonAbstractClassRule.php ├── ForbiddenFuncCallRule.php ├── ForbiddenMultipleClassLikeInOneFileRule.php ├── ForbiddenNodeRule.php ├── ForbiddenStaticClassConstFetchRule.php ├── MaximumIgnoredErrorCountRule.php ├── NoDynamicNameRule.php ├── NoEntityOutsideEntityNamespaceRule.php ├── NoGlobalConstRule.php ├── NoReferenceRule.php ├── NoReturnSetterMethodRule.php ├── NoValueObjectInServiceConstructorRule.php ├── PHPUnit │ ├── NoAssertFuncCallInTestsRule.php │ ├── NoDoubleConsecutiveTestMockRule.php │ ├── NoMockObjectAndRealObjectPropertyRule.php │ ├── NoMockOnlyTestRule.php │ ├── NoTestMocksRule.php │ └── PublicStaticDataProviderRule.php ├── PreferredClassRule.php ├── PreventParentMethodVisibilityOverrideRule.php ├── Rector │ ├── NoClassReflectionStaticReflectionRule.php │ ├── NoInstanceOfStaticReflectionRule.php │ ├── NoLeadingBackslashInNameRule.php │ ├── PhpUpgradeDowngradeRegisteredInSetRule.php │ └── PhpUpgradeImplementsMinPhpVersionInterfaceRule.php ├── RequireAttributeNameRule.php ├── SeeAnnotationToTestRule.php ├── StringFileAbsolutePathExistsRule.php ├── Symfony │ ├── ConfigClosure │ │ ├── AlreadyRegisteredAutodiscoveryServiceRule.php │ │ ├── NoBundleResourceConfigRule.php │ │ ├── NoDuplicateArgAutowireByTypeRule.php │ │ ├── NoDuplicateArgsAutowireByTypeRule.php │ │ ├── NoServiceSameNameSetClassRule.php │ │ ├── PreferAutowireAttributeOverConfigParamRule.php │ │ ├── ServicesExcludedDirectoryMustExistRule.php │ │ └── TaggedIteratorOverRepeatedServiceCallRule.php │ ├── FormTypeClassNameRule.php │ ├── NoAbstractControllerConstructorRule.php │ ├── NoBareAndSecurityIsGrantedContentsRule.php │ ├── NoClassLevelRouteRule.php │ ├── NoConstructorAndRequiredTogetherRule.php │ ├── NoFindTaggedServiceIdsCallRule.php │ ├── NoGetDoctrineInControllerRule.php │ ├── NoGetInCommandRule.php │ ├── NoGetInControllerRule.php │ ├── NoListenerWithoutContractRule.php │ ├── NoRequiredOutsideClassRule.php │ ├── NoRouteTrailingSlashPathRule.php │ ├── NoRoutingPrefixRule.php │ ├── NoStringInGetSubscribedEventsRule.php │ ├── RequireInvokableControllerRule.php │ ├── RequireIsGrantedEnumRule.php │ ├── RequireRouteNameToGenerateControllerRouteRule.php │ ├── RequiredOnlyInAbstractRule.php │ ├── SingleArgEventDispatchRule.php │ └── SingleRequiredMethodRule.php └── UppercaseConstantRule.php ├── Symfony ├── ConfigClosure │ ├── SymfonyClosureServicesExcludeResolver.php │ ├── SymfonyClosureServicesLoadResolver.php │ ├── SymfonyClosureServicesSetClassesResolver.php │ └── SymfonyServiceReferenceFunctionAnalyzer.php ├── NodeAnalyzer │ ├── SymfonyClosureDetector.php │ ├── SymfonyCommandAnalyzer.php │ └── SymfonyControllerAnalyzer.php ├── NodeFinder │ └── RepeatedServiceAdderCallNameFinder.php └── Reflection │ └── ClassConstructorTypesResolver.php ├── Testing └── PHPUnitTestAnalyser.php ├── TypeAnalyzer ├── CallableTypeAnalyzer.php └── RectorAllowedAutoloadedTypeAnalyzer.php ├── ValueObject └── Configuration │ └── RequiredWithMessage.php └── functions └── fast-functions.php /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | indent_size = 4 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | --------------- 3 | 4 | Copyright (c) 2020 Tomas Votruba (https://tomasvotruba.com) 5 | 6 | Permission is hereby granted, free of charge, to any person 7 | obtaining a copy of this software and associated documentation 8 | files (the "Software"), to deal in the Software without 9 | restriction, including without limitation the rights to use, 10 | copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the 12 | Software is furnished to do so, subject to the following 13 | conditions: 14 | 15 | The above copyright notice and this permission notice shall be 16 | included in all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 22 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 23 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 24 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 25 | OTHER DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /build/rector-downgrade-php.php: -------------------------------------------------------------------------------- 1 | withDowngradeSets(php74: true) 9 | ->withSkip(['*/Tests/*', '*/tests/*', __DIR__ . '/../tests']); 10 | -------------------------------------------------------------------------------- /build/target-repository/.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | github: tomasvotruba 3 | custom: https://www.paypal.me/rectorphp 4 | -------------------------------------------------------------------------------- /build/target-repository/.github/workflows/standalone_install.yaml: -------------------------------------------------------------------------------- 1 | name: Standalone Install 2 | 3 | on: [pull_request, push] 4 | 5 | jobs: 6 | standalone_install: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | php_version: ['7.4', '8.0', '8.3'] 13 | 14 | steps: 15 | # prepare empty composer.json that allows the phpstan extension plugin 16 | - run: composer init --name just/for-test --quiet 17 | - run: composer config --no-plugins allow-plugins.phpstan/extension-installer true 18 | 19 | - run: composer require phpstan/phpstan phpstan/extension-installer --dev 20 | - run: composer require symplify/phpstan-rules:dev-main --dev 21 | 22 | 23 | -------------------------------------------------------------------------------- /build/target-repository/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symplify/phpstan-rules", 3 | "type": "phpstan-extension", 4 | "description": "Set of Symplify rules for PHPStan", 5 | "license": "MIT", 6 | "require": { 7 | "php": "^7.4|^8.0", 8 | "phpstan/phpstan": "^2.1.8", 9 | "nette/utils": "^3.2|^4.0", 10 | "webmozart/assert": "^1.11" 11 | }, 12 | "autoload": { 13 | "psr-4": { 14 | "Symplify\\PHPStanRules\\": "src" 15 | }, 16 | "files": [ 17 | "src/functions/fast-functions.php" 18 | ] 19 | }, 20 | "extra": { 21 | "phpstan": { 22 | "includes": [ 23 | "config/services/services.neon" 24 | ] 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /build/target-repository/phpstan-for-tests.neon: -------------------------------------------------------------------------------- 1 | # this config is only for tests, it verifies all the rules are runnable 2 | includes: 3 | - vendor/symplify/phpstan-rules/config/symplify-rules.neon 4 | 5 | parameters: 6 | level: 4 7 | 8 | ignoreErrors: 9 | - '#Class method "getName\(\)" is never used#' 10 | -------------------------------------------------------------------------------- /build/target-repository/tests/SomeClass.php.inc: -------------------------------------------------------------------------------- 1 | name; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symplify/phpstan-rules", 3 | "type": "phpstan-extension", 4 | "description": "Set of Symplify rules for PHPStan", 5 | "license": "MIT", 6 | "require": { 7 | "php": ">=8.2", 8 | "webmozart/assert": "^1.11", 9 | "phpstan/phpstan": "^2.1.8", 10 | "nette/utils": "^3.2|^4.0", 11 | "phpstan/phpdoc-parser": "^2.1" 12 | }, 13 | "require-dev": { 14 | "nikic/php-parser": "^5.4", 15 | "phpunit/phpunit": "^11.5", 16 | "symfony/framework-bundle": "6.1.*", 17 | "phpecs/phpecs": "^2.1", 18 | "tomasvotruba/class-leak": "^2.0", 19 | "rector/rector": "^2.0.11", 20 | "phpstan/extension-installer": "^1.4", 21 | "symplify/phpstan-extensions": "^12.0", 22 | "tomasvotruba/unused-public": "^2.0", 23 | "tomasvotruba/type-coverage": "^2.0", 24 | "shipmonk/composer-dependency-analyser": "^1.8" 25 | }, 26 | "autoload": { 27 | "psr-4": { 28 | "Symplify\\PHPStanRules\\": "src" 29 | }, 30 | "files": [ 31 | "src/functions/fast-functions.php" 32 | ] 33 | }, 34 | "autoload-dev": { 35 | "psr-4": { 36 | "Symplify\\PHPStanRules\\Tests\\": "tests" 37 | }, 38 | "classmap": [ 39 | "stubs" 40 | ], 41 | "files": [ 42 | "vendor/symfony/dependency-injection/Loader/Configurator/ContainerConfigurator.php" 43 | ] 44 | }, 45 | "config": { 46 | "platform-check": false, 47 | "allow-plugins": { 48 | "phpstan/extension-installer": true 49 | } 50 | }, 51 | "scripts": { 52 | "check-cs": "vendor/bin/ecs check --ansi", 53 | "fix-cs": "vendor/bin/ecs check --fix --ansi", 54 | "phpstan": "vendor/bin/phpstan analyse --ansi", 55 | "rector": "vendor/bin/rector process --dry-run --ansi" 56 | }, 57 | "extra": { 58 | "phpstan": { 59 | "includes": [ 60 | "config/services/services.neon" 61 | ] 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /config/code-complexity-rules.neon: -------------------------------------------------------------------------------- 1 | rules: 2 | - Symplify\PHPStanRules\Rules\NoDynamicNameRule 3 | - Symplify\PHPStanRules\Rules\Complexity\NoJustPropertyAssignRule 4 | - Symplify\PHPStanRules\Rules\Complexity\NoArrayMapWithArrayCallableRule 5 | - Symplify\PHPStanRules\Rules\Complexity\NoConstructorOverrideRule 6 | -------------------------------------------------------------------------------- /config/configurable-rules.neon: -------------------------------------------------------------------------------- 1 | services: 2 | - 3 | class: Symplify\PHPStanRules\Rules\ForbiddenNodeRule 4 | tags: [phpstan.rules.rule] 5 | arguments: 6 | forbiddenNodes: 7 | - PhpParser\Node\Stmt\Trait_ 8 | - PhpParser\Node\Expr\Empty_ 9 | - PhpParser\Node\Stmt\Switch_ 10 | - PhpParser\Node\Expr\ErrorSuppress 11 | - PhpParser\Node\Scalar\Encapsed 12 | - PhpParser\Node\Scalar\EncapsedStringPart 13 | # use pre* nodes instead 14 | - PhpParser\Node\Expr\PostInc 15 | - PhpParser\Node\Expr\PostDec 16 | 17 | - 18 | class: Symplify\PHPStanRules\Rules\PreferredClassRule 19 | tags: [phpstan.rules.rule] 20 | arguments: 21 | oldToPreferredClasses: 22 | # prevents typos 23 | PHPStan\Node\ClassMethod: 'PhpParser\Node\Stmt\ClassMethod' 24 | 'PhpCsFixer\Finder': 'Symfony\Component\Finder\Finder' 25 | 26 | - 27 | class: Symplify\PHPStanRules\Rules\ForbiddenFuncCallRule 28 | tags: [phpstan.rules.rule] 29 | arguments: 30 | forbiddenFunctions: 31 | - 'd' 32 | - 'dd' 33 | - 'dump' 34 | - 'var_dump' 35 | - 'extract' 36 | - 'curl_*' 37 | - 'compact' 38 | - 'method_exists' 39 | - 'property_exists' 40 | - 'spl_autoload_register' 41 | - 'spl_autoload_unregister' 42 | - array_walk 43 | 44 | - 45 | class: Symplify\PHPStanRules\Rules\SeeAnnotationToTestRule 46 | tags: [phpstan.rules.rule] 47 | arguments: 48 | requiredSeeTypes: 49 | - PHPStan\Rules\Rule 50 | - PHP_CodeSniffer\Sniffs\Sniff 51 | - PHP_CodeSniffer\Fixer 52 | -------------------------------------------------------------------------------- /config/doctrine-rules.neon: -------------------------------------------------------------------------------- 1 | rules: 2 | - Symplify\PHPStanRules\Rules\Doctrine\NoGetRepositoryOutsideServiceRule 3 | - Symplify\PHPStanRules\Rules\Doctrine\NoParentRepositoryRule 4 | - Symplify\PHPStanRules\Rules\Doctrine\NoRepositoryCallInDataFixtureRule 5 | - Symplify\PHPStanRules\Rules\Doctrine\NoGetRepositoryOnServiceRepositoryEntityRule 6 | 7 | - Symplify\PHPStanRules\Rules\Doctrine\NoDoctrineListenerWithoutContractRule 8 | 9 | # test fixtures 10 | - Symplify\PHPStanRules\Rules\Doctrine\RequireQueryBuilderOnRepositoryRule 11 | -------------------------------------------------------------------------------- /config/naming-rules.neon: -------------------------------------------------------------------------------- 1 | rules: 2 | - Symplify\PHPStanRules\Rules\Explicit\ExplicitClassPrefixSuffixRule 3 | - Symplify\PHPStanRules\Rules\NoReturnSetterMethodRule 4 | - Symplify\PHPStanRules\Rules\UppercaseConstantRule 5 | - Symplify\PHPStanRules\Rules\ClassNameRespectsParentSuffixRule 6 | -------------------------------------------------------------------------------- /config/phpunit-rules.neon: -------------------------------------------------------------------------------- 1 | rules: 2 | - Symplify\PHPStanRules\Rules\PHPUnit\PublicStaticDataProviderRule 3 | - Symplify\PHPStanRules\Rules\PHPUnit\NoAssertFuncCallInTestsRule 4 | 5 | # mocking 6 | - Symplify\PHPStanRules\Rules\PHPUnit\NoMockOnlyTestRule 7 | - Symplify\PHPStanRules\Rules\Doctrine\NoDocumentMockingRule 8 | - Symplify\PHPStanRules\Rules\Doctrine\NoEntityMockingRule 9 | - Symplify\PHPStanRules\Rules\PHPUnit\NoMockObjectAndRealObjectPropertyRule 10 | - Symplify\PHPStanRules\Rules\PHPUnit\NoDoubleConsecutiveTestMockRule 11 | -------------------------------------------------------------------------------- /config/services/services.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | # related to MaximumIgnoredErrorCountRule 3 | maximumIgnoredErrorCount: 0 4 | 5 | parametersSchema: 6 | # related to MaximumIgnoredErrorCountRule 7 | maximumIgnoredErrorCount: int() 8 | 9 | services: 10 | - Symplify\PHPStanRules\NodeTraverser\SimpleCallableNodeTraverser 11 | - Symplify\PHPStanRules\PhpDocParser\PhpDocNodeTraverser 12 | - Symplify\PHPStanRules\Reflection\ReflectionParser 13 | - Symplify\PHPStanRules\Matcher\ArrayStringAndFnMatcher 14 | - Symplify\PHPStanRules\NodeFinder\TypeAwareNodeFinder 15 | - Symplify\PHPStanRules\PhpDoc\SeePhpDocTagNodesFinder 16 | - Symplify\PHPStanRules\Formatter\RequiredWithMessageFormatter 17 | - Symplify\PHPStanRules\Naming\ClassToSuffixResolver 18 | - Symplify\PHPStanRules\NodeAnalyzer\AttributeFinder 19 | - Symplify\PHPStanRules\NodeAnalyzer\EnumAnalyzer 20 | - Symplify\PHPStanRules\ParentClassMethodNodeResolver 21 | - Symplify\PHPStanRules\PhpDoc\BarePhpDocParser 22 | - Symplify\PHPStanRules\PhpDoc\PhpDocResolver 23 | - Symplify\PHPStanRules\TypeAnalyzer\CallableTypeAnalyzer 24 | 25 | # doctrine 26 | - Symplify\PHPStanRules\Doctrine\DoctrineEntityDocumentAnalyser 27 | 28 | # symfony 29 | - Symplify\PHPStanRules\Symfony\Reflection\ClassConstructorTypesResolver 30 | 31 | # rules enabled by configuration 32 | - 33 | class: Symplify\PHPStanRules\Rules\MaximumIgnoredErrorCountRule 34 | tags: [phpstan.rules.rule] 35 | arguments: 36 | limit: %maximumIgnoredErrorCount% 37 | -------------------------------------------------------------------------------- /config/static-rules.neon: -------------------------------------------------------------------------------- 1 | rules: 2 | - Symplify\PHPStanRules\Rules\ForbiddenExtendOfNonAbstractClassRule 3 | - Symplify\PHPStanRules\Rules\NoGlobalConstRule 4 | 5 | # domain 6 | - Symplify\PHPStanRules\Rules\Domain\RequireExceptionNamespaceRule 7 | - Symplify\PHPStanRules\Rules\Domain\RequireAttributeNamespaceRule 8 | - Symplify\PHPStanRules\Rules\CheckRequiredInterfaceInContractNamespaceRule 9 | - Symplify\PHPStanRules\Rules\RequireAttributeNameRule 10 | 11 | - Symplify\PHPStanRules\Rules\Enum\RequireUniqueEnumConstantRule 12 | - Symplify\PHPStanRules\Rules\PreventParentMethodVisibilityOverrideRule 13 | 14 | - Symplify\PHPStanRules\Rules\ForbiddenMultipleClassLikeInOneFileRule 15 | - Symplify\PHPStanRules\Rules\NoReferenceRule 16 | - Symplify\PHPStanRules\Rules\ForbiddenStaticClassConstFetchRule 17 | - Symplify\PHPStanRules\Rules\Complexity\ForbiddenArrayMethodCallRule 18 | -------------------------------------------------------------------------------- /config/symfony-config-rules.neon: -------------------------------------------------------------------------------- 1 | rules: 2 | - Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\ServicesExcludedDirectoryMustExistRule 3 | - Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\NoBundleResourceConfigRule 4 | - Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\AlreadyRegisteredAutodiscoveryServiceRule 5 | - Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\TaggedIteratorOverRepeatedServiceCallRule 6 | 7 | # args() and arg() call 8 | - Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\NoDuplicateArgsAutowireByTypeRule 9 | - Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\NoDuplicateArgAutowireByTypeRule 10 | 11 | # #[Autowire() in-class attribute over param() in config 12 | - Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\PreferAutowireAttributeOverConfigParamRule 13 | 14 | # $services->set('X', 'X') 15 | - Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\NoServiceSameNameSetClassRule 16 | -------------------------------------------------------------------------------- /config/symfony-rules.neon: -------------------------------------------------------------------------------- 1 | rules: 2 | - Symplify\PHPStanRules\Rules\Symfony\NoAbstractControllerConstructorRule 3 | - Symplify\PHPStanRules\Rules\Symfony\NoRequiredOutsideClassRule 4 | - Symplify\PHPStanRules\Rules\Symfony\SingleArgEventDispatchRule 5 | - Symplify\PHPStanRules\Rules\Symfony\NoListenerWithoutContractRule 6 | - Symplify\PHPStanRules\Rules\Symfony\NoStringInGetSubscribedEventsRule 7 | - Symplify\PHPStanRules\Rules\Symfony\RequireInvokableControllerRule 8 | - Symplify\PHPStanRules\Rules\Symfony\FormTypeClassNameRule 9 | 10 | # routing 11 | - Symplify\PHPStanRules\Rules\Symfony\NoRoutingPrefixRule 12 | - Symplify\PHPStanRules\Rules\Symfony\NoClassLevelRouteRule 13 | - Symplify\PHPStanRules\Rules\Symfony\NoRouteTrailingSlashPathRule 14 | - Symplify\PHPStanRules\Rules\Symfony\RequireRouteNameToGenerateControllerRouteRule 15 | 16 | # dependency injection 17 | - Symplify\PHPStanRules\Rules\Symfony\NoGetInControllerRule 18 | - Symplify\PHPStanRules\Rules\Symfony\NoGetInCommandRule 19 | - Symplify\PHPStanRules\Rules\Symfony\NoGetDoctrineInControllerRule 20 | - Symplify\PHPStanRules\Rules\Symfony\NoFindTaggedServiceIdsCallRule 21 | 22 | # magic required inject 23 | - Symplify\PHPStanRules\Rules\Symfony\SingleRequiredMethodRule 24 | - Symplify\PHPStanRules\Rules\Symfony\RequiredOnlyInAbstractRule 25 | - Symplify\PHPStanRules\Rules\Symfony\NoConstructorAndRequiredTogetherRule 26 | 27 | # attributes 28 | - Symplify\PHPStanRules\Rules\Symfony\RequireIsGrantedEnumRule 29 | - Symplify\PHPStanRules\Rules\Symfony\NoBareAndSecurityIsGrantedContentsRule 30 | -------------------------------------------------------------------------------- /config/symplify-rules.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - code-complexity-rules.neon 3 | - configurable-rules.neon 4 | - naming-rules.neon 5 | - static-rules.neon 6 | -------------------------------------------------------------------------------- /ecs.php: -------------------------------------------------------------------------------- 1 | withPaths([ 9 | __DIR__ . '/config', 10 | __DIR__ . '/src', 11 | __DIR__ . '/tests', 12 | ]) 13 | ->withRootFiles() 14 | ->withPreparedSets(psr12: true, common: true) 15 | ->withSkip([ 16 | '*/Source/*', 17 | '*/Fixture/*', 18 | ]); 19 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - config/services/services.neon 3 | - config/naming-rules.neon 4 | 5 | parameters: 6 | treatPhpDocTypesAsCertain: false 7 | errorFormat: symplify 8 | 9 | level: 8 10 | 11 | # custom configuration 12 | maximumIgnoredErrorCount: 12 13 | 14 | paths: 15 | - src 16 | - config 17 | - tests 18 | 19 | excludePaths: 20 | # parallel 21 | - packages/*-phpstan-printer/tests/*ToPhpCompiler/Fixture* 22 | 23 | # tests 24 | - '*/tests/**/Source/*' 25 | - */stubs/* 26 | - */Fixture/* 27 | 28 | ignoreErrors: 29 | - '#Method Symplify\\PHPStanRules\\Reflection\\ReflectionParser\:\:parseNativeClassReflection\(\) has parameter \$reflectionClass with generic class ReflectionClass but does not specify its types\: T#' 30 | 31 | # overly detailed 32 | - '#Class Symplify\\PHPStanRules\\(.*?) extends generic class PHPStan\\Testing\\RuleTestCase but does not specify its types\: TRule#' 33 | - '#Method Symplify\\PHPStanRules\\(.*?)\:\:getRule\(\) return type with generic interface PHPStan\\Rules\\Rule does not specify its types\: TNodeType#' 34 | - '#Parameter \#2 \$expectedErrors of method PHPStan\\Testing\\RuleTestCase\:\:analyse\(\) expects list, (.*?) given#' 35 | 36 | # part of public contract 37 | - '#Method Symplify\\PHPStanRules\\Tests\\Rules\\(.*?)\\(.*?)Test\:\:testRule\(\) has parameter \$(expectedError(.*?)|expectedErrors) with no value type specified in iterable type array#' 38 | 39 | # useful to have IDE know the types 40 | - identifier: phpstanApi.instanceofType 41 | 42 | # fast effective check 43 | - 44 | message: '#Function is_a\(\) is a runtime reflection concept that might not work in PHPStan because it uses fully static reflection engine#' 45 | path: src/Rules/SeeAnnotationToTestRule.php 46 | 47 | # used in tests 48 | - '#Public constant "Symplify\\PHPStanRules\\(.*?)Rule\:\:ERROR_MESSAGE" is never used#' 49 | 50 | - '#Although PHPStan\\Node\\InClassNode is covered by backward compatibility promise, this instanceof assumption might break because (.*?) not guaranteed to always stay the same#' 51 | - '#PHPStan\\DependencyInjection\\NeonAdapter#' 52 | 53 | # not useful 54 | - '#with generic class ReflectionAttribute (but )?does not specify its types#' 55 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | withPhpSets() 10 | ->withPreparedSets(codeQuality: true, deadCode: true, codingStyle: true, typeDeclarations: true, naming: true, privatization: true, earlyReturn: true, phpunitCodeQuality: true) 11 | ->withPaths([__DIR__ . '/config', __DIR__ . '/src', __DIR__ . '/tests']) 12 | ->withRootFiles() 13 | ->withImportNames() 14 | ->withSkip([ 15 | '*/Source/*', 16 | '*/Fixture/*', 17 | StringClassNameToClassConstantRector::class => [ 18 | __DIR__ . '/src/Symfony/NodeAnalyzer/SymfonyControllerAnalyzer.php', 19 | __DIR__ . '/tests/Naming/ClassToSuffixResolverTest.php', 20 | __DIR__ . '/tests/Rules/Rector/PhpUpgradeImplementsMinPhpVersionInterfaceRule/PhpUpgradeImplementsMinPhpVersionInterfaceRuleTest.php', 21 | ], 22 | ]); 23 | -------------------------------------------------------------------------------- /src/Contract/PhpDocParser/PhpDocNodeVisitorInterface.php: -------------------------------------------------------------------------------- 1 | getResolvedPhpDoc(); 20 | if (! $resolvedPhpDocBlock instanceof ResolvedPhpDocBlock) { 21 | return false; 22 | } 23 | 24 | foreach (self::ENTITY_DOCBLOCK_MARKERS as $entityDocBlockMarkers) { 25 | if (str_contains($resolvedPhpDocBlock->getPhpDocString(), $entityDocBlockMarkers)) { 26 | return true; 27 | } 28 | } 29 | 30 | return false; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Doctrine/DoctrineEventSubscriberAnalyzer.php: -------------------------------------------------------------------------------- 1 | getMethods() as $classMethod) { 16 | if (in_array($classMethod->name->toString(), DoctrineEvents::ORM_LIST)) { 17 | return true; 18 | } 19 | 20 | if (in_array($classMethod->name->toString(), DoctrineEvents::ODM_LIST)) { 21 | return true; 22 | } 23 | } 24 | 25 | return false; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Doctrine/RepositoryClassResolver.php: -------------------------------------------------------------------------------- 1 | .*?)\"#'; 18 | 19 | /** 20 | * @var string 21 | */ 22 | private const REPOSITORY_CLASS_CONST_REGEX = '#repositoryClass=?(\\\\)(?.*?)::class#'; 23 | 24 | /** 25 | * @var string 26 | */ 27 | private const USE_REPOSITORY_REGEX = '#use (?.*?Repository);#'; 28 | 29 | /** 30 | * @var string[] 31 | */ 32 | private const REGEX_TRAIN = [ 33 | self::QUOTED_REPOSITORY_CLASS_REGEX, 34 | self::REPOSITORY_CLASS_CONST_REGEX, 35 | self::USE_REPOSITORY_REGEX, 36 | ]; 37 | 38 | public function __construct( 39 | private ReflectionProvider $reflectionProvider 40 | ) { 41 | } 42 | 43 | public function resolveFromEntityClass(string $entityClassName): ?string 44 | { 45 | if (! $this->reflectionProvider->hasClass($entityClassName)) { 46 | throw new ShouldNotHappenException(sprintf('Entity "%s" class was not found', $entityClassName)); 47 | } 48 | 49 | $classReflection = $this->reflectionProvider->getClass($entityClassName); 50 | 51 | $entityClassFileName = $classReflection->getFileName(); 52 | if ($entityClassFileName === null) { 53 | return null; 54 | } 55 | 56 | $entityFileContents = FileSystem::read($entityClassFileName); 57 | $repositoryClass = null; 58 | 59 | foreach (self::REGEX_TRAIN as $regex) { 60 | $match = Strings::match($entityFileContents, $regex); 61 | if ($match === null) { 62 | continue; 63 | } 64 | 65 | $repositoryClass = $match['repositoryClass']; 66 | break; 67 | } 68 | 69 | if ($repositoryClass === null) { 70 | return null; 71 | } 72 | 73 | if (! $this->reflectionProvider->hasClass($repositoryClass)) { 74 | $errorMessage = sprintf('Repository class "%s" for entity "%s" does not exist', $repositoryClass, $entityClassName); 75 | throw new ShouldNotHappenException($errorMessage); 76 | } 77 | 78 | return $repositoryClass; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Enum/ClassName.php: -------------------------------------------------------------------------------- 1 | $configuration 14 | * @return RequiredWithMessage[] forbidden values as keys, optional helpful messages as value 15 | */ 16 | public function normalizeConfig(array $configuration): array 17 | { 18 | $requiredWithMessages = []; 19 | 20 | foreach ($configuration as $key => $value) { 21 | if (is_int($key)) { 22 | $requiredWithMessages[] = new RequiredWithMessage($value, null); 23 | } elseif (is_string($key)) { 24 | $requiredWithMessages[] = new RequiredWithMessage($key, $value); 25 | } else { 26 | throw new ShouldNotHappenException(); 27 | } 28 | } 29 | 30 | return $requiredWithMessages; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Helper/NamingHelper.php: -------------------------------------------------------------------------------- 1 | name)) { 17 | return $node->name; 18 | } 19 | 20 | if ($node instanceof Identifier || $node instanceof Name) { 21 | return $node->toString(); 22 | } 23 | 24 | return null; 25 | } 26 | 27 | public static function isName(Node $node, string $name): bool 28 | { 29 | return self::getName($node) === $name; 30 | } 31 | 32 | /** 33 | * @param string[] $names 34 | */ 35 | public static function isNames(Node $node, array $names): bool 36 | { 37 | foreach ($names as $name) { 38 | if (self::isName($node, $name)) { 39 | return true; 40 | } 41 | } 42 | 43 | return false; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Matcher/ArrayStringAndFnMatcher.php: -------------------------------------------------------------------------------- 1 | isMatch($currentValue, $matchingValues)) { 18 | return true; 19 | } 20 | 21 | foreach ($matchingValues as $matchingValue) { 22 | if (is_a($currentValue, $matchingValue, true)) { 23 | return true; 24 | } 25 | } 26 | 27 | return false; 28 | } 29 | 30 | /** 31 | * @param string[] $matchingValues 32 | */ 33 | public function isMatch(string $currentValue, array $matchingValues): bool 34 | { 35 | foreach ($matchingValues as $matchingValue) { 36 | if ($currentValue === $matchingValue) { 37 | return true; 38 | } 39 | 40 | if (fnmatch($matchingValue, $currentValue)) { 41 | return true; 42 | } 43 | 44 | if (fnmatch($matchingValue, $currentValue, FNM_NOESCAPE)) { 45 | return true; 46 | } 47 | } 48 | 49 | return false; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Naming/ClassToSuffixResolver.php: -------------------------------------------------------------------------------- 1 | removeAbstractInterfacePrefixSuffix($expectedSuffix); 23 | 24 | // special case for tests 25 | if ($expectedSuffix === 'TestCase') { 26 | return 'Test'; 27 | } 28 | 29 | return $expectedSuffix; 30 | } 31 | 32 | private function removeAbstractInterfacePrefixSuffix(string $parentType): string 33 | { 34 | if (\str_ends_with($parentType, 'Interface')) { 35 | $parentType = substr($parentType, 0, -strlen('Interface')); 36 | } 37 | 38 | if (\str_ends_with($parentType, 'Abstract')) { 39 | $parentType = substr($parentType, 0, -strlen('Abstract')); 40 | } 41 | 42 | if (\str_starts_with($parentType, 'Abstract')) { 43 | return substr($parentType, strlen('Abstract')); 44 | } 45 | 46 | return $parentType; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/NodeAnalyzer/AttributeFinder.php: -------------------------------------------------------------------------------- 1 | findAttribute($node, $desiredAttributeClass); 19 | } 20 | 21 | /** 22 | * @return Attribute[] 23 | */ 24 | private function findAttributes(ClassMethod | Property | ClassLike | Param $node): array 25 | { 26 | $attributes = []; 27 | 28 | foreach ($node->attrGroups as $attrGroup) { 29 | $attributes = array_merge($attributes, $attrGroup->attrs); 30 | } 31 | 32 | return $attributes; 33 | } 34 | 35 | private function findAttribute( 36 | ClassMethod | Property | ClassLike | Param $node, 37 | string $desiredAttributeClass 38 | ): ?Attribute { 39 | $attributes = $this->findAttributes($node); 40 | 41 | foreach ($attributes as $attribute) { 42 | if (! $attribute->name instanceof FullyQualified) { 43 | continue; 44 | } 45 | 46 | if ($attribute->name->toString() === $desiredAttributeClass) { 47 | return $attribute; 48 | } 49 | } 50 | 51 | return null; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/NodeAnalyzer/EnumAnalyzer.php: -------------------------------------------------------------------------------- 1 | getClassReflection(); 29 | if (! $classReflection instanceof ClassReflection) { 30 | return false; 31 | } 32 | 33 | if ($this->hasEnumAnnotation($classLike)) { 34 | return true; 35 | } 36 | 37 | if ($classReflection->is('MyCLabs\Enum\Enum')) { 38 | return true; 39 | } 40 | 41 | // is in /Enum/ namespace 42 | return str_contains($classReflection->getName(), '\\Enum\\'); 43 | } 44 | 45 | private function hasEnumAnnotation(Class_ $class): bool 46 | { 47 | $phpPhpDocNode = $this->barePhpDocParser->parseNode($class); 48 | if (! $phpPhpDocNode instanceof PhpDocNode) { 49 | return false; 50 | } 51 | 52 | return (bool) $phpPhpDocNode->getTagsByName('@enum'); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/NodeAnalyzer/MethodCallNameAnalyzer.php: -------------------------------------------------------------------------------- 1 | name instanceof Identifier) { 16 | return false; 17 | } 18 | 19 | if ($methodCall->name->toString() !== $methodName) { 20 | return false; 21 | } 22 | 23 | // is "$this"? 24 | if (! $methodCall->var instanceof Variable) { 25 | return false; 26 | } 27 | 28 | if (! is_string($methodCall->var->name)) { 29 | return false; 30 | } 31 | 32 | return $methodCall->var->name === 'this'; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/NodeAnalyzer/SymfonyRequiredMethodAnalyzer.php: -------------------------------------------------------------------------------- 1 | isPublic()) { 17 | return false; 18 | } 19 | 20 | if ($classMethod->isMagic()) { 21 | return false; 22 | } 23 | 24 | foreach ($classMethod->getAttrGroups() as $attributeGroup) { 25 | foreach ($attributeGroup->attrs as $attr) { 26 | if ($attr->name->toString() === SymfonyClass::REQUIRED_ATTRIBUTE) { 27 | return true; 28 | } 29 | } 30 | } 31 | 32 | $docComment = $classMethod->getDocComment(); 33 | if (! $docComment instanceof Doc) { 34 | return false; 35 | } 36 | 37 | return str_contains($docComment->getText(), '@required'); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/NodeFinder/TypeAwareNodeFinder.php: -------------------------------------------------------------------------------- 1 | nodeFinder = new NodeFinder(); 20 | } 21 | 22 | /** 23 | * @template TNode as Node 24 | * 25 | * @param Node[]|Node $nodes 26 | * @param class-string $type 27 | * @return TNode|null 28 | */ 29 | public function findFirstInstanceOf(array|Node $nodes, string $type): ?Node 30 | { 31 | return $this->nodeFinder->findFirstInstanceOf($nodes, $type); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/NodeTraverser/SimpleCallableNodeTraverser.php: -------------------------------------------------------------------------------- 1 | addVisitor($callableNodeVisitor); 37 | $nodeTraverser->traverse($nodes); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/NodeVisitor/CallableNodeVisitor.php: -------------------------------------------------------------------------------- 1 | callable = $callable; 26 | } 27 | 28 | public function enterNode(Node $node): int|Node|null 29 | { 30 | $originalNode = $node; 31 | 32 | $callable = $this->callable; 33 | 34 | /** @var int|Node|null $newNode */ 35 | $newNode = $callable($node); 36 | 37 | if ($originalNode instanceof Stmt && $newNode instanceof Expr) { 38 | return new Expression($newNode); 39 | } 40 | 41 | return $newNode; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/NodeVisitor/HasScopedReturnNodeVisitor.php: -------------------------------------------------------------------------------- 1 | expr instanceof Expr) { 32 | return null; 33 | } 34 | 35 | $this->hasReturn = true; 36 | return $node; 37 | } 38 | 39 | public function hasReturn(): bool 40 | { 41 | return $this->hasReturn; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/PHPUnit/DataProviderMethodResolver.php: -------------------------------------------------------------------------------- 1 | getDocComment(); 15 | if (! $docComment instanceof Doc) { 16 | return null; 17 | } 18 | 19 | if (! str_contains($docComment->getText(), '@dataProvider')) { 20 | return null; 21 | } 22 | 23 | preg_match('/@dataProvider\s+(?\w+)/', $docComment->getText(), $matches); 24 | 25 | // reference to static call on another class 26 | if (! isset($matches['method_name'])) { 27 | return null; 28 | } 29 | 30 | return $matches['method_name']; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/ParentClassMethodNodeResolver.php: -------------------------------------------------------------------------------- 1 | getParentClassReflections($scope); 24 | 25 | foreach ($parentClassReflections as $parentClassReflection) { 26 | if (! $parentClassReflection->hasMethod($methodName)) { 27 | continue; 28 | } 29 | 30 | $classReflection = $this->reflectionProvider->getClass($parentClassReflection->getName()); 31 | $parentMethodReflection = $classReflection->getMethod($methodName, $scope); 32 | return $this->reflectionParser->parseMethodReflection($parentMethodReflection); 33 | } 34 | 35 | return null; 36 | } 37 | 38 | /** 39 | * @return ClassReflection[] 40 | */ 41 | private function getParentClassReflections(Scope $scope): array 42 | { 43 | $mainClassReflection = $scope->getClassReflection(); 44 | if (! $mainClassReflection instanceof ClassReflection) { 45 | return []; 46 | } 47 | 48 | // all parent classes and interfaces 49 | return array_filter( 50 | $mainClassReflection->getAncestors(), 51 | static fn (ClassReflection $classReflection): bool => $classReflection !== $mainClassReflection 52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/PhpDoc/BarePhpDocParser.php: -------------------------------------------------------------------------------- 1 | getDocComment(); 29 | if (! $docComment instanceof Doc) { 30 | return null; 31 | } 32 | 33 | return $this->parseDocBlock($docComment->getText()); 34 | } 35 | 36 | /** 37 | * @api 38 | * @return PhpDocTagNode[] 39 | */ 40 | public function parseNodeToPhpDocTagNodes(Node $node): array 41 | { 42 | $phpDocNode = $this->parseNode($node); 43 | if (! $phpDocNode instanceof PhpDocNode) { 44 | return []; 45 | } 46 | 47 | return $this->resolvePhpDocTagNodes($phpDocNode); 48 | } 49 | 50 | private function parseDocBlock(string $docBlock): PhpDocNode 51 | { 52 | $tokens = $this->lexer->tokenize($docBlock); 53 | $tokenIterator = new TokenIterator($tokens); 54 | 55 | return $this->phpDocParser->parse($tokenIterator); 56 | } 57 | 58 | /** 59 | * @return PhpDocTagNode[] 60 | */ 61 | private function resolvePhpDocTagNodes(PhpDocNode $phpDocNode): array 62 | { 63 | $phpDocTagNodes = []; 64 | foreach ($phpDocNode->children as $phpDocChildNode) { 65 | if (! $phpDocChildNode instanceof PhpDocTagNode) { 66 | continue; 67 | } 68 | 69 | $phpDocTagNodes[] = $phpDocChildNode; 70 | } 71 | 72 | return $phpDocTagNodes; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/PhpDoc/PhpDocResolver.php: -------------------------------------------------------------------------------- 1 | fileTypeMapper->getResolvedPhpDoc( 23 | $scope->getFile(), 24 | $classReflection->getName(), 25 | null, 26 | null, 27 | $doc->getText() 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/PhpDoc/SeePhpDocTagNodesFinder.php: -------------------------------------------------------------------------------- 1 | getPhpDocNodes() as $phpDocNode) { 20 | foreach ($phpDocNode->children as $phpDocChildNode) { 21 | if (! $phpDocChildNode instanceof PhpDocTagNode) { 22 | continue; 23 | } 24 | 25 | if ($phpDocChildNode->name !== '@see') { 26 | continue; 27 | } 28 | 29 | $seePhpDocTagNodes[] = $phpDocChildNode; 30 | } 31 | } 32 | 33 | return $seePhpDocTagNodes; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/PhpDocParser/PhpDocNodeVisitor/AbstractPhpDocNodeVisitor.php: -------------------------------------------------------------------------------- 1 | callable = $callable; 24 | } 25 | 26 | public function enterNode(Node $node): int|Node|null 27 | { 28 | $callable = $this->callable; 29 | return $callable($node, $this->docContent); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Reflection/InvokeClassMethodResolver.php: -------------------------------------------------------------------------------- 1 | hasMethod('__invoke')) { 13 | return null; 14 | } 15 | 16 | $nativeReflectionClass = $controllerClassReflection->getNativeReflection(); 17 | return $nativeReflectionClass->getMethod('__invoke'); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/ReturnTypeExtension/NodeGetAttributeTypeExtension.php: -------------------------------------------------------------------------------- 1 | 31 | */ 32 | private const ARGUMENT_KEY_TO_RETURN_TYPE = [ 33 | 'scope' => Scope::class, 34 | ClassName::RECTOR_ATTRIBUTE_KEY . '::SCOPE' => Scope::class, 35 | 'originalNode' => Node::class, 36 | ClassName::RECTOR_ATTRIBUTE_KEY . '::ORIGINAL_NODE' => Node::class, 37 | ]; 38 | 39 | public function getClass(): string 40 | { 41 | return Node::class; 42 | } 43 | 44 | public function isMethodSupported(MethodReflection $methodReflection): bool 45 | { 46 | return $methodReflection->getName() === 'getAttribute'; 47 | } 48 | 49 | public function getTypeFromMethodCall( 50 | MethodReflection $methodReflection, 51 | MethodCall $methodCall, 52 | Scope $scope 53 | ): ?Type { 54 | $firstArg = $methodCall->getArgs()[0]; 55 | 56 | $argumentValue = $this->resolveArgumentValue($firstArg->value); 57 | if ($argumentValue === null) { 58 | return null; 59 | } 60 | 61 | if (! isset(self::ARGUMENT_KEY_TO_RETURN_TYPE[$argumentValue])) { 62 | return null; 63 | } 64 | 65 | $knownReturnType = self::ARGUMENT_KEY_TO_RETURN_TYPE[$argumentValue]; 66 | return new UnionType([new ObjectType($knownReturnType), new NullType()]); 67 | } 68 | 69 | private function resolveArgumentValue(Expr $expr): ?string 70 | { 71 | if ($expr instanceof String_) { 72 | return $expr->value; 73 | } 74 | 75 | if ($expr instanceof ClassConstFetch) { 76 | if (! $expr->class instanceof FullyQualified) { 77 | return null; 78 | } 79 | 80 | if (! $expr->name instanceof Identifier) { 81 | return null; 82 | } 83 | 84 | $className = $expr->class->toString(); 85 | $value = $expr->name->toString(); 86 | 87 | return $className . '::' . $value; 88 | } 89 | 90 | return null; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Rules/CheckRequiredInterfaceInContractNamespaceRule.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | final class CheckRequiredInterfaceInContractNamespaceRule implements Rule 20 | { 21 | /** 22 | * @var string 23 | */ 24 | public const ERROR_MESSAGE = 'Interface must be located in "Contract" or "Contracts" namespace'; 25 | 26 | /** 27 | * @var string 28 | * @see https://regex101.com/r/kmrIG1/2 29 | */ 30 | private const A_CONTRACT_NAMESPACE_REGEX = '#\bContracts?\b#'; 31 | 32 | public function getNodeType(): string 33 | { 34 | return Interface_::class; 35 | } 36 | 37 | /** 38 | * @param Interface_ $node 39 | */ 40 | public function processNode(Node $node, Scope $scope): array 41 | { 42 | $namespace = $scope->getNamespace(); 43 | if ($namespace === null) { 44 | return []; 45 | } 46 | 47 | if (Strings::match($namespace, self::A_CONTRACT_NAMESPACE_REGEX)) { 48 | return []; 49 | } 50 | 51 | return [RuleErrorBuilder::message(self::ERROR_MESSAGE) 52 | ->identifier(RuleIdentifier::REQUIRED_INTERFACE_CONTRACT_NAMESPACE) 53 | ->build()]; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Rules/Complexity/ForbiddenArrayMethodCallRule.php: -------------------------------------------------------------------------------- 1 | 19 | * @see \Symplify\PHPStanRules\Tests\Rules\Complexity\ForbiddenArrayMethodCallRule\ForbiddenArrayMethodCallRuleTest 20 | */ 21 | final class ForbiddenArrayMethodCallRule implements Rule 22 | { 23 | /** 24 | * @var string 25 | */ 26 | public const ERROR_MESSAGE = 'Array method calls [$this, "method"] are not allowed. Use explicit method instead to help PhpStorm, PHPStan and Rector understand your code'; 27 | 28 | public function getNodeType(): string 29 | { 30 | return Array_::class; 31 | } 32 | 33 | /** 34 | * @param Array_ $node 35 | */ 36 | public function processNode(Node $node, Scope $scope): array 37 | { 38 | if (count($node->items) !== 2) { 39 | return []; 40 | } 41 | 42 | $typeWithClassName = $this->resolveFirstArrayItemClassType($node, $scope); 43 | if (! $typeWithClassName instanceof TypeWithClassName) { 44 | return []; 45 | } 46 | 47 | $methodName = $this->resolveSecondArrayItemMethodName($node, $scope); 48 | if ($methodName === null) { 49 | return []; 50 | } 51 | 52 | // does method exist? 53 | if (! $typeWithClassName->hasMethod($methodName)->yes()) { 54 | return []; 55 | } 56 | 57 | return [RuleErrorBuilder::message(self::ERROR_MESSAGE) 58 | ->identifier(RuleIdentifier::FORBIDDEN_ARRAY_METHOD_CALL) 59 | ->build()]; 60 | } 61 | 62 | private function resolveFirstArrayItemClassType(Array_ $array, Scope $scope): ?TypeWithClassName 63 | { 64 | $firstItem = $array->items[0]; 65 | if (! $firstItem instanceof ArrayItem) { 66 | return null; 67 | } 68 | 69 | $firstItemType = $scope->getType($firstItem->value); 70 | if (! $firstItemType instanceof TypeWithClassName) { 71 | return null; 72 | } 73 | 74 | return $firstItemType; 75 | } 76 | 77 | private function resolveSecondArrayItemMethodName(Array_ $array, Scope $scope): ?string 78 | { 79 | $secondItem = $array->items[1]; 80 | if (! $secondItem instanceof ArrayItem) { 81 | return null; 82 | } 83 | 84 | $secondItemValue = $secondItem->value; 85 | 86 | $secondItemType = $scope->getType($secondItemValue); 87 | if (! $secondItemType instanceof ConstantStringType) { 88 | return null; 89 | } 90 | 91 | return $secondItemType->getValue(); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Rules/Complexity/ForbiddenNewArgumentRule.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | final readonly class ForbiddenNewArgumentRule implements Rule 20 | { 21 | /** 22 | * @param string[] $forbiddenTypes 23 | */ 24 | public function __construct( 25 | private array $forbiddenTypes 26 | ) { 27 | } 28 | 29 | public function getNodeType(): string 30 | { 31 | return New_::class; 32 | } 33 | 34 | /** 35 | * @param New_ $node 36 | * @return RuleError[] 37 | */ 38 | public function processNode(Node $node, Scope $scope): array 39 | { 40 | if (! $node->class instanceof Name) { 41 | return []; 42 | } 43 | 44 | $className = $node->class->toString(); 45 | if (! in_array($className, $this->forbiddenTypes)) { 46 | return []; 47 | } 48 | 49 | $errorMessage = sprintf( 50 | 'Type "%s" is forbidden to be created manually. Use service and constructor injection instead', 51 | $className 52 | ); 53 | 54 | $identifierRuleError = RuleErrorBuilder::message($errorMessage) 55 | ->identifier(RuleIdentifier::FORBIDDEN_NEW_INSTANCE) 56 | ->build(); 57 | 58 | return [$identifierRuleError]; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Rules/Complexity/NoArrayMapWithArrayCallableRule.php: -------------------------------------------------------------------------------- 1 | 19 | * 20 | * @see \Symplify\PHPStanRules\Tests\Rules\NoArrayMapWithArrayCallableRule\NoArrayMapWithArrayCallableRuleTest 21 | */ 22 | final class NoArrayMapWithArrayCallableRule implements Rule 23 | { 24 | /** 25 | * @var string 26 | */ 27 | public const ERROR_MESSAGE = 'Avoid using array callables in array_map(), as it cripples static analysis on used method'; 28 | 29 | public function getNodeType(): string 30 | { 31 | return FuncCall::class; 32 | } 33 | 34 | /** 35 | * @param FuncCall $node 36 | * @return RuleError[] 37 | */ 38 | public function processNode(Node $node, Scope $scope): array 39 | { 40 | if (! $node->name instanceof Name) { 41 | return []; 42 | } 43 | 44 | $functionName = $node->name->toString(); 45 | if ($functionName !== 'array_map') { 46 | return []; 47 | } 48 | 49 | if ($node->isFirstClassCallable()) { 50 | return []; 51 | } 52 | 53 | $args = $node->getArgs(); 54 | $firstArgValue = $args[0]->value; 55 | if (! $firstArgValue instanceof Array_) { 56 | return []; 57 | } 58 | 59 | $identifierRuleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) 60 | ->identifier(RuleIdentifier::NO_ARRAY_MAP_WITH_ARRAY_CALLABLE) 61 | ->build(); 62 | 63 | return [$identifierRuleError]; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Rules/Complexity/NoConstructorOverrideRule.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | final class NoConstructorOverrideRule implements Rule 20 | { 21 | /** 22 | * @var string 23 | */ 24 | public const ERROR_MESSAGE = 'Possible __construct() override, this can cause missing dependencies or setup'; 25 | 26 | /** 27 | * @var string 28 | */ 29 | private const CONSTRUCTOR_NAME = '__construct'; 30 | 31 | public function getNodeType(): string 32 | { 33 | return ClassMethod::class; 34 | } 35 | 36 | /** 37 | * @param ClassMethod $node 38 | */ 39 | public function processNode(Node $node, Scope $scope): array 40 | { 41 | if (! fast_node_named($node->name, self::CONSTRUCTOR_NAME)) { 42 | return []; 43 | } 44 | 45 | if ($node->stmts === null) { 46 | return []; 47 | } 48 | 49 | // has parent constructor call? 50 | if (! $scope->isInClass()) { 51 | return []; 52 | } 53 | 54 | if (! fast_has_parent_constructor($scope)) { 55 | return []; 56 | } 57 | 58 | $nodeFinder = new NodeFinder(); 59 | $parentConstructorStaticCall = $nodeFinder->findFirst($node->stmts, function (Node $node): bool { 60 | if (! $node instanceof StaticCall) { 61 | return false; 62 | } 63 | 64 | return fast_node_named($node->name, self::CONSTRUCTOR_NAME); 65 | }); 66 | 67 | if ($parentConstructorStaticCall instanceof StaticCall) { 68 | return []; 69 | } 70 | 71 | $identifierRuleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) 72 | ->identifier(RuleIdentifier::NO_CONSTRUCTOR_OVERRIDE) 73 | ->build(); 74 | 75 | return [$identifierRuleError]; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Rules/Convention/ParamNameToTypeConventionRule.php: -------------------------------------------------------------------------------- 1 | 19 | * @see \Symplify\PHPStanRules\Tests\Rules\Convention\ParamNameToTypeConventionRule\ParamNameToTypeConventionRuleTest 20 | */ 21 | final class ParamNameToTypeConventionRule implements Rule 22 | { 23 | /** 24 | * @var string 25 | */ 26 | public const ERROR_MESSAGE = 'Parameter name "$%s" should probably have "%s" type'; 27 | 28 | /** 29 | * @param array $paramNamesToTypes 30 | */ 31 | public function __construct( 32 | private array $paramNamesToTypes 33 | ) { 34 | Assert::notEmpty($paramNamesToTypes); 35 | 36 | Assert::allString(array_keys($paramNamesToTypes)); 37 | Assert::allString($paramNamesToTypes); 38 | } 39 | 40 | public function getNodeType(): string 41 | { 42 | return Param::class; 43 | } 44 | 45 | /** 46 | * @param Param $node 47 | * @return RuleError[] 48 | */ 49 | public function processNode(Node $node, Scope $scope): array 50 | { 51 | // param type is known, let's skip it 52 | if ($node->type instanceof Node) { 53 | return []; 54 | } 55 | 56 | // unable to fill the type 57 | if ($node->variadic) { 58 | return []; 59 | } 60 | 61 | if (! $node->var instanceof Variable) { 62 | return []; 63 | } 64 | 65 | if (! is_string($node->var->name)) { 66 | return []; 67 | } 68 | 69 | $variableName = $node->var->name; 70 | 71 | $expectedType = $this->paramNamesToTypes[$variableName] ?? null; 72 | if ($expectedType === null) { 73 | return []; 74 | } 75 | 76 | $errorMessage = sprintf(self::ERROR_MESSAGE, $variableName, $expectedType); 77 | 78 | $identifierRuleError = RuleErrorBuilder::message($errorMessage) 79 | ->identifier(RuleIdentifier::CONVENTION_PARAM_NAME_TO_TYPE) 80 | ->build(); 81 | 82 | return [$identifierRuleError]; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Rules/Doctrine/NoDoctrineListenerWithoutContractRule.php: -------------------------------------------------------------------------------- 1 | 21 | * 22 | * @see \Symplify\PHPStanRules\Tests\Rules\Doctrine\NoDoctrineListenerWithoutContractRule\NoDoctrineListenerWithoutContractRuleTest 23 | */ 24 | final class NoDoctrineListenerWithoutContractRule implements Rule 25 | { 26 | /** 27 | * @var string 28 | */ 29 | public const ERROR_MESSAGE = 'There should be no Doctrine listeners modified in config. Implement "Document\Event\EventSubscriber" to provide events in the class itself'; 30 | 31 | public function getNodeType(): string 32 | { 33 | return InClassNode::class; 34 | } 35 | 36 | /** 37 | * @param InClassNode $node 38 | */ 39 | public function processNode(Node $node, Scope $scope): array 40 | { 41 | if (! $scope->isInClass()) { 42 | return []; 43 | } 44 | 45 | $classReflection = $scope->getClassReflection(); 46 | if (! str_ends_with($classReflection->getName(), 'Listener')) { 47 | return []; 48 | } 49 | 50 | $classLike = $node->getOriginalNode(); 51 | if (! $classLike instanceof Class_) { 52 | return []; 53 | } 54 | 55 | if ($classLike->implements !== []) { 56 | return []; 57 | } 58 | 59 | if (! DoctrineEventSubscriberAnalyzer::detect($classLike)) { 60 | return []; 61 | } 62 | 63 | $identifierRuleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) 64 | ->identifier(DoctrineRuleIdentifier::NO_LISTENER_WITHOUT_CONTRACT) 65 | ->build(); 66 | 67 | return [$identifierRuleError]; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Rules/Doctrine/NoDocumentMockingRule.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | final class NoDocumentMockingRule implements Rule 19 | { 20 | /** 21 | * @var string 22 | */ 23 | public const ERROR_MESSAGE = 'Instead of document mocking, create object directly to get better type support'; 24 | 25 | public function getNodeType(): string 26 | { 27 | return MethodCall::class; 28 | } 29 | 30 | /** 31 | * @param MethodCall $node 32 | */ 33 | public function processNode(Node $node, Scope $scope): array 34 | { 35 | if ($node->isFirstClassCallable()) { 36 | return []; 37 | } 38 | 39 | if (! NamingHelper::isName($node->name, 'createMock')) { 40 | return []; 41 | } 42 | 43 | $firstArg = $node->getArgs()[0]; 44 | $mockedClassType = $scope->getType($firstArg->value); 45 | foreach ($mockedClassType->getConstantStrings() as $constantString) { 46 | if (! str_contains($constantString->getValue(), '\\Document\\')) { 47 | continue; 48 | } 49 | 50 | $ruleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) 51 | ->identifier(PHPUnitRuleIdentifier::NO_DOCUMENT_MOCKING) 52 | ->build(); 53 | 54 | return [$ruleError]; 55 | } 56 | 57 | return []; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Rules/Doctrine/NoEntityMockingRule.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | final readonly class NoEntityMockingRule implements Rule 26 | { 27 | /** 28 | * @var string 29 | */ 30 | public const ERROR_MESSAGE = 'Instead of entity or document mocking, create object directly to get better type support'; 31 | 32 | public function __construct( 33 | private ReflectionProvider $reflectionProvider 34 | ) { 35 | } 36 | 37 | public function getNodeType(): string 38 | { 39 | return MethodCall::class; 40 | } 41 | 42 | /** 43 | * @param MethodCall $node 44 | */ 45 | public function processNode(Node $node, Scope $scope): array 46 | { 47 | if (! MethodCallNameAnalyzer::isThisMethodCall($node, 'createMock')) { 48 | return []; 49 | } 50 | 51 | $firstArg = $node->getArgs()[0]; 52 | $mockedClassType = $scope->getType($firstArg->value); 53 | 54 | foreach ($mockedClassType->getConstantStrings() as $constantStringType) { 55 | if (! $this->reflectionProvider->hasClass($constantStringType->getValue())) { 56 | continue; 57 | } 58 | 59 | $classReflection = $this->reflectionProvider->getClass($constantStringType->getValue()); 60 | if (! DoctrineEntityDocumentAnalyser::isEntityClass($classReflection)) { 61 | continue; 62 | } 63 | 64 | $ruleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) 65 | ->identifier(DoctrineRuleIdentifier::NO_ENTITY_MOCKING) 66 | ->build(); 67 | 68 | return [$ruleError]; 69 | } 70 | 71 | return []; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Rules/Doctrine/NoGetRepositoryOutsideServiceRule.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | final class NoGetRepositoryOutsideServiceRule implements Rule 24 | { 25 | /** 26 | * @var string 27 | */ 28 | public const ERROR_MESSAGE = 'Instead of getting repository from EntityManager, use constructor injection and service pattern to keep code clean'; 29 | 30 | public function getNodeType(): string 31 | { 32 | return MethodCall::class; 33 | } 34 | 35 | /** 36 | * @param MethodCall $node 37 | */ 38 | public function processNode(Node $node, Scope $scope): array 39 | { 40 | if ($node->isFirstClassCallable()) { 41 | return []; 42 | } 43 | 44 | if (! NamingHelper::isName($node->name, 'getRepository')) { 45 | return []; 46 | } 47 | 48 | if ($this->isDynamicArg($node)) { 49 | return []; 50 | } 51 | 52 | if (! $scope->isInClass()) { 53 | $ruleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) 54 | ->identifier(DoctrineRuleIdentifier::NO_GET_REPOSITORY_OUTSIDE_SERVICE) 55 | ->build(); 56 | 57 | return [$ruleError]; 58 | } 59 | 60 | // dummy check 61 | $classReflection = $scope->getClassReflection(); 62 | if (str_ends_with($classReflection->getName(), 'Repository')) { 63 | return []; 64 | } 65 | 66 | $ruleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) 67 | ->identifier(DoctrineRuleIdentifier::NO_GET_REPOSITORY_OUTSIDE_SERVICE) 68 | ->build(); 69 | 70 | return [$ruleError]; 71 | } 72 | 73 | private function isDynamicArg(MethodCall $methodCall): bool 74 | { 75 | $firstArg = $methodCall->getArgs()[0]; 76 | if ($firstArg->value instanceof String_) { 77 | return false; 78 | } 79 | 80 | if ($firstArg->value instanceof ClassConstFetch) { 81 | $classConstFetch = $firstArg->value; 82 | return ! $classConstFetch->class instanceof Name; 83 | } 84 | 85 | return true; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Rules/Doctrine/NoParentRepositoryRule.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | final class NoParentRepositoryRule implements Rule 25 | { 26 | /** 27 | * @var string 28 | */ 29 | public const ERROR_MESSAGE = 'Extending EntityRepository is not allowed, use constructor injection and pass entity manager instead'; 30 | 31 | public function getNodeType(): string 32 | { 33 | return Class_::class; 34 | } 35 | 36 | /** 37 | * @param Class_ $node 38 | */ 39 | public function processNode(Node $node, Scope $scope): array 40 | { 41 | if (! $node->extends instanceof Name) { 42 | return []; 43 | } 44 | 45 | $parentClass = $node->extends->toString(); 46 | if ($parentClass !== DoctrineClass::ENTITY_REPOSITORY) { 47 | return []; 48 | } 49 | 50 | $identifierRuleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) 51 | ->identifier(DoctrineRuleIdentifier::NO_PARENT_REPOSITORY) 52 | ->build(); 53 | 54 | return [$identifierRuleError]; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Rules/Doctrine/NoRepositoryCallInDataFixtureRule.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | final class NoRepositoryCallInDataFixtureRule implements Rule 24 | { 25 | /** 26 | * @var string 27 | */ 28 | public const ERROR_MESSAGE = 'Refactor read-data fixtures to write-only, make use of references'; 29 | 30 | public function getNodeType(): string 31 | { 32 | return MethodCall::class; 33 | } 34 | 35 | /** 36 | * @param MethodCall $node 37 | * @return IdentifierRuleError[] 38 | */ 39 | public function processNode(Node $node, Scope $scope): array 40 | { 41 | if ($node->isFirstClassCallable()) { 42 | return []; 43 | } 44 | 45 | if (! $this->isDataFixtureClass($scope)) { 46 | return []; 47 | } 48 | 49 | if (! $node->name instanceof Identifier) { 50 | return []; 51 | } 52 | 53 | $methodName = $node->name->toString(); 54 | if (! in_array($methodName, ['getRepository', 'find', 'findAll', 'findBy', 'findOneBy'])) { 55 | return []; 56 | } 57 | 58 | $identifierRuleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) 59 | ->identifier(DoctrineRuleIdentifier::NO_REPOSITORY_CALL_IN_DATA_FIXTURES) 60 | ->build(); 61 | 62 | return [$identifierRuleError]; 63 | } 64 | 65 | private function isDataFixtureClass(Scope $scope): bool 66 | { 67 | if (! $scope->isInClass()) { 68 | return false; 69 | } 70 | 71 | $classReflection = $scope->getClassReflection(); 72 | return $classReflection->is(DoctrineClass::FIXTURE_INTERFACE); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Rules/Doctrine/RequireQueryBuilderOnRepositoryRule.php: -------------------------------------------------------------------------------- 1 | 21 | * @see \Symplify\PHPStanRules\Tests\Rules\Doctrine\RequireQueryBuilderOnRepositoryRule\RequireQueryBuilderOnRepositoryRuleTest 22 | */ 23 | final class RequireQueryBuilderOnRepositoryRule implements Rule 24 | { 25 | /** 26 | * @var string 27 | */ 28 | public const ERROR_MESSAGE = 'Avoid calling ->createQueryBuilder() directly on EntityManager as it requires select() + from() calls with specific values. Use $repository->createQueryBuilder() to be safe instead'; 29 | 30 | public function getNodeType(): string 31 | { 32 | return MethodCall::class; 33 | } 34 | 35 | /** 36 | * @param MethodCall $node 37 | */ 38 | public function processNode(Node $node, Scope $scope): array 39 | { 40 | if (! NamingHelper::isName($node->name, 'createQueryBuilder')) { 41 | return []; 42 | } 43 | 44 | $callerType = $scope->getType($node->var); 45 | if ($this->isValidRepositoryObjectType($callerType)) { 46 | return []; 47 | } 48 | 49 | $identifierRuleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) 50 | ->identifier(DoctrineRuleIdentifier::REQUIRE_QUERY_BUILDER_ON_REPOSITORY) 51 | ->build(); 52 | 53 | return [$identifierRuleError]; 54 | } 55 | 56 | private function isValidRepositoryObjectType(Type $type): bool 57 | { 58 | if ($type instanceof UnionType) { 59 | foreach ($type->getTypes() as $unionType) { 60 | if ($this->isValidRepositoryObjectType($unionType)) { 61 | return true; 62 | } 63 | } 64 | } 65 | 66 | if (! $type instanceof ObjectType) { 67 | return true; 68 | } 69 | 70 | // we safe as both select() + from() calls are made on the repository 71 | if ($type->isInstanceOf(DoctrineClass::ENTITY_REPOSITORY)->yes()) { 72 | return true; 73 | } 74 | 75 | if ($type->isInstanceOf(DoctrineClass::DOCUMENT_REPOSITORY)->yes()) { 76 | return true; 77 | } 78 | 79 | return $type->isInstanceOf(DoctrineClass::CONNECTION)->yes(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Rules/Doctrine/RequireServiceRepositoryParentRule.php: -------------------------------------------------------------------------------- 1 | 19 | * 20 | * @see \Symplify\PHPStanRules\Tests\Rules\Doctrine\RequireServiceRepositoryParentRule\RequireServiceRepositoryParentRuleTest 21 | */ 22 | final class RequireServiceRepositoryParentRule implements Rule 23 | { 24 | /** 25 | * @var string 26 | */ 27 | public const ERROR_MESSAGE = 'Repository must extend "%s", "%s" or implement "%s", so it can be injected as a service'; 28 | 29 | public function getNodeType(): string 30 | { 31 | return InClassNode::class; 32 | } 33 | 34 | /** 35 | * @param InClassNode $node 36 | * @return RuleError[] 37 | */ 38 | public function processNode(Node $node, Scope $scope): array 39 | { 40 | $classReflection = $node->getClassReflection(); 41 | 42 | // no parent? probably not a repository service yet 43 | if (! $this->isDoctrineRepositoryClass($classReflection)) { 44 | return []; 45 | } 46 | 47 | if ($this->isExtendingServiceRepository($classReflection)) { 48 | return []; 49 | } 50 | 51 | $errorMessage = sprintf(self::ERROR_MESSAGE, DoctrineClass::ODM_SERVICE_REPOSITORY, DoctrineClass::ORM_SERVICE_REPOSITORY, DoctrineClass::ODM_SERVICE_REPOSITORY_INTERFACE); 52 | 53 | $identifierRuleError = RuleErrorBuilder::message($errorMessage) 54 | ->identifier(DoctrineRuleIdentifier::REQUIRE_SERVICE_PARENT_REPOSITORY) 55 | ->build(); 56 | 57 | return [$identifierRuleError]; 58 | } 59 | 60 | private function isExtendingServiceRepository(ClassReflection $classReflection): bool 61 | { 62 | if ($classReflection->is(DoctrineClass::ODM_SERVICE_REPOSITORY)) { 63 | return true; 64 | } 65 | 66 | if ($classReflection->is(DoctrineClass::ORM_SERVICE_REPOSITORY)) { 67 | return true; 68 | } 69 | 70 | return $classReflection->is(DoctrineClass::ODM_SERVICE_REPOSITORY_INTERFACE); 71 | } 72 | 73 | private function isDoctrineRepositoryClass(ClassReflection $classReflection): bool 74 | { 75 | if (! $classReflection->isClass()) { 76 | return false; 77 | } 78 | 79 | // simple check 80 | return str_ends_with($classReflection->getName(), 'Repository'); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Rules/Domain/RequireAttributeNamespaceRule.php: -------------------------------------------------------------------------------- 1 | 16 | * @see \Symplify\PHPStanRules\Tests\Rules\Domain\RequireAttributeNamespaceRule\RequireAttributeNamespaceRuleTest 17 | */ 18 | final class RequireAttributeNamespaceRule implements Rule 19 | { 20 | /** 21 | * @var string 22 | */ 23 | public const ERROR_MESSAGE = 'Attribute must be located in "Attribute" namespace'; 24 | 25 | /** 26 | * @return class-string 27 | */ 28 | public function getNodeType(): string 29 | { 30 | return InClassNode::class; 31 | } 32 | 33 | /** 34 | * @param InClassNode $node 35 | */ 36 | public function processNode(Node $node, Scope $scope): array 37 | { 38 | $classReflection = $node->getClassReflection(); 39 | if (! $classReflection->isAttributeClass()) { 40 | return []; 41 | } 42 | 43 | // is class in "Attribute" namespace? 44 | $className = $classReflection->getName(); 45 | if (str_contains($className, '\\Attribute\\')) { 46 | return []; 47 | } 48 | 49 | return [RuleErrorBuilder::message(self::ERROR_MESSAGE) 50 | ->identifier(RuleIdentifier::REQUIRE_ATTRIBUTE_NAMESPACE) 51 | ->build()]; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Rules/Domain/RequireExceptionNamespaceRule.php: -------------------------------------------------------------------------------- 1 | 16 | * @see \Symplify\PHPStanRules\Tests\Rules\Domain\RequireExceptionNamespaceRule\RequireExceptionNamespaceRuleTest 17 | */ 18 | final class RequireExceptionNamespaceRule implements Rule 19 | { 20 | /** 21 | * @var string 22 | */ 23 | public const ERROR_MESSAGE = 'Exception must be located in "Exception" namespace'; 24 | 25 | public function getNodeType(): string 26 | { 27 | return InClassNode::class; 28 | } 29 | 30 | /** 31 | * @param InClassNode $node 32 | */ 33 | public function processNode(Node $node, Scope $scope): array 34 | { 35 | $classReflection = $node->getClassReflection(); 36 | 37 | if ($classReflection->isAnonymous()) { 38 | return []; 39 | } 40 | 41 | if (! $classReflection->isClass()) { 42 | return []; 43 | } 44 | 45 | if (! $classReflection->is('Exception')) { 46 | return []; 47 | } 48 | 49 | // is class in "Exception" namespace? 50 | $className = $classReflection->getName(); 51 | if (str_contains($className, '\\Exception\\')) { 52 | return []; 53 | } 54 | 55 | return [RuleErrorBuilder::message(self::ERROR_MESSAGE) 56 | ->identifier(RuleIdentifier::REQUIRE_EXCEPTION_NAMESPACE) 57 | ->build()]; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Rules/ForbiddenExtendOfNonAbstractClassRule.php: -------------------------------------------------------------------------------- 1 | 17 | * @see \Symplify\PHPStanRules\Tests\Rules\ForbiddenExtendOfNonAbstractClassRule\ForbiddenExtendOfNonAbstractClassRuleTest 18 | */ 19 | final class ForbiddenExtendOfNonAbstractClassRule implements Rule 20 | { 21 | /** 22 | * @var string 23 | */ 24 | public const ERROR_MESSAGE = 'Only abstract classes can be extended'; 25 | 26 | /** 27 | * @return class-string 28 | */ 29 | public function getNodeType(): string 30 | { 31 | return InClassNode::class; 32 | } 33 | 34 | /** 35 | * @param InClassNode $node 36 | */ 37 | public function processNode(Node $node, Scope $scope): array 38 | { 39 | $classReflection = $node->getClassReflection(); 40 | 41 | if ($classReflection->isAnonymous()) { 42 | return []; 43 | } 44 | 45 | $parentClassReflection = $classReflection->getParentClass(); 46 | if (! $parentClassReflection instanceof ClassReflection) { 47 | return []; 48 | } 49 | 50 | if ($parentClassReflection->isAbstract()) { 51 | return []; 52 | } 53 | 54 | // skip native PHP classes 55 | if ($parentClassReflection->isBuiltin()) { 56 | return []; 57 | } 58 | 59 | // skip vendor based classes, as designed for extension 60 | $fileName = $parentClassReflection->getFileName(); 61 | if (is_string($fileName) && str_contains($fileName, 'vendor')) { 62 | return []; 63 | } 64 | 65 | return [RuleErrorBuilder::message(self::ERROR_MESSAGE) 66 | ->identifier(RuleIdentifier::FORBIDDEN_EXTEND_OF_NON_ABSTRACT_CLASS) 67 | ->build()]; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Rules/ForbiddenMultipleClassLikeInOneFileRule.php: -------------------------------------------------------------------------------- 1 | 19 | * @see \Symplify\PHPStanRules\Tests\Rules\ForbiddenMultipleClassLikeInOneFileRule\ForbiddenMultipleClassLikeInOneFileRuleTest 20 | */ 21 | final readonly class ForbiddenMultipleClassLikeInOneFileRule implements Rule 22 | { 23 | /** 24 | * @var string 25 | */ 26 | public const ERROR_MESSAGE = 'Multiple class/interface/trait is not allowed in single file'; 27 | 28 | private NodeFinder $nodeFinder; 29 | 30 | public function __construct( 31 | ) { 32 | $this->nodeFinder = new NodeFinder(); 33 | } 34 | 35 | public function getNodeType(): string 36 | { 37 | return FileNode::class; 38 | } 39 | 40 | /** 41 | * @param FileNode $node 42 | */ 43 | public function processNode(Node $node, Scope $scope): array 44 | { 45 | /** @var ClassLike[] $classLikes */ 46 | $classLikes = $this->nodeFinder->findInstanceOf($node->getNodes(), ClassLike::class); 47 | 48 | $findclassLikes = []; 49 | foreach ($classLikes as $classLike) { 50 | if (! $classLike->name instanceof Identifier) { 51 | continue; 52 | } 53 | 54 | $findclassLikes[] = $classLike; 55 | } 56 | 57 | if (count($findclassLikes) <= 1) { 58 | return []; 59 | } 60 | 61 | return [RuleErrorBuilder::message(self::ERROR_MESSAGE) 62 | ->identifier(RuleIdentifier::MULTIPLE_CLASS_LIKE_IN_FILE) 63 | ->build()]; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Rules/ForbiddenNodeRule.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | final class ForbiddenNodeRule implements Rule 22 | { 23 | /** 24 | * @var string 25 | */ 26 | public const ERROR_MESSAGE = '"%s" is forbidden to use'; 27 | 28 | /** 29 | * @var array> 30 | */ 31 | private array $forbiddenNodes = []; 32 | 33 | private readonly Standard $standard; 34 | 35 | /** 36 | * @param array> $forbiddenNodes 37 | */ 38 | public function __construct( 39 | array $forbiddenNodes 40 | ) { 41 | Assert::allIsAOf($forbiddenNodes, Node::class); 42 | 43 | $this->forbiddenNodes = $forbiddenNodes; 44 | $this->standard = new Standard(); 45 | } 46 | 47 | public function getNodeType(): string 48 | { 49 | return Node::class; 50 | } 51 | 52 | public function processNode(Node $node, Scope $scope): array 53 | { 54 | foreach ($this->forbiddenNodes as $forbiddenNode) { 55 | if (! $node instanceof $forbiddenNode) { 56 | continue; 57 | } 58 | 59 | // this node can't be printed as standalone 60 | if ($node instanceof EncapsedStringPart) { 61 | $contents = $this->standard->prettyPrintExpr(new Encapsed([$node])); 62 | } else { 63 | $contents = $this->standard->prettyPrint([$node]); 64 | } 65 | 66 | $errorMessage = sprintf(self::ERROR_MESSAGE, $contents); 67 | 68 | $ruleError = RuleErrorBuilder::message($errorMessage) 69 | ->identifier(RuleIdentifier::FORBIDDEN_NODE) 70 | ->build(); 71 | 72 | return [$ruleError]; 73 | } 74 | 75 | return []; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Rules/ForbiddenStaticClassConstFetchRule.php: -------------------------------------------------------------------------------- 1 | 17 | * @see \Symplify\PHPStanRules\Tests\Rules\ForbiddenStaticClassConstFetchRule\ForbiddenStaticClassConstFetchRuleTest 18 | */ 19 | final class ForbiddenStaticClassConstFetchRule implements Rule 20 | { 21 | /** 22 | * @var string 23 | */ 24 | public const ERROR_MESSAGE = 'Avoid static access of constants, as they can change value. Use interface and contract method instead'; 25 | 26 | public function getNodeType(): string 27 | { 28 | return ClassConstFetch::class; 29 | } 30 | 31 | /** 32 | * @param ClassConstFetch $node 33 | */ 34 | public function processNode(Node $node, Scope $scope): array 35 | { 36 | if (! $node->class instanceof Name) { 37 | return []; 38 | } 39 | 40 | if ($node->class->toString() !== 'static') { 41 | return []; 42 | } 43 | 44 | return [RuleErrorBuilder::message(self::ERROR_MESSAGE) 45 | ->identifier(RuleIdentifier::FORBIDDEN_STATIC_CLASS_CONST_FETCH) 46 | ->build()]; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Rules/MaximumIgnoredErrorCountRule.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | final readonly class MaximumIgnoredErrorCountRule implements Rule 19 | { 20 | /** 21 | * @var string 22 | */ 23 | public const ERROR_MESSAGE = "Ignored error count %d in phpstan.neon surpassed maximum limit %d.\nInstead of ignoring more errors, fix them to keep your codebase fit."; 24 | 25 | private NeonAdapter $neonAdapter; 26 | 27 | public function __construct( 28 | private int $limit = 0 29 | ) { 30 | $this->neonAdapter = new NeonAdapter(); 31 | } 32 | 33 | /** 34 | * @return class-string 35 | */ 36 | public function getNodeType(): string 37 | { 38 | // hack to run this rule just once 39 | return CollectedDataNode::class; 40 | } 41 | 42 | /** 43 | * @param CollectedDataNode $node 44 | */ 45 | public function processNode(Node $node, Scope $scope): array 46 | { 47 | // not enabled yet, use " 48 | if ($this->limit === 0) { 49 | return []; 50 | } 51 | 52 | $configFilePath = getcwd() . '/phpstan.neon'; 53 | 54 | // unable to find config 55 | if (! file_exists($configFilePath)) { 56 | return []; 57 | } 58 | 59 | $phpstanNeon = $this->neonAdapter->load($configFilePath); 60 | $ignoreErrors = $phpstanNeon['parameters']['ignoreErrors'] ?? []; 61 | if (count($ignoreErrors) <= $this->limit) { 62 | return []; 63 | } 64 | 65 | $errorMessage = sprintf(self::ERROR_MESSAGE, count($ignoreErrors), $this->limit); 66 | 67 | $identifierRuleError = RuleErrorBuilder::message($errorMessage) 68 | ->identifier(RuleIdentifier::MAXIMUM_IGNORED_ERROR_COUNT) 69 | ->nonIgnorable() 70 | ->build(); 71 | 72 | return [$identifierRuleError]; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Rules/NoDynamicNameRule.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | final readonly class NoDynamicNameRule implements Rule 28 | { 29 | /** 30 | * @var string 31 | */ 32 | public const ERROR_MESSAGE = 'Use explicit names over dynamic ones'; 33 | 34 | public function __construct( 35 | private CallableTypeAnalyzer $callableTypeAnalyzer, 36 | ) { 37 | } 38 | 39 | public function getNodeType(): string 40 | { 41 | // trick to allow multiple node types 42 | return Node::class; 43 | } 44 | 45 | public function processNode(Node $node, Scope $scope): array 46 | { 47 | if ($node instanceof ClassConstFetch || $node instanceof StaticPropertyFetch) { 48 | if (! $node->class instanceof Expr) { 49 | return []; 50 | } 51 | 52 | if (! $node->name instanceof Identifier) { 53 | return []; 54 | } 55 | 56 | if ($node->name->toString() === 'class') { 57 | return []; 58 | } 59 | 60 | $ruleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) 61 | ->identifier(RuleIdentifier::NO_DYNAMIC_NAME) 62 | ->build(); 63 | 64 | return [$ruleError]; 65 | } 66 | 67 | if ($node instanceof MethodCall || $node instanceof StaticCall || $node instanceof FuncCall || $node instanceof PropertyFetch) { 68 | 69 | if (! $node->name instanceof Expr) { 70 | return []; 71 | } 72 | 73 | if ($this->callableTypeAnalyzer->isClosureOrCallableType($scope, $node->name)) { 74 | return []; 75 | } 76 | 77 | $ruleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) 78 | ->identifier(RuleIdentifier::NO_DYNAMIC_NAME) 79 | ->build(); 80 | 81 | return [$ruleError]; 82 | } 83 | 84 | return []; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Rules/NoEntityOutsideEntityNamespaceRule.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | final class NoEntityOutsideEntityNamespaceRule implements Rule 19 | { 20 | /** 21 | * @var string 22 | */ 23 | public const ERROR_MESSAGE = 'Class with #[Entity] attribute must be located in "Entity" namespace to be loaded by Doctrine'; 24 | 25 | public function getNodeType(): string 26 | { 27 | return Class_::class; 28 | } 29 | 30 | /** 31 | * @param Class_ $node 32 | */ 33 | public function processNode(Node $node, Scope $scope): array 34 | { 35 | if (! $this->hasEntityAttribute($node)) { 36 | return []; 37 | } 38 | 39 | // we need a namespace to check 40 | if (! $node->namespacedName instanceof Name) { 41 | return []; 42 | } 43 | 44 | $namespaceParts = $node->namespacedName->getParts(); 45 | 46 | if (in_array('Entity', $namespaceParts, true)) { 47 | return []; 48 | } 49 | 50 | return [RuleErrorBuilder::message(self::ERROR_MESSAGE) 51 | ->identifier(RuleIdentifier::NO_ENTITY_OUTSIDE_ENTITY_NAMESPACE) 52 | ->build()]; 53 | } 54 | 55 | private function hasEntityAttribute(Class_ $class): bool 56 | { 57 | foreach ($class->attrGroups as $attrGroup) { 58 | foreach ($attrGroup->attrs as $attr) { 59 | if ($attr->name->toString() === 'Doctrine\ORM\Mapping\Entity') { 60 | return true; 61 | } 62 | 63 | if ($attr->name->toString() === 'Doctrine\ORM\Mapping\Embeddable') { 64 | return true; 65 | } 66 | } 67 | } 68 | 69 | return false; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Rules/NoGlobalConstRule.php: -------------------------------------------------------------------------------- 1 | 16 | * @see \Symplify\PHPStanRules\Tests\Rules\NoGlobalConstRule\NoGlobalConstRuleTest 17 | */ 18 | final class NoGlobalConstRule implements Rule 19 | { 20 | /** 21 | * @var string 22 | */ 23 | public const ERROR_MESSAGE = 'Global constants are forbidden. Use enum-like class list instead'; 24 | 25 | public function getNodeType(): string 26 | { 27 | return Const_::class; 28 | } 29 | 30 | /** 31 | * @param Const_ $node 32 | */ 33 | public function processNode(Node $node, Scope $scope): array 34 | { 35 | $identifierRuleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) 36 | ->identifier(RuleIdentifier::NO_GLOBAL_CONST) 37 | ->build(); 38 | 39 | return [$identifierRuleError]; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Rules/NoValueObjectInServiceConstructorRule.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | final class NoValueObjectInServiceConstructorRule implements Rule 20 | { 21 | public function getNodeType(): string 22 | { 23 | return ClassMethod::class; 24 | } 25 | 26 | /** 27 | * @param ClassMethod $node 28 | */ 29 | public function processNode(Node $node, Scope $scope): array 30 | { 31 | if (! NamingHelper::isName($node->name, '__construct')) { 32 | return []; 33 | } 34 | 35 | if (! $scope->isInClass()) { 36 | return []; 37 | } 38 | 39 | $classReflection = $scope->getClassReflection(); 40 | 41 | // value objects can accept value objects 42 | if ($this->isValueObject($classReflection->getName())) { 43 | return []; 44 | } 45 | 46 | $ruleErrors = []; 47 | 48 | foreach ($node->params as $param) { 49 | if (! $param->type instanceof Name) { 50 | continue; 51 | } 52 | 53 | $paramType = $param->type->toString(); 54 | if (! $this->isValueObject($paramType)) { 55 | continue; 56 | } 57 | 58 | $ruleErrors[] = RuleErrorBuilder::message(sprintf( 59 | 'Value object "%s" cannot be passed to constructor of a service. Pass it as a method argument instead', 60 | $paramType 61 | )) 62 | ->identifier(RuleIdentifier::NO_VALUE_OBJECT_IN_SERVICE_CONSTRUCTOR) 63 | ->build(); 64 | } 65 | 66 | return $ruleErrors; 67 | } 68 | 69 | private function isValueObject(string $className): bool 70 | { 71 | return preg_match('#(ValueObject|DataObject|Models)#', $className) === 1; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Rules/PHPUnit/NoAssertFuncCallInTestsRule.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | final class NoAssertFuncCallInTestsRule implements Rule 18 | { 19 | /** 20 | * @var string 21 | */ 22 | public const ERROR_MESSAGE = 'Instead of assert() that can miss important checks, use native PHPUnit assert call'; 23 | 24 | private const TEST_FILE_SUFFIXES = [ 25 | 'Test.php', 26 | 'TestCase.php', 27 | 'Context.php', 28 | ]; 29 | 30 | public function getNodeType(): string 31 | { 32 | return FuncCall::class; 33 | } 34 | 35 | /** 36 | * @param FuncCall $node 37 | * @return IdentifierRuleError[] 38 | */ 39 | public function processNode(Node $node, Scope $scope): array 40 | { 41 | if (! NamingHelper::isName($node->name, 'assert')) { 42 | return []; 43 | } 44 | 45 | if (! $this->isTestFile($scope)) { 46 | return []; 47 | } 48 | 49 | $identifierRuleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) 50 | ->identifier(PHPUnitRuleIdentifier::NO_ASSERT_FUNC_CALL_IN_TESTS) 51 | ->build(); 52 | 53 | return [$identifierRuleError]; 54 | } 55 | 56 | private function isTestFile(Scope $scope): bool 57 | { 58 | foreach (self::TEST_FILE_SUFFIXES as $testFileSuffix) { 59 | if (str_ends_with($scope->getFile(), $testFileSuffix)) { 60 | return true; 61 | } 62 | } 63 | 64 | return false; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Rules/PHPUnit/NoDoubleConsecutiveTestMockRule.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | final class NoDoubleConsecutiveTestMockRule implements Rule 21 | { 22 | /** 23 | * @var string 24 | */ 25 | public const ERROR_MESSAGE = 'Do not use "willReturnOnConsecutiveCalls()" and "willReturnCallback()" on the same mock. Use "willReturnCallback() only instead to make test more clear.'; 26 | 27 | public function getNodeType(): string 28 | { 29 | return MethodCall::class; 30 | } 31 | 32 | /** 33 | * @param MethodCall $node 34 | * @return RuleError[] 35 | */ 36 | public function processNode(Node $node, Scope $scope): array 37 | { 38 | // 1. detect if we're in a PHPUnit test case 39 | if (! $scope->isInClass()) { 40 | return []; 41 | } 42 | 43 | $classReflection = $scope->getClassReflection(); 44 | if (! $classReflection->is(PHPUnitClassName::TEST_CASE)) { 45 | return []; 46 | } 47 | 48 | // 2. find a phpunit mock call, that uses "willReturnOnConsecutiveCalls" and "willReturnCallback" on the same line 49 | if (! $node->var instanceof MethodCall) { 50 | return []; 51 | } 52 | 53 | $parentCall = $node->var; 54 | if (! $this->containsBothMethodCallNames($node, $parentCall)) { 55 | return []; 56 | } 57 | 58 | $identifierRuleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) 59 | ->identifier(PHPUnitRuleIdentifier::NO_DOUBLE_CONSECUTIVE_TEST_MOCK) 60 | ->build(); 61 | 62 | return [$identifierRuleError]; 63 | } 64 | 65 | private function containsBothMethodCallNames(MethodCall $firstMethodCall, MethodCall $secondMethodCall): bool 66 | { 67 | if (NamingHelper::isName($firstMethodCall->name, 'willReturnOnConsecutiveCalls') && NamingHelper::isName($secondMethodCall->name, 'willReturnCallback')) { 68 | return true; 69 | } 70 | 71 | return NamingHelper::isName($secondMethodCall->name, 'willReturnOnConsecutiveCalls') && NamingHelper::isName($firstMethodCall->name, 'willReturnCallback'); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Rules/PHPUnit/NoMockObjectAndRealObjectPropertyRule.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | final class NoMockObjectAndRealObjectPropertyRule implements Rule 23 | { 24 | /** 25 | * @var string 26 | */ 27 | public const ERROR_MESSAGE = 'Instead of ambiguous mock + object mix, pick single type that is more relevant'; 28 | 29 | public function getNodeType(): string 30 | { 31 | return Property::class; 32 | } 33 | 34 | /** 35 | * @param Property $node 36 | * @return RuleError[] 37 | */ 38 | public function processNode(Node $node, Scope $scope): array 39 | { 40 | if (! $node->type instanceof IntersectionType && ! $node->type instanceof UnionType) { 41 | return []; 42 | } 43 | 44 | foreach ($node->type->types as $type) { 45 | if (! $type instanceof Name) { 46 | continue; 47 | } 48 | 49 | if ($type->toString() !== ClassName::MOCK_OBJECT_CLASS) { 50 | continue; 51 | } 52 | 53 | return [RuleErrorBuilder::message(self::ERROR_MESSAGE) 54 | ->identifier(PHPUnitRuleIdentifier::NO_MOCK_OBJECT_AND_REAL_OBJECT_PROPERTY) 55 | ->build()]; 56 | } 57 | 58 | return []; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Rules/PHPUnit/NoMockOnlyTestRule.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | final readonly class NoMockOnlyTestRule implements Rule 25 | { 26 | /** 27 | * @var string 28 | */ 29 | public const ERROR_MESSAGE = 'Test should have at least one non-mocked property, to test something'; 30 | 31 | public function getNodeType(): string 32 | { 33 | return InClassNode::class; 34 | } 35 | 36 | /** 37 | * @param InClassNode $node 38 | */ 39 | public function processNode(Node $node, Scope $scope): array 40 | { 41 | if (! PHPUnitTestAnalyser::isTestClass($scope)) { 42 | return []; 43 | } 44 | 45 | $classLike = $node->getOriginalNode(); 46 | if (! $classLike instanceof Class_) { 47 | return []; 48 | } 49 | 50 | if ($classLike->extends instanceof Name && $classLike->extends->toString() === SymfonyClass::VALIDATOR_TEST_CASE) { 51 | return []; 52 | } 53 | 54 | if ($classLike->getProperties() === []) { 55 | return []; 56 | } 57 | 58 | $hasExclusivelyMockedProperties = true; 59 | $hasSomeProperties = false; 60 | 61 | foreach ($classLike->getProperties() as $property) { 62 | if (! $property->type instanceof Name) { 63 | continue; 64 | } 65 | 66 | $propertyClassName = $property->type->toString(); 67 | 68 | if ($propertyClassName !== ClassName::MOCK_OBJECT_CLASS) { 69 | $hasExclusivelyMockedProperties = false; 70 | } else { 71 | $hasSomeProperties = true; 72 | } 73 | } 74 | 75 | if ($hasExclusivelyMockedProperties === false || $hasSomeProperties === false) { 76 | return []; 77 | } 78 | 79 | $identifierRuleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) 80 | ->identifier(PHPUnitRuleIdentifier::NO_MOCK_ONLY) 81 | ->build(); 82 | 83 | return [$identifierRuleError]; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Rules/PHPUnit/NoTestMocksRule.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | final readonly class NoTestMocksRule implements Rule 20 | { 21 | /** 22 | * @api 23 | * @var string 24 | */ 25 | public const ERROR_MESSAGE = 'Mocking "%s" class is forbidden. Use direct/anonymous class instead for better static analysis'; 26 | 27 | /** 28 | * @var string[] 29 | */ 30 | private const MOCKING_METHOD_NAMES = ['createMock', 'createPartialMock', 'createConfiguredMock', 'createStub']; 31 | 32 | /** 33 | * @param string[] $allowedTypes 34 | */ 35 | public function __construct( 36 | private array $allowedTypes = [] 37 | ) { 38 | } 39 | 40 | public function getNodeType(): string 41 | { 42 | return MethodCall::class; 43 | } 44 | 45 | /** 46 | * @param MethodCall $node 47 | */ 48 | public function processNode(Node $node, Scope $scope): array 49 | { 50 | if (! $node->name instanceof Identifier) { 51 | return []; 52 | } 53 | 54 | $methodName = $node->name->toString(); 55 | if (! in_array($methodName, self::MOCKING_METHOD_NAMES, true)) { 56 | return []; 57 | } 58 | 59 | $mockedObjectType = $this->resolveMockedObjectType($node, $scope); 60 | if (! $mockedObjectType instanceof ObjectType) { 61 | return []; 62 | } 63 | 64 | if ($this->isAllowedType($mockedObjectType)) { 65 | return []; 66 | } 67 | 68 | $errorMessage = sprintf(self::ERROR_MESSAGE, $mockedObjectType->getClassName()); 69 | 70 | return [RuleErrorBuilder::message($errorMessage) 71 | ->identifier(RuleIdentifier::NO_TEST_MOCKS) 72 | ->build()]; 73 | } 74 | 75 | private function resolveMockedObjectType(MethodCall $methodCall, Scope $scope): ?ObjectType 76 | { 77 | $args = $methodCall->getArgs(); 78 | 79 | $mockedArgValue = $args[0]->value; 80 | $variableType = $scope->getType($mockedArgValue); 81 | 82 | foreach ($variableType->getConstantStrings() as $constantStringType) { 83 | return new ObjectType($constantStringType->getValue()); 84 | } 85 | 86 | return null; 87 | } 88 | 89 | private function isAllowedType(ObjectType $objectType): bool 90 | { 91 | foreach ($this->allowedTypes as $allowedType) { 92 | if ($objectType->getClassName() === $allowedType) { 93 | return true; 94 | } 95 | 96 | if ($objectType->isInstanceOf($allowedType)->yes()) { 97 | return true; 98 | } 99 | } 100 | 101 | return false; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Rules/PHPUnit/PublicStaticDataProviderRule.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | final class PublicStaticDataProviderRule implements Rule 23 | { 24 | /** 25 | * @api used in test 26 | * @var string 27 | */ 28 | public const PUBLIC_ERROR_MESSAGE = 'PHPUnit data provider method "%s" must be public'; 29 | 30 | /** 31 | * @api used in test 32 | * @var string 33 | */ 34 | public const STATIC_ERROR_MESSAGE = 'PHPUnit data provider method "%s" must be static'; 35 | 36 | public function getNodeType(): string 37 | { 38 | return InClassNode::class; 39 | } 40 | 41 | /** 42 | * @param InClassNode $node 43 | */ 44 | public function processNode(Node $node, Scope $scope): array 45 | { 46 | if (! PHPUnitTestAnalyser::isTestClass($scope)) { 47 | return []; 48 | } 49 | 50 | $ruleErrors = []; 51 | 52 | $classLike = $node->getOriginalNode(); 53 | foreach ($classLike->getMethods() as $classMethod) { 54 | if (! PHPUnitTestAnalyser::isTestClassMethod($classMethod)) { 55 | continue; 56 | } 57 | 58 | $dataProviderMethodName = DataProviderMethodResolver::match($classMethod); 59 | if (! is_string($dataProviderMethodName)) { 60 | continue; 61 | } 62 | 63 | $dataProviderClassMethod = $classLike->getMethod($dataProviderMethodName); 64 | if (! $dataProviderClassMethod instanceof ClassMethod) { 65 | continue; 66 | } 67 | 68 | if (! $dataProviderClassMethod->isStatic()) { 69 | $errorMessage = sprintf(self::STATIC_ERROR_MESSAGE, $dataProviderMethodName); 70 | $ruleErrors[] = RuleErrorBuilder::message($errorMessage) 71 | ->identifier('phpunit.staticDataProvider') 72 | ->line($dataProviderClassMethod->getLine()) 73 | ->build(); 74 | } 75 | 76 | if (! $dataProviderClassMethod->isPublic()) { 77 | $errorMessage = sprintf(self::PUBLIC_ERROR_MESSAGE, $dataProviderMethodName); 78 | $ruleErrors[] = RuleErrorBuilder::message($errorMessage) 79 | ->identifier(PHPUnitRuleIdentifier::PUBLIC_STATIC_DATA_PROVIDER) 80 | ->line($dataProviderClassMethod->getLine()) 81 | ->build(); 82 | } 83 | } 84 | 85 | return $ruleErrors; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Rules/Rector/NoClassReflectionStaticReflectionRule.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | final class NoClassReflectionStaticReflectionRule implements Rule 23 | { 24 | /** 25 | * @var string 26 | */ 27 | public const ERROR_MESSAGE = 'Instead of "new ClassReflection()" use ReflectionProvider service or "(new PHPStan\Reflection\ClassReflection())" for static reflection to work'; 28 | 29 | public function getNodeType(): string 30 | { 31 | return New_::class; 32 | } 33 | 34 | /** 35 | * @param New_ $node 36 | */ 37 | public function processNode(Node $node, Scope $scope): array 38 | { 39 | if (count($node->getArgs()) !== 1) { 40 | return []; 41 | } 42 | 43 | if (! $node->class instanceof Name) { 44 | return []; 45 | } 46 | 47 | if ($node->class->toString() !== ReflectionClass::class) { 48 | return []; 49 | } 50 | 51 | $argValue = $node->getArgs()[0]->value; 52 | $exprStaticType = $scope->getType($argValue); 53 | 54 | if (RectorAllowedAutoloadedTypeAnalyzer::isAllowedType($exprStaticType)) { 55 | return []; 56 | } 57 | 58 | return [RuleErrorBuilder::message(self::ERROR_MESSAGE) 59 | ->identifier(RectorRuleIdentifier::NO_CLASS_REFLECTION_STATIC_REFLECTION) 60 | ->build()]; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Rules/Rector/NoInstanceOfStaticReflectionRule.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | final class NoInstanceOfStaticReflectionRule implements Rule 29 | { 30 | /** 31 | * @var string 32 | */ 33 | public const ERROR_MESSAGE = 'Instead of "instanceof/is_a()" use ReflectionProvider service or "(new ObjectType())->isSuperTypeOf()" for static reflection to work'; 34 | 35 | public function getNodeType(): string 36 | { 37 | return Expr::class; 38 | } 39 | 40 | /** 41 | * @param Expr $node 42 | */ 43 | public function processNode(Node $node, Scope $scope): array 44 | { 45 | if (! $node instanceof FuncCall && ! $node instanceof Instanceof_) { 46 | return []; 47 | } 48 | 49 | $exprStaticType = $this->resolveExprStaticType($node, $scope); 50 | if (! $exprStaticType instanceof Type) { 51 | return []; 52 | } 53 | 54 | if (RectorAllowedAutoloadedTypeAnalyzer::isAllowedType($exprStaticType)) { 55 | return []; 56 | } 57 | 58 | return [RuleErrorBuilder::message(self::ERROR_MESSAGE) 59 | ->identifier(RectorRuleIdentifier::NO_INSTANCE_OF_STATIC_REFLECTION) 60 | ->build()]; 61 | } 62 | 63 | private function resolveExprStaticType(FuncCall|Instanceof_ $node, Scope $scope): ?Type 64 | { 65 | if ($node instanceof Instanceof_) { 66 | return $this->resolveInstanceOfType($node, $scope); 67 | } 68 | 69 | if (! NamingHelper::isName($node->name, 'is_a')) { 70 | return null; 71 | } 72 | 73 | $typeArgValue = $node->getArgs()[1]->value; 74 | return $scope->getType($typeArgValue); 75 | } 76 | 77 | private function resolveInstanceOfType(Instanceof_ $instanceof, Scope $scope): ?Type 78 | { 79 | if ($instanceof->class instanceof Name) { 80 | $className = $instanceof->class->toString(); 81 | 82 | // skip self as allowed 83 | if ($className === 'self') { 84 | return null; 85 | } 86 | 87 | return new ConstantStringType($instanceof->class->toString()); 88 | } 89 | 90 | return $scope->getType($instanceof->class); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Rules/Rector/NoLeadingBackslashInNameRule.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | final class NoLeadingBackslashInNameRule implements Rule 24 | { 25 | /** 26 | * @var string 27 | */ 28 | public const ERROR_MESSAGE = 'Instead of "new Name(\'\\\\Foo\')" use "new FullyQualified(\'Foo\')"'; 29 | 30 | public function getNodeType(): string 31 | { 32 | return New_::class; 33 | } 34 | 35 | /** 36 | * @param New_ $node 37 | */ 38 | public function processNode(Node $node, Scope $scope): array 39 | { 40 | if ($node->getArgs() === []) { 41 | return []; 42 | } 43 | 44 | if (! $node->class instanceof Name) { 45 | return []; 46 | } 47 | 48 | $className = $node->class->toString(); 49 | if (! in_array($className, [Name::class, FullyQualified::class, Relative::class], true)) { 50 | return []; 51 | } 52 | 53 | $argValue = $node->getArgs()[0]->value; 54 | $argType = $scope->getType($argValue); 55 | 56 | if (! $argType instanceof ConstantStringType) { 57 | return []; 58 | } 59 | 60 | if (! str_starts_with($argType->getValue(), '\\')) { 61 | return []; 62 | } 63 | 64 | return [RuleErrorBuilder::message(self::ERROR_MESSAGE) 65 | ->identifier(RuleIdentifier::PHP_PARSER_NO_LEADING_BACKSLASH_IN_NAME) 66 | ->build()]; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Rules/Rector/PhpUpgradeImplementsMinPhpVersionInterfaceRule.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | final class PhpUpgradeImplementsMinPhpVersionInterfaceRule implements Rule 23 | { 24 | /** 25 | * @var string 26 | */ 27 | public const ERROR_MESSAGE = 'Rule %s must implements Rector\VersionBonding\Contract\MinPhpVersionInterface'; 28 | 29 | /** 30 | * @var string 31 | * @see https://regex101.com/r/9d3jGP/2/ 32 | */ 33 | private const PREFIX_REGEX = '#\\\\Php\d+\\\\#'; 34 | 35 | public function getNodeType(): string 36 | { 37 | return Class_::class; 38 | } 39 | 40 | /** 41 | * @param Class_ $node 42 | */ 43 | public function processNode(Node $node, Scope $scope): array 44 | { 45 | /** @var string $className */ 46 | $className = (string) $node->namespacedName; 47 | if (! str_ends_with($className, 'Rector')) { 48 | return []; 49 | } 50 | 51 | if (Strings::match($className, self::PREFIX_REGEX) === null) { 52 | return []; 53 | } 54 | 55 | $implements = $node->implements; 56 | foreach ($implements as $implement) { 57 | if (! $implement instanceof FullyQualified) { 58 | continue; 59 | } 60 | 61 | if ($implement->toString() !== MinPhpVersionInterface::class) { 62 | continue; 63 | } 64 | 65 | return []; 66 | } 67 | 68 | $identifierRuleError = RuleErrorBuilder::message(sprintf(self::ERROR_MESSAGE, $className)) 69 | ->identifier(RectorRuleIdentifier::PHP_RULE_IMPLEMENTS_MIN_VERSION) 70 | ->build(); 71 | 72 | return [$identifierRuleError]; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Rules/RequireAttributeNameRule.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | final class RequireAttributeNameRule implements Rule 21 | { 22 | /** 23 | * @var string 24 | */ 25 | public const ERROR_MESSAGE = 'Attribute must have all names explicitly defined'; 26 | 27 | public function getNodeType(): string 28 | { 29 | return AttributeGroup::class; 30 | } 31 | 32 | /** 33 | * @param AttributeGroup $node 34 | */ 35 | public function processNode(Node $node, Scope $scope): array 36 | { 37 | $ruleErrors = []; 38 | 39 | foreach ($node->attrs as $attribute) { 40 | $attributeName = $attribute->name->toString(); 41 | if ($attributeName === Attribute::class) { 42 | continue; 43 | } 44 | 45 | // skip PHPUnit 46 | if (str_starts_with($attributeName, 'PHPUnit\Framework\Attributes\\')) { 47 | continue; 48 | } 49 | 50 | foreach ($attribute->args as $arg) { 51 | if ($arg->name instanceof Identifier) { 52 | continue; 53 | } 54 | 55 | $ruleErrors[] = RuleErrorBuilder::message(self::ERROR_MESSAGE) 56 | ->identifier(RuleIdentifier::REQUIRE_ATTRIBUTE_NAME) 57 | ->line($attribute->getLine()) 58 | ->build(); 59 | } 60 | } 61 | 62 | return $ruleErrors; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Rules/StringFileAbsolutePathExistsRule.php: -------------------------------------------------------------------------------- 1 | 17 | * 18 | * @see \Symplify\PHPStanRules\Tests\Rules\StringFileAbsolutePathExistsRule\StringFileAbsolutePathExistsRuleTest 19 | */ 20 | final class StringFileAbsolutePathExistsRule implements Rule 21 | { 22 | /** 23 | * @var string 24 | */ 25 | public const ERROR_MESSAGE = 'File "%s" could not be found. Make sure it exists'; 26 | 27 | /** 28 | * @var string[] 29 | */ 30 | private const SUFFIXES_TO_CHECK = [ 31 | '.sql', 32 | '.php', 33 | '.yml', 34 | '.yaml', 35 | '.json', 36 | ]; 37 | 38 | public function getNodeType(): string 39 | { 40 | return Concat::class; 41 | } 42 | 43 | /** 44 | * @param Concat $node 45 | * @return RuleError[] 46 | */ 47 | public function processNode(Node $node, Scope $scope): array 48 | { 49 | // look for __DIR__ . '/some_file.' 50 | if (! $node->left instanceof Dir) { 51 | return []; 52 | } 53 | 54 | if (! $node->right instanceof String_) { 55 | return []; 56 | } 57 | 58 | $stringValue = $node->right->value; 59 | if (! $this->isDesiredFileSuffix($stringValue)) { 60 | return []; 61 | } 62 | 63 | // probably glob or wildcard, cannot be checked 64 | if (str_contains($stringValue, '*')) { 65 | return []; 66 | } 67 | 68 | $absoluteFilePath = $this->getAbsoluteFilePath($scope, $stringValue); 69 | if (file_exists($absoluteFilePath)) { 70 | return []; 71 | } 72 | 73 | $errorMessage = sprintf(self::ERROR_MESSAGE, $absoluteFilePath); 74 | 75 | $identifierRuleError = RuleErrorBuilder::message($errorMessage) 76 | ->identifier(RuleIdentifier::STRING_FILE_ABSOLUTE_PATH_EXISTS) 77 | ->build(); 78 | 79 | return [$identifierRuleError]; 80 | } 81 | 82 | private function getAbsoluteFilePath(Scope $scope, string $stringValue): string 83 | { 84 | $directorPath = dirname($scope->getFile()); 85 | return $directorPath . $stringValue; 86 | } 87 | 88 | private function isDesiredFileSuffix(string $stringValue): bool 89 | { 90 | foreach (self::SUFFIXES_TO_CHECK as $suffixToCheck) { 91 | if (str_ends_with($stringValue, $suffixToCheck)) { 92 | return true; 93 | } 94 | } 95 | 96 | return false; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Rules/Symfony/ConfigClosure/NoBundleResourceConfigRule.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | final class NoBundleResourceConfigRule implements Rule 20 | { 21 | /** 22 | * @var string 23 | */ 24 | private const ERROR_MESSAGE = 'Avoid using configs in Bundle/Resources directory. Move them to "/config" directory instead'; 25 | 26 | public function getNodeType(): string 27 | { 28 | return Closure::class; 29 | } 30 | 31 | /** 32 | * @param Closure $node 33 | * @return IdentifierRuleError[] 34 | */ 35 | public function processNode(Node $node, Scope $scope): array 36 | { 37 | if (! SymfonyClosureDetector::detect($node)) { 38 | return []; 39 | } 40 | 41 | if (! str_contains($scope->getFile(), 'Resources/config')) { 42 | return []; 43 | } 44 | 45 | $identifierRuleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) 46 | ->identifier(SymfonyRuleIdentifier::NO_BUNDLE_RESOURCE_CONFIG) 47 | ->build(); 48 | 49 | return [$identifierRuleError]; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Rules/Symfony/ConfigClosure/TaggedIteratorOverRepeatedServiceCallRule.php: -------------------------------------------------------------------------------- 1 | 20 | * 21 | * @see \Symplify\PHPStanRules\Tests\Rules\Symfony\ConfigClosure\TaggedIteratorOverRepeatedServiceCallRule\TaggedIteratorOverRepeatedServiceCallRuleTest 22 | */ 23 | final class TaggedIteratorOverRepeatedServiceCallRule implements Rule 24 | { 25 | /** 26 | * @var string 27 | */ 28 | public const ERROR_MESSAGE = 'Instead of repeated "->call(%s, ...)" calls, pass services as tagged iterator argument to the constructor'; 29 | 30 | /** 31 | * @var string 32 | */ 33 | private const RULE_IDENTIFIER = 'symfony.taggedIteratorOverRepeatedServiceCall'; 34 | 35 | public function getNodeType(): string 36 | { 37 | return Closure::class; 38 | } 39 | 40 | /** 41 | * @param Closure $node 42 | * @return RuleError[] 43 | */ 44 | public function processNode(Node $node, Scope $scope): array 45 | { 46 | if (! SymfonyClosureDetector::detect($node)) { 47 | return []; 48 | } 49 | 50 | $ruleErrors = []; 51 | 52 | foreach ($node->stmts as $stmt) { 53 | if (! $stmt instanceof Expression) { 54 | continue; 55 | } 56 | 57 | $nestedExpr = $stmt->expr; 58 | if (! $nestedExpr instanceof MethodCall) { 59 | continue; 60 | } 61 | 62 | if ($nestedExpr->isFirstClassCallable()) { 63 | continue; 64 | } 65 | 66 | $adderCallName = RepeatedServiceAdderCallNameFinder::find($nestedExpr); 67 | if (! is_string($adderCallName)) { 68 | continue; 69 | } 70 | 71 | $ruleError = RuleErrorBuilder::message(sprintf(self::ERROR_MESSAGE, $adderCallName)) 72 | ->identifier(self::RULE_IDENTIFIER) 73 | ->line($stmt->getStartLine()) 74 | ->build(); 75 | 76 | $ruleErrors[] = $ruleError; 77 | } 78 | 79 | return $ruleErrors; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Rules/Symfony/FormTypeClassNameRule.php: -------------------------------------------------------------------------------- 1 | 20 | * 21 | * @see \Symplify\PHPStanRules\Tests\Rules\Symfony\FormTypeClassNameRule\FormTypeClassNameRuleTest 22 | */ 23 | final class FormTypeClassNameRule implements Rule 24 | { 25 | /** 26 | * @var string 27 | */ 28 | public const ERROR_MESSAGE = 'Class extends "%s" must have "FormType" suffix to make form explicit, "%s" given'; 29 | 30 | public function getNodeType(): string 31 | { 32 | return Class_::class; 33 | } 34 | 35 | /** 36 | * @param Class_ $node 37 | * @return RuleError[] 38 | */ 39 | public function processNode(Node $node, Scope $scope): array 40 | { 41 | if (! $node->namespacedName instanceof Name) { 42 | return []; 43 | } 44 | 45 | // all good 46 | $className = $node->namespacedName->toString(); 47 | if (str_ends_with($className, 'FormType')) { 48 | return []; 49 | } 50 | 51 | $currentObjectType = new ObjectType($className); 52 | 53 | $parentObjectType = new ObjectType(SymfonyClass::FORM_TYPE); 54 | if (! $parentObjectType->isSuperTypeOf($currentObjectType)->yes()) { 55 | return []; 56 | } 57 | 58 | $errorMessage = sprintf(self::ERROR_MESSAGE, SymfonyClass::FORM_TYPE, $className); 59 | 60 | $identifierRuleError = RuleErrorBuilder::message($errorMessage) 61 | ->identifier(SymfonyRuleIdentifier::FORM_TYPE_CLASS_NAME) 62 | ->build(); 63 | 64 | return [$identifierRuleError]; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Rules/Symfony/NoAbstractControllerConstructorRule.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | final class NoAbstractControllerConstructorRule implements Rule 24 | { 25 | /** 26 | * @var string 27 | */ 28 | public const ERROR_MESSAGE = 'Abstract controller should not have constructor, to avoid override by child classes. Use #[Require] or @require and autowire() method instead'; 29 | 30 | public function getNodeType(): string 31 | { 32 | return Class_::class; 33 | } 34 | 35 | /** 36 | * @param Class_ $node 37 | */ 38 | public function processNode(Node $node, Scope $scope): array 39 | { 40 | if (! $node->isAbstract()) { 41 | return []; 42 | } 43 | 44 | if (! $node->name instanceof Identifier) { 45 | return []; 46 | } 47 | 48 | $className = $node->name->toString(); 49 | if (! str_ends_with($className, 'Controller')) { 50 | return []; 51 | } 52 | 53 | if (! $node->getMethod('__construct')) { 54 | return []; 55 | } 56 | 57 | $identifierRuleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) 58 | ->identifier(SymfonyRuleIdentifier::SYMFONY_NO_ABSTRACT_CONTROLLER_CONSTRUCTOR) 59 | ->build(); 60 | 61 | return [$identifierRuleError]; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Rules/Symfony/NoBareAndSecurityIsGrantedContentsRule.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | final class NoBareAndSecurityIsGrantedContentsRule implements Rule 23 | { 24 | public const ERROR_MESSAGE = 'Instead of using one long "and" condition join, split into multiple standalone #[IsGranted] attributes'; 25 | 26 | public function getNodeType(): string 27 | { 28 | return Attribute::class; 29 | } 30 | 31 | /** 32 | * @param Attribute $node 33 | */ 34 | public function processNode(Node $node, Scope $scope): array 35 | { 36 | if (! in_array($node->name->toString(), [SensioClass::SECURITY, SensioClass::IS_GRANTED, SymfonyClass::IS_GRANTED], true)) { 37 | return []; 38 | } 39 | 40 | $attributeExpr = $node->args[0]->value; 41 | if (! $attributeExpr instanceof String_) { 42 | return []; 43 | } 44 | 45 | // nothing to split 46 | if (str_contains($attributeExpr->value, ' or ')) { 47 | return []; 48 | } 49 | 50 | if (! str_contains($attributeExpr->value, ' and ') && ! str_contains($attributeExpr->value, ' && ')) { 51 | return []; 52 | } 53 | 54 | if ($this->usesCustomFunctios($attributeExpr)) { 55 | return []; 56 | } 57 | 58 | $identifierRuleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) 59 | ->identifier(SymfonyRuleIdentifier::REQUIRED_IS_GRANTED_ENUM) 60 | ->build(); 61 | 62 | return [$identifierRuleError]; 63 | } 64 | 65 | private function usesCustomFunctios(String_ $string): bool 66 | { 67 | $joinedItems = preg_split('# (and|&&|or) #', $string->value, -1, PREG_SPLIT_NO_EMPTY); 68 | 69 | if ($joinedItems === false) { 70 | return false; 71 | } 72 | 73 | foreach ($joinedItems as $joinedItem) { 74 | if (str_contains($joinedItem, 'is_granted')) { 75 | continue; 76 | } 77 | 78 | if (str_contains($joinedItem, 'has_role')) { 79 | continue; 80 | } 81 | 82 | return true; 83 | } 84 | 85 | return false; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Rules/Symfony/NoClassLevelRouteRule.php: -------------------------------------------------------------------------------- 1 | 18 | * 19 | * @see \Symplify\PHPStanRules\Tests\Rules\Symfony\NoClassLevelRouteRule\NoClassLevelRouteRuleTest 20 | */ 21 | final class NoClassLevelRouteRule implements Rule 22 | { 23 | /** 24 | * @var string 25 | */ 26 | public const ERROR_MESSAGE = 'Avoid class-level route prefixing. Use method route to keep single source of truth and focus'; 27 | 28 | public function getNodeType(): string 29 | { 30 | return InClassNode::class; 31 | } 32 | 33 | /** 34 | * @param InClassNode $node 35 | * @return RuleError[] 36 | */ 37 | public function processNode(Node $node, Scope $scope): array 38 | { 39 | if (! SymfonyControllerAnalyzer::isControllerScope($scope)) { 40 | return []; 41 | } 42 | 43 | $classLike = $node->getOriginalNode(); 44 | if (! SymfonyControllerAnalyzer::hasRouteAnnotationOrAttribute($classLike)) { 45 | return []; 46 | } 47 | 48 | $ruleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) 49 | ->line($node->getStartLine()) 50 | ->identifier(SymfonyRuleIdentifier::NO_CLASS_LEVEL_ROUTE) 51 | ->build(); 52 | 53 | return [$ruleError]; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Rules/Symfony/NoConstructorAndRequiredTogetherRule.php: -------------------------------------------------------------------------------- 1 | 19 | * 20 | * @see \Symplify\PHPStanRules\Tests\Rules\Symfony\NoConstructorAndRequiredTogetherRule\NoConstructorAndRequiredTogetherRuleTest 21 | */ 22 | final class NoConstructorAndRequiredTogetherRule implements Rule 23 | { 24 | /** 25 | * @var string 26 | */ 27 | public const ERROR_MESSAGE = 'Avoid using __construct() and @required in the same class. Pick one to keep architecture clean'; 28 | 29 | public function getNodeType(): string 30 | { 31 | return Class_::class; 32 | } 33 | 34 | /** 35 | * @param Class_ $node 36 | * @return IdentifierRuleError[] 37 | */ 38 | public function processNode(Node $node, Scope $scope): array 39 | { 40 | if ($node->isAnonymous()) { 41 | return []; 42 | } 43 | 44 | if (! $node->getMethod(MethodName::CONSTRUCTOR)) { 45 | return []; 46 | } 47 | 48 | if (! $this->hasAutowiredMethod($node)) { 49 | return []; 50 | } 51 | 52 | $identifierRuleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) 53 | ->identifier(SymfonyRuleIdentifier::NO_CONSTRUCT_AND_REQUIRED) 54 | ->build(); 55 | 56 | return [ 57 | $identifierRuleError, 58 | ]; 59 | } 60 | 61 | private function hasAutowiredMethod(Class_ $class): bool 62 | { 63 | foreach ($class->getMethods() as $classMethod) { 64 | if (! $classMethod->isPublic()) { 65 | continue; 66 | } 67 | 68 | $docComment = $classMethod->getDocComment(); 69 | if (! $docComment instanceof Doc) { 70 | continue; 71 | } 72 | 73 | if (! str_contains($docComment->getText(), '@required')) { 74 | continue; 75 | } 76 | 77 | // special case when its allowed, to avoid circular references 78 | if (str_contains($docComment->getText(), 'circular')) { 79 | continue; 80 | } 81 | 82 | return true; 83 | } 84 | 85 | return false; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Rules/Symfony/NoFindTaggedServiceIdsCallRule.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | final class NoFindTaggedServiceIdsCallRule implements Rule 20 | { 21 | /** 22 | * @var string 23 | */ 24 | public const ERROR_MESSAGE = 'Instead of "$this->findTaggedServiceIds()" use more reliable registerForAutoconfiguration() and tagged iterator attribute. Those work outside any configuration and avoid missed tag errors'; 25 | 26 | public function getNodeType(): string 27 | { 28 | return MethodCall::class; 29 | } 30 | 31 | /** 32 | * @param MethodCall $node 33 | * @return IdentifierRuleError[] 34 | */ 35 | public function processNode(Node $node, Scope $scope): array 36 | { 37 | if (! NamingHelper::isName($node->name, 'findTaggedServiceIds')) { 38 | return []; 39 | } 40 | 41 | $identifierRuleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) 42 | ->identifier(SymfonyRuleIdentifier::NO_FIND_TAGGED_SERVICE_IDS_CALL) 43 | ->build(); 44 | 45 | return [ 46 | $identifierRuleError, 47 | ]; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Rules/Symfony/NoGetDoctrineInControllerRule.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | final class NoGetDoctrineInControllerRule implements Rule 20 | { 21 | /** 22 | * @var string 23 | */ 24 | private const ERROR_MESSAGE = 'Do not use $this->getDoctrine() method in controller. Use __construct(EntityManagerInterface $entityManager) instead'; 25 | 26 | public function getNodeType(): string 27 | { 28 | return MethodCall::class; 29 | } 30 | 31 | /** 32 | * @param MethodCall $node 33 | */ 34 | public function processNode(Node $node, Scope $scope): array 35 | { 36 | if (! MethodCallNameAnalyzer::isThisMethodCall($node, 'getDoctrine')) { 37 | return []; 38 | } 39 | 40 | if (! SymfonyControllerAnalyzer::isControllerScope($scope)) { 41 | return []; 42 | } 43 | 44 | $identifierRuleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) 45 | ->identifier(SymfonyRuleIdentifier::NO_GET_DOCTRINE_IN_CONTROLLER) 46 | ->build(); 47 | 48 | return [$identifierRuleError]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Rules/Symfony/NoGetInCommandRule.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | final class NoGetInCommandRule implements Rule 20 | { 21 | /** 22 | * @var string 23 | */ 24 | public const ERROR_MESSAGE = 'Do not use $this->get(Type::class) method in commands to get services. Use __construct(Type $type) instead'; 25 | 26 | public function getNodeType(): string 27 | { 28 | return MethodCall::class; 29 | } 30 | 31 | /** 32 | * @param MethodCall $node 33 | */ 34 | public function processNode(Node $node, Scope $scope): array 35 | { 36 | if (! MethodCallNameAnalyzer::isThisMethodCall($node, 'get')) { 37 | return []; 38 | } 39 | 40 | if (! SymfonyCommandAnalyzer::isCommandScope($scope)) { 41 | return []; 42 | } 43 | 44 | $identifierRuleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) 45 | ->identifier(SymfonyRuleIdentifier::NO_GET_IN_COMMAND) 46 | ->build(); 47 | 48 | return [$identifierRuleError]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Rules/Symfony/NoGetInControllerRule.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | final class NoGetInControllerRule implements Rule 20 | { 21 | /** 22 | * @var string 23 | */ 24 | private const ERROR_MESSAGE = 'Do not use $this->get(Type::class) method in controller to get services. Use __construct(Type $type) instead'; 25 | 26 | public function getNodeType(): string 27 | { 28 | return MethodCall::class; 29 | } 30 | 31 | /** 32 | * @param MethodCall $node 33 | */ 34 | public function processNode(Node $node, Scope $scope): array 35 | { 36 | if (! MethodCallNameAnalyzer::isThisMethodCall($node, 'get')) { 37 | return []; 38 | } 39 | 40 | if (! SymfonyControllerAnalyzer::isControllerScope($scope)) { 41 | return []; 42 | } 43 | 44 | $identifierRuleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) 45 | ->identifier(SymfonyRuleIdentifier::NO_GET_IN_CONTROLLER) 46 | ->build(); 47 | 48 | return [$identifierRuleError]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Rules/Symfony/NoRequiredOutsideClassRule.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | final class NoRequiredOutsideClassRule implements Rule 21 | { 22 | /** 23 | * @var string 24 | */ 25 | public const ERROR_MESSAGE = 'Symfony #[Require]/@required should be used only in classes to avoid misuse'; 26 | 27 | public function getNodeType(): string 28 | { 29 | return Trait_::class; 30 | } 31 | 32 | /** 33 | * @param Trait_ $node 34 | */ 35 | public function processNode(Node $node, Scope $scope): array 36 | { 37 | $ruleErrors = []; 38 | 39 | foreach ($node->getMethods() as $classMethod) { 40 | if (! SymfonyRequiredMethodAnalyzer::detect($classMethod)) { 41 | continue; 42 | } 43 | 44 | $ruleErrors[] = RuleErrorBuilder::message(self::ERROR_MESSAGE) 45 | ->identifier(SymfonyRuleIdentifier::SYMFONY_NO_REQUIRED_OUTSIDE_CLASS) 46 | ->line($classMethod->getLine()) 47 | ->build(); 48 | } 49 | 50 | return $ruleErrors; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Rules/Symfony/NoRouteTrailingSlashPathRule.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | final class NoRouteTrailingSlashPathRule implements Rule 21 | { 22 | /** 23 | * @var string 24 | */ 25 | public const ERROR_MESSAGE = 'Avoid trailing slash in route path "%s", to prevent redirects and SEO issues'; 26 | 27 | public function getNodeType(): string 28 | { 29 | return ClassMethod::class; 30 | } 31 | 32 | /** 33 | * @param ClassMethod $node 34 | * @return RuleError[] 35 | */ 36 | public function processNode(Node $node, Scope $scope): array 37 | { 38 | if ($node->isMagic() || ! $node->isPublic()) { 39 | return []; 40 | } 41 | 42 | if (! SymfonyControllerAnalyzer::isControllerScope($scope)) { 43 | return []; 44 | } 45 | 46 | $routePath = $this->matchRouteDocblockPath($node); 47 | if (! is_string($routePath)) { 48 | return []; 49 | } 50 | 51 | // path is valid 52 | if ($routePath === '/' || ! str_ends_with($routePath, '/')) { 53 | return []; 54 | } 55 | 56 | $identifierRuleError = RuleErrorBuilder::message(sprintf(self::ERROR_MESSAGE, $routePath)) 57 | ->identifier(SymfonyRuleIdentifier::NO_ROUTE_TRAILING_SLASH_PATH) 58 | ->build(); 59 | 60 | return [$identifierRuleError]; 61 | } 62 | 63 | private function matchRouteDocblockPath(ClassMethod $classMethod): ?string 64 | { 65 | $docComment = $classMethod->getDocComment(); 66 | if (! $docComment instanceof Doc) { 67 | return null; 68 | } 69 | 70 | // not a route 71 | if (! str_contains($docComment->getText(), 'Route')) { 72 | return null; 73 | } 74 | 75 | /** @see https://regex101.com/r/Qo7aLu/1 */ 76 | preg_match('#@Route\((path=)?"(?[\/\w\-]+)"#', $docComment->getText(), $matches); 77 | 78 | return $matches['path'] ?? null; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Rules/Symfony/NoRoutingPrefixRule.php: -------------------------------------------------------------------------------- 1 | 22 | * 23 | * @see \Symplify\PHPStanRules\Tests\Rules\Symfony\NoRoutingPrefixRule\NoRoutingPrefixRuleTest 24 | */ 25 | final class NoRoutingPrefixRule implements Rule 26 | { 27 | /** 28 | * @var string 29 | */ 30 | public const ERROR_MESSAGE = 'Avoid global route prefixing, to use single place for paths and improve static analysis'; 31 | 32 | public function getNodeType(): string 33 | { 34 | return MethodCall::class; 35 | } 36 | 37 | /** 38 | * @param MethodCall $node 39 | * @return RuleError[] 40 | */ 41 | public function processNode(Node $node, Scope $scope): array 42 | { 43 | if (! NamingHelper::isName($node->name, 'prefix')) { 44 | return []; 45 | } 46 | 47 | $callerType = $scope->getType($node->var); 48 | if (! $callerType instanceof ObjectType) { 49 | return []; 50 | } 51 | 52 | if (! $callerType->isInstanceOf(SymfonyClass::ROUTE_IMPORT_CONFIGURATOR)->yes()) { 53 | return []; 54 | } 55 | 56 | if ($this->isAllowedExternalBundleImport($node)) { 57 | return []; 58 | } 59 | 60 | $ruleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) 61 | ->line($node->getStartLine()) 62 | ->identifier(SymfonyRuleIdentifier::NO_ROUTING_PREFIX) 63 | ->build(); 64 | 65 | return [$ruleError]; 66 | } 67 | 68 | private function isAllowedExternalBundleImport(MethodCall $methodCall): bool 69 | { 70 | if (! $methodCall->var instanceof MethodCall) { 71 | return false; 72 | } 73 | 74 | $parentCaller = $methodCall->var; 75 | if (! $parentCaller->name instanceof Identifier || $parentCaller->name->toString() !== 'import') { 76 | return false; 77 | } 78 | 79 | $importArgPath = $parentCaller->getArgs()[0]->value; 80 | if (! $importArgPath instanceof String_) { 81 | return false; 82 | } 83 | 84 | // these external bundles are typically prefixed on purpose 85 | if (str_starts_with($importArgPath->value, '@FrameworkBundle')) { 86 | return true; 87 | } 88 | 89 | return str_starts_with($importArgPath->value, '@WebProfilerBundle'); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Rules/Symfony/NoStringInGetSubscribedEventsRule.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | final class NoStringInGetSubscribedEventsRule implements Rule 24 | { 25 | /** 26 | * @var string 27 | */ 28 | private const ERROR_MESSAGE = 'Symfony getSubscribedEvents() method must contain only event class references, no strings'; 29 | 30 | public function getNodeType(): string 31 | { 32 | return ClassMethod::class; 33 | } 34 | 35 | /** 36 | * @param ClassMethod $node 37 | */ 38 | public function processNode(Node $node, Scope $scope): array 39 | { 40 | if ($node->stmts === null) { 41 | return []; 42 | } 43 | 44 | if ($node->name->toString() !== 'getSubscribedEvents') { 45 | return []; 46 | } 47 | 48 | $classReflection = $scope->getClassReflection(); 49 | if (! $classReflection instanceof ClassReflection) { 50 | return []; 51 | } 52 | 53 | // only handle symfony one 54 | if (! $classReflection->implementsInterface(SymfonyClass::EVENT_SUBSCRIBER_INTERFACE)) { 55 | return []; 56 | } 57 | 58 | $nodeFinder = new NodeFinder(); 59 | 60 | /** @var ArrayItem[] $arrayItems */ 61 | $arrayItems = $nodeFinder->findInstanceOf($node->stmts, ArrayItem::class); 62 | 63 | foreach ($arrayItems as $arrayItem) { 64 | if (! $arrayItem->key instanceof Expr) { 65 | continue; 66 | } 67 | 68 | // must be class const fetch 69 | if ($arrayItem->key instanceof ClassConstFetch) { 70 | $classConstFetch = $arrayItem->key; 71 | 72 | if ($classConstFetch->class instanceof Expr) { 73 | continue; 74 | } 75 | 76 | // skip Symfony FormEvents::class 77 | if ($classConstFetch->class->toString() === SymfonyClass::FORM_EVENTS) { 78 | continue; 79 | } 80 | 81 | if ($classConstFetch->name instanceof Expr) { 82 | continue; 83 | } 84 | 85 | if ($classConstFetch->name->toString() === 'class') { 86 | continue; 87 | } 88 | 89 | continue; 90 | } 91 | 92 | $ruleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) 93 | ->identifier(SymfonyRuleIdentifier::NO_STRING_IN_GET_SUBSCRIBED_EVENTS) 94 | ->build(); 95 | 96 | return [$ruleError]; 97 | } 98 | 99 | return []; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Rules/Symfony/RequireInvokableControllerRule.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | final class RequireInvokableControllerRule implements Rule 21 | { 22 | /** 23 | * @var string 24 | */ 25 | public const ERROR_MESSAGE = 'Use invokable controller with __invoke() method instead of named action method'; 26 | 27 | /** 28 | * @return class-string 29 | */ 30 | public function getNodeType(): string 31 | { 32 | return InClassNode::class; 33 | } 34 | 35 | /** 36 | * @param InClassNode $node 37 | */ 38 | public function processNode(Node $node, Scope $scope): array 39 | { 40 | if (! SymfonyControllerAnalyzer::isControllerScope($scope)) { 41 | return []; 42 | } 43 | 44 | $ruleErrors = []; 45 | 46 | $classLike = $node->getOriginalNode(); 47 | foreach ($classLike->getMethods() as $classMethod) { 48 | if (! SymfonyControllerAnalyzer::isControllerActionMethod($classMethod)) { 49 | continue; 50 | } 51 | 52 | if ($classMethod->isMagic()) { 53 | continue; 54 | } 55 | 56 | if ($classMethod->name->toString() === MethodName::INVOKE) { 57 | continue; 58 | } 59 | 60 | $ruleErrors[] = RuleErrorBuilder::message(self::ERROR_MESSAGE) 61 | ->identifier(SymfonyRuleIdentifier::SYMFONY_REQUIRE_INVOKABLE_CONTROLLER) 62 | ->line($classMethod->getLine()) 63 | ->build(); 64 | } 65 | 66 | return $ruleErrors; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Rules/Symfony/RequireIsGrantedEnumRule.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | final class RequireIsGrantedEnumRule implements Rule 23 | { 24 | public const ERROR_MESSAGE = 'Instead of "%s" string, use enum constant for #[IsGranted]'; 25 | 26 | public function getNodeType(): string 27 | { 28 | return Attribute::class; 29 | } 30 | 31 | /** 32 | * @param Attribute $node 33 | */ 34 | public function processNode(Node $node, Scope $scope): array 35 | { 36 | if (! in_array($node->name->toString(), [SensioClass::IS_GRANTED, SymfonyClass::IS_GRANTED], true)) { 37 | return []; 38 | } 39 | 40 | $isGrantedExpr = $node->args[0]->value; 41 | if (! $isGrantedExpr instanceof String_) { 42 | return []; 43 | } 44 | 45 | $identifierRuleError = RuleErrorBuilder::message(sprintf(self::ERROR_MESSAGE, $isGrantedExpr->value)) 46 | ->identifier(SymfonyRuleIdentifier::REQUIRED_IS_GRANTED_ENUM) 47 | ->build(); 48 | 49 | return [$identifierRuleError]; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Rules/Symfony/SingleArgEventDispatchRule.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | final class SingleArgEventDispatchRule implements Rule 21 | { 22 | /** 23 | * @var string 24 | */ 25 | public const ERROR_MESSAGE = 'The event dispatch() method can have only 1 arg - the event object'; 26 | 27 | public function getNodeType(): string 28 | { 29 | return MethodCall::class; 30 | } 31 | 32 | /** 33 | * @param MethodCall $node 34 | */ 35 | public function processNode(Node $node, Scope $scope): array 36 | { 37 | if (! $node->name instanceof Identifier) { 38 | return []; 39 | } 40 | 41 | if ($node->name->toString() !== 'dispatch') { 42 | return []; 43 | } 44 | 45 | // all good 46 | if (count($node->getArgs()) === 1) { 47 | return []; 48 | } 49 | 50 | $callerType = $scope->getType($node->var); 51 | if (! $callerType instanceof ObjectType) { 52 | return []; 53 | } 54 | 55 | if (! $callerType->isInstanceOf(SymfonyClass::EVENT_DISPATCHER_INTERFACE)->yes()) { 56 | return []; 57 | } 58 | 59 | $identifierRuleError = RuleErrorBuilder::message(self::ERROR_MESSAGE) 60 | ->identifier(SymfonyRuleIdentifier::SINGLE_ARG_EVENT_DISPATCH) 61 | ->build(); 62 | 63 | return [$identifierRuleError]; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Rules/Symfony/SingleRequiredMethodRule.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | final class SingleRequiredMethodRule implements Rule 18 | { 19 | public const ERROR_MESSAGE = 'Found %d @required methods. Use only one method to avoid unexpected behavior.'; 20 | 21 | public function getNodeType(): string 22 | { 23 | return Class_::class; 24 | } 25 | 26 | /** 27 | * @param Class_ $node 28 | * @return RuleError[] 29 | */ 30 | public function processNode(Node $node, Scope $scope): array 31 | { 32 | $requiredClassMethodCount = 0; 33 | 34 | foreach ($node->getMethods() as $classMethod) { 35 | if (! SymfonyRequiredMethodAnalyzer::detect($classMethod)) { 36 | continue; 37 | } 38 | 39 | ++$requiredClassMethodCount; 40 | } 41 | 42 | if ($requiredClassMethodCount < 2) { 43 | return []; 44 | } 45 | 46 | $errorMessage = sprintf(self::ERROR_MESSAGE, $requiredClassMethodCount); 47 | 48 | $identifierRuleError = RuleErrorBuilder::message($errorMessage) 49 | ->identifier(SymfonyRuleIdentifier::SINGLE_REQUIRED_METHOD) 50 | ->build(); 51 | 52 | return [$identifierRuleError]; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Rules/UppercaseConstantRule.php: -------------------------------------------------------------------------------- 1 | 16 | * 17 | * @see \Symplify\PHPStanRules\Tests\Rules\UppercaseConstantRule\UppercaseConstantRuleTest 18 | */ 19 | final class UppercaseConstantRule implements Rule 20 | { 21 | /** 22 | * @var string 23 | */ 24 | public const ERROR_MESSAGE = 'Constant "%s" must be uppercase'; 25 | 26 | public function getNodeType(): string 27 | { 28 | return ClassConst::class; 29 | } 30 | 31 | /** 32 | * @param ClassConst $node 33 | */ 34 | public function processNode(Node $node, Scope $scope): array 35 | { 36 | foreach ($node->consts as $const) { 37 | $constantName = (string) $const->name; 38 | if (strtoupper($constantName) === $constantName) { 39 | continue; 40 | } 41 | 42 | $errorMessage = sprintf(self::ERROR_MESSAGE, $constantName); 43 | return [RuleErrorBuilder::message($errorMessage) 44 | ->identifier(RuleIdentifier::UPPERCASE_CONSTANT) 45 | ->build()]; 46 | } 47 | 48 | return []; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Symfony/ConfigClosure/SymfonyClosureServicesExcludeResolver.php: -------------------------------------------------------------------------------- 1 | load('...', '...') 22 | * ->exclude(['X', 'Y']); 23 | */ 24 | final class SymfonyClosureServicesExcludeResolver 25 | { 26 | /** 27 | * @return string[] 28 | */ 29 | public static function resolve(Closure $closure, Scope $scope): array 30 | { 31 | $excludedPaths = []; 32 | 33 | $nodeFinder = new NodeFinder(); 34 | $nodeFinder->find($closure->stmts, function (Node $node) use (&$excludedPaths, $scope): bool { 35 | if (! $node instanceof MethodCall) { 36 | return false; 37 | } 38 | 39 | if (! self::isName($node->name, 'exclude')) { 40 | return false; 41 | } 42 | 43 | $excludedExpr = $node->getArgs()[0]->value; 44 | if (! $excludedExpr instanceof Array_) { 45 | return false; 46 | } 47 | 48 | foreach ($excludedExpr->items as $arrayItem) { 49 | if (! $arrayItem->value instanceof Concat) { 50 | continue; 51 | } 52 | 53 | $concat = $arrayItem->value; 54 | if (! $concat->right instanceof String_) { 55 | continue; 56 | } 57 | 58 | $excludedPath = dirname($scope->getFile()) . $concat->right->value; 59 | $realExcludedPath = realpath($excludedPath); 60 | if (! is_string($realExcludedPath)) { 61 | continue; 62 | } 63 | 64 | $excludedPaths[] = $realExcludedPath; 65 | } 66 | 67 | return true; 68 | }); 69 | 70 | return array_unique($excludedPaths); 71 | } 72 | 73 | private static function isName(Node $node, string $name): bool 74 | { 75 | if (! $node instanceof Name && ! $node instanceof Identifier) { 76 | return false; 77 | } 78 | 79 | return $node->toString() === $name; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Symfony/ConfigClosure/SymfonyClosureServicesLoadResolver.php: -------------------------------------------------------------------------------- 1 | load('Y') 19 | */ 20 | final class SymfonyClosureServicesLoadResolver 21 | { 22 | /** 23 | * @return string[] 24 | */ 25 | public static function resolve(Closure $closure): array 26 | { 27 | $loadedNamespaces = []; 28 | 29 | $nodeFinder = new NodeFinder(); 30 | $nodeFinder->find($closure->stmts, function (Node $node) use (&$loadedNamespaces): bool { 31 | if (! $node instanceof MethodCall) { 32 | return false; 33 | } 34 | 35 | if (! self::isName($node->name, 'load')) { 36 | return false; 37 | } 38 | 39 | $namespaceExpr = $node->getArgs()[0]->value; 40 | if (! $namespaceExpr instanceof String_) { 41 | return false; 42 | } 43 | 44 | $loadedNamespaces[] = $namespaceExpr->value; 45 | return true; 46 | }); 47 | 48 | return $loadedNamespaces; 49 | } 50 | 51 | private static function isName(Node $node, string $name): bool 52 | { 53 | if (! $node instanceof Name && ! $node instanceof Identifier) { 54 | return false; 55 | } 56 | 57 | return $node->toString() === $name; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Symfony/ConfigClosure/SymfonyClosureServicesSetClassesResolver.php: -------------------------------------------------------------------------------- 1 | set(X); 21 | */ 22 | final class SymfonyClosureServicesSetClassesResolver 23 | { 24 | /** 25 | * @return array 26 | */ 27 | public static function resolve(Closure $closure): array 28 | { 29 | $standaloneSetServices = []; 30 | 31 | $nodeFinder = new NodeFinder(); 32 | $nodeFinder->find($closure, function (Node $node) use (&$standaloneSetServices): bool { 33 | if (! $node instanceof Expression) { 34 | return false; 35 | } 36 | 37 | if (! $node->expr instanceof MethodCall) { 38 | return false; 39 | } 40 | 41 | $methodCall = $node->expr; 42 | if (! $methodCall->var instanceof Variable) { 43 | return false; 44 | } 45 | 46 | // dummy services check, to avoid collecting parameters 47 | if (! self::isName($methodCall->var->name, 'services')) { 48 | return false; 49 | } 50 | 51 | if (! self::isName($methodCall->name, 'set')) { 52 | return false; 53 | } 54 | 55 | $setServiceExpr = $methodCall->getArgs()[0]->value; 56 | if (! $setServiceExpr instanceof ClassConstFetch) { 57 | return false; 58 | } 59 | 60 | if (! $setServiceExpr->class instanceof Name) { 61 | return false; 62 | } 63 | 64 | $serviceClass = $setServiceExpr->class->toString(); 65 | $standaloneSetServices[$serviceClass] = $setServiceExpr->getStartLine(); 66 | 67 | return true; 68 | }); 69 | 70 | return $standaloneSetServices; 71 | } 72 | 73 | private static function isName(Node|string $node, string $name): bool 74 | { 75 | if (is_string($node)) { 76 | return $node === $name; 77 | } 78 | 79 | if (! $node instanceof Name && ! $node instanceof Identifier) { 80 | return false; 81 | } 82 | 83 | return $node->toString() === $name; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Symfony/ConfigClosure/SymfonyServiceReferenceFunctionAnalyzer.php: -------------------------------------------------------------------------------- 1 | name instanceof Name) { 21 | return false; 22 | } 23 | 24 | $functionName = $expr->name->toString(); 25 | 26 | return in_array($functionName, [ 27 | SymfonyFunctionName::REF, 28 | SymfonyFunctionName::SERVICE, 29 | ]); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Symfony/NodeAnalyzer/SymfonyClosureDetector.php: -------------------------------------------------------------------------------- 1 | getParams()) !== 1) { 16 | return false; 17 | } 18 | 19 | $onlyParam = $closure->getParams()[0]; 20 | if (! $onlyParam->type instanceof Name) { 21 | return false; 22 | } 23 | 24 | $parameterName = $onlyParam->type->toString(); 25 | return $parameterName === SymfonyClass::CONTAINER_CONFIGURATOR; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Symfony/NodeAnalyzer/SymfonyCommandAnalyzer.php: -------------------------------------------------------------------------------- 1 | isInClass()) { 15 | return false; 16 | } 17 | 18 | $classReflection = $scope->getClassReflection(); 19 | return $classReflection->is(SymfonyClass::COMMAND); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Symfony/NodeAnalyzer/SymfonyControllerAnalyzer.php: -------------------------------------------------------------------------------- 1 | isInClass()) { 27 | return false; 28 | } 29 | 30 | $classReflection = $scope->getClassReflection(); 31 | foreach (self::CONTROLLER_TYPES as $controllerType) { 32 | if ($classReflection->is($controllerType)) { 33 | return true; 34 | } 35 | } 36 | 37 | return false; 38 | } 39 | 40 | public static function isControllerActionMethod(ClassMethod $classMethod): bool 41 | { 42 | return self::hasRouteAnnotationOrAttribute($classMethod); 43 | } 44 | 45 | public static function hasRouteAnnotationOrAttribute(ClassLike | ClassMethod $node): bool 46 | { 47 | if ($node instanceof ClassMethod && ! $node->isPublic()) { 48 | return false; 49 | } 50 | 51 | $attributeFinder = new AttributeFinder(); 52 | 53 | if ($attributeFinder->hasAttribute($node, SymfonyClass::ROUTE_ATTRIBUTE)) { 54 | return true; 55 | } 56 | 57 | $docComment = $node->getDocComment(); 58 | if (! $docComment instanceof Doc) { 59 | return false; 60 | } 61 | 62 | if (str_contains($docComment->getText(), 'Symfony\Component\Routing\Annotation\Route')) { 63 | return true; 64 | } 65 | 66 | return \str_contains($docComment->getText(), '@Route'); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Symfony/NodeFinder/RepeatedServiceAdderCallNameFinder.php: -------------------------------------------------------------------------------- 1 | getArgs()[0]->value; 29 | $callMethodName = $calledMethodNameExpr->value; 30 | 31 | // is passing a service references? 32 | $passedExpr = $callMethodCall->getArgs()[1]->value; 33 | if (! $passedExpr instanceof Array_) { 34 | continue; 35 | } 36 | 37 | if (count($passedExpr->items) !== 1) { 38 | continue; 39 | } 40 | 41 | $firstArrayItem = $passedExpr->items[0]; 42 | if (! SymfonyServiceReferenceFunctionAnalyzer::isReferenceCall($firstArrayItem->value)) { 43 | continue; 44 | } 45 | 46 | $callMethodNames[] = $callMethodName; 47 | } 48 | 49 | $methodNamesToCount = array_count_values($callMethodNames); 50 | foreach ($methodNamesToCount as $methodName => $count) { 51 | if ($count < self::MIN_ALERT_COUNT) { 52 | continue; 53 | } 54 | 55 | return $methodName; 56 | } 57 | 58 | return null; 59 | } 60 | 61 | /** 62 | * @return MethodCall[] 63 | */ 64 | private static function findCallMethodCalls(MethodCall $methodCall): array 65 | { 66 | $nodeFinder = new NodeFinder(); 67 | 68 | /** @var MethodCall[] $callMethodCalls */ 69 | $callMethodCalls = $nodeFinder->find($methodCall, function (Node $node): bool { 70 | if (! $node instanceof MethodCall) { 71 | return false; 72 | } 73 | 74 | if (! fast_node_named($node->name, self::CALL_NAME)) { 75 | return false; 76 | } 77 | 78 | if (count($node->getArgs()) !== 2) { 79 | return false; 80 | } 81 | 82 | $callNameExpr = $node->getArgs()[0]->value; 83 | return $callNameExpr instanceof String_; 84 | }); 85 | 86 | return $callMethodCalls; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Symfony/Reflection/ClassConstructorTypesResolver.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | public function resolveClassConstructorNamesToTypes(MethodCall $methodCall): array 25 | { 26 | $serviceClassOrName = $this->resolveClassNameFromServicesSetMethodCall($methodCall); 27 | if (! is_string($serviceClassOrName)) { 28 | return []; 29 | } 30 | 31 | $classArgumentNamesToTypes = []; 32 | if (! $this->reflectionProvider->hasClass($serviceClassOrName)) { 33 | return []; 34 | } 35 | 36 | $classReflection = $this->reflectionProvider->getClass($serviceClassOrName); 37 | if (! $classReflection->hasConstructor()) { 38 | return []; 39 | } 40 | 41 | $extendedMethodReflection = $classReflection->getConstructor(); 42 | 43 | foreach ($extendedMethodReflection->getOnlyVariant()->getParameters() as $parameterReflection) { 44 | $parameterType = $parameterReflection->getType(); 45 | if (! $parameterType instanceof ObjectType) { 46 | continue; 47 | } 48 | 49 | $classArgumentNamesToTypes[$parameterReflection->getName()] = $parameterType->getClassName(); 50 | } 51 | 52 | return $classArgumentNamesToTypes; 53 | } 54 | 55 | private function resolveClassNameFromServicesSetMethodCall(MethodCall $methodCall): ?string 56 | { 57 | $currentMethodCall = $methodCall; 58 | while ($currentMethodCall->var instanceof MethodCall) { 59 | $currentMethodCall = $currentMethodCall->var; 60 | 61 | if (! NamingHelper::isName($currentMethodCall->name, 'set')) { 62 | continue; 63 | } 64 | 65 | $serviceClassOrName = $currentMethodCall->getArgs()[0]->value; 66 | if ($serviceClassOrName instanceof ClassConstFetch) { 67 | return NamingHelper::getName($serviceClassOrName->class); 68 | } 69 | 70 | $secondArg = $currentMethodCall->getArgs()[1] ?? null; 71 | if ($secondArg instanceof Arg) { 72 | $secondExpr = $secondArg->value; 73 | if ($secondExpr instanceof ClassConstFetch) { 74 | return NamingHelper::getName($secondExpr->class); 75 | } 76 | } 77 | } 78 | 79 | return null; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Testing/PHPUnitTestAnalyser.php: -------------------------------------------------------------------------------- 1 | getClassReflection(); 21 | if (! $classReflection instanceof ClassReflection) { 22 | return false; 23 | } 24 | 25 | return $classReflection->is(self::TEST_CASE_CLASS); 26 | } 27 | 28 | /** 29 | * @api is used 30 | */ 31 | public static function isTestClassMethod(ClassMethod $classMethod): bool 32 | { 33 | if (! $classMethod->isPublic()) { 34 | return false; 35 | } 36 | 37 | if (! $classMethod->isMagic()) { 38 | return true; 39 | } 40 | 41 | return str_starts_with($classMethod->name->toString(), 'test'); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/TypeAnalyzer/CallableTypeAnalyzer.php: -------------------------------------------------------------------------------- 1 | getType($expr); 19 | $unwrappedNameStaticType = TypeCombinator::removeNull($nameStaticType); 20 | 21 | if ($unwrappedNameStaticType->isCallable()->yes()) { 22 | return true; 23 | } 24 | 25 | return $this->isInvokableObjectType($unwrappedNameStaticType); 26 | } 27 | 28 | private function isInvokableObjectType(Type $type): bool 29 | { 30 | if (! $type instanceof ObjectType) { 31 | return false; 32 | } 33 | 34 | return $type->hasMethod(MethodName::INVOKE)->yes(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/TypeAnalyzer/RectorAllowedAutoloadedTypeAnalyzer.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | private const ALLOWED_CLASSES = [ 28 | Node::class, 29 | PhpDocNode::class, 30 | ]; 31 | 32 | public static function isAllowedType(Type $type): bool 33 | { 34 | if ($type instanceof UnionType) { 35 | foreach ($type->getTypes() as $unionedType) { 36 | if (! self::isAllowedType($unionedType)) { 37 | return false; 38 | } 39 | } 40 | 41 | return true; 42 | } 43 | 44 | if ($type instanceof ConstantStringType) { 45 | return self::isAllowedClassString($type->getValue()); 46 | } 47 | 48 | if ($type instanceof ObjectType) { 49 | return self::isAllowedClassString($type->getClassName()); 50 | } 51 | 52 | if ($type instanceof GenericClassStringType) { 53 | return self::isAllowedType($type->getGenericType()); 54 | } 55 | 56 | return false; 57 | } 58 | 59 | private static function isAllowedClassString(string $value): bool 60 | { 61 | // autoloaded allowed type 62 | if (Strings::match($value, self::AUTOLOADED_CLASS_PREFIX_REGEX) !== null) { 63 | return true; 64 | } 65 | 66 | foreach (self::ALLOWED_CLASSES as $allowedClass) { 67 | if ($value === $allowedClass) { 68 | return true; 69 | } 70 | 71 | if (is_a($value, $allowedClass, true)) { 72 | return true; 73 | } 74 | } 75 | 76 | return false; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/ValueObject/Configuration/RequiredWithMessage.php: -------------------------------------------------------------------------------- 1 | required; 18 | } 19 | 20 | public function getMessage(): ?string 21 | { 22 | return $this->message; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/functions/fast-functions.php: -------------------------------------------------------------------------------- 1 | toString() === $desiredName; 17 | } 18 | 19 | return false; 20 | } 21 | 22 | // reflections 23 | 24 | function fast_has_parent_constructor(Scope $scope): bool 25 | { 26 | $classReflection = $scope->getClassReflection(); 27 | if (! $classReflection instanceof ClassReflection) { 28 | return false; 29 | } 30 | 31 | // anonymous class? let it go 32 | if ($classReflection->isAnonymous()) { 33 | return false; 34 | } 35 | 36 | $parentClassReflection = $classReflection->getParentClass(); 37 | 38 | // no parent class? let it go 39 | if (! $parentClassReflection instanceof ClassReflection) { 40 | return false; 41 | } 42 | 43 | return $parentClassReflection->hasConstructor(); 44 | } 45 | --------------------------------------------------------------------------------