Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions e2e/bug-15102/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/vendor/
composer.lock
30 changes: 30 additions & 0 deletions e2e/bug-15102/bootstrap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php declare(strict_types = 1);

// The shape typo3/class-alias-loader creates: the project's own Composer loader is taken
// out of the spl_autoload queue and a wrapper takes its place, resolving legacy names
// through class_alias() and delegating everything else. The wrapper is not a ClassLoader
// instance, so the project has no Composer entry left in the queue.
$composerLoader = require __DIR__ . '/vendor/autoload.php';
spl_autoload_unregister([$composerLoader, 'loadClass']);

final class AliasLoader
{

public function __construct(private Composer\Autoload\ClassLoader $wrapped)
{
}

public function loadClass(string $class): void
{
if ($class === 'Legacy\\Validate') {
class_alias(Modern\Validate::class, 'Legacy\\Validate');

return;
}

$this->wrapped->loadClass($class);
}

}

spl_autoload_register([new AliasLoader($composerLoader), 'loadClass']);
7 changes: 7 additions & 0 deletions e2e/bug-15102/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"autoload": {
"psr-4": {
"Modern\\": "src/"
}
}
}
8 changes: 8 additions & 0 deletions e2e/bug-15102/phpstan.dist.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
parameters:
level: 8
bootstrapFiles:
- bootstrap.php
paths:
- test.php
- src
- stub
12 changes: 12 additions & 0 deletions e2e/bug-15102/src/Validate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php declare(strict_types = 1);

namespace Modern;

class Validate
{

public function doFoo(): void
{
}

}
13 changes: 13 additions & 0 deletions e2e/bug-15102/stub/Validate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php declare(strict_types = 1);

namespace Legacy;

// An IDE-only stub, never loaded at runtime: it declares the legacy name as a plain class
// so editors resolve it. At runtime the name is a class_alias to Modern\Validate, which is
// the declaration PHPStan has to use.
die('never loaded at runtime');

class Validate
{

}
5 changes: 5 additions & 0 deletions e2e/bug-15102/test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php declare(strict_types = 1);

function (Legacy\Validate $validate): void {
$validate->doFoo();
};
55 changes: 45 additions & 10 deletions src/autoloadFunctions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,13 +35,34 @@ 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
* 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.
*
* 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<mixed>|false $autoloadFunctionsBefore
* @param list<mixed>|false $autoloadFunctionsAfter
* @return array{prepended: list<mixed>, appended: list<mixed>}
Expand All @@ -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;
}
}
}

Expand All @@ -85,7 +120,7 @@ function collectNewAutoloadFunctions($autoloadFunctionsBefore, $autoloadFunction
}
}

if ($composerIndex !== null && $index < $composerIndex) {
if ($composerIndex === null || $index < $composerIndex) {
$prepended[] = $after;
} else {
$appended[] = $after;
Expand Down
79 changes: 63 additions & 16 deletions tests/PHPStan/CollectNewAutoloadFunctionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{

Expand All @@ -23,46 +29,86 @@ 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);

$this->assertSame([$prepended], $result['prepended']);
$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);

Expand All @@ -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);

Expand Down
Loading