diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 6a8715514db..3d17425dd1a 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -128,6 +128,14 @@ jobs: cd e2e/bug-14514 composer install ../../bin/phpstan analyze bug-14515.php + - script: | + cd e2e/bug-12972b + composer install + ../../bin/phpstan analyze + - script: | + cd e2e/bug-12972c + composer install + ../../bin/phpstan analyze - script: | cd e2e/bug-14724 composer install diff --git a/bin/phpstan b/bin/phpstan index 217a147ac1b..15e67dce78d 100755 --- a/bin/phpstan +++ b/bin/phpstan @@ -91,31 +91,15 @@ use Symfony\Component\Console\Helper\ProgressBar; $autoloadFunctionsAfter = spl_autoload_functions(); if ($autoloadFunctionsBefore !== false && $autoloadFunctionsAfter !== false) { - $newAutoloadFunctions = []; - foreach ($autoloadFunctionsAfter as $after) { - if ( - is_array($after) - && count($after) > 0 - ) { - if (is_object($after[0]) - && get_class($after[0]) === \Composer\Autoload\ClassLoader::class - ) { - continue; - } - if ($after[0] === 'PHPStan\\PharAutoloader') { - continue; - } - } - foreach ($autoloadFunctionsBefore as $before) { - if ($after === $before) { - continue 2; - } - } - - $newAutoloadFunctions[] = $after; - } - - $GLOBALS['__phpstanAutoloadFunctions'] = $newAutoloadFunctions; + $collectedAutoloadFunctions = \PHPStan\collectNewAutoloadFunctions($autoloadFunctionsBefore, $autoloadFunctionsAfter); + $GLOBALS['__phpstanAutoloadFunctions'] = array_merge( + $GLOBALS['__phpstanAutoloadFunctions'] ?? [], + $collectedAutoloadFunctions['appended'], + ); + $GLOBALS['__phpstanAutoloadFunctionsPrependedToComposer'] = array_merge( + $GLOBALS['__phpstanAutoloadFunctionsPrependedToComposer'] ?? [], + $collectedAutoloadFunctions['prepended'], + ); } $devOrPharLoader->register(true); diff --git a/e2e/bug-12972b/.gitignore b/e2e/bug-12972b/.gitignore new file mode 100644 index 00000000000..3a9875b460f --- /dev/null +++ b/e2e/bug-12972b/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +composer.lock diff --git a/e2e/bug-12972b/autoloader.php b/e2e/bug-12972b/autoloader.php new file mode 100644 index 00000000000..aaaae3ef08f --- /dev/null +++ b/e2e/bug-12972b/autoloader.php @@ -0,0 +1,9 @@ +doBar(new \other12972\MyClass()); diff --git a/e2e/bug-12972b/src/folder/file2.php b/e2e/bug-12972b/src/folder/file2.php new file mode 100644 index 00000000000..d81e18c01b5 --- /dev/null +++ b/e2e/bug-12972b/src/folder/file2.php @@ -0,0 +1,9 @@ +prependedOnly(); +} diff --git a/e2e/bug-12972c/phpstan.dist.neon b/e2e/bug-12972c/phpstan.dist.neon new file mode 100644 index 00000000000..c9f081939c2 --- /dev/null +++ b/e2e/bug-12972c/phpstan.dist.neon @@ -0,0 +1,8 @@ +parameters: + level: 9 + + paths: + - consumer + + bootstrapFiles: + - autoloader.php diff --git a/e2e/bug-12972c/prepended/Thing.php b/e2e/bug-12972c/prepended/Thing.php new file mode 100644 index 00000000000..b97d6a4750d --- /dev/null +++ b/e2e/bug-12972c/prepended/Thing.php @@ -0,0 +1,10 @@ +prependedOnly(), "\n"; diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php index aa8a2a2210c..30cd4561dd2 100644 --- a/src/Command/CommandHelper.php +++ b/src/Command/CommandHelper.php @@ -41,6 +41,7 @@ use function array_filter; use function array_key_exists; use function array_map; +use function array_merge; use function array_values; use function class_exists; use function count; @@ -56,6 +57,7 @@ use function is_file; use function is_readable; use function is_string; +use function PHPStan\collectNewAutoloadFunctions; use function register_shutdown_function; use function spl_autoload_functions; use function sprintf; @@ -526,18 +528,15 @@ public static function begin( $autoloadFunctionsAfter = spl_autoload_functions(); if ($autoloadFunctionsBefore !== false && $autoloadFunctionsAfter !== false) { - $newAutoloadFunctions = $GLOBALS['__phpstanAutoloadFunctions'] ?? []; - foreach ($autoloadFunctionsAfter as $after) { - foreach ($autoloadFunctionsBefore as $before) { - if ($after === $before) { - continue 2; - } - } - - $newAutoloadFunctions[] = $after; - } - - $GLOBALS['__phpstanAutoloadFunctions'] = $newAutoloadFunctions; + $collectedAutoloadFunctions = collectNewAutoloadFunctions($autoloadFunctionsBefore, $autoloadFunctionsAfter); + $GLOBALS['__phpstanAutoloadFunctions'] = array_merge( + $GLOBALS['__phpstanAutoloadFunctions'] ?? [], + $collectedAutoloadFunctions['appended'], + ); + $GLOBALS['__phpstanAutoloadFunctionsPrependedToComposer'] = array_merge( + $GLOBALS['__phpstanAutoloadFunctionsPrependedToComposer'] ?? [], + $collectedAutoloadFunctions['prepended'], + ); } if (PHP_VERSION_ID >= 80000) { diff --git a/src/Reflection/BetterReflection/BetterReflectionSourceLocatorFactory.php b/src/Reflection/BetterReflection/BetterReflectionSourceLocatorFactory.php index 47d29ca36d4..055df460810 100644 --- a/src/Reflection/BetterReflection/BetterReflectionSourceLocatorFactory.php +++ b/src/Reflection/BetterReflection/BetterReflectionSourceLocatorFactory.php @@ -98,12 +98,18 @@ public function create(): SourceLocator } $astLocator = new Locator($this->parser); + + // Custom autoloaders that bootstrap files registered *before* Composer's + // class loader (e.g. spl_autoload_register($fn, true, true)) run before + // Composer resolves the class at runtime, so they are consulted before + // the static source locators below - mirroring that runtime order. $locators[] = new AutoloadFunctionsSourceLocator( new AutoloadSourceLocator($this->fileNodesFetcher, false), new ReflectionClassSourceLocator( $astLocator, $this->reflectionSourceStubber, ), + true, ); $analysedDirectories = []; @@ -184,6 +190,22 @@ public function create(): SourceLocator $this->phpVersion, )); + // Custom autoloaders registered *after* Composer's class loader are + // consulted only as a fallback, after the static locators above + // (analysed files, Composer class map/PSR-4, PHP internals). At runtime + // Composer's class loader resolves such classes first, so these custom + // autoloaders are never invoked for them - invoking them eagerly here + // would run code paths that cannot happen at runtime. They remain useful + // for classes that only a custom autoloader can produce (e.g. eval'd). + $locators[] = new AutoloadFunctionsSourceLocator( + new AutoloadSourceLocator($this->fileNodesFetcher, false), + new ReflectionClassSourceLocator( + $astLocator, + $this->reflectionSourceStubber, + ), + false, + ); + $locators[] = new AutoloadSourceLocator($this->fileNodesFetcher, true); $locators[] = new PhpVersionBlacklistSourceLocator(new PhpInternalSourceLocator($astLocator, $this->reflectionSourceStubber), $this->phpstormStubsSourceStubber); $locators[] = new PhpVersionBlacklistSourceLocator(new EvaledCodeSourceLocator($astLocator, $this->reflectionSourceStubber), $this->phpstormStubsSourceStubber); diff --git a/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php b/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php index 3da44c64bf1..30ab9fa2d1f 100644 --- a/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php +++ b/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php @@ -11,14 +11,21 @@ use function class_exists; use function interface_exists; use function PHPStan\autoloadFunctions; +use function PHPStan\autoloadFunctionsPrependedToComposer; use function trait_exists; final class AutoloadFunctionsSourceLocator implements SourceLocator { + /** + * @param bool $prependedToComposer When true, consult only the autoloaders + * registered before Composer's class loader (prepended); otherwise consult + * the ones registered after it (appended). + */ public function __construct( private AutoloadSourceLocator $autoloadSourceLocator, private ReflectionClassSourceLocator $reflectionClassSourceLocator, + private bool $prependedToComposer, ) { } @@ -35,7 +42,9 @@ public function locateIdentifier(Reflector $reflector, Identifier $identifier): return null; } - $autoloadFunctions = autoloadFunctions(); + $autoloadFunctions = $this->prependedToComposer + ? autoloadFunctionsPrependedToComposer() + : autoloadFunctions(); foreach ($autoloadFunctions as $autoloadFunction) { $autoloadFunction($className); $reflection = $this->autoloadSourceLocator->locateIdentifier($reflector, $identifier); diff --git a/src/autoloadFunctions.php b/src/autoloadFunctions.php index 3239fe6aff6..617d7f7ee53 100644 --- a/src/autoloadFunctions.php +++ b/src/autoloadFunctions.php @@ -2,10 +2,95 @@ namespace PHPStan; +use Composer\Autoload\ClassLoader; +use function count; +use function get_class; +use function is_array; +use function is_object; + /** + * Autoloaders that were registered *after* Composer's class loader in the + * spl_autoload queue. At runtime Composer resolves the class first, so these + * are consulted only as a fallback, after the static source locators. + * * @return array */ function autoloadFunctions(): array // phpcs:ignore Squiz.Functions.GlobalFunction.Found { return $GLOBALS['__phpstanAutoloadFunctions'] ?? []; } + +/** + * Autoloaders that were registered *before* Composer's class loader in the + * spl_autoload queue (e.g. spl_autoload_register($fn, true, true)). At runtime + * these run before Composer resolves the class, so they must be consulted + * before the static Composer source locators to mirror that order. + * + * @return array + */ +function autoloadFunctionsPrependedToComposer(): array // phpcs:ignore Squiz.Functions.GlobalFunction.Found +{ + return $GLOBALS['__phpstanAutoloadFunctionsPrependedToComposer'] ?? []; +} + +/** + * Splits the autoload functions registered while loading Composer's autoloader + * and the bootstrap files into those registered before and after Composer's own + * class loader in the spl_autoload queue. This lets PHPStan consult them in the + * same order relative to Composer as PHP does at runtime, instead of always + * invoking them before (or after) the static Composer source locators. + * + * @param list|false $autoloadFunctionsBefore + * @param list|false $autoloadFunctionsAfter + * @return array{prepended: list, appended: list} + */ +function collectNewAutoloadFunctions($autoloadFunctionsBefore, $autoloadFunctionsAfter): array // phpcs:ignore Squiz.Functions.GlobalFunction.Found +{ + $prepended = []; + $appended = []; + + if ($autoloadFunctionsBefore === false || $autoloadFunctionsAfter === false) { + return ['prepended' => $prepended, 'appended' => $appended]; + } + + $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; + } + } + + foreach ($autoloadFunctionsAfter as $index => $after) { + if (is_array($after) && count($after) > 0) { + if ( + is_object($after[0]) + && get_class($after[0]) === ClassLoader::class + ) { + continue; + } + if ($after[0] === 'PHPStan\\PharAutoloader') { + continue; + } + } + + foreach ($autoloadFunctionsBefore as $before) { + if ($after === $before) { + continue 2; + } + } + + if ($composerIndex !== null && $index < $composerIndex) { + $prepended[] = $after; + } else { + $appended[] = $after; + } + } + + return ['prepended' => $prepended, 'appended' => $appended]; +} diff --git a/tests/PHPStan/CollectNewAutoloadFunctionsTest.php b/tests/PHPStan/CollectNewAutoloadFunctionsTest.php new file mode 100644 index 00000000000..9e2c18f551c --- /dev/null +++ b/tests/PHPStan/CollectNewAutoloadFunctionsTest.php @@ -0,0 +1,92 @@ +assertSame( + ['prepended' => [], 'appended' => []], + collectNewAutoloadFunctions(false, false), + ); + + $after = [static function (string $class): void { + }]; + $this->assertSame( + ['prepended' => [], 'appended' => []], + collectNewAutoloadFunctions(false, $after), + ); + } + + public function testAutoloadersAreSplitByComposerPosition(): void + { + $prepended = static function (string $class): void { + }; + $composer = new ClassLoader(); + $appended = static function (string $class): void { + }; + + $before = []; + $after = [$prepended, [$composer, 'loadClass'], $appended]; + + $result = collectNewAutoloadFunctions($before, $after); + + $this->assertSame([$prepended], $result['prepended']); + $this->assertSame([$appended], $result['appended']); + } + + public function testWithoutComposerEverythingIsAppended(): void + { + $first = static function (string $class): void { + }; + $second = static function (string $class): void { + }; + + $result = collectNewAutoloadFunctions([], [$first, $second]); + + $this->assertSame([], $result['prepended']); + $this->assertSame([$first, $second], $result['appended']); + } + + public function testComposerAndPharAutoloaderAndPreexistingAreExcluded(): void + { + $preexisting = static function (string $class): void { + }; + $composer = new ClassLoader(); + $bootstrap = static function (string $class): void { + }; + + $before = [$preexisting]; + $after = [$preexisting, [$composer, 'loadClass'], ['PHPStan\\PharAutoloader', 'loadClass'], $bootstrap]; + + $result = collectNewAutoloadFunctions($before, $after); + + $this->assertSame([], $result['prepended']); + $this->assertSame([$bootstrap], $result['appended']); + } + + public function testPreexistingAutoloaderBeforeComposerIsNotReported(): void + { + $preexisting = static function (string $class): void { + }; + $composer = new ClassLoader(); + $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']]; + + $result = collectNewAutoloadFunctions($before, $after); + + $this->assertSame([$prependedBootstrap], $result['prepended']); + $this->assertSame([], $result['appended']); + } + +}