Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.30% covered (warning)
81.30%
100 / 123
54.55% covered (warning)
54.55%
6 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
DependenciesCommand
81.30% covered (warning)
81.30%
100 / 123
54.55% covered (warning)
54.55%
6 / 11
29.09
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 configure
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
1
 execute
72.50% covered (warning)
72.50%
29 / 40
0.00% covered (danger)
0.00%
0 / 1
5.52
 getComposerDependencyAnalyserCommand
80.00% covered (warning)
80.00%
12 / 15
0.00% covered (danger)
0.00%
0 / 1
4.13
 getSwissKnifeBreakpointCommand
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getOpenVersionsCommand
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getRaiseToInstalledCommand
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getComposerUpdateCommand
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getComposerNormalizeCommand
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 resolveMaximumOutdated
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 shouldIgnoreOutdatedFailures
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5/**
6 * Fast Forward Development Tools for PHP projects.
7 *
8 * This file is part of fast-forward/dev-tools project.
9 *
10 * @author   Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
11 * @license  https://opensource.org/licenses/MIT MIT License
12 *
13 * @see      https://github.com/php-fast-forward/
14 * @see      https://github.com/php-fast-forward/dev-tools
15 * @see      https://github.com/php-fast-forward/dev-tools/issues
16 * @see      https://php-fast-forward.github.io/dev-tools/
17 * @see      https://datatracker.ietf.org/doc/html/rfc2119
18 */
19
20namespace FastForward\DevTools\Console\Command;
21
22use FastForward\DevTools\Console\Command\Traits\LogsCommandResults;
23use FastForward\DevTools\Console\Input\HasJsonOption;
24use FastForward\DevTools\Config\ComposerDependencyAnalyserConfig;
25use FastForward\DevTools\Path\DevToolsPathResolver;
26use FastForward\DevTools\Process\ProcessBuilderInterface;
27use FastForward\DevTools\Process\ProcessQueueInterface;
28use InvalidArgumentException;
29use Symfony\Component\Config\FileLocatorInterface;
30use Symfony\Component\Console\Attribute\AsCommand;
31use Symfony\Component\Console\Command\Command;
32use Symfony\Component\Console\Input\InputInterface;
33use Symfony\Component\Console\Input\InputOption;
34use Symfony\Component\Console\Output\BufferedOutput;
35use Symfony\Component\Console\Output\OutputInterface;
36use Symfony\Component\Process\Process;
37use function is_numeric;
38
39/**
40 * Orchestrates dependency analysis across the supported Composer analyzers.
41 * This command MUST report missing, unused, and misplaced dependencies using a single,
42 * deterministic report that is friendly for local development and CI runs.
43 */
44#[AsCommand(
45    name: 'dev-tools:deps',
46    description: 'Analyzes missing, unused, misplaced, and outdated Composer dependencies.',
47    aliases: ['deps', 'dependencies']
48)]
49 class DependenciesCommand extends Command
50{
51    use HasJsonOption;
52    use LogsCommandResults;
53
54    private const string ANALYSER_CONFIG = 'composer-dependency-analyser.php';
55
56    private const int DISABLE_OUTDATED_THRESHOLD = -1;
57
58    /**
59     * @param ProcessBuilderInterface $processBuilder creates analyzer and upgrade processes
60     * @param ProcessQueueInterface $processQueue executes queued processes
61     * @param FileLocatorInterface $fileLocator resolves the dependency analyser configuration
62     */
63    public function __construct(
64        private  ProcessBuilderInterface $processBuilder,
65        private  ProcessQueueInterface $processQueue,
66        private  FileLocatorInterface $fileLocator,
67    ) {
68        return parent::__construct();
69    }
70
71    /**
72     * Configures the dependency workflow options.
73     */
74    protected function configure(): void
75    {
76        $this->setHelp(
77            'This command runs composer-dependency-analyser and Rector Swiss Knife to report missing, unused, misplaced, and'
78            . ' outdated Composer dependencies.'
79        );
80
81        $this->addJsonOption()
82            ->addOption(
83                name: 'max-outdated',
84                mode: InputOption::VALUE_REQUIRED,
85                description: 'Maximum number of outdated packages allowed by swiss-knife breakpoint. Use -1 to keep the report but ignore Rector Swiss Knife failures.',
86                default: '5',
87            )
88            ->addOption(
89                name: 'dev',
90                mode: InputOption::VALUE_NONE,
91                description: 'Prioritize dev dependencies where Rector Swiss Knife supports it.',
92            )
93            ->addOption(
94                name: 'upgrade',
95                mode: InputOption::VALUE_NONE,
96                description: 'Apply Rector Swiss Knife dependency upgrades before executing the dependency analyzers.',
97            )
98            ->addOption(
99                name: 'dump-usage',
100                mode: InputOption::VALUE_REQUIRED,
101                description: 'Dump usages for the given package pattern and show all matched usages.',
102            )
103            ->addOption(
104                name: 'show-shadow-dependencies',
105                mode: InputOption::VALUE_NONE,
106                description: 'Report shadow dependencies instead of applying Fast Forward intentional-shadow ignores.',
107            );
108    }
109
110    /**
111     * Executes the dependency analysis workflow.
112     *
113     * @param InputInterface $input the runtime command input
114     * @param OutputInterface $output the console output stream
115     *
116     * @return int the command execution status code
117     */
118    protected function execute(InputInterface $input, OutputInterface $output): int
119    {
120        $jsonOutput = $this->isJsonOutput($input);
121        $processOutput = $jsonOutput ? new BufferedOutput() : $output;
122
123        try {
124            $maximumOutdated = $this->resolveMaximumOutdated($input);
125        } catch (InvalidArgumentException $invalidArgumentException) {
126            return $this->failure($invalidArgumentException->getMessage(), $input);
127        }
128
129        $this->processQueue->add(
130            process: $this->getRaiseToInstalledCommand($input),
131            label: 'Raising Dependency Constraints with Rector Swiss Knife',
132        );
133        $this->processQueue->add(
134            process: $this->getOpenVersionsCommand($input),
135            label: 'Opening Dependency Constraints with Rector Swiss Knife',
136        );
137
138        if ($input->getOption('upgrade')) {
139            $this->processQueue->add(
140                process: $this->getComposerUpdateCommand(),
141                label: 'Updating Dependencies with Composer'
142            );
143            $this->processQueue->add(
144                process: $this->getComposerNormalizeCommand(),
145                label: 'Normalizing composer.json with Composer Normalize',
146            );
147        }
148
149        $this->log('Running dependency analysis...', $input);
150
151        $this->processQueue->add(
152            process: $this->getComposerDependencyAnalyserCommand($input),
153            label: 'Analyzing Dependencies with Composer Dependency Analyser',
154        );
155        $this->processQueue->add(
156            process: $this->getSwissKnifeBreakpointCommand($input, $maximumOutdated),
157            ignoreFailure: $this->shouldIgnoreOutdatedFailures($maximumOutdated),
158            label: 'Checking Outdated Dependencies with Rector Swiss Knife',
159        );
160
161        $result = $this->processQueue->run($processOutput);
162
163        if (self::SUCCESS === $result) {
164            return $this->success('Dependency analysis completed successfully.', $input, [
165                'output' => $processOutput,
166            ]);
167        }
168
169        return $this->failure('Dependency analysis failed.', $input, [
170            'output' => $processOutput,
171        ]);
172    }
173
174    /**
175     * Builds the Composer Dependency Analyser process.
176     *
177     * @param InputInterface $input the runtime command input
178     *
179     * @return Process the configured Composer Dependency Analyser process
180     */
181    private function getComposerDependencyAnalyserCommand(InputInterface $input): Process
182    {
183        $processBuilder = $this->processBuilder
184            ->withArgument('--config', $this->fileLocator->locate(self::ANALYSER_CONFIG));
185
186        $dumpUsage = $input->getOption('dump-usage');
187
188        if (\is_string($dumpUsage) && '' !== $dumpUsage) {
189            $processBuilder = $processBuilder
190                ->withArgument('--dump-usages', $dumpUsage)
191                ->withArgument('--show-all-usages');
192        }
193
194        $showShadowDependencies = (bool) $input->getOption('show-shadow-dependencies');
195        $process = $processBuilder->build(
196            [DevToolsPathResolver::getPreferredToolBinaryPath('composer-dependency-analyser')]
197        );
198        $process->setEnv([
199            ComposerDependencyAnalyserConfig::ENV_SHOW_SHADOW_DEPENDENCIES => $showShadowDependencies ? '1' : '0',
200        ]);
201
202        return $process;
203    }
204
205    /**
206     * Builds the Rector Swiss Knife breakpoint process.
207     *
208     * @param InputInterface $input the runtime command input
209     * @param int $maximumOutdated the maximum number of outdated packages accepted by Rector Swiss Knife
210     *
211     * @return Process the configured Rector Swiss Knife breakpoint process
212     */
213    private function getSwissKnifeBreakpointCommand(InputInterface $input, int $maximumOutdated): Process
214    {
215        $processBuilder = $this->processBuilder;
216
217        if ((bool) $input->getOption('dev')) {
218            $processBuilder = $processBuilder->withArgument('--dev');
219        }
220
221        if (! $this->shouldIgnoreOutdatedFailures($maximumOutdated)) {
222            $processBuilder = $processBuilder->withArgument('--limit', (string) $maximumOutdated);
223        }
224
225        return $processBuilder->build([DevToolsPathResolver::getPreferredToolBinaryPath('swiss-knife'), 'breakpoint']);
226    }
227
228    /**
229     * Builds the Rector Swiss Knife open-versions process.
230     *
231     * @param InputInterface $input the runtime command input
232     *
233     * @return Process the configured Rector Swiss Knife open-versions process
234     */
235    private function getOpenVersionsCommand(InputInterface $input): Process
236    {
237        $processBuilder = $this->processBuilder;
238
239        if ((bool) $input->getOption('dev')) {
240            $processBuilder = $processBuilder->withArgument('--dev');
241        }
242
243        if (! (bool) $input->getOption('upgrade')) {
244            $processBuilder = $processBuilder->withArgument('--dry-run');
245        }
246
247        return $processBuilder->build(
248            [DevToolsPathResolver::getPreferredToolBinaryPath('swiss-knife'), 'open-versions']
249        );
250    }
251
252    /**
253     * Builds the Rector Swiss Knife raise-to-installed process.
254     *
255     * @param InputInterface $input the runtime command input
256     *
257     * @return Process the configured Rector Swiss Knife raise-to-installed process
258     */
259    private function getRaiseToInstalledCommand(InputInterface $input): Process
260    {
261        $processBuilder = $this->processBuilder;
262
263        if (! (bool) $input->getOption('upgrade')) {
264            $processBuilder = $processBuilder->withArgument('--dry-run');
265        }
266
267        return $processBuilder->build(
268            [DevToolsPathResolver::getPreferredToolBinaryPath('swiss-knife'), 'raise-to-installed']
269        );
270    }
271
272    /**
273     * Builds the Composer update process.
274     *
275     * @return Process the configured Composer update process
276     */
277    private function getComposerUpdateCommand(): Process
278    {
279        return $this->processBuilder
280            ->withArgument('-W')
281            ->withArgument('--ansi')
282            ->withArgument('--no-progress')
283            ->build('composer update');
284    }
285
286    /**
287     * Builds the Composer Normalize process.
288     *
289     * @return Process the configured Composer Normalize process
290     */
291    private function getComposerNormalizeCommand(): Process
292    {
293        return $this->processBuilder
294            ->withArgument('--ansi')
295            ->build('composer normalize');
296    }
297
298    /**
299     * Resolves the maximum outdated dependency threshold.
300     *
301     * @param InputInterface $input the runtime command input
302     *
303     * @return int the validated maximum number of outdated packages
304     */
305    private function resolveMaximumOutdated(InputInterface $input): int
306    {
307        $maximumOutdated = $input->getOption('max-outdated');
308
309        if (! is_numeric($maximumOutdated)) {
310            throw new InvalidArgumentException('The --max-outdated option MUST be a numeric threshold.');
311        }
312
313        $maximumOutdated = (int) $maximumOutdated;
314
315        if (self::DISABLE_OUTDATED_THRESHOLD > $maximumOutdated) {
316            throw new InvalidArgumentException('The --max-outdated option MUST be -1 or greater.');
317        }
318
319        return $maximumOutdated;
320    }
321
322    /**
323     * Determines whether Rector Swiss Knife outdated failures SHOULD be ignored for the given threshold.
324     *
325     * @param int $maximumOutdated the validated outdated threshold option
326     *
327     * @return bool true when the outdated threshold is explicitly disabled
328     */
329    private function shouldIgnoreOutdatedFailures(int $maximumOutdated): bool
330    {
331        return self::DISABLE_OUTDATED_THRESHOLD === $maximumOutdated;
332    }
333}