Skip to content

Commit b5a6d39

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 4f80a48 commit b5a6d39

11 files changed

Lines changed: 219 additions & 7 deletions

File tree

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

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -128,15 +128,19 @@ jobs:
128128
cd e2e/bug-14514
129129
composer install
130130
../../bin/phpstan analyze bug-14515.php
131-
# Fails since the revert of the bootstrap autoloader changes:
131+
# Fails until https://github.com/phpstan/phpstan/issues/14988 is fixed again:
132132
# - script: |
133133
# cd e2e/bug-14988
134134
# composer install
135135
# ../../bin/phpstan analyse
136-
# - script: |
137-
# cd e2e/bug-12972b
138-
# composer install
139-
# ../../bin/phpstan analyze
136+
- script: |
137+
cd e2e/bug-12972b
138+
composer install
139+
../../bin/phpstan analyze
140+
- script: |
141+
cd e2e/debug-class-loader
142+
composer install
143+
../../bin/phpstan analyze
140144
- script: |
141145
cd e2e/bug-12972c
142146
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 & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use PHPStan\Type\ConstantTypeHelper;
2323
use ReflectionClass;
2424
use ReflectionFunction;
25+
use Throwable;
2526
use function array_key_exists;
2627
use function array_keys;
2728
use function class_exists;
@@ -343,7 +344,15 @@ static function () use ($className): ?array {
343344
}
344345

345346
foreach ($functions as $preExistingAutoloader) {
346-
$preExistingAutoloader($className);
347+
try {
348+
$preExistingAutoloader($className);
349+
} catch (Throwable) {
350+
// Asking every registered autoloader for the class is not the order PHP uses,
351+
// so an autoloader that throws for names outside its own scope throws here for
352+
// names it is never invoked for at runtime. Its exception would abort the
353+
// analysis of the file with an internal error, so it is swallowed - the file the
354+
// autoloader asked for before throwing is still recorded by the trap below.
355+
}
347356

348357
/**
349358
* This static variable is populated by the side-effect of the stream wrapper
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)