Skip to content

Commit 585a069

Browse files
authored
DevDependencyInProductionCodeError (autodetect scan paths from composer.json) (#2)
1 parent de84a32 commit 585a069

13 files changed

+502
-121
lines changed

README.md

+28-12
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,27 @@
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
## Usage:
1821

1922
```sh
20-
composer dump-autoload -o # we use composer's autoloader to detect which class belongs to which package
21-
vendor/bin/composer-dependency-analyser src
23+
composer dump-autoload --classmap-authoritative # we use composer's autoloader to detect which class belongs to which package
24+
vendor/bin/composer-dependency-analyser
2225
```
2326

2427
Example output:
@@ -35,14 +38,27 @@ Found shadow dependencies!
3538

3639
You can add `--verbose` flag to see first usage of each class.
3740

38-
## Shadow dependency risks
39-
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.
41+
## What it does:
42+
This tool reads your `composer.json` and scans all paths listed in both `autoload` sections while analysing:
43+
44+
- Shadowed dependencies
45+
- Those are dependencies of your dependencies, which are not listed in `composer.json`
46+
- Your code can break when your direct dependency gets updated to newer version which does not require that shadowed dependency anymore
47+
- You should list all those classes within your dependencies
48+
- Dev dependencies used in production code
49+
- Your code can break once you run your application with `composer install --no-dev`
50+
- You should move those to `require` from `require-dev`
51+
- Unknown classes
52+
- Any class missing in composer classmap gets reported as we cannot say if that one is shadowed or not
53+
- This might be expected in some cases, so you can disable this behaviour by `--ignore-unknown-classes`
54+
55+
It is expected to run this tool in root of your project, where the `composer.json` is located.
56+
If you want to run it elsewhere, you can use `--composer-json=path/to/composer.json` option.
4057

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

4360
## Future scope:
4461
- Detecting dead dependencies
45-
- Detecting dev dependencies used in production code
4662

4763
## Limitations:
4864
- Files without namespace has limited support

bin/composer-dependency-analyser

+21-91
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@
33

44
use Composer\Autoload\ClassLoader;
55
use ShipMonk\Composer\ComposerDependencyAnalyser;
6-
use ShipMonk\Composer\Error\ClassmapEntryMissingError;
7-
use ShipMonk\Composer\Error\ShadowDependencyError;
8-
use ShipMonk\Composer\Error\SymbolError;
6+
use ShipMonk\Composer\ComposerJson;
7+
use ShipMonk\Composer\Printer;
98

109
$usage = <<<EOD
1110
@@ -15,7 +14,8 @@ Usage:
1514
Options:
1615
--help Print this help text and exit.
1716
--verbose Print verbose output
18-
--composer_json <path> Provide custom path to composer.json
17+
--ignore-unknown-classes Ignore when class is not found in classmap
18+
--composer-json <path> Provide custom path to composer.json
1919
2020
EOD;
2121

@@ -31,56 +31,36 @@ foreach ($autoloadFiles as $autoloadFile) {
3131
}
3232
}
3333

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

