From 1a4b22d014bfbfa1ed81b854780ac58d0c54ab4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Wed, 26 Aug 2026 20:14:29 +0100 Subject: [PATCH 1/3] expose polyfill force decision per WP version predict_registration() shares the rule table register_scripts() applies --- .../changelog/add-predict-registration | 4 + .../src/class-wp-build-polyfills.php | 144 ++++++++++++------ .../tests/php/WP_Build_Polyfills_Test.php | 93 +++++++++++ .../changelog/add-provenance-predict-command | 4 + 4 files changed, 198 insertions(+), 47 deletions(-) create mode 100644 projects/packages/wp-build-polyfills/changelog/add-predict-registration create mode 100644 projects/plugins/debug-helper/changelog/add-provenance-predict-command diff --git a/projects/packages/wp-build-polyfills/changelog/add-predict-registration b/projects/packages/wp-build-polyfills/changelog/add-predict-registration new file mode 100644 index 000000000000..382bddcd36d0 --- /dev/null +++ b/projects/packages/wp-build-polyfills/changelog/add-predict-registration @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add WP_Build_Polyfills::predict_registration() to expose the force-replacement decision per polyfill for a given WordPress and Gutenberg version. diff --git a/projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php b/projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php index abe824ccc21e..21b3d147ccb2 100644 --- a/projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php +++ b/projects/packages/wp-build-polyfills/src/class-wp-build-polyfills.php @@ -185,46 +185,9 @@ private static function register_scripts( $scripts, $build_dir, $base_file, $wp_ // Gutenberg cannot be trusted to provide a compatible implementation. $gutenberg_version = defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : null; - $polyfills = array( - 'wp-notices' => array( - 'path' => 'notices', - 'force_threshold' => '7.0', - // Only force-replace on older WP without Gutenberg: older Core - // versions ship notices without SnackbarNotices and InlineNotices - // component exports that @wordpress/boot depends on. - ), - 'wp-private-apis' => array( - 'path' => 'private-apis', - 'force_threshold' => '7.1', - 'gutenberg_min_version' => self::GUTENBERG_PRIVATE_APIS_MIN_VERSION, - // WP 7.0 and older versions ship private-apis with an incomplete - // allowlist that rejects @wordpress/theme, @wordpress/route, and - // newer dashboard packages. Active Gutenberg is only a safe - // substitute once its private-apis allowlist includes those - // dashboard packages too. - ), - 'wp-rich-text' => array( - 'path' => 'rich-text', - 'force_threshold' => '7.1', - 'gutenberg_min_version' => self::GUTENBERG_RICH_TEXT_MIN_VERSION, - // WP 7.0 and older ship a rich-text whose `privateApis` current - // dashboard dependencies cannot use (e.g. @wordpress/dataviews - // >= 17.2 dataform controls, which unlock it at module scope). - // WP 6.9 exports no `privateApis` at all, which throws "Cannot - // unlock an undefined object"; WP 7.0 exports one locked with - // only `useRichText`, so destructuring the other keys yields - // undefined. Either way the page blanks. Older Gutenberg is not - // a safe substitute either — see the constant's doc. - ), - 'wp-theme' => array( - 'path' => 'theme', - ), - 'wp-views' => array( - 'path' => 'views', - ), - ); + $wp_version = $GLOBALS['wp_version'] ?? '0'; - foreach ( $polyfills as $handle => $data ) { + foreach ( self::script_policies() as $handle => $data ) { if ( ! isset( self::$requested[ $handle ] ) ) { continue; } @@ -235,14 +198,7 @@ private static function register_scripts( $scripts, $build_dir, $base_file, $wp_ continue; } - $force_threshold = $data['force_threshold'] ?? null; - if ( null !== $force_threshold && version_compare( $wp_version_threshold, $force_threshold, '>' ) ) { - $force_threshold = $wp_version_threshold; - } - - $force = null !== $force_threshold - && ! self::is_gutenberg_version_safe( $data['gutenberg_min_version'] ?? null, $gutenberg_version ) - && version_compare( $GLOBALS['wp_version'] ?? '0', $force_threshold, '<' ); + $force = self::should_force( $data, $wp_version, $gutenberg_version, $wp_version_threshold ); if ( ! $force && $scripts->query( $handle, 'registered' ) ) { continue; @@ -282,6 +238,100 @@ private static function register_scripts( $scripts, $build_dir, $base_file, $wp_ } } + /** + * Predicts how each polyfill registers on a given WordPress and Gutenberg version. + * + * `force` replaces the copy Core or Gutenberg registered under the handle; + * `fallback` only registers when nobody else did. Script modules are always + * `fallback`: `wp_register_script_module()` keeps the first registration. + * + * @since $$next-version$$ + * + * @param string $wp_version WordPress version to evaluate. + * @param string|null $gutenberg_version Gutenberg plugin version, or null when it is inactive. + * @param string $wp_version_threshold WordPress version below which force-replacements apply. Defaults to '7.0'. + * @return array Script handle or module ID => 'force' | 'fallback'. + */ + public static function predict_registration( $wp_version, $gutenberg_version = null, $wp_version_threshold = '7.0' ) { + $modes = array(); + foreach ( self::script_policies() as $handle => $policy ) { + $modes[ $handle ] = self::should_force( $policy, $wp_version, $gutenberg_version, $wp_version_threshold ) ? 'force' : 'fallback'; + } + foreach ( self::MODULE_IDS as $module_id ) { + $modes[ $module_id ] = 'fallback'; + } + + return $modes; + } + + /** + * Registration policy per polyfilled classic script. + * + * @return array + */ + private static function script_policies() { + return array( + 'wp-notices' => array( + 'path' => 'notices', + 'force_threshold' => '7.0', + // Only force-replace on older WP without Gutenberg: older Core + // versions ship notices without SnackbarNotices and InlineNotices + // component exports that @wordpress/boot depends on. + ), + 'wp-private-apis' => array( + 'path' => 'private-apis', + 'force_threshold' => '7.1', + 'gutenberg_min_version' => self::GUTENBERG_PRIVATE_APIS_MIN_VERSION, + // WP 7.0 and older versions ship private-apis with an incomplete + // allowlist that rejects @wordpress/theme, @wordpress/route, and + // newer dashboard packages. Active Gutenberg is only a safe + // substitute once its private-apis allowlist includes those + // dashboard packages too. + ), + 'wp-rich-text' => array( + 'path' => 'rich-text', + 'force_threshold' => '7.1', + 'gutenberg_min_version' => self::GUTENBERG_RICH_TEXT_MIN_VERSION, + // WP 7.0 and older ship a rich-text whose `privateApis` current + // dashboard dependencies cannot use (e.g. @wordpress/dataviews + // >= 17.2 dataform controls, which unlock it at module scope). + // WP 6.9 exports no `privateApis` at all, which throws "Cannot + // unlock an undefined object"; WP 7.0 exports one locked with + // only `useRichText`, so destructuring the other keys yields + // undefined. Either way the page blanks. Older Gutenberg is not + // a safe substitute either — see the constant's doc. + ), + 'wp-theme' => array( + 'path' => 'theme', + ), + 'wp-views' => array( + 'path' => 'views', + ), + ); + } + + /** + * Whether a polyfill must replace an existing registration. + * + * @param array $policy Entry from script_policies(). + * @param string $wp_version WordPress version to evaluate. + * @param string|null $gutenberg_version Gutenberg plugin version, or null when it is inactive. + * @param string $wp_version_threshold WordPress version below which force-replacements apply. + * @return bool + */ + private static function should_force( $policy, $wp_version, $gutenberg_version, $wp_version_threshold ) { + $force_threshold = $policy['force_threshold'] ?? null; + if ( null === $force_threshold ) { + return false; + } + if ( version_compare( $wp_version_threshold, $force_threshold, '>' ) ) { + $force_threshold = $wp_version_threshold; + } + + return ! self::is_gutenberg_version_safe( $policy['gutenberg_min_version'] ?? null, $gutenberg_version ) + && version_compare( $wp_version, $force_threshold, '<' ); + } + /** * Check whether the active Gutenberg plugin can satisfy a forced script. * diff --git a/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php b/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php index c78823c8eccc..16fe3e42ed2a 100644 --- a/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php +++ b/projects/packages/wp-build-polyfills/tests/php/WP_Build_Polyfills_Test.php @@ -990,4 +990,97 @@ public function test_register_scripts_only_registers_requested_handles() { $this->assertFalse( $scripts->query( 'wp-private-apis', 'registered' ) ); $this->assertFalse( $scripts->query( 'wp-theme', 'registered' ) ); } + + /** + * Data provider for test_predict_registration. + * + * @return array[] + */ + public static function provide_predict_registration() { + return array( + 'WP 6.9, no Gutenberg' => array( + '6.9.1', + null, + '7.0', + array( + 'wp-notices' => 'force', + 'wp-private-apis' => 'force', + 'wp-rich-text' => 'force', + ), + ), + 'WP 7.0.4, no Gutenberg' => array( + '7.0.4', + null, + '7.0', + array( + 'wp-notices' => 'fallback', + 'wp-private-apis' => 'force', + 'wp-rich-text' => 'force', + ), + ), + 'WP 7.1, no Gutenberg' => array( + '7.1', + null, + '7.0', + array( + 'wp-notices' => 'fallback', + 'wp-private-apis' => 'fallback', + 'wp-rich-text' => 'fallback', + ), + ), + 'WP 7.0.4, Gutenberg 23.4.0' => array( + '7.0.4', + '23.4.0', + '7.0', + array( + 'wp-private-apis' => 'force', + 'wp-rich-text' => 'force', + ), + ), + 'WP 7.0.4, Gutenberg 23.5.0' => array( + '7.0.4', + '23.5.0', + '7.0', + array( + 'wp-private-apis' => 'fallback', + 'wp-rich-text' => 'force', + ), + ), + 'WP 7.0.4, Gutenberg 23.6.0' => array( + '7.0.4', + '23.6.0', + '7.0', + array( + 'wp-private-apis' => 'fallback', + 'wp-rich-text' => 'fallback', + ), + ), + 'WP 6.9, Gutenberg 23.8.0' => array( '6.9.1', '23.8.0', '7.0', array( 'wp-notices' => 'fallback' ) ), + 'consumer threshold 7.1' => array( '7.0.4', null, '7.1', array( 'wp-notices' => 'force' ) ), + ); + } + + /** + * Test predict_registration() against the force rules. + * + * @dataProvider provide_predict_registration + * + * @param string $wp_version WordPress version. + * @param string|null $gutenberg_version Gutenberg version or null. + * @param string $threshold Consumer threshold. + * @param array $expected Expected modes for a subset of handles. + */ + #[DataProvider( 'provide_predict_registration' )] + public function test_predict_registration( $wp_version, $gutenberg_version, $threshold, $expected ) { + $modes = WP_Build_Polyfills::predict_registration( $wp_version, $gutenberg_version, $threshold ); + + foreach ( $expected as $handle => $mode ) { + $this->assertSame( $mode, $modes[ $handle ], $handle ); + } + $this->assertSame( 'fallback', $modes['wp-theme'] ); + $this->assertSame( 'fallback', $modes['wp-views'] ); + foreach ( WP_Build_Polyfills::MODULE_IDS as $module_id ) { + $this->assertSame( 'fallback', $modes[ $module_id ], $module_id ); + } + } } diff --git a/projects/plugins/debug-helper/changelog/add-provenance-predict-command b/projects/plugins/debug-helper/changelog/add-provenance-predict-command new file mode 100644 index 000000000000..4c4ecb46928f --- /dev/null +++ b/projects/plugins/debug-helper/changelog/add-provenance-predict-command @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Package Provenance: Add a WP-CLI command that predicts which runtime serves each polyfilled package on a given WordPress and Gutenberg version, and whether the site's bundles would be rejected by the winning private-apis allowlist. From cadbc9eb62526e7a8317dc8da715744944521cf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Wed, 26 Aug 2026 20:14:32 +0100 Subject: [PATCH 2/3] add provenance predict command to debug-helper predicts providers per WP/Gutenberg pair and checks opt-ins against the winning private-apis allowlist --- projects/plugins/debug-helper/.gitignore | 1 + projects/plugins/debug-helper/README.md | 13 + projects/plugins/debug-helper/composer.json | 16 + projects/plugins/debug-helper/composer.lock | 1893 ++++++++++++++++- .../class-package-provenance-helper.php | 7 + .../inc/class-package-provenance-cli.php | 146 ++ .../class-package-provenance-predictor.php | 170 ++ .../inc/class-package-provenance-sources.php | 500 +++++ .../plugins/debug-helper/phpunit.11.xml.dist | 36 + .../plugins/debug-helper/phpunit.12.xml.dist | 36 + .../plugins/debug-helper/phpunit.9.xml.dist | 17 + .../php/Package_Provenance_Predictor_Test.php | 302 +++ .../debug-helper/tests/php/bootstrap.php | 12 + 13 files changed, 3146 insertions(+), 3 deletions(-) create mode 100644 projects/plugins/debug-helper/modules/inc/class-package-provenance-cli.php create mode 100644 projects/plugins/debug-helper/modules/inc/class-package-provenance-predictor.php create mode 100644 projects/plugins/debug-helper/modules/inc/class-package-provenance-sources.php create mode 100644 projects/plugins/debug-helper/phpunit.11.xml.dist create mode 100644 projects/plugins/debug-helper/phpunit.12.xml.dist create mode 100644 projects/plugins/debug-helper/phpunit.9.xml.dist create mode 100644 projects/plugins/debug-helper/tests/php/Package_Provenance_Predictor_Test.php create mode 100644 projects/plugins/debug-helper/tests/php/bootstrap.php diff --git a/projects/plugins/debug-helper/.gitignore b/projects/plugins/debug-helper/.gitignore index 57872d0f1e5f..27eff3710aa2 100644 --- a/projects/plugins/debug-helper/.gitignore +++ b/projects/plugins/debug-helper/.gitignore @@ -1 +1,2 @@ /vendor/ +/.phpunit.cache/ diff --git a/projects/plugins/debug-helper/README.md b/projects/plugins/debug-helper/README.md index 2749b4dfb447..979ca8c4576b 100644 --- a/projects/plugins/debug-helper/README.md +++ b/projects/plugins/debug-helper/README.md @@ -37,3 +37,16 @@ There are two mockers implemented: #### Adding a Custom Mocker 1. Create a class implementing `Automattic\Jetpack\Debug_Helper\Mocker\Runner_Interface` that mocks the data. 2. Add it into the list in `Automattic\Jetpack\Debug_Helper\Mocker::$runners`. + +### Package Provenance + +Shows which runtime serves each WordPress package on the current admin screen: WordPress core, the Gutenberg plugin, Jetpack's wp-build-polyfills, or another plugin. A badge in the admin bar (`WP x · GB y`) opens a panel listing every registered `wp-*` script and script module with its provider, the plugin tree the file ships from, and its version. + +With the module active, a WP-CLI command predicts the same table for WordPress and Gutenberg versions the site is not running, and checks whether the private-apis copy that would win accepts the module names the site's bundles opt in with: + +``` +wp jetpack-debug provenance predict --wp=7.0.4,7.1 --gutenberg=off,23.8.0 +wp jetpack-debug provenance predict --wp=7.1 --gutenberg=/tmp/gutenberg.zip --format=json +``` + +`--wp` takes release versions or `trunk` (read from the WordPress/WordPress mirror on GitHub; the running version is read from disk). `--gutenberg` takes `off`, `active`, a release version (downloaded from GitHub Releases), or a path to a plugin zip or directory. Downloads are cached in the system temp directory; `--refresh` bypasses the cache. The command exits with status 1 when any cell rejects an opt-in, so it can gate a script. diff --git a/projects/plugins/debug-helper/composer.json b/projects/plugins/debug-helper/composer.json index ca4594985fc6..7ea3e8632372 100644 --- a/projects/plugins/debug-helper/composer.json +++ b/projects/plugins/debug-helper/composer.json @@ -4,6 +4,22 @@ "type": "wordpress-plugin", "license": "GPL-2.0-or-later", "require": {}, + "require-dev": { + "yoast/phpunit-polyfills": "^4.0.0", + "automattic/phpunit-select-config": "@dev", + "automattic/jetpack-wp-build-polyfills": "@dev" + }, + "scripts": { + "phpunit": [ + "phpunit-select-config phpunit.#.xml.dist --colors=always" + ], + "test-php": [ + "@composer phpunit" + ], + "test-php-coverage": [ + "php -dpcov.directory=. ./vendor/bin/phpunit-select-config phpunit.#.xml.dist --coverage-php \"$COVERAGE_DIR/php.cov\"" + ] + }, "repositories": [ { "type": "path", diff --git a/projects/plugins/debug-helper/composer.lock b/projects/plugins/debug-helper/composer.lock index bb986d8e9801..60bf7dc693e9 100644 --- a/projects/plugins/debug-helper/composer.lock +++ b/projects/plugins/debug-helper/composer.lock @@ -4,12 +4,1899 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "be2a261e907885f53e1a47c3b8165f4e", + "content-hash": "b03ac2eb10b931018645e3e1888c4975", "packages": [], - "packages-dev": [], + "packages-dev": [ + { + "name": "automattic/jetpack-wp-build-polyfills", + "version": "dev-trunk", + "dist": { + "type": "path", + "url": "../../packages/wp-build-polyfills", + "reference": "0f48f794fce34f17e3e7dbce24a211d9488bf906" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "automattic/jetpack-test-environment": "@dev", + "automattic/phpunit-select-config": "@dev", + "yoast/phpunit-polyfills": "^4.0.0" + }, + "type": "jetpack-library", + "extra": { + "autotagger": true, + "mirror-repo": "Automattic/jetpack-wp-build-polyfills", + "textdomain": "jetpack-wp-build-polyfills", + "changelogger": { + "link-template": "https://github.com/Automattic/jetpack-wp-build-polyfills/compare/v${old}...v${new}" + }, + "branch-alias": { + "dev-trunk": "0.4.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "scripts": { + "build-production": [ + "pnpm run build-production" + ], + "build-development": [ + "pnpm run build" + ], + "phpunit": [ + "phpunit-select-config phpunit.#.xml.dist --colors=always" + ], + "test-php": [ + "@composer phpunit" + ], + "test-php-coverage": [ + "php -dpcov.directory=. ./vendor/bin/phpunit-select-config phpunit.#.xml.dist --coverage-php \"$COVERAGE_DIR/php.cov\"" + ], + "test-js": [ + "pnpm run build", + "pnpm run test" + ], + "test-js-coverage": [ + "pnpm run build", + "pnpm run test-coverage" + ] + }, + "license": [ + "GPL-2.0-or-later" + ], + "description": "Polyfills for WordPress Core packages not available or incomplete in older WP versions", + "transport-options": { + "relative": true + } + }, + { + "name": "automattic/phpunit-select-config", + "version": "dev-trunk", + "dist": { + "type": "path", + "url": "../../packages/phpunit-select-config", + "reference": "f34d251d44e96b67a97272b464b69fc9ffb7de76" + }, + "require": { + "composer-runtime-api": "^2.2.2", + "php": ">=5.4", + "phpunit/phpunit": "*" + }, + "require-dev": { + "antecedent/patchwork": "^2.2", + "yoast/phpunit-polyfills": "^4.0.0" + }, + "bin": [ + "bin/phpunit-select-config" + ], + "type": "library", + "extra": { + "autotagger": true, + "branch-alias": { + "dev-trunk": "1.0.x-dev" + }, + "changelogger": { + "link-template": "https://github.com/Automattic/phpunit-select-config/compare/v${old}...v${new}" + }, + "mirror-repo": "Automattic/phpunit-select-config" + }, + "scripts": { + "phpunit": [ + "./vendor/bin/phpunit-select-config phpunit.#.xml.dist --colors=always" + ], + "test-php": [ + "@composer phpunit" + ], + "test-php-coverage": [ + "php -dpcov.directory=. ./vendor/bin/phpunit-select-config phpunit.#.xml.dist --coverage-php \"$COVERAGE_DIR/php.cov\"" + ], + "post-install-cmd": [ + "rm -f vendor/bin/phpunit-select-config && ln -s ../../bin/local-phpunit-select-config vendor/bin/phpunit-select-config" + ], + "post-update-cmd": [ + "rm -f vendor/bin/phpunit-select-config && ln -s ../../bin/local-phpunit-select-config vendor/bin/phpunit-select-config" + ] + }, + "license": [ + "GPL-2.0-or-later" + ], + "description": "Run PHPUnit, choosing a configuration file by version.", + "keywords": [ + "config", + "dev", + "phpunit" + ], + "transport-options": { + "relative": true + } + }, + { + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "12.5.7", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "186dab580576598076de6818596d12b61801880e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/186dab580576598076de6818596d12b61801880e", + "reference": "186dab580576598076de6818596d12b61801880e", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.3", + "phpunit/php-text-template": "^5.0", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.1.2", + "sebastian/lines-of-code": "^4.0.1", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.28" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.7" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-06-01T13:24:19+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a248d1640ab059b075f53a2ef0f9856e864e06b5", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.33" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-08-25T14:40:53+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^12.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:58+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:16+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:38+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "12.5.33", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "b98e028a26c5c5ba7e4a54be96ccf35f2914d184" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b98e028a26c5c5ba7e4a54be96ccf35f2914d184", + "reference": "b98e028a26c5c5ba7e4a54be96ccf35f2914d184", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.7", + "phpunit/php-file-iterator": "^6.0.1", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.0", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", + "sebastian/diff": "^7.0.0", + "sebastian/environment": "^8.1.2", + "sebastian/exporter": "^7.0.3", + "sebastian/global-state": "^8.0.3", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.4", + "sebastian/version": "^6.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.33" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-07-28T13:58:09+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "4.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-05-17T05:29:34+00:00" + }, + { + "name": "sebastian/comparator", + "version": "7.1.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "7c65c1e79836812819705b473a90c12399542485" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-05-21T04:45:25+00:00" + }, + { + "name": "sebastian/complexity", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.33", + "symfony/process": "^7.4.17" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" + } + ], + "time": "2026-08-25T15:35:54+00:00" + }, + { + "name": "sebastian/environment", + "version": "8.1.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.26" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:40:20+00:00" + }, + { + "name": "sebastian/exporter", + "version": "7.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-05-20T04:37:17+00:00" + }, + { + "name": "sebastian/global-state", + "version": "8.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^12.5.28" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2026-06-01T15:10:33+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.7.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-05-19T16:22:07+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:57:48+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:17+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:44:59+00:00" + }, + { + "name": "sebastian/type", + "version": "6.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "82ff822c2edc46724be9f7411d3163021f602773" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2026-05-20T06:45:45+00:00" + }, + { + "name": "sebastian/version", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T05:00:38+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" + }, + { + "name": "yoast/phpunit-polyfills", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/Yoast/PHPUnit-Polyfills.git", + "reference": "134921bfca9b02d8f374c48381451da1d98402f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/134921bfca9b02d8f374c48381451da1d98402f9", + "reference": "134921bfca9b02d8f374c48381451da1d98402f9", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "phpunit/phpunit": "^7.5 || ^8.0 || ^9.0 || ^11.0 || ^12.0" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "yoast/yoastcs": "^3.1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.x-dev" + } + }, + "autoload": { + "files": [ + "phpunitpolyfills-autoload.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Team Yoast", + "email": "support@yoast.com", + "homepage": "https://yoast.com" + }, + { + "name": "Contributors", + "homepage": "https://github.com/Yoast/PHPUnit-Polyfills/graphs/contributors" + } + ], + "description": "Set of polyfills for changed PHPUnit functionality to allow for creating PHPUnit cross-version compatible tests", + "homepage": "https://github.com/Yoast/PHPUnit-Polyfills", + "keywords": [ + "phpunit", + "polyfill", + "testing" + ], + "support": { + "issues": "https://github.com/Yoast/PHPUnit-Polyfills/issues", + "security": "https://github.com/Yoast/PHPUnit-Polyfills/security/policy", + "source": "https://github.com/Yoast/PHPUnit-Polyfills" + }, + "time": "2025-02-09T18:58:54+00:00" + } + ], "aliases": [], "minimum-stability": "dev", - "stability-flags": {}, + "stability-flags": { + "automattic/jetpack-wp-build-polyfills": 20, + "automattic/phpunit-select-config": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": {}, diff --git a/projects/plugins/debug-helper/modules/class-package-provenance-helper.php b/projects/plugins/debug-helper/modules/class-package-provenance-helper.php index 77d47e5fe080..1769cfbd9067 100644 --- a/projects/plugins/debug-helper/modules/class-package-provenance-helper.php +++ b/projects/plugins/debug-helper/modules/class-package-provenance-helper.php @@ -458,3 +458,10 @@ public function render() { } new Package_Provenance_Helper(); + +if ( defined( 'WP_CLI' ) && WP_CLI ) { + require_once __DIR__ . '/inc/class-package-provenance-predictor.php'; + require_once __DIR__ . '/inc/class-package-provenance-sources.php'; + require_once __DIR__ . '/inc/class-package-provenance-cli.php'; + WP_CLI::add_command( 'jetpack-debug provenance', 'Package_Provenance_CLI' ); +} diff --git a/projects/plugins/debug-helper/modules/inc/class-package-provenance-cli.php b/projects/plugins/debug-helper/modules/inc/class-package-provenance-cli.php new file mode 100644 index 000000000000..6c1e656f55d8 --- /dev/null +++ b/projects/plugins/debug-helper/modules/inc/class-package-provenance-cli.php @@ -0,0 +1,146 @@ +] + * : Comma-separated WordPress versions, or `trunk`. Defaults to the running version. + * + * [--gutenberg=] + * : Comma-separated Gutenberg specs: `off`, `active`, a release version, or a path to a plugin zip or directory. Default `off`. + * + * [--plugins=] + * : Comma-separated directories to scan for bundles. Defaults to the active plugins and mu-plugins. + * + * [--refresh] + * : Ignore cached downloads. + * + * [--format=] + * : Output format. + * --- + * default: table + * options: + * - table + * - json + * - yaml + * - csv + * --- + * + * ## EXAMPLES + * + * wp jetpack-debug provenance predict --wp=7.0.4,7.1 --gutenberg=off,23.8.0 + * wp jetpack-debug provenance predict --wp=7.1 --gutenberg=/tmp/gutenberg.zip --format=json + * + * @subcommand predict + * + * @param array $args Positional arguments (unused). + * @param array $assoc_args Named arguments. + */ + public function predict( $args, $assoc_args ) { + try { + $this->run( $assoc_args ); + } catch ( RuntimeException $e ) { + WP_CLI::error( $e->getMessage() ); + } + } + + /** + * Builds the inventories, predicts every cell and prints the result. + * + * @param array $assoc_args Named arguments. + * @throws RuntimeException When an inventory cannot be built. + */ + private function run( $assoc_args ) { + $format = $assoc_args['format'] ?? 'table'; + $sources = new Package_Provenance_Sources( ! empty( $assoc_args['refresh'] ) ); + + $wp_versions = $this->split( $assoc_args['wp'] ?? (string) $GLOBALS['wp_version'] ); + $gb_specs = $this->split( $assoc_args['gutenberg'] ?? 'off' ); + $dirs = isset( $assoc_args['plugins'] ) ? $this->split( $assoc_args['plugins'] ) : $sources->default_bundle_dirs(); + + $polyfill = $sources->polyfill_inventory(); + $optins = $sources->optins( $dirs, $polyfill['root'] ); + + $cells = array(); + foreach ( $wp_versions as $wp ) { + $core = $sources->core_inventory( $wp ); + foreach ( $gb_specs as $spec ) { + $gutenberg = $sources->gutenberg_inventory( $spec ); + $version = null; + if ( null !== $gutenberg ) { + $version = '' !== $gutenberg['version'] ? $gutenberg['version'] : $spec; + } + $modes = \Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills::predict_registration( 'trunk' === $wp ? '99' : $wp, $version ); + + $cells[] = Package_Provenance_Predictor::predict( + array( + 'wp' => $wp, + 'gutenberg' => $version, + 'modes' => $modes, + 'core' => $core, + 'gutenberg_inventory' => $gutenberg, + 'polyfill' => $polyfill, + 'optins' => $optins, + ) + ); + } + } + + $rejections = 0; + if ( 'table' === $format ) { + WP_CLI::log( sprintf( 'Bundles scanned in: %s', implode( ', ', $dirs ) ) ); + WP_CLI::log( sprintf( 'wp-build-polyfills %s from %s', $polyfill['version'], $polyfill['root'] ) ); + if ( array() === $optins ) { + WP_CLI::warning( 'No private-apis opt-ins found in the scanned bundles. Unbuilt plugins hide rejections; build them or pass --plugins.' ); + } + foreach ( $cells as $cell ) { + WP_CLI::log( '' ); + WP_CLI::log( sprintf( '== WP %s · Gutenberg %s ==', $cell['wp'], $cell['gutenberg'] ?? 'inactive' ) ); + WP_CLI\Utils\format_items( 'table', $cell['rows'], array( 'package', 'type', 'provider', 'reason' ) ); + WP_CLI::log( sprintf( 'private-apis served by %s (allowlist: %d modules)', $cell['private_apis']['provider'], $cell['private_apis']['allowlist_size'] ) ); + foreach ( $cell['private_apis']['rejected'] as $module => $files ) { + ++$rejections; + WP_CLI::warning( sprintf( 'opt-in rejected: %s (%s)', $module, implode( ', ', $files ) ) ); + } + } + } else { + foreach ( $cells as $cell ) { + $rejections += count( $cell['private_apis']['rejected'] ); + } + WP_CLI::print_value( $cells, array( 'format' => $format ) ); + } + + if ( $rejections > 0 ) { + WP_CLI::halt( 1 ); + } + } + + /** + * Splits a comma-separated list. + * + * @param string $value Raw value. + * @return string[] + */ + private function split( $value ) { + return array_values( array_filter( array_map( 'trim', explode( ',', (string) $value ) ), 'strlen' ) ); + } +} diff --git a/projects/plugins/debug-helper/modules/inc/class-package-provenance-predictor.php b/projects/plugins/debug-helper/modules/inc/class-package-provenance-predictor.php new file mode 100644 index 000000000000..97dd3745cd5c --- /dev/null +++ b/projects/plugins/debug-helper/modules/inc/class-package-provenance-predictor.php @@ -0,0 +1,170 @@ + count( $best ) ) { + $best = $names[0]; + } + } + + $best = array_values( array_unique( $best ) ); + sort( $best ); + return $best; + } + + /** + * Extracts the module names a built file opts in with. + * + * Matches `__dangerousOptInToUnstableAPIsOnlyForCoreModules( , '@wordpress/x' )` + * in the plain form and in the minified `(0,e.__dangerous…)( … )` form; the consent + * argument may be a string literal or a variable, and quotes may be escaped. + * + * @param string $js File contents. + * @return string[] Sorted unique module names. + */ + public static function parse_optins( $js ) { + if ( false === strpos( $js, 'OptInToUnstableAPIsOnlyForCoreModules' ) ) { + return array(); + } + + // Quotes may be backslash-escaped: development builds wrap modules in eval( "…" ). + preg_match_all( + '/OptInToUnstableAPIsOnlyForCoreModules\)?\s*\(\s*(?:\\\\?["\'][^"\'\\\\]*\\\\?["\']|[A-Za-z_$][\w$.]*)\s*,\s*\\\\?["\'](@wordpress\/[a-z0-9-]+)\\\\?["\']/', + $js, + $matches + ); + + $names = array_values( array_unique( $matches[1] ) ); + sort( $names ); + return $names; + } + + /** + * Predicts the provider of every polyfilled package for one cell. + * + * `$cell` keys: `wp` and `gutenberg` (versions, Gutenberg null when inactive), + * `modes` (handle or module ID => 'force' | 'fallback', from + * WP_Build_Polyfills::predict_registration()), `core`, `gutenberg_inventory` + * (null when inactive) and `polyfill` (inventories), and `optins` (module name + * => files that opt in with it). + * + * The result carries `wp`, `gutenberg`, `rows` (package, type, provider, reason) + * and `private_apis` (provider, allowlist_size, rejected as module name => files). + * + * @param array $cell Cell to evaluate. + * @return array Prediction. + */ + public static function predict( array $cell ) { + $core = $cell['core']; + $gutenberg = $cell['gutenberg_inventory']; + $polyfill = $cell['polyfill']; + + $rows = array(); + $providers = array(); + foreach ( $cell['modes'] as $name => $mode ) { + $is_module = 0 === strpos( $name, '@wordpress/' ); + $key = $is_module ? 'modules' : 'scripts'; + + if ( 'force' === $mode && in_array( $name, $polyfill[ $key ], true ) ) { + $provider = self::PROVIDER_POLYFILL; + $reason = 'forced: WP too old and no Gutenberg new enough'; + } elseif ( null !== $gutenberg && in_array( $name, $gutenberg[ $key ], true ) ) { + $provider = self::PROVIDER_GUTENBERG; + $reason = 'Gutenberg registers it'; + } elseif ( in_array( $name, $core[ $key ], true ) ) { + $provider = self::PROVIDER_CORE; + $reason = 'core registers it'; + } elseif ( in_array( $name, $polyfill[ $key ], true ) ) { + $provider = self::PROVIDER_POLYFILL; + $reason = 'nobody else ships it'; + } else { + $provider = self::PROVIDER_MISSING; + $reason = 'nobody ships it'; + } + + $providers[ $name ] = $provider; + $rows[] = array( + 'package' => $name, + 'type' => $is_module ? 'module' : 'classic', + 'provider' => $provider, + 'reason' => $reason, + ); + } + + $inventories = array( + self::PROVIDER_CORE => $core, + self::PROVIDER_GUTENBERG => $gutenberg, + self::PROVIDER_POLYFILL => $polyfill, + ); + $winner = $providers['wp-private-apis'] ?? self::PROVIDER_MISSING; + $winning = $inventories[ $winner ] ?? null; + $allowlist = is_array( $winning ) ? $winning['allowlist'] : array(); + + // Polyfill copies that win a slot opt in too (rich-text inlines compose, for example). + $optins = $cell['optins']; + foreach ( $polyfill['optins'] ?? array() as $handle => $modules ) { + if ( self::PROVIDER_POLYFILL !== ( $providers[ $handle ] ?? '' ) ) { + continue; + } + foreach ( $modules as $module ) { + $optins[ $module ] = array_merge( $optins[ $module ] ?? array(), array( 'wp-build-polyfills:' . $handle ) ); + } + } + ksort( $optins ); + + $rejected = array(); + foreach ( $optins as $module => $files ) { + if ( ! in_array( $module, $allowlist, true ) ) { + $rejected[ $module ] = $files; + } + } + + return array( + 'wp' => $cell['wp'], + 'gutenberg' => $cell['gutenberg'], + 'rows' => $rows, + 'private_apis' => array( + 'provider' => $winner, + 'allowlist_size' => count( $allowlist ), + 'rejected' => $rejected, + ), + ); + } +} diff --git a/projects/plugins/debug-helper/modules/inc/class-package-provenance-sources.php b/projects/plugins/debug-helper/modules/inc/class-package-provenance-sources.php new file mode 100644 index 000000000000..34abacd0a960 --- /dev/null +++ b/projects/plugins/debug-helper/modules/inc/class-package-provenance-sources.php @@ -0,0 +1,500 @@ +refresh = (bool) $refresh; + } + + /** + * Core inventory for a WordPress version. + * + * @param string $version WordPress version, or `trunk`. + * @return array Inventory plus `source` (`local` or the mirror ref). + * @throws RuntimeException When the mirror cannot be read. + */ + public function core_inventory( $version ) { + $running = (string) ( $GLOBALS['wp_version'] ?? '' ); + if ( 'trunk' !== $version && version_compare( $version, $running, '==' ) ) { + $dist = ABSPATH . WPINC . '/js/dist'; + $inventory = array( + 'scripts' => $this->handles_from_files( self::glob( $dist . '/*.js' ) ), + 'modules' => $this->ids_from_dirs( self::glob( $dist . '/script-modules/*', GLOB_ONLYDIR ) ), + 'allowlist' => Package_Provenance_Predictor::parse_allowlist( (string) file_get_contents( $dist . '/private-apis.js' ) ), // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + 'source' => 'local', + ); + return $inventory; + } + + $ref = 'trunk' === $version ? 'master' : $version; + $cache = $this->cache_path( 'core-' . $ref . '.json' ); + if ( ! $this->refresh && 'master' !== $ref && is_file( $cache ) ) { + $cached = json_decode( (string) file_get_contents( $cache ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + if ( is_array( $cached ) ) { + return $cached; + } + } + + $scripts = array(); + foreach ( $this->mirror_listing( 'wp-includes/js/dist', $ref ) as $entry ) { + if ( 'file' === $entry['type'] && substr( $entry['name'], -3 ) === '.js' && substr( $entry['name'], -7 ) !== '.min.js' ) { + $scripts[] = 'wp-' . substr( $entry['name'], 0, -3 ); + } + } + $modules = array(); + foreach ( $this->mirror_listing( 'wp-includes/js/dist/script-modules', $ref ) as $entry ) { + if ( 'dir' === $entry['type'] ) { + $modules[] = '@wordpress/' . $entry['name']; + } + } + sort( $scripts ); + sort( $modules ); + + $inventory = array( + 'scripts' => $scripts, + 'modules' => $modules, + 'allowlist' => Package_Provenance_Predictor::parse_allowlist( $this->fetch( self::MIRROR_RAW . $ref . '/wp-includes/js/dist/private-apis.js' ) ), + 'source' => 'WordPress/WordPress@' . $ref, + ); + file_put_contents( $cache, wp_json_encode( $inventory, JSON_UNESCAPED_SLASHES ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + return $inventory; + } + + /** + * Gutenberg inventory for a spec. + * + * @param string $spec `off`, `active`, a release version, or a path to a plugin zip or directory. + * @return array|null Inventory plus `version` and `source`, or null for `off`. + * @throws RuntimeException When the spec cannot be resolved. + */ + public function gutenberg_inventory( $spec ) { + $spec = trim( (string) $spec ); + if ( '' === $spec || 'off' === $spec || 'none' === $spec ) { + return null; + } + if ( 'active' === $spec ) { + $dir = WP_PLUGIN_DIR . '/gutenberg'; + if ( ! is_dir( $dir ) ) { + throw new RuntimeException( 'The Gutenberg plugin is not installed in ' . WP_PLUGIN_DIR ); + } + return $this->gutenberg_from_dir( $dir, 'active plugin' ); + } + if ( is_dir( $spec ) ) { + return $this->gutenberg_from_dir( $spec, $spec ); + } + if ( is_file( $spec ) ) { + return $this->gutenberg_from_zip( $spec, $spec ); + } + if ( preg_match( '/^\d+\.\d+(\.\d+)?(-[\w.]+)?$/', $spec ) ) { + $zip = $this->cache_path( 'gutenberg-' . $spec . '.zip' ); + if ( $this->refresh || ! is_file( $zip ) ) { + $this->download( 'https://github.com/WordPress/gutenberg/releases/download/v' . $spec . '/gutenberg.zip', $zip ); + } + return $this->gutenberg_from_zip( $zip, 'release v' . $spec ); + } + throw new RuntimeException( 'Cannot resolve Gutenberg spec: ' . $spec ); + } + + /** + * Inventory of the wp-build-polyfills build loaded on this site. + * + * @return array Inventory plus `optins` (module names each build opts in with, by handle), `root` and `version`. + * @throws RuntimeException When no plugin ships the package. + */ + public function polyfill_inventory() { + $class = '\\Automattic\\Jetpack\\WP_Build_Polyfills\\WP_Build_Polyfills'; + if ( ! class_exists( $class ) ) { + throw new RuntimeException( 'No active plugin ships automattic/jetpack-wp-build-polyfills.' ); + } + if ( ! method_exists( $class, 'predict_registration' ) ) { + throw new RuntimeException( 'The loaded wp-build-polyfills is too old: it has no predict_registration().' ); + } + + $root = dirname( ( new ReflectionClass( $class ) )->getFileName(), 2 ); + $build = $root . '/build'; + $apis = is_file( $build . '/scripts/private-apis/index.js' ) ? $build . '/scripts/private-apis/index.js' : $build . '/scripts/private-apis/index.min.js'; + + // Released copies carry the version in composer.json; monorepo checkouts only in package.json. + $version = ''; + foreach ( array( 'composer.json', 'package.json' ) as $manifest ) { + $data = is_file( $root . '/' . $manifest ) ? json_decode( (string) file_get_contents( $root . '/' . $manifest ), true ) : null; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + if ( is_array( $data ) && ! empty( $data['version'] ) ) { + $version = (string) $data['version']; + break; + } + } + + $optins = array(); + foreach ( self::glob( $build . '/scripts/*', GLOB_ONLYDIR ) as $dir ) { + $optins[ 'wp-' . basename( $dir ) ] = $this->optins_in_dir( $dir ); + } + foreach ( self::glob( $build . '/modules/*', GLOB_ONLYDIR ) as $dir ) { + $optins[ '@wordpress/' . basename( $dir ) ] = $this->optins_in_dir( $dir ); + } + + return array( + 'scripts' => $this->handles_from_dirs( self::glob( $build . '/scripts/*', GLOB_ONLYDIR ) ), + 'modules' => $this->ids_from_dirs( self::glob( $build . '/modules/*', GLOB_ONLYDIR ) ), + 'allowlist' => is_file( $apis ) ? Package_Provenance_Predictor::parse_allowlist( (string) file_get_contents( $apis ) ) : array(), // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + 'optins' => array_filter( $optins ), + 'root' => $root, + 'version' => $version, + ); + } + + /** + * Directories of the active plugins and the mu-plugins directory. + * + * @return string[] + */ + public function default_bundle_dirs() { + $dirs = array(); + foreach ( (array) get_option( 'active_plugins', array() ) as $plugin ) { + $dir = WP_PLUGIN_DIR . '/' . dirname( $plugin ); + if ( '.' !== dirname( $plugin ) && is_dir( $dir ) ) { + $dirs[ $dir ] = true; + } + } + if ( is_dir( WPMU_PLUGIN_DIR ) ) { + $dirs[ WPMU_PLUGIN_DIR ] = true; + } + return array_keys( $dirs ); + } + + /** + * Module names the JavaScript under the given directories opts in with. + * + * @param string[] $dirs Directories to scan recursively. + * @param string $exclude_root Directory to skip (the polyfill package, accounted for separately). + * @return array Module name => files, as `/`. + */ + public function optins( array $dirs, $exclude_root = '' ) { + $exclude_root = '' === $exclude_root ? '' : (string) realpath( $exclude_root ); + $found = array(); + foreach ( $dirs as $dir ) { + $real = realpath( $dir ); + if ( false === $real ) { + continue; + } + $iterator = new RecursiveIteratorIterator( + new RecursiveCallbackFilterIterator( + new RecursiveDirectoryIterator( $real, FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS ), + function ( $current ) use ( $exclude_root ) { + if ( $current->isDir() ) { + if ( in_array( $current->getFilename(), self::SKIP_DIRS, true ) ) { + return false; + } + // Compare real paths: plugins reach the package through symlinks. + return '' === $exclude_root || 0 !== strpos( (string) $current->getRealPath(), $exclude_root ); + } + return (bool) preg_match( '/\.m?js$/', $current->getFilename() ); + } + ) + ); + foreach ( $iterator as $file ) { + $names = Package_Provenance_Predictor::parse_optins( (string) file_get_contents( $file->getPathname() ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + foreach ( $names as $name ) { + $found[ $name ][] = basename( $dir ) . substr( $file->getPathname(), strlen( $real ) ); + } + } + } + ksort( $found ); + return $found; + } + + /** + * Module names the JavaScript directly under a build directory opts in with. + * + * @param string $dir Directory. + * @return string[] + */ + private function optins_in_dir( $dir ) { + $names = array(); + foreach ( self::glob( $dir . '/*.js' ) as $file ) { + $names = array_merge( $names, Package_Provenance_Predictor::parse_optins( (string) file_get_contents( $file ) ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + } + $names = array_values( array_unique( $names ) ); + sort( $names ); + return $names; + } + + /** + * Inventory of a Gutenberg plugin directory (old `build/` and new `build/scripts/` layouts). + * + * @param string $dir Plugin directory. + * @param string $source Label for the `source` key. + * @return array + */ + private function gutenberg_from_dir( $dir, $source ) { + $dir = rtrim( $dir, '/' ); + $scripts = is_dir( $dir . '/build/scripts' ) ? $dir . '/build/scripts' : $dir . '/build'; + $modules = is_dir( $dir . '/build/modules' ) ? $dir . '/build/modules' : $dir . '/build-module'; + + $script_dirs = array(); + foreach ( self::glob( $scripts . '/*', GLOB_ONLYDIR ) as $candidate ) { + if ( is_file( $candidate . '/index.min.js' ) ) { + $script_dirs[] = $candidate; + } + } + $apis = $scripts . '/private-apis/index.min.js'; + + $version = ''; + if ( is_file( $dir . '/build/constants.php' ) ) { + $constants = include $dir . '/build/constants.php'; + $version = (string) ( $constants['version'] ?? '' ); + } + if ( '' === $version && is_file( $dir . '/gutenberg.php' ) && preg_match( '/^\s*\*\s*Version:\s*(\S+)/m', (string) file_get_contents( $dir . '/gutenberg.php' ), $m ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $version = $m[1]; + } + + return array( + 'scripts' => $this->handles_from_dirs( $script_dirs ), + 'modules' => $this->ids_from_dirs( self::glob( $modules . '/*', GLOB_ONLYDIR ) ), + 'allowlist' => is_file( $apis ) ? Package_Provenance_Predictor::parse_allowlist( (string) file_get_contents( $apis ) ) : array(), // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + 'version' => $version, + 'source' => $source, + ); + } + + /** + * Inventory of a Gutenberg plugin zip, without extracting it. + * + * @param string $zip Zip path. + * @param string $source Label for the `source` key. + * @return array + * @throws RuntimeException When the zip cannot be opened. + */ + private function gutenberg_from_zip( $zip, $source ) { + $archive = new ZipArchive(); + if ( true !== $archive->open( $zip ) ) { + throw new RuntimeException( 'Cannot open ' . $zip ); + } + + $names = array(); + for ( $i = 0; $i < $archive->numFiles; $i++ ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + $names[] = $archive->getNameIndex( $i ); + } + + // Release zips may nest everything under one top-level directory. + $prefix = ''; + foreach ( $names as $name ) { + if ( preg_match( '#^((?:[^/]+/)?)gutenberg\.php$#', $name, $m ) ) { + $prefix = $m[1]; + break; + } + } + + $layout = in_array( $prefix . 'build/scripts.php', $names, true ) ? 'build/scripts/' : 'build/'; + $modules = in_array( $prefix . 'build/modules.php', $names, true ) ? 'build/modules/' : 'build-module/'; + + $scripts = array(); + $ids = array(); + foreach ( $names as $name ) { + if ( preg_match( '#^' . preg_quote( $prefix . $layout, '#' ) . '([a-z0-9-]+)/index\.min\.js$#', $name, $m ) ) { + $scripts[] = 'wp-' . $m[1]; + } elseif ( preg_match( '#^' . preg_quote( $prefix . $modules, '#' ) . '([a-z0-9-]+)/index(\.min)?\.js$#', $name, $m ) ) { + $ids[] = '@wordpress/' . $m[1]; + } + } + $scripts = array_values( array_unique( $scripts ) ); + $ids = array_values( array_unique( $ids ) ); + sort( $scripts ); + sort( $ids ); + + $version = ''; + $constants = $archive->getFromName( $prefix . 'build/constants.php' ); + if ( false !== $constants && preg_match( "/'version'\s*=>\s*'([^']+)'/", $constants, $m ) ) { + $version = $m[1]; + } + $header = $archive->getFromName( $prefix . 'gutenberg.php' ); + if ( '' === $version && false !== $header && preg_match( '/^\s*\*\s*Version:\s*(\S+)/m', $header, $m ) ) { + $version = $m[1]; + } + + $apis = $archive->getFromName( $prefix . $layout . 'private-apis/index.min.js' ); + $archive->close(); + + return array( + 'scripts' => $scripts, + 'modules' => $ids, + 'allowlist' => false !== $apis ? Package_Provenance_Predictor::parse_allowlist( $apis ) : array(), + 'version' => $version, + 'source' => $source, + ); + } + + /** + * Lists a directory of the WordPress/WordPress mirror. + * + * @param string $path Repository path. + * @param string $ref Tag or branch. + * @return array[] Entries with `name` and `type`. + * @throws RuntimeException When the listing is not JSON. + */ + private function mirror_listing( $path, $ref ) { + $entries = json_decode( $this->fetch( self::MIRROR_API . $path . '?ref=' . rawurlencode( $ref ) ), true ); + if ( ! is_array( $entries ) || isset( $entries['message'] ) ) { + throw new RuntimeException( 'Cannot list ' . $path . ' at WordPress/WordPress@' . $ref . ( isset( $entries['message'] ) ? ': ' . $entries['message'] : '' ) ); + } + return $entries; + } + + /** + * GETs a URL and returns the body. + * + * @param string $url URL. + * @return string + * @throws RuntimeException On HTTP failure. + */ + private function fetch( $url ) { + $response = wp_remote_get( $url, array( 'timeout' => 30 ) ); + if ( is_wp_error( $response ) ) { + throw new RuntimeException( $url . ': ' . $response->get_error_message() ); + } + $code = wp_remote_retrieve_response_code( $response ); + if ( 200 !== $code ) { + throw new RuntimeException( $url . ': HTTP ' . $code ); + } + return (string) wp_remote_retrieve_body( $response ); + } + + /** + * Downloads a URL to a path. + * + * @param string $url URL. + * @param string $path Destination. + * @throws RuntimeException On failure. + */ + private function download( $url, $path ) { + $response = wp_remote_get( + $url, + array( + 'timeout' => 120, + 'stream' => true, + 'filename' => $path, + ) + ); + if ( is_wp_error( $response ) ) { + throw new RuntimeException( $url . ': ' . $response->get_error_message() ); + } + $code = wp_remote_retrieve_response_code( $response ); + if ( 200 !== $code ) { + @unlink( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink + throw new RuntimeException( $url . ': HTTP ' . $code ); + } + } + + /** + * Path inside the cache directory, creating the directory on first use. + * + * @param string $file File name. + * @return string + */ + private function cache_path( $file ) { + $dir = rtrim( get_temp_dir(), '/' ) . '/jetpack-debug-helper-provenance'; + if ( ! is_dir( $dir ) ) { + wp_mkdir_p( $dir ); + } + return $dir . '/' . $file; + } + + /** + * Wraps glob() so it never returns false. + * + * @param string $pattern Pattern. + * @param int $flags Flags. + * @return string[] + */ + private static function glob( $pattern, $flags = 0 ) { + $matches = glob( $pattern, $flags ); + return false === $matches ? array() : $matches; + } + + /** + * `wp-` handles from `.js` files. + * + * @param string[] $files File paths. + * @return string[] + */ + private function handles_from_files( array $files ) { + $handles = array(); + foreach ( $files as $file ) { + $name = basename( $file ); + if ( substr( $name, -7 ) !== '.min.js' ) { + $handles[] = 'wp-' . substr( $name, 0, -3 ); + } + } + sort( $handles ); + return $handles; + } + + /** + * `wp-` handles from `/` directories. + * + * @param string[] $dirs Directory paths. + * @return string[] + */ + private function handles_from_dirs( array $dirs ) { + $handles = array_map( + function ( $dir ) { + return 'wp-' . basename( $dir ); + }, + $dirs + ); + sort( $handles ); + return $handles; + } + + /** + * `@wordpress/` IDs from `/` directories. + * + * @param string[] $dirs Directory paths. + * @return string[] + */ + private function ids_from_dirs( array $dirs ) { + $ids = array_map( + function ( $dir ) { + return '@wordpress/' . basename( $dir ); + }, + $dirs + ); + sort( $ids ); + return $ids; + } +} diff --git a/projects/plugins/debug-helper/phpunit.11.xml.dist b/projects/plugins/debug-helper/phpunit.11.xml.dist new file mode 100644 index 000000000000..4f8a41c316bf --- /dev/null +++ b/projects/plugins/debug-helper/phpunit.11.xml.dist @@ -0,0 +1,36 @@ + + + + + tests/php + + + + + + + + modules + + + + + diff --git a/projects/plugins/debug-helper/phpunit.12.xml.dist b/projects/plugins/debug-helper/phpunit.12.xml.dist new file mode 100644 index 000000000000..4f8a41c316bf --- /dev/null +++ b/projects/plugins/debug-helper/phpunit.12.xml.dist @@ -0,0 +1,36 @@ + + + + + tests/php + + + + + + + + modules + + + + + diff --git a/projects/plugins/debug-helper/phpunit.9.xml.dist b/projects/plugins/debug-helper/phpunit.9.xml.dist new file mode 100644 index 000000000000..3965963c485e --- /dev/null +++ b/projects/plugins/debug-helper/phpunit.9.xml.dist @@ -0,0 +1,17 @@ + + + + + tests/php + + + diff --git a/projects/plugins/debug-helper/tests/php/Package_Provenance_Predictor_Test.php b/projects/plugins/debug-helper/tests/php/Package_Provenance_Predictor_Test.php new file mode 100644 index 000000000000..18897b569d5f --- /dev/null +++ b/projects/plugins/debug-helper/tests/php/Package_Provenance_Predictor_Test.php @@ -0,0 +1,302 @@ + + */ + const MODES_FORCED = array( + 'wp-notices' => 'fallback', + 'wp-private-apis' => 'force', + 'wp-rich-text' => 'force', + 'wp-theme' => 'fallback', + 'wp-views' => 'fallback', + '@wordpress/boot' => 'fallback', + '@wordpress/route' => 'fallback', + '@wordpress/a11y' => 'fallback', + '@wordpress/widget-primitives' => 'fallback', + ); + + /** + * Registration modes where nothing is forced (WP 7.1, or Gutenberg new enough). + * + * @var array + */ + const MODES_FALLBACK = array( + 'wp-notices' => 'fallback', + 'wp-private-apis' => 'fallback', + 'wp-rich-text' => 'fallback', + 'wp-theme' => 'fallback', + 'wp-views' => 'fallback', + '@wordpress/boot' => 'fallback', + '@wordpress/route' => 'fallback', + '@wordpress/a11y' => 'fallback', + '@wordpress/widget-primitives' => 'fallback', + ); + + /** + * Core inventory: no views, no widget-primitives. + * + * @param string[] $allowlist Allowlist. + * @return array + */ + private function core( array $allowlist ) { + return array( + 'scripts' => array( 'wp-notices', 'wp-private-apis', 'wp-rich-text', 'wp-theme' ), + 'modules' => array( '@wordpress/a11y', '@wordpress/boot', '@wordpress/route' ), + 'allowlist' => $allowlist, + ); + } + + /** + * Gutenberg inventory: everything but views. + * + * @param string[] $allowlist Allowlist. + * @return array + */ + private function gutenberg( array $allowlist ) { + return array( + 'scripts' => array( 'wp-compose', 'wp-notices', 'wp-private-apis', 'wp-rich-text', 'wp-theme' ), + 'modules' => array( '@wordpress/a11y', '@wordpress/boot', '@wordpress/route', '@wordpress/widget-primitives' ), + 'allowlist' => $allowlist, + ); + } + + /** + * Polyfill inventory: everything, allowlist with the dashboard packages. + * + * @return array + */ + private function polyfill() { + return array( + 'scripts' => array( 'wp-notices', 'wp-private-apis', 'wp-rich-text', 'wp-theme', 'wp-views' ), + 'modules' => array( '@wordpress/a11y', '@wordpress/boot', '@wordpress/route', '@wordpress/widget-primitives' ), + 'allowlist' => array( '@wordpress/compose', '@wordpress/dataviews', '@wordpress/fields', '@wordpress/rich-text', '@wordpress/theme', '@wordpress/views' ), + 'optins' => array( + 'wp-rich-text' => array( '@wordpress/compose', '@wordpress/rich-text' ), + 'wp-theme' => array( '@wordpress/theme' ), + 'wp-views' => array( '@wordpress/views' ), + '@wordpress/boot' => array( '@wordpress/boot' ), + ), + ); + } + + /** + * Provider per package from a prediction. + * + * @param array $prediction Prediction. + * @return array + */ + private function providers( array $prediction ) { + $providers = array(); + foreach ( $prediction['rows'] as $row ) { + $providers[ $row['package'] ] = $row['provider']; + } + return $providers; + } + + /** + * The allowlist is the largest @wordpress/* array in the file. + */ + public function test_parse_allowlist_picks_largest_array() { + $js = 'var a=["@wordpress/a11y"];const CORE=["@wordpress/block-editor", "@wordpress/dataviews",' . "\n" . '"@wordpress/data"];x(["@wordpress/hooks","@wordpress/i18n"]);'; + + $this->assertSame( + array( '@wordpress/block-editor', '@wordpress/data', '@wordpress/dataviews' ), + Package_Provenance_Predictor::parse_allowlist( $js ) + ); + } + + /** + * Single quotes and trailing commas parse too. + */ + public function test_parse_allowlist_accepts_single_quotes() { + $js = "const CORE_MODULES_USING_PRIVATE_APIS = [\n\t'@wordpress/views',\n\t'@wordpress/theme',\n];"; + + $this->assertSame( array( '@wordpress/theme', '@wordpress/views' ), Package_Provenance_Predictor::parse_allowlist( $js ) ); + } + + /** + * No array means an empty allowlist. + */ + public function test_parse_allowlist_returns_empty_without_array() { + $this->assertSame( array(), Package_Provenance_Predictor::parse_allowlist( 'console.log("@wordpress/dataviews")' ) ); + } + + /** + * Opt-ins are found in the plain, the minified, and the hoisted-consent forms. + */ + public function test_parse_optins_matches_every_call_form() { + $js = "__dangerousOptInToUnstableAPIsOnlyForCoreModules( 'I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.', '@wordpress/fields' );" + . '(0,e.__dangerousOptInToUnstableAPIsOnlyForCoreModules)("I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.","@wordpress/dataviews");' + . '(0,t.__dangerousOptInToUnstableAPIsOnlyForCoreModules)(o,"@wordpress/views");' + . '(0,t.__dangerousOptInToUnstableAPIsOnlyForCoreModules)(o,"@wordpress/views");'; + + $this->assertSame( + array( '@wordpress/dataviews', '@wordpress/fields', '@wordpress/views' ), + Package_Provenance_Predictor::parse_optins( $js ) + ); + } + + /** + * Development builds wrap modules in eval( "…" ), escaping every quote. + */ + public function test_parse_optins_reads_escaped_quotes() { + $js = 'eval("var{lock,unlock}=__dangerousOptInToUnstableAPIsOnlyForCoreModules(\\"I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.\\",\\"@wordpress/dataviews\\");")'; + + $this->assertSame( array( '@wordpress/dataviews' ), Package_Provenance_Predictor::parse_optins( $js ) ); + } + + /** + * A file without the call yields nothing. + */ + public function test_parse_optins_returns_empty_without_call() { + $this->assertSame( array(), Package_Provenance_Predictor::parse_optins( 'unlock(privateApis)' ) ); + } + + /** + * WP 7.0.x without Gutenberg: the polyfill forces private-apis and rich-text and fills the gaps. + */ + public function test_predict_polyfill_wins_forced_and_missing_packages() { + $prediction = Package_Provenance_Predictor::predict( + array( + 'wp' => '7.0.4', + 'gutenberg' => null, + 'modes' => self::MODES_FORCED, + 'core' => $this->core( array( '@wordpress/rich-text', '@wordpress/theme' ) ), + 'gutenberg_inventory' => null, + 'polyfill' => $this->polyfill(), + 'optins' => array( '@wordpress/dataviews' => array( 'jetpack/_inc/build/forms.js' ) ), + ) + ); + + $this->assertSame( + array( + 'wp-notices' => 'core', + 'wp-private-apis' => 'polyfill', + 'wp-rich-text' => 'polyfill', + 'wp-theme' => 'core', + 'wp-views' => 'polyfill', + '@wordpress/boot' => 'core', + '@wordpress/route' => 'core', + '@wordpress/a11y' => 'core', + '@wordpress/widget-primitives' => 'polyfill', + ), + $this->providers( $prediction ) + ); + $this->assertSame( 'polyfill', $prediction['private_apis']['provider'] ); + $this->assertSame( 6, $prediction['private_apis']['allowlist_size'] ); + $this->assertSame( array(), $prediction['private_apis']['rejected'] ); + } + + /** + * An active Gutenberg that ships the package beats core and the polyfill. + */ + public function test_predict_gutenberg_overrides_core() { + $prediction = Package_Provenance_Predictor::predict( + array( + 'wp' => '7.0.4', + 'gutenberg' => '23.8.0', + 'modes' => self::MODES_FALLBACK, + 'core' => $this->core( array( '@wordpress/rich-text', '@wordpress/theme' ) ), + 'gutenberg_inventory' => $this->gutenberg( array( '@wordpress/dataviews', '@wordpress/rich-text', '@wordpress/theme', '@wordpress/views' ) ), + 'polyfill' => $this->polyfill(), + 'optins' => array( '@wordpress/dataviews' => array( 'jetpack/_inc/build/forms.js' ) ), + ) + ); + + $providers = $this->providers( $prediction ); + $this->assertSame( 'gutenberg', $providers['wp-private-apis'] ); + $this->assertSame( 'gutenberg', $providers['wp-notices'] ); + $this->assertSame( 'gutenberg', $providers['@wordpress/widget-primitives'] ); + $this->assertSame( 'polyfill', $providers['wp-views'] ); + $this->assertSame( 'gutenberg', $prediction['private_apis']['provider'] ); + $this->assertSame( array(), $prediction['private_apis']['rejected'] ); + } + + /** + * A winning private-apis whose allowlist dropped a bundled package rejects that bundle's opt-in. + */ + public function test_predict_rejects_optins_missing_from_winning_allowlist() { + $files = array( 'jetpack/_inc/build/forms.js', 'jetpack/jetpack_vendor/automattic/jetpack-premium-analytics/build/index.js' ); + $prediction = Package_Provenance_Predictor::predict( + array( + 'wp' => '7.1', + 'gutenberg' => '23.9.0', + 'modes' => self::MODES_FALLBACK, + 'core' => $this->core( array( '@wordpress/dataviews', '@wordpress/rich-text', '@wordpress/theme', '@wordpress/views' ) ), + 'gutenberg_inventory' => $this->gutenberg( array( '@wordpress/rich-text', '@wordpress/theme', '@wordpress/views' ) ), + 'polyfill' => $this->polyfill(), + 'optins' => array( + '@wordpress/dataviews' => $files, + '@wordpress/fields' => array( 'jetpack/_inc/build/forms.js' ), + ), + ) + ); + + $this->assertSame( 'gutenberg', $prediction['private_apis']['provider'] ); + $this->assertSame( + array( + '@wordpress/dataviews' => $files, + '@wordpress/fields' => array( 'jetpack/_inc/build/forms.js' ), + ), + $prediction['private_apis']['rejected'] + ); + } + + /** + * A polyfilled script that opts in with its own name is checked against the winning allowlist too. + */ + public function test_predict_checks_polyfilled_self_optins() { + $prediction = Package_Provenance_Predictor::predict( + array( + 'wp' => '7.1', + 'gutenberg' => null, + 'modes' => self::MODES_FALLBACK, + 'core' => $this->core( array( '@wordpress/dataviews', '@wordpress/rich-text', '@wordpress/theme' ) ), + 'gutenberg_inventory' => null, + 'polyfill' => $this->polyfill(), + 'optins' => array(), + ) + ); + + $this->assertSame( 'core', $prediction['private_apis']['provider'] ); + $this->assertSame( 'polyfill', $this->providers( $prediction )['wp-views'] ); + $this->assertSame( array( '@wordpress/views' => array( 'wp-build-polyfills:wp-views' ) ), $prediction['private_apis']['rejected'] ); + } + + /** + * A package nobody ships is reported as missing, not silently dropped. + */ + public function test_predict_reports_missing_packages() { + $polyfill = $this->polyfill(); + $polyfill['scripts'] = array( 'wp-private-apis' ); + $prediction = Package_Provenance_Predictor::predict( + array( + 'wp' => '7.1', + 'gutenberg' => null, + 'modes' => array( 'wp-views' => 'fallback' ), + 'core' => $this->core( array() ), + 'gutenberg_inventory' => null, + 'polyfill' => $polyfill, + 'optins' => array(), + ) + ); + + $this->assertSame( array( 'wp-views' => 'missing' ), $this->providers( $prediction ) ); + $this->assertSame( 'missing', $prediction['private_apis']['provider'] ); + $this->assertSame( 0, $prediction['private_apis']['allowlist_size'] ); + } +} diff --git a/projects/plugins/debug-helper/tests/php/bootstrap.php b/projects/plugins/debug-helper/tests/php/bootstrap.php new file mode 100644 index 000000000000..0fe59f6010c3 --- /dev/null +++ b/projects/plugins/debug-helper/tests/php/bootstrap.php @@ -0,0 +1,12 @@ + Date: Thu, 27 Aug 2026 08:51:02 +0100 Subject: [PATCH 3/3] add --polyfills to provenance predict evaluate another checkout's wp-build-polyfills build; print rejections in order with a summary --- projects/plugins/debug-helper/README.md | 8 ++++++++ .../inc/class-package-provenance-cli.php | 14 ++++++++++++-- .../inc/class-package-provenance-sources.php | 18 +++++++++++++----- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/projects/plugins/debug-helper/README.md b/projects/plugins/debug-helper/README.md index 979ca8c4576b..18bb72f20c5e 100644 --- a/projects/plugins/debug-helper/README.md +++ b/projects/plugins/debug-helper/README.md @@ -50,3 +50,11 @@ wp jetpack-debug provenance predict --wp=7.1 --gutenberg=/tmp/gutenberg.zip --fo ``` `--wp` takes release versions or `trunk` (read from the WordPress/WordPress mirror on GitHub; the running version is read from disk). `--gutenberg` takes `off`, `active`, a release version (downloaded from GitHub Releases), or a path to a plugin zip or directory. Downloads are cached in the system temp directory; `--refresh` bypasses the cache. The command exits with status 1 when any cell rejects an opt-in, so it can gate a script. + +To evaluate another checkout (a branch that bumps the bundled `@wordpress/*` packages, say) without switching sites, point `--polyfills` at that checkout's `projects/packages/wp-build-polyfills` and `--plugins` at its built plugins. In the monorepo Docker environment, mount the other worktree through `tools/docker/jetpack-docker-config.yml`: + +``` +wp jetpack-debug provenance predict --wp=7.0.4,7.1 --gutenberg=off,23.8.0 \ + --polyfills=/usr/local/src/other-worktree/projects/packages/wp-build-polyfills \ + --plugins=/usr/local/src/other-worktree/projects/plugins/jetpack,/usr/local/src/other-worktree/projects/plugins/premium-analytics +``` diff --git a/projects/plugins/debug-helper/modules/inc/class-package-provenance-cli.php b/projects/plugins/debug-helper/modules/inc/class-package-provenance-cli.php index 6c1e656f55d8..b666a1bf6d84 100644 --- a/projects/plugins/debug-helper/modules/inc/class-package-provenance-cli.php +++ b/projects/plugins/debug-helper/modules/inc/class-package-provenance-cli.php @@ -31,6 +31,9 @@ class Package_Provenance_CLI { * [--plugins=] * : Comma-separated directories to scan for bundles. Defaults to the active plugins and mu-plugins. * + * [--polyfills=] + * : wp-build-polyfills package directory to evaluate instead of the copy this site loads (its `build/` must exist). + * * [--refresh] * : Ignore cached downloads. * @@ -49,6 +52,7 @@ class Package_Provenance_CLI { * * wp jetpack-debug provenance predict --wp=7.0.4,7.1 --gutenberg=off,23.8.0 * wp jetpack-debug provenance predict --wp=7.1 --gutenberg=/tmp/gutenberg.zip --format=json + * wp jetpack-debug provenance predict --polyfills=/src/branch/projects/packages/wp-build-polyfills --plugins=/src/branch/projects/plugins/jetpack * * @subcommand predict * @@ -77,7 +81,7 @@ private function run( $assoc_args ) { $gb_specs = $this->split( $assoc_args['gutenberg'] ?? 'off' ); $dirs = isset( $assoc_args['plugins'] ) ? $this->split( $assoc_args['plugins'] ) : $sources->default_bundle_dirs(); - $polyfill = $sources->polyfill_inventory(); + $polyfill = $sources->polyfill_inventory( (string) ( $assoc_args['polyfills'] ?? '' ) ); $optins = $sources->optins( $dirs, $polyfill['root'] ); $cells = array(); @@ -117,11 +121,17 @@ private function run( $assoc_args ) { WP_CLI::log( sprintf( '== WP %s · Gutenberg %s ==', $cell['wp'], $cell['gutenberg'] ?? 'inactive' ) ); WP_CLI\Utils\format_items( 'table', $cell['rows'], array( 'package', 'type', 'provider', 'reason' ) ); WP_CLI::log( sprintf( 'private-apis served by %s (allowlist: %d modules)', $cell['private_apis']['provider'], $cell['private_apis']['allowlist_size'] ) ); + // Plain log lines, not warnings: STDERR interleaves out of order when piped. foreach ( $cell['private_apis']['rejected'] as $module => $files ) { ++$rejections; - WP_CLI::warning( sprintf( 'opt-in rejected: %s (%s)', $module, implode( ', ', $files ) ) ); + $shown = array_slice( $files, 0, 5 ); + $more = count( $files ) - count( $shown ); + WP_CLI::log( sprintf( 'REJECTED %s: %s%s', $module, implode( ', ', $shown ), $more > 0 ? sprintf( ' (+%d more)', $more ) : '' ) ); } } + if ( $rejections > 0 ) { + WP_CLI::warning( sprintf( '%d rejected opt-in(s); those bundles throw at load time in the cells listed above.', $rejections ) ); + } } else { foreach ( $cells as $cell ) { $rejections += count( $cell['private_apis']['rejected'] ); diff --git a/projects/plugins/debug-helper/modules/inc/class-package-provenance-sources.php b/projects/plugins/debug-helper/modules/inc/class-package-provenance-sources.php index 34abacd0a960..a1885c8def68 100644 --- a/projects/plugins/debug-helper/modules/inc/class-package-provenance-sources.php +++ b/projects/plugins/debug-helper/modules/inc/class-package-provenance-sources.php @@ -132,12 +132,17 @@ public function gutenberg_inventory( $spec ) { } /** - * Inventory of the wp-build-polyfills build loaded on this site. + * Inventory of a wp-build-polyfills build. * + * Defaults to the copy loaded on this site. Pass another package directory to + * evaluate a different build (a branch checkout, for example); the force rules + * still come from the loaded class. + * + * @param string $root Package directory holding `build/`, or '' for the loaded copy. * @return array Inventory plus `optins` (module names each build opts in with, by handle), `root` and `version`. - * @throws RuntimeException When no plugin ships the package. + * @throws RuntimeException When no plugin ships the package, or the directory has no build. */ - public function polyfill_inventory() { + public function polyfill_inventory( $root = '' ) { $class = '\\Automattic\\Jetpack\\WP_Build_Polyfills\\WP_Build_Polyfills'; if ( ! class_exists( $class ) ) { throw new RuntimeException( 'No active plugin ships automattic/jetpack-wp-build-polyfills.' ); @@ -146,9 +151,12 @@ public function polyfill_inventory() { throw new RuntimeException( 'The loaded wp-build-polyfills is too old: it has no predict_registration().' ); } - $root = dirname( ( new ReflectionClass( $class ) )->getFileName(), 2 ); + $root = '' === $root ? dirname( ( new ReflectionClass( $class ) )->getFileName(), 2 ) : rtrim( $root, '/' ); $build = $root . '/build'; - $apis = is_file( $build . '/scripts/private-apis/index.js' ) ? $build . '/scripts/private-apis/index.js' : $build . '/scripts/private-apis/index.min.js'; + if ( ! is_dir( $build . '/scripts' ) ) { + throw new RuntimeException( 'No wp-build-polyfills build under ' . $root . '; run its build first.' ); + } + $apis = is_file( $build . '/scripts/private-apis/index.js' ) ? $build . '/scripts/private-apis/index.js' : $build . '/scripts/private-apis/index.min.js'; // Released copies carry the version in composer.json; monorepo checkouts only in package.json. $version = '';