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