diff --git a/README.md b/README.md index fca1529..5f37de0 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ across Fast Forward libraries. - Aggregates refactoring, PHPDoc, code style, tests, and reporting under a single Composer-facing command vocabulary +- Adds dependency analysis for missing and unused Composer packages through a + single report entrypoint - Ships shared workflow stubs, `.editorconfig`, Dependabot configuration, and other onboarding defaults for consumer repositories - Synchronizes packaged agent skills into consumer `.agents/skills` @@ -47,6 +49,10 @@ You can also run individual commands for specific development tasks: # Run PHPUnit tests composer dev-tools tests +# Analyze missing and unused Composer dependencies +composer dependencies +vendor/bin/dev-tools dependencies + # Check and fix code style using ECS and Composer Normalize composer dev-tools code-style @@ -77,6 +83,10 @@ composer dev-tools gitignore composer dev-tools:sync ``` +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 `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 @@ -89,6 +99,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 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:sync` | Updates scripts, workflow stubs, `.editorconfig`, `.gitignore`, wiki setup, and packaged skills. | diff --git a/composer.json b/composer.json index 263cfc6..b56e328 100644 --- a/composer.json +++ b/composer.json @@ -34,14 +34,23 @@ "fakerphp/faker": "^1.24", "fast-forward/phpdoc-bootstrap-template": "^1.0", "friendsofphp/php-cs-fixer": "^3.94", + "icanhazstring/composer-unused": "^0.9.6", "jolicode/jolinotif": "^3.3", + "nikic/php-parser": "^5.7", "phpdocumentor/shim": "^3.9", "phpro/grumphp": "^2.19", + "phpspec/prophecy": "^1.26", "phpspec/prophecy-phpunit": "^2.5", "phpunit/phpunit": "^12.5", + "psr/log": "^3.0", "pyrech/composer-changelogs": "^2.2", "rector/rector": "^2.3", "saggre/phpdocumentor-markdown": "^1.0", + "shipmonk/composer-dependency-analyser": "^1.8.4", + "symfony/console": "^7.3", + "symfony/filesystem": "^7.4", + "symfony/finder": "^7.4", + "symfony/process": "^7.4", "symfony/var-dumper": "^7.4", "symfony/var-exporter": "^7.4", "symplify/easy-coding-standard": "^13.0", diff --git a/docs/api/commands.rst b/docs/api/commands.rst index aa2a04e..9226d2a 100644 --- a/docs/api/commands.rst +++ b/docs/api/commands.rst @@ -30,6 +30,9 @@ resolution, configuration fallback, PSR-4 lookup, and child-command dispatch. * - ``FastForward\DevTools\Command\TestsCommand`` - ``tests`` - Runs PHPUnit with optional coverage output. + * - ``FastForward\DevTools\Command\DependenciesCommand`` + - ``dependencies`` + - Reports missing and unused Composer dependencies. * - ``FastForward\DevTools\Command\DocsCommand`` - ``docs`` - Builds the HTML documentation site. diff --git a/docs/running/specialized-commands.rst b/docs/running/specialized-commands.rst index 674cd43..c2f53a3 100644 --- a/docs/running/specialized-commands.rst +++ b/docs/running/specialized-commands.rst @@ -21,6 +21,26 @@ Important details: - ``--no-cache`` disables ``tmp/cache/phpunit``; - the packaged configuration registers the DevTools PHPUnit extension. +``dependencies`` +---------------- + +Analyzes missing and unused Composer dependencies. + +.. code-block:: bash + + composer dependencies + vendor/bin/dev-tools dependencies + +Important details: + +- it ships ``shipmonk/composer-dependency-analyser`` and + ``icanhazstring/composer-unused`` as direct dependencies of + ``fast-forward/dev-tools``; +- it uses ``composer-dependency-analyser`` only for missing dependency checks + and leaves unused-package reporting to ``composer-unused``; +- it returns a non-zero exit code when missing or unused dependencies are + found. + ``code-style`` -------------- diff --git a/src/Command/AbstractCommand.php b/src/Command/AbstractCommand.php index 134015e..643d5d5 100644 --- a/src/Command/AbstractCommand.php +++ b/src/Command/AbstractCommand.php @@ -64,10 +64,11 @@ public function __construct(?Filesystem $filesystem = null) * * @param Process $command the configured process instance to run * @param OutputInterface $output the output interface to log warnings or results + * @param bool $tty * * @return int the status code of the command execution */ - protected function runProcess(Process $command, OutputInterface $output): int + protected function runProcess(Process $command, OutputInterface $output, bool $tty = true): int { /** @var ProcessHelper $processHelper */ $processHelper = $this->getHelper('process'); @@ -75,13 +76,16 @@ protected function runProcess(Process $command, OutputInterface $output): int $command = $command->setWorkingDirectory($this->getCurrentWorkingDirectory()); $callback = null; - if (Process::isTtySupported()) { - $command->setTty(true); - } else { + try { + $command->setTty($tty); + } catch (RuntimeException) { $output->writeln( 'Warning: TTY is not supported. The command may not display output as expected.' ); + $tty = false; + } + if (! $tty) { $callback = function (string $type, string $buffer) use ($output): void { $output->write($buffer); }; @@ -89,17 +93,7 @@ protected function runProcess(Process $command, OutputInterface $output): int $process = $processHelper->run(output: $output, cmd: $command, callback: $callback); - if (! $process->isSuccessful()) { - $output->writeln(\sprintf( - 'Command "%s" failed with exit code %d. Please check the output above for details.', - $command->getCommandLine(), - $command->getExitCode() - )); - - return self::FAILURE; - } - - return self::SUCCESS; + return $process->isSuccessful() ? self::SUCCESS : self::FAILURE; } /** diff --git a/src/Command/DependenciesCommand.php b/src/Command/DependenciesCommand.php new file mode 100644 index 0000000..f7f533b --- /dev/null +++ b/src/Command/DependenciesCommand.php @@ -0,0 +1,83 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Command; + +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Process\Process; + +/** + * Orchestrates dependency analysis across the supported Composer analyzers. + * This command MUST report missing and unused dependencies using a single, + * deterministic report that is friendly for local development and CI runs. + */ +final class DependenciesCommand extends AbstractCommand +{ + /** + * Configures the dependency analysis command metadata. + * + * The command MUST expose the `dependencies` name so it can run via both + * Composer and the local `dev-tools` binary. + * + * @return void + */ + protected function configure(): void + { + $this + ->setName('dependencies') + ->setAliases(['deps']) + ->setDescription('Analyzes missing and unused Composer dependencies.') + ->setHelp( + 'This command runs composer-dependency-analyser and composer-unused to report ' + . 'missing and unused Composer dependencies.' + ); + } + + /** + * Executes the dependency analysis workflow. + * + * The command MUST verify the required binaries before executing the tools, + * SHOULD normalize their machine-readable output into a unified report, and + * SHALL return a non-zero exit code when findings or execution failures exist. + * + * @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 dependency analysis...'); + + $composerJson = $this->getConfigFile('composer.json'); + + $results[] = $this->runProcess( + new Process(['vendor/bin/composer-unused', $composerJson, '--no-progress']), + $output + ); + $results[] = $this->runProcess(new Process([ + 'vendor/bin/composer-dependency-analyser', + '--composer-json=' . $composerJson, + '--ignore-unused-deps', + '--ignore-prod-only-in-dev-deps', + ]), $output); + + return \in_array(self::FAILURE, $results, true) ? self::FAILURE : self::SUCCESS; + } +} diff --git a/src/Composer/Capability/DevToolsCommandProvider.php b/src/Composer/Capability/DevToolsCommandProvider.php index 2172e61..c031c1e 100644 --- a/src/Composer/Capability/DevToolsCommandProvider.php +++ b/src/Composer/Capability/DevToolsCommandProvider.php @@ -21,6 +21,7 @@ use FastForward\DevTools\Command\AbstractCommand; use Composer\Plugin\Capability\CommandProvider as CommandProviderCapability; use FastForward\DevTools\Command\CodeStyleCommand; +use FastForward\DevTools\Command\DependenciesCommand; use FastForward\DevTools\Command\DocsCommand; use FastForward\DevTools\Command\GitIgnoreCommand; use FastForward\DevTools\Command\PhpDocCommand; @@ -52,6 +53,7 @@ public function getCommands() new CodeStyleCommand(), new RefactorCommand(), new TestsCommand(), + new DependenciesCommand(), new PhpDocCommand(), new DocsCommand(), new StandardsCommand(), diff --git a/tests/Command/DependenciesCommandTest.php b/tests/Command/DependenciesCommandTest.php new file mode 100644 index 0000000..57976bd --- /dev/null +++ b/tests/Command/DependenciesCommandTest.php @@ -0,0 +1,240 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Command; + +use FastForward\DevTools\Command\DependenciesCommand; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use Prophecy\Argument; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Process\Process; + +use function Safe\getcwd; +use function str_contains; + +#[CoversClass(DependenciesCommand::class)] +final class DependenciesCommandTest extends AbstractCommandTestCase +{ + /** + * @return string + */ + protected function getCommandClass(): string + { + return DependenciesCommand::class; + } + + /** + * @return string + */ + protected function getCommandName(): string + { + return 'dependencies'; + } + + /** + * @return string + */ + protected function getCommandDescription(): string + { + return 'Analyzes missing and unused Composer dependencies.'; + } + + /** + * @return string + */ + protected function getCommandHelp(): string + { + return 'This command runs composer-dependency-analyser and composer-unused to report missing and unused Composer dependencies.'; + } + + /** + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $cwd = getcwd(); + $this->filesystem->exists($cwd . '/composer.json')->willReturn(true); + } + + /** + * @return void + */ + #[Test] + public function executeWillReturnSuccessWhenBothToolsSucceed(): void + { + $processUnused = $this->prophesize(Process::class); + $processUnused->isSuccessful() + ->willReturn(true); + + $processDepAnalyser = $this->prophesize(Process::class); + $processDepAnalyser->isSuccessful() + ->willReturn(true); + + $this->processHelper + ->run(Argument::type(OutputInterface::class), Argument::that( + static fn(Process $p): bool => str_contains($p->getCommandLine(), 'composer-unused') + ), Argument::cetera()) + ->willReturn($processUnused->reveal()) + ->shouldBeCalled(); + + $this->processHelper + ->run(Argument::type(OutputInterface::class), Argument::that( + static fn(Process $p): bool => str_contains($p->getCommandLine(), 'composer-dependency-analyser') + ), Argument::cetera()) + ->willReturn($processDepAnalyser->reveal()) + ->shouldBeCalled(); + + $this->output->writeln('Running dependency analysis...') + ->shouldBeCalled(); + $this->output->writeln( + 'Warning: TTY is not supported. The command may not display output as expected.' + ) + ->shouldBeCalled(); + + self::assertSame(DependenciesCommand::SUCCESS, $this->invokeExecute()); + } + + /** + * @return void + */ + #[Test] + public function executeWillReturnFailureWhenFirstToolFails(): void + { + $processUnused = $this->prophesize(Process::class); + $processUnused->isSuccessful() + ->willReturn(false); + + $processDepAnalyser = $this->prophesize(Process::class); + $processDepAnalyser->isSuccessful() + ->willReturn(true); + + $this->processHelper + ->run(Argument::type(OutputInterface::class), Argument::that( + static fn(Process $p): bool => str_contains($p->getCommandLine(), 'composer-unused') + ), Argument::cetera()) + ->willReturn($processUnused->reveal()) + ->shouldBeCalled(); + + $this->processHelper + ->run(Argument::type(OutputInterface::class), Argument::that( + static fn(Process $p): bool => str_contains($p->getCommandLine(), 'composer-dependency-analyser') + ), Argument::cetera()) + ->willReturn($processDepAnalyser->reveal()) + ->shouldBeCalled(); + + $this->output->writeln('Running dependency analysis...') + ->shouldBeCalled(); + $this->output->writeln( + 'Warning: TTY is not supported. The command may not display output as expected.' + ) + ->shouldBeCalled(); + + self::assertSame(DependenciesCommand::FAILURE, $this->invokeExecute()); + } + + /** + * @return void + */ + #[Test] + public function executeWillReturnFailureWhenSecondToolFails(): void + { + $processUnused = $this->prophesize(Process::class); + $processUnused->isSuccessful() + ->willReturn(true); + + $processDepAnalyser = $this->prophesize(Process::class); + $processDepAnalyser->isSuccessful() + ->willReturn(false); + + $this->processHelper + ->run(Argument::type(OutputInterface::class), Argument::that( + static fn(Process $p): bool => str_contains($p->getCommandLine(), 'composer-unused') + ), Argument::cetera()) + ->willReturn($processUnused->reveal()) + ->shouldBeCalled(); + + $this->processHelper + ->run(Argument::type(OutputInterface::class), Argument::that( + static fn(Process $p): bool => str_contains($p->getCommandLine(), 'composer-dependency-analyser') + ), Argument::cetera()) + ->willReturn($processDepAnalyser->reveal()) + ->shouldBeCalled(); + + $this->output->writeln('Running dependency analysis...') + ->shouldBeCalled(); + $this->output->writeln( + 'Warning: TTY is not supported. The command may not display output as expected.' + ) + ->shouldBeCalled(); + + self::assertSame(DependenciesCommand::FAILURE, $this->invokeExecute()); + } + + /** + * @return void + */ + #[Test] + public function executeWillCallBothDependencyToolsWithComposerJson(): void + { + $cwd = getcwd(); + $composerJsonPath = $cwd . '/composer.json'; + + $this->filesystem->exists($composerJsonPath) + ->willReturn(true); + + $processUnused = $this->prophesize(Process::class); + $processUnused->isSuccessful() + ->willReturn(true); + $processUnused->getCommandLine() + ->willReturn('vendor/bin/composer-unused ' . $composerJsonPath . ' --no-progress'); + + $processDepAnalyser = $this->prophesize(Process::class); + $processDepAnalyser->isSuccessful() + ->willReturn(true); + $processDepAnalyser->getCommandLine() + ->willReturn( + 'vendor/bin/composer-dependency-analyser --composer-json=' . $composerJsonPath . ' --ignore-unused-deps --ignore-prod-only-in-dev-deps' + ); + + $this->processHelper + ->run(Argument::type(OutputInterface::class), Argument::that( + static fn(Process $p): bool => str_contains($p->getCommandLine(), 'composer-unused') + ), Argument::cetera()) + ->willReturn($processUnused->reveal()) + ->shouldBeCalled(); + + $this->processHelper + ->run(Argument::type(OutputInterface::class), Argument::that( + static fn(Process $p): bool => str_contains($p->getCommandLine(), 'composer-dependency-analyser') + ), Argument::cetera()) + ->willReturn($processDepAnalyser->reveal()) + ->shouldBeCalled(); + + $this->output->writeln('Running dependency analysis...') + ->shouldBeCalled(); + $this->output->writeln( + 'Warning: TTY is not supported. The command may not display output as expected.' + ) + ->shouldBeCalled(); + + self::assertSame(DependenciesCommand::SUCCESS, $this->invokeExecute()); + } +} diff --git a/tests/Composer/Capability/DevToolsCommandProviderTest.php b/tests/Composer/Capability/DevToolsCommandProviderTest.php index 6b35e08..b867d01 100644 --- a/tests/Composer/Capability/DevToolsCommandProviderTest.php +++ b/tests/Composer/Capability/DevToolsCommandProviderTest.php @@ -20,6 +20,7 @@ use FastForward\DevTools\Command\AbstractCommand; use FastForward\DevTools\Command\CodeStyleCommand; +use FastForward\DevTools\Command\DependenciesCommand; use FastForward\DevTools\Command\DocsCommand; use FastForward\DevTools\Command\GitIgnoreCommand; use FastForward\DevTools\Command\SyncCommand; @@ -43,6 +44,7 @@ #[UsesClass(CodeStyleCommand::class)] #[UsesClass(RefactorCommand::class)] #[UsesClass(TestsCommand::class)] +#[UsesClass(DependenciesCommand::class)] #[UsesClass(PhpDocCommand::class)] #[UsesClass(DocsCommand::class)] #[UsesClass(StandardsCommand::class)] @@ -77,6 +79,7 @@ public function getCommandsWillReturnAllSupportedCommandsInExpectedOrder(): void new CodeStyleCommand(), new RefactorCommand(), new TestsCommand(), + new DependenciesCommand(), new PhpDocCommand(), new DocsCommand(), new StandardsCommand(),