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