Skip to content

Commit d128f06

Browse files
SanderMullerclaude
authored andcommitted
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) <noreply@anthropic.com>
1 parent 1047d30 commit d128f06

7 files changed

Lines changed: 181 additions & 4 deletions

File tree

‎src/Command/BootstrapFilesRunner.php‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use PHPStan\DependencyInjection\AutowiredService;
66
use PHPStan\DependencyInjection\Container;
7+
use PHPStan\Reflection\BetterReflection\SourceLocator\BootstrapAutoloaderExceptionReporter;
78
use function spl_autoload_functions;
89

910
/**
@@ -32,7 +33,10 @@ final class BootstrapFilesRunner
3233

3334
private bool $hasRun = false;
3435

35-
public function __construct(private Container $container)
36+
public function __construct(
37+
private Container $container,
38+
private BootstrapAutoloaderExceptionReporter $bootstrapAutoloaderExceptionReporter,
39+
)
3640
{
3741
}
3842

@@ -41,6 +45,10 @@ public function __construct(private Container $container)
4145
*/
4246
public function run(Output $errorOutput, bool $debugEnabled): void
4347
{
48+
if ($debugEnabled) {
49+
$this->bootstrapAutoloaderExceptionReporter->enable($errorOutput);
50+
}
51+
4452
if ($this->hasRun) {
4553
return;
4654
}

‎src/Reflection/BetterReflection/BetterReflectionSourceLocatorFactory.php‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use PHPStan\Php\PhpVersion;
2020
use PHPStan\Reflection\BetterReflection\SourceLocator\AutoloadFunctionsSourceLocator;
2121
use PHPStan\Reflection\BetterReflection\SourceLocator\AutoloadSourceLocator;
22+
use PHPStan\Reflection\BetterReflection\SourceLocator\BootstrapAutoloaderExceptionReporter;
2223
use PHPStan\Reflection\BetterReflection\SourceLocator\CachedPhpInternalSourceLocator;
2324
use PHPStan\Reflection\BetterReflection\SourceLocator\ComposerJsonAndInstalledJsonSourceLocatorMaker;
2425
use PHPStan\Reflection\BetterReflection\SourceLocator\FileNodesFetcher;
@@ -69,6 +70,7 @@ public function __construct(
6970
private ComposerJsonAndInstalledJsonSourceLocatorMaker $composerJsonAndInstalledJsonSourceLocatorMaker,
7071
private OptimizedPsrAutoloaderLocatorFactory $optimizedPsrAutoloaderLocatorFactory,
7172
private FileNodesFetcher $fileNodesFetcher,
73+
private BootstrapAutoloaderExceptionReporter $bootstrapAutoloaderExceptionReporter,
7274
#[AutowiredParameter]
7375
private array $scanFiles,
7476
#[AutowiredParameter]
@@ -109,6 +111,7 @@ public function create(): SourceLocator
109111
$astLocator,
110112
$this->reflectionSourceStubber,
111113
),
114+
$this->bootstrapAutoloaderExceptionReporter,
112115
));
113116

114117
$analysedDirectories = [];

