Skip to content

Commit

Permalink
DevDependencyInProductionCodeError (autodetect scan paths from compos…
Browse files Browse the repository at this point in the history
…er.json) (#2)
  • Loading branch information
janedbal authored Jan 3, 2024
1 parent de84a32 commit 585a069
Show file tree
Hide file tree
Showing 13 changed files with 502 additions and 121 deletions.
40 changes: 28 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
# Composer dependency analyser

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

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

| Project | Analysis of 13k files |
|---------------------------------------|-----------------------|
| shipmonk/composer-dependency-analyser | 2 secs |
| maglnet/composer-require-checker | 124 secs |

## Installation:

```sh
composer require --dev shipmonk/composer-dependency-analyser
```

*Note that this package itself has zero composer dependencies.*

## Usage:

```sh
composer dump-autoload -o # we use composer's autoloader to detect which class belongs to which package
vendor/bin/composer-dependency-analyser src
composer dump-autoload --classmap-authoritative # we use composer's autoloader to detect which class belongs to which package
vendor/bin/composer-dependency-analyser
```

Example output:
Expand All @@ -35,14 +38,27 @@ Found shadow dependencies!

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

## Shadow dependency risks
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.
## What it does:
This tool reads your `composer.json` and scans all paths listed in both `autoload` sections while analysing:

- Shadowed dependencies
- Those are dependencies of your dependencies, which are not listed in `composer.json`
- Your code can break when your direct dependency gets updated to newer version which does not require that shadowed dependency anymore
- You should list all those classes within your dependencies
- Dev dependencies used in production code
- Your code can break once you run your application with `composer install --no-dev`
- You should move those to `require` from `require-dev`
- Unknown classes
- Any class missing in composer classmap gets reported as we cannot say if that one is shadowed or not
- This might be expected in some cases, so you can disable this behaviour by `--ignore-unknown-classes`

It is expected to run this tool in root of your project, where the `composer.json` is located.
If you want to run it elsewhere, you can use `--composer-json=path/to/composer.json` option.

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

## Future scope:
- Detecting dead dependencies
- Detecting dev dependencies used in production code

## Limitations:
- Files without namespace has limited support
Expand Down
112 changes: 21 additions & 91 deletions bin/composer-dependency-analyser
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@

use Composer\Autoload\ClassLoader;
use ShipMonk\Composer\ComposerDependencyAnalyser;
use ShipMonk\Composer\Error\ClassmapEntryMissingError;
use ShipMonk\Composer\Error\ShadowDependencyError;
use ShipMonk\Composer\Error\SymbolError;
use ShipMonk\Composer\ComposerJson;
use ShipMonk\Composer\Printer;

$usage = <<<EOD
Expand All @@ -15,7 +14,8 @@ Usage:
Options:
--help Print this help text and exit.
--verbose Print verbose output
--composer_json <path> Provide custom path to composer.json
--ignore-unknown-classes Ignore when class is not found in classmap
--composer-json <path> Provide custom path to composer.json
EOD;

Expand All @@ -31,56 +31,36 @@ foreach ($autoloadFiles as $autoloadFile) {
}
}

$colorMap = [
"<red>" => "\033[31m",
"<green>" => "\033[32m",
"<orange>" => "\033[33m",
"<gray>" => "\033[37m",
"</red>" => "\033[0m",
"</green>" => "\033[0m",
"</orange>" => "\033[0m",
"</gray>" => "\033[0m",
];

$colorize = static function (string $input) use ($colorMap): string {
return str_replace(array_keys($colorMap), array_values($colorMap), $input);
};

$echo = static function (string $input) use ($colorize): void {
echo $colorize($input) . PHP_EOL;
};
$printer = new Printer();

