diff --git a/composer.json b/composer.json index 9660a8c5..1cd4e81f 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "http-interop/http-factory-guzzle": "^1.2", "knplabs/github-api": "^3.13", "m4tthumphrey/php-gitlab-api": "^11.12", + "nette/neon": "^3.4", "nikic/php-parser": "4.15.*", "phpstan/phpstan": "^1.10", "spryker-sdk/azure-php-client": "^0.2.1", diff --git a/composer.lock b/composer.lock index 2978cfc2..d31e1c5d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b65be9e5a7516b937829e37a0fd7a6a1", + "content-hash": "8d9d0654ca17cfd66c8d12b645e32828", "packages": [ { "name": "aws/aws-crt-php", @@ -1799,6 +1799,74 @@ }, "time": "2023-08-25T10:54:48+00:00" }, + { + "name": "nette/neon", + "version": "v3.4.3", + "source": { + "type": "git", + "url": "https://github.com/nette/neon.git", + "reference": "c8481c104431c8d94cc88424a1e21f47f8c93280" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/neon/zipball/c8481c104431c8d94cc88424a1e21f47f8c93280", + "reference": "c8481c104431c8d94cc88424a1e21f47f8c93280", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "8.0 - 8.3" + }, + "require-dev": { + "nette/tester": "^2.4", + "phpstan/phpstan": "^1.0", + "tracy/tracy": "^2.7" + }, + "bin": [ + "bin/neon-lint" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.4-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🍸 Nette NEON: encodes and decodes NEON file format.", + "homepage": "https://ne-on.org", + "keywords": [ + "export", + "import", + "neon", + "nette", + "yaml" + ], + "support": { + "issues": "https://github.com/nette/neon/issues", + "source": "https://github.com/nette/neon/tree/v3.4.3" + }, + "time": "2024-06-26T14:53:59+00:00" + }, { "name": "nikic/php-parser", "version": "v4.15.5", diff --git a/config/DynamicEvaluator/checker_phpstan.neon b/config/DynamicEvaluator/checker_phpstan.neon index 246249ab..f6faa7e6 100644 --- a/config/DynamicEvaluator/checker_phpstan.neon +++ b/config/DynamicEvaluator/checker_phpstan.neon @@ -1,5 +1,7 @@ parameters: level: 3 # for performance reasons + parallel: + maximumNumberOfProcesses: 2 paths: - %currentWorkingDirectory%/src excludePaths: @@ -8,3 +10,5 @@ parameters: - %currentWorkingDirectory%/src/Orm/* - %currentWorkingDirectory%/src/Pyz/Zed/*/Persistence/*Repository.php - %currentWorkingDirectory%/src/Pyz/Zed/*/Persistence/*QueryContainer.php + - %currentWorkingDirectory%/src/**/Spy*.php + - %currentWorkingDirectory%/src/**/*Transfer.php diff --git a/config/DynamicEvaluator/checker_phpstan_include_project.neon b/config/DynamicEvaluator/checker_phpstan_include_project.neon new file mode 100644 index 00000000..c47f4dd3 --- /dev/null +++ b/config/DynamicEvaluator/checker_phpstan_include_project.neon @@ -0,0 +1,16 @@ +includes: + - %currentWorkingDirectory%/phpstan.neon +parameters: + level: 3 # for performance reasons + parallel: + maximumNumberOfProcesses: 2 + paths: + - %currentWorkingDirectory%/src + excludePaths: + analyse: + - %currentWorkingDirectory%/src/Generated/* + - %currentWorkingDirectory%/src/Orm/* + - %currentWorkingDirectory%/src/**/*Repository.php + - %currentWorkingDirectory%/src/**/*QueryContainer.php + - %currentWorkingDirectory%/src/**/Spy*.php + - %currentWorkingDirectory%/src/**/*Transfer.php diff --git a/config/DynamicEvaluator/services.yaml b/config/DynamicEvaluator/services.yaml index 2d39deb9..e9ab50c7 100644 --- a/config/DynamicEvaluator/services.yaml +++ b/config/DynamicEvaluator/services.yaml @@ -1,6 +1,7 @@ parameters: checker_broken_php_files_executable_path: '%kernel.project_dir%/vendor/bin/phpstan' checker_broken_php_files_config_executable_path: '%upgrader.root_dir%/config/DynamicEvaluator/checker_phpstan.neon' + checker_broken_php_files_with_project_config_executable_path: '%upgrader.root_dir%/config/DynamicEvaluator/checker_phpstan_include_project.neon' services: _defaults: @@ -17,6 +18,7 @@ services: DynamicEvaluator\Application\Checker\BrokenPhpFilesChecker\FileErrorsFetcher\FileErrorsFetcher: arguments: - '%checker_broken_php_files_config_executable_path%' + - '%checker_broken_php_files_with_project_config_executable_path%' - '%checker_broken_php_files_executable_path%' - '@SprykerSdk\Utils\Infrastructure\Service\ProcessRunnerService' diff --git a/src/DynamicEvaluator/Application/Checker/BrokenPhpFilesChecker/FileErrorsFetcher/FileErrorsFetcher.php b/src/DynamicEvaluator/Application/Checker/BrokenPhpFilesChecker/FileErrorsFetcher/FileErrorsFetcher.php index afd416b6..29ddd711 100644 --- a/src/DynamicEvaluator/Application/Checker/BrokenPhpFilesChecker/FileErrorsFetcher/FileErrorsFetcher.php +++ b/src/DynamicEvaluator/Application/Checker/BrokenPhpFilesChecker/FileErrorsFetcher/FileErrorsFetcher.php @@ -13,10 +13,14 @@ use DynamicEvaluator\Application\Checker\BrokenPhpFilesChecker\Dto\FileErrorDto; use Exception; use InvalidArgumentException; +use Nette\Neon\Neon; use Psr\Log\LoggerInterface; use RuntimeException; use SprykerSdk\Utils\Infrastructure\Service\ProcessRunnerServiceInterface; +use Symfony\Component\Finder\Finder; use Symfony\Component\Process\Exception\ProcessTimedOutException; +use Symfony\Component\Process\Process; +use Upgrade\Infrastructure\Configuration\ConfigurationProvider; class FileErrorsFetcher implements FileErrorsFetcherInterface { @@ -45,6 +49,11 @@ class FileErrorsFetcher implements FileErrorsFetcherInterface */ protected LoggerInterface $logger; + /** + * @var \Upgrade\Infrastructure\Configuration\ConfigurationProvider + */ + protected ConfigurationProvider $configurationProvider; + /** * @var string */ @@ -56,6 +65,7 @@ class FileErrorsFetcher implements FileErrorsFetcherInterface * @param \SprykerSdk\Utils\Infrastructure\Service\ProcessRunnerServiceInterface $processRunnerService * @param \DynamicEvaluator\Application\Checker\BrokenPhpFilesChecker\Baseline\BaselineStorageInterface $baselineStorage * @param \Psr\Log\LoggerInterface $logger + * @param \Upgrade\Infrastructure\Configuration\ConfigurationProvider $configurationProvider * @param string $phpstanNeonFileName */ public function __construct( @@ -64,6 +74,7 @@ public function __construct( ProcessRunnerServiceInterface $processRunnerService, BaselineStorageInterface $baselineStorage, LoggerInterface $logger, + ConfigurationProvider $configurationProvider, string $phpstanNeonFileName = 'phpstan.neon' ) { $this->executableConfig = $executableConfig; @@ -71,6 +82,7 @@ public function __construct( $this->processRunnerService = $processRunnerService; $this->baselineStorage = $baselineStorage; $this->logger = $logger; + $this->configurationProvider = $configurationProvider; $this->phpstanNeonFileName = $phpstanNeonFileName; } @@ -83,6 +95,10 @@ public function fetchProjectFileErrorsAndSaveInBaseLine(array $dirs = []): array { $fileErrors = []; + if ($dirs === [] && $this->configurationProvider->isPhpStanOptimizationRun() === true) { + $dirs = $this->getDirectories($dirs); + } + try { $errors = $this->fetchErrorsArray($dirs); } catch (ProcessTimedOutException $e) { @@ -150,14 +166,51 @@ protected function fetchNewFileError(string $file, array $message): ?FileErrorDt } /** - * @param array $dirs + * @return void + */ + public function reset(): void + { + $this->processRunnerService->mustRun([ + $this->executable, + 'clear-result-cache', + '-c', + $this->executableConfig, + ]); + + $this->baselineStorage->clear(); + } + + /** + * @param array $data + * @param string $key + * @param bool $isArray * - * @throws \RuntimeException + * @throws \InvalidArgumentException + * + * @return void + */ + protected function assertArrayKeyExists(array $data, string $key, bool $isArray = false): void + { + if (!array_key_exists($key, $data) || ($isArray && !is_array($data[$key]))) { + throw new InvalidArgumentException(sprintf( + 'Unable to find %s key or it\'s not an array in %s. Tooling export format is changes.', + $key, + substr(json_encode($data, \JSON_THROW_ON_ERROR), 0, 100), + )); + } + } + + /** + * @param array $dirs * * @return array */ protected function fetchErrorsArray(array $dirs): array { + if ($dirs !== [] && $this->configurationProvider->isPhpStanOptimizationRun() === true) { + return $this->fetchErrorsArrayPerDirectory($dirs); + } + $process = $this->processRunnerService->run([ $this->executable, 'analyse', @@ -168,6 +221,44 @@ protected function fetchErrorsArray(array $dirs): array ...$dirs, ]); + return $this->runProcess($process); + } + + /** + * @param array $dirs + * + * @return array + */ + protected function fetchErrorsArrayPerDirectory(array $dirs): array + { + $result = []; + + foreach ($dirs as $dir) { + $process = $this->processRunnerService->run([ + $this->executable, + 'analyse', + '-c', + $this->executableConfig, + '--error-format', + 'prettyJson', + $dir, + ]); + + $result = array_merge_recursive($result, $this->runProcess($process)); + } + + return $result; + } + + /** + * @param \Symfony\Component\Process\Process $process + * + * @throws \RuntimeException + * + * @return array + */ + protected function runProcess(Process $process): array + { try { $result = json_decode($process->getOutput(), true, 512, \JSON_THROW_ON_ERROR); } catch (Exception $e) { @@ -187,37 +278,66 @@ protected function fetchErrorsArray(array $dirs): array } /** - * @return void + * @param string $neonFilePath + * + * @return array */ - public function reset(): void + protected function parseNeonFile(string $neonFilePath): array { - $this->processRunnerService->mustRun([ - $this->executable, - 'clear-result-cache', - '-c', - $this->executableConfig, - ]); + if (file_exists($neonFilePath) === false) { + return []; + } - $this->baselineStorage->clear(); + $neonContent = file_get_contents($neonFilePath); + + if ($neonContent === false) { + return []; + } + + return Neon::decode($neonContent); } /** - * @param array $data - * @param string $key - * @param bool $isArray + * @param array $dirs * - * @throws \InvalidArgumentException + * @return array + */ + protected function getDirectories(array $dirs = []): array + { + $config = $this->parseNeonFile($this->executableConfig); + + if ($config === []) { + return $dirs; + } + + foreach ($config['parameters']['paths'] as $basePath) { + $basePath = str_replace('%currentWorkingDirectory%', getcwd() ?: '', $basePath); + $dirs = array_merge($dirs, $this->findDirectories($basePath, $config['parameters']['excludePaths']['analyse'])); + } + + return $dirs; + } + + /** + * @param string $baseDir + * @param array $excludePaths * - * @return void + * @return array */ - protected function assertArrayKeyExists(array $data, string $key, bool $isArray = false): void + protected function findDirectories(string $baseDir, array $excludePaths): array { - if (!array_key_exists($key, $data) || ($isArray && !is_array($data[$key]))) { - throw new InvalidArgumentException(sprintf( - 'Unable to find %s key or it\'s not an array in %s. Tooling export format is changes.', - $key, - substr(json_encode($data, \JSON_THROW_ON_ERROR), 0, 100), - )); + $finder = new Finder(); + $finder->directories()->in($baseDir)->depth('== 1'); + + foreach ($excludePaths as $excludePath) { + $finder->notPath($excludePath); } + + $dirs = []; + foreach ($finder as $dir) { + $dirs[] = $dir->getRealPath(); + } + + return $dirs; } } diff --git a/src/Upgrade/Application/Provider/ConfigurationProviderInterface.php b/src/Upgrade/Application/Provider/ConfigurationProviderInterface.php index e9ce21bd..410d0348 100644 --- a/src/Upgrade/Application/Provider/ConfigurationProviderInterface.php +++ b/src/Upgrade/Application/Provider/ConfigurationProviderInterface.php @@ -153,4 +153,12 @@ public function getManifestsRatingThreshold(): int; * @return array */ public function getPullRequestReviewers(): array; + + /** + * Specification: + * - Defines whether to run PHPStan per directory or analyze all files at once. + * + * @return bool + */ + public function isPhpStanOptimizationRun(): bool; } diff --git a/src/Upgrade/Infrastructure/Configuration/ConfigurationProvider.php b/src/Upgrade/Infrastructure/Configuration/ConfigurationProvider.php index 1cffa71a..f820ed35 100644 --- a/src/Upgrade/Infrastructure/Configuration/ConfigurationProvider.php +++ b/src/Upgrade/Infrastructure/Configuration/ConfigurationProvider.php @@ -420,4 +420,14 @@ public function getPullRequestReviewers(): array { return getenv('PULL_REQUEST_REVIEWERS') ? explode(',', getenv('PULL_REQUEST_REVIEWERS')) : []; } + + /** + * {@inheritDoc} + * + * @return bool + */ + public function isPhpStanOptimizationRun(): bool + { + return EnvFetcher::getBool('PHPSTAN_OPTIMIZATION_RUN', false); + } } diff --git a/tests/DynamicEvaluatorTest/Application/Checker/BrokenPhpFilesChecker/FileErrorsFetcher/FileErrorsFetcherTest.php b/tests/DynamicEvaluatorTest/Application/Checker/BrokenPhpFilesChecker/FileErrorsFetcher/FileErrorsFetcherTest.php index 7791de44..49277e37 100644 --- a/tests/DynamicEvaluatorTest/Application/Checker/BrokenPhpFilesChecker/FileErrorsFetcher/FileErrorsFetcherTest.php +++ b/tests/DynamicEvaluatorTest/Application/Checker/BrokenPhpFilesChecker/FileErrorsFetcher/FileErrorsFetcherTest.php @@ -19,6 +19,7 @@ use SprykerSdk\Utils\Infrastructure\Service\ProcessRunnerServiceInterface; use Symfony\Component\Process\Exception\ProcessTimedOutException; use Symfony\Component\Process\Process; +use Upgrade\Infrastructure\Configuration\ConfigurationProvider; class FileErrorsFetcherTest extends TestCase { @@ -34,7 +35,14 @@ public function testFetchProjectFileErrorsAndSaveInBaseLineShouldValidate(array // Arrange $this->expectException(InvalidArgumentException::class); - $fileErrorsFetcher = new FileErrorsFetcher('', '', $this->createProcessRunnerServiceMock($toolOutput), new BaselineStorage(), $this->createLoggerMock()); + $fileErrorsFetcher = new FileErrorsFetcher( + '', + '', + $this->createProcessRunnerServiceMock($toolOutput), + new BaselineStorage(), + $this->createLoggerMock(), + $this->createConfigurationProviderMock(), + ); // Act $fileErrorsFetcher->fetchProjectFileErrorsAndSaveInBaseLine(); @@ -64,7 +72,7 @@ public function testFetchProjectFileErrorsAndSaveInBaseLineShouldReturnEmptyWhen $baseLineStorage = new BaselineStorage(); $baseLineStorage->addFileError(new FileErrorDto('src/someClass.php', 1, 'test message')); - $fileErrorsFetcher = new FileErrorsFetcher('', '', $this->createProcessRunnerServiceMock($toolOutput), $baseLineStorage, $this->createLoggerMock()); + $fileErrorsFetcher = new FileErrorsFetcher('', '', $this->createProcessRunnerServiceMock($toolOutput), $baseLineStorage, $this->createLoggerMock(), $this->createConfigurationProviderMock()); // Act $fileErrors = $fileErrorsFetcher->fetchProjectFileErrorsAndSaveInBaseLine(); @@ -85,7 +93,7 @@ public function testFetchProjectFileErrorsAndSaveInBaseLineShouldFetchFileErrors { // Arrange $baseLineStorage = new BaselineStorage(); - $fileErrorsFetcher = new FileErrorsFetcher('', '', $this->createProcessRunnerServiceMock($toolOutput), $baseLineStorage, $this->createLoggerMock()); + $fileErrorsFetcher = new FileErrorsFetcher('', '', $this->createProcessRunnerServiceMock($toolOutput), $baseLineStorage, $this->createLoggerMock(), $this->createConfigurationProviderMock()); // Act $fileErrors = $fileErrorsFetcher->fetchProjectFileErrorsAndSaveInBaseLine(); @@ -127,7 +135,7 @@ public function testResetShouldInvokeBaselineStorageResetting(): void $baseLineStorageMock = $this->createMock(BaselineStorage::class); $baseLineStorageMock->expects($this->once())->method('clear'); - $fileErrorsFetcher = new FileErrorsFetcher('', '', $this->createProcessRunnerServiceMock([]), $baseLineStorageMock, $this->createLoggerMock()); + $fileErrorsFetcher = new FileErrorsFetcher('', '', $this->createProcessRunnerServiceMock([]), $baseLineStorageMock, $this->createLoggerMock(), $this->createConfigurationProviderMock()); // Act $fileErrorsFetcher->reset(); @@ -158,6 +166,19 @@ public function createLoggerMock(): LoggerInterface return $this->createMock(LoggerInterface::class); } + /** + * @param bool $configurableValue + * + * @return \Upgrade\Infrastructure\Configuration\ConfigurationProvider + */ + protected function createConfigurationProviderMock(bool $configurableValue = false): ConfigurationProvider + { + $configurationProviderMock = $this->createMock(ConfigurationProvider::class); + $configurationProviderMock->method('isPhpStanOptimizationRun')->willReturn($configurableValue); + + return $configurationProviderMock; + } + /** * @return void */ @@ -174,7 +195,7 @@ public function testRunWithoutProjectConfig(): void ->with(['phpstan', 'analyse', '-c', 'internal', '--error-format', 'prettyJson']) ->willReturn($processMock); - $fileErrorsFetcher = new FileErrorsFetcher('internal', 'phpstan', $processRunnerServiceMock, new BaselineStorage(), $this->createLoggerMock(), 'nonexist.neon'); + $fileErrorsFetcher = new FileErrorsFetcher('internal', 'phpstan', $processRunnerServiceMock, new BaselineStorage(), $this->createLoggerMock(), $this->createConfigurationProviderMock(), 'nonexist.neon'); // Act $fileErrors = $fileErrorsFetcher->fetchProjectFileErrorsAndSaveInBaseLine(); @@ -201,7 +222,7 @@ public function testRunProcessWithTimeout(): void ->method('run') ->willThrowException(new ProcessTimedOutException($processMock, ProcessTimedOutException::TYPE_GENERAL)); - $fileErrorsFetcher = new FileErrorsFetcher('', '', $processRunnerServiceMock, new BaselineStorage(), $loggerMock, 'phpstan.neon'); + $fileErrorsFetcher = new FileErrorsFetcher('', '', $processRunnerServiceMock, new BaselineStorage(), $loggerMock, $this->createConfigurationProviderMock(), 'phpstan.neon'); // Act $fileErrors = $fileErrorsFetcher->fetchProjectFileErrorsAndSaveInBaseLine(); @@ -230,7 +251,7 @@ public function testRunProcessWithException(): void ->method('run') ->willThrowException(new Exception('error')); - $fileErrorsFetcher = new FileErrorsFetcher('', '', $processRunnerServiceMock, new BaselineStorage(), $loggerMock, 'phpstan.neon'); + $fileErrorsFetcher = new FileErrorsFetcher('', '', $processRunnerServiceMock, new BaselineStorage(), $loggerMock, $this->createConfigurationProviderMock(), 'phpstan.neon'); // Act $fileErrors = $fileErrorsFetcher->fetchProjectFileErrorsAndSaveInBaseLine(); @@ -243,4 +264,48 @@ public function testRunProcessWithException(): void ProcessRunnerServiceInterface::DEFAULT_PROCESS_TIMEOUT, ), $fileErrors[0]->getMessage()); } + + /** + * @return void + */ + public function testFetchProjectFileErrorsExecutesPerDir(): void + { + // Arrange + $returnValues = [ + json_encode(['files' => ['src/someClass.php' => ['messages' => [['line' => 1, 'message' => 'test message']]]]], \JSON_THROW_ON_ERROR), + json_encode(['files' => ['src/someClass2.php' => ['messages' => [['line' => 1, 'message' => 'test message2']]]]], \JSON_THROW_ON_ERROR), + ]; + + $process = $this->createMock(Process::class); + $process->expects($this->exactly(2)) + ->method('getOutput') + ->willReturnOnConsecutiveCalls(...$returnValues); + + /** @var \SprykerSdk\Utils\Infrastructure\Service\ProcessRunnerServiceInterface&\PHPUnit\Framework\MockObject\MockObject $processRunnerServiceMock */ + $processRunnerServiceMock = $this->createMock(ProcessRunnerServiceInterface::class); + $processRunnerServiceMock->expects($this->exactly(2)) + ->method('run') + ->willReturn($process); + + $fileErrorsFetcher = new FileErrorsFetcher( + 'config/DynamicEvaluator/checker_phpstan.neon', + '', + $processRunnerServiceMock, + new BaselineStorage(), + $this->createLoggerMock(), + $this->createConfigurationProviderMock(true), + ); + + // Act + $fileErrors = $fileErrorsFetcher->fetchProjectFileErrorsAndSaveInBaseLine(['dir1', 'dir2']); + + // Assert + $this->assertCount(2, $fileErrors); + $this->assertSame('src/someClass.php', $fileErrors[0]->getFilename()); + $this->assertSame(1, $fileErrors[0]->getLine()); + $this->assertSame('test message', $fileErrors[0]->getMessage()); + $this->assertSame('src/someClass2.php', $fileErrors[1]->getFilename()); + $this->assertSame(1, $fileErrors[1]->getLine()); + $this->assertSame('test message2', $fileErrors[1]->getMessage()); + } }