├── .editorconfig ├── .gitignore ├── .php-cs-fixer.dist.php ├── .php-version.dist ├── .scrutinizer.yml ├── CONTRIBUTING.md ├── CONTRIBUTORS.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── bin-stub └── phparkitect ├── box.json ├── composer.json ├── composer.phar ├── phparkitect-stub.php ├── phparkitect.php ├── psalm.xml └── src ├── Analyzer ├── ClassDependency.php ├── ClassDescription.php ├── ClassDescriptionBuilder.php ├── Docblock.php ├── DocblockParser.php ├── DocblockParserFactory.php ├── DocblockTypesResolver.php ├── FileParser.php ├── FileParserFactory.php ├── FilePath.php ├── FileVisitor.php ├── FullyQualifiedClassName.php ├── Parser.php └── PatternString.php ├── CLI ├── AnalysisResult.php ├── Baseline.php ├── Command │ ├── Check.php │ ├── DebugExpression.php │ └── Init.php ├── Config.php ├── ConfigBuilder.php ├── PhpArkitectApplication.php ├── Printer │ ├── GitlabPrinter.php │ ├── JsonPrinter.php │ ├── Printer.php │ ├── PrinterFactory.php │ └── TextPrinter.php ├── Progress │ ├── DebugProgress.php │ ├── Progress.php │ ├── ProgressBarProgress.php │ └── VoidProgress.php ├── Runner.php ├── TargetPhpVersion.php └── Version.php ├── ClassSet.php ├── ClassSetRules.php ├── Exceptions ├── FailOnFirstViolationException.php ├── IndexNotFoundException.php ├── InvalidPatternException.php └── PhpVersionNotValidException.php ├── Expression ├── Description.php ├── Expression.php └── ForClasses │ ├── ContainDocBlockLike.php │ ├── DependsOnlyOnTheseNamespaces.php │ ├── Extend.php │ ├── HaveAttribute.php │ ├── HaveNameMatching.php │ ├── Implement.php │ ├── IsA.php │ ├── IsAbstract.php │ ├── IsEnum.php │ ├── IsFinal.php │ ├── IsInterface.php │ ├── IsNotAbstract.php │ ├── IsNotEnum.php │ ├── IsNotFinal.php │ ├── IsNotInterface.php │ ├── IsNotReadonly.php │ ├── IsNotTrait.php │ ├── IsReadonly.php │ ├── IsTrait.php │ ├── MatchOneOfTheseNames.php │ ├── NotContainDocBlockLike.php │ ├── NotDependsOnTheseNamespaces.php │ ├── NotExtend.php │ ├── NotHaveDependencyOutsideNamespace.php │ ├── NotHaveNameMatching.php │ ├── NotImplement.php │ ├── NotResideInTheseNamespaces.php │ └── ResideInOneOfTheseNamespaces.php ├── Glob.php ├── PHPUnit └── ArchRuleCheckerConstraintAdapter.php ├── RuleBuilders └── Architecture │ ├── Architecture.php │ ├── Component.php │ ├── DefinedBy.php │ ├── MayDependOnAnyComponent.php │ ├── MayDependOnComponents.php │ ├── Rules.php │ ├── ShouldNotDependOnAnyComponent.php │ ├── ShouldOnlyDependOnComponents.php │ └── Where.php └── Rules ├── AllClasses.php ├── AndThatShould.php ├── ArchRule.php ├── Because.php ├── Constraints.php ├── DSL ├── AndThatShouldParser.php ├── ArchRule.php ├── BecauseParser.php └── ThatParser.php ├── ParsingError.php ├── ParsingErrors.php ├── Rule.php ├── RuleBuilder.php ├── Specs.php ├── Violation.php ├── ViolationMessage.php └── Violations.php /.editorconfig: -------------------------------------------------------------------------------- 1 | ; This file is for unifying the coding style for different editors and IDEs. 2 | ; More information at http://editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | end_of_line = lf 9 | insert_final_newline = true 10 | indent_style = space 11 | indent_size = 4 12 | trim_trailing_whitespace = true 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | .phpunit.result.cache 3 | .idea 4 | .php_cs.cache 5 | .php-cs-fixer.cache 6 | /web 7 | /bin/ 8 | !/bin/box.phar 9 | !/bin/psalm.phar 10 | phparkitect.phar 11 | composer.lock 12 | .php-version 13 | composer.phar 14 | .phpunit.cache/ 15 | .vscode -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | in(__DIR__) 5 | ->exclude('vendor/') 6 | ->notPath('tests/E2E/_fixtures/parse_error/Services/CartService.php'); 7 | 8 | return (new PhpCsFixer\Config()) 9 | ->setFinder($finder) 10 | ->setRiskyAllowed(true) 11 | ->setRules([ 12 | '@PER-CS' => true, 13 | '@Symfony' => true, 14 | '@Symfony:risky' => true, 15 | '@PHP71Migration:risky' => true, 16 | '@PSR2' => true, 17 | '@DoctrineAnnotation' => true, 18 | 'array_syntax' => ['syntax' => 'short'], 19 | 'fully_qualified_strict_types' => true, // Transforms imported FQCN parameters and return types in function arguments to short version. 20 | 'dir_constant' => true, // Replaces dirname(__FILE__) expression with equivalent __DIR__ constant. 21 | 'heredoc_to_nowdoc' => true, 22 | 'linebreak_after_opening_tag' => true, // Ensure there is no code on the same line as the PHP open tag. 23 | 'blank_line_after_opening_tag' => false, 24 | 'modernize_types_casting' => true, // Replaces intval, floatval, doubleval, strval and boolval function calls with according type casting operator. 25 | 'multiline_whitespace_before_semicolons' => true, // Forbid multi-line whitespace before the closing semicolon or move the semicolon to the new line for chained calls. 26 | 'no_unreachable_default_argument_value' => true, // In function arguments there must not be arguments with default values before non-default ones. 27 | 'no_superfluous_phpdoc_tags' => ['allow_mixed' => true], // To avoid problems of compatibility with the old php-cs-fixer version used on PHP 7.3 28 | 'no_useless_else' => true, 29 | 'no_useless_return' => true, 30 | 'ordered_class_elements' => true, // Orders the elements of classes/interfaces/traits. 31 | 'ordered_imports' => true, 32 | 'phpdoc_add_missing_param_annotation' => ['only_untyped' => false], // PHPDoc should contain @param for all params (for untyped parameters only). 33 | 'phpdoc_order' => true, // Annotations in PHPDoc should be ordered so that @param annotations come first, then @throws annotations, then @return annotations. 34 | 'declare_strict_types' => true, 35 | 'psr_autoloading' => true, // Class names should match the file name. 36 | 'no_php4_constructor' => true, // Convert PHP4-style constructors to __construct. 37 | 'semicolon_after_instruction' => true, 38 | 'align_multiline_comment' => true, 39 | 'general_phpdoc_annotation_remove' => ['annotations' => ['author', 'package']], 40 | 'list_syntax' => ['syntax' => 'short'], 41 | 'phpdoc_to_comment' => false, 42 | 'php_unit_method_casing' => ['case' => 'snake_case'], 43 | 'function_to_constant' => false, 44 | 'php_unit_data_provider_static' => true , 45 | 'php_unit_test_case_static_method_calls' => ['call_type' => 'self'], 46 | 'phpdoc_array_type' => true 47 | ]); 48 | -------------------------------------------------------------------------------- /.php-version.dist: -------------------------------------------------------------------------------- 1 | 8.1 -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | build: 2 | environment: 3 | php: 7.4 4 | 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to arkitect 2 | 3 | How Can I Contribute? 4 | 5 | ## Reporting Bugs 6 | 7 | To report bugs you can open an issue on this repository. Please provide as much information as you can to help discover and fix the bug. 8 | Useful information are: 9 | - Which PHP version are you running? 10 | - Which problem are you experiencing? 11 | 12 | If possible, a test highlihting the bug would be great. 13 | If you are fixing a bug, create a pull request, linking the issue with bug's details (if there is any) and provide tests. 14 | The build must be green for the PR being merged. 15 | 16 | ## Suggesting Enhancements 17 | 18 | If you want to propose an enhancements open an issue explaining why you think it would be useful. 19 | Once you get a green light implement stuff, create a PR. Remember to provide tests. 20 | The build must be green for the PR being merged. 21 | 22 | ## How to develop arkitect 23 | 24 | In order to fix a bug or submit a new enhancement we suggest to run the build locally or using docker (with the dockerfile provided). 25 | Some common tasks are available in the Makefile file (you still can use it to see how run things even your system does not support make). 26 | 27 | To create the docker image and then enter the docker container shell: 28 | 29 | ```shell 30 | docker image build -t phparkitect . 31 | docker run --rm -it --entrypoint= -v $(PWD):/arkitect phparkitect bash 32 | ``` 33 | 34 | If you prefer use more shorter make commands (use `make` without arguments for help): 35 | 36 | ```shell 37 | make dbi 38 | make shell 39 | ``` 40 | 41 | The first time, after the docker container has been created, remember to install the packages with composer: 42 | 43 | ```shell 44 | composer install 45 | ``` 46 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | Shout out to our top contributors! 4 | 5 | - [AlessandroMinoccheri](https://api.github.com/users/AlessandroMinoccheri) 6 | - [fain182](https://api.github.com/users/fain182) 7 | - [micheleorselli](https://api.github.com/users/micheleorselli) 8 | - [pfazzi](https://api.github.com/users/pfazzi) 9 | - [github-actions[bot]](https://api.github.com/users/github-actions%5Bbot%5D) 10 | - [sebastianstucke87](https://api.github.com/users/sebastianstucke87) 11 | - [szepeviktor](https://api.github.com/users/szepeviktor) 12 | - [ricfio](https://api.github.com/users/ricfio) 13 | - [JulienRAVIA](https://api.github.com/users/JulienRAVIA) 14 | - [helyakin](https://api.github.com/users/helyakin) 15 | - [hgraca](https://api.github.com/users/hgraca) 16 | - [ben-challis](https://api.github.com/users/ben-challis) 17 | - [raffaelecarelle](https://api.github.com/users/raffaelecarelle) 18 | - [LuigiCardamone](https://api.github.com/users/LuigiCardamone) 19 | - [simivar](https://api.github.com/users/simivar) 20 | - [annervisser](https://api.github.com/users/annervisser) 21 | - [jdomenechbLendable](https://api.github.com/users/jdomenechbLendable) 22 | - [dbu](https://api.github.com/users/dbu) 23 | - [stephpy](https://api.github.com/users/stephpy) 24 | - [marmichalski](https://api.github.com/users/marmichalski) 25 | - [EmilMassey](https://api.github.com/users/EmilMassey) 26 | - [frankverhoeven](https://api.github.com/users/frankverhoeven) 27 | - [hectorespert](https://api.github.com/users/hectorespert) 28 | - [kapersoft](https://api.github.com/users/kapersoft) 29 | - [jerowork](https://api.github.com/users/jerowork) 30 | - [jkrzefski](https://api.github.com/users/jkrzefski) 31 | - [mloru](https://api.github.com/users/mloru) 32 | - [nikow13](https://api.github.com/users/nikow13) 33 | - [OskarStark](https://api.github.com/users/OskarStark) 34 | - [philipp-yoummday](https://api.github.com/users/philipp-yoummday) 35 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PHP_VERSION=7.4 2 | 3 | FROM php:${PHP_VERSION}-cli-alpine AS php_build 4 | 5 | COPY --from=composer:2.0 /usr/bin/composer /usr/bin/composer 6 | 7 | WORKDIR /arkitect 8 | 9 | COPY bin-stub ./bin-stub 10 | COPY src ./src 11 | COPY composer.json ./composer.json 12 | COPY box.json ./box.json 13 | COPY phpunit.xml ./phpunit.xml 14 | COPY psalm.xml ./psalm.xml 15 | 16 | RUN composer install --no-dev --optimize-autoloader --prefer-dist 17 | 18 | RUN apk add zip git bash make icu-dev 19 | 20 | ENV PATH="/arkitect/bin-stub:${PATH}" 21 | 22 | ENTRYPOINT [ "phparkitect"] 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 micheleorselli 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test build db dt dbi dphar csfix 2 | .DEFAULT_GOAL := help 3 | 4 | TMP_DIR = /tmp/arkitect 5 | 6 | help: ## it shows help menu 7 | @awk 'BEGIN {FS = ":.*#"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z0-9_-]+:.*?#/ { printf " \033[36m%-27s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) 8 | 9 | dbi: ## it creates docker image 10 | docker image build -t phparkitect . 11 | 12 | shell: ## it enters into the container 13 | docker run --rm -it --entrypoint= -v $(PWD):/arkitect phparkitect bash 14 | 15 | test: ## it launches tests 16 | bin/phpunit -v 17 | 18 | test_%: ## it launches a test 19 | bin/phpunit --filter $@ 20 | 21 | %Test: ## it launches a test 22 | bin/phpunit --filter $@ 23 | 24 | phar: ## it creates phar 25 | rm -rf ${TMP_DIR} && mkdir -p ${TMP_DIR} 26 | cp -R src bin-stub box.json README.md composer.json phparkitect-stub.php bin ${TMP_DIR} 27 | cd ${TMP_DIR} && composer install --prefer-source --no-dev -o 28 | bin/box.phar compile -c ${TMP_DIR}/box.json 29 | cp ${TMP_DIR}/phparkitect.phar . 30 | 31 | outdated: 32 | composer outdated 33 | 34 | coverage: ## it launches coverage 35 | phpdbg -qrr ./bin/phpunit --coverage-html build/coverage 36 | 37 | csfix: ## it launches cs fix 38 | PHP_CS_FIXER_IGNORE_ENV=1 bin/php-cs-fixer fix -v 39 | 40 | psalm: ## it launches psalm 41 | bin/psalm.phar --no-cache 42 | 43 | build: ## it launches all the build 44 | composer install 45 | PHP_CS_FIXER_IGNORE_ENV=1 bin/php-cs-fixer fix -v 46 | bin/psalm.phar --no-cache 47 | bin/phpunit 48 | 49 | sfbuild: ## it launches all the build 50 | symfony php composer.phar install 51 | PHP_CS_FIXER_IGNORE_ENV=1 symfony php bin/php-cs-fixer fix -v 52 | symfony php bin/psalm 53 | symfony php bin/phpunit 54 | 55 | dt: ##it launches tests using container 56 | docker run --rm -it --entrypoint= -v $(PWD):/arkitect phparkitect make test 57 | 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📐 PHPArkitect 2 | [![Latest Stable Version](https://poser.pugx.org/phparkitect/phparkitect/v/stable)](https://packagist.org/packages/phparkitect/phparkitect) ![PHPArkitect](https://github.com/phparkitect/arkitect/workflows/Arkitect/badge.svg?branch=master) 3 | [![Packagist](https://img.shields.io/packagist/dt/phparkitect/phparkitect.svg)](https://packagist.org/packages/phparkitect/phparkitect) 4 | [![codecov](https://codecov.io/gh/phparkitect/arkitect/branch/main/graph/badge.svg)](https://codecov.io/gh/phparkitect/arkitect) 5 | 6 | 7 | 1. [Introduction](#introduction) 8 | 1. [Installation](#installation) 9 | 1. [Usage](#usage) 10 | 1. [Available rules](#available-rules) 11 | 1. [Rule Builders](#rule-builders) 12 | 1. [Integrations](#integrations) 13 | 14 | # Introduction 15 | 16 | PHPArkitect helps you to keep your PHP codebase coherent and solid, by permitting to add some architectural constraint check to your workflow. 17 | You can express the constraint that you want to enforce, in simple and readable PHP code, for example: 18 | 19 | ```php 20 | Rule::allClasses() 21 | ->that(new ResideInOneOfTheseNamespaces('App\Controller')) 22 | ->should(new HaveNameMatching('*Controller')) 23 | ->because('it\'s a symfony naming convention'); 24 | ``` 25 | # Installation 26 | 27 | ## Using Composer 28 | 29 | ```bash 30 | composer require --dev phparkitect/phparkitect 31 | ``` 32 | 33 | ## Using a Phar 34 | Sometimes your project can conflict with one or more of PHPArkitect's dependencies. In that case you may find the Phar (a self-contained PHP executable) useful. 35 | 36 | The Phar can be downloaded from GitHub: 37 | 38 | ``` 39 | wget https://github.com/phparkitect/arkitect/releases/latest/download/phparkitect.phar 40 | chmod +x phparkitect.phar 41 | ./phparkitect.phar check 42 | ``` 43 | 44 | When you run phparkitect as phar and you have custom rules in need of autoloading the project classes you'll need to specify the option `--autoload=[AUTOLOAD_FILE]`. 45 | 46 | # Usage 47 | 48 | To use this tool you need to launch a command via Bash: 49 | 50 | ``` 51 | phparkitect check 52 | ``` 53 | 54 | With this command `phparkitect` will search all rules in the root of your project the default config file called `phparkitect.php`. 55 | You can also specify your configuration file using `--config` option like this: 56 | 57 | ``` 58 | phparkitect check --config=/project/yourConfigFile.php 59 | ``` 60 | 61 | By default, a progress bar will show the status of the ongoing analysis. 62 | 63 | ### Using a baseline file 64 | 65 | If there are a lot of violations in your codebase and you can't fix them now, 66 | you can use the baseline feature to instruct the tool to ignore past violations. 67 | 68 | To create a baseline file, run the `check` command with the `generate-baseline` parameter as follows: 69 | 70 | ``` 71 | phparkitect check --generate-baseline 72 | ``` 73 | This will create a `phparkitect-baseline.json`, if you want a different file name you can do it with: 74 | ``` 75 | phparkitect check --generate-baseline=my-baseline.json 76 | ``` 77 | 78 | It will produce a json file with the current list of violations. 79 | 80 | If is present a baseline file with the default name will be used automatically. 81 | 82 | To use a different baseline file, run the `check` command with the `use-baseline` parameter as follows: 83 | 84 | ``` 85 | phparkitect check --use-baseline=my-baseline.json 86 | ``` 87 | 88 | To avoid using the default baseline file, you can use the `skip-baseline` option: 89 | 90 | ``` 91 | phparkitect check --skip-baseline 92 | ``` 93 | 94 | ### Line numbers in baseline 95 | 96 | By default, the baseline check also looks at line numbers of known violations. 97 | When a line before the offending line changes, the line numbers change and the check fails despite the baseline. 98 | 99 | With the optional flag `ignore-baseline-linenumbers`, you can ignore the line numbers of violations: 100 | 101 | ``` 102 | phparkitect check --ignore-baseline-linenumbers 103 | ``` 104 | 105 | *Warning*: When ignoring line numbers, phparkitect can no longer discover if a rule is violated additional times in the same file. 106 | 107 | ## Output format 108 | 109 | Output format can be controlled using the parameter `format=[FORMAT]`. There are two available output formats 110 | * `text`: the default one 111 | * `json`: this format allows custom report using github action or another platform as Sonarqube and so on... Note that this will suppress any output apart from the violation reporting. 112 | * `gitlab`: this follows Gitlab's [code quality format](https://docs.gitlab.com/ci/testing/code_quality/#code-quality-report-format). Note that this will suppress any output apart from the violation reporting. 113 | 114 | ## Configuration 115 | 116 | Example of configuration file `phparkitect.php` 117 | 118 | ```php 119 | that(new ResideInOneOfTheseNamespaces('App\Controller')) 136 | ->should(new HaveNameMatching('*Controller')) 137 | ->because('we want uniform naming'); 138 | 139 | $rules[] = Rule::allClasses() 140 | ->that(new ResideInOneOfTheseNamespaces('App\Domain')) 141 | ->should(new NotHaveDependencyOutsideNamespace('App\Domain')) 142 | ->because('we want protect our domain'); 143 | 144 | $config 145 | ->add($mvcClassSet, ...$rules); 146 | }; 147 | ``` 148 | PHPArkitect can detect violations also on DocBlocks custom annotations (like `@Assert\NotBlank` or `@Serializer\Expose`). 149 | If you want to disable this feature you can add this simple configuration: 150 | ```php 151 | $config->skipParsingCustomAnnotations(); 152 | ``` 153 | 154 | # Available rules 155 | 156 | **Hint**: If you want to test how a Rule work, you can use the command like `phparkitect debug:expression ` to check which class satisfy the rule in your current folder. 157 | 158 | For example: `phparkitect debug:expression ResideInOneOfTheseNamespaces App` 159 | 160 | --- 161 | 162 | Currently, you can check if a class: 163 | 164 | ### Depends on a namespace 165 | 166 | ```php 167 | $rules[] = Rule::allClasses() 168 | ->that(new ResideInOneOfTheseNamespaces('App\Domain')) 169 | ->should(new DependsOnlyOnTheseNamespaces(['App\Domain', 'Ramsey\Uuid'], ['App\Excluded'])) 170 | ->because('we want to protect our domain from external dependencies except for Ramsey\Uuid'); 171 | ``` 172 | 173 | ### Doc block contains a string 174 | 175 | ```php 176 | $rules[] = Rule::allClasses() 177 | ->that(new ResideInOneOfTheseNamespaces('App\Domain\Events')) 178 | ->should(new ContainDocBlockLike('@psalm-immutable')) 179 | ->because('we want to enforce immutability'); 180 | ``` 181 | 182 | ### Doc block not contains a string 183 | 184 | ```php 185 | $rules[] = Rule::allClasses() 186 | ->that(new ResideInOneOfTheseNamespaces('App\Controller')) 187 | ->should(new NotContainDocBlockLike('@psalm-immutable')) 188 | ->because('we don\'t want to enforce immutability'); 189 | ``` 190 | 191 | ### Extend another class 192 | 193 | ```php 194 | $rules[] = Rule::allClasses() 195 | ->that(new ResideInOneOfTheseNamespaces('App\Controller')) 196 | ->should(new Extend('App\Controller\AbstractController')) 197 | ->because('we want to be sure that all controllers extend AbstractController'); 198 | 199 | You can add multiple parameters, the violation will happen when none of them match 200 | ``` 201 | 202 | ### Has an attribute (requires PHP >= 8.0) 203 | 204 | ```php 205 | $rules[] = Rule::allClasses() 206 | ->that(new ResideInOneOfTheseNamespaces('App\Controller')) 207 | ->should(new HaveAttribute('Symfony\Component\HttpKernel\Attribute\AsController')) 208 | ->because('it configures the service container'); 209 | ``` 210 | 211 | ### Have a name matching a pattern 212 | 213 | ```php 214 | $rules[] = Rule::allClasses() 215 | ->that(new ResideInOneOfTheseNamespaces('App\Service')) 216 | ->should(new HaveNameMatching('*Service')) 217 | ->because('we want uniform naming for services'); 218 | ``` 219 | 220 | ### Implements an interface 221 | 222 | ```php 223 | $rules[] = Rule::allClasses() 224 | ->that(new ResideInOneOfTheseNamespaces('App\Controller')) 225 | ->should(new Implement('ContainerAwareInterface')) 226 | ->because('all controllers should be container aware'); 227 | ``` 228 | 229 | ### Not implements an interface 230 | 231 | ```php 232 | $rules[] = Rule::allClasses() 233 | ->that(new ResideInOneOfTheseNamespaces('App\Infrastructure\RestApi\Public')) 234 | ->should(new NotImplement('ContainerAwareInterface')) 235 | ->because('all public controllers should not be container aware'); 236 | ``` 237 | 238 | ### Is abstract 239 | 240 | ```php 241 | $rules[] = Rule::allClasses() 242 | ->that(new ResideInOneOfTheseNamespaces('App\Customer\Service')) 243 | ->should(new IsAbstract()) 244 | ->because('we want to be sure that classes are abstract in a specific namespace'); 245 | ``` 246 | 247 | ### Is trait 248 | 249 | ```php 250 | $rules[] = Rule::allClasses() 251 | ->that(new ResideInOneOfTheseNamespaces('App\Customer\Service\Traits')) 252 | ->should(new IsTrait()) 253 | ->because('we want to be sure that there are only traits in a specific namespace'); 254 | ``` 255 | 256 | ### Is final 257 | 258 | ```php 259 | $rules[] = Rule::allClasses() 260 | ->that(new ResideInOneOfTheseNamespaces('App\Domain\Aggregates')) 261 | ->should(new IsFinal()) 262 | ->because('we want to be sure that aggregates are final classes'); 263 | ``` 264 | 265 | ### Is readonly 266 | 267 | ```php 268 | $rules[] = Rule::allClasses() 269 | ->that(new ResideInOneOfTheseNamespaces('App\Domain\ValueObjects')) 270 | ->should(new IsReadonly()) 271 | ->because('we want to be sure that value objects are readonly classes'); 272 | ``` 273 | 274 | ### Is interface 275 | 276 | ```php 277 | $rules[] = Rule::allClasses() 278 | ->that(new ResideInOneOfTheseNamespaces('App\Interfaces')) 279 | ->should(new IsInterface()) 280 | ->because('we want to be sure that all interfaces are in one directory'); 281 | ``` 282 | 283 | ### Is enum 284 | 285 | ```php 286 | $rules[] = Rule::allClasses() 287 | ->that(new ResideInOneOfTheseNamespaces('App\Enum')) 288 | ->should(new IsEnum()) 289 | ->because('we want to be sure that all classes are enum'); 290 | ``` 291 | 292 | ### Is not abstract 293 | 294 | ```php 295 | $rules[] = Rule::allClasses() 296 | ->that(new ResideInOneOfTheseNamespaces('App\Domain')) 297 | ->should(new IsNotAbstract()) 298 | ->because('we want to avoid abstract classes into our domain'); 299 | ``` 300 | 301 | ### Is not trait 302 | 303 | ```php 304 | $rules[] = Rule::allClasses() 305 | ->that(new ResideInOneOfTheseNamespaces('App\Domain')) 306 | ->should(new IsNotTrait()) 307 | ->because('we want to avoid traits in our codebase'); 308 | ``` 309 | 310 | ### Is not final 311 | 312 | ```php 313 | $rules[] = Rule::allClasses() 314 | ->that(new ResideInOneOfTheseNamespaces('App\Infrastructure\Doctrine')) 315 | ->should(new IsNotFinal()) 316 | ->because('we want to be sure that our adapters are not final classes'); 317 | ``` 318 | 319 | ### Is not readonly 320 | 321 | ```php 322 | $rules[] = Rule::allClasses() 323 | ->that(new ResideInOneOfTheseNamespaces('App\Domain\Entity')) 324 | ->should(new IsNotReadonly()) 325 | ->because('we want to be sure that there are no readonly entities'); 326 | ``` 327 | 328 | ### Is not interface 329 | 330 | ```php 331 | $rules[] = Rule::allClasses() 332 | ->that(new ResideInOneOfTheseNamespaces('Tests\Integration')) 333 | ->should(new IsNotInterface()) 334 | ->because('we want to be sure that we do not have interfaces in tests'); 335 | ``` 336 | 337 | ### Is not enum 338 | 339 | ```php 340 | $rules[] = Rule::allClasses() 341 | ->that(new ResideInOneOfTheseNamespaces('App\Controller')) 342 | ->should(new IsNotEnum()) 343 | ->because('we want to be sure that all classes are not enum'); 344 | ``` 345 | 346 | ### Not depends on a namespace 347 | 348 | ```php 349 | $rules[] = Rule::allClasses() 350 | ->that(new ResideInOneOfTheseNamespaces('App\Application')) 351 | ->should(new NotDependsOnTheseNamespaces(['App\Infrastructure'], ['App\Infrastructure\Repository'])) 352 | ->because('we want to avoid coupling between application layer and infrastructure layer'); 353 | ``` 354 | 355 | ### Not extend another class 356 | 357 | ```php 358 | $rules[] = Rule::allClasses() 359 | ->that(new ResideInOneOfTheseNamespaces('App\Controller\Admin')) 360 | ->should(new NotExtend('App\Controller\AbstractController')) 361 | ->because('we want to be sure that all admin controllers not extend AbstractController for security reasons'); 362 | 363 | You can add multiple parameters, the violation will happen when one of them match 364 | ``` 365 | 366 | ### Don't have dependency outside a namespace 367 | 368 | ```php 369 | $rules[] = Rule::allClasses() 370 | ->that(new ResideInOneOfTheseNamespaces('App\Domain')) 371 | ->should(new NotHaveDependencyOutsideNamespace('App\Domain', ['Ramsey\Uuid'], true)) 372 | ->because('we want protect our domain except for Ramsey\Uuid'); 373 | ``` 374 | 375 | ### Not have a name matching a pattern 376 | 377 | ```php 378 | $rules[] = Rule::allClasses() 379 | ->that(new ResideInOneOfTheseNamespaces('App')) 380 | ->should(new NotHaveNameMatching('*Manager')) 381 | ->because('*Manager is too vague in naming classes'); 382 | ``` 383 | 384 | ### Reside in a namespace 385 | 386 | ```php 387 | $rules[] = Rule::allClasses() 388 | ->that(new HaveNameMatching('*Handler')) 389 | ->should(new ResideInOneOfTheseNamespaces('App\Application')) 390 | ->because('we want to be sure that all CommandHandlers are in a specific namespace'); 391 | ``` 392 | 393 | 394 | ### Not reside in a namespace 395 | 396 | ```php 397 | $rules[] = Rule::allClasses() 398 | ->that(new Extend('App\Domain\Event')) 399 | ->should(new NotResideInTheseNamespaces('App\Application', 'App\Infrastructure')) 400 | ->because('we want to be sure that all events not reside in wrong layers'); 401 | ``` 402 | 403 | You can also define components and ensure that a component: 404 | - should not depend on any component 405 | - may depend on specific components 406 | - may depend on any component 407 | 408 | Check out [this demo project](https://github.com/phparkitect/arkitect-demo) to get an idea on how write rules. 409 | 410 | 411 | # Rule Builders 412 | 413 | PHPArkitect offers some builders that enable you to implement more readable rules for specific contexts. 414 | 415 | ### Component Architecture Rule Builder 416 | 417 | Thanks to this builder you can define components and enforce dependency constraints between them in a more readable fashion. 418 | 419 | ```php 420 | component('Controller')->definedBy('App\Controller\*') 436 | ->component('Service')->definedBy('App\Service\*') 437 | ->component('Repository')->definedBy('App\Repository\*') 438 | ->component('Entity')->definedBy('App\Entity\*') 439 | ->component('Domain')->definedBy('App\Domain\*') 440 | 441 | ->where('Controller')->mayDependOnComponents('Service', 'Entity') 442 | ->where('Service')->mayDependOnComponents('Repository', 'Entity') 443 | ->where('Repository')->mayDependOnComponents('Entity') 444 | ->where('Entity')->shouldNotDependOnAnyComponent() 445 | ->where('Domain')->shouldOnlyDependOnComponents('Domain') 446 | 447 | ->rules(); 448 | 449 | // Other rule definitions... 450 | 451 | $config->add($classSet, $serviceNamingRule, $repositoryNamingRule, ...$layeredArchitectureRules); 452 | }; 453 | ``` 454 | 455 | ### Excluding classes when parser run 456 | If you want to exclude some classes from the parser you can use the `except` function inside your config file like this: 457 | 458 | ```php 459 | $rules[] = Rule::allClasses() 460 | ->except('App\Controller\FolderController\*') 461 | ->that(new ResideInOneOfTheseNamespaces('App\Controller')) 462 | ->should(new HaveNameMatching('*Controller')) 463 | ->because('we want uniform naming'); 464 | ``` 465 | 466 | You can use wildcards or the exact name of a class. 467 | 468 | ## Optional parameters and options 469 | You can add parameters when you launch the tool. At the moment you can add these parameters and options: 470 | * `-v` : with this option you launch Arkitect with the verbose mode to see every parsed file 471 | * `--config`: with this parameter, you can specify your config file instead of the default. like this: 472 | ``` 473 | phparkitect check --config=/project/yourConfigFile.php 474 | ``` 475 | * `--target-php-version`: With this parameter, you can specify which PHP version should use the parser. This can be useful to debug problems and to understand if there are problems with a different PHP version. 476 | Supported PHP versions are: 7.4, 8.0, 8.1, 8.2 8.3 477 | * `--stop-on-failure`: With this option the process will end immediately after the first violation. 478 | * `--autoload`: specify the path of an autoload file to be loaded when running phparkitect. 479 | 480 | ## Run only a specific rule 481 | For some reasons, you might want to run only a specific rule, you can do it using `runOnlyThis` like this: 482 | 483 | ```php 484 | $rules[] = Rule::allClasses() 485 | ->except('App\Controller\FolderController\*') 486 | ->that(new ResideInOneOfTheseNamespaces('App\Controller')) 487 | ->should(new HaveNameMatching('*Controller')) 488 | ->because('we want uniform naming') 489 | ->runOnlyThis(); 490 | ``` 491 | 492 | # Integrations 493 | 494 | ## Laravel 495 | If you plan to use Arkitect with Laravel, [smortexa](https://github.com/smortexa) wrote a nice wrapper with some predefined rules for laravel: https://github.com/smortexa/laravel-arkitect 496 | -------------------------------------------------------------------------------- /bin-stub/phparkitect: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | run(); -------------------------------------------------------------------------------- /box.json: -------------------------------------------------------------------------------- 1 | { 2 | "banner": [ 3 | "Arkitect" 4 | ], 5 | "directories": [ 6 | "src", 7 | "vendor" 8 | ], 9 | "files": [ 10 | "composer.json", 11 | "phparkitect-stub.php" 12 | ], 13 | "compression": "GZ", 14 | "compactors": [ 15 | "KevinGH\\Box\\Compactor\\Json" 16 | ], 17 | "main": "bin-stub/phparkitect", 18 | "output": "phparkitect.phar", 19 | "datetime": "release-date", 20 | "dump-autoload": true, 21 | "exclude-composer-files": false 22 | } -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phparkitect/phparkitect", 3 | "description": "Enforce architectural constraints in your PHP applications", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Pietro Campagnano", 9 | "email": "hello@pietro.camp" 10 | }, 11 | { 12 | "name": "Patrick Luca Fazzi", 13 | "email": "patrick91@live.it" 14 | }, 15 | { 16 | "name": "Alessandro Minoccheri", 17 | "email": "alessandro.minoccheri@gmail.com" 18 | }, 19 | { 20 | "name": "Michele Orselli", 21 | "email": "michele.orselli@gmail.com" 22 | } 23 | ], 24 | "require": { 25 | "php": "^7.4|^8", 26 | "symfony/finder": "^3.0|^4.0|^5.0|^6.0|^7.0", 27 | "symfony/event-dispatcher": "^3.0|^4.0|^5.0|^6.0|^7.0", 28 | "symfony/console": "^3.0|^4.0|^5.0|^6.0|^7.0", 29 | "symfony/polyfill-php80": "^1.20", 30 | "nikic/php-parser": "~5", 31 | "webmozart/assert": "^1.9", 32 | "ext-json": "*", 33 | "phpstan/phpdoc-parser": "^1.2|^2.0", 34 | "ondram/ci-detector": "^4.1" 35 | }, 36 | "require-dev": { 37 | "friendsofphp/php-cs-fixer": "^3.75", 38 | "mikey179/vfsstream": "^1.6", 39 | "phpspec/prophecy": "^1.10", 40 | "phpspec/prophecy-phpunit": "^2.3", 41 | "phpunit/phpunit": "^7.5|^9.0|^10.0", 42 | "roave/security-advisories": "dev-master", 43 | "symfony/var-dumper": "^3.0|^4.0|^5.0|^6.0|^7.0" 44 | }, 45 | "autoload": { 46 | "psr-4": { 47 | "Arkitect\\": "src/" 48 | } 49 | }, 50 | "autoload-dev": { 51 | "psr-4": { 52 | "Arkitect\\Tests\\": "tests/" 53 | } 54 | }, 55 | "config": { 56 | "bin-dir": "bin", 57 | "sort-packages": true 58 | }, 59 | "bin": [ 60 | "bin-stub/phparkitect" 61 | ] 62 | } 63 | -------------------------------------------------------------------------------- /composer.phar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phparkitect/arkitect/c09490a43fb3f4e497bdae7c2ab3df895db82f37/composer.phar -------------------------------------------------------------------------------- /phparkitect-stub.php: -------------------------------------------------------------------------------- 1 | component('Controller')->definedBy('App\Controller\*') 17 | ->component('Service')->definedBy('App\Service\*') 18 | ->component('Repository')->definedBy('App\Repository\*') 19 | ->component('Entity')->definedBy('App\Entity\*') 20 | 21 | ->where('Controller')->mayDependOnComponents('Service', 'Entity') 22 | ->where('Service')->mayDependOnComponents('Repository', 'Entity') 23 | ->where('Repository')->mayDependOnComponents('Entity') 24 | ->where('Entity')->shouldNotDependOnAnyComponent() 25 | 26 | ->rules(); 27 | 28 | $serviceNamingRule = Rule::allClasses() 29 | ->that(new ResideInOneOfTheseNamespaces('App\Service')) 30 | ->should(new HaveNameMatching('*Service')) 31 | ->because('we want uniform naming for services'); 32 | 33 | $repositoryNamingRule = Rule::allClasses() 34 | ->that(new ResideInOneOfTheseNamespaces('App\Repository')) 35 | ->should(new HaveNameMatching('*Repository')) 36 | ->because('we want uniform naming for repositories'); 37 | 38 | $config->add($classSet, $serviceNamingRule, $repositoryNamingRule, ...$layeredArchitectureRules); 39 | }; 40 | -------------------------------------------------------------------------------- /phparkitect.php: -------------------------------------------------------------------------------- 1 | that(new ResideInOneOfTheseNamespaces('Arkitect\Expression\ForClasses')) 19 | ->should(new Implement('Arkitect\Expression\Expression')) 20 | ->because('we want that all rules for classes implement Expression class.'); 21 | 22 | $rules[] = Rule::allClasses() 23 | ->that(new Extend('Symfony\Component\Console\Command\Command')) 24 | ->should(new ResideInOneOfTheseNamespaces('Arkitect\CLI\Command')) 25 | ->because('we want find easily all the commands'); 26 | 27 | $config 28 | ->add($classSet, ...$rules); 29 | }; 30 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/Analyzer/ClassDependency.php: -------------------------------------------------------------------------------- 1 | line = $line; 15 | $this->FQCN = FullyQualifiedClassName::fromString($FQCN); 16 | } 17 | 18 | public function matches(string $pattern): bool 19 | { 20 | return $this->FQCN->matches($pattern); 21 | } 22 | 23 | public function matchesOneOf(string ...$patterns): bool 24 | { 25 | foreach ($patterns as $pattern) { 26 | if ($this->FQCN->matches($pattern)) { 27 | return true; 28 | } 29 | } 30 | 31 | return false; 32 | } 33 | 34 | public function getLine(): int 35 | { 36 | return $this->line; 37 | } 38 | 39 | public function getFQCN(): FullyQualifiedClassName 40 | { 41 | return $this->FQCN; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Analyzer/ClassDescription.php: -------------------------------------------------------------------------------- 1 | */ 14 | private array $dependencies; 15 | 16 | /** @var list */ 17 | private array $interfaces; 18 | 19 | /** @var list */ 20 | private array $extends; 21 | 22 | /** @var list */ 23 | private array $docBlock; 24 | 25 | /** @var list */ 26 | private array $attributes; 27 | 28 | private bool $final; 29 | 30 | private bool $readonly; 31 | 32 | private bool $abstract; 33 | 34 | private bool $interface; 35 | 36 | private bool $trait; 37 | 38 | private bool $enum; 39 | 40 | /** 41 | * @param list $dependencies 42 | * @param list $interfaces 43 | * @param list $extends 44 | * @param list $attributes 45 | * @param list $docBlock 46 | */ 47 | public function __construct( 48 | FullyQualifiedClassName $FQCN, 49 | array $dependencies, 50 | array $interfaces, 51 | array $extends, 52 | bool $final, 53 | bool $readonly, 54 | bool $abstract, 55 | bool $interface, 56 | bool $trait, 57 | bool $enum, 58 | array $docBlock, 59 | array $attributes, 60 | string $filePath 61 | ) { 62 | $this->FQCN = $FQCN; 63 | $this->filePath = $filePath; 64 | $this->dependencies = $dependencies; 65 | $this->interfaces = $interfaces; 66 | $this->extends = $extends; 67 | $this->final = $final; 68 | $this->readonly = $readonly; 69 | $this->abstract = $abstract; 70 | $this->docBlock = $docBlock; 71 | $this->attributes = $attributes; 72 | $this->interface = $interface; 73 | $this->trait = $trait; 74 | $this->enum = $enum; 75 | } 76 | 77 | public static function getBuilder(string $FQCN, string $filePath): ClassDescriptionBuilder 78 | { 79 | $cb = new ClassDescriptionBuilder(); 80 | $cb->setClassName($FQCN); 81 | $cb->setFilePath($filePath); 82 | 83 | return $cb; 84 | } 85 | 86 | public function getName(): string 87 | { 88 | return $this->FQCN->className(); 89 | } 90 | 91 | public function getFQCN(): string 92 | { 93 | return $this->FQCN->toString(); 94 | } 95 | 96 | public function getFilePath(): string 97 | { 98 | return $this->filePath; 99 | } 100 | 101 | public function namespaceMatches(string $pattern): bool 102 | { 103 | return $this->FQCN->matches($pattern); 104 | } 105 | 106 | public function namespaceMatchesOneOfTheseNamespaces(array $classesToBeExcluded): bool 107 | { 108 | foreach ($classesToBeExcluded as $classToBeExcluded) { 109 | if ($this->namespaceMatches($classToBeExcluded)) { 110 | return true; 111 | } 112 | } 113 | 114 | return false; 115 | } 116 | 117 | /** 118 | * @return list 119 | */ 120 | public function getDependencies(): array 121 | { 122 | return $this->dependencies; 123 | } 124 | 125 | /** 126 | * @return list 127 | */ 128 | public function getInterfaces(): array 129 | { 130 | return $this->interfaces; 131 | } 132 | 133 | /** 134 | * @return list 135 | */ 136 | public function getExtends(): array 137 | { 138 | return $this->extends; 139 | } 140 | 141 | public function isFinal(): bool 142 | { 143 | return $this->final; 144 | } 145 | 146 | public function isReadonly(): bool 147 | { 148 | return $this->readonly; 149 | } 150 | 151 | public function isAbstract(): bool 152 | { 153 | return $this->abstract; 154 | } 155 | 156 | public function isInterface(): bool 157 | { 158 | return $this->interface; 159 | } 160 | 161 | public function isTrait(): bool 162 | { 163 | return $this->trait; 164 | } 165 | 166 | public function isEnum(): bool 167 | { 168 | return $this->enum; 169 | } 170 | 171 | public function getDocBlock(): array 172 | { 173 | return $this->docBlock; 174 | } 175 | 176 | public function containsDocBlock(string $search): bool 177 | { 178 | foreach ($this->docBlock as $docBlock) { 179 | if (str_contains($docBlock, $search)) { 180 | return true; 181 | } 182 | } 183 | 184 | return false; 185 | } 186 | 187 | /** 188 | * @return list 189 | */ 190 | public function getAttributes(): array 191 | { 192 | return $this->attributes; 193 | } 194 | 195 | public function hasAttribute(string $pattern): bool 196 | { 197 | return array_reduce( 198 | $this->attributes, 199 | static function (bool $carry, FullyQualifiedClassName $attribute) use ($pattern): bool { 200 | return $carry || $attribute->matches($pattern); 201 | }, 202 | false 203 | ); 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/Analyzer/ClassDescriptionBuilder.php: -------------------------------------------------------------------------------- 1 | */ 11 | private array $classDependencies = []; 12 | 13 | private ?FullyQualifiedClassName $FQCN = null; 14 | 15 | /** @var list */ 16 | private array $interfaces = []; 17 | 18 | /** @var list */ 19 | private array $extends = []; 20 | 21 | private bool $final = false; 22 | 23 | private bool $readonly = false; 24 | 25 | private bool $abstract = false; 26 | 27 | /** @var list */ 28 | private array $docBlock = []; 29 | 30 | /** @var list */ 31 | private array $attributes = []; 32 | 33 | private bool $interface = false; 34 | 35 | private bool $trait = false; 36 | 37 | private bool $enum = false; 38 | 39 | private ?string $filePath = null; 40 | 41 | public function clear(): void 42 | { 43 | $this->FQCN = null; 44 | $this->classDependencies = []; 45 | $this->interfaces = []; 46 | $this->extends = []; 47 | $this->final = false; 48 | $this->readonly = false; 49 | $this->abstract = false; 50 | $this->docBlock = []; 51 | $this->attributes = []; 52 | $this->interface = false; 53 | $this->trait = false; 54 | $this->enum = false; 55 | } 56 | 57 | public function setFilePath(?string $filePath): self 58 | { 59 | $this->filePath = $filePath; 60 | 61 | return $this; 62 | } 63 | 64 | public function setClassName(string $FQCN): self 65 | { 66 | $this->FQCN = FullyQualifiedClassName::fromString($FQCN); 67 | 68 | return $this; 69 | } 70 | 71 | public function addInterface(string $FQCN, int $line): self 72 | { 73 | $this->addDependency(new ClassDependency($FQCN, $line)); 74 | $this->interfaces[] = FullyQualifiedClassName::fromString($FQCN); 75 | 76 | return $this; 77 | } 78 | 79 | public function addDependency(ClassDependency $cd): self 80 | { 81 | $this->classDependencies[] = $cd; 82 | 83 | return $this; 84 | } 85 | 86 | public function addExtends(string $FQCN, int $line): self 87 | { 88 | $this->addDependency(new ClassDependency($FQCN, $line)); 89 | $this->extends[] = FullyQualifiedClassName::fromString($FQCN); 90 | 91 | return $this; 92 | } 93 | 94 | public function setFinal(bool $final): self 95 | { 96 | $this->final = $final; 97 | 98 | return $this; 99 | } 100 | 101 | public function setReadonly(bool $readonly): self 102 | { 103 | $this->readonly = $readonly; 104 | 105 | return $this; 106 | } 107 | 108 | public function setAbstract(bool $abstract): self 109 | { 110 | $this->abstract = $abstract; 111 | 112 | return $this; 113 | } 114 | 115 | public function setInterface(bool $interface): self 116 | { 117 | $this->interface = $interface; 118 | 119 | return $this; 120 | } 121 | 122 | public function setTrait(bool $trait): self 123 | { 124 | $this->trait = $trait; 125 | 126 | return $this; 127 | } 128 | 129 | public function setEnum(bool $enum): self 130 | { 131 | $this->enum = $enum; 132 | 133 | return $this; 134 | } 135 | 136 | public function addDocBlock(string $docBlock): self 137 | { 138 | $this->docBlock[] = $docBlock; 139 | 140 | return $this; 141 | } 142 | 143 | public function addAttribute(string $FQCN, int $line): self 144 | { 145 | $this->addDependency(new ClassDependency($FQCN, $line)); 146 | $this->attributes[] = FullyQualifiedClassName::fromString($FQCN); 147 | 148 | return $this; 149 | } 150 | 151 | public function build(): ClassDescription 152 | { 153 | Assert::notNull($this->FQCN, 'You must set an FQCN'); 154 | Assert::notNull($this->filePath, 'You must set a file path'); 155 | 156 | return new ClassDescription( 157 | $this->FQCN, 158 | $this->classDependencies, 159 | $this->interfaces, 160 | $this->extends, 161 | $this->final, 162 | $this->readonly, 163 | $this->abstract, 164 | $this->interface, 165 | $this->trait, 166 | $this->enum, 167 | $this->docBlock, 168 | $this->attributes, 169 | $this->filePath 170 | ); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/Analyzer/Docblock.php: -------------------------------------------------------------------------------- 1 | phpDocNode = $phpDocNode; 22 | } 23 | 24 | public function getParamTagTypesByName(string $name): ?string 25 | { 26 | foreach ($this->phpDocNode->getParamTagValues() as $paramTag) { 27 | if ($paramTag->parameterName === $name) { 28 | return $this->getType($paramTag->type); 29 | } 30 | } 31 | 32 | return null; 33 | } 34 | 35 | public function getReturnTagTypes(): array 36 | { 37 | $returnTypes = array_map( 38 | fn (ReturnTagValueNode $returnTag) => $this->getType($returnTag->type), 39 | $this->phpDocNode->getReturnTagValues() 40 | ); 41 | 42 | // remove null values 43 | return array_filter($returnTypes); 44 | } 45 | 46 | public function getVarTagTypes(): array 47 | { 48 | $varTypes = array_map( 49 | fn (VarTagValueNode $varTag) => $this->getType($varTag->type), 50 | $this->phpDocNode->getVarTagValues() 51 | ); 52 | 53 | // remove null values 54 | return array_filter($varTypes); 55 | } 56 | 57 | public function getDoctrineLikeAnnotationTypes(): array 58 | { 59 | $doctrineAnnotations = []; 60 | 61 | foreach ($this->phpDocNode->getTags() as $tag) { 62 | if ('@' === $tag->name[0] && !str_contains($tag->name, '@var')) { 63 | $doctrineAnnotations[] = str_replace('@', '', $tag->name); 64 | } 65 | } 66 | 67 | return $doctrineAnnotations; 68 | } 69 | 70 | private function getType(TypeNode $typeNode): ?string 71 | { 72 | if ($typeNode instanceof IdentifierTypeNode) { 73 | // this handles ClassName 74 | return $typeNode->name; 75 | } 76 | 77 | if ($typeNode instanceof GenericTypeNode) { 78 | // this handles list 79 | if (1 === \count($typeNode->genericTypes)) { 80 | return (string) $typeNode->genericTypes[0]; 81 | } 82 | 83 | // this handles array 84 | if (2 === \count($typeNode->genericTypes)) { 85 | return (string) $typeNode->genericTypes[1]; 86 | } 87 | } 88 | 89 | // this handles ClassName[] 90 | if ($typeNode instanceof ArrayTypeNode) { 91 | return (string) $typeNode->type; 92 | } 93 | 94 | return null; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Analyzer/DocblockParser.php: -------------------------------------------------------------------------------- 1 | innerParser = $innerParser; 19 | $this->innerLexer = $innerLexer; 20 | } 21 | 22 | public function parse(string $docblock): Docblock 23 | { 24 | $tokens = $this->innerLexer->tokenize($docblock); 25 | $tokenIterator = new TokenIterator($tokens); 26 | 27 | return new Docblock($this->innerParser->parse($tokenIterator)); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Analyzer/DocblockParserFactory.php: -------------------------------------------------------------------------------- 1 | nameContext = new NameContext(new ErrorHandler\Throwing()); 37 | 38 | $this->parseCustomAnnotations = $parseCustomAnnotations; 39 | 40 | $this->docblockParser = DocblockParserFactory::create(); 41 | } 42 | 43 | public function beforeTraverse(array $nodes): ?array 44 | { 45 | // this also clears the name context so there is not need to reinstantiate it 46 | $this->nameContext->startNamespace(); 47 | 48 | return null; 49 | } 50 | 51 | public function enterNode(Node $node): void 52 | { 53 | if ($node instanceof Stmt\Namespace_) { 54 | $this->nameContext->startNamespace($node->name); 55 | } 56 | 57 | if ($node instanceof Stmt\Use_) { 58 | $this->addAliases($node->uses, $node->type, null); 59 | } 60 | 61 | if ($node instanceof Stmt\GroupUse) { 62 | $this->addAliases($node->uses, $node->type, $node->prefix); 63 | } 64 | 65 | $this->resolveFunctionTypes($node); 66 | 67 | $this->resolvePropertyTypes($node); 68 | } 69 | 70 | private function resolvePropertyTypes(Node $node): void 71 | { 72 | if (!($node instanceof Stmt\Property)) { 73 | return; 74 | } 75 | 76 | $docblock = $this->parseDocblock($node); 77 | 78 | if (null === $docblock) { 79 | return; 80 | } 81 | 82 | $arrayItemType = $docblock->getVarTagTypes(); 83 | $arrayItemType = array_pop($arrayItemType); 84 | 85 | if ($this->isTypeClass($arrayItemType)) { 86 | $node->type = $this->resolveName(new Name($arrayItemType), Stmt\Use_::TYPE_NORMAL); 87 | 88 | return; 89 | } 90 | 91 | if ($this->parseCustomAnnotations && !($node->type instanceof FullyQualified)) { 92 | $doctrineAnnotations = $docblock->getDoctrineLikeAnnotationTypes(); 93 | $doctrineAnnotations = array_shift($doctrineAnnotations); 94 | 95 | if (null === $doctrineAnnotations) { 96 | return; 97 | } 98 | 99 | $node->type = $this->resolveName(new Name($doctrineAnnotations), Stmt\Use_::TYPE_NORMAL); 100 | } 101 | } 102 | 103 | private function resolveFunctionTypes(Node $node): void 104 | { 105 | if ( 106 | !($node instanceof Stmt\ClassMethod 107 | || $node instanceof Stmt\Function_ 108 | || $node instanceof Expr\Closure 109 | || $node instanceof Expr\ArrowFunction) 110 | ) { 111 | return; 112 | } 113 | 114 | $docblock = $this->parseDocblock($node); 115 | 116 | if (null === $docblock) { // no docblock, nothing to do 117 | return; 118 | } 119 | 120 | // extract param types from param tags 121 | foreach ($node->params as $param) { 122 | if (!$this->isTypeArray($param->type)) { // not an array, nothing to do 123 | continue; 124 | } 125 | 126 | if (!($param->var instanceof Expr\Variable) || !\is_string($param->var->name)) { 127 | continue; 128 | } 129 | 130 | $type = $docblock->getParamTagTypesByName('$'.$param->var->name); 131 | 132 | // we ignore any type which is not a class 133 | if (!$this->isTypeClass($type)) { 134 | continue; 135 | } 136 | 137 | $param->type = $this->resolveName(new Name($type), Stmt\Use_::TYPE_NORMAL); 138 | } 139 | 140 | // extract return type from return tag 141 | if ($this->isTypeArray($node->returnType)) { 142 | $type = $docblock->getReturnTagTypes(); 143 | $type = array_pop($type); 144 | 145 | // we ignore any type which is not a class 146 | if (!$this->isTypeClass($type)) { 147 | return; 148 | } 149 | 150 | $node->returnType = $this->resolveName(new Name($type), Stmt\Use_::TYPE_NORMAL); 151 | } 152 | } 153 | 154 | /** 155 | * Resolve name, according to name resolver options. 156 | * 157 | * @param Name $name Function or constant name to resolve 158 | * @param Stmt\Use_::TYPE_* $type One of Stmt\Use_::TYPE_* 159 | * 160 | * @return Name Resolved name, or original name with attribute 161 | */ 162 | private function resolveName(Name $name, int $type): Name 163 | { 164 | $resolvedName = $this->nameContext->getResolvedName($name, $type); 165 | 166 | if (null !== $resolvedName) { 167 | return $resolvedName; 168 | } 169 | 170 | return $name; 171 | } 172 | 173 | /** 174 | * @param array $uses 175 | */ 176 | private function addAliases(array $uses, int $type, ?Name $prefix = null): void 177 | { 178 | foreach ($uses as $useItem) { 179 | $this->addAlias($useItem, $type, $prefix); 180 | } 181 | } 182 | 183 | /** 184 | * @psalm-suppress PossiblyNullArgument 185 | * @psalm-suppress ArgumentTypeCoercion 186 | */ 187 | private function addAlias(Node\UseItem $use, int $type, ?Name $prefix = null): void 188 | { 189 | // Add prefix for group uses 190 | $name = $prefix ? Name::concat($prefix, $use->name) : $use->name; 191 | // Type is determined either by individual element or whole use declaration 192 | $type |= $use->type; 193 | 194 | $this->nameContext->addAlias( 195 | $name, 196 | (string) $use->getAlias(), 197 | $type, 198 | $use->getAttributes() 199 | ); 200 | } 201 | 202 | private function parseDocblock(NodeAbstract $node): ?Docblock 203 | { 204 | if (null === $node->getDocComment()) { 205 | return null; 206 | } 207 | 208 | /** @var Doc $docComment */ 209 | $docComment = $node->getDocComment(); 210 | 211 | return $this->docblockParser->parse($docComment->getText()); 212 | } 213 | 214 | /** 215 | * @param Node\Identifier|Name|Node\ComplexType|null $type 216 | */ 217 | private function isTypeArray($type): bool 218 | { 219 | return null !== $type && isset($type->name) && 'array' === $type->name; 220 | } 221 | 222 | /** 223 | * @psalm-assert-if-true string $fqcn 224 | */ 225 | private function isTypeClass(?string $fqcn): bool 226 | { 227 | if (null === $fqcn) { 228 | return false; 229 | } 230 | 231 | $validFqcn = '/^[a-zA-Z0-9_\x7f-\xff\\\\]*[a-zA-Z0-9_\x7f-\xff]$/'; 232 | 233 | return (bool) preg_match($validFqcn, $fqcn); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/Analyzer/FileParser.php: -------------------------------------------------------------------------------- 1 | */ 25 | private array $parsingErrors; 26 | 27 | public function __construct( 28 | NodeTraverser $traverser, 29 | FileVisitor $fileVisitor, 30 | NameResolver $nameResolver, 31 | DocblockTypesResolver $docblockTypesResolver, 32 | TargetPhpVersion $targetPhpVersion 33 | ) { 34 | $this->fileVisitor = $fileVisitor; 35 | $this->parsingErrors = []; 36 | 37 | $this->parser = (new ParserFactory())->createForVersion(PhpVersion::fromString($targetPhpVersion->get())); 38 | $this->traverser = $traverser; 39 | $this->traverser->addVisitor($nameResolver); 40 | $this->traverser->addVisitor($docblockTypesResolver); 41 | $this->traverser->addVisitor($this->fileVisitor); 42 | } 43 | 44 | /** 45 | * @return array 46 | */ 47 | public function getClassDescriptions(): array 48 | { 49 | return $this->fileVisitor->getClassDescriptions(); 50 | } 51 | 52 | public function parse(string $fileContent, string $filename): void 53 | { 54 | $this->parsingErrors = []; 55 | try { 56 | $this->fileVisitor->clearParsedClassDescriptions(); 57 | $this->fileVisitor->setFilePath($filename); 58 | 59 | $errorHandler = new Collecting(); 60 | $stmts = $this->parser->parse($fileContent, $errorHandler); 61 | 62 | if ($errorHandler->hasErrors()) { 63 | foreach ($errorHandler->getErrors() as $error) { 64 | $this->parsingErrors[] = ParsingError::create($filename, $error->getMessage()); 65 | } 66 | } 67 | 68 | if (null === $stmts) { 69 | return; 70 | } 71 | 72 | $this->traverser->traverse($stmts); 73 | } catch (\Throwable $e) { 74 | echo 'Parse Error: ', $e->getMessage(); 75 | print_r($e->getTraceAsString()); 76 | } 77 | } 78 | 79 | public function getParsingErrors(): array 80 | { 81 | return $this->parsingErrors; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Analyzer/FileParserFactory.php: -------------------------------------------------------------------------------- 1 | path; 13 | } 14 | 15 | public function set(string $path): void 16 | { 17 | $this->path = $path; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Analyzer/FileVisitor.php: -------------------------------------------------------------------------------- 1 | */ 16 | private array $classDescriptions = []; 17 | 18 | public function __construct(ClassDescriptionBuilder $classDescriptionBuilder) 19 | { 20 | $this->classDescriptionBuilder = $classDescriptionBuilder; 21 | } 22 | 23 | public function setFilePath(?string $filePath): void 24 | { 25 | $this->classDescriptionBuilder->setFilePath($filePath); 26 | } 27 | 28 | public function enterNode(Node $node): void 29 | { 30 | $this->handleClassNode($node); 31 | 32 | // handles anonymous class definition like new class() {} 33 | $this->handleAnonClassNode($node); 34 | 35 | // handles enum definition 36 | $this->handleEnumNode($node); 37 | 38 | // handles interface definition like interface MyInterface {} 39 | $this->handleInterfaceNode($node); 40 | 41 | // handles trait definition like trait MyTrait {} 42 | $this->handleTraitNode($node); 43 | 44 | // handles code like $constantValue = StaticClass::constant; 45 | $this->handleStaticClassConstantNode($node); 46 | 47 | // handles code like $static = StaticClass::foo(); 48 | $this->handleStaticClassCallsNode($node); 49 | 50 | // handles code lik $a instanceof MyClass 51 | $this->handleInstanceOf($node); 52 | 53 | // handles code like $a = new MyClass(); 54 | $this->handleNewExpression($node); 55 | 56 | // handles code like public MyClass $myClass; 57 | $this->handleTypedProperty($node); 58 | 59 | // handles docblock like /** @var MyClass $myClass */ 60 | $this->handleDocComment($node); 61 | 62 | // handles code like public function myMethod(MyClass $myClass) {} 63 | $this->handleParamDependency($node); 64 | 65 | // handles code like public function myMethod(): MyClass {} 66 | $this->handleReturnTypeDependency($node); 67 | 68 | // handles attribute definition like #[MyAttribute] 69 | $this->handleAttributeNode($node); 70 | } 71 | 72 | public function getClassDescriptions(): array 73 | { 74 | return $this->classDescriptions; 75 | } 76 | 77 | public function clearParsedClassDescriptions(): void 78 | { 79 | $this->classDescriptions = []; 80 | $this->classDescriptionBuilder->setFilePath(null); 81 | $this->classDescriptionBuilder->clear(); 82 | } 83 | 84 | public function leaveNode(Node $node): void 85 | { 86 | if ($node instanceof Node\Stmt\Class_ && !$node->isAnonymous()) { 87 | $this->classDescriptions[] = $this->classDescriptionBuilder->build(); 88 | $this->classDescriptionBuilder->clear(); 89 | } 90 | 91 | if ($node instanceof Node\Stmt\Enum_) { 92 | $this->classDescriptions[] = $this->classDescriptionBuilder->build(); 93 | $this->classDescriptionBuilder->clear(); 94 | } 95 | 96 | if ($node instanceof Node\Stmt\Interface_) { 97 | $this->classDescriptions[] = $this->classDescriptionBuilder->build(); 98 | $this->classDescriptionBuilder->clear(); 99 | } 100 | 101 | if ($node instanceof Node\Stmt\Trait_) { 102 | $this->classDescriptions[] = $this->classDescriptionBuilder->build(); 103 | $this->classDescriptionBuilder->clear(); 104 | } 105 | } 106 | 107 | private function handleClassNode(Node $node): void 108 | { 109 | if (!($node instanceof Node\Stmt\Class_)) { 110 | return; 111 | } 112 | 113 | if ($node->isAnonymous()) { 114 | return; 115 | } 116 | 117 | if (null !== $node->namespacedName) { 118 | $this->classDescriptionBuilder->setClassName($node->namespacedName->toCodeString()); 119 | } 120 | 121 | foreach ($node->implements as $interface) { 122 | $this->classDescriptionBuilder 123 | ->addInterface($interface->toString(), $interface->getLine()); 124 | } 125 | 126 | if (null !== $node->extends) { 127 | $this->classDescriptionBuilder 128 | ->addExtends($node->extends->toString(), $node->getLine()); 129 | } 130 | 131 | $this->classDescriptionBuilder->setFinal($node->isFinal()); 132 | 133 | $this->classDescriptionBuilder->setReadonly($node->isReadonly()); 134 | 135 | $this->classDescriptionBuilder->setAbstract($node->isAbstract()); 136 | } 137 | 138 | private function handleAnonClassNode(Node $node): void 139 | { 140 | if (!($node instanceof Node\Stmt\Class_)) { 141 | return; 142 | } 143 | 144 | if (!$node->isAnonymous()) { 145 | return; 146 | } 147 | 148 | foreach ($node->implements as $interface) { 149 | $this->classDescriptionBuilder 150 | ->addDependency(new ClassDependency($interface->toString(), $interface->getLine())); 151 | } 152 | 153 | if (null !== $node->extends) { 154 | $this->classDescriptionBuilder 155 | ->addDependency(new ClassDependency($node->extends->toString(), $node->getLine())); 156 | } 157 | } 158 | 159 | private function handleEnumNode(Node $node): void 160 | { 161 | if (!($node instanceof Node\Stmt\Enum_)) { 162 | return; 163 | } 164 | 165 | if (null == $node->namespacedName) { 166 | return; 167 | } 168 | 169 | $this->classDescriptionBuilder->setClassName($node->namespacedName->toCodeString()); 170 | $this->classDescriptionBuilder->setEnum(true); 171 | 172 | foreach ($node->implements as $interface) { 173 | $this->classDescriptionBuilder 174 | ->addInterface($interface->toString(), $interface->getLine()); 175 | } 176 | } 177 | 178 | private function handleStaticClassConstantNode(Node $node): void 179 | { 180 | if (!($node instanceof Node\Expr\ClassConstFetch)) { 181 | return; 182 | } 183 | 184 | if (!($node->class instanceof Node\Name\FullyQualified)) { 185 | return; 186 | } 187 | 188 | $this->classDescriptionBuilder 189 | ->addDependency(new ClassDependency($node->class->toString(), $node->getLine())); 190 | } 191 | 192 | private function handleStaticClassCallsNode(Node $node): void 193 | { 194 | if (!($node instanceof Node\Expr\StaticCall)) { 195 | return; 196 | } 197 | 198 | if (!($node->class instanceof Node\Name\FullyQualified)) { 199 | return; 200 | } 201 | 202 | $this->classDescriptionBuilder 203 | ->addDependency(new ClassDependency($node->class->toString(), $node->getLine())); 204 | } 205 | 206 | private function handleInstanceOf(Node $node): void 207 | { 208 | if (!($node instanceof Node\Expr\Instanceof_)) { 209 | return; 210 | } 211 | 212 | if (!($node->class instanceof Node\Name\FullyQualified)) { 213 | return; 214 | } 215 | 216 | $this->classDescriptionBuilder 217 | ->addDependency(new ClassDependency($node->class->toString(), $node->getLine())); 218 | } 219 | 220 | private function handleNewExpression(Node $node): void 221 | { 222 | if (!($node instanceof Node\Expr\New_)) { 223 | return; 224 | } 225 | 226 | if (!($node->class instanceof Node\Name\FullyQualified)) { 227 | return; 228 | } 229 | 230 | $this->classDescriptionBuilder 231 | ->addDependency(new ClassDependency($node->class->toString(), $node->getLine())); 232 | } 233 | 234 | private function handleTypedProperty(Node $node): void 235 | { 236 | if (!($node instanceof Node\Stmt\Property)) { 237 | return; 238 | } 239 | 240 | if (null === $node->type) { 241 | return; 242 | } 243 | 244 | $type = $node->type instanceof NullableType ? $node->type->type : $node->type; 245 | 246 | if (!($type instanceof Node\Name\FullyQualified)) { 247 | return; 248 | } 249 | 250 | $this->classDescriptionBuilder 251 | ->addDependency(new ClassDependency($type->toString(), $node->getLine())); 252 | } 253 | 254 | private function handleDocComment(Node $node): void 255 | { 256 | $docComment = $node->getDocComment(); 257 | 258 | if (null === $docComment) { 259 | return; 260 | } 261 | 262 | $this->classDescriptionBuilder->addDocBlock($docComment->getText()); 263 | } 264 | 265 | private function handleParamDependency(Node $node): void 266 | { 267 | if ($node instanceof Node\Param) { 268 | $this->addParamDependency($node); 269 | } 270 | } 271 | 272 | private function handleInterfaceNode(Node $node): void 273 | { 274 | if (!($node instanceof Node\Stmt\Interface_)) { 275 | return; 276 | } 277 | 278 | if (null === $node->namespacedName) { 279 | return; 280 | } 281 | 282 | $this->classDescriptionBuilder->setClassName($node->namespacedName->toCodeString()); 283 | $this->classDescriptionBuilder->setInterface(true); 284 | 285 | foreach ($node->extends as $interface) { 286 | $this->classDescriptionBuilder 287 | ->addExtends($interface->toString(), $interface->getLine()); 288 | } 289 | } 290 | 291 | private function handleTraitNode(Node $node): void 292 | { 293 | if (!($node instanceof Node\Stmt\Trait_)) { 294 | return; 295 | } 296 | 297 | if (null === $node->namespacedName) { 298 | return; 299 | } 300 | 301 | $this->classDescriptionBuilder->setClassName($node->namespacedName->toCodeString()); 302 | $this->classDescriptionBuilder->setTrait(true); 303 | } 304 | 305 | private function handleReturnTypeDependency(Node $node): void 306 | { 307 | if (!($node instanceof Node\Stmt\ClassMethod)) { 308 | return; 309 | } 310 | 311 | $returnType = $node->returnType; 312 | 313 | if (!($returnType instanceof Node\Name\FullyQualified)) { 314 | return; 315 | } 316 | 317 | $this->classDescriptionBuilder 318 | ->addDependency(new ClassDependency($returnType->toString(), $returnType->getLine())); 319 | } 320 | 321 | private function handleAttributeNode(Node $node): void 322 | { 323 | if (!($node instanceof Node\Attribute)) { 324 | return; 325 | } 326 | 327 | $nodeName = $node->name; 328 | 329 | if (!($nodeName instanceof Node\Name\FullyQualified)) { 330 | return; 331 | } 332 | 333 | $this->classDescriptionBuilder 334 | ->addAttribute($node->name->toString(), $node->getLine()); 335 | } 336 | 337 | private function addParamDependency(Node\Param $node): void 338 | { 339 | if (null === $node->type || $node->type instanceof Node\Identifier) { 340 | return; 341 | } 342 | 343 | $type = $node->type instanceof NullableType ? $node->type->type : $node->type; 344 | 345 | if (!($type instanceof Node\Name\FullyQualified)) { 346 | return; 347 | } 348 | 349 | $this->classDescriptionBuilder 350 | ->addDependency(new ClassDependency($type->toString(), $node->getLine())); 351 | } 352 | } 353 | -------------------------------------------------------------------------------- /src/Analyzer/FullyQualifiedClassName.php: -------------------------------------------------------------------------------- 1 | fqcnString = $fqcnString; 19 | $this->namespace = $namespace; 20 | $this->class = $class; 21 | } 22 | 23 | public function toString(): string 24 | { 25 | return $this->fqcnString->toString(); 26 | } 27 | 28 | public function classMatches(string $pattern): bool 29 | { 30 | if ($this->isNotAValidPattern($pattern)) { 31 | throw new InvalidPatternException("'$pattern' is not a valid class or namespace pattern. Regex are not allowed, only * and ? wildcard."); 32 | } 33 | 34 | return $this->class->matches($pattern); 35 | } 36 | 37 | public function matches(string $pattern): bool 38 | { 39 | if ($this->isNotAValidPattern($pattern)) { 40 | throw new InvalidPatternException("'$pattern' is not a valid class or namespace pattern. Regex are not allowed, only * and ? wildcard."); 41 | } 42 | 43 | return $this->fqcnString->matches($pattern); 44 | } 45 | 46 | public function className(): string 47 | { 48 | return $this->class->toString(); 49 | } 50 | 51 | public function namespace(): string 52 | { 53 | return $this->namespace->toString(); 54 | } 55 | 56 | public static function fromString(string $fqcn): self 57 | { 58 | $validFqcn = '/^[a-zA-Z0-9_\x7f-\xff\\\\]*[a-zA-Z0-9_\x7f-\xff]$/'; 59 | 60 | if (!(bool) preg_match($validFqcn, $fqcn)) { 61 | throw new \RuntimeException("$fqcn is not a valid namespace definition"); 62 | } 63 | 64 | $pieces = explode('\\', $fqcn); 65 | $piecesWithoutEmpty = array_filter($pieces); 66 | $className = array_pop($piecesWithoutEmpty); 67 | $namespace = implode('\\', $piecesWithoutEmpty); 68 | 69 | return new self(new PatternString($fqcn), new PatternString($namespace), new PatternString($className)); 70 | } 71 | 72 | public function isNotAValidPattern(string $pattern): bool 73 | { 74 | $validClassNameCharacters = '[a-zA-Z0-9_\x80-\xff]'; 75 | $or = '|'; 76 | $backslash = '\\\\'; 77 | 78 | return 0 === preg_match('/^('.$validClassNameCharacters.$or.$backslash.$or.'\*'.$or.'\?)*$/', $pattern); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Analyzer/Parser.php: -------------------------------------------------------------------------------- 1 | value = $value; 13 | } 14 | 15 | public function matches(string $pattern): bool 16 | { 17 | if ('' === $pattern) { 18 | return false; 19 | } 20 | 21 | if (!$this->containsWildcard($pattern)) { 22 | $slashTerminatedPattern = str_ends_with($pattern, '\\') ? $pattern : $pattern.'\\'; 23 | $isInThisNamespace = str_starts_with($this->value, $slashTerminatedPattern); 24 | $isThisClass = $this->value == $pattern; 25 | 26 | return $isInThisNamespace || $isThisClass; 27 | } 28 | 29 | return fnmatch($pattern, $this->value, \FNM_NOESCAPE); 30 | } 31 | 32 | public function toString(): string 33 | { 34 | return $this->value; 35 | } 36 | 37 | private function containsWildcard(string $pattern): bool 38 | { 39 | return 40 | str_contains($pattern, '*') 41 | || str_contains($pattern, '?') 42 | || str_contains($pattern, '.') 43 | || str_contains($pattern, '['); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/CLI/AnalysisResult.php: -------------------------------------------------------------------------------- 1 | violations = $violations; 18 | $this->parsingErrors = $parsingErrors; 19 | } 20 | 21 | public function getViolations(): Violations 22 | { 23 | return $this->violations; 24 | } 25 | 26 | public function getParsingErrors(): ParsingErrors 27 | { 28 | return $this->parsingErrors; 29 | } 30 | 31 | public function hasErrors(): bool 32 | { 33 | return $this->hasViolations() || $this->hasParsingErrors(); 34 | } 35 | 36 | public function hasViolations(): bool 37 | { 38 | return $this->violations->count() > 0; 39 | } 40 | 41 | public function hasParsingErrors(): bool 42 | { 43 | return $this->parsingErrors->count() > 0; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/CLI/Baseline.php: -------------------------------------------------------------------------------- 1 | violations = $violations; 17 | $this->filename = $filename; 18 | } 19 | 20 | public function getFilename(): string 21 | { 22 | return $this->filename; 23 | } 24 | 25 | public function applyTo(Violations $violations, bool $ignoreBaselineLinenumbers): void 26 | { 27 | $violations->remove($this->violations, $ignoreBaselineLinenumbers); 28 | } 29 | 30 | /** 31 | * @psalm-suppress RiskyTruthyFalsyComparison 32 | */ 33 | public static function resolveFilePath(?string $filePath, string $defaultFilePath): ?string 34 | { 35 | if (!$filePath && file_exists($defaultFilePath)) { 36 | $filePath = $defaultFilePath; 37 | } 38 | 39 | return $filePath ?: null; 40 | } 41 | 42 | public static function empty(): self 43 | { 44 | return new self(new Violations(), ''); 45 | } 46 | 47 | public static function create(bool $skipBaseline, ?string $baselineFilePath): self 48 | { 49 | if ($skipBaseline || null === $baselineFilePath) { 50 | return self::empty(); 51 | } 52 | 53 | return self::loadFromFile($baselineFilePath); 54 | } 55 | 56 | public static function loadFromFile(string $filename): self 57 | { 58 | if (!file_exists($filename)) { 59 | throw new \RuntimeException("Baseline file '$filename' not found."); 60 | } 61 | 62 | return new self( 63 | Violations::fromJson(file_get_contents($filename)), 64 | $filename 65 | ); 66 | } 67 | 68 | public static function save(?string $filename, string $defaultFilePath, Violations $violations): string 69 | { 70 | if (null === $filename) { 71 | $filename = $defaultFilePath; 72 | } 73 | 74 | file_put_contents($filename, json_encode($violations, \JSON_PRETTY_PRINT)); 75 | 76 | return $filename; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/CLI/Command/Check.php: -------------------------------------------------------------------------------- 1 | setDescription('Check that architectural rules are matched.') 51 | ->setHelp('This command allows you check that architectural rules defined in your config file are matched.') 52 | ->addOption( 53 | self::CONFIG_FILENAME_PARAM, 54 | 'c', 55 | InputOption::VALUE_OPTIONAL, 56 | 'File containing configs, such as rules to be matched', 57 | self::DEFAULT_RULES_FILENAME 58 | ) 59 | ->addOption( 60 | self::TARGET_PHP_PARAM, 61 | 't', 62 | InputOption::VALUE_OPTIONAL, 63 | 'Target php version to use for parsing' 64 | ) 65 | ->addOption( 66 | self::STOP_ON_FAILURE_PARAM, 67 | 's', 68 | InputOption::VALUE_NONE, 69 | 'Stop on failure' 70 | ) 71 | ->addOption( 72 | self::GENERATE_BASELINE_PARAM, 73 | 'g', 74 | InputOption::VALUE_OPTIONAL, 75 | 'Generate a file containing the current errors', 76 | false 77 | ) 78 | ->addOption( 79 | self::USE_BASELINE_PARAM, 80 | 'b', 81 | InputOption::VALUE_REQUIRED, 82 | 'Ignore errors in baseline file' 83 | ) 84 | ->addOption( 85 | self::SKIP_BASELINE_PARAM, 86 | 'k', 87 | InputOption::VALUE_NONE, 88 | 'Don\'t use the default baseline' 89 | ) 90 | ->addOption( 91 | self::IGNORE_BASELINE_LINENUMBERS_PARAM, 92 | 'i', 93 | InputOption::VALUE_NONE, 94 | 'Ignore line numbers when checking the baseline' 95 | ) 96 | ->addOption( 97 | self::FORMAT_PARAM, 98 | 'f', 99 | InputOption::VALUE_OPTIONAL, 100 | 'Output format: text (default), json, gitlab', 101 | 'text' 102 | ) 103 | ->addOption( 104 | self::AUTOLOAD_PARAM, 105 | 'a', 106 | InputOption::VALUE_REQUIRED, 107 | 'Specify an autoload file to use', 108 | ); 109 | } 110 | 111 | protected function execute(InputInterface $input, OutputInterface $output): int 112 | { 113 | ini_set('memory_limit', '-1'); 114 | ini_set('xdebug.max_nesting_level', '10000'); 115 | $startTime = microtime(true); 116 | 117 | try { 118 | $verbose = (bool) $input->getOption('verbose'); 119 | $rulesFilename = $input->getOption(self::CONFIG_FILENAME_PARAM); 120 | $stopOnFailure = (bool) $input->getOption(self::STOP_ON_FAILURE_PARAM); 121 | $useBaseline = (string) $input->getOption(self::USE_BASELINE_PARAM); 122 | $skipBaseline = (bool) $input->getOption(self::SKIP_BASELINE_PARAM); 123 | $ignoreBaselineLinenumbers = (bool) $input->getOption(self::IGNORE_BASELINE_LINENUMBERS_PARAM); 124 | $generateBaseline = $input->getOption(self::GENERATE_BASELINE_PARAM); 125 | $phpVersion = $input->getOption('target-php-version'); 126 | $format = $input->getOption(self::FORMAT_PARAM); 127 | 128 | // we write everything on STDERR apart from the list of violations which goes on STDOUT 129 | // this allows to pipe the output of this command to a file while showing output on the terminal 130 | $stdOut = $output; 131 | $output = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; 132 | 133 | $this->printHeadingLine($output); 134 | 135 | $config = ConfigBuilder::loadFromFile($rulesFilename) 136 | ->autoloadFilePath($input->getOption(self::AUTOLOAD_PARAM)) 137 | ->stopOnFailure($stopOnFailure) 138 | ->targetPhpVersion(TargetPhpVersion::create($phpVersion)) 139 | ->baselineFilePath(Baseline::resolveFilePath($useBaseline, self::DEFAULT_BASELINE_FILENAME)) 140 | ->ignoreBaselineLinenumbers($ignoreBaselineLinenumbers) 141 | ->skipBaseline($skipBaseline) 142 | ->format($format); 143 | 144 | $this->requireAutoload($output, $config->getAutoloadFilePath()); 145 | $printer = $this->createPrinter($output, $config->getFormat()); 146 | $progress = $this->createProgress($output, $verbose); 147 | $baseline = $this->createBaseline($output, $config->isSkipBaseline(), $config->getBaselineFilePath()); 148 | 149 | $output->writeln("Config file '$rulesFilename' found\n"); 150 | 151 | $runner = new Runner(); 152 | 153 | if (false !== $generateBaseline) { 154 | $result = $runner->baseline($config, $progress); 155 | 156 | $baselineFilePath = Baseline::save($generateBaseline, self::DEFAULT_BASELINE_FILENAME, $result->getViolations()); 157 | 158 | $output->writeln("ℹ️ Baseline file '$baselineFilePath' created!"); 159 | 160 | return self::SUCCESS_CODE; 161 | } 162 | 163 | $result = $runner->run($config, $baseline, $progress); 164 | 165 | // we always print this so we do not have to do additional ifs later 166 | $stdOut->writeln($printer->print($result->getViolations()->groupedByFqcn())); 167 | 168 | if ($result->hasViolations()) { 169 | $output->writeln("⚠️ {$result->getViolations()->count()} violations detected!"); 170 | } 171 | 172 | if ($result->hasParsingErrors()) { 173 | $output->writeln('❌ could not parse these files:'); 174 | $output->writeln($result->getParsingErrors()->toString()); 175 | } 176 | 177 | !$result->hasErrors() && $output->writeln('✅ No violations detected'); 178 | 179 | return $result->hasErrors() ? self::ERROR_CODE : self::SUCCESS_CODE; 180 | } catch (\Throwable $e) { 181 | $output->writeln("❌ {$e->getMessage()}"); 182 | 183 | return self::ERROR_CODE; 184 | } finally { 185 | $this->printExecutionTime($output, $startTime); 186 | } 187 | } 188 | 189 | /** 190 | * @psalm-suppress UnresolvableInclude 191 | */ 192 | protected function requireAutoload(OutputInterface $output, ?string $filePath): void 193 | { 194 | if (null === $filePath) { 195 | return; 196 | } 197 | 198 | Assert::file($filePath, "Cannot find '$filePath'"); 199 | 200 | require_once $filePath; 201 | 202 | $output->writeln("Autoload file '$filePath' added"); 203 | } 204 | 205 | protected function createPrinter(OutputInterface $output, string $format): Printer 206 | { 207 | $output->writeln("Output format: $format"); 208 | 209 | return PrinterFactory::create($format); 210 | } 211 | 212 | protected function createProgress(OutputInterface $output, bool $verbose): Progress 213 | { 214 | $output->writeln('Progress: '.($verbose ? 'debug' : 'bar')); 215 | 216 | return $verbose ? new DebugProgress($output) : new ProgressBarProgress($output); 217 | } 218 | 219 | protected function createBaseline(OutputInterface $output, bool $skipBaseline, ?string $baselineFilePath): Baseline 220 | { 221 | $baseline = Baseline::create($skipBaseline, $baselineFilePath); 222 | 223 | $baseline->getFilename() && $output->writeln("Baseline file '{$baseline->getFilename()}' found"); 224 | 225 | return $baseline; 226 | } 227 | 228 | protected function printHeadingLine(OutputInterface $output): void 229 | { 230 | $app = $this->getApplication(); 231 | 232 | $version = $app ? $app->getVersion() : 'unknown'; 233 | 234 | $output->writeln("PHPArkitect $version\n"); 235 | } 236 | 237 | protected function printExecutionTime(OutputInterface $output, float $startTime): void 238 | { 239 | $endTime = microtime(true); 240 | $executionTime = number_format($endTime - $startTime, 2); 241 | 242 | $output->writeln("⏱️ Execution time: $executionTime\n"); 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /src/CLI/Command/DebugExpression.php: -------------------------------------------------------------------------------- 1 | setHelp(self::$help) 38 | ->addArgument('expression', InputArgument::REQUIRED) 39 | ->addArgument('arguments', InputArgument::IS_ARRAY) 40 | ->addOption( 41 | 'from-dir', 42 | 'd', 43 | InputOption::VALUE_REQUIRED, 44 | 'The folder in which to search the classes', 45 | '.' 46 | ) 47 | ->addOption( 48 | 'target-php-version', 49 | 't', 50 | InputOption::VALUE_OPTIONAL, 51 | 'Target php version to use for parsing' 52 | ); 53 | } 54 | 55 | protected function execute(InputInterface $input, OutputInterface $output): int 56 | { 57 | $fileParser = $this->getParser($input); 58 | 59 | $classSet = ClassSet::fromDir($input->getOption('from-dir')); 60 | foreach ($classSet as $file) { 61 | $fileParser->parse($file->getContents(), $file->getRelativePathname()); 62 | 63 | $this->showParsingErrors($fileParser, $output); 64 | 65 | $ruleName = $input->getArgument('expression'); 66 | /** @var class-string $ruleFQCN */ 67 | $ruleFQCN = 'Arkitect\Expression\ForClasses\\'.$ruleName; 68 | $arguments = $input->getArgument('arguments'); 69 | 70 | $argumentError = $this->getArgumentsError($arguments, $ruleName, $ruleFQCN); 71 | if (null !== $argumentError) { 72 | $output->writeln($argumentError); 73 | 74 | return 2; 75 | } 76 | 77 | $rule = new $ruleFQCN(...$arguments); 78 | foreach ($fileParser->getClassDescriptions() as $classDescription) { 79 | $violations = new Violations(); 80 | $rule->evaluate($classDescription, $violations, ''); 81 | if (0 === $violations->count()) { 82 | $output->writeln($classDescription->getFQCN()); 83 | } 84 | } 85 | } 86 | 87 | return 0; 88 | } 89 | 90 | /** 91 | * @throws \Arkitect\Exceptions\PhpVersionNotValidException 92 | */ 93 | private function getParser(InputInterface $input): \Arkitect\Analyzer\FileParser 94 | { 95 | $phpVersion = $input->getOption('target-php-version'); 96 | $targetPhpVersion = TargetPhpVersion::create($phpVersion); 97 | $fileParser = FileParserFactory::createFileParser($targetPhpVersion); 98 | 99 | return $fileParser; 100 | } 101 | 102 | private function showParsingErrors(\Arkitect\Analyzer\FileParser $fileParser, OutputInterface $output): void 103 | { 104 | $parsedErrors = $fileParser->getParsingErrors(); 105 | 106 | if (\count($parsedErrors) > 0) { 107 | $output->writeln('WARNING: Some files could not be parsed for these errors:'); 108 | /** @var ParsingError $parsedError */ 109 | foreach ($parsedErrors as $parsedError) { 110 | $output->writeln(' - '.$parsedError->getError().': '.$parsedError->getRelativeFilePath()); 111 | } 112 | $output->writeln(''); 113 | } 114 | } 115 | 116 | private function getArgumentsError(array $arguments, string $ruleName, string $ruleFQCN): ?string 117 | { 118 | try { 119 | /** @var class-string $ruleFQCN */ 120 | $expressionReflection = new \ReflectionClass($ruleFQCN); 121 | } catch (\ReflectionException $exception) { 122 | return "Error: Expression '$ruleName' not found."; 123 | } 124 | 125 | $constructorReflection = $expressionReflection->getConstructor(); 126 | if (null === $constructorReflection) { 127 | $maxNumberOfArguments = 0; 128 | $minNumberOfArguments = 0; 129 | } else { 130 | $maxNumberOfArguments = $constructorReflection->getNumberOfParameters(); 131 | $minNumberOfArguments = $constructorReflection->getNumberOfRequiredParameters(); 132 | } 133 | 134 | if (\count($arguments) < $minNumberOfArguments) { 135 | return "Error: Too few arguments for '$ruleName'."; 136 | } 137 | 138 | if (\count($arguments) > $maxNumberOfArguments) { 139 | return "Error: Too many arguments for '$ruleName'."; 140 | } 141 | 142 | return null; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/CLI/Command/Init.php: -------------------------------------------------------------------------------- 1 | -d /dest/path 23 | EOT; 24 | 25 | public function __construct() 26 | { 27 | parent::__construct('init'); 28 | } 29 | 30 | protected function configure(): void 31 | { 32 | $this 33 | ->addUsage('creates a phparkitect.php file in the current dir') 34 | ->addUsage('--dest-dir=/path/to/dir creates a phparkitect.php file in /path/to/dir') 35 | ->addUsage('-d /path/to/dir creates a phparkitect.php file in /path/to/dir') 36 | ->setHelp(self::$help) 37 | ->addOption( 38 | 'dest-dir', 39 | 'd', 40 | InputOption::VALUE_REQUIRED, 41 | 'destination directory for the file', 42 | '.' 43 | ); 44 | } 45 | 46 | protected function execute(InputInterface $input, OutputInterface $output): int 47 | { 48 | $output->writeln(''); 49 | 50 | try { 51 | $sourceFilePath = __DIR__.'/../../../phparkitect-stub.php'; 52 | /** @psalm-suppress PossiblyInvalidCast $destPath */ 53 | $destPath = (string) $input->getOption('dest-dir'); 54 | $destFilePath = "$destPath/phparkitect.php"; 55 | 56 | if (file_exists($destFilePath)) { 57 | $output->writeln('File phparkitect.php found in current directory, nothing to do'); 58 | $output->writeln('You are good to go, customize it and run with php bin/phparkitect check'); 59 | 60 | return 0; 61 | } 62 | 63 | if (!is_writable($destPath)) { 64 | $output->writeln("Ops, it seems I cannot create the file in {$destPath}"); 65 | $output->writeln('Please check the directory is writable'); 66 | 67 | return -1; 68 | } 69 | 70 | $output->write('Creating phparkitect.php file...'); 71 | 72 | Assert::file($sourceFilePath); 73 | 74 | copy($sourceFilePath, $destFilePath); 75 | 76 | $output->writeln(' done'); 77 | $output->writeln('customize it and run with php bin/phparkitect check'); 78 | } catch (\Throwable $e) { 79 | $output->writeln(''); 80 | $output->writeln('Ops, something went wrong: '); 81 | $output->writeln("{$e->getMessage()}"); 82 | 83 | return -1; 84 | } 85 | 86 | return 0; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/CLI/Config.php: -------------------------------------------------------------------------------- 1 | */ 14 | private array $classSetRules; 15 | 16 | private bool $runOnlyARule; 17 | 18 | private bool $parseCustomAnnotations; 19 | 20 | private bool $stopOnFailure; 21 | 22 | private bool $skipBaseline; 23 | 24 | private ?string $baselineFilePath; 25 | 26 | private bool $ignoreBaselineLinenumbers; 27 | 28 | private string $format; 29 | 30 | private ?string $autoloadFilePath; 31 | 32 | private TargetPhpVersion $targetPhpVersion; 33 | 34 | public function __construct() 35 | { 36 | $this->classSetRules = []; 37 | $this->runOnlyARule = false; 38 | $this->parseCustomAnnotations = true; 39 | $this->stopOnFailure = false; 40 | $this->skipBaseline = false; 41 | $this->baselineFilePath = null; 42 | $this->ignoreBaselineLinenumbers = false; 43 | $this->format = PrinterFactory::default(); 44 | $this->autoloadFilePath = null; 45 | $this->targetPhpVersion = TargetPhpVersion::latest(); 46 | } 47 | 48 | public function add(ClassSet $classSet, ArchRule ...$rules): self 49 | { 50 | if ($this->runOnlyARule) { 51 | return $this; 52 | } 53 | 54 | /** @var ArchRule $rule */ 55 | foreach ($rules as $rule) { 56 | if ($rule->isRunOnlyThis()) { 57 | $rules = []; 58 | $rules[] = $rule; 59 | 60 | $this->runOnlyARule = true; 61 | break; 62 | } 63 | } 64 | 65 | $this->classSetRules[] = ClassSetRules::create($classSet, ...$rules); 66 | 67 | return $this; 68 | } 69 | 70 | public function getClassSetRules(): array 71 | { 72 | return $this->classSetRules; 73 | } 74 | 75 | public function skipParsingCustomAnnotations(): self 76 | { 77 | $this->parseCustomAnnotations = false; 78 | 79 | return $this; 80 | } 81 | 82 | public function isParseCustomAnnotationsEnabled(): bool 83 | { 84 | return $this->parseCustomAnnotations; 85 | } 86 | 87 | public function targetPhpVersion(TargetPhpVersion $targetPhpVersion): self 88 | { 89 | $this->targetPhpVersion = $targetPhpVersion; 90 | 91 | return $this; 92 | } 93 | 94 | public function getTargetPhpVersion(): TargetPhpVersion 95 | { 96 | return $this->targetPhpVersion; 97 | } 98 | 99 | public function stopOnFailure(bool $stopOnFailure): self 100 | { 101 | $this->stopOnFailure = $stopOnFailure; 102 | 103 | return $this; 104 | } 105 | 106 | public function isStopOnFailure(): bool 107 | { 108 | return $this->stopOnFailure; 109 | } 110 | 111 | public function baselineFilePath(?string $baselineFilePath): self 112 | { 113 | $this->baselineFilePath = $baselineFilePath; 114 | 115 | return $this; 116 | } 117 | 118 | public function getBaselineFilePath(): ?string 119 | { 120 | return $this->baselineFilePath; 121 | } 122 | 123 | public function ignoreBaselineLinenumbers(bool $ignoreBaselineLinenumbers): self 124 | { 125 | $this->ignoreBaselineLinenumbers = $ignoreBaselineLinenumbers; 126 | 127 | return $this; 128 | } 129 | 130 | public function isIgnoreBaselineLinenumbers(): bool 131 | { 132 | return $this->ignoreBaselineLinenumbers; 133 | } 134 | 135 | public function format(string $format): self 136 | { 137 | $this->format = $format; 138 | 139 | return $this; 140 | } 141 | 142 | public function getFormat(): string 143 | { 144 | return $this->format; 145 | } 146 | 147 | public function skipBaseline(bool $skipBaseline): self 148 | { 149 | $this->skipBaseline = $skipBaseline; 150 | 151 | return $this; 152 | } 153 | 154 | public function isSkipBaseline(): bool 155 | { 156 | return $this->skipBaseline; 157 | } 158 | 159 | public function autoloadFilePath(?string $autoloadFilePath): self 160 | { 161 | $this->autoloadFilePath = $autoloadFilePath; 162 | 163 | return $this; 164 | } 165 | 166 | public function getAutoloadFilePath(): ?string 167 | { 168 | return $this->autoloadFilePath; 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/CLI/ConfigBuilder.php: -------------------------------------------------------------------------------- 1 | add(new Check()); 25 | $this->add(new Init()); 26 | $this->add(new DebugExpression()); 27 | } 28 | 29 | public function getLongVersion(): string 30 | { 31 | return \sprintf("%s\n\n%s version %s", self::$logo, $this->getName(), $this->getVersion()); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/CLI/Printer/GitlabPrinter.php: -------------------------------------------------------------------------------- 1 | $violationsByFqcn 17 | */ 18 | foreach ($violationsCollection as $class => $violationsByFqcn) { 19 | foreach ($violationsByFqcn as $violation) { 20 | $checkName = $class.'.'.$this->toKebabCase($violation->getError()); 21 | 22 | $error = [ 23 | 'description' => $violation->getError(), 24 | 'check_name' => $checkName, 25 | 'fingerprint' => hash('sha256', $checkName), 26 | 'severity' => 'major', 27 | 'location' => [ 28 | 'path' => $violation->getFilePath(), 29 | 'lines' => [ 30 | 'begin' => $violation->getLine() ?? 1, 31 | ], 32 | ], 33 | ]; 34 | 35 | $allErrors[] = $error; 36 | } 37 | } 38 | 39 | return json_encode($allErrors); 40 | } 41 | 42 | private function toKebabCase(string $string): string 43 | { 44 | $string = preg_replace('/[^a-zA-Z0-9]+/', ' ', $string); 45 | $string = preg_replace('/\s+/', ' ', $string); 46 | $string = strtolower(trim($string)); 47 | $string = str_replace(' ', '-', $string); 48 | 49 | return $string; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/CLI/Printer/JsonPrinter.php: -------------------------------------------------------------------------------- 1 | $violationsByFqcn 18 | */ 19 | foreach ($violationsCollection as $class => $violationsByFqcn) { 20 | $violationForThisFqcn = \count($violationsByFqcn); 21 | $totalViolations += $violationForThisFqcn; 22 | 23 | $details[$class] = []; 24 | 25 | foreach ($violationsByFqcn as $key => $violation) { 26 | $details[$class][$key]['error'] = $violation->getError(); 27 | 28 | if (null !== $violation->getLine()) { 29 | $details[$class][$key]['line'] = $violation->getLine(); 30 | } 31 | } 32 | } 33 | 34 | $errors = [ 35 | 'totalViolations' => $totalViolations, 36 | 'details' => $details, 37 | ]; 38 | 39 | return json_encode($errors); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/CLI/Printer/Printer.php: -------------------------------------------------------------------------------- 1 | $violationsByFqcn 17 | */ 18 | foreach ($violationsCollection as $key => $violationsByFqcn) { 19 | $violationForThisFqcn = \count($violationsByFqcn); 20 | $errors .= "\n$key has {$violationForThisFqcn} violations"; 21 | 22 | foreach ($violationsByFqcn as $violation) { 23 | $errors .= "\n ".$violation->getError(); 24 | 25 | if (null !== $violation->getLine()) { 26 | $errors .= ' (on line '.$violation->getLine().')'; 27 | } 28 | } 29 | $errors .= "\n"; 30 | } 31 | 32 | return $errors; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/CLI/Progress/DebugProgress.php: -------------------------------------------------------------------------------- 1 | output = $output; 16 | } 17 | 18 | public function startFileSetAnalysis(ClassSet $set): void 19 | { 20 | $this->output->writeln("Start analyze dirs {$set->getDirsDescription()}"); 21 | } 22 | 23 | public function startParsingFile(string $file): void 24 | { 25 | $this->output->writeln("parsing $file"); 26 | } 27 | 28 | public function endParsingFile(string $file): void 29 | { 30 | } 31 | 32 | public function endFileSetAnalysis(ClassSet $set): void 33 | { 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/CLI/Progress/Progress.php: -------------------------------------------------------------------------------- 1 | isCiDetected()) { 27 | $this->output = new NullOutput(); 28 | } else { 29 | $this->output = $output; 30 | } 31 | $this->progress = new ProgressBar($output); 32 | } 33 | 34 | public function startFileSetAnalysis(ClassSet $set): void 35 | { 36 | $this->output->writeln("analyze class set {$set->getDirsDescription()}"); 37 | $this->output->writeln(''); 38 | $this->progress = new ProgressBar($this->output, iterator_count($set)); 39 | 40 | $this->progress->start(); 41 | } 42 | 43 | public function startParsingFile(string $file): void 44 | { 45 | } 46 | 47 | public function endParsingFile(string $file): void 48 | { 49 | $this->progress->advance(); 50 | } 51 | 52 | public function endFileSetAnalysis(ClassSet $set): void 53 | { 54 | $this->progress->finish(); 55 | $this->output->writeln(''); 56 | $this->output->writeln(''); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/CLI/Progress/VoidProgress.php: -------------------------------------------------------------------------------- 1 | doRun($config, $progress); 22 | 23 | $baseline->applyTo($violations, $config->isIgnoreBaselineLinenumbers()); 24 | 25 | return new AnalysisResult( 26 | $violations, 27 | $parsingErrors, 28 | ); 29 | } 30 | 31 | public function baseline(Config $config, Progress $progress): AnalysisResult 32 | { 33 | [$violations, $parsingErrors] = $this->doRun($config, $progress); 34 | 35 | return new AnalysisResult( 36 | $violations, 37 | $parsingErrors, 38 | ); 39 | } 40 | 41 | public function check( 42 | ClassSetRules $classSetRule, 43 | Progress $progress, 44 | Parser $fileParser, 45 | Violations $violations, 46 | ParsingErrors $parsingErrors, 47 | bool $stopOnFailure 48 | ): void { 49 | /** @var SplFileInfo $file */ 50 | foreach ($classSetRule->getClassSet() as $file) { 51 | $fileViolations = new Violations(); 52 | 53 | $progress->startParsingFile($file->getRelativePathname()); 54 | 55 | $fileParser->parse($file->getContents(), $file->getRelativePathname()); 56 | $parsedErrors = $fileParser->getParsingErrors(); 57 | 58 | foreach ($parsedErrors as $parsedError) { 59 | $parsingErrors->add($parsedError); 60 | } 61 | 62 | /** @var ClassDescription $classDescription */ 63 | foreach ($fileParser->getClassDescriptions() as $classDescription) { 64 | foreach ($classSetRule->getRules() as $rule) { 65 | $rule->check($classDescription, $fileViolations); 66 | 67 | if ($stopOnFailure && $fileViolations->count() > 0) { 68 | $violations->merge($fileViolations); 69 | 70 | throw new FailOnFirstViolationException(); 71 | } 72 | } 73 | } 74 | 75 | $violations->merge($fileViolations); 76 | 77 | $progress->endParsingFile($file->getRelativePathname()); 78 | } 79 | } 80 | 81 | protected function doRun(Config $config, Progress $progress): array 82 | { 83 | $violations = new Violations(); 84 | $parsingErrors = new ParsingErrors(); 85 | 86 | $fileParser = FileParserFactory::createFileParser( 87 | $config->getTargetPhpVersion(), 88 | $config->isParseCustomAnnotationsEnabled() 89 | ); 90 | 91 | /** @var ClassSetRules $classSetRule */ 92 | foreach ($config->getClassSetRules() as $classSetRule) { 93 | $progress->startFileSetAnalysis($classSetRule->getClassSet()); 94 | 95 | try { 96 | $this->check($classSetRule, $progress, $fileParser, $violations, $parsingErrors, $config->isStopOnFailure()); 97 | } catch (FailOnFirstViolationException $e) { 98 | break; 99 | } finally { 100 | $progress->endFileSetAnalysis($classSetRule->getClassSet()); 101 | } 102 | } 103 | 104 | $violations->sort(); 105 | 106 | return [$violations, $parsingErrors]; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/CLI/TargetPhpVersion.php: -------------------------------------------------------------------------------- 1 | version = $version; 41 | } 42 | 43 | public static function latest(): self 44 | { 45 | return new self(self::PHP_8_4); 46 | } 47 | 48 | public static function create(?string $version): self 49 | { 50 | return new self($version ?? phpversion()); 51 | } 52 | 53 | public function get(): string 54 | { 55 | return $this->version; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/CLI/Version.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class ClassSet implements \IteratorAggregate 13 | { 14 | /** @var array */ 15 | private array $directoryList; 16 | 17 | private array $exclude; 18 | 19 | private function __construct(string ...$directoryList) 20 | { 21 | $this->directoryList = $directoryList; 22 | $this->exclude = []; 23 | } 24 | 25 | public function excludePath(string $pattern): self 26 | { 27 | $this->exclude[] = Glob::toRegex($pattern); 28 | 29 | return $this; 30 | } 31 | 32 | public static function fromDir(string ...$directoryList): self 33 | { 34 | return new self(...$directoryList); 35 | } 36 | 37 | public function getDirsDescription(): string 38 | { 39 | return implode(', ', $this->directoryList); 40 | } 41 | 42 | public function getIterator(): \Traversable 43 | { 44 | $finder = (new Finder()) 45 | ->files() 46 | ->in($this->directoryList) 47 | ->name('*.php') 48 | ->sortByName() 49 | ->followLinks() 50 | ->ignoreUnreadableDirs(true) 51 | ->ignoreVCS(true); 52 | 53 | if ([] !== $this->exclude) { 54 | $finder->notPath($this->exclude); 55 | } 56 | 57 | return $finder; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/ClassSetRules.php: -------------------------------------------------------------------------------- 1 | */ 14 | private array $rules; 15 | 16 | private function __construct(ClassSet $classSet, ArchRule ...$rules) 17 | { 18 | $this->classSet = $classSet; 19 | $this->rules = $rules; 20 | } 21 | 22 | public static function create(ClassSet $classSet, ArchRule ...$rules): self 23 | { 24 | return new self($classSet, ...$rules); 25 | } 26 | 27 | public function getClassSet(): ClassSet 28 | { 29 | return $this->classSet; 30 | } 31 | 32 | public function getRules(): array 33 | { 34 | return $this->rules; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Exceptions/FailOnFirstViolationException.php: -------------------------------------------------------------------------------- 1 | description = $description; 15 | 16 | if ('' !== $because) { 17 | $this->description .= ' because '.$because; 18 | } 19 | } 20 | 21 | public function toString(): string 22 | { 23 | return $this->description; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Expression/Expression.php: -------------------------------------------------------------------------------- 1 | docBlock = $docBlock; 22 | } 23 | 24 | public function describe(ClassDescription $theClass, string $because): Description 25 | { 26 | return new Description("should have a doc block that contains {$this->docBlock}", $because); 27 | } 28 | 29 | public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void 30 | { 31 | if (!$theClass->containsDocBlock($this->docBlock)) { 32 | $violation = Violation::create( 33 | $theClass->getFQCN(), 34 | ViolationMessage::selfExplanatory($this->describe($theClass, $because)), 35 | $theClass->getFilePath() 36 | ); 37 | $violations->add($violation); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Expression/ForClasses/DependsOnlyOnTheseNamespaces.php: -------------------------------------------------------------------------------- 1 | */ 18 | private array $namespaces; 19 | 20 | /** @var array */ 21 | private array $exclude; 22 | 23 | public function __construct(array $namespaces = [], array $exclude = []) 24 | { 25 | $this->namespaces = $namespaces; 26 | $this->exclude = $exclude; 27 | } 28 | 29 | public function describe(ClassDescription $theClass, string $because): Description 30 | { 31 | $desc = implode(', ', $this->namespaces); 32 | 33 | return new Description("should depend only on classes in one of these namespaces: $desc", $because); 34 | } 35 | 36 | public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void 37 | { 38 | $dependencies = $theClass->getDependencies(); 39 | 40 | /** @var ClassDependency $dependency */ 41 | foreach ($dependencies as $dependency) { 42 | if ('' === $dependency->getFQCN()->namespace()) { 43 | continue; // skip root namespace 44 | } 45 | 46 | if ($theClass->namespaceMatches($dependency->getFQCN()->namespace())) { 47 | continue; // skip classes in the same namespace 48 | } 49 | 50 | if ($dependency->matchesOneOf(...$this->exclude)) { 51 | continue; // skip excluded namespaces 52 | } 53 | 54 | if (!$dependency->matchesOneOf(...$this->namespaces)) { 55 | $violation = Violation::createWithErrorLine( 56 | $theClass->getFQCN(), 57 | ViolationMessage::withDescription( 58 | $this->describe($theClass, $because), 59 | "depends on {$dependency->getFQCN()->toString()}" 60 | ), 61 | $dependency->getLine(), 62 | $theClass->getFilePath() 63 | ); 64 | 65 | $violations->add($violation); 66 | } 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Expression/ForClasses/Extend.php: -------------------------------------------------------------------------------- 1 | */ 17 | private array $classNames; 18 | 19 | public function __construct(string ...$classNames) 20 | { 21 | $this->classNames = $classNames; 22 | } 23 | 24 | public function describe(ClassDescription $theClass, string $because): Description 25 | { 26 | $desc = implode(', ', $this->classNames); 27 | 28 | return new Description("should extend one of these classes: {$desc}", $because); 29 | } 30 | 31 | public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void 32 | { 33 | $extends = $theClass->getExtends(); 34 | 35 | /** @var string $className */ 36 | foreach ($this->classNames as $className) { 37 | foreach ($extends as $extend) { 38 | if ($extend->matches($className)) { 39 | return; 40 | } 41 | } 42 | } 43 | 44 | $violation = Violation::create( 45 | $theClass->getFQCN(), 46 | ViolationMessage::selfExplanatory($this->describe($theClass, $because)), 47 | $theClass->getFilePath() 48 | ); 49 | 50 | $violations->add($violation); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Expression/ForClasses/HaveAttribute.php: -------------------------------------------------------------------------------- 1 | attribute = $attribute; 21 | } 22 | 23 | public function describe(ClassDescription $theClass, string $because): Description 24 | { 25 | return new Description("should have the attribute {$this->attribute}", $because); 26 | } 27 | 28 | public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void 29 | { 30 | if ($theClass->hasAttribute($this->attribute)) { 31 | return; 32 | } 33 | 34 | $violations->add( 35 | Violation::create( 36 | $theClass->getFQCN(), 37 | ViolationMessage::selfExplanatory($this->describe($theClass, $because)), 38 | $theClass->getFilePath() 39 | ) 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Expression/ForClasses/HaveNameMatching.php: -------------------------------------------------------------------------------- 1 | name = $name; 23 | } 24 | 25 | public function describe(ClassDescription $theClass, string $because): Description 26 | { 27 | return new Description("should have a name that matches {$this->name}", $because); 28 | } 29 | 30 | public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void 31 | { 32 | $fqcn = FullyQualifiedClassName::fromString($theClass->getFQCN()); 33 | if (!$fqcn->classMatches($this->name)) { 34 | $violation = Violation::create( 35 | $theClass->getFQCN(), 36 | ViolationMessage::selfExplanatory($this->describe($theClass, $because)), 37 | $theClass->getFilePath() 38 | ); 39 | $violations->add($violation); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Expression/ForClasses/Implement.php: -------------------------------------------------------------------------------- 1 | interface = $interface; 23 | } 24 | 25 | public function describe(ClassDescription $theClass, string $because): Description 26 | { 27 | return new Description("should implement {$this->interface}", $because); 28 | } 29 | 30 | public function appliesTo(ClassDescription $theClass): bool 31 | { 32 | return !($theClass->isInterface() || $theClass->isTrait()); 33 | } 34 | 35 | public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void 36 | { 37 | if ($theClass->isInterface() || $theClass->isTrait()) { 38 | return; 39 | } 40 | 41 | $interface = $this->interface; 42 | $interfaces = $theClass->getInterfaces(); 43 | $implements = function (FullyQualifiedClassName $FQCN) use ($interface): bool { 44 | return $FQCN->matches($interface); 45 | }; 46 | 47 | if (0 === \count(array_filter($interfaces, $implements))) { 48 | $violation = Violation::create( 49 | $theClass->getFQCN(), 50 | ViolationMessage::selfExplanatory($this->describe($theClass, $because)), 51 | $theClass->getFilePath() 52 | ); 53 | $violations->add($violation); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Expression/ForClasses/IsA.php: -------------------------------------------------------------------------------- 1 | allowedFqcn = $allowedFqcn; 25 | } 26 | 27 | public function describe(ClassDescription $theClass, string $because = ''): Description 28 | { 29 | return new Description("should inherit from: $this->allowedFqcn", $because); 30 | } 31 | 32 | public function evaluate(ClassDescription $theClass, Violations $violations, string $because = ''): void 33 | { 34 | if (!is_a($theClass->getFQCN(), $this->allowedFqcn, true)) { 35 | $violation = Violation::create( 36 | $theClass->getFQCN(), 37 | ViolationMessage::selfExplanatory($this->describe($theClass, $because)), 38 | $theClass->getFilePath() 39 | ); 40 | 41 | $violations->add($violation); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Expression/ForClasses/IsAbstract.php: -------------------------------------------------------------------------------- 1 | getName()} should be abstract", $because); 19 | } 20 | 21 | public function appliesTo(ClassDescription $theClass): bool 22 | { 23 | return !($theClass->isInterface() || $theClass->isTrait() || $theClass->isEnum() || $theClass->isFinal()); 24 | } 25 | 26 | public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void 27 | { 28 | if ($theClass->isAbstract()) { 29 | return; 30 | } 31 | 32 | $violation = Violation::create( 33 | $theClass->getFQCN(), 34 | ViolationMessage::selfExplanatory($this->describe($theClass, $because)), 35 | $theClass->getFilePath() 36 | ); 37 | 38 | $violations->add($violation); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Expression/ForClasses/IsEnum.php: -------------------------------------------------------------------------------- 1 | getName()} should be an enum", $because); 19 | } 20 | 21 | public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void 22 | { 23 | if ($theClass->isEnum()) { 24 | return; 25 | } 26 | 27 | $violation = Violation::create( 28 | $theClass->getFQCN(), 29 | ViolationMessage::selfExplanatory($this->describe($theClass, $because)), 30 | $theClass->getFilePath() 31 | ); 32 | 33 | $violations->add($violation); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Expression/ForClasses/IsFinal.php: -------------------------------------------------------------------------------- 1 | getName()} should be final", $because); 19 | } 20 | 21 | public function appliesTo(ClassDescription $theClass): bool 22 | { 23 | return !($theClass->isInterface() || $theClass->isTrait() || $theClass->isEnum() || $theClass->isAbstract()); 24 | } 25 | 26 | public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void 27 | { 28 | if ($theClass->isFinal()) { 29 | return; 30 | } 31 | 32 | $violation = Violation::create( 33 | $theClass->getFQCN(), 34 | ViolationMessage::selfExplanatory($this->describe($theClass, $because)), 35 | $theClass->getFilePath() 36 | ); 37 | 38 | $violations->add($violation); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Expression/ForClasses/IsInterface.php: -------------------------------------------------------------------------------- 1 | getName()} should be an interface", $because); 19 | } 20 | 21 | public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void 22 | { 23 | if ($theClass->isInterface()) { 24 | return; 25 | } 26 | 27 | $violation = Violation::create( 28 | $theClass->getFQCN(), 29 | ViolationMessage::selfExplanatory($this->describe($theClass, $because)), 30 | $theClass->getFilePath() 31 | ); 32 | 33 | $violations->add($violation); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Expression/ForClasses/IsNotAbstract.php: -------------------------------------------------------------------------------- 1 | getName()} should not be abstract", $because); 19 | } 20 | 21 | public function appliesTo(ClassDescription $theClass): bool 22 | { 23 | return !($theClass->isInterface() || $theClass->isTrait() || $theClass->isEnum() || $theClass->isFinal()); 24 | } 25 | 26 | public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void 27 | { 28 | if (!$theClass->isAbstract()) { 29 | return; 30 | } 31 | 32 | $violation = Violation::create( 33 | $theClass->getFQCN(), 34 | ViolationMessage::selfExplanatory($this->describe($theClass, $because)), 35 | $theClass->getFilePath() 36 | ); 37 | 38 | $violations->add($violation); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Expression/ForClasses/IsNotEnum.php: -------------------------------------------------------------------------------- 1 | getName()} should not be an enum", $because); 19 | } 20 | 21 | public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void 22 | { 23 | if (false === $theClass->isEnum()) { 24 | return; 25 | } 26 | 27 | $violation = Violation::create( 28 | $theClass->getFQCN(), 29 | ViolationMessage::selfExplanatory($this->describe($theClass, $because)), 30 | $theClass->getFilePath() 31 | ); 32 | 33 | $violations->add($violation); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Expression/ForClasses/IsNotFinal.php: -------------------------------------------------------------------------------- 1 | getName()} should not be final", $because); 19 | } 20 | 21 | public function appliesTo(ClassDescription $theClass): bool 22 | { 23 | return !($theClass->isInterface() || $theClass->isTrait() || $theClass->isEnum() || $theClass->isAbstract()); 24 | } 25 | 26 | public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void 27 | { 28 | if (!$theClass->isFinal()) { 29 | return; 30 | } 31 | 32 | $violation = Violation::create( 33 | $theClass->getFQCN(), 34 | ViolationMessage::selfExplanatory($this->describe($theClass, $because)), 35 | $theClass->getFilePath() 36 | ); 37 | 38 | $violations->add($violation); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Expression/ForClasses/IsNotInterface.php: -------------------------------------------------------------------------------- 1 | getName()} should not be an interface", $because); 19 | } 20 | 21 | public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void 22 | { 23 | if (!$theClass->isInterface()) { 24 | return; 25 | } 26 | 27 | $violation = Violation::create( 28 | $theClass->getFQCN(), 29 | ViolationMessage::selfExplanatory($this->describe($theClass, $because)), 30 | $theClass->getFilePath() 31 | ); 32 | 33 | $violations->add($violation); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Expression/ForClasses/IsNotReadonly.php: -------------------------------------------------------------------------------- 1 | getName()} should not be readonly", $because); 19 | } 20 | 21 | public function appliesTo(ClassDescription $theClass): bool 22 | { 23 | return !($theClass->isInterface() || $theClass->isTrait() || $theClass->isEnum()); 24 | } 25 | 26 | public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void 27 | { 28 | if (!$theClass->isReadonly()) { 29 | return; 30 | } 31 | 32 | $violation = Violation::create( 33 | $theClass->getFQCN(), 34 | ViolationMessage::selfExplanatory($this->describe($theClass, $because)), 35 | $theClass->getFilePath() 36 | ); 37 | 38 | $violations->add($violation); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Expression/ForClasses/IsNotTrait.php: -------------------------------------------------------------------------------- 1 | getName()} should not be trait", $because); 19 | } 20 | 21 | public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void 22 | { 23 | if (!$theClass->isTrait()) { 24 | return; 25 | } 26 | 27 | $violation = Violation::create( 28 | $theClass->getFQCN(), 29 | ViolationMessage::selfExplanatory($this->describe($theClass, $because)), 30 | $theClass->getFilePath() 31 | ); 32 | 33 | $violations->add($violation); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Expression/ForClasses/IsReadonly.php: -------------------------------------------------------------------------------- 1 | getName()} should be readonly", $because); 19 | } 20 | 21 | public function appliesTo(ClassDescription $theClass): bool 22 | { 23 | return !($theClass->isInterface() || $theClass->isTrait() || $theClass->isEnum()); 24 | } 25 | 26 | public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void 27 | { 28 | if ($theClass->isReadonly()) { 29 | return; 30 | } 31 | 32 | $violation = Violation::create( 33 | $theClass->getFQCN(), 34 | ViolationMessage::selfExplanatory($this->describe($theClass, $because)), 35 | $theClass->getFilePath() 36 | ); 37 | 38 | $violations->add($violation); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Expression/ForClasses/IsTrait.php: -------------------------------------------------------------------------------- 1 | getName()} should be trait", $because); 19 | } 20 | 21 | public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void 22 | { 23 | if ($theClass->isTrait()) { 24 | return; 25 | } 26 | 27 | $violation = Violation::create( 28 | $theClass->getFQCN(), 29 | ViolationMessage::selfExplanatory($this->describe($theClass, $because)), 30 | $theClass->getFilePath() 31 | ); 32 | 33 | $violations->add($violation); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Expression/ForClasses/MatchOneOfTheseNames.php: -------------------------------------------------------------------------------- 1 | */ 18 | private $names; 19 | 20 | public function __construct(array $names) 21 | { 22 | $this->names = $names; 23 | } 24 | 25 | public function describe(ClassDescription $theClass, string $because): Description 26 | { 27 | $names = implode(', ', $this->names); 28 | 29 | return new Description("should have a name that matches {$names}", $because); 30 | } 31 | 32 | public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void 33 | { 34 | $fqcn = FullyQualifiedClassName::fromString($theClass->getFQCN()); 35 | $matches = false; 36 | foreach ($this->names as $name) { 37 | $matches = $matches || $fqcn->classMatches($name); 38 | } 39 | 40 | if (!$matches) { 41 | $violation = Violation::create( 42 | $theClass->getFQCN(), 43 | ViolationMessage::selfExplanatory($this->describe($theClass, $because)), 44 | $theClass->getFilePath() 45 | ); 46 | $violations->add($violation); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Expression/ForClasses/NotContainDocBlockLike.php: -------------------------------------------------------------------------------- 1 | docBlock = $docBlock; 22 | } 23 | 24 | public function describe(ClassDescription $theClass, string $because): Description 25 | { 26 | return new Description("should not have a doc block that contains {$this->docBlock}", $because); 27 | } 28 | 29 | public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void 30 | { 31 | if ($theClass->containsDocBlock($this->docBlock)) { 32 | $violation = Violation::create( 33 | $theClass->getFQCN(), 34 | ViolationMessage::selfExplanatory($this->describe($theClass, $because)), 35 | $theClass->getFilePath() 36 | ); 37 | $violations->add($violation); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Expression/ForClasses/NotDependsOnTheseNamespaces.php: -------------------------------------------------------------------------------- 1 | */ 18 | private array $namespaces; 19 | 20 | /** @var array */ 21 | private array $exclude; 22 | 23 | public function __construct(array $namespaces, array $exclude = []) 24 | { 25 | $this->namespaces = $namespaces; 26 | $this->exclude = $exclude; 27 | } 28 | 29 | public function describe(ClassDescription $theClass, string $because): Description 30 | { 31 | $desc = implode(', ', $this->namespaces); 32 | 33 | return new Description("should not depend on these namespaces: $desc", $because); 34 | } 35 | 36 | public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void 37 | { 38 | $dependencies = $theClass->getDependencies(); 39 | 40 | /** @var ClassDependency $dependency */ 41 | foreach ($dependencies as $dependency) { 42 | if ('' === $dependency->getFQCN()->namespace()) { 43 | continue; // skip root namespace 44 | } 45 | 46 | if ($dependency->matchesOneOf(...$this->exclude)) { 47 | continue; // skip excluded namespaces 48 | } 49 | 50 | if ($dependency->matchesOneOf(...$this->namespaces)) { 51 | $violation = Violation::createWithErrorLine( 52 | $theClass->getFQCN(), 53 | ViolationMessage::withDescription( 54 | $this->describe($theClass, $because), 55 | "depends on {$dependency->getFQCN()->toString()}" 56 | ), 57 | $dependency->getLine(), 58 | $theClass->getFilePath() 59 | ); 60 | 61 | $violations->add($violation); 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Expression/ForClasses/NotExtend.php: -------------------------------------------------------------------------------- 1 | */ 17 | private array $classNames; 18 | 19 | public function __construct(string ...$classNames) 20 | { 21 | $this->classNames = $classNames; 22 | } 23 | 24 | public function describe(ClassDescription $theClass, string $because): Description 25 | { 26 | $desc = implode(', ', $this->classNames); 27 | 28 | return new Description("should not extend one of these classes: {$desc}", $because); 29 | } 30 | 31 | public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void 32 | { 33 | $extends = $theClass->getExtends(); 34 | 35 | /** @var string $className */ 36 | foreach ($this->classNames as $className) { 37 | foreach ($extends as $extend) { 38 | if ($extend->matches($className)) { 39 | $violation = Violation::create( 40 | $theClass->getFQCN(), 41 | ViolationMessage::selfExplanatory($this->describe($theClass, $because)), 42 | $theClass->getFilePath() 43 | ); 44 | 45 | $violations->add($violation); 46 | } 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Expression/ForClasses/NotHaveDependencyOutsideNamespace.php: -------------------------------------------------------------------------------- 1 | */ 20 | private array $externalDependenciesToExclude; 21 | 22 | private bool $excludeCoreNamespace; 23 | 24 | public function __construct(string $namespace, array $externalDependenciesToExclude = [], bool $excludeCoreNamespace = false) 25 | { 26 | $this->namespace = $namespace; 27 | $this->externalDependenciesToExclude = $externalDependenciesToExclude; 28 | $this->excludeCoreNamespace = $excludeCoreNamespace; 29 | } 30 | 31 | public function describe(ClassDescription $theClass, string $because): Description 32 | { 33 | return new Description("should not depend on classes outside namespace {$this->namespace}", $because); 34 | } 35 | 36 | public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void 37 | { 38 | $namespace = $this->namespace; 39 | $depends = function (ClassDependency $dependency) use ($namespace): bool { 40 | return !$dependency->getFQCN()->matches($namespace); 41 | }; 42 | 43 | $dependencies = $theClass->getDependencies(); 44 | $externalDeps = array_filter($dependencies, $depends); 45 | 46 | /** @var ClassDependency $externalDep */ 47 | foreach ($externalDeps as $externalDep) { 48 | if ($externalDep->matchesOneOf(...$this->externalDependenciesToExclude)) { 49 | continue; 50 | } 51 | 52 | if ($this->excludeCoreNamespace && '' === $externalDep->getFQCN()->namespace()) { 53 | continue; 54 | } 55 | 56 | $violation = Violation::createWithErrorLine( 57 | $theClass->getFQCN(), 58 | ViolationMessage::withDescription( 59 | $this->describe($theClass, $because), 60 | "depends on {$externalDep->getFQCN()->toString()}" 61 | ), 62 | $externalDep->getLine(), 63 | $theClass->getFilePath() 64 | ); 65 | $violations->add($violation); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Expression/ForClasses/NotHaveNameMatching.php: -------------------------------------------------------------------------------- 1 | name = $name; 23 | } 24 | 25 | public function describe(ClassDescription $theClass, string $because): Description 26 | { 27 | return new Description("should not have a name that matches {$this->name}", $because); 28 | } 29 | 30 | public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void 31 | { 32 | $fqcn = FullyQualifiedClassName::fromString($theClass->getFQCN()); 33 | if ($fqcn->classMatches($this->name)) { 34 | $violation = Violation::create( 35 | $theClass->getFQCN(), 36 | ViolationMessage::selfExplanatory($this->describe($theClass, $because)), 37 | $theClass->getFilePath() 38 | ); 39 | $violations->add($violation); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Expression/ForClasses/NotImplement.php: -------------------------------------------------------------------------------- 1 | interface = $interface; 23 | } 24 | 25 | public function describe(ClassDescription $theClass, string $because): Description 26 | { 27 | return new Description("should not implement {$this->interface}", $because); 28 | } 29 | 30 | public function appliesTo(ClassDescription $theClass): bool 31 | { 32 | return !($theClass->isInterface() || $theClass->isTrait()); 33 | } 34 | 35 | public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void 36 | { 37 | if ($theClass->isInterface() || $theClass->isTrait()) { 38 | return; 39 | } 40 | 41 | $interface = $this->interface; 42 | $interfaces = $theClass->getInterfaces(); 43 | $implements = function (FullyQualifiedClassName $FQCN) use ($interface): bool { 44 | return $FQCN->matches($interface); 45 | }; 46 | 47 | if (\count(array_filter($interfaces, $implements)) > 0) { 48 | $violation = Violation::create( 49 | $theClass->getFQCN(), 50 | ViolationMessage::selfExplanatory($this->describe($theClass, $because)), 51 | $theClass->getFilePath() 52 | ); 53 | $violations->add($violation); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Expression/ForClasses/NotResideInTheseNamespaces.php: -------------------------------------------------------------------------------- 1 | */ 17 | private $namespaces; 18 | 19 | public function __construct(string ...$namespaces) 20 | { 21 | $this->namespaces = $namespaces; 22 | } 23 | 24 | public function describe(ClassDescription $theClass, string $because): Description 25 | { 26 | $descr = implode(', ', $this->namespaces); 27 | 28 | return new Description("should not reside in one of these namespaces: $descr", $because); 29 | } 30 | 31 | public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void 32 | { 33 | $resideInNamespace = false; 34 | foreach ($this->namespaces as $namespace) { 35 | if ($theClass->namespaceMatches($namespace)) { 36 | $resideInNamespace = true; 37 | } 38 | } 39 | 40 | if ($resideInNamespace) { 41 | $violation = Violation::create( 42 | $theClass->getFQCN(), 43 | ViolationMessage::selfExplanatory($this->describe($theClass, $because)), 44 | $theClass->getFilePath() 45 | ); 46 | $violations->add($violation); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Expression/ForClasses/ResideInOneOfTheseNamespaces.php: -------------------------------------------------------------------------------- 1 | */ 17 | private $namespaces; 18 | 19 | public function __construct(string ...$namespaces) 20 | { 21 | $this->namespaces = array_values(array_unique($namespaces)); 22 | } 23 | 24 | public function describe(ClassDescription $theClass, string $because): Description 25 | { 26 | $descr = implode(', ', $this->namespaces); 27 | 28 | return new Description("should reside in one of these namespaces: $descr", $because); 29 | } 30 | 31 | public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void 32 | { 33 | $resideInNamespace = false; 34 | foreach ($this->namespaces as $namespace) { 35 | if ($theClass->namespaceMatches($namespace.'*')) { 36 | $resideInNamespace = true; 37 | } 38 | } 39 | 40 | if (!$resideInNamespace) { 41 | $violation = Violation::create( 42 | $theClass->getFQCN(), 43 | ViolationMessage::selfExplanatory($this->describe($theClass, $because)), 44 | $theClass->getFilePath() 45 | ); 46 | $violations->add($violation); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Glob.php: -------------------------------------------------------------------------------- 1 | '.*', 12 | '\?' => '.', 13 | '\[' => '[', 14 | '\]' => ']', 15 | '\[\!' => '[ˆ', 16 | ]); 17 | 18 | return '/'.$regexp.'/'; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/PHPUnit/ArchRuleCheckerConstraintAdapter.php: -------------------------------------------------------------------------------- 1 | runner = new Runner(); 50 | $this->fileparser = FileParserFactory::createFileParser($targetPhpVersion); 51 | $this->classSet = $classSet; 52 | $this->violations = new Violations(); 53 | $this->parsingErrors = new ParsingErrors(); 54 | $this->printer = PrinterFactory::create(Printer::FORMAT_TEXT); 55 | } 56 | 57 | public function toString(): string 58 | { 59 | return 'satisfies all architectural constraints'; 60 | } 61 | 62 | protected function matches( 63 | /** @var $rule ArchRule */ 64 | $other 65 | ): bool { 66 | $this->runner->check( 67 | ClassSetRules::create($this->classSet, $other), 68 | new VoidProgress(), 69 | $this->fileparser, 70 | $this->violations, 71 | $this->parsingErrors, 72 | false 73 | ); 74 | 75 | $violationsCount = $this->violations->count(); 76 | $parsingErrorsCount = $this->parsingErrors->count(); 77 | 78 | return 0 === $violationsCount && 0 === $parsingErrorsCount; 79 | } 80 | 81 | protected function failureDescription($other): string 82 | { 83 | if ($this->parsingErrors->count() > 0) { 84 | return "\n parsing error: ".$this->parsingErrors->toString(); 85 | } 86 | 87 | return "\n".$this->printer->print($this->violations->groupedByFqcn()); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/RuleBuilders/Architecture/Architecture.php: -------------------------------------------------------------------------------- 1 | */ 16 | private $componentSelectors; 17 | /** @var array> */ 18 | private $allowedDependencies; 19 | /** @var array> */ 20 | private $componentDependsOnlyOnTheseNamespaces; 21 | 22 | private function __construct() 23 | { 24 | $this->componentName = ''; 25 | $this->componentSelectors = []; 26 | $this->allowedDependencies = []; 27 | $this->componentDependsOnlyOnTheseNamespaces = []; 28 | } 29 | 30 | public static function withComponents(): Component 31 | { 32 | return new self(); 33 | } 34 | 35 | public function component(string $name): DefinedBy 36 | { 37 | $this->componentName = $name; 38 | 39 | return $this; 40 | } 41 | 42 | public function definedBy(string $selector) 43 | { 44 | $this->componentSelectors[$this->componentName] = $selector; 45 | 46 | return $this; 47 | } 48 | 49 | public function where(string $componentName) 50 | { 51 | $this->componentName = $componentName; 52 | 53 | return $this; 54 | } 55 | 56 | public function shouldNotDependOnAnyComponent() 57 | { 58 | $this->allowedDependencies[$this->componentName] = []; 59 | 60 | return $this; 61 | } 62 | 63 | public function shouldOnlyDependOnComponents(string ...$componentNames) 64 | { 65 | $this->componentDependsOnlyOnTheseNamespaces[$this->componentName] = $componentNames; 66 | 67 | return $this; 68 | } 69 | 70 | public function mayDependOnComponents(string ...$componentNames) 71 | { 72 | $this->allowedDependencies[$this->componentName] = $componentNames; 73 | 74 | return $this; 75 | } 76 | 77 | public function mayDependOnAnyComponent() 78 | { 79 | $this->allowedDependencies[$this->componentName] = array_keys($this->componentSelectors); 80 | 81 | return $this; 82 | } 83 | 84 | public function rules(string $because = 'of component architecture'): iterable 85 | { 86 | $layerNames = array_keys($this->componentSelectors); 87 | 88 | foreach ($this->componentSelectors as $name => $selector) { 89 | if (isset($this->allowedDependencies[$name])) { 90 | $forbiddenComponents = array_diff($layerNames, [$name], $this->allowedDependencies[$name]); 91 | 92 | if (!empty($forbiddenComponents)) { 93 | $forbiddenSelectors = array_values(array_map(function (string $componentName): string { 94 | return $this->componentSelectors[$componentName]; 95 | }, $forbiddenComponents)); 96 | 97 | yield Rule::allClasses() 98 | ->that(new ResideInOneOfTheseNamespaces($selector)) 99 | ->should(new NotDependsOnTheseNamespaces($forbiddenSelectors)) 100 | ->because($because); 101 | } 102 | } 103 | 104 | if (!isset($this->componentDependsOnlyOnTheseNamespaces[$name])) { 105 | continue; 106 | } 107 | 108 | $allowedDependencies = array_values(array_map(function (string $componentName): string { 109 | return $this->componentSelectors[$componentName]; 110 | }, $this->componentDependsOnlyOnTheseNamespaces[$name])); 111 | 112 | yield Rule::allClasses() 113 | ->that(new ResideInOneOfTheseNamespaces($selector)) 114 | ->should(new DependsOnlyOnTheseNamespaces($allowedDependencies)) 115 | ->because($because); 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/RuleBuilders/Architecture/Component.php: -------------------------------------------------------------------------------- 1 | $componentNames 12 | * 13 | * @return Where&Rules 14 | */ 15 | public function mayDependOnComponents(string ...$componentNames); 16 | } 17 | -------------------------------------------------------------------------------- /src/RuleBuilders/Architecture/Rules.php: -------------------------------------------------------------------------------- 1 | */ 11 | public function rules(string $because = 'of component architecture'): iterable; 12 | } 13 | -------------------------------------------------------------------------------- /src/RuleBuilders/Architecture/ShouldNotDependOnAnyComponent.php: -------------------------------------------------------------------------------- 1 | $componentNames 12 | * 13 | * @return Where&Rules 14 | */ 15 | public function shouldOnlyDependOnComponents(string ...$componentNames); 16 | } 17 | -------------------------------------------------------------------------------- /src/RuleBuilders/Architecture/Where.php: -------------------------------------------------------------------------------- 1 | ruleBuilder = new RuleBuilder(); 18 | } 19 | 20 | public function that(Expression $expression): AndThatShouldParser 21 | { 22 | $this->ruleBuilder->addThat($expression); 23 | 24 | return new AndThatShould($this->ruleBuilder); 25 | } 26 | 27 | public function except(string ...$classesToBeExcluded): ThatParser 28 | { 29 | $this->ruleBuilder->classesToBeExcluded(...$classesToBeExcluded); 30 | 31 | return $this; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Rules/AndThatShould.php: -------------------------------------------------------------------------------- 1 | ruleBuilder = $expressionBuilder; 18 | } 19 | 20 | public function andThat(Expression $expression): AndThatShouldParser 21 | { 22 | $this->ruleBuilder->addThat($expression); 23 | 24 | return $this; 25 | } 26 | 27 | public function should(Expression $expression): BecauseParser 28 | { 29 | $this->ruleBuilder->addShould($expression); 30 | 31 | return new Because($this->ruleBuilder); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Rules/ArchRule.php: -------------------------------------------------------------------------------- 1 | thats = $specs; 34 | $this->shoulds = $constraints; 35 | $this->because = $because; 36 | $this->classesToBeExcluded = $classesToBeExcluded; 37 | $this->runOnlyThis = $runOnlyThis; 38 | } 39 | 40 | public function check(ClassDescription $classDescription, Violations $violations): void 41 | { 42 | if ($classDescription->namespaceMatchesOneOfTheseNamespaces($this->classesToBeExcluded)) { 43 | return; 44 | } 45 | 46 | if (!$this->thats->allSpecsAreMatchedBy($classDescription, $this->because)) { 47 | return; 48 | } 49 | 50 | $this->shoulds->checkAll($classDescription, $violations, $this->because); 51 | } 52 | 53 | public function isRunOnlyThis(): bool 54 | { 55 | return $this->runOnlyThis; 56 | } 57 | 58 | public function runOnlyThis(): DSL\ArchRule 59 | { 60 | $this->runOnlyThis = true; 61 | 62 | return $this; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Rules/Because.php: -------------------------------------------------------------------------------- 1 | ruleBuilder = $expressionBuilder; 18 | } 19 | 20 | public function because(string $reason): ArchRule 21 | { 22 | $this->ruleBuilder->setBecause($reason); 23 | 24 | return $this->ruleBuilder->build(); 25 | } 26 | 27 | public function andShould(Expression $expression): BecauseParser 28 | { 29 | $this->ruleBuilder->addShould($expression); 30 | 31 | return $this; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Rules/Constraints.php: -------------------------------------------------------------------------------- 1 | expressions[] = $expression; 18 | } 19 | 20 | public function checkAll(ClassDescription $classDescription, Violations $violations, string $because): void 21 | { 22 | /** @var Expression $expression */ 23 | foreach ($this->expressions as $expression) { 24 | $expression->evaluate($classDescription, $violations, $because); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Rules/DSL/AndThatShouldParser.php: -------------------------------------------------------------------------------- 1 | error = $error; 18 | $this->relativeFilePath = $relativeFilePath; 19 | } 20 | 21 | public static function create(string $relativeFilePath, string $error): self 22 | { 23 | return new self($relativeFilePath, $error); 24 | } 25 | 26 | public function getError(): string 27 | { 28 | return $this->error; 29 | } 30 | 31 | public function getRelativeFilePath(): string 32 | { 33 | return $this->relativeFilePath; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Rules/ParsingErrors.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class ParsingErrors implements \IteratorAggregate, \Countable 13 | { 14 | /** 15 | * @var array 16 | */ 17 | private $parsingErrors; 18 | 19 | public function __construct(array $parsingErrors = []) 20 | { 21 | $this->parsingErrors = $parsingErrors; 22 | } 23 | 24 | public function add(ParsingError $parsingError): void 25 | { 26 | $this->parsingErrors[] = $parsingError; 27 | } 28 | 29 | public function get(int $index): ParsingError 30 | { 31 | if (!\array_key_exists($index, $this->parsingErrors)) { 32 | throw new IndexNotFoundException($index); 33 | } 34 | 35 | return $this->parsingErrors[$index]; 36 | } 37 | 38 | public function getIterator(): \Traversable 39 | { 40 | foreach ($this->parsingErrors as $parsingError) { 41 | yield $parsingError; 42 | } 43 | } 44 | 45 | public function count(): int 46 | { 47 | return \count($this->parsingErrors); 48 | } 49 | 50 | public function toString(): string 51 | { 52 | $errors = ''; 53 | 54 | /** @var ParsingError $parsingError */ 55 | foreach ($this->parsingErrors as $parsingError) { 56 | $errors .= "\n".$parsingError->getError().' in file: '.$parsingError->getRelativeFilePath(); 57 | $errors .= "\n"; 58 | } 59 | 60 | return $errors; 61 | } 62 | 63 | public function toArray(): array 64 | { 65 | return $this->parsingErrors; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Rules/Rule.php: -------------------------------------------------------------------------------- 1 | thats = new Specs(); 27 | $this->shoulds = new Constraints(); 28 | $this->because = ''; 29 | $this->classesToBeExcluded = []; 30 | $this->runOnlyThis = false; 31 | } 32 | 33 | public function addThat(Expression $that): self 34 | { 35 | $this->thats->add($that); 36 | 37 | return $this; 38 | } 39 | 40 | public function addShould(Expression $should): self 41 | { 42 | $this->shoulds->add($should); 43 | 44 | return $this; 45 | } 46 | 47 | public function setBecause(string $because): self 48 | { 49 | $this->because = $because; 50 | 51 | return $this; 52 | } 53 | 54 | public function build(): ArchRule 55 | { 56 | return new ArchRule( 57 | $this->thats, 58 | $this->shoulds, 59 | $this->because, 60 | $this->classesToBeExcluded, 61 | $this->runOnlyThis 62 | ); 63 | } 64 | 65 | public function classesToBeExcluded(string ...$classesToBeExcluded): self 66 | { 67 | $this->classesToBeExcluded = $classesToBeExcluded; 68 | 69 | return $this; 70 | } 71 | 72 | public function setRunOnlyThis(): self 73 | { 74 | $this->runOnlyThis = true; 75 | 76 | return $this; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Rules/Specs.php: -------------------------------------------------------------------------------- 1 | expressions[] = $expression; 18 | } 19 | 20 | public function allSpecsAreMatchedBy(ClassDescription $classDescription, string $because): bool 21 | { 22 | /** @var Expression $spec */ 23 | foreach ($this->expressions as $spec) { 24 | // incremental way to introduce this method 25 | if (method_exists($spec, 'appliesTo')) { 26 | $canApply = $spec->appliesTo($classDescription); 27 | 28 | if (false === $canApply) { 29 | return false; 30 | } 31 | } 32 | 33 | $violations = new Violations(); 34 | $spec->evaluate($classDescription, $violations, $because); 35 | 36 | if ($violations->count() > 0) { 37 | return false; 38 | } 39 | } 40 | 41 | return true; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Rules/Violation.php: -------------------------------------------------------------------------------- 1 | fqcn = $fqcn; 20 | $this->error = $error; 21 | $this->line = $line; 22 | $this->filePath = $filePath; 23 | } 24 | 25 | public static function create(string $fqcn, ViolationMessage $error, string $filePath): self 26 | { 27 | return new self($fqcn, $error->toString(), null, $filePath); 28 | } 29 | 30 | public static function createWithErrorLine(string $fqcn, ViolationMessage $error, int $line, string $filePath): self 31 | { 32 | return new self($fqcn, $error->toString(), $line, $filePath); 33 | } 34 | 35 | public function getFqcn(): string 36 | { 37 | return $this->fqcn; 38 | } 39 | 40 | public function getError(): string 41 | { 42 | return $this->error; 43 | } 44 | 45 | public function getLine(): ?int 46 | { 47 | return $this->line; 48 | } 49 | 50 | public function getFilePath(): ?string 51 | { 52 | return $this->filePath; 53 | } 54 | 55 | public function jsonSerialize(): array 56 | { 57 | return get_object_vars($this); 58 | } 59 | 60 | public static function fromJson(array $json): self 61 | { 62 | return new self($json['fqcn'], $json['error'], $json['line'], $json['filePath'] ?? null); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Rules/ViolationMessage.php: -------------------------------------------------------------------------------- 1 | rule = $rule; 19 | $this->violation = $violation; 20 | } 21 | 22 | public static function withDescription(Description $brokenRule, string $description): self 23 | { 24 | return new self($brokenRule->toString(), $description); 25 | } 26 | 27 | public static function selfExplanatory(Description $brokenRule): self 28 | { 29 | return new self($brokenRule->toString(), null); 30 | } 31 | 32 | public function toString(): string 33 | { 34 | if (null === $this->violation) { 35 | return $this->rule; 36 | } 37 | 38 | return "$this->violation, but $this->rule"; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Rules/Violations.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class Violations implements \IteratorAggregate, \Countable, \JsonSerializable 13 | { 14 | /** 15 | * @var array 16 | */ 17 | private array $violations; 18 | 19 | public function __construct() 20 | { 21 | $this->violations = []; 22 | } 23 | 24 | public static function fromJson(string $json): self 25 | { 26 | $json = json_decode($json, true); 27 | 28 | $instance = new self(); 29 | 30 | $instance->violations = array_map(function (array $json): Violation { 31 | return Violation::fromJson($json); 32 | }, $json['violations']); 33 | 34 | return $instance; 35 | } 36 | 37 | public function add(Violation $violation): void 38 | { 39 | $this->violations[] = $violation; 40 | } 41 | 42 | public function merge(self $other): void 43 | { 44 | $this->violations = array_merge($this->violations, $other->toArray()); 45 | } 46 | 47 | public function get(int $index): Violation 48 | { 49 | if (!\array_key_exists($index, $this->violations)) { 50 | throw new IndexNotFoundException($index); 51 | } 52 | 53 | return $this->violations[$index]; 54 | } 55 | 56 | public function getIterator(): \Traversable 57 | { 58 | foreach ($this->violations as $violation) { 59 | yield $violation; 60 | } 61 | } 62 | 63 | public function count(): int 64 | { 65 | return \count($this->violations); 66 | } 67 | 68 | public function groupedByFqcn(): array 69 | { 70 | return array_reduce($this->violations, function (array $accumulator, Violation $element) { 71 | $accumulator[$element->getFqcn()][] = $element; 72 | 73 | return $accumulator; 74 | }, []); 75 | } 76 | 77 | public function toArray(): array 78 | { 79 | return $this->violations; 80 | } 81 | 82 | /** 83 | * @param Violations $violations Known violations from the baseline 84 | * @param bool $ignoreBaselineLinenumbers If set to true, violations from the baseline are ignored for the same file even if the line number is different 85 | */ 86 | public function remove(self $violations, bool $ignoreBaselineLinenumbers = false): void 87 | { 88 | if (!$ignoreBaselineLinenumbers) { 89 | $this->violations = array_values(array_udiff( 90 | $this->violations, 91 | $violations->violations, 92 | [__CLASS__, 'compareViolations'] 93 | )); 94 | 95 | return; 96 | } 97 | 98 | $baselineViolations = $violations->violations; 99 | foreach ($this->violations as $idx => $violation) { 100 | foreach ($baselineViolations as $baseIdx => $baselineViolation) { 101 | if ( 102 | $baselineViolation->getFqcn() === $violation->getFqcn() 103 | && $baselineViolation->getError() === $violation->getError() 104 | ) { 105 | unset($this->violations[$idx], $baselineViolations[$baseIdx]); 106 | continue 2; 107 | } 108 | } 109 | } 110 | 111 | $this->violations = array_values($this->violations); 112 | } 113 | 114 | public function sort(): void 115 | { 116 | usort($this->violations, static function (Violation $v1, Violation $v2): int { 117 | return $v1 <=> $v2; 118 | }); 119 | } 120 | 121 | public function jsonSerialize(): array 122 | { 123 | return get_object_vars($this); 124 | } 125 | 126 | /** 127 | * Comparison method that respects all fields in the violation. 128 | */ 129 | public static function compareViolations(Violation $a, Violation $b): int 130 | { 131 | return $a <=> $b; 132 | } 133 | 134 | /** 135 | * Comparison method that only checks the namespace and error but ignores the line number. 136 | */ 137 | public static function compareViolationsIgnoreLineNumber(Violation $a, Violation $b): int 138 | { 139 | if (($a->getFqcn() === $b->getFqcn()) && ($a->getError() === $b->getError())) { 140 | return 0; 141 | } 142 | 143 | return self::compareViolations($a, $b); 144 | } 145 | } 146 | --------------------------------------------------------------------------------