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