diff --git a/docs/checks.md b/docs/checks.md
index 6ab4a3a4a..acb211f3d 100644
--- a/docs/checks.md
+++ b/docs/checks.md
@@ -9,7 +9,7 @@
| plugin_content | plugin_repo | Detects content that does not comply with the WordPress.org plugin guidelines. | [Learn more](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/) |
| direct_file_access | security, plugin_repo | Checks that plugin files include proper security validation using the ABSPATH constant to prevent direct file access. | [Learn more](https://developer.wordpress.org/plugins/plugin-basics/best-practices/#file-security) |
| file_type | plugin_repo | Detects the usage of hidden and compressed files, VCS directories, application files, badly named files, AI development directories (.cursor, .claude, .aider, .continue, .windsurf, .ai, .github), and unexpected markdown files in plugin root. | [Learn more](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/) |
-| plugin_header_fields | plugin_repo | Checks adherence to the Headers requirements, including validation of "Tested up to" header matching between plugin file and readme.txt. | [Learn more](https://developer.wordpress.org/plugins/plugin-basics/header-requirements/) |
+| plugin_header_fields | plugin_repo | Checks adherence to the Headers requirements. | [Learn more](https://developer.wordpress.org/plugins/plugin-basics/header-requirements/) |
| late_escaping | security, plugin_repo | Checks that all output is escaped before being sent to the browser. | [Learn more](https://developer.wordpress.org/apis/security/escaping/) |
| safe_redirect | security, plugin_repo | Checks that redirects use wp_safe_redirect() instead of wp_redirect() for security. | [Learn more](https://developer.wordpress.org/reference/functions/wp_safe_redirect/) |
| plugin_updater | plugin_repo | Prevents altering WordPress update routines or using custom updaters, which are not allowed on WordPress.org. | [Learn more](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/) |
diff --git a/includes/Checker/Checks/Plugin_Repo/Plugin_Readme_Check.php b/includes/Checker/Checks/Plugin_Repo/Plugin_Readme_Check.php
index 395234a46..a8a8d45d8 100644
--- a/includes/Checker/Checks/Plugin_Repo/Plugin_Readme_Check.php
+++ b/includes/Checker/Checks/Plugin_Repo/Plugin_Readme_Check.php
@@ -126,8 +126,8 @@ protected function check_files( Check_Result $result, array $files ) {
// Check the readme for language.
$this->check_language( $result, $readme_file, $parser );
- // Check for mismatched "Tested up to" header between plugin header and readme.
- $this->check_tested_up_to_mismatch( $result, $parser, $result->plugin()->main_file() );
+ // Check for a "Tested up to" header declared in the main plugin file.
+ $this->check_tested_up_to_in_header( $result, $parser, $result->plugin()->main_file() );
}
/**
@@ -980,7 +980,12 @@ private function check_language( Check_Result $result, string $readme_file, $par
/**
- * Checks for mismatched "Tested up to" header between plugin header and readme.
+ * Checks for a "Tested up to" header declared in the main plugin file.
+ *
+ * The "Tested up to" field is readme metadata and must be declared
+ * exclusively in the readme file. This reports an error whenever
+ * the main plugin file also declares it, whether the readme value is
+ * missing, equal, or different.
*
* @since 1.8.0
*
@@ -988,20 +993,13 @@ private function check_language( Check_Result $result, string $readme_file, $par
* @param DotorgParser|PCPParser $parser The Parser object.
* @param string $plugin_main_file The main plugin file path.
*/
- private function check_tested_up_to_mismatch( Check_Result $result, $parser, string $plugin_main_file ) {
+ private function check_tested_up_to_in_header( Check_Result $result, $parser, string $plugin_main_file ) {
// Check if single file plugin, then bail early.
if ( $result->plugin()->is_single_file_plugin() ) {
return;
}
- // Get the "Tested up to" value from the readme.
- $readme_tested = isset( $parser->tested ) ? $parser->tested : '';
-
- if ( empty( $readme_tested ) ) {
- return;
- }
-
// Get the "Tested up to" value from the plugin header.
$tested_header = get_file_data(
$plugin_main_file,
@@ -1014,28 +1012,42 @@ private function check_tested_up_to_mismatch( Check_Result $result, $parser, str
return;
}
- // Normalize versions by removing any suffixes (like -RC1, -beta1).
- $readme_tested_normalized = strtok( $readme_tested, '-' );
- $plugin_tested_normalized = strtok( $plugin_tested, '-' );
+ // Get the "Tested up to" value from the readme, if available.
+ $readme_tested = isset( $parser->tested ) ? $parser->tested : '';
- // Compare the two values.
- if ( $readme_tested_normalized !== $plugin_tested_normalized ) {
- $this->add_result_error_for_file(
- $result,
- sprintf(
- /* translators: 1: Tested up to value from readme, 2: Tested up to value from plugin header */
- __( 'Mismatched "Tested up to": %1$s != %2$s.
The "Tested up to" value in the readme file must match the "Tested up to" value in the plugin header. If the plugin header has a "Tested up to" value, it will override the readme value, which can cause confusion.', 'plugin-check' ),
- esc_html( $readme_tested_normalized ),
+ if ( ! empty( $readme_tested ) ) {
+ // Normalize versions by removing any suffixes (like -RC1, -beta1).
+ $readme_tested_normalized = strtok( $readme_tested, '-' );
+ $plugin_tested_normalized = strtok( $plugin_tested, '-' );
+
+ if ( $readme_tested_normalized !== $plugin_tested_normalized ) {
+ $message = sprintf(
+ /* translators: 1: Tested up to value from plugin header, 2: Tested up to value from readme */
+ __( 'The "Tested up to" field must be declared only in the readme file: %1$s != %2$s.
Remove the "Tested up to" line from the main plugin PHP header and keep it only in the readme file.', 'plugin-check' ),
+ esc_html( $plugin_tested_normalized ),
+ esc_html( $readme_tested_normalized )
+ );
+ } else {
+ $message = sprintf(
+ /* translators: %s: Tested up to value */
+ __( 'The "Tested up to" field must be declared only in the readme file (currently: %s).
Keeping the value in both places can cause them to silently get out of sync in a future release. Remove the "Tested up to" line from the main plugin PHP header and keep it only in the readme file.', 'plugin-check' ),
esc_html( $plugin_tested_normalized )
- ),
- 'mismatched_tested_up_to_header',
- $plugin_main_file,
- 0,
- 0,
- 'https://developer.wordpress.org/plugins/wordpress-org/how-your-readme-txt-works/#readme-header-information',
- 7
- );
+ );
+ }
+ } else {
+ $message = __( 'The "Tested up to" field must be declared only in the readme file.
Remove the "Tested up to" line from the main plugin PHP header and keep it only in the readme file.', 'plugin-check' );
}
+
+ $this->add_result_error_for_file(
+ $result,
+ $message,
+ 'plugin_header_tested_up_to_not_allowed',
+ $plugin_main_file,
+ 0,
+ 0,
+ 'https://developer.wordpress.org/plugins/wordpress-org/how-your-readme-txt-works/#readme-header-information',
+ 7
+ );
}
/**
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 b9ab7acbe..80ce14dae 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
@@ -244,7 +244,7 @@ public function test_run_with_mismatched_tested_up_to() {
// The "Tested up to" mismatch check has been moved to Plugin_Readme_Check.
// This test now verifies that Plugin_Header_Fields_Check does NOT report this error.
- $error_items = wp_list_filter( $errors['load.php'][0][0] ?? array(), array( 'code' => 'mismatched_tested_up_to_header' ) );
+ $error_items = wp_list_filter( $errors['load.php'][0][0] ?? array(), array( 'code' => 'plugin_header_tested_up_to_not_allowed' ) );
$this->assertCount( 0, $error_items );
}
@@ -258,7 +258,7 @@ public function test_run_with_matching_tested_up_to() {
$errors = $check_result->get_errors();
// Should not have mismatched tested up to error.
- $error_items = wp_list_filter( $errors['load.php'][0][0] ?? array(), array( 'code' => 'mismatched_tested_up_to_header' ) );
+ $error_items = wp_list_filter( $errors['load.php'][0][0] ?? array(), array( 'code' => 'plugin_header_tested_up_to_not_allowed' ) );
$this->assertCount( 0, $error_items );
}
diff --git a/tests/phpunit/tests/Checker/Checks/Plugin_Readme_Check_Tests.php b/tests/phpunit/tests/Checker/Checks/Plugin_Readme_Check_Tests.php
index bc7ffda8b..02800174f 100644
--- a/tests/phpunit/tests/Checker/Checks/Plugin_Readme_Check_Tests.php
+++ b/tests/phpunit/tests/Checker/Checks/Plugin_Readme_Check_Tests.php
@@ -767,11 +767,11 @@ public function test_run_with_mismatch() {
$this->assertNotEmpty( $errors );
$this->assertArrayHasKey( 'load.php', $errors );
- // Check for mismatched "Tested up to" error.
- $this->assertCount( 1, wp_list_filter( $errors['load.php'][0][0], array( 'code' => 'mismatched_tested_up_to_header' ) ) );
+ // Check for "Tested up to" declared in the plugin header error.
+ $this->assertCount( 1, wp_list_filter( $errors['load.php'][0][0], array( 'code' => 'plugin_header_tested_up_to_not_allowed' ) ) );
// Verify the error message contains the correct versions.
- $error_items = wp_list_filter( $errors['load.php'][0][0], array( 'code' => 'mismatched_tested_up_to_header' ) );
+ $error_items = wp_list_filter( $errors['load.php'][0][0], array( 'code' => 'plugin_header_tested_up_to_not_allowed' ) );
$error_message = reset( $error_items )['message'];
$this->assertStringContainsString( '6.7', $error_message );
$this->assertStringContainsString( '6.5', $error_message );
@@ -786,18 +786,15 @@ public function test_run_with_match() {
$errors = $check_result->get_errors();
- // Should not have mismatched tested up to error when values match.
- // Note: Other readme errors may still be present.
- if ( ! empty( $errors ) ) {
- foreach ( $errors as $file => $file_errors ) {
- if ( isset( $file_errors[0][0] ) ) {
- $this->assertCount( 0, wp_list_filter( $file_errors[0][0], array( 'code' => 'mismatched_tested_up_to_header' ) ) );
- }
- }
- }
+ $this->assertNotEmpty( $errors );
+ $this->assertArrayHasKey( 'load.php', $errors );
- // Explicitly assert that we checked for the error code.
- $this->assertTrue( true );
+ // Even when the values match, declaring "Tested up to" in the header must be reported.
+ $this->assertCount( 1, wp_list_filter( $errors['load.php'][0][0], array( 'code' => 'plugin_header_tested_up_to_not_allowed' ) ) );
+
+ $error_items = wp_list_filter( $errors['load.php'][0][0], array( 'code' => 'plugin_header_tested_up_to_not_allowed' ) );
+ $error_message = reset( $error_items )['message'];
+ $this->assertStringContainsString( '6.7', $error_message );
}
public function test_run_with_readme_only() {
@@ -809,12 +806,12 @@ public function test_run_with_readme_only() {
$errors = $check_result->get_errors();
- // Should not have mismatched tested up to error when only readme has the value.
+ // Should not report an error when "Tested up to" is only declared in the readme.
// Note: Other readme errors may still be present.
if ( ! empty( $errors ) ) {
foreach ( $errors as $file => $file_errors ) {
if ( isset( $file_errors[0][0] ) ) {
- $this->assertCount( 0, wp_list_filter( $file_errors[0][0], array( 'code' => 'mismatched_tested_up_to_header' ) ) );
+ $this->assertCount( 0, wp_list_filter( $file_errors[0][0], array( 'code' => 'plugin_header_tested_up_to_not_allowed' ) ) );
}
}
}
@@ -832,18 +829,11 @@ public function test_run_with_header_only() {
$errors = $check_result->get_errors();
- // Should not have mismatched tested up to error when only header has the value.
- // Note: Other readme errors may still be present.
- if ( ! empty( $errors ) ) {
- foreach ( $errors as $file => $file_errors ) {
- if ( isset( $file_errors[0][0] ) ) {
- $this->assertCount( 0, wp_list_filter( $file_errors[0][0], array( 'code' => 'mismatched_tested_up_to_header' ) ) );
- }
- }
- }
+ $this->assertNotEmpty( $errors );
+ $this->assertArrayHasKey( 'load.php', $errors );
- // Explicitly assert that we checked for the error code.
- $this->assertTrue( true );
+ // "Tested up to" declared only in the header must still be reported.
+ $this->assertCount( 1, wp_list_filter( $errors['load.php'][0][0], array( 'code' => 'plugin_header_tested_up_to_not_allowed' ) ) );
}
public function test_run_with_single_file_plugin() {
@@ -855,12 +845,12 @@ public function test_run_with_single_file_plugin() {
$errors = $check_result->get_errors();
- // Should not have mismatched tested up to errors for single-file plugins.
+ // Should not report "Tested up to" header errors for single-file plugins.
// Note: Other header field errors may still be present.
if ( ! empty( $errors ) ) {
foreach ( $errors as $file => $file_errors ) {
if ( isset( $file_errors[0][0] ) ) {
- $this->assertCount( 0, wp_list_filter( $file_errors[0][0], array( 'code' => 'mismatched_tested_up_to_header' ) ) );
+ $this->assertCount( 0, wp_list_filter( $file_errors[0][0], array( 'code' => 'plugin_header_tested_up_to_not_allowed' ) ) );
}
}
}
@@ -879,6 +869,6 @@ public function test_run_with_no_readme() {
$errors = $check_result->get_errors();
// Should not have tested up to errors when readme doesn't exist.
- $this->assertEmpty( wp_list_filter( $errors, array( 'code' => 'mismatched_tested_up_to_header' ) ) );
+ $this->assertEmpty( wp_list_filter( $errors, array( 'code' => 'plugin_header_tested_up_to_not_allowed' ) ) );
}
}