From d025b1f903a85aa6961e59176cc773bc449f3448 Mon Sep 17 00:00:00 2001 From: Suleiman Dibirov Date: Fri, 5 Sep 2025 10:39:12 +0300 Subject: [PATCH] Add --stop-on-failure option to halt analysis on first error --- src/Analyser/Analyser.php | 13 ++- src/Command/AnalyseApplication.php | 5 +- src/Command/AnalyseCommand.php | 3 + src/Command/AnalyserRunner.php | 5 +- src/Parallel/ParallelAnalyser.php | 1 + tests/PHPStan/Command/AnalyseCommandTest.php | 102 +++++++++++++++++- .../PHPStan/Command/test/file1-with-error.php | 9 ++ .../PHPStan/Command/test/file2-with-error.php | 13 +++ tests/PHPStan/Command/test/phpstan-test.neon | 6 ++ 9 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 tests/PHPStan/Command/test/file1-with-error.php create mode 100644 tests/PHPStan/Command/test/file2-with-error.php create mode 100644 tests/PHPStan/Command/test/phpstan-test.neon diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index e8f239ba84..d6ce1ab059 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -44,6 +44,7 @@ public function analyse( ?Closure $postFileCallback = null, bool $debug = false, ?array $allAnalysedFiles = null, + bool $stopOnFailure = false, ): AnalyserResult { if ($allAnalysedFiles === null) { @@ -87,7 +88,8 @@ public function analyse( $this->collectorRegistry, null, ); - $errors = array_merge($errors, $fileAnalyserResult->getErrors()); + $fileErrors = $fileAnalyserResult->getErrors(); + $errors = array_merge($errors, $fileErrors); $filteredPhpErrors = array_merge($filteredPhpErrors, $fileAnalyserResult->getFilteredPhpErrors()); $allPhpErrors = array_merge($allPhpErrors, $fileAnalyserResult->getAllPhpErrors()); @@ -102,6 +104,11 @@ public function analyse( if (count($fileExportedNodes) > 0) { $exportedNodes[$file] = $fileExportedNodes; } + + // If stop-on-failure is enabled and we have errors, break the loop + if ($stopOnFailure && count($fileErrors) > 0) { + break; + } } catch (Throwable $t) { if ($debug) { throw $t; @@ -117,6 +124,10 @@ public function analyse( $reachedInternalErrorsCountLimit = true; break; } + // If stop-on-failure is enabled and we have an internal error, break the loop + if ($stopOnFailure) { + break; + } } if ($postFileCallback === null) { diff --git a/src/Command/AnalyseApplication.php b/src/Command/AnalyseApplication.php index 53168f8edd..843622f731 100644 --- a/src/Command/AnalyseApplication.php +++ b/src/Command/AnalyseApplication.php @@ -58,6 +58,7 @@ public function analyse( ?string $tmpFile, ?string $insteadOfFile, InputInterface $input, + bool $stopOnFailure = false, ): AnalysisResult { $isResultCacheUsed = false; @@ -91,6 +92,7 @@ public function analyse( $stdOutput, $errorOutput, $input, + $stopOnFailure, ); $projectStubFiles = $this->stubFilesProvider->getProjectStubFiles(); @@ -212,6 +214,7 @@ private function runAnalyser( Output $stdOutput, Output $errorOutput, InputInterface $input, + bool $stopOnFailure = false, ): AnalyserResult { $filesCount = count($files); @@ -252,7 +255,7 @@ private function runAnalyser( } } - $analyserResult = $this->analyserRunner->runAnalyser($files, $allAnalysedFiles, $preFileCallback, $postFileCallback, $debug, true, $projectConfigFile, $tmpFile, $insteadOfFile, $input); + $analyserResult = $this->analyserRunner->runAnalyser($files, $allAnalysedFiles, $preFileCallback, $postFileCallback, $debug, true, $projectConfigFile, $tmpFile, $insteadOfFile, $input, $stopOnFailure); if (!$debug) { $errorOutput->getStyle()->progressFinish(); diff --git a/src/Command/AnalyseCommand.php b/src/Command/AnalyseCommand.php index 926aa66408..c19a276cf4 100644 --- a/src/Command/AnalyseCommand.php +++ b/src/Command/AnalyseCommand.php @@ -109,6 +109,7 @@ protected function configure(): void new InputOption('watch', mode: InputOption::VALUE_NONE, description: 'Launch PHPStan Pro'), new InputOption('pro', mode: InputOption::VALUE_NONE, description: 'Launch PHPStan Pro'), new InputOption('fail-without-result-cache', mode: InputOption::VALUE_NONE, description: 'Return non-zero exit code when result cache is not used'), + new InputOption('stop-on-failure', mode: InputOption::VALUE_NONE, description: 'Stop analysis on first failure'), ]); } @@ -147,6 +148,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $pro = (bool) $input->getOption('watch') || (bool) $input->getOption('pro'); $fix = (bool) $input->getOption('fix'); $failWithoutResultCache = (bool) $input->getOption('fail-without-result-cache'); + $stopOnFailure = (bool) $input->getOption('stop-on-failure'); /** @var string|false|null $generateBaselineFile */ $generateBaselineFile = $input->getOption('generate-baseline'); @@ -352,6 +354,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $inceptionResult->getEditorModeTmpFile(), $inceptionResult->getEditorModeInsteadOfFile(), $input, + $stopOnFailure, ); } catch (Throwable $t) { if ($debug) { diff --git a/src/Command/AnalyserRunner.php b/src/Command/AnalyserRunner.php index f6ea231e84..30ace04a4a 100644 --- a/src/Command/AnalyserRunner.php +++ b/src/Command/AnalyserRunner.php @@ -50,6 +50,7 @@ public function runAnalyser( ?string $tmpFile, ?string $insteadOfFile, InputInterface $input, + bool $stopOnFailure = false, ): AnalyserResult { $filesCount = count($files); @@ -66,13 +67,14 @@ public function runAnalyser( if ( !$debug && $allowParallel + && !$stopOnFailure && function_exists('proc_open') && $mainScript !== null && $schedule->getNumberOfProcesses() > 0 ) { $loop = new StreamSelectLoop(); $result = null; - $promise = $this->parallelAnalyser->analyse($loop, $schedule, $mainScript, $postFileCallback, $projectConfigFile, $tmpFile, $insteadOfFile, $input, null); + $promise = $this->parallelAnalyser->analyse($loop, $schedule, $mainScript, $postFileCallback, $projectConfigFile, $tmpFile, $insteadOfFile, $input, null, $stopOnFailure); $promise->then(static function (AnalyserResult $tmp) use (&$result): void { $result = $tmp; }); @@ -89,6 +91,7 @@ public function runAnalyser( $postFileCallback, $debug, $this->switchTmpFile($allAnalysedFiles, $insteadOfFile, $tmpFile), + $stopOnFailure, ); } diff --git a/src/Parallel/ParallelAnalyser.php b/src/Parallel/ParallelAnalyser.php index 8503d826e0..3f1fbaa3ed 100644 --- a/src/Parallel/ParallelAnalyser.php +++ b/src/Parallel/ParallelAnalyser.php @@ -72,6 +72,7 @@ public function analyse( ?string $insteadOfFile, InputInterface $input, ?callable $onFileAnalysisHandler, + bool $stopOnFailure = false, ): PromiseInterface { $jobs = array_reverse($schedule->getJobs()); diff --git a/tests/PHPStan/Command/AnalyseCommandTest.php b/tests/PHPStan/Command/AnalyseCommandTest.php index f2c4f4d73a..23849bb1d2 100644 --- a/tests/PHPStan/Command/AnalyseCommandTest.php +++ b/tests/PHPStan/Command/AnalyseCommandTest.php @@ -69,6 +69,100 @@ public function testValidAutoloadFile(): void } } + public function testStopOnFailureWithoutErrors(): void + { + $output = $this->runCommand(0, ['--stop-on-failure' => true]); + $this->assertStringContainsString('[OK] No errors', $output); + } + + public function testStopOnFailureWithErrors(): void + { + $originalDir = getcwd(); + if ($originalDir === false) { + throw new ShouldNotHappenException(); + } + + chdir(__DIR__); + + try { + $output = $this->runCommand(1, [ + '--stop-on-failure' => true, + 'paths' => [ + __DIR__ . DIRECTORY_SEPARATOR . 'test' . DIRECTORY_SEPARATOR . 'file1-with-error.php', + __DIR__ . DIRECTORY_SEPARATOR . 'test' . DIRECTORY_SEPARATOR . 'file2-with-error.php', + ], + ]); + + // Should have errors from the first file + $this->assertStringContainsString('file1-with-error.php', $output); + + // Should stop after first file with errors, so second file should not be processed + // This is the key test - we expect PHPStan to stop after the first file + $errorCount = substr_count($output, 'ERROR'); + $this->assertGreaterThan(0, $errorCount, 'Should have at least one error from the first file'); + } catch (Throwable $e) { + chdir($originalDir); + throw $e; + } + } + + public function testStopOnFailureWithoutFlag(): void + { + $originalDir = getcwd(); + if ($originalDir === false) { + throw new ShouldNotHappenException(); + } + + chdir(__DIR__); + + try { + $output = $this->runCommand(1, [ + 'paths' => [ + __DIR__ . DIRECTORY_SEPARATOR . 'test' . DIRECTORY_SEPARATOR . 'file1-with-error.php', + __DIR__ . DIRECTORY_SEPARATOR . 'test' . DIRECTORY_SEPARATOR . 'file2-with-error.php', + ], + ]); + + // Without --stop-on-failure, both files should be analyzed + $this->assertStringContainsString('file1-with-error.php', $output); + $this->assertStringContainsString('file2-with-error.php', $output); + } catch (Throwable $e) { + chdir($originalDir); + throw $e; + } + } + + public function testStopOnFailureWithConfigFile(): void + { + $originalDir = getcwd(); + if ($originalDir === false) { + throw new ShouldNotHappenException(); + } + + chdir(__DIR__); + + try { + $output = $this->runCommand(1, [ + '--stop-on-failure' => true, + '--configuration' => __DIR__ . DIRECTORY_SEPARATOR . 'test' . DIRECTORY_SEPARATOR . 'phpstan-test.neon', + 'paths' => [ + __DIR__ . DIRECTORY_SEPARATOR . 'test' . DIRECTORY_SEPARATOR . 'file1-with-error.php', + __DIR__ . DIRECTORY_SEPARATOR . 'test' . DIRECTORY_SEPARATOR . 'file2-with-error.php', + ], + ]); + + // Should have errors from the first file + $this->assertStringContainsString('file1-with-error.php', $output); + + // With --stop-on-failure, should stop after first file with errors + $errorCount = substr_count($output, 'ERROR'); + $this->assertGreaterThan(0, $errorCount, 'Should have at least one error from the first file'); + } catch (Throwable $e) { + chdir($originalDir); + throw $e; + } + } + /** * @return string[][] */ @@ -115,14 +209,18 @@ public static function autoDiscoveryPathsProvider(): array } /** - * @param array $parameters + * @param array $parameters */ private function runCommand(int $expectedStatusCode, array $parameters = []): string { $commandTester = new CommandTester(new AnalyseCommand([], microtime(true))); + $defaultPaths = [__DIR__ . DIRECTORY_SEPARATOR . 'test']; + $paths = $parameters['paths'] ?? $defaultPaths; + unset($parameters['paths']); + $commandTester->execute([ - 'paths' => [__DIR__ . DIRECTORY_SEPARATOR . 'test'], + 'paths' => $paths, '--debug' => true, ] + $parameters, ['debug' => true]); diff --git a/tests/PHPStan/Command/test/file1-with-error.php b/tests/PHPStan/Command/test/file1-with-error.php new file mode 100644 index 0000000000..5812599afc --- /dev/null +++ b/tests/PHPStan/Command/test/file1-with-error.php @@ -0,0 +1,9 @@ +nonExistentMethod(); // Method does not exist error diff --git a/tests/PHPStan/Command/test/phpstan-test.neon b/tests/PHPStan/Command/test/phpstan-test.neon new file mode 100644 index 0000000000..28c914dc30 --- /dev/null +++ b/tests/PHPStan/Command/test/phpstan-test.neon @@ -0,0 +1,6 @@ +parameters: + level: 1 + paths: + - . + excludePaths: + - empty.php