├── .editorconfig ├── .gitattributes ├── .gitignore ├── .php_cs ├── LICENSE ├── Makefile ├── README.md ├── build.xml ├── composer.json ├── extension.neon ├── phpstan.neon ├── rules.neon └── src └── Taptima └── PHPStan └── Rules └── Properties └── EntityPropertyHasGetterAndSetterRule.php /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | 9 | [*.{php,phpt}] 10 | indent_style = tab 11 | indent_size = 4 12 | 13 | [*.xml] 14 | indent_style = tab 15 | indent_size = 4 16 | 17 | [*.neon] 18 | indent_style = tab 19 | indent_size = 4 20 | 21 | [*.{yaml,yml}] 22 | indent_style = space 23 | indent_size = 2 24 | 25 | [composer.json] 26 | indent_style = tab 27 | indent_size = 4 28 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /tests export-ignore 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .php_cs.cache 2 | vendor 3 | 4 | /bin/ 5 | .DS_Store 6 | .idea 7 | .env 8 | .php_cs.cache 9 | 10 | composer.lock 11 | 12 | *.sublime-project 13 | *.sublime-workspace 14 | -------------------------------------------------------------------------------- /.php_cs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/php 2 | files() 6 | ->name('*.php') 7 | ->in(__DIR__ . '/src') 8 | ->in(__DIR__ . '/tests') 9 | ; 10 | 11 | $config = PhpCsFixer\Config::create() 12 | ->setRules([ 13 | '@Symfony' => true, 14 | '@DoctrineAnnotation' => true, 15 | '@PhpCsFixer' => true, 16 | '@PHP71Migration' => true, 17 | 18 | 'blank_line_after_namespace' => true, 19 | 'single_import_per_statement' => true, 20 | 'single_line_after_imports' => true, 21 | 'no_unused_imports' => true, 22 | 23 | 'ordered_imports' => true, 24 | 'global_namespace_import' => true, 25 | 'ordered_class_elements' => [ 26 | 'order' => [ 27 | 'use_trait', 28 | 'constant', 29 | 'constant_public', 30 | 'constant_protected', 31 | 'constant_private', 32 | 'property_public_static', 33 | 'property_public', 34 | 'property_protected_static', 35 | 'property_protected', 36 | 'property_private_static', 37 | 'property_private', 38 | 'construct', 39 | 'destruct', 40 | 'magic', 41 | 'phpunit', 42 | 'method_public_static', 43 | 'method_public', 44 | 'method_protected_static', 45 | 'method_protected', 46 | 'method_private_static', 47 | 'method_private', 48 | ], 49 | ], 50 | 51 | 'method_chaining_indentation' => false, 52 | 53 | 'nullable_type_declaration_for_default_null_value' => [ 54 | 'use_nullable_type_declaration' => false, 55 | ], 56 | 57 | 'final_static_access' => true, 58 | 'self_static_accessor' => true, 59 | 60 | 'ternary_to_null_coalescing' => true, 61 | 'binary_operator_spaces' => [ 62 | 'default' => 'single_space', 63 | 'operators' => [ 64 | '=' => 'align_single_space_minimal', 65 | '+=' => 'align_single_space_minimal', 66 | '-=' => 'align_single_space_minimal', 67 | '/=' => 'align_single_space_minimal', 68 | '*=' => 'align_single_space_minimal', 69 | '%=' => 'align_single_space_minimal', 70 | '**=' => 'align_single_space_minimal', 71 | '=>' => 'align_single_space_minimal', 72 | ], 73 | ], 74 | 'yoda_style' => [ 75 | 'equal' => false, 76 | 'identical' => false, 77 | 'less_and_greater' => null, 78 | ], 79 | 'concat_space' => [ 80 | 'spacing' => 'one' 81 | ], 82 | 'array_syntax' => [ 83 | 'syntax' => 'short' 84 | ], 85 | 'braces' => true, 86 | 'elseif' => true, 87 | 'trim_array_spaces' => true, 88 | 89 | 'function_declaration' => true, 90 | 'no_spaces_after_function_name' => true, 91 | 'no_spaces_inside_parenthesis' => true, 92 | 93 | 'cast_spaces' => true, 94 | 'encoding' => true, 95 | 'full_opening_tag' => true, 96 | 'linebreak_after_opening_tag' => true, 97 | 'no_closing_tag' => true, 98 | 'indentation_type' => true, 99 | 'line_ending' => true, 100 | 'single_blank_line_at_eof' => false, 101 | 'no_trailing_whitespace' => true, 102 | 'lowercase_keywords' => true, 103 | 'no_whitespace_in_blank_line' => true, 104 | 'no_short_echo_tag' => true, 105 | 106 | 'doctrine_annotation_braces' => false, 107 | 'doctrine_annotation_array_assignment' => [ 108 | 'operator' => '=', 109 | ], 110 | 111 | 'align_multiline_comment' => [ 112 | 'comment_type' => 'all_multiline' 113 | ], 114 | 115 | 'no_superfluous_phpdoc_tags' => false, 116 | 'phpdoc_order' => true, 117 | 'phpdoc_separation' => true, 118 | 'phpdoc_var_without_name' => false, 119 | 'phpdoc_var_annotation_correct_order' => true, 120 | 'phpdoc_types_order' => [ 121 | 'sort_algorithm' => 'none', 122 | 'null_adjustment' => 'always_last', 123 | ], 124 | ]) 125 | ->setUsingCache(true) 126 | ->setFinder($finder) 127 | ; 128 | 129 | if (version_compare(PHP_VERSION, '7.1', '>=')) { 130 | $config->setRules(array_merge($config->getRules(), [ 131 | 'list_syntax' => [ 132 | 'syntax' => 'short', 133 | ], 134 | ])); 135 | } 136 | 137 | 138 | return $config; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) taptima 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | THIS_FILE := $(lastword $(MAKEFILE_LIST)) 2 | 3 | -include .env 4 | 5 | test: 6 | vendor/bin/phing tests 7 | 8 | cs: 9 | vendor/bin/phing cs-fix 10 | 11 | cs-dry-run: 12 | vendor/bin/phing cs 13 | 14 | c-inst: 15 | vendor/bin/phing composer 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Taptima](https://taptima.ru/) customs extensions for PHPStan 2 | 3 | [![Latest Stable Version](https://poser.pugx.org/taptima/phpstan-custom/v/stable)](https://packagist.org/packages/taptima/phpstan-custom) 4 | [![License](https://poser.pugx.org/taptima/phpstan-custom/license)](https://packagist.org/packages/taptima/phpstan-custom) 5 | 6 | * [PHPStan](https://phpstan.org/) 7 | 8 | This extension provides following features: 9 | 10 | * Validates common entity properties existence of methods `set*`, `get*`. 11 | * Validates boolean entity properties existence of methods `set*`, `is*` or `has*`. 12 | * Validates `ArrayCollection` entity properties existence of methods `add*`, `remove*` and `get*`. 13 | 14 | ## Installation 15 | Open a command console, enter your project directory and execute the following command to download the latest stable version of this extension: 16 | ```bash 17 | composer require --dev taptima/phpstan-custom dev-master 18 | ``` 19 | 20 | Then include extension.neon in your project's PHPStan config: 21 | 22 | ```neon 23 | includes: 24 | - vendor/taptima/phpstan-custom/extension.neon 25 | ``` 26 | 27 | and 28 | 29 | ```neon 30 | includes: 31 | - vendor/taptima/phpstan-custom/rules.neon 32 | ``` 33 | 34 | This extensions depends on [phpstan-doctrine](https://github.com/phpstan/phpstan-doctrine), so you have to configure it. 35 | 36 | ## Contribution 37 | 38 | Before to create a pull request to submit your contributon, you must: 39 | - run tests and be sure nothing is broken 40 | 41 | ### How to run tests 42 | 43 | ```bash 44 | make test 45 | ``` 46 | -------------------------------------------------------------------------------- /build.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 13 | 14 | 20 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "taptima/phpstan-custom", 3 | "type": "phpstan-extension", 4 | "description": "Taptima extensions for PHPStan", 5 | "keywords": [ 6 | "phpstan", 7 | "phpstan-rules", 8 | "Code Quality" 9 | ], 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Mark Tertishniy", 14 | "email": "mtertishniy@gmail.com" 15 | } 16 | ], 17 | "require": { 18 | "php": "^7.4 || ^8.0", 19 | "phpstan/phpstan": "^0.12.33", 20 | "phpstan/phpstan-doctrine": "^0.12" 21 | }, 22 | "conflict": { 23 | "doctrine/collections": "<1.0", 24 | "doctrine/common": "<2.7", 25 | "doctrine/orm": "<2.5" 26 | }, 27 | "require-dev": { 28 | "doctrine/collections": "^1.0", 29 | "doctrine/common": "^2.7", 30 | "doctrine/orm": "^2.5", 31 | "ergebnis/composer-normalize": "^2.0.2", 32 | "friendsofphp/php-cs-fixer": "^2.18", 33 | "jakub-onderka/php-parallel-lint": "^1.0", 34 | "phing/phing": "^2.16.0", 35 | "phpstan/phpstan-phpunit": "^0.12", 36 | "phpstan/phpstan-strict-rules": "^0.12", 37 | "phpunit/phpunit": "^7.0" 38 | }, 39 | "config": { 40 | "sort-packages": true 41 | }, 42 | "extra": { 43 | "branch-alias": { 44 | "dev-master": "1.x-dev" 45 | }, 46 | "phpstan": { 47 | "includes": [ 48 | "extension.neon", 49 | "rules.neon" 50 | ] 51 | } 52 | }, 53 | "autoload": { 54 | "psr-4": { 55 | "Taptima\\PHPStan\\": "src/Taptima/PHPStan" 56 | } 57 | }, 58 | "autoload-dev": { 59 | "classmap": [ 60 | "tests/" 61 | ] 62 | }, 63 | "minimum-stability": "dev", 64 | "prefer-stable": true 65 | } 66 | -------------------------------------------------------------------------------- /extension.neon: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taptima/phpstan-custom/077f0a9abb487145064670e735a3620e0edd484e/extension.neon -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - rules.neon 3 | - extension.neon 4 | - vendor/phpstan/phpstan-strict-rules/rules.neon 5 | - vendor/phpstan/phpstan-phpunit/extension.neon 6 | - vendor/phpstan/phpstan-phpunit/rules.neon 7 | - vendor/phpstan/phpstan-doctrine/extension.neon 8 | - vendor/phpstan/phpstan-doctrine/rules.neon 9 | - phar://phpstan.phar/conf/bleedingEdge.neon 10 | 11 | parameters: 12 | excludes_analyse: 13 | - tests/*/data/* 14 | -------------------------------------------------------------------------------- /rules.neon: -------------------------------------------------------------------------------- 1 | rules: 2 | - Taptima\PHPStan\Rules\Properties\EntityPropertyHasGetterAndSetterRule 3 | -------------------------------------------------------------------------------- /src/Taptima/PHPStan/Rules/Properties/EntityPropertyHasGetterAndSetterRule.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | final class EntityPropertyHasGetterAndSetterRule implements Rule 25 | { 26 | /** 27 | * @var \PHPStan\Type\Doctrine\ObjectMetadataResolver 28 | */ 29 | private $objectMetadataResolver; 30 | 31 | /** 32 | * EntityPropertyHasGetterAndSetterRule constructor. 33 | * 34 | * @param ObjectMetadataResolver $objectMetadataResolver 35 | */ 36 | public function __construct(ObjectMetadataResolver $objectMetadataResolver) 37 | { 38 | $this->objectMetadataResolver = $objectMetadataResolver; 39 | } 40 | 41 | /** 42 | * {@inheritdoc} 43 | */ 44 | public function getNodeType(): string 45 | { 46 | return \PhpParser\Node\Stmt\PropertyProperty::class; 47 | } 48 | 49 | /** 50 | * {@inheritdoc} 51 | * 52 | * @throws ShouldNotHappenException 53 | */ 54 | public function processNode(Node $node, Scope $scope): array 55 | { 56 | $class = $scope->getClassReflection(); 57 | if ($class === null) { 58 | return []; 59 | } 60 | 61 | $objectManager = $this->objectMetadataResolver->getObjectManager(); 62 | if ($objectManager === null) { 63 | return []; 64 | } 65 | 66 | $className = $class->getName(); 67 | if ($objectManager->getMetadataFactory()->isTransient($className)) { 68 | return []; 69 | } 70 | 71 | try { 72 | $metadata = $objectManager->getClassMetadata($className); 73 | } catch (Throwable $e) { 74 | return []; 75 | } 76 | 77 | if (!$metadata instanceof ClassMetadataInfo) { 78 | return []; 79 | } 80 | 81 | $propertyName = (string) $node->name; 82 | 83 | try { 84 | $class->getNativeProperty($propertyName); 85 | } catch (MissingPropertyFromReflectionException $e) { 86 | return []; 87 | } 88 | 89 | if (!isset($metadata->fieldMappings[$propertyName]) && !isset($metadata->associationMappings[$propertyName])) { 90 | return []; 91 | } 92 | 93 | $messages = []; 94 | 95 | if (isset($metadata->fieldMappings[$propertyName])) { 96 | $messages = array_merge($messages, $this->checkCommonProperty($metadata, $class, $metadata->fieldMappings[$propertyName], $propertyName)); 97 | } 98 | 99 | if (isset($metadata->associationMappings[$propertyName])) { 100 | $messages = array_merge($messages, $this->checkAssociationProperty($class, $metadata->associationMappings[$propertyName], $propertyName)); 101 | } 102 | 103 | return $messages; 104 | } 105 | 106 | /** 107 | * @param ClassMetadataInfo $metadata 108 | * @param ClassReflection $class 109 | * @param array $fieldMapping 110 | * @param string $propertyName 111 | * 112 | * @throws ShouldNotHappenException 113 | * 114 | * @return array 115 | * @phpstan-ignore-next-line 116 | */ 117 | private function checkCommonProperty(ClassMetadataInfo $metadata, ClassReflection $class, array $fieldMapping, $propertyName) 118 | { 119 | $messages = []; 120 | 121 | $setter = sprintf('set%s', ucfirst($propertyName)); 122 | $isser = sprintf('is%s', ucfirst($propertyName)); 123 | $hasser = sprintf('has%s', ucfirst($propertyName)); 124 | $getter = sprintf('get%s', ucfirst($propertyName)); 125 | 126 | if (!$class->hasMethod($setter) && !$metadata->isIdentifier($propertyName)) { 127 | $messages[] = $this->buildError($class, $propertyName, sprintf('must have a setter "%s"', $setter)); 128 | } 129 | 130 | if ($fieldMapping['type'] !== Type::BOOLEAN) { 131 | if (!$class->hasMethod($getter)) { 132 | $messages[] = $this->buildError($class, $propertyName, sprintf('must have a getter "%s"', $getter)); 133 | } 134 | 135 | return $messages; 136 | } 137 | 138 | $result = false; 139 | $methods = [$isser, $hasser]; 140 | foreach ($methods as $method) { 141 | $result = $result || $class->hasMethod($method); 142 | } 143 | 144 | $methodsStr = implode(', ', array_map(static function ($el): string { 145 | return sprintf('"%s"', $el); 146 | }, $methods)); 147 | 148 | if (!$result) { 149 | $messages[] = $this->buildError($class, $propertyName, sprintf('must have one of the following methods %s', $methodsStr)); 150 | } 151 | 152 | if ($class->hasMethod($getter)) { 153 | $messages[] = $this->buildError($class, $propertyName, sprintf('type of boolean must no have a getter "%s". Instead, it should have one of the following methods %s', $getter, $methodsStr)); 154 | } 155 | 156 | return $messages; 157 | } 158 | 159 | /** 160 | * @param ClassReflection $class 161 | * @param array $associationMapping 162 | * @param string $propertyName 163 | * 164 | * @throws ShouldNotHappenException 165 | * 166 | * @return array 167 | * @phpstan-ignore-next-line 168 | */ 169 | private function checkAssociationProperty(ClassReflection $class, array $associationMapping, $propertyName) 170 | { 171 | $messages = []; 172 | 173 | $getter = sprintf('get%s', ucfirst($propertyName)); 174 | if (!$class->hasMethod($getter)) { 175 | $messages[] = $this->buildError($class, $propertyName, sprintf('must have a getter "%s"', $getter)); 176 | } 177 | 178 | if ($associationMapping['type'] !== ClassMetadata::ONE_TO_MANY && $associationMapping['type'] !== ClassMetadata::MANY_TO_MANY) { 179 | $setter = sprintf('set%s', ucfirst($propertyName)); 180 | if (!$class->hasMethod($getter)) { 181 | $messages[] = $this->buildError($class, $propertyName, sprintf('must have a setter "%s"', $setter)); 182 | } 183 | 184 | return $messages; 185 | } 186 | 187 | $adders = []; 188 | $removers = []; 189 | $hasAdder = false; 190 | $hasRemover = false; 191 | $singulars = (array) Inflector::singularize($propertyName); 192 | foreach ($singulars as $singular) { 193 | $adder = sprintf('add%s', ucfirst($singular)); 194 | $remover = sprintf('remove%s', ucfirst($singular)); 195 | 196 | $hasAdder = $hasAdder || $class->hasMethod($adder); 197 | $hasRemover = $hasRemover || $class->hasMethod($remover); 198 | 199 | $adders[] = $adder; 200 | $removers[] = $remover; 201 | } 202 | 203 | if (!$hasAdder) { 204 | $messages[] = $this->buildError($class, $propertyName, sprintf('must have an adder. E.g. %s', implode(', ', array_map(static function ($el): string { 205 | return sprintf('"%s"', $el); 206 | }, $adders)))); 207 | } 208 | 209 | if (!$hasRemover) { 210 | $messages[] = $this->buildError($class, $propertyName, sprintf('must have an remover. E.g. %s', implode(', ', array_map(static function ($el): string { 211 | return sprintf('"%s"', $el); 212 | }, $removers)))); 213 | } 214 | 215 | return $messages; 216 | } 217 | 218 | /** 219 | * @param ClassReflection $class 220 | * @param string $propertyName 221 | * @param string $message 222 | * 223 | * @throws ShouldNotHappenException 224 | * 225 | * @return \PHPStan\Rules\RuleError 226 | */ 227 | private function buildError(ClassReflection $class, $propertyName, $message) 228 | { 229 | return RuleErrorBuilder::message(\sprintf('Property %s::$%s %s.', $class->getDisplayName(), $propertyName, $message))->build(); 230 | } 231 | } 232 | --------------------------------------------------------------------------------