diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml
index f69e3a2b3..b5e00481f 100644
--- a/.github/workflows/deployment.yml
+++ b/.github/workflows/deployment.yml
@@ -60,6 +60,12 @@ jobs:
name: manifest
path: vendor/wp-cli/wp-cli/manifest.json
+ # Prefixes the composer/composer dependency tree so the Phar stops
+ # imposing its own psr/log, Symfony and React versions on the site it
+ # runs against. See https://github.com/wp-cli/wp-cli/issues/5920
+ - name: Prefix bundled dependencies
+ run: php utils/scope-dependencies.php
+
- name: Build the Phar file
run: php -dphar.readonly=0 utils/make-phar.php wp-cli.phar --version=$CLI_VERSION
diff --git a/features/dependency-isolation.feature b/features/dependency-isolation.feature
new file mode 100644
index 000000000..2129ce9dd
--- /dev/null
+++ b/features/dependency-isolation.feature
@@ -0,0 +1,98 @@
+Feature: Bundled dependencies do not conflict with the site's own
+
+ # WP-CLI's autoloader is registered before WordPress boots, so for any class
+ # shipped both by the Phar and by the site, the Phar's copy wins and is
+ # imposed on the site. Prefixing the `composer/composer` dependency tree stops
+ # the Phar from claiming those names at all.
+ #
+ # These scenarios run against the built Phar, which is the only artifact the
+ # prefixing applies to; a Composer-based installation resolves its own
+ # dependency versions and has no conflict to avoid.
+ #
+ # See https://github.com/wp-cli/wp-cli/issues/5920
+
+ Scenario: A site providing its own psr/log is not broken by the bundled one
+ Given a WP installation
+ # Stands in for a site that ships psr/log v3 through its own vendor
+ # directory, as anything depending on monolog/monolog does. The typed
+ # signatures are incompatible with the psr/log v1 that composer/composer
+ # resolves to under the Phar's PHP 7.2 platform requirement, so whichever
+ # copy of the interface loads first decides whether this fatals.
+ And a wp-content/mu-plugins/site-logger.php file:
+ """
+
*/utils/get-package-require-from-composer\.php$
*/utils/make-phar\.php$
+ */utils/scope-dependencies\.php$
+ */utils/scoper/scoper\.inc\.php$
*/utils/get-package-require-from-composer\.php$
*/utils/make-phar\.php$
+ */utils/scope-dependencies\.php$
+ */utils/scoper/scoper\.inc\.php$
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index a1f53a4d5..10e149d75 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -3,6 +3,11 @@ parameters:
paths:
- php
- utils
+ excludePaths:
+ analyse:
+ # Isolated toolchain with its own composer.json; its dependencies are not
+ # installed in this project's vendor directory.
+ - utils/scoper/scoper.inc.php
scanDirectories:
- vendor/wp-cli/wp-cli
scanFiles:
diff --git a/utils/make-phar.php b/utils/make-phar.php
index 8aee6a586..dec33d651 100644
--- a/utils/make-phar.php
+++ b/utils/make-phar.php
@@ -18,7 +18,6 @@
require WP_CLI_VENDOR_DIR . '/autoload.php';
require WP_CLI_ROOT . '/php/utils.php';
-use Symfony\Component\Finder\Finder;
use WP_CLI\Utils;
use WP_CLI\Configurator;
@@ -190,7 +189,21 @@ function get_composer_versions( $current_version ) {
$phar->startBuffering();
// PHP files
-$finder = new Finder();
+/*
+ * `utils/scope-dependencies.php` prefixes symfony/finder along with the rest
+ * of the Composer tree, so the class this build script itself relies on moves
+ * depending on whether prefixing has already run.
+ */
+$finder_class = class_exists( 'Symfony\\Component\\Finder\\Finder' )
+ ? 'Symfony\\Component\\Finder\\Finder'
+ : 'WP_CLI\\Vendor\\Symfony\\Component\\Finder\\Finder';
+
+if ( ! class_exists( $finder_class ) ) {
+ fwrite( STDERR, 'Missing Symfony Finder; run `composer install` first.' . PHP_EOL );
+ exit( 1 );
+}
+
+$finder = new $finder_class();
$finder
->files()
->ignoreVCS( true )
@@ -265,7 +278,7 @@ function get_composer_versions( $current_version ) {
}
// other files
-$finder = new Finder();
+$finder = new $finder_class();
$finder
->files()
->ignoreVCS( true )
@@ -280,7 +293,7 @@ function get_composer_versions( $current_version ) {
if ( 'cli' !== BUILD ) {
// Include base project files, because the autoloader will load them
if ( WP_CLI_BASE_PATH !== WP_CLI_BUNDLE_ROOT && is_dir( WP_CLI_BASE_PATH . '/src' ) ) {
- $finder = new Finder();
+ $finder = new $finder_class();
$finder
->files()
->ignoreVCS( true )
diff --git a/utils/scope-dependencies.php b/utils/scope-dependencies.php
new file mode 100644
index 000000000..dac204e37
--- /dev/null
+++ b/utils/scope-dependencies.php
@@ -0,0 +1,318 @@
+] [--quiet]
+ *
+ * @see https://github.com/wp-cli/wp-cli/issues/5920
+ */
+
+declare( strict_types=1 );
+
+define( 'WP_CLI_BUNDLE_ROOT', rtrim( dirname( __DIR__ ), '/' ) );
+
+/**
+ * Vendor directories handed to php-scoper. Keep in sync with the finders in
+ * `utils/scoper/scoper.inc.php`.
+ */
+const SCOPED_VENDOR_DIRS = [
+ 'composer',
+ 'justinrainbow',
+ 'marc-mabe', // spellchecker:disable-line
+ 'psr',
+ 'react',
+ 'seld',
+ 'symfony',
+];
+
+$options = getopt( '', [ 'vendor-dir::', 'quiet' ] );
+$be_quiet = isset( $options['quiet'] );
+$vendor_dir = isset( $options['vendor-dir'] ) && is_string( $options['vendor-dir'] )
+ ? rtrim( $options['vendor-dir'], '/' )
+ : WP_CLI_BUNDLE_ROOT . '/vendor';
+
+$scoper_dir = WP_CLI_BUNDLE_ROOT . '/utils/scoper';
+
+/**
+ * Write a progress line unless running quietly.
+ */
+function report( string $message ): void {
+ if ( ! $GLOBALS['be_quiet'] ) {
+ fwrite( STDOUT, $message . PHP_EOL );
+ }
+}
+
+/**
+ * Run a command, returning its exit code.
+ *
+ * @param array $command
+ */
+function run( array $command, ?string $cwd = null ): int {
+ $cwd_prefix = null !== $cwd ? sprintf( 'cd %s && ', escapeshellarg( $cwd ) ) : '';
+ $escaped = implode( ' ', array_map( 'escapeshellarg', $command ) );
+
+ passthru( $cwd_prefix . $escaped, $exit_code );
+
+ return $exit_code;
+}
+
+/**
+ * Fail with a message.
+ */
+function fail( string $message ): void {
+ fwrite( STDERR, 'Error: ' . $message . PHP_EOL );
+ exit( 1 );
+}
+
+if ( ! is_dir( $vendor_dir ) ) {
+ fail( sprintf( "Vendor directory '%s' does not exist. Run `composer install` first.", $vendor_dir ) );
+}
+
+// php-scoper needs PHP 8.2+, which is why it lives in its own composer.json
+// rather than in the bundle's (that one still has to resolve against PHP 7.2.24).
+if ( PHP_VERSION_ID < 80200 ) {
+ fail( sprintf( 'php-scoper requires PHP 8.2 or newer, but this is PHP %s.', PHP_VERSION ) );
+}
+
+// --- 1. Make sure the isolated toolchain is installed. ----------------------
+
+if ( ! file_exists( $scoper_dir . '/vendor/bin/php-scoper' ) ) {
+ report( 'Installing the php-scoper toolchain...' );
+ if ( 0 !== run( [ 'composer', 'install', '--no-interaction', '--prefer-dist', '--quiet' ], $scoper_dir ) ) {
+ fail( 'Failed to install the php-scoper toolchain.' );
+ }
+}
+
+// --- 2. Prefix the dependency tree. -----------------------------------------
+
+$output_dir = $vendor_dir . '/../build/scoped-vendor';
+
+if ( is_dir( $output_dir ) ) {
+ run( [ 'rm', '-rf', $output_dir ] );
+}
+
+report( 'Prefixing third-party dependencies...' );
+
+putenv( 'WP_CLI_SCOPER_VENDOR_DIR=' . $vendor_dir );
+
+$scoper_exit = run(
+ [
+ $scoper_dir . '/vendor/bin/php-scoper',
+ 'add-prefix',
+ '--config=' . $scoper_dir . '/scoper.inc.php',
+ '--output-dir=' . $output_dir,
+ '--force',
+ '--no-interaction',
+ $be_quiet ? '--quiet' : '--no-ansi',
+ ]
+);
+
+if ( 0 !== $scoper_exit ) {
+ fail( 'php-scoper failed.' );
+}
+
+// --- 3. Swap the prefixed tree into vendor/. --------------------------------
+
+foreach ( SCOPED_VENDOR_DIRS as $dir ) {
+ $scoped = $output_dir . '/' . $dir;
+ $target = $vendor_dir . '/' . $dir;
+
+ if ( ! is_dir( $scoped ) ) {
+ continue;
+ }
+
+ report( sprintf( ' Replacing vendor/%s', $dir ) );
+
+ // Composer's autoloader machinery lives alongside the composer/* packages
+ // in vendor/composer and is regenerated below, so only the package
+ // subdirectories are replaced wholesale.
+ if ( 0 !== run( [ 'cp', '-a', $scoped . '/.', $target . '/' ] ) ) {
+ fail( sprintf( "Failed to copy the prefixed '%s' into place.", $dir ) );
+ }
+}
+
+// --- 4. Teach Composer about the new class names. ---------------------------
+
+/*
+ * The prefixed files no longer satisfy their packages' PSR-4 rules: the classes
+ * in vendor/psr/log now declare WP_CLI\Vendor\Psr\Log\*, while psr/log's
+ * composer.json still maps Psr\Log\ to that directory. Left alone, a dump would
+ * both re-advertise the unprefixed prefix and skip the prefixed classes as
+ * "not compliant with PSR-4".
+ *
+ * Rewriting the affected packages' autoload rules to a classmap sidesteps both
+ * problems: Composer scans the directories and records whatever class names the
+ * files actually declare.
+ */
+$installed_json = $vendor_dir . '/composer/installed.json';
+
+if ( ! file_exists( $installed_json ) ) {
+ fail( sprintf( "Could not find '%s'.", $installed_json ) );
+}
+
+$decoded = json_decode( (string) file_get_contents( $installed_json ), true );
+
+if ( ! is_array( $decoded ) || ! isset( $decoded['packages'] ) || ! is_array( $decoded['packages'] ) ) {
+ fail( sprintf( "Could not decode '%s'.", $installed_json ) );
+}
+
+/**
+ * @var array $decoded
+ * @var array $packages
+ */
+$packages = $decoded['packages'];
+$patched = 0;
+
+foreach ( $packages as $index => $package ) {
+ if ( ! is_array( $package ) || ! isset( $package['name'] ) || ! is_string( $package['name'] ) ) {
+ continue;
+ }
+
+ $vendor_name = explode( '/', $package['name'] )[0];
+
+ if ( ! in_array( $vendor_name, SCOPED_VENDOR_DIRS, true ) ) {
+ continue;
+ }
+
+ if ( ! isset( $package['autoload'] ) || ! is_array( $package['autoload'] ) ) {
+ continue;
+ }
+
+ $autoload = $package['autoload'];
+ $roots = [];
+
+ foreach ( [ 'psr-4', 'psr-0' ] as $standard ) {
+ $rules = $autoload[ $standard ] ?? [];
+
+ if ( ! is_array( $rules ) ) {
+ continue;
+ }
+
+ foreach ( $rules as $paths ) {
+ foreach ( (array) $paths as $path ) {
+ if ( is_string( $path ) ) {
+ $roots[] = '' === $path ? '.' : $path;
+ }
+ }
+ }
+ }
+
+ $classmap_rules = $autoload['classmap'] ?? [];
+
+ if ( is_array( $classmap_rules ) ) {
+ foreach ( $classmap_rules as $path ) {
+ if ( is_string( $path ) ) {
+ $roots[] = $path;
+ }
+ }
+ }
+
+ if ( ! $roots ) {
+ continue;
+ }
+
+ $package['autoload'] = [ 'classmap' => array_values( array_unique( $roots ) ) ];
+ $packages[ $index ] = $package;
+ ++$patched;
+}
+
+$decoded['packages'] = $packages;
+
+report( sprintf( 'Rewrote autoload rules for %d prefixed package(s).', $patched ) );
+
+$encoded = json_encode( $decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
+
+if ( false === $encoded || false === file_put_contents( $installed_json, $encoded ) ) {
+ fail( sprintf( "Failed to write '%s'.", $installed_json ) );
+}
+
+// --- 5. Regenerate the autoloader. ------------------------------------------
+
+report( 'Regenerating the Composer autoloader...' );
+
+/*
+ * --classmap-authoritative makes the ClassLoader consult only the classmap, so
+ * no leftover PSR-4 rule can resurrect an unprefixed name. Everything the Phar
+ * runs is inside the Phar, so there is nothing to discover at runtime.
+ */
+// Derived from the vendor directory rather than assumed, so the script can be
+// pointed at a scratch tree for testing.
+$composer_root = dirname( $vendor_dir );
+
+if ( 0 !== run( [ 'composer', 'dump-autoload', '--classmap-authoritative', '--no-interaction' ], $composer_root ) ) {
+ fail( 'Failed to regenerate the Composer autoloader.' );
+}
+
+run( [ 'rm', '-rf', dirname( $output_dir ) ] );
+
+// --- 6. Verify the autoloader no longer claims the unprefixed names. --------
+
+/*
+ * The failure mode this guards against is silent: php-scoper rewrites source
+ * files but not Composer's generated maps, so a tree that looks scoped can
+ * still resolve `Psr\Log\LoggerInterface` to the bundled copy and reintroduce
+ * the conflict with nothing in the build output to show for it.
+ */
+$autoload_files = array_filter(
+ [
+ $vendor_dir . '/composer/autoload_classmap.php',
+ $vendor_dir . '/composer/autoload_psr4.php',
+ $vendor_dir . '/composer/autoload_static.php',
+ ],
+ 'file_exists'
+);
+
+$must_not_appear = [
+ 'Psr\\Log\\',
+ 'Symfony\\Component\\Console\\',
+ 'React\\Promise\\',
+ 'Seld\\JsonLint\\',
+];
+
+$leaked = [];
+
+foreach ( $autoload_files as $file ) {
+ $contents = (string) file_get_contents( $file );
+
+ foreach ( $must_not_appear as $symbol ) {
+ // Written as it appears in the generated PHP source, where each
+ // namespace separator is escaped.
+ $needle = str_replace( '\\', '\\\\', $symbol );
+ $prefixed = 'WP_CLI\\\\Vendor\\\\' . $needle;
+ $occurring = substr_count( $contents, $needle ) - substr_count( $contents, $prefixed );
+
+ if ( $occurring > 0 ) {
+ $leaked[] = sprintf( ' %s advertises %s (%d time(s))', basename( $file ), $symbol, $occurring );
+ }
+ }
+}
+
+if ( $leaked ) {
+ fail(
+ "The regenerated autoloader still advertises unprefixed dependencies:\n"
+ . implode( "\n", $leaked )
+ . "\nThe Phar would keep imposing these on the site. See https://github.com/wp-cli/wp-cli/issues/5920"
+ );
+}
+
+$classmap = $vendor_dir . '/composer/autoload_classmap.php';
+
+// strpos() rather than str_contains() so the file still parses under the 7.2
+// baseline phpcs checks this repository against, even though the script itself
+// refuses to run on anything below PHP 8.2.
+if ( file_exists( $classmap ) && false === strpos( (string) file_get_contents( $classmap ), 'WP_CLI\\\\Vendor\\\\' ) ) {
+ fail( 'The regenerated classmap contains no prefixed classes at all; the prefixing step did not take effect.' );
+}
+
+report( 'Verified: the autoloader advertises only prefixed dependencies.' );
+report( 'Done.' );
diff --git a/utils/scoper/.gitignore b/utils/scoper/.gitignore
new file mode 100644
index 000000000..57872d0f1
--- /dev/null
+++ b/utils/scoper/.gitignore
@@ -0,0 +1 @@
+/vendor/
diff --git a/utils/scoper/composer.json b/utils/scoper/composer.json
new file mode 100644
index 000000000..4db272d65
--- /dev/null
+++ b/utils/scoper/composer.json
@@ -0,0 +1,13 @@
+{
+ "name": "wp-cli/phar-scoper-toolchain",
+ "description": "Isolated toolchain used to prefix the Phar's third-party dependencies. Kept out of the bundle's own composer.json because php-scoper requires PHP 8.2+, while WP-CLI still targets PHP 7.2.24.",
+ "license": "MIT",
+ "type": "project",
+ "require": {
+ "humbug/php-scoper": "^0.18"
+ },
+ "config": {
+ "sort-packages": true,
+ "lock": false
+ }
+}
diff --git a/utils/scoper/scoper.inc.php b/utils/scoper/scoper.inc.php
new file mode 100644
index 000000000..fce325037
--- /dev/null
+++ b/utils/scoper/scoper.inc.php
@@ -0,0 +1,133 @@
+ 'WP_CLI\\Vendor',
+ 'finders' => [
+ /*
+ * Deliberately without exclusions. The prefixed output is merged back
+ * over `vendor/` rather than replacing it, because php-scoper only
+ * emits the PHP files it processed and the directories also hold
+ * assets the Phar needs (certificate bundles, templates, stubs).
+ * Any PHP file skipped here would therefore survive the merge with its
+ * original namespace intact and be picked up by the regenerated
+ * classmap -- which is exactly the unprefixed name the Phar is not
+ * supposed to advertise any more. Test fixtures are the usual culprit:
+ * `Psr\Log\Test\TestLogger` implements the very interface at issue.
+ */
+ $finder_class::create()
+ ->files()
+ ->ignoreVCS( true )
+ ->name( '*.php' )
+ ->in( $scoped_paths ),
+ ],
+
+ /*
+ * Left unprefixed so third-party Composer plugins keep implementing the
+ * real interfaces. References from these files to the prefixed vendors are
+ * still rewritten by php-scoper.
+ */
+ 'exclude-namespaces' => [
+ 'Composer',
+ ],
+
+ 'exclude-classes' => [],
+ 'exclude-functions' => [],
+ 'exclude-constants' => [],
+
+ 'patchers' => [
+ /*
+ * Excluding a namespace stops php-scoper prefixing its declarations,
+ * but not string literals that name classes inside it. Composer passes
+ * plenty of class names around as strings -- `ArrayLoader::load()`
+ * defaults `$class` to 'Composer\Package\CompletePackage' and compares
+ * against it -- and prefixing those strings points them at classes
+ * that do not exist, because `Composer\` itself was left alone.
+ *
+ * Left unpatched this is quiet rather than fatal: Composer emits a
+ * spurious "The $class arg is deprecated" notice and carries on, while
+ * the same mismatch in a `new $class` path would be a hard failure.
+ */
+ static function ( string $file_path, string $prefix, string $contents ): string {
+ return str_replace(
+ [
+ $prefix . '\\Composer\\',
+ $prefix . '\\\\Composer\\\\',
+ ],
+ [
+ 'Composer\\',
+ 'Composer\\\\',
+ ],
+ $contents
+ );
+ },
+ ],
+];