From ffb7fc75e4cbcda6a73dc6d55d28138fd13cf99f Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Wed, 26 Aug 2026 18:25:17 +0200 Subject: [PATCH] Split bootstrap autoloaders at the project's class loader collectNewAutoloadFunctions() decided whether a bootstrap-registered autoloader runs before or after the static source locators by looking for the first Composer ClassLoader instance in the spl_autoload queue. That instance is PHPStan's own: bin/phpstan requires its own autoloader long before any project code runs, so every collected autoloader looked like it came after Composer and was consulted only after the static locators. The boundary is the analysed project's loader, so skip the first ClassLoader in the before-snapshot and split at the last one instead. When that loader is gone from the queue after the bootstrap files ran, a bootstrap has taken Composer's place - typo3/class-alias-loader unregisters [$composerLoader, 'loadClass'] and registers a wrapper - and its loader carries Composer's runtime priority, so the collected autoloaders are consulted first, as they were until 2.2.9. e2e/bug-15102 covers the replacement shape: an IDE-only stub in the analysed paths declares a legacy name that only exists as a class_alias at runtime. The static locator returns the stub without the fix. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/e2e-tests.yml | 4 + e2e/bug-15102/.gitignore | 2 + e2e/bug-15102/bootstrap.php | 30 +++++++ e2e/bug-15102/composer.json | 7 ++ e2e/bug-15102/phpstan.dist.neon | 8 ++ e2e/bug-15102/src/Validate.php | 12 +++ e2e/bug-15102/stub/Validate.php | 13 +++ e2e/bug-15102/test.php | 5 ++ src/autoloadFunctions.php | 55 ++++++++++--- .../CollectNewAutoloadFunctionsTest.php | 79 +++++++++++++++---- 10 files changed, 189 insertions(+), 26 deletions(-) create mode 100644 e2e/bug-15102/.gitignore create mode 100644 e2e/bug-15102/bootstrap.php create mode 100644 e2e/bug-15102/composer.json create mode 100644 e2e/bug-15102/phpstan.dist.neon create mode 100644 e2e/bug-15102/src/Validate.php create mode 100644 e2e/bug-15102/stub/Validate.php create mode 100644 e2e/bug-15102/test.php diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 188ecf899e9..1ab8c24c1ee 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -143,6 +143,10 @@ jobs: - script: | cd e2e/bug-15102b ../../bin/phpstan analyze + - script: | + cd e2e/bug-15102 + composer install + ../../bin/phpstan analyze - script: | cd e2e/bug-14724 composer install diff --git a/e2e/bug-15102/.gitignore b/e2e/bug-15102/.gitignore new file mode 100644 index 00000000000..3a9875b460f --- /dev/null +++ b/e2e/bug-15102/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +composer.lock diff --git a/e2e/bug-15102/bootstrap.php b/e2e/bug-15102/bootstrap.php new file mode 100644 index 00000000000..b78f0408c97 --- /dev/null +++ b/e2e/bug-15102/bootstrap.php @@ -0,0 +1,30 @@ +wrapped->loadClass($class); + } + +} + +spl_autoload_register([new AliasLoader($composerLoader), 'loadClass']); diff --git a/e2e/bug-15102/composer.json b/e2e/bug-15102/composer.json new file mode 100644 index 00000000000..4865ec6b071 --- /dev/null +++ b/e2e/bug-15102/composer.json @@ -0,0 +1,7 @@ +{ + "autoload": { + "psr-4": { + "Modern\\": "src/" + } + } +} diff --git a/e2e/bug-15102/phpstan.dist.neon b/e2e/bug-15102/phpstan.dist.neon new file mode 100644 index 00000000000..d802ef81693 --- /dev/null +++ b/e2e/bug-15102/phpstan.dist.neon @@ -0,0 +1,8 @@ +parameters: + level: 8 + bootstrapFiles: + - bootstrap.php + paths: + - test.php + - src + - stub diff --git a/e2e/bug-15102/src/Validate.php b/e2e/bug-15102/src/Validate.php new file mode 100644 index 00000000000..897b1df0ddd --- /dev/null +++ b/e2e/bug-15102/src/Validate.php @@ -0,0 +1,12 @@ +doFoo(); +}; diff --git a/src/autoloadFunctions.php b/src/autoloadFunctions.php index 617d7f7ee53..17bcce1dcc0 100644 --- a/src/autoloadFunctions.php +++ b/src/autoloadFunctions.php @@ -3,10 +3,12 @@ namespace PHPStan; use Composer\Autoload\ClassLoader; +use function array_shift; use function count; use function get_class; use function is_array; use function is_object; +use function spl_object_id; /** * Autoloaders that were registered *after* Composer's class loader in the @@ -33,6 +35,19 @@ function autoloadFunctionsPrependedToComposer(): array // phpcs:ignore Squiz.Fun return $GLOBALS['__phpstanAutoloadFunctionsPrependedToComposer'] ?? []; } +/** + * Whether the spl_autoload entry is a Composer ClassLoader's loadClass() callable. + * + * @param mixed $autoloadFunction + */ +function isComposerClassLoader($autoloadFunction): bool // phpcs:ignore Squiz.Functions.GlobalFunction.Found +{ + return is_array($autoloadFunction) + && count($autoloadFunction) > 0 + && is_object($autoloadFunction[0]) + && get_class($autoloadFunction[0]) === ClassLoader::class; +} + /** * Splits the autoload functions registered while loading Composer's autoloader * and the bootstrap files into those registered before and after Composer's own @@ -40,6 +55,14 @@ function autoloadFunctionsPrependedToComposer(): array // phpcs:ignore Squiz.Fun * same order relative to Composer as PHP does at runtime, instead of always * invoking them before (or after) the static Composer source locators. * + * When no Composer ClassLoader instance is left in the queue there is no + * boundary to split on: a bootstrap file has taken Composer's place, so its + * loader carries Composer's runtime priority and belongs before the static + * source locators. Bucketing those as appended would demote a loader that + * actually resolves the class first, which is what + * https://github.com/phpstan/phpstan/issues/15102 reported for + * typo3/class-alias-loader. + * * @param list|false $autoloadFunctionsBefore * @param list|false $autoloadFunctionsAfter * @return array{prepended: list, appended: list} @@ -53,16 +76,28 @@ function collectNewAutoloadFunctions($autoloadFunctionsBefore, $autoloadFunction return ['prepended' => $prepended, 'appended' => $appended]; } + // The split has to happen at the *analysed project's* class loader. PHPStan's own + // loader is a ClassLoader as well and is always registered first - before any project + // code runs - so searching the queue for the first ClassLoader instance would make + // every bootstrap-registered autoloader look like it came after Composer, and demote + // it below the static source locators. + $classLoaderIds = []; + foreach ($autoloadFunctionsBefore as $before) { + if (isComposerClassLoader($before)) { + $classLoaderIds[] = spl_object_id($before[0]); + } + } + + array_shift($classLoaderIds); + $projectLoaderId = $classLoaderIds === [] ? null : $classLoaderIds[count($classLoaderIds) - 1]; + $composerIndex = null; - foreach ($autoloadFunctionsAfter as $index => $after) { - if ( - is_array($after) - && count($after) > 0 - && is_object($after[0]) - && get_class($after[0]) === ClassLoader::class - ) { - $composerIndex = $index; - break; + if ($projectLoaderId !== null) { + foreach ($autoloadFunctionsAfter as $index => $after) { + if (isComposerClassLoader($after) && spl_object_id($after[0]) === $projectLoaderId) { + $composerIndex = $index; + break; + } } } @@ -85,7 +120,7 @@ function collectNewAutoloadFunctions($autoloadFunctionsBefore, $autoloadFunction } } - if ($composerIndex !== null && $index < $composerIndex) { + if ($composerIndex === null || $index < $composerIndex) { $prepended[] = $after; } else { $appended[] = $after; diff --git a/tests/PHPStan/CollectNewAutoloadFunctionsTest.php b/tests/PHPStan/CollectNewAutoloadFunctionsTest.php index 9e2c18f551c..4f83636c714 100644 --- a/tests/PHPStan/CollectNewAutoloadFunctionsTest.php +++ b/tests/PHPStan/CollectNewAutoloadFunctionsTest.php @@ -5,6 +5,12 @@ use Composer\Autoload\ClassLoader; use PHPUnit\Framework\TestCase; +/** + * The before-snapshot always starts with PHPStan's own Composer ClassLoader - bin/phpstan + * requires its own autoloader long before any project code runs - so every case here models + * that entry. Which loader the split happens at is the whole point: see + * https://github.com/phpstan/phpstan/issues/15102 + */ class CollectNewAutoloadFunctionsTest extends TestCase { @@ -23,16 +29,17 @@ public function testFalseInputsYieldEmptyResult(): void ); } - public function testAutoloadersAreSplitByComposerPosition(): void + public function testAutoloadersAreSplitByTheProjectsComposerPosition(): void { + $phpstanOwn = [new ClassLoader(), 'loadClass']; + $project = [new ClassLoader(), 'loadClass']; $prepended = static function (string $class): void { }; - $composer = new ClassLoader(); $appended = static function (string $class): void { }; - $before = []; - $after = [$prepended, [$composer, 'loadClass'], $appended]; + $before = [$phpstanOwn, $project]; + $after = [$phpstanOwn, $prepended, $project, $appended]; $result = collectNewAutoloadFunctions($before, $after); @@ -40,29 +47,68 @@ public function testAutoloadersAreSplitByComposerPosition(): void $this->assertSame([$appended], $result['appended']); } - public function testWithoutComposerEverythingIsAppended(): void + /** + * PHPStan's own loader must never be the boundary. It is registered before anything + * else, so splitting on the first ClassLoader in the queue would classify every + * bootstrap-registered autoloader as "after Composer" and consult it only after the + * static source locators - the regression this test pins. + */ + public function testPhpstansOwnLoaderIsNotTheBoundary(): void { - $first = static function (string $class): void { + $phpstanOwn = [new ClassLoader(), 'loadClass']; + $bootstrap = static function (string $class): void { }; - $second = static function (string $class): void { + + $before = [$phpstanOwn]; + $after = [$phpstanOwn, $bootstrap]; + + $result = collectNewAutoloadFunctions($before, $after); + + $this->assertSame([$bootstrap], $result['prepended']); + $this->assertSame([], $result['appended']); + } + + /** + * The shape typo3/class-alias-loader creates: the bootstrap unregisters + * [$composerLoader, 'loadClass'] and registers a wrapper that delegates to it. With the + * project's loader gone from the queue there is no boundary left, and the wrapper holds + * the priority Composer had, so everything collected is consulted first. + */ + public function testProjectLoaderReplacedByABootstrapWrapperIsPrepended(): void + { + $phpstanOwn = [new ClassLoader(), 'loadClass']; + $project = [new ClassLoader(), 'loadClass']; + $replacement = new class { + + public function loadClass(string $class): void + { + } + + }; + $wrapper = [$replacement, 'loadClass']; + $bootstrap = static function (string $class): void { }; - $result = collectNewAutoloadFunctions([], [$first, $second]); + $before = [$phpstanOwn, $project]; + $after = [$phpstanOwn, $wrapper, $bootstrap]; - $this->assertSame([], $result['prepended']); - $this->assertSame([$first, $second], $result['appended']); + $result = collectNewAutoloadFunctions($before, $after); + + $this->assertSame([$wrapper, $bootstrap], $result['prepended']); + $this->assertSame([], $result['appended']); } public function testComposerAndPharAutoloaderAndPreexistingAreExcluded(): void { + $phpstanOwn = [new ClassLoader(), 'loadClass']; $preexisting = static function (string $class): void { }; - $composer = new ClassLoader(); + $project = [new ClassLoader(), 'loadClass']; $bootstrap = static function (string $class): void { }; - $before = [$preexisting]; - $after = [$preexisting, [$composer, 'loadClass'], ['PHPStan\\PharAutoloader', 'loadClass'], $bootstrap]; + $before = [$phpstanOwn, $preexisting, $project]; + $after = [$phpstanOwn, $preexisting, $project, ['PHPStan\\PharAutoloader', 'loadClass'], $bootstrap]; $result = collectNewAutoloadFunctions($before, $after); @@ -72,16 +118,17 @@ public function testComposerAndPharAutoloaderAndPreexistingAreExcluded(): void public function testPreexistingAutoloaderBeforeComposerIsNotReported(): void { + $phpstanOwn = [new ClassLoader(), 'loadClass']; $preexisting = static function (string $class): void { }; - $composer = new ClassLoader(); + $project = [new ClassLoader(), 'loadClass']; $prependedBootstrap = static function (string $class): void { }; // $preexisting was registered before PHPStan loaded the project - it must // be ignored even though it sits before Composer in the queue. - $before = [$preexisting]; - $after = [$preexisting, $prependedBootstrap, [$composer, 'loadClass']]; + $before = [$phpstanOwn, $preexisting, $project]; + $after = [$phpstanOwn, $preexisting, $prependedBootstrap, $project]; $result = collectNewAutoloadFunctions($before, $after);