Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
80.00% covered (warning)
80.00%
72 / 90
27.27% covered (danger)
27.27%
3 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
DependenciesCommand
80.00% covered (warning)
80.00%
72 / 90
27.27% covered (danger)
27.27%
3 / 11
31.41
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%
22 / 22
100.00% covered (success)
100.00%
1 / 1
1
 execute
81.48% covered (warning)
81.48%
22 / 27
0.00% covered (danger)
0.00%
0 / 1
6.23
 getComposerDependencyAnalyserCommand
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
3.47
 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 / 1
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 Composer\Command\BaseCommand;
24use FastForward\DevTools\Console\Input\HasJsonOption;
25use FastForward\DevTools\Process\ProcessBuilderInterface;
26use FastForward\DevTools\Process\ProcessQueueInterface;
27use InvalidArgumentException;
28use Psr\Log\LoggerInterface;
29use Symfony\Component\Config\FileLocatorInterface;
30use Symfony\Component\Console\Attribute\AsCommand;
31use Symfony\Component\Console\Input\InputInterface;
32use Symfony\Component\Console\Input\InputOption;
33use Symfony\Component\Console\Output\BufferedOutput;
34use Symfony\Component\Console\Output\OutputInterface;
35use Symfony\Component\Process\Process;
36
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: 'dependencies',
46    description: 'Analyzes missing, unused, misplaced, and outdated Composer dependencies.',
47    aliases: ['deps'],
48    help: 'This command runs composer-dependency-analyser and Jack to report missing, unused, misplaced, and outdated Composer dependencies.'
49)]
50 class DependenciesCommand extends BaseCommand implements LoggerAwareCommandInterface
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     * @param LoggerInterface $logger writes command feedback
64     */
65    public function __construct(
66        private  ProcessBuilderInterface $processBuilder,
67        private  ProcessQueueInterface $processQueue,
68        private  FileLocatorInterface $fileLocator,
69        private  LoggerInterface $logger,
70    ) {
71        return parent::__construct();
72    }
73
74    /**
75     * Configures the dependency workflow options.
76     */
77    protected function configure(): void
78    {
79        $this->addJsonOption()
80            ->addOption(
81                name: 'max-outdated',
82                mode: InputOption::VALUE_REQUIRED,
83                description: 'Maximum number of outdated packages allowed by jack breakpoint. Use -1 to keep the report but ignore Jack failures.',
84                default: '5',
85            )
86            ->addOption(
87                name: 'dev',
88                mode: InputOption::VALUE_NONE,
89                description: 'Prioritize dev dependencies where Jack supports it.',
90            )
91            ->addOption(
92                name: 'upgrade',
93                mode: InputOption::VALUE_NONE,
94                description: 'Apply Jack dependency upgrades before executing the dependency analyzers.',
95            )
96            ->addOption(
97                name: 'dump-usage',
98                mode: InputOption::VALUE_REQUIRED,
99                description: 'Dump usages for the given package pattern and show all matched usages.',
100            );
101    }
102
103    /**
104     * Executes the dependency analysis workflow.
105     *
106     * @param InputInterface $input the runtime command input
107     * @param OutputInterface $output the console output stream
108     *
109     * @return int the command execution status code
110     */
111    protected function execute(InputInterface $input, OutputInterface $output): int
112    {
113        $jsonOutput = $this->isJsonOutput($input);
114        $processOutput = $jsonOutput ? new BufferedOutput() : $output;
115
116        try {
117            $maximumOutdated = $this->resolveMaximumOutdated($input);
118        } catch (InvalidArgumentException $invalidArgumentException) {
119            return $this->failure($invalidArgumentException->getMessage(), $input);
120        }
121
122        $this->processQueue->add($this->getRaiseToInstalledCommand($input));
123        $this->processQueue->add($this->getOpenVersionsCommand($input));
124
125        if ($input->getOption('upgrade')) {
126            $this->processQueue->add($this->getComposerUpdateCommand());
127            $this->processQueue->add($this->getComposerNormalizeCommand());
128        }
129
130        if (! $jsonOutput) {
131            $this->logger->info('Running dependency analysis...', [
132                'input' => $input,
133            ]);
134        }
135
136        $this->processQueue->add($this->getComposerDependencyAnalyserCommand($input));
137        $this->processQueue->add(
138            $this->getJackBreakpointCommand($input, $maximumOutdated),
139            $this->shouldIgnoreOutdatedFailures($maximumOutdated),
140        );
141
142        $result = $this->processQueue->run($processOutput);
143
144        if (self::SUCCESS === $result) {
145            return $this->success('Dependency analysis completed successfully.', $input, [
146                'output' => $processOutput,
147            ]);
148        }
149
150        return $this->failure('Dependency analysis failed.', $input, [
151            'output' => $processOutput,
152        ]);
153    }
154
155    /**
156     * Builds the Composer Dependency Analyser process.
157     *
158     * @param InputInterface $input the runtime command input
159     *
160     * @return Process the configured Composer Dependency Analyser process
161     */
162    private function getComposerDependencyAnalyserCommand(InputInterface $input): Process
163    {
164        $processBuilder = $this->processBuilder
165            ->withArgument('--config', $this->fileLocator->locate(self::ANALYSER_CONFIG));
166
167        $dumpUsage = $input->getOption('dump-usage');
168
169        if (\is_string($dumpUsage) && '' !== $dumpUsage) {
170            $processBuilder = $processBuilder
171                ->withArgument('--dump-usages', $dumpUsage)
172                ->withArgument('--show-all-usages');
173        }
174
175        return $processBuilder->build('vendor/bin/composer-dependency-analyser');
176    }
177
178    /**
179     * Builds the Jack breakpoint process.
180     *
181     * @param InputInterface $input the runtime command input
182     * @param int $maximumOutdated the maximum number of outdated packages accepted by Jack
183     *
184     * @return Process the configured Jack breakpoint process
185     */
186    private function getJackBreakpointCommand(InputInterface $input, int $maximumOutdated): Process
187    {
188        $command = 'vendor/bin/jack breakpoint';
189
190        if ((bool) $input->getOption('dev')) {
191            $command .= ' --dev';
192        }
193
194        if (! $this->shouldIgnoreOutdatedFailures($maximumOutdated)) {
195            $command .= ' --limit ' . $maximumOutdated;
196        }
197
198        return $this->processBuilder->build($command);
199    }
200
201    /**
202     * Builds the Jack open-versions process.
203     *
204     * @param InputInterface $input the runtime command input
205     *
206     * @return Process the configured Jack open-versions process
207     */
208    private function getOpenVersionsCommand(InputInterface $input): Process
209    {
210        $command = 'vendor/bin/jack open-versions';
211
212        if ((bool) $input->getOption('dev')) {
213            $command .= ' --dev';
214        }
215
216        if (! (bool) $input->getOption('upgrade')) {
217            $command .= ' --dry-run';
218        }
219
220        return $this->processBuilder->build($command);
221    }
222
223    /**
224     * Builds the Jack raise-to-installed process.
225     *
226     * @param InputInterface $input the runtime command input
227     *
228     * @return Process the configured Jack raise-to-installed process
229     */
230    private function getRaiseToInstalledCommand(InputInterface $input): Process
231    {
232        $command = 'vendor/bin/jack raise-to-installed';
233
234        if ((bool) $input->getOption('dev')) {
235            $command .= ' --dev';
236        }
237
238        if (! (bool) $input->getOption('upgrade')) {
239            $command .= ' --dry-run';
240        }
241
242        return $this->processBuilder->build($command);
243    }
244
245    /**
246     * Builds the Composer update process.
247     *
248     * @return Process the configured Composer update process
249     */
250    private function getComposerUpdateCommand(): Process
251    {
252        return $this->processBuilder
253            ->withArgument('-W')
254            ->withArgument('--ansi')
255            ->withArgument('--no-progress')
256            ->build('composer update');
257    }
258
259    /**
260     * Builds the Composer Normalize process.
261     *
262     * @return Process the configured Composer Normalize process
263     */
264    private function getComposerNormalizeCommand(): Process
265    {
266        return $this->processBuilder->build('composer normalize');
267    }
268
269    /**
270     * Resolves the maximum outdated dependency threshold.
271     *
272     * @param InputInterface $input the runtime command input
273     *
274     * @return int the validated maximum number of outdated packages
275     */
276    private function resolveMaximumOutdated(InputInterface $input): int
277    {
278        $maximumOutdated = $input->getOption('max-outdated');
279
280        if (! is_numeric($maximumOutdated)) {
281            throw new InvalidArgumentException('The --max-outdated option MUST be a numeric threshold.');
282        }
283
284        $maximumOutdated = (int) $maximumOutdated;
285
286        if (self::DISABLE_OUTDATED_THRESHOLD > $maximumOutdated) {
287            throw new InvalidArgumentException('The --max-outdated option MUST be -1 or greater.');
288        }
289
290        return $maximumOutdated;
291    }
292
293    /**
294     * Determines whether Jack outdated failures SHOULD be ignored for the given threshold.
295     *
296     * @param int $maximumOutdated the validated outdated threshold option
297     *
298     * @return bool true when the outdated threshold is explicitly disabled
299     */
300    private function shouldIgnoreOutdatedFailures(int $maximumOutdated): bool
301    {
302        return self::DISABLE_OUTDATED_THRESHOLD === $maximumOutdated;
303    }
304}