Skip to content

Commit c12c14e

Browse files
SanderMullerclaude
andcommitted
Decline a bootstrap autoloader only when it would re-include a loaded file
The guard added for phpstan/phpstan#14988 declined this locator whenever a function of the class's name existed. Classes and functions live in separate symbol spaces, so that also blocked class names which merely coincide with a function - Laravel's facade aliases are exactly that shape, since Cache, File, Str and friends coincide with the global helpers cache(), file() and str(), and `use Cache;` started reporting class.notFound. The hazard was never the name: it was a catch-all autoloader resolving a class name to the function's own file and including it a second time. Probing the autoloaders under the file-read trap says which file they would read without executing it, so only that case declines. A loader that defines the class without reading a file - class_alias(), eval() - now runs as it did before. Closes phpstan/phpstan#15102 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e6357e5 commit c12c14e

2 files changed

Lines changed: 218 additions & 17 deletions

File tree

src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php

Lines changed: 93 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,13 @@
1010
use PHPStan\BetterReflection\SourceLocator\Type\SourceLocator;
1111
use function class_exists;
1212
use function function_exists;
13+
use function get_included_files;
14+
use function in_array;
1315
use function interface_exists;
1416
use function PHPStan\autoloadFunctions;
1517
use function PHPStan\autoloadFunctionsPrependedToComposer;
18+
use function restore_error_handler;
19+
use function set_error_handler;
1620
use function trait_exists;
1721

1822
final class AutoloadFunctionsSourceLocator implements SourceLocator
@@ -43,29 +47,31 @@ public function locateIdentifier(Reflector $reflector, Identifier $identifier):
4347
return null;
4448
}
4549

46-
// If the name is already a defined function, this locator must not run the bootstrap
47-
// autoloaders for it: a catch-all autoloader (e.g. PHP_CodeSniffer's, which falls back to
48-
// Composer's findFile()) would resolve the name to the function's own file and plain-include
49-
// it a second time - it was loaded once already, e.g. by a package that ships one function
50-
// per PSR-4 path and requires it from its bootstrap - fatally redeclaring the function.
51-
// Returning null only declines this locator; a class and a function may share a name in PHP,
52-
// and a class that genuinely exists under this name in another file is still located by the
53-
// later source locators in the chain. See https://github.com/phpstan/phpstan/issues/14988
54-
if (function_exists($className)) {
55-
return null;
56-
}
57-
5850
$autoloadFunctions = $this->prependedToComposer
5951
? autoloadFunctionsPrependedToComposer()
6052
: autoloadFunctions();
53+
54+
if ($autoloadFunctions === []) {
55+
return null;
56+
}
57+
58+
if (function_exists($className)) {
59+
if ($this->wouldReIncludeALoadedFile($autoloadFunctions, $className)) {
60+
return null;
61+
}
62+
63+
// The trap intercepts file reads, not execution, so the probe ran the autoloaders for
64+
// real. One that defines the class without reading a file - class_alias(), eval() -
65+
// has already done its work, and calling it again would redeclare what it defined.
66+
if (class_exists($className, false) || interface_exists($className, false) || trait_exists($className, false)) {
67+
return $this->locateWithoutAutoloading($reflector, $identifier);
68+
}
69+
}
70+
6171
foreach ($autoloadFunctions as $autoloadFunction) {
6272
$autoloadFunction($className);
63-
$reflection = $this->autoloadSourceLocator->locateIdentifier($reflector, $identifier);
64-
if ($reflection !== null) {
65-
return $reflection;
66-
}
6773

68-
$reflection = $this->reflectionClassSourceLocator->locateIdentifier($reflector, $identifier);
74+
$reflection = $this->locateWithoutAutoloading($reflector, $identifier);
6975
if ($reflection !== null) {
7076
return $reflection;
7177
}
@@ -74,6 +80,76 @@ public function locateIdentifier(Reflector $reflector, Identifier $identifier):
7480
return null;
7581
}
7682

