Skip to content

Commit 36cdf86

Browse files
committed
Separate Printer and ComposerJson
1 parent 7f3cc3a commit 36cdf86

7 files changed

+249
-128
lines changed

README.md

+19-13
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
# Composer dependency analyser
22

3-
This package aims to detect shadowed composer dependencies in your project, fast!
4-
See comparison with existing projects:
3+
This package aims to detect composer dependency issues in your project, fast!
54

6-
| Project | Analysis of 13k files |
7-
|---------------------------------------------------------------------------------------|-----------------------|
8-
| shipmonk/composer-dependency-analyser | 2 secs |
9-
| [maglnet/composer-require-checker](https://github.com/maglnet/ComposerRequireChecker) | 124 secs |
5+
For example, it detects shadowed depencencies similar to [maglnet/composer-require-checker](https://github.com/maglnet/ComposerRequireChecker), but **much faster**:
6+
7+
| Project | Analysis of 13k files |
8+
|---------------------------------------|-----------------------|
9+
| shipmonk/composer-dependency-analyser | 2 secs |
10+
| maglnet/composer-require-checker | 124 secs |
1011

1112
## Installation:
1213

1314
```sh
1415
composer require --dev shipmonk/composer-dependency-analyser
1516
```
1617

18+
*Note that this package itself has zero composer dependencies.*
19+
1720
## Preconditions:
1821
- To achieve such performance, your project needs follow some `use statements` limitations
1922
- Disallowed approaches:
@@ -61,18 +64,21 @@ Found shadow dependencies!
6164

6265
You can add `--verbose` flag to see first usage of each class.
6366

64-
## How it works:
65-
This tool extracts dependencies and autoloader paths from your composer.json and detects:
67+
## What it does:
68+
This tool reads your `composer.json` and scans all paths listed in both `autoload` sections while analysing:
6669

6770
- Shadowed dependencies
68-
- This stop working if such package gets removed in one of your dependencies
71+
- Those are dependencies of your dependencies, which are not listed in `composer.json`
72+
- Your code can break when your direct dependency gets updated to newer version which does not require that shadowed dependency anymore
73+
- You should list all those classes within your dependencies
6974
- Dev dependencies used in production code
70-
- Those stop working if you ever run your application with `composer install --no-dev`
75+
- Your code can break once you run your application with `composer install --no-dev`
76+
- You should move those to `require` from `require-dev`
7177

72-
## Shadow dependency risks
73-
You are not in control of dependencies of your dependencies, so your code can break if you rely on such transitive dependency and your direct dependency will be updated to newer version which does not require that transitive dependency anymore.
78+
It is expected to run this tool in root of your project, where the `composer.json` is located.
79+
If you want to run it elsewhere, you can use `--composer_json=path/to/composer.json` option.
7480

75-
Every used class should be listed in your `require` (or `require-dev`) section of `composer.json`.
81+
Currently, it only supports those autoload sections: `psr-4`, `psr-0`, `files`.
7682

7783
## Future scope:
7884
- Detecting dead dependencies

bin/composer-dependency-analyser

+13-114
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33

44
use Composer\Autoload\ClassLoader;
55
use ShipMonk\Composer\ComposerDependencyAnalyser;
6+
use ShipMonk\Composer\ComposerJson;
67
use ShipMonk\Composer\Error\ClassmapEntryMissingError;
78
use ShipMonk\Composer\Error\DevDependencyInProductionCodeError;
89
use ShipMonk\Composer\Error\ShadowDependencyError;
910
use ShipMonk\Composer\Error\SymbolError;
11+
use ShipMonk\Composer\Printer;
1012

1113
$usage = <<<EOD
1214
@@ -32,30 +34,13 @@ foreach ($autoloadFiles as $autoloadFile) {
3234
}
3335
}
3436

35-
$colorMap = [
36-
"<red>" => "\033[31m",
37-
"<green>" => "\033[32m",
38-
"<orange>" => "\033[33m",
39-
"<gray>" => "\033[37m",
40-
"</red>" => "\033[0m",
41-
"</green>" => "\033[0m",
42-
"</orange>" => "\033[0m",
43-
"</gray>" => "\033[0m",
44-
];
45-
46-
$colorize = static function (string $input) use ($colorMap): string {
47-
return str_replace(array_keys($colorMap), array_values($colorMap), $input);
48-
};
49-
50-
$echo = static function (string $input) use ($colorize): void {
51-
echo $colorize($input) . PHP_EOL;
52-
};
37+
$printer = new Printer();
5338

5439
/**
5540
* @return never
5641
*/
57-
$exit = static function (string $message) use ($echo): void {
58-
$echo("<red>$message</red>" . PHP_EOL);
42+
$exit = static function (string $message) use ($printer): void {
43+
$printer->printLine("<red>$message</red>" . PHP_EOL);
5944
exit(255);
6045
};
6146

@@ -90,7 +75,6 @@ if ($composerJsonRawData === false) {
9075
$exit("Failure while reading $composerJsonPath file.");
9176
}
9277

93-
/** @var array{require?: array<string, string>, require-dev?: array<string, string>} $composerJsonData */
9478
$composerJsonData = json_decode($composerJsonRawData, true);
9579

9680
$jsonError = json_last_error();
@@ -99,40 +83,12 @@ if ($jsonError !== JSON_ERROR_NONE) {
9983
$exit("Failure while parsing $composerJsonPath file: " . json_last_error_msg());
10084
}
10185

102-
$filterPackages = static function (string $package): bool {
103-
return strpos($package, '/') !== false;
104-
};
105-
106-
$requiredPackages = $composerJsonData['require'] ?? [];
107-
$requiredDevPackages = $composerJsonData['require-dev'] ?? [];
86+
$composerJson = new ComposerJson($composerJsonData); // @phpstan-ignore-line ignore mixed given
10887

109-
if (count($requiredPackages) === 0 && count($requiredDevPackages) === 0) {
88+
if (count($composerJson->dependencies) === 0) {
11089
$exit("No packages found in $composerJsonPath file.");
11190
}
11291

113-
// autodetect paths from composer autoload // TODO use some parser, properly resolve relative path based on where composer.json is
114-
if ($providedPaths === []) {
115-
$relativeProductionPaths = array_merge(
116-
array_values($composerJsonData['autoload']['psr-4'] ?? []), // TODO support both list & single item
117-
$composerJsonData['autoload']['classmap'] ?? []
118-
);
119-
$relativeDevPaths = array_merge(
120-
array_values($composerJsonData['autoload-dev']['psr-4'] ?? []),
121-
$composerJsonData['autoload-dev']['classmap'] ?? []
122-
);
123-
$relativePaths = array_merge(
124-
array_fill_keys($relativeProductionPaths, false),
125-
array_fill_keys($relativeDevPaths, true)
126-
);
127-
} else {
128-
$relativePaths = array_fill_keys($providedPaths, true); // TODO detect from composer if that one is dev or not?
129-
}
130-
131-
$dependencies = array_merge(
132-
array_fill_keys(array_keys(array_filter($requiredPackages, $filterPackages, ARRAY_FILTER_USE_KEY)), false),
133-
array_fill_keys(array_keys(array_filter($requiredDevPackages, $filterPackages, ARRAY_FILTER_USE_KEY)), true)
134-
);
135-
13692
$loaders = ClassLoader::getRegisteredLoaders();
13793
if (count($loaders) !== 1) {
13894
$exit('This tool works only with single composer autoloader');
@@ -144,75 +100,18 @@ if (!$loaders[$vendorDir]->isClassMapAuthoritative()) {
144100
}
145101

146102
$absolutePaths = [];
147-
foreach ($relativePaths as $relativePath => $isDevPath) {
148-
$absolutePath = $cwd . '/' . $relativePath;
103+
foreach ($composerJson->autoloadPaths as $relativePath => $isDevPath) {
104+
$absolutePath = dirname($composerJsonPath) . '/' . $relativePath;
149105
if (!is_dir($absolutePath) && !is_file($absolutePath)) {
150-
$exit("Invalid path given, $absolutePath is not a file nor directory.");
106+
$exit("Unexpected path detected, $absolutePath is not a file nor directory.");
151107
}
152108
$absolutePaths[$absolutePath] = $isDevPath;
153109
}
154110

155-
$detector = new ComposerDependencyAnalyser($vendorDir, $loaders[$vendorDir]->getClassMap(), $dependencies, ['php']);
111+
$detector = new ComposerDependencyAnalyser($vendorDir, $loaders[$vendorDir]->getClassMap(), $composerJson->dependencies, ['php']);
156112
$errors = $detector->scan($absolutePaths);
157113

158-
if (count($errors) > 0) {
159-
/** @var ClassmapEntryMissingError[] $classmapErrors */
160-
$classmapErrors = array_filter($errors, static function (SymbolError $error): bool {
161-
return $error instanceof ClassmapEntryMissingError;
162-
});
163-
/** @var ShadowDependencyError[] $shadowDependencyErrors */
164-
$shadowDependencyErrors = array_filter($errors, static function (SymbolError $error): bool {
165-
return $error instanceof ShadowDependencyError;
166-
});
167-
/** @var DevDependencyInProductionCodeError[] $devDependencyInProductionErrors */
168-
$devDependencyInProductionErrors = array_filter($errors, static function (SymbolError $error): bool {
169-
return $error instanceof DevDependencyInProductionCodeError;
170-
});
171-
172-
if (count($classmapErrors) > 0) {
173-
$echo('');
174-
$echo("<red>Classes not found in composer classmap!</red>");
175-
$echo("<gray>(this usually means that preconditions are not met, see readme)</gray>" . PHP_EOL);
176-
177-
foreach ($classmapErrors as $error) {
178-
$echo(" • <orange>{$error->getSymbolName()}</orange>");
179-
if ($verbose) {
180-
$echo(" <gray>first usage in {$error->getExampleUsageFilepath()}</gray>" . PHP_EOL);
181-
}
182-
}
183-
$echo('');
184-
}
185-
186-
if (count($shadowDependencyErrors) > 0) {
187-
$echo('');
188-
$echo("<red>Found shadow dependencies!</red>");
189-
$echo("<gray>(those are used, but not listed as dependency in composer.json)</gray>" . PHP_EOL);
190-
191-
foreach ($shadowDependencyErrors as $error) {
192-
$echo(" • <orange>{$error->getSymbolName()}</orange> ({$error->getPackageName()})");
193-
if ($verbose) {
194-
$echo(" <gray>first usage in {$error->getExampleUsageFilepath()}</gray>" . PHP_EOL);
195-
}
196-
}
197-
$echo('');
198-
}
199-
200-
if (count($devDependencyInProductionErrors) > 0) {
201-
$echo('');
202-
$echo("<red>Found dev dependencies in production code!</red>");
203-
$echo("<gray>(those are not listed as dev dependency in composer.json)</gray>" . PHP_EOL);
204-
205-
foreach ($devDependencyInProductionErrors as $error) {
206-
$echo(" • <orange>{$error->getSymbolName()}</orange> ({$error->getPackageName()})");
207-
if ($verbose) {
208-
$echo(" <gray>first usage in {$error->getExampleUsageFilepath()}</gray>" . PHP_EOL);
209-
}
210-
}
211-
$echo('');
212-
}
114+
$exitCode = $printer->printResult(array_values($errors), $verbose);
115+
exit($exitCode);
213116

214-
exit(255);
215117

216-
} else {
217-
$echo("<green>No composer issues found</green>" . PHP_EOL);
218-
}

composer.json

+5-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,11 @@
3131
"ShipMonk\\Composer\\": "tests/"
3232
},
3333
"classmap": [
34-
"tests/data"
34+
"tests/vendor/",
35+
"tests/app/"
36+
],
37+
"exclude-from-classmap": [
38+
"tests/data/"
3539
]
3640
},
3741
"bin": [

src/ComposerJson.php

+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace ShipMonk\Composer;
4+
5+
use function array_fill_keys;
6+
use function array_filter;
7+
use function array_keys;
8+
use function array_merge;
9+
use function is_array;
10+
use function strpos;
11+
use const ARRAY_FILTER_USE_KEY;
12+
13+
class ComposerJson
14+
{
15+
16+
/**
17+
* Path => isDev
18+
*
19+
* @readonly
20+
* @var array<string, bool>
21+
*/
22+
public array $dependencies;
23+
24+
/**
25+
* Path => isDev
26+
*
27+
* @readonly
28+
* @var array<string, bool>
29+
*/
30+
public array $autoloadPaths;
31+
32+
/**
33+
* @param array{require?: array<string, string>, require-dev?: array<string, string>, autoload?: array{psr-0?: array<string, string|string[]>, psr-4?: array<string, string|string[]>, files?: string[]}, autoload-dev?: array{psr-0?: array<string, string|string[]>, psr-4?: array<string, string|string[]>, files?: string[]}} $composerJsonData
34+
*/
35+
public function __construct(array $composerJsonData)
36+
{
37+
$requiredPackages = $composerJsonData['require'] ?? [];
38+
$requiredDevPackages = $composerJsonData['require-dev'] ?? [];
39+
40+
$this->autoloadPaths = array_merge(
41+
$this->extractAutoloadPaths($composerJsonData['autoload']['psr-0'] ?? [], false),
42+
$this->extractAutoloadPaths($composerJsonData['autoload']['psr-4'] ?? [], false),
43+
$this->extractAutoloadPaths($composerJsonData['autoload']['files'] ?? [], false),
44+
$this->extractAutoloadPaths($composerJsonData['autoload-dev']['psr-0'] ?? [], true),
45+
$this->extractAutoloadPaths($composerJsonData['autoload-dev']['psr-4'] ?? [], true),
46+
$this->extractAutoloadPaths($composerJsonData['autoload-dev']['files'] ?? [], true),
47+
// classmap not supported
48+
);
49+
50+
$filterPackages = static function (string $package): bool {
51+
return strpos($package, '/') !== false;
52+
};
53+
54+
$this->dependencies = array_merge(
55+
array_fill_keys(array_keys(array_filter($requiredPackages, $filterPackages, ARRAY_FILTER_USE_KEY)), false),
56+
array_fill_keys(array_keys(array_filter($requiredDevPackages, $filterPackages, ARRAY_FILTER_USE_KEY)), true)
57+
);
58+
}
59+
60+
/**
61+
* @param array<string|array<string>> $autoload
62+
* @return array<string, bool>
63+
*/
64+
private function extractAutoloadPaths(array $autoload, bool $isDev): array
65+
{
66+
$result = [];
67+
68+
foreach ($autoload as $paths) {
69+
if (!is_array($paths)) {
70+
$paths = [$paths];
71+
}
72+
73+
foreach ($paths as $path) {
74+
$result[$path] = $isDev;
75+
}
76+
}
77+
78+
return $result;
79+
}
80+
81+
}

src/Error/ClassmapEntryMissingError.php

+5
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,9 @@ public function getExampleUsageFilepath(): string
3434
return $this->exampleUsageFilepath;
3535
}
3636

37+
public function getPackageName(): ?string
38+
{
39+
return null;
40+
}
41+
3742
}

src/Error/SymbolError.php

+2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
interface SymbolError
66
{
77

8+
public function getPackageName(): ?string;
9+
810
public function getSymbolName(): string;
911

1012
public function getExampleUsageFilepath(): string;

0 commit comments

Comments
 (0)