diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 188ecf899e..1ab8c24c1e 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 0000000000..3a9875b460 --- /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 0000000000..b78f0408c9 --- /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 0000000000..4865ec6b07 --- /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 0000000000..d802ef8169 --- /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 0000000000..897b1df0dd --- /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 617d7f7ee5..17bcce1dcc 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 9e2c18f551..4f83636c71 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);