Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
60.29% covered (warning)
60.29%
126 / 209
30.00% covered (danger)
30.00%
3 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
InstalledJson
60.29% covered (warning)
60.29%
126 / 209
30.00% covered (danger)
30.00%
3 / 10
260.49
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 copyInstalledJson
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 getJsonFile
66.67% covered (warning)
66.67%
10 / 15
0.00% covered (danger)
0.00%
0 / 1
2.15
 updatePackagePaths
36.84% covered (danger)
36.84%
7 / 19
0.00% covered (danger)
0.00%
0 / 1
11.30
 removeMissingAutoloadKeyPaths
37.93% covered (danger)
37.93%
22 / 58
0.00% covered (danger)
0.00%
0 / 1
105.32
 removeMovedPackagesAutoloadKeyFromVendorDirInstalledJson
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 removeMovedPackagesAutoloadKeyFromTargetDirInstalledJson
52.94% covered (warning)
52.94%
9 / 17
0.00% covered (danger)
0.00%
0 / 1
9.75
 updateNamespaces
75.68% covered (warning)
75.68%
28 / 37
0.00% covered (danger)
0.00%
0 / 1
18.24
 cleanTargetDirInstalledJson
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
3
 cleanupVendorInstalledJson
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Changes "install-path" to point to vendor-prefixed target directory.
4 *
5 * * create new vendor-prefixed/composer/installed.json file with copied packages
6 * * when delete is enabled, update package paths in the original vendor/composer/installed.json
7 * * when delete is enabled, remove dead entries in the original vendor/composer/installed.json
8 * * update psr-0 autoload keys to have matching classmap entries
9 *
10 * @see vendor/composer/installed.json
11 *
12 * TODO: when delete_vendor_files is used, the original directory still exists so the paths are not updated.
13 *
14 * @package brianhenryie/strauss
15 */
16
17namespace BrianHenryIE\Strauss\Pipeline\Cleanup;
18
19use BrianHenryIE\Strauss\Composer\ComposerPackage;
20use BrianHenryIE\Strauss\Config\CleanupConfigInterface;
21use BrianHenryIE\Strauss\Helpers\FileSystem;
22use BrianHenryIE\Strauss\Types\DiscoveredSymbols;
23use Composer\Json\JsonFile;
24use Composer\Json\JsonValidationException;
25use Psr\Log\LoggerAwareTrait;
26use Psr\Log\LoggerInterface;
27use Seld\JsonLint\ParsingException;
28
29/**
30 * @phpstan-type InstalledJsonPackageSourceArray array{type:string, url:string, reference:string}
31 * @phpstan-type InstalledJsonPackageDistArray array{type:string, url:string, reference:string, shasum:string}
32 * @phpstan-type InstalledJsonPackageAutoloadArray array<string,array<string,string>>
33 * @phpstan-type InstalledJsonPackageAuthorArray array{name:string,email:string}
34 * @phpstan-type InstalledJsonPackageSupportArray array{issues:string, source:string}
35 *
36 * @phpstan-type InstalledJsonPackageArray array{name:string, version:string, version_normalized:string, source:InstalledJsonPackageSourceArray, dist:InstalledJsonPackageDistArray, require:array<string,string>, require-dev:array<string,string>, time:string, type:string, installation-source:string, autoload:InstalledJsonPackageAutoloadArray, notification-url:string, license:array<string>, authors:array<InstalledJsonPackageAuthorArray>, description:string, homepage:string, keywords:array<string>, support:InstalledJsonPackageSupportArray, install-path:string}
37 *
38 * @phpstan-type InstalledJsonArray array{packages:array<InstalledJsonPackageArray>, dev:bool, dev-package-names:array<string>}
39 */
40class InstalledJson
41{
42    use LoggerAwareTrait;
43
44    protected CleanupConfigInterface $config;
45
46    protected FileSystem $filesystem;
47
48    public function __construct(
49        CleanupConfigInterface $config,
50        FileSystem $filesystem,
51        LoggerInterface $logger
52    ) {
53        $this->config = $config;
54        $this->filesystem = $filesystem;
55
56        $this->setLogger($logger);
57    }
58
59    public function copyInstalledJson(): void
60    {
61        $source = $this->config->getVendorDirectory() . 'composer/installed.json';
62        $target = $this->config->getTargetDirectory() . 'composer/installed.json';
63
64        $this->logger->info('Copying {sourcePath} to {targetPath}', [
65            'sourcePath' => $source,
66            'targetPath' => $target
67        ]);
68
69        $this->filesystem->copy(
70            $source,
71            $target
72        );
73
74        $this->logger->info('Copied {sourcePath} to {targetPath}', [
75            'sourcePath' => $source,
76            'targetPath' => $target
77        ]);
78
79        $this->logger->debug($this->filesystem->read($this->config->getTargetDirectory() . 'composer/installed.json'));
80    }
81
82    /**
83     * @throws JsonValidationException
84     * @throws ParsingException
85     */
86    protected function getJsonFile(string $vendorDir): JsonFile
87    {
88        $installedJsonFile = new JsonFile(
89            sprintf(
90                '%scomposer/installed.json',
91                $vendorDir
92            )
93        );
94        if (!$installedJsonFile->exists()) {
95            $this->logger->error(
96                'Expected {installedJsonFilePath} does not exist.',
97                ['installedJsonFilePath' => $installedJsonFile->getPath()]
98            );
99            throw new \Exception('Expected vendor/composer/installed.json does not exist.');
100        }
101
102        $installedJsonFile->validateSchema(JsonFile::LAX_SCHEMA);
103
104        $this->logger->info('Loaded file: {installedJsonFilePath}', ['installedJsonFilePath' => $installedJsonFile->getPath()]);
105
106        return $installedJsonFile;
107    }
108
109    /**
110     * @param InstalledJsonArray $installedJsonArray
111     * @param array<string,ComposerPackage> $flatDependencyTree
112     */
113    protected function updatePackagePaths(array $installedJsonArray, array $flatDependencyTree, string $path): array
114    {
115
116        foreach ($installedJsonArray['packages'] as $key => $package) {
117            // Skip packages that were never copied in the first place.
118            if (!in_array($package['name'], array_keys($flatDependencyTree))) {
119                $this->logger->debug('Skipping package: ' . $package['name']);
120                continue;
121            }
122            $this->logger->info('Checking package: ' . $package['name']);
123
124            // `composer/` is here because the install-path is relative to the `vendor/composer` directory.
125            $packageDir = $path . 'composer/' . $package['install-path'] . '/';
126            if (!$this->filesystem->directoryExists($packageDir)) {
127                $this->logger->debug('Package directory does not exist at : ' . $packageDir);
128
129                $newInstallPath = $path . str_replace('../', '', $package['install-path']);
130
131                if (!$this->filesystem->directoryExists($newInstallPath)) {
132                    $this->logger->warning('Package directory unexpectedly DOES NOT exist: ' . $newInstallPath);
133                    continue;
134                }
135
136                $newRelativePath = $this->filesystem->getRelativePath(
137                    $path . 'composer/',
138                    $newInstallPath
139                );
140
141                $installedJsonArray['packages'][$key]['install-path'] = $newRelativePath;
142            } else {
143                $this->logger->debug('Original package directory exists at : ' . $packageDir);
144            }
145        }
146        return $installedJsonArray;
147    }
148
149    /**
150     * Remove autoload key entries from `installed.json` whose file or directory does not exist after deleting.
151     */
152    protected function removeMissingAutoloadKeyPaths(array $installedJsonArray, string $vendorDir, string $installedJsonPath): array
153    {
154        foreach ($installedJsonArray['packages'] as $packageIndex => $packageArray) {
155            if (!isset($packageArray['autoload'])) {
156                $this->logger->info(
157                    'Package {packageName} has no autoload key in {installedJsonPath}',
158                    ['packageName' => $packageArray['name'],'installedJsonPath'=>$installedJsonPath]
159                );
160                continue;
161            }
162            $path = $vendorDir . 'composer/' . $packageArray['install-path'];
163            $pathExists = $this->filesystem->directoryExists($path);
164            // delete_vendor_packages
165            if (!$pathExists) {
166                $this->logger->info(
167                    'Removing package autoload key from {installedJsonPath}: {packageName}',
168                    ['packageName' => $packageArray['name'],'installedJsonPath'=>$installedJsonPath]
169                );
170                $installedJsonArray['packages'][$packageIndex]['autoload'] = [];
171            }
172            // delete_vendor_files
173            foreach ($installedJsonArray['packages'][$packageIndex]['autoload'] as $type => $autoload) {
174                $pathExistsInPackage = function (string $vendorDir, array $packageArray, string $relativePath) {
175                    return $this->filesystem->exists(
176                        $vendorDir . 'composer/' . $packageArray['install-path'] . '/' . $relativePath
177                    );
178                };
179
180                switch ($type) {
181                    case 'files':
182                    case 'classmap':
183                        $installedJsonArray['packages'][$packageIndex]['autoload'][$type] = array_filter(
184                            $installedJsonArray['packages'][$packageIndex]['autoload'][$type],
185                            fn(string $relativePath) => $pathExistsInPackage($vendorDir, $packageArray, $relativePath)
186                        );
187                        break;
188                    case 'psr-0':
189                    case 'psr-4':
190                        foreach ($autoload as $namespace => $paths) {
191                            switch (true) {
192                                case is_array($paths):
193                                    // e.g. [ 'psr-4' => [ 'BrianHenryIE\Project' => ['src','lib] ] ]
194                                    $validPaths = [];
195                                    foreach ($paths as $path) {
196                                        if ($pathExistsInPackage($vendorDir, $packageArray, $path)) {
197                                            $validPaths[] = $path;
198                                        } else {
199                                            $this->logger->debug('Removing non-existent path from autoload: ' . $path);
200                                        }
201                                    }
202                                    if (!empty($validPaths)) {
203                                        $installedJsonArray['packages'][$packageIndex]['autoload'][$type][$namespace] = $validPaths;
204                                    } else {
205                                        $this->logger->debug('Removing autoload key: ' . $type);
206                                        unset($installedJsonArray['packages'][$packageIndex]['autoload'][$type][$namespace]);
207                                    }
208                                    break;
209                                case is_string($paths):
210                                    // e.g. [ 'psr-4' => [ 'BrianHenryIE\Project' => 'src' ] ]
211                                    if (!$pathExistsInPackage($vendorDir, $packageArray, $paths)) {
212                                        $this->logger->debug('Removing autoload key: ' . $type . ' for ' . $paths);
213                                        unset($installedJsonArray['packages'][$packageIndex]['autoload'][$type][$namespace]);
214                                    }
215                                    break;
216                                default:
217                                    $this->logger->warning('Unexpectedly got neither a string nor array for autoload key in installed.json: ' . $type . ' ' . json_encode($paths));
218                                    break;
219                            }
220                        }
221                        break;
222                    case 'exclude-from-classmap':
223                        break;
224                    default:
225                        $this->logger->warning(
226                            'Unexpected autoload type in {installedJsonPath}: {type}',
227                            ['installedJsonPath'=>$installedJsonPath,'type'=>$type]
228                        );
229                        break;
230                }
231            }
232        }
233        return $installedJsonArray;
234    }
235
236    /**
237     * Remove the autoload key for packages from `installed.json` whose target directory does not exist after deleting.
238     *
239     * E.g. after the file is copied to the target directory, this will remove dev dependencies and unmodified dependencies from the second installed.json
240     *
241     * @param InstalledJsonArray $installedJsonArray
242     * @param array<string,ComposerPackage> $flatDependencyTree
243     */
244    protected function removeMovedPackagesAutoloadKeyFromVendorDirInstalledJson(array $installedJsonArray, array $flatDependencyTree, string $installedJsonPath): array
245    {
246        /**
247         * @var int $key
248         * @var InstalledJsonPackageArray $package
249         */
250        foreach ($installedJsonArray['packages'] as $key => $packageArray) {
251            $packageName = $packageArray['name'];
252            $package = $flatDependencyTree[$packageName] ?? null;
253            if (!$package) {
254                // Probably a dev dependency that we aren't tracking.
255                continue;
256            }
257
258            if ($package->didDelete()) {
259                $this->logger->info(
260                    'Removing deleted package autoload key from {installedJsonPath}: {packageName}',
261                    ['installedJsonPath' => $installedJsonPath, 'packageName' => $packageName]
262                );
263                $installedJsonArray['packages'][$key]['autoload'] = [];
264            }
265        }
266        return $installedJsonArray;
267    }
268
269    /**
270     * Remove the autoload key for packages from `vendor-prefixed/composer/installed.json` whose target directory does not exist in `vendor-prefixed`.
271     *
272     * E.g. after the file is copied to the target directory, this will remove dev dependencies and unmodified dependencies from the second installed.json
273     *
274     * @param InstalledJsonArray $installedJsonArray
275     * @param array<string,ComposerPackage> $flatDependencyTree
276     */
277    protected function removeMovedPackagesAutoloadKeyFromTargetDirInstalledJson(array $installedJsonArray, array $flatDependencyTree, string $installedJsonPath): array
278    {
279        /**
280         * @var int $key
281         * @var InstalledJsonPackageArray $package
282         */
283        foreach ($installedJsonArray['packages'] as $key => $packageArray) {
284            $packageName = $packageArray['name'];
285
286            $remove = false;
287
288            if (!in_array($packageName, array_keys($flatDependencyTree))) {
289                // If it's not a package we were ever considering copying, then we can remove it.
290                $remove = true;
291            } else {
292                $package = $flatDependencyTree[$packageName] ?? null;
293                if (!$package) {
294                    // Probably a dev dependency.
295                    continue;
296                }
297                if (!$package->didCopy()) {
298                    // If it was marked not to copy, then we know it's not in the vendor-prefixed directory, and we can remove it.
299                    $remove = true;
300                }
301            }
302
303            if ($remove) {
304                $this->logger->info(
305                    'Removing deleted package autoload key from {installedJsonPath}: {packageName}',
306                    ['installedJsonPath' => $installedJsonPath, 'packageName' => $packageName]
307                );
308                $installedJsonArray['packages'][$key]['autoload'] = [];
309            }
310        }
311        return $installedJsonArray;
312    }
313
314    protected function updateNamespaces(array $installedJsonArray, DiscoveredSymbols $discoveredSymbols): array
315    {
316        $discoveredNamespaces = $discoveredSymbols->getNamespaces();
317
318        foreach ($installedJsonArray['packages'] as $key => $package) {
319            if (!isset($package['autoload'])) {
320                // woocommerce/action-scheduler
321                $this->logger->debug('Package has no autoload key: ' . $package['name'] . ' ' . $package['type']);
322                continue;
323            }
324
325            $autoload_key = $package['autoload'];
326            if (!isset($autoload_key['classmap'])) {
327                $autoload_key['classmap'] = [];
328            }
329            foreach ($autoload_key as $type => $autoload) {
330                switch ($type) {
331                    case 'psr-0':
332                        foreach (array_values((array) $autoload_key['psr-0']) as $relativePath) {
333                            $packageRelativePath = $package['install-path'];
334                            if (1 === preg_match('#.*'.preg_quote($this->filesystem->normalize($this->config->getTargetDirectory()), '#').'/(.*)#', $packageRelativePath, $matches)) {
335                                $packageRelativePath = $matches[1];
336                            }
337                            if ($this->filesystem->directoryExists($this->config->getTargetDirectory() . 'composer/' . $packageRelativePath . $relativePath)) {
338                                $autoload_key['classmap'][] = $relativePath;
339                            }
340                        }
341                        // Intentionally fall through
342                        // Although the PSR-0 implementation here is a bit of a hack.
343                    case 'psr-4':
344                        /**
345                         * e.g.
346                         * * {"psr-4":{"Psr\\Log\\":"Psr\/Log\/"}}
347                         * * {"psr-4":{"":"src\/"}}
348                         * * {"psr-4":{"Symfony\\Polyfill\\Mbstring\\":""}}
349                         * * {"psr-0":{"PayPal":"lib\/"}}
350                         */
351                        foreach ($autoload_key[$type] as $originalNamespace => $packageRelativeDirectory) {
352                            // Replace $originalNamespace with updated namespace
353
354                            // Just for dev â€“ find a package like this and write a test for it.
355                            if (empty($originalNamespace)) {
356                                // In the case of `nesbot/carbon`, it uses an empty namespace but the classes are in the `Carbon`
357                                // namespace, so using `override_autoload` should be a good solution if this proves to be an issue.
358                                // The package directory will be updated, so for whatever reason the original empty namespace
359                                // works, maybe the updated namespace will work too.
360                                $this->logger->warning('Empty namespace found in autoload. Behaviour is not fully documented: ' . $package['name']);
361                                continue;
362                            }
363
364                            $trimmedOriginalNamespace = trim($originalNamespace, '\\');
365
366                            $this->logger->info('Checking '.$type.' namespace: ' . $trimmedOriginalNamespace);
367
368                            if (isset($discoveredNamespaces[$trimmedOriginalNamespace])) {
369                                $namespaceSymbol = $discoveredNamespaces[$trimmedOriginalNamespace];
370                            } else {
371                                $this->logger->debug('Namespace not found in list of changes: ' . $trimmedOriginalNamespace);
372                                continue;
373                            }
374
375                            if ($trimmedOriginalNamespace === trim($namespaceSymbol->getReplacement(), '\\')) {
376                                $this->logger->debug('Namespace is unchanged: ' . $trimmedOriginalNamespace);
377                                continue;
378                            }
379
380                            // Update the namespace if it has changed.
381                            $this->logger->info('Updating namespace: ' . $trimmedOriginalNamespace . ' => ' . $namespaceSymbol->getReplacement());
382                            $autoload_key[$type][str_replace($trimmedOriginalNamespace, $namespaceSymbol->getReplacement(), $originalNamespace)] = $autoload_key[$type][$originalNamespace];
383                            unset($autoload_key[$type][$originalNamespace]);
384
385//                            if (is_array($packageRelativeDirectory)) {
386//                                $autoload_key[$type][$originalNamespace] = array_filter(
387//                                    $packageRelativeDirectory,
388//                                    function ($dir) use ($packageDir) {
389//                                                $dir = $packageDir . $dir;
390//                                                $exists = $this->filesystem->directoryExists($dir) || $this->filesystem->fileExists($dir);
391//                                        if (!$exists) {
392//                                            $this->logger->info('Removing non-existent directory from autoload: ' . $dir);
393//                                        } else {
394//                                            $this->logger->debug('Keeping directory in autoload: ' . $dir);
395//                                        }
396//                                        return $exists;
397//                                    }
398//                                );
399//                            } else {
400//                                $dir = $packageDir . $packageRelativeDirectory;
401//                                if (! ($this->filesystem->directoryExists($dir) || $this->filesystem->fileExists($dir))) {
402//                                    $this->logger->info('Removing non-existent directory from autoload: ' . $dir);
403//                                    // /../../../vendor-prefixed/lib
404//                                    unset($autoload_key[$type][$originalNamespace]);
405//                                } else {
406//                                    $this->logger->debug('Keeping directory in autoload: ' . $dir);
407//                                }
408//                            }
409                        }
410                        break;
411                    default: // files, classmap, psr-0
412                        /**
413                         * E.g.
414                         *
415                         * * {"classmap":["src\/"]}
416                         * * {"files":["src\/functions.php"]}
417                         *
418                         * Also:
419                         * * {"exclude-from-classmap":["\/Tests\/"]}
420                         */
421
422//                        $autoload_key[$type] = array_filter($autoload, function ($file) use ($packageDir) {
423//                            $filename = $packageDir . '/' . $file;
424//                            $exists = $this->filesystem->directoryExists($filename) || $this->filesystem->fileExists($filename);
425//                            if (!$exists) {
426//                                $this->logger->info('Removing non-existent file from autoload: ' . $filename);
427//                            } else {
428//                                $this->logger->debug('Keeping file in autoload: ' . $filename);
429//                            }
430//                        });
431                        break;
432                }
433            }
434            $installedJsonArray['packages'][$key]['autoload'] = array_filter($autoload_key);
435        }
436
437        return $installedJsonArray;
438    }
439    /**
440     * @param array<string,ComposerPackage> $flatDependencyTree
441     * @param DiscoveredSymbols $discoveredSymbols
442     */
443    public function cleanTargetDirInstalledJson(array $flatDependencyTree, DiscoveredSymbols $discoveredSymbols): void
444    {
445        $targetDir = $this->config->getTargetDirectory();
446
447        $installedJsonFile = $this->getJsonFile($targetDir);
448
449        /**
450         * @var InstalledJsonArray $installedJsonArray
451         */
452        $installedJsonArray = $installedJsonFile->read();
453
454        $this->logger->debug(
455            '{installedJsonFilePath} before: {installedJsonArray}',
456            ['installedJsonFilePath' => $installedJsonFile, 'installedJsonArray' => json_encode($installedJsonArray)]
457        );
458
459        $installedJsonArray = $this->updatePackagePaths($installedJsonArray, $flatDependencyTree, $this->config->getTargetDirectory());
460
461        $installedJsonArray = $this->removeMissingAutoloadKeyPaths($installedJsonArray, $this->config->getTargetDirectory(), $installedJsonFile->getPath());
462
463        $installedJsonArray = $this->removeMovedPackagesAutoloadKeyFromTargetDirInstalledJson(
464            $installedJsonArray,
465            $flatDependencyTree,
466            $installedJsonFile->getPath()
467        );
468
469        $installedJsonArray = $this->updateNamespaces($installedJsonArray, $discoveredSymbols);
470
471        foreach ($installedJsonArray['packages'] as $index => $package) {
472            if (!in_array($package['name'], array_keys($flatDependencyTree))) {
473                unset($installedJsonArray['packages'][$index]);
474            }
475        }
476
477        $installedJsonArray['dev'] = false;
478        $installedJsonArray['dev-package-names'] = [];
479
480        $this->logger->debug('Installed.json after: ' . json_encode($installedJsonArray));
481
482        $this->logger->info('Writing installed.json to ' . $targetDir);
483
484        $installedJsonFile->write($installedJsonArray);
485
486        $this->logger->info('Installed.json written to ' . $targetDir);
487    }
488
489    /**
490     * Composer creates a file `vendor/composer/installed.json` which is used when running `composer dump-autoload`.
491     * When `delete-vendor-packages` or `delete-vendor-files` is true, files and directories which have been deleted
492     * must also be removed from `installed.json` or Composer will throw an error.
493     *
494     * @param array<string,ComposerPackage> $flatDependencyTree
495     */
496    public function cleanupVendorInstalledJson(array $flatDependencyTree, DiscoveredSymbols $discoveredSymbols): void
497    {
498
499        $vendorDir = $this->config->getVendorDirectory();
500
501        $vendorInstalledJsonFile = $this->getJsonFile($vendorDir);
502
503        $this->logger->info('Cleaning up {installedJsonPath}', ['installedJsonPath' => $vendorInstalledJsonFile->getPath()]);
504
505        /**
506         * @var InstalledJsonArray $installedJsonArray
507         */
508        $installedJsonArray = $vendorInstalledJsonFile->read();
509
510        $installedJsonArray = $this->removeMissingAutoloadKeyPaths($installedJsonArray, $this->config->getVendorDirectory(), $vendorInstalledJsonFile->getPath());
511
512        $installedJsonArray = $this->removeMovedPackagesAutoloadKeyFromVendorDirInstalledJson($installedJsonArray, $flatDependencyTree, $vendorInstalledJsonFile->getPath());
513
514        $installedJsonArray = $this->updatePackagePaths($installedJsonArray, $flatDependencyTree, $this->config->getVendorDirectory());
515
516        // Only relevant when source = target.
517        $installedJsonArray = $this->updateNamespaces($installedJsonArray, $discoveredSymbols);
518
519        $vendorInstalledJsonFile->write($installedJsonArray);
520    }
521}