‎src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ final class AutoloadFunctionsSourceLocator implements SourceLocator
2020
public function __construct(
2121
private AutoloadSourceLocator $autoloadSourceLocator,
2222
private ReflectionClassSourceLocator $reflectionClassSourceLocator,
23+
private BootstrapAutoloaderExceptionReporter $exceptionReporter,
2324
)
2425
{
2526
}
@@ -40,15 +41,18 @@ public function locateIdentifier(Reflector $reflector, Identifier $identifier):
4041
foreach ($autoloadFunctions as $autoloadFunction) {
4142
try {
4243
$autoloadFunction($className);
43-
} catch (Throwable) {
44+
} catch (Throwable $e) {
4445
// This locator asks every bootstrap-registered autoloader for the class, which is not
4546
// the order PHP uses: at runtime the class loader that resolves the class first is
4647
// often another one, so an autoloader that throws for names outside its own scope is
4748
// never invoked for them and its exception cannot happen. Letting it propagate here
4849
// aborts the analysis of the file with an internal error instead, so it is swallowed
4950
// and the remaining autoloaders and source locators get their turn. The class may
5051
// still have been defined before the throw, so the locators below run either way.
52+
// The autoloader ran for real, so its message is its real reason - with --debug,
53+
// the reporter prints it.
5154
// See https://github.com/phpstan/phpstan/issues/14976
55+
$this->exceptionReporter->report($className, $e);
5256
}
5357

5458
$reflection = $this->autoloadSourceLocator->locateIdentifier($reflector, $identifier);

‎src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,10 @@ static function () use ($className, &$foundByComposer): ?array {
372372
// throws for names outside its own scope throws here for names it is never
373373
// invoked for at runtime. Either exception would abort the analysis of the file
374374
// with an internal error, so both are swallowed - the file the autoloader asked
375-
// for is recorded by the trap before the throw.
375+
// for is recorded by the trap before the throw. Unlike AutoloadFunctionsSourceLocator,
376+
// this catch does not report the message: the trap serves an empty script, so an
377+
// autoloader that checks what the file declared (Symfony's DebugClassLoader) throws
378+
// even for a class that the file does declare.
376379
}
377380

378381
/**
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Reflection\BetterReflection\SourceLocator;
4+
5+
use PHPStan\Command\Output;
6+
use PHPStan\DependencyInjection\AutowiredService;
7+
use Throwable;
8+
use function get_class;
9+
use function sprintf;
10+
11+
/**
12+
* Prints the exceptions that AutoloadFunctionsSourceLocator swallows from autoloaders registered
13+
* by bootstrapFiles. BootstrapFilesRunner enables it with --debug, where the analysis runs in the
14+
* main process. The class is then reported as not found, and this keeps the autoloader's reason.
15+
*/
16+
#[AutowiredService]
17+
final class BootstrapAutoloaderExceptionReporter
18+
{
19+
20+
private ?Output $output = null;
21+
22+
public function enable(Output $output): void
23+
{
24+
$this->output = $output;
25+
}
26+
27+
public function report(string $className, Throwable $e): void
28+
{
29+
if ($this->output === null) {
30+
return;
31+
}
32+
33+
$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()));
34+
$this->output->writeLineFormatted($e->getTraceAsString());
35+
}
36+
37+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Command;
4+
5+
use LogicException;
6+
use PHPStan\Command\Symfony\SymfonyOutput;
7+
use PHPStan\Command\Symfony\SymfonyStyle;
8+
use PHPStan\Reflection\BetterReflection\SourceLocator\BootstrapAutoloaderExceptionReporter;
9+
use PHPStan\Testing\PHPStanTestCase;
10+
use PHPUnit\Framework\Attributes\DataProvider;
11+
use Symfony\Component\Console\Input\InputInterface;
12+
use Symfony\Component\Console\Output\StreamOutput;
13+
use function fopen;
14+
use function rewind;
15+
use function stream_get_contents;
16+
17+
class BootstrapFilesRunnerTest extends PHPStanTestCase
18+
{
19+
20+
/**
21+
* @return iterable<string, array{bool, bool}>
22+
*/
23+
public static function dataReporterIsEnabledOnlyWithDebug(): iterable
24+
{
25+
yield 'debug' => [true, true];
26+
yield 'no debug' => [false, false];
27+
}
28+
29+
/**
30+
* @throws InceptionNotSuccessfulException
31+
*/
32+
#[DataProvider('dataReporterIsEnabledOnlyWithDebug')]
33+
public function testReporterIsEnabledOnlyWithDebug(bool $debugEnabled, bool $expectPrinted): void
34+
{
35+
$stream = fopen('php://memory', 'w+');
36+
$this->assertNotFalse($stream);
37+
$output = new StreamOutput($stream);
38+
$errorOutput = new SymfonyOutput($output, new SymfonyStyle(new \Symfony\Component\Console\Style\SymfonyStyle($this->createStub(InputInterface::class), $output)));
39+
40+
$reporter = new BootstrapAutoloaderExceptionReporter();
41+
$runner = new BootstrapFilesRunner(self::getContainer(), $reporter);
42+
$runner->run($errorOutput, $debugEnabled);
43+
$reporter->report('SomeClass', new LogicException('Cannot read the class map.'));
44+
45+
rewind($stream);
46+
$printed = stream_get_contents($stream);
47+
$this->assertIsString($printed);
48+
if ($expectPrinted) {
49+
$this->assertStringContainsString('by a bootstrap autoloader for class SomeClass: Cannot read the class map.', $printed);
50+
} else {
51+
$this->assertSame('', $printed);
52+
}
53+
}
54+
55+
/**
56+
* @throws InceptionNotSuccessfulException
57+
*/
58+
public function testReporterIsEnabledWhenTheFilesAlreadyRan(): void
59+
{
60+
$stream = fopen('php://memory', 'w+');
61+
$this->assertNotFalse($stream);
62+
$output = new StreamOutput($stream);
63+
$errorOutput = new SymfonyOutput($output, new SymfonyStyle(new \Symfony\Component\Console\Style\SymfonyStyle($this->createStub(InputInterface::class), $output)));
64+
65+
$reporter = new BootstrapAutoloaderExceptionReporter();
66+
$runner = new BootstrapFilesRunner(self::getContainer(), $reporter);
67+
$runner->run($errorOutput, false);
68+
$runner->run($errorOutput, true);
69+
$reporter->report('SomeClass', new LogicException('Cannot read the class map.'));
70+
71+
rewind($stream);
72+
$printed = stream_get_contents($stream);
73+
$this->assertIsString($printed);
74+
$this->assertStringContainsString('by a bootstrap autoloader for class SomeClass', $printed);
75+
}
76+
77+
}

