From 6b6e5cc6a63a22a0290701852906a545b1d55416 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Sat, 4 May 2024 12:10:18 +0200 Subject: [PATCH] init --- .github/FUNDING.yml | 2 + .github/workflows/code_analysis.yaml | 49 +++++ .github/workflows/rector.yaml | 43 +++++ .gitignore | 11 +- LICENSE | 4 +- README.md | 60 ++++-- bin/class-leak | 4 - bin/class-leak.php | 30 --- bin/phpstan-bodyscan | 4 + bin/phpstan-bodyscan.php | 37 ++++ composer.json | 41 ++-- ecs.php | 6 +- phpstan.neon | 24 ++- phpunit.xml | 18 +- rector.php | 1 - src/ClassNameResolver.php | 52 ------ src/Command/RunCommand.php | 176 ++++++++++++++++++ src/Commands/CheckCommand.php | 150 --------------- src/DependencyInjection/ContainerFactory.php | 70 ------- src/Exception/AnalysisFailedException.php | 11 ++ .../StaticRelativeFilePathHelper.php | 18 -- src/Filtering/PossiblyUnusedClassesFilter.php | 132 ------------- src/Finder/ClassNamesFinder.php | 42 ----- src/Finder/PhpFilesFinder.php | 44 ----- src/Logger.php | 18 ++ .../FullyQualifiedNameNodeDecorator.php | 24 --- src/NodeVisitor/ClassNameNodeVisitor.php | 124 ------------ src/NodeVisitor/UsedClassNodeVisitor.php | 80 -------- src/Process/AnalyseProcessFactory.php | 85 +++++++++ src/Reporting/UnusedClassReporter.php | 78 -------- src/Reporting/UnusedClassesResultFactory.php | 30 --- src/UseImportsResolver.php | 45 ----- src/Utils/FileLoader.php | 37 ++++ src/Utils/JsonLoader.php | 26 +++ src/ValueObject/ClassNames.php | 36 ---- src/ValueObject/FileWithClass.php | 57 ------ src/ValueObject/PHPStanLevelResult.php | 24 +++ src/ValueObject/UnusedClassesResult.php | 39 ---- tests/AbstractTestCase.php | 28 --- .../ClassNameResolverTest.php | 66 ------- .../Fixture/ClassWithAnyComment.php | 10 - .../Fixture/ClassWithApiComment.php | 10 - .../Fixture/SomeAttribute.php | 12 -- tests/ClassNameResolver/Fixture/SomeClass.php | 14 -- .../Fixture/SomeMethodAttribute.php | 12 -- tests/FileSystem/Fixture/some-file.php | 5 - .../StaticRelativeFilePathHelperTest.php | 17 -- tests/Finder/Fixture/core_file.php | 5 - tests/Finder/Fixture/core_file.phtml | 5 - tests/Finder/Fixture/some/file.php | 5 - .../Fixture/some/more-nested/nested-file.php | 5 - tests/Finder/PhpFilesFinderTest.php | 29 --- .../Fixture/FileUsesStaticCall.php | 15 -- .../Fixture/FileUsingOtherClasses.php | 16 -- .../Fixture/SomeFactory.php | 15 -- .../Source/FirstUsedClass.php | 9 - .../Source/FourthUsedClass.php | 13 -- .../Source/SecondUsedClass.php | 9 - .../Source/ThirdUsedClass.php | 9 - .../UseImportsResolverTest.php | 42 ----- 60 files changed, 611 insertions(+), 1472 deletions(-) create mode 100644 .github/FUNDING.yml create mode 100644 .github/workflows/code_analysis.yaml create mode 100644 .github/workflows/rector.yaml delete mode 100755 bin/class-leak delete mode 100755 bin/class-leak.php create mode 100755 bin/phpstan-bodyscan create mode 100755 bin/phpstan-bodyscan.php delete mode 100644 src/ClassNameResolver.php create mode 100644 src/Command/RunCommand.php delete mode 100644 src/Commands/CheckCommand.php delete mode 100644 src/DependencyInjection/ContainerFactory.php create mode 100644 src/Exception/AnalysisFailedException.php delete mode 100644 src/FileSystem/StaticRelativeFilePathHelper.php delete mode 100644 src/Filtering/PossiblyUnusedClassesFilter.php delete mode 100644 src/Finder/ClassNamesFinder.php delete mode 100644 src/Finder/PhpFilesFinder.php create mode 100644 src/Logger.php delete mode 100644 src/NodeDecorator/FullyQualifiedNameNodeDecorator.php delete mode 100644 src/NodeVisitor/ClassNameNodeVisitor.php delete mode 100644 src/NodeVisitor/UsedClassNodeVisitor.php create mode 100644 src/Process/AnalyseProcessFactory.php delete mode 100644 src/Reporting/UnusedClassReporter.php delete mode 100644 src/Reporting/UnusedClassesResultFactory.php delete mode 100644 src/UseImportsResolver.php create mode 100644 src/Utils/FileLoader.php create mode 100644 src/Utils/JsonLoader.php delete mode 100644 src/ValueObject/ClassNames.php delete mode 100644 src/ValueObject/FileWithClass.php create mode 100644 src/ValueObject/PHPStanLevelResult.php delete mode 100644 src/ValueObject/UnusedClassesResult.php delete mode 100644 tests/AbstractTestCase.php delete mode 100644 tests/ClassNameResolver/ClassNameResolverTest.php delete mode 100644 tests/ClassNameResolver/Fixture/ClassWithAnyComment.php delete mode 100644 tests/ClassNameResolver/Fixture/ClassWithApiComment.php delete mode 100644 tests/ClassNameResolver/Fixture/SomeAttribute.php delete mode 100644 tests/ClassNameResolver/Fixture/SomeClass.php delete mode 100644 tests/ClassNameResolver/Fixture/SomeMethodAttribute.php delete mode 100644 tests/FileSystem/Fixture/some-file.php delete mode 100644 tests/FileSystem/StaticRelativeFilePathHelperTest.php delete mode 100644 tests/Finder/Fixture/core_file.php delete mode 100644 tests/Finder/Fixture/core_file.phtml delete mode 100644 tests/Finder/Fixture/some/file.php delete mode 100644 tests/Finder/Fixture/some/more-nested/nested-file.php delete mode 100644 tests/Finder/PhpFilesFinderTest.php delete mode 100644 tests/UseImportsResolver/Fixture/FileUsesStaticCall.php delete mode 100644 tests/UseImportsResolver/Fixture/FileUsingOtherClasses.php delete mode 100644 tests/UseImportsResolver/Fixture/SomeFactory.php delete mode 100644 tests/UseImportsResolver/Source/FirstUsedClass.php delete mode 100644 tests/UseImportsResolver/Source/FourthUsedClass.php delete mode 100644 tests/UseImportsResolver/Source/SecondUsedClass.php delete mode 100644 tests/UseImportsResolver/Source/ThirdUsedClass.php delete mode 100644 tests/UseImportsResolver/UseImportsResolverTest.php diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..35ae259 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +# These are supported funding model platforms +github: tomasvotruba diff --git a/.github/workflows/code_analysis.yaml b/.github/workflows/code_analysis.yaml new file mode 100644 index 0000000..289502d --- /dev/null +++ b/.github/workflows/code_analysis.yaml @@ -0,0 +1,49 @@ +name: Code Analysis + +on: + pull_request: null + push: + branches: + - main + +jobs: + code_analysis: + strategy: + fail-fast: false + matrix: + actions: + - + name: 'PHPStan' + run: composer phpstan --ansi + + - + name: 'Composer Validate' + run: composer validate --ansi + + - + name: 'Coding Standard' + run: composer fix-cs --ansi + + - + name: 'Check Commented Code' + run: vendor/bin/easy-ci check-commented-code src --ansi + + - + name: 'Check Active Classes' + run: vendor/bin/class-leak check src --ansi + + name: ${{ matrix.actions.name }} + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + # see https://github.com/shivammathur/setup-php + - uses: shivammathur/setup-php@v2 + with: + php-version: 8.2 + coverage: none + + # composer install cache - https://github.com/ramsey/composer-install + - uses: "ramsey/composer-install@v2" + + - run: ${{ matrix.actions.run }} diff --git a/.github/workflows/rector.yaml b/.github/workflows/rector.yaml new file mode 100644 index 0000000..6757d41 --- /dev/null +++ b/.github/workflows/rector.yaml @@ -0,0 +1,43 @@ +name: Rector + +on: + pull_request: null + +jobs: + rector: + # run only on core developers with access + if: github.event.pull_request.head.repo.full_name == github.repository + + runs-on: ubuntu-latest + + steps: + - + uses: actions/checkout@v2 + with: + token: ${{ secrets.ACCESS_TOKEN }} + + - + uses: shivammathur/setup-php@v2 + with: + php-version: 8.2 + + - uses: "ramsey/composer-install@v1" + + ## First run Rector - here can't be --dry-run !!! it would stop the job with it and not commit anything in the future + - run: vendor/bin/rector process ${{ matrix.directories }} --ansi + + - run: vendor/bin/ecs check --fix --ansi + + # see https://github.com/EndBug/add-and-commit + - + # commit only to core contributors who have repository access + if: github.event.pull_request.head.repo.full_name == github.repository + uses: EndBug/add-and-commit@v7.0.0 + with: + # The arguments for the `git add` command (see the paragraph below for more info) + add: . + message: "[ci-review] Rector Rectify" + author_name: "GitHub Action" + author_email: "action@github.com" + env: + GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} diff --git a/.gitignore b/.gitignore index b390008..62f96ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,4 @@ composer.lock /vendor -.phpunit.cache - - -bootstrap/cache/* -!bootstrap/cache/.gitkeep - -storage/* - -# avoid commiting scoper phar file -php-scoper.phar +bodyscan-log.txt diff --git a/LICENSE b/LICENSE index bd1ea72..3a606cf 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,7 @@ The MIT License --------------- -Copyright (c) 2020 Tomas Votruba (https://tomasvotruba.com) +Copyright (c) 2024 Tomas Votruba (https://tomasvotruba.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation @@ -22,4 +22,4 @@ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file +OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 11003fe..a0faabe 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,75 @@ -# Class Leak +# PHPStan Bodyscan -[![Downloads total](https://img.shields.io/packagist/dt/tomasvotruba/class-leak.svg?style=flat-square)](https://packagist.org/packages/tomasvotruba/class-leak/stats) +[![Downloads total](https://img.shields.io/packagist/dt/tomasvotruba/phpstan-bodyscan.svg?style=flat-square)](https://packagist.org/packages/tomasvotruba/phpstan-bodyscan/stats) + +Do you want to get quick glimpse of new project code quality? + +Get error count for each PHPStan level! -Find leaking classes that you never use... and get rid of them. ## Install ```bash -composer require tomasvotruba/class-leak --dev +composer require tomasvotruba/phpstan-bodyscan --dev ``` ## Usage -Pass directories you want to check: +Run tool in your project. It will take some time, as it will run PHPStan for each level. + ```bash -vendor/bin/class-leak check bin src +vendor/bin/phpstan-bodyscan ``` -Make sure to exclude `/tests` directories, to keep reporting classes that are used in tests, but never used in the code-base. - -
+↓ -Many types are excluded by default, as they're collected by framework magic, e.g. console command classes. To exclude another class, e.g. your interface collector, use `--skip-type`: +To get errors count per level: ```bash -vendor/bin/class-leak check bin src --skip-type="App\\Contract\\SomeInterface" ++-------+-------------+ +| Level | Error count | ++-------+-------------+ +| 0 | 0 | +| 1 | 35 | +| 2 | 59 | +| 3 | 93 | +| 4 | 120 | +| 5 | 125 | +| 6 | 253 | +| 7 | 350 | +| 8 | 359 | ++-------+-------------+ ``` -What if your classes do no implement any type? Use `--skip-suffix` instead: +
+ +### Limit level count + +Are you interested only in a few levels? You can limit ranges by the options: ```bash -vendor/bin/class-leak check bin src --skip-suffix "Controller" +vendor/bin/phpstan-bodyscan run --min-level 0 --max-level 3 ``` -If you want to skip classes that use a specific attribute or have methods that use a specific attribute, use `--skip-attribute`: +
+ +### Load env file + +Some projects need to load `.env` file to run PHPStan. You can do it like this: ```bash -vendor/bin/class-leak check bin src --skip-attribute "Symfony\\Component\\HttpKernel\\Attribute\\AsController" +vendor/bin/phpstan-bodyscan run --env-file some-parameters.env ```
+### Debugging + +Running PHPStan on a new project you don't know might crash. To save data from finished levels, we dump them to the `bodyscan-log.txt` file. + +If the run crashes for any reason, the PHPStan error output is also dumped to the same file. + +
+ Happy coding! diff --git a/bin/class-leak b/bin/class-leak deleted file mode 100755 index 66669f8..0000000 --- a/bin/class-leak +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env php -create(); - -/** @var Application $application */ -$application = $container->make(Application::class); - -$exitCode = $application->run(new ArgvInput(), new ConsoleOutput()); -exit($exitCode); diff --git a/bin/phpstan-bodyscan b/bin/phpstan-bodyscan new file mode 100755 index 0000000..95a64af --- /dev/null +++ b/bin/phpstan-bodyscan @@ -0,0 +1,4 @@ +#!/usr/bin/env php +add($runCommand); +$application->setDefaultCommand('run'); + +// hide default commands +$application->get('completion') + ->setHidden(); +$application->get('help') + ->setHidden(); +$application->get('list') + ->setHidden(); + +$exitCode = $application->run(new ArgvInput(), new ConsoleOutput()); +exit($exitCode); diff --git a/composer.json b/composer.json index 7d2dc78..6f2ade2 100644 --- a/composer.json +++ b/composer.json @@ -1,49 +1,36 @@ { - "name": "tomasvotruba/class-leak", - "description": "Detect leaking classes", + "name": "tomasvotruba/phpstan-bodyscan", + "description": "Get error count for each PHPStan level", "license": "MIT", "bin": [ - "bin/class-leak", - "bin/class-leak.php" + "bin/phpstan-bodyscan", + "bin/phpstan-bodyscan.php" ], "require": { "php": ">=8.2", - "illuminate/container": "^11.0", - "nette/utils": "^3.2", - "nikic/php-parser": "^4.19", "symfony/console": "^6.4", - "symfony/finder": "^6.4", + "symfony/process": "^7.0", "webmozart/assert": "^1.11" }, "require-dev": { - "phpstan/extension-installer": "^1.2", - "phpstan/phpstan": "^1.10.57", - "phpunit/phpunit": "^10.5", + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan": "^1.10", "rector/rector": "^1.0", + "symplify/easy-ci": "^12.1", "symplify/easy-coding-standard": "^12.1", - "symplify/phpstan-extensions": "^11.2", - "tomasvotruba/unused-public": "^0.2", + "symplify/phpstan-rules": "^12.4", + "tomasvotruba/class-leak": "^0.2.13", + "tomasvotruba/type-coverage": "^0.2.8", + "tomasvotruba/unused-public": "^0.3.8", "tracy/tracy": "^2.10" }, "autoload": { "psr-4": { - "TomasVotruba\\ClassLeak\\": "src" + "TomasVotruba\\PHPStanBodyscan\\": "src" } }, - "autoload-dev": { - "psr-4": { - "TomasVotruba\\ClassLeak\\Tests\\": "tests" - } - }, - "replace": { - "symfony/polyfill-ctype": "*", - "symfony/polyfill-intl-grapheme": "*", - "symfony/polyfill-intl-normalizer": "*", - "symfony/polyfill-mbstring": "*" - }, "config": { "sort-packages": true, - "platform-check": false, "allow-plugins": { "phpstan/extension-installer": true } @@ -51,7 +38,7 @@ "scripts": { "check-cs": "vendor/bin/ecs check --ansi", "fix-cs": "vendor/bin/ecs check --fix --ansi", - "phpstan": "vendor/bin/phpstan analyse --ansi --error-format symplify", + "phpstan": "vendor/bin/phpstan analyse --ansi", "rector": "vendor/bin/rector process --dry-run --ansi" } } diff --git a/ecs.php b/ecs.php index 22b69d9..e4b6b03 100644 --- a/ecs.php +++ b/ecs.php @@ -1,12 +1,16 @@ withPaths([ + __DIR__ . '/bin', __DIR__ . '/src', - __DIR__ . '/tests', + ]) + ->withRules([ + LineLengthFixer::class, ]) ->withPreparedSets(psr12: true, common: true, symplify: true); diff --git a/phpstan.neon b/phpstan.neon index 0ba4006..f50d3d4 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,11 +1,27 @@ +# https://github.com/symplify/phpstan-rules +includes: + - vendor/symplify/phpstan-rules/config/code-complexity-rules.neon + - vendor/symplify/phpstan-rules/config/collector-rules.neon + - vendor/symplify/phpstan-rules/config/naming-rules.neon + - vendor/symplify/phpstan-rules/config/regex-rules.neon + - vendor/symplify/phpstan-rules/config/static-rules.neon + parameters: level: 8 paths: - bin - src - - tests - excludePaths: - - */Fixture/* - - */Source/* + unused_public: + methods: true + constants: true + properties: true + + type_coverage: + param: 99 + property: 99 + return: 99 + + ignoreErrors: + - '#Relative file path \"bodyscan-log\.txt\" is not allowed, use absolute one with __DIR__#' diff --git a/phpunit.xml b/phpunit.xml index 9250bea..b566793 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,8 +1,14 @@ - - - - tests - - + + + + tests + + diff --git a/rector.php b/rector.php index a2fdce5..5421976 100644 --- a/rector.php +++ b/rector.php @@ -7,7 +7,6 @@ return RectorConfig::configure() ->withPaths([ __DIR__ . '/src', - __DIR__ . '/tests', ]) ->withPreparedSets( deadCode: true, naming: true, privatization: true, earlyReturn: true, codeQuality: true, codingStyle: true, typeDeclarations: true diff --git a/src/ClassNameResolver.php b/src/ClassNameResolver.php deleted file mode 100644 index 159b79e..0000000 --- a/src/ClassNameResolver.php +++ /dev/null @@ -1,52 +0,0 @@ -parser->parse($fileContents); - if ($stmts === null) { - return null; - } - - $this->fullyQualifiedNameNodeDecorator->decorate($stmts); - - $classNameNodeVisitor = new ClassNameNodeVisitor(); - $nodeTraverser = new NodeTraverser(); - $nodeTraverser->addVisitor($classNameNodeVisitor); - $nodeTraverser->traverse($stmts); - - $className = $classNameNodeVisitor->getClassName(); - if (! is_string($className)) { - return null; - } - - return new ClassNames( - $className, - $classNameNodeVisitor->hasParentClassOrInterface(), - $classNameNodeVisitor->getAttributes(), - ); - } -} diff --git a/src/Command/RunCommand.php b/src/Command/RunCommand.php new file mode 100644 index 0000000..b01fc8a --- /dev/null +++ b/src/Command/RunCommand.php @@ -0,0 +1,176 @@ +setName('run'); + $this->setDescription('Check classes that are not used in any config and in the code'); + + $this->addArgument('directory', InputArgument::OPTIONAL, 'Directory to scan', getcwd()); + $this->addOption('min-level', null, InputOption::VALUE_REQUIRED, 'Min PHPStan level to run', 0); + $this->addOption('max-level', null, InputOption::VALUE_REQUIRED, 'Max PHPStan level to run', 8); + + $this->addOption('env-file', null, InputOption::VALUE_REQUIRED, 'Path to project .env file'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $minPhpStanLevel = (int) $input->getOption('min-level'); + $maxPhpStanLevel = (int) $input->getOption('max-level'); + $projectDirectory = $input->getArgument('directory'); + + // 1. is phpstan installed in the project? + $this->ensurePHPStanIsInstalled($projectDirectory); + + $envFile = $input->getOption('env-file'); + $envVariables = []; + if (is_string($envFile)) { + $envVariables = FileLoader::resolveEnvVariablesFromFile($envFile); + $this->symfonyStyle->note(sprintf('Adding envs from "%s" file:', $envFile)); + + foreach ($envVariables as $name => $value) { + $this->symfonyStyle->writeln(' * ' . $name . ': ' . $value); + } + + $this->symfonyStyle->newLine(); + } + + $phpStanLevelResults = []; + + // 1. prepare empty phpstan config + // no baselines, ignores etc. etc :) + file_put_contents( + $projectDirectory . '/phpstan-bodyscan.neon', + "parameters:\n reportUnmatchedIgnoredErrors: false\n" . PHP_EOL + ); + + // 2. measure phpstan levels + for ($phpStanLevel = $minPhpStanLevel; $phpStanLevel <= $maxPhpStanLevel; ++$phpStanLevel) { + $this->symfonyStyle->section(sprintf('Running PHPStan level %d', $phpStanLevel)); + + $phpStanLevelResults[] = $this->measureErrorCountInLevel($phpStanLevel, $projectDirectory, $envVariables); + + $this->symfonyStyle->newLine(); + } + + // 3. tidy up temporary config + unlink($projectDirectory . '/phpstan-bodyscan.neon'); + + $this->renderResultInTable($phpStanLevelResults); + + return self::SUCCESS; + } + + /** + * @param array $envVariables + */ + private function measureErrorCountInLevel( + int $phpStanLevel, + string $projectDirectory, + array $envVariables + ): PHPStanLevelResult { + $analyseLevelProcess = $this->analyseProcessFactory->create($projectDirectory, $phpStanLevel, $envVariables); + + $this->symfonyStyle->writeln('Running: ' . $analyseLevelProcess->getCommandLine() . ''); + $analyseLevelProcess->run(); + + $jsonResult = $analyseLevelProcess->getOutput(); + $json = JsonLoader::loadToArray($jsonResult, $analyseLevelProcess); + + // fatal errors, they stop the analyss + if ((int) $json['totals']['errors'] > 0) { + $loggedOutput = $jsonResult ?: $analyseLevelProcess->getErrorOutput(); + + Logger::log($loggedOutput); + + throw new AnalysisFailedException(sprintf( + 'PHPStan failed on level %d with %d fatal errors. See %s for more', + $phpStanLevel, + (int) $json['totals']['errors'], + Logger::LOG_FILE_PATH + )); + } + + $fileErrorCount = (int) $json['totals']['file_errors']; + + $this->symfonyStyle->writeln(sprintf('Found %d errors', $fileErrorCount)); + $this->symfonyStyle->newLine(); + + Logger::log(sprintf( + 'Project directory "%s" - PHPStan level %d: %d errors', + $projectDirectory, + $phpStanLevel, + $fileErrorCount + )); + + return new PHPStanLevelResult($phpStanLevel, $fileErrorCount); + } + + /** + * @param PHPStanLevelResult[] $phpStanLevelResults + */ + private function renderResultInTable(array $phpStanLevelResults): void + { + // convert to symfony table data + $tableRows = []; + foreach ($phpStanLevelResults as $phpStanLevelResult) { + $tableRows[] = [$phpStanLevelResult->getLevel(), $phpStanLevelResult->getErrorCount()]; + } + + $tableStyle = new TableStyle(); + $tableStyle->setPadType(STR_PAD_LEFT); + + $this->symfonyStyle->newLine(2); + + $this->symfonyStyle->createTable() + ->setHeaders(['Level', 'Error count']) + ->setRows($tableRows) + // align right + ->setStyle($tableStyle) + ->render(); + } + + private function ensurePHPStanIsInstalled(string $projectDirectory): void + { + if (! file_exists($projectDirectory . '/vendor/phpstan')) { + $this->symfonyStyle->note('PHPStan not found in the project... installing'); + $requirePHPStanProcess = new Process([ + 'composer', + 'require', + 'phpstan/phpstan', + '--dev', + ], $projectDirectory); + $requirePHPStanProcess->mustRun(); + } else { + $this->symfonyStyle->note('PHPStan found in the project, lets run it!'); + $this->symfonyStyle->newLine(2); + } + } +} diff --git a/src/Commands/CheckCommand.php b/src/Commands/CheckCommand.php deleted file mode 100644 index 6fbd49a..0000000 --- a/src/Commands/CheckCommand.php +++ /dev/null @@ -1,150 +0,0 @@ -setName('check'); - $this->setDescription('Check classes that are not used in any config and in the code'); - - $this->addArgument( - 'paths', - InputArgument::REQUIRED | InputArgument::IS_ARRAY, - 'Files and directories to analyze' - ); - $this->addOption( - 'skip-type', - null, - InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, - 'Class types that should be skipped' - ); - - $this->addOption( - 'skip-suffix', - null, - InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, - 'Class suffix that should be skipped' - ); - - $this->addOption( - 'skip-attribute', - null, - InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, - 'Class attribute that should be skipped' - ); - - $this->addOption( - 'file-extension', - null, - InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, - 'File extensions to check', - ['php'] - ); - - $this->addOption('json', null, InputOption::VALUE_NONE, 'Output as JSON'); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - /** @var string[] $paths */ - $paths = (array) $input->getArgument('paths'); - - /** @var string[] $typesToSkip */ - $typesToSkip = (array) $input->getOption('skip-type'); - - /** @var string[] $suffixesToSkip */ - $suffixesToSkip = (array) $input->getOption('skip-suffix'); - - /** @var string[] $attributesToSkip */ - $attributesToSkip = (array) $input->getOption('skip-attribute'); - - $isJson = (bool) $input->getOption('json'); - - /** @var string[] $fileExtensions */ - $fileExtensions = (array) $input->getOption('file-extension'); - - $phpFilePaths = $this->phpFilesFinder->findPhpFiles($paths, $fileExtensions); - - if (! $isJson) { - $this->symfonyStyle->progressStart(count($phpFilePaths)); - $this->symfonyStyle->newLine(); - } - - $usedNames = $this->resolveUsedClassNames($phpFilePaths, function () use ($isJson): void { - if ($isJson) { - return; - } - - $this->symfonyStyle->progressAdvance(); - }); - - $existingFilesWithClasses = $this->classNamesFinder->resolveClassNamesToCheck($phpFilePaths); - - $possiblyUnusedFilesWithClasses = $this->possiblyUnusedClassesFilter->filter( - $existingFilesWithClasses, - $usedNames, - $typesToSkip, - $suffixesToSkip, - $attributesToSkip - ); - - $unusedClassesResult = $this->unusedClassesResultFactory->create($possiblyUnusedFilesWithClasses); - - return $this->unusedClassReporter->reportResult( - $unusedClassesResult, - count($existingFilesWithClasses), - $isJson - ); - } - - /** - * @param string[] $phpFilePaths - * @return string[] - */ - private function resolveUsedClassNames(array $phpFilePaths, Closure $progressClosure): array - { - $usedNames = []; - - foreach ($phpFilePaths as $phpFilePath) { - $currentUsedNames = $this->useImportsResolver->resolve($phpFilePath); - $usedNames = [...$usedNames, ...$currentUsedNames]; - - $progressClosure(); - } - - $usedNames = array_unique($usedNames); - sort($usedNames); - - return $usedNames; - } -} diff --git a/src/DependencyInjection/ContainerFactory.php b/src/DependencyInjection/ContainerFactory.php deleted file mode 100644 index 8bee809..0000000 --- a/src/DependencyInjection/ContainerFactory.php +++ /dev/null @@ -1,70 +0,0 @@ -singleton(Parser::class, static function (): Parser { - $parserFactory = new ParserFactory(); - return $parserFactory->create(ParserFactory::PREFER_PHP7); - }); - - $container->singleton( - SymfonyStyle::class, - static function (): SymfonyStyle { - // use null output ofr tests to avoid printing - $consoleOutput = defined('PHPUNIT_COMPOSER_INSTALL') ? new NullOutput() : new ConsoleOutput(); - return new SymfonyStyle(new ArrayInput([]), $consoleOutput); - } - ); - - $container->singleton(Application::class, function (Container $container): Application { - /** @var CheckCommand $checkCommand */ - $checkCommand = $container->make(CheckCommand::class); - - $application = new Application(); - $application->add($checkCommand); - - $this->hideDefaultCommands($application); - - return $application; - }); - - return $container; - } - - /** - * @see https://tomasvotruba.com/blog/how-make-your-tool-commands-list-easy-to-read - */ - private function hideDefaultCommands(Application $application): void - { - $application->get('completion') - ->setHidden(); - $application->get('help') - ->setHidden(); - $application->get('list') - ->setHidden(); - } -} diff --git a/src/Exception/AnalysisFailedException.php b/src/Exception/AnalysisFailedException.php new file mode 100644 index 0000000..e9d4922 --- /dev/null +++ b/src/Exception/AnalysisFailedException.php @@ -0,0 +1,11 @@ +getClassName(), $usedClassNames, true)) { - continue; - } - - // is excluded interfaces? - if ($this->shouldSkip($fileWithClass->getClassName(), $typesToSkip)) { - continue; - } - - // is excluded suffix? - foreach ($suffixesToSkip as $suffixToSkip) { - if (str_ends_with($fileWithClass->getClassName(), $suffixToSkip)) { - continue 2; - } - } - - // is excluded attributes? - foreach ($fileWithClass->getAttributes() as $attribute) { - if ($this->shouldSkip($attribute, $attributesToSkip)) { - continue 2; - } - } - - $possiblyUnusedFilesWithClasses[] = $fileWithClass; - } - - return $possiblyUnusedFilesWithClasses; - } - - /** - * @param string[] $skips - */ - private function shouldSkip(string $type, array $skips): bool - { - foreach ($skips as $skip) { - if (! str_contains($type, '*') && is_a($type, $skip, true)) { - return true; - } - - if (fnmatch($skip, $type, FNM_NOESCAPE)) { - return true; - } - } - - return false; - } -} diff --git a/src/Finder/ClassNamesFinder.php b/src/Finder/ClassNamesFinder.php deleted file mode 100644 index b2435f5..0000000 --- a/src/Finder/ClassNamesFinder.php +++ /dev/null @@ -1,42 +0,0 @@ -classNameResolver->resolveFromFromFilePath($filePath); - if (! $classNames instanceof ClassNames) { - continue; - } - - $filesWithClasses[] = new FileWithClass( - $filePath, - $classNames->getClassName(), - $classNames->hasParentClassOrInterface(), - $classNames->getAttributes(), - ); - } - - return $filesWithClasses; - } -} diff --git a/src/Finder/PhpFilesFinder.php b/src/Finder/PhpFilesFinder.php deleted file mode 100644 index 3c16c4e..0000000 --- a/src/Finder/PhpFilesFinder.php +++ /dev/null @@ -1,44 +0,0 @@ -files() - ->in($paths) - ->sortByName(); - - foreach ($fileExtensions as $fileExtension) { - $currentFileFinder->name('*.' . $fileExtension); - } - - foreach ($currentFileFinder as $fileInfo) { - /** @var SplFileInfo $fileInfo */ - $filePaths[] = $fileInfo->getRealPath(); - } - - return $filePaths; - } -} diff --git a/src/Logger.php b/src/Logger.php new file mode 100644 index 0000000..2789b3d --- /dev/null +++ b/src/Logger.php @@ -0,0 +1,18 @@ +addVisitor(new NameResolver()); - $nodeTraverser->addVisitor(new NodeConnectingVisitor()); - $nodeTraverser->traverse($stmts); - } -} diff --git a/src/NodeVisitor/ClassNameNodeVisitor.php b/src/NodeVisitor/ClassNameNodeVisitor.php deleted file mode 100644 index b950555..0000000 --- a/src/NodeVisitor/ClassNameNodeVisitor.php +++ /dev/null @@ -1,124 +0,0 @@ -className = null; - $this->hasParentClassOrInterface = false; - $this->attributes = []; - - return $nodes; - } - - public function enterNode(Node $node): ?int - { - if (! $node instanceof ClassLike) { - return null; - } - - if (! $node->name instanceof Identifier) { - return null; - } - - if ($this->hasApiTag($node)) { - return null; - } - - if (! $node->namespacedName instanceof Name) { - return null; - } - - $this->className = $node->namespacedName->toString(); - if ($node instanceof Class_) { - if ($node->extends instanceof Name) { - $this->hasParentClassOrInterface = true; - } - - if ($node->implements !== []) { - $this->hasParentClassOrInterface = true; - } - } - - if ($node instanceof Interface_ && $node->extends !== []) { - $this->hasParentClassOrInterface = true; - } - - foreach ($node->attrGroups as $attrGroup) { - foreach ($attrGroup->attrs as $attr) { - $this->attributes[] = $attr->name->toString(); - } - } - - foreach ($node->getMethods() as $classMethod) { - foreach ($classMethod->attrGroups as $attrGroup) { - foreach ($attrGroup->attrs as $attr) { - $this->attributes[] = $attr->name->toString(); - } - } - } - - return NodeTraverser::DONT_TRAVERSE_CURRENT_AND_CHILDREN; - } - - public function getClassName(): ?string - { - return $this->className; - } - - public function hasParentClassOrInterface(): bool - { - return $this->hasParentClassOrInterface; - } - - /** - * @return string[] - */ - public function getAttributes(): array - { - return array_unique($this->attributes); - } - - private function hasApiTag(ClassLike $classLike): bool - { - $doc = $classLike->getDocComment(); - if (! $doc instanceof Doc) { - return false; - } - - return preg_match(self::API_TAG_REGEX, $doc->getText(), $matches) === 1; - } -} diff --git a/src/NodeVisitor/UsedClassNodeVisitor.php b/src/NodeVisitor/UsedClassNodeVisitor.php deleted file mode 100644 index 0d26f6c..0000000 --- a/src/NodeVisitor/UsedClassNodeVisitor.php +++ /dev/null @@ -1,80 +0,0 @@ -usedNames = []; - return $nodes; - } - - public function enterNode(Node $node): Node|null|int - { - if ($node instanceof ConstFetch) { - return NodeTraverser::DONT_TRAVERSE_CHILDREN; - } - - if (! $node instanceof Name) { - return null; - } - - if ($this->isNonNameNode($node)) { - return null; - } - - // class names itself are skipped automatically, as they are Identifier node - - $this->usedNames[] = $node->toString(); - - return $node; - } - - /** - * @return string[] - */ - public function getUsedNames(): array - { - $uniqueUsedNames = array_unique($this->usedNames); - sort($uniqueUsedNames); - - return $uniqueUsedNames; - } - - private function isNonNameNode(Name $name): bool - { - // skip nodes that are not part of class names - $parent = $name->getAttribute('parent'); - if ($parent instanceof Namespace_) { - return true; - } - - if ($parent instanceof FuncCall) { - return true; - } - - return $parent instanceof ClassMethod; - } -} diff --git a/src/Process/AnalyseProcessFactory.php b/src/Process/AnalyseProcessFactory.php new file mode 100644 index 0000000..cad250d --- /dev/null +++ b/src/Process/AnalyseProcessFactory.php @@ -0,0 +1,85 @@ + $envVariables + */ + public function create(string $projectDirectory, int $phpStanLevel, array $envVariables): Process + { + $phpStanBinFilePath = $this->resolvePhpStanBinFile($projectDirectory); + + // resolve source paths + $sourcePaths = array_filter( + self::POSSIBLE_SOURCE_PATHS, + static fn (string $possibleSourcePath): bool => file_exists($projectDirectory . '/' . $possibleSourcePath) + ); + + return $this->createAnalyseLevelProcess( + $phpStanBinFilePath, + $sourcePaths, + $phpStanLevel, + $projectDirectory, + $envVariables + ); + } + + /** + * @param string[] $sourcePaths + * @param array $envVariables + */ + private function createAnalyseLevelProcess( + string $phpstanBinFilePath, + array $sourcePaths, + int $phpStanLevel, + string $projectDirectory, + array $envVariables + ): Process { + $command = [ + $phpstanBinFilePath, + 'analyse', + ...$sourcePaths, + '--error-format', + 'json', + '--level', + $phpStanLevel, + '--configuration', + 'phpstan-bodyscan.neon', + ]; + + return new Process( + $command, + $projectDirectory, + $envVariables, + null, + // timeout in seconds + self::TIMEOUT_IN_SECONDS, + ); + } + + private function resolvePhpStanBinFile(string $projectDirectory): string + { + if (file_exists($projectDirectory . '/vendor/bin/phpstan')) { + return 'vendor/bin/phpstan'; + } + + // possible that /bin directory is used + return 'bin/phpstan'; + } +} diff --git a/src/Reporting/UnusedClassReporter.php b/src/Reporting/UnusedClassReporter.php deleted file mode 100644 index f99aae0..0000000 --- a/src/Reporting/UnusedClassReporter.php +++ /dev/null @@ -1,78 +0,0 @@ - $unusedClassesResult->getCount(), - 'unused_parent_less_classes' => $unusedClassesResult->getParentLessFileWithClasses(), - 'unused_classes_with_parents' => $unusedClassesResult->getWithParentsFileWithClasses(), - ]; - - $this->symfonyStyle->writeln(Json::encode($jsonResult, Json::PRETTY)); - - return Command::SUCCESS; - } - - $this->symfonyStyle->newLine(2); - - if ($unusedClassesResult->getCount() === 0) { - $this->symfonyStyle->success(sprintf('All the %d services are used. Great job!', $classCount)); - return Command::SUCCESS; - } - - // separate with and without parent, as first one can be removed more easily - if ($unusedClassesResult->getWithParentsFileWithClasses() !== []) { - $this->symfonyStyle->title('Classes with a parent/interface - possibly used by type'); - - $this->reportFileWithClasses($unusedClassesResult->getWithParentsFileWithClasses()); - } - - if ($unusedClassesResult->getParentLessFileWithClasses() !== []) { - $this->symfonyStyle->newLine(); - $this->symfonyStyle->title('Classes without any parent/interface - easier to remove'); - - $this->reportFileWithClasses($unusedClassesResult->getParentLessFileWithClasses()); - } - - $this->symfonyStyle->newLine(); - $this->symfonyStyle->error(sprintf( - 'Found %d unused classes. Check and remove them or skip them using "--skip-type" option', - $unusedClassesResult->getCount() - )); - - return Command::FAILURE; - } - - /** - * @param FileWithClass[] $fileWithClasses - */ - private function reportFileWithClasses(array $fileWithClasses): void - { - foreach ($fileWithClasses as $fileWithClass) { - $this->symfonyStyle->writeln(' * ' . $fileWithClass->getClassName()); - $this->symfonyStyle->writeln($fileWithClass->getFilePath()); - $this->symfonyStyle->newLine(); - } - } -} diff --git a/src/Reporting/UnusedClassesResultFactory.php b/src/Reporting/UnusedClassesResultFactory.php deleted file mode 100644 index 399947c..0000000 --- a/src/Reporting/UnusedClassesResultFactory.php +++ /dev/null @@ -1,30 +0,0 @@ -hasParentClassOrInterface()) { - $withParentsFileWithClasses[] = $unusedFileWithClass; - } else { - $parentLessFileWithClasses[] = $unusedFileWithClass; - } - } - - return new UnusedClassesResult($parentLessFileWithClasses, $withParentsFileWithClasses); - } -} diff --git a/src/UseImportsResolver.php b/src/UseImportsResolver.php deleted file mode 100644 index be34bc3..0000000 --- a/src/UseImportsResolver.php +++ /dev/null @@ -1,45 +0,0 @@ -parser->parse($fileContents); - if ($stmts === null) { - return []; - } - - $this->fullyQualifiedNameNodeDecorator->decorate($stmts); - - $nodeTraverser = new NodeTraverser(); - $usedClassNodeVisitor = new UsedClassNodeVisitor(); - $nodeTraverser->addVisitor($usedClassNodeVisitor); - $nodeTraverser->traverse($stmts); - - return $usedClassNodeVisitor->getUsedNames(); - } -} diff --git a/src/Utils/FileLoader.php b/src/Utils/FileLoader.php new file mode 100644 index 0000000..eab5760 --- /dev/null +++ b/src/Utils/FileLoader.php @@ -0,0 +1,37 @@ + + */ + public static function resolveEnvVariablesFromFile(string $envFile): array + { + Assert::fileExists($envFile); + + // load env file + /** @var string $envContent */ + $envContent = file_get_contents($envFile); + + $envLines = explode("\n", $envContent); + + // split by "=" + $envVariables = []; + foreach ($envLines as $envLine) { + $envLineParts = explode('=', $envLine); + if (count($envLineParts) !== 2) { + continue; + } + + $envVariables[$envLineParts[0]] = $envLineParts[1]; + } + + return $envVariables; + } +} diff --git a/src/Utils/JsonLoader.php b/src/Utils/JsonLoader.php new file mode 100644 index 0000000..81b57f9 --- /dev/null +++ b/src/Utils/JsonLoader.php @@ -0,0 +1,26 @@ + + */ + public static function loadToArray(string $json, Process $process): array + { + try { + return json_decode($json, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $jsonException) { + throw new JsonException(sprintf( + 'Could not decode JSON from phpstan: "%s"', + $json ?: $process->getErrorOutput() + ), 0, $jsonException); + } + } +} diff --git a/src/ValueObject/ClassNames.php b/src/ValueObject/ClassNames.php deleted file mode 100644 index 23910f5..0000000 --- a/src/ValueObject/ClassNames.php +++ /dev/null @@ -1,36 +0,0 @@ -className; - } - - public function hasParentClassOrInterface(): bool - { - return $this->hasParentClassOrInterface; - } - - /** - * @return string[] - */ - public function getAttributes(): array - { - return $this->attributes; - } -} diff --git a/src/ValueObject/FileWithClass.php b/src/ValueObject/FileWithClass.php deleted file mode 100644 index ca3645e..0000000 --- a/src/ValueObject/FileWithClass.php +++ /dev/null @@ -1,57 +0,0 @@ -className; - } - - public function getFilePath(): string - { - return StaticRelativeFilePathHelper::resolveFromCwd($this->filePath); - } - - public function hasParentClassOrInterface(): bool - { - return $this->hasParentClassOrInterface; - } - - /** - * @return string[] - */ - public function getAttributes(): array - { - return $this->attributes; - } - - /** - * @return array{file_path: string, class: string, attributes: string[]} - */ - public function jsonSerialize(): array - { - return [ - 'file_path' => $this->filePath, - 'class' => $this->className, - 'attributes' => $this->attributes, - ]; - } -} diff --git a/src/ValueObject/PHPStanLevelResult.php b/src/ValueObject/PHPStanLevelResult.php new file mode 100644 index 0000000..556ad73 --- /dev/null +++ b/src/ValueObject/PHPStanLevelResult.php @@ -0,0 +1,24 @@ +level; + } + + public function getErrorCount(): int + { + return $this->errorCount; + } +} diff --git a/src/ValueObject/UnusedClassesResult.php b/src/ValueObject/UnusedClassesResult.php deleted file mode 100644 index 043ab7e..0000000 --- a/src/ValueObject/UnusedClassesResult.php +++ /dev/null @@ -1,39 +0,0 @@ -parentLessFileWithClasses; - } - - /** - * @return FileWithClass[] - */ - public function getWithParentsFileWithClasses(): array - { - return $this->withParentsFileWithClasses; - } - - public function getCount(): int - { - return count($this->parentLessFileWithClasses) + count($this->withParentsFileWithClasses); - } -} diff --git a/tests/AbstractTestCase.php b/tests/AbstractTestCase.php deleted file mode 100644 index 9c2b320..0000000 --- a/tests/AbstractTestCase.php +++ /dev/null @@ -1,28 +0,0 @@ - $type - * @return TType - */ - protected function make(string $type): object - { - $containerFactory = new ContainerFactory(); - $container = $containerFactory->create(); - - $service = $container->make($type); - Assert::isInstanceOf($service, $type); - - return $service; - } -} diff --git a/tests/ClassNameResolver/ClassNameResolverTest.php b/tests/ClassNameResolver/ClassNameResolverTest.php deleted file mode 100644 index a89649f..0000000 --- a/tests/ClassNameResolver/ClassNameResolverTest.php +++ /dev/null @@ -1,66 +0,0 @@ -classNameResolver = $this->make(ClassNameResolver::class); - } - - #[DataProvider('provideData')] - public function test(string $filePath, ClassNames $expectedClassNames): void - { - $resolvedClassNames = $this->classNameResolver->resolveFromFromFilePath($filePath); - - $this->assertInstanceOf(ClassNames::class, $resolvedClassNames); - $this->assertSame($expectedClassNames->getClassName(), $resolvedClassNames->getClassName()); - $this->assertSame( - $expectedClassNames->hasParentClassOrInterface(), - $resolvedClassNames->hasParentClassOrInterface() - ); - $this->assertSame($expectedClassNames->getAttributes(), $resolvedClassNames->getAttributes()); - } - - public static function provideData(): Iterator - { - yield [ - __DIR__ . '/Fixture/SomeClass.php', - new ClassNames(SomeClass::class, false, [SomeAttribute::class, SomeMethodAttribute::class]), - ]; - - yield [ - __DIR__ . '/Fixture/ClassWithAnyComment.php', - new ClassNames(ClassWithAnyComment::class, false, []), - ]; - } - - #[DataProvider('provideNoClassContainedData')] - public function testNoClassContained(string $filePath): void - { - $resolvedClassNames = $this->classNameResolver->resolveFromFromFilePath($filePath); - $this->assertNull($resolvedClassNames); - } - - public static function provideNoClassContainedData(): Iterator - { - yield [__DIR__ . '/Fixture/ClassWithApiComment.php']; - } -} diff --git a/tests/ClassNameResolver/Fixture/ClassWithAnyComment.php b/tests/ClassNameResolver/Fixture/ClassWithAnyComment.php deleted file mode 100644 index 909d709..0000000 --- a/tests/ClassNameResolver/Fixture/ClassWithAnyComment.php +++ /dev/null @@ -1,10 +0,0 @@ -assertSame('tests/FileSystem/Fixture/some-file.php', $relativeFilePath); - } -} diff --git a/tests/Finder/Fixture/core_file.php b/tests/Finder/Fixture/core_file.php deleted file mode 100644 index 935b82c..0000000 --- a/tests/Finder/Fixture/core_file.php +++ /dev/null @@ -1,5 +0,0 @@ -phpFilesFinder = $this->make(PhpFilesFinder::class); - } - - public function test(): void - { - $phpFiles = $this->phpFilesFinder->findPhpFiles([__DIR__ . '/Fixture'], ['php', 'phtml']); - $this->assertCount(4, $phpFiles); - - $phpFiles = $this->phpFilesFinder->findPhpFiles([__DIR__ . '/Fixture'], ['php']); - $this->assertCount(3, $phpFiles); - } -} diff --git a/tests/UseImportsResolver/Fixture/FileUsesStaticCall.php b/tests/UseImportsResolver/Fixture/FileUsesStaticCall.php deleted file mode 100644 index 61dca23..0000000 --- a/tests/UseImportsResolver/Fixture/FileUsesStaticCall.php +++ /dev/null @@ -1,15 +0,0 @@ -useImportsResolver = $this->make(UseImportsResolver::class); - } - - /** - * @param string[] $expectedClassUsages - */ - #[DataProvider('provideData')] - public function test(string $filePath, array $expectedClassUsages): void - { - $resolvedClassUsages = $this->useImportsResolver->resolve($filePath); - $this->assertSame($expectedClassUsages, $resolvedClassUsages); - } - - public static function provideData(): Iterator - { - yield [__DIR__ . '/Fixture/FileUsingOtherClasses.php', [FirstUsedClass::class, SecondUsedClass::class]]; - yield [__DIR__ . '/Fixture/FileUsesStaticCall.php', [SomeFactory::class, FourthUsedClass::class]]; - } -}