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]];
- }
-}