From 66464aa18ec4b3ca9905e7ff6c1f155437410fdf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 14:29:14 +0000 Subject: [PATCH 1/6] Prefix the Phar's Composer dependency tree WP-CLI registers its autoloader 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. A site using monolog/monolog against psr/log v3 gets the Phar's psr/log 1.1.4 instead and fatals on the incompatible LoggerInterface signature. Moving wp-cli/package-command to require-dev fixed this for Composer-based installations, but the Phar is still built with dev dependencies, so it continues to ship composer/composer and its tree unprefixed: symfony/console v5.4.47, psr/log 1.1.4, react/promise, seld/*. Prefix that tree with php-scoper, with two constraints: * The `Composer\` namespace itself is left alone. Third-party Composer plugins are compiled against the real `Composer\Plugin\PluginInterface`, so prefixing it would break `wp package install` for any package shipping one. References from inside `Composer\` to the prefixed vendors are still rewritten, so Composer keeps using its own psr/log. * Nothing reachable from WP-CLI's public API is touched: php-cli-tools (`Utils\make_progress_bar()`), Requests (`Utils\http_request()`, plus RequestsLibrary deliberately sharing the library with Core), and every wp-cli/* package. php-scoper needs PHP 8.2 while WP-CLI still targets 7.2.24, so the toolchain lives in utils/scoper with its own composer.json. Two things this turned up that are easy to get wrong: * php-scoper rewrites source files but not Composer's generated autoload maps. A scoped tree with a stale autoloader still advertises `Psr\Log\` and the conflict survives with nothing to show for it, so the autoloader is regenerated and then asserted on. * Excluding a namespace does not stop php-scoper prefixing string literals naming classes inside it. Composer compares `$class` against 'Composer\Package\CompletePackage', which the prefix silently breaks; a patcher restores those. Refs https://github.com/wp-cli/wp-cli/issues/5920 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RHtjyXkZh8X16sBgmidSQi --- .github/workflows/deployment.yml | 6 + features/dependency-isolation.feature | 100 +++++++++ utils/scope-dependencies.php | 294 ++++++++++++++++++++++++++ utils/scoper/.gitignore | 1 + utils/scoper/composer.json | 13 ++ utils/scoper/scoper.inc.php | 133 ++++++++++++ 6 files changed, 547 insertions(+) create mode 100644 features/dependency-isolation.feature create mode 100644 utils/scope-dependencies.php create mode 100644 utils/scoper/.gitignore create mode 100644 utils/scoper/composer.json create mode 100644 utils/scoper/scoper.inc.php 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..5012491d5 --- /dev/null +++ b/features/dependency-isolation.feature @@ -0,0 +1,100 @@ +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 + + @require-mysql + 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: + """ + ] [--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', + '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 ) ); +} + +$installed = json_decode( (string) file_get_contents( $installed_json ), true ); + +if ( ! is_array( $installed ) || ! isset( $installed['packages'] ) ) { + fail( sprintf( "Could not decode '%s'.", $installed_json ) ); +} + +$patched = 0; + +foreach ( $installed['packages'] as $index => $package ) { + $name = $package['name'] ?? ''; + + if ( '' === $name ) { + continue; + } + + $vendor_name = explode( '/', $name )[0]; + + if ( ! in_array( $vendor_name, SCOPED_VENDOR_DIRS, true ) ) { + continue; + } + + if ( ! isset( $package['autoload'] ) || ! is_array( $package['autoload'] ) ) { + continue; + } + + $roots = []; + + foreach ( [ 'psr-4', 'psr-0' ] as $standard ) { + foreach ( (array) ( $package['autoload'][ $standard ] ?? [] ) as $paths ) { + foreach ( (array) $paths as $path ) { + $roots[] = '' === $path ? '.' : $path; + } + } + } + + foreach ( (array) ( $package['autoload']['classmap'] ?? [] ) as $path ) { + $roots[] = $path; + } + + if ( ! $roots ) { + continue; + } + + $installed['packages'][ $index ]['autoload'] = [ 'classmap' => array_values( array_unique( $roots ) ) ]; + ++$patched; +} + +report( sprintf( 'Rewrote autoload rules for %d prefixed package(s).', $patched ) ); + +$encoded = json_encode( $installed, 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'; + +if ( file_exists( $classmap ) && ! str_contains( (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..d584fcb22 --- /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 + ); + }, + ], +]; From b4576506dc6cd69cd61ccdb8e5e1c99eab642b8f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 15:08:41 +0000 Subject: [PATCH 2/6] Resolve Symfony Finder in make-phar.php at runtime `utils/scope-dependencies.php` prefixes symfony/finder along with the rest of the Composer tree, but `utils/make-phar.php` builds the Phar with that same Finder. Once prefixing has run, `Symfony\Component\Finder\Finder` no longer exists and the build dies before writing anything. Resolve the class name at runtime instead, so the build works whether or not the dependencies have been prefixed yet. Also drop `@require-mysql` from the isolation scenarios: they only need a WordPress installation, which the Behat suite can provide on SQLite too. Refs https://github.com/wp-cli/wp-cli/issues/5920 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RHtjyXkZh8X16sBgmidSQi --- features/dependency-isolation.feature | 2 -- utils/make-phar.php | 21 +++++++++++++++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/features/dependency-isolation.feature b/features/dependency-isolation.feature index 5012491d5..2b1efc1a8 100644 --- a/features/dependency-isolation.feature +++ b/features/dependency-isolation.feature @@ -11,7 +11,6 @@ Feature: Bundled dependencies do not conflict with the site's own # # See https://github.com/wp-cli/wp-cli/issues/5920 - @require-mysql 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 @@ -55,7 +54,6 @@ Feature: Bundled dependencies do not conflict with the site's own """ And the return code should be 0 - @require-mysql Scenario: A site providing its own Symfony Console is not broken by the bundled one Given a WP installation And a wp-content/mu-plugins/site-console.php file: 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 ) From 8d503851d7063efa9ce506541699a66e1b601290 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 15:59:30 +0000 Subject: [PATCH 3/6] Keep the scoping build scripts within the code quality checks The static analysis config lints everything under `utils`, so the new build scripts need the same treatment the existing ones already get. * Exempt them from the two WordPress-context sniffs `make-phar.php` is already exempt from; they are procedural stand-alone scripts that never run inside WordPress. * Exclude `utils/scoper/scoper.inc.php` from PHPStan. It is an isolated toolchain with its own composer.json, so the classes it references are not installed in this project's vendor directory. * Swap `str_contains()` for `strpos()`. The script refuses to run below PHP 8.2, but phpcs checks this repository against a 7.2 baseline and flags the newer function. Refs https://github.com/wp-cli/wp-cli/issues/5920 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RHtjyXkZh8X16sBgmidSQi --- phpcs.xml.dist | 4 ++++ phpstan.neon.dist | 5 +++++ utils/scope-dependencies.php | 5 ++++- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/phpcs.xml.dist b/phpcs.xml.dist index eb99f8845..d22045599 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -52,10 +52,14 @@ */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/scope-dependencies.php b/utils/scope-dependencies.php index de3cb8fc4..f2b7fb904 100644 --- a/utils/scope-dependencies.php +++ b/utils/scope-dependencies.php @@ -286,7 +286,10 @@ function fail( string $message ): void { $classmap = $vendor_dir . '/composer/autoload_classmap.php'; -if ( file_exists( $classmap ) && ! str_contains( (string) file_get_contents( $classmap ), 'WP_CLI\\\\Vendor\\\\' ) ) { +// 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.' ); } From d29611844265d7b913f7d8d0be915048a111c620 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 16:11:37 +0000 Subject: [PATCH 4/6] Fix the dependency isolation scenarios failing on PHP 7.x The mu-plugins used a `\Stringable|string` union type, which is a parse error on the PHP 7.2 to 7.4 jobs in the matrix, so the scenarios failed with "syntax error, unexpected '|'" rather than exercising anything. Narrowing an untyped parameter to `string` is the same contravariance violation and parses on every version the suite runs, so the scenarios still distinguish a prefixed tree from an unprefixed one: declaring the class against an unprefixed `psr/log` v1 remains a fatal error, and succeeds once the bundled copy is prefixed. Also drop `--format=count` from `wp package list`, which does not offer that format and made the step fail on argument parsing. Refs https://github.com/wp-cli/wp-cli/issues/5920 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RHtjyXkZh8X16sBgmidSQi --- features/dependency-isolation.feature | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/features/dependency-isolation.feature b/features/dependency-isolation.feature index 2b1efc1a8..2129ce9dd 100644 --- a/features/dependency-isolation.feature +++ b/features/dependency-isolation.feature @@ -31,14 +31,14 @@ Feature: Bundled dependencies do not conflict with the site's own eval( 'namespace Psr\Log; interface LoggerInterface { - public function emergency( \Stringable|string $message, array $context = [] ): void; + public function emergency( string $message, array $context = [] ): void; }' ); } ); final class Site_Logger implements \Psr\Log\LoggerInterface { - public function emergency( \Stringable|string $message, array $context = [] ): void { + public function emergency( string $message, array $context = [] ): void { } } """ @@ -69,14 +69,14 @@ Feature: Bundled dependencies do not conflict with the site's own eval( 'namespace Symfony\Component\Console\Output; interface OutputInterface { - public function writeln( \Stringable|string $messages, int $options = 0 ): void; + public function writeln( string $messages, int $options = 0 ): void; }' ); } ); final class Site_Output implements \Symfony\Component\Console\Output\OutputInterface { - public function writeln( \Stringable|string $messages, int $options = 0 ): void { + public function writeln( string $messages, int $options = 0 ): void { } } """ @@ -93,6 +93,6 @@ Feature: Bundled dependencies do not conflict with the site's own # strings, which prefixing of static `use` statements does not cover. Given an empty directory - When I run `wp package list --format=count` + When I run `wp package list` Then STDERR should be empty And the return code should be 0 From 00a9caf70d75a708e2409b7ebdd8a10f4b0e62ac Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 16:21:18 +0000 Subject: [PATCH 5/6] Satisfy PHPCS, PHPStan and the spell checker Three separate code quality failures on the new build scripts: * PHPStan runs at level 9, where every offset read off `json_decode()` output is `mixed`. Narrow the decoded `installed.json` explicitly and build the packages list in a local variable instead of writing back through nested offsets. * Align the scoper config's array arrows on the longest key. * `marc-mabe` is a vendor name, not a misspelling of "maybe"; mark those two lines with the `spellchecker:disable-line` annotation the repo's `.typos.toml` already recognises. Re-ran the prefixing end to end against a scratch tree after the PHPStan refactor: 30 packages rewritten, autoloader regenerated, verification still passes. Refs https://github.com/wp-cli/wp-cli/issues/5920 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RHtjyXkZh8X16sBgmidSQi --- utils/scope-dependencies.php | 53 +++++++++++++++++++++++++----------- utils/scoper/scoper.inc.php | 14 +++++----- 2 files changed, 44 insertions(+), 23 deletions(-) diff --git a/utils/scope-dependencies.php b/utils/scope-dependencies.php index f2b7fb904..d860cca47 100644 --- a/utils/scope-dependencies.php +++ b/utils/scope-dependencies.php @@ -28,7 +28,7 @@ const SCOPED_VENDOR_DIRS = [ 'composer', 'justinrainbow', - 'marc-mabe', + 'marc-mabe', // spellchecker:disable-line 'psr', 'react', 'seld', @@ -160,22 +160,25 @@ function fail( string $message ): void { fail( sprintf( "Could not find '%s'.", $installed_json ) ); } -$installed = json_decode( (string) file_get_contents( $installed_json ), true ); +$decoded = json_decode( (string) file_get_contents( $installed_json ), true ); -if ( ! is_array( $installed ) || ! isset( $installed['packages'] ) ) { +if ( ! is_array( $decoded ) || ! isset( $decoded['packages'] ) || ! is_array( $decoded['packages'] ) ) { fail( sprintf( "Could not decode '%s'.", $installed_json ) ); } -$patched = 0; - -foreach ( $installed['packages'] as $index => $package ) { - $name = $package['name'] ?? ''; +/** + * @var array $decoded + * @var array $packages + */ +$packages = $decoded['packages']; +$patched = 0; - if ( '' === $name ) { +foreach ( $packages as $index => $package ) { + if ( ! is_array( $package ) || ! isset( $package['name'] ) || ! is_string( $package['name'] ) ) { continue; } - $vendor_name = explode( '/', $name )[0]; + $vendor_name = explode( '/', $package['name'] )[0]; if ( ! in_array( $vendor_name, SCOPED_VENDOR_DIRS, true ) ) { continue; @@ -185,31 +188,49 @@ function fail( string $message ): void { continue; } - $roots = []; + $autoload = $package['autoload']; + $roots = []; foreach ( [ 'psr-4', 'psr-0' ] as $standard ) { - foreach ( (array) ( $package['autoload'][ $standard ] ?? [] ) as $paths ) { + $rules = $autoload[ $standard ] ?? []; + + if ( ! is_array( $rules ) ) { + continue; + } + + foreach ( $rules as $paths ) { foreach ( (array) $paths as $path ) { - $roots[] = '' === $path ? '.' : $path; + if ( is_string( $path ) ) { + $roots[] = '' === $path ? '.' : $path; + } } } } - foreach ( (array) ( $package['autoload']['classmap'] ?? [] ) as $path ) { - $roots[] = $path; + $classmap_rules = $autoload['classmap'] ?? []; + + if ( is_array( $classmap_rules ) ) { + foreach ( $classmap_rules as $path ) { + if ( is_string( $path ) ) { + $roots[] = $path; + } + } } if ( ! $roots ) { continue; } - $installed['packages'][ $index ]['autoload'] = [ 'classmap' => array_values( array_unique( $roots ) ) ]; + $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( $installed, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); +$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 ) ); diff --git a/utils/scoper/scoper.inc.php b/utils/scoper/scoper.inc.php index d584fcb22..fce325037 100644 --- a/utils/scoper/scoper.inc.php +++ b/utils/scoper/scoper.inc.php @@ -59,7 +59,7 @@ static function ( $relative ) use ( $vendor_dir ) { [ 'composer', 'justinrainbow', - 'marc-mabe', + 'marc-mabe', // spellchecker:disable-line 'psr', 'react', 'seld', @@ -70,8 +70,8 @@ static function ( $relative ) use ( $vendor_dir ) { ); return [ - 'prefix' => 'WP_CLI\\Vendor', - 'finders' => [ + 'prefix' => 'WP_CLI\\Vendor', + 'finders' => [ /* * Deliberately without exclusions. The prefixed output is merged back * over `vendor/` rather than replacing it, because php-scoper only @@ -99,11 +99,11 @@ static function ( $relative ) use ( $vendor_dir ) { 'Composer', ], - 'exclude-classes' => [], - 'exclude-functions' => [], - 'exclude-constants' => [], + 'exclude-classes' => [], + 'exclude-functions' => [], + 'exclude-constants' => [], - 'patchers' => [ + 'patchers' => [ /* * Excluding a namespace stops php-scoper prefixing its declarations, * but not string literals that name classes inside it. Composer passes From 6bd71ebe223215a1ed8d95e26825eacabe0bdd88 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 16:25:40 +0000 Subject: [PATCH 6/6] Align the assignments PHPCS flagged in scope-dependencies.php Refs https://github.com/wp-cli/wp-cli/issues/5920 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RHtjyXkZh8X16sBgmidSQi --- utils/scope-dependencies.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/utils/scope-dependencies.php b/utils/scope-dependencies.php index d860cca47..dac204e37 100644 --- a/utils/scope-dependencies.php +++ b/utils/scope-dependencies.php @@ -221,8 +221,8 @@ function fail( string $message ): void { continue; } - $package['autoload'] = [ 'classmap' => array_values( array_unique( $roots ) ) ]; - $packages[ $index ] = $package; + $package['autoload'] = [ 'classmap' => array_values( array_unique( $roots ) ) ]; + $packages[ $index ] = $package; ++$patched; }