diff --git a/src/Command/AnalyseCommand.php b/src/Command/AnalyseCommand.php index 88e07f64676..8c006c98642 100644 --- a/src/Command/AnalyseCommand.php +++ b/src/Command/AnalyseCommand.php @@ -492,6 +492,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int ); } + // CI runners read stdout and stderr through separate pipes and interleave them + // non-deterministically, so the output following human-readable stdout output goes to stdout too + $summaryOutput = ($generateBaselineFile !== null || $errorFormat === 'table') && (new CiDetector())->isCiDetected() + ? $inceptionResult->getStdOutput() + : $errorOutput; + if ($generateBaselineFile !== null) { $this->runDiagnoseExtensions($container, $inceptionResult->getErrorOutput(), $analysisResult->getProcessedFiles()); if (count($internalErrorsTuples) > 0) { @@ -505,10 +511,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int count($internalErrors) === 1 ? 'An internal error' : 'Internal errors', )); - return $inceptionResult->handleReturn(1, $analysisResult->getPeakMemoryUsageBytes(), $this->analysisStartTime, $analysisResult->getWorkerCount()); + return $inceptionResult->handleReturn(1, $analysisResult->getPeakMemoryUsageBytes(), $this->analysisStartTime, $analysisResult->getWorkerCount(), $summaryOutput); } - return $this->generateBaseline($generateBaselineFile, $inceptionResult, $analysisResult, $output, $allowEmptyBaseline, $baselineExtension, $failWithoutResultCache); + return $this->generateBaseline($generateBaselineFile, $inceptionResult, $analysisResult, $output, $allowEmptyBaseline, $baselineExtension, $failWithoutResultCache, $summaryOutput); } /** @var ErrorFormatter $errorFormatter */ @@ -532,18 +538,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int $exitCode = $errorFormatter->formatErrors($analysisResult, $inceptionResult->getStdOutput()); - $this->runDiagnoseExtensions($container, $inceptionResult->getErrorOutput(), $analysisResult->getProcessedFiles()); + $this->runDiagnoseExtensions($container, $summaryOutput, $analysisResult->getProcessedFiles()); - $errorOutput->writeLineFormatted('⚠️ Result is incomplete because of severe errors. ⚠️'); - $errorOutput->writeLineFormatted(' Fix these errors first and then re-run PHPStan'); - $errorOutput->writeLineFormatted(' to get all reported errors.'); - $errorOutput->writeLineFormatted(''); + $summaryOutput->writeLineFormatted('⚠️ Result is incomplete because of severe errors. ⚠️'); + $summaryOutput->writeLineFormatted(' Fix these errors first and then re-run PHPStan'); + $summaryOutput->writeLineFormatted(' to get all reported errors.'); + $summaryOutput->writeLineFormatted(''); return $inceptionResult->handleReturn( $exitCode, $analysisResult->getPeakMemoryUsageBytes(), $this->analysisStartTime, $analysisResult->getWorkerCount(), + $summaryOutput, ); } @@ -640,15 +647,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int $exitCode = 2; } - $this->reportMissingResultCacheInCi($errorOutput, $analysisResult, $onlyFiles); + $this->reportMissingResultCacheInCi($summaryOutput, $analysisResult, $onlyFiles); - $this->runDiagnoseExtensions($container, $inceptionResult->getErrorOutput(), $analysisResult->getProcessedFiles()); + $this->runDiagnoseExtensions($container, $summaryOutput, $analysisResult->getProcessedFiles()); return $inceptionResult->handleReturn( $exitCode, $analysisResult->getPeakMemoryUsageBytes(), $this->analysisStartTime, $analysisResult->getWorkerCount(), + $summaryOutput, ); } @@ -757,13 +765,13 @@ private function getMessageFromInternalError(FileHelper $fileHelper, InternalErr return $message; } - private function generateBaseline(string $generateBaselineFile, InceptionResult $inceptionResult, AnalysisResult $analysisResult, OutputInterface $output, bool $allowEmptyBaseline, string $baselineExtension, bool $failWithoutResultCache): int + private function generateBaseline(string $generateBaselineFile, InceptionResult $inceptionResult, AnalysisResult $analysisResult, OutputInterface $output, bool $allowEmptyBaseline, string $baselineExtension, bool $failWithoutResultCache, Output $summaryOutput): int { if (!$allowEmptyBaseline && !$analysisResult->hasErrors()) { $inceptionResult->getStdOutput()->getStyle()->error('No errors were found during the analysis. Baseline could not be generated.'); $inceptionResult->getStdOutput()->writeLineFormatted('To allow generating empty baselines, pass --allow-empty-baseline option.'); - return $inceptionResult->handleReturn(1, $analysisResult->getPeakMemoryUsageBytes(), $this->analysisStartTime, $analysisResult->getWorkerCount()); + return $inceptionResult->handleReturn(1, $analysisResult->getPeakMemoryUsageBytes(), $this->analysisStartTime, $analysisResult->getWorkerCount(), $summaryOutput); } $streamOutput = $this->createStreamOutput(); @@ -791,7 +799,7 @@ private function generateBaseline(string $generateBaselineFile, InceptionResult } catch (DirectoryCreatorException $e) { $inceptionResult->getStdOutput()->writeLineFormatted($e->getMessage()); - return $inceptionResult->handleReturn(1, $analysisResult->getPeakMemoryUsageBytes(), $this->analysisStartTime, $analysisResult->getWorkerCount()); + return $inceptionResult->handleReturn(1, $analysisResult->getPeakMemoryUsageBytes(), $this->analysisStartTime, $analysisResult->getWorkerCount(), $summaryOutput); } try { @@ -799,7 +807,7 @@ private function generateBaseline(string $generateBaselineFile, InceptionResult } catch (CouldNotWriteFileException $e) { $inceptionResult->getStdOutput()->writeLineFormatted($e->getMessage()); - return $inceptionResult->handleReturn(1, $analysisResult->getPeakMemoryUsageBytes(), $this->analysisStartTime, $analysisResult->getWorkerCount()); + return $inceptionResult->handleReturn(1, $analysisResult->getPeakMemoryUsageBytes(), $this->analysisStartTime, $analysisResult->getWorkerCount(), $summaryOutput); } $errorsCount = 0; @@ -839,7 +847,7 @@ private function generateBaseline(string $generateBaselineFile, InceptionResult $exitCode = 2; } - return $inceptionResult->handleReturn($exitCode, $analysisResult->getPeakMemoryUsageBytes(), $this->analysisStartTime, $analysisResult->getWorkerCount()); + return $inceptionResult->handleReturn($exitCode, $analysisResult->getPeakMemoryUsageBytes(), $this->analysisStartTime, $analysisResult->getWorkerCount(), $summaryOutput); } /** diff --git a/src/Command/InceptionResult.php b/src/Command/InceptionResult.php index 0999d63329b..1ed88391b45 100644 --- a/src/Command/InceptionResult.php +++ b/src/Command/InceptionResult.php @@ -103,36 +103,38 @@ public function getEditorModeInsteadOfFile(): ?string /** * @param int|null $peakMemoryUsageBytes the heaviest parallel worker's peak, 0 when the * analysis ran in this process + * @param Output|null $summaryOutput error output by default */ - public function handleReturn(int $exitCode, ?int $peakMemoryUsageBytes, float $analysisStartTime, int $workerCount = 0): int + public function handleReturn(int $exitCode, ?int $peakMemoryUsageBytes, float $analysisStartTime, int $workerCount = 0, ?Output $summaryOutput = null): int { - if ($this->getErrorOutput()->isVerbose()) { + $summaryOutput ??= $this->getErrorOutput(); + if ($summaryOutput->isVerbose()) { $elapsedTime = round(microtime(true) - $analysisStartTime, 2); if ($elapsedTime < 60) { $elapsedTimeString = sprintf('%.2f seconds', $elapsedTime); } else { $elapsedTimeString = $this->formatDuration((int) $elapsedTime); } - $this->getErrorOutput()->writeLineFormatted(sprintf( + $summaryOutput->writeLineFormatted(sprintf( 'Elapsed time: %s', $elapsedTimeString, )); } - if ($peakMemoryUsageBytes !== null && $this->getErrorOutput()->isVerbose()) { + if ($peakMemoryUsageBytes !== null && $summaryOutput->isVerbose()) { // This process's peak is read here, at the very end, so it covers collecting // the workers' results and saving the result cache - the part of a parallel // run where the main process is at its largest. Both numbers are per process, // which is also how memory_limit applies. $mainProcessPeak = memory_get_peak_usage(true); if ($peakMemoryUsageBytes === 0 || $workerCount === 0) { - $this->getErrorOutput()->writeLineFormatted(sprintf( + $summaryOutput->writeLineFormatted(sprintf( 'Peak memory: %s', BytesHelper::bytes($mainProcessPeak), )); } else { $mechanism = $this->container->getByType(ForkParallelChecker::class)->isSupported() ? 'forked' : 'spawned'; - $this->getErrorOutput()->writeLineFormatted(sprintf( + $summaryOutput->writeLineFormatted(sprintf( 'Peak memory: %s (main process), %s (%s)', BytesHelper::bytes($mainProcessPeak), BytesHelper::bytes($peakMemoryUsageBytes), diff --git a/tests/PHPStan/Command/AnalyseCommandTest.php b/tests/PHPStan/Command/AnalyseCommandTest.php index 4eb75fc45c2..f1a8c406e0a 100644 --- a/tests/PHPStan/Command/AnalyseCommandTest.php +++ b/tests/PHPStan/Command/AnalyseCommandTest.php @@ -7,13 +7,17 @@ use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Tester\CommandTester; use Throwable; use function chdir; use function getcwd; +use function getenv; use function microtime; +use function putenv; use function realpath; use function sprintf; +use function sys_get_temp_dir; use const DIRECTORY_SEPARATOR; use const PHP_EOL; @@ -71,6 +75,69 @@ public function testValidAutoloadFile(): void } } + public function testSummaryIsPrintedToStdOutputInCiWithTableFormat(): void + { + [$stdOutput, $errorOutput] = $this->runCommandInGithubActions('table'); + $this->assertStringContainsString('[OK] No errors', $stdOutput); + $this->assertStringContainsString('PHPStan version:', $stdOutput); + $this->assertStringContainsString('Elapsed time:', $stdOutput); + $this->assertStringContainsString('Peak memory:', $stdOutput); + $this->assertStringNotContainsString('PHPStan version:', $errorOutput); + $this->assertStringNotContainsString('Elapsed time:', $errorOutput); + $this->assertStringNotContainsString('Peak memory:', $errorOutput); + } + + public function testSummaryIsPrintedToErrorOutputInCiWithMachineReadableFormat(): void + { + [$stdOutput, $errorOutput] = $this->runCommandInGithubActions('json'); + $this->assertStringNotContainsString('PHPStan version:', $stdOutput); + $this->assertStringNotContainsString('Elapsed time:', $stdOutput); + $this->assertStringNotContainsString('Peak memory:', $stdOutput); + $this->assertStringContainsString('PHPStan version:', $errorOutput); + $this->assertStringContainsString('Elapsed time:', $errorOutput); + $this->assertStringContainsString('Peak memory:', $errorOutput); + } + + public function testSummaryIsPrintedToStdOutputInCiWhenGeneratingBaseline(): void + { + [$stdOutput, $errorOutput] = $this->runCommandInGithubActions('json', 1, ['--generate-baseline' => sys_get_temp_dir() . '/phpstan-summary-baseline.neon']); + $this->assertStringContainsString('To allow generating empty baselines', $stdOutput); + $this->assertStringContainsString('Elapsed time:', $stdOutput); + $this->assertStringNotContainsString('Elapsed time:', $errorOutput); + } + + /** + * @param array $parameters + * @return array{string, string} + */ + private function runCommandInGithubActions(string $errorFormat, int $expectedStatusCode = 0, array $parameters = []): array + { + $originalDir = getcwd(); + if ($originalDir === false) { + throw new ShouldNotHappenException(); + } + $originalGithubActions = getenv('GITHUB_ACTIONS'); + + chdir(__DIR__); + putenv('GITHUB_ACTIONS=true'); + + try { + $commandTester = new CommandTester(new AnalyseCommand([], microtime(true))); + $commandTester->execute([ + 'paths' => [__DIR__ . DIRECTORY_SEPARATOR . 'test'], + '--debug' => true, + '--error-format' => $errorFormat, + ] + $parameters, ['verbosity' => OutputInterface::VERBOSITY_DEBUG, 'capture_stderr_separately' => true]); + + $this->assertSame($expectedStatusCode, $commandTester->getStatusCode(), $commandTester->getDisplay() . $commandTester->getErrorOutput()); + + return [$commandTester->getDisplay(), $commandTester->getErrorOutput()]; + } finally { + chdir($originalDir); + putenv($originalGithubActions === false ? 'GITHUB_ACTIONS' : 'GITHUB_ACTIONS=' . $originalGithubActions); + } + } + /** * @return string[][] */