Skip to content

Commit 598faaa

Browse files
ondrejmirtesclaude
andcommitted
Resolve bootstrap autoloaders' classes from the files they ask for, running them for real only when that cannot redeclare a symbol
The function_exists($className) gate added for phpstan/phpstan#14988 confused two meanings of "functions": AutoloadFunctionsSourceLocator is about autoload functions - the spl_autoload_register() callbacks that bootstrap files register - not about class names coinciding with defined functions. Executed for a class, such an autoloader either asks for a file, which the file-read trap detects, or defines the class without one, through class_alias() or eval(). The gate's probe also poisoned its own evidence: a trapped include succeeds with empty content, defining nothing but still registering in get_included_files(), so comparing the trapped files against get_included_files() taken after the probe declined the very lookup it was probing. Laravel's facade aliases whose target class is not loaded yet hit exactly that: resolving Redirect makes class_alias() ask for the file of Illuminate\Support\Facades\Redirect, the probe trapped that read, found the file "already included" and declined, and larastan's monicahq/monica e2e failed with seven "unknown class Redirect" errors. Any earlier probe trapping the same file poisoned later lookups the same way, since the pseudo-include sticks for the rest of the process. Now every lookup probes under the trap first, the class is located statically in the files the autoloaders asked for, and the autoloaders run for real only when executing those files could not fatally redeclare anything - judged by the files' statically parsed symbols, which pseudo-includes cannot poison, instead of by get_included_files(). The bug-15102c e2e covers the shape Illuminate\Foundation\AliasLoader creates in a real Laravel app: a prepended autoloader aliasing a name that collides with a global helper function to a class only Composer can autoload. See phpstan/phpstan#15102 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JkCjnojdWZvswKCPmzt4zY
1 parent 2889154 commit 598faaa

10 files changed

Lines changed: 172 additions & 35 deletions

File tree

.github/workflows/e2e-tests.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,10 @@ jobs:
147147
cd e2e/bug-15102
148148
composer install
149149
../../bin/phpstan analyze
150+
- script: |
151+
cd e2e/bug-15102c
152+
composer install
153+
../../bin/phpstan analyze
150154
- script: |
151155
cd e2e/bug-14724
152156
composer install

e2e/bug-15102c/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/vendor/
2+
composer.lock

e2e/bug-15102c/bootstrap.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php declare(strict_types = 1);
2+
3+
// The shape Illuminate\Foundation\AliasLoader creates in a real Laravel app: a prepended
4+
// autoloader resolving a short alias with class_alias(), where the aliased class is not
5+
// loaded yet - Composer autoloads it on demand when class_alias() asks for it. The alias
6+
// name collides with a global helper function ('Redirect' vs redirect()), like Laravel's
7+
// Redirect, Cache, View and Session aliases do.
8+
require_once __DIR__ . '/vendor/autoload.php';
9+
10+
spl_autoload_register(static function (string $class): void {
11+
if ($class !== 'Redirect') {
12+
return;
13+
}
14+
15+
class_alias(E2eFacadeAlias\Redirect::class, 'Redirect');
16+
}, true, true);

e2e/bug-15102c/composer.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"autoload": {
3+
"psr-4": {
4+
"E2eFacadeAlias\\": "src/"
5+
},
6+
"files": [
7+
"helpers.php"
8+
]
9+
}
10+
}

e2e/bug-15102c/helpers.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php declare(strict_types = 1);
2+
3+
// The global helper whose name collides with the alias - Laravel loads these via
4+
// Composer's autoload.files, so the function exists before analysis starts and
5+
// function_exists('Redirect') is true (function names are case-insensitive).
6+
function redirect(): int
7+
{
8+
return 1;
9+
}

e2e/bug-15102c/phpstan.dist.neon

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
parameters:
2+
level: 8
3+
bootstrapFiles:
4+
- bootstrap.php
5+
paths:
6+
- test.php

e2e/bug-15102c/src/Redirect.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace E2eFacadeAlias;
4+
5+
// The class behind the alias. Unlike bug-15102b's fixture it is NOT loaded during
6+
// bootstrap - only Composer can autoload it - so resolving the alias makes
7+
// class_alias() trigger a nested file read while the probe's trap is active.
8+
class Redirect
9+
{
10+
11+
public function doFoo(): void
12+
{
13+
}
14+
15+
}

e2e/bug-15102c/test.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<?php declare(strict_types = 1);
2+
3+
function (Redirect $redirect): void {
4+
$redirect->doFoo();
5+
};

src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php

Lines changed: 49 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,20 @@
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;
1513
use function interface_exists;
14+
use function opcache_invalidate;
1615
use function PHPStan\autoloadFunctions;
1716
use function PHPStan\autoloadFunctionsPrependedToComposer;
1817
use function restore_error_handler;
1918
use function set_error_handler;
2019
use function trait_exists;
2120

21+
/**
22+
* Consults the autoload functions that bootstrap files registered - spl_autoload_register()
23+
* callbacks that are not Composer's class loader. Asked for a class, such an autoloader either
24+
* reads a file (which the file-read trap detects, so the class is located in it statically) or
25+
* defines the class without one, through class_alias() or eval().
26+
*/
2227
final class AutoloadFunctionsSourceLocator implements SourceLocator
2328
{
2429

@@ -55,17 +60,36 @@ public function locateIdentifier(Reflector $reflector, Identifier $identifier):
5560
return null;
5661
}
5762

58-
if (function_exists($className)) {
59-
if ($this->wouldReIncludeALoadedFile($autoloadFunctions, $className)) {
60-
return null;
61-
}
63+
$locatedFiles = $this->probeAutoloadFunctions($autoloadFunctions, $className);
6264

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-
}
65+
// An autoloader can define the class without reading any file - class_alias() with an
66+
// already-loaded target, or eval(). The trap intercepts file reads, not execution, so
67+
// such an autoloader has already done its work during the probe.
68+
if (class_exists($className, false) || interface_exists($className, false) || trait_exists($className, false)) {
69+
return $this->locateWithoutAutoloading($reflector, $identifier);
70+
}
71+
72+
if ($locatedFiles === []) {
73+
return null;
74+
}
75+
76+
// The autoloaders asked for these files - locate the class in them statically, without
77+
// executing anything.
78+
$reflection = $this->autoloadSourceLocator->locateIdentifierInFiles($reflector, $identifier, $locatedFiles);
79+
if ($reflection !== null) {
80+
return $reflection;
81+
}
82+
83+
// The located files do not declare the class under this name. Running the autoloaders
84+
// for real can still resolve it: class_alias() whose target Composer has to autoload
85+
// first asks for the *target's* file, which never declares the alias name - Laravel's
86+
// Redirect alias reads the file of Illuminate\Support\Facades\Redirect. But a real run
87+
// is only safe when it cannot redeclare anything: a catch-all autoloader can resolve a
88+
// class name to the file of an already-loaded function of the same name and fatally
89+
// include it a second time, which is what
90+
// https://github.com/phpstan/phpstan/issues/14988 reported.
91+
if ($this->autoloadSourceLocator->wouldIncludingFilesRedeclareSymbols($locatedFiles)) {
92+
return null;
6993
}
7094

7195
foreach ($autoloadFunctions as $autoloadFunction) {
@@ -91,22 +115,16 @@ private function locateWithoutAutoloading(Reflector $reflector, Identifier $iden
91115
}
92116

93117
/**
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
118+
* Runs the autoload functions under the file-read trap and reports which files they asked
119+
* for. No file content is executed - the trap serves empty data - so the probe is free of
120+
* the side effects that make running bootstrap autoloaders for real hazardous. Mirrors
121+
* spl_autoload_call() by stopping at the first autoloader that defines the name or asks
122+
* for a file.
106123
*
107124
* @param array<int, callable(string): void> $autoloadFunctions
125+
* @return string[]
108126
*/
109-
private function wouldReIncludeALoadedFile(array $autoloadFunctions, string $className): bool
127+
private function probeAutoloadFunctions(array $autoloadFunctions, string $className): array
110128
{
111129
set_error_handler(static fn (): bool => true);
112130

@@ -136,21 +154,17 @@ static function () use ($autoloadFunctions, $className): array {
136154
restore_error_handler();
137155
}
138156

139-
if ($locatedFiles === []) {
140-
return false;
157+
if (!function_exists('opcache_invalidate')) {
158+
return $locatedFiles;
141159
}
142160

143-
// PHP canonicalises the path before it reaches a stream wrapper - a `/./` segment, a
144-
// symlinked directory or an include-path-relative name all arrive resolved - so the
145-
// trapped paths compare directly against get_included_files().
146-
$includedFiles = get_included_files();
161+
// The pseudo-include may have cached the trap's empty content; running the autoloaders
162+
// for real afterwards has to compile the actual file.
147163
foreach ($locatedFiles as $locatedFile) {
148-
if (in_array($locatedFile, $includedFiles, true)) {
149-
return true;
150-
}
164+
opcache_invalidate($locatedFile, true);
151165
}
152166

153-
return false;
167+
return $locatedFiles;
154168
}
155169

156170
#[Override]

src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,62 @@ public function locateIdentifier(Reflector $reflector, Identifier $identifier):
168168
return null;
169169
}
170170

171+
/**
172+
* Whether including these files for real would redeclare a class or function that is
173+
* already defined in this process - which is fatal, so the caller must not execute them.
174+
* The check is against the files' statically parsed symbols, not get_included_files():
175+
* a file-read-trap probe pseudo-includes every file it traps (the include succeeds with
176+
* empty content and registers in get_included_files() while defining nothing), so the
177+
* include list overreports what is genuinely loaded.
178+
*
179+
* @param string[] $files
180+
*/
181+
public function wouldIncludingFilesRedeclareSymbols(array $files): bool
182+
{
183+
foreach ($files as $file) {
184+
if (!is_file($file)) {
185+
continue;
186+
}
187+
188+
$result = $this->fileNodesFetcher->fetchNodes($file);
189+
foreach (array_keys($result->getClassNodes()) as $className) {
190+
if (class_exists($className, false) || interface_exists($className, false) || trait_exists($className, false)) {
191+
return true;
192+
}
193+
}
194+
195+
foreach (array_keys($result->getFunctionNodes()) as $functionName) {
196+
if (function_exists($functionName)) {
197+
return true;
198+
}
199+
}
200+
}
201+
202+
return false;
203+
}
204+
205+
/**
206+
* Locates the identifier in the given files without invoking any autoloader - used by
207+
* AutoloadFunctionsSourceLocator with the files its file-read-trap probe recorded.
208+
*
209+
* @param string[] $files
210+
*/
211+
public function locateIdentifierInFiles(Reflector $reflector, Identifier $identifier, array $files): ?Reflection
212+
{
213+
foreach ($files as $file) {
214+
if (!is_file($file)) {
215+
continue;
216+
}
217+
218+
$reflection = $this->findReflection($reflector, $file, $identifier, null);
219+
if ($reflection !== null) {
220+
return $reflection;
221+
}
222+
}
223+
224+
return null;
225+
}
226+
171227
private function findReflection(Reflector $reflector, string $file, Identifier $identifier, ?int $startLine): ?Reflection
172228
{
173229
$result = $this->fileNodesFetcher->fetchNodes($file);

0 commit comments

Comments
 (0)