├── .gitattributes ├── .github ├── .kodiak.toml ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── php-linter.yml │ ├── static-analysis.yml │ └── unit-tests.yml ├── .gitignore ├── LICENSE ├── README.md ├── bin ├── phpstan-baseline-analyze ├── phpstan-baseline-analyze.php ├── phpstan-baseline-filter ├── phpstan-baseline-filter.php ├── phpstan-baseline-graph ├── phpstan-baseline-graph.php ├── phpstan-baseline-trend └── phpstan-baseline-trend.php ├── composer.json ├── lib ├── AnalyzeApplication.php ├── AnalyzerResult.php ├── AnalyzerResultReader.php ├── Baseline.php ├── BaselineAnalyzer.php ├── BaselineError.php ├── BaselineFilter.php ├── BaselineFinder.php ├── FilterApplication.php ├── FilterConfig.php ├── GraphApplication.php ├── GraphTemplate.php ├── ResultPrinter.php ├── TrendApplication.php └── TrendResult.php ├── phpstan-baseline.neon ├── phpstan.neon.dist └── phpunit.xml /.gitattributes: -------------------------------------------------------------------------------- 1 | /tests export-ignore 2 | -------------------------------------------------------------------------------- /.github/.kodiak.toml: -------------------------------------------------------------------------------- 1 | # .kodiak.toml 2 | version = 1 3 | 4 | [merge] 5 | method = "squash" 6 | delete_branch_on_merge = true 7 | dont_wait_on_status_checks = ["WIP"] # handle github.com/apps/wip 8 | # label to use to enable Kodiak to merge a PR 9 | automerge_label = "automerge" # default: "automerge" 10 | # require that the automerge label be set for Kodiak to merge a PR. if you 11 | # disable this Kodiak will immediately attempt to merge every PR you create 12 | require_automerge_label = true 13 | 14 | [merge.message] 15 | title = "pull_request_title" 16 | body = "empty" 17 | include_coauthors = true 18 | include_pr_number = true 19 | strip_html_comments = true # remove html comments to auto remove PR templates 20 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [staabm] 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Dependabot config reference 2 | # https://help.github.com/en/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: composer 7 | directory: / 8 | versioning-strategy: increase 9 | schedule: 10 | interval: weekly 11 | 12 | - package-ecosystem: github-actions 13 | directory: / 14 | schedule: 15 | interval: weekly 16 | -------------------------------------------------------------------------------- /.github/workflows/php-linter.yml: -------------------------------------------------------------------------------- 1 | name: PHP Linter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: [opened, synchronize, reopened, ready_for_review] 9 | 10 | jobs: 11 | php-linter: 12 | runs-on: ubuntu-latest 13 | if: github.event.pull_request.draft == false 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | repository: ${{ github.event.client_payload.pull_request.head.repo.full_name }} 19 | ref: ${{ github.event.client_payload.pull_request.head.ref }} 20 | 21 | - name: Setup PHP 22 | uses: shivammathur/setup-php@v2 23 | with: 24 | php-version: 7.4 25 | coverage: none 26 | tools: parallel-lint 27 | 28 | - name: Lint PHP 29 | run: composer exec --no-interaction -- parallel-lint bin/ lib/ tests/ 30 | -------------------------------------------------------------------------------- /.github/workflows/static-analysis.yml: -------------------------------------------------------------------------------- 1 | name: Static Analysis 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: [opened, synchronize, reopened, ready_for_review] 9 | 10 | jobs: 11 | phpstan: 12 | name: phpstan static code analysis 13 | runs-on: ${{ matrix.os }} 14 | 15 | strategy: 16 | matrix: 17 | include: 18 | #- os: ubuntu-18.04 19 | # php-version: '7.2' 20 | - os: ubuntu-latest 21 | php-version: '7.4' 22 | - os: ubuntu-latest 23 | php-version: '8.0' 24 | - os: ubuntu-latest 25 | php-version: '8.1' 26 | - os: ubuntu-latest 27 | php-version: '8.2' 28 | 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v4 32 | 33 | - name: Setup PHP 34 | uses: shivammathur/setup-php@v2 35 | with: 36 | php-version: ${{ matrix.php-version }} 37 | extensions: gd, intl, pdo_mysql 38 | coverage: none # disable xdebug, pcov 39 | 40 | - name: Composer install 41 | uses: ramsey/composer-install@v3 42 | with: 43 | composer-options: '--ansi --prefer-dist' 44 | 45 | - name: Run phpstan analysis 46 | run: vendor/bin/phpstan analyse --ansi 47 | -------------------------------------------------------------------------------- /.github/workflows/unit-tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: [opened, synchronize, reopened, ready_for_review] 9 | 10 | jobs: 11 | phpunit: 12 | runs-on: ${{ matrix.os }} 13 | 14 | strategy: 15 | matrix: 16 | include: 17 | #- os: ubuntu-18.04 18 | # php-version: '7.2' 19 | - os: ubuntu-latest 20 | php-version: '7.4' 21 | - os: ubuntu-latest 22 | php-version: '8.0' 23 | - os: ubuntu-latest 24 | php-version: '8.1' 25 | - os: ubuntu-latest 26 | php-version: '8.2' 27 | 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v4 31 | 32 | - name: Setup PHP 33 | uses: shivammathur/setup-php@v2 34 | with: 35 | php-version: ${{ matrix.php-version }} 36 | extensions: gd, intl, pdo_mysql 37 | coverage: none # disable xdebug, pcov 38 | 39 | - name: Composer install 40 | uses: ramsey/composer-install@v3 41 | with: 42 | composer-options: '--ansi --prefer-dist' 43 | 44 | - name: Setup Problem Matchers for PHPUnit 45 | run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" 46 | 47 | - name: Run unit tests 48 | run: vendor/bin/phpunit --colors=always 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /tmp/ 3 | /.phpunit.result.cache 4 | /composer.lock 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Markus Staab 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Analyzes phpstan baseline files 2 | ------------------------------- 3 | 4 | Analyzes PHPStan baseline files and creates aggregated error trend-reports. 5 | 6 | [Read more in the Blog post.](https://staabm.github.io/2022/07/04/phpstan-baseline-analysis.html) 7 | 8 | You need at least one of the supported PHPStan RuleSets/Rules configured in your project, to get meaningful results. 9 | 10 | ## Installation 11 | 12 | ``` 13 | composer require staabm/phpstan-baseline-analysis --dev 14 | ``` 15 | 16 | ## Supported Rules 17 | 18 | ### PHPStan RuleSets 19 | - https://github.com/phpstan/phpstan-deprecation-rules 20 | 21 | ### PHPStan Rules 22 | - PHPStan\Rules\PhpDoc\InvalidPhpDocTagValueRule 23 | 24 | ### [Symplify PHPStan Rules](https://github.com/symplify/phpstan-rules) 25 | - Symplify\PHPStanRules\Rules\Explicit\NoMixedMethodCallerRule 26 | - Symplify\PHPStanRules\Rules\Explicit\NoMixedPropertyFetcherRule 27 | 28 | ### [tomasvotruba/cognitive-complexity](https://github.com/TomasVotruba/cognitive-complexity) Rules 29 | - TomasVotruba\CognitiveComplexity\Rules\ClassLikeCognitiveComplexityRule 30 | 31 | ### [tomasvotruba/type-coverage](https://github.com/TomasVotruba/type-coverage) Rules 32 | - TomasVotruba\TypeCoverage\Rules\ParamTypeCoverageRule 33 | - TomasVotruba\TypeCoverage\Rules\PropertyTypeCoverageRule 34 | - TomasVotruba\TypeCoverage\Rules\ReturnTypeCoverageRule 35 | 36 | ### [tomasvotruba/unused-public](https://github.com/TomasVotruba/unused-public) Rules 37 | - TomasVotruba\UnusedPublic\Rules\UnusedPublicClassConstRule 38 | - TomasVotruba\UnusedPublic\Rules\UnusedPublicClassMethodRule 39 | - TomasVotruba\UnusedPublic\Rules\UnusedPublicPropertyRule 40 | 41 | 42 | ## example report 43 | 44 | Starting from the current directory, the command will recursively search for files matching the glob pattern and report a summary for each baseline found. 45 | 46 | ``` 47 | $ phpstan-baseline-analyze *phpstan-baseline.neon 48 | Analyzing app/portal/phpstan-baseline.neon 49 | Overall-Errors: 41 50 | Classes-Cognitive-Complexity: 70 51 | Deprecations: 2 52 | Invalid-Phpdocs: 5 53 | Unknown-Types: 1 54 | Anonymous-Variables: 4 55 | Native-Property-Type-Coverage: 1 56 | Native-Param-Type-Coverage: 27 57 | Native-Return-Type-Coverage: 4 58 | Unused-Symbols: 3 59 | ``` 60 | 61 | ## example error filtering 62 | 63 | Filter a existing baseline and output only errors NOT matching the given filter key: 64 | 65 | > [!TIP] 66 | > This can be helpful to remove a class of errors out of an existing baseline, so PHPStan will start reporting them again. 67 | 68 | ``` 69 | $ phpstan-baseline-filter *phpstan-baseline.neon --exclude=Unknown-Types 70 | ``` 71 | 72 | Filter a existing baseline and output only errors matching the given filter key: 73 | ``` 74 | $ phpstan-baseline-filter *phpstan-baseline.neon --include=Invalid-Phpdocs 75 | ``` 76 | 77 | [Currently supported filter keys](https://github.com/staabm/phpstan-baseline-analysis/blob/1e8ea32a10e1a50c3fd21396201495a1ae1a5d1d/lib/ResultPrinter.php#L42-L51) can be found in the source. 78 | 79 | ## example graph analysis 80 | 81 | ``` 82 | $ git clone ... 83 | 84 | $ phpstan-baseline-analyze *phpstan-baseline.neon --json > now.json 85 | 86 | $ git checkout `git rev-list -n 1 --before="1 week ago" HEAD` 87 | $ phpstan-baseline-analyze '*phpstan-baseline.neon' --json > 1-week-ago.json 88 | 89 | $ git checkout `git rev-list -n 1 --before="2 week ago" HEAD` 90 | $ phpstan-baseline-analyze '*phpstan-baseline.neon' --json > 2-weeks-ago.json 91 | 92 | $ php phpstan-baseline-graph '*.json' > result.html 93 | ``` 94 | 95 | ![PHPStan baseline analysis graph](https://github.com/staabm/phpstan-baseline-analysis/assets/120441/ea5abe25-21e8-43f2-9118-0967a75517c6) 96 | 97 | 98 | ## example trend analysis 99 | 100 | the following example shows the evolution of errors in your phpstan baselines. 101 | see the trend between 2 different points in time like: 102 | 103 | ``` 104 | $ git clone ... 105 | 106 | $ phpstan-baseline-analyze '*phpstan-baseline.neon' --json > now.json 107 | 108 | $ git checkout `git rev-list -n 1 --before="1 week ago" HEAD` 109 | 110 | $ phpstan-baseline-analyze '*phpstan-baseline.neon' --json > reference.json 111 | 112 | $ phpstan-baseline-trend reference.json now.json 113 | Analyzing Trend for app/portal/phpstan-baseline.neon 114 | Overall-Errors: 30 -> 17 => improved 115 | Classes-Cognitive-Complexity: 309 -> 177 => improved 116 | Deprecations: 1 -> 2 => worse 117 | Invalid-Phpdocs: 3 -> 1 => good 118 | Unknown-Types: 5 -> 15 => worse 119 | Anonymous-Variables: 4 -> 3 => good 120 | Unused-Symbols: 1 -> 1 => good 121 | Native-Return-Type-Coverage: 20 -> 2 => worse 122 | Native-Property-Type-Coverage: 3 -> 3 => good 123 | Native-Param-Type-Coverage: 4 -> 40 => improved 124 | ``` 125 | 126 | ## Usage example in a scheduled GitHub Action with Mattermost notification 127 | 128 | Copy the following workflow into your repository. Make sure to adjust as needed: 129 | - adjust the cron schedule pattern 130 | - actions/checkout might require a token - e.g. for private repos 131 | - adjust the comparison period, as you see fit 132 | - adjust the notification to your needs - e.g. use Slack, Discord, E-Mail,.. 133 | 134 | ``` 135 | name: Trends Analyse 136 | 137 | on: 138 | workflow_dispatch: 139 | schedule: 140 | - cron: '0 8 * * 4' 141 | 142 | jobs: 143 | 144 | behat: 145 | name: Trends 146 | runs-on: ubuntu-latest 147 | timeout-minutes: 10 148 | 149 | steps: 150 | - run: "composer global require staabm/phpstan-baseline-analysis" 151 | - run: echo "$(composer global config bin-dir --absolute --quiet)" >> $GITHUB_PATH 152 | 153 | - uses: actions/checkout@v2 154 | with: 155 | fetch-depth: 50 # fetch the last X commits. 156 | 157 | - run: "phpstan-baseline-analyze '*phpstan-baseline.neon' --json > ../now.json" 158 | 159 | - run: git checkout `git rev-list -n 1 --before="1 week ago" HEAD` 160 | 161 | - run: "phpstan-baseline-analyze '*phpstan-baseline.neon' --json > ../reference.json" 162 | 163 | - name: analyze trend 164 | shell: php {0} 165 | run: | 166 | ../trend.txt', $output, $exitCode); 168 | $project = '${{ github.repository }}'; 169 | 170 | if ($exitCode == 0) { 171 | # improvements 172 | file_put_contents( 173 | 'mattermost.json', 174 | json_encode(["username" => "github-action-trend-bot", "text" => $project ." :tada:\n". file_get_contents("../trend.txt")]) 175 | ); 176 | } 177 | elseif ($exitCode == 1) { 178 | # steady 179 | file_put_contents( 180 | 'mattermost.json', 181 | json_encode(["username" => "github-action-trend-bot", "text" => $project ." :green_heart:\n". file_get_contents("../trend.txt")]) 182 | ); 183 | } 184 | elseif ($exitCode == 2) { 185 | # got worse 186 | file_put_contents( 187 | 'mattermost.json', 188 | json_encode(["username" => "github-action-trend-bot", "text" => $project ." :broken_heart:\n". file_get_contents("../trend.txt")]) 189 | ); 190 | } 191 | 192 | - run: 'curl -X POST -H "Content-Type: application/json" -d @mattermost.json ${{ secrets.MATTERMOST_WEBHOOK_URL }}' 193 | if: always() 194 | 195 | ``` 196 | 197 | ## 💌 Give back some love 198 | 199 | [Consider supporting the project](https://github.com/sponsors/staabm), so we can make this tool even better even faster for everyone. 200 | -------------------------------------------------------------------------------- /bin/phpstan-baseline-analyze: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | 8 | * 9 | * For the full copyright and license information, please view the LICENSE 10 | * file that was distributed with this source code. 11 | * 12 | * https://github.com/staabm/phpstan-baseline-analysis 13 | */ 14 | 15 | require __DIR__ .'/phpstan-baseline-analyze.php'; 16 | -------------------------------------------------------------------------------- /bin/phpstan-baseline-analyze.php: -------------------------------------------------------------------------------- 1 | help(); 32 | exit(254); 33 | } 34 | 35 | 36 | $format = ResultPrinter::FORMAT_TEXT; 37 | if (in_array('--json', $argv)) { 38 | $format = ResultPrinter::FORMAT_JSON; 39 | } 40 | 41 | $exitCode = $app->start($argv[1], $format); 42 | exit($exitCode); 43 | -------------------------------------------------------------------------------- /bin/phpstan-baseline-filter: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | 8 | * 9 | * For the full copyright and license information, please view the LICENSE 10 | * file that was distributed with this source code. 11 | * 12 | * https://github.com/staabm/phpstan-baseline-analysis 13 | */ 14 | 15 | require __DIR__ .'/phpstan-baseline-filter.php'; 16 | -------------------------------------------------------------------------------- /bin/phpstan-baseline-filter.php: -------------------------------------------------------------------------------- 1 | help(); 33 | exit(254); 34 | } 35 | 36 | try { 37 | $filterConfig = \staabm\PHPStanBaselineAnalysis\FilterConfig::fromArgs($argv[2]); 38 | 39 | $exitCode = $app->start($argv[1], $filterConfig); 40 | exit($exitCode); 41 | } catch (\Exception $e) { 42 | echo 'ERROR: '. $e->getMessage().PHP_EOL.PHP_EOL; 43 | $app->help(); 44 | exit(254); 45 | } 46 | -------------------------------------------------------------------------------- /bin/phpstan-baseline-graph: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | 8 | * 9 | * For the full copyright and license information, please view the LICENSE 10 | * file that was distributed with this source code. 11 | * 12 | * https://github.com/staabm/phpstan-baseline-analysis 13 | */ 14 | 15 | require __DIR__ .'/phpstan-baseline-graph.php'; 16 | -------------------------------------------------------------------------------- /bin/phpstan-baseline-graph.php: -------------------------------------------------------------------------------- 1 | help(); 30 | exit(254); 31 | } 32 | 33 | $exitCode = $app->start($argv[1]); 34 | exit($exitCode); 35 | -------------------------------------------------------------------------------- /bin/phpstan-baseline-trend: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | 8 | * 9 | * For the full copyright and license information, please view the LICENSE 10 | * file that was distributed with this source code. 11 | * 12 | * https://github.com/staabm/phpstan-baseline-analysis 13 | */ 14 | 15 | require __DIR__ .'/phpstan-baseline-trend.php'; 16 | -------------------------------------------------------------------------------- /bin/phpstan-baseline-trend.php: -------------------------------------------------------------------------------- 1 | help(); 30 | exit(254); 31 | } 32 | 33 | $exitCode = $app->start($argv[1], $argv[2], extractOutputFormat($argv)); 34 | exit($exitCode); 35 | 36 | /** 37 | * @param list $args 38 | * @return \staabm\PHPStanBaselineAnalysis\TrendApplication::OUTPUT_FORMAT_* 39 | */ 40 | function extractOutputFormat(array $args): string 41 | { 42 | foreach($args as $arg) { 43 | if (false === strpos($arg, '--format=')) { 44 | continue; 45 | } 46 | 47 | $format = substr($arg, strlen('--format=')); 48 | if (in_array($format, \staabm\PHPStanBaselineAnalysis\TrendApplication::getAllowedOutputFormats(), true)) { 49 | return $format; 50 | } 51 | 52 | throw new \InvalidArgumentException(sprintf('Invalid output format "%s".', $format)); 53 | } 54 | 55 | return \staabm\PHPStanBaselineAnalysis\TrendApplication::OUTPUT_FORMAT_DEFAULT; 56 | } 57 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "staabm/phpstan-baseline-analysis", 3 | "license": "MIT", 4 | "keywords": ["dev", "phpstan", "phpstan-extension", "static analysis", "baseline analysis"], 5 | "autoload": { 6 | "classmap": ["lib/"] 7 | }, 8 | "autoload-dev": { 9 | "classmap": [ 10 | "tests/" 11 | ] 12 | }, 13 | "require": { 14 | "php": "^7.4 || ^8.0", 15 | "nette/neon": "^3.2", 16 | "symfony/polyfill-php80": "^1.26" 17 | }, 18 | "require-dev": { 19 | "phpstan/extension-installer": "^1.4", 20 | "phpstan/phpstan": "^2", 21 | "phpstan/phpstan-deprecation-rules": "^2", 22 | "phpunit/phpunit": "^9.6", 23 | "symfony/var-dumper": "^5.3", 24 | "tomasvotruba/cognitive-complexity": "^1", 25 | "tomasvotruba/type-coverage": "^2.0", 26 | "tomasvotruba/unused-public": "^2.0" 27 | }, 28 | "conflict": { 29 | "tomasvotruba/type-coverage": "<1.0" 30 | }, 31 | "config": { 32 | "optimize-autoloader": true, 33 | "sort-packages": true, 34 | "allow-plugins": { 35 | "cweagans/composer-patches": false, 36 | "phpstan/extension-installer": true 37 | } 38 | }, 39 | "scripts": { 40 | "phpstan": "phpstan analyze", 41 | "phpstan-baseline": "phpstan analyse -c phpstan.neon.dist --generate-baseline || true", 42 | "test": "phpunit" 43 | }, 44 | "bin": [ 45 | "bin/phpstan-baseline-analyze", 46 | "bin/phpstan-baseline-filter", 47 | "bin/phpstan-baseline-trend", 48 | "bin/phpstan-baseline-graph" 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /lib/AnalyzeApplication.php: -------------------------------------------------------------------------------- 1 | analyze(); 38 | 39 | if ($format == ResultPrinter::FORMAT_JSON) { 40 | $stream = $printer->streamJson($baseline, $result); 41 | } else { 42 | $stream = $printer->streamText($baseline, $result); 43 | } 44 | 45 | $this->printResult($format, $isFirst, $isLast, $stream); 46 | } 47 | 48 | if ($numBaselines > 0) { 49 | return self::EXIT_SUCCESS; 50 | } 51 | return self::EXIT_ERROR; 52 | } 53 | 54 | /** 55 | * @api 56 | */ 57 | public function help(): void 58 | { 59 | printf('USAGE: phpstan-baseline-analyze '); 60 | } 61 | 62 | /** 63 | * @param Iterator $stream 64 | */ 65 | private function printResult(string $format, bool $isFirst, bool $isLast, Iterator $stream): void 66 | { 67 | if ($format == ResultPrinter::FORMAT_JSON) { 68 | if ($isFirst) { 69 | printf('['); 70 | } 71 | } 72 | 73 | foreach ($stream as $string) { 74 | printf($string); 75 | 76 | if ($format == ResultPrinter::FORMAT_JSON && !$isLast) { 77 | printf(",\n"); 78 | } 79 | } 80 | 81 | if ($format == ResultPrinter::FORMAT_JSON) { 82 | if ($isLast) { 83 | printf(']'); 84 | } 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /lib/AnalyzerResult.php: -------------------------------------------------------------------------------- 1 | 46 | */ 47 | public $propertyTypeCoverage = 0; 48 | 49 | /** 50 | * @var int<0, 100> 51 | */ 52 | public $paramTypeCoverage = 0; 53 | 54 | /** 55 | * @var int<0, 100> 56 | */ 57 | public $returnTypeCoverage = 0; 58 | 59 | } 60 | -------------------------------------------------------------------------------- /lib/AnalyzerResultReader.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | public function readFile(string $filePath): array 10 | { 11 | $json = $this->readResultArray($filePath); 12 | 13 | $decoded = []; 14 | foreach ($json as $data) { 15 | 16 | if (!is_array($data)) { 17 | throw new \RuntimeException('Expecting array, got ' . gettype($data)); 18 | } 19 | 20 | foreach ($data as $baselinePath => $resultArray) { 21 | 22 | if (!is_string($baselinePath)) { 23 | throw new \RuntimeException('Expecting string, got ' . gettype($baselinePath)); 24 | } 25 | if (!is_array($resultArray)) { 26 | throw new \RuntimeException('Expecting array, got ' . gettype($resultArray)); 27 | } 28 | 29 | $decoded[$baselinePath] = $this->buildAnalyzerResult($resultArray); 30 | } 31 | } 32 | 33 | return $decoded; 34 | } 35 | 36 | /** 37 | * @param string $filePath 38 | * @return array 39 | */ 40 | private function readResultArray(string $filePath): array 41 | { 42 | fwrite(STDERR, 'Reading file ' . $filePath . PHP_EOL); 43 | 44 | $content = file_get_contents($filePath); 45 | if ($content === '') { 46 | throw new \RuntimeException('File ' . $filePath . ' is empty'); 47 | } 48 | $json = json_decode($content, true); 49 | if (!is_array($json)) { 50 | throw new \RuntimeException('Expecting array, got ' . get_debug_type($json)); 51 | } 52 | 53 | return $json; 54 | } 55 | 56 | /** 57 | * @param array $resultArray 58 | */ 59 | private function buildAnalyzerResult(array $resultArray): AnalyzerResult 60 | { 61 | $result = new AnalyzerResult(); 62 | if (array_key_exists(ResultPrinter::KEY_REFERENCE_DATE, $resultArray)) { 63 | 64 | $dt = \DateTimeImmutable::createFromFormat( 65 | ResultPrinter::DATE_FORMAT, 66 | $resultArray[ResultPrinter::KEY_REFERENCE_DATE] 67 | ); 68 | if ($dt !== false) { 69 | $result->referenceDate = $dt; 70 | } 71 | } 72 | if (array_key_exists(ResultPrinter::KEY_OVERALL_ERRORS, $resultArray)) { 73 | $result->overallErrors = $resultArray[ResultPrinter::KEY_OVERALL_ERRORS]; 74 | } 75 | if (array_key_exists(ResultPrinter::KEY_CLASSES_COMPLEXITY, $resultArray)) { 76 | $result->classesComplexity = $resultArray[ResultPrinter::KEY_CLASSES_COMPLEXITY]; 77 | } 78 | if (array_key_exists(ResultPrinter::KEY_DEPRECATIONS, $resultArray)) { 79 | $result->deprecations = $resultArray[ResultPrinter::KEY_DEPRECATIONS]; 80 | } 81 | if (array_key_exists(ResultPrinter::KEY_INVALID_PHPDOCS, $resultArray)) { 82 | $result->invalidPhpdocs = $resultArray[ResultPrinter::KEY_INVALID_PHPDOCS]; 83 | } 84 | if (array_key_exists(ResultPrinter::KEY_UNKNOWN_TYPES, $resultArray)) { 85 | $result->unknownTypes = $resultArray[ResultPrinter::KEY_UNKNOWN_TYPES]; 86 | } 87 | if (array_key_exists(ResultPrinter::KEY_ANONYMOUS_VARIABLES, $resultArray)) { 88 | $result->anonymousVariables = $resultArray[ResultPrinter::KEY_ANONYMOUS_VARIABLES]; 89 | } 90 | if (array_key_exists(ResultPrinter::KEY_PROPERTY_TYPE_COVERAGE, $resultArray)) { 91 | $result->propertyTypeCoverage = $resultArray[ResultPrinter::KEY_PROPERTY_TYPE_COVERAGE]; 92 | } 93 | if (array_key_exists(ResultPrinter::KEY_PARAM_TYPE_COVERAGE, $resultArray)) { 94 | $result->paramTypeCoverage = $resultArray[ResultPrinter::KEY_PARAM_TYPE_COVERAGE]; 95 | } 96 | if (array_key_exists(ResultPrinter::KEY_RETURN_TYPE_COVERAGE, $resultArray)) { 97 | $result->returnTypeCoverage = $resultArray[ResultPrinter::KEY_RETURN_TYPE_COVERAGE]; 98 | } 99 | if (array_key_exists(ResultPrinter::KEY_UNUSED_SYMBOLS, $resultArray)) { 100 | $result->unusedSymbols = $resultArray[ResultPrinter::KEY_UNUSED_SYMBOLS]; 101 | } 102 | return $result; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /lib/Baseline.php: -------------------------------------------------------------------------------- 1 | }} 12 | */ 13 | private $content; 14 | 15 | /** 16 | * @var string 17 | */ 18 | private $filePath; 19 | 20 | static public function forFile(string $filePath):self { 21 | $baselineExtension = pathinfo($filePath, PATHINFO_EXTENSION); 22 | 23 | if ($baselineExtension === 'php') { 24 | $decoded = require $filePath; 25 | } else { 26 | $content = file_get_contents($filePath); 27 | $decoded = Neon::decode($content); 28 | } 29 | 30 | if (!is_array($decoded)) { 31 | throw new RuntimeException(sprintf('expecting baseline %s to be non-empty', $filePath)); 32 | } 33 | 34 | $baseline = new self(); 35 | $baseline->content = $decoded; 36 | $baseline->filePath = $filePath; 37 | return $baseline; 38 | } 39 | 40 | /** 41 | * @return Iterator 42 | */ 43 | public function getIgnoreErrors(): Iterator { 44 | if (!array_key_exists('parameters', $this->content) || !is_array($this->content['parameters'])) { 45 | throw new RuntimeException(sprintf('missing parameters from baseline %s', $this->filePath)); 46 | } 47 | $parameters = $this->content['parameters']; 48 | 49 | if (!array_key_exists('ignoreErrors', $parameters) || !is_array($parameters['ignoreErrors'])) { 50 | throw new RuntimeException(sprintf('missing ignoreErrors from baseline %s', $this->filePath)); 51 | } 52 | $ignoreErrors = $parameters['ignoreErrors']; 53 | 54 | foreach($ignoreErrors as $error) { 55 | yield new BaselineError($error['count'], $error['message'], $error['path']); 56 | } 57 | } 58 | 59 | public function getFilePath():string { 60 | return $this->filePath; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /lib/BaselineAnalyzer.php: -------------------------------------------------------------------------------- 1 | baseline = $baseline; 38 | } 39 | 40 | public function analyze(): AnalyzerResult 41 | { 42 | $result = new AnalyzerResult(); 43 | $result->referenceDate = new DateTimeImmutable(); 44 | 45 | /** 46 | * @var BaselineError $baselineError 47 | */ 48 | foreach ($this->baseline->getIgnoreErrors() as $baselineError) { 49 | // accumulating errors 50 | $result->overallErrors += $baselineError->count; 51 | $result->deprecations += $this->countDeprecations($baselineError); 52 | $result->classesComplexity += $this->countClassesComplexity($baselineError); 53 | $result->invalidPhpdocs += $this->countInvalidPhpdocs($baselineError); 54 | $result->unknownTypes += $this->countUnknownTypes($baselineError); 55 | $result->anonymousVariables += $this->countAnonymousVariables($baselineError); 56 | $result->unusedSymbols += $this->countUnusedSymbols($baselineError); 57 | 58 | // project wide errors, only reported once per baseline 59 | $this->checkSeaLevels($result, $baselineError); 60 | } 61 | 62 | return $result; 63 | } 64 | 65 | private function countDeprecations(BaselineError $baselineError): int 66 | { 67 | return $baselineError->isDeprecationError() ? $baselineError->count : 0; 68 | } 69 | 70 | private function countClassesComplexity(BaselineError $baselineError): int 71 | { 72 | if (sscanf($baselineError->unwrapMessage(), self::CLASS_COMPLEXITY_ERROR_MESSAGE, $value, $limit) > 0) { 73 | return (int)$value * $baselineError->count; 74 | } 75 | return 0; 76 | } 77 | 78 | private function countInvalidPhpdocs(BaselineError $baselineError): int 79 | { 80 | return $baselineError->isInvalidPhpDocError() 81 | ? $baselineError->count 82 | : 0; 83 | } 84 | 85 | private function countUnknownTypes(BaselineError $baselineError): int 86 | { 87 | return $baselineError->isUnknownTypeError() 88 | ? $baselineError->count 89 | : 0; 90 | } 91 | 92 | private function countAnonymousVariables(BaselineError $baselineError): int 93 | { 94 | return $baselineError->isAnonymousVariableError() 95 | ? $baselineError->count 96 | : 0; 97 | } 98 | 99 | private function countUnusedSymbols(BaselineError $baselineError): int 100 | { 101 | return $baselineError->isUnusedSymbolError() 102 | ? $baselineError->count 103 | : 0; 104 | } 105 | 106 | private function checkSeaLevels(AnalyzerResult $result, BaselineError $baselineError): void 107 | { 108 | if ( 109 | sscanf( 110 | $this->normalizeMessage($baselineError), 111 | $this->printfToScanfFormat(self::PROPERTY_TYPE_DEClARATION_SEA_LEVEL_MESSAGE), 112 | $absoluteCountMin, $coveragePercent, $goalPercent) >= 2 113 | ) { 114 | if (!is_int($coveragePercent) || $coveragePercent < 0 || $coveragePercent > 100) { 115 | throw new \LogicException('Invalid property coveragePercent: '. $coveragePercent); 116 | } 117 | $result->propertyTypeCoverage = $coveragePercent; 118 | } 119 | 120 | if ( 121 | sscanf( 122 | $this->normalizeMessage($baselineError), 123 | $this->printfToScanfFormat(self::PARAM_TYPE_DEClARATION_SEA_LEVEL_MESSAGE), 124 | $absoluteCountMin, $coveragePercent, $goalPercent) >= 2 125 | ) { 126 | if (!is_int($coveragePercent) || $coveragePercent < 0 || $coveragePercent > 100) { 127 | throw new \LogicException('Invalid parameter coveragePercent: '. $coveragePercent); 128 | } 129 | $result->paramTypeCoverage = $coveragePercent; 130 | } 131 | 132 | if ( 133 | sscanf( 134 | $this->normalizeMessage($baselineError), 135 | $this->printfToScanfFormat(self::RETURN_TYPE_DEClARATION_SEA_LEVEL_MESSAGE), 136 | $absoluteCountMin, $coveragePercent, $goalPercent) >= 2 137 | ) { 138 | if (!is_int($coveragePercent) || $coveragePercent < 0 || $coveragePercent > 100) { 139 | throw new \LogicException('Invalid return coveragePercent: '. $coveragePercent); 140 | } 141 | $result->returnTypeCoverage = $coveragePercent; 142 | } 143 | } 144 | 145 | private function printfToScanfFormat(string $format): string { 146 | // we don't need the float value, therefore simply ignore it, to make the format parseable by sscanf 147 | // see https://github.com/php/php-src/issues/12126 148 | // additionally this makes the output format of tomasvotruba/type-coverage 0.2.* compatible with tomasvotruba/type-coverage 0.1.* 149 | return str_replace('%d - %.1f', '%d', $format); 150 | } 151 | 152 | private function normalizeMessage(BaselineError $baselineError): string { 153 | // makes the message format of tomasvotruba/type-coverage 0.2.* compatible with tomasvotruba/type-coverage 0.1.* 154 | return preg_replace('/only \d+ \- (\d+).\d %/', 'only $1 %', $baselineError->unwrapMessage()); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /lib/BaselineError.php: -------------------------------------------------------------------------------- 1 | count = $count; 19 | $this->message = $message; 20 | $this->path = $path; 21 | } 22 | 23 | /** 24 | * Returns the baseline error message, without regex delimiters. 25 | * Note: the message may still contain escaped regex meta characters. 26 | */ 27 | public function unwrapMessage(): string { 28 | $msg = $this->message; 29 | $msg = str_replace(['\\-', '\\.', '%%'], ['-', '.', '%'], $msg); 30 | $msg = trim($msg, '#^$'); 31 | return $msg; 32 | } 33 | 34 | public function isDeprecationError(): bool 35 | { 36 | return str_contains($this->message, ' deprecated class ') 37 | || str_contains($this->message, ' deprecated method ') 38 | || str_contains($this->message, ' deprecated function ') 39 | || str_contains($this->message, ' deprecated property '); 40 | } 41 | 42 | public function isComplexityError(): bool 43 | { 44 | return sscanf($this->unwrapMessage(), BaselineAnalyzer::CLASS_COMPLEXITY_ERROR_MESSAGE, $value, $limit) > 0; 45 | } 46 | 47 | public function isInvalidPhpDocError(): bool 48 | { 49 | return str_contains($this->message, 'PHPDoc tag '); 50 | } 51 | 52 | public function isUnknownTypeError(): bool 53 | { 54 | return preg_match('/Instantiated class .+ not found/', $this->message, $matches) === 1 55 | || str_contains($this->message, 'on an unknown class') 56 | || str_contains($this->message, 'has invalid type unknown') 57 | || str_contains($this->message, 'unknown_type as its type'); 58 | } 59 | 60 | public function isAnonymousVariableError(): bool 61 | { 62 | return str_contains($this->message, 'Anonymous variable'); 63 | } 64 | 65 | public function isUnusedSymbolError(): bool 66 | { 67 | return str_ends_with($this->message, 'is never used$#'); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /lib/BaselineFilter.php: -------------------------------------------------------------------------------- 1 | baseline = $baseline; 12 | } 13 | 14 | /** 15 | * @return list 16 | */ 17 | public function filter(FilterConfig $filterConfig): array 18 | { 19 | $result = []; 20 | 21 | foreach ($this->baseline->getIgnoreErrors() as $baselineError) { 22 | $result = $this->addErrorToResultIfFitting($filterConfig, $result, $baselineError); 23 | } 24 | 25 | return $result; 26 | } 27 | 28 | /** 29 | * @param list $result 30 | * 31 | * @return list 32 | */ 33 | private function addErrorToResultIfFitting(FilterConfig $filterConfig, array $result, BaselineError $baselineError): array 34 | { 35 | $matched = $this->matchedFilter($filterConfig, $baselineError); 36 | 37 | if ($filterConfig->isExcluding()) { 38 | if (!$matched) { 39 | $result[] = $baselineError; 40 | } 41 | } else { 42 | if ($matched) { 43 | $result[] = $baselineError; 44 | } 45 | } 46 | 47 | return $result; 48 | } 49 | 50 | private function matchedFilter(FilterConfig $filterConfig, BaselineError $baselineError): bool 51 | { 52 | $matched = false; 53 | if ($filterConfig->containsKey(ResultPrinter::KEY_CLASSES_COMPLEXITY) 54 | && $baselineError->isComplexityError()) { 55 | $matched = true; 56 | } 57 | 58 | if ($filterConfig->containsKey(ResultPrinter::KEY_DEPRECATIONS) 59 | && $baselineError->isDeprecationError()) { 60 | $matched = true; 61 | } 62 | 63 | if ( 64 | $filterConfig->containsKey(ResultPrinter::KEY_INVALID_PHPDOCS) 65 | && $baselineError->isInvalidPhpDocError()) { 66 | $matched = true; 67 | } 68 | 69 | if ($filterConfig->containsKey(ResultPrinter::KEY_UNKNOWN_TYPES) 70 | && $baselineError->isUnknownTypeError()) { 71 | $matched = true; 72 | } 73 | 74 | if ($filterConfig->containsKey(ResultPrinter::KEY_ANONYMOUS_VARIABLES) 75 | && $baselineError->isAnonymousVariableError()) { 76 | $matched = true; 77 | } 78 | 79 | if ($filterConfig->containsKey(ResultPrinter::KEY_UNUSED_SYMBOLS) 80 | && $baselineError->isUnusedSymbolError()) { 81 | $matched = true; 82 | } 83 | 84 | return $matched; 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /lib/BaselineFinder.php: -------------------------------------------------------------------------------- 1 | filter($filterConfig); 35 | 36 | $this->printResult($errors); 37 | } 38 | 39 | if ($numBaselines > 0) { 40 | return self::EXIT_SUCCESS; 41 | } 42 | 43 | return self::EXIT_ERROR; 44 | } 45 | 46 | /** 47 | * @api 48 | */ 49 | public function help(): void 50 | { 51 | echo 'USAGE: phpstan-baseline-filter [--exclude=,...] [--include=,...]'.PHP_EOL.PHP_EOL; 52 | printf('valid FILTER-KEYs: %s', implode(', ', ResultPrinter::getFilterKeys())); 53 | 54 | echo PHP_EOL; 55 | } 56 | 57 | /** 58 | * @param list $errors 59 | */ 60 | private function printResult(array $errors): void 61 | { 62 | $ignoreErrors = []; 63 | foreach ($errors as $error) { 64 | $ignoreErrors[] = [ 65 | 'message' => $error->message, 66 | 'count' => $error->count, 67 | 'path' => $error->path, 68 | ]; 69 | 70 | } 71 | 72 | // encode analog PHPStan 73 | $neon = Neon::encode([ 74 | 'parameters' => [ 75 | 'ignoreErrors' => $ignoreErrors, 76 | ], 77 | ], true); 78 | 79 | if (substr($neon, -2) !== "\n\n") { 80 | throw new ShouldNotHappenException(); 81 | } 82 | 83 | echo substr($neon, 0, -1); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /lib/FilterConfig.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | private array $excluded; 13 | /** 14 | * @var array 15 | */ 16 | private array $included; 17 | 18 | /** 19 | * @param array $excluded 20 | * @param array $included 21 | */ 22 | private function __construct(array $excluded, array $included) { 23 | $this->excluded = $excluded; 24 | $this->included = $included; 25 | } 26 | 27 | static public function fromArgs(string $args): self { 28 | $args = explode(' ', $args); 29 | 30 | $excluded = []; 31 | $included = []; 32 | foreach ($args as $arg) { 33 | if (str_starts_with($arg, '--exclude=')) { 34 | foreach(explode(',', substr($arg, 10)) as $key) { 35 | if (!ResultPrinter::isFilterKey($key)) { 36 | throw new \Exception("Invalid filter key: $key"); 37 | } 38 | $excluded[] = $key; 39 | } 40 | } else if (str_starts_with($arg, '--include=')) { 41 | foreach(explode(',', substr($arg, 10)) as $key) { 42 | if (!ResultPrinter::isFilterKey($key)) { 43 | throw new \Exception("Invalid filter key: $key"); 44 | } 45 | $included[] = $key; 46 | } 47 | } 48 | } 49 | 50 | if (count($excluded) > 0 && count($included) > 0) { 51 | throw new \Exception("Cannot use --exclude and --include at the same time"); 52 | } 53 | 54 | return new self($excluded, $included); 55 | } 56 | 57 | public function isExcluding(): bool { 58 | return count($this->excluded) > 0; 59 | } 60 | 61 | /** 62 | * @param ResultPrinter::KEY_* $key 63 | */ 64 | public function containsKey(string $key): bool { 65 | return in_array($key, $this->excluded, true) || in_array($key, $this->included, true); 66 | } 67 | } -------------------------------------------------------------------------------- /lib/GraphApplication.php: -------------------------------------------------------------------------------- 1 | iterateOverFiles($jsonFiles); 19 | 20 | $graph = new GraphTemplate(); 21 | echo $graph->render($it); 22 | 23 | return 0; 24 | } 25 | 26 | /** 27 | * @api 28 | */ 29 | public function help(): void 30 | { 31 | printf("USAGE: phpstan-baseline-graph ''"); 32 | } 33 | 34 | /** 35 | * @param list $jsonFiles 36 | * @return Iterator 37 | */ 38 | private function iterateOverFiles(array $jsonFiles): Iterator 39 | { 40 | $reader = new AnalyzerResultReader(); 41 | foreach ($jsonFiles as $jsonFile) { 42 | if (strpos($jsonFile, '.json') === false) { 43 | throw new \RuntimeException('Expecting json file, got ' . $jsonFile); 44 | } 45 | 46 | $results = $reader->readFile($jsonFile); 47 | 48 | foreach ($results as $baselinePath => $analyzerResult) { 49 | yield [$baselinePath, $analyzerResult]; 50 | } 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /lib/GraphTemplate.php: -------------------------------------------------------------------------------- 1 | $it 12 | */ 13 | public function render(Iterator $it):string { 14 | $splines = []; 15 | $dates = []; 16 | $dataByDates = []; 17 | foreach($it as $data) { 18 | /** @var AnalyzerResult $analyzerResult */ 19 | list($baselinePath, $analyzerResult) = $data; 20 | 21 | if ($analyzerResult->referenceDate === null) { 22 | continue; 23 | } 24 | 25 | if (!array_key_exists($baselinePath, $splines)) { 26 | $splines[$baselinePath] = []; 27 | 28 | $splines[$baselinePath][0] = [ 29 | 'label' => ResultPrinter::KEY_OVERALL_ERRORS, 30 | 'borderColor' => 'blue', 31 | 'data' => [] 32 | ]; 33 | $splines[$baselinePath][1] = [ 34 | 'label' => ResultPrinter::KEY_CLASSES_COMPLEXITY, 35 | 'borderColor' => self::COMPLEXITY_COLOR, 36 | 'data' => [] 37 | ]; 38 | $splines[$baselinePath][2] = [ 39 | 'label' => ResultPrinter::KEY_DEPRECATIONS, 40 | 'borderColor' => 'lightgreen', 41 | 'data' => [] 42 | ]; 43 | $splines[$baselinePath][3] = [ 44 | 'label' => ResultPrinter::KEY_INVALID_PHPDOCS, 45 | 'borderColor' => 'lightblue', 46 | 'data' => [] 47 | ]; 48 | $splines[$baselinePath][4] = [ 49 | 'label' => ResultPrinter::KEY_UNKNOWN_TYPES, 50 | 'borderColor' => 'purple', 51 | 'data' => [] 52 | ]; 53 | $splines[$baselinePath][5] = [ 54 | 'label' => ResultPrinter::KEY_ANONYMOUS_VARIABLES, 55 | 'borderColor' => 'pink', 56 | 'data' => [] 57 | ]; 58 | $splines[$baselinePath][6] = [ 59 | 'label' => ResultPrinter::KEY_PROPERTY_TYPE_COVERAGE, 60 | 'yAxisID' => 'yPercent', 61 | 'borderColor' => 'lightcoral', 62 | 'borderWidth' => 2, 63 | 'type' => 'bar', 64 | 'data' => [] 65 | ]; 66 | $splines[$baselinePath][7] = [ 67 | 'label' => ResultPrinter::KEY_PARAM_TYPE_COVERAGE, 68 | 'yAxisID' => 'yPercent', 69 | 'borderColor' => 'lightseagreen', 70 | 'borderWidth' => 2, 71 | 'type' => 'bar', 72 | 'data' => [] 73 | ]; 74 | $splines[$baselinePath][8] = [ 75 | 'label' => ResultPrinter::KEY_RETURN_TYPE_COVERAGE, 76 | 'yAxisID' => 'yPercent', 77 | 'borderColor' => 'lightsteelblue', 78 | 'borderWidth' => 2, 79 | 'type' => 'bar', 80 | 'data' => [] 81 | ]; 82 | $splines[$baselinePath][9] = [ 83 | 'label' => ResultPrinter::KEY_UNUSED_SYMBOLS, 84 | 'borderColor' => 'lightyellow', 85 | 'data' => [] 86 | ]; 87 | } 88 | 89 | $dataByDates[$baselinePath][$analyzerResult->referenceDate->getTimestamp()] = [ 90 | $analyzerResult->overallErrors, 91 | $analyzerResult->classesComplexity, 92 | $analyzerResult->deprecations, 93 | $analyzerResult->invalidPhpdocs, 94 | $analyzerResult->unknownTypes, 95 | $analyzerResult->anonymousVariables, 96 | $analyzerResult->propertyTypeCoverage, 97 | $analyzerResult->paramTypeCoverage, 98 | $analyzerResult->returnTypeCoverage, 99 | $analyzerResult->unusedSymbols, 100 | ]; 101 | } 102 | 103 | foreach ($dataByDates as $baselinePath => $dataByDate) { 104 | foreach ($dataByDate as $date => $data) { 105 | $dates[$baselinePath][] = 'new Date(' . $date . ' * 1000).toLocaleDateString("de-DE")'; 106 | $splines[$baselinePath][0]['data'][] = $data[0]; 107 | $splines[$baselinePath][1]['data'][] = $data[1]; 108 | $splines[$baselinePath][2]['data'][] = $data[2]; 109 | $splines[$baselinePath][3]['data'][] = $data[3]; 110 | $splines[$baselinePath][4]['data'][] = $data[4]; 111 | $splines[$baselinePath][5]['data'][] = $data[5]; 112 | $splines[$baselinePath][6]['data'][] = $data[6]; 113 | $splines[$baselinePath][7]['data'][] = $data[7]; 114 | $splines[$baselinePath][8]['data'][] = $data[8]; 115 | } 116 | } 117 | 118 | $chartsHtml = ''; 119 | foreach($splines as $baselinePath => $data) { 120 | $chartData = [ 121 | 'labels' => $dates[$baselinePath], 122 | 'datasets' => $data 123 | ]; 124 | $chartsHtml .= ' 125 | 126 | 163 | '; 164 | } 165 | 166 | return ' 167 | 168 | 169 | 170 | 171 | 172 | '. $chartsHtml.' 173 | 174 | 175 | '; 176 | } 177 | } -------------------------------------------------------------------------------- /lib/ResultPrinter.php: -------------------------------------------------------------------------------- 1 | 38 | */ 39 | public static function getFilterKeys(): array { 40 | return [ 41 | self::KEY_CLASSES_COMPLEXITY, 42 | self::KEY_DEPRECATIONS, 43 | self::KEY_INVALID_PHPDOCS, 44 | self::KEY_UNKNOWN_TYPES, 45 | self::KEY_ANONYMOUS_VARIABLES, 46 | self::KEY_UNUSED_SYMBOLS, 47 | ]; 48 | } 49 | 50 | /** 51 | * @return Iterator 52 | */ 53 | public function streamText(Baseline $baseline, AnalyzerResult $result): Iterator 54 | { 55 | $referenceDate = ''; 56 | if ($result->referenceDate !== null) { 57 | $referenceDate = $result->referenceDate->format(ResultPrinter::DATE_FORMAT); 58 | } 59 | 60 | yield sprintf("Analyzing %s\n", $baseline->getFilePath()); 61 | yield sprintf(" %s: %s\n", self::KEY_REFERENCE_DATE, $referenceDate); 62 | yield sprintf(" %s: %s\n", self::KEY_OVERALL_ERRORS, $result->overallErrors); 63 | yield sprintf(" %s: %s\n", self::KEY_CLASSES_COMPLEXITY, $result->classesComplexity); 64 | yield sprintf(" %s: %s\n", self::KEY_DEPRECATIONS, $result->deprecations); 65 | yield sprintf(" %s: %s\n", self::KEY_INVALID_PHPDOCS, $result->invalidPhpdocs); 66 | yield sprintf(" %s: %s\n", self::KEY_UNKNOWN_TYPES, $result->unknownTypes); 67 | yield sprintf(" %s: %s\n", self::KEY_ANONYMOUS_VARIABLES, $result->anonymousVariables); 68 | yield sprintf(" %s: %s\n", self::KEY_PROPERTY_TYPE_COVERAGE, $result->propertyTypeCoverage); 69 | yield sprintf(" %s: %s\n", self::KEY_PARAM_TYPE_COVERAGE, $result->paramTypeCoverage); 70 | yield sprintf(" %s: %s\n", self::KEY_RETURN_TYPE_COVERAGE, $result->returnTypeCoverage); 71 | yield sprintf(" %s: %s\n", self::KEY_UNUSED_SYMBOLS, $result->unusedSymbols); 72 | } 73 | 74 | 75 | /** 76 | * @return Iterator 77 | */ 78 | public function streamJson(Baseline $baseline, AnalyzerResult $result): Iterator 79 | { 80 | $referenceDate = null; 81 | if ($result->referenceDate !== null) { 82 | $referenceDate = $result->referenceDate->format(ResultPrinter::DATE_FORMAT); 83 | } 84 | 85 | yield json_encode([ 86 | $baseline->getFilePath() => [ 87 | self::KEY_REFERENCE_DATE => $referenceDate, 88 | self::KEY_OVERALL_ERRORS => $result->overallErrors, 89 | self::KEY_CLASSES_COMPLEXITY => $result->classesComplexity, 90 | self::KEY_DEPRECATIONS => $result->deprecations, 91 | self::KEY_INVALID_PHPDOCS => $result->invalidPhpdocs, 92 | self::KEY_UNKNOWN_TYPES => $result->unknownTypes, 93 | self::KEY_ANONYMOUS_VARIABLES => $result->anonymousVariables, 94 | self::KEY_PROPERTY_TYPE_COVERAGE => $result->propertyTypeCoverage, 95 | self::KEY_PARAM_TYPE_COVERAGE => $result->paramTypeCoverage, 96 | self::KEY_RETURN_TYPE_COVERAGE => $result->returnTypeCoverage, 97 | self::KEY_UNUSED_SYMBOLS => $result->unusedSymbols, 98 | ] 99 | ]); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /lib/TrendApplication.php: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | public static function getAllowedOutputFormats(): array 31 | { 32 | return [ 33 | self::OUTPUT_FORMAT_DEFAULT, 34 | self::OUTPUT_FORMAT_JSON, 35 | ]; 36 | } 37 | 38 | /** 39 | * @param self::OUTPUT_FORMAT_* $outputFormat 40 | * @return self::EXIT_* 41 | * 42 | * @api 43 | */ 44 | public function start(string $referenceFilePath, string $comparingFilePath, string $outputFormat): int 45 | { 46 | $exitCode = self::EXIT_IMPROVED; 47 | 48 | $reader = new AnalyzerResultReader(); 49 | $reference = $reader->readFile($referenceFilePath); 50 | $comparing = $reader->readFile($comparingFilePath); 51 | 52 | if ($outputFormat === self::OUTPUT_FORMAT_JSON) { 53 | return $this->createOutputJson($reference, $comparing, $exitCode); 54 | } 55 | 56 | return $this->createOutputText($reference, $comparing, $exitCode); 57 | } 58 | 59 | /** 60 | * @api 61 | */ 62 | public function help(): void 63 | { 64 | printf('USAGE: phpstan-baseline-trend [--format=json|text]'); 65 | } 66 | 67 | 68 | /** 69 | * @param array $reference 70 | * @param array $comparing 71 | * @param self::EXIT_* $exitCode 72 | * 73 | * @return self::EXIT_* 74 | */ 75 | private function createOutputText(array $reference, array $comparing, int $exitCode): int 76 | { 77 | foreach ($reference as $baselinePath => $result) { 78 | list($trendResult, $exitCode) = $this->createTrendResult($baselinePath, $comparing, $result, $exitCode); 79 | 80 | echo $trendResult->headline . "\n"; 81 | foreach($trendResult->results as $key => $stats) { 82 | echo ' '.$key.': '.$stats['reference']." -> ".$stats['comparing']." => ".$stats['trend']."\n"; 83 | } 84 | } 85 | 86 | return $exitCode; 87 | } 88 | 89 | /** 90 | * @param array $reference 91 | * @param array $comparing 92 | * @param self::EXIT_* $exitCode 93 | * 94 | * @return self::EXIT_* 95 | */ 96 | private function createOutputJson(array $reference, array $comparing, int $exitCode): int 97 | { 98 | $trendResults = []; 99 | foreach ($reference as $baselinePath => $result) { 100 | 101 | list($trendResult, $exitCode) = $this->createTrendResult($baselinePath, $comparing, $result, $exitCode); 102 | 103 | $trendResults[] = $trendResult; 104 | } 105 | 106 | echo json_encode($trendResults); 107 | 108 | 109 | return $exitCode; 110 | } 111 | 112 | /** 113 | * @param array $comparing 114 | * @param self::EXIT_* $exitCode 115 | * 116 | * @return array{TrendResult, self::EXIT_*} 117 | */ 118 | private function createTrendResult(string $baselinePath, array $comparing, AnalyzerResult $reference, int $exitCode): array 119 | { 120 | $trendResult = new TrendResult('Analyzing Trend for ' . $baselinePath); 121 | 122 | if (!isset($comparing[$baselinePath])) { 123 | return array($trendResult, $exitCode); 124 | } 125 | 126 | // decreased trends are better 127 | $exitCode = $this->compareDecreasing($trendResult, ResultPrinter::KEY_OVERALL_ERRORS, $reference->overallErrors, $comparing[$baselinePath]->overallErrors, $exitCode); 128 | $exitCode = $this->compareDecreasing($trendResult, ResultPrinter::KEY_CLASSES_COMPLEXITY, $reference->classesComplexity, $comparing[$baselinePath]->classesComplexity, $exitCode); 129 | $exitCode = $this->compareDecreasing($trendResult, ResultPrinter::KEY_DEPRECATIONS, $reference->deprecations, $comparing[$baselinePath]->deprecations, $exitCode); 130 | $exitCode = $this->compareDecreasing($trendResult, ResultPrinter::KEY_INVALID_PHPDOCS, $reference->invalidPhpdocs, $comparing[$baselinePath]->invalidPhpdocs, $exitCode); 131 | $exitCode = $this->compareDecreasing($trendResult, ResultPrinter::KEY_UNKNOWN_TYPES, $reference->unknownTypes, $comparing[$baselinePath]->unknownTypes, $exitCode); 132 | $exitCode = $this->compareDecreasing($trendResult, ResultPrinter::KEY_ANONYMOUS_VARIABLES, $reference->anonymousVariables, $comparing[$baselinePath]->anonymousVariables, $exitCode); 133 | $exitCode = $this->compareDecreasing($trendResult, ResultPrinter::KEY_UNUSED_SYMBOLS, $reference->unusedSymbols, $comparing[$baselinePath]->unusedSymbols, $exitCode); 134 | 135 | // increased trends are better 136 | $exitCode = $this->compareIncreasing($trendResult, ResultPrinter::KEY_RETURN_TYPE_COVERAGE, $reference->returnTypeCoverage, $comparing[$baselinePath]->returnTypeCoverage, $exitCode); 137 | $exitCode = $this->compareIncreasing($trendResult, ResultPrinter::KEY_PROPERTY_TYPE_COVERAGE, $reference->propertyTypeCoverage, $comparing[$baselinePath]->propertyTypeCoverage, $exitCode); 138 | $exitCode = $this->compareIncreasing($trendResult, ResultPrinter::KEY_PARAM_TYPE_COVERAGE, $reference->paramTypeCoverage, $comparing[$baselinePath]->paramTypeCoverage, $exitCode); 139 | 140 | return array($trendResult, $exitCode); 141 | } 142 | 143 | /** 144 | * @param ResultPrinter::KEY_* $key 145 | * @param int $referenceValue 146 | * @param int $comparingValue 147 | * @param self::EXIT_* $exitCode 148 | * 149 | * @return self::EXIT_* 150 | */ 151 | private function compareDecreasing(TrendResult $trendResult, string $key, $referenceValue, $comparingValue, int $exitCode): int 152 | { 153 | if ($comparingValue > $referenceValue) { 154 | $trendResult->setKey($key, $referenceValue, $comparingValue, 'worse'); 155 | $exitCode = max($exitCode, self::EXIT_WORSE); 156 | } elseif ($comparingValue < $referenceValue) { 157 | $trendResult->setKey($key, $referenceValue, $comparingValue, 'improved'); 158 | $exitCode = max($exitCode, self::EXIT_IMPROVED); 159 | } else { 160 | $trendResult->setKey($key, $referenceValue, $comparingValue, 'good'); 161 | $exitCode = max($exitCode, self::EXIT_STEADY); 162 | } 163 | 164 | return $exitCode; 165 | } 166 | 167 | /** 168 | * @param ResultPrinter::KEY_* $key 169 | * @param int $referenceValue 170 | * @param int $comparingValue 171 | * @param self::EXIT_* $exitCode 172 | * 173 | * @return self::EXIT_* 174 | */ 175 | private function compareIncreasing(TrendResult $trendResult, string $key, $referenceValue, $comparingValue, int $exitCode): int 176 | { 177 | if ($comparingValue > $referenceValue) { 178 | $trendResult->setKey($key, $referenceValue, $comparingValue, 'improved'); 179 | $exitCode = max($exitCode, self::EXIT_IMPROVED); 180 | } elseif ($comparingValue < $referenceValue) { 181 | $trendResult->setKey($key, $referenceValue, $comparingValue, 'worse'); 182 | $exitCode = max($exitCode, self::EXIT_WORSE); 183 | } else { 184 | $trendResult->setKey($key, $referenceValue, $comparingValue, 'good'); 185 | $exitCode = max($exitCode, self::EXIT_STEADY); 186 | } 187 | 188 | return $exitCode; 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /lib/TrendResult.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | public array $results; 13 | 14 | public function __construct(string $headline) 15 | { 16 | $this->headline = $headline; 17 | $this->results = []; 18 | } 19 | 20 | /** 21 | * @param ResultPrinter::KEY_* $key 22 | * @param int $referenceValue 23 | * @param int $comparingValue 24 | * @return void 25 | */ 26 | public function setKey(string $key, $referenceValue, $comparingValue, string $trend): void 27 | { 28 | $this->results[$key] = [ 29 | 'reference' => $referenceValue, 30 | 'comparing' => $comparingValue, 31 | 'trend' => $trend, 32 | ]; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /phpstan-baseline.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | - 4 | message: '#^Out of 28 possible constant types, only 0 \- 0\.0 %% actually have it\. Add more constant types to get over 99 %%$#' 5 | count: 2 6 | path: lib/AnalyzeApplication.php 7 | 8 | - 9 | message: '#^Parameter \#1 \$json of function json_decode expects string, string\|false given\.$#' 10 | identifier: argument.type 11 | count: 1 12 | path: lib/AnalyzerResultReader.php 13 | 14 | - 15 | message: '#^Parameter \#2 \$datetime of static method DateTimeImmutable\:\:createFromFormat\(\) expects string, mixed given\.$#' 16 | identifier: argument.type 17 | count: 1 18 | path: lib/AnalyzerResultReader.php 19 | 20 | - 21 | message: '#^Property staabm\\PHPStanBaselineAnalysis\\AnalyzerResult\:\:\$anonymousVariables \(int\) does not accept mixed\.$#' 22 | identifier: assign.propertyType 23 | count: 1 24 | path: lib/AnalyzerResultReader.php 25 | 26 | - 27 | message: '#^Property staabm\\PHPStanBaselineAnalysis\\AnalyzerResult\:\:\$classesComplexity \(int\) does not accept mixed\.$#' 28 | identifier: assign.propertyType 29 | count: 1 30 | path: lib/AnalyzerResultReader.php 31 | 32 | - 33 | message: '#^Property staabm\\PHPStanBaselineAnalysis\\AnalyzerResult\:\:\$deprecations \(int\) does not accept mixed\.$#' 34 | identifier: assign.propertyType 35 | count: 1 36 | path: lib/AnalyzerResultReader.php 37 | 38 | - 39 | message: '#^Property staabm\\PHPStanBaselineAnalysis\\AnalyzerResult\:\:\$invalidPhpdocs \(int\) does not accept mixed\.$#' 40 | identifier: assign.propertyType 41 | count: 1 42 | path: lib/AnalyzerResultReader.php 43 | 44 | - 45 | message: '#^Property staabm\\PHPStanBaselineAnalysis\\AnalyzerResult\:\:\$overallErrors \(int\) does not accept mixed\.$#' 46 | identifier: assign.propertyType 47 | count: 1 48 | path: lib/AnalyzerResultReader.php 49 | 50 | - 51 | message: '#^Property staabm\\PHPStanBaselineAnalysis\\AnalyzerResult\:\:\$paramTypeCoverage \(int\<0, 100\>\) does not accept mixed\.$#' 52 | identifier: assign.propertyType 53 | count: 1 54 | path: lib/AnalyzerResultReader.php 55 | 56 | - 57 | message: '#^Property staabm\\PHPStanBaselineAnalysis\\AnalyzerResult\:\:\$propertyTypeCoverage \(int\<0, 100\>\) does not accept mixed\.$#' 58 | identifier: assign.propertyType 59 | count: 1 60 | path: lib/AnalyzerResultReader.php 61 | 62 | - 63 | message: '#^Property staabm\\PHPStanBaselineAnalysis\\AnalyzerResult\:\:\$returnTypeCoverage \(int\<0, 100\>\) does not accept mixed\.$#' 64 | identifier: assign.propertyType 65 | count: 1 66 | path: lib/AnalyzerResultReader.php 67 | 68 | - 69 | message: '#^Property staabm\\PHPStanBaselineAnalysis\\AnalyzerResult\:\:\$unknownTypes \(int\) does not accept mixed\.$#' 70 | identifier: assign.propertyType 71 | count: 1 72 | path: lib/AnalyzerResultReader.php 73 | 74 | - 75 | message: '#^Property staabm\\PHPStanBaselineAnalysis\\AnalyzerResult\:\:\$unusedSymbols \(int\) does not accept mixed\.$#' 76 | identifier: assign.propertyType 77 | count: 1 78 | path: lib/AnalyzerResultReader.php 79 | 80 | - 81 | message: '#^Call to function is_array\(\) with array\{ignoreErrors\?\: list\\} will always evaluate to true\.$#' 82 | identifier: function.alreadyNarrowedType 83 | count: 1 84 | path: lib/Baseline.php 85 | 86 | - 87 | message: '#^Call to function is_array\(\) with list\ will always evaluate to true\.$#' 88 | identifier: function.alreadyNarrowedType 89 | count: 1 90 | path: lib/Baseline.php 91 | 92 | - 93 | message: '#^Parameter \#1 \$input of static method Nette\\Neon\\Neon\:\:decode\(\) expects string, string\|false given\.$#' 94 | identifier: argument.type 95 | count: 1 96 | path: lib/Baseline.php 97 | 98 | - 99 | message: '#^Property staabm\\PHPStanBaselineAnalysis\\Baseline\:\:\$content \(array\{parameters\?\: array\{ignoreErrors\?\: list\\}\}\) does not accept array\\.$#' 100 | identifier: assign.propertyType 101 | count: 1 102 | path: lib/Baseline.php 103 | 104 | - 105 | message: '#^Method staabm\\PHPStanBaselineAnalysis\\BaselineAnalyzer\:\:normalizeMessage\(\) should return string but returns string\|null\.$#' 106 | identifier: return.type 107 | count: 1 108 | path: lib/BaselineAnalyzer.php 109 | 110 | - 111 | message: '#^Out of 28 possible constant types, only 0 \- 0\.0 %% actually have it\. Add more constant types to get over 99 %%$#' 112 | count: 4 113 | path: lib/BaselineAnalyzer.php 114 | 115 | - 116 | message: '#^Out of 28 possible constant types, only 0 \- 0\.0 %% actually have it\. Add more constant types to get over 99 %%$#' 117 | count: 2 118 | path: lib/FilterApplication.php 119 | 120 | - 121 | message: '#^Out of 28 possible constant types, only 0 \- 0\.0 %% actually have it\. Add more constant types to get over 99 %%$#' 122 | count: 1 123 | path: lib/GraphTemplate.php 124 | 125 | - 126 | message: '#^Generator expects value type string, string\|false given\.$#' 127 | identifier: generator.valueType 128 | count: 1 129 | path: lib/ResultPrinter.php 130 | 131 | - 132 | message: '#^Out of 28 possible constant types, only 0 \- 0\.0 %% actually have it\. Add more constant types to get over 99 %%$#' 133 | count: 14 134 | path: lib/ResultPrinter.php 135 | 136 | - 137 | message: '#^Out of 28 possible constant types, only 0 \- 0\.0 %% actually have it\. Add more constant types to get over 99 %%$#' 138 | count: 5 139 | path: lib/TrendApplication.php 140 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | includes: 2 | - phar://phpstan.phar/conf/bleedingEdge.neon 3 | - phpstan-baseline.neon 4 | 5 | parameters: 6 | level: max 7 | reportUnmatchedIgnoredErrors: false 8 | 9 | paths: 10 | - bin/ 11 | - lib/ 12 | 13 | unused_public: 14 | methods: true 15 | properties: true 16 | constants: true 17 | 18 | cognitive_complexity: 19 | class: 50 20 | function: 13 21 | 22 | type_coverage: 23 | return_type: 100 24 | param_type: 92 25 | property_type: 0 26 | print_suggestions: false 27 | 28 | ignoreErrors: 29 | - 30 | message: '#Cognitive complexity for "staabm\\PHPStanBaselineAnalysis\\FilterConfig::fromArgs\(\)" is 25, keep it under 13#' 31 | path: 'lib/FilterConfig.php' -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | tests 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | --------------------------------------------------------------------------------