From 730ebb30e86db7e7639f542997edc078412f95fd Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Sun, 15 Feb 2026 18:17:52 +0530 Subject: [PATCH 1/2] Add AI agent detection to auto-switch to JSON output --- composer.json | 1 + composer.lock | 70 ++++++++++++++- src/Command/AnalyseCommand.php | 12 ++- .../AgentDetectedErrorFormatter.php | 35 ++++++++ src/Command/ErrorsConsoleStyle.php | 13 ++- .../AgentDetectedErrorFormatterTest.php | 86 +++++++++++++++++++ 6 files changed, 213 insertions(+), 4 deletions(-) create mode 100644 src/Command/ErrorFormatter/AgentDetectedErrorFormatter.php create mode 100644 tests/PHPStan/Command/ErrorFormatter/AgentDetectedErrorFormatterTest.php diff --git a/composer.json b/composer.json index 1377c35a63..84a883c9fe 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "nette/utils": "^3.2.5", "nikic/php-parser": "^5.7.0", "ondram/ci-detector": "^4.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..ecdb7b146c 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": "b5e59905b1e998eeaab3c3ab52d1c959", "packages": [ { "name": "clue/ndjson-react", @@ -3361,6 +3361,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/src/Command/AnalyseCommand.php b/src/Command/AnalyseCommand.php index ee40d2b0d7..46aabc87cd 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 = 'json'; + } + } + 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..c6a63321c9 --- /dev/null +++ b/src/Command/ErrorFormatter/AgentDetectedErrorFormatter.php @@ -0,0 +1,35 @@ +isAgent; + } + + public function formatErrors(AnalysisResult $analysisResult, Output $output): int + { + return $this->jsonErrorFormatter->formatErrors($analysisResult, $output); + } + +} 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..6ba54b9346 --- /dev/null +++ b/tests/PHPStan/Command/ErrorFormatter/AgentDetectedErrorFormatterTest.php @@ -0,0 +1,86 @@ +assertFalse($formatter->isAgentDetected()); + } + + public function testIsAgentDetectedReturnsTrueWithAiAgent(): void + { + putenv('AI_AGENT=test'); + $formatter = new AgentDetectedErrorFormatter(new JsonErrorFormatter(false)); + $this->assertTrue($formatter->isAgentDetected()); + } + + public function testIsAgentDetectedReturnsTrueWithClaudeCode(): void + { + putenv('CLAUDE_CODE=1'); + $formatter = new AgentDetectedErrorFormatter(new JsonErrorFormatter(false)); + $this->assertTrue($formatter->isAgentDetected()); + } + + public function testFormatErrorsProducesValidJson(): void + { + $formatter = new AgentDetectedErrorFormatter(new JsonErrorFormatter(false)); + + $exitCode = $formatter->formatErrors( + $this->getAnalysisResult(1, 0), + $this->getOutput(), + ); + + $this->assertSame(1, $exitCode); + $this->assertJsonStringEqualsJsonString( + '{"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->getOutputContent(), + ); + } + + public function testFormatErrorsNoErrors(): void + { + $formatter = new AgentDetectedErrorFormatter(new JsonErrorFormatter(false)); + + $exitCode = $formatter->formatErrors( + $this->getAnalysisResult(0, 0), + $this->getOutput(), + ); + + $this->assertSame(0, $exitCode); + $this->assertJsonStringEqualsJsonString( + '{"totals":{"errors":0,"file_errors":0},"files":{},"errors":[]}', + $this->getOutputContent(), + ); + } + +} From 4790241563bb56c254078a8ac17d0ea8016ee11a Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Sun, 15 Feb 2026 18:59:04 +0530 Subject: [PATCH 2/2] Switch agent output from JSON to TOON format for ~19% token reduction Adds helgesverre/toon and creates ToonErrorFormatter that outputs in TOON (Token-Oriented Object Notation) format. When an AI agent is detected, PHPStan now outputs in TOON instead of JSON, reducing token consumption while remaining machine-parseable. Follows up on #4938. --- composer.json | 1 + composer.lock | 67 ++++++++++++++++++- conf/services.neon | 3 + src/Command/AnalyseCommand.php | 2 +- .../AgentDetectedErrorFormatter.php | 6 +- .../ErrorFormatter/ToonErrorFormatter.php | 65 ++++++++++++++++++ .../AgentDetectedErrorFormatterTest.php | 54 +++++++++++---- 7 files changed, 179 insertions(+), 19 deletions(-) create mode 100644 src/Command/ErrorFormatter/ToonErrorFormatter.php diff --git a/composer.json b/composer.json index 84a883c9fe..2472dffd64 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "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", diff --git a/composer.lock b/composer.lock index ecdb7b146c..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": "b5e59905b1e998eeaab3c3ab52d1c959", + "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", 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 46aabc87cd..8338e8db19 100644 --- a/src/Command/AnalyseCommand.php +++ b/src/Command/AnalyseCommand.php @@ -242,7 +242,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int /** @var AgentDetectedErrorFormatter $agentFormatter */ $agentFormatter = $container->getByType(AgentDetectedErrorFormatter::class); if ($agentFormatter->isAgentDetected()) { - $errorFormat = 'json'; + $errorFormat = 'toon'; } } diff --git a/src/Command/ErrorFormatter/AgentDetectedErrorFormatter.php b/src/Command/ErrorFormatter/AgentDetectedErrorFormatter.php index c6a63321c9..17dc223fd3 100644 --- a/src/Command/ErrorFormatter/AgentDetectedErrorFormatter.php +++ b/src/Command/ErrorFormatter/AgentDetectedErrorFormatter.php @@ -16,8 +16,8 @@ final class AgentDetectedErrorFormatter implements ErrorFormatter { public function __construct( - #[AutowiredParameter(ref: '@errorFormatter.json')] - private JsonErrorFormatter $jsonErrorFormatter, + #[AutowiredParameter(ref: '@errorFormatter.toon')] + private ToonErrorFormatter $toonErrorFormatter, ) { } @@ -29,7 +29,7 @@ public function isAgentDetected(): bool public function formatErrors(AnalysisResult $analysisResult, Output $output): int { - return $this->jsonErrorFormatter->formatErrors($analysisResult, $output); + 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/tests/PHPStan/Command/ErrorFormatter/AgentDetectedErrorFormatterTest.php b/tests/PHPStan/Command/ErrorFormatter/AgentDetectedErrorFormatterTest.php index 6ba54b9346..256759d732 100644 --- a/tests/PHPStan/Command/ErrorFormatter/AgentDetectedErrorFormatterTest.php +++ b/tests/PHPStan/Command/ErrorFormatter/AgentDetectedErrorFormatterTest.php @@ -2,6 +2,7 @@ namespace PHPStan\Command\ErrorFormatter; +use HelgeSverre\Toon\Toon; use Override; use PHPStan\Testing\ErrorFormatterTestCase; use function putenv; @@ -33,27 +34,27 @@ protected function tearDown(): void public function testIsAgentDetectedReturnsFalse(): void { - $formatter = new AgentDetectedErrorFormatter(new JsonErrorFormatter(false)); + $formatter = new AgentDetectedErrorFormatter(new ToonErrorFormatter()); $this->assertFalse($formatter->isAgentDetected()); } public function testIsAgentDetectedReturnsTrueWithAiAgent(): void { putenv('AI_AGENT=test'); - $formatter = new AgentDetectedErrorFormatter(new JsonErrorFormatter(false)); + $formatter = new AgentDetectedErrorFormatter(new ToonErrorFormatter()); $this->assertTrue($formatter->isAgentDetected()); } public function testIsAgentDetectedReturnsTrueWithClaudeCode(): void { putenv('CLAUDE_CODE=1'); - $formatter = new AgentDetectedErrorFormatter(new JsonErrorFormatter(false)); + $formatter = new AgentDetectedErrorFormatter(new ToonErrorFormatter()); $this->assertTrue($formatter->isAgentDetected()); } - public function testFormatErrorsProducesValidJson(): void + public function testFormatErrorsProducesToonOutput(): void { - $formatter = new AgentDetectedErrorFormatter(new JsonErrorFormatter(false)); + $formatter = new AgentDetectedErrorFormatter(new ToonErrorFormatter()); $exitCode = $formatter->formatErrors( $this->getAnalysisResult(1, 0), @@ -61,15 +62,33 @@ public function testFormatErrorsProducesValidJson(): void ); $this->assertSame(1, $exitCode); - $this->assertJsonStringEqualsJsonString( - '{"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->getOutputContent(), - ); + + $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 JsonErrorFormatter(false)); + $formatter = new AgentDetectedErrorFormatter(new ToonErrorFormatter()); $exitCode = $formatter->formatErrors( $this->getAnalysisResult(0, 0), @@ -77,10 +96,17 @@ public function testFormatErrorsNoErrors(): void ); $this->assertSame(0, $exitCode); - $this->assertJsonStringEqualsJsonString( - '{"totals":{"errors":0,"file_errors":0},"files":{},"errors":[]}', - $this->getOutputContent(), - ); + + $expectedData = [ + 'totals' => [ + 'errors' => 0, + 'file_errors' => 0, + ], + 'files' => [], + 'errors' => [], + ]; + + $this->assertSame(Toon::encode($expectedData), $this->getOutputContent()); } }