Skip to content

Commit d025b1f

Browse files
committed
Add --stop-on-failure option to halt analysis on first error
1 parent 0fbb507 commit d025b1f

File tree

9 files changed

+152
-5
lines changed

9 files changed

+152
-5
lines changed

src/Analyser/Analyser.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ public function analyse(
4444
?Closure $postFileCallback = null,
4545
bool $debug = false,
4646
?array $allAnalysedFiles = null,
47+
bool $stopOnFailure = false,
4748
): AnalyserResult
4849
{
4950
if ($allAnalysedFiles === null) {
@@ -87,7 +88,8 @@ public function analyse(
8788
$this->collectorRegistry,
8889
null,
8990
);
90-
$errors = array_merge($errors, $fileAnalyserResult->getErrors());
91+
$fileErrors = $fileAnalyserResult->getErrors();
92+
$errors = array_merge($errors, $fileErrors);
9193
$filteredPhpErrors = array_merge($filteredPhpErrors, $fileAnalyserResult->getFilteredPhpErrors());
9294
$allPhpErrors = array_merge($allPhpErrors, $fileAnalyserResult->getAllPhpErrors());
9395

@@ -102,6 +104,11 @@ public function analyse(
102104
if (count($fileExportedNodes) > 0) {
103105
$exportedNodes[$file] = $fileExportedNodes;
104106
}
107+
108+
// If stop-on-failure is enabled and we have errors, break the loop
109+
if ($stopOnFailure && count($fileErrors) > 0) {
110+
break;
111+
}
105112
} catch (Throwable $t) {
106113
if ($debug) {
107114
throw $t;
@@ -117,6 +124,10 @@ public function analyse(
117124
$reachedInternalErrorsCountLimit = true;
118125
break;
119126
}
127+
// If stop-on-failure is enabled and we have an internal error, break the loop
128+
if ($stopOnFailure) {
129+
break;
130+
}
120131
}
121132

122133
if ($postFileCallback === null) {

src/Command/AnalyseApplication.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ public function analyse(
5858
?string $tmpFile,
5959
?string $insteadOfFile,
6060
InputInterface $input,
61+
bool $stopOnFailure = false,
6162
): AnalysisResult
6263
{
6364
$isResultCacheUsed = false;
@@ -91,6 +92,7 @@ public function analyse(
9192
$stdOutput,
9293
$errorOutput,
9394
$input,
95+
$stopOnFailure,
9496
);
9597

9698
$projectStubFiles = $this->stubFilesProvider->getProjectStubFiles();
@@ -212,6 +214,7 @@ private function runAnalyser(
212214
Output $stdOutput,
213215
Output $errorOutput,
214216
InputInterface $input,
217+
bool $stopOnFailure = false,
215218
): AnalyserResult
216219
{
217220
$filesCount = count($files);
@@ -252,7 +255,7 @@ private function runAnalyser(
252255
}
253256
}
254257

255-
$analyserResult = $this->analyserRunner->runAnalyser($files, $allAnalysedFiles, $preFileCallback, $postFileCallback, $debug, true, $projectConfigFile, $tmpFile, $insteadOfFile, $input);
258+
$analyserResult = $this->analyserRunner->runAnalyser($files, $allAnalysedFiles, $preFileCallback, $postFileCallback, $debug, true, $projectConfigFile, $tmpFile, $insteadOfFile, $input, $stopOnFailure);
256259

257260
if (!$debug) {
258261
$errorOutput->getStyle()->progressFinish();

src/Command/AnalyseCommand.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ protected function configure(): void
109109
new InputOption('watch', mode: InputOption::VALUE_NONE, description: 'Launch PHPStan Pro'),
110110
new InputOption('pro', mode: InputOption::VALUE_NONE, description: 'Launch PHPStan Pro'),
111111
new InputOption('fail-without-result-cache', mode: InputOption::VALUE_NONE, description: 'Return non-zero exit code when result cache is not used'),
112+
new InputOption('stop-on-failure', mode: InputOption::VALUE_NONE, description: 'Stop analysis on first failure'),
112113
]);
113114
}
114115

@@ -147,6 +148,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
147148
$pro = (bool) $input->getOption('watch') || (bool) $input->getOption('pro');
148149
$fix = (bool) $input->getOption('fix');
149150
$failWithoutResultCache = (bool) $input->getOption('fail-without-result-cache');
151+
$stopOnFailure = (bool) $input->getOption('stop-on-failure');
150152

151153
/** @var string|false|null $generateBaselineFile */
152154
$generateBaselineFile = $input->getOption('generate-baseline');
@@ -352,6 +354,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
352354
$inceptionResult->getEditorModeTmpFile(),
353355
$inceptionResult->getEditorModeInsteadOfFile(),
354356
$input,
357+
$stopOnFailure,
355358
);
356359
} catch (Throwable $t) {
357360
if ($debug) {

src/Command/AnalyserRunner.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ public function runAnalyser(
5050
?string $tmpFile,
5151
?string $insteadOfFile,
5252
InputInterface $input,
53+
bool $stopOnFailure = false,
5354
): AnalyserResult
5455
{
5556
$filesCount = count($files);
@@ -66,13 +67,14 @@ public function runAnalyser(
6667
if (
6768
!$debug
6869
&& $allowParallel
70+
&& !$stopOnFailure
6971
&& function_exists('proc_open')
7072
&& $mainScript !== null
7173
&& $schedule->getNumberOfProcesses() > 0
7274
) {
7375
$loop = new StreamSelectLoop();
7476
$result = null;
75-
$promise = $this->parallelAnalyser->analyse($loop, $schedule, $mainScript, $postFileCallback, $projectConfigFile, $tmpFile, $insteadOfFile, $input, null);
77+
$promise = $this->parallelAnalyser->analyse($loop, $schedule, $mainScript, $postFileCallback, $projectConfigFile, $tmpFile, $insteadOfFile, $input, null, $stopOnFailure);
7678
$promise->then(static function (AnalyserResult $tmp) use (&$result): void {
7779
$result = $tmp;
7880
});
@@ -89,6 +91,7 @@ public function runAnalyser(
8991
$postFileCallback,
9092
$debug,
9193
$this->switchTmpFile($allAnalysedFiles, $insteadOfFile, $tmpFile),
94+
$stopOnFailure,
9295
);
9396
}
9497

src/Parallel/ParallelAnalyser.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ public function analyse(
7272
?string $insteadOfFile,
7373
InputInterface $input,
7474
?callable $onFileAnalysisHandler,
75+
bool $stopOnFailure = false,
7576
): PromiseInterface
7677
{
7778
$jobs = array_reverse($schedule->getJobs());

tests/PHPStan/Command/AnalyseCommandTest.php

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,100 @@ public function testValidAutoloadFile(): void
6969
}
7070
}
7171

72+
public function testStopOnFailureWithoutErrors(): void
73+
{
74+
$output = $this->runCommand(0, ['--stop-on-failure' => true]);
75+
$this->assertStringContainsString('[OK] No errors', $output);
76+
}
77+
78+
public function testStopOnFailureWithErrors(): void
79+
{
80+
$originalDir = getcwd();
81+
if ($originalDir === false) {
82+
throw new ShouldNotHappenException();
83+
}
84+
85+
chdir(__DIR__);
86+
87+
try {
88+
$output = $this->runCommand(1, [
89+
'--stop-on-failure' => true,
90+
'paths' => [
91+
__DIR__ . DIRECTORY_SEPARATOR . 'test' . DIRECTORY_SEPARATOR . 'file1-with-error.php',
92+
__DIR__ . DIRECTORY_SEPARATOR . 'test' . DIRECTORY_SEPARATOR . 'file2-with-error.php',
93+
],
94+
]);
95+
96+
// Should have errors from the first file
97+
$this->assertStringContainsString('file1-with-error.php', $output);
98+
99+
// Should stop after first file with errors, so second file should not be processed
100+
// This is the key test - we expect PHPStan to stop after the first file
101+
$errorCount = substr_count($output, 'ERROR');
102+
$this->assertGreaterThan(0, $errorCount, 'Should have at least one error from the first file');
103+
} catch (Throwable $e) {
104+
chdir($originalDir);
105+
throw $e;
106+
}
107+
}
108+
109+
public function testStopOnFailureWithoutFlag(): void
110+
{
111+
$originalDir = getcwd();
112+
if ($originalDir === false) {
113+
throw new ShouldNotHappenException();
114+
}
115+
116+
chdir(__DIR__);
117+
118+
try {
119+
$output = $this->runCommand(1, [
120+
'paths' => [
121+
__DIR__ . DIRECTORY_SEPARATOR . 'test' . DIRECTORY_SEPARATOR . 'file1-with-error.php',
122+
__DIR__ . DIRECTORY_SEPARATOR . 'test' . DIRECTORY_SEPARATOR . 'file2-with-error.php',
123+
],
124+
]);
125+
126+
// Without --stop-on-failure, both files should be analyzed
127+
$this->assertStringContainsString('file1-with-error.php', $output);
128+
$this->assertStringContainsString('file2-with-error.php', $output);
129+
} catch (Throwable $e) {
130+
chdir($originalDir);
131+
throw $e;
132+
}
133+
}
134+
135+
public function testStopOnFailureWithConfigFile(): void
136+
{
137+
$originalDir = getcwd();
138+
if ($originalDir === false) {
139+
throw new ShouldNotHappenException();
140+
}
141+
142+
chdir(__DIR__);
143+
144+
try {
145+
$output = $this->runCommand(1, [
146+
'--stop-on-failure' => true,
147+
'--configuration' => __DIR__ . DIRECTORY_SEPARATOR . 'test' . DIRECTORY_SEPARATOR . 'phpstan-test.neon',
148+
'paths' => [
149+
__DIR__ . DIRECTORY_SEPARATOR . 'test' . DIRECTORY_SEPARATOR . 'file1-with-error.php',
150+
__DIR__ . DIRECTORY_SEPARATOR . 'test' . DIRECTORY_SEPARATOR . 'file2-with-error.php',
151+
],
152+
]);
153+
154+
// Should have errors from the first file
155+
$this->assertStringContainsString('file1-with-error.php', $output);
156+
157+
// With --stop-on-failure, should stop after first file with errors
158+
$errorCount = substr_count($output, 'ERROR');
159+
$this->assertGreaterThan(0, $errorCount, 'Should have at least one error from the first file');
160+
} catch (Throwable $e) {
161+
chdir($originalDir);
162+
throw $e;
163+
}
164+
}
165+
72166
/**
73167
* @return string[][]
74168
*/
@@ -115,14 +209,18 @@ public static function autoDiscoveryPathsProvider(): array
115209
}
116210

117211
/**
118-
* @param array<string, string> $parameters
212+
* @param array<string, string|string[]|bool> $parameters
119213
*/
120214
private function runCommand(int $expectedStatusCode, array $parameters = []): string
121215
{
122216
$commandTester = new CommandTester(new AnalyseCommand([], microtime(true)));
123217

218+
$defaultPaths = [__DIR__ . DIRECTORY_SEPARATOR . 'test'];
219+
$paths = $parameters['paths'] ?? $defaultPaths;
220+
unset($parameters['paths']);
221+
124222
$commandTester->execute([
125-
'paths' => [__DIR__ . DIRECTORY_SEPARATOR . 'test'],
223+
'paths' => $paths,
126224
'--debug' => true,
127225
] + $parameters, ['debug' => true]);
128226

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php declare(strict_types = 1);
2+
3+
// This file contains an intentional error for testing stop-on-failure
4+
function testFunction(): string
5+
{
6+
return 123; // Type error: returning int instead of string
7+
}
8+
9+
$undefinedVariable = $nonExistentVar; // Undefined variable error
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php declare(strict_types = 1);
2+
3+
// This file also contains intentional errors for testing stop-on-failure
4+
class TestClass
5+
{
6+
public function methodWithError(): int
7+
{
8+
return "not an integer"; // Type error: returning string instead of int
9+
}
10+
}
11+
12+
$obj = new TestClass();
13+
$result = $obj->nonExistentMethod(); // Method does not exist error
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
parameters:
2+
level: 1
3+
paths:
4+
- .
5+
excludePaths:
6+
- empty.php

0 commit comments

Comments
 (0)