83+
private function locateWithoutAutoloading(Reflector $reflector, Identifier $identifier): ?Reflection
84+
{
85+
$reflection = $this->autoloadSourceLocator->locateIdentifier($reflector, $identifier);
86+
if ($reflection !== null) {
87+
return $reflection;
88+
}
89+
90+
return $this->reflectionClassSourceLocator->locateIdentifier($reflector, $identifier);
91+
}
92+
93+
/**
94+
* Whether running these autoloaders for $className would include a file that is loaded already.
95+
*
96+
* A function of this name exists, so an autoloader that maps names to paths - a catch-all one
97+
* like PHP_CodeSniffer's, falling back to Composer's findFile() - can resolve this *class* name
98+
* to the *function's* own file. Including that file a second time fatally redeclares the
99+
* function, which is what https://github.com/phpstan/phpstan/issues/14988 reported.
100+
*
101+
* Probing under the file-read trap answers which file the autoloaders would read without
102+
* executing it, so only that case is declined. Declining on the name alone would also block
103+
* class names that merely coincide with a function - classes and functions live in separate
104+
* symbol spaces, and Laravel's facade aliases (Cache, File, Str, ...) collide with the global
105+
* helpers cache(), file() and str(). See https://github.com/phpstan/phpstan/issues/15102
106+
*
107+
* @param array<int, callable(string): void> $autoloadFunctions
108+
*/
109+
private function wouldReIncludeALoadedFile(array $autoloadFunctions, string $className): bool
110+
{
111+
set_error_handler(static fn (): bool => true);
112+
113+
try {
114+
$locatedFiles = FileReadTrapStreamWrapper::withStreamWrapperOverride(
115+
static function () use ($autoloadFunctions, $className): array {
116+
foreach ($autoloadFunctions as $autoloadFunction) {
117+
$autoloadFunction($className);
118+
119+
// Stop as soon as the name is defined, the way spl_autoload_call() does:
120+
// a later autoloader must not get the chance to resolve a name that is
121+
// already taken care of. Under the trap a file read cannot define
122+
// anything, so this means the autoloader defined it by itself.
123+
if (class_exists($className, false) || interface_exists($className, false) || trait_exists($className, false)) {
124+
return [];
125+
}
126+
127+
if (FileReadTrapStreamWrapper::$autoloadLocatedFiles !== []) {
128+
return FileReadTrapStreamWrapper::$autoloadLocatedFiles;
129+
}
130+
}
131+
132+
return [];
133+
},
134+
);
135+
} finally {
136+
restore_error_handler();
137+
}
138+
139+
if ($locatedFiles === []) {
140+
return false;
141+
}
142+
143+
$includedFiles = get_included_files();
144+
foreach ($locatedFiles as $locatedFile) {
145+
if (in_array($locatedFile, $includedFiles, true)) {
146+
return true;
147+
}
148+
}
149+
150+
return false;
151+
}
152+
77153
#[Override]
78154
public function locateIdentifiersByType(Reflector $reflector, IdentifierType $identifierType): array
79155
{
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Reflection\BetterReflection\SourceLocator;
4+
5+
use PhpParser\PrettyPrinter\Standard;
6+
use PHPStan\BetterReflection\Identifier\Identifier;
7+
use PHPStan\BetterReflection\Identifier\IdentifierType;
8+
use PHPStan\BetterReflection\Reflector\DefaultReflector;
9+
use PHPStan\BetterReflection\SourceLocator\Ast\Locator;
10+
use PHPStan\BetterReflection\SourceLocator\SourceStubber\ReflectionSourceStubber;
11+
use PHPStan\Testing\PHPStanTestCase;
12+
use TestSingleFileSourceLocator\AFoo;
13+
use function class_alias;
14+
use function class_exists;
15+
use function function_exists;
16+
17+
class AutoloadFunctionsSourceLocatorTest extends PHPStanTestCase
18+
{
19+
20+
/**
21+
* A class alias created by a bootstrap-registered autoloader must be located even when a
22+
* *function* of the same name exists - classes and functions live in separate symbol
23+
* spaces, and Laravel's facade aliases (Cache, File, Str, ...) collide with global
24+
* helpers like cache(), file() and str().
25+
*
26+
* @see https://github.com/phpstan/phpstan/issues/15102
27+
*/
28+
public function testLocatesAliasWhoseNameCollidesWithAFunction(): void
29+
{
30+
require_once __DIR__ . '/data/a.php';
31+
$this->assertTrue(function_exists('file'), 'precondition: file() is a built-in function');
32+
$this->assertFalse(class_exists('File', false), 'precondition: no File class yet');
33+
34+
$invocations = 0;
35+
$GLOBALS['__phpstanAutoloadFunctions'] = [
36+
static function (string $class) use (&$invocations): void {
37+
if ($class !== 'File') {
38+
return;
39+
}
40+
41+
$invocations++;
42+
class_alias(AFoo::class, 'File');
43+
},
44+
];
45+
46+
try {
47+
$locator = $this->createLocator();
48+
$reflection = $locator->locateIdentifier(
49+
new DefaultReflector($locator),
50+
new Identifier('File', new IdentifierType(IdentifierType::IDENTIFIER_CLASS)),
51+
);
52+
53+
// Non-null is the point: before the fix this locator declined outright because a
54+
// function named file() exists. The reflection carries the alias *target*'s name -
55+
// rewriting it to the alias is RewriteClassAliasSourceLocator's job, further up the chain.
56+
$this->assertNotNull($reflection, 'the aliased class should be located');
57+
$this->assertSame(AFoo::class, $reflection->getName());
58+
59+
// An autoloader that defines the class itself must not be called a second time:
60+
// class_alias() would then warn that the name is already in use.
61+
$this->assertSame(1, $invocations, 'the autoloader should run exactly once');
62+
} finally {
63+
unset($GLOBALS['__phpstanAutoloadFunctions']);
64+
}
65+
}
66+
67+
/**
68+
* A defining autoloader must win over a later catch-all one that would resolve the same name to
69+
* an already-loaded file: the name is taken care of by the time the catch-all runs, exactly as
70+
* spl_autoload_call() would stop there.
71+
*/
72+
public function testDefiningAutoloaderWinsOverALaterCatchAllOne(): void
73+
{
74+
require_once __DIR__ . '/data/a.php';
75+
$this->assertTrue(function_exists('hash'), 'precondition: hash() is a built-in function');
76+
$this->assertFalse(class_exists('Hash', false), 'precondition: no Hash class yet');
77+
78+
$GLOBALS['__phpstanAutoloadFunctions'] = [
79+
static function (string $class): void {
80+
if ($class !== 'Hash') {
81+
return;
82+
}
83+
84+
class_alias(AFoo::class, 'Hash');
85+
},
86+
static function (string $class): void {
87+
if ($class !== 'Hash') {
88+
return;
89+
}
90+
91+
// A catch-all autoloader mapping names to paths - PHP_CodeSniffer's shape - landing
92+
// on a file that is loaded already.
93+
require __DIR__ . '/data/a.php';
94+
},
95+
];
96+
97+
try {
98+
$locator = $this->createLocator();
99+
$reflection = $locator->locateIdentifier(
100+
new DefaultReflector($locator),
101+
new Identifier('Hash', new IdentifierType(IdentifierType::IDENTIFIER_CLASS)),
102+
);
103+
104+
$this->assertNotNull($reflection, 'the aliased class should be located');
105+
$this->assertSame(AFoo::class, $reflection->getName());
106+
} finally {
107+
unset($GLOBALS['__phpstanAutoloadFunctions']);
108+
}
109+
}
110+
111+
private function createLocator(): AutoloadFunctionsSourceLocator
112+
{
113+
$container = self::getContainer();
114+
115+
return new AutoloadFunctionsSourceLocator(
116+
new AutoloadSourceLocator($container->getByType(FileNodesFetcher::class), false),
117+
new ReflectionClassSourceLocator(
118+
new Locator($container->getService('phpParserDecorator')),
119+
new ReflectionSourceStubber(new Standard()),
120+
),
121+
false,
122+
);
123+
}
124+
125+
}

0 commit comments

Comments
 (0)