From 4f686d2bde4c03583cdd8ff0d9a68daec937c97b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 9 Apr 2026 21:40:46 -0300 Subject: [PATCH 1/4] [command] Add dependency analysis command (#10) --- README.md | 14 + docs/api/commands.rst | 3 + docs/running/specialized-commands.rst | 19 + src/Command/DependenciesCommand.php | 507 ++++++++++++++++++ .../Capability/DevToolsCommandProvider.php | 2 + tests/Command/DependenciesCommandTest.php | 284 ++++++++++ .../DevToolsCommandProviderTest.php | 3 + 7 files changed, 832 insertions(+) create mode 100644 src/Command/DependenciesCommand.php create mode 100644 tests/Command/DependenciesCommandTest.php diff --git a/README.md b/README.md index fca1529..bf2cd56 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,13 @@ composer dev-tools gitignore composer dev-tools:sync ``` +The `dependencies` command expects both dependency analyzers to be installed in +the target project: + +```bash +composer require --dev shipmonk/composer-dependency-analyser icanhazstring/composer-unused +``` + 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 +102,7 @@ automation assets. |---------|---------| | `composer dev-tools` | Runs the full `standards` pipeline. | | `composer dev-tools tests` | Runs PHPUnit with local-or-packaged configuration. | +| `composer 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/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..fbbdf69 100644 --- a/docs/running/specialized-commands.rst +++ b/docs/running/specialized-commands.rst @@ -21,6 +21,25 @@ 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 requires ``shipmonk/composer-dependency-analyser`` and + ``icanhazstring/composer-unused`` to be installed in the target project; +- 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/DependenciesCommand.php b/src/Command/DependenciesCommand.php new file mode 100644 index 0000000..e37b10b --- /dev/null +++ b/src/Command/DependenciesCommand.php @@ -0,0 +1,507 @@ + + * @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 Closure; +use DOMDocument; +use JsonException; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Process\Process; + +use function Safe\json_decode; +use function array_values; +use function trim; + +/** + * 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 +{ + /** + * @var string the root composer manifest expected by the dependency analysers + */ + public const string COMPOSER_JSON = 'composer.json'; + + /** + * @var string the packaged path to shipmonk/composer-dependency-analyser + */ + public const string DEPENDENCY_ANALYSER = 'vendor/bin/composer-dependency-analyser'; + + /** + * @var string the packaged path to icanhazstring/composer-unused + */ + public const string COMPOSER_UNUSED = 'vendor/bin/composer-unused'; + + /** + * @var Closure(list): array{exitCode:int, output:string}|null custom process runner used for testing + */ + private readonly ?Closure $processRunner; + + /** + * Constructs the dependencies command. + * + * The command MAY receive a custom runner for deterministic tests while the + * default runtime MUST execute the real analyzers through Symfony Process. + * + * @param Filesystem|null $filesystem the filesystem utility used by the command + * @param callable(list): array{exitCode:int, output:string}|null $processRunner custom analyzer executor + */ + public function __construct(?Filesystem $filesystem = null, ?callable $processRunner = null) + { + $this->processRunner = null === $processRunner ? null : Closure::fromCallable($processRunner); + + parent::__construct($filesystem); + } + + /** + * 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') + ->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->getAbsolutePath(self::COMPOSER_JSON); + $dependencyAnalyser = $this->getAbsolutePath(self::DEPENDENCY_ANALYSER); + $composerUnused = $this->getAbsolutePath(self::COMPOSER_UNUSED); + + $missingRequirements = $this->resolveMissingRequirements( + composerJson: $composerJson, + dependencyAnalyser: $dependencyAnalyser, + composerUnused: $composerUnused, + ); + + if ([] !== $missingRequirements) { + $output->writeln('Dependency analysis requires the following files:'); + + foreach ($missingRequirements as $requirement) { + $output->writeln($requirement); + } + + return self::FAILURE; + } + + $missingDependencies = $this->analyzeMissingDependencies($composerJson, $dependencyAnalyser); + $unusedDependencies = $this->analyzeUnusedDependencies($composerJson, $composerUnused); + + $output->writeln(''); + $output->writeln('Dependency Analysis Report'); + $output->writeln(''); + + $hasExecutionFailure = $this->renderMissingDependenciesSection($output, $missingDependencies); + + $output->writeln(''); + + if ($this->renderUnusedDependenciesSection($output, $unusedDependencies)) { + $hasExecutionFailure = true; + } + + $output->writeln(''); + $output->writeln('Summary:'); + + if ($hasExecutionFailure) { + $output->writeln('- dependency analysis could not be completed.'); + + return self::FAILURE; + } + + $output->writeln(\sprintf('- %d missing', \count($missingDependencies['dependencies']))); + $output->writeln(\sprintf('- %d unused', \count($unusedDependencies['dependencies']))); + + if ([] !== $missingDependencies['dependencies'] || [] !== $unusedDependencies['dependencies']) { + return self::FAILURE; + } + + return self::SUCCESS; + } + + /** + * Resolves missing runtime requirements for the command. + * + * @param string $composerJson absolute path to composer.json + * @param string $dependencyAnalyser absolute path to composer-dependency-analyser + * @param string $composerUnused absolute path to composer-unused + * + * @return list the user-facing requirement errors + */ + private function resolveMissingRequirements( + string $composerJson, + string $dependencyAnalyser, + string $composerUnused, + ): array { + $missing = []; + + if (! $this->filesystem->exists($composerJson)) { + $missing[] = '- composer.json not found in the current working directory.'; + } + + if (! $this->filesystem->exists($dependencyAnalyser)) { + $missing[] = '- vendor/bin/composer-dependency-analyser not found. Install: composer require --dev shipmonk/composer-dependency-analyser'; + } + + if (! $this->filesystem->exists($composerUnused)) { + $missing[] = '- vendor/bin/composer-unused not found. Install: composer require --dev icanhazstring/composer-unused'; + } + + return $missing; + } + + /** + * Executes the missing dependency analyzer and normalizes its result. + * + * @param string $composerJson absolute path to composer.json + * @param string $dependencyAnalyser absolute path to composer-dependency-analyser + * + * @return array{dependencies:list, examples:array, rawOutput:string, executionFailed:bool} + */ + private function analyzeMissingDependencies(string $composerJson, string $dependencyAnalyser): array + { + $result = $this->runDependencyProcess([ + $dependencyAnalyser, + '--composer-json=' . $composerJson, + '--format=junit', + '--ignore-unused-deps', + '--ignore-dev-in-prod-deps', + '--ignore-prod-only-in-dev-deps', + '--ignore-unknown-classes', + '--ignore-unknown-functions', + ]); + + $normalized = $this->parseMissingDependencies($result['output']); + + if (null === $normalized) { + return [ + 'dependencies' => [], + 'examples' => [], + 'rawOutput' => $result['output'], + 'executionFailed' => true, + ]; + } + + if (self::SUCCESS !== $result['exitCode'] && [] === $normalized['dependencies']) { + return [ + 'dependencies' => [], + 'examples' => [], + 'rawOutput' => $result['output'], + 'executionFailed' => true, + ]; + } + + return [ + 'dependencies' => $normalized['dependencies'], + 'examples' => $normalized['examples'], + 'rawOutput' => $result['output'], + 'executionFailed' => false, + ]; + } + + /** + * Executes composer-unused and normalizes its result. + * + * @param string $composerJson absolute path to composer.json + * @param string $composerUnused absolute path to composer-unused + * + * @return array{dependencies:list, rawOutput:string, executionFailed:bool} + */ + private function analyzeUnusedDependencies(string $composerJson, string $composerUnused): array + { + $result = $this->runDependencyProcess([ + $composerUnused, + $composerJson, + '--output-format=json', + '--no-progress', + ]); + + $dependencies = $this->parseUnusedDependencies($result['output']); + + if (null === $dependencies) { + return [ + 'dependencies' => [], + 'rawOutput' => $result['output'], + 'executionFailed' => true, + ]; + } + + if (self::SUCCESS !== $result['exitCode'] && [] === $dependencies) { + return [ + 'dependencies' => [], + 'rawOutput' => $result['output'], + 'executionFailed' => true, + ]; + } + + return [ + 'dependencies' => $dependencies, + 'rawOutput' => $result['output'], + 'executionFailed' => false, + ]; + } + + /** + * Renders the normalized missing dependency section. + * + * @param OutputInterface $output the console output stream + * @param array{dependencies:list, examples:array, rawOutput:string, executionFailed:bool} $analysis + * + * @return bool true when the analyzer failed operationally + */ + private function renderMissingDependenciesSection(OutputInterface $output, array $analysis): bool + { + $output->writeln('[Missing Dependencies]'); + + if ($analysis['executionFailed']) { + $output->writeln('composer-dependency-analyser did not return a readable report.'); + $this->renderRawOutput($output, $analysis['rawOutput']); + + return true; + } + + if ([] === $analysis['dependencies']) { + $output->writeln('None detected.'); + + return false; + } + + foreach ($analysis['dependencies'] as $dependency) { + $line = '- ' . $dependency; + + if (\array_key_exists($dependency, $analysis['examples'])) { + $line .= ' (' . $analysis['examples'][$dependency] . ')'; + } + + $output->writeln($line); + } + + return false; + } + + /** + * Renders the normalized unused dependency section. + * + * @param OutputInterface $output the console output stream + * @param array{dependencies:list, rawOutput:string, executionFailed:bool} $analysis + * + * @return bool true when the analyzer failed operationally + */ + private function renderUnusedDependenciesSection(OutputInterface $output, array $analysis): bool + { + $output->writeln('[Unused Dependencies]'); + + if ($analysis['executionFailed']) { + $output->writeln('composer-unused did not return a readable report.'); + $this->renderRawOutput($output, $analysis['rawOutput']); + + return true; + } + + if ([] === $analysis['dependencies']) { + $output->writeln('None detected.'); + + return false; + } + + foreach ($analysis['dependencies'] as $dependency) { + $output->writeln('- ' . $dependency); + } + + return false; + } + + /** + * Prints the raw analyzer output when normalization is not possible. + * + * @param OutputInterface $output the console output stream + * @param string $rawOutput the raw analyzer output + * + * @return void + */ + private function renderRawOutput(OutputInterface $output, string $rawOutput): void + { + $trimmedOutput = trim($rawOutput); + + if ('' === $trimmedOutput) { + $output->writeln('No analyzer output was captured.'); + + return; + } + + $output->writeln($trimmedOutput); + } + + /** + * Parses the shadow dependency suite from the analyzer JUnit output. + * + * @param string $xml the raw JUnit XML payload + * + * @return array{dependencies:list, examples:array}|null + */ + private function parseMissingDependencies(string $xml): ?array + { + $trimmedXml = trim($xml); + + if ('' === $trimmedXml || ! \extension_loaded('dom') || ! \extension_loaded('libxml')) { + return null; + } + + $internalErrors = libxml_use_internal_errors(true); + + $document = new DOMDocument(); + $loaded = $document->loadXML($trimmedXml); + + libxml_clear_errors(); + libxml_use_internal_errors($internalErrors); + + if (! $loaded) { + return null; + } + + $dependencies = []; + $examples = []; + + foreach ($document->getElementsByTagName('testsuite') as $suite) { + if ('shadow dependencies' !== $suite->getAttribute('name')) { + continue; + } + + foreach ($suite->getElementsByTagName('testcase') as $testCase) { + $dependency = trim($testCase->getAttribute('name')); + + if ('' === $dependency) { + continue; + } + + $dependencies[] = $dependency; + + foreach ($testCase->getElementsByTagName('failure') as $failure) { + $example = trim((string) $failure->nodeValue); + + if ('' !== $example) { + $examples[$dependency] = $example; + + break; + } + } + } + } + + return [ + 'dependencies' => array_values($dependencies), + 'examples' => $examples, + ]; + } + + /** + * Parses the composer-unused JSON output. + * + * @param string $json the raw JSON payload + * + * @return list|null + */ + private function parseUnusedDependencies(string $json): ?array + { + $trimmedJson = trim($json); + + if ('' === $trimmedJson) { + return null; + } + + try { + $decoded = json_decode($trimmedJson, true); + } catch (JsonException) { + return null; + } + + if (! \is_array($decoded) || ! \array_key_exists('unused-packages', $decoded) || ! \is_array( + $decoded['unused-packages'] + )) { + return null; + } + + $dependencies = []; + + foreach ($decoded['unused-packages'] as $dependency) { + if (\is_string($dependency) && '' !== $dependency) { + $dependencies[] = $dependency; + } + } + + return array_values($dependencies); + } + + /** + * Executes a dependency analyzer and captures its output for normalization. + * + * @param list $command the analyzer command to execute + * + * @return array{exitCode:int, output:string} + */ + private function runDependencyProcess(array $command): array + { + if ($this->processRunner instanceof Closure) { + return ($this->processRunner)($command); + } + + $process = new Process($command, $this->getCurrentWorkingDirectory(), [ + 'NO_COLOR' => '1', + ]); + $process->setTimeout(null); + + $buffer = ''; + + $process->run(static function (string $type, string $output) use (&$buffer): void { + $buffer .= $output; + }); + + return [ + 'exitCode' => $process->getExitCode() ?? self::FAILURE, + 'output' => trim($buffer), + ]; + } +} 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..838b3d6 --- /dev/null +++ b/tests/Command/DependenciesCommandTest.php @@ -0,0 +1,284 @@ + + * @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 function Safe\getcwd; + +#[CoversClass(DependenciesCommand::class)] +final class DependenciesCommandTest extends AbstractCommandTestCase +{ + /** + * @var list + */ + private array $queuedResults = []; + + /** + * @var list> + */ + private array $receivedCommands = []; + + private DependenciesCommand $dependenciesCommand; + + /** + * @return DependenciesCommand + */ + protected function getCommandClass(): DependenciesCommand + { + $this->queuedResults = []; + $this->receivedCommands = []; + $queuedResults = &$this->queuedResults; + $receivedCommands = &$this->receivedCommands; + + $this->dependenciesCommand = new DependenciesCommand( + $this->filesystem->reveal(), + static function (array $command) use (&$queuedResults, &$receivedCommands): array { + $receivedCommands[] = $command; + + return array_shift($queuedResults) ?? [ + 'exitCode' => DependenciesCommand::SUCCESS, + 'output' => '', + ]; + }, + ); + + return $this->dependenciesCommand; + } + + /** + * @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 + */ + #[Test] + public function executeWillFailWhenRequiredFilesAreMissing(): void + { + $cwd = getcwd(); + + $this->filesystem->exists($cwd . '/composer.json')->willReturn(true); + $this->filesystem->exists($cwd . '/vendor/bin/composer-dependency-analyser')->willReturn(false); + $this->filesystem->exists($cwd . '/vendor/bin/composer-unused')->willReturn(false); + + $this->output->writeln('Running dependency analysis...') + ->shouldBeCalled(); + $this->output->writeln('Dependency analysis requires the following files:') + ->shouldBeCalled(); + $this->output->writeln( + '- vendor/bin/composer-dependency-analyser not found. Install: composer require --dev shipmonk/composer-dependency-analyser' + ) + ->shouldBeCalled(); + $this->output->writeln( + '- vendor/bin/composer-unused not found. Install: composer require --dev icanhazstring/composer-unused' + ) + ->shouldBeCalled(); + + self::assertSame(DependenciesCommand::FAILURE, $this->invokeExecute()); + } + + /** + * @return void + */ + #[Test] + public function executeWillRenderNormalizedDependencyReport(): void + { + $cwd = getcwd(); + + $this->filesystem->exists($cwd . '/composer.json')->willReturn(true); + $this->filesystem->exists($cwd . '/vendor/bin/composer-dependency-analyser')->willReturn(true); + $this->filesystem->exists($cwd . '/vendor/bin/composer-unused')->willReturn(true); + + $this->queuedResults[] = [ + 'exitCode' => DependenciesCommand::FAILURE, + 'output' => <<<'XML' + + + + + src/Command/DependenciesCommand.php:42 + + + + XML, + ]; + $this->queuedResults[] = [ + 'exitCode' => DependenciesCommand::FAILURE, + 'output' => <<<'JSON' + { + "unused-packages": [ + "monolog/monolog" + ] + } + JSON, + ]; + + $this->output->writeln('Running dependency analysis...') + ->shouldBeCalled(); + $this->output->writeln('Dependency Analysis Report') + ->shouldBeCalled(); + $this->output->writeln('[Missing Dependencies]') + ->shouldBeCalled(); + $this->output->writeln('- symfony/console (src/Command/DependenciesCommand.php:42)') + ->shouldBeCalled(); + $this->output->writeln('[Unused Dependencies]') + ->shouldBeCalled(); + $this->output->writeln('- monolog/monolog') + ->shouldBeCalled(); + $this->output->writeln('Summary:') + ->shouldBeCalled(); + $this->output->writeln('- 1 missing') + ->shouldBeCalled(); + $this->output->writeln('- 1 unused') + ->shouldBeCalled(); + $this->output->writeln('') + ->shouldBeCalledTimes(4); + + self::assertSame(DependenciesCommand::FAILURE, $this->invokeExecute()); + self::assertSame([ + [ + $cwd . '/vendor/bin/composer-dependency-analyser', + '--composer-json=' . $cwd . '/composer.json', + '--format=junit', + '--ignore-unused-deps', + '--ignore-dev-in-prod-deps', + '--ignore-prod-only-in-dev-deps', + '--ignore-unknown-classes', + '--ignore-unknown-functions', + ], + [ + $cwd . '/vendor/bin/composer-unused', + $cwd . '/composer.json', + '--output-format=json', + '--no-progress', + ], + ], $this->receivedCommands); + } + + /** + * @return void + */ + #[Test] + public function executeWillReturnSuccessWhenNoFindingsAreReported(): void + { + $cwd = getcwd(); + + $this->filesystem->exists($cwd . '/composer.json')->willReturn(true); + $this->filesystem->exists($cwd . '/vendor/bin/composer-dependency-analyser')->willReturn(true); + $this->filesystem->exists($cwd . '/vendor/bin/composer-unused')->willReturn(true); + + $this->queuedResults[] = [ + 'exitCode' => DependenciesCommand::SUCCESS, + 'output' => '', + ]; + $this->queuedResults[] = [ + 'exitCode' => DependenciesCommand::SUCCESS, + 'output' => '{"unused-packages":[]}', + ]; + + $this->output->writeln('Running dependency analysis...') + ->shouldBeCalled(); + $this->output->writeln('Dependency Analysis Report') + ->shouldBeCalled(); + $this->output->writeln('[Missing Dependencies]') + ->shouldBeCalled(); + $this->output->writeln('None detected.') + ->shouldBeCalledTimes(2); + $this->output->writeln('[Unused Dependencies]') + ->shouldBeCalled(); + $this->output->writeln('Summary:') + ->shouldBeCalled(); + $this->output->writeln('- 0 missing') + ->shouldBeCalled(); + $this->output->writeln('- 0 unused') + ->shouldBeCalled(); + $this->output->writeln('') + ->shouldBeCalledTimes(4); + + self::assertSame(DependenciesCommand::SUCCESS, $this->invokeExecute()); + } + + /** + * @return void + */ + #[Test] + public function executeWillReportUnreadableAnalyzerOutputAsFailure(): void + { + $cwd = getcwd(); + + $this->filesystem->exists($cwd . '/composer.json')->willReturn(true); + $this->filesystem->exists($cwd . '/vendor/bin/composer-dependency-analyser')->willReturn(true); + $this->filesystem->exists($cwd . '/vendor/bin/composer-unused')->willReturn(true); + + $this->queuedResults[] = [ + 'exitCode' => DependenciesCommand::FAILURE, + 'output' => 'unexpected failure', + ]; + $this->queuedResults[] = [ + 'exitCode' => DependenciesCommand::SUCCESS, + 'output' => '{"unused-packages":[]}', + ]; + + $this->output->writeln('Running dependency analysis...') + ->shouldBeCalled(); + $this->output->writeln('Dependency Analysis Report') + ->shouldBeCalled(); + $this->output->writeln('[Missing Dependencies]') + ->shouldBeCalled(); + $this->output->writeln('composer-dependency-analyser did not return a readable report.') + ->shouldBeCalled(); + $this->output->writeln('unexpected failure') + ->shouldBeCalled(); + $this->output->writeln('[Unused Dependencies]') + ->shouldBeCalled(); + $this->output->writeln('None detected.') + ->shouldBeCalled(); + $this->output->writeln('Summary:') + ->shouldBeCalled(); + $this->output->writeln('- dependency analysis could not be completed.') + ->shouldBeCalled(); + $this->output->writeln('') + ->shouldBeCalledTimes(4); + + self::assertSame(DependenciesCommand::FAILURE, $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(), From a7749a868a3a95aae5b7794e3870802f2f26a3f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 9 Apr 2026 21:45:26 -0300 Subject: [PATCH 2/4] [command] Bundle dependency analysers with dev-tools (#10) --- README.md | 9 +++------ composer.json | 2 ++ docs/running/specialized-commands.rst | 5 +++-- src/Command/DependenciesCommand.php | 4 ++-- tests/Command/DependenciesCommandTest.php | 4 ++-- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index bf2cd56..3fd9c35 100644 --- a/README.md +++ b/README.md @@ -83,12 +83,9 @@ composer dev-tools gitignore composer dev-tools:sync ``` -The `dependencies` command expects both dependency analyzers to be installed in -the target project: - -```bash -composer require --dev shipmonk/composer-dependency-analyser icanhazstring/composer-unused -``` +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 diff --git a/composer.json b/composer.json index 263cfc6..90d3fce 100644 --- a/composer.json +++ b/composer.json @@ -34,6 +34,7 @@ "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", "phpdocumentor/shim": "^3.9", "phpro/grumphp": "^2.19", @@ -42,6 +43,7 @@ "pyrech/composer-changelogs": "^2.2", "rector/rector": "^2.3", "saggre/phpdocumentor-markdown": "^1.0", + "shipmonk/composer-dependency-analyser": "^1.8.4", "symfony/var-dumper": "^7.4", "symfony/var-exporter": "^7.4", "symplify/easy-coding-standard": "^13.0", diff --git a/docs/running/specialized-commands.rst b/docs/running/specialized-commands.rst index fbbdf69..c2f53a3 100644 --- a/docs/running/specialized-commands.rst +++ b/docs/running/specialized-commands.rst @@ -33,8 +33,9 @@ Analyzes missing and unused Composer dependencies. Important details: -- it requires ``shipmonk/composer-dependency-analyser`` and - ``icanhazstring/composer-unused`` to be installed in the target project; +- 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 diff --git a/src/Command/DependenciesCommand.php b/src/Command/DependenciesCommand.php index e37b10b..b007a6f 100644 --- a/src/Command/DependenciesCommand.php +++ b/src/Command/DependenciesCommand.php @@ -183,11 +183,11 @@ private function resolveMissingRequirements( } if (! $this->filesystem->exists($dependencyAnalyser)) { - $missing[] = '- vendor/bin/composer-dependency-analyser not found. Install: composer require --dev shipmonk/composer-dependency-analyser'; + $missing[] = '- vendor/bin/composer-dependency-analyser not found. Reinstall fast-forward/dev-tools dependencies.'; } if (! $this->filesystem->exists($composerUnused)) { - $missing[] = '- vendor/bin/composer-unused not found. Install: composer require --dev icanhazstring/composer-unused'; + $missing[] = '- vendor/bin/composer-unused not found. Reinstall fast-forward/dev-tools dependencies.'; } return $missing; diff --git a/tests/Command/DependenciesCommandTest.php b/tests/Command/DependenciesCommandTest.php index 838b3d6..8e68591 100644 --- a/tests/Command/DependenciesCommandTest.php +++ b/tests/Command/DependenciesCommandTest.php @@ -105,11 +105,11 @@ public function executeWillFailWhenRequiredFilesAreMissing(): void $this->output->writeln('Dependency analysis requires the following files:') ->shouldBeCalled(); $this->output->writeln( - '- vendor/bin/composer-dependency-analyser not found. Install: composer require --dev shipmonk/composer-dependency-analyser' + '- vendor/bin/composer-dependency-analyser not found. Reinstall fast-forward/dev-tools dependencies.' ) ->shouldBeCalled(); $this->output->writeln( - '- vendor/bin/composer-unused not found. Install: composer require --dev icanhazstring/composer-unused' + '- vendor/bin/composer-unused not found. Reinstall fast-forward/dev-tools dependencies.' ) ->shouldBeCalled(); From ba338c4e00b11f1dc41f2d8c4178b5b122d7aa80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 9 Apr 2026 23:05:34 -0300 Subject: [PATCH 3/4] feat: Added support for dependency analysis with new commands and execution improvements. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe Sayão Lobato Abreu --- composer.json | 7 + src/Command/AbstractCommand.php | 24 +- src/Command/DependenciesCommand.php | 442 +--------------------- tests/Command/DependenciesCommandTest.php | 288 ++++++-------- 4 files changed, 147 insertions(+), 614 deletions(-) diff --git a/composer.json b/composer.json index 90d3fce..b56e328 100644 --- a/composer.json +++ b/composer.json @@ -36,14 +36,21 @@ "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/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 index b007a6f..f7f533b 100644 --- a/src/Command/DependenciesCommand.php +++ b/src/Command/DependenciesCommand.php @@ -18,18 +18,10 @@ namespace FastForward\DevTools\Command; -use Closure; -use DOMDocument; -use JsonException; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Process\Process; -use function Safe\json_decode; -use function array_values; -use function trim; - /** * Orchestrates dependency analysis across the supported Composer analyzers. * This command MUST report missing and unused dependencies using a single, @@ -37,42 +29,6 @@ */ final class DependenciesCommand extends AbstractCommand { - /** - * @var string the root composer manifest expected by the dependency analysers - */ - public const string COMPOSER_JSON = 'composer.json'; - - /** - * @var string the packaged path to shipmonk/composer-dependency-analyser - */ - public const string DEPENDENCY_ANALYSER = 'vendor/bin/composer-dependency-analyser'; - - /** - * @var string the packaged path to icanhazstring/composer-unused - */ - public const string COMPOSER_UNUSED = 'vendor/bin/composer-unused'; - - /** - * @var Closure(list): array{exitCode:int, output:string}|null custom process runner used for testing - */ - private readonly ?Closure $processRunner; - - /** - * Constructs the dependencies command. - * - * The command MAY receive a custom runner for deterministic tests while the - * default runtime MUST execute the real analyzers through Symfony Process. - * - * @param Filesystem|null $filesystem the filesystem utility used by the command - * @param callable(list): array{exitCode:int, output:string}|null $processRunner custom analyzer executor - */ - public function __construct(?Filesystem $filesystem = null, ?callable $processRunner = null) - { - $this->processRunner = null === $processRunner ? null : Closure::fromCallable($processRunner); - - parent::__construct($filesystem); - } - /** * Configures the dependency analysis command metadata. * @@ -85,6 +41,7 @@ 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 ' @@ -108,400 +65,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $output->writeln('Running dependency analysis...'); - $composerJson = $this->getAbsolutePath(self::COMPOSER_JSON); - $dependencyAnalyser = $this->getAbsolutePath(self::DEPENDENCY_ANALYSER); - $composerUnused = $this->getAbsolutePath(self::COMPOSER_UNUSED); + $composerJson = $this->getConfigFile('composer.json'); - $missingRequirements = $this->resolveMissingRequirements( - composerJson: $composerJson, - dependencyAnalyser: $dependencyAnalyser, - composerUnused: $composerUnused, + $results[] = $this->runProcess( + new Process(['vendor/bin/composer-unused', $composerJson, '--no-progress']), + $output ); - - if ([] !== $missingRequirements) { - $output->writeln('Dependency analysis requires the following files:'); - - foreach ($missingRequirements as $requirement) { - $output->writeln($requirement); - } - - return self::FAILURE; - } - - $missingDependencies = $this->analyzeMissingDependencies($composerJson, $dependencyAnalyser); - $unusedDependencies = $this->analyzeUnusedDependencies($composerJson, $composerUnused); - - $output->writeln(''); - $output->writeln('Dependency Analysis Report'); - $output->writeln(''); - - $hasExecutionFailure = $this->renderMissingDependenciesSection($output, $missingDependencies); - - $output->writeln(''); - - if ($this->renderUnusedDependenciesSection($output, $unusedDependencies)) { - $hasExecutionFailure = true; - } - - $output->writeln(''); - $output->writeln('Summary:'); - - if ($hasExecutionFailure) { - $output->writeln('- dependency analysis could not be completed.'); - - return self::FAILURE; - } - - $output->writeln(\sprintf('- %d missing', \count($missingDependencies['dependencies']))); - $output->writeln(\sprintf('- %d unused', \count($unusedDependencies['dependencies']))); - - if ([] !== $missingDependencies['dependencies'] || [] !== $unusedDependencies['dependencies']) { - return self::FAILURE; - } - - return self::SUCCESS; - } - - /** - * Resolves missing runtime requirements for the command. - * - * @param string $composerJson absolute path to composer.json - * @param string $dependencyAnalyser absolute path to composer-dependency-analyser - * @param string $composerUnused absolute path to composer-unused - * - * @return list the user-facing requirement errors - */ - private function resolveMissingRequirements( - string $composerJson, - string $dependencyAnalyser, - string $composerUnused, - ): array { - $missing = []; - - if (! $this->filesystem->exists($composerJson)) { - $missing[] = '- composer.json not found in the current working directory.'; - } - - if (! $this->filesystem->exists($dependencyAnalyser)) { - $missing[] = '- vendor/bin/composer-dependency-analyser not found. Reinstall fast-forward/dev-tools dependencies.'; - } - - if (! $this->filesystem->exists($composerUnused)) { - $missing[] = '- vendor/bin/composer-unused not found. Reinstall fast-forward/dev-tools dependencies.'; - } - - return $missing; - } - - /** - * Executes the missing dependency analyzer and normalizes its result. - * - * @param string $composerJson absolute path to composer.json - * @param string $dependencyAnalyser absolute path to composer-dependency-analyser - * - * @return array{dependencies:list, examples:array, rawOutput:string, executionFailed:bool} - */ - private function analyzeMissingDependencies(string $composerJson, string $dependencyAnalyser): array - { - $result = $this->runDependencyProcess([ - $dependencyAnalyser, + $results[] = $this->runProcess(new Process([ + 'vendor/bin/composer-dependency-analyser', '--composer-json=' . $composerJson, - '--format=junit', '--ignore-unused-deps', - '--ignore-dev-in-prod-deps', '--ignore-prod-only-in-dev-deps', - '--ignore-unknown-classes', - '--ignore-unknown-functions', - ]); - - $normalized = $this->parseMissingDependencies($result['output']); - - if (null === $normalized) { - return [ - 'dependencies' => [], - 'examples' => [], - 'rawOutput' => $result['output'], - 'executionFailed' => true, - ]; - } - - if (self::SUCCESS !== $result['exitCode'] && [] === $normalized['dependencies']) { - return [ - 'dependencies' => [], - 'examples' => [], - 'rawOutput' => $result['output'], - 'executionFailed' => true, - ]; - } - - return [ - 'dependencies' => $normalized['dependencies'], - 'examples' => $normalized['examples'], - 'rawOutput' => $result['output'], - 'executionFailed' => false, - ]; - } - - /** - * Executes composer-unused and normalizes its result. - * - * @param string $composerJson absolute path to composer.json - * @param string $composerUnused absolute path to composer-unused - * - * @return array{dependencies:list, rawOutput:string, executionFailed:bool} - */ - private function analyzeUnusedDependencies(string $composerJson, string $composerUnused): array - { - $result = $this->runDependencyProcess([ - $composerUnused, - $composerJson, - '--output-format=json', - '--no-progress', - ]); - - $dependencies = $this->parseUnusedDependencies($result['output']); - - if (null === $dependencies) { - return [ - 'dependencies' => [], - 'rawOutput' => $result['output'], - 'executionFailed' => true, - ]; - } - - if (self::SUCCESS !== $result['exitCode'] && [] === $dependencies) { - return [ - 'dependencies' => [], - 'rawOutput' => $result['output'], - 'executionFailed' => true, - ]; - } - - return [ - 'dependencies' => $dependencies, - 'rawOutput' => $result['output'], - 'executionFailed' => false, - ]; - } - - /** - * Renders the normalized missing dependency section. - * - * @param OutputInterface $output the console output stream - * @param array{dependencies:list, examples:array, rawOutput:string, executionFailed:bool} $analysis - * - * @return bool true when the analyzer failed operationally - */ - private function renderMissingDependenciesSection(OutputInterface $output, array $analysis): bool - { - $output->writeln('[Missing Dependencies]'); - - if ($analysis['executionFailed']) { - $output->writeln('composer-dependency-analyser did not return a readable report.'); - $this->renderRawOutput($output, $analysis['rawOutput']); - - return true; - } - - if ([] === $analysis['dependencies']) { - $output->writeln('None detected.'); - - return false; - } - - foreach ($analysis['dependencies'] as $dependency) { - $line = '- ' . $dependency; - - if (\array_key_exists($dependency, $analysis['examples'])) { - $line .= ' (' . $analysis['examples'][$dependency] . ')'; - } - - $output->writeln($line); - } - - return false; - } - - /** - * Renders the normalized unused dependency section. - * - * @param OutputInterface $output the console output stream - * @param array{dependencies:list, rawOutput:string, executionFailed:bool} $analysis - * - * @return bool true when the analyzer failed operationally - */ - private function renderUnusedDependenciesSection(OutputInterface $output, array $analysis): bool - { - $output->writeln('[Unused Dependencies]'); - - if ($analysis['executionFailed']) { - $output->writeln('composer-unused did not return a readable report.'); - $this->renderRawOutput($output, $analysis['rawOutput']); - - return true; - } - - if ([] === $analysis['dependencies']) { - $output->writeln('None detected.'); - - return false; - } - - foreach ($analysis['dependencies'] as $dependency) { - $output->writeln('- ' . $dependency); - } - - return false; - } - - /** - * Prints the raw analyzer output when normalization is not possible. - * - * @param OutputInterface $output the console output stream - * @param string $rawOutput the raw analyzer output - * - * @return void - */ - private function renderRawOutput(OutputInterface $output, string $rawOutput): void - { - $trimmedOutput = trim($rawOutput); - - if ('' === $trimmedOutput) { - $output->writeln('No analyzer output was captured.'); - - return; - } - - $output->writeln($trimmedOutput); - } - - /** - * Parses the shadow dependency suite from the analyzer JUnit output. - * - * @param string $xml the raw JUnit XML payload - * - * @return array{dependencies:list, examples:array}|null - */ - private function parseMissingDependencies(string $xml): ?array - { - $trimmedXml = trim($xml); - - if ('' === $trimmedXml || ! \extension_loaded('dom') || ! \extension_loaded('libxml')) { - return null; - } - - $internalErrors = libxml_use_internal_errors(true); - - $document = new DOMDocument(); - $loaded = $document->loadXML($trimmedXml); - - libxml_clear_errors(); - libxml_use_internal_errors($internalErrors); - - if (! $loaded) { - return null; - } - - $dependencies = []; - $examples = []; - - foreach ($document->getElementsByTagName('testsuite') as $suite) { - if ('shadow dependencies' !== $suite->getAttribute('name')) { - continue; - } - - foreach ($suite->getElementsByTagName('testcase') as $testCase) { - $dependency = trim($testCase->getAttribute('name')); - - if ('' === $dependency) { - continue; - } - - $dependencies[] = $dependency; - - foreach ($testCase->getElementsByTagName('failure') as $failure) { - $example = trim((string) $failure->nodeValue); - - if ('' !== $example) { - $examples[$dependency] = $example; - - break; - } - } - } - } - - return [ - 'dependencies' => array_values($dependencies), - 'examples' => $examples, - ]; - } - - /** - * Parses the composer-unused JSON output. - * - * @param string $json the raw JSON payload - * - * @return list|null - */ - private function parseUnusedDependencies(string $json): ?array - { - $trimmedJson = trim($json); - - if ('' === $trimmedJson) { - return null; - } - - try { - $decoded = json_decode($trimmedJson, true); - } catch (JsonException) { - return null; - } - - if (! \is_array($decoded) || ! \array_key_exists('unused-packages', $decoded) || ! \is_array( - $decoded['unused-packages'] - )) { - return null; - } - - $dependencies = []; - - foreach ($decoded['unused-packages'] as $dependency) { - if (\is_string($dependency) && '' !== $dependency) { - $dependencies[] = $dependency; - } - } - - return array_values($dependencies); - } - - /** - * Executes a dependency analyzer and captures its output for normalization. - * - * @param list $command the analyzer command to execute - * - * @return array{exitCode:int, output:string} - */ - private function runDependencyProcess(array $command): array - { - if ($this->processRunner instanceof Closure) { - return ($this->processRunner)($command); - } - - $process = new Process($command, $this->getCurrentWorkingDirectory(), [ - 'NO_COLOR' => '1', - ]); - $process->setTimeout(null); - - $buffer = ''; - - $process->run(static function (string $type, string $output) use (&$buffer): void { - $buffer .= $output; - }); + ]), $output); - return [ - 'exitCode' => $process->getExitCode() ?? self::FAILURE, - 'output' => trim($buffer), - ]; + return \in_array(self::FAILURE, $results, true) ? self::FAILURE : self::SUCCESS; } } diff --git a/tests/Command/DependenciesCommandTest.php b/tests/Command/DependenciesCommandTest.php index 8e68591..57976bd 100644 --- a/tests/Command/DependenciesCommandTest.php +++ b/tests/Command/DependenciesCommandTest.php @@ -21,47 +21,22 @@ 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 { /** - * @var list - */ - private array $queuedResults = []; - - /** - * @var list> - */ - private array $receivedCommands = []; - - private DependenciesCommand $dependenciesCommand; - - /** - * @return DependenciesCommand + * @return string */ - protected function getCommandClass(): DependenciesCommand + protected function getCommandClass(): string { - $this->queuedResults = []; - $this->receivedCommands = []; - $queuedResults = &$this->queuedResults; - $receivedCommands = &$this->receivedCommands; - - $this->dependenciesCommand = new DependenciesCommand( - $this->filesystem->reveal(), - static function (array $command) use (&$queuedResults, &$receivedCommands): array { - $receivedCommands[] = $command; - - return array_shift($queuedResults) ?? [ - 'exitCode' => DependenciesCommand::SUCCESS, - 'output' => '', - ]; - }, - ); - - return $this->dependenciesCommand; + return DependenciesCommand::class; } /** @@ -91,194 +66,175 @@ protected function getCommandHelp(): string /** * @return void */ - #[Test] - public function executeWillFailWhenRequiredFilesAreMissing(): void + protected function setUp(): void { - $cwd = getcwd(); + parent::setUp(); + $cwd = getcwd(); $this->filesystem->exists($cwd . '/composer.json')->willReturn(true); - $this->filesystem->exists($cwd . '/vendor/bin/composer-dependency-analyser')->willReturn(false); - $this->filesystem->exists($cwd . '/vendor/bin/composer-unused')->willReturn(false); + } - $this->output->writeln('Running dependency analysis...') + /** + * @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->output->writeln('Dependency analysis requires the following files:') + + $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( - '- vendor/bin/composer-dependency-analyser not found. Reinstall fast-forward/dev-tools dependencies.' - ) + + $this->output->writeln('Running dependency analysis...') ->shouldBeCalled(); $this->output->writeln( - '- vendor/bin/composer-unused not found. Reinstall fast-forward/dev-tools dependencies.' + 'Warning: TTY is not supported. The command may not display output as expected.' ) ->shouldBeCalled(); - self::assertSame(DependenciesCommand::FAILURE, $this->invokeExecute()); + self::assertSame(DependenciesCommand::SUCCESS, $this->invokeExecute()); } /** * @return void */ #[Test] - public function executeWillRenderNormalizedDependencyReport(): void + public function executeWillReturnFailureWhenFirstToolFails(): void { - $cwd = getcwd(); + $processUnused = $this->prophesize(Process::class); + $processUnused->isSuccessful() + ->willReturn(false); - $this->filesystem->exists($cwd . '/composer.json')->willReturn(true); - $this->filesystem->exists($cwd . '/vendor/bin/composer-dependency-analyser')->willReturn(true); - $this->filesystem->exists($cwd . '/vendor/bin/composer-unused')->willReturn(true); - - $this->queuedResults[] = [ - 'exitCode' => DependenciesCommand::FAILURE, - 'output' => <<<'XML' - - - - - src/Command/DependenciesCommand.php:42 - - - - XML, - ]; - $this->queuedResults[] = [ - 'exitCode' => DependenciesCommand::FAILURE, - 'output' => <<<'JSON' - { - "unused-packages": [ - "monolog/monolog" - ] - } - JSON, - ]; + $processDepAnalyser = $this->prophesize(Process::class); + $processDepAnalyser->isSuccessful() + ->willReturn(true); - $this->output->writeln('Running dependency analysis...') - ->shouldBeCalled(); - $this->output->writeln('Dependency Analysis Report') - ->shouldBeCalled(); - $this->output->writeln('[Missing Dependencies]') - ->shouldBeCalled(); - $this->output->writeln('- symfony/console (src/Command/DependenciesCommand.php:42)') - ->shouldBeCalled(); - $this->output->writeln('[Unused Dependencies]') - ->shouldBeCalled(); - $this->output->writeln('- monolog/monolog') + $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->output->writeln('Summary:') + + $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('- 1 missing') + + $this->output->writeln('Running dependency analysis...') ->shouldBeCalled(); - $this->output->writeln('- 1 unused') + $this->output->writeln( + 'Warning: TTY is not supported. The command may not display output as expected.' + ) ->shouldBeCalled(); - $this->output->writeln('') - ->shouldBeCalledTimes(4); self::assertSame(DependenciesCommand::FAILURE, $this->invokeExecute()); - self::assertSame([ - [ - $cwd . '/vendor/bin/composer-dependency-analyser', - '--composer-json=' . $cwd . '/composer.json', - '--format=junit', - '--ignore-unused-deps', - '--ignore-dev-in-prod-deps', - '--ignore-prod-only-in-dev-deps', - '--ignore-unknown-classes', - '--ignore-unknown-functions', - ], - [ - $cwd . '/vendor/bin/composer-unused', - $cwd . '/composer.json', - '--output-format=json', - '--no-progress', - ], - ], $this->receivedCommands); } /** * @return void */ #[Test] - public function executeWillReturnSuccessWhenNoFindingsAreReported(): void + public function executeWillReturnFailureWhenSecondToolFails(): void { - $cwd = getcwd(); + $processUnused = $this->prophesize(Process::class); + $processUnused->isSuccessful() + ->willReturn(true); - $this->filesystem->exists($cwd . '/composer.json')->willReturn(true); - $this->filesystem->exists($cwd . '/vendor/bin/composer-dependency-analyser')->willReturn(true); - $this->filesystem->exists($cwd . '/vendor/bin/composer-unused')->willReturn(true); - - $this->queuedResults[] = [ - 'exitCode' => DependenciesCommand::SUCCESS, - 'output' => '', - ]; - $this->queuedResults[] = [ - 'exitCode' => DependenciesCommand::SUCCESS, - 'output' => '{"unused-packages":[]}', - ]; + $processDepAnalyser = $this->prophesize(Process::class); + $processDepAnalyser->isSuccessful() + ->willReturn(false); - $this->output->writeln('Running dependency analysis...') - ->shouldBeCalled(); - $this->output->writeln('Dependency Analysis Report') + $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->output->writeln('[Missing Dependencies]') - ->shouldBeCalled(); - $this->output->writeln('None detected.') - ->shouldBeCalledTimes(2); - $this->output->writeln('[Unused Dependencies]') - ->shouldBeCalled(); - $this->output->writeln('Summary:') + + $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('- 0 missing') + + $this->output->writeln('Running dependency analysis...') ->shouldBeCalled(); - $this->output->writeln('- 0 unused') + $this->output->writeln( + 'Warning: TTY is not supported. The command may not display output as expected.' + ) ->shouldBeCalled(); - $this->output->writeln('') - ->shouldBeCalledTimes(4); - self::assertSame(DependenciesCommand::SUCCESS, $this->invokeExecute()); + self::assertSame(DependenciesCommand::FAILURE, $this->invokeExecute()); } /** * @return void */ #[Test] - public function executeWillReportUnreadableAnalyzerOutputAsFailure(): void + public function executeWillCallBothDependencyToolsWithComposerJson(): void { $cwd = getcwd(); + $composerJsonPath = $cwd . '/composer.json'; - $this->filesystem->exists($cwd . '/composer.json')->willReturn(true); - $this->filesystem->exists($cwd . '/vendor/bin/composer-dependency-analyser')->willReturn(true); - $this->filesystem->exists($cwd . '/vendor/bin/composer-unused')->willReturn(true); - - $this->queuedResults[] = [ - 'exitCode' => DependenciesCommand::FAILURE, - 'output' => 'unexpected failure', - ]; - $this->queuedResults[] = [ - 'exitCode' => DependenciesCommand::SUCCESS, - 'output' => '{"unused-packages":[]}', - ]; + $this->filesystem->exists($composerJsonPath) + ->willReturn(true); - $this->output->writeln('Running dependency analysis...') - ->shouldBeCalled(); - $this->output->writeln('Dependency Analysis Report') - ->shouldBeCalled(); - $this->output->writeln('[Missing Dependencies]') - ->shouldBeCalled(); - $this->output->writeln('composer-dependency-analyser did not return a readable report.') - ->shouldBeCalled(); - $this->output->writeln('unexpected failure') - ->shouldBeCalled(); - $this->output->writeln('[Unused Dependencies]') + $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->output->writeln('None detected.') + + $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('Summary:') + + $this->output->writeln('Running dependency analysis...') ->shouldBeCalled(); - $this->output->writeln('- dependency analysis could not be completed.') + $this->output->writeln( + 'Warning: TTY is not supported. The command may not display output as expected.' + ) ->shouldBeCalled(); - $this->output->writeln('') - ->shouldBeCalledTimes(4); - self::assertSame(DependenciesCommand::FAILURE, $this->invokeExecute()); + self::assertSame(DependenciesCommand::SUCCESS, $this->invokeExecute()); } } From 8b3db07c69fc37c6fa65a308172394b38c03c6e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 9 Apr 2026 23:09:04 -0300 Subject: [PATCH 4/4] fix: Update command for reporting missing and unused Composer dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felipe Sayão Lobato Abreu --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3fd9c35..5f37de0 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ automation assets. |---------|---------| | `composer dev-tools` | Runs the full `standards` pipeline. | | `composer dev-tools tests` | Runs PHPUnit with local-or-packaged configuration. | -| `composer dependencies` | Reports missing and unused Composer dependencies. | +| `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. |