diff --git a/.agents/skills/package-readme/SKILL.md b/.agents/skills/package-readme/SKILL.md index 0ab09b1dc..44313450e 100644 --- a/.agents/skills/package-readme/SKILL.md +++ b/.agents/skills/package-readme/SKILL.md @@ -44,7 +44,7 @@ This skill provides a comprehensive, reusable checklist and structure for creati - **Badges** - Read [references/badges.md](references/badges.md) before drafting the badge block. - - For current Fast Forward packages, default to this order: PHP Version, Composer Package, Tests, Coverage, Docs, License, GitHub Sponsors. + - For current Fast Forward packages, default to this order: PHP Version, Composer Package, Tests, Coverage, Metrics, Docs, License, GitHub Sponsors. - Add a second standards row for relevant PSRs when the package contract is centered on them. - Keep Packagist visible both in the badge block via `Composer Package` and in the links section. @@ -54,6 +54,7 @@ This skill provides a comprehensive, reusable checklist and structure for creati [![Composer Package](https://img.shields.io/badge/composer-fast--forward%2Fcomponent-F28D1A.svg?logo=composer&logoColor=white)](https://packagist.org/packages/fast-forward/component) [![Tests](https://img.shields.io/github/actions/workflow/status/php-fast-forward/component/tests.yml?logo=githubactions&logoColor=white&label=tests&color=22C55E)](https://github.com/php-fast-forward/component/actions/workflows/tests.yml) [![Coverage](https://img.shields.io/badge/coverage-phpunit-4ADE80?logo=php&logoColor=white)](https://php-fast-forward.github.io/component/coverage/index.html) + [![Metrics](https://img.shields.io/badge/metrics-phpmetrics-8B5CF6?logo=php&logoColor=white)](https://php-fast-forward.github.io/component/metrics/index.html) [![Docs](https://img.shields.io/github/deployments/php-fast-forward/component/github-pages?logo=readthedocs&logoColor=white&label=docs&labelColor=1E293B&color=38BDF8&style=flat)](https://php-fast-forward.github.io/component/index.html) [![License](https://img.shields.io/github/license/php-fast-forward/component?color=64748B)](LICENSE) [![GitHub Sponsors](https://img.shields.io/github/sponsors/php-fast-forward?logo=githubsponsors&logoColor=white&color=EC4899)](https://github.com/sponsors/php-fast-forward) @@ -169,6 +170,7 @@ This skill provides a comprehensive, reusable checklist and structure for creati ## 🔗 Links - [Repository](https://github.com/php-fast-forward/component) - [Packagist](https://packagist.org/packages/php-fast-forward/component) + - [Metrics Report](https://php-fast-forward.github.io/component/metrics/index.html) - [RFC 2119](https://datatracker.ietf.org/doc/html/rfc2119) - [PSR-11](https://www.php-fig.org/psr/psr-11/) - [Sphinx Documentation](docs/index.rst) @@ -205,7 +207,7 @@ This skill provides a comprehensive, reusable checklist and structure for creati ## Quick Review Checklist - [ ] Project title and short description -- [ ] Badges follow the current Fast Forward stack: PHP Version, Composer Package, Tests, Coverage, Docs, License, GitHub Sponsors, plus relevant standards badges +- [ ] Badges follow the current Fast Forward stack: PHP Version, Composer Package, Tests, Coverage, Metrics, Docs, License, GitHub Sponsors, plus relevant standards badges - [ ] Features (bulleted, with emoji) - [ ] Installation (composer, requirements) - [ ] Usage (basic and advanced) @@ -217,7 +219,7 @@ This skill provides a comprehensive, reusable checklist and structure for creati - [ ] FAQ (if relevant) - [ ] License - [ ] Contributing -- [ ] Links (repository, Packagist, docs, RFCs, PSRs) +- [ ] Links (repository, Packagist, docs, metrics, RFCs, PSRs) - [ ] Comparison table (if relevant) - [ ] Formatting and style guidelines followed - [ ] SEO/discoverability (keywords, cross-links) diff --git a/.github/wiki b/.github/wiki index c1a6da340..3ca041cf3 160000 --- a/.github/wiki +++ b/.github/wiki @@ -1 +1 @@ -Subproject commit c1a6da3400771286a79f57cabf50bd07717feeb4 +Subproject commit 3ca041cf3de0a56951cb017c63e91f65913eceae diff --git a/.github/workflows/reports.yml b/.github/workflows/reports.yml index 275541f35..8343780f4 100644 --- a/.github/workflows/reports.yml +++ b/.github/workflows/reports.yml @@ -72,7 +72,7 @@ jobs: - name: Generate reports env: COMPOSER_ROOT_VERSION: ${{ env.REPORTS_ROOT_VERSION }} - run: composer dev-tools reports -- --target=tmp/reports --coverage=tmp/reports/coverage + run: composer dev-tools reports -- --target=tmp/reports --coverage=tmp/reports/coverage --metrics=tmp/reports/metrics - name: Fix permissions run: | @@ -139,6 +139,7 @@ jobs: - Docs: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/previews/pr-${{ github.event.pull_request.number }}/ - Coverage: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/previews/pr-${{ github.event.pull_request.number }}/coverage/ + - Metrics: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/previews/pr-${{ github.event.pull_request.number }}/metrics/ cleanup_preview: if: github.event_name == 'pull_request' && github.event.action == 'closed' diff --git a/README.md b/README.md index a2f1c3ca7..6d55ad24c 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ across Fast Forward libraries. [![Composer Package](https://img.shields.io/badge/composer-fast--forward%2Fdev--tools-F28D1A.svg?logo=composer&logoColor=white)](https://packagist.org/packages/fast-forward/dev-tools) [![Tests](https://img.shields.io/github/actions/workflow/status/php-fast-forward/dev-tools/tests.yml?logo=githubactions&logoColor=white&label=tests&color=22C55E)](https://github.com/php-fast-forward/dev-tools/actions/workflows/tests.yml) [![Coverage](https://img.shields.io/badge/coverage-phpunit-4ADE80?logo=php&logoColor=white)](https://php-fast-forward.github.io/dev-tools/coverage/index.html) +[![Metrics](https://img.shields.io/badge/metrics-phpmetrics-8B5CF6?logo=php&logoColor=white)](https://php-fast-forward.github.io/dev-tools/metrics/index.html) [![Docs](https://img.shields.io/github/deployments/php-fast-forward/dev-tools/github-pages?logo=readthedocs&logoColor=white&label=docs&labelColor=1E293B&color=38BDF8&style=flat)](https://php-fast-forward.github.io/dev-tools/index.html) [![License](https://img.shields.io/github/license/php-fast-forward/dev-tools?color=64748B)](LICENSE) [![GitHub Sponsors](https://img.shields.io/github/sponsors/php-fast-forward?logo=githubsponsors&logoColor=white&color=EC4899)](https://github.com/sponsors/php-fast-forward) @@ -53,6 +54,11 @@ composer dev-tools tests composer dependencies vendor/bin/dev-tools dependencies +# Analyze code metrics with PhpMetrics +composer metrics +composer dev-tools metrics -- --report-html=build/metrics +composer dev-tools metrics -- --working-dir=packages/example + # Check and fix code style using ECS and Composer Normalize composer dev-tools code-style @@ -70,6 +76,7 @@ composer dev-tools wiki # Generate documentation frontpage and related reports composer dev-tools reports +composer dev-tools reports -- --metrics # Synchronize packaged agent skills into .agents/skills composer dev-tools skills @@ -102,6 +109,10 @@ The `dependencies` command ships with both dependency analyzers as direct dependencies of `fast-forward/dev-tools`, so it works without extra installation in the consumer project. +The `metrics` command ships with `phpmetrics/phpmetrics` as a direct +dependency of `fast-forward/dev-tools`, so consumer repositories can generate +metrics reports without extra setup. + The `skills` command keeps `.agents/skills` aligned with the packaged Fast Forward skill set. It creates missing links, repairs broken links, and preserves existing non-symlink directories. The `dev-tools:sync` command calls @@ -115,6 +126,7 @@ automation assets. | `composer dev-tools` | Runs the full `standards` pipeline. | | `composer dev-tools tests` | Runs PHPUnit with local-or-packaged configuration. | | `composer dev-tools dependencies` | Reports missing and unused Composer dependencies. | +| `composer dev-tools metrics` | Runs PhpMetrics for a working directory and generates requested report artifacts. | | `composer dev-tools docs` | Builds the HTML documentation site from PSR-4 code and `docs/`. | | `composer dev-tools skills` | Creates or repairs packaged skill links in `.agents/skills`. | | `composer dev-tools gitattributes` | Manages export-ignore rules in .gitattributes. | diff --git a/composer.json b/composer.json index ada8e32c2..e8e4b8960 100644 --- a/composer.json +++ b/composer.json @@ -43,6 +43,7 @@ "php-di/php-di": "^7.1", "php-parallel-lint/php-parallel-lint": "^1.4", "phpdocumentor/shim": "^3.9", + "phpmetrics/phpmetrics": "^2.9", "phpro/grumphp-shim": "^2.19", "phpspec/prophecy": "^1.26", "phpspec/prophecy-phpunit": "^2.5", diff --git a/docs/commands/index.rst b/docs/commands/index.rst index bb8fd6b3c..eb6e7b6f5 100644 --- a/docs/commands/index.rst +++ b/docs/commands/index.rst @@ -9,6 +9,7 @@ Detailed documentation for each dev-tools command. standards tests dependencies + metrics code-style refactor phpdoc @@ -22,4 +23,4 @@ Detailed documentation for each dev-tools command. license copy-resource git-hooks - update-composer-json \ No newline at end of file + update-composer-json diff --git a/docs/commands/metrics.rst b/docs/commands/metrics.rst new file mode 100644 index 000000000..3c14559a5 --- /dev/null +++ b/docs/commands/metrics.rst @@ -0,0 +1,73 @@ +metrics +======= + +Analyzes code metrics with PhpMetrics. + +Overview +-------- + +The ``metrics`` command runs `PhpMetrics `_ +against the current working directory and forwards the requested report +artifacts. + +Usage +----- + +.. code-block:: bash + + composer metrics + composer dev-tools metrics -- [options] + vendor/bin/dev-tools metrics [options] + +Options +------- + +``--working-dir=`` + Composer's inherited working-directory option. Use it when you want to run + the command from another directory without changing your current shell + session. + + Default: the current working directory. + +``--exclude=`` + Comma-separated directories that should be excluded from analysis. + + Default: + ``vendor,test,tests,tmp,cache,spec,build,backup,resources``. + +``--report-html=`` + Optional output directory for the generated HTML report. + +``--report-json=`` + Optional output file for the generated JSON report. + +``--report-summary-json=`` + Optional output file for the generated summary JSON report. + +Examples +-------- + +Analyze the current repository with defaults: + +.. code-block:: bash + + composer metrics + +Generate an HTML report for manual inspection: + +.. code-block:: bash + + composer dev-tools metrics -- --report-html=build/metrics + +Generate JSON and HTML reports for CI artifacts: + +.. code-block:: bash + + vendor/bin/dev-tools metrics --report-json=build/metrics.json --report-html=build/metrics + +Behavior +-------- + +- the command forwards report options directly to PhpMetrics; +- it runs PhpMetrics through the active PHP binary and suppresses PhpMetrics + deprecation notices emitted by the dependency itself. diff --git a/docs/commands/reports.rst b/docs/commands/reports.rst index c561fb845..82cc4d504 100644 --- a/docs/commands/reports.rst +++ b/docs/commands/reports.rst @@ -11,6 +11,7 @@ coverage. It combines: - ``docs --target`` - generates API documentation - ``tests --coverage`` - generates test coverage reports +- optionally ``metrics --report-html`` - generates PhpMetrics HTML reports These are run in parallel for efficiency. @@ -34,6 +35,10 @@ Options The target directory for the generated test coverage report. Default: ``public/coverage``. +``--metrics`` (optional) + Generate the metrics HTML report. When passed without a value, the report is + generated in ``public/metrics``. + Examples -------- @@ -49,6 +54,13 @@ Generate to custom directories: composer reports --target=build --coverage=build/coverage +Generate reports including metrics: + +.. code-block:: bash + + composer reports --metrics + composer reports --metrics=build/metrics + Exit Codes --------- @@ -66,6 +78,7 @@ Behavior --------- - Runs ``docs`` and ``tests --coverage`` in parallel. +- Runs ``metrics --report-html`` in parallel when ``--metrics`` is enabled. - Runs tests with ``--no-progress`` and ``--coverage-summary`` so report builds keep PHPUnit output concise. - Used by the ``standards`` command as the final phase. diff --git a/docs/running/specialized-commands.rst b/docs/running/specialized-commands.rst index 9bcaccfb7..5ea1e1d32 100644 --- a/docs/running/specialized-commands.rst +++ b/docs/running/specialized-commands.rst @@ -42,6 +42,28 @@ Important details: - it returns a non-zero exit code when missing or unused dependencies are found. +``metrics`` +----------- + +Analyzes code metrics with PhpMetrics. + +.. code-block:: bash + + composer metrics + composer dev-tools metrics -- --report-html=build/metrics + composer dev-tools metrics -- --working-dir=packages/example + +Important details: + +- it ships ``phpmetrics/phpmetrics`` as a direct dependency of + ``fast-forward/dev-tools``; +- it analyzes the selected ``--working-dir`` and forwards the requested + report options directly to PhpMetrics; +- ``--report-html``, ``--report-json``, and ``--report-summary-json`` allow + persisting the native PhpMetrics reports for CI artifacts or manual review; +- it suppresses deprecation notices emitted by the PhpMetrics dependency + itself so the command output stays readable. + ``code-style`` -------------- @@ -139,6 +161,7 @@ Important details: - it calls ``docs --target public``; - it calls ``tests --coverage public/coverage --no-progress --coverage-summary``; +- ``--metrics`` adds ``metrics --report-html public/metrics``; - it is the reporting stage used by ``standards``. ``skills`` diff --git a/src/Console/Command/MetricsCommand.php b/src/Console/Command/MetricsCommand.php new file mode 100644 index 000000000..b45bea477 --- /dev/null +++ b/src/Console/Command/MetricsCommand.php @@ -0,0 +1,125 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Console\Command; + +use Composer\Command\BaseCommand; +use FastForward\DevTools\Process\ProcessBuilderInterface; +use FastForward\DevTools\Process\ProcessQueueInterface; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +#[AsCommand( + name: 'metrics', + description: 'Analyzes code metrics with PhpMetrics.', + help: 'This command runs PhpMetrics to analyze the current working directory.', +)] +final class MetricsCommand extends BaseCommand +{ + /** + * @var string the bundled PhpMetrics binary path relative to the consumer root + */ + private const string BINARY = 'vendor/bin/phpmetrics'; + + /** + * @var int the PHP error reporting mask that suppresses deprecations emitted by PhpMetrics internals + */ + private const int PHP_ERROR_REPORTING = \E_ALL & ~\E_DEPRECATED; + + /** + * @param ProcessBuilderInterface $processBuilder the builder used to assemble the PhpMetrics process + * @param ProcessQueueInterface $processQueue the queue used to execute the PhpMetrics process + */ + public function __construct( + private readonly ProcessBuilderInterface $processBuilder, + private readonly ProcessQueueInterface $processQueue, + ) { + parent::__construct(); + } + + /** + * @return void + */ + protected function configure(): void + { + $this + ->addOption( + name: 'exclude', + mode: InputOption::VALUE_OPTIONAL, + description: 'Comma-separated directories that SHOULD be excluded from analysis.', + default: 'vendor,test,tests,tmp,cache,spec,build,backup,resources', + ) + ->addOption( + name: 'report-html', + mode: InputOption::VALUE_OPTIONAL, + description: 'Optional target directory for the generated HTML report.', + default: 'public/metrics', + ) + ->addOption( + name: 'report-json', + mode: InputOption::VALUE_OPTIONAL, + description: 'Optional target file for the generated JSON report.', + default: 'public/metrics/report.json', + ) + ->addOption( + name: 'report-summary-json', + mode: InputOption::VALUE_OPTIONAL, + description: 'Optional target file for the generated summary JSON report.', + default: 'public/metrics/report-summary.json', + ) + ->addOption( + name: 'junit', + mode: InputOption::VALUE_OPTIONAL, + description: 'Optional target file for the generated JUnit XML report.', + ); + } + + /** + * @param InputInterface $input the runtime command input + * @param OutputInterface $output the console output stream + * + * @return int the command execution status code + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $output->writeln('Running code metrics analysis...'); + + $processBuilder = $this->processBuilder + ->withArgument('--ansi') + ->withArgument('--git', 'git') + ->withArgument('--exclude', (string) $input->getOption('exclude')) + ->withArgument('--report-html', $input->getOption('report-html')) + ->withArgument('--report-json', $input->getOption('report-json')) + ->withArgument('--report-summary-json', $input->getOption('report-summary-json')); + + if (null !== $input->getOption('junit')) { + $processBuilder = $processBuilder->withArgument('--junit', $input->getOption('junit')); + } + + $this->processQueue->add( + $processBuilder + ->withArgument('.') + ->build([\PHP_BINARY, '-derror_reporting=' . self::PHP_ERROR_REPORTING, self::BINARY]) + ); + + return $this->processQueue->run($output); + } +} diff --git a/src/Console/Command/ReportsCommand.php b/src/Console/Command/ReportsCommand.php index af97a2e92..533270744 100644 --- a/src/Console/Command/ReportsCommand.php +++ b/src/Console/Command/ReportsCommand.php @@ -69,6 +69,12 @@ protected function configure(): void mode: InputOption::VALUE_OPTIONAL, description: 'The target directory for the generated test coverage report.', default: 'public/coverage', + ) + ->addOption( + name: 'metrics', + mode: InputOption::VALUE_OPTIONAL, + description: 'Generate code metrics and optionally choose the HTML output directory.', + default: 'public/metrics', ); } @@ -99,8 +105,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int ->withArgument('--coverage', $input->getOption('coverage')) ->build('composer dev-tools tests --'); + $metrics = $this->processBuilder + ->withArgument('--ansi') + ->withArgument('--junit', $input->getOption('coverage') . '/junit.xml') + ->withArgument('--report-html', $input->getOption('metrics')) + ->build('composer dev-tools metrics --'); + $this->processQueue->add(process: $docs, detached: true); - $this->processQueue->add(process: $coverage, detached: true); + $this->processQueue->add(process: $coverage); + $this->processQueue->add(process: $metrics); return $this->processQueue->run($output); } diff --git a/src/Console/Command/SyncCommand.php b/src/Console/Command/SyncCommand.php index 5ed1ae1dc..55f8450af 100644 --- a/src/Console/Command/SyncCommand.php +++ b/src/Console/Command/SyncCommand.php @@ -124,7 +124,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int private function queueDevToolsCommand(array $arguments, bool $detached = false): void { $processBuilder = $this->processBuilder; - $arguments = array_filter($arguments, static fn (?string $arg): bool => $arg !== null); + $arguments = array_filter($arguments, static fn(?string $arg): bool => null !== $arg); foreach ($arguments as $argument) { $processBuilder = $processBuilder->withArgument($argument); diff --git a/src/Console/Command/TestsCommand.php b/src/Console/Command/TestsCommand.php index c1c411e35..3a7413191 100644 --- a/src/Console/Command/TestsCommand.php +++ b/src/Console/Command/TestsCommand.php @@ -278,7 +278,8 @@ private function configureCoverageArguments( ->withArgument('--coverage-text') ->withArgument('--coverage-html', $coveragePath) ->withArgument('--testdox-html', $coveragePath . '/testdox.html') - ->withArgument('--coverage-clover', $coveragePath . '/clover.xml'); + ->withArgument('--coverage-clover', $coveragePath . '/clover.xml') + ->withArgument('--log-junit', $coveragePath . '/junit.xml'); if ($input->getOption('coverage-summary')) { $processBuilder = $processBuilder->withArgument('--only-summary-for-coverage-text'); diff --git a/src/Process/ProcessBuilder.php b/src/Process/ProcessBuilder.php index 0f562e2e7..41e61e0da 100644 --- a/src/Process/ProcessBuilder.php +++ b/src/Process/ProcessBuilder.php @@ -88,12 +88,16 @@ public function getArguments(): array * process MUST preserve the final token order exactly as assembled by this * method. * - * @param string $command the base command used to initialize the process + * @param string|array $command the base command used to initialize the process * * @return Process the configured process instance ready for execution */ - public function build(string $command): Process + public function build(string|array $command): Process { - return new Process(command: [...explode(' ', $command), ...$this->arguments], timeout: 0); + if (\is_string($command)) { + $command = explode(' ', $command); + } + + return new Process(command: [...$command, ...$this->arguments], timeout: 0); } } diff --git a/src/Process/ProcessBuilderInterface.php b/src/Process/ProcessBuilderInterface.php index 679a517a0..daede3fb0 100644 --- a/src/Process/ProcessBuilderInterface.php +++ b/src/Process/ProcessBuilderInterface.php @@ -52,9 +52,9 @@ public function withArgument(string $argument, ?string $value = null): self; * command and all arguments previously collected by the builder. The * returned process SHOULD be ready for execution by the caller. * - * @param string $command the base command that SHALL be used to create the process + * @param string|array $command the base command that SHALL be used to create the process * * @return Process the configured process instance */ - public function build(string $command): Process; + public function build(string|array $command): Process; } diff --git a/tests/Console/Command/MetricsCommandTest.php b/tests/Console/Command/MetricsCommandTest.php new file mode 100644 index 000000000..2987c994b --- /dev/null +++ b/tests/Console/Command/MetricsCommandTest.php @@ -0,0 +1,268 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Console\Command; + +use FastForward\DevTools\Console\Command\MetricsCommand; +use FastForward\DevTools\Process\ProcessBuilderInterface; +use FastForward\DevTools\Process\ProcessQueueInterface; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; +use ReflectionMethod; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Process\Process; + +use function Safe\file_put_contents; +use function uniqid; + +#[CoversClass(MetricsCommand::class)] +final class MetricsCommandTest extends TestCase +{ + use ProphecyTrait; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $processBuilder; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $processQueue; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $input; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $output; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $process; + + private string $jsonReport; + + private string $summaryReport; + + private MetricsCommand $command; + + /** + * @return void + */ + protected function setUp(): void + { + $this->processBuilder = $this->prophesize(ProcessBuilderInterface::class); + $this->processQueue = $this->prophesize(ProcessQueueInterface::class); + $this->input = $this->prophesize(InputInterface::class); + $this->output = $this->prophesize(OutputInterface::class); + $this->process = $this->prophesize(Process::class); + $this->jsonReport = sys_get_temp_dir() . '/metrics-' . uniqid() . '.json'; + $this->summaryReport = sys_get_temp_dir() . '/metrics-summary-' . uniqid() . '.json'; + $jsonReport = $this->jsonReport; + $summaryReport = $this->summaryReport; + + foreach (['exclude', 'report-html', 'report-json', 'report-summary-json', 'junit'] as $option) { + $this->input->getOption($option) + ->willReturn($this->commandDefaultOption($option)); + } + + $this->processBuilder->withArgument(Argument::cetera()) + ->willReturn($this->processBuilder->reveal()); + $this->processBuilder->build([\PHP_BINARY, '-derror_reporting=' . (\E_ALL & ~\E_DEPRECATED), 'vendor/bin/phpmetrics']) + ->willReturn($this->process->reveal()); + + $this->processQueue->run($this->output->reveal()) + ->will(static function () use ($summaryReport, $jsonReport): int { + file_put_contents($summaryReport, <<<'JSON' + {"OOP":{"classes":2},"Complexity":{"avgCyclomaticComplexityByClass":4}} + JSON); + file_put_contents($jsonReport, <<<'JSON' + {"App\\Foo":{"_type":"Hal\\Metric\\ClassMetric","mi":80,"methods":[{"_type":"Hal\\Metric\\FunctionMetric"},{"_type":"Hal\\Metric\\FunctionMetric"}]},"App\\Bar":{"_type":"Hal\\Metric\\ClassMetric","mi":70}} + JSON); + + return MetricsCommand::SUCCESS; + }); + + $this->command = new MetricsCommand($this->processBuilder->reveal(), $this->processQueue->reveal()); + } + + /** + * @return void + */ + protected function tearDown(): void + { + foreach ([$this->jsonReport, $this->summaryReport] as $path) { + if (file_exists($path)) { + \unlink($path); + } + } + } + + /** + * @return void + */ + #[Test] + public function commandWillSetExpectedNameDescriptionAndHelp(): void + { + self::assertSame('metrics', $this->command->getName()); + self::assertSame('Analyzes code metrics with PhpMetrics.', $this->command->getDescription()); + self::assertSame( + 'This command runs PhpMetrics to analyze the current working directory.', + $this->command->getHelp(), + ); + } + + /** + * @return void + */ + #[Test] + public function commandWillHaveExpectedOptions(): void + { + $definition = $this->command->getDefinition(); + + self::assertFalse($definition->hasOption('working-dir')); + self::assertFalse($definition->hasOption('src')); + self::assertTrue($definition->hasOption('exclude')); + self::assertTrue($definition->hasOption('report-html')); + self::assertTrue($definition->hasOption('report-json')); + self::assertTrue($definition->hasOption('report-summary-json')); + self::assertTrue($definition->hasOption('junit')); + self::assertFalse($definition->hasOption('cache-dir')); + } + + /** + * @return void + */ + #[Test] + public function executeWillRunPhpMetrics(): void + { + $this->output->writeln('Running code metrics analysis...') + ->shouldBeCalledOnce(); + $this->processBuilder->withArgument('--ansi') + ->shouldBeCalledOnce() + ->willReturn($this->processBuilder->reveal()); + $this->processBuilder->withArgument('--git', 'git') + ->shouldBeCalledOnce() + ->willReturn($this->processBuilder->reveal()); + $this->processBuilder->withArgument( + '--exclude', + 'vendor,test,tests,tmp,cache,spec,build,backup,resources' + ) + ->shouldBeCalledOnce() + ->willReturn($this->processBuilder->reveal()); + $this->processBuilder->withArgument('--report-json', $this->jsonReport) + ->shouldBeCalledOnce() + ->willReturn($this->processBuilder->reveal()); + $this->processBuilder->withArgument('--report-summary-json', $this->summaryReport) + ->shouldBeCalledOnce() + ->willReturn($this->processBuilder->reveal()); + $this->processBuilder->withArgument('--junit', null) + ->shouldNotBeCalled(); + $this->processBuilder->withArgument('.') + ->shouldBeCalledOnce() + ->willReturn($this->processBuilder->reveal()); + $this->processQueue->add($this->process->reveal()) + ->shouldBeCalledOnce(); + + self::assertSame(MetricsCommand::SUCCESS, $this->executeCommand()); + } + + /** + * @return void + */ + #[Test] + public function executeWillSkipUnsetOptionalReports(): void + { + $this->input->getOption('report-json') + ->willReturn(null); + $this->input->getOption('report-summary-json') + ->willReturn(null); + $this->input->getOption('junit') + ->willReturn(null); + + $this->processBuilder->withArgument('--report-json', null) + ->shouldBeCalledOnce() + ->willReturn($this->processBuilder->reveal()); + $this->processBuilder->withArgument('--report-summary-json', null) + ->shouldBeCalledOnce() + ->willReturn($this->processBuilder->reveal()); + $this->processBuilder->withArgument('--junit', Argument::any()) + ->shouldNotBeCalled(); + $this->processQueue->add($this->process->reveal()) + ->shouldBeCalledOnce(); + + self::assertSame(MetricsCommand::SUCCESS, $this->executeCommand()); + } + + /** + * @return void + */ + #[Test] + public function executeWillIncludeHtmlReportWhenRequested(): void + { + $this->input->getOption('report-html') + ->willReturn('build/metrics'); + $this->input->getOption('junit') + ->willReturn(null); + + $this->processBuilder->withArgument('--report-html', 'build/metrics') + ->shouldBeCalledOnce() + ->willReturn($this->processBuilder->reveal()); + $this->processQueue->add($this->process->reveal()) + ->shouldBeCalledOnce(); + + self::assertSame(MetricsCommand::SUCCESS, $this->executeCommand()); + } + + /** + * @param string $option the option name to resolve + * + * @return mixed the default option value used by the command + */ + private function commandDefaultOption(string $option): mixed + { + return match ($option) { + 'exclude' => 'vendor,test,tests,tmp,cache,spec,build,backup,resources', + 'report-json' => $this->jsonReport, + 'report-summary-json' => $this->summaryReport, + 'junit' => null, + default => null, + }; + } + + /** + * @return int + */ + private function executeCommand(): int + { + $reflectionMethod = new ReflectionMethod($this->command, 'execute'); + + return $reflectionMethod->invoke($this->command, $this->input->reveal(), $this->output->reveal()); + } +} diff --git a/tests/Console/Command/ReportsCommandTest.php b/tests/Console/Command/ReportsCommandTest.php index 7557ce100..b0d09fbff 100644 --- a/tests/Console/Command/ReportsCommandTest.php +++ b/tests/Console/Command/ReportsCommandTest.php @@ -68,6 +68,11 @@ final class ReportsCommandTest extends TestCase */ private ObjectProphecy $testsProcess; + /** + * @var ObjectProphecy + */ + private ObjectProphecy $metricsProcess; + private ReportsCommand $command; /** @@ -81,11 +86,14 @@ protected function setUp(): void $this->output = $this->prophesize(OutputInterface::class); $this->docsProcess = $this->prophesize(Process::class); $this->testsProcess = $this->prophesize(Process::class); + $this->metricsProcess = $this->prophesize(Process::class); $this->input->getOption('target') ->willReturn('public'); $this->input->getOption('coverage') ->willReturn('public/coverage'); + $this->input->getOption('metrics') + ->willReturn('public/metrics'); $this->processBuilder->withArgument(Argument::cetera()) ->willReturn($this->processBuilder->reveal()); @@ -95,6 +103,8 @@ protected function setUp(): void $this->processBuilder->build('composer dev-tools tests --') ->willReturn($this->testsProcess->reveal()); + $this->processBuilder->build('composer dev-tools metrics --') + ->willReturn($this->metricsProcess->reveal()); $this->processQueue->run($this->output->reveal()) ->willReturn(ReportsCommand::SUCCESS); @@ -126,13 +136,14 @@ public function commandWillHaveExpectedOptions(): void self::assertTrue($definition->hasOption('target')); self::assertTrue($definition->hasOption('coverage')); + self::assertTrue($definition->hasOption('metrics')); } /** * @return void */ #[Test] - public function executeWillRunDocsAndTestsCommandAsDetachedProcesses(): void + public function executeWillRunDocsAsDetachedAndTestsAndMetricsInSequence(): void { $this->output->writeln('Generating frontpage for Fast Forward documentation...') ->shouldBeCalledOnce(); @@ -157,10 +168,20 @@ public function executeWillRunDocsAndTestsCommandAsDetachedProcesses(): void ->shouldBeCalledOnce() ->willReturn($this->processBuilder->reveal()); + $this->processBuilder->withArgument('--report-html', 'public/metrics') + ->shouldBeCalledOnce() + ->willReturn($this->processBuilder->reveal()); + $this->processBuilder->withArgument('--junit', 'public/coverage/junit.xml') + ->shouldBeCalledOnce() + ->willReturn($this->processBuilder->reveal()); + $this->processQueue->add($this->docsProcess->reveal(), false, true) ->shouldBeCalledOnce(); - $this->processQueue->add($this->testsProcess->reveal(), false, true) + $this->processQueue->add($this->testsProcess->reveal()) + ->shouldBeCalledOnce(); + + $this->processQueue->add($this->metricsProcess->reveal()) ->shouldBeCalledOnce(); $result = $this->executeCommand(); @@ -168,6 +189,32 @@ public function executeWillRunDocsAndTestsCommandAsDetachedProcesses(): void self::assertSame(ReportsCommand::SUCCESS, $result); } + /** + * @return void + */ + #[Test] + public function executeWillRunMetricsCommandWhenRequested(): void + { + $this->input->getOption('metrics') + ->willReturn('tmp/metrics'); + + $this->processBuilder->withArgument('--report-html', 'tmp/metrics') + ->shouldBeCalledOnce() + ->willReturn($this->processBuilder->reveal()); + $this->processBuilder->withArgument('--junit', 'public/coverage/junit.xml') + ->shouldBeCalledOnce() + ->willReturn($this->processBuilder->reveal()); + + $this->processQueue->add($this->docsProcess->reveal(), false, true) + ->shouldBeCalledOnce(); + $this->processQueue->add($this->testsProcess->reveal()) + ->shouldBeCalledOnce(); + $this->processQueue->add($this->metricsProcess->reveal()) + ->shouldBeCalledOnce(); + + self::assertSame(ReportsCommand::SUCCESS, $this->executeCommand()); + } + /** * @return int */