├── .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 | 
4 | [](https://packagist.org/packages/CakeDC/cakephp-phpstan)
5 | [](https://packagist.org/packages/CakeDC/cakephp-phpstan)
6 | [](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 |
--------------------------------------------------------------------------------