diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 768a653e9f8..4576e6c35ae 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 @@ +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 3da44c64bf1..026b31166f4 100644 --- a/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php +++ b/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php @@ -8,6 +8,7 @@ use PHPStan\BetterReflection\Reflection\Reflection; use PHPStan\BetterReflection\Reflector\Reflector; use PHPStan\BetterReflection\SourceLocator\Type\SourceLocator; +use Throwable; use function class_exists; use function interface_exists; use function PHPStan\autoloadFunctions; @@ -19,6 +20,7 @@ final class AutoloadFunctionsSourceLocator implements SourceLocator public function __construct( private AutoloadSourceLocator $autoloadSourceLocator, private ReflectionClassSourceLocator $reflectionClassSourceLocator, + private BootstrapAutoloaderExceptionReporter $exceptionReporter, ) { } @@ -37,7 +39,22 @@ public function locateIdentifier(Reflector $reflector, Identifier $identifier): $autoloadFunctions = autoloadFunctions(); foreach ($autoloadFunctions as $autoloadFunction) { - $autoloadFunction($className); + try { + $autoloadFunction($className); + } 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 + // never invoked for them and its exception cannot happen. Letting it propagate here + // 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); if ($reflection !== null) { return $reflection; diff --git a/src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php b/src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php index 0a5a61c3793..0a4d146287d 100644 --- a/src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php +++ b/src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php @@ -3,7 +3,6 @@ namespace PHPStan\Reflection\BetterReflection\SourceLocator; use Override; -use ParseError; use PhpParser\Node\Arg; use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Name; @@ -23,6 +22,7 @@ use PHPStan\Type\ConstantTypeHelper; use ReflectionClass; use ReflectionFunction; +use Throwable; use function array_key_exists; use function array_keys; use function class_exists; @@ -346,10 +346,18 @@ static function () use ($className): ?array { foreach ($functions as $preExistingAutoloader) { 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. 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 new file mode 100644 index 00000000000..14e2751690e --- /dev/null +++ b/tests/PHPStan/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocatorTest.php @@ -0,0 +1,142 @@ +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']); + } + } + + /** + * 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(); + + return new AutoloadFunctionsSourceLocator( + new AutoloadSourceLocator($container->getByType(FileNodesFetcher::class), false), + new ReflectionClassSourceLocator( + new Locator($container->getService('phpParserDecorator')), + new ReflectionSourceStubber(new Standard()), + ), + $reporter ?? new BootstrapAutoloaderExceptionReporter(), + ); + } + +} 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(