From 5b41295b87af63229fdfd9cdd488f4e4817e1fd2 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Thu, 27 Aug 2026 22:53:19 +0200 Subject: [PATCH 1/3] Do not let a bootstrap autoloader's exception abort the analysis Both source locators that ask the registered autoloaders for a class walk the whole spl queue, which is not the order PHP uses: at runtime the class loader that resolves the class first means the other autoloaders are never invoked for that name. An autoloader that throws when the file it found does not declare the class it was asked for therefore throws here for names it can never see at runtime, and the exception surfaces as an internal error that aborts the file. Two widely used ones do that - symfony/error-handler's DebugClassLoader, which throws a RuntimeException in four conditions, and Yii 2's Yii::autoload(), which throws an UnknownClassException when the file it included did not declare the class and YII_DEBUG is on. Swallowing it lets the remaining autoloaders and source locators take their turn, so the class is resolved the way it is at runtime, or reported as not found. The trapped loop in AutoloadSourceLocator has the same defect; it predates the recently reverted work and is fixed here too, since fixing only one of the two leaves any name no static locator resolves still aborting. Unlike the reverted attempts this changes neither the order of the locators nor the way autoloaders are run, and it declines nothing by name. e2e/debug-class-loader is the DebugClassLoader case with the real package: an internal error before, the unknown class reported after. e2e/bug-12972b, the project phpstan/phpstan#14976 is about, is re-enabled. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/e2e-tests.yml | 14 ++- e2e/debug-class-loader/.gitignore | 2 + e2e/debug-class-loader/bootstrap.php | 8 ++ e2e/debug-class-loader/composer.json | 10 ++ e2e/debug-class-loader/phpstan.dist.neon | 12 +++ e2e/debug-class-loader/src/Widget.php | 10 ++ e2e/debug-class-loader/test.php | 17 ++++ .../AutoloadFunctionsSourceLocator.php | 15 ++- .../SourceLocator/AutoloadSourceLocator.php | 15 ++- .../AutoloadFunctionsSourceLocatorTest.php | 97 +++++++++++++++++++ .../AutoloadSourceLocatorTest.php | 38 ++++++++ 11 files changed, 227 insertions(+), 11 deletions(-) create mode 100644 e2e/debug-class-loader/.gitignore create mode 100644 e2e/debug-class-loader/bootstrap.php create mode 100644 e2e/debug-class-loader/composer.json create mode 100644 e2e/debug-class-loader/phpstan.dist.neon create mode 100644 e2e/debug-class-loader/src/Widget.php create mode 100644 e2e/debug-class-loader/test.php create mode 100644 tests/PHPStan/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocatorTest.php diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 329be6ea74a..156c910b68a 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -219,15 +219,19 @@ jobs: cd e2e/bug-14514 composer install ../../bin/phpstan analyze bug-14515.php - # Fails since the revert of the bootstrap autoloader changes: + # Fails until https://github.com/phpstan/phpstan/issues/14988 is fixed again: # - script: | # cd e2e/bug-14988 # composer install # ../../bin/phpstan analyse - # - script: | - # cd e2e/bug-12972b - # composer install - # ../../bin/phpstan analyze + - script: | + cd e2e/bug-12972b + composer install + ../../bin/phpstan analyze + - script: | + cd e2e/debug-class-loader + composer install + ../../bin/phpstan analyze - script: | cd e2e/bug-12972c composer install diff --git a/e2e/debug-class-loader/.gitignore b/e2e/debug-class-loader/.gitignore new file mode 100644 index 00000000000..de4a392c331 --- /dev/null +++ b/e2e/debug-class-loader/.gitignore @@ -0,0 +1,2 @@ +/vendor +/composer.lock diff --git a/e2e/debug-class-loader/bootstrap.php b/e2e/debug-class-loader/bootstrap.php new file mode 100644 index 00000000000..6ad1c1bb33a --- /dev/null +++ b/e2e/debug-class-loader/bootstrap.php @@ -0,0 +1,8 @@ +autoloadSourceLocator->locateIdentifier($reflector, $identifier); if ($reflection !== null) { return $reflection; diff --git a/src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php b/src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php index 6ebc2a09132..785a69a2946 100644 --- a/src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php +++ b/src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php @@ -4,7 +4,6 @@ use Composer\Autoload\ClassLoader; use Override; -use ParseError; use PhpParser\Node\Arg; use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Name; @@ -24,6 +23,7 @@ use PHPStan\Type\ConstantTypeHelper; use ReflectionClass; use ReflectionFunction; +use Throwable; use function array_key_exists; use function array_keys; use function class_exists; @@ -364,10 +364,15 @@ static function () use ($className, &$foundByComposer): ?array { try { $preExistingAutoloader($className); - } catch (ParseError) { - // the trap served a parse error instead of the empty - // script, see FileReadTrapStreamWrapper::stream_read(); - // the file was recorded before the include compiled it + } catch (Throwable) { + // Two unrelated throws land here, and neither one means the class was not found. + // The trap can serve a parse error instead of the empty script, see + // FileReadTrapStreamWrapper::stream_read(). And asking every registered + // autoloader for the class is not the order PHP uses, so an autoloader that + // throws for names outside its own scope throws here for names it is never + // invoked for at runtime. Either exception would abort the analysis of the file + // with an internal error, so both are swallowed - the file the autoloader asked + // for is recorded by the trap before the throw. } /** diff --git a/tests/PHPStan/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocatorTest.php b/tests/PHPStan/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocatorTest.php new file mode 100644 index 00000000000..272c2aa934d --- /dev/null +++ b/tests/PHPStan/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocatorTest.php @@ -0,0 +1,97 @@ +assertFalse(class_exists('ThrowingAutoloaderAlias', false), 'precondition: the alias does not exist yet'); + + $GLOBALS['__phpstanAutoloadFunctions'] = [ + static function (string $class): void { + throw new LogicException('this should not happen'); + }, + static function (string $class): void { + if ($class !== 'ThrowingAutoloaderAlias') { + return; + } + + class_alias(AFoo::class, 'ThrowingAutoloaderAlias'); + }, + ]; + + try { + $locator = $this->createLocator(); + $reflection = $locator->locateIdentifier( + new DefaultReflector($locator), + new Identifier('ThrowingAutoloaderAlias', new IdentifierType(IdentifierType::IDENTIFIER_CLASS)), + ); + + $this->assertNotNull($reflection, 'the class defined by the second autoloader should be located'); + $this->assertSame(AFoo::class, $reflection->getName()); + } finally { + unset($GLOBALS['__phpstanAutoloadFunctions']); + } + } + + /** + * Nothing resolves the name, so the locator declines - the point is that it declines instead of + * letting the autoloader's exception surface as an internal error. + */ + public function testAThrowingAutoloaderMakesTheLocatorDecline(): void + { + $GLOBALS['__phpstanAutoloadFunctions'] = [ + static function (string $class): void { + throw new LogicException('this should not happen'); + }, + ]; + + try { + $locator = $this->createLocator(); + $reflection = $locator->locateIdentifier( + new DefaultReflector($locator), + new Identifier('NeverDefinedByAnyAutoloader', new IdentifierType(IdentifierType::IDENTIFIER_CLASS)), + ); + + $this->assertNull($reflection); + } finally { + unset($GLOBALS['__phpstanAutoloadFunctions']); + } + } + + private function createLocator(): AutoloadFunctionsSourceLocator + { + $container = self::getContainer(); + + return new AutoloadFunctionsSourceLocator( + new AutoloadSourceLocator($container->getByType(FileNodesFetcher::class), false), + new ReflectionClassSourceLocator( + new Locator($container->getService('phpParserDecorator')), + new ReflectionSourceStubber(new Standard()), + ), + ); + } + +} diff --git a/tests/PHPStan/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocatorTest.php b/tests/PHPStan/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocatorTest.php index 522bc15e224..40d51e5bead 100644 --- a/tests/PHPStan/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocatorTest.php +++ b/tests/PHPStan/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocatorTest.php @@ -2,6 +2,9 @@ namespace PHPStan\Reflection\BetterReflection\SourceLocator; +use LogicException; +use PHPStan\BetterReflection\Identifier\Identifier; +use PHPStan\BetterReflection\Identifier\IdentifierType; use PHPStan\BetterReflection\Reflection\ReflectionClass; use PHPStan\BetterReflection\Reflector\DefaultReflector; use PHPStan\Reflection\InitializerExprContext; @@ -12,6 +15,8 @@ use TestSingleFileSourceLocator\InCondition; use function array_merge; use function class_alias; +use function spl_autoload_register; +use function spl_autoload_unregister; function testFunctionForLocator(): void // phpcs:disable { @@ -79,6 +84,39 @@ class_alias(AFoo::class, 'A_Foo'); $this->assertSame(AFoo::class, $class->getName()); } + /** + * This locator asks every registered autoloader for the class, which is not the order PHP uses, + * so an autoloader that throws for names outside its own scope must not abort the analysis. + * + * @see https://github.com/phpstan/phpstan/issues/14976 + */ + public function testThrowingAutoloader(): void + { + // Everything that needs autoloading has to be loaded before the throwing autoloader joins + // the queue - it is registered globally, so anything loaded lazily inside the try would + // throw as well. That includes building the container. + $locator = new AutoloadSourceLocator(self::getContainer()->getByType(FileNodesFetcher::class), true); + + $autoloader = static function (string $class): void { + if ($class !== 'NeverDefinedByAnyAutoloader') { + return; + } + + throw new LogicException('this should not happen'); + }; + spl_autoload_register($autoloader); + + try { + $reflection = $locator->locateIdentifier( + new DefaultReflector($locator), + new Identifier('NeverDefinedByAnyAutoloader', new IdentifierType(IdentifierType::IDENTIFIER_CLASS)), + ); + $this->assertNull($reflection); + } finally { + spl_autoload_unregister($autoloader); + } + } + public static function getAdditionalConfigFiles(): array { return array_merge( From 988c598bc6d8c5ed22a69718f1efec50f82c5193 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Wed, 14 Oct 2026 17:56:50 +0200 Subject: [PATCH 2/3] Print a bootstrap autoloader's swallowed exception with --debug AutoloadFunctionsSourceLocator runs bootstrap autoloaders for real, so an exception from one of them carries the real reason why the class was not found. With --debug, BootstrapFilesRunner enables BootstrapAutoloaderExceptionReporter, which prints the exception and its trace to the error output, like a bootstrap file's exception. The file-read trap in AutoloadSourceLocator stays silent: it serves an empty script, so an autoloader that checks what the file declared throws even for a valid class. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Command/BootstrapFilesRunner.php | 10 ++- .../BetterReflectionSourceLocatorFactory.php | 3 + .../AutoloadFunctionsSourceLocator.php | 6 +- .../SourceLocator/AutoloadSourceLocator.php | 5 +- .../BootstrapAutoloaderExceptionReporter.php | 37 +++++++++ .../Command/BootstrapFilesRunnerTest.php | 77 +++++++++++++++++++ .../AutoloadFunctionsSourceLocatorTest.php | 47 ++++++++++- 7 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 src/Reflection/BetterReflection/SourceLocator/BootstrapAutoloaderExceptionReporter.php create mode 100644 tests/PHPStan/Command/BootstrapFilesRunnerTest.php diff --git a/src/Command/BootstrapFilesRunner.php b/src/Command/BootstrapFilesRunner.php index d5ad14d8696..541556ff0da 100644 --- a/src/Command/BootstrapFilesRunner.php +++ b/src/Command/BootstrapFilesRunner.php @@ -4,6 +4,7 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\DependencyInjection\Container; +use PHPStan\Reflection\BetterReflection\SourceLocator\BootstrapAutoloaderExceptionReporter; use function spl_autoload_functions; /** @@ -32,7 +33,10 @@ final class BootstrapFilesRunner private bool $hasRun = false; - public function __construct(private Container $container) + public function __construct( + private Container $container, + private BootstrapAutoloaderExceptionReporter $bootstrapAutoloaderExceptionReporter, + ) { } @@ -41,6 +45,10 @@ public function __construct(private Container $container) */ public function run(Output $errorOutput, bool $debugEnabled): void { + if ($debugEnabled) { + $this->bootstrapAutoloaderExceptionReporter->enable($errorOutput); + } + if ($this->hasRun) { return; } diff --git a/src/Reflection/BetterReflection/BetterReflectionSourceLocatorFactory.php b/src/Reflection/BetterReflection/BetterReflectionSourceLocatorFactory.php index 4c9d522ee1e..d4a0fb8c61e 100644 --- a/src/Reflection/BetterReflection/BetterReflectionSourceLocatorFactory.php +++ b/src/Reflection/BetterReflection/BetterReflectionSourceLocatorFactory.php @@ -19,6 +19,7 @@ use PHPStan\Php\PhpVersion; use PHPStan\Reflection\BetterReflection\SourceLocator\AutoloadFunctionsSourceLocator; use PHPStan\Reflection\BetterReflection\SourceLocator\AutoloadSourceLocator; +use PHPStan\Reflection\BetterReflection\SourceLocator\BootstrapAutoloaderExceptionReporter; use PHPStan\Reflection\BetterReflection\SourceLocator\CachedPhpInternalSourceLocator; use PHPStan\Reflection\BetterReflection\SourceLocator\ComposerJsonAndInstalledJsonSourceLocatorMaker; use PHPStan\Reflection\BetterReflection\SourceLocator\FileNodesFetcher; @@ -69,6 +70,7 @@ public function __construct( private ComposerJsonAndInstalledJsonSourceLocatorMaker $composerJsonAndInstalledJsonSourceLocatorMaker, private OptimizedPsrAutoloaderLocatorFactory $optimizedPsrAutoloaderLocatorFactory, private FileNodesFetcher $fileNodesFetcher, + private BootstrapAutoloaderExceptionReporter $bootstrapAutoloaderExceptionReporter, #[AutowiredParameter] private array $scanFiles, #[AutowiredParameter] @@ -109,6 +111,7 @@ public function create(): SourceLocator $astLocator, $this->reflectionSourceStubber, ), + $this->bootstrapAutoloaderExceptionReporter, )); $analysedDirectories = []; diff --git a/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php b/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php index 15e129e4fdd..026b31166f4 100644 --- a/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php +++ b/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php @@ -20,6 +20,7 @@ final class AutoloadFunctionsSourceLocator implements SourceLocator public function __construct( private AutoloadSourceLocator $autoloadSourceLocator, private ReflectionClassSourceLocator $reflectionClassSourceLocator, + private BootstrapAutoloaderExceptionReporter $exceptionReporter, ) { } @@ -40,7 +41,7 @@ public function locateIdentifier(Reflector $reflector, Identifier $identifier): foreach ($autoloadFunctions as $autoloadFunction) { try { $autoloadFunction($className); - } catch (Throwable) { + } catch (Throwable $e) { // This locator asks every bootstrap-registered autoloader for the class, which is not // the order PHP uses: at runtime the class loader that resolves the class first is // often another one, so an autoloader that throws for names outside its own scope is @@ -48,7 +49,10 @@ public function locateIdentifier(Reflector $reflector, Identifier $identifier): // aborts the analysis of the file with an internal error instead, so it is swallowed // and the remaining autoloaders and source locators get their turn. The class may // still have been defined before the throw, so the locators below run either way. + // The autoloader ran for real, so its message is its real reason - with --debug, + // the reporter prints it. // See https://github.com/phpstan/phpstan/issues/14976 + $this->exceptionReporter->report($className, $e); } $reflection = $this->autoloadSourceLocator->locateIdentifier($reflector, $identifier); diff --git a/src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php b/src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php index 785a69a2946..bfb9e2cff4e 100644 --- a/src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php +++ b/src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php @@ -372,7 +372,10 @@ static function () use ($className, &$foundByComposer): ?array { // throws for names outside its own scope throws here for names it is never // invoked for at runtime. Either exception would abort the analysis of the file // with an internal error, so both are swallowed - the file the autoloader asked - // for is recorded by the trap before the throw. + // for is recorded by the trap before the throw. Unlike AutoloadFunctionsSourceLocator, + // this catch does not report the message: the trap serves an empty script, so an + // autoloader that checks what the file declared (Symfony's DebugClassLoader) throws + // even for a class that the file does declare. } /** diff --git a/src/Reflection/BetterReflection/SourceLocator/BootstrapAutoloaderExceptionReporter.php b/src/Reflection/BetterReflection/SourceLocator/BootstrapAutoloaderExceptionReporter.php new file mode 100644 index 00000000000..56c50d553a2 --- /dev/null +++ b/src/Reflection/BetterReflection/SourceLocator/BootstrapAutoloaderExceptionReporter.php @@ -0,0 +1,37 @@ +output = $output; + } + + public function report(string $className, Throwable $e): void + { + if ($this->output === null) { + return; + } + + $this->output->writeLineFormatted(sprintf('%s thrown in %s on line %d by a bootstrap autoloader for class %s: %s', get_class($e), $e->getFile(), $e->getLine(), $className, $e->getMessage())); + $this->output->writeLineFormatted($e->getTraceAsString()); + } + +} diff --git a/tests/PHPStan/Command/BootstrapFilesRunnerTest.php b/tests/PHPStan/Command/BootstrapFilesRunnerTest.php new file mode 100644 index 00000000000..5492d3aae69 --- /dev/null +++ b/tests/PHPStan/Command/BootstrapFilesRunnerTest.php @@ -0,0 +1,77 @@ + + */ + public static function dataReporterIsEnabledOnlyWithDebug(): iterable + { + yield 'debug' => [true, true]; + yield 'no debug' => [false, false]; + } + + /** + * @throws InceptionNotSuccessfulException + */ + #[DataProvider('dataReporterIsEnabledOnlyWithDebug')] + public function testReporterIsEnabledOnlyWithDebug(bool $debugEnabled, bool $expectPrinted): void + { + $stream = fopen('php://memory', 'w+'); + $this->assertNotFalse($stream); + $output = new StreamOutput($stream); + $errorOutput = new SymfonyOutput($output, new SymfonyStyle(new \Symfony\Component\Console\Style\SymfonyStyle($this->createStub(InputInterface::class), $output))); + + $reporter = new BootstrapAutoloaderExceptionReporter(); + $runner = new BootstrapFilesRunner(self::getContainer(), $reporter); + $runner->run($errorOutput, $debugEnabled); + $reporter->report('SomeClass', new LogicException('Cannot read the class map.')); + + rewind($stream); + $printed = stream_get_contents($stream); + $this->assertIsString($printed); + if ($expectPrinted) { + $this->assertStringContainsString('by a bootstrap autoloader for class SomeClass: Cannot read the class map.', $printed); + } else { + $this->assertSame('', $printed); + } + } + + /** + * @throws InceptionNotSuccessfulException + */ + public function testReporterIsEnabledWhenTheFilesAlreadyRan(): void + { + $stream = fopen('php://memory', 'w+'); + $this->assertNotFalse($stream); + $output = new StreamOutput($stream); + $errorOutput = new SymfonyOutput($output, new SymfonyStyle(new \Symfony\Component\Console\Style\SymfonyStyle($this->createStub(InputInterface::class), $output))); + + $reporter = new BootstrapAutoloaderExceptionReporter(); + $runner = new BootstrapFilesRunner(self::getContainer(), $reporter); + $runner->run($errorOutput, false); + $runner->run($errorOutput, true); + $reporter->report('SomeClass', new LogicException('Cannot read the class map.')); + + rewind($stream); + $printed = stream_get_contents($stream); + $this->assertIsString($printed); + $this->assertStringContainsString('by a bootstrap autoloader for class SomeClass', $printed); + } + +} diff --git a/tests/PHPStan/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocatorTest.php b/tests/PHPStan/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocatorTest.php index 272c2aa934d..14e2751690e 100644 --- a/tests/PHPStan/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocatorTest.php +++ b/tests/PHPStan/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocatorTest.php @@ -9,10 +9,17 @@ use PHPStan\BetterReflection\Reflector\DefaultReflector; use PHPStan\BetterReflection\SourceLocator\Ast\Locator; use PHPStan\BetterReflection\SourceLocator\SourceStubber\ReflectionSourceStubber; +use PHPStan\Command\Symfony\SymfonyOutput; +use PHPStan\Command\Symfony\SymfonyStyle; use PHPStan\Testing\PHPStanTestCase; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\StreamOutput; use TestSingleFileSourceLocator\AFoo; use function class_alias; use function class_exists; +use function fopen; +use function rewind; +use function stream_get_contents; class AutoloadFunctionsSourceLocatorTest extends PHPStanTestCase { @@ -81,7 +88,44 @@ static function (string $class): void { } } - private function createLocator(): AutoloadFunctionsSourceLocator + /** + * With --debug, BootstrapFilesRunner enables the reporter, and the autoloader's own message + * reaches the error output. The class is still reported as not found. + */ + public function testTheReporterPrintsTheSwallowedException(): void + { + $GLOBALS['__phpstanAutoloadFunctions'] = [ + static function (string $class): void { + throw new LogicException('Cannot read the class map.'); + }, + ]; + + $stream = fopen('php://memory', 'w+'); + $this->assertNotFalse($stream); + $output = new StreamOutput($stream); + $reporter = new BootstrapAutoloaderExceptionReporter(); + $reporter->enable(new SymfonyOutput($output, new SymfonyStyle(new \Symfony\Component\Console\Style\SymfonyStyle($this->createStub(InputInterface::class), $output)))); + + try { + $locator = $this->createLocator($reporter); + $reflection = $locator->locateIdentifier( + new DefaultReflector($locator), + new Identifier('NeverDefinedByAnyAutoloader', new IdentifierType(IdentifierType::IDENTIFIER_CLASS)), + ); + + $this->assertNull($reflection); + } finally { + unset($GLOBALS['__phpstanAutoloadFunctions']); + } + + rewind($stream); + $printed = stream_get_contents($stream); + $this->assertIsString($printed); + $this->assertStringContainsString('LogicException thrown in ' . __FILE__, $printed); + $this->assertStringContainsString('by a bootstrap autoloader for class NeverDefinedByAnyAutoloader: Cannot read the class map.', $printed); + } + + private function createLocator(?BootstrapAutoloaderExceptionReporter $reporter = null): AutoloadFunctionsSourceLocator { $container = self::getContainer(); @@ -91,6 +135,7 @@ private function createLocator(): AutoloadFunctionsSourceLocator new Locator($container->getService('phpParserDecorator')), new ReflectionSourceStubber(new Standard()), ), + $reporter ?? new BootstrapAutoloaderExceptionReporter(), ); } From 66cf5685e7edb5c77f06ab088962955be36f9896 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Sat, 24 Oct 2026 12:07:23 +0200 Subject: [PATCH 3/3] Test the --debug report of a bootstrap autoloader's exception end to end The debug-class-loader e2e step now runs the analysis with --debug as well, and checks that it prints the exception DebugClassLoader threw for the unknown class. This replaces BootstrapFilesRunnerTest. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/e2e-tests.yml | 5 ++ .../Command/BootstrapFilesRunnerTest.php | 77 ------------------- 2 files changed, 5 insertions(+), 77 deletions(-) delete mode 100644 tests/PHPStan/Command/BootstrapFilesRunnerTest.php diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 156c910b68a..45c9091c917 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -232,6 +232,11 @@ jobs: cd e2e/debug-class-loader composer install ../../bin/phpstan analyze + # With --debug, the analysis runs in the main process, and PHPStan prints the + # exception that DebugClassLoader threw for the unknown class. + OUTPUT=$(../bashunit assert exit_code "0" "../../bin/phpstan analyze --debug") + echo "$OUTPUT" + ../bashunit assert contains 'by a bootstrap autoloader for class DebugClassLoaderE2e\Widget: The autoloader expected class' "$OUTPUT" - script: | cd e2e/bug-12972c composer install diff --git a/tests/PHPStan/Command/BootstrapFilesRunnerTest.php b/tests/PHPStan/Command/BootstrapFilesRunnerTest.php deleted file mode 100644 index 5492d3aae69..00000000000 --- a/tests/PHPStan/Command/BootstrapFilesRunnerTest.php +++ /dev/null @@ -1,77 +0,0 @@ - - */ - public static function dataReporterIsEnabledOnlyWithDebug(): iterable - { - yield 'debug' => [true, true]; - yield 'no debug' => [false, false]; - } - - /** - * @throws InceptionNotSuccessfulException - */ - #[DataProvider('dataReporterIsEnabledOnlyWithDebug')] - public function testReporterIsEnabledOnlyWithDebug(bool $debugEnabled, bool $expectPrinted): void - { - $stream = fopen('php://memory', 'w+'); - $this->assertNotFalse($stream); - $output = new StreamOutput($stream); - $errorOutput = new SymfonyOutput($output, new SymfonyStyle(new \Symfony\Component\Console\Style\SymfonyStyle($this->createStub(InputInterface::class), $output))); - - $reporter = new BootstrapAutoloaderExceptionReporter(); - $runner = new BootstrapFilesRunner(self::getContainer(), $reporter); - $runner->run($errorOutput, $debugEnabled); - $reporter->report('SomeClass', new LogicException('Cannot read the class map.')); - - rewind($stream); - $printed = stream_get_contents($stream); - $this->assertIsString($printed); - if ($expectPrinted) { - $this->assertStringContainsString('by a bootstrap autoloader for class SomeClass: Cannot read the class map.', $printed); - } else { - $this->assertSame('', $printed); - } - } - - /** - * @throws InceptionNotSuccessfulException - */ - public function testReporterIsEnabledWhenTheFilesAlreadyRan(): void - { - $stream = fopen('php://memory', 'w+'); - $this->assertNotFalse($stream); - $output = new StreamOutput($stream); - $errorOutput = new SymfonyOutput($output, new SymfonyStyle(new \Symfony\Component\Console\Style\SymfonyStyle($this->createStub(InputInterface::class), $output))); - - $reporter = new BootstrapAutoloaderExceptionReporter(); - $runner = new BootstrapFilesRunner(self::getContainer(), $reporter); - $runner->run($errorOutput, false); - $runner->run($errorOutput, true); - $reporter->report('SomeClass', new LogicException('Cannot read the class map.')); - - rewind($stream); - $printed = stream_get_contents($stream); - $this->assertIsString($printed); - $this->assertStringContainsString('by a bootstrap autoloader for class SomeClass', $printed); - } - -}