From c1ee7e83160d60160beda9b9718564f9e0a2f49b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Fr=C3=B6mer?= Date: Tue, 4 Jan 2022 20:21:48 +0100 Subject: [PATCH] Resolve #189: Add feature for unused zombies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Andreas Frömer --- CHANGELOG.md | 1 + composer.json | 3 +- .../FilterDependencyCollectionCommand.php | 45 ++-------- ...lectFilteredDependenciesCommandHandler.php | 26 +++--- src/Composer/Config.php | 10 +++ src/Console/Command/UnusedCommand.php | 32 +++++-- src/Filter/FilterCollection.php | 85 +++++++++++++++++++ src/Filter/FilterInterface.php | 16 ++++ src/Filter/NamedFilter.php | 35 ++++++++ src/Filter/PatternFilter.php | 35 ++++++++ tests/Integration/UnusedCommandTest.php | 26 ++++-- tests/Stubs/TestDependency.php | 61 +++++++++++++ tests/Unit/Filter/NamedFilterTest.php | 45 ++++++++++ tests/Unit/Filter/PatternFilterTest.php | 48 +++++++++++ .../TestProjects/EmptyRequire/composer.json | 5 -- .../ExtDsRequirement/composer.json | 3 - .../composer.json | 5 -- .../OnlyLanguageRequirement/composer.json | 3 - .../TestProjects/UnusedZombies/composer.json | 10 +++ 19 files changed, 412 insertions(+), 82 deletions(-) create mode 100644 src/Filter/FilterCollection.php create mode 100644 src/Filter/FilterInterface.php create mode 100644 src/Filter/NamedFilter.php create mode 100644 src/Filter/PatternFilter.php create mode 100644 tests/Stubs/TestDependency.php create mode 100644 tests/Unit/Filter/NamedFilterTest.php create mode 100644 tests/Unit/Filter/PatternFilterTest.php create mode 100644 tests/assets/TestProjects/UnusedZombies/composer.json diff --git a/CHANGELOG.md b/CHANGELOG.md index c8a913b7..64cab8b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Added CLI argument `composer-json` which can be used to parse external projects. This will default to the current working directory. - Added error message when `composer.json` is not readable +- Added check for zombie exclusion. It will report if any excluded packages or pattern did match any package ### Changed - Change `bin/composer-unused` to be used as standalone binary - Package type is now `library` instead of `composer-plugin` diff --git a/composer.json b/composer.json index 328a42df..651037be 100644 --- a/composer.json +++ b/composer.json @@ -48,7 +48,8 @@ "autoload-dev": { "psr-4": { "ComposerUnused\\ComposerUnused\\Test\\Integration\\": "tests/Integration", - "ComposerUnused\\ComposerUnused\\Test\\Unit\\": "tests/Unit" + "ComposerUnused\\ComposerUnused\\Test\\Unit\\": "tests/Unit", + "ComposerUnused\\ComposerUnused\\Test\\Stubs\\": "tests/Stubs" } }, "bin": [ diff --git a/src/Command/FilterDependencyCollectionCommand.php b/src/Command/FilterDependencyCollectionCommand.php index 16303a1b..e06df652 100644 --- a/src/Command/FilterDependencyCollectionCommand.php +++ b/src/Command/FilterDependencyCollectionCommand.php @@ -5,39 +5,19 @@ namespace ComposerUnused\ComposerUnused\Command; use ComposerUnused\ComposerUnused\Dependency\DependencyCollection; - -use function array_merge; +use ComposerUnused\ComposerUnused\Filter\FilterCollection; final class FilterDependencyCollectionCommand { - private const GLOBAL_NAMED_EXCLUSION = [ - 'composer-plugin-api' - ]; - - private const GLOBAL_PATTERN_EXCLUSION = [ - '/-implementation$/i' - ]; - - /** @var DependencyCollection */ - private $requiredDependencyCollection; - /** @var array */ - private $namedExclusion; - /** @var array */ - private $patternExclusion; + private DependencyCollection $requiredDependencyCollection; + private FilterCollection $filterCollection; - /** - * @param DependencyCollection $requiredDependencyCollection - * @param array $namedExclusion - * @param array $patternExclusion - */ public function __construct( DependencyCollection $requiredDependencyCollection, - array $namedExclusion = [], - array $patternExclusion = [] + FilterCollection $filterCollection ) { $this->requiredDependencyCollection = $requiredDependencyCollection; - $this->namedExclusion = array_merge(self::GLOBAL_NAMED_EXCLUSION, $namedExclusion); - $this->patternExclusion = array_merge(self::GLOBAL_PATTERN_EXCLUSION, $patternExclusion); + $this->filterCollection = $filterCollection; } public function getRequiredDependencyCollection(): DependencyCollection @@ -45,19 +25,8 @@ public function getRequiredDependencyCollection(): DependencyCollection return $this->requiredDependencyCollection; } - /** - * @return array - */ - public function getNamedExclusion(): array - { - return $this->namedExclusion; - } - - /** - * @return array - */ - public function getPatternExclusion(): array + public function getFilters(): FilterCollection { - return $this->patternExclusion; + return $this->filterCollection; } } diff --git a/src/Command/Handler/CollectFilteredDependenciesCommandHandler.php b/src/Command/Handler/CollectFilteredDependenciesCommandHandler.php index 7a3749e4..62cf86fc 100644 --- a/src/Command/Handler/CollectFilteredDependenciesCommandHandler.php +++ b/src/Command/Handler/CollectFilteredDependenciesCommandHandler.php @@ -12,24 +12,18 @@ final class CollectFilteredDependenciesCommandHandler { public function collect(FilterDependencyCollectionCommand $command): DependencyCollection { - $namedExclusion = $command->getNamedExclusion(); - $patternExclusion = $command->getPatternExclusion(); + $filters = $command->getFilters(); - return $command->getRequiredDependencyCollection()->filter(static function (DependencyInterface $dependency) use ( - $namedExclusion, - $patternExclusion - ) { - if (in_array($dependency->getName(), $namedExclusion, true)) { - return false; - } - - foreach ($patternExclusion as $exclusion) { - if (preg_match($exclusion, $dependency->getName())) { - return false; + return $command->getRequiredDependencyCollection()->filter( + static function (DependencyInterface $dependency) use ($filters) { + foreach ($filters as $filter) { + if ($filter->applies($dependency)) { + return false; + } } - } - return true; - }); + return true; + } + ); } } diff --git a/src/Composer/Config.php b/src/Composer/Config.php index c36bd526..2838e9cb 100644 --- a/src/Composer/Config.php +++ b/src/Composer/Config.php @@ -15,6 +15,8 @@ final class Config private array $autoload = []; /** @var array */ private array $suggests = []; + /** @var array */ + private array $extra = []; public function getName(): string { @@ -45,6 +47,14 @@ public function getSuggests(): array return $this->suggests; } + /** + * @return array + */ + public function getExtra(): array + { + return $this->extra; + } + public function get(string $property): string { $value = $this->config[$property] ?? null; diff --git a/src/Console/Command/UnusedCommand.php b/src/Console/Command/UnusedCommand.php index e99dc8b0..9fecddb8 100644 --- a/src/Console/Command/UnusedCommand.php +++ b/src/Console/Command/UnusedCommand.php @@ -17,6 +17,7 @@ use ComposerUnused\ComposerUnused\Dependency\DependencyInterface; use ComposerUnused\ComposerUnused\Dependency\InvalidDependency; use ComposerUnused\ComposerUnused\Dependency\RequiredDependency; +use ComposerUnused\ComposerUnused\Filter\FilterCollection; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -141,11 +142,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int ) ); + $filterCollection = new FilterCollection( + array_merge( + $config->getExtra()['unused'] ?? [], + $input->getOption('excludePackage') + ), + [] // TODO pattern exclusion from CLI + ); + $requiredDependencyCollection = $this->collectFilteredDependenciesCommandHandler->collect( new FilterDependencyCollectionCommand( $unfilteredRequiredDependencyCollection, - $input->getOption('excludePackage'), - [] // TODO use pattern exclude option from command line + $filterCollection ) ); @@ -199,10 +207,11 @@ static function (DependencyInterface $dependency) { $io->writeln( sprintf( - 'Found %d used, %d unused and %d ignored packages', + 'Found %d used, %d unused, %d ignored and %d zombie packages', count($usedDependencyCollection), count($unusedDependencyCollection), - count($invalidDependencyCollection) + count($invalidDependencyCollection), + count($filterCollection->getUnused()) ) ); @@ -271,7 +280,20 @@ static function (DependencyInterface $dependency) { ); } - if ($unusedDependencyCollection->count() > 0) { + $io->newLine(); + $io->text('Zombies exclusions (did not match any package))'); + + foreach ($filterCollection->getUnused() as $filter) { + $io->writeln( + sprintf( + ' %s %s', + "\u{1F480}", + $filter->toString() + ) + ); + } + + if ($unusedDependencyCollection->count() > 0 || count($filterCollection->getUnused()) > 0) { return 1; } diff --git a/src/Filter/FilterCollection.php b/src/Filter/FilterCollection.php new file mode 100644 index 00000000..4364ac30 --- /dev/null +++ b/src/Filter/FilterCollection.php @@ -0,0 +1,85 @@ + + */ +final class FilterCollection implements IteratorAggregate, Countable +{ + /** + * List of global named packages which should be excluded from unused check + * Key => package name + * Value => always "used" + */ + private const GLOBAL_NAMED_EXCLUSION = [ + 'composer-plugin-api' => true + ]; + + /** + * List of global pattern which should be excluded from unused check + * Key => pattern + * Value => always "used" + */ + private const GLOBAL_PATTERN_EXCLUSION = [ + '/-implementation$/i' => true + ]; + + /** @var array */ + private array $items; + + /** + * @param array $namedFilter + * @param array $patternFilter + */ + public function __construct(array $namedFilter, array $patternFilter) + { + $globalNamedFilter = array_map( + static fn(string $named, $used) => new NamedFilter($named, $used), + array_keys(self::GLOBAL_NAMED_EXCLUSION), + array_values(self::GLOBAL_NAMED_EXCLUSION) + ); + + $globalPatternFilter = + array_map( + static fn(string $pattern, $used) => new PatternFilter($pattern, $used), + array_keys(self::GLOBAL_PATTERN_EXCLUSION), + array_values(self::GLOBAL_PATTERN_EXCLUSION) + ); + + $named = array_map(static fn(string $named) => new NamedFilter($named), $namedFilter); + $pattern = array_map(static fn(string $pattern) => new PatternFilter($pattern), $patternFilter); + + $this->items = array_merge($globalNamedFilter, $globalPatternFilter, $named, $pattern); + } + + /** + * @return ArrayIterator + */ + public function getIterator(): ArrayIterator + { + return new ArrayIterator($this->items); + } + + public function count(): int + { + return count($this->items); + } + + /** + * @return array + */ + public function getUnused(): array + { + return array_filter( + $this->items, + static fn(FilterInterface $filter) => $filter->used() === false + ); + } +} diff --git a/src/Filter/FilterInterface.php b/src/Filter/FilterInterface.php new file mode 100644 index 00000000..5739583c --- /dev/null +++ b/src/Filter/FilterInterface.php @@ -0,0 +1,16 @@ +filterString = $filterString; + $this->alwaysUsed = $alwaysUsed; + } + + public function applies(DependencyInterface $dependency): bool + { + return $this->used = $dependency->getName() === $this->filterString; + } + + public function used(): bool + { + return $this->alwaysUsed || $this->used; + } + + public function toString(): string + { + return $this->filterString; + } +} diff --git a/src/Filter/PatternFilter.php b/src/Filter/PatternFilter.php new file mode 100644 index 00000000..b57107f3 --- /dev/null +++ b/src/Filter/PatternFilter.php @@ -0,0 +1,35 @@ +pattern = $pattern; + $this->alwaysUsed = $alwaysUsed; + } + + public function applies(DependencyInterface $dependency): bool + { + return $this->used = (bool)preg_match($this->pattern, $dependency->getName()); + } + + public function used(): bool + { + return $this->alwaysUsed || $this->used; + } + + public function toString(): string + { + return $this->pattern; + } +} diff --git a/tests/Integration/UnusedCommandTest.php b/tests/Integration/UnusedCommandTest.php index 206f731e..8b578b89 100644 --- a/tests/Integration/UnusedCommandTest.php +++ b/tests/Integration/UnusedCommandTest.php @@ -55,7 +55,7 @@ public function itShouldNotReportExtDsAsUnused(): void $exitCode = $commandTester->execute([]); self::assertSame(0, $exitCode); - self::assertStringContainsString('Found 2 used, 0 unused and 0 ignored packages', $commandTester->getDisplay()); + self::assertStringContainsString('Found 2 used, 0 unused, 0 ignored and 0 zombie packages', $commandTester->getDisplay()); } /** @@ -68,7 +68,7 @@ public function itShouldNoReportUnusedWithAutoloadFilesWithRequire(): void $exitCode = $commandTester->execute([]); self::assertSame(0, $exitCode); - self::assertStringContainsString('Found 2 used, 0 unused and 0 ignored packages', $commandTester->getDisplay()); + self::assertStringContainsString('Found 2 used, 0 unused, 0 ignored and 0 zombie packages', $commandTester->getDisplay()); } /** @@ -82,7 +82,7 @@ public function itShouldNotReportSpecialPackages(): void self::assertSame(0, $exitCode); self::assertStringNotContainsString('composer-plugin-api', $commandTester->getDisplay()); - self::assertStringContainsString('Found 0 used, 0 unused and 0 ignored packages', $commandTester->getDisplay()); + self::assertStringContainsString('Found 0 used, 0 unused, 0 ignored and 0 zombie packages', $commandTester->getDisplay()); } /** @@ -96,7 +96,7 @@ public function itShouldNotReportExcludedPackages(): void self::assertSame(0, $exitCode); self::assertStringNotContainsString('dummy/test-package', $commandTester->getDisplay()); - self::assertStringContainsString('Found 0 used, 0 unused and 0 ignored packages', $commandTester->getDisplay()); + self::assertStringContainsString('Found 0 used, 0 unused, 0 ignored and 0 zombie packages', $commandTester->getDisplay()); } /** @@ -111,7 +111,7 @@ public function itShouldNotReportPatternExcludedPackages(): void self::assertSame(1, $exitCode); self::assertStringNotContainsString('-implementation', $commandTester->getDisplay()); self::assertStringContainsString('dummy/test-package', $commandTester->getDisplay()); - self::assertStringContainsString('Found 0 used, 1 unused and 0 ignored packages', $commandTester->getDisplay()); + self::assertStringContainsString('Found 0 used, 1 unused, 0 ignored and 0 zombie packages', $commandTester->getDisplay()); } /** @@ -124,6 +124,20 @@ public function itShouldNotReportFileDependencyWithFunctionGuard(): void $exitCode = $commandTester->execute([]); self::assertSame(0, $exitCode); - self::assertStringContainsString('Found 1 used, 0 unused and 0 ignored packages', $commandTester->getDisplay()); + self::assertStringContainsString('Found 1 used, 0 unused, 0 ignored and 0 zombie packages', $commandTester->getDisplay()); + } + + /** + * @test + */ + public function itShouldReportUnusedZombies(): void + { + chdir(__DIR__ . '/../assets/TestProjects/UnusedZombies'); + $commandTester = new CommandTester($this->container->get(UnusedCommand::class)); + $exitCode = $commandTester->execute([]); + + self::assertSame(1, $exitCode); + self::assertStringNotContainsString('dummy/test-package', $commandTester->getDisplay()); + self::assertStringContainsString('Found 0 used, 0 unused, 0 ignored and 1 zombie packages', $commandTester->getDisplay()); } } diff --git a/tests/Stubs/TestDependency.php b/tests/Stubs/TestDependency.php new file mode 100644 index 00000000..72bb3beb --- /dev/null +++ b/tests/Stubs/TestDependency.php @@ -0,0 +1,61 @@ +name = $name; + } + + public function getName(): string + { + return $this->name; + } + + public function inState(string $state): bool + { + return false; + } + + public function provides(SymbolInterface $symbol): bool + { + return false; + } + + public function requires(DependencyInterface $dependency): bool + { + return false; + } + + public function suggests(DependencyInterface $dependency): bool + { + return false; + } + + public function requiredBy(DependencyInterface $dependency): void + { + } + + public function getRequiredBy(): array + { + return []; + } + + public function suggestedBy(DependencyInterface $dependency): void + { + } + + public function getSuggestedBy(): array + { + return []; + } +} diff --git a/tests/Unit/Filter/NamedFilterTest.php b/tests/Unit/Filter/NamedFilterTest.php new file mode 100644 index 00000000..7967a95e --- /dev/null +++ b/tests/Unit/Filter/NamedFilterTest.php @@ -0,0 +1,45 @@ +used()); + } + + /** + * @test + */ + public function itShouldMarkFilterAsUsed(): void + { + $filter = new NamedFilter('test'); + $dependency = new TestDependency('test'); + + self::assertTrue($filter->applies($dependency), 'dependency named "test" should apply to named filter "test"'); + self::assertTrue($filter->used()); + } + + /** + * @test + */ + public function itShouldRemainUnused(): void + { + $filter = new NamedFilter('test'); + $dependency = new TestDependency('fubar'); + + self::assertFalse($filter->applies($dependency), 'dependency named "fubar" should not apply to named filter "test"'); + self::assertFalse($filter->used()); + } +} diff --git a/tests/Unit/Filter/PatternFilterTest.php b/tests/Unit/Filter/PatternFilterTest.php new file mode 100644 index 00000000..88ac70b0 --- /dev/null +++ b/tests/Unit/Filter/PatternFilterTest.php @@ -0,0 +1,48 @@ +applies($dependency), 'Dependency named "fubar" should not apply to pattern "/test/"'); + self::assertTrue($filter->used(), 'Filter should remain true'); + } + + /** + * @test + */ + public function itShouldMarkFilterAsUsed(): void + { + $filter = new PatternFilter('/test/'); + $dependency = new TestDependency('test'); + + self::assertTrue($filter->applies($dependency), 'dependency named "test" should apply to pattern "/test/"'); + self::assertTrue($filter->used()); + } + + /** + * @test + */ + public function itShouldRemainUnused(): void + { + $filter = new PatternFilter('/test/'); + $dependency = new TestDependency('fubar'); + + self::assertFalse($filter->applies($dependency), 'dependency named "fubar" should not apply to pattern "/test/"'); + self::assertFalse($filter->used()); + } +} diff --git a/tests/assets/TestProjects/EmptyRequire/composer.json b/tests/assets/TestProjects/EmptyRequire/composer.json index eb0d2c25..e080b1f9 100644 --- a/tests/assets/TestProjects/EmptyRequire/composer.json +++ b/tests/assets/TestProjects/EmptyRequire/composer.json @@ -1,15 +1,10 @@ { "name": "foo/bar", - "description": "foobar", - "type": "library", - "minimum-stability": "stable", "require": { }, "autoload": { "psr-4": { "EmptyRequire\\": "src/" } - }, - "require-dev": { } } diff --git a/tests/assets/TestProjects/ExtDsRequirement/composer.json b/tests/assets/TestProjects/ExtDsRequirement/composer.json index ad78fd6c..64ca7440 100644 --- a/tests/assets/TestProjects/ExtDsRequirement/composer.json +++ b/tests/assets/TestProjects/ExtDsRequirement/composer.json @@ -1,8 +1,5 @@ { "name": "foo/bar", - "description": "foobar", - "type": "library", - "minimum-stability": "stable", "require": { "php": ">=8.0", "ext-ds": "*" diff --git a/tests/assets/TestProjects/FileDependencyFunctionWithGuard/composer.json b/tests/assets/TestProjects/FileDependencyFunctionWithGuard/composer.json index 29232cfd..f3e32e08 100644 --- a/tests/assets/TestProjects/FileDependencyFunctionWithGuard/composer.json +++ b/tests/assets/TestProjects/FileDependencyFunctionWithGuard/composer.json @@ -1,8 +1,5 @@ { "name": "foo/bar", - "description": "foobar", - "type": "library", - "minimum-stability": "stable", "require": { "test/file-dependency": "*" }, @@ -10,7 +7,5 @@ "psr-4": { "FileDependencyFunctionWithGuard\\": "src/" } - }, - "require-dev": { } } diff --git a/tests/assets/TestProjects/OnlyLanguageRequirement/composer.json b/tests/assets/TestProjects/OnlyLanguageRequirement/composer.json index cd700514..862dc515 100644 --- a/tests/assets/TestProjects/OnlyLanguageRequirement/composer.json +++ b/tests/assets/TestProjects/OnlyLanguageRequirement/composer.json @@ -1,8 +1,5 @@ { "name": "foo/bar", - "description": "foobar", - "type": "library", - "minimum-stability": "stable", "require": { "php": ">=7.4 <7.5" }, diff --git a/tests/assets/TestProjects/UnusedZombies/composer.json b/tests/assets/TestProjects/UnusedZombies/composer.json new file mode 100644 index 00000000..7cb4c442 --- /dev/null +++ b/tests/assets/TestProjects/UnusedZombies/composer.json @@ -0,0 +1,10 @@ +{ + "name": "foo/bar", + "require": { + }, + "extra": { + "unused": [ + "test/dependency" + ] + } +}