/**
* @return never
*/
$exit = static function (string $message) use ($echo): void {
$echo("<red>$message</red>" . PHP_EOL);
$exit = static function (string $message) use ($printer): void {
$printer->printLine("<red>$message</red>" . PHP_EOL);
exit(255);
};

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

$cwd = getcwd();
$relativePaths = array_slice($argv, $restIndex);
$providedPaths = array_slice($argv, $restIndex);

if (isset($providedOptions['help'])) {
echo $usage;
exit;
}

if ($relativePaths === []) {
$exit("No paths given to scan.");
}

$verbose = isset($providedOptions['verbose']);
$ignoreUnknown = isset($providedOptions['ignore-unknown-classes']);

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

/** @var string[] $providedOptions */
$composerJsonPath = isset($providedOptions['composer_json'])
? ($cwd . "/" . $providedOptions['composer_json'])
$composerJsonPath = isset($providedOptions['composer-json'])
? ($cwd . "/" . $providedOptions['composer-json'])
: ($cwd . "/composer.json");

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

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

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

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

$requiredPackages = $composerJsonData['require'] ?? [];
$requiredDevPackages = $composerJsonData['require-dev'] ?? [];

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

$dependencies = array_merge(
array_fill_keys(array_keys(array_filter($requiredPackages, $filterPackages, ARRAY_FILTER_USE_KEY)), false),
array_fill_keys(array_keys(array_filter($requiredDevPackages, $filterPackages, ARRAY_FILTER_USE_KEY)), true)
);

$loaders = ClassLoader::getRegisteredLoaders();
if (count($loaders) !== 1) {
$exit('This tool works only with single composer autoloader');
Expand All @@ -129,57 +98,18 @@ if (!$loaders[$vendorDir]->isClassMapAuthoritative()) {
}

$absolutePaths = [];
foreach ($relativePaths as $relativePath) {
$absolutePath = $cwd . '/' . $relativePath;
foreach ($composerJson->autoloadPaths as $relativePath => $isDevPath) {
$absolutePath = dirname($composerJsonPath) . '/' . $relativePath;
if (!is_dir($absolutePath) && !is_file($absolutePath)) {
$exit("Invalid path given, $absolutePath is not a directory.");
$exit("Unexpected path detected, $absolutePath is not a file nor directory.");
}
$absolutePaths[] = $absolutePath;
$absolutePaths[$absolutePath] = $isDevPath;
}

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

if (count($errors) > 0) {
/** @var ClassmapEntryMissingError[] $classmapErrors */
$classmapErrors = array_filter($errors, static function (SymbolError $error): bool {
return $error instanceof ClassmapEntryMissingError;
});
/** @var ShadowDependencyError[] $shadowDependencyErrors */
$shadowDependencyErrors = array_filter($errors, static function (SymbolError $error): bool {
return $error instanceof ShadowDependencyError;
});

if (count($classmapErrors) > 0) {
$echo('');
$echo("<red>Classes not found in composer classmap!</red>");
$echo("<gray>(this usually means that preconditions are not met, see readme)</gray>" . PHP_EOL);

foreach ($classmapErrors as $error) {
$echo(" • <orange>{$error->getSymbolName()}</orange>");
if ($verbose) {
$echo(" <gray>first usage in {$error->getExampleUsageFilepath()}</gray>" . PHP_EOL);
}
}
$echo('');
}
$exitCode = $printer->printResult(array_values($errors), $ignoreUnknown, $verbose);
exit($exitCode);

if (count($shadowDependencyErrors) > 0) {
$echo('');
$echo("<red>Found shadow dependencies!</red>");
$echo("<gray>(those are used, but not listed as dependency in composer.json)</gray>" . PHP_EOL);

foreach ($shadowDependencyErrors as $error) {
$echo(" • <orange>{$error->getSymbolName()}</orange> ({$error->getPackageName()})");
if ($verbose) {
$echo(" <gray>first usage in {$error->getExampleUsageFilepath()}</gray>" . PHP_EOL);
}
}
$echo('');
}

exit(255);

} else {
$echo("<green>No shadow dependencies found</green>" . PHP_EOL);
}
8 changes: 6 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@
"ShipMonk\\Composer\\": "tests/"
},
"classmap": [
"tests/data"
"tests/vendor/",
"tests/app/"
],
"exclude-from-classmap": [
"tests/data/"
]
},
"bin": [
Expand All @@ -58,7 +62,7 @@
"check:composer": "composer normalize --dry-run --no-check-lock --no-update-lock",
"check:cs": "phpcs",
"check:ec": "ec src tests",
"check:self": "bin/composer-dependency-analyser src",
"check:self": "bin/composer-dependency-analyser --ignore-unknown-classes",
"check:tests": "phpunit -vvv tests",
"check:types": "phpstan analyse -vvv --ansi",
"fix:cs": "phpcbf"
Expand Down
15 changes: 13 additions & 2 deletions src/ComposerDependencyAnalyser.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use RecursiveIteratorIterator;
use ReflectionClass;
use ShipMonk\Composer\Error\ClassmapEntryMissingError;
use ShipMonk\Composer\Error\DevDependencyInProductionCodeError;
use ShipMonk\Composer\Error\ShadowDependencyError;
use ShipMonk\Composer\Error\SymbolError;
use UnexpectedValueException;
Expand Down Expand Up @@ -78,14 +79,14 @@ public function __construct(
}

/**
* @param list<string> $scanPaths
* @param array<string, bool> $scanPaths path => is dev path
* @return array<string, SymbolError>
*/
public function scan(array $scanPaths): array
{
$errors = [];

foreach ($scanPaths as $scanPath) {
foreach ($scanPaths as $scanPath => $isDevPath) {
foreach ($this->listPhpFilesIn($scanPath) as $filePath) {
foreach ($this->getUsedSymbolsInFile($filePath) as $usedSymbol) {
if ($this->isInternalClass($usedSymbol)) {
Expand All @@ -111,6 +112,10 @@ public function scan(array $scanPaths): array
if ($this->isShadowDependency($packageName)) {
$errors[$usedSymbol] = new ShadowDependencyError($usedSymbol, $packageName, $filePath);
}

if (!$isDevPath && $this->isDevDependency($packageName)) {
$errors[$usedSymbol] = new DevDependencyInProductionCodeError($usedSymbol, $packageName, $filePath);
}
}
}
}
Expand All @@ -125,6 +130,12 @@ private function isShadowDependency(string $packageName): bool
return !isset($this->composerJsonDependencies[$packageName]);
}

private function isDevDependency(string $packageName): bool
{
$isDevDependency = $this->composerJsonDependencies[$packageName] ?? null;
return $isDevDependency === true;
}

private function getPackageNameFromVendorPath(string $realPath): string
{
$filePathInVendor = trim(str_replace($this->vendorDir, '', $realPath), DIRECTORY_SEPARATOR);
Expand Down
Loading

0 comments on commit 585a069

Please sign in to comment.