From 7dd79ad14c3b0b029054e50152a788165097ec62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Sat, 18 Apr 2026 21:40:27 -0300 Subject: [PATCH 1/6] [metrics] Add PhpMetrics command (#15) --- README.md | 9 + composer.json | 4 +- docs/commands/index.rst | 3 +- docs/commands/metrics.rst | 80 ++++++ docs/running/specialized-commands.rst | 20 ++ src/Console/Command/MetricsCommand.php | 243 ++++++++++++++++ src/Metrics/Report.php | 39 +++ src/Metrics/ReportLoader.php | 111 ++++++++ src/Metrics/ReportLoaderInterface.php | 33 +++ src/Metrics/SummaryRenderer.php | 44 +++ src/Metrics/SummaryRendererInterface.php | 33 +++ .../DevToolsServiceProvider.php | 8 + tests/Console/Command/MetricsCommandTest.php | 269 ++++++++++++++++++ tests/Metrics/ReportLoaderTest.php | 99 +++++++ tests/Metrics/SummaryRendererTest.php | 49 ++++ 15 files changed, 1042 insertions(+), 2 deletions(-) create mode 100644 docs/commands/metrics.rst create mode 100644 src/Console/Command/MetricsCommand.php create mode 100644 src/Metrics/Report.php create mode 100644 src/Metrics/ReportLoader.php create mode 100644 src/Metrics/ReportLoaderInterface.php create mode 100644 src/Metrics/SummaryRenderer.php create mode 100644 src/Metrics/SummaryRendererInterface.php create mode 100644 tests/Console/Command/MetricsCommandTest.php create mode 100644 tests/Metrics/ReportLoaderTest.php create mode 100644 tests/Metrics/SummaryRendererTest.php diff --git a/README.md b/README.md index a2f1c3ca7..311dd95dc 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,10 @@ 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 + # Check and fix code style using ECS and Composer Normalize composer dev-tools code-style @@ -102,6 +106,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 +123,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 and prints a reduced code-metrics summary. | | `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..c2faa5089 100644 --- a/composer.json +++ b/composer.json @@ -42,6 +42,7 @@ "nikic/php-parser": "^5.7", "php-di/php-di": "^7.1", "php-parallel-lint/php-parallel-lint": "^1.4", + "phpmetrics/phpmetrics": "^2.9", "phpdocumentor/shim": "^3.9", "phpro/grumphp-shim": "^2.19", "phpspec/prophecy": "^1.26", @@ -113,6 +114,7 @@ }, "scripts": { "dev-tools": "dev-tools", - "dev-tools:fix": "@dev-tools --fix" + "dev-tools:fix": "@dev-tools --fix", + "metrics": "@dev-tools metrics" } } 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..364ef1983 --- /dev/null +++ b/docs/commands/metrics.rst @@ -0,0 +1,80 @@ +metrics +======= + +Analyzes code metrics with PhpMetrics. + +Overview +-------- + +The ``metrics`` command runs `PhpMetrics `_ +against the selected source directory, generates a JSON report, and prints a +reduced summary with: + +- average cyclomatic complexity by class; +- average maintainability index by class; +- number of classes analyzed; +- number of functions analyzed. + +Usage +----- + +.. code-block:: bash + + composer metrics + composer dev-tools metrics -- [options] + vendor/bin/dev-tools metrics [options] + +Options +------- + +``--src=`` + Source directory to analyze. + + Default: ``src``. + +``--exclude=`` + Comma-separated directories that should be excluded from analysis. + + Default: + ``vendor,test,Test,tests,Tests,testing,Testing,bower_components,node_modules,cache,spec,build``. + +``--report-html=`` + Optional output directory for the generated HTML report. + +``--report-json=`` + Optional output file for the generated JSON report. + +``--cache-dir=`` + Cache directory used for temporary JSON reports when ``--report-json`` is + not provided. + + Default: ``tmp/cache/phpmetrics``. + +Examples +-------- + +Generate the reduced summary 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 both 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 fails early when ``vendor/bin/phpmetrics`` is not installed; +- the source directory must exist; +- the reduced summary is derived from the generated PhpMetrics JSON report; +- optional HTML and JSON report destinations are created before execution. diff --git a/docs/running/specialized-commands.rst b/docs/running/specialized-commands.rst index 9bcaccfb7..fce3b6ca7 100644 --- a/docs/running/specialized-commands.rst +++ b/docs/running/specialized-commands.rst @@ -42,6 +42,26 @@ 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 + +Important details: + +- it ships ``phpmetrics/phpmetrics`` as a direct dependency of + ``fast-forward/dev-tools``; +- it prints a reduced summary with average cyclomatic complexity, average + maintainability index, and analyzed class/function counts; +- ``--report-html`` and ``--report-json`` allow persisting the native + PhpMetrics reports for CI artifacts or manual review; +- it fails early when the PhpMetrics binary or source directory is missing. + ``code-style`` -------------- diff --git a/src/Console/Command/MetricsCommand.php b/src/Console/Command/MetricsCommand.php new file mode 100644 index 000000000..622a1594f --- /dev/null +++ b/src/Console/Command/MetricsCommand.php @@ -0,0 +1,243 @@ + + * @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\Filesystem\FilesystemInterface; +use FastForward\DevTools\Metrics\ReportLoaderInterface; +use FastForward\DevTools\Metrics\SummaryRendererInterface; +use FastForward\DevTools\Process\ProcessBuilderInterface; +use FastForward\DevTools\Process\ProcessQueueInterface; +use RuntimeException; +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 source code and prints a reduced summary.', +)] +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 string the default cache directory used for temporary metrics reports + */ + private const string CACHE_DIR = 'tmp/cache/phpmetrics'; + + /** + * @param FilesystemInterface $filesystem the filesystem utility used for path handling and report persistence + * @param ProcessBuilderInterface $processBuilder the builder used to assemble the PhpMetrics process + * @param ProcessQueueInterface $processQueue the queue used to execute the PhpMetrics process + * @param ReportLoaderInterface $reportLoader the loader used to derive a reduced summary from the JSON report + * @param SummaryRendererInterface $summaryRenderer the renderer used to format the reduced summary + */ + public function __construct( + private readonly FilesystemInterface $filesystem, + private readonly ProcessBuilderInterface $processBuilder, + private readonly ProcessQueueInterface $processQueue, + private readonly ReportLoaderInterface $reportLoader, + private readonly SummaryRendererInterface $summaryRenderer, + ) { + parent::__construct(); + } + + /** + * @return void + */ + protected function configure(): void + { + $this + ->addOption( + name: 'src', + mode: InputOption::VALUE_OPTIONAL, + description: 'Path to the source directory that MUST be analyzed.', + default: 'src', + ) + ->addOption( + name: 'exclude', + mode: InputOption::VALUE_OPTIONAL, + description: 'Comma-separated directories that SHOULD be excluded from analysis.', + default: 'vendor,test,Test,tests,Tests,testing,Testing,bower_components,node_modules,cache,spec,build', + ) + ->addOption( + name: 'report-html', + mode: InputOption::VALUE_OPTIONAL, + description: 'Optional target directory for the generated HTML report.', + ) + ->addOption( + name: 'report-json', + mode: InputOption::VALUE_OPTIONAL, + description: 'Optional target file for the generated JSON report.', + ) + ->addOption( + name: 'cache-dir', + mode: InputOption::VALUE_OPTIONAL, + description: 'Path to the cache directory used for temporary metrics reports.', + default: self::CACHE_DIR, + ); + } + + /** + * @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...'); + + try { + $binary = $this->resolveBinaryPath(); + $source = $this->resolveSourcePath($input); + $cacheDir = $this->resolveCacheDirectory($input); + $jsonReport = $this->resolveJsonReportPath($input, $cacheDir); + $htmlReport = $this->resolveOptionalReportDirectory($input, 'report-html'); + } catch (RuntimeException $runtimeException) { + $output->writeln('' . $runtimeException->getMessage() . ''); + + return self::FAILURE; + } + + $processBuilder = $this->processBuilder + ->withArgument('--quiet') + ->withArgument('--exclude', (string) $input->getOption('exclude')) + ->withArgument('--report-json', $jsonReport); + + if (null !== $htmlReport) { + $processBuilder = $processBuilder->withArgument('--report-html', $htmlReport); + } + + $this->processQueue->add( + $processBuilder + ->withArgument($source) + ->build(self::BINARY) + ); + + $result = $this->processQueue->run($output); + + if (self::SUCCESS !== $result) { + return $result; + } + + try { + $output->writeln($this->summaryRenderer->render($this->reportLoader->load($jsonReport))); + } catch (RuntimeException $runtimeException) { + $output->writeln('' . $runtimeException->getMessage() . ''); + + return self::FAILURE; + } + + return self::SUCCESS; + } + + /** + * @return string the absolute path to the PhpMetrics binary + */ + private function resolveBinaryPath(): string + { + $binary = $this->filesystem->getAbsolutePath(self::BINARY); + + if (! $this->filesystem->exists($binary)) { + throw new RuntimeException( + \sprintf( + 'The PhpMetrics binary was not found at %s. Install dependencies before running the metrics command.', + $binary, + ) + ); + } + + return $binary; + } + + /** + * @param InputInterface $input the runtime command input + * + * @return string the absolute source directory path + */ + private function resolveSourcePath(InputInterface $input): string + { + $source = $this->filesystem->getAbsolutePath((string) $input->getOption('src')); + + if (! $this->filesystem->exists($source)) { + throw new RuntimeException(\sprintf('Source directory not found: %s', $source)); + } + + return $source; + } + + /** + * @param InputInterface $input the runtime command input + * + * @return string the absolute cache directory path + */ + private function resolveCacheDirectory(InputInterface $input): string + { + $cacheDir = $this->filesystem->getAbsolutePath((string) $input->getOption('cache-dir')); + $this->filesystem->mkdir($cacheDir); + + return $cacheDir; + } + + /** + * @param InputInterface $input the runtime command input + * @param string $cacheDir the absolute cache directory used for fallback output + * + * @return string the absolute JSON report path + */ + private function resolveJsonReportPath(InputInterface $input, string $cacheDir): string + { + $reportJson = $input->getOption('report-json'); + $reportJsonPath = null === $reportJson + ? $this->filesystem->getAbsolutePath('metrics.json', $cacheDir) + : $this->filesystem->getAbsolutePath((string) $reportJson); + + $this->filesystem->mkdir($this->filesystem->dirname($reportJsonPath)); + + return $reportJsonPath; + } + + /** + * @param InputInterface $input the runtime command input + * @param string $option the option that may contain a report directory + * + * @return string|null the absolute report directory path when configured + */ + private function resolveOptionalReportDirectory(InputInterface $input, string $option): ?string + { + $reportDirectory = $input->getOption($option); + + if (null === $reportDirectory) { + return null; + } + + $reportDirectory = $this->filesystem->getAbsolutePath((string) $reportDirectory); + $this->filesystem->mkdir($reportDirectory); + + return $reportDirectory; + } +} diff --git a/src/Metrics/Report.php b/src/Metrics/Report.php new file mode 100644 index 000000000..e7086c2f2 --- /dev/null +++ b/src/Metrics/Report.php @@ -0,0 +1,39 @@ + + * @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\Metrics; + +/** + * Represents the reduced metrics summary shown by the dev-tools metrics command. + */ +final readonly class Report +{ + /** + * @param float $averageCyclomaticComplexityByClass the average class cyclomatic complexity reported by PhpMetrics + * @param float $averageMaintainabilityIndexByClass the average class maintainability index reported by PhpMetrics + * @param int $classesAnalyzed the number of analyzed classes + * @param int $functionsAnalyzed the number of analyzed functions + */ + public function __construct( + public float $averageCyclomaticComplexityByClass, + public float $averageMaintainabilityIndexByClass, + public int $classesAnalyzed, + public int $functionsAnalyzed, + ) {} +} diff --git a/src/Metrics/ReportLoader.php b/src/Metrics/ReportLoader.php new file mode 100644 index 000000000..f88948222 --- /dev/null +++ b/src/Metrics/ReportLoader.php @@ -0,0 +1,111 @@ + + * @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\Metrics; + +use FastForward\DevTools\Filesystem\FilesystemInterface; +use JsonException; +use RuntimeException; + +use function Safe\json_decode; +use function is_array; +use function is_numeric; +use function round; + +/** + * Derives a reduced command summary from the raw PhpMetrics JSON payload. + */ +final readonly class ReportLoader implements ReportLoaderInterface +{ + /** + * @param FilesystemInterface $filesystem the filesystem used to read the generated report + */ + public function __construct( + private FilesystemInterface $filesystem, + ) {} + + /** + * {@inheritDoc} + */ + public function load(string $path): Report + { + try { + $report = json_decode($this->filesystem->readFile($path), true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $jsonException) { + throw new RuntimeException( + 'The PhpMetrics JSON report could not be decoded.', + previous: $jsonException, + ); + } + + if (! is_array($report)) { + throw new RuntimeException('The PhpMetrics JSON report MUST decode to an array.'); + } + + $classesAnalyzed = 0; + $functionsAnalyzed = 0; + $cyclomaticComplexityTotal = 0.0; + $maintainabilityIndexTotal = 0.0; + + foreach ($report as $metric) { + if (! is_array($metric)) { + continue; + } + + $type = $metric['_type'] ?? null; + + if (\Hal\Metric\ClassMetric::class === $type) { + ++$classesAnalyzed; + $cyclomaticComplexityTotal += $this->toFloat($metric['ccn'] ?? 0); + $maintainabilityIndexTotal += $this->toFloat($metric['mi'] ?? 0); + + continue; + } + + if (\Hal\Metric\FunctionMetric::class === $type) { + ++$functionsAnalyzed; + } + } + + return new Report( + averageCyclomaticComplexityByClass: 0 === $classesAnalyzed + ? 0.0 + : round($cyclomaticComplexityTotal / $classesAnalyzed, 2), + averageMaintainabilityIndexByClass: 0 === $classesAnalyzed + ? 0.0 + : round($maintainabilityIndexTotal / $classesAnalyzed, 2), + classesAnalyzed: $classesAnalyzed, + functionsAnalyzed: $functionsAnalyzed, + ); + } + + /** + * @param mixed $value the raw metric value to normalize + * + * @return float the normalized floating-point metric value + */ + private function toFloat(mixed $value): float + { + if (! is_numeric($value)) { + return 0.0; + } + + return (float) $value; + } +} diff --git a/src/Metrics/ReportLoaderInterface.php b/src/Metrics/ReportLoaderInterface.php new file mode 100644 index 000000000..859261015 --- /dev/null +++ b/src/Metrics/ReportLoaderInterface.php @@ -0,0 +1,33 @@ + + * @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\Metrics; + +/** + * Loads a reduced metrics summary from a PhpMetrics JSON report. + */ +interface ReportLoaderInterface +{ + /** + * @param string $path the absolute path to the PhpMetrics JSON report + * + * @return Report the reduced summary derived from the report + */ + public function load(string $path): Report; +} diff --git a/src/Metrics/SummaryRenderer.php b/src/Metrics/SummaryRenderer.php new file mode 100644 index 000000000..508fe04d0 --- /dev/null +++ b/src/Metrics/SummaryRenderer.php @@ -0,0 +1,44 @@ + + * @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\Metrics; + +/** + * Formats reduced PhpMetrics data as a concise console summary. + */ +final class SummaryRenderer implements SummaryRendererInterface +{ + /** + * {@inheritDoc} + */ + public function render(Report $report): string + { + return \sprintf( + "Metrics summary\n" + . "Average cyclomatic complexity by class: %.2f\n" + . "Average maintainability index by class: %.2f\n" + . "Classes analyzed: %d\n" + . "Functions analyzed: %d", + $report->averageCyclomaticComplexityByClass, + $report->averageMaintainabilityIndexByClass, + $report->classesAnalyzed, + $report->functionsAnalyzed, + ); + } +} diff --git a/src/Metrics/SummaryRendererInterface.php b/src/Metrics/SummaryRendererInterface.php new file mode 100644 index 000000000..5bcf38725 --- /dev/null +++ b/src/Metrics/SummaryRendererInterface.php @@ -0,0 +1,33 @@ + + * @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\Metrics; + +/** + * Renders a human-readable metrics summary for console output. + */ +interface SummaryRendererInterface +{ + /** + * @param Report $report the reduced summary to render + * + * @return string the formatted summary + */ + public function render(Report $report): string; +} diff --git a/src/ServiceProvider/DevToolsServiceProvider.php b/src/ServiceProvider/DevToolsServiceProvider.php index e1d28c2b5..6068f51e4 100644 --- a/src/ServiceProvider/DevToolsServiceProvider.php +++ b/src/ServiceProvider/DevToolsServiceProvider.php @@ -50,6 +50,10 @@ use FastForward\DevTools\License\GeneratorInterface; use FastForward\DevTools\License\Resolver; use FastForward\DevTools\License\ResolverInterface; +use FastForward\DevTools\Metrics\ReportLoader; +use FastForward\DevTools\Metrics\ReportLoaderInterface; +use FastForward\DevTools\Metrics\SummaryRenderer; +use FastForward\DevTools\Metrics\SummaryRendererInterface; use FastForward\DevTools\PhpUnit\Coverage\CoverageSummaryLoader; use FastForward\DevTools\PhpUnit\Coverage\CoverageSummaryLoaderInterface; use FastForward\DevTools\Process\ProcessBuilder; @@ -127,6 +131,10 @@ public function getFactories(): array GeneratorInterface::class => get(Generator::class), ResolverInterface::class => get(Resolver::class), + // Metrics + ReportLoaderInterface::class => get(ReportLoader::class), + SummaryRendererInterface::class => get(SummaryRenderer::class), + // Twig LoaderInterface::class => create(FilesystemLoader::class)->constructor(\dirname(__DIR__, 2) . '/resources'), ]; diff --git a/tests/Console/Command/MetricsCommandTest.php b/tests/Console/Command/MetricsCommandTest.php new file mode 100644 index 000000000..1a7cbefea --- /dev/null +++ b/tests/Console/Command/MetricsCommandTest.php @@ -0,0 +1,269 @@ + + * @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\Filesystem\FilesystemInterface; +use FastForward\DevTools\Metrics\Report; +use FastForward\DevTools\Metrics\ReportLoaderInterface; +use FastForward\DevTools\Metrics\SummaryRendererInterface; +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; + +#[CoversClass(MetricsCommand::class)] +final class MetricsCommandTest extends TestCase +{ + use ProphecyTrait; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $filesystem; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $processBuilder; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $processQueue; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $reportLoader; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $summaryRenderer; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $input; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $output; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $process; + + private MetricsCommand $command; + + /** + * @return void + */ + protected function setUp(): void + { + $this->filesystem = $this->prophesize(FilesystemInterface::class); + $this->processBuilder = $this->prophesize(ProcessBuilderInterface::class); + $this->processQueue = $this->prophesize(ProcessQueueInterface::class); + $this->reportLoader = $this->prophesize(ReportLoaderInterface::class); + $this->summaryRenderer = $this->prophesize(SummaryRendererInterface::class); + $this->input = $this->prophesize(InputInterface::class); + $this->output = $this->prophesize(OutputInterface::class); + $this->process = $this->prophesize(Process::class); + + foreach (['src', 'exclude', 'report-html', 'report-json', 'cache-dir'] as $option) { + $this->input->getOption($option) + ->willReturn($this->commandDefaultOption($option)); + } + + $this->filesystem->getAbsolutePath('vendor/bin/phpmetrics') + ->willReturn('/app/vendor/bin/phpmetrics'); + $this->filesystem->exists('/app/vendor/bin/phpmetrics') + ->willReturn(true); + $this->filesystem->getAbsolutePath('src') + ->willReturn('/app/src'); + $this->filesystem->exists('/app/src') + ->willReturn(true); + $this->filesystem->getAbsolutePath('tmp/cache/phpmetrics') + ->willReturn('/app/tmp/cache/phpmetrics'); + $this->filesystem->getAbsolutePath('metrics.json', '/app/tmp/cache/phpmetrics') + ->willReturn('/app/tmp/cache/phpmetrics/metrics.json'); + $this->filesystem->dirname('/app/tmp/cache/phpmetrics/metrics.json') + ->willReturn('/app/tmp/cache/phpmetrics'); + + $this->processBuilder->withArgument(Argument::cetera()) + ->willReturn($this->processBuilder->reveal()); + $this->processBuilder->build('vendor/bin/phpmetrics') + ->willReturn($this->process->reveal()); + + $this->processQueue->run($this->output->reveal()) + ->willReturn(MetricsCommand::SUCCESS); + + $this->reportLoader->load('/app/tmp/cache/phpmetrics/metrics.json') + ->willReturn(new Report(4.0, 75.0, 2, 1)); + $this->summaryRenderer->render(Argument::type(Report::class)) + ->willReturn( + "Metrics summary\n" + . "Average cyclomatic complexity by class: 4.00\n" + . "Average maintainability index by class: 75.00\n" + . "Classes analyzed: 2\n" + . "Functions analyzed: 1" + ); + + $this->command = new MetricsCommand( + $this->filesystem->reveal(), + $this->processBuilder->reveal(), + $this->processQueue->reveal(), + $this->reportLoader->reveal(), + $this->summaryRenderer->reveal(), + ); + } + + /** + * @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 source code and prints a reduced summary.', + $this->command->getHelp(), + ); + } + + /** + * @return void + */ + #[Test] + public function commandWillHaveExpectedOptions(): void + { + $definition = $this->command->getDefinition(); + + self::assertTrue($definition->hasOption('src')); + self::assertTrue($definition->hasOption('exclude')); + self::assertTrue($definition->hasOption('report-html')); + self::assertTrue($definition->hasOption('report-json')); + self::assertTrue($definition->hasOption('cache-dir')); + } + + /** + * @return void + */ + #[Test] + public function executeWillRunPhpMetricsAndPrintSummary(): void + { + $this->output->writeln('Running code metrics analysis...') + ->shouldBeCalledOnce(); + $this->filesystem->mkdir('/app/tmp/cache/phpmetrics') + ->shouldBeCalledTimes(2); + $this->processBuilder->withArgument('--quiet') + ->shouldBeCalledOnce() + ->willReturn($this->processBuilder->reveal()); + $this->processBuilder->withArgument('--exclude', 'vendor,test,Test,tests,Tests,testing,Testing,bower_components,node_modules,cache,spec,build') + ->shouldBeCalledOnce() + ->willReturn($this->processBuilder->reveal()); + $this->processBuilder->withArgument('--report-json', '/app/tmp/cache/phpmetrics/metrics.json') + ->shouldBeCalledOnce() + ->willReturn($this->processBuilder->reveal()); + $this->processBuilder->withArgument('/app/src') + ->shouldBeCalledOnce() + ->willReturn($this->processBuilder->reveal()); + $this->processQueue->add($this->process->reveal()) + ->shouldBeCalledOnce(); + $this->output->writeln(Argument::containingString('Metrics summary')) + ->shouldBeCalledOnce(); + + self::assertSame(MetricsCommand::SUCCESS, $this->executeCommand()); + } + + /** + * @return void + */ + #[Test] + public function executeWillFailWhenBinaryIsMissing(): void + { + $this->filesystem->exists('/app/vendor/bin/phpmetrics') + ->willReturn(false); + + $this->output->writeln('Running code metrics analysis...') + ->shouldBeCalledOnce(); + $this->output->writeln(Argument::containingString('PhpMetrics binary was not found')) + ->shouldBeCalledOnce(); + + self::assertSame(MetricsCommand::FAILURE, $this->executeCommand()); + } + + /** + * @return void + */ + #[Test] + public function executeWillIncludeHtmlReportWhenRequested(): void + { + $this->input->getOption('report-html') + ->willReturn('build/metrics'); + $this->filesystem->getAbsolutePath('build/metrics') + ->willReturn('/app/build/metrics'); + + $this->filesystem->mkdir('/app/build/metrics') + ->shouldBeCalledOnce(); + $this->processBuilder->withArgument('--report-html', '/app/build/metrics') + ->shouldBeCalledOnce() + ->willReturn($this->processBuilder->reveal()); + + 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) { + 'src' => 'src', + 'exclude' => 'vendor,test,Test,tests,Tests,testing,Testing,bower_components,node_modules,cache,spec,build', + 'cache-dir' => 'tmp/cache/phpmetrics', + 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/Metrics/ReportLoaderTest.php b/tests/Metrics/ReportLoaderTest.php new file mode 100644 index 000000000..f00db7d0d --- /dev/null +++ b/tests/Metrics/ReportLoaderTest.php @@ -0,0 +1,99 @@ + + * @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\Metrics; + +use FastForward\DevTools\Filesystem\FilesystemInterface; +use FastForward\DevTools\Metrics\Report; +use FastForward\DevTools\Metrics\ReportLoader; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; +use RuntimeException; + +#[CoversClass(Report::class)] +#[CoversClass(ReportLoader::class)] +final class ReportLoaderTest extends TestCase +{ + use ProphecyTrait; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $filesystem; + + private ReportLoader $loader; + + /** + * @return void + */ + protected function setUp(): void + { + $this->filesystem = $this->prophesize(FilesystemInterface::class); + $this->loader = new ReportLoader($this->filesystem->reveal()); + } + + /** + * @return void + */ + #[Test] + public function loadWillAggregateClassAndFunctionMetrics(): void + { + $this->filesystem->readFile('/app/tmp/cache/phpmetrics/metrics.json') + ->willReturn((string) json_encode([ + 'App\\Foo' => [ + '_type' => \Hal\Metric\ClassMetric::class, + 'ccn' => 3, + 'mi' => 80, + ], + 'App\\Bar' => [ + '_type' => \Hal\Metric\ClassMetric::class, + 'ccn' => 5, + 'mi' => 70, + ], + 'App\\helper' => [ + '_type' => \Hal\Metric\FunctionMetric::class, + ], + ])); + + $report = $this->loader->load('/app/tmp/cache/phpmetrics/metrics.json'); + + self::assertSame(4.0, $report->averageCyclomaticComplexityByClass); + self::assertSame(75.0, $report->averageMaintainabilityIndexByClass); + self::assertSame(2, $report->classesAnalyzed); + self::assertSame(1, $report->functionsAnalyzed); + } + + /** + * @return void + */ + #[Test] + public function loadWillFailWhenJsonCannotBeDecoded(): void + { + $this->filesystem->readFile('/app/tmp/cache/phpmetrics/metrics.json') + ->willReturn('{invalid'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The PhpMetrics JSON report could not be decoded.'); + + $this->loader->load('/app/tmp/cache/phpmetrics/metrics.json'); + } +} diff --git a/tests/Metrics/SummaryRendererTest.php b/tests/Metrics/SummaryRendererTest.php new file mode 100644 index 000000000..eb4456cbe --- /dev/null +++ b/tests/Metrics/SummaryRendererTest.php @@ -0,0 +1,49 @@ + + * @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\Metrics; + +use FastForward\DevTools\Metrics\Report; +use FastForward\DevTools\Metrics\SummaryRenderer; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; + +#[CoversClass(Report::class)] +#[CoversClass(SummaryRenderer::class)] +final class SummaryRendererTest extends TestCase +{ + /** + * @return void + */ + #[Test] + public function renderWillFormatTheExpectedSummary(): void + { + $renderer = new SummaryRenderer(); + + self::assertSame( + "Metrics summary\n" + . "Average cyclomatic complexity by class: 4.50\n" + . "Average maintainability index by class: 78.25\n" + . "Classes analyzed: 8\n" + . "Functions analyzed: 3", + $renderer->render(new Report(4.5, 78.25, 8, 3)), + ); + } +} From 515f5b6f08501ec288106595bda05df56ab0974a Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 00:41:12 +0000 Subject: [PATCH 2/6] Update wiki submodule pointer for PR #98 --- .github/wiki | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/wiki b/.github/wiki index c1a6da340..1d03abf2e 160000 --- a/.github/wiki +++ b/.github/wiki @@ -1 +1 @@ -Subproject commit c1a6da3400771286a79f57cabf50bd07717feeb4 +Subproject commit 1d03abf2edd7096ea9c380f44a1f8468aa45d195 From 5d8639409b82c26a1c8dc17092b32742f2bfe0a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Sat, 18 Apr 2026 22:46:18 -0300 Subject: [PATCH 3/6] [metrics] Finalize metrics workflow and docs (#15) --- .agents/skills/package-readme/SKILL.md | 8 +- .github/workflows/reports.yml | 3 +- README.md | 5 +- composer.json | 5 +- docs/commands/metrics.rst | 37 ++-- docs/commands/reports.rst | 13 ++ docs/running/specialized-commands.rst | 13 +- src/Console/Command/MetricsCommand.php | 161 ++---------------- src/Console/Command/ReportsCommand.php | 13 ++ src/Console/Command/SyncCommand.php | 2 +- src/Metrics/Report.php | 39 ----- src/Metrics/ReportLoader.php | 111 ------------ src/Metrics/ReportLoaderInterface.php | 33 ---- src/Metrics/SummaryRenderer.php | 44 ----- src/Metrics/SummaryRendererInterface.php | 33 ---- src/Process/ProcessBuilder.php | 10 +- src/Process/ProcessBuilderInterface.php | 4 +- .../DevToolsServiceProvider.php | 8 - tests/Console/Command/MetricsCommandTest.php | 161 ++++++++---------- tests/Console/Command/ReportsCommandTest.php | 39 ++++- tests/Metrics/ReportLoaderTest.php | 99 ----------- tests/Metrics/SummaryRendererTest.php | 49 ------ 22 files changed, 202 insertions(+), 688 deletions(-) delete mode 100644 src/Metrics/Report.php delete mode 100644 src/Metrics/ReportLoader.php delete mode 100644 src/Metrics/ReportLoaderInterface.php delete mode 100644 src/Metrics/SummaryRenderer.php delete mode 100644 src/Metrics/SummaryRendererInterface.php delete mode 100644 tests/Metrics/ReportLoaderTest.php delete mode 100644 tests/Metrics/SummaryRendererTest.php 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/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 311dd95dc..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) @@ -56,6 +57,7 @@ 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 @@ -74,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 @@ -123,7 +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 and prints a reduced code-metrics summary. | +| `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 c2faa5089..e8e4b8960 100644 --- a/composer.json +++ b/composer.json @@ -42,8 +42,8 @@ "nikic/php-parser": "^5.7", "php-di/php-di": "^7.1", "php-parallel-lint/php-parallel-lint": "^1.4", - "phpmetrics/phpmetrics": "^2.9", "phpdocumentor/shim": "^3.9", + "phpmetrics/phpmetrics": "^2.9", "phpro/grumphp-shim": "^2.19", "phpspec/prophecy": "^1.26", "phpspec/prophecy-phpunit": "^2.5", @@ -114,7 +114,6 @@ }, "scripts": { "dev-tools": "dev-tools", - "dev-tools:fix": "@dev-tools --fix", - "metrics": "@dev-tools metrics" + "dev-tools:fix": "@dev-tools --fix" } } diff --git a/docs/commands/metrics.rst b/docs/commands/metrics.rst index 364ef1983..3c14559a5 100644 --- a/docs/commands/metrics.rst +++ b/docs/commands/metrics.rst @@ -7,13 +7,8 @@ Overview -------- The ``metrics`` command runs `PhpMetrics `_ -against the selected source directory, generates a JSON report, and prints a -reduced summary with: - -- average cyclomatic complexity by class; -- average maintainability index by class; -- number of classes analyzed; -- number of functions analyzed. +against the current working directory and forwards the requested report +artifacts. Usage ----- @@ -27,16 +22,18 @@ Usage Options ------- -``--src=`` - Source directory to analyze. +``--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: ``src``. + Default: the current working directory. ``--exclude=`` Comma-separated directories that should be excluded from analysis. Default: - ``vendor,test,Test,tests,Tests,testing,Testing,bower_components,node_modules,cache,spec,build``. + ``vendor,test,tests,tmp,cache,spec,build,backup,resources``. ``--report-html=`` Optional output directory for the generated HTML report. @@ -44,16 +41,13 @@ Options ``--report-json=`` Optional output file for the generated JSON report. -``--cache-dir=`` - Cache directory used for temporary JSON reports when ``--report-json`` is - not provided. - - Default: ``tmp/cache/phpmetrics``. +``--report-summary-json=`` + Optional output file for the generated summary JSON report. Examples -------- -Generate the reduced summary with defaults: +Analyze the current repository with defaults: .. code-block:: bash @@ -65,7 +59,7 @@ Generate an HTML report for manual inspection: composer dev-tools metrics -- --report-html=build/metrics -Generate both JSON and HTML reports for CI artifacts: +Generate JSON and HTML reports for CI artifacts: .. code-block:: bash @@ -74,7 +68,6 @@ Generate both JSON and HTML reports for CI artifacts: Behavior -------- -- the command fails early when ``vendor/bin/phpmetrics`` is not installed; -- the source directory must exist; -- the reduced summary is derived from the generated PhpMetrics JSON report; -- optional HTML and JSON report destinations are created before execution. +- 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 fce3b6ca7..5ea1e1d32 100644 --- a/docs/running/specialized-commands.rst +++ b/docs/running/specialized-commands.rst @@ -51,16 +51,18 @@ Analyzes code metrics with PhpMetrics. 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 prints a reduced summary with average cyclomatic complexity, average - maintainability index, and analyzed class/function counts; -- ``--report-html`` and ``--report-json`` allow persisting the native - PhpMetrics reports for CI artifacts or manual review; -- it fails early when the PhpMetrics binary or source directory is missing. +- 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`` -------------- @@ -159,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 index 622a1594f..a696d56a7 100644 --- a/src/Console/Command/MetricsCommand.php +++ b/src/Console/Command/MetricsCommand.php @@ -20,12 +20,8 @@ namespace FastForward\DevTools\Console\Command; use Composer\Command\BaseCommand; -use FastForward\DevTools\Filesystem\FilesystemInterface; -use FastForward\DevTools\Metrics\ReportLoaderInterface; -use FastForward\DevTools\Metrics\SummaryRendererInterface; use FastForward\DevTools\Process\ProcessBuilderInterface; use FastForward\DevTools\Process\ProcessQueueInterface; -use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -34,7 +30,7 @@ #[AsCommand( name: 'metrics', description: 'Analyzes code metrics with PhpMetrics.', - help: 'This command runs PhpMetrics to analyze source code and prints a reduced summary.', + help: 'This command runs PhpMetrics to analyze the current working directory.', )] final class MetricsCommand extends BaseCommand { @@ -44,23 +40,17 @@ final class MetricsCommand extends BaseCommand private const string BINARY = 'vendor/bin/phpmetrics'; /** - * @var string the default cache directory used for temporary metrics reports + * @var int the PHP error reporting mask that suppresses deprecations emitted by PhpMetrics internals */ - private const string CACHE_DIR = 'tmp/cache/phpmetrics'; + private const int PHP_ERROR_REPORTING = \E_ALL & ~\E_DEPRECATED; /** - * @param FilesystemInterface $filesystem the filesystem utility used for path handling and report persistence * @param ProcessBuilderInterface $processBuilder the builder used to assemble the PhpMetrics process * @param ProcessQueueInterface $processQueue the queue used to execute the PhpMetrics process - * @param ReportLoaderInterface $reportLoader the loader used to derive a reduced summary from the JSON report - * @param SummaryRendererInterface $summaryRenderer the renderer used to format the reduced summary */ public function __construct( - private readonly FilesystemInterface $filesystem, private readonly ProcessBuilderInterface $processBuilder, private readonly ProcessQueueInterface $processQueue, - private readonly ReportLoaderInterface $reportLoader, - private readonly SummaryRendererInterface $summaryRenderer, ) { parent::__construct(); } @@ -71,17 +61,11 @@ public function __construct( protected function configure(): void { $this - ->addOption( - name: 'src', - mode: InputOption::VALUE_OPTIONAL, - description: 'Path to the source directory that MUST be analyzed.', - default: 'src', - ) ->addOption( name: 'exclude', mode: InputOption::VALUE_OPTIONAL, description: 'Comma-separated directories that SHOULD be excluded from analysis.', - default: 'vendor,test,Test,tests,Tests,testing,Testing,bower_components,node_modules,cache,spec,build', + default: 'vendor,test,tests,tmp,cache,spec,build,backup,resources', ) ->addOption( name: 'report-html', @@ -94,10 +78,9 @@ protected function configure(): void description: 'Optional target file for the generated JSON report.', ) ->addOption( - name: 'cache-dir', + name: 'report-summary-json', mode: InputOption::VALUE_OPTIONAL, - description: 'Path to the cache directory used for temporary metrics reports.', - default: self::CACHE_DIR, + description: 'Optional target file for the generated summary JSON report.', ); } @@ -111,133 +94,25 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $output->writeln('Running code metrics analysis...'); - try { - $binary = $this->resolveBinaryPath(); - $source = $this->resolveSourcePath($input); - $cacheDir = $this->resolveCacheDirectory($input); - $jsonReport = $this->resolveJsonReportPath($input, $cacheDir); - $htmlReport = $this->resolveOptionalReportDirectory($input, 'report-html'); - } catch (RuntimeException $runtimeException) { - $output->writeln('' . $runtimeException->getMessage() . ''); - - return self::FAILURE; - } - $processBuilder = $this->processBuilder - ->withArgument('--quiet') - ->withArgument('--exclude', (string) $input->getOption('exclude')) - ->withArgument('--report-json', $jsonReport); + ->withArgument('--ansi') + ->withArgument('--git', 'git') + ->withArgument('--exclude', (string) $input->getOption('exclude')); - if (null !== $htmlReport) { - $processBuilder = $processBuilder->withArgument('--report-html', $htmlReport); + foreach (['report-html', 'report-json', 'report-summary-json'] as $option) { + if (null === $input->getOption($option)) { + continue; + } + + $processBuilder = $processBuilder->withArgument('--' . $option, (string) $input->getOption($option)); } $this->processQueue->add( $processBuilder - ->withArgument($source) - ->build(self::BINARY) + ->withArgument('.') + ->build([\PHP_BINARY, '-derror_reporting=' . self::PHP_ERROR_REPORTING, self::BINARY]) ); - $result = $this->processQueue->run($output); - - if (self::SUCCESS !== $result) { - return $result; - } - - try { - $output->writeln($this->summaryRenderer->render($this->reportLoader->load($jsonReport))); - } catch (RuntimeException $runtimeException) { - $output->writeln('' . $runtimeException->getMessage() . ''); - - return self::FAILURE; - } - - return self::SUCCESS; - } - - /** - * @return string the absolute path to the PhpMetrics binary - */ - private function resolveBinaryPath(): string - { - $binary = $this->filesystem->getAbsolutePath(self::BINARY); - - if (! $this->filesystem->exists($binary)) { - throw new RuntimeException( - \sprintf( - 'The PhpMetrics binary was not found at %s. Install dependencies before running the metrics command.', - $binary, - ) - ); - } - - return $binary; - } - - /** - * @param InputInterface $input the runtime command input - * - * @return string the absolute source directory path - */ - private function resolveSourcePath(InputInterface $input): string - { - $source = $this->filesystem->getAbsolutePath((string) $input->getOption('src')); - - if (! $this->filesystem->exists($source)) { - throw new RuntimeException(\sprintf('Source directory not found: %s', $source)); - } - - return $source; - } - - /** - * @param InputInterface $input the runtime command input - * - * @return string the absolute cache directory path - */ - private function resolveCacheDirectory(InputInterface $input): string - { - $cacheDir = $this->filesystem->getAbsolutePath((string) $input->getOption('cache-dir')); - $this->filesystem->mkdir($cacheDir); - - return $cacheDir; - } - - /** - * @param InputInterface $input the runtime command input - * @param string $cacheDir the absolute cache directory used for fallback output - * - * @return string the absolute JSON report path - */ - private function resolveJsonReportPath(InputInterface $input, string $cacheDir): string - { - $reportJson = $input->getOption('report-json'); - $reportJsonPath = null === $reportJson - ? $this->filesystem->getAbsolutePath('metrics.json', $cacheDir) - : $this->filesystem->getAbsolutePath((string) $reportJson); - - $this->filesystem->mkdir($this->filesystem->dirname($reportJsonPath)); - - return $reportJsonPath; - } - - /** - * @param InputInterface $input the runtime command input - * @param string $option the option that may contain a report directory - * - * @return string|null the absolute report directory path when configured - */ - private function resolveOptionalReportDirectory(InputInterface $input, string $option): ?string - { - $reportDirectory = $input->getOption($option); - - if (null === $reportDirectory) { - return null; - } - - $reportDirectory = $this->filesystem->getAbsolutePath((string) $reportDirectory); - $this->filesystem->mkdir($reportDirectory); - - return $reportDirectory; + return $this->processQueue->run($output); } } diff --git a/src/Console/Command/ReportsCommand.php b/src/Console/Command/ReportsCommand.php index af97a2e92..b36fbd247 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', ); } @@ -102,6 +108,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->processQueue->add(process: $docs, detached: true); $this->processQueue->add(process: $coverage, detached: true); + $metrics = $this->processBuilder + ->withArgument('--ansi') + ->withArgument('--report-html', $input->getOption('metrics')) + ->build('composer dev-tools metrics --'); + + $this->processQueue->add(process: $metrics, detached: true); + 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/Metrics/Report.php b/src/Metrics/Report.php deleted file mode 100644 index e7086c2f2..000000000 --- a/src/Metrics/Report.php +++ /dev/null @@ -1,39 +0,0 @@ - - * @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\Metrics; - -/** - * Represents the reduced metrics summary shown by the dev-tools metrics command. - */ -final readonly class Report -{ - /** - * @param float $averageCyclomaticComplexityByClass the average class cyclomatic complexity reported by PhpMetrics - * @param float $averageMaintainabilityIndexByClass the average class maintainability index reported by PhpMetrics - * @param int $classesAnalyzed the number of analyzed classes - * @param int $functionsAnalyzed the number of analyzed functions - */ - public function __construct( - public float $averageCyclomaticComplexityByClass, - public float $averageMaintainabilityIndexByClass, - public int $classesAnalyzed, - public int $functionsAnalyzed, - ) {} -} diff --git a/src/Metrics/ReportLoader.php b/src/Metrics/ReportLoader.php deleted file mode 100644 index f88948222..000000000 --- a/src/Metrics/ReportLoader.php +++ /dev/null @@ -1,111 +0,0 @@ - - * @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\Metrics; - -use FastForward\DevTools\Filesystem\FilesystemInterface; -use JsonException; -use RuntimeException; - -use function Safe\json_decode; -use function is_array; -use function is_numeric; -use function round; - -/** - * Derives a reduced command summary from the raw PhpMetrics JSON payload. - */ -final readonly class ReportLoader implements ReportLoaderInterface -{ - /** - * @param FilesystemInterface $filesystem the filesystem used to read the generated report - */ - public function __construct( - private FilesystemInterface $filesystem, - ) {} - - /** - * {@inheritDoc} - */ - public function load(string $path): Report - { - try { - $report = json_decode($this->filesystem->readFile($path), true, 512, JSON_THROW_ON_ERROR); - } catch (JsonException $jsonException) { - throw new RuntimeException( - 'The PhpMetrics JSON report could not be decoded.', - previous: $jsonException, - ); - } - - if (! is_array($report)) { - throw new RuntimeException('The PhpMetrics JSON report MUST decode to an array.'); - } - - $classesAnalyzed = 0; - $functionsAnalyzed = 0; - $cyclomaticComplexityTotal = 0.0; - $maintainabilityIndexTotal = 0.0; - - foreach ($report as $metric) { - if (! is_array($metric)) { - continue; - } - - $type = $metric['_type'] ?? null; - - if (\Hal\Metric\ClassMetric::class === $type) { - ++$classesAnalyzed; - $cyclomaticComplexityTotal += $this->toFloat($metric['ccn'] ?? 0); - $maintainabilityIndexTotal += $this->toFloat($metric['mi'] ?? 0); - - continue; - } - - if (\Hal\Metric\FunctionMetric::class === $type) { - ++$functionsAnalyzed; - } - } - - return new Report( - averageCyclomaticComplexityByClass: 0 === $classesAnalyzed - ? 0.0 - : round($cyclomaticComplexityTotal / $classesAnalyzed, 2), - averageMaintainabilityIndexByClass: 0 === $classesAnalyzed - ? 0.0 - : round($maintainabilityIndexTotal / $classesAnalyzed, 2), - classesAnalyzed: $classesAnalyzed, - functionsAnalyzed: $functionsAnalyzed, - ); - } - - /** - * @param mixed $value the raw metric value to normalize - * - * @return float the normalized floating-point metric value - */ - private function toFloat(mixed $value): float - { - if (! is_numeric($value)) { - return 0.0; - } - - return (float) $value; - } -} diff --git a/src/Metrics/ReportLoaderInterface.php b/src/Metrics/ReportLoaderInterface.php deleted file mode 100644 index 859261015..000000000 --- a/src/Metrics/ReportLoaderInterface.php +++ /dev/null @@ -1,33 +0,0 @@ - - * @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\Metrics; - -/** - * Loads a reduced metrics summary from a PhpMetrics JSON report. - */ -interface ReportLoaderInterface -{ - /** - * @param string $path the absolute path to the PhpMetrics JSON report - * - * @return Report the reduced summary derived from the report - */ - public function load(string $path): Report; -} diff --git a/src/Metrics/SummaryRenderer.php b/src/Metrics/SummaryRenderer.php deleted file mode 100644 index 508fe04d0..000000000 --- a/src/Metrics/SummaryRenderer.php +++ /dev/null @@ -1,44 +0,0 @@ - - * @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\Metrics; - -/** - * Formats reduced PhpMetrics data as a concise console summary. - */ -final class SummaryRenderer implements SummaryRendererInterface -{ - /** - * {@inheritDoc} - */ - public function render(Report $report): string - { - return \sprintf( - "Metrics summary\n" - . "Average cyclomatic complexity by class: %.2f\n" - . "Average maintainability index by class: %.2f\n" - . "Classes analyzed: %d\n" - . "Functions analyzed: %d", - $report->averageCyclomaticComplexityByClass, - $report->averageMaintainabilityIndexByClass, - $report->classesAnalyzed, - $report->functionsAnalyzed, - ); - } -} diff --git a/src/Metrics/SummaryRendererInterface.php b/src/Metrics/SummaryRendererInterface.php deleted file mode 100644 index 5bcf38725..000000000 --- a/src/Metrics/SummaryRendererInterface.php +++ /dev/null @@ -1,33 +0,0 @@ - - * @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\Metrics; - -/** - * Renders a human-readable metrics summary for console output. - */ -interface SummaryRendererInterface -{ - /** - * @param Report $report the reduced summary to render - * - * @return string the formatted summary - */ - public function render(Report $report): string; -} 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/src/ServiceProvider/DevToolsServiceProvider.php b/src/ServiceProvider/DevToolsServiceProvider.php index 6068f51e4..e1d28c2b5 100644 --- a/src/ServiceProvider/DevToolsServiceProvider.php +++ b/src/ServiceProvider/DevToolsServiceProvider.php @@ -50,10 +50,6 @@ use FastForward\DevTools\License\GeneratorInterface; use FastForward\DevTools\License\Resolver; use FastForward\DevTools\License\ResolverInterface; -use FastForward\DevTools\Metrics\ReportLoader; -use FastForward\DevTools\Metrics\ReportLoaderInterface; -use FastForward\DevTools\Metrics\SummaryRenderer; -use FastForward\DevTools\Metrics\SummaryRendererInterface; use FastForward\DevTools\PhpUnit\Coverage\CoverageSummaryLoader; use FastForward\DevTools\PhpUnit\Coverage\CoverageSummaryLoaderInterface; use FastForward\DevTools\Process\ProcessBuilder; @@ -131,10 +127,6 @@ public function getFactories(): array GeneratorInterface::class => get(Generator::class), ResolverInterface::class => get(Resolver::class), - // Metrics - ReportLoaderInterface::class => get(ReportLoader::class), - SummaryRendererInterface::class => get(SummaryRenderer::class), - // Twig LoaderInterface::class => create(FilesystemLoader::class)->constructor(\dirname(__DIR__, 2) . '/resources'), ]; diff --git a/tests/Console/Command/MetricsCommandTest.php b/tests/Console/Command/MetricsCommandTest.php index 1a7cbefea..242570eef 100644 --- a/tests/Console/Command/MetricsCommandTest.php +++ b/tests/Console/Command/MetricsCommandTest.php @@ -20,10 +20,6 @@ namespace FastForward\DevTools\Tests\Console\Command; use FastForward\DevTools\Console\Command\MetricsCommand; -use FastForward\DevTools\Filesystem\FilesystemInterface; -use FastForward\DevTools\Metrics\Report; -use FastForward\DevTools\Metrics\ReportLoaderInterface; -use FastForward\DevTools\Metrics\SummaryRendererInterface; use FastForward\DevTools\Process\ProcessBuilderInterface; use FastForward\DevTools\Process\ProcessQueueInterface; use PHPUnit\Framework\Attributes\CoversClass; @@ -37,16 +33,14 @@ 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 $filesystem; - /** * @var ObjectProphecy */ @@ -57,16 +51,6 @@ final class MetricsCommandTest extends TestCase */ private ObjectProphecy $processQueue; - /** - * @var ObjectProphecy - */ - private ObjectProphecy $reportLoader; - - /** - * @var ObjectProphecy - */ - private ObjectProphecy $summaryRenderer; - /** * @var ObjectProphecy */ @@ -82,6 +66,10 @@ final class MetricsCommandTest extends TestCase */ private ObjectProphecy $process; + private string $jsonReport; + + private string $summaryReport; + private MetricsCommand $command; /** @@ -89,61 +77,51 @@ final class MetricsCommandTest extends TestCase */ protected function setUp(): void { - $this->filesystem = $this->prophesize(FilesystemInterface::class); $this->processBuilder = $this->prophesize(ProcessBuilderInterface::class); $this->processQueue = $this->prophesize(ProcessQueueInterface::class); - $this->reportLoader = $this->prophesize(ReportLoaderInterface::class); - $this->summaryRenderer = $this->prophesize(SummaryRendererInterface::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 (['src', 'exclude', 'report-html', 'report-json', 'cache-dir'] as $option) { + foreach (['exclude', 'report-html', 'report-json', 'report-summary-json'] as $option) { $this->input->getOption($option) ->willReturn($this->commandDefaultOption($option)); } - $this->filesystem->getAbsolutePath('vendor/bin/phpmetrics') - ->willReturn('/app/vendor/bin/phpmetrics'); - $this->filesystem->exists('/app/vendor/bin/phpmetrics') - ->willReturn(true); - $this->filesystem->getAbsolutePath('src') - ->willReturn('/app/src'); - $this->filesystem->exists('/app/src') - ->willReturn(true); - $this->filesystem->getAbsolutePath('tmp/cache/phpmetrics') - ->willReturn('/app/tmp/cache/phpmetrics'); - $this->filesystem->getAbsolutePath('metrics.json', '/app/tmp/cache/phpmetrics') - ->willReturn('/app/tmp/cache/phpmetrics/metrics.json'); - $this->filesystem->dirname('/app/tmp/cache/phpmetrics/metrics.json') - ->willReturn('/app/tmp/cache/phpmetrics'); - $this->processBuilder->withArgument(Argument::cetera()) ->willReturn($this->processBuilder->reveal()); - $this->processBuilder->build('vendor/bin/phpmetrics') + $this->processBuilder->build([\PHP_BINARY, '-derror_reporting=' . (\E_ALL & ~\E_DEPRECATED), 'vendor/bin/phpmetrics']) ->willReturn($this->process->reveal()); $this->processQueue->run($this->output->reveal()) - ->willReturn(MetricsCommand::SUCCESS); - - $this->reportLoader->load('/app/tmp/cache/phpmetrics/metrics.json') - ->willReturn(new Report(4.0, 75.0, 2, 1)); - $this->summaryRenderer->render(Argument::type(Report::class)) - ->willReturn( - "Metrics summary\n" - . "Average cyclomatic complexity by class: 4.00\n" - . "Average maintainability index by class: 75.00\n" - . "Classes analyzed: 2\n" - . "Functions analyzed: 1" - ); - - $this->command = new MetricsCommand( - $this->filesystem->reveal(), - $this->processBuilder->reveal(), - $this->processQueue->reveal(), - $this->reportLoader->reveal(), - $this->summaryRenderer->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); + } + } } /** @@ -155,7 +133,7 @@ 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 source code and prints a reduced summary.', + 'This command runs PhpMetrics to analyze the current working directory.', $this->command->getHelp(), ); } @@ -168,39 +146,46 @@ public function commandWillHaveExpectedOptions(): void { $definition = $this->command->getDefinition(); - self::assertTrue($definition->hasOption('src')); + self::assertTrue($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('cache-dir')); + self::assertTrue($definition->hasOption('report-summary-json')); + self::assertFalse($definition->hasOption('cache-dir')); } /** * @return void */ #[Test] - public function executeWillRunPhpMetricsAndPrintSummary(): void + public function executeWillRunPhpMetrics(): void { $this->output->writeln('Running code metrics analysis...') ->shouldBeCalledOnce(); - $this->filesystem->mkdir('/app/tmp/cache/phpmetrics') - ->shouldBeCalledTimes(2); - $this->processBuilder->withArgument('--quiet') + $this->processBuilder->withArgument('--ansi') ->shouldBeCalledOnce() ->willReturn($this->processBuilder->reveal()); - $this->processBuilder->withArgument('--exclude', 'vendor,test,Test,tests,Tests,testing,Testing,bower_components,node_modules,cache,spec,build') + $this->processBuilder->withArgument('--git', 'git') ->shouldBeCalledOnce() ->willReturn($this->processBuilder->reveal()); - $this->processBuilder->withArgument('--report-json', '/app/tmp/cache/phpmetrics/metrics.json') + $this->processBuilder->withArgument( + '--exclude', + 'vendor,test,tests,tmp,cache,spec,build,backup,resources' + ) ->shouldBeCalledOnce() ->willReturn($this->processBuilder->reveal()); - $this->processBuilder->withArgument('/app/src') + $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('.') ->shouldBeCalledOnce() ->willReturn($this->processBuilder->reveal()); $this->processQueue->add($this->process->reveal()) ->shouldBeCalledOnce(); - $this->output->writeln(Argument::containingString('Metrics summary')) - ->shouldBeCalledOnce(); self::assertSame(MetricsCommand::SUCCESS, $this->executeCommand()); } @@ -209,17 +194,21 @@ public function executeWillRunPhpMetricsAndPrintSummary(): void * @return void */ #[Test] - public function executeWillFailWhenBinaryIsMissing(): void + public function executeWillSkipUnsetOptionalReports(): void { - $this->filesystem->exists('/app/vendor/bin/phpmetrics') - ->willReturn(false); - - $this->output->writeln('Running code metrics analysis...') - ->shouldBeCalledOnce(); - $this->output->writeln(Argument::containingString('PhpMetrics binary was not found')) + $this->input->getOption('report-json') + ->willReturn(null); + $this->input->getOption('report-summary-json') + ->willReturn(null); + + $this->processBuilder->withArgument('--report-json', Argument::any()) + ->shouldNotBeCalled(); + $this->processBuilder->withArgument('--report-summary-json', Argument::any()) + ->shouldNotBeCalled(); + $this->processQueue->add($this->process->reveal()) ->shouldBeCalledOnce(); - self::assertSame(MetricsCommand::FAILURE, $this->executeCommand()); + self::assertSame(MetricsCommand::SUCCESS, $this->executeCommand()); } /** @@ -230,14 +219,12 @@ public function executeWillIncludeHtmlReportWhenRequested(): void { $this->input->getOption('report-html') ->willReturn('build/metrics'); - $this->filesystem->getAbsolutePath('build/metrics') - ->willReturn('/app/build/metrics'); - $this->filesystem->mkdir('/app/build/metrics') - ->shouldBeCalledOnce(); - $this->processBuilder->withArgument('--report-html', '/app/build/metrics') + $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()); } @@ -250,9 +237,9 @@ public function executeWillIncludeHtmlReportWhenRequested(): void private function commandDefaultOption(string $option): mixed { return match ($option) { - 'src' => 'src', - 'exclude' => 'vendor,test,Test,tests,Tests,testing,Testing,bower_components,node_modules,cache,spec,build', - 'cache-dir' => 'tmp/cache/phpmetrics', + 'exclude' => 'vendor,test,tests,tmp,cache,spec,build,backup,resources', + 'report-json' => $this->jsonReport, + 'report-summary-json' => $this->summaryReport, default => null, }; } diff --git a/tests/Console/Command/ReportsCommandTest.php b/tests/Console/Command/ReportsCommandTest.php index 7557ce100..4dca74cd1 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 executeWillRunDocsTestsAndMetricsCommandAsDetachedProcesses(): void { $this->output->writeln('Generating frontpage for Fast Forward documentation...') ->shouldBeCalledOnce(); @@ -157,17 +168,43 @@ public function executeWillRunDocsAndTestsCommandAsDetachedProcesses(): void ->shouldBeCalledOnce() ->willReturn($this->processBuilder->reveal()); + $this->processBuilder->withArgument('--report-html', 'public/metrics') + ->shouldBeCalledOnce() + ->willReturn($this->processBuilder->reveal()); + $this->processQueue->add($this->docsProcess->reveal(), false, true) ->shouldBeCalledOnce(); $this->processQueue->add($this->testsProcess->reveal(), false, true) ->shouldBeCalledOnce(); + $this->processQueue->add($this->metricsProcess->reveal(), false, true) + ->shouldBeCalledOnce(); + $result = $this->executeCommand(); 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->processQueue->add(Argument::type(Process::class), false, true) + ->shouldBeCalledTimes(3); + + self::assertSame(ReportsCommand::SUCCESS, $this->executeCommand()); + } + /** * @return int */ diff --git a/tests/Metrics/ReportLoaderTest.php b/tests/Metrics/ReportLoaderTest.php deleted file mode 100644 index f00db7d0d..000000000 --- a/tests/Metrics/ReportLoaderTest.php +++ /dev/null @@ -1,99 +0,0 @@ - - * @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\Metrics; - -use FastForward\DevTools\Filesystem\FilesystemInterface; -use FastForward\DevTools\Metrics\Report; -use FastForward\DevTools\Metrics\ReportLoader; -use PHPUnit\Framework\Attributes\CoversClass; -use PHPUnit\Framework\Attributes\Test; -use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; -use RuntimeException; - -#[CoversClass(Report::class)] -#[CoversClass(ReportLoader::class)] -final class ReportLoaderTest extends TestCase -{ - use ProphecyTrait; - - /** - * @var ObjectProphecy - */ - private ObjectProphecy $filesystem; - - private ReportLoader $loader; - - /** - * @return void - */ - protected function setUp(): void - { - $this->filesystem = $this->prophesize(FilesystemInterface::class); - $this->loader = new ReportLoader($this->filesystem->reveal()); - } - - /** - * @return void - */ - #[Test] - public function loadWillAggregateClassAndFunctionMetrics(): void - { - $this->filesystem->readFile('/app/tmp/cache/phpmetrics/metrics.json') - ->willReturn((string) json_encode([ - 'App\\Foo' => [ - '_type' => \Hal\Metric\ClassMetric::class, - 'ccn' => 3, - 'mi' => 80, - ], - 'App\\Bar' => [ - '_type' => \Hal\Metric\ClassMetric::class, - 'ccn' => 5, - 'mi' => 70, - ], - 'App\\helper' => [ - '_type' => \Hal\Metric\FunctionMetric::class, - ], - ])); - - $report = $this->loader->load('/app/tmp/cache/phpmetrics/metrics.json'); - - self::assertSame(4.0, $report->averageCyclomaticComplexityByClass); - self::assertSame(75.0, $report->averageMaintainabilityIndexByClass); - self::assertSame(2, $report->classesAnalyzed); - self::assertSame(1, $report->functionsAnalyzed); - } - - /** - * @return void - */ - #[Test] - public function loadWillFailWhenJsonCannotBeDecoded(): void - { - $this->filesystem->readFile('/app/tmp/cache/phpmetrics/metrics.json') - ->willReturn('{invalid'); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('The PhpMetrics JSON report could not be decoded.'); - - $this->loader->load('/app/tmp/cache/phpmetrics/metrics.json'); - } -} diff --git a/tests/Metrics/SummaryRendererTest.php b/tests/Metrics/SummaryRendererTest.php deleted file mode 100644 index eb4456cbe..000000000 --- a/tests/Metrics/SummaryRendererTest.php +++ /dev/null @@ -1,49 +0,0 @@ - - * @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\Metrics; - -use FastForward\DevTools\Metrics\Report; -use FastForward\DevTools\Metrics\SummaryRenderer; -use PHPUnit\Framework\Attributes\CoversClass; -use PHPUnit\Framework\Attributes\Test; -use PHPUnit\Framework\TestCase; - -#[CoversClass(Report::class)] -#[CoversClass(SummaryRenderer::class)] -final class SummaryRendererTest extends TestCase -{ - /** - * @return void - */ - #[Test] - public function renderWillFormatTheExpectedSummary(): void - { - $renderer = new SummaryRenderer(); - - self::assertSame( - "Metrics summary\n" - . "Average cyclomatic complexity by class: 4.50\n" - . "Average maintainability index by class: 78.25\n" - . "Classes analyzed: 8\n" - . "Functions analyzed: 3", - $renderer->render(new Report(4.5, 78.25, 8, 3)), - ); - } -} From 1275f561ef02b676990005e86d75887a6ed21813 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 01:47:44 +0000 Subject: [PATCH 4/6] Update wiki submodule pointer for PR #98 --- .github/wiki | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/wiki b/.github/wiki index 1d03abf2e..3ca041cf3 160000 --- a/.github/wiki +++ b/.github/wiki @@ -1 +1 @@ -Subproject commit 1d03abf2edd7096ea9c380f44a1f8468aa45d195 +Subproject commit 3ca041cf3de0a56951cb017c63e91f65913eceae From 8e97b302568050c1aea12a7380072361d0b45387 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Sat, 18 Apr 2026 23:12:34 -0300 Subject: [PATCH 5/6] [metrics] Align reports execution flow and tests (#15) --- .github/wiki | 2 +- src/Console/Command/MetricsCommand.php | 21 ++++++++++++------- src/Console/Command/ReportsCommand.php | 8 +++---- src/Console/Command/TestsCommand.php | 3 ++- tests/Console/Command/MetricsCommandTest.php | 22 +++++++++++++++----- tests/Console/Command/ReportsCommandTest.php | 20 +++++++++++++----- 6 files changed, 53 insertions(+), 23 deletions(-) diff --git a/.github/wiki b/.github/wiki index 3ca041cf3..c1a6da340 160000 --- a/.github/wiki +++ b/.github/wiki @@ -1 +1 @@ -Subproject commit 3ca041cf3de0a56951cb017c63e91f65913eceae +Subproject commit c1a6da3400771286a79f57cabf50bd07717feeb4 diff --git a/src/Console/Command/MetricsCommand.php b/src/Console/Command/MetricsCommand.php index a696d56a7..b45bea477 100644 --- a/src/Console/Command/MetricsCommand.php +++ b/src/Console/Command/MetricsCommand.php @@ -71,16 +71,24 @@ protected function configure(): void 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.', ); } @@ -97,14 +105,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int $processBuilder = $this->processBuilder ->withArgument('--ansi') ->withArgument('--git', 'git') - ->withArgument('--exclude', (string) $input->getOption('exclude')); - - foreach (['report-html', 'report-json', 'report-summary-json'] as $option) { - if (null === $input->getOption($option)) { - continue; - } + ->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')); - $processBuilder = $processBuilder->withArgument('--' . $option, (string) $input->getOption($option)); + if (null !== $input->getOption('junit')) { + $processBuilder = $processBuilder->withArgument('--junit', $input->getOption('junit')); } $this->processQueue->add( diff --git a/src/Console/Command/ReportsCommand.php b/src/Console/Command/ReportsCommand.php index b36fbd247..533270744 100644 --- a/src/Console/Command/ReportsCommand.php +++ b/src/Console/Command/ReportsCommand.php @@ -105,15 +105,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int ->withArgument('--coverage', $input->getOption('coverage')) ->build('composer dev-tools tests --'); - $this->processQueue->add(process: $docs, detached: true); - $this->processQueue->add(process: $coverage, detached: true); - $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: $metrics, detached: true); + $this->processQueue->add(process: $docs, detached: true); + $this->processQueue->add(process: $coverage); + $this->processQueue->add(process: $metrics); return $this->processQueue->run($output); } 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/tests/Console/Command/MetricsCommandTest.php b/tests/Console/Command/MetricsCommandTest.php index 242570eef..2987c994b 100644 --- a/tests/Console/Command/MetricsCommandTest.php +++ b/tests/Console/Command/MetricsCommandTest.php @@ -87,7 +87,7 @@ protected function setUp(): void $jsonReport = $this->jsonReport; $summaryReport = $this->summaryReport; - foreach (['exclude', 'report-html', 'report-json', 'report-summary-json'] as $option) { + foreach (['exclude', 'report-html', 'report-json', 'report-summary-json', 'junit'] as $option) { $this->input->getOption($option) ->willReturn($this->commandDefaultOption($option)); } @@ -146,12 +146,13 @@ public function commandWillHaveExpectedOptions(): void { $definition = $this->command->getDefinition(); - self::assertTrue($definition->hasOption('working-dir')); + 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')); } @@ -181,6 +182,8 @@ public function executeWillRunPhpMetrics(): void $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()); @@ -200,10 +203,16 @@ public function executeWillSkipUnsetOptionalReports(): void ->willReturn(null); $this->input->getOption('report-summary-json') ->willReturn(null); + $this->input->getOption('junit') + ->willReturn(null); - $this->processBuilder->withArgument('--report-json', Argument::any()) - ->shouldNotBeCalled(); - $this->processBuilder->withArgument('--report-summary-json', Argument::any()) + $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(); @@ -219,6 +228,8 @@ 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() @@ -240,6 +251,7 @@ private function commandDefaultOption(string $option): mixed 'exclude' => 'vendor,test,tests,tmp,cache,spec,build,backup,resources', 'report-json' => $this->jsonReport, 'report-summary-json' => $this->summaryReport, + 'junit' => null, default => null, }; } diff --git a/tests/Console/Command/ReportsCommandTest.php b/tests/Console/Command/ReportsCommandTest.php index 4dca74cd1..b0d09fbff 100644 --- a/tests/Console/Command/ReportsCommandTest.php +++ b/tests/Console/Command/ReportsCommandTest.php @@ -143,7 +143,7 @@ public function commandWillHaveExpectedOptions(): void * @return void */ #[Test] - public function executeWillRunDocsTestsAndMetricsCommandAsDetachedProcesses(): void + public function executeWillRunDocsAsDetachedAndTestsAndMetricsInSequence(): void { $this->output->writeln('Generating frontpage for Fast Forward documentation...') ->shouldBeCalledOnce(); @@ -171,14 +171,17 @@ public function executeWillRunDocsTestsAndMetricsCommandAsDetachedProcesses(): v $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(), false, true) + $this->processQueue->add($this->metricsProcess->reveal()) ->shouldBeCalledOnce(); $result = $this->executeCommand(); @@ -198,9 +201,16 @@ public function executeWillRunMetricsCommandWhenRequested(): void $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(Argument::type(Process::class), false, true) - ->shouldBeCalledTimes(3); + $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()); } From 17ad45bf402d46c3766cd41e234fa6bf0c22d627 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 02:14:41 +0000 Subject: [PATCH 6/6] Update wiki submodule pointer for PR #98 --- .github/wiki | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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