├── .github └── workflows │ └── ci.yml ├── .semver ├── .travis.yml ├── LICENSE.txt ├── README.md ├── composer.json ├── extension.neon ├── phpcs.xml ├── phpstan-test-app.neon ├── phpstan-test-plugin.neon ├── rules.neon └── src ├── Constraint └── ArrayOfStringStartsWith.php ├── Method ├── AssociationTableMixinClassReflectionExtension.php ├── DummyParameter.php └── TableFindByPropertyMethodReflection.php ├── PhpDoc └── TableAssociationTypeNodeResolverExtension.php ├── Rule ├── Controller │ └── LoadComponentExistsClassRule.php ├── LoadObjectExistsCakeClassRule.php ├── Mailer │ └── GetMailerExistsClassRule.php ├── Model │ ├── AddAssociationExistsTableClassRule.php │ ├── AddAssociationMatchOptionsTypesRule.php │ ├── AddBehaviorExistsClassRule.php │ ├── DisallowEntityArrayAccessRule.php │ ├── OrmSelectQueryFindMatchOptionsTypesRule.php │ └── TableGetMatchOptionsTypesRule.php └── Traits │ └── ParseClassNameFromArgTrait.php ├── Testing ├── AnalyseCheckLineStartsWithTrait.php └── CustomRuleTestCase.php ├── Traits ├── BaseCakeRegistryReturnTrait.php ├── IsFromTargetTrait.php └── RepositoryReferenceTrait.php ├── Type ├── BaseTraitExpressionTypeResolverExtension.php ├── ComponentLoadDynamicReturnTypeExtension.php ├── ConsoleHelperLoadDynamicReturnTypeExtension.php ├── ControllerFetchTableDynamicReturnTypeExtension.php ├── RepositoryEntityDynamicReturnTypeExtension.php ├── RepositoryFirstArgIsTheReturnTypeExtension.php └── TableLocatorDynamicReturnTypeExtension.php ├── Utility └── CakeNameRegistry.php └── Visitor └── AddAssociationSetClassNameVisitor.php /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'master' 7 | - '3.next-cake5' 8 | pull_request: 9 | branches: 10 | - '*' 11 | workflow_dispatch: 12 | 13 | permissions: 14 | contents: read # to fetch code (actions/checkout) 15 | 16 | jobs: 17 | testsuite-linux: 18 | runs-on: ubuntu-22.04 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | php-version: ['8.1', '8.2', '8.3'] 23 | dependencies: ['highest'] 24 | include: 25 | - php-version: '8.1' 26 | dependencies: 'lowest' 27 | - php-version: '8.2' 28 | dependencies: 'highest' 29 | - php-version: '8.3' 30 | dependencies: 'highest' 31 | 32 | steps: 33 | - uses: actions/checkout@v3 34 | 35 | - name: Setup PHP 36 | uses: shivammathur/setup-php@v2 37 | with: 38 | php-version: ${{ matrix.php-version }} 39 | extensions: mbstring, intl 40 | ini-values: zend.assertions=1 41 | tools: cs2pr 42 | 43 | - name: Composer install 44 | uses: ramsey/composer-install@v2 45 | with: 46 | dependency-versions: ${{ matrix.dependencies }} 47 | composer-options: ${{ matrix.composer-options }} 48 | 49 | - name: Run PHPUnit 50 | run: vendor/bin/phpunit 51 | 52 | cs-stan: 53 | name: Coding Standard & Static Analysis 54 | runs-on: ubuntu-22.04 55 | 56 | steps: 57 | - uses: actions/checkout@v3 58 | 59 | - name: Setup PHP 60 | uses: shivammathur/setup-php@v2 61 | with: 62 | php-version: '8.2' 63 | extensions: mbstring, intl 64 | coverage: none 65 | tools: phive, cs2pr 66 | 67 | - name: Composer Install 68 | uses: ramsey/composer-install@v2 69 | 70 | - name: Run phpcs 71 | run: vendor/bin/phpcs --report=checkstyle src/ tests/ | cs2pr 72 | 73 | - name: Run phpstan 74 | if: always() 75 | run: vendor/bin/phpstan analyse --debug --error-format=github src/ 76 | 77 | - name: Run phpstan integration test app 78 | if: always() 79 | run: vendor/bin/phpstan analyse --debug -c phpstan-test-app.neon --error-format=github tests/test_app/ 80 | 81 | - name: Run phpstan integration test plugin 82 | if: always() 83 | run: vendor/bin/phpstan analyse --debug -c phpstan-test-plugin.neon --error-format=github tests/test_plugin/ 84 | -------------------------------------------------------------------------------- /.semver: -------------------------------------------------------------------------------- 1 | --- 2 | :major: 4 3 | :minor: 0 4 | :patch: 0 5 | :special: '' 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | dist: xenial 4 | 5 | php: 6 | - 7.2 7 | - 7.3 8 | 9 | sudo: false 10 | 11 | cache: 12 | directories: 13 | - vendor 14 | - $HOME/.composer/cache 15 | 16 | env: 17 | global: 18 | - DEFAULT=1 19 | 20 | matrix: 21 | fast_finish: true 22 | 23 | include: 24 | - php: 7.3 25 | env: PHPCS=1 DEFAULT=0 26 | 27 | - php: 7.3 28 | env: PHPSTAN=1 DEFAULT=0 29 | 30 | - php: 7.3 31 | env: COVERAGE=1 DEFAULT=0' 32 | 33 | before_script: 34 | - composer install --prefer-dist --no-interaction 35 | 36 | script: 37 | - if [[ $DEFAULT = 1 ]]; then composer test; fi 38 | - if [[ $DEFAULT = 1 ]]; then composer stan-integration; fi 39 | - if [[ $PHPCS = 1 ]]; then composer cs-check; fi 40 | - if [[ $PHPSTAN = 1 ]]; then composer stan; fi 41 | 42 | notifications: 43 | email: false 44 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright 2009-2024 4 | Cake Development Corporation 5 | 1785 E. Sahara Avenue, Suite 490-423 6 | Las Vegas, Nevada 89104 7 | Phone: +1 702 425 5085 8 | https://www.cakedc.com 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining a 11 | copy of this software and associated documentation files (the "Software"), 12 | to deal in the Software without restriction, including without limitation 13 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 14 | and/or sell copies of the Software, and to permit persons to whom the 15 | Software is furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in 18 | all copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 25 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 26 | DEALINGS IN THE SOFTWARE. 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CakePHP extension for PHPStan 2 | 3 | ![Build Status](https://github.com/CakeDC/cakephp-phpstan/actions/workflows/ci.yml/badge.svg) 4 | [![Downloads](https://poser.pugx.org/CakeDC/cakephp-phpstan/d/total.svg)](https://packagist.org/packages/CakeDC/cakephp-phpstan) 5 | [![Latest Version](https://poser.pugx.org/CakeDC/cakephp-phpstan/v/stable.svg)](https://packagist.org/packages/CakeDC/cakephp-phpstan) 6 | [![License](https://poser.pugx.org/CakeDC/cakephp-phpstan/license.svg)](LICENSE.txt) 7 | 8 | * [PHPStan](https://phpstan.org/) 9 | * [CakePHP](https://cakephp.org/) 10 | 11 | Provide services and rules for a better PHPStan analyze on CakePHP applications, includes services to resolve types (Table, Helpers, Behaviors, etc) 12 | and multiple rules. 13 | 14 | # Installation 15 | 16 | To use this extension, require it through [Composer](https://getcomposer.org/): 17 | 18 | ``` 19 | composer require --dev cakedc/cakephp-phpstan 20 | ``` 21 | 22 | 23 | If you also install [phpstan/extension-installer](https://github.com/phpstan/extension-installer), then you're all set! 24 | 25 |
26 | Manual installation 27 | 28 | If you don't want to use `phpstan/extension-installer`, include `extension.neon` in your project's PHPStan config: 29 | ``` 30 | includes: 31 | - vendor/cakedc/cakephp-phpstan/extension.neon 32 | ``` 33 | 34 |
35 | 36 | 37 | # General class load|fetch extensions 38 | Features included: 39 | 1. Provide correct return type for `Cake\ORM\Locator\LocatorInterface::get()` 40 | 1. Provide correct return type for `Cake\Controller\Controller::loadComponent()` 41 | 1. Provide correct return type for `Cake\Controller\Controller::fetchTable()` 42 | 1. Provide correct return type for `Cake\Controller\Component::fetchTable()` 43 | 1. Provide correct return type for `Cake\Command\Command::fetchTable()` 44 | 1. Provide correct return type for `Cake\Mailer\Mailer::fetchTable()` 45 | 1. Provide correct return type for `Cake\View\Cell::fetchTable()` 46 | 1. Provide correct return type for `Cake\Console\ConsoleIo::helper()` 47 | 48 | # Table class return type extensions 49 | ### TableEntityDynamicReturnTypeExtension 50 | 1. Provide correct return type for `Cake\ORM\Table::get` based on your table class name 51 | 1. Provide correct return type for `Cake\ORM\Table::newEntity` based on your table class name 52 | 1. Provide correct return type for `Cake\ORM\Table::newEntities` based on your table class name 53 | 1. Provide correct return type for `Cake\ORM\Table::newEmptyEntity` based on your table class name 54 | 1. Provide correct return type for `Cake\ORM\Table::findOrCreate` based on your table class name 55 | 56 |
57 | Examples: 58 | 59 | ```php 60 | //Now PHPStan know that \App\Models\Table\NotesTable::get returns \App\Model\Entity\Note 61 | $note = $this->Notes->get(1); 62 | $note->note = 'My new note';//No error 63 | 64 | //Now PHPStan know that \App\Models\Table\NotesTable::newEntity returns \App\Model\Entity\Note 65 | $note = $this->Notes->newEntity($data); 66 | $note->note = 'My new note new entity';//No error 67 | 68 | //Now PHPStan know that \App\Models\Table\NotesTable::newEmptyEntity returns \App\Model\Entity\Note 69 | $note = $this->Notes->newEmptyEntity($data); 70 | $note->note = 'My new note new empty entity';//No error 71 | 72 | //Now PHPStan know that \App\Models\Table\NotesTable::findOrCreate returns \App\Model\Entity\Note 73 | $note = $this->Notes->findOrCreate($data); 74 | $note->note = 'My entity found or created';//No error 75 | 76 | //Now PHPStan know that \App\Models\Table\NotesTable::newEntities returns \App\Model\Entity\Note[] 77 | $notes = $this->Notes->newEntities($data); 78 | foreach ($notes as $note) { 79 | $note->note = 'My new note';//No error 80 | } 81 | ``` 82 |
83 | 84 | ### TableFirstArgIsTheReturnTypeExtension 85 | 1. Provide correct return type for `Cake\ORM\Table::patchEntity` based on the first argument passed 86 | 1. Provide correct return type for `Cake\ORM\Table::patchEntities` based on the first argument passed 87 | 1. Provide correct return type for `Cake\ORM\Table::save` based on the first argument passed 88 | 1. Provide correct return type for `Cake\ORM\Table::saveOrFail` based on the first argument passed 89 | 1. Provide correct return type for `Cake\ORM\Table::saveMany` based on the first argument passed 90 | 1. Provide correct return type for `Cake\ORM\Table::saveManyOrFail` based on the first argument passed 91 | 1. Provide correct return type for `Cake\ORM\Table::deleteMany` based on the first argument passed 92 | 1. Provide correct return type for `Cake\ORM\Table::deleteManyOrFail` based on the first argument passed 93 | 1. Provide correct return type for `Cake\ORM\Locator\LocatorAwareTrait::fetchTable` based on the first argument passed 94 | 1. Provide correct return type for `Cake\Mailer\MailerAwareTrait::getMailer` based on the first argument passed 95 | 96 |
97 | Examples: 98 | 99 | ```php 100 | //Now PHPStan know that \App\Models\Table\NotesTable::get returns \App\Model\Entity\Note 101 | $note = $this->Notes->get(1); 102 | $notes = $this->Notes->newEntities($data); 103 | 104 | //Since PHPStan knows the type of $note, these methods call use the same type as return type: 105 | $note = $this->Notes->patchEntity($note, $data); 106 | $text = $note->note;//No error. 107 | 108 | $note = $this->Notes->save($note); 109 | $text = $note->note;//No error. 110 | 111 | $note = $this->Notes->saveOrFail($note); 112 | $text = $note->note;//No error. 113 | //Since PHPStan knows the type of $notes, these methods call use the same type as return type: 114 | $notes = $this->Notes->patchEntities($notes); 115 | $notes = $this->Notes->saveMany($notes); 116 | $notes = $this->Notes->saveManyOrFail($notes); 117 | $notes = $this->Notes->deleteMany($notes); 118 | $notes = $this->Notes->deleteManyOrFail($notes); 119 | ``` 120 |
121 | 122 | # Rules 123 | All rules provided by this library are included in [rules.neon](rules.neon) and are enabled by default: 124 | 125 | ### AddAssociationExistsTableClassRule 126 | This rule check if the target association has a valid table class when calling to Table::belongsTo, 127 | Table::hasMany, Table::belongsToMany, Table::hasOne and AssociationCollection::load. 128 | 129 | ### AddAssociationMatchOptionsTypesRule 130 | This rule check if association options are valid option types based on what each class expects. This cover calls to Table::belongsTo, 131 | Table::hasMany, Table::belongsToMany, Table::hasOne and AssociationCollection::load. 132 | 133 | ### AddBehaviorExistsClassRule 134 | This rule check if the target behavior has a valid class when calling to Table::addBehavior and BehaviorRegistry::load. 135 | 136 | ### DisallowEntityArrayAccessRule 137 | This rule disallow array access to entity in favor of object notation, is easier to detect a wrong property and to refactor code. 138 | 139 | ### GetMailerExistsClassRule 140 | This rule check if the target mailer is a valid class when calling to Cake\Mailer\MailerAwareTrait::getMailer. 141 | 142 | ### LoadComponentExistsClassRule 143 | This rule check if the target component has a valid class when calling to Controller::loadComponent and ComponentRegistry::load. 144 | 145 | ### OrmSelectQueryFindMatchOptionsTypesRule 146 | This rule check if the options (args) passed to Table::find and SelectQuery are valid find options types. 147 | 148 | ### TableGetMatchOptionsTypesRule 149 | This rule check if the options (args) passed to Table::get are valid find options types. 150 | 151 | To enable this rule update your phpstan.neon with: 152 | 153 | ``` 154 | parameters: 155 | cakeDC: 156 | disallowEntityArrayAccessRule: true 157 | ``` 158 | 159 | ### How to disable a rule 160 | Each rule has a parameter in cakeDC 'namespace' to enable or disable, it is the same name of the 161 | rule with first letter in lowercase. 162 | For example to disable the rule AddAssociationExistsTableClassRule you should have 163 | ``` 164 | parameters: 165 | cakeDC: 166 | addAssociationExistsTableClassRule: false 167 | ``` 168 | 169 | # PHPDoc Extensions 170 | ### TableAssociationTypeNodeResolverExtension 171 | Fix intersection association phpDoc to correct generic object type, ex: 172 | 173 | Change `\Cake\ORM\Association\BelongsTo&\App\Model\Table\UsersTable` to `\Cake\ORM\Association\BelongsTo<\App\Model\Table\UsersTable>` 174 | 175 | 176 | ### Tips 177 | To make your life easier make sure to have `@mixin` and `@method` annotations in your table classes. 178 | The `@mixin` annotation will help phpstan know you are using methods from behavior, and `@method` annotations 179 | will allow it to know the correct return types for methods like `Table::get()`, `Table::newEntity()`. 180 | 181 | You can easily update annotations with the plugin [IdeHelper](https://github.com/dereuromark/cakephp-ide-helper). 182 | 183 | Support 184 | ------- 185 | 186 | For bugs and feature requests, please use the [issues](https://github.com/CakeDC/cakephp-phpstan/issues) section of this repository. 187 | 188 | Commercial support is also available, [contact us](https://www.cakedc.com/contact) for more information. 189 | 190 | Contributing 191 | ------------ 192 | 193 | If you'd like to contribute new features, enhancements or bug fixes to the plugin, please read our [Contribution Guidelines](https://www.cakedc.com/contribution-guidelines) for detailed instructions. 194 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cakedc/cakephp-phpstan", 3 | "description": "CakePHP plugin extension for PHPStan.", 4 | "type": "phpstan-extension", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "CakeDC", 9 | "homepage": "https://www.cakedc.com", 10 | "role": "Author" 11 | } 12 | ], 13 | "require": { 14 | "php": ">=8.1.0", 15 | "phpstan/phpstan": "^2.0", 16 | "cakephp/cakephp": "^5.0" 17 | }, 18 | "require-dev": { 19 | "phpstan/phpstan-phpunit": "^2.0", 20 | "phpunit/phpunit": "^10.1", 21 | "cakephp/cakephp-codesniffer": "^5.0", 22 | "phpstan/phpstan-deprecation-rules": "^2.0", 23 | "phpstan/phpstan-strict-rules": "^2.0" 24 | }, 25 | "extra": { 26 | "phpstan": { 27 | "includes": [ 28 | "extension.neon" 29 | ] 30 | } 31 | }, 32 | "autoload": { 33 | "psr-4": { 34 | "CakeDC\\PHPStan\\": "src/" 35 | } 36 | }, 37 | "autoload-dev": { 38 | "psr-4": { 39 | "CakeDC\\PHPStan\\Test\\": "tests/", 40 | "CakeDC\\MyPlugin\\": "tests/test_plugin", 41 | "App\\": "tests/test_app" 42 | } 43 | }, 44 | "scripts": { 45 | "cs-check": "phpcs -p src/ tests", 46 | "cs-fix": "phpcbf -p src/ tests", 47 | "test": "phpunit --stderr", 48 | "stan-integration": [ 49 | "phpstan analyse --debug -c phpstan-test-app.neon", 50 | "phpstan analyse --debug -c phpstan-test-plugin.neon" 51 | ], 52 | "stan": "phpstan analyse --debug", 53 | "check": [ 54 | "@cs-check", 55 | "@stan", 56 | "@test", 57 | "@stan-integration" 58 | ] 59 | }, 60 | "config": { 61 | "allow-plugins": { 62 | "dealerdirect/phpcodesniffer-composer-installer": true 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /extension.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - rules.neon 3 | services: 4 | - 5 | class: CakeDC\PHPStan\Method\AssociationTableMixinClassReflectionExtension 6 | tags: 7 | - phpstan.broker.methodsClassReflectionExtension 8 | - phpstan.broker.propertiesClassReflectionExtension 9 | - 10 | factory: CakeDC\PHPStan\Type\TableLocatorDynamicReturnTypeExtension(Cake\ORM\Locator\LocatorAwareTrait, fetchTable) 11 | tags: 12 | - phpstan.broker.dynamicMethodReturnTypeExtension 13 | - 14 | factory: CakeDC\PHPStan\Type\ControllerFetchTableDynamicReturnTypeExtension(Cake\Controller\Controller, fetchTable) 15 | tags: 16 | - phpstan.broker.dynamicMethodReturnTypeExtension 17 | - 18 | factory: CakeDC\PHPStan\Type\TableLocatorDynamicReturnTypeExtension(Cake\ORM\Locator\LocatorInterface, get) 19 | tags: 20 | - phpstan.broker.dynamicMethodReturnTypeExtension 21 | - 22 | factory: CakeDC\PHPStan\Type\RepositoryEntityDynamicReturnTypeExtension(Cake\ORM\Table) 23 | tags: 24 | - phpstan.broker.dynamicMethodReturnTypeExtension 25 | - 26 | factory: CakeDC\PHPStan\Type\RepositoryEntityDynamicReturnTypeExtension(Cake\ORM\Association) 27 | tags: 28 | - phpstan.broker.dynamicMethodReturnTypeExtension 29 | - 30 | factory: CakeDC\PHPStan\Type\RepositoryFirstArgIsTheReturnTypeExtension(Cake\ORM\Table) 31 | tags: 32 | - phpstan.broker.dynamicMethodReturnTypeExtension 33 | - 34 | factory: CakeDC\PHPStan\Type\RepositoryFirstArgIsTheReturnTypeExtension(Cake\ORM\Association) 35 | tags: 36 | - phpstan.broker.dynamicMethodReturnTypeExtension 37 | - 38 | class: CakeDC\PHPStan\Type\ComponentLoadDynamicReturnTypeExtension 39 | tags: 40 | - phpstan.broker.dynamicMethodReturnTypeExtension 41 | - 42 | class: CakeDC\PHPStan\Type\ConsoleHelperLoadDynamicReturnTypeExtension 43 | tags: 44 | - phpstan.broker.dynamicMethodReturnTypeExtension 45 | - 46 | class: CakeDC\PHPStan\PhpDoc\TableAssociationTypeNodeResolverExtension 47 | tags: 48 | - phpstan.phpDoc.typeNodeResolverExtension 49 | - 50 | factory: CakeDC\PHPStan\Type\BaseTraitExpressionTypeResolverExtension(Cake\Mailer\MailerAwareTrait, getMailer, %s\Mailer\%sMailer) 51 | tags: 52 | - phpstan.broker.expressionTypeResolverExtension 53 | - 54 | factory: CakeDC\PHPStan\Type\BaseTraitExpressionTypeResolverExtension(Cake\ORM\Locator\LocatorAwareTrait, fetchTable, %s\Model\Table\%sTable, defaultTable) 55 | tags: 56 | - phpstan.broker.expressionTypeResolverExtension 57 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /phpstan-test-app.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - extension.neon 3 | - vendor/phpstan/phpstan-deprecation-rules/rules.neon 4 | parameters: 5 | level: max 6 | paths: 7 | - tests/test_app 8 | treatPhpDocTypesAsCertain: false 9 | cakeDC: 10 | disallowEntityArrayAccessRule: true 11 | ignoreErrors: 12 | - 13 | identifier: missingType.generics 14 | -------------------------------------------------------------------------------- /phpstan-test-plugin.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - extension.neon 3 | - vendor/phpstan/phpstan-deprecation-rules/rules.neon 4 | parameters: 5 | level: max 6 | paths: 7 | - tests/test_plugin 8 | treatPhpDocTypesAsCertain: false 9 | cakeDC: 10 | disallowEntityArrayAccessRule: true 11 | -------------------------------------------------------------------------------- /rules.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | cakeDC: 3 | addAssociationExistsTableClassRule: true 4 | addAssociationMatchOptionsTypesRule: true 5 | addBehaviorExistsClassRule: true 6 | tableGetMatchOptionsTypesRule: true 7 | ormSelectQueryFindMatchOptionsTypesRule: true 8 | disallowEntityArrayAccessRule: false 9 | getMailerExistsClassRule: true 10 | loadComponentExistsClassRule: true 11 | parametersSchema: 12 | cakeDC: structure([ 13 | addAssociationExistsTableClassRule: anyOf(bool(), arrayOf(bool())) 14 | addAssociationMatchOptionsTypesRule: anyOf(bool(), arrayOf(bool())) 15 | addBehaviorExistsClassRule: anyOf(bool(), arrayOf(bool())) 16 | tableGetMatchOptionsTypesRule: anyOf(bool(), arrayOf(bool())) 17 | ormSelectQueryFindMatchOptionsTypesRule: anyOf(bool(), arrayOf(bool())) 18 | disallowEntityArrayAccessRule: anyOf(bool(), arrayOf(bool())) 19 | getMailerExistsClassRule: anyOf(bool(), arrayOf(bool())) 20 | loadComponentExistsClassRule: anyOf(bool(), arrayOf(bool())) 21 | ]) 22 | 23 | conditionalTags: 24 | CakeDC\PHPStan\Visitor\AddAssociationSetClassNameVisitor: 25 | phpstan.parser.richParserNodeVisitor: %cakeDC.addAssociationExistsTableClassRule% 26 | CakeDC\PHPStan\Rule\Controller\LoadComponentExistsClassRule: 27 | phpstan.rules.rule: %cakeDC.loadComponentExistsClassRule% 28 | CakeDC\PHPStan\Rule\Model\AddAssociationExistsTableClassRule: 29 | phpstan.rules.rule: %cakeDC.addAssociationExistsTableClassRule% 30 | CakeDC\PHPStan\Rule\Model\AddAssociationMatchOptionsTypesRule: 31 | phpstan.rules.rule: %cakeDC.addAssociationMatchOptionsTypesRule% 32 | CakeDC\PHPStan\Rule\Model\AddBehaviorExistsClassRule: 33 | phpstan.rules.rule: %cakeDC.addBehaviorExistsClassRule% 34 | CakeDC\PHPStan\Rule\Model\DisallowEntityArrayAccessRule: 35 | phpstan.rules.rule: %cakeDC.disallowEntityArrayAccessRule% 36 | CakeDC\PHPStan\Rule\Mailer\GetMailerExistsClassRule: 37 | phpstan.rules.rule: %cakeDC.getMailerExistsClassRule% 38 | CakeDC\PHPStan\Rule\Model\TableGetMatchOptionsTypesRule: 39 | phpstan.rules.rule: %cakeDC.tableGetMatchOptionsTypesRule% 40 | CakeDC\PHPStan\Rule\Model\OrmSelectQueryFindMatchOptionsTypesRule: 41 | phpstan.rules.rule: %cakeDC.ormSelectQueryFindMatchOptionsTypesRule% 42 | 43 | services: 44 | - 45 | class: CakeDC\PHPStan\Visitor\AddAssociationSetClassNameVisitor 46 | - 47 | class: CakeDC\PHPStan\Rule\Controller\LoadComponentExistsClassRule 48 | - 49 | class: CakeDC\PHPStan\Rule\Model\AddAssociationExistsTableClassRule 50 | - 51 | class: CakeDC\PHPStan\Rule\Model\AddAssociationMatchOptionsTypesRule 52 | - 53 | class: CakeDC\PHPStan\Rule\Model\AddBehaviorExistsClassRule 54 | - 55 | class: CakeDC\PHPStan\Rule\Model\DisallowEntityArrayAccessRule 56 | - 57 | class: CakeDC\PHPStan\Rule\Mailer\GetMailerExistsClassRule 58 | - 59 | class: CakeDC\PHPStan\Rule\Model\TableGetMatchOptionsTypesRule 60 | - 61 | class: CakeDC\PHPStan\Rule\Model\OrmSelectQueryFindMatchOptionsTypesRule 62 | -------------------------------------------------------------------------------- /src/Constraint/ArrayOfStringStartsWith.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | private readonly array $actual; 14 | /** 15 | * @var array 16 | */ 17 | private array $result = []; 18 | /** 19 | * @var array 20 | */ 21 | private array $notExpected = []; 22 | 23 | /** 24 | * @param array $actual 25 | */ 26 | public function __construct(array $actual) 27 | { 28 | $this->actual = $actual; 29 | } 30 | 31 | /** 32 | * @return string 33 | */ 34 | public function toString(): string 35 | { 36 | return 'a list of errors'; 37 | } 38 | 39 | /** 40 | * @param mixed $other 41 | * @return bool 42 | */ 43 | protected function matches(mixed $other): bool 44 | { 45 | $result = true; 46 | $this->notExpected = $this->actual; 47 | assert(is_array($other)); 48 | foreach ($other as $key => $error) { 49 | $error = is_string($error) ? $error : 'Wrong error: ' . json_encode($error); 50 | if (!isset($this->actual[$key])) { 51 | $this->result[$key] = ['expected' => $error, 'type' => 'missing', 'actual' => null]; 52 | $result = false; 53 | continue; 54 | } 55 | unset($this->notExpected[$key]); 56 | if (!str_starts_with($this->actual[$key], $error)) { 57 | $this->result[$key] = ['expected' => $error, 'type' => 'not-equal', 'actual' => $this->actual[$key]]; 58 | $result = false; 59 | } 60 | } 61 | 62 | return $result && $this->notExpected === []; 63 | } 64 | 65 | /** 66 | * @param mixed $other 67 | * @return string 68 | */ 69 | protected function failureDescription(mixed $other): string 70 | { 71 | $text = "\n"; 72 | foreach ($this->result as $item) { 73 | if ($item['type'] === 'not-equal') { 74 | $text .= sprintf(" -%s \n +%s \n", $item['expected'], $item['actual']); 75 | } 76 | if ($item['type'] === 'missing') { 77 | $text .= sprintf(" -%s \n", $item['expected']); 78 | } 79 | } 80 | 81 | foreach ($this->notExpected as $item) { 82 | $text .= sprintf(" \n +%s", $item); 83 | } 84 | 85 | return $text; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Method/AssociationTableMixinClassReflectionExtension.php: -------------------------------------------------------------------------------- 1 | reflectionProvider = $reflectionProvider; 34 | } 35 | 36 | /** 37 | * @return \PHPStan\Reflection\ClassReflection 38 | */ 39 | protected function getTableReflection(): ClassReflection 40 | { 41 | return $this->reflectionProvider->getClass(Table::class); 42 | } 43 | 44 | /** 45 | * @param \PHPStan\Reflection\ClassReflection $classReflection Class reflection 46 | * @param string $methodName Method name 47 | * @return bool 48 | */ 49 | public function hasMethod(ClassReflection $classReflection, string $methodName): bool 50 | { 51 | // magic findBy* method 52 | if ($classReflection->isSubclassOf(Table::class) && preg_match('/^find(?:\w+)?By/', $methodName) > 0) { 53 | return true; 54 | } 55 | 56 | if (!$classReflection->isSubclassOf(Association::class)) { 57 | return false; 58 | } 59 | 60 | return $this->getTableReflection()->hasMethod($methodName); 61 | } 62 | 63 | /** 64 | * @param \PHPStan\Reflection\ClassReflection $classReflection Class reflection 65 | * @param string $methodName Method name 66 | * @return \PHPStan\Reflection\MethodReflection 67 | */ 68 | public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection 69 | { 70 | // magic findBy* method 71 | if ($classReflection->isSubclassOf(Table::class) && preg_match('/^find(?:\w+)?By/', $methodName) > 0) { 72 | return new TableFindByPropertyMethodReflection($methodName, $classReflection); 73 | } 74 | 75 | return $this->getTableReflection()->getNativeMethod($methodName); 76 | } 77 | 78 | /** 79 | * @param \PHPStan\Reflection\ClassReflection $classReflection Class reflection 80 | * @param string $propertyName Method name 81 | * @return bool 82 | */ 83 | public function hasProperty(ClassReflection $classReflection, string $propertyName): bool 84 | { 85 | if (!$classReflection->isSubclassOf(Association::class)) { 86 | return false; 87 | } 88 | 89 | return $this->getTableReflection()->hasProperty($propertyName); 90 | } 91 | 92 | /** 93 | * @param \PHPStan\Reflection\ClassReflection $classReflection Class reflection 94 | * @param string $propertyName Method name 95 | * @return \PHPStan\Reflection\PropertyReflection 96 | */ 97 | public function getProperty(ClassReflection $classReflection, string $propertyName): PropertyReflection 98 | { 99 | return $this->getTableReflection()->getNativeProperty($propertyName); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Method/DummyParameter.php: -------------------------------------------------------------------------------- 1 | name = $name; 54 | $this->type = $type; 55 | $this->optional = $optional; 56 | $this->variadic = $variadic; 57 | $this->defaultValue = $defaultValue; 58 | $this->passedByReference = $passedByReference ?? PassedByReference::createNo(); 59 | } 60 | 61 | /** 62 | * @return string 63 | */ 64 | public function getName(): string 65 | { 66 | return $this->name; 67 | } 68 | 69 | /** 70 | * @return bool 71 | */ 72 | public function isOptional(): bool 73 | { 74 | return $this->optional; 75 | } 76 | 77 | /** 78 | * @return \PHPStan\Type\Type 79 | */ 80 | public function getType(): Type 81 | { 82 | return $this->type; 83 | } 84 | 85 | /** 86 | * @return \PHPStan\Reflection\PassedByReference 87 | */ 88 | public function passedByReference(): PassedByReference 89 | { 90 | return $this->passedByReference; 91 | } 92 | 93 | /** 94 | * @return bool 95 | */ 96 | public function isVariadic(): bool 97 | { 98 | return $this->variadic; 99 | } 100 | 101 | /** 102 | * @return \PHPStan\Type\Type|null 103 | */ 104 | public function getDefaultValue(): ?Type 105 | { 106 | return $this->defaultValue; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/Method/TableFindByPropertyMethodReflection.php: -------------------------------------------------------------------------------- 1 | 36 | */ 37 | private array $variants; 38 | 39 | /** 40 | * @param string $name 41 | * @param \PHPStan\Reflection\ClassReflection $declaringClass 42 | */ 43 | public function __construct(string $name, ClassReflection $declaringClass) 44 | { 45 | $this->name = $name; 46 | 47 | $this->declaringClass = $declaringClass; 48 | $params = array_map(fn ($field) => new DummyParameter( 49 | $field, 50 | new MixedType(), 51 | false, 52 | null, 53 | false, 54 | null 55 | ), $this->getParams($name)); 56 | 57 | $returnType = new ObjectType(SelectQuery::class); 58 | 59 | $this->variants = [ 60 | new FunctionVariant( 61 | TemplateTypeMap::createEmpty(), 62 | null, 63 | $params, 64 | false, 65 | $returnType 66 | ), 67 | ]; 68 | } 69 | 70 | /** 71 | * @return \PHPStan\Reflection\ClassReflection 72 | */ 73 | public function getDeclaringClass(): ClassReflection 74 | { 75 | return $this->declaringClass; 76 | } 77 | 78 | /** 79 | * @return \PHPStan\Reflection\ClassMemberReflection 80 | */ 81 | public function getPrototype(): ClassMemberReflection 82 | { 83 | return $this; 84 | } 85 | 86 | /** 87 | * @return bool 88 | */ 89 | public function isStatic(): bool 90 | { 91 | return false; 92 | } 93 | 94 | /** 95 | * @return array<\PHPStan\Reflection\ParameterReflection> 96 | */ 97 | public function getParameters(): array 98 | { 99 | return []; 100 | } 101 | 102 | /** 103 | * @return bool 104 | */ 105 | public function isVariadic(): bool 106 | { 107 | return false; 108 | } 109 | 110 | /** 111 | * @return bool 112 | */ 113 | public function isPrivate(): bool 114 | { 115 | return false; 116 | } 117 | 118 | /** 119 | * @return bool 120 | */ 121 | public function isPublic(): bool 122 | { 123 | return true; 124 | } 125 | 126 | /** 127 | * @return string 128 | */ 129 | public function getName(): string 130 | { 131 | return $this->name; 132 | } 133 | 134 | /** 135 | * @return \PHPStan\Type\ObjectType 136 | */ 137 | public function getReturnType(): ObjectType 138 | { 139 | return new ObjectType('\Cake\ORM\Query\SelectQuery'); 140 | } 141 | 142 | /** 143 | * @return string|null 144 | */ 145 | public function getDocComment(): ?string 146 | { 147 | return null; 148 | } 149 | 150 | /** 151 | * @inheritDoc 152 | */ 153 | public function getVariants(): array 154 | { 155 | return $this->variants; 156 | } 157 | 158 | /** 159 | * @return \PHPStan\TrinaryLogic 160 | */ 161 | public function isDeprecated(): TrinaryLogic 162 | { 163 | return TrinaryLogic::createNo(); 164 | } 165 | 166 | /** 167 | * @return string|null 168 | */ 169 | public function getDeprecatedDescription(): ?string 170 | { 171 | return null; 172 | } 173 | 174 | /** 175 | * @return \PHPStan\TrinaryLogic 176 | */ 177 | public function isFinal(): TrinaryLogic 178 | { 179 | return TrinaryLogic::createNo(); 180 | } 181 | 182 | /** 183 | * @return \PHPStan\TrinaryLogic 184 | */ 185 | public function isInternal(): TrinaryLogic 186 | { 187 | return TrinaryLogic::createNo(); 188 | } 189 | 190 | /** 191 | * @return \PHPStan\Type\Type|null 192 | */ 193 | public function getThrowType(): ?Type 194 | { 195 | return null; 196 | } 197 | 198 | /** 199 | * @return \PHPStan\TrinaryLogic 200 | */ 201 | public function hasSideEffects(): TrinaryLogic 202 | { 203 | return TrinaryLogic::createNo(); 204 | } 205 | 206 | /** 207 | * @param string $method 208 | * @return list 209 | */ 210 | protected function getParams(string $method): array 211 | { 212 | $method = Inflector::underscore($method); 213 | $fields = substr($method, 8); 214 | if (str_contains($fields, '_and_')) { 215 | return explode('_and_', $fields); 216 | } 217 | 218 | if (str_contains($fields, '_or_')) { 219 | return explode('_or_', $fields); 220 | } 221 | 222 | return [$fields]; 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/PhpDoc/TableAssociationTypeNodeResolverExtension.php: -------------------------------------------------------------------------------- 1 | ` 24 | * 25 | * The type `\Cake\ORM\Association\BelongsTo&\App\Model\Table\UsersTable` is considered invalid (NeverType) by PHPStan 26 | */ 27 | class TableAssociationTypeNodeResolverExtension implements TypeNodeResolverExtension, TypeNodeResolverAwareExtension 28 | { 29 | private TypeNodeResolver $typeNodeResolver; 30 | 31 | /** 32 | * @var array 33 | */ 34 | protected array $associationTypes = [ 35 | BelongsTo::class, 36 | BelongsToMany::class, 37 | HasMany::class, 38 | HasOne::class, 39 | Association::class, 40 | ]; 41 | 42 | /** 43 | * @param \PHPStan\PhpDoc\TypeNodeResolver $typeNodeResolver 44 | * @return void 45 | */ 46 | public function setTypeNodeResolver(TypeNodeResolver $typeNodeResolver): void 47 | { 48 | $this->typeNodeResolver = $typeNodeResolver; 49 | } 50 | 51 | /** 52 | * @param \PHPStan\PhpDocParser\Ast\Type\TypeNode $typeNode 53 | * @param \PHPStan\Analyser\NameScope $nameScope 54 | * @return \PHPStan\Type\Type|null 55 | */ 56 | public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type 57 | { 58 | if (!$typeNode instanceof IntersectionTypeNode) { 59 | return null; 60 | } 61 | $types = $this->typeNodeResolver->resolveMultiple($typeNode->types, $nameScope); 62 | $config = [ 63 | 'association' => null, 64 | 'table' => null, 65 | ]; 66 | foreach ($types as $type) { 67 | if (!$type->isObject()->yes()) { 68 | continue; 69 | } 70 | $className = $type->getObjectClassNames()[0] ?? null; 71 | if ($className === null) { 72 | continue; 73 | } 74 | if ($config['association'] === null && in_array($className, $this->associationTypes, true)) { 75 | $config['association'] = $type; 76 | } elseif ($config['table'] === null && str_ends_with($className, 'Table')) { 77 | $config['table'] = $type; 78 | } 79 | } 80 | if ($config['table'] !== null && $config['association'] !== null) { 81 | return new GenericObjectType( 82 | $config['association']->getObjectClassNames()[0], 83 | [$config['table']] 84 | ); 85 | } 86 | 87 | return null; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Rule/Controller/LoadComponentExistsClassRule.php: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | protected array $sourceMethods = [ 31 | 'loadComponent', 32 | ]; 33 | 34 | /** 35 | * @var array 36 | */ 37 | protected array $componentRegistryMethods = [ 38 | 'load', 39 | ]; 40 | 41 | /** 42 | * @inheritDoc 43 | */ 44 | protected function getTargetClassName(string $name): ?string 45 | { 46 | return CakeNameRegistry::getComponentClassName($name); 47 | } 48 | 49 | /** 50 | * @inheritDoc 51 | */ 52 | protected function getDetails(string $reference, array $args): ?array 53 | { 54 | if (str_ends_with($reference, 'Controller')) { 55 | return [ 56 | 'alias' => $args[0] ?? null, 57 | 'options' => $args[1] ?? null, 58 | 'sourceMethods' => $this->sourceMethods, 59 | ]; 60 | } 61 | if ($reference === ComponentRegistry::class) { 62 | return [ 63 | 'alias' => $args[0] ?? null, 64 | 'options' => $args[1] ?? null, 65 | 'sourceMethods' => $this->componentRegistryMethods, 66 | ]; 67 | } 68 | 69 | return null; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Rule/LoadObjectExistsCakeClassRule.php: -------------------------------------------------------------------------------- 1 | 47 | */ 48 | public function processNode(Node $node, Scope $scope): array 49 | { 50 | assert($node instanceof MethodCall); 51 | $args = $node->getArgs(); 52 | if (!$node->name instanceof Node\Identifier) { 53 | return []; 54 | } 55 | $reference = $scope->getType($node->var)->getReferencedClasses()[0] ?? null; 56 | if ($reference === null) { 57 | return []; 58 | } 59 | $details = $this->getDetails($reference, $args); 60 | 61 | if ( 62 | $details === null 63 | || !in_array($node->name->name, $details['sourceMethods'], true) 64 | || !$details['alias'] instanceof Arg 65 | || !$details['alias']->value instanceof String_ 66 | ) { 67 | return []; 68 | } 69 | 70 | $inputClassName = $this->getInputClassNameFromNode($node); 71 | if ($inputClassName === null) { 72 | $inputClassName = $this->getInputClassName( 73 | $details['alias']->value, 74 | $details['options'] 75 | ); 76 | } 77 | if ($inputClassName === null || $this->getTargetClassName($inputClassName) !== null) { 78 | return []; 79 | } 80 | 81 | return [ 82 | RuleErrorBuilder::message(sprintf( 83 | 'Call to %s::%s could not find the class for "%s"', 84 | $reference, 85 | $node->name->name, 86 | $inputClassName, 87 | )) 88 | ->identifier($this->identifier) 89 | ->build(), 90 | ]; 91 | } 92 | 93 | /** 94 | * @param \PhpParser\Node\Scalar\String_ $nameArg 95 | * @param \PhpParser\Node\Arg|null $options 96 | * @return string|null 97 | */ 98 | protected function getInputClassName(String_ $nameArg, ?Arg $options): ?string 99 | { 100 | $className = $nameArg->value; 101 | if ( 102 | $options === null 103 | || !$options->value instanceof Node\Expr\Array_ 104 | ) { 105 | return $className; 106 | } 107 | foreach ($options->value->items as $item) { 108 | if ( 109 | !$item instanceof Node\Expr\ArrayItem 110 | || !$item->key instanceof String_ 111 | || $item->key->value !== 'className' 112 | ) { 113 | continue; 114 | } 115 | if ($item->value instanceof ConstFetch && $item->value->name->toString() === 'null') { 116 | return $className; 117 | } 118 | 119 | return $this->parseClassNameFromExprTrait($item->value); 120 | } 121 | 122 | return $className; 123 | } 124 | 125 | /** 126 | * @param string $name 127 | * @return string|null 128 | */ 129 | abstract protected function getTargetClassName(string $name): ?string; 130 | 131 | /** 132 | * @param string $reference 133 | * @param array<\PhpParser\Node\Arg> $args 134 | * @return array{'alias': ?\PhpParser\Node\Arg, 'options': ?\PhpParser\Node\Arg, 'sourceMethods':array}|null 135 | */ 136 | abstract protected function getDetails(string $reference, array $args): ?array; 137 | 138 | /** 139 | * @param \PhpParser\Node\Expr\MethodCall $node 140 | * @return string|null 141 | */ 142 | protected function getInputClassNameFromNode(MethodCall $node): ?string 143 | { 144 | return null; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/Rule/Mailer/GetMailerExistsClassRule.php: -------------------------------------------------------------------------------- 1 | 44 | */ 45 | public function processNode(Node $node, Scope $scope): array 46 | { 47 | assert($node instanceof MethodCall); 48 | if ( 49 | !$node->name instanceof Node\Identifier 50 | || $node->name->name !== 'getMailer' 51 | ) { 52 | return []; 53 | } 54 | 55 | $args = $node->getArgs(); 56 | if (!isset($args[0])) { 57 | return []; 58 | } 59 | $value = $args[0]->value; 60 | if (!$value instanceof String_) { 61 | return []; 62 | } 63 | $callerType = $scope->getType($node->var); 64 | if (!$callerType instanceof ThisType) { 65 | return []; 66 | } 67 | $reflection = $callerType->getClassReflection(); 68 | 69 | if (CakeNameRegistry::getMailerClassName($value->value) !== null) { 70 | return []; 71 | } 72 | 73 | return [ 74 | RuleErrorBuilder::message(sprintf( 75 | 'Call to %s::%s could not find the class for "%s"', 76 | $reflection->getName(), 77 | $node->name->name, 78 | $value->value, 79 | )) 80 | ->identifier($this->identifier) 81 | ->build(), 82 | ]; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Rule/Model/AddAssociationExistsTableClassRule.php: -------------------------------------------------------------------------------- 1 | 32 | */ 33 | protected array $tableSourceMethods = [ 34 | 'belongsTo', 35 | 'belongsToMany', 36 | 'hasMany', 37 | 'hasOne', 38 | ]; 39 | 40 | /** 41 | * @var array 42 | */ 43 | protected array $associationCollectionMethods = ['load']; 44 | 45 | /** 46 | * @inheritDoc 47 | */ 48 | protected function getTargetClassName(string $name): ?string 49 | { 50 | return CakeNameRegistry::getTableClassName($name); 51 | } 52 | 53 | /** 54 | * @inheritDoc 55 | */ 56 | protected function getDetails(string $reference, array $args): ?array 57 | { 58 | if (str_ends_with($reference, 'Table')) { 59 | return [ 60 | 'alias' => $args[0] ?? null, 61 | 'options' => $args[1] ?? null, 62 | 'sourceMethods' => $this->tableSourceMethods, 63 | ]; 64 | } 65 | if ($reference === AssociationCollection::class) { 66 | return [ 67 | 'alias' => $args[1] ?? null, 68 | 'options' => $args[2] ?? null, 69 | 'sourceMethods' => $this->associationCollectionMethods, 70 | ]; 71 | } 72 | 73 | return null; 74 | } 75 | 76 | /** 77 | * @inheritDoc 78 | */ 79 | protected function getInputClassNameFromNode(MethodCall $node): ?string 80 | { 81 | $setClassNameValue = $node->getAttribute(AddAssociationSetClassNameVisitor::ATTRIBUTE_NAME); 82 | if ($setClassNameValue instanceof Expr) { 83 | return $this->parseClassNameFromExprTrait($setClassNameValue); 84 | } 85 | 86 | return null; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Rule/Model/AddAssociationMatchOptionsTypesRule.php: -------------------------------------------------------------------------------- 1 | ruleLevelHelper = $ruleLevelHelper; 41 | } 42 | 43 | /** 44 | * @var array 45 | */ 46 | protected array $tableSourceMethods = [ 47 | 'belongsTo' => BelongsTo::class, 48 | 'belongsToMany' => BelongsToMany::class, 49 | 'hasMany' => HasMany::class, 50 | 'hasOne' => HasOne::class, 51 | ]; 52 | 53 | /** 54 | * @inheritDoc 55 | */ 56 | public function getNodeType(): string 57 | { 58 | return MethodCall::class; 59 | } 60 | 61 | /** 62 | * @inheritDoc 63 | */ 64 | public function processNode(Node $node, Scope $scope): array 65 | { 66 | assert($node instanceof MethodCall); 67 | $args = $node->getArgs(); 68 | if (!$node->name instanceof Node\Identifier) { 69 | return []; 70 | } 71 | $reference = $scope->getType($node->var)->getReferencedClasses()[0] ?? null; 72 | if ($reference === null) { 73 | return []; 74 | } 75 | $details = $this->getDetails($reference, $node->name->name, $args); 76 | if ($details === null || $details['type'] === null) { 77 | return []; 78 | } 79 | if ( 80 | $details['options'] === null 81 | || !$details['options']->value instanceof Node\Expr\Array_ 82 | ) { 83 | return []; 84 | } 85 | $properties = $this->getPropertiesTypeCheck($details['type']); 86 | $errors = []; 87 | foreach ($details['options']->value->items as $item) { 88 | if ( 89 | !$item instanceof ArrayItem 90 | || !$item->key instanceof String_ 91 | ) { 92 | continue; 93 | } 94 | if (isset($properties[$item->key->value])) { 95 | $error = $this->processPropertyTypeCheck( 96 | $details, 97 | $properties[$item->key->value], 98 | $item, 99 | $scope 100 | ); 101 | if ($error !== null) { 102 | $errors[] = $error; 103 | } 104 | } else { 105 | $errors[] = RuleErrorBuilder::message(sprintf( 106 | 'Call to %s::%s with unknown option "%s".', 107 | $reference, 108 | $node->name->name, 109 | $item->key->value 110 | )) 111 | ->identifier('cake.addAssociationWithValidOption.unknownOption') 112 | ->build(); 113 | } 114 | } 115 | 116 | return $errors; 117 | } 118 | 119 | /** 120 | * @param string $reference 121 | * @param string $methodName 122 | * @param array<\PhpParser\Node\Arg> $args 123 | * @return array{'alias': ?string, 'options': ?\PhpParser\Node\Arg, 'type': ?string, 'reference':string, 'methodName':string}|null 124 | */ 125 | protected function getDetails(string $reference, string $methodName, array $args): ?array 126 | { 127 | if (str_ends_with($reference, 'Table')) { 128 | return [ 129 | 'alias' => isset($args[0]) ? $this->parseClassNameFromExprTrait($args[0]->value) : null, 130 | 'options' => $args[1] ?? null, 131 | 'type' => $this->tableSourceMethods[$methodName] ?? null, 132 | 'reference' => $reference, 133 | 'methodName' => $methodName, 134 | ]; 135 | } 136 | if ( 137 | $reference === AssociationCollection::class 138 | && $methodName === 'load' 139 | && isset($args[0]) 140 | && $args[0] instanceof Arg 141 | ) { 142 | return [ 143 | 'alias' => isset($args[1]) ? $this->parseClassNameFromExprTrait($args[1]->value) : null, 144 | 'options' => $args[2] ?? null, 145 | 'type' => $this->parseClassNameFromExprTrait($args[0]->value), 146 | 'reference' => $reference, 147 | 'methodName' => $methodName, 148 | ]; 149 | } 150 | 151 | return null; 152 | } 153 | 154 | /** 155 | * @param array{'alias': ?string, 'options': ?\PhpParser\Node\Arg, 'type': string, 'reference':string, 'methodName':string} $details 156 | * @param string $property 157 | * @param \PhpParser\Node\ArrayItem $item 158 | * @param \PHPStan\Analyser\Scope $scope 159 | * @return \PHPStan\Rules\IdentifierRuleError|null 160 | * @throws \PHPStan\Reflection\MissingPropertyFromReflectionException 161 | * @throws \PHPStan\ShouldNotHappenException 162 | */ 163 | protected function processPropertyTypeCheck( 164 | array $details, 165 | string $property, 166 | ArrayItem $item, 167 | Scope $scope 168 | ): ?IdentifierRuleError { 169 | $object = new ObjectType($details['type']); 170 | $classReflection = $object->getClassReflection(); 171 | assert($classReflection instanceof ClassReflection); 172 | $propertyType = $classReflection 173 | ->getProperty('_' . $property, $scope) 174 | ->getWritableType(); 175 | $assignedValueType = $scope->getType($item->value); 176 | $accepts = $this->ruleLevelHelper->accepts($propertyType, $assignedValueType, true); 177 | if ($accepts->result) { 178 | return null; 179 | } 180 | assert($item->key instanceof String_); 181 | $propertyDescription = sprintf( 182 | 'Call to %s::%s with option "%s"', 183 | $details['reference'], 184 | $details['methodName'], 185 | $item->key->value 186 | ); 187 | $verbosityLevel = VerbosityLevel::getRecommendedLevelByType($propertyType, $assignedValueType); 188 | 189 | return RuleErrorBuilder::message( 190 | sprintf( 191 | '%s (%s) does not accept %s.', 192 | $propertyDescription, 193 | $propertyType->describe($verbosityLevel), 194 | $assignedValueType->describe($verbosityLevel) 195 | ) 196 | ) 197 | ->acceptsReasonsTip($accepts->reasons) 198 | ->identifier('cake.addAssociationWithValidOption.invalidType') 199 | ->build(); 200 | } 201 | 202 | /** 203 | * @param string $type 204 | * @return array 205 | */ 206 | protected function getPropertiesTypeCheck(string $type): array 207 | { 208 | $properties = [ 209 | 'cascadeCallbacks', 210 | 'className', 211 | 'conditions', 212 | 'dependent', 213 | 'finder', 214 | 'bindingKey', 215 | 'foreignKey', 216 | 'joinType', 217 | 'tableLocator', 218 | 'propertyName', 219 | 'sourceTable', 220 | 'targetTable', 221 | 'strategy', 222 | ]; 223 | $properties = array_combine($properties, $properties); 224 | if ($type === BelongsToMany::class) { 225 | $properties['targetForeignKey'] = 'targetForeignKey'; 226 | $properties['through'] = 'through'; 227 | $properties['joinTable'] = 'junctionTableName'; 228 | } 229 | if ($type === HasMany::class || $type === BelongsToMany::class) { 230 | $properties['saveStrategy'] = 'saveStrategy'; 231 | $properties['sort'] = 'sort'; 232 | } 233 | 234 | return $properties; 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /src/Rule/Model/AddBehaviorExistsClassRule.php: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | protected array $tableSourceMethods = [ 31 | 'addBehavior', 32 | ]; 33 | 34 | /** 35 | * @var array 36 | */ 37 | protected array $behaviorRegistryMethods = [ 38 | 'load', 39 | ]; 40 | 41 | /** 42 | * @inheritDoc 43 | */ 44 | protected function getTargetClassName(string $name): ?string 45 | { 46 | return CakeNameRegistry::getBehaviorClassName($name); 47 | } 48 | 49 | /** 50 | * @inheritDoc 51 | */ 52 | protected function getDetails(string $reference, array $args): ?array 53 | { 54 | if (str_ends_with($reference, 'Table')) { 55 | return [ 56 | 'alias' => $args[0] ?? null, 57 | 'options' => $args[1] ?? null, 58 | 'sourceMethods' => $this->tableSourceMethods, 59 | ]; 60 | } 61 | if ($reference === BehaviorRegistry::class) { 62 | return [ 63 | 'alias' => $args[0] ?? null, 64 | 'options' => $args[1] ?? null, 65 | 'sourceMethods' => $this->behaviorRegistryMethods, 66 | ]; 67 | } 68 | 69 | return null; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Rule/Model/DisallowEntityArrayAccessRule.php: -------------------------------------------------------------------------------- 1 | 27 | * @throws \PHPStan\ShouldNotHappenException 28 | */ 29 | public function processNode(Node $node, Scope $scope): array 30 | { 31 | assert($node instanceof ArrayDimFetch); 32 | $type = $scope->getType($node->var); 33 | if (!$type->isObject()->yes()) { 34 | return []; 35 | } 36 | $reflection = $type->getObjectClassReflections()[0] ?? null; 37 | if ($reflection === null || !$reflection->is(EntityInterface::class)) { 38 | return []; 39 | } 40 | 41 | return [ 42 | RuleErrorBuilder::message(sprintf( 43 | 'Array access to entity to %s is not allowed, access as object instead', 44 | $reflection->getName(), 45 | )) 46 | ->identifier('cake.entity.arrayAccess') 47 | ->build(), 48 | ]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Rule/Model/OrmSelectQueryFindMatchOptionsTypesRule.php: -------------------------------------------------------------------------------- 1 | 43 | */ 44 | protected array $targetMethods = ['find']; 45 | /** 46 | * @var array 47 | */ 48 | protected array $queryOptionsMap = [ 49 | 'select' => 'select', 50 | 'fields' => 'select', 51 | 'conditions' => 'where', 52 | 'where' => 'where', 53 | 'join' => 'join', 54 | 'order' => 'orderBy', 55 | 'orderBy' => 'orderBy', 56 | 'limit' => 'limit', 57 | 'offset' => 'offset', 58 | 'group' => 'groupBy', 59 | 'groupBy' => 'groupBy', 60 | 'having' => 'having', 61 | 'contain' => 'contain', 62 | 'page' => 'page', 63 | ]; 64 | /** 65 | * @var array 66 | */ 67 | protected array $associationTypes = [ 68 | BelongsTo::class, 69 | BelongsToMany::class, 70 | HasMany::class, 71 | HasOne::class, 72 | Association::class, 73 | ]; 74 | 75 | /** 76 | * @param \PHPStan\Rules\RuleLevelHelper $ruleLevelHelper 77 | */ 78 | public function __construct(RuleLevelHelper $ruleLevelHelper) 79 | { 80 | $this->ruleLevelHelper = $ruleLevelHelper; 81 | } 82 | 83 | /** 84 | * @inheritDoc 85 | */ 86 | public function getNodeType(): string 87 | { 88 | return MethodCall::class; 89 | } 90 | 91 | /** 92 | * @param \PhpParser\Node $node 93 | * @param \PHPStan\Analyser\Scope $scope 94 | * @return list<\PHPStan\Rules\IdentifierRuleError> 95 | * @throws \PHPStan\ShouldNotHappenException 96 | * @throws \PHPStan\Reflection\MissingMethodFromReflectionException 97 | */ 98 | public function processNode(Node $node, Scope $scope): array 99 | { 100 | assert($node instanceof MethodCall); 101 | $args = $node->getArgs(); 102 | if (!$node->name instanceof Node\Identifier || !in_array($node->name->name, $this->targetMethods, true)) { 103 | return []; 104 | } 105 | $referenceClasses = $scope->getType($node->var)->getReferencedClasses(); 106 | if ($referenceClasses === []) { 107 | return []; 108 | } 109 | try { 110 | $details = $this->getDetails($referenceClasses, $node->name->name, $args); 111 | } catch (InvalidArgumentException) { 112 | return []; 113 | } 114 | if ($details === null) { 115 | return []; 116 | } 117 | $specificFinderOptions = $this->getSpecificFinderOptions($details, $scope); 118 | if ($details['options'] === []) { 119 | return $this->checkMissingRequiredOptions($specificFinderOptions, $details, []); 120 | } 121 | $paramNamesIgnore = $this->getParamNamesIgnore($scope, $node); 122 | $errors = []; 123 | foreach ($details['options'] as $name => $item) { 124 | if (in_array($name, $paramNamesIgnore, true)) { 125 | continue; 126 | } 127 | $parameterType = $this->getExpectedType($name, $scope, $specificFinderOptions); 128 | $error = $parameterType !== null ? $this->processPropertyTypeCheck( 129 | $parameterType, 130 | $scope->getType($item), 131 | $details, 132 | $name 133 | ) : null; 134 | 135 | if ($error !== null) { 136 | if (!$error instanceof IdentifierRuleError) { 137 | throw new ShouldNotHappenException(sprintf( 138 | 'Expected error message to be instance of "%s", but got instance of "%s" instead.', 139 | IdentifierRuleError::class, 140 | $error::class, 141 | )); 142 | } 143 | $errors[] = $error; 144 | } 145 | } 146 | 147 | return $this->checkMissingRequiredOptions($specificFinderOptions, $details, $errors); 148 | } 149 | 150 | /** 151 | * @param array $referenceClasses 152 | * @param string $methodName 153 | * @param array<\PhpParser\Node\Arg> $args 154 | * @return array{'options': array<\PhpParser\Node\Expr>, 'reference':string, 'methodName':string, 'finder': string|null}|null 155 | */ 156 | protected function getDetails(array $referenceClasses, string $methodName, array $args): ?array 157 | { 158 | $reference = $this->getReference($referenceClasses); 159 | if ( 160 | str_ends_with($reference, 'Table') 161 | || $reference === SelectQuery::class 162 | || in_array($reference, $this->associationTypes, true) 163 | ) { 164 | $lastOptionPosition = 1; 165 | $finder = 'all'; 166 | if (isset($args[0]) && $args[0]->value instanceof String_) { 167 | $finder = $args[0]->value->value; 168 | } 169 | $options = $this->getOptions($args, $lastOptionPosition); 170 | 171 | return [ 172 | 'finder' => $finder, 173 | 'options' => $options, 174 | 'reference' => $reference, 175 | 'methodName' => $methodName, 176 | ]; 177 | } 178 | 179 | return null; 180 | } 181 | 182 | /** 183 | * @param \PHPStan\Type\Type $inputType 184 | * @param \PHPStan\Type\Type $assignedValueType 185 | * @param array{'reference':string, 'methodName':string} $details 186 | * @param string $property 187 | * @return \PHPStan\Rules\RuleError|null 188 | * @throws \PHPStan\ShouldNotHappenException 189 | */ 190 | protected function processPropertyTypeCheck( 191 | Type $inputType, 192 | Type $assignedValueType, 193 | array $details, 194 | string $property 195 | ): ?RuleError { 196 | $accepts = $this->ruleLevelHelper->accepts($inputType, $assignedValueType, true); 197 | 198 | if ($accepts->result) { 199 | return null; 200 | } 201 | $propertyDescription = sprintf( 202 | 'Call to %s::%s with option "%s"', 203 | $details['reference'], 204 | $details['methodName'], 205 | $property 206 | ); 207 | $verbosityLevel = VerbosityLevel::getRecommendedLevelByType($inputType, $assignedValueType); 208 | 209 | return RuleErrorBuilder::message( 210 | sprintf( 211 | '%s (%s) does not accept %s.', 212 | $propertyDescription, 213 | $inputType->describe($verbosityLevel), 214 | $assignedValueType->describe($verbosityLevel) 215 | ) 216 | ) 217 | ->acceptsReasonsTip($accepts->reasons) 218 | ->identifier('cake.tableGetMatchOptionsTypes.invalidType') 219 | ->build(); 220 | } 221 | 222 | /** 223 | * @param \PHPStan\Analyser\Scope $scope 224 | * @param string $targetMethod 225 | * @return \PHPStan\Reflection\ExtendedMethodReflection|null 226 | * @throws \PHPStan\Reflection\MissingMethodFromReflectionException 227 | */ 228 | protected function getTargetMethod(Scope $scope, string $targetMethod): ?ExtendedMethodReflection 229 | { 230 | $object = new ObjectType(SelectQuery::class); 231 | $classReflection = $object->getClassReflection(); 232 | 233 | return $classReflection?->getMethod($targetMethod, $scope); 234 | } 235 | 236 | /** 237 | * @param \PhpParser\Node\Arg $arg 238 | * @param array<\PhpParser\Node\Expr> $options 239 | * @return array<\PhpParser\Node\Expr> 240 | */ 241 | protected function extractOptionsUnpackedArray(Node\Arg $arg, array $options): array 242 | { 243 | if ($arg->value instanceof Array_ && $arg->unpack) { 244 | $options = $this->getOptionsFromArray($arg->value, $options); 245 | } 246 | 247 | return $options; 248 | } 249 | 250 | /** 251 | * @param array<\PhpParser\Node\Arg> $args 252 | * @return array<\PhpParser\Node\Expr> 253 | */ 254 | protected function getOptions(array $args, int $optionsArgPosition): array 255 | { 256 | $lastArgPos = $optionsArgPosition; 257 | $totalArgsMethod = $optionsArgPosition + 1; 258 | $options = []; 259 | if ( 260 | count($args) === $totalArgsMethod 261 | && $args[$lastArgPos]->value instanceof Array_ 262 | && $args[$lastArgPos]->name === null 263 | && $args[$lastArgPos]->unpack !== true 264 | ) { 265 | return $this->getOptionsFromArray($args[$lastArgPos]->value, $options); 266 | } 267 | foreach ($args as $arg) { 268 | if ($arg->name !== null) { 269 | $options[$arg->name->name] = $arg->value; 270 | } 271 | $options = $this->extractOptionsUnpackedArray($arg, $options); 272 | } 273 | 274 | return $options; 275 | } 276 | 277 | /** 278 | * @param \PhpParser\Node\Expr\Array_ $source 279 | * @param array<\PhpParser\Node\Expr> $options 280 | * @return array<\PhpParser\Node\Expr> 281 | */ 282 | protected function getOptionsFromArray(Array_ $source, array $options): array 283 | { 284 | foreach ($source->items as $item) { 285 | if (isset($item->key) && $item->key instanceof String_) { 286 | $options[$item->key->value] = $item->value; 287 | } else { 288 | throw new InvalidArgumentException('Rule is ignored because one option key is not string'); 289 | } 290 | } 291 | 292 | return $options; 293 | } 294 | 295 | /** 296 | * @param \PHPStan\Analyser\Scope $scope 297 | * @param \PhpParser\Node\Expr\MethodCall $node 298 | * @return array 299 | */ 300 | protected function getParamNamesIgnore(Scope $scope, MethodCall $node): array 301 | { 302 | assert($node->name instanceof Node\Identifier); 303 | $methodReflection = $scope->getMethodReflection($scope->getType($node->var), $node->name->toString()); 304 | if ($methodReflection === null) { 305 | return []; 306 | } 307 | 308 | $parameters = $methodReflection 309 | ->getVariants()[0]->getParameters(); 310 | $names = []; 311 | foreach ($parameters as $parameter) { 312 | if (!$parameter->isVariadic()) { 313 | $names[] = $parameter->getName(); 314 | } 315 | } 316 | 317 | return $names; 318 | } 319 | 320 | /** 321 | * @param array $referenceClasses 322 | * @return string 323 | */ 324 | protected function getReference(array $referenceClasses): string 325 | { 326 | $reference = $referenceClasses[0]; 327 | //Is association generic? ex: Cake\ORM\Association\BelongsTo 328 | if (isset($referenceClasses[1]) && in_array($reference, $this->associationTypes, true)) { 329 | $reference = $referenceClasses[1]; 330 | } 331 | 332 | return $reference; 333 | } 334 | 335 | /** 336 | * @param array{'options': array<\PhpParser\Node\Expr>, 'reference':string, 'methodName':string, 'finder': string|null} $details 337 | * @param \PHPStan\Analyser\Scope $scope 338 | * @return array 339 | */ 340 | protected function getSpecificFinderOptions(array $details, Scope $scope): array 341 | { 342 | $specificFinderOptions = []; 343 | $finder = $details['finder']; 344 | if ($finder === null || $finder === 'all') { 345 | return []; 346 | } 347 | $tableClass = $details['reference']; 348 | if (!str_ends_with($details['reference'], 'Table')) { 349 | $tableClass = Table::class; 350 | } 351 | $object = new ObjectType($tableClass); 352 | $finderMethod = 'find' . $finder; 353 | if (!$object->hasMethod($finderMethod)->yes()) { 354 | return []; 355 | } 356 | $method = $object->getMethod($finderMethod, $scope); 357 | $parameters = $method->getVariants()[0]->getParameters(); 358 | 359 | if (!isset($parameters[1])) { 360 | return []; 361 | } 362 | if (count($parameters) === 2) { 363 | //Backward compatibility with CakePHP 4 finder structure, findSomething($query, array $options) 364 | $secondParam = $parameters[1]; 365 | $paramType = $secondParam->getType(); 366 | if ( 367 | $secondParam->getName() === 'options' 368 | && !$secondParam->isVariadic() 369 | && ($paramType instanceof MixedType || $paramType->isArray()->yes()) 370 | ) { 371 | return []; 372 | } 373 | } 374 | foreach ($parameters as $key => $param) { 375 | if ($key === 0 || $param->isVariadic()) { 376 | continue; 377 | } 378 | 379 | $specificFinderOptions[$param->getName()] = $param; 380 | } 381 | 382 | return $specificFinderOptions; 383 | } 384 | 385 | /** 386 | * @param string|int $name 387 | * @param \PHPStan\Analyser\Scope $scope 388 | * @param array $specificFinderOptions 389 | * @return \PHPStan\Type\Type|null 390 | * @throws \PHPStan\Reflection\MissingMethodFromReflectionException 391 | */ 392 | protected function getExpectedType(int|string $name, Scope $scope, array $specificFinderOptions): ?Type 393 | { 394 | if (isset($this->queryOptionsMap[$name])) { 395 | $methodReflection = $this->getTargetMethod($scope, $this->queryOptionsMap[$name]); 396 | if ($methodReflection === null) { 397 | return null; 398 | } 399 | $parameter = $methodReflection->getVariants()[0]->getParameters()[0]; 400 | 401 | return $parameter->getType(); 402 | } 403 | 404 | if (isset($specificFinderOptions[$name])) { 405 | return $specificFinderOptions[$name]->getType(); 406 | } 407 | 408 | return null; 409 | } 410 | 411 | /** 412 | * @param array $specificFinderOptions 413 | * @param array{'options': array<\PhpParser\Node\Expr>, 'reference':string, 'methodName':string, 'finder': string|null} $details 414 | * @param list<\PHPStan\Rules\IdentifierRuleError> $errors 415 | * @return list<\PHPStan\Rules\IdentifierRuleError> 416 | * @throws \PHPStan\ShouldNotHappenException 417 | */ 418 | protected function checkMissingRequiredOptions(array $specificFinderOptions, array $details, array $errors): array 419 | { 420 | foreach ($specificFinderOptions as $requireOptionName => $parameter) { 421 | if (!$parameter->isOptional() && !array_key_exists($requireOptionName, $details['options'])) { 422 | $errorMessage = sprintf( 423 | 'Call to %s::%s is missing required finder option "%s".', 424 | $details['reference'], 425 | $details['methodName'], 426 | $requireOptionName 427 | ); 428 | $errors[] = RuleErrorBuilder::message($errorMessage) 429 | ->identifier('cake.tableGetMatchOptionsTypes.invalidType') 430 | ->build(); 431 | } 432 | } 433 | 434 | return $errors; 435 | } 436 | } 437 | -------------------------------------------------------------------------------- /src/Rule/Model/TableGetMatchOptionsTypesRule.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | protected array $targetMethods = ['get']; 12 | 13 | /** 14 | * @inheritDoc 15 | */ 16 | protected function getDetails(array $referenceClasses, string $methodName, array $args): ?array 17 | { 18 | $reference = $this->getReference($referenceClasses); 19 | if (str_ends_with($reference, 'Table') || in_array($reference, $this->associationTypes, true)) { 20 | $lastOptionPosition = 4; 21 | $options = $this->getOptions($args, $lastOptionPosition); 22 | 23 | return [ 24 | 'finder' => null, 25 | 'options' => $options, 26 | 'reference' => $reference, 27 | 'methodName' => $methodName, 28 | ]; 29 | } 30 | 31 | return null; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Rule/Traits/ParseClassNameFromArgTrait.php: -------------------------------------------------------------------------------- 1 | value; 21 | } 22 | 23 | if ($value instanceof ClassConstFetch) { 24 | assert($value->class instanceof Name); 25 | 26 | return $value->class->toString(); 27 | } 28 | 29 | return null; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Testing/AnalyseCheckLineStartsWithTrait.php: -------------------------------------------------------------------------------- 1 | $files 16 | * @param array{array{'0': string, '1':int}} $expected 17 | * @return void 18 | */ 19 | public function analyseCheckLineStartsWith(array $files, array $expected): void 20 | { 21 | $actualErrors = $this->gatherAnalyserErrors($files); 22 | $messageText = static function (int $line, string $message): string { 23 | return sprintf('%02d: %s', $line, $message); 24 | }; 25 | $actualErrors = array_map(static function (Error $error) use ($messageText): string { 26 | $line = $error->getLine(); 27 | if ($line === null) { 28 | return $messageText(-1, $error->getMessage()); 29 | } 30 | 31 | return $messageText($line, $error->getMessage()); 32 | }, $actualErrors); 33 | 34 | $expected = array_map(static function (array $item) use ($messageText): string { 35 | return $messageText($item[1], $item[0]); 36 | }, $expected); 37 | static::assertThat($expected, new ArrayOfStringStartsWith($actualErrors)); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Testing/CustomRuleTestCase.php: -------------------------------------------------------------------------------- 1 | getArgs()) === 0) { 40 | return null; 41 | } 42 | 43 | $argType = $scope->getType($methodCall->getArgs()[0]->value); 44 | if (!method_exists($argType, 'getValue')) { 45 | return new ObjectType($this->defaultClass); 46 | } 47 | $value = $argType->getValue(); 48 | if (!is_string($value)) { 49 | return null; 50 | } 51 | 52 | return $this->getCakeType($value); 53 | } 54 | 55 | /** 56 | * @inheritDoc 57 | */ 58 | public function getClass(): string 59 | { 60 | return $this->className; 61 | } 62 | 63 | /** 64 | * @param \PHPStan\Reflection\MethodReflection $methodReflection 65 | * @return bool 66 | */ 67 | public function isMethodSupported(MethodReflection $methodReflection): bool 68 | { 69 | return $methodReflection->getName() === $this->methodName; 70 | } 71 | 72 | /** 73 | * @param string $baseName 74 | * @return \PHPStan\Type\ObjectType 75 | */ 76 | protected function getCakeType(string $baseName): ObjectType 77 | { 78 | $className = CakeNameRegistry::getClassName($baseName, $this->namespaceFormat); 79 | if ($className !== null) { 80 | return new ObjectType($className); 81 | } 82 | 83 | return new ObjectType($this->defaultClass); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Traits/IsFromTargetTrait.php: -------------------------------------------------------------------------------- 1 | getTraits() as $trait) { 17 | if ($trait->getName() === $targetTrait) { 18 | return true; 19 | } 20 | } 21 | foreach ($reflection->getParents() as $parent) { 22 | if ($this->isFromTargetTrait($parent, $targetTrait)) { 23 | return true; 24 | } 25 | } 26 | 27 | return false; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Traits/RepositoryReferenceTrait.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | protected array $associationsClasses = [ 20 | Association::class, 21 | BelongsTo::class, 22 | BelongsToMany::class, 23 | HasMany::class, 24 | HasOne::class, 25 | ]; 26 | 27 | /** 28 | * @param \PHPStan\Analyser\Scope $scope 29 | * @param \PhpParser\Node\Expr\MethodCall $methodCall 30 | * @return string|null 31 | */ 32 | protected function getReferenceClass(Scope $scope, MethodCall $methodCall): ?string 33 | { 34 | $classes = $scope->getType($methodCall->var)->getReferencedClasses(); 35 | if (!isset($classes[0])) { 36 | return null; 37 | } 38 | if (!in_array($classes[0], $this->associationsClasses, true)) { 39 | return $classes[0]; 40 | } 41 | //We should have key 1 for associations, ex: BelongsTo<\App\Model\Table\UsersTable> 42 | 43 | return $classes[1] ?? null; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Type/BaseTraitExpressionTypeResolverExtension.php: -------------------------------------------------------------------------------- 1 | name instanceof Identifier 58 | || $expr->name->toString() !== $this->methodName 59 | ) { 60 | return null; 61 | } 62 | 63 | $callerType = $scope->getType($expr->var); 64 | if (!$callerType->isObject()->yes()) { 65 | return null; 66 | } 67 | $reflection = $callerType->getObjectClassReflections()[0] ?? null; 68 | if ($reflection === null || !$this->isFromTargetTrait($reflection, $this->targetTrait)) { 69 | return null; 70 | } 71 | 72 | $value = $expr->getArgs()[0]->value ?? null; 73 | $baseName = $this->getBaseName($value, $reflection); 74 | if ($baseName === null) { 75 | return null; 76 | } 77 | $className = CakeNameRegistry::getClassName($baseName, $this->namespaceFormat); 78 | if ($className !== null) { 79 | return new ObjectType($className); 80 | } 81 | 82 | return null; 83 | } 84 | 85 | /** 86 | * @param \PhpParser\Node\Expr|null $value 87 | * @param \PHPStan\Reflection\ClassReflection $reflection 88 | * @return string|null 89 | */ 90 | protected function getBaseName(?Expr $value, ClassReflection $reflection): ?string 91 | { 92 | if ($value instanceof String_) { 93 | return $value->value; 94 | } 95 | 96 | try { 97 | if ($value === null && $this->propertyDefaultValue !== null) { 98 | $default = $reflection->getNativeReflection() 99 | ->getProperty('defaultTable') 100 | ->getDefaultValueExpression(); 101 | if ($default instanceof String_) { 102 | return $default->value; 103 | } 104 | } 105 | 106 | return null; 107 | } catch (ReflectionException) { 108 | return null; 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Type/ComponentLoadDynamicReturnTypeExtension.php: -------------------------------------------------------------------------------- 1 | className = Controller::class; 49 | $this->methodName = 'loadComponent'; 50 | $this->defaultClass = Component::class; 51 | $this->namespaceFormat = '%s\\Controller\Component\\%sComponent'; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Type/ConsoleHelperLoadDynamicReturnTypeExtension.php: -------------------------------------------------------------------------------- 1 | className = ConsoleIo::class; 51 | $this->methodName = 'helper'; 52 | $this->defaultClass = Helper::class; 53 | $this->namespaceFormat = '%s\\Command\Helper\\%sHelper'; 54 | } 55 | 56 | /** 57 | * Before calling BaseCakeRegistryReturnTrait::getCakeType uppercase the 58 | * first letter as done in the method ConsoleIo::helper 59 | * 60 | * @param string $baseName 61 | * @return \PHPStan\Type\ObjectType 62 | */ 63 | protected function getCakeType(string $baseName): Type 64 | { 65 | $baseName = ucfirst($baseName); 66 | 67 | return $this->_getCakeType($baseName); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Type/ControllerFetchTableDynamicReturnTypeExtension.php: -------------------------------------------------------------------------------- 1 | getDefaultTableByControllerClass($targetClassReflection); 37 | if ($tableClassName !== null) { 38 | return new ObjectType($tableClassName); 39 | } 40 | 41 | return null; 42 | } 43 | 44 | /** 45 | * @param \PHPStan\Reflection\ClassReflection $targetClassReflection 46 | * @return string|null 47 | */ 48 | protected function getDefaultTableByControllerClass(ClassReflection $targetClassReflection): ?string 49 | { 50 | $hasProperty = $targetClassReflection->hasProperty('defaultTable'); 51 | if (!$hasProperty) { 52 | return null; 53 | } 54 | $nativeReflection = $targetClassReflection->getNativeReflection(); 55 | $namespace = $nativeReflection->getNamespaceName(); 56 | $pos = strrpos($namespace, '\\Controller'); 57 | if ($pos === false) { 58 | return null; 59 | } 60 | $baseNamespace = substr($namespace, 0, $pos); 61 | $shortName = $nativeReflection->getShortName(); 62 | $shortName = str_replace('Controller', '', $shortName); 63 | $tableClassName = sprintf( 64 | '%s\\Model\\Table\\%sTable', 65 | $baseNamespace, 66 | $shortName 67 | ); 68 | 69 | if (class_exists($tableClassName)) { 70 | return $tableClassName; 71 | } 72 | 73 | return null; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Type/RepositoryEntityDynamicReturnTypeExtension.php: -------------------------------------------------------------------------------- 1 | 41 | */ 42 | private array $methodNames = [ 43 | 'get', 44 | 'newEntity', 45 | 'newEntities', 46 | 'newEmptyEntity', 47 | 'findOrCreate', 48 | ]; 49 | 50 | /** 51 | * @var string 52 | */ 53 | protected string $defaultClass; 54 | /** 55 | * @var string 56 | */ 57 | protected string $namespaceFormat; 58 | 59 | /** 60 | * @param class-string $className The target className. 61 | */ 62 | public function __construct(string $className) 63 | { 64 | $this->className = $className; 65 | $this->defaultClass = EntityInterface::class; 66 | $this->namespaceFormat = '%s\\Model\Entity\\%s'; 67 | } 68 | 69 | /** 70 | * @param \PHPStan\Reflection\MethodReflection $methodReflection 71 | * @return bool 72 | */ 73 | public function isMethodSupported(MethodReflection $methodReflection): bool 74 | { 75 | return in_array($methodReflection->getName(), $this->methodNames, true); 76 | } 77 | 78 | /** 79 | * @param \PHPStan\Reflection\MethodReflection $methodReflection 80 | * @param \PhpParser\Node\Expr\MethodCall $methodCall 81 | * @param \PHPStan\Analyser\Scope $scope 82 | * @return \PHPStan\Type\Type|null 83 | * @throws \PHPStan\ShouldNotHappenException 84 | */ 85 | public function getTypeFromMethodCall( 86 | MethodReflection $methodReflection, 87 | MethodCall $methodCall, 88 | Scope $scope 89 | ): ?Type { 90 | $className = $this->getReferenceClass($scope, $methodCall); 91 | if ($className === null || $className === Table::class) { 92 | return null; 93 | } 94 | 95 | $entityClass = $this->getEntityClassByTableClass($className); 96 | 97 | if ($entityClass !== null && class_exists($entityClass)) { 98 | if ($methodReflection->getName() === 'newEntities') { 99 | return new ArrayType(new IntegerType(), new ObjectType($entityClass)); 100 | } 101 | 102 | return new ObjectType($entityClass); 103 | } 104 | 105 | return null; 106 | } 107 | 108 | /** 109 | * @param string $className 110 | * @return string|null 111 | */ 112 | protected function getEntityClassByTableClass(string $className): ?string 113 | { 114 | $parts = explode('\\', $className); 115 | $count = count($parts); 116 | $nameIndex = $count - 1; 117 | $folderIndex = $count - 2; 118 | if ($count < 3 || $parts[$folderIndex] !== 'Table') { 119 | return null; 120 | } 121 | $name = str_replace('Table', '', $parts[$nameIndex]); 122 | $name = Inflector::singularize($name); 123 | $parts[$folderIndex] = 'Entity'; 124 | $parts[$nameIndex] = $name; 125 | 126 | return implode('\\', $parts); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/Type/RepositoryFirstArgIsTheReturnTypeExtension.php: -------------------------------------------------------------------------------- 1 | 34 | */ 35 | private array $methodNames = [ 36 | 'patchEntity', 37 | 'patchEntities', 38 | 'save', 39 | 'saveOrFail', 40 | 'saveMany', 41 | 'saveManyOrFail', 42 | 'deleteMany', 43 | 'deleteManyOrFail', 44 | ]; 45 | /** 46 | * @var class-string 47 | */ 48 | protected string $className; 49 | 50 | /** 51 | * @var string 52 | */ 53 | protected string $defaultClass; 54 | /** 55 | * @var string 56 | */ 57 | protected string $namespaceFormat; 58 | 59 | /** 60 | * @param class-string $className The target className. 61 | */ 62 | public function __construct(string $className) 63 | { 64 | $this->className = $className; 65 | $this->defaultClass = EntityInterface::class; 66 | $this->namespaceFormat = '%s\\Model\Entity\\%s'; 67 | } 68 | 69 | /** 70 | * @param \PHPStan\Reflection\MethodReflection $methodReflection 71 | * @return bool 72 | */ 73 | public function isMethodSupported(MethodReflection $methodReflection): bool 74 | { 75 | return in_array($methodReflection->getName(), $this->methodNames, true); 76 | } 77 | 78 | /** 79 | * @param \PHPStan\Reflection\MethodReflection $methodReflection 80 | * @param \PhpParser\Node\Expr\MethodCall $methodCall 81 | * @param \PHPStan\Analyser\Scope $scope 82 | * @return \PHPStan\Type\Type|null 83 | * @throws \PHPStan\ShouldNotHappenException 84 | */ 85 | public function getTypeFromMethodCall( 86 | MethodReflection $methodReflection, 87 | MethodCall $methodCall, 88 | Scope $scope 89 | ): ?Type { 90 | $args = $methodCall->getArgs(); 91 | if (count($args) === 0) { 92 | return null; 93 | } 94 | 95 | $type = $scope->getType($args[0]->value); 96 | 97 | $name = $methodReflection->getName(); 98 | if (in_array($name, ['save', 'saveMany', 'deleteMany'], true)) { 99 | if ($type instanceof UnionType) { 100 | $types = $type->getTypes(); 101 | $types[] = new ConstantBooleanType(false); 102 | } else { 103 | $types = [$type, new ConstantBooleanType(false)]; 104 | } 105 | 106 | return new UnionType($types); 107 | } 108 | if ($methodReflection->getName() === 'patchEntities') { 109 | if (!$type->isIterable()->yes()) { 110 | return null; 111 | } 112 | $valueType = $type->getIterableValueType(); 113 | if ($valueType->isObject()->yes()) { 114 | return new ArrayType(new IntegerType(), $valueType); 115 | } 116 | 117 | return null; 118 | } 119 | 120 | return $type; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/Type/TableLocatorDynamicReturnTypeExtension.php: -------------------------------------------------------------------------------- 1 | className = $className; 53 | $this->methodName = $methodName; 54 | $this->defaultClass = Table::class; 55 | $this->namespaceFormat = '%s\\Model\\Table\\%sTable'; 56 | } 57 | 58 | /** 59 | * @param \PHPStan\Reflection\MethodReflection $methodReflection 60 | * @param \PhpParser\Node\Expr\MethodCall $methodCall 61 | * @param \PHPStan\Analyser\Scope $scope 62 | * @return \PHPStan\Type\Type|null 63 | * @throws \PHPStan\ShouldNotHappenException 64 | */ 65 | public function getTypeFromMethodCall( 66 | MethodReflection $methodReflection, 67 | MethodCall $methodCall, 68 | Scope $scope 69 | ): ?Type { 70 | if (count($methodCall->getArgs()) === 0) { 71 | $targetClassReflection = $this->getTargetClassReflection($scope, $methodCall); 72 | $type = null; 73 | if ($targetClassReflection !== null) { 74 | $type = $this->getReturnTypeWithoutArgs($methodReflection, $methodCall, $targetClassReflection); 75 | } 76 | if ($type !== null) { 77 | return $type; 78 | } 79 | 80 | return null; 81 | } 82 | 83 | return $this->getTypeFromMethodCallWithArgs($methodReflection, $methodCall, $scope); 84 | } 85 | 86 | /** 87 | * @param \PHPStan\Reflection\ClassReflection $target 88 | * @return string|null 89 | * @throws \ReflectionException 90 | */ 91 | protected function getDefaultTable(ClassReflection $target): ?string 92 | { 93 | $default = $target->getNativeReflection() 94 | ->getProperty('defaultTable') 95 | ->getDefaultValueExpression(); 96 | if ($default instanceof String_) { 97 | return $default->value; 98 | } 99 | 100 | return null; 101 | } 102 | 103 | /** 104 | * @param \PHPStan\Reflection\MethodReflection $methodReflection 105 | * @param \PhpParser\Node\Expr\MethodCall $methodCall 106 | * @param \PHPStan\Reflection\ClassReflection $targetClassReflection 107 | * @return \PHPStan\Type\Type|null 108 | */ 109 | protected function getReturnTypeWithoutArgs( 110 | MethodReflection $methodReflection, 111 | MethodCall $methodCall, 112 | ClassReflection $targetClassReflection 113 | ): ?Type { 114 | try { 115 | $defaultTable = $this->getDefaultTable($targetClassReflection); 116 | if (is_string($defaultTable) && $defaultTable !== '') { 117 | return $this->getCakeType($defaultTable); 118 | } 119 | } catch (ReflectionException) { 120 | } 121 | 122 | return null; 123 | } 124 | 125 | /** 126 | * @param \PHPStan\Analyser\Scope $scope 127 | * @param \PhpParser\Node\Expr\MethodCall $methodCall 128 | * @return \PHPStan\Reflection\ClassReflection|null 129 | */ 130 | protected function getTargetClassReflection(Scope $scope, MethodCall $methodCall): ?ClassReflection 131 | { 132 | return $scope->getType($methodCall->var)->getObjectClassReflections()[0] ?? null; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/Utility/CakeNameRegistry.php: -------------------------------------------------------------------------------- 1 | |string $namespaceFormat 23 | * @return string|null 24 | */ 25 | public static function getClassName(string $baseName, string|array $namespaceFormat): ?string 26 | { 27 | if (str_contains($baseName, '\\')) { 28 | return class_exists($baseName) ? $baseName : null; 29 | } 30 | 31 | [$plugin, $name] = static::pluginSplit($baseName); 32 | $prefixes = $plugin !== null ? [$plugin] : ['App', 'Cake']; 33 | $namespaceFormat = (array)$namespaceFormat; 34 | foreach ($namespaceFormat as $format) { 35 | foreach ($prefixes as $prefix) { 36 | $namespace = str_replace('/', '\\', $prefix); 37 | $className = sprintf($format, $namespace, $name); 38 | if (class_exists($className)) { 39 | return $className; 40 | } 41 | } 42 | } 43 | 44 | return null; 45 | } 46 | 47 | /** 48 | * @param string $name 49 | * @return string|null 50 | */ 51 | public static function getComponentClassName(string $name): ?string 52 | { 53 | return static::getClassName($name, [ 54 | '%s\\Controller\\Component\\%sComponent', 55 | '%s\\Controller\\Component\\%sComponent', 56 | ]); 57 | } 58 | 59 | /** 60 | * @param string $name 61 | * @return string|null 62 | */ 63 | public static function getBehaviorClassName(string $name): ?string 64 | { 65 | return static::getClassName($name, [ 66 | '%s\\Model\\Behavior\\%sBehavior', 67 | '%s\\ORM\\Behavior\\%sBehavior', 68 | ]); 69 | } 70 | 71 | /** 72 | * @param string $name 73 | * @return string|null 74 | */ 75 | public static function getTableClassName(string $name): ?string 76 | { 77 | return static::getClassName($name, [ 78 | '%s\\Model\\Table\\%sTable', 79 | ]); 80 | } 81 | 82 | /** 83 | * @param string $name 84 | * @return string|null 85 | */ 86 | public static function getMailerClassName(string $name): ?string 87 | { 88 | return static::getClassName($name, [ 89 | '%s\\Mailer\\%sMailer', 90 | ]); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Visitor/AddAssociationSetClassNameVisitor.php: -------------------------------------------------------------------------------- 1 | $nodes 28 | * @return array<\PhpParser\Node>|null 29 | */ 30 | public function beforeTraverse(array $nodes): ?array 31 | { 32 | $this->optionsSet = null; 33 | 34 | return null; 35 | } 36 | 37 | /** 38 | * @param \PhpParser\Node $node 39 | * @return \PhpParser\Node|null 40 | */ 41 | public function enterNode(Node $node): ?Node 42 | { 43 | if (!$node instanceof MethodCall || !$node->name instanceof Node\Identifier) { 44 | return null; 45 | } 46 | if ($this->optionsSet === null && $node->name->name === 'setClassName') { 47 | $arg = $node->args[0] ?? null; 48 | $this->optionsSet = $arg instanceof Arg ? $arg->value : null; 49 | } 50 | if (in_array($node->name->name, ['load', 'belongsTo', 'belongsToMany', 'hasOne', 'hasMany'], true)) { 51 | $node->setAttribute(self::ATTRIBUTE_NAME, $this->optionsSet); 52 | } 53 | 54 | return null; 55 | } 56 | 57 | /** 58 | * @inheritDoc 59 | */ 60 | public function leaveNode(Node $node) 61 | { 62 | $this->optionsSet = null; 63 | 64 | return null; 65 | } 66 | } 67 | --------------------------------------------------------------------------------