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