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