diff --git a/assets/css/plugin-check-admin.css b/assets/css/plugin-check-admin.css index 9f7f2281f..7575ed52b 100644 --- a/assets/css/plugin-check-admin.css +++ b/assets/css/plugin-check-admin.css @@ -62,6 +62,58 @@ } } +/* AI Analysis Styles */ +.plugin-check__ai-analysis { + display: inline-flex; + align-items: center; + gap: 5px; + margin-top: 8px; + padding: 6px 10px; + border-radius: 3px; + font-size: 0.9em; + line-height: 1.4; +} + +.plugin-check__ai-analysis--false-positive { + background-color: #fff3cd; + border-left: 3px solid #ffc107; + color: #856404; +} + +.plugin-check__ai-analysis--valid { + background-color: #d1ecf1; + border-left: 3px solid #17a2b8; + color: #0c5460; +} + +.plugin-check__ai-analysis .dashicons { + font-size: 16px; + width: 16px; + height: 16px; +} + +.plugin-check__ai-reasoning { + display: block; + margin-top: 5px; + padding: 8px; + background-color: #f8f9fa; + border-left: 3px solid #6c757d; + font-size: 0.9em; + font-style: normal; + color: #495057; + line-height: 1.5; +} + +.plugin-check__ai-recommendation { + display: block; + margin-top: 5px; + padding: 8px; + background-color: #e7f3ff; + border-left: 3px solid #0066cc; + font-size: 0.9em; + color: #004085; + line-height: 1.5; +} .plugin-check__options { display: flex; } @@ -70,6 +122,10 @@ margin-left: 40px; } +.plugin-check__options #plugin-check__ai-container { + margin-left: 40px; +} + /* JSON output formatting */ #plugin-check-namer-raw { font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace; diff --git a/assets/js/plugin-check-admin.js b/assets/js/plugin-check-admin.js index 9a81cee37..f8834879d 100644 --- a/assets/js/plugin-check-admin.js +++ b/assets/js/plugin-check-admin.js @@ -36,6 +36,7 @@ const includeExperimental = document.getElementById( 'plugin-check__include-experimental' ); + const useAi = document.getElementById( 'plugin-check__use-ai' ); // Handle disabling the Check it button when a plugin is not selected. function canRunChecks() { @@ -87,6 +88,12 @@ for ( let i = 0; i < typesList.length; i++ ) { typesList[ i ].disabled = true; } + if ( useAi ) { + useAi.disabled = true; + } + if ( includeExperimental ) { + includeExperimental.disabled = true; + } getChecksToRun() .then( setUpEnvironment ) @@ -133,6 +140,12 @@ for ( let i = 0; i < typesList.length; i++ ) { typesList[ i ].disabled = false; } + if ( useAi ) { + useAi.disabled = false; + } + if ( includeExperimental ) { + includeExperimental.disabled = false; + } } function createEmptyAggregatedResults() { @@ -400,6 +413,10 @@ 'include-experimental', includeExperimental && includeExperimental.checked ? 1 : 0 ); + pluginCheckData.append( + 'use-ai', + useAi && useAi.checked ? 1 : 0 + ); for ( let i = 0; i < data.checks.length; i++ ) { pluginCheckData.append( 'checks[]', data.checks[ i ] ); @@ -472,6 +489,10 @@ 'include-experimental', includeExperimental && includeExperimental.checked ? 1 : 0 ); + pluginCheckData.append( + 'use-ai', + useAi && useAi.checked ? 1 : 0 + ); for ( let i = 0; i < categoriesList.length; i++ ) { if ( categoriesList[ i ].checked ) { @@ -515,6 +536,7 @@ */ async function runChecks( data ) { let isSuccessMessage = true; + let aiStats = null; for ( let i = 0; i < data.checks.length; i++ ) { try { const results = await runCheck( data.plugin, data.checks[ i ] ); @@ -528,12 +550,27 @@ } mergeAggregatedResults( results ); renderResults( results ); + + // Collect AI stats from the last check. + if ( results.ai_stats ) { + // Merge stats if multiple checks. + if ( ! aiStats ) { + aiStats = { + tokens_spent: 0, + false_positives: 0, + issues_analyzed: 0, + }; + } + aiStats.tokens_spent += results.ai_stats.tokens_spent || 0; + aiStats.false_positives += results.ai_stats.false_positives || 0; + aiStats.issues_analyzed += results.ai_stats.issues_analyzed || 0; + } } catch { // Ignore for now. } } - renderResultsMessage( isSuccessMessage ); + renderResultsMessage( isSuccessMessage, aiStats ); } /** @@ -542,13 +579,24 @@ * @since 1.0.0 * * @param {boolean} isSuccessMessage Whether the message is a success message. + * @param {Object} aiStats AI statistics. */ - function renderResultsMessage( isSuccessMessage ) { + function renderResultsMessage( isSuccessMessage, aiStats ) { const messageType = isSuccessMessage ? 'success' : 'error'; - const messageText = isSuccessMessage + let messageText = isSuccessMessage ? pluginCheck.successMessage : pluginCheck.errorMessage; + // Add AI statistics to the message if available. + if ( aiStats && aiStats.false_positives > 0 ) { + let aiInfo = ' AI detected ' + aiStats.false_positives + ' '; + aiInfo += ( 1 === aiStats.false_positives ) ? 'false positive' : 'false positives'; + if ( aiStats.tokens_spent > 0 ) { + aiInfo += ' (Tokens spent: ' + aiStats.tokens_spent.toLocaleString() + ')'; + } + messageText += '.' + aiInfo; + } + resultsContainer.innerHTML = renderTemplate( 'plugin-check-results-complete', { type: messageType, @@ -578,6 +626,10 @@ 'include-experimental', includeExperimental && includeExperimental.checked ? 1 : 0 ); + pluginCheckData.append( + 'use-ai', + useAi && useAi.checked ? 1 : 0 + ); for ( let i = 0; i < typesList.length; i++ ) { if ( typesList[ i ].checked ) { @@ -600,6 +652,14 @@ throw new Error( 'Response contains no data' ); } + // Debug: Log AI data if present. + if ( responseData.data.ai_analysis ) { + console.log( 'AI Analysis received:', responseData.data.ai_analysis ); + } + if ( responseData.data.ai_stats ) { + console.log( 'AI Stats received:', responseData.data.ai_stats ); + } + return responseData.data; } ); } @@ -638,20 +698,25 @@ * @param {Object} results The results object. */ function renderResults( results ) { - const { errors, warnings } = results; + const { errors, warnings, ai_analysis } = results || {}; + + // Debug: Log AI analysis data if available. + if ( ai_analysis && typeof ai_analysis === 'object' && Object.keys( ai_analysis ).length > 0 ) { + console.log( 'AI Analysis data in renderResults:', ai_analysis ); + } // Render errors and warnings for files. for ( const file in errors ) { if ( warnings[ file ] ) { - renderFileResults( file, errors[ file ], warnings[ file ] ); + renderFileResults( file, errors[ file ], warnings[ file ], ai_analysis ); delete warnings[ file ]; } else { - renderFileResults( file, errors[ file ], [] ); + renderFileResults( file, errors[ file ], [], ai_analysis ); } } // Render remaining files with only warnings. for ( const file in warnings ) { - renderFileResults( file, [], warnings[ file ] ); + renderFileResults( file, [], warnings[ file ], ai_analysis ); } } @@ -660,11 +725,12 @@ * * @since 1.0.0 * - * @param {string} file The file name for the results. - * @param {Object} errors The file errors. - * @param {Object} warnings The file warnings. + * @param {string} file The file name for the results. + * @param {Object} errors The file errors. + * @param {Object} warnings The file warnings. + * @param {Object} ai_analysis AI analysis results. */ - function renderFileResults( file, errors, warnings ) { + function renderFileResults( file, errors, warnings, ai_analysis ) { const index = Date.now().toString( 36 ) + Math.random().toString( 36 ).substr( 2 ); @@ -683,8 +749,8 @@ ); // Render results to the table. - renderResultRows( 'ERROR', errors, resultsTable, hasLinks ); - renderResultRows( 'WARNING', warnings, resultsTable, hasLinks ); + renderResultRows( 'ERROR', errors, resultsTable, hasLinks, ai_analysis, file ); + renderResultRows( 'WARNING', warnings, resultsTable, hasLinks, ai_analysis, file ); } /** @@ -713,12 +779,14 @@ * * @since 1.0.0 * - * @param {string} type The result type. Either ERROR or WARNING. - * @param {Object} results The results object. - * @param {Object} table The HTML table to append a result row to. - * @param {boolean} hasLinks Whether any result has links. + * @param {string} type The result type. Either ERROR or WARNING. + * @param {Object} results The results object. + * @param {Object} table The HTML table to append a result row to. + * @param {boolean} hasLinks Whether any result has links. + * @param {Object} ai_analysis AI analysis results. + * @param {string} file The file path. */ - function renderResultRows( type, results, table, hasLinks ) { + function renderResultRows( type, results, table, hasLinks, ai_analysis, file ) { // Loop over each result by the line, column and messages. for ( const line in results ) { for ( const column in results[ line ] ) { @@ -728,24 +796,94 @@ const code = results[ line ][ column ][ i ].code; const link = results[ line ][ column ][ i ].link; + // Find AI analysis for this issue. + let aiData = null; + if ( ai_analysis && typeof ai_analysis === 'object' ) { + // Try to find by file, line, column, and code match. + // ai_analysis is an object where keys are MD5 hashes and values are analysis data. + const analysisEntries = Object.values( ai_analysis ); + aiData = analysisEntries.find( function( analysis ) { + if ( ! analysis || typeof analysis !== 'object' ) { + return false; + } + // Normalize values for comparison. + const analysisFile = String( analysis.file || '' ); + const currentFile = String( file || '' ); + const analysisLine = parseInt( analysis.line, 10 ); + const currentLine = parseInt( line, 10 ); + const analysisColumn = parseInt( analysis.column, 10 ); + const currentColumn = parseInt( column, 10 ); + const analysisCode = String( analysis.code || '' ); + const currentCode = String( code || '' ); + + const fileMatch = analysisFile === currentFile; + const lineMatch = analysisLine === currentLine; + const columnMatch = analysisColumn === currentColumn; + const codeMatch = analysisCode === currentCode; + + if ( fileMatch && lineMatch && columnMatch && codeMatch ) { + console.log( 'AI match found:', { + file: currentFile, + line: currentLine, + column: currentColumn, + code: currentCode, + analysis: analysis, + } ); + return true; + } + + return false; + } ) || null; + } + + const rowData = { + line, + column, + type, + message, + docs, + code, + link, + hasLinks, + }; + + // Add AI analysis data if available. + if ( aiData ) { + rowData.ai_analysis = aiData; + } + table.innerHTML += renderTemplate( 'plugin-check-results-row', - { - line, - column, - type, - message, - docs, - code, - link, - hasLinks, - } + rowData ); } } } } + /** + * Generates a unique key for an issue. + * + * @since 1.8.0 + * + * @param {string} file File path. + * @param {number} line Line number. + * @param {number} column Column number. + * @param {string} code Issue code. + * @return {string} Unique key. + */ + function getIssueKey( file, line, column, code ) { + const str = file + ':' + line + ':' + column + ':' + code; + // Simple MD5-like hash (using built-in hash if available, otherwise a simple hash). + let hash = 0; + for ( let i = 0; i < str.length; i++ ) { + const char = str.charCodeAt( i ); + hash = ( hash << 5 ) - hash + char; + hash = hash & hash; // Convert to 32bit integer. + } + return hash.toString( 36 ); + } + /** * Renders the template with data. * diff --git a/includes/Admin/Admin_AJAX.php b/includes/Admin/Admin_AJAX.php index 435401289..12b6aed35 100644 --- a/includes/Admin/Admin_AJAX.php +++ b/includes/Admin/Admin_AJAX.php @@ -229,15 +229,23 @@ public function get_checks_to_run() { $categories = filter_input( INPUT_POST, 'categories', FILTER_DEFAULT, FILTER_FORCE_ARRAY ); $categories = is_null( $categories ) ? array() : $categories; - $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; + $plugin = filter_input( INPUT_POST, 'plugin', FILTER_SANITIZE_FULL_SPECIAL_CHARS ); + $include_experimental = 1 === filter_input( INPUT_POST, 'include-experimental', FILTER_VALIDATE_INT ); + $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, 403 ); + wp_send_json_error( $runner, 500 ); } try { $this->configure_runner( $runner ); $runner->set_categories( $categories ); + $runner->set_use_ai( $use_ai ); $plugin_basename = $runner->get_plugin_basename(); $checks_to_run = $runner->get_checks_to_run(); @@ -270,11 +278,34 @@ public function run_checks() { wp_send_json_error( $runner, 500 ); } - $types = filter_input( INPUT_POST, 'types', FILTER_DEFAULT, FILTER_FORCE_ARRAY ); - $types = is_null( $types ) ? array() : $types; + $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 ); + + $include_experimental = 1 === filter_input( INPUT_POST, 'include-experimental', FILTER_VALIDATE_INT ); + $use_ai = 1 === filter_input( INPUT_POST, 'use-ai', FILTER_VALIDATE_INT ); + $types = filter_input( INPUT_POST, 'types', FILTER_DEFAULT, FILTER_FORCE_ARRAY ); + $types = is_null( $types ) ? array( 'error', 'warning' ) : $types; try { - $this->configure_runner( $runner ); + $runner->set_experimental_flag( $include_experimental ); + $runner->set_check_slugs( $checks ); + $runner->set_plugin( $plugin ); + $runner->set_use_ai( $use_ai ); $results = $runner->run(); } catch ( Exception $error ) { wp_send_json_error( @@ -285,6 +316,18 @@ public function run_checks() { $response_data = $this->prepare_results_response( $results, $types ); + // Include AI analysis results if available. + $ai_analysis = $results->get_ai_analysis(); + if ( ! empty( $ai_analysis ) ) { + $response_data['ai_analysis'] = $ai_analysis; + } + + // Include AI statistics if available. + $ai_stats = $results->get_ai_stats(); + if ( ! empty( $ai_stats ) ) { + $response_data['ai_stats'] = $ai_stats; + } + wp_send_json_success( $response_data ); } @@ -315,7 +358,6 @@ private function prepare_results_response( $results, array $types ) { return $response; } - /** * Handles exporting Plugin Check results. * diff --git a/includes/Admin/Admin_Page.php b/includes/Admin/Admin_Page.php index 89e8d182d..6fc96dc73 100644 --- a/includes/Admin/Admin_Page.php +++ b/includes/Admin/Admin_Page.php @@ -204,6 +204,7 @@ public function enqueue_scripts() { 'actionExportResults' => Admin_AJAX::ACTION_EXPORT_RESULTS, 'successMessage' => __( 'No errors found.', 'plugin-check' ), 'errorMessage' => __( 'Errors were found.', 'plugin-check' ), + 'settingsPageUrl' => admin_url( 'options-general.php?page=plugin-check-settings' ), 'strings' => array( 'exportCsv' => __( 'Export CSV', 'plugin-check' ), 'exportJson' => __( 'Export JSON', 'plugin-check' ), diff --git a/includes/Admin/Settings_Page.php b/includes/Admin/Settings_Page.php new file mode 100644 index 000000000..8d8e63ce8 --- /dev/null +++ b/includes/Admin/Settings_Page.php @@ -0,0 +1,387 @@ +hook_suffix = add_submenu_page( + 'options-general.php', + __( 'Plugin Check', 'plugin-check' ), + __( 'Plugin Check', 'plugin-check' ), + 'manage_options', + self::PAGE_SLUG, + array( $this, 'render_page' ) + ); + } + + /** + * Registers settings and settings fields. + * + * @since 1.8.0 + */ + public function register_settings() { + register_setting( + self::OPTION_GROUP, + self::OPTION_NAME, + array( + 'sanitize_callback' => array( $this, 'sanitize_settings' ), + 'default' => array( + 'ai_model_preference' => '', + 'ai_severity_errors' => 7, + 'ai_severity_warnings' => 6, + ), + ) + ); + + // AI Code Review section. + add_settings_section( + 'ai_code_review_section', + __( 'AI Code Review', 'plugin-check' ), + array( $this, 'render_ai_section_description' ), + self::PAGE_SLUG + ); + + add_settings_field( + 'ai_model_preference', + __( 'AI Model', 'plugin-check' ), + array( $this, 'render_model_preference_field' ), + self::PAGE_SLUG, + 'ai_code_review_section', + array( + 'label_for' => 'ai_model_preference', + ) + ); + + // Severity threshold section. + add_settings_section( + 'ai_severity_section', + __( 'Severity Threshold', 'plugin-check' ), + array( $this, 'render_severity_section_description' ), + self::PAGE_SLUG + ); + + add_settings_field( + 'ai_severity_errors', + __( 'Errors', 'plugin-check' ), + array( $this, 'render_severity_errors_field' ), + self::PAGE_SLUG, + 'ai_severity_section', + array( + 'label_for' => 'ai_severity_errors', + ) + ); + + add_settings_field( + 'ai_severity_warnings', + __( 'Warnings', 'plugin-check' ), + array( $this, 'render_severity_warnings_field' ), + self::PAGE_SLUG, + 'ai_severity_section', + array( + 'label_for' => 'ai_severity_warnings', + ) + ); + } + + /** + * Renders the AI settings section description. + * + * @since 1.8.0 + */ + public function render_ai_section_description() { + $has_connectors = ! $this->has_no_active_ai_connectors(); + ?> +

+ +

+ +
+

+ configure an AI connector in WordPress settings first.', 'plugin-check' ), + array( 'a' => array( 'href' => array() ) ) + ), + esc_url( admin_url( 'options-general.php' ) ) + ); + ?> +

+
+ + +

+ +

+ get_available_model_preferences(); + $has_models = ! empty( $grouped_models ); + ?> + + +

+ +

+ +

+ +

+ + + +

+ +

+ + +

+ +

+ = 1 && $value <= 10 ) ? $value : 7; + } else { + $sanitized['ai_severity_errors'] = 7; + } + + if ( isset( $input['ai_severity_warnings'] ) ) { + $value = intval( $input['ai_severity_warnings'] ); + $sanitized['ai_severity_warnings'] = ( $value >= 1 && $value <= 10 ) ? $value : 6; + } else { + $sanitized['ai_severity_warnings'] = 6; + } + + return $sanitized; + } + + /** + * Gets the saved AI model preference. + * + * @since 1.8.0 + * + * @return string AI model preference (e.g., 'openai::gpt-4o') or empty for auto. + */ + public static function get_model_preference() { + $settings = get_option( self::OPTION_NAME, array() ); + return isset( $settings['ai_model_preference'] ) ? $settings['ai_model_preference'] : ''; + } + + /** + * Gets the AI severity threshold for errors. + * + * @since 1.8.0 + * + * @return int AI severity threshold for errors. + */ + public static function get_severity_errors() { + $settings = get_option( self::OPTION_NAME, array() ); + return isset( $settings['ai_severity_errors'] ) ? intval( $settings['ai_severity_errors'] ) : 7; + } + + /** + * Gets the AI severity threshold for warnings. + * + * @since 1.8.0 + * + * @return int AI severity threshold for warnings. + */ + public static function get_severity_warnings() { + $settings = get_option( self::OPTION_NAME, array() ); + return isset( $settings['ai_severity_warnings'] ) ? intval( $settings['ai_severity_warnings'] ) : 6; + } + + /** + * Renders the settings page. + * + * @since 1.8.0 + */ + public function render_page() { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'plugin-check' ) ); + } + + ?> +
+

+ + + +
+ +
+
+ ] + * : AI model preference for analysis (e.g., 'openai::gpt-4o'). Requires --use-ai. + * * ## EXAMPLES * * wp plugin check akismet * wp plugin check akismet --checks=late_escaping * wp plugin check akismet --format=json * wp plugin check akismet --mode=update + * wp plugin check akismet --use-ai + * wp plugin check akismet --use-ai --ai-model=openai::gpt-4o * * @subcommand check * @@ -161,7 +169,7 @@ public function __construct( Plugin_Context $plugin_context ) { */ public function check( $args, $assoc_args ) { // Get options based on the CLI arguments. - $options = $this->get_options( + $options = $this->get_options( $assoc_args, array( 'checks' => '', @@ -177,6 +185,8 @@ public function check( $args, $assoc_args ) { 'slug' => '', 'ignore-codes' => '', 'mode' => 'new', + 'use-ai' => false, + 'ai-model' => '', ) ); @@ -237,6 +247,10 @@ static function ( $dirs ) use ( $excluded_files ) { $runner->set_categories( $categories ); $runner->set_slug( $options['slug'] ); $runner->set_mode( $options['mode'] ); + $runner->set_use_ai( $options['use-ai'] ); + if ( ! empty( $options['ai-model'] ) ) { + $runner->set_ai_model_preference( $options['ai-model'] ); + } } catch ( Exception $error ) { WP_CLI::error( $error->getMessage() ); } @@ -263,8 +277,40 @@ static function ( $dirs ) use ( $excluded_files ) { $warnings = $result->get_warnings(); } + // Get AI analysis results if available. + $ai_analysis = array(); + if ( $result && $options['use-ai'] ) { + $ai_analysis = $result->get_ai_analysis(); + } + + // Get AI statistics if available. + $ai_stats = array(); + if ( $result && $options['use-ai'] ) { + $ai_stats = $result->get_ai_stats(); + } + if ( empty( $errors ) && empty( $warnings ) ) { - WP_CLI::success( __( 'Checks complete. No errors found.', 'plugin-check' ) ); + $message = __( 'Checks complete. No errors found.', 'plugin-check' ); + + // Add AI statistics to the message if available. + if ( ! empty( $ai_stats ) && isset( $ai_stats['false_positives'] ) && $ai_stats['false_positives'] > 0 ) { + $ai_info = sprintf( + // translators: %1$d: Number of false positives, %2$s: Tokens spent (formatted). + __( ' AI detected %1$d %2$s', 'plugin-check' ), + $ai_stats['false_positives'], + _n( 'false positive', 'false positives', $ai_stats['false_positives'], 'plugin-check' ) + ); + if ( isset( $ai_stats['tokens_spent'] ) && $ai_stats['tokens_spent'] > 0 ) { + $ai_info .= sprintf( + // translators: %s: Tokens spent (formatted). + __( ' (Tokens spent: %s)', 'plugin-check' ), + number_format_i18n( $ai_stats['tokens_spent'] ) + ); + } + $message .= '.' . $ai_info; + } + + WP_CLI::success( $message ); return; } @@ -346,6 +392,11 @@ static function ( $dirs ) use ( $excluded_files ) { foreach ( $results_by_file as $file_name => $file_results ) { $this->display_results( $formatter, $file_name, $file_results ); } + + // Display AI analysis summary if available. + if ( ! empty( $ai_analysis ) || ! empty( $ai_stats ) ) { + $this->display_ai_summary( $ai_analysis, $ai_stats ); + } } /** @@ -626,6 +677,82 @@ private function display_results( $formatter, $file_name, $file_results ) { WP_CLI::line(); } + /** + * Displays AI analysis summary. + * + * @since 1.8.0 + * + * @param array $ai_analysis AI analysis results. + * @param array $ai_stats AI statistics. + */ + private function display_ai_summary( array $ai_analysis, array $ai_stats ) { + WP_CLI::line( '' ); + WP_CLI::line( str_repeat( '─', 60 ) ); + WP_CLI::line( '✨ ' . __( 'AI False Positive Analysis', 'plugin-check' ) ); + WP_CLI::line( str_repeat( '─', 60 ) ); + + if ( ! empty( $ai_stats ) ) { + $issues_analyzed = isset( $ai_stats['issues_analyzed'] ) ? (int) $ai_stats['issues_analyzed'] : 0; + $false_positives = isset( $ai_stats['false_positives'] ) ? (int) $ai_stats['false_positives'] : 0; + $tokens_spent = isset( $ai_stats['tokens_spent'] ) ? (int) $ai_stats['tokens_spent'] : 0; + + WP_CLI::line( + sprintf( + /* translators: %d: Number of issues analyzed. */ + __( 'Issues analyzed: %d', 'plugin-check' ), + $issues_analyzed + ) + ); + WP_CLI::line( + sprintf( + /* translators: %d: Number of false positives detected. */ + __( 'False positives detected: %d', 'plugin-check' ), + $false_positives + ) + ); + + if ( $tokens_spent > 0 ) { + WP_CLI::line( + sprintf( + /* translators: %s: Number of tokens spent. */ + __( 'Tokens spent: %s', 'plugin-check' ), + number_format_i18n( $tokens_spent ) + ) + ); + } + } + + // Show individual false positive details. + $fp_items = array(); + foreach ( $ai_analysis as $key => $analysis ) { + if ( ! empty( $analysis['is_false_positive'] ) ) { + $fp_items[] = $analysis; + } + } + + if ( ! empty( $fp_items ) ) { + WP_CLI::line( '' ); + WP_CLI::line( __( 'Likely false positives:', 'plugin-check' ) ); + + foreach ( $fp_items as $item ) { + $location = isset( $item['file'] ) ? $item['file'] : ''; + if ( isset( $item['line'] ) ) { + $location .= ':' . $item['line']; + } + + WP_CLI::line( + sprintf( + ' ✨ %s — %s', + $location, + isset( $item['reasoning'] ) ? $item['reasoning'] : '' + ) + ); + } + } + + WP_CLI::line( '' ); + } + /** * Returns check results filtered by severity level. * diff --git a/includes/Checker/Abstract_Check_Runner.php b/includes/Checker/Abstract_Check_Runner.php index ff568686d..1a4bb4116 100644 --- a/includes/Checker/Abstract_Check_Runner.php +++ b/includes/Checker/Abstract_Check_Runner.php @@ -10,6 +10,7 @@ use Exception; use WordPress\Plugin_Check\Checker\Exception\Invalid_Check_Slug_Exception; use WordPress\Plugin_Check\Checker\Preparations\Universal_Runtime_Preparation; +use WordPress\Plugin_Check\Traits\AI_Analyzer; use WordPress\Plugin_Check\Utilities\Plugin_Request_Utility; /** @@ -22,6 +23,8 @@ */ abstract class Abstract_Check_Runner implements Check_Runner { + use AI_Analyzer; + /** * True if the class was initialized early in the WordPress load process. * @@ -30,6 +33,22 @@ abstract class Abstract_Check_Runner implements Check_Runner { */ protected $initialized_early; + /** + * Whether to use AI analysis for false positive detection. + * + * @since 1.8.0 + * @var bool + */ + protected $use_ai = false; + + /** + * AI model preference for analysis. + * + * @since 1.8.0 + * @var string + */ + protected $ai_model_preference = ''; + /** * The check slugs to run. * @@ -293,6 +312,40 @@ final public function set_experimental_flag( $include_experimental ) { $this->include_experimental = $include_experimental; } + /** + * Sets whether to use AI analysis for false positive detection. + * + * @since 1.8.0 + * + * @param bool $use_ai True to enable AI analysis, false to disable. + */ + final public function set_use_ai( $use_ai ) { + $this->use_ai = (bool) $use_ai; + } + + /** + * Sets the AI model preference for analysis. + * + * @since 1.8.0 + * + * @param string $model_preference Model preference (e.g., 'openai::gpt-4o'). + */ + final public function set_ai_model_preference( $model_preference ) { + $this->ai_model_preference = (string) $model_preference; + } + + /** + * Determines if AI analysis should be used. + * + * @since 1.8.0 + * + * @return bool True if AI analysis should be used, false otherwise. + */ + protected function should_use_ai() { + // Check if explicitly set via setter (e.g., CLI flag or checkbox). + return $this->use_ai; + } + /** * Sets categories for filtering the checks. * @@ -390,6 +443,22 @@ final public function run() { $results = $this->get_checks_instance()->run_checks( $this->get_check_context(), $checks, $this ); + // Run AI analysis if enabled. + if ( $this->should_use_ai() ) { + // Use CLI model preference, or fall back to saved settings. + $model_preference = $this->ai_model_preference; + if ( empty( $model_preference ) && class_exists( '\WordPress\Plugin_Check\Admin\Settings_Page' ) ) { + $model_preference = \WordPress\Plugin_Check\Admin\Settings_Page::get_model_preference(); + } + $ai_result = $this->analyze_results_with_ai( $results, $this->get_check_context(), $model_preference ); + if ( ! is_wp_error( $ai_result ) ) { + $ai_analysis = isset( $ai_result['analysis'] ) ? $ai_result['analysis'] : array(); + $ai_stats = isset( $ai_result['stats'] ) ? $ai_result['stats'] : array(); + $results->set_ai_analysis( $ai_analysis ); + $results->set_ai_stats( $ai_stats ); + } + } + if ( ! empty( $cleanups ) ) { foreach ( $cleanups as $cleanup ) { $cleanup(); diff --git a/includes/Checker/Check_Result.php b/includes/Checker/Check_Result.php index 389cb8217..a5073f63f 100644 --- a/includes/Checker/Check_Result.php +++ b/includes/Checker/Check_Result.php @@ -54,6 +54,22 @@ final class Check_Result { */ protected $warning_count = 0; + /** + * AI analysis results for false positives. + * + * @since 1.8.0 + * @var array + */ + protected $ai_analysis = array(); + + /** + * AI statistics (tokens spent, false positives count, etc.). + * + * @since 1.8.0 + * @var array + */ + protected $ai_stats = array(); + /** * Sets the context for the plugin to check. * @@ -187,4 +203,48 @@ public function get_error_count() { public function get_warning_count() { return $this->warning_count; } + + /** + * Sets AI analysis results. + * + * @since 1.8.0 + * + * @param array $analysis AI analysis results. + */ + public function set_ai_analysis( array $analysis ) { + $this->ai_analysis = $analysis; + } + + /** + * Returns AI analysis results. + * + * @since 1.8.0 + * + * @return array AI analysis results. + */ + public function get_ai_analysis() { + return $this->ai_analysis; + } + + /** + * Sets AI statistics. + * + * @since 1.8.0 + * + * @param array $stats AI statistics. + */ + public function set_ai_stats( array $stats ) { + $this->ai_stats = $stats; + } + + /** + * Returns AI statistics. + * + * @since 1.8.0 + * + * @return array AI statistics. + */ + public function get_ai_stats() { + return $this->ai_stats; + } } diff --git a/includes/Plugin_Main.php b/includes/Plugin_Main.php index dffcccca8..3aa0a4343 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\Settings_Page; /** * Main class for the plugin. @@ -55,6 +56,11 @@ public function context() { * @global Plugin_Context $context The plugin context instance. */ public function add_hooks() { + // Initialize AI Client on init hook if the class exists. + if ( class_exists( '\WordPress\AI_Client\AI_Client' ) ) { + add_action( 'init', array( '\WordPress\AI_Client\AI_Client', 'init' ) ); + } + if ( defined( 'WP_CLI' ) && WP_CLI ) { global $context; @@ -68,6 +74,10 @@ public function add_hooks() { $admin_page = new Admin_Page( $admin_ajax ); $admin_page->add_hooks(); + // Create the Settings page. + $settings_page = new Settings_Page(); + $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(); diff --git a/includes/Traits/AI_Analyzer.php b/includes/Traits/AI_Analyzer.php new file mode 100644 index 000000000..a9710c36d --- /dev/null +++ b/includes/Traits/AI_Analyzer.php @@ -0,0 +1,733 @@ + 'ai-review-late-escaping.md', + 'PluginCheck.CodeAnalysis.EscapeOutput' => 'ai-review-late-escaping.md', + 'WordPress.Security.NonceVerification' => 'ai-review-nonce-verification.md', + 'WordPress.Security.ValidatedSanitizedInput' => 'ai-review-sanitization.md', + 'WordPress.DB.DirectDatabaseQuery' => 'ai-review-direct-db-queries.md', + 'WordPress.DB.PreparedSQL' => 'ai-review-direct-db-queries.md', + 'PluginCheck.CodeAnalysis.Obfuscation' => 'ai-review-code-obfuscation.md', + 'PluginCheck.CodeAnalysis.SettingSanitization' => 'ai-review-setting-sanitization.md', + 'PluginCheck.CodeAnalysis.PluginUpdater' => 'ai-review-plugin-updater.md', + ); + + /** + * Checks if AI analysis is available via WordPress core AI client. + * + * @since 1.8.0 + * + * @return bool True if AI client is available, false otherwise. + */ + protected function is_ai_available() { + if ( ! function_exists( 'wp_ai_client_prompt' ) ) { + return false; + } + + // Check WP 7.0+ AI support. + if ( function_exists( 'wp_supports_ai' ) && ! wp_supports_ai() ) { + return false; + } + + return true; + } + + /** + * Analyzes check results for false positives using batched AI requests. + * + * Issues are grouped by check code prefix, and each group is analyzed + * with a check-specific prompt. Only issues with severity below the + * configured threshold are analyzed. + * + * @since 1.8.0 + * + * @param Check_Result $result Check result to analyze. + * @param Check_Context $check_context Check context instance. + * @param string $model_preference Optional model preference. + * @return array|WP_Error Array with 'analysis' and 'stats' keys, or WP_Error on failure. + */ + protected function analyze_results_with_ai( Check_Result $result, Check_Context $check_context, $model_preference = '' ) { + if ( ! $this->is_ai_available() ) { + return new WP_Error( + 'ai_not_available', + __( 'AI analysis requires WordPress 7.0 or newer with AI support enabled.', 'plugin-check' ) + ); + } + + $errors = $result->get_errors(); + $warnings = $result->get_warnings(); + + if ( empty( $errors ) && empty( $warnings ) ) { + return $this->empty_ai_result(); + } + + // Collect all issues eligible for AI review, grouped by prompt type. + $grouped_issues = $this->collect_issues_for_ai( $errors, $warnings, $check_context ); + + if ( empty( $grouped_issues ) ) { + return $this->empty_ai_result(); + } + + // Process each group with its specific prompt. + $analysis_results = array(); + $total_tokens = 0; + $false_positives = 0; + $issues_analyzed = 0; + + foreach ( $grouped_issues as $prompt_file => $cases ) { + $batch_result = $this->analyze_batch( $prompt_file, $cases, $check_context, $model_preference ); + + if ( is_wp_error( $batch_result ) ) { + continue; + } + + foreach ( $batch_result['cases'] as $case_analysis ) { + $case_id = $case_analysis['case_id']; + if ( isset( $cases[ $case_id ] ) ) { + $original = $cases[ $case_id ]; + $analysis_results[ $case_id ] = array( + 'is_false_positive' => false === $case_analysis['issue'], + 'reasoning' => sanitize_text_field( $case_analysis['short_explanation'] ), + 'file' => $original['file'], + 'line' => $original['line'], + 'column' => $original['column'], + 'code' => $original['code'], + 'type' => $original['type'], + ); + + ++$issues_analyzed; + + if ( false === $case_analysis['issue'] ) { + ++$false_positives; + } + } + } + + if ( isset( $batch_result['token_usage']['total_tokens'] ) ) { + $total_tokens += (int) $batch_result['token_usage']['total_tokens']; + } + } + + return array( + 'analysis' => $analysis_results, + 'stats' => array( + 'tokens_spent' => $total_tokens, + 'false_positives' => $false_positives, + 'issues_analyzed' => $issues_analyzed, + ), + ); + } + + /** + * Collects issues eligible for AI review, grouped by prompt template. + * + * Only issues with severity below the configured threshold are included. + * + * @since 1.8.0 + * + * @param array $errors Errors from Check_Result. + * @param array $warnings Warnings from Check_Result. + * @param Check_Context $check_context Check context instance. + * @return array Issues grouped by prompt filename. Each value is an associative + * array keyed by case_id with issue metadata. + */ + protected function collect_issues_for_ai( array $errors, array $warnings, Check_Context $check_context ) { + $error_threshold = $this->get_ai_severity_threshold( 'error' ); + $warning_threshold = $this->get_ai_severity_threshold( 'warning' ); + + $grouped = array(); + $counts = array(); // Track count per prompt to enforce limit. + + // Process errors. + $this->collect_issues_from_collection( $errors, 'error', $error_threshold, $check_context, $grouped, $counts ); + + // Process warnings. + $this->collect_issues_from_collection( $warnings, 'warning', $warning_threshold, $check_context, $grouped, $counts ); + + return $grouped; + } + + /** + * Collects issues from a single collection (errors or warnings). + * + * @since 1.8.0 + * + * @param array $collection The errors or warnings collection. + * @param string $type 'error' or 'warning'. + * @param int $threshold Severity threshold. + * @param Check_Context $check_context Check context instance. + * @param array $grouped Reference to grouped issues array. + * @param array $counts Reference to counts per prompt. + */ + protected function collect_issues_from_collection( array $collection, $type, $threshold, Check_Context $check_context, array &$grouped, array &$counts ) { + foreach ( $collection as $file => $file_issues ) { + foreach ( $file_issues as $line => $line_issues ) { + foreach ( $line_issues as $column => $column_issues ) { + foreach ( $column_issues as $issue ) { + $severity = isset( $issue['severity'] ) ? (int) $issue['severity'] : 5; + if ( $severity >= $threshold ) { + continue; + } + + $code = isset( $issue['code'] ) ? $issue['code'] : ''; + $prompt_file = $this->get_prompt_for_code( $code ); + + if ( ! isset( $counts[ $prompt_file ] ) ) { + $counts[ $prompt_file ] = 0; + } + + if ( $counts[ $prompt_file ] >= self::AI_MAX_CASES_PER_CHECK ) { + continue; + } + + $case_id = $this->get_issue_key( $file, $line, $column, $code ); + + if ( ! isset( $grouped[ $prompt_file ] ) ) { + $grouped[ $prompt_file ] = array(); + } + + $grouped[ $prompt_file ][ $case_id ] = array( + 'file' => $file, + 'line' => $line, + 'column' => $column, + 'code' => $code, + 'message' => isset( $issue['message'] ) ? $issue['message'] : '', + 'type' => $type, + ); + + ++$counts[ $prompt_file ]; + } + } + } + } + } + + /** + * Analyzes a batch of issues with a specific prompt template. + * + * If the batch exceeds AI_BATCH_SIZE, it is split into sub-batches + * and each sub-batch is sent as a separate AI request. + * + * @since 1.8.0 + * + * @param string $prompt_file Prompt template filename. + * @param array $cases Cases to analyze, keyed by case_id. + * @param Check_Context $check_context Check context instance. + * @param string $model_preference Optional model preference. + * @return array|WP_Error Array with 'cases' and 'token_usage' keys, or WP_Error. + */ + protected function analyze_batch( $prompt_file, array $cases, Check_Context $check_context, $model_preference = '' ) { + $issue_description = $this->load_prompt_template( $prompt_file ); + if ( is_wp_error( $issue_description ) ) { + return $issue_description; + } + + // Split into sub-batches if needed. + $batches = array_chunk( $cases, self::AI_BATCH_SIZE, true ); + $all_cases = array(); + $total_tokens = 0; + + foreach ( $batches as $batch ) { + $result = $this->execute_batch_ai_request( $issue_description, $batch, $check_context, $model_preference ); + + if ( is_wp_error( $result ) ) { + continue; + } + + if ( isset( $result['cases'] ) && is_array( $result['cases'] ) ) { + $all_cases = array_merge( $all_cases, $result['cases'] ); + } + + if ( isset( $result['token_usage']['total_tokens'] ) ) { + $total_tokens += (int) $result['token_usage']['total_tokens']; + } + } + + return array( + 'cases' => $all_cases, + 'token_usage' => array( + 'total_tokens' => $total_tokens, + ), + ); + } + + /** + * Executes a single batched AI request for a group of cases. + * + * Builds a prompt following the internal scanner pattern: + * system instructions + issue description + cases list + output format. + * + * @since 1.8.0 + * + * @param string $issue_description Issue description from prompt template. + * @param array $cases Cases to analyze, keyed by case_id. + * @param Check_Context $check_context Check context instance. + * @param string $model_preference Optional model preference. + * @return array|WP_Error Array with 'cases' and 'token_usage', or WP_Error. + */ + protected function execute_batch_ai_request( $issue_description, array $cases, Check_Context $check_context, $model_preference = '' ) { + $prompt = $this->build_batch_prompt( $issue_description, $cases, $check_context ); + + if ( ! function_exists( 'wp_ai_client_prompt' ) ) { + return new WP_Error( + 'ai_client_not_available', + __( 'AI client is not available. This feature requires WordPress 7.0 or newer.', 'plugin-check' ) + ); + } + + $builder = wp_ai_client_prompt( $prompt ); + if ( is_wp_error( $builder ) ) { + return $builder; + } + + // Apply model preference if provided. + if ( ! empty( $model_preference ) ) { + $builder = $this->apply_ai_model_preference( $builder, $model_preference ); + if ( is_wp_error( $builder ) ) { + return $builder; + } + } + + try { + // Try to generate a rich result first. + $result = null; + if ( is_callable( array( $builder, 'generate_text_result' ) ) ) { + $result = $builder->generate_text_result(); + } elseif ( is_callable( array( $builder, 'generateTextResult' ) ) ) { + $result = $builder->generateTextResult(); + } + + if ( ! $result || is_wp_error( $result ) ) { + // Fallback to plain text generation. + $text = $builder->generate_text(); + if ( is_wp_error( $text ) ) { + return $text; + } + + return array( + 'cases' => $this->parse_batch_response( (string) $text ), + 'token_usage' => array(), + ); + } + + $text = method_exists( $result, 'to_text' ) ? $result->to_text() : ( method_exists( $result, 'toText' ) ? $result->toText() : '' ); + $usage = $this->extract_ai_token_usage( $result ); + + return array( + 'cases' => $this->parse_batch_response( $text ), + 'token_usage' => $usage ? $usage : array(), + ); + } catch ( \Throwable $e ) { + return new WP_Error( + 'ai_request_failed', + sprintf( + /* translators: %s: Error message. */ + __( 'AI analysis failed: %s', 'plugin-check' ), + $e->getMessage() + ) + ); + } + } + + /** + * Builds the batched prompt following the internal scanner pattern. + * + * @since 1.8.0 + * + * @param string $issue_description Issue description from prompt template. + * @param array $cases Cases to analyze, keyed by case_id. + * @param Check_Context $check_context Check context instance. + * @return string The complete prompt. + */ + protected function build_batch_prompt( $issue_description, array $cases, Check_Context $check_context ) { + $prompt = "You are an expert in WordPress security reviewing code for security, compatibility and performance.\n\n"; + $prompt .= "You are given several cases to analyze. Each case references code in a WordPress plugin.\n"; + $prompt .= "Do not trust on code comments to determine that something is not an issue.\n"; + $prompt .= "Look up the code, understand the context and determine if there is specifically an issue with the following:\n\n"; + + $prompt .= $issue_description . "\n\n"; + + $prompt .= "## Cases\n\n"; + + foreach ( $cases as $case_id => $case ) { + $location = $case['file'] . ':' . $case['line']; + $code_context = $this->get_code_context_for_case( $case, $check_context ); + + $prompt .= '- Case ID ' . $case_id . ' : File and line "' . $location . '". '; + $prompt .= 'Issue message: "' . $case['message'] . '"'; + + if ( ! empty( $code_context ) ) { + $prompt .= "\n Code context:\n ```\n" . $code_context . "\n ```"; + } + + $prompt .= "\n\n"; + } + + $prompt .= "## Output\n\n"; + $prompt .= "Respond ONLY with valid JSON matching this structure:\n"; + $prompt .= "{\n"; + $prompt .= ' "cases": [' . "\n"; + $prompt .= " {\n"; + $prompt .= ' "case_id": "the mentioned Case ID for each case",' . "\n"; + $prompt .= ' "issue": true if there is a genuine issue (false if it is a false positive),' . "\n"; + $prompt .= ' "short_explanation": "a very short explanation in one line"' . "\n"; + $prompt .= " }\n"; + $prompt .= " ]\n"; + $prompt .= "}\n"; + + return $prompt; + } + + /** + * Gets code context for a specific case. + * + * @since 1.8.0 + * + * @param array $issue_case Case data with file, line, column. + * @param Check_Context $check_context Check context instance. + * @param int $context_lines Number of lines before and after. + * @return string Code context or empty string. + */ + protected function get_code_context_for_case( array $issue_case, Check_Context $check_context, $context_lines = 10 ) { + $file_path = $check_context->path( '/' ) . $issue_case['file']; + + if ( ! file_exists( $file_path ) || ! is_readable( $file_path ) ) { + return ''; + } + + $file_content = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + if ( empty( $file_content ) ) { + return ''; + } + + return $this->get_code_context( $file_content, $issue_case['line'], $context_lines ); + } + + /** + * Gets code context around a specific line. + * + * @since 1.8.0 + * + * @param string $file_content Full file content. + * @param int $line Line number (1-based). + * @param int $context Number of lines before and after. + * @return string Code context with line numbers. + */ + protected function get_code_context( $file_content, $line, $context = 10 ) { + if ( empty( $file_content ) ) { + return ''; + } + + $lines = explode( "\n", $file_content ); + $start = max( 0, $line - $context - 1 ); + $end = min( count( $lines ), $line + $context ); + + $context_lines = array(); + for ( $i = $start; $i < $end; $i++ ) { + $line_num = $i + 1; + $marker = ( $line_num === (int) $line ) ? ' >>>' : ' '; + $context_lines[] = sprintf( '%s %4d | %s', $marker, $line_num, $lines[ $i ] ); + } + + return implode( "\n", $context_lines ); + } + + /** + * Parses the batched AI response into individual case results. + * + * @since 1.8.0 + * + * @param string $response_text AI response text. + * @return array Array of case results. + */ + protected function parse_batch_response( $response_text ) { + if ( empty( $response_text ) ) { + return array(); + } + + // Remove markdown code fences if present. + $text = preg_replace( '/^```(?:json)?\s*\n?/m', '', $response_text ); + $text = preg_replace( '/\n?```\s*$/m', '', $text ); + $text = trim( $text ); + + // Try to find JSON object in the response. + $json_start = strpos( $text, '{' ); + $json_end = strrpos( $text, '}' ); + + if ( false === $json_start || false === $json_end || $json_end <= $json_start ) { + return array(); + } + + $json_text = substr( $text, $json_start, $json_end - $json_start + 1 ); + $decoded = json_decode( $json_text, true ); + + if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $decoded ) ) { + return array(); + } + + if ( ! isset( $decoded['cases'] ) || ! is_array( $decoded['cases'] ) ) { + return array(); + } + + $results = array(); + foreach ( $decoded['cases'] as $case ) { + if ( ! isset( $case['case_id'] ) ) { + continue; + } + + $results[] = array( + 'case_id' => (string) $case['case_id'], + 'issue' => isset( $case['issue'] ) ? (bool) $case['issue'] : true, + 'short_explanation' => isset( $case['short_explanation'] ) ? (string) $case['short_explanation'] : '', + ); + } + + return $results; + } + + /** + * Determines the prompt template filename for a given check code. + * + * @since 1.8.0 + * + * @param string $code The check code (e.g., 'WordPress.Security.EscapeOutput.OutputNotEscaped'). + * @return string Prompt template filename. + */ + protected function get_prompt_for_code( $code ) { + foreach ( self::AI_PROMPT_MAP as $prefix => $prompt_file ) { + if ( 0 === strpos( $code, $prefix ) ) { + return $prompt_file; + } + } + + return 'ai-review-generic.md'; + } + + /** + * Loads a prompt template from the prompts/ directory. + * + * @since 1.8.0 + * + * @param string $filename Prompt template filename. + * @return string|WP_Error Prompt content or WP_Error. + */ + protected function load_prompt_template( $filename ) { + if ( ! defined( 'WP_PLUGIN_CHECK_PLUGIN_DIR_PATH' ) ) { + return new WP_Error( 'plugin_constant_not_defined', __( 'Plugin constant not defined.', 'plugin-check' ) ); + } + + $path = WP_PLUGIN_CHECK_PLUGIN_DIR_PATH . 'prompts/' . $filename; + + if ( ! file_exists( $path ) ) { + return new WP_Error( + 'prompt_not_found', + sprintf( + /* translators: %s: Prompt filename. */ + __( 'AI prompt template not found: %s', 'plugin-check' ), + $filename + ) + ); + } + + $contents = (string) file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $contents = trim( $contents ); + + if ( empty( $contents ) ) { + return new WP_Error( 'prompt_empty', __( 'AI prompt template is empty.', 'plugin-check' ) ); + } + + return $contents; + } + + /** + * Gets the AI severity threshold for a given type. + * + * @since 1.8.0 + * + * @param string $type 'error' or 'warning'. + * @return int Severity threshold. + */ + protected function get_ai_severity_threshold( $type ) { + if ( class_exists( Settings_Page::class ) ) { + $default = 'error' === $type ? Settings_Page::get_severity_errors() : Settings_Page::get_severity_warnings(); + } else { + $default = 'error' === $type ? 7 : 6; + } + + /** + * Filters the AI severity threshold. + * + * @since 1.8.0 + * + * @param int $threshold Threshold from settings (7 for errors, 6 for warnings). + * @param string $type 'error' or 'warning'. + */ + return (int) apply_filters( 'wp_plugin_check_ai_severity_threshold', $default, $type ); + } + + /** + * Applies a model preference to the prompt builder. + * + * @since 1.8.0 + * + * @param object $builder Prompt builder instance. + * @param string $model_preference Model preference string. + * @return object|WP_Error Updated builder or WP_Error. + */ + protected function apply_ai_model_preference( $builder, $model_preference ) { + if ( empty( $model_preference ) ) { + return $builder; + } + + $preference = trim( (string) $model_preference ); + + // Parse provider::model format. + foreach ( array( '::', '|', ':' ) as $separator ) { + if ( false !== strpos( $preference, $separator ) ) { + list( $provider, $model ) = array_map( 'trim', explode( $separator, $preference, 2 ) ); + if ( '' !== $provider && '' !== $model ) { + $preference = array( $provider, $model ); + break; + } + } + } + + try { + $result = $builder->using_model_preference( $preference ); + return $result ? $result : $builder; + } catch ( \Exception $e ) { + return new WP_Error( + 'model_preference_error', + sprintf( + /* translators: %s: Exception message. */ + __( 'Failed to apply model preference: %s', 'plugin-check' ), + $e->getMessage() + ) + ); + } + } + + /** + * Extracts token usage from a result object. + * + * @since 1.8.0 + * + * @param object $result Result object. + * @return array|null Token usage array or null. + */ + protected function extract_ai_token_usage( $result ) { + $usage = null; + + if ( method_exists( $result, 'get_token_usage' ) ) { + $usage = $result->get_token_usage(); + } elseif ( method_exists( $result, 'getTokenUsage' ) ) { + $usage = $result->getTokenUsage(); + } + + if ( ! $usage || ! is_object( $usage ) ) { + return null; + } + + $prompt_tokens = method_exists( $usage, 'get_prompt_tokens' ) ? $usage->get_prompt_tokens() : ( method_exists( $usage, 'getPromptTokens' ) ? $usage->getPromptTokens() : null ); + $completion_tokens = method_exists( $usage, 'get_completion_tokens' ) ? $usage->get_completion_tokens() : ( method_exists( $usage, 'getCompletionTokens' ) ? $usage->getCompletionTokens() : null ); + $total_tokens = method_exists( $usage, 'get_total_tokens' ) ? $usage->get_total_tokens() : ( method_exists( $usage, 'getTotalTokens' ) ? $usage->getTotalTokens() : null ); + + if ( null === $total_tokens && null !== $prompt_tokens && null !== $completion_tokens ) { + $total_tokens = $prompt_tokens + $completion_tokens; + } + + if ( null === $prompt_tokens && null === $completion_tokens && null === $total_tokens ) { + return null; + } + + return array_filter( + array( + 'prompt_tokens' => $prompt_tokens, + 'completion_tokens' => $completion_tokens, + 'total_tokens' => $total_tokens, + ), + static function ( $value ) { + return null !== $value; + } + ); + } + + /** + * Returns an empty AI result structure. + * + * @since 1.8.0 + * + * @return array Empty result with zeroed stats. + */ + protected function empty_ai_result() { + return array( + 'analysis' => array(), + 'stats' => array( + 'tokens_spent' => 0, + 'false_positives' => 0, + 'issues_analyzed' => 0, + ), + ); + } + + /** + * Generates a unique key for an issue. + * + * @since 1.8.0 + * + * @param string $file File path. + * @param int $line Line number. + * @param int $column Column number. + * @param string $code Issue code. + * @return string Unique key. + */ + protected function get_issue_key( $file, $line, $column, $code ) { + return md5( $file . ':' . $line . ':' . $column . ':' . $code ); + } +} diff --git a/prompts/ai-review-code-obfuscation.md b/prompts/ai-review-code-obfuscation.md new file mode 100644 index 000000000..eb30b8f23 --- /dev/null +++ b/prompts/ai-review-code-obfuscation.md @@ -0,0 +1,15 @@ +## Code Obfuscation Issues + +A code obfuscation issue occurs when code is intentionally made difficult to read or understand, which is not allowed for plugins hosted on WordPress.org. + +Using the case as a reference, check the code to determine if it is genuinely obfuscated or if it is a false positive. + +Details: +- Obfuscated code includes: base64-encoded PHP code that is decoded and executed, eval'd strings, encoded variable names, packed JavaScript. +- Minified JavaScript or CSS is NOT obfuscation — it is a separate check. +- Base64-encoded data used for images, fonts, or non-executable content is NOT obfuscation. +- Encoded strings used as configuration values, API tokens, or data payloads (not executed as code) are NOT obfuscation. +- `base64_decode()` used to decode data (not code) is generally acceptable. +- `eval()` usage is always flagged regardless of context. +- `str_rot13()` used on executable code is obfuscation. +- Compressed/packed JavaScript (e.g., Dean Edwards packer) is considered obfuscation. diff --git a/prompts/ai-review-direct-db-queries.md b/prompts/ai-review-direct-db-queries.md new file mode 100644 index 000000000..5bf059b38 --- /dev/null +++ b/prompts/ai-review-direct-db-queries.md @@ -0,0 +1,15 @@ +## Direct Database Query Issues + +A direct database query issue occurs when SQL queries are not properly prepared before execution, potentially leading to SQL injection vulnerabilities. + +Using the case as a reference, check the code to see if the database query is properly prepared. + +Details: +- All SQL queries with variable data must use `$wpdb->prepare()`. +- Queries using only hardcoded values (no variables) do not need `$wpdb->prepare()`. +- `$wpdb->insert()`, `$wpdb->update()`, `$wpdb->delete()`, and `$wpdb->replace()` handle their own preparation when format parameters are provided. +- Table names cannot be prepared with `$wpdb->prepare()` — using `$wpdb->prefix` concatenation for table names is acceptable. +- Column names also cannot be prepared — they should be whitelisted/validated instead. +- `IN` clauses with dynamic lists need special handling with multiple placeholders. +- If the variable used in the query comes from a trusted source (e.g., `$wpdb->posts`, `$wpdb->prefix`), it may not be an issue. +- Interpolated variables in SQL strings that are not user-controlled may be flagged but could be acceptable if the source is verified. diff --git a/prompts/ai-review-generic.md b/prompts/ai-review-generic.md new file mode 100644 index 000000000..ed5d65dc7 --- /dev/null +++ b/prompts/ai-review-generic.md @@ -0,0 +1,12 @@ +## Generic Code Review + +Analyze the flagged code to determine if the reported issue is a genuine problem or a false positive. + +Using the case as a reference, check the code to see if the issue is valid considering the full context. + +Details: +- Consider the broader context of the code, not just the flagged line. +- Check if the issue is mitigated by code elsewhere in the same function or file. +- Consider WordPress coding standards and best practices. +- If the flagged code follows a common WordPress pattern that is generally accepted, it may be a false positive. +- Consider whether the code is in a context where the flagged issue is not applicable (e.g., admin-only code, CLI context, etc.). diff --git a/prompts/ai-review-late-escaping.md b/prompts/ai-review-late-escaping.md new file mode 100644 index 000000000..f5049395b --- /dev/null +++ b/prompts/ai-review-late-escaping.md @@ -0,0 +1,16 @@ +## Escaping Issues + +An escaping issue is data that is not escaped before being output. + +Using the case as a reference, check the code to see if the case in question has been escaped. + +Details: +- Data must be escaped as late as possible, ideally as part of the output statement. +- Escaping earlier in the code and then outputting later is not considered late escaping. +- Common escaping functions: `esc_html()`, `esc_attr()`, `esc_url()`, `esc_js()`, `esc_textarea()`, `wp_kses()`, `wp_kses_post()`, `wp_kses_data()`. +- `__()`, `_e()`, `_x()` and similar i18n functions do NOT escape data. +- `printf()` / `sprintf()` do NOT escape data by themselves. +- If the value being output is a hardcoded string with no variables, it is not an issue. +- If the value is the direct return of an escaping function, it is not an issue. +- If the value comes from a function that internally escapes its output (e.g., `get_avatar()`, `paginate_links()`, `wp_nonce_field()`), it may not be an issue depending on context. +- Check if the data flows through any escaping function before the output point. diff --git a/prompts/ai-review-nonce-verification.md b/prompts/ai-review-nonce-verification.md new file mode 100644 index 000000000..1743ccbed --- /dev/null +++ b/prompts/ai-review-nonce-verification.md @@ -0,0 +1,16 @@ +## Nonce Verification Issues + +A nonce verification issue occurs when processing form submissions or AJAX requests without verifying a nonce, or when accessing `$_POST`, `$_GET`, `$_REQUEST` data without prior nonce verification. + +Using the case as a reference, check the code to see if nonce verification is properly implemented. + +Details: +- Nonce verification functions: `wp_verify_nonce()`, `check_admin_referer()`, `check_ajax_referer()`. +- Nonce verification should happen before processing any user input. +- If the code accesses `$_POST`, `$_GET`, or `$_REQUEST` but is only reading data for display (not processing/saving), it may be acceptable in some contexts. +- AJAX handlers should use `check_ajax_referer()` or `wp_verify_nonce()`. +- Form processing should use `check_admin_referer()` or `wp_verify_nonce()`. +- If the nonce check happens earlier in the same function or in a parent/calling function, it is not an issue. +- REST API endpoints use a different authentication mechanism and do not require nonces. +- If the code is in a REST API callback with a proper `permission_callback`, nonce verification is not required. +- Capability checks (`current_user_can()`) alone are not sufficient — nonces are still needed for form submissions. diff --git a/prompts/ai-review-plugin-updater.md b/prompts/ai-review-plugin-updater.md new file mode 100644 index 000000000..a99bff123 --- /dev/null +++ b/prompts/ai-review-plugin-updater.md @@ -0,0 +1,13 @@ +## Plugin Updater Issues + +A plugin updater issue occurs when a plugin includes its own update mechanism instead of relying on the WordPress.org update system. + +Using the case as a reference, check the code to determine if the plugin is implementing a custom update mechanism. + +Details: +- Plugins hosted on WordPress.org must not include their own update mechanisms. +- Common patterns: hooking into `pre_set_site_transient_update_plugins`, `site_transient_update_plugins`, or using custom update checker libraries. +- Libraries like `plugin-update-checker`, `YahnisElsts/plugin-update-checker`, or custom classes that check external servers for updates are not allowed. +- If the code is part of a library that is excluded by default (e.g., in a `vendor/` directory), it may not be flagged. +- License key validation that gates features (not updates) is a separate concern. +- Auto-update UI modifications (enabling/disabling WordPress core auto-updates) are generally acceptable. diff --git a/prompts/ai-review-sanitization.md b/prompts/ai-review-sanitization.md new file mode 100644 index 000000000..4b220fa25 --- /dev/null +++ b/prompts/ai-review-sanitization.md @@ -0,0 +1,15 @@ +## Sanitization Issues + +A sanitization issue is user input data that is not sanitized before being stored or used. + +Using the case as a reference, check the code to see if the case in question has been properly sanitized. + +Details: +- Data from `$_POST`, `$_GET`, `$_REQUEST`, `$_SERVER`, `$_COOKIE` must be sanitized. +- Common sanitization functions: `sanitize_text_field()`, `sanitize_email()`, `sanitize_file_name()`, `sanitize_title()`, `sanitize_url()`, `absint()`, `intval()`, `wp_kses()`, `wp_kses_post()`. +- Type casting (`(int)`, `(float)`, `(bool)`) counts as sanitization for the respective types. +- `isset()` and `empty()` are NOT sanitization functions. +- `wp_unslash()` is NOT a sanitization function by itself. +- If the data is passed directly to a function that handles its own sanitization (e.g., `update_option()` with a registered sanitize callback), it may not be an issue. +- If the data is only used in a comparison (e.g., `if ( $_GET['action'] === 'delete' )`), the risk is lower but sanitization is still recommended. +- Array access on superglobals should also be sanitized. diff --git a/prompts/ai-review-setting-sanitization.md b/prompts/ai-review-setting-sanitization.md new file mode 100644 index 000000000..0bad1353b --- /dev/null +++ b/prompts/ai-review-setting-sanitization.md @@ -0,0 +1,13 @@ +## Setting Sanitization Issues + +A setting sanitization issue occurs when `register_setting()` is called without a proper sanitize callback, leaving settings data unsanitized. + +Using the case as a reference, check the code to determine if the setting registration includes proper sanitization. + +Details: +- `register_setting()` should include a `sanitize_callback` argument. +- The sanitize callback should properly validate and sanitize the data before it is saved to the database. +- If `register_setting()` is called with a third argument that includes `sanitize_callback`, it is properly sanitized. +- If the setting is registered with a `type` and `show_in_rest` with a `schema`, WordPress may handle some validation, but explicit sanitization is still recommended. +- Settings registered with `sanitize_option_{$option}` filter are also considered sanitized. +- If the setting only stores simple boolean or integer values and uses appropriate type casting, it may be acceptable. diff --git a/templates/admin-page.php b/templates/admin-page.php index f9e1e8257..d8bfe935e 100644 --- a/templates/admin-page.php +++ b/templates/admin-page.php @@ -80,11 +80,15 @@ +
+

+

+ +

+
- -

diff --git a/templates/results-row.php b/templates/results-row.php index fba068492..a2ef2cd2d 100644 --- a/templates/results-row.php +++ b/templates/results-row.php @@ -21,6 +21,34 @@ <# } #> + <# if ( data.ai_analysis ) { #> +
+ <# if ( data.ai_analysis.is_false_positive ) { #> + + + + <# if ( data.ai_analysis.confidence ) { #> + (: {{Math.round(data.ai_analysis.confidence * 100)}}%) + <# } #> + + <# } else { #> + + + + <# if ( data.ai_analysis.confidence ) { #> + (: {{Math.round(data.ai_analysis.confidence * 100)}}%) + <# } #> + + <# } #> + <# if ( data.ai_analysis.reasoning ) { #> +
+ {{{data.ai_analysis.reasoning}}} + <# } #> + <# if ( data.ai_analysis.recommendation ) { #> +
+ : {{{data.ai_analysis.recommendation}}} + <# } #> + <# } #> <# if ( data.hasLinks ) { #>