‎tests/PHPStan/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocatorTest.php‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,17 @@
99
use PHPStan\BetterReflection\Reflector\DefaultReflector;
1010
use PHPStan\BetterReflection\SourceLocator\Ast\Locator;
1111
use PHPStan\BetterReflection\SourceLocator\SourceStubber\ReflectionSourceStubber;
12+
use PHPStan\Command\Symfony\SymfonyOutput;
13+
use PHPStan\Command\Symfony\SymfonyStyle;
1214
use PHPStan\Testing\PHPStanTestCase;
15+
use Symfony\Component\Console\Input\InputInterface;
16+
use Symfony\Component\Console\Output\StreamOutput;
1317
use TestSingleFileSourceLocator\AFoo;
1418
use function class_alias;
1519
use function class_exists;
20+
use function fopen;
21+
use function rewind;
22+
use function stream_get_contents;
1623

1724
class AutoloadFunctionsSourceLocatorTest extends PHPStanTestCase
1825
{
@@ -81,7 +88,44 @@ static function (string $class): void {
8188
}
8289
}
8390

84-
private function createLocator(): AutoloadFunctionsSourceLocator
91+
/**
92+
* With --debug, BootstrapFilesRunner enables the reporter, and the autoloader's own message
93+
* reaches the error output. The class is still reported as not found.
94+
*/
95+
public function testTheReporterPrintsTheSwallowedException(): void
96+
{
97+
$GLOBALS['__phpstanAutoloadFunctions'] = [
98+
static function (string $class): void {
99+
throw new LogicException('Cannot read the class map.');
100+
},
101+
];
102+
103+
$stream = fopen('php://memory', 'w+');
104+
$this->assertNotFalse($stream);
105+
$output = new StreamOutput($stream);
106+
$reporter = new BootstrapAutoloaderExceptionReporter();
107+
$reporter->enable(new SymfonyOutput($output, new SymfonyStyle(new \Symfony\Component\Console\Style\SymfonyStyle($this->createStub(InputInterface::class), $output))));
108+
109+
try {
110+
$locator = $this->createLocator($reporter);
111+
$reflection = $locator->locateIdentifier(
112+
new DefaultReflector($locator),
113+
new Identifier('NeverDefinedByAnyAutoloader', new IdentifierType(IdentifierType::IDENTIFIER_CLASS)),
114+
);
115+
116+
$this->assertNull($reflection);
117+
} finally {
118+
unset($GLOBALS['__phpstanAutoloadFunctions']);
119+
}
120+
121+
rewind($stream);
122+
$printed = stream_get_contents($stream);
123+
$this->assertIsString($printed);
124+
$this->assertStringContainsString('LogicException thrown in ' . __FILE__, $printed);
125+
$this->assertStringContainsString('by a bootstrap autoloader for class NeverDefinedByAnyAutoloader: Cannot read the class map.', $printed);
126+
}
127+
128+
private function createLocator(?BootstrapAutoloaderExceptionReporter $reporter = null): AutoloadFunctionsSourceLocator
85129
{
86130
$container = self::getContainer();
87131

@@ -91,6 +135,7 @@ private function createLocator(): AutoloadFunctionsSourceLocator
91135
new Locator($container->getService('phpParserDecorator')),
92136
new ReflectionSourceStubber(new Standard()),
93137
),
138+
$reporter ?? new BootstrapAutoloaderExceptionReporter(),
94139
);
95140
}
96141

0 commit comments

Comments
 (0)