Skip to content

Prefix bundled dependencies to prevent conflicts with site code - #1110

Draft
swissspidy wants to merge 10 commits into
mainfrom
claude/wp-cli-issue-5920-sf6uat
Draft

Prefix bundled dependencies to prevent conflicts with site code#1110
swissspidy wants to merge 10 commits into
mainfrom
claude/wp-cli-issue-5920-sf6uat

Conversation

@swissspidy

Copy link
Copy Markdown
Member

Summary

This change implements namespace prefixing for WP-CLI's bundled third-party dependencies to prevent conflicts when the Phar runs against WordPress sites that ship their own versions of those libraries. Since WP-CLI's autoloader is registered before WordPress boots, unprefixed classes from the Phar take precedence over the site's own dependencies, causing fatal errors when interface signatures differ (e.g., psr/log v1 vs v3).

Key Changes

  • New utils/scope-dependencies.php script: Orchestrates the prefixing workflow using php-scoper. The script:

    • Installs an isolated php-scoper toolchain (requires PHP 8.2+)
    • Prefixes the composer/composer dependency tree under the WP_CLI\Vendor namespace
    • Rewrites Composer's autoload maps from PSR-4/PSR-0 to classmap format to advertise prefixed class names
    • Validates that the regenerated autoloader contains no unprefixed dependencies
    • Runs between composer install and utils/make-phar.php
  • New utils/scoper/scoper.inc.php configuration: Defines php-scoper behavior:

    • Prefixes vendor directories: composer, justinrainbow, marc-mabe, psr, react, seld, symfony
    • Keeps the Composer\ namespace unprefixed so third-party Composer plugins continue working
    • Includes all PHP files (including test fixtures) to prevent unprefixed names from leaking into the classmap
    • Patches string literals that reference Composer\ classes to avoid broken dynamic class resolution
  • New utils/scoper/composer.json: Isolated toolchain configuration requiring php-scoper ^0.18, kept separate because php-scoper needs PHP 8.2+ while WP-CLI targets PHP 7.2.24

  • Updated utils/make-phar.php: Adapts to work with both prefixed and unprefixed Symfony Finder class depending on whether scope-dependencies.php has already run

  • New features/dependency-isolation.feature: Behat scenarios validating that:

    • Sites providing their own psr/log v3 don't fatal due to interface incompatibility
    • Sites providing their own Symfony Console don't conflict with the bundled version
    • Package management still works against the prefixed dependency tree
  • Updated .github/workflows/deployment.yml: Adds prefixing step to the Phar build pipeline

Notable Implementation Details

  • The script validates that no unprefixed dependencies leak into the regenerated autoloader by scanning generated PHP files for unescaped namespace references
  • Composer's autoload rules are rewritten to classmap format because prefixed files no longer satisfy their original PSR-4 declarations
  • The --classmap-authoritative flag ensures the ClassLoader consults only the classmap, preventing any leftover PSR-4 rules from resurrecting unprefixed names
  • Test fixtures are intentionally included in prefixing to prevent them from surviving the merge with original namespaces intact

https://claude.ai/code/session_01RHtjyXkZh8X16sBgmidSQi

