From 585a0697647d2a10ef7777bf8f574bbca6798db8 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Wed, 3 Jan 2024 12:47:23 +0100 Subject: [PATCH] DevDependencyInProductionCodeError (autodetect scan paths from composer.json) (#2) --- README.md | 40 ++++-- bin/composer-dependency-analyser | 112 +++------------ composer.json | 8 +- src/ComposerDependencyAnalyser.php | 15 +- src/ComposerJson.php | 81 +++++++++++ src/Error/ClassmapEntryMissingError.php | 5 + .../DevDependencyInProductionCodeError.php | 49 +++++++ src/Error/SymbolError.php | 2 + src/Printer.php | 129 ++++++++++++++++++ tests/BinTest.php | 24 ++-- tests/ComposerDependencyAnalyserTest.php | 4 +- tests/ComposerJsonTest.php | 54 ++++++++ tests/PrinterTest.php | 100 ++++++++++++++ 13 files changed, 502 insertions(+), 121 deletions(-) create mode 100644 src/ComposerJson.php create mode 100644 src/Error/DevDependencyInProductionCodeError.php create mode 100644 src/Printer.php create mode 100644 tests/ComposerJsonTest.php create mode 100644 tests/PrinterTest.php diff --git a/README.md b/README.md index 7b06f37..2b9f890 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # 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: @@ -14,11 +15,13 @@ See comparison with existing projects: 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: @@ -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 diff --git a/bin/composer-dependency-analyser b/bin/composer-dependency-analyser index a2f247a..6b4a7c3 100755 --- a/bin/composer-dependency-analyser +++ b/bin/composer-dependency-analyser @@ -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 = << Provide custom path to composer.json + --ignore-unknown-classes Ignore when class is not found in classmap + --composer-json Provide custom path to composer.json EOD; @@ -31,56 +31,36 @@ foreach ($autoloadFiles as $autoloadFile) { } } -$colorMap = [ - "" => "\033[31m", - "" => "\033[32m", - "" => "\033[33m", - "" => "\033[37m", - "" => "\033[0m", - "" => "\033[0m", - "" => "\033[0m", - "" => "\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("$message" . PHP_EOL); +$exit = static function (string $message) use ($printer): void { + $printer->printLine("$message" . 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)) { @@ -93,7 +73,6 @@ if ($composerJsonRawData === false) { $exit("Failure while reading $composerJsonPath file."); } -/** @var array{require?: array, require-dev?: array} $composerJsonData */ $composerJsonData = json_decode($composerJsonRawData, true); $jsonError = json_last_error(); @@ -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'); @@ -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("Classes not found in composer classmap!"); - $echo("(this usually means that preconditions are not met, see readme)" . PHP_EOL); - - foreach ($classmapErrors as $error) { - $echo(" • {$error->getSymbolName()}"); - if ($verbose) { - $echo(" first usage in {$error->getExampleUsageFilepath()}" . PHP_EOL); - } - } - $echo(''); - } +$exitCode = $printer->printResult(array_values($errors), $ignoreUnknown, $verbose); +exit($exitCode); - if (count($shadowDependencyErrors) > 0) { - $echo(''); - $echo("Found shadow dependencies!"); - $echo("(those are used, but not listed as dependency in composer.json)" . PHP_EOL); - - foreach ($shadowDependencyErrors as $error) { - $echo(" • {$error->getSymbolName()} ({$error->getPackageName()})"); - if ($verbose) { - $echo(" first usage in {$error->getExampleUsageFilepath()}" . PHP_EOL); - } - } - $echo(''); - } - exit(255); - -} else { - $echo("No shadow dependencies found" . PHP_EOL); -} diff --git a/composer.json b/composer.json index 40062a6..e5b1e02 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,11 @@ "ShipMonk\\Composer\\": "tests/" }, "classmap": [ - "tests/data" + "tests/vendor/", + "tests/app/" + ], + "exclude-from-classmap": [ + "tests/data/" ] }, "bin": [ @@ -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" diff --git a/src/ComposerDependencyAnalyser.php b/src/ComposerDependencyAnalyser.php index 61e8a37..0dfb5e7 100644 --- a/src/ComposerDependencyAnalyser.php +++ b/src/ComposerDependencyAnalyser.php @@ -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; @@ -78,14 +79,14 @@ public function __construct( } /** - * @param list $scanPaths + * @param array $scanPaths path => is dev path * @return array */ 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)) { @@ -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); + } } } } @@ -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); diff --git a/src/ComposerJson.php b/src/ComposerJson.php new file mode 100644 index 0000000..704bf5d --- /dev/null +++ b/src/ComposerJson.php @@ -0,0 +1,81 @@ + isDev + * + * @readonly + * @var array + */ + public $dependencies; + + /** + * Path => isDev + * + * @readonly + * @var array + */ + public $autoloadPaths; + + /** + * @param array{require?: array, require-dev?: array, autoload?: array{psr-0?: array, psr-4?: array, files?: string[]}, autoload-dev?: array{psr-0?: array, psr-4?: array, files?: string[]}} $composerJsonData + */ + public function __construct(array $composerJsonData) + { + $requiredPackages = $composerJsonData['require'] ?? []; + $requiredDevPackages = $composerJsonData['require-dev'] ?? []; + + $this->autoloadPaths = array_merge( + $this->extractAutoloadPaths($composerJsonData['autoload']['psr-0'] ?? [], false), + $this->extractAutoloadPaths($composerJsonData['autoload']['psr-4'] ?? [], false), + $this->extractAutoloadPaths($composerJsonData['autoload']['files'] ?? [], false), + $this->extractAutoloadPaths($composerJsonData['autoload-dev']['psr-0'] ?? [], true), + $this->extractAutoloadPaths($composerJsonData['autoload-dev']['psr-4'] ?? [], true), + $this->extractAutoloadPaths($composerJsonData['autoload-dev']['files'] ?? [], true) + // classmap not supported + ); + + $filterPackages = static function (string $package): bool { + return strpos($package, '/') !== false; + }; + + $this->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) + ); + } + + /** + * @param array> $autoload + * @return array + */ + private function extractAutoloadPaths(array $autoload, bool $isDev): array + { + $result = []; + + foreach ($autoload as $paths) { + if (!is_array($paths)) { + $paths = [$paths]; + } + + foreach ($paths as $path) { + $result[$path] = $isDev; + } + } + + return $result; + } + +} diff --git a/src/Error/ClassmapEntryMissingError.php b/src/Error/ClassmapEntryMissingError.php index 9c2e9ee..f7dedc3 100644 --- a/src/Error/ClassmapEntryMissingError.php +++ b/src/Error/ClassmapEntryMissingError.php @@ -34,4 +34,9 @@ public function getExampleUsageFilepath(): string return $this->exampleUsageFilepath; } + public function getPackageName(): ?string + { + return null; + } + } diff --git a/src/Error/DevDependencyInProductionCodeError.php b/src/Error/DevDependencyInProductionCodeError.php new file mode 100644 index 0000000..94da9dc --- /dev/null +++ b/src/Error/DevDependencyInProductionCodeError.php @@ -0,0 +1,49 @@ +className = $className; + $this->packageName = $packageName; + $this->exampleUsageFilepath = $exampleUsageFilepath; + } + + public function getPackageName(): string + { + return $this->packageName; + } + + public function getSymbolName(): string + { + return $this->className; + } + + public function getExampleUsageFilepath(): string + { + return $this->exampleUsageFilepath; + } + +} diff --git a/src/Error/SymbolError.php b/src/Error/SymbolError.php index 8aa9dbc..efb222d 100644 --- a/src/Error/SymbolError.php +++ b/src/Error/SymbolError.php @@ -5,6 +5,8 @@ interface SymbolError { + public function getPackageName(): ?string; + public function getSymbolName(): string; public function getExampleUsageFilepath(): string; diff --git a/src/Printer.php b/src/Printer.php new file mode 100644 index 0000000..46fb322 --- /dev/null +++ b/src/Printer.php @@ -0,0 +1,129 @@ +' => "\033[31m", + '' => "\033[32m", + '' => "\033[33m", + '' => "\033[37m", + '' => "\033[0m", + '' => "\033[0m", + '' => "\033[0m", + '' => "\033[0m", + ]; + + /** + * @param list $errors + */ + public function printResult( + array $errors, + bool $ignoreUnknownClasses, + bool $verbose + ): int + { + $errorReported = false; + $classmapErrors = $this->filterErrors($errors, ClassmapEntryMissingError::class); + $shadowDependencyErrors = $this->filterErrors($errors, ShadowDependencyError::class); + $devDependencyInProductionErrors = $this->filterErrors($errors, DevDependencyInProductionCodeError::class); + + if (count($classmapErrors) > 0 && !$ignoreUnknownClasses) { + $this->printErrors( + 'Unknown classes!', + 'those are not present in composer classmap, so we cannot check them', + $classmapErrors, + $verbose + ); + $errorReported = true; + } + + if (count($shadowDependencyErrors) > 0) { + $this->printErrors( + 'Found shadow dependencies!', + 'those are used, but not listed as dependency in composer.json', + $shadowDependencyErrors, + $verbose + ); + $errorReported = true; + } + + if (count($devDependencyInProductionErrors) > 0) { + $this->printErrors( + 'Found dev dependencies in production code!', + 'those are wrongly listed as dev dependency in composer.json', + $devDependencyInProductionErrors, + $verbose + ); + $errorReported = true; + } + + if (!$errorReported) { + $this->printLine('No composer issues found' . PHP_EOL); + return 0; + } + + return 255; + } + + /** + * @param list $errors + */ + private function printErrors(string $title, string $subtitle, array $errors, bool $verbose): void + { + $this->printLine(''); + $this->printLine("$title"); + $this->printLine("($subtitle)" . PHP_EOL); + + foreach ($errors as $error) { + $append = $error->getPackageName() !== null ? " ({$error->getPackageName()})" : ''; + + $this->printLine(" • {$error->getSymbolName()}$append"); + + if ($verbose) { + $this->printLine(" first usage in {$error->getExampleUsageFilepath()}" . PHP_EOL); + } + } + + $this->printLine(''); + } + + public function printLine(string $string): void + { + echo $this->colorize($string) . PHP_EOL; + } + + private function colorize(string $string): string + { + return str_replace(array_keys(self::COLORS), array_values(self::COLORS), $string); + } + + /** + * @template T of SymbolError + * @param list $errors + * @param class-string $class + * @return list + */ + private function filterErrors(array $errors, string $class): array + { + $filtered = array_filter($errors, static function (SymbolError $error) use ($class): bool { + return is_a($error, $class, true); + }); + return array_values($filtered); + } + +} diff --git a/tests/BinTest.php b/tests/BinTest.php index a68614b..38c07cd 100644 --- a/tests/BinTest.php +++ b/tests/BinTest.php @@ -20,28 +20,28 @@ public function test(): void $noPackagesError = 'No packages found'; $parseError = 'Failure while parsing'; - $okOutput = 'No shadow dependencies found'; + $okOutput = 'No composer issues found'; $helpOutput = 'Usage:'; $this->runCommand('composer dump-autoload --classmap-authoritative', $rootDir, 0, 'Generated optimized autoload files'); - $this->runCommand('php bin/composer-dependency-analyser --verbose src', $rootDir, 0, $okOutput); - $this->runCommand('php bin/composer-dependency-analyser src', $rootDir, 0, $okOutput); - $this->runCommand('php ../bin/composer-dependency-analyser src', $testsDir, 255, $noComposerJsonError); + $this->runCommand('php bin/composer-dependency-analyser --ignore-unknown-classes', $rootDir, 0, $okOutput); + $this->runCommand('php bin/composer-dependency-analyser --ignore-unknown-classes --verbose', $rootDir, 0, $okOutput); + $this->runCommand('php ../bin/composer-dependency-analyser', $testsDir, 255, $noComposerJsonError); $this->runCommand('php bin/composer-dependency-analyser --help', $rootDir, 0, $helpOutput); $this->runCommand('php ../bin/composer-dependency-analyser --help', $testsDir, 0, $helpOutput); - $this->runCommand('php bin/composer-dependency-analyser --composer_json=composer.json src', $rootDir, 0, $okOutput); - $this->runCommand('php bin/composer-dependency-analyser --composer_json=composer.lock src', $rootDir, 255, $noPackagesError); - $this->runCommand('php bin/composer-dependency-analyser --composer_json=README.md src', $rootDir, 255, $parseError); - $this->runCommand('php ../bin/composer-dependency-analyser --composer_json=composer.json src', $testsDir, 255, $noComposerJsonError); - $this->runCommand('php ../bin/composer-dependency-analyser --composer_json=../composer.json ../src', $testsDir, 0, $okOutput); + $this->runCommand('php bin/composer-dependency-analyser --ignore-unknown-classes --composer-json=composer.json', $rootDir, 0, $okOutput); + $this->runCommand('php bin/composer-dependency-analyser --ignore-unknown-classes --composer-json=composer.lock', $rootDir, 255, $noPackagesError); + $this->runCommand('php bin/composer-dependency-analyser --ignore-unknown-classes --composer-json=README.md', $rootDir, 255, $parseError); + $this->runCommand('php ../bin/composer-dependency-analyser --ignore-unknown-classes --composer-json=composer.json', $testsDir, 255, $noComposerJsonError); + $this->runCommand('php ../bin/composer-dependency-analyser --ignore-unknown-classes --composer-json=../composer.json', $testsDir, 0, $okOutput); } private function runCommand( string $command, string $cwd, int $expectedExitCode, - ?string $expectedOutputContains = null + string $expectedOutputContains ): void { $desc = [ @@ -71,9 +71,7 @@ private function runCommand( "Error was:\n" . $errorOutput . "\n" ); - if ($expectedOutputContains !== null) { - self::assertStringContainsString($expectedOutputContains, $output); - } + self::assertStringContainsString($expectedOutputContains, $output); } } diff --git a/tests/ComposerDependencyAnalyserTest.php b/tests/ComposerDependencyAnalyserTest.php index 1f69ce1..49ce7e2 100644 --- a/tests/ComposerDependencyAnalyserTest.php +++ b/tests/ComposerDependencyAnalyserTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\TestCase; use ShipMonk\Composer\Error\ClassmapEntryMissingError; +use ShipMonk\Composer\Error\DevDependencyInProductionCodeError; use ShipMonk\Composer\Error\ShadowDependencyError; class ComposerDependencyAnalyserTest extends TestCase @@ -29,11 +30,12 @@ public function test(): void $dependencies ); $scanPath = __DIR__ . '/data/shadow-dependencies.php'; - $result = $detector->scan([$scanPath]); + $result = $detector->scan([$scanPath => false]); self::assertEquals([ 'Unknown\Clazz' => new ClassmapEntryMissingError('Unknown\Clazz', $scanPath), 'Shadow\Package\Clazz' => new ShadowDependencyError('Shadow\Package\Clazz', 'shadow/package', $scanPath), + 'Dev\Package\Clazz' => new DevDependencyInProductionCodeError('Dev\Package\Clazz', 'dev/package', $scanPath), ], $result); } diff --git a/tests/ComposerJsonTest.php b/tests/ComposerJsonTest.php new file mode 100644 index 0000000..0cab942 --- /dev/null +++ b/tests/ComposerJsonTest.php @@ -0,0 +1,54 @@ + [ + 'php' => '^8.0', + 'nette/utils' => '^3.0', + ], + 'require-dev' => [ + 'phpstan/phpstan' => '^1.0', + ], + 'autoload' => [ + 'psr-4' => [ + 'App\\' => 'src/', + ], + 'files' => [ + 'public/bootstrap.php', + ], + ], + 'autoload-dev' => [ + 'psr-4' => [ + 'App\\' => ['build/', 'tests/'], + ], + ], + ]); + + self::assertSame( + [ + 'nette/utils' => false, + 'phpstan/phpstan' => true, + ], + $composerJson->dependencies + ); + + self::assertSame( + [ + 'src/' => false, + 'public/bootstrap.php' => false, + 'build/' => true, + 'tests/' => true, + ], + $composerJson->autoloadPaths + ); + } + +} diff --git a/tests/PrinterTest.php b/tests/PrinterTest.php new file mode 100644 index 0000000..5c0015b --- /dev/null +++ b/tests/PrinterTest.php @@ -0,0 +1,100 @@ +captureAndNormalizeOutput(static function () use ($printer): void { + $printer->printLine('Hello, world!'); + }); + + self::assertSame("Hello, \033[31mworld\033[0m!\n", $output); + self::assertSame("Hello, world!\n", $this->removeColors($output)); + } + + public function testPrintResult(): void + { + $printer = new Printer(); + + $output1 = $this->captureAndNormalizeOutput(static function () use ($printer): void { + $printer->printResult([], false, false); + }); + + self::assertSame("No composer issues found\n\n", $this->removeColors($output1)); + + $output2 = $this->captureAndNormalizeOutput(static function () use ($printer): void { + $printer->printResult([ + new ClassmapEntryMissingError('Foo', 'foo.php'), + new ShadowDependencyError('Bar', 'some/package', 'bar.php'), + new DevDependencyInProductionCodeError('Baz', 'some/package', 'baz.php'), + ], false, true); + }); + + // editorconfig-checker-disable + $fullOutput = <<<'OUT' + +Unknown classes! +(those are not present in composer classmap, so we cannot check them) + + • Foo + first usage in foo.php + + + +Found shadow dependencies! +(those are used, but not listed as dependency in composer.json) + + • Bar (some/package) + first usage in bar.php + + + +Found dev dependencies in production code! +(those are wrongly listed as dev dependency in composer.json) + + • Baz (some/package) + first usage in baz.php + + + +OUT; + // editorconfig-checker-enable + self::assertSame($this->normalizeEol($fullOutput), $this->removeColors($output2)); + } + + private function removeColors(string $output): string + { + return (string) preg_replace('#\\x1b[[][^A-Za-z]*[A-Za-z]#', '', $output); + } + + /** + * @param Closure(): void $closure + */ + private function captureAndNormalizeOutput(Closure $closure): string + { + ob_start(); + $closure(); + return $this->normalizeEol((string) ob_get_clean()); + } + + private function normalizeEol(string $string): string + { + return str_replace("\r\n", "\n", $string); + } + +}