From 48a7bbe9fdc48aeb306ce1777337e805d971eb85 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Wed, 5 Aug 2026 18:18:35 +0200 Subject: [PATCH] Do not autoload a class whose name is an already defined function AutoloadFunctionsSourceLocator runs the autoloaders collected from bootstrap files to find a class, without first checking whether the name is already a defined function. A catch-all bootstrap autoloader then resolves a name like VeeWee\Xml\Dom\Builder\value to the file that defines the function and plain-includes it. That file was already loaded once - veewee/xml ships one function per PSR-4 path and requires it from its files-autoload bootstrap - so the second include fatally redeclares the function and the worker dies with "Cannot redeclare function ...". The common real-world autoloader here is PHP_CodeSniffer's, loaded via bootstrapFiles because the package ships no Composer autoload metadata; it falls back to Composer's findFile() for any name and includes with a plain include. The name is probed as a class and the file holds a function, so running the autoloader cannot find a class there; it only re-includes the file. Return early when the name is already a defined function, the same way the locator already returns early for an existing class, interface or trait. A class that genuinely exists under that name in another file is still found by the later source locators in the chain. Wrapping the autoloader call in FileReadTrapStreamWrapper (as AutoloadSourceLocator does) was considered but is not viable here: these bootstrap autoloaders run for real, and some read a file for its return value (a loader delegating via "$loader = include ...; $loader->loadClass()") or write cache files, which the trap breaks. e2e/bug-14988 reproduces it: an unguarded one-function-per-file, a bootstrap that requires it, and a catch-all autoloader shaped like PHP_CodeSniffer's. Before the change the worker fatals with the redeclare; after it, analysis completes. It also covers a class and a function that share a name in separate files (both reachable only through that bootstrap autoloader, not Composer), asserting the class still resolves. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/e2e-tests.yml | 4 ++ e2e/bug-14988/.gitignore | 1 + e2e/bug-14988/classes/gadget.php | 10 +++++ e2e/bug-14988/composer.json | 7 +++ e2e/bug-14988/composer.lock | 18 ++++++++ e2e/bug-14988/custom-autoloader.php | 43 +++++++++++++++++++ e2e/bug-14988/phpstan.neon | 12 ++++++ e2e/bug-14988/pkg/bootstrap.php | 9 ++++ e2e/bug-14988/pkg/gadget.php | 8 ++++ e2e/bug-14988/pkg/thing.php | 8 ++++ e2e/bug-14988/src/probe.php | 24 +++++++++++ .../AutoloadFunctionsSourceLocator.php | 13 ++++++ 12 files changed, 157 insertions(+) create mode 100644 e2e/bug-14988/.gitignore create mode 100644 e2e/bug-14988/classes/gadget.php create mode 100644 e2e/bug-14988/composer.json create mode 100644 e2e/bug-14988/composer.lock create mode 100644 e2e/bug-14988/custom-autoloader.php create mode 100644 e2e/bug-14988/phpstan.neon create mode 100644 e2e/bug-14988/pkg/bootstrap.php create mode 100644 e2e/bug-14988/pkg/gadget.php create mode 100644 e2e/bug-14988/pkg/thing.php create mode 100644 e2e/bug-14988/src/probe.php diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index df2ca12db66..11504b44489 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -128,6 +128,10 @@ jobs: cd e2e/bug-14514 composer install ../../bin/phpstan analyze bug-14515.php + - script: | + cd e2e/bug-14988 + composer install + ../../bin/phpstan analyse - script: | cd e2e/bug-14724 composer install diff --git a/e2e/bug-14988/.gitignore b/e2e/bug-14988/.gitignore new file mode 100644 index 00000000000..61ead86667c --- /dev/null +++ b/e2e/bug-14988/.gitignore @@ -0,0 +1 @@ +/vendor diff --git a/e2e/bug-14988/classes/gadget.php b/e2e/bug-14988/classes/gadget.php new file mode 100644 index 00000000000..5ff2deec60c --- /dev/null +++ b/e2e/bug-14988/classes/gadget.php @@ -0,0 +1,10 @@ +size; + } + +} diff --git a/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php b/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php index 3da44c64bf1..750c0a175b8 100644 --- a/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php +++ b/src/Reflection/BetterReflection/SourceLocator/AutoloadFunctionsSourceLocator.php @@ -9,6 +9,7 @@ use PHPStan\BetterReflection\Reflector\Reflector; use PHPStan\BetterReflection\SourceLocator\Type\SourceLocator; use function class_exists; +use function function_exists; use function interface_exists; use function PHPStan\autoloadFunctions; use function trait_exists; @@ -35,6 +36,18 @@ public function locateIdentifier(Reflector $reflector, Identifier $identifier): return null; } + // If the name is already a defined function, this locator must not run the bootstrap + // autoloaders for it: a catch-all autoloader (e.g. PHP_CodeSniffer's, which falls back to + // Composer's findFile()) would resolve the name to the function's own file and plain-include + // it a second time - it was loaded once already, e.g. by a package that ships one function + // per PSR-4 path and requires it from its bootstrap - fatally redeclaring the function. + // Returning null only declines this locator; a class and a function may share a name in PHP, + // and a class that genuinely exists under this name in another file is still located by the + // later source locators in the chain. See https://github.com/phpstan/phpstan/issues/14988 + if (function_exists($className)) { + return null; + } + $autoloadFunctions = autoloadFunctions(); foreach ($autoloadFunctions as $autoloadFunction) { $autoloadFunction($className);