claude added 2 commits August 10, 2026 14:29
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 wp-cli/wp-cli#5920

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHtjyXkZh8X16sBgmidSQi
`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 wp-cli/wp-cli#5920

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHtjyXkZh8X16sBgmidSQi
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

claude added 6 commits August 10, 2026 15:59
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 wp-cli/wp-cli#5920

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHtjyXkZh8X16sBgmidSQi
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 wp-cli/wp-cli#5920

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHtjyXkZh8X16sBgmidSQi
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 wp-cli/wp-cli#5920

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHtjyXkZh8X16sBgmidSQi
Replaces the earlier approach on this branch, which rewrote vendor/ in
place from a separate build step.

WP-CLI registers its autoloader before WordPress boots, so for any class
shipped both by the Phar and by the site's own vendor/ the Phar's copy wins.
A site depending on psr/log v3 therefore loads the Phar's psr/log v1 and
fatals on the incompatible LoggerInterface signature.

Composer's dependency tree (composer/composer and everything it pulls in,
resolved from installed.json) is now rewritten under the WP_CLI\Vendor
namespace into third_party/, which gets its own classmap autoloader that
php/boot-phar.php registers. vendor/ is left untouched; utils/make-phar.php
bundles third_party/ instead of the original packages and drops them from
the main autoload maps, so the Phar stops advertising the unprefixed names.
A Composer-based installation of the bundle is unaffected.

The prefixing runs as Composer's post-install-cmd/post-update-cmd hook
(`composer prefix-dependencies`) rather than as a separate build step. The
php-scoper toolchain needs PHP 8.2+ while the bundle targets 7.2, so it
lives in utils/scoper/composer.json with a committed lock file; on an older
PHP the hook skips with a notice and a Phar built from that checkout
bundles the unprefixed tree. CI that must test on older PHP installs the
toolchain with a recent interpreter first and hands it over through
WP_CLI_SCOPER_PHP, as the deployment test matrix now does. The build job
refuses to build a Phar without third_party/.

`Composer\` itself stays unprefixed so that third-party Composer plugins
run by `wp package install` keep implementing the real interfaces, and the
Symfony polyfills are copied verbatim since they only work under their
global names. The hook verifies that the generated classmap advertises
nothing else under an original name, and that no bundled WP-CLI package
depends on a prefixed namespace.

See wp-cli/wp-cli#5920

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrV8jGVsfiypo6buRmZfug

Copy link
Copy Markdown
Member Author

All the failing checks on 2519e02 share a single root cause — php-scoper cannot parse one file, and because the prefixing now runs as post-install-cmd, its non-zero exit aborts composer install itself:

In ParsingException.php line 23:
  Could not parse the file ".../vendor/composer/ca-bundle/src/CaBundle.php".

In UseStmtCollection.php line 56:
  Using null as an array offset is deprecated, use an empty string instead

Error: php-scoper failed.
Script php utils/prefix-dependencies.php handling the prefix-dependencies event returned with error code 1
Script @prefix-dependencies was called via post-install-cmd

Every job that installs dependencies dies with it, which is why the Behat cells fail after ~30s instead of running for 5–9 minutes, and why Lint PHP files and Setup environment are red too. They are not independent failures.

Candidate fixes, cheapest first:

  • Pass --php-version to php-scoper so PHP-Parser targets a level that handles the file.
  • Bump humbug/php-scoper in utils/scoper/composer.json — the lock is committed, so it may be pinned to a version predating the fix for this.
  • Copy composer/ca-bundle verbatim instead of running it through the scoper. It declares Composer\CaBundle, which is inside the excluded Composer\ namespace and never needed prefixing — the same treatment the Symfony polyfills already get.

Worth deciding separately from the parse bug: whether the hook should hard-fail. As wired, any scoper problem takes composer install down with it, for contributors as well as CI. If it warned and skipped instead, this would have been one red check on a working tree rather than a fully blocked PR — and the guarantee is still preserved, since the build job already refuses to build a Phar without third_party/.

On the positive side, PHPCS, PHPStan and the spell check are all green on this head.


Generated by Claude Code

The toolchain lock file had been resolved against PHP 8.4, so it pinned
fidry/filesystem 1.3 and symfony/string 8.1, which refuse to install on the
PHP 8.2 and 8.3 legs of the test matrix. Lock against a PHP 8.2 platform.

On PHP 8.5, php-scoper 0.18.18 died while parsing the first file: its own
UseStmtCollection used null as an array key, and its error handler turned
the resulting deprecation into an exception. Bump to 0.18.19, which fixes
both, and keep deprecations out of the php-scoper run so that a future PHP
cannot fail the build the same way.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrV8jGVsfiypo6buRmZfug

Copy link
Copy Markdown
Member Author

The package.feature failure on 042f7d6 is not caused by the prefixing — worth saying so before it gets debugged as one.

Uncaught Error: Class "Composer\Console\GithubActionError" not found
  in phar://.../third_party/composer/composer/src/Composer/Installer.php:460
#0 Installer.php(293): Composer\Installer->doUpdate()
#1 vendor/wp-cli/package-command/src/Package_Command.php(438): Composer\Installer->run()

utils/make-phar.php excludes composer/composer/src/Composer/Console from the Phar, and Composer\Installer instantiates new GithubActionError($this->io) on each of its dependency-resolution failure paths (lines 554, 735 and 829 in 2.10.2). The environment check lives inside GithubActionError::emit(), not at the call site, so the class has to exist regardless of whether the run is on GitHub Actions. When wp package install hits an unsolvable dependency problem the Phar therefore fatals where the scenario expects a clean Error: Package installation failed.

main carries the identical exclusion and the same composer/composer 2.10.2, so the missing class predates this branch — the prefixing only changed the path it is reported under (third_party/… rather than vendor/…). I have not checked whether main's own suite reaches the same code path, so I can't say whether it is failing there too or merely latent.

The fix is to stop excluding that class from the Phar — either the whole Console/ directory or just GithubActionError.php, which is referenced from two places in make-phar.php on this branch (the regex around line 109 and the ->exclude() around line 297). Composer flags the same need itself: SelfUpdateCommand.php does a bare class_exists('Composer\Console\GithubActionError'); to force it to load before an error path can need it.


Generated by Claude Code

`wp package install` fataled with "Class Composer\Console\GithubActionError
not found" whenever dependency resolution failed: Composer\Installer
instantiates that class on every failure path. The scoper configuration
had carried over make-phar.php's exclusions of Composer's Command, Console
and Pear code, but those exclusions never actually applied on main: Finder's
exclude() takes paths relative to the in() roots, and the ones listed are
relative to vendor/ instead, so main's Phar has always shipped those files.
Drop the exclusions so the prefixed tree matches what the Phar really
contained before.

The reusable code-quality workflow runs parallel-lint over the whole
checkout with --show-deprecated, which now walks the php-scoper toolchain
(whose IDE stubs redeclare PHP's own functions) and third_party/. Pass both
through its parallel-lint-excludes input.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrV8jGVsfiypo6buRmZfug
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants