From b26e1b795db816d9355773ba05f2b503c6059ceb Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Sat, 11 Jul 2026 11:02:52 +0600 Subject: [PATCH 1/2] fix(checks): validate each Requires Plugins dependency individually - Report a separate error per malformed slug in the Requires Plugins header, naming the offending slug, instead of one generic message for the whole comma-separated value - Add a warning per declared dependency that is not installed or not active in the environment the check runs in This gives plugin authors clear, per-dependency feedback when they declare more than one plugin dependency, instead of a single vague error covering the entire header. Fixes #1385 --- .../Plugin_Header_Fields_Check.php | 142 ++++++++++++++++-- .../load.php | 17 +++ .../Plugin_Header_Fields_Check_Tests.php | 80 +++++++++- 3 files changed, 222 insertions(+), 17 deletions(-) create mode 100644 tests/phpunit/testdata/plugins/test-plugin-requires-plugins-status/load.php diff --git a/includes/Checker/Checks/Plugin_Repo/Plugin_Header_Fields_Check.php b/includes/Checker/Checks/Plugin_Repo/Plugin_Header_Fields_Check.php index b6900ca07..0674c9a71 100644 --- a/includes/Checker/Checks/Plugin_Repo/Plugin_Header_Fields_Check.php +++ b/includes/Checker/Checks/Plugin_Repo/Plugin_Header_Fields_Check.php @@ -372,22 +372,7 @@ public function run( Check_Result $result ) { } if ( ! empty( $plugin_header['RequiresPlugins'] ) ) { - if ( ! preg_match( '/^[a-z0-9-]+(?:,\s*[a-z0-9-]+)*$/', $plugin_header['RequiresPlugins'] ) ) { - $this->add_result_error_for_file( - $result, - sprintf( - /* translators: %s: plugin header field */ - __( 'The "%s" header in the plugin file must contain a comma-separated list of WordPress.org-formatted slugs.', 'plugin-check' ), - esc_html( $labels['RequiresPlugins'] ) - ), - 'plugin_header_invalid_requires_plugins', - $plugin_main_file, - 0, - 0, - '', - 7 - ); - } + $this->check_requires_plugins_header( $result, $plugin_header['RequiresPlugins'], $labels['RequiresPlugins'], $plugin_main_file ); } if ( empty( $plugin_header['License'] ) ) { @@ -536,6 +521,131 @@ public function run( Check_Result $result ) { } } + /** + * Validates each slug declared in the "Requires Plugins" header individually. + * + * Reports an error per malformed slug, and a warning per validly formatted + * slug that is not installed or not active in the environment the check + * is running in. + * + * @since 2.1.0 + * + * @param Check_Result $result The check result to amend. + * @param string $requires_plugins Raw value of the "Requires Plugins" header. + * @param string $label Label of the "Requires Plugins" header. + * @param string $plugin_main_file Absolute path to the main plugin file. + */ + private function check_requires_plugins_header( Check_Result $result, string $requires_plugins, string $label, string $plugin_main_file ) { + $slugs = array_map( 'trim', explode( ',', $requires_plugins ) ); + + foreach ( $slugs as $slug ) { + if ( '' === $slug ) { + continue; + } + + if ( ! preg_match( '/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug ) ) { + $this->add_result_error_for_file( + $result, + sprintf( + /* translators: 1: plugin header field, 2: dependency slug */ + __( 'The "%1$s" header in the plugin file contains "%2$s", which is not a valid WordPress.org-formatted slug.', 'plugin-check' ), + esc_html( $label ), + esc_html( $slug ) + ), + 'plugin_header_invalid_requires_plugins', + $plugin_main_file, + 0, + 0, + '', + 7 + ); + continue; + } + + $this->check_requires_plugins_slug_status( $result, $slug, $plugin_main_file ); + } + } + + /** + * Checks whether a single declared plugin dependency is installed and active. + * + * @since 2.1.0 + * + * @param Check_Result $result The check result to amend. + * @param string $slug The dependency's WordPress.org slug. + * @param string $plugin_main_file Absolute path to the main plugin file. + */ + private function check_requires_plugins_slug_status( Check_Result $result, string $slug, string $plugin_main_file ) { + $dependency_filepath = $this->get_installed_plugin_file_by_slug( $slug ); + + if ( false === $dependency_filepath ) { + $this->add_result_warning_for_file( + $result, + sprintf( + /* translators: %s: dependency slug */ + __( 'The plugin declares "%s" as a required plugin, but it is not installed in this environment.', 'plugin-check' ), + esc_html( $slug ) + ), + 'plugin_header_requires_plugins_not_installed', + $plugin_main_file, + 0, + 0, + '', + 3 + ); + return; + } + + if ( is_plugin_inactive( $dependency_filepath ) ) { + $this->add_result_warning_for_file( + $result, + sprintf( + /* translators: %s: dependency slug */ + __( 'The plugin declares "%s" as a required plugin, but it is not active in this environment.', 'plugin-check' ), + esc_html( $slug ) + ), + 'plugin_header_requires_plugins_not_active', + $plugin_main_file, + 0, + 0, + '', + 3 + ); + } + } + + /** + * Finds the installed plugin file matching a WordPress.org slug. + * + * Mirrors the slug derivation used by WP_Plugin_Dependencies::convert_to_slug(), + * but looks up any installed plugin rather than only those already registered + * as a dependency of another plugin. + * + * @since 2.1.0 + * + * @param string $slug The plugin's WordPress.org slug. + * @return string|false The plugin file relative to the plugins directory, or false if not installed. + */ + private function get_installed_plugin_file_by_slug( string $slug ) { + if ( ! function_exists( 'get_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + foreach ( array_keys( get_plugins() ) as $plugin_file ) { + $plugin_slug = str_contains( $plugin_file, '/' ) ? dirname( $plugin_file ) : str_replace( '.php', '', $plugin_file ); + + if ( 'hello.php' === $plugin_file ) { + $plugin_slug = 'hello-dolly'; + } + + if ( $plugin_slug === $slug ) { + return $plugin_file; + } + } + + return false; + } + /** * Parses the plugin contents to retrieve plugin's metadata. * diff --git a/tests/phpunit/testdata/plugins/test-plugin-requires-plugins-status/load.php b/tests/phpunit/testdata/plugins/test-plugin-requires-plugins-status/load.php new file mode 100644 index 000000000..54e3fa76c --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-requires-plugins-status/load.php @@ -0,0 +1,17 @@ +assertCount( 1, wp_list_filter( $warnings['load.php'][0][0], array( 'code' => 'plugin_header_nonexistent_domain_path' ) ) ); if ( is_wp_version_compatible( '6.5' ) ) { - $this->assertCount( 1, wp_list_filter( $errors['load.php'][0][0], array( 'code' => 'plugin_header_invalid_requires_plugins' ) ) ); + // Fixture declares "Example Plugin, OtherPlugin", neither of which is a valid slug. + $this->assertCount( 2, wp_list_filter( $errors['load.php'][0][0], array( 'code' => 'plugin_header_invalid_requires_plugins' ) ) ); } } @@ -105,6 +106,83 @@ public function test_run_with_valid_requires_plugins_header() { } } + public function test_run_with_multiple_invalid_requires_plugins_slugs_reported_individually() { + /* + * Test plugin has following header, with both slugs invalid. + * Requires Plugins: Example Plugin, OtherPlugin + */ + + $check = new Plugin_Header_Fields_Check(); + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-header-fields-with-errors/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check->run( $check_result ); + + $errors = $check_result->get_errors(); + + if ( ! is_wp_version_compatible( '6.5' ) ) { + $this->assertTrue( true ); + return; + } + + $messages = wp_list_pluck( wp_list_filter( $errors['load.php'][0][0], array( 'code' => 'plugin_header_invalid_requires_plugins' ) ), 'message' ); + + $this->assertCount( 2, $messages, 'Each invalid slug should be reported as its own error.' ); + + $example_plugin_messages = array_filter( + $messages, + static function ( $message ) { + return str_contains( $message, 'Example Plugin' ); + } + ); + $other_plugin_messages = array_filter( + $messages, + static function ( $message ) { + return str_contains( $message, 'OtherPlugin' ); + } + ); + + $this->assertCount( 1, $example_plugin_messages, 'Exactly one error should name "Example Plugin".' ); + $this->assertStringNotContainsString( 'OtherPlugin', reset( $example_plugin_messages ) ); + + $this->assertCount( 1, $other_plugin_messages, 'Exactly one error should name "OtherPlugin".' ); + $this->assertStringNotContainsString( 'Example Plugin', reset( $other_plugin_messages ) ); + } + + public function test_run_with_requires_plugins_dependency_status() { + /* + * Test plugin has following header. + * Requires Plugins: plugin-check, hello-dolly, not-installed-plugin + * + * "plugin-check" is installed and active, "hello-dolly" is installed + * but inactive, and "not-installed-plugin" is not installed at all. + */ + + $check = new Plugin_Header_Fields_Check(); + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-requires-plugins-status/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check->run( $check_result ); + + $errors = $check_result->get_errors(); + $warnings = $check_result->get_warnings(); + + if ( ! is_wp_version_compatible( '6.5' ) ) { + $this->assertTrue( true ); + return; + } + + $this->assertCount( 0, wp_list_filter( $errors['load.php'][0][0] ?? array(), array( 'code' => 'plugin_header_invalid_requires_plugins' ) ) ); + + $not_active_items = wp_list_filter( $warnings['load.php'][0][0], array( 'code' => 'plugin_header_requires_plugins_not_active' ) ); + $this->assertCount( 1, $not_active_items ); + $this->assertStringContainsString( 'hello-dolly', reset( $not_active_items )['message'] ); + + $not_installed_items = wp_list_filter( $warnings['load.php'][0][0], array( 'code' => 'plugin_header_requires_plugins_not_installed' ) ); + $this->assertCount( 1, $not_installed_items ); + $this->assertStringContainsString( 'not-installed-plugin', reset( $not_installed_items )['message'] ); + } + public function test_run_with_mismatched_tested_up_to() { $check = new Plugin_Header_Fields_Check(); $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-tested-up-to-mismatch/load.php' ); From 689a1a1462962d8063d740a4fad20468ed3f8922 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Sun, 12 Jul 2026 20:47:53 +0600 Subject: [PATCH 2/2] fix(checks): validate Requires Plugins slugs against .org directory Check no longer inspects local install/active state, per ernilambar's review feedback that this isn't the responsibility of a plugin_repo check. Each declared dependency slug is now looked up against the WordPress.org plugin directory API instead, warning only when the slug can't be found there (covers nonexistent and closed plugins). API calls are transient-cached and fail silently when unreachable. Refs #1391 --- .../Plugin_Header_Fields_Check.php | 90 ++++++++----------- .../Plugin_Header_Fields_Check_Tests.php | 29 ++++-- 2 files changed, 56 insertions(+), 63 deletions(-) diff --git a/includes/Checker/Checks/Plugin_Repo/Plugin_Header_Fields_Check.php b/includes/Checker/Checks/Plugin_Repo/Plugin_Header_Fields_Check.php index 0674c9a71..fae1568be 100644 --- a/includes/Checker/Checks/Plugin_Repo/Plugin_Header_Fields_Check.php +++ b/includes/Checker/Checks/Plugin_Repo/Plugin_Header_Fields_Check.php @@ -525,8 +525,7 @@ public function run( Check_Result $result ) { * Validates each slug declared in the "Requires Plugins" header individually. * * Reports an error per malformed slug, and a warning per validly formatted - * slug that is not installed or not active in the environment the check - * is running in. + * slug that could not be found in the WordPress.org plugin directory. * * @since 2.1.0 * @@ -567,7 +566,7 @@ private function check_requires_plugins_header( Check_Result $result, string $re } /** - * Checks whether a single declared plugin dependency is installed and active. + * Checks whether a single declared plugin dependency exists in the WordPress.org plugin directory. * * @since 2.1.0 * @@ -576,74 +575,57 @@ private function check_requires_plugins_header( Check_Result $result, string $re * @param string $plugin_main_file Absolute path to the main plugin file. */ private function check_requires_plugins_slug_status( Check_Result $result, string $slug, string $plugin_main_file ) { - $dependency_filepath = $this->get_installed_plugin_file_by_slug( $slug ); + $exists = $this->plugin_exists_in_directory( $slug ); - if ( false === $dependency_filepath ) { - $this->add_result_warning_for_file( - $result, - sprintf( - /* translators: %s: dependency slug */ - __( 'The plugin declares "%s" as a required plugin, but it is not installed in this environment.', 'plugin-check' ), - esc_html( $slug ) - ), - 'plugin_header_requires_plugins_not_installed', - $plugin_main_file, - 0, - 0, - '', - 3 - ); + // Unknown due to an unreachable API; skip rather than risk a false positive. + if ( null === $exists || $exists ) { return; } - if ( is_plugin_inactive( $dependency_filepath ) ) { - $this->add_result_warning_for_file( - $result, - sprintf( - /* translators: %s: dependency slug */ - __( 'The plugin declares "%s" as a required plugin, but it is not active in this environment.', 'plugin-check' ), - esc_html( $slug ) - ), - 'plugin_header_requires_plugins_not_active', - $plugin_main_file, - 0, - 0, - '', - 3 - ); - } + $this->add_result_warning_for_file( + $result, + sprintf( + /* translators: %s: dependency slug */ + __( 'The plugin declares "%s" as a required plugin, but it could not be found in the WordPress.org plugin directory.', 'plugin-check' ), + esc_html( $slug ) + ), + 'plugin_header_requires_plugins_not_in_directory', + $plugin_main_file, + 0, + 0, + '', + 3 + ); } /** - * Finds the installed plugin file matching a WordPress.org slug. - * - * Mirrors the slug derivation used by WP_Plugin_Dependencies::convert_to_slug(), - * but looks up any installed plugin rather than only those already registered - * as a dependency of another plugin. + * Checks whether a plugin slug exists in the WordPress.org plugin directory. * * @since 2.1.0 * * @param string $slug The plugin's WordPress.org slug. - * @return string|false The plugin file relative to the plugins directory, or false if not installed. + * @return bool|null True if found, false if not found, null if the API could not be reached. */ - private function get_installed_plugin_file_by_slug( string $slug ) { - if ( ! function_exists( 'get_plugins' ) ) { - require_once ABSPATH . 'wp-admin/includes/plugin.php'; - } + private function plugin_exists_in_directory( string $slug ) { + $transient_key = 'wp_plugin_check_requires_plugin_' . $slug; + $exists = get_transient( $transient_key ); - foreach ( array_keys( get_plugins() ) as $plugin_file ) { - $plugin_slug = str_contains( $plugin_file, '/' ) ? dirname( $plugin_file ) : str_replace( '.php', '', $plugin_file ); + if ( false !== $exists ) { + return 'yes' === $exists; + } - if ( 'hello.php' === $plugin_file ) { - $plugin_slug = 'hello-dolly'; - } + $response = wp_remote_get( "https://api.wordpress.org/plugins/info/1.0/{$slug}.json" ); - if ( $plugin_slug === $slug ) { - return $plugin_file; - } + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { + return null; } - return false; + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + $exists = isset( $body['slug'] ) && ! isset( $body['error'] ); + + set_transient( $transient_key, $exists ? 'yes' : 'no', DAY_IN_SECONDS ); + + return $exists; } /** diff --git a/tests/phpunit/tests/Checker/Checks/Plugin_Header_Fields_Check_Tests.php b/tests/phpunit/tests/Checker/Checks/Plugin_Header_Fields_Check_Tests.php index f01560780..0c3c5e5d2 100644 --- a/tests/phpunit/tests/Checker/Checks/Plugin_Header_Fields_Check_Tests.php +++ b/tests/phpunit/tests/Checker/Checks/Plugin_Header_Fields_Check_Tests.php @@ -90,6 +90,9 @@ public function test_run_with_valid_requires_plugins_header() { * Requires Plugins: woocommerce, contact-form-7 */ + set_transient( 'wp_plugin_check_requires_plugin_woocommerce', 'yes' ); + set_transient( 'wp_plugin_check_requires_plugin_contact-form-7', 'yes' ); + $check = new Plugin_Header_Fields_Check(); $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-unfiltered-uploads-with-errors/load.php' ); $check_result = new Check_Result( $check_context ); @@ -98,6 +101,9 @@ public function test_run_with_valid_requires_plugins_header() { $errors = $check_result->get_errors(); + delete_transient( 'wp_plugin_check_requires_plugin_woocommerce' ); + delete_transient( 'wp_plugin_check_requires_plugin_contact-form-7' ); + if ( is_wp_version_compatible( '6.5' ) ) { $this->assertCount( 0, wp_list_filter( $errors['load.php'][0][0], array( 'code' => 'plugin_header_invalid_requires_plugins' ) ) ); } else { @@ -154,10 +160,15 @@ public function test_run_with_requires_plugins_dependency_status() { * Test plugin has following header. * Requires Plugins: plugin-check, hello-dolly, not-installed-plugin * - * "plugin-check" is installed and active, "hello-dolly" is installed - * but inactive, and "not-installed-plugin" is not installed at all. + * "plugin-check" and "hello-dolly" are seeded as published in the + * WordPress.org plugin directory; "not-installed-plugin" is seeded + * as not found there. */ + set_transient( 'wp_plugin_check_requires_plugin_plugin-check', 'yes' ); + set_transient( 'wp_plugin_check_requires_plugin_hello-dolly', 'yes' ); + set_transient( 'wp_plugin_check_requires_plugin_not-installed-plugin', 'no' ); + $check = new Plugin_Header_Fields_Check(); $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-requires-plugins-status/load.php' ); $check_result = new Check_Result( $check_context ); @@ -167,6 +178,10 @@ public function test_run_with_requires_plugins_dependency_status() { $errors = $check_result->get_errors(); $warnings = $check_result->get_warnings(); + delete_transient( 'wp_plugin_check_requires_plugin_plugin-check' ); + delete_transient( 'wp_plugin_check_requires_plugin_hello-dolly' ); + delete_transient( 'wp_plugin_check_requires_plugin_not-installed-plugin' ); + if ( ! is_wp_version_compatible( '6.5' ) ) { $this->assertTrue( true ); return; @@ -174,13 +189,9 @@ public function test_run_with_requires_plugins_dependency_status() { $this->assertCount( 0, wp_list_filter( $errors['load.php'][0][0] ?? array(), array( 'code' => 'plugin_header_invalid_requires_plugins' ) ) ); - $not_active_items = wp_list_filter( $warnings['load.php'][0][0], array( 'code' => 'plugin_header_requires_plugins_not_active' ) ); - $this->assertCount( 1, $not_active_items ); - $this->assertStringContainsString( 'hello-dolly', reset( $not_active_items )['message'] ); - - $not_installed_items = wp_list_filter( $warnings['load.php'][0][0], array( 'code' => 'plugin_header_requires_plugins_not_installed' ) ); - $this->assertCount( 1, $not_installed_items ); - $this->assertStringContainsString( 'not-installed-plugin', reset( $not_installed_items )['message'] ); + $not_in_directory_items = wp_list_filter( $warnings['load.php'][0][0], array( 'code' => 'plugin_header_requires_plugins_not_in_directory' ) ); + $this->assertCount( 1, $not_in_directory_items, 'Only the dependency missing from the directory should get a warning.' ); + $this->assertStringContainsString( 'not-installed-plugin', reset( $not_in_directory_items )['message'] ); } public function test_run_with_mismatched_tested_up_to() {