diff --git a/README.md b/README.md index e3b5240..f740cba 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/mediact/dependency-guard/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/mediact/dependency-guard/?branch=master) [![Code Coverage](https://scrutinizer-ci.com/g/mediact/dependency-guard/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/mediact/dependency-guard/?branch=master) [![Build Status](https://scrutinizer-ci.com/g/mediact/dependency-guard/badges/build.png?b=master)](https://scrutinizer-ci.com/g/mediact/dependency-guard/build-status/master) +[![Build status](https://ci.appveyor.com/api/projects/status/79f486l0u1p2i5gq/branch/master?svg=true)](https://ci.appveyor.com/project/mediactbv/dependency-guard/branch/master) [![Code Intelligence Status](https://scrutinizer-ci.com/g/mediact/dependency-guard/badges/code-intelligence.svg?b=master)](https://scrutinizer-ci.com/code-intelligence) [![license](https://img.shields.io/github/license/mediact/dependency-guard.png)](LICENSE.md) diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 0000000..82ed371 --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,43 @@ +build: off +platform: [x64] +clone_folder: c:\projects\php-project-workspace + +init: + - SET PATH=C:\Program Files\OpenSSL;c:\tools\php;%PATH% + - SET COMPOSER_NO_INTERACTION=1 + - SET PHP=1 + - SET ANSICON=121x90 (121x90) + +environment: + matrix: + - dependencies: lowest + php_ver_target: 7.1 + - dependencies: current + php_ver_target: 7.2 + +cache: + - composer.phar + - C:\ProgramData\chocolatey\bin -> .appveyor.yml + - C:\ProgramData\chocolatey\lib -> .appveyor.yml + - c:\tools\php -> .appveyor.yml + +install: + - IF EXIST c:\tools\php (SET PHP=0) + - ps: appveyor-retry cinst --params '""/InstallDir:C:\tools\php""' --ignore-checksums -y php --version ((choco search php --exact --all-versions -r | select-string -pattern $env:php_ver_target | sort { [version]($_ -split '\|' | select -last 1) } -Descending | Select-Object -first 1) -replace '[php|]','') + - cd c:\tools\php + - IF %PHP%==1 copy php.ini-production php.ini /Y + - IF %PHP%==1 echo date.timezone="UTC" >> php.ini + - IF %PHP%==1 echo extension_dir=ext >> php.ini + - IF %PHP%==1 echo extension=php_openssl.dll >> php.ini + - IF %PHP%==1 echo extension=php_mbstring.dll >> php.ini + - IF %PHP%==1 echo extension=php_fileinfo.dll >> php.ini + - IF %PHP%==1 echo @php %%~dp0composer.phar %%* > composer.bat + - appveyor-retry appveyor DownloadFile https://getcomposer.org/composer.phar + - cd c:\projects\php-project-workspace + - del /f composer.lock + - appveyor-retry composer update --no-progress --profile + - composer show + +test_script: + - cd c:\projects\php-project-workspace + - vendor/bin/grumphp run diff --git a/phpunit.xml b/phpunit.xml index 5c4659c..ad07d23 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -3,7 +3,12 @@ bootstrap="vendor/autoload.php"> + tests/Regression + tests + + tests + tests/Regression tests/Regression diff --git a/src/Candidate/CandidateExtractor.php b/src/Candidate/CandidateExtractor.php index e065041..806214e 100644 --- a/src/Candidate/CandidateExtractor.php +++ b/src/Candidate/CandidateExtractor.php @@ -30,7 +30,7 @@ public function extract( SymbolIteratorInterface $symbols ): iterable { $repository = $composer->getRepositoryManager()->getLocalRepository(); - $vendorPath = $composer->getConfig()->get('vendor-dir', 0); + $vendorPath = str_replace('\\', '/', $composer->getConfig()->get('vendor-dir', 0)); $packages = []; @@ -103,7 +103,7 @@ private function extractPackage( if (!array_key_exists($name, $packagesPerSymbol)) { $reflection = $this->getClassReflection($name); - $file = $reflection->getFileName(); + $file = str_replace('\\', '/', $reflection->getFileName()); // This happens for symbols in the current package. if (strpos($file, $vendorPath) !== 0) { @@ -111,11 +111,11 @@ private function extractPackage( } $structure = explode( - DIRECTORY_SEPARATOR, + '/', preg_replace( sprintf( '/^%s/', - preg_quote($vendorPath . DIRECTORY_SEPARATOR, '/') + preg_quote($vendorPath . '/', '/') ), '', $file diff --git a/src/Composer/Command/Exporter/TextViolationExporter.php b/src/Composer/Command/Exporter/TextViolationExporter.php index 5395b57..e746248 100644 --- a/src/Composer/Command/Exporter/TextViolationExporter.php +++ b/src/Composer/Command/Exporter/TextViolationExporter.php @@ -42,7 +42,7 @@ public function __construct( */ public function export(ViolationIteratorInterface $violations): void { - $root = $this->workingDirectory . DIRECTORY_SEPARATOR; + $root = preg_quote($this->workingDirectory . '/', '#'); foreach ($violations as $violation) { $this->prompt->error($violation->getMessage()); diff --git a/src/Composer/Iterator/SourceFileIteratorFactory.php b/src/Composer/Iterator/SourceFileIteratorFactory.php index 88a862d..fbdb9b7 100644 --- a/src/Composer/Iterator/SourceFileIteratorFactory.php +++ b/src/Composer/Iterator/SourceFileIteratorFactory.php @@ -141,26 +141,21 @@ private function createClassmapIterator( ); } - return new class ($files, ...$exclude) extends FilterIterator { + return new class ($files, $this->preparePattern(...$exclude)) extends FilterIterator { /** @var string|null */ private $excludePattern; /** * Constructor. * - * @param Iterator $iterator - * @param string ...$excludePatterns + * @param Iterator $iterator + * @param string|null $excludePattern */ public function __construct( Iterator $iterator, - string ...$excludePatterns + ?string $excludePattern ) { - if (!empty($excludePatterns)) { - $this->excludePattern = sprintf( - '@^(%s)$@', - implode('|', $excludePatterns) - ); - } + $this->excludePattern = $excludePattern; parent::__construct($iterator); } @@ -181,11 +176,39 @@ public function accept(): bool $this->excludePattern === null ?: !preg_match( $this->excludePattern, - $file->getRealPath() + str_replace('\\', '/', $file->getRealPath()) ) ) ); } }; } + + /** + * @param string ...$excludePatterns + * + * @return string|null + */ + private function preparePattern(string ...$excludePatterns): ?string + { + if (count($excludePatterns) === 0) { + return null; + } + + return sprintf( + '@^(%s)$@', + implode( + '|', + array_map( + function (string $pattern): string { + return preg_quote( + str_replace('\\', '/', $pattern), + '@' + ); + }, + $excludePatterns + ) + ) + ); + } } diff --git a/tests/Composer/Iterator/SourceFileIteratorFactoryTest.php b/tests/Composer/Iterator/SourceFileIteratorFactoryTest.php index affa003..57aad5f 100644 --- a/tests/Composer/Iterator/SourceFileIteratorFactoryTest.php +++ b/tests/Composer/Iterator/SourceFileIteratorFactoryTest.php @@ -35,6 +35,7 @@ class SourceFileIteratorFactoryTest extends TestCase * @covers ::createFilesIterator * @covers ::createNamespaceIterator * @covers ::createClassmapIterator + * @covers ::preparePattern */ public function testCreate(Composer $composer): void { diff --git a/tests/Regression/Issue31/CandidateExtractorTest.php b/tests/Regression/Issue31/CandidateExtractorTest.php new file mode 100644 index 0000000..0324940 --- /dev/null +++ b/tests/Regression/Issue31/CandidateExtractorTest.php @@ -0,0 +1,266 @@ +extract($composer, $symbols); + + $this->assertCount($expected, $candidates); + + foreach ($candidates as $candidate) { + $this->assertInstanceOf(CandidateInterface::class, $candidate); + } + } + + /** + * @return Composer[][]|SymbolIteratorInterface[][]|int[][] + */ + public function extractProvider(): array + { + $config = $this->createMock(Config::class); + + $config + ->expects(self::any()) + ->method('get') + ->with('vendor-dir', 0) + ->willReturn( + str_replace( + '/', + '\\', + realpath( + __DIR__ . '/../../../vendor' + ) + ) + ); + + return [ + [ + $this->createComposer( + $config, + $this->createRepository() + ), + $this->createSymbolIterator(), + 0 + ], + [ + $this->createComposer( + $config, + $this->createRepository() + ), + $this->createSymbolIterator( + // Code outside vendor. + $this->createSymbol(CandidateExtractor::class), + // Code inside vendor. + $this->createSymbol(Composer::class), + // Core code, inside vendor, outside a vendor package. + $this->createSymbol(ClassLoader::class) + ), + 0 + ], + [ + $this->createComposer( + $config, + $this->createRepository( + $this->createPackage('composer/composer') + ) + ), + $this->createSymbolIterator(), + 0 + ], + [ + $this->createComposer( + $config, + $this->createRepository( + $this->createPackage('composer/composer') + ) + ), + $this->createSymbolIterator( + $this->createSymbol(Composer::class) + ), + 1 + ], + [ + $this->createComposer( + $config, + $this->createRepository( + $this->createPackage('composer/composer') + ) + ), + // Multiple symbols per package. + $this->createSymbolIterator( + $this->createSymbol(Composer::class), + $this->createSymbol(Composer::class) + ), + 1 + ], + [ + $this->createComposer( + $config, + // Contains duplicate packages. + $this->createRepository( + $this->createPackage('composer/composer'), + $this->createPackage('composer/composer') + ) + ), + // Multiple symbols per package. + $this->createSymbolIterator( + $this->createSymbol(Composer::class), + $this->createSymbol(Composer::class) + ), + 1 + ] + ]; + } + + /** + * @param string $name + * + * @return SymbolInterface + */ + private function createSymbol(string $name): SymbolInterface + { + /** @var SymbolInterface|MockObject $symbol */ + $symbol = $this->createMock(SymbolInterface::class); + + $symbol + ->expects(self::any()) + ->method('getName') + ->willReturn($name); + + return $symbol; + } + + /** + * @param SymbolInterface ...$symbols + * + * @return SymbolIteratorInterface + */ + private function createSymbolIterator( + SymbolInterface ...$symbols + ): SymbolIteratorInterface { + /** @var SymbolIteratorInterface|MockObject $iterator */ + $iterator = $this->createMock(SymbolIteratorInterface::class); + $valid = array_fill(0, count($symbols), true); + $valid[] = false; + + $iterator + ->expects(self::exactly(count($valid))) + ->method('valid') + ->willReturn(...$valid); + + $iterator + ->expects(self::exactly(count($symbols))) + ->method('current') + ->willReturnOnConsecutiveCalls(...$symbols); + + return $iterator; + } + + /** + * @param string $name + * + * @return PackageInterface + */ + private function createPackage(string $name): PackageInterface + { + /** @var PackageInterface|MockObject $package */ + $package = $this->createMock(PackageInterface::class); + + $package + ->expects(self::any()) + ->method('getName') + ->willReturn($name); + + return $package; + } + + /** + * @param PackageInterface ...$packages + * + * @return WritableRepositoryInterface + */ + private function createRepository( + PackageInterface ...$packages + ): WritableRepositoryInterface { + /** @var WritableRepositoryInterface|MockObject $repository */ + $repository = $this->createMock(WritableRepositoryInterface::class); + + $repository + ->expects(self::any()) + ->method('getPackages') + ->willReturn($packages); + + return $repository; + } + + /** + * @param Config $config + * @param WritableRepositoryInterface $localRepository + * + * @return Composer + */ + private function createComposer( + Config $config, + WritableRepositoryInterface $localRepository + ): Composer { + $repositoryManager = $this->createMock(RepositoryManager::class); + + $repositoryManager + ->expects(self::any()) + ->method('getLocalRepository') + ->willReturn($localRepository); + + /** @var Composer|MockObject $composer */ + $composer = $this->createMock(Composer::class); + + $composer + ->expects(self::any()) + ->method('getConfig') + ->willReturn($config); + + $composer + ->expects(self::any()) + ->method('getRepositoryManager') + ->willReturn($repositoryManager); + + return $composer; + } +} diff --git a/tests/Regression/Issue31/SourceFileIteratorFactoryTest.php b/tests/Regression/Issue31/SourceFileIteratorFactoryTest.php new file mode 100644 index 0000000..0b8bd2f --- /dev/null +++ b/tests/Regression/Issue31/SourceFileIteratorFactoryTest.php @@ -0,0 +1,276 @@ +assertInstanceOf( + FileIteratorInterface::class, + $subject->create($composer) + ); + } + + /** + * @param Config $config + * @param AutoloadGenerator $autoloadGenerator + * + * @return Composer + */ + private function createComposer( + Config $config, + AutoloadGenerator $autoloadGenerator + ): Composer { + /** @var Composer|MockObject $composer */ + $composer = $this->createMock(Composer::class); + + $composer + ->expects(self::any()) + ->method('getInstallationManager') + ->willReturn( + $this->createMock(InstallationManager::class) + ); + + $composer + ->expects(self::any()) + ->method('getPackage') + ->willReturn( + $this->createMock(RootPackageInterface::class) + ); + + $composer + ->expects(self::any()) + ->method('getConfig') + ->willReturn($config); + + $composer + ->expects(self::any()) + ->method('getAutoloadGenerator') + ->willReturn($autoloadGenerator); + + return $composer; + } + + /** + * @param bool $authoritative + * + * @return Config + */ + private function createConfig(bool $authoritative): Config + { + /** @var Config|MockObject $config */ + $config = $this->createMock(Config::class); + + $config + ->expects(self::any()) + ->method('get') + ->with('classmap-authoritative') + ->willReturn($authoritative); + + return $config; + } + + /** + * @param array $directives + * + * @return AutoloadGenerator + */ + public function createAutoloadGenerator( + array $directives = [] + ): AutoloadGenerator { + /** @var AutoloadGenerator|MockObject $generator */ + $generator = $this->createMock(AutoloadGenerator::class); + + $generator + ->expects(self::any()) + ->method('buildPackageMap') + ->with( + self::isInstanceOf(InstallationManager::class), + self::isInstanceOf(RootPackageInterface::class), + self::isType('array') + ) + ->willReturn([]); + + $generator + ->expects(self::any()) + ->method('parseAutoloads') + ->with( + self::isType('array'), + self::isInstanceOf(RootPackageInterface::class) + ) + ->willReturn($directives); + + return $generator; + } + + /** + * @return Composer[][] + */ + public function emptyProvider(): array + { + return [ + [ + $this->createComposer( + $this->createConfig(true), + $this->createAutoloadGenerator() + ) + ], + [ + $this->createComposer( + $this->createConfig(false), + $this->createAutoloadGenerator() + ) + ] + ]; + } + + /** + * @return Composer[][] + */ + public function filesProvider(): array + { + $config = $this->createConfig(true); + + return [ + [ + $this->createComposer( + $config, + $this->createAutoloadGenerator() + ) + ], + [ + $this->createComposer( + $config, + $this->createAutoloadGenerator( + [ + 'files' => [ + // Readable file. + __FILE__, + // Not readable. + __CLASS__ + ] + ] + ) + ) + ] + ]; + } + + /** + * @return Composer[][] + */ + public function classmapProvider(): array + { + return [ + [ + $this->createComposer( + $this->createConfig(true), + $this->createAutoloadGenerator( + [ + 'classmap' => [ + __NAMESPACE__ => __DIR__ + ] + ] + ) + ) + ], + [ + $this->createComposer( + $this->createConfig(false), + $this->createAutoloadGenerator( + [ + 'classmap' => [ + __NAMESPACE__ => __DIR__ + ] + ] + ) + ) + ], + [ + $this->createComposer( + $this->createConfig(true), + $this->createAutoloadGenerator( + [ + 'classmap' => [ + __NAMESPACE__ => __DIR__, + __CLASS__ => __FILE__ + ], + 'exclude-from-classmap' => [ + str_replace('/', '\\', __FILE__) + ] + ] + ) + ) + ], + ]; + } + + /** + * @return Composer[][] + */ + public function namespaceProvider(): array + { + $config = $this->createConfig(true); + + return [ + [ + $this->createComposer( + $config, + $this->createAutoloadGenerator( + [ + 'psr-0' => [ + __NAMESPACE__ => [__DIR__], + __CLASS__ => [__DIR__] + ] + ] + ) + ) + ], + [ + $this->createComposer( + $config, + $this->createAutoloadGenerator( + [ + 'psr-4' => [ + __NAMESPACE__ => [__DIR__], + __CLASS__ => [__DIR__] + ] + ] + ) + ) + ] + ]; + } +} diff --git a/tests/Regression/Issue31/TextViolationExporterTest.php b/tests/Regression/Issue31/TextViolationExporterTest.php new file mode 100644 index 0000000..abb2a77 --- /dev/null +++ b/tests/Regression/Issue31/TextViolationExporterTest.php @@ -0,0 +1,230 @@ +markTestSkipped( + 'This can only be tested on an OS having backslash (\\) as directory separator.' + ); + return; + } + + /** @var SymfonyStyle|MockObject $prompt */ + $prompt = $this->createMock(SymfonyStyle::class); + $subject = new TextViolationExporter( + $prompt, + static::WORKING_DIRECTORY + ); + + $prompt + ->expects(self::exactly($numSuccess)) + ->method('success') + ->with('No dependency violations encountered!'); + + $prompt + ->expects(self::exactly($numError)) + ->method('error') + ->with(self::isType('string')); + + $subject->export($violations); + } + + /** + * @param ViolationInterface ...$violations + * + * @return ViolationIteratorInterface + */ + private function createViolations( + ViolationInterface ...$violations + ): ViolationIteratorInterface { + /** @var ViolationIteratorInterface|MockObject $iterator */ + $iterator = $this->createMock(ViolationIteratorInterface::class); + $valid = array_fill(0, count($violations), true); + $valid[] = false; + + $iterator + ->expects(self::any()) + ->method('count') + ->willReturn(count($violations)); + + $iterator + ->expects(self::any()) + ->method('valid') + ->willReturn(...$valid); + + $iterator + ->expects(self::any()) + ->method('current') + ->willReturnOnConsecutiveCalls( + ...$violations + ); + + return $iterator; + } + + /** + * @param string $message + * @param SymbolInterface ...$symbols + * + * @return ViolationInterface + */ + private function createViolation( + string $message, + SymbolInterface ...$symbols + ): ViolationInterface { + /** @var SymbolIteratorInterface|MockObject $symbolIterator */ + $symbolIterator = $this->createMock(SymbolIteratorInterface::class); + $valid = array_fill(0, count($symbols), true); + $valid[] = false; + + $symbolIterator + ->expects(self::any()) + ->method('valid') + ->willReturn(...$valid); + + $symbolIterator + ->expects(self::any()) + ->method('current') + ->willReturnOnConsecutiveCalls(...$symbols); + + /** @var ViolationInterface|MockObject $violation */ + $violation = $this->createMock(ViolationInterface::class); + + $violation + ->expects(self::any()) + ->method('getMessage') + ->willReturn($message); + + $violation + ->expects(self::any()) + ->method('getSymbols') + ->willReturn($symbolIterator); + + return $violation; + } + + /** + * @param string $name + * @param string $file + * @param int $line + * + * @return SymbolInterface + */ + private function createSymbol( + string $name, + string $file, + int $line + ): SymbolInterface { + /** @var SymbolInterface|MockObject $symbol */ + $symbol = $this->createMock(SymbolInterface::class); + + $symbol + ->expects(self::any()) + ->method('getName') + ->willReturn($name); + + $symbol + ->expects(self::any()) + ->method('getFile') + ->willReturn( + str_replace('/', '\\', $file) + ); + + $symbol + ->expects(self::any()) + ->method('getLine') + ->willReturn($line); + + return $symbol; + } + + /** + * @return ViolationIteratorInterface[][]|int[][] + */ + public function violationProvider(): array + { + return [ + [ + $this->createViolations(), + 1, + 0 + ], + [ + $this->createViolations( + $this->createViolation('foo') + ), + 0, + 2 + ], + [ + $this->createViolations( + $this->createViolation('foo'), + $this->createViolation( + 'bar', + $this->createSymbol( + __CLASS__, + __FILE__, + __LINE__ + ) + ), + $this->createViolation( + 'baz', + $this->createSymbol( + __CLASS__, + __FILE__, + __LINE__ + ), + $this->createSymbol( + __CLASS__, + __FILE__, + __LINE__ + ), + $this->createSymbol( + __CLASS__, + __FILE__, + __LINE__ + ) + ) + ), + 0, + 4 + ] + ]; + } +}