5336
/**
5437
* @return never
5538
*/
56-
$exit = static function (string $message) use ($echo): void {
57-
$echo("<red>$message</red>" . PHP_EOL);
39+
$exit = static function (string $message) use ($printer): void {
40+
$printer->printLine("<red>$message</red>" . PHP_EOL);
5841
exit(255);
5942
};
6043

6144
/** @var int $restIndex */
62-
$providedOptions = getopt('', ['help', 'verbose', 'composer_json:'], $restIndex);
45+
$providedOptions = getopt('', ['help', 'verbose', 'ignore-unknown-classes', 'composer-json:'], $restIndex);
6346

6447
$cwd = getcwd();
65-
$relativePaths = array_slice($argv, $restIndex);
48+
$providedPaths = array_slice($argv, $restIndex);
6649

6750
if (isset($providedOptions['help'])) {
6851
echo $usage;
6952
exit;
7053
}
7154

72-
if ($relativePaths === []) {
73-
$exit("No paths given to scan.");
74-
}
75-
7655
$verbose = isset($providedOptions['verbose']);
56+
$ignoreUnknown = isset($providedOptions['ignore-unknown-classes']);
7757

7858
/** @var non-empty-string $cwd */
7959
$cwd = getcwd();
8060

8161
/** @var string[] $providedOptions */
82-
$composerJsonPath = isset($providedOptions['composer_json'])
83-
? ($cwd . "/" . $providedOptions['composer_json'])
62+
$composerJsonPath = isset($providedOptions['composer-json'])
63+
? ($cwd . "/" . $providedOptions['composer-json'])
8464
: ($cwd . "/composer.json");
8565

8666
if (!is_file($composerJsonPath)) {
@@ -93,7 +73,6 @@ if ($composerJsonRawData === false) {
9373
$exit("Failure while reading $composerJsonPath file.");
9474
}
9575

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

9978
$jsonError = json_last_error();
@@ -102,22 +81,12 @@ if ($jsonError !== JSON_ERROR_NONE) {
10281
$exit("Failure while parsing $composerJsonPath file: " . json_last_error_msg());
10382
}
10483

105-
$filterPackages = static function (string $package): bool {
106-
return strpos($package, '/') !== false;
107-
};
84+
$composerJson = new ComposerJson($composerJsonData); // @phpstan-ignore-line ignore mixed given
10885

109-
$requiredPackages = $composerJsonData['require'] ?? [];
110-
$requiredDevPackages = $composerJsonData['require-dev'] ?? [];
111-
112-
if (count($requiredPackages) === 0 && count($requiredDevPackages) === 0) {
86+
if (count($composerJson->dependencies) === 0) {
11387
$exit("No packages found in $composerJsonPath file.");
11488
}
11589

116-
$dependencies = array_merge(
117-
array_fill_keys(array_keys(array_filter($requiredPackages, $filterPackages, ARRAY_FILTER_USE_KEY)), false),
118-
array_fill_keys(array_keys(array_filter($requiredDevPackages, $filterPackages, ARRAY_FILTER_USE_KEY)), true)
119-
);
120-
12190
$loaders = ClassLoader::getRegisteredLoaders();
12291
if (count($loaders) !== 1) {
12392
$exit('This tool works only with single composer autoloader');
@@ -129,57 +98,18 @@ if (!$loaders[$vendorDir]->isClassMapAuthoritative()) {
12998
}
13099

131100
$absolutePaths = [];
132-
foreach ($relativePaths as $relativePath) {
133-
$absolutePath = $cwd . '/' . $relativePath;
101+
foreach ($composerJson->autoloadPaths as $relativePath => $isDevPath) {
102+
$absolutePath = dirname($composerJsonPath) . '/' . $relativePath;
134103
if (!is_dir($absolutePath) && !is_file($absolutePath)) {
135-
$exit("Invalid path given, $absolutePath is not a directory.");
104+
$exit("Unexpected path detected, $absolutePath is not a file nor directory.");
136105
}
137-
$absolutePaths[] = $absolutePath;
106+
$absolutePaths[$absolutePath] = $isDevPath;
138107
}
139108

140-
$detector = new ComposerDependencyAnalyser($vendorDir, $loaders[$vendorDir]->getClassMap(), $dependencies, ['php']);
109+
$detector = new ComposerDependencyAnalyser($vendorDir, $loaders[$vendorDir]->getClassMap(), $composerJson->dependencies, ['php']);
141110
$errors = $detector->scan($absolutePaths);
142111

143-
if (count($errors) > 0) {
144-
/** @var ClassmapEntryMissingError[] $classmapErrors */
145-
$classmapErrors = array_filter($errors, static function (SymbolError $error): bool {
146-
return $error instanceof ClassmapEntryMissingError;
147-
});
148-
/** @var ShadowDependencyError[] $shadowDependencyErrors */
149-
$shadowDependencyErrors = array_filter($errors, static function (SymbolError $error): bool {
150-
return $error instanceof ShadowDependencyError;
151-
});
152-
153-
if (count($classmapErrors) > 0) {
154-
$echo('');
155-
$echo("<red>Classes not found in composer classmap!</red>");
156-
$echo("<gray>(this usually means that preconditions are not met, see readme)</gray>" . PHP_EOL);
157-
158-
foreach ($classmapErrors as $error) {
159-
$echo(" • <orange>{$error->getSymbolName()}</orange>");
160-
if ($verbose) {
161-
$echo(" <gray>first usage in {$error->getExampleUsageFilepath()}</gray>" . PHP_EOL);
162-
}
163-
}
164-
$echo('');
165-
}
112+
$exitCode = $printer->printResult(array_values($errors), $ignoreUnknown, $verbose);
113+
exit($exitCode);
166114

167-
if (count($shadowDependencyErrors) > 0) {
168-
$echo('');
169-
$echo("<red>Found shadow dependencies!</red>");
170-
$echo("<gray>(those are used, but not listed as dependency in composer.json)</gray>" . PHP_EOL);
171-
172-
foreach ($shadowDependencyErrors as $error) {
173-
$echo(" • <orange>{$error->getSymbolName()}</orange> ({$error->getPackageName()})");
174-
if ($verbose) {
175-
$echo(" <gray>first usage in {$error->getExampleUsageFilepath()}</gray>" . PHP_EOL);
176-
}
177-
}
178-
$echo('');
179-
}
180115

181-
exit(255);
182-
183-
} else {
184-
$echo("<green>No shadow dependencies found</green>" . PHP_EOL);
185-
}

composer.json

+6-2
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": [
@@ -58,7 +62,7 @@
5862
"check:composer": "composer normalize --dry-run --no-check-lock --no-update-lock",
5963
"check:cs": "phpcs",
6064
"check:ec": "ec src tests",
61-
"check:self": "bin/composer-dependency-analyser src",
65+
"check:self": "bin/composer-dependency-analyser --ignore-unknown-classes",
6266
"check:tests": "phpunit -vvv tests",
6367
"check:types": "phpstan analyse -vvv --ansi",
6468
"fix:cs": "phpcbf"

src/ComposerDependencyAnalyser.php

+13-2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use RecursiveIteratorIterator;
1010
use ReflectionClass;
1111
use ShipMonk\Composer\Error\ClassmapEntryMissingError;
12+
use ShipMonk\Composer\Error\DevDependencyInProductionCodeError;
1213
use ShipMonk\Composer\Error\ShadowDependencyError;
1314
use ShipMonk\Composer\Error\SymbolError;
1415
use UnexpectedValueException;
@@ -78,14 +79,14 @@ public function __construct(
7879
}
7980

8081
/**
81-
* @param list<string> $scanPaths
82+
* @param array<string, bool> $scanPaths path => is dev path
8283
* @return array<string, SymbolError>
8384
*/
8485
public function scan(array $scanPaths): array
8586
{
8687
$errors = [];
8788

88-
foreach ($scanPaths as $scanPath) {
89+
foreach ($scanPaths as $scanPath => $isDevPath) {
8990
foreach ($this->listPhpFilesIn($scanPath) as $filePath) {
9091
foreach ($this->getUsedSymbolsInFile($filePath) as $usedSymbol) {
9192
if ($this->isInternalClass($usedSymbol)) {
@@ -111,6 +112,10 @@ public function scan(array $scanPaths): array
111112
if ($this->isShadowDependency($packageName)) {
112113
$errors[$usedSymbol] = new ShadowDependencyError($usedSymbol, $packageName, $filePath);
113114
}
115+
116+
if (!$isDevPath && $this->isDevDependency($packageName)) {
117+
$errors[$usedSymbol] = new DevDependencyInProductionCodeError($usedSymbol, $packageName, $filePath);
118+
}
114119
}
115120
}
116121
}
@@ -125,6 +130,12 @@ private function isShadowDependency(string $packageName): bool
125130
return !isset($this->composerJsonDependencies[$packageName]);
126131
}
127132

133+
private function isDevDependency(string $packageName): bool
134+
{
135+
$isDevDependency = $this->composerJsonDependencies[$packageName] ?? null;
136+
return $isDevDependency === true;
137+
}
138+
128139
private function getPackageNameFromVendorPath(string $realPath): string
129140
{
130141
$filePathInVendor = trim(str_replace($this->vendorDir, '', $realPath), DIRECTORY_SEPARATOR);

0 commit comments

Comments
 (0)