diff --git a/includes/Admin/Admin_AJAX.php b/includes/Admin/Admin_AJAX.php index 3d2b42216..aa529be85 100644 --- a/includes/Admin/Admin_AJAX.php +++ b/includes/Admin/Admin_AJAX.php @@ -143,6 +143,10 @@ private function get_ajax_runner() { return new WP_Error( 'invalid-runner', __( 'AJAX Runner was not initialized correctly.', 'plugin-check' ) ); } + // Register the runner so that checks relying on Plugin_Request_Utility::get_runner() + // (e.g. AI_Name_Check) can access it, even when no runtime checks are involved. + Plugin_Request_Utility::set_runner( $runner ); + return $runner; } @@ -218,12 +222,12 @@ public function clean_up_environment() { public function get_checks_to_run() { $this->check_request_validity(); - $categories = filter_input( INPUT_POST, 'categories', FILTER_DEFAULT, FILTER_FORCE_ARRAY ); - $categories = is_null( $categories ) ? array() : $categories; - $checks = filter_input( INPUT_POST, 'checks', FILTER_DEFAULT, FILTER_FORCE_ARRAY ); - $checks = is_null( $checks ) ? array() : $checks; - $use_ai = 1 === filter_input( INPUT_POST, 'use-ai', FILTER_VALIDATE_INT ); - $runner = $this->get_ajax_runner(); + $categories = filter_input( INPUT_POST, 'categories', FILTER_DEFAULT, FILTER_FORCE_ARRAY ); + $categories = is_null( $categories ) ? array() : $categories; + $checks = filter_input( INPUT_POST, 'checks', FILTER_DEFAULT, FILTER_FORCE_ARRAY ); + $checks = is_null( $checks ) ? array() : $checks; + $use_ai = 1 === filter_input( INPUT_POST, 'use-ai', FILTER_VALIDATE_INT ); + $runner = $this->get_ajax_runner(); if ( is_wp_error( $runner ) ) { wp_send_json_error( $runner, 500 ); @@ -265,20 +269,6 @@ public function run_checks() { wp_send_json_error( $runner, 500 ); } - $runner = Plugin_Request_Utility::get_runner(); - - if ( is_null( $runner ) ) { - $runner = new AJAX_Runner(); - } - - // Make sure we are using the correct runner instance. - if ( ! ( $runner instanceof AJAX_Runner ) ) { - wp_send_json_error( - new WP_Error( 'invalid-runner', __( 'AJAX Runner was not initialized correctly.', 'plugin-check' ) ), - 500 - ); - } - $checks = filter_input( INPUT_POST, 'checks', FILTER_DEFAULT, FILTER_FORCE_ARRAY ); $checks = is_null( $checks ) ? array() : $checks; $plugin = filter_input( INPUT_POST, 'plugin', FILTER_SANITIZE_FULL_SPECIAL_CHARS ); diff --git a/includes/Checker/Abstract_Check_Runner.php b/includes/Checker/Abstract_Check_Runner.php index 8f66010ac..664c2aafd 100644 --- a/includes/Checker/Abstract_Check_Runner.php +++ b/includes/Checker/Abstract_Check_Runner.php @@ -21,6 +21,7 @@ * * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) * @SuppressWarnings(PHPMD.TooManyPublicMethods) + * @SuppressWarnings(PHPMD.TooManyFields) */ abstract class Abstract_Check_Runner implements Check_Runner { @@ -342,11 +343,22 @@ final public function set_ai_model_preference( $model_preference ) { * * @return bool True if AI analysis should be used, false otherwise. */ - protected function should_use_ai() { + public function should_use_ai() { // Check if explicitly set via setter (e.g., CLI flag or checkbox). return $this->use_ai; } + /** + * Gets the AI model preference for analysis. + * + * @since x.x.x + * + * @return string Model preference. + */ + final public function get_ai_model_preference() { + return $this->ai_model_preference; + } + /** * Sets categories for filtering the checks. * diff --git a/includes/Checker/Checks/Plugin_Repo/AI_Name_Check.php b/includes/Checker/Checks/Plugin_Repo/AI_Name_Check.php new file mode 100644 index 000000000..c1433ff28 --- /dev/null +++ b/includes/Checker/Checks/Plugin_Repo/AI_Name_Check.php @@ -0,0 +1,288 @@ +should_run_ai_check( $runner ) ) { + return; + } + + $model_preference = $this->get_model_preference( $runner ); + $plugin_data = $this->get_plugin_name_and_author( $result ); + if ( ! $plugin_data ) { + return; + } + + $analysis = $this->run_name_analysis( $model_preference, $plugin_data['name'], $plugin_data['author'] ); + $this->handle_analysis_response( $result, $analysis, $plugin_data['file'] ); + } + + /** + * Determines if the AI check should run. + * + * @since x.x.x + * + * @param mixed $runner The active runner. + * @return bool True if the check should run, false otherwise. + */ + private function should_run_ai_check( $runner ) { + if ( ! $runner ) { + return false; + } + if ( ! method_exists( $runner, 'should_use_ai' ) ) { + return false; + } + if ( ! $runner->should_use_ai() ) { + return false; + } + if ( is_wp_error( $this->check_ai_prerequisites() ) ) { + return false; + } + if ( is_wp_error( $this->check_ai_connectors() ) ) { + return false; + } + return true; + } + + /** + * Gets the selected AI model preference. + * + * @since x.x.x + * + * @param mixed $runner The active runner. + * @return string The model preference. + */ + private function get_model_preference( $runner ) { + $model = ''; + if ( $runner && method_exists( $runner, 'get_ai_model_preference' ) ) { + $model = $runner->get_ai_model_preference(); + } + if ( empty( $model ) && class_exists( Settings_Page::class ) ) { + $model = Settings_Page::get_model_preference(); + } + return $model; + } + + /** + * Gets the plugin name and author from headers. + * + * @since x.x.x + * + * @param Check_Result $result The check result. + * @return array|null Name and author if found, null otherwise. + */ + private function get_plugin_name_and_author( Check_Result $result ) { + if ( ! function_exists( 'get_plugin_data' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + $file = $result->plugin()->main_file(); + $header = get_plugin_data( $file ); + $name = isset( $header['Name'] ) ? $header['Name'] : ''; + $author = isset( $header['AuthorName'] ) ? $header['AuthorName'] : ''; + + if ( empty( $name ) ) { + return null; + } + + return array( + 'name' => $name, + 'author' => $author, + 'file' => $file, + ); + } + + /** + * Handles the AI analysis response and parses results. + * + * @since x.x.x + * + * @param Check_Result $result The check result. + * @param mixed $analysis The analysis response or WP_Error. + * @param string $plugin_main_file The plugin main file path. + */ + private function handle_analysis_response( Check_Result $result, $analysis, string $plugin_main_file ) { + if ( is_wp_error( $analysis ) ) { + $this->add_result_warning_for_file( + $result, + sprintf( + /* translators: %s: Error message. */ + __( 'AI plugin name check failed: %s', 'plugin-check' ), + $analysis->get_error_message() + ), + 'ai_name_check_failed', + $plugin_main_file + ); + return; + } + + $parsed = $this->parse_analysis( $analysis ); + $this->process_analysis_results( $result, $parsed, $plugin_main_file ); + } + + /** + * Process analysis results and add appropriate warnings/errors. + * + * @since x.x.x + * + * @param Check_Result $result The check result. + * @param array $parsed The parsed analysis data. + * @param string $plugin_main_file The plugin main file path. + */ + private function process_analysis_results( Check_Result $result, array $parsed, string $plugin_main_file ) { + if ( ! isset( $parsed['processed_data'] ) ) { + return; + } + + $data = $parsed['processed_data']; + + $this->check_disallowed_name( $result, $data, $plugin_main_file ); + $this->check_naming_issues( $result, $data, $plugin_main_file ); + $this->check_owner_issues( $result, $data, $plugin_main_file ); + $this->check_similar_plugins( $result, $parsed, $plugin_main_file ); + } + + /** + * Check if the plugin name is disallowed. + * + * @since x.x.x + * + * @param Check_Result $result The check result. + * @param array $data The parsed analysis data. + * @param string $plugin_main_file The plugin main file path. + */ + private function check_disallowed_name( Check_Result $result, array $data, string $plugin_main_file ) { + if ( ! empty( $data['disallowed'] ) ) { + $msg = isset( $data['disallowed_explanation'] ) ? $data['disallowed_explanation'] : __( 'The plugin name is disallowed.', 'plugin-check' ); + $this->add_result_error_for_file( $result, $msg, 'plugin_name_disallowed', $plugin_main_file ); + } + } + + /** + * Check for possible naming issues. + * + * @since x.x.x + * + * @param Check_Result $result The check result. + * @param array $data The parsed analysis data. + * @param string $plugin_main_file The plugin main file path. + */ + private function check_naming_issues( Check_Result $result, array $data, string $plugin_main_file ) { + if ( ! empty( $data['possible_naming_issues'] ) ) { + $msg = isset( $data['naming_explanation'] ) ? $data['naming_explanation'] : __( 'The plugin name has possible naming issues.', 'plugin-check' ); + $this->add_result_warning_for_file( $result, $msg, 'plugin_name_issue', $plugin_main_file ); + } + } + + /** + * Check for possible owner issues. + * + * @since x.x.x + * + * @param Check_Result $result The check result. + * @param array $data The parsed analysis data. + * @param string $plugin_main_file The plugin main file path. + */ + private function check_owner_issues( Check_Result $result, array $data, string $plugin_main_file ) { + if ( ! empty( $data['possible_owner_issues'] ) ) { + $msg = isset( $data['owner_explanation'] ) ? $data['owner_explanation'] : __( 'The plugin name has possible trademark or ownership issues.', 'plugin-check' ); + $this->add_result_warning_for_file( $result, $msg, 'plugin_name_trademark_issue', $plugin_main_file ); + } + } + + /** + * Check for similar plugins. + * + * @since x.x.x + * + * @param Check_Result $result The check result. + * @param array $parsed The parsed analysis data. + * @param string $plugin_main_file The plugin main file path. + */ + private function check_similar_plugins( Check_Result $result, array $parsed, string $plugin_main_file ) { + if ( ! empty( $parsed['confusion_existing_plugins'] ) && is_array( $parsed['confusion_existing_plugins'] ) ) { + $plugins_list = array(); + foreach ( $parsed['confusion_existing_plugins'] as $plugin ) { + $plugins_list[] = sprintf( '%s (%s, %s active installs)', $plugin['name'], $plugin['similarity_level'], $plugin['active_installations'] ); + } + $this->add_result_warning_for_file( + $result, + sprintf( + /* translators: %s: List of similar plugins. */ + __( 'Plugin name is similar to existing plugins: %s', 'plugin-check' ), + implode( '; ', $plugins_list ) + ), + 'plugin_name_similarity', + $plugin_main_file + ); + } + } + + /** + * Gets the description for the check. + * + * @since x.x.x + * + * @return string Description. + */ + public function get_description(): string { + return __( 'Checks the plugin name for guideline compliance, trademark conflicts, and similarity with existing plugins using AI.', 'plugin-check' ); + } + + /** + * Gets the documentation URL for the check. + * + * @since x.x.x + * + * @return string The documentation URL. + */ + public function get_documentation_url(): string { + return __( 'https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/', 'plugin-check' ); + } +} diff --git a/includes/Checker/Default_Check_Repository.php b/includes/Checker/Default_Check_Repository.php index d4d5c548d..00778a080 100644 --- a/includes/Checker/Default_Check_Repository.php +++ b/includes/Checker/Default_Check_Repository.php @@ -105,6 +105,7 @@ private function register_default_checks() { 'external_admin_menu_links' => new Checks\Plugin_Repo\External_Admin_Menu_Links_Check(), 'wp_functions_compatibility' => new Checks\Plugin_Repo\WP_Functions_Compatibility_Check(), 'ai_provider' => new Checks\General\AI_Provider_Check(), + 'ai_name' => new Checks\Plugin_Repo\AI_Name_Check(), ) ); diff --git a/includes/Plugin_Main.php b/includes/Plugin_Main.php index 210034611..518c53a59 100644 --- a/includes/Plugin_Main.php +++ b/includes/Plugin_Main.php @@ -9,6 +9,7 @@ use WordPress\Plugin_Check\Admin\Admin_AJAX; use WordPress\Plugin_Check\Admin\Admin_Page; +use WordPress\Plugin_Check\Admin\Namer_Page; use WordPress\Plugin_Check\Admin\Settings_Page; use WordPress\Plugin_Check\Admin\SVN_Checker_Page; @@ -80,8 +81,7 @@ public function add_hooks() { $settings_page->add_hooks(); // Create the Plugin Check Namer tool page. - $namer_page_class = '\\WordPress\\Plugin_Check\\Admin\\Namer_Page'; - $namer_page = new $namer_page_class(); + $namer_page = new Namer_Page(); $namer_page->add_hooks(); // Create the SVN Checker page. diff --git a/includes/Utilities/Plugin_Request_Utility.php b/includes/Utilities/Plugin_Request_Utility.php index 3451f8052..fd05763ec 100644 --- a/includes/Utilities/Plugin_Request_Utility.php +++ b/includes/Utilities/Plugin_Request_Utility.php @@ -16,6 +16,8 @@ * Class providing utility methods to return plugin information based on the request. * * @since 1.0.0 + * + * @SuppressWarnings(PHPMD.TooManyPublicMethods) */ class Plugin_Request_Utility { @@ -143,6 +145,21 @@ public static function get_runner() { return null; } + /** + * Sets the Runner class for the current request. + * + * This is needed for requests (e.g. AJAX requests running only static checks) + * where the runner is not registered early via the object-cache.php drop-in, + * so that checks relying on get_runner() (such as AI_Name_Check) can access it. + * + * @since x.x.x + * + * @param Abstract_Check_Runner $runner The runner instance for the current request. + */ + public static function set_runner( Abstract_Check_Runner $runner ) { + static::$runner = $runner; + } + /** * Runs the cleanup functions and destroys the runner. * diff --git a/tests/phpunit/tests/Checker/Checks/AI_Name_Check_Tests.php b/tests/phpunit/tests/Checker/Checks/AI_Name_Check_Tests.php new file mode 100644 index 000000000..759cd6d1f --- /dev/null +++ b/tests/phpunit/tests/Checker/Checks/AI_Name_Check_Tests.php @@ -0,0 +1,32 @@ +createMock( AJAX_Runner::class ); + $runner->method( 'should_use_ai' )->willReturn( false ); + + $reflection = new \ReflectionClass( Plugin_Request_Utility::class ); + $property = $reflection->getProperty( 'runner' ); + $property->setAccessible( true ); + $property->setValue( null, $runner ); + + $check = new AI_Name_Check(); + $context = new Check_Context( WP_PLUGIN_CHECK_MAIN_FILE ); + $result = new Check_Result( $context ); + + $check->run( $result ); + + $this->assertEmpty( $result->get_errors() ); + $this->assertEmpty( $result->get_warnings() ); + + $property->setValue( null, null ); + } +}