diff --git a/composer.json b/composer.json index 1377c35a63..2472dffd64 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,8 @@ "nette/utils": "^3.2.5", "nikic/php-parser": "^5.7.0", "ondram/ci-detector": "^4.0", + "helgesverre/toon": "^1.0", + "shipfastlabs/agent-detector": "^1.0", "ondrejmirtes/better-reflection": "6.65.0.9", "ondrejmirtes/composer-attribute-collector": "^1.1.1", "ondrejmirtes/php-merge": "^4.1", diff --git a/composer.lock b/composer.lock index cc2d0a824f..8986776d11 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": "497c2b3e1f40e830554ddfc28db64bf3", + "content-hash": "6d8bd877d4224b1f6901c6d37d8c47d6", "packages": [ { "name": "clue/ndjson-react", @@ -528,6 +528,71 @@ }, "time": "2020-11-24T22:02:12+00:00" }, + { + "name": "helgesverre/toon", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/HelgeSverre/toon-php.git", + "reference": "1d6703a0bbe69663099f36db8c76b4930355667a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/HelgeSverre/toon-php/zipball/1d6703a0bbe69663099f36db8c76b4930355667a", + "reference": "1d6703a0bbe69663099f36db8c76b4930355667a", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "laravel/pint": "^1.10", + "phpbench/phpbench": "^1.4", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "HelgeSverre\\Toon\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Helge Sverre", + "email": "helge.sverre@gmail.com" + } + ], + "description": "Token-Oriented Object Notation - A compact data format for reducing token consumption when sending structured data to LLMs", + "keywords": [ + "ai", + "anthropic", + "claude", + "compression", + "cost-reduction", + "format", + "gpt", + "laravel", + "llm", + "openai", + "optimization", + "prompt", + "serialization", + "token" + ], + "support": { + "issues": "https://github.com/HelgeSverre/toon-php/issues", + "source": "https://github.com/HelgeSverre/toon-php/tree/v1.4.0" + }, + "time": "2025-11-06T11:37:12+00:00" + }, { "name": "hoa/compiler", "version": "3.17.08.08", @@ -3361,6 +3426,74 @@ ], "time": "2024-07-03T04:53:05+00:00" }, + { + "name": "shipfastlabs/agent-detector", + "version": "v1.0.1", + "source": { + "type": "git", + "url": "https://github.com/shipfastlabs/agent-detector.git", + "reference": "4c77d504ea709c570ca0e740c2334add991de244" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/shipfastlabs/agent-detector/zipball/4c77d504ea709c570ca0e740c2334add991de244", + "reference": "4c77d504ea709c570ca0e740c2334add991de244", + "shasum": "" + }, + "require": { + "php": "^8.2.0" + }, + "require-dev": { + "laravel/pint": "^1.24.0", + "peckphp/peck": "^0.1.3", + "pestphp/pest": "^3.8.5|^4.1.0", + "pestphp/pest-plugin-type-coverage": "^3.0|^4.0.2", + "phpstan/phpstan": "^2.1.26", + "rector/rector": "^2.1.7", + "symfony/var-dumper": "^7.3.3" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "AgentDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Pushpak Chahjed", + "email": "pushpak1300@gmail.com" + } + ], + "description": "Detect if code is running in an AI agent or automated development environment", + "keywords": [ + "Agent", + "ai", + "automation", + "claude", + "cursor", + "detection", + "devin", + "php" + ], + "support": { + "issues": "https://github.com/shipfastlabs/agent-detector/issues", + "source": "https://github.com/shipfastlabs/agent-detector/tree/v1.0.1" + }, + "funding": [ + { + "url": "https://github.com/pushpak1300", + "type": "github" + } + ], + "time": "2026-02-12T10:04:52+00:00" + }, { "name": "symfony/console", "version": "v5.4.47", diff --git a/conf/services.neon b/conf/services.neon index bccbdbcf5c..cf8619faa9 100644 --- a/conf/services.neon +++ b/conf/services.neon @@ -197,6 +197,9 @@ services: arguments: pretty: true + errorFormatter.toon: + class: PHPStan\Command\ErrorFormatter\ToonErrorFormatter + stubFileTypeMapper: class: PHPStan\Type\FileTypeMapper arguments: diff --git a/src/Command/AnalyseCommand.php b/src/Command/AnalyseCommand.php index ee40d2b0d7..8338e8db19 100644 --- a/src/Command/AnalyseCommand.php +++ b/src/Command/AnalyseCommand.php @@ -5,6 +5,7 @@ use OndraM\CiDetector\CiDetector; use Override; use PHPStan\Analyser\InternalError; +use PHPStan\Command\ErrorFormatter\AgentDetectedErrorFormatter; use PHPStan\Command\ErrorFormatter\BaselineNeonErrorFormatter; use PHPStan\Command\ErrorFormatter\BaselinePhpErrorFormatter; use PHPStan\Command\ErrorFormatter\ErrorFormatter; @@ -235,11 +236,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int $errorFormat = $inceptionResult->getContainer()->getParameter('errorFormat'); } + $container = $inceptionResult->getContainer(); + + if ($errorFormat === null) { + /** @var AgentDetectedErrorFormatter $agentFormatter */ + $agentFormatter = $container->getByType(AgentDetectedErrorFormatter::class); + if ($agentFormatter->isAgentDetected()) { + $errorFormat = 'toon'; + } + } + if ($errorFormat === null) { $errorFormat = 'table'; } - $container = $inceptionResult->getContainer(); $errorFormatterServiceName = sprintf('errorFormatter.%s', $errorFormat); if (!$container->hasService($errorFormatterServiceName)) { $errorOutput->writeLineFormatted(sprintf( diff --git a/src/Command/ErrorFormatter/AgentDetectedErrorFormatter.php b/src/Command/ErrorFormatter/AgentDetectedErrorFormatter.php new file mode 100644 index 0000000000..17dc223fd3 --- /dev/null +++ b/src/Command/ErrorFormatter/AgentDetectedErrorFormatter.php @@ -0,0 +1,35 @@ +isAgent; + } + + public function formatErrors(AnalysisResult $analysisResult, Output $output): int + { + return $this->toonErrorFormatter->formatErrors($analysisResult, $output); + } + +} diff --git a/src/Command/ErrorFormatter/ToonErrorFormatter.php b/src/Command/ErrorFormatter/ToonErrorFormatter.php new file mode 100644 index 0000000000..5a5051dd9c --- /dev/null +++ b/src/Command/ErrorFormatter/ToonErrorFormatter.php @@ -0,0 +1,65 @@ + [ + 'errors' => count($analysisResult->getNotFileSpecificErrors()), + 'file_errors' => count($analysisResult->getFileSpecificErrors()), + ], + 'files' => [], + 'errors' => [], + ]; + + $tipFormatter = new OutputFormatter(false); + + foreach ($analysisResult->getFileSpecificErrors() as $fileSpecificError) { + $file = $fileSpecificError->getFile(); + if (!isset($errorsArray['files'][$file])) { + $errorsArray['files'][$file] = [ + 'errors' => 0, + 'messages' => [], + ]; + } + $errorsArray['files'][$file]['errors']++; + + $message = [ + 'message' => $fileSpecificError->getMessage(), + 'line' => $fileSpecificError->getLine(), + 'ignorable' => $fileSpecificError->canBeIgnored(), + ]; + + if ($fileSpecificError->getTip() !== null) { + $message['tip'] = $tipFormatter->format($fileSpecificError->getTip()); + } + + if ($fileSpecificError->getIdentifier() !== null) { + $message['identifier'] = $fileSpecificError->getIdentifier(); + } + + $errorsArray['files'][$file]['messages'][] = $message; + } + + foreach ($analysisResult->getNotFileSpecificErrors() as $notFileSpecificError) { + $errorsArray['errors'][] = $notFileSpecificError; + } + + $toon = Toon::encode($errorsArray); + + $output->writeRaw($toon); + + return $analysisResult->hasErrors() ? 1 : 0; + } + +} diff --git a/src/Command/ErrorsConsoleStyle.php b/src/Command/ErrorsConsoleStyle.php index 18301f5ab8..7600e52c11 100644 --- a/src/Command/ErrorsConsoleStyle.php +++ b/src/Command/ErrorsConsoleStyle.php @@ -2,6 +2,7 @@ namespace PHPStan\Command; +use AgentDetector\AgentDetector; use OndraM\CiDetector\CiDetector; use Override; use Symfony\Component\Console\Helper\Helper; @@ -29,6 +30,8 @@ final class ErrorsConsoleStyle extends SymfonyStyle private ?bool $isCiDetected = null; + private ?bool $isAgentDetected = null; + public function __construct(InputInterface $input, OutputInterface $output) { parent::__construct($input, $output); @@ -45,6 +48,11 @@ private function isCiDetected(): bool return $this->isCiDetected; } + private function isAgentDetected(): bool + { + return $this->isAgentDetected ??= AgentDetector::detect()->isAgent; + } + /** * @param string[] $headers * @param string[][] $rows @@ -95,9 +103,10 @@ public function createProgressBar(int $max = 0): ProgressBar } $ci = $this->isCiDetected(); - $this->progressBar->setOverwrite(!$ci); + $agent = $this->isAgentDetected(); + $this->progressBar->setOverwrite(!$ci && !$agent); - if ($ci) { + if ($ci || $agent) { $this->progressBar->minSecondsBetweenRedraws(15); $this->progressBar->maxSecondsBetweenRedraws(30); } elseif (DIRECTORY_SEPARATOR === '\\') { diff --git a/tests/PHPStan/Command/ErrorFormatter/AgentDetectedErrorFormatterTest.php b/tests/PHPStan/Command/ErrorFormatter/AgentDetectedErrorFormatterTest.php new file mode 100644 index 0000000000..256759d732 --- /dev/null +++ b/tests/PHPStan/Command/ErrorFormatter/AgentDetectedErrorFormatterTest.php @@ -0,0 +1,112 @@ +assertFalse($formatter->isAgentDetected()); + } + + public function testIsAgentDetectedReturnsTrueWithAiAgent(): void + { + putenv('AI_AGENT=test'); + $formatter = new AgentDetectedErrorFormatter(new ToonErrorFormatter()); + $this->assertTrue($formatter->isAgentDetected()); + } + + public function testIsAgentDetectedReturnsTrueWithClaudeCode(): void + { + putenv('CLAUDE_CODE=1'); + $formatter = new AgentDetectedErrorFormatter(new ToonErrorFormatter()); + $this->assertTrue($formatter->isAgentDetected()); + } + + public function testFormatErrorsProducesToonOutput(): void + { + $formatter = new AgentDetectedErrorFormatter(new ToonErrorFormatter()); + + $exitCode = $formatter->formatErrors( + $this->getAnalysisResult(1, 0), + $this->getOutput(), + ); + + $this->assertSame(1, $exitCode); + + $expectedData = [ + 'totals' => [ + 'errors' => 0, + 'file_errors' => 1, + ], + 'files' => [ + '/data/folder/with space/and unicode 😃/project/folder with unicode 😃/file name with "spaces" and unicode 😃.php' => [ + 'errors' => 1, + 'messages' => [ + [ + 'message' => 'Foo', + 'line' => 4, + 'ignorable' => true, + ], + ], + ], + ], + 'errors' => [], + ]; + + $this->assertSame(Toon::encode($expectedData), $this->getOutputContent()); + } + + public function testFormatErrorsNoErrors(): void + { + $formatter = new AgentDetectedErrorFormatter(new ToonErrorFormatter()); + + $exitCode = $formatter->formatErrors( + $this->getAnalysisResult(0, 0), + $this->getOutput(), + ); + + $this->assertSame(0, $exitCode); + + $expectedData = [ + 'totals' => [ + 'errors' => 0, + 'file_errors' => 0, + ], + 'files' => [], + 'errors' => [], + ]; + + $this->assertSame(Toon::encode($expectedData), $this->getOutputContent()); + } + +}