Skip to content

Decline a bootstrap autoloader only when it would re-include a loaded file - #6265

Open
SanderMuller wants to merge 1 commit into
phpstan:2.2.xfrom
SanderMuller:autoloader-function-name-collision
Open

Decline a bootstrap autoloader only when it would re-include a loaded file#6265
SanderMuller wants to merge 1 commit into
phpstan:2.2.xfrom
SanderMuller:autoloader-function-name-collision

Conversation

@SanderMuller

@SanderMuller SanderMuller commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Fixes the second regression reported in phpstan/phpstan#15102 - the one @hoetaek reduced to a portable repro. This is not the ordering issue that opened that ticket; that one is #6069's heuristic and needs its own change.

What was wrong

#6185 added this to AutoloadFunctionsSourceLocator:

if (function_exists($className)) {
    return null;
}

Classes and functions occupy separate symbol spaces, so a class name coinciding with a function name is legal and common. Laravel is the worst case: the facade aliases Cache, File, Str, Hash coincide with the global helpers cache(), str() and with PHP's own file(), hash(). Illuminate\Foundation\AliasLoader creates those aliases lazily from a prepended autoloader, so with the guard in place use Cache; reports class.notFound for every larastan user. Verified against the release phars on @hoetaek's repro:

File (collides with file()) Alias (no such function)
2.2.8 resolved resolved
2.2.9 class.notFound resolved

What the hazard actually was

phpstan/phpstan#14988 was not about the name. A catch-all autoloader - PHP_CodeSniffer's, falling back to Composer's findFile() - resolved a class name to a function's file and plain-included it a second time, fatally redeclaring the function.

So the check now asks that question instead of guessing from the name: probe the autoloaders under FileReadTrapStreamWrapper, which reports which file they would read without executing it, and decline only when that file is already in get_included_files(). An autoloader that defines the class without reading a file - class_alias(), eval() - runs exactly as it did in 2.2.8. The probe only runs for names that actually collide with a function, and not at all when the bucket holds no autoloaders - so a project without bootstrap autoloaders never reaches it: analysing src/Rules triggers 0 probes. Where it does run, get_included_files() held ~35 entries, so the scan is nothing.

One subtlety the trap forces: it intercepts file reads, not execution, so the probe really does run the autoloaders. An autoloader that defines the class without reading a file has therefore already done its work by the time the probe returns, and calling it again would redeclare what it defined - class_alias() warns that the name is already in use. So when the class exists after the probe, the locator reflects it directly instead of looping over the autoloaders again; the test asserts the autoloader runs exactly once. For the same reason the probe stops at the first autoloader that defines the name, the way spl_autoload_call() does - otherwise a later catch-all autoloader in the same bucket could still resolve that name to a loaded file and veto a class the first one had already defined.

Verification

  • New AutoloadFunctionsSourceLocatorTest pins two cases, both verified failing without the change: the alias case (on 2.2.x the locator declines and returns null), and a defining autoloader followed by a catch-all one in the same bucket. It also asserts the autoloader is invoked exactly once - the outcome assertion alone passes either way, only the count (2 vs 1) separates them.
  • e2e/bug-14988 still exits 0 - the redeclare fatal does not come back, it is prevented by the trap rather than by the name.
  • e2e/bug-12972b, e2e/bug-12972c (Consult bootstrap-registered custom autoloaders only after the static source locators #6069) and CollectNewAutoloadFunctionsTest unchanged and green.
  • Full suite 21146 green, self-analysis clean, phpcs clean, rebased on current 2.2.x.
  • The red checks are today's 2.2.x breakage, not this branch: the same 13 - integration tests, phpstan-doctrine on all four PHP versions (Call to deprecated method toMutatingScope()), Test (PHP 8.5), Result cache E2E bug-11826, Check for typos (which fails on the tip of 2.2.x itself over EXPECTED_EXTENSION_VERSION = '873ede9') - are red on every PR rebased today, e.g. Implement worker auto-scaler (stability and performance boost) #6256 and Run bootstrapFiles per forked worker instead of inheriting the parent's #6264. The one extra, Windows IntersectionTypeTest::testIsAcceptedBy, is the toggle leak Isolate intersection test toggle state #6255 fixes.

On coverage, one thing worth knowing: an e2e/* project cannot pin this. Those run ../../bin/phpstan from source, and in a source run the class is rescued further down the locator chain, so the same project passes with and without the guard - only the phar exhibits it end-to-end. That is why the regression test is at unit level, where the locator is exercised directly and the outcome does not depend on how PHPStan itself was loaded. If a phar-based e2e lane is wanted, that is worth doing on its own for this whole area, since the ordering half of #15102 has the same problem.

… 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>
@SanderMuller
SanderMuller force-pushed the autoloader-function-name-collision branch from dae2292 to 01f2943 Compare August 25, 2026 08:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant