Skip to content

Commit a858388

Browse files
SanderMullerclaude
andcommitted
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) <noreply@anthropic.com>
1 parent e187945 commit a858388

11 files changed

Lines changed: 227 additions & 11 deletions

File tree

‎.github/workflows/e2e-tests.yml‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -219,15 +219,19 @@ jobs:
219219
cd e2e/bug-14514
220220
composer install
221221
../../bin/phpstan analyze bug-14515.php
222-
# Fails since the revert of the bootstrap autoloader changes:
222+
# Fails until https://github.com/phpstan/phpstan/issues/14988 is fixed again:
223223
# - script: |
224224
# cd e2e/bug-14988
225225
# composer install
226226
# ../../bin/phpstan analyse
227-
# - script: |
228-
# cd e2e/bug-12972b
229-
# composer install
230-
# ../../bin/phpstan analyze
227+
- script: |
228+
cd e2e/bug-12972b
229+
composer install
230+
../../bin/phpstan analyze
231+
- script: |
232+
cd e2e/debug-class-loader
233+
composer install
234+
../../bin/phpstan analyze
231235
- script: |
232236
cd e2e/bug-12972c
233237
composer install

‎e2e/debug-class-loader/.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/vendor
2+
/composer.lock
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php declare(strict_types = 1);
2+
3+
// What a Symfony application's entry script does in dev: DebugClassLoader wraps every
4+
// registered autoloader and throws a RuntimeException when the file it found does not
5+
// declare the class it was asked for, or when the case does not match.
6+
require_once __DIR__ . '/vendor/autoload.php';
7+
8+
Symfony\Component\ErrorHandler\DebugClassLoader::enable();
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"require": {
3+
"symfony/error-handler": "^7.0"
4+
},
5+
"autoload": {
6+
"psr-4": {
7+
"DebugClassLoaderE2e\\": "src/"
8+
}
9+
}
10+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
parameters:
2+
level: 6
3+
bootstrapFiles:
4+
- bootstrap.php
5+
paths:
6+
- test.php
7+
ignoreErrors:
8+
# The point of this project is that analysis completes and reports the unknown class
9+
# instead of dying inside the autoloader.
10+
-
11+
message: '#^Parameter \$widget of method DebugClassLoaderE2e\\Consumer\:\:doFoo\(\) has invalid type DebugClassLoaderE2e\\Widget\.$#'
12+
path: test.php
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace DebugClassLoaderE2e;
4+
5+
// The declared name does not match the file name, so Composer's PSR-4 map sends
6+
// DebugClassLoaderE2e\Widget to this file and DebugClassLoader throws for it.
7+
class Widgets
8+
{
9+
10+
}

‎e2e/debug-class-loader/test.php‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace DebugClassLoaderE2e;
4+
5+
class Consumer
6+
{
7+
8+
// Nothing ever instantiates the mistyped name, so no autoloader is invoked for it at
9+
// runtime. PHPStan resolves every name it sees, so it does ask - and the exception used
10+
// to abort the analysis of this file with an internal error instead of reporting the
11+
// unknown class. See https://github.com/phpstan/phpstan/issues/14976
12+
/** @param Widget $widget */
13+
public function doFoo($widget): void
14+
{
15+
}
16+
17+
}

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use PHPStan\BetterReflection\Reflection\Reflection;
99
use PHPStan\BetterReflection\Reflector\Reflector;
1010
use PHPStan\BetterReflection\SourceLocator\Type\SourceLocator;
11+
use Throwable;
1112
use function class_exists;
1213
use function interface_exists;
1314
use function PHPStan\autoloadFunctions;
@@ -37,7 +38,19 @@ public function locateIdentifier(Reflector $reflector, Identifier $identifier):
3738

3839
$autoloadFunctions = autoloadFunctions();
3940
foreach ($autoloadFunctions as $autoloadFunction) {
40-
$autoloadFunction($className);
41+
try {
42+
$autoloadFunction($className);
43+
} catch (Throwable) {
44+
// This locator asks every bootstrap-registered autoloader for the class, which is not
45+
// the order PHP uses: at runtime the class loader that resolves the class first is
46+
// often another one, so an autoloader that throws for names outside its own scope is
47+
// never invoked for them and its exception cannot happen. Letting it propagate here
48+
// aborts the analysis of the file with an internal error instead, so it is swallowed
49+
// and the remaining autoloaders and source locators get their turn. The class may
50+
// still have been defined before the throw, so the locators below run either way.
51+
// See https://github.com/phpstan/phpstan/issues/14976
52+
}
53+
4154
$reflection = $this->autoloadSourceLocator->locateIdentifier($reflector, $identifier);
4255
if ($reflection !== null) {
4356
return $reflection;

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

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
namespace PHPStan\Reflection\BetterReflection\SourceLocator;
44

55
use Override;
6-
use ParseError;
76
use PhpParser\Node\Arg;
87
use PhpParser\Node\Expr\FuncCall;
98
use PhpParser\Node\Name;
@@ -23,6 +22,7 @@
2322
use PHPStan\Type\ConstantTypeHelper;
2423
use ReflectionClass;
2524
use ReflectionFunction;
25+
use Throwable;
2626
use function array_key_exists;
2727
use function array_keys;
2828
use function class_exists;
@@ -346,10 +346,15 @@ static function () use ($className): ?array {
346346
foreach ($functions as $preExistingAutoloader) {
347347
try {
348348
$preExistingAutoloader($className);
349-
} catch (ParseError) {
350-
// the trap served a parse error instead of the empty
351-
// script, see FileReadTrapStreamWrapper::stream_read();
352-
// the file was recorded before the include compiled it
349+
} catch (Throwable) {
350+
// Two unrelated throws land here, and neither one means the class was not found.
351+
// The trap can serve a parse error instead of the empty script, see
352+
// FileReadTrapStreamWrapper::stream_read(). And asking every registered
353+
// autoloader for the class is not the order PHP uses, so an autoloader that
354+
// throws for names outside its own scope throws here for names it is never
355+
// invoked for at runtime. Either exception would abort the analysis of the file
356+
// with an internal error, so both are swallowed - the file the autoloader asked
357+
// for is recorded by the trap before the throw.
353358
}
354359

355360
/**
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Reflection\BetterReflection\SourceLocator;
4+
5+
use LogicException;
6+
use PhpParser\PrettyPrinter\Standard;
7+
use PHPStan\BetterReflection\Identifier\Identifier;
8+
use PHPStan\BetterReflection\Identifier\IdentifierType;
9+
use PHPStan\BetterReflection\Reflector\DefaultReflector;
10+
use PHPStan\BetterReflection\SourceLocator\Ast\Locator;
11+
use PHPStan\BetterReflection\SourceLocator\SourceStubber\ReflectionSourceStubber;
12+
use PHPStan\Testing\PHPStanTestCase;
13+
use TestSingleFileSourceLocator\AFoo;
14+
use function class_alias;
15+
use function class_exists;
16+
17+
class AutoloadFunctionsSourceLocatorTest extends PHPStanTestCase
18+
{
19+
20+
/**
21+
* A bootstrap autoloader that throws for names outside its own scope must not abort the
22+
* analysis: this locator asks every registered autoloader, while at runtime the class loader
23+
* that resolves the class first means the throwing one is never invoked for that name.
24+
*
25+
* @see https://github.com/phpstan/phpstan/issues/14976
26+
*/
27+
public function testAThrowingAutoloaderDoesNotStopTheRemainingOnes(): void
28+
{
29+
require_once __DIR__ . '/data/a.php';
30+
$this->assertFalse(class_exists('ThrowingAutoloaderAlias', false), 'precondition: the alias does not exist yet');
31+
32+
$GLOBALS['__phpstanAutoloadFunctions'] = [
33+
static function (string $class): void {
34+
throw new LogicException('this should not happen');
35+
},
36+
static function (string $class): void {
37+
if ($class !== 'ThrowingAutoloaderAlias') {
38+
return;
39+
}
40+
41+
class_alias(AFoo::class, 'ThrowingAutoloaderAlias');
42+
},
43+
];
44+
45+
try {
46+
$locator = $this->createLocator();
47+
$reflection = $locator->locateIdentifier(
48+
new DefaultReflector($locator),
49+
new Identifier('ThrowingAutoloaderAlias', new IdentifierType(IdentifierType::IDENTIFIER_CLASS)),
50+
);
51+
52+
$this->assertNotNull($reflection, 'the class defined by the second autoloader should be located');
53+
$this->assertSame(AFoo::class, $reflection->getName());
54+
} finally {
55+
unset($GLOBALS['__phpstanAutoloadFunctions']);
56+
}
57+
}
58+
59+
/**
60+
* Nothing resolves the name, so the locator declines - the point is that it declines instead of
61+
* letting the autoloader's exception surface as an internal error.
62+
*/
63+
public function testAThrowingAutoloaderMakesTheLocatorDecline(): void
64+
{
65+
$GLOBALS['__phpstanAutoloadFunctions'] = [
66+
static function (string $class): void {
67+
throw new LogicException('this should not happen');
68+
},
69+
];
70+
71+
try {
72+
$locator = $this->createLocator();
73+
$reflection = $locator->locateIdentifier(
74+
new DefaultReflector($locator),
75+
new Identifier('NeverDefinedByAnyAutoloader', new IdentifierType(IdentifierType::IDENTIFIER_CLASS)),
76+
);
77+
78+
$this->assertNull($reflection);
79+
} finally {
80+
unset($GLOBALS['__phpstanAutoloadFunctions']);
81+
}
82+
}
83+
84+
private function createLocator(): AutoloadFunctionsSourceLocator
85+
{
86+
$container = self::getContainer();
87+
88+
return new AutoloadFunctionsSourceLocator(
89+
new AutoloadSourceLocator($container->getByType(FileNodesFetcher::class), false),
90+
new ReflectionClassSourceLocator(
91+
new Locator($container->getService('phpParserDecorator')),
92+
new ReflectionSourceStubber(new Standard()),
93+
),
94+
);
95+
}
96+
97+
}

0 commit comments

Comments
 (0)