From 5f2e0f6edb2b22803b29ad3df11cf9ffd6a94299 Mon Sep 17 00:00:00 2001 From: Shameem Reza Date: Wed, 17 Jun 2026 14:45:40 +0600 Subject: [PATCH 1/2] Fix compatibility false positive on guarded calls WP_Functions_Compatibility_Check tokenizes function calls without any control flow analysis, so a call guarded by function_exists() was still reported as incompatible with the plugin's minimum WordPress version. That guard is a documented backward compatibility pattern, so the call never runs on versions that lack the function. Collect function names guarded by a function_exists( 'name' ) literal and suppress compatibility findings for those names within the same file. This also covers the deferred hook case where the guard and the call live in different methods, which the reported example uses. Add a test fixture and case for the guarded pattern. Closes #1363 --- .../WP_Functions_Compatibility_Check.php | 64 ++++++++++++++++++- .../load.php | 19 ++++++ .../uses-guarded-function.php | 22 +++++++ ...WP_Functions_Compatibility_Check_Tests.php | 11 ++++ 4 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 tests/phpunit/testdata/plugins/test-plugin-wp-functions-compatibility-with-function-exists-guard/load.php create mode 100644 tests/phpunit/testdata/plugins/test-plugin-wp-functions-compatibility-with-function-exists-guard/uses-guarded-function.php diff --git a/includes/Checker/Checks/Plugin_Repo/WP_Functions_Compatibility_Check.php b/includes/Checker/Checks/Plugin_Repo/WP_Functions_Compatibility_Check.php index fda72fdee..d3f54dccb 100644 --- a/includes/Checker/Checks/Plugin_Repo/WP_Functions_Compatibility_Check.php +++ b/includes/Checker/Checks/Plugin_Repo/WP_Functions_Compatibility_Check.php @@ -252,8 +252,9 @@ private function find_wp_function_calls( string $file ): array { return array(); } - $tokens = token_get_all( $source ); - $calls = array(); + $tokens = token_get_all( $source ); + $guarded_functions = $this->get_function_exists_guarded_names( $tokens ); + $calls = array(); foreach ( $tokens as $index => $token ) { if ( ! is_array( $token ) || T_STRING !== $token[0] ) { @@ -269,8 +270,18 @@ private function find_wp_function_calls( string $file ): array { continue; } + $function_name = strtolower( $token[1] ); + + // Skip calls that are guarded by a function_exists() check for the same + // function anywhere in the file. This is a documented backward + // compatibility pattern, so the call is never reached on WordPress + // versions that lack the function. + if ( isset( $guarded_functions[ $function_name ] ) ) { + continue; + } + $calls[] = array( - 'name' => strtolower( $token[1] ), + 'name' => $function_name, 'line' => (int) $token[2], ); } @@ -278,6 +289,53 @@ private function find_wp_function_calls( string $file ): array { return $calls; } + /** + * Collects function names guarded by a function_exists() call within the file. + * + * Only string literal arguments are considered, since a dynamic argument cannot + * be resolved to a specific function name through tokenization. + * + * @since 2.0.0 + * + * @param array $tokens Token stream. + * @return array Map of guarded function names, keyed by lowercase name. + */ + private function get_function_exists_guarded_names( array $tokens ): array { + $guarded = array(); + + foreach ( $tokens as $index => $token ) { + if ( ! is_array( $token ) || T_STRING !== $token[0] || 'function_exists' !== strtolower( $token[1] ) ) { + continue; + } + + $open_index = $this->get_next_significant_token_index( $tokens, $index ); + if ( null === $open_index || '(' !== $tokens[ $open_index ] ) { + continue; + } + + if ( ! $this->is_global_function_call( $tokens, $index ) ) { + continue; + } + + $argument_index = $this->get_next_significant_token_index( $tokens, $open_index ); + if ( null === $argument_index ) { + continue; + } + + $argument_token = $tokens[ $argument_index ]; + if ( ! is_array( $argument_token ) || T_CONSTANT_ENCAPSED_STRING !== $argument_token[0] ) { + continue; + } + + $guarded_name = strtolower( ltrim( trim( $argument_token[1], '\'"' ), '\\' ) ); + if ( '' !== $guarded_name ) { + $guarded[ $guarded_name ] = true; + } + } + + return $guarded; + } + /** * Checks whether a tokenized T_STRING is a global function call. * diff --git a/tests/phpunit/testdata/plugins/test-plugin-wp-functions-compatibility-with-function-exists-guard/load.php b/tests/phpunit/testdata/plugins/test-plugin-wp-functions-compatibility-with-function-exists-guard/load.php new file mode 100644 index 000000000..89d0813c5 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-wp-functions-compatibility-with-function-exists-guard/load.php @@ -0,0 +1,19 @@ +assertEmpty( wp_list_filter( $errors['uses-compatible-function.php'][8][0] ?? array(), array( 'code' => 'wp_function_not_compatible_with_requires_wp' ) ) ); } + public function test_run_with_function_exists_guard_without_errors() { + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-wp-functions-compatibility-with-function-exists-guard/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check = new WP_Functions_Compatibility_Check(); + $check->run( $check_result ); + + $errors = $check_result->get_errors(); + $this->assertArrayNotHasKey( 'uses-guarded-function.php', $errors ); + } + public function test_run_with_php_serialize_without_errors() { $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-wp-functions-compatibility-with-php-serialize/load.php' ); $check_result = new Check_Result( $check_context ); From ed6e29c6c2140bf31ece6de65e318d24bce21fbc Mon Sep 17 00:00:00 2001 From: Shameem Reza Date: Wed, 17 Jun 2026 14:59:28 +0600 Subject: [PATCH 2/2] Refactor guard helper to satisfy NPath limit Extract the function_exists() argument resolution into its own method so neither method exceeds the PHPMD NPath complexity threshold. No behavior change; the guarded-call suppression works exactly as before. --- .../WP_Functions_Compatibility_Check.php | 54 ++++++++++++------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/includes/Checker/Checks/Plugin_Repo/WP_Functions_Compatibility_Check.php b/includes/Checker/Checks/Plugin_Repo/WP_Functions_Compatibility_Check.php index d3f54dccb..4e7d51372 100644 --- a/includes/Checker/Checks/Plugin_Repo/WP_Functions_Compatibility_Check.php +++ b/includes/Checker/Checks/Plugin_Repo/WP_Functions_Compatibility_Check.php @@ -308,32 +308,48 @@ private function get_function_exists_guarded_names( array $tokens ): array { continue; } - $open_index = $this->get_next_significant_token_index( $tokens, $index ); - if ( null === $open_index || '(' !== $tokens[ $open_index ] ) { - continue; + $guarded_name = $this->resolve_function_exists_argument( $tokens, $index ); + if ( null !== $guarded_name ) { + $guarded[ $guarded_name ] = true; } + } - if ( ! $this->is_global_function_call( $tokens, $index ) ) { - continue; - } + return $guarded; + } - $argument_index = $this->get_next_significant_token_index( $tokens, $open_index ); - if ( null === $argument_index ) { - continue; - } + /** + * Resolves the string literal argument of a function_exists() call to a function name. + * + * @since 2.0.0 + * + * @param array $tokens Token stream. + * @param int $index Index of the function_exists T_STRING token. + * @return string|null Lowercase function name, or null when the call is not a + * global function_exists() with a single string literal argument. + */ + private function resolve_function_exists_argument( array $tokens, int $index ): ?string { + $open_index = $this->get_next_significant_token_index( $tokens, $index ); + if ( null === $open_index || '(' !== $tokens[ $open_index ] ) { + return null; + } - $argument_token = $tokens[ $argument_index ]; - if ( ! is_array( $argument_token ) || T_CONSTANT_ENCAPSED_STRING !== $argument_token[0] ) { - continue; - } + if ( ! $this->is_global_function_call( $tokens, $index ) ) { + return null; + } - $guarded_name = strtolower( ltrim( trim( $argument_token[1], '\'"' ), '\\' ) ); - if ( '' !== $guarded_name ) { - $guarded[ $guarded_name ] = true; - } + $argument_index = $this->get_next_significant_token_index( $tokens, $open_index ); + if ( null === $argument_index ) { + return null; } - return $guarded; + $argument_token = $tokens[ $argument_index ]; + if ( ! is_array( $argument_token ) || T_CONSTANT_ENCAPSED_STRING !== $argument_token[0] ) { + return null; + } + + $guarded_name = strtolower( ltrim( trim( $argument_token[1], '\'"' ), '\\' ) ); + + return '' !== $guarded_name ? $guarded_name : null; } /**