diff --git a/assets/css/plugin-check-admin.css b/assets/css/plugin-check-admin.css index 9f7f2281f..115a348f7 100644 --- a/assets/css/plugin-check-admin.css +++ b/assets/css/plugin-check-admin.css @@ -62,6 +62,82 @@ } } +/* 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-icon { + font-size: 14px; + line-height: 1; +} + +.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__false-positives { + margin: 1.5em 0; + border: 1px solid #dcdcde; + background: #fff; +} + +.plugin-check__false-positives summary { + padding: 12px 14px; + font-weight: 600; + cursor: pointer; +} + +.plugin-check__false-positive-results { + padding: 0 14px 14px; +} + +#plugin-check__results .plugin-check__false-positive-results h4:first-child { + margin-top: 12px; +} + +.plugin-check__false-positive-results table.plugin-check__results-table { + margin-bottom: 1em; +} + .plugin-check__options { display: flex; } @@ -70,6 +146,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 816c71811..22227cc4e 100644 --- a/assets/js/plugin-check-admin.js +++ b/assets/js/plugin-check-admin.js @@ -29,6 +29,7 @@ } let aggregatedResults = createEmptyAggregatedResults(); + let falsePositiveResults = createEmptyAggregatedResults(); let checksCompleted = false; exportContainer.classList.add( 'is-hidden' ); exportContainer.addEventListener( 'click', onExportContainerClick ); @@ -36,6 +37,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 +89,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 +141,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() { @@ -144,14 +158,35 @@ function resetAggregatedResults() { aggregatedResults = createEmptyAggregatedResults(); + falsePositiveResults = createEmptyAggregatedResults(); } function mergeAggregatedResults( results ) { - if ( results.errors ) { - mergeResultTree( aggregatedResults.errors, results.errors ); + const splitResults = splitResultsByFalsePositive( results ); + + if ( splitResults.actionable.errors ) { + mergeResultTree( + aggregatedResults.errors, + splitResults.actionable.errors + ); } - if ( results.warnings ) { - mergeResultTree( aggregatedResults.warnings, results.warnings ); + if ( splitResults.actionable.warnings ) { + mergeResultTree( + aggregatedResults.warnings, + splitResults.actionable.warnings + ); + } + if ( splitResults.falsePositive.errors ) { + mergeResultTree( + falsePositiveResults.errors, + splitResults.falsePositive.errors + ); + } + if ( splitResults.falsePositive.warnings ) { + mergeResultTree( + falsePositiveResults.warnings, + splitResults.falsePositive.warnings + ); } } @@ -189,6 +224,106 @@ } } + function splitResultsByFalsePositive( results ) { + const splitResults = { + actionable: createEmptyAggregatedResults(), + falsePositive: createEmptyAggregatedResults(), + }; + const aiAnalysis = + results && results.ai_analysis ? results.ai_analysis : {}; + + splitResultType( + results && results.errors ? results.errors : {}, + splitResults.actionable.errors, + splitResults.falsePositive.errors, + aiAnalysis + ); + splitResultType( + results && results.warnings ? results.warnings : {}, + splitResults.actionable.warnings, + splitResults.falsePositive.warnings, + aiAnalysis + ); + + return splitResults; + } + + function splitResultType( results, actionable, falsePositive, aiAnalysis ) { + for ( const file of Object.keys( results ) ) { + const lines = results[ file ] || {}; + + for ( const line of Object.keys( lines ) ) { + const columns = lines[ line ] || {}; + + for ( const column of Object.keys( columns ) ) { + for ( const entry of columns[ column ] || [] ) { + const aiData = findAiAnalysisForIssue( + file, + line, + column, + entry.code, + aiAnalysis + ); + const target = + aiData && aiData.is_false_positive + ? falsePositive + : actionable; + const targetEntry = cloneResultEntry( entry ); + + if ( aiData ) { + targetEntry.ai_analysis = aiData; + } + + addResultEntry( + target, + file, + line, + column, + targetEntry + ); + } + } + } + } + } + + function addResultEntry( target, file, line, column, entry ) { + if ( ! hasOwn( target, file ) ) { + target[ file ] = {}; + } + if ( ! hasOwn( target[ file ], line ) ) { + target[ file ][ line ] = {}; + } + if ( ! hasOwn( target[ file ][ line ], column ) ) { + target[ file ][ line ][ column ] = []; + } + + target[ file ][ line ][ column ].push( entry ); + } + + function findAiAnalysisForIssue( file, line, column, code, aiAnalysis ) { + if ( ! aiAnalysis || typeof aiAnalysis !== 'object' ) { + return null; + } + + const analysisEntries = Object.values( aiAnalysis ); + return ( + analysisEntries.find( function ( analysis ) { + if ( ! analysis || typeof analysis !== 'object' ) { + return false; + } + + return ( + String( analysis.file || '' ) === String( file || '' ) && + parseInt( analysis.line, 10 ) === parseInt( line, 10 ) && + parseInt( analysis.column, 10 ) === + parseInt( column, 10 ) && + String( analysis.code || '' ) === String( code || '' ) + ); + } ) || null + ); + } + function cloneResultEntry( entry ) { return { ...entry }; } @@ -196,8 +331,24 @@ function hasAggregatedResults() { return ( hasEntries( aggregatedResults.errors ) || - hasEntries( aggregatedResults.warnings ) + hasEntries( aggregatedResults.warnings ) || + hasEntries( falsePositiveResults.errors ) || + hasEntries( falsePositiveResults.warnings ) + ); + } + + function getExportResults() { + const exportResults = createEmptyAggregatedResults(); + + mergeResultTree( exportResults.errors, aggregatedResults.errors ); + mergeResultTree( exportResults.warnings, aggregatedResults.warnings ); + mergeResultTree( exportResults.errors, falsePositiveResults.errors ); + mergeResultTree( + exportResults.warnings, + falsePositiveResults.warnings ); + + return exportResults; } function hasEntries( tree ) { @@ -369,7 +520,7 @@ payload.append( 'plugin', pluginsList.value ); } payload.append( 'plugin_label', getSelectedPluginLabel() ); - payload.append( 'results', JSON.stringify( aggregatedResults ) ); + payload.append( 'results', JSON.stringify( getExportResults() ) ); return fetch( ajaxurl, { method: 'POST', @@ -444,6 +595,7 @@ '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 ] ); @@ -516,6 +668,7 @@ '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 ) { @@ -559,11 +712,17 @@ */ 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 ] ); - const errorsLength = Object.values( results.errors ).length; - const warningsLength = Object.values( results.warnings ).length; + const splitResults = splitResultsByFalsePositive( results ); + const errorsLength = countResultTree( + splitResults.actionable.errors + ); + const warningsLength = countResultTree( + splitResults.actionable.warnings + ); if ( isSuccessMessage && ( errorsLength > 0 || warningsLength > 0 ) @@ -572,12 +731,44 @@ } mergeAggregatedResults( results ); renderResults( results ); + + // Collect AI stats across checks. + if ( results.ai_stats ) { + if ( ! aiStats ) { + aiStats = { + tokens_spent: 0, + input_tokens: 0, + output_tokens: 0, + false_positives: 0, + issues_analyzed: 0, + models_used: [], + providers_used: [], + }; + } + aiStats.tokens_spent += results.ai_stats.tokens_spent || 0; + aiStats.input_tokens += results.ai_stats.input_tokens || 0; + aiStats.output_tokens += + results.ai_stats.output_tokens || 0; + aiStats.false_positives += + results.ai_stats.false_positives || 0; + aiStats.issues_analyzed += + results.ai_stats.issues_analyzed || 0; + if ( results.ai_stats.model_used ) { + aiStats.models_used.push( results.ai_stats.model_used ); + } + if ( results.ai_stats.provider_used ) { + aiStats.providers_used.push( + results.ai_stats.provider_used + ); + } + } } catch { // Ignore for now. } } - renderResultsMessage( isSuccessMessage ); + renderFalsePositiveResults(); + renderResultsMessage( isSuccessMessage, aiStats ); } /** @@ -586,8 +777,9 @@ * @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 ) { // Count errors and warnings to determine notice severity and compose the message. const { errorCount, warningCount } = isSuccessMessage ? { errorCount: 0, warningCount: 0 } @@ -674,6 +866,67 @@ } } + if ( aiStats ) { + const aiParts = []; + const modelsUsed = [ + ...new Set( aiStats.models_used.filter( Boolean ) ), + ]; + const providersUsed = [ + ...new Set( aiStats.providers_used.filter( Boolean ) ), + ]; + + if ( aiStats.false_positives > 0 ) { + aiParts.push( + 'AI detected ' + + aiStats.false_positives + + ' ' + + ( 1 === aiStats.false_positives + ? 'possible false positive' + : 'possible false positives' ) + ); + } + if ( aiStats.input_tokens > 0 ) { + aiParts.push( + 'Input tokens: ' + aiStats.input_tokens.toLocaleString() + ); + } + if ( aiStats.output_tokens > 0 ) { + aiParts.push( + 'Output tokens: ' + aiStats.output_tokens.toLocaleString() + ); + } + if ( aiStats.tokens_spent > 0 ) { + aiParts.push( + 'Tokens spent: ' + aiStats.tokens_spent.toLocaleString() + ); + } + if ( modelsUsed.length > 0 || providersUsed.length > 0 ) { + if ( 1 === modelsUsed.length && 1 === providersUsed.length ) { + aiParts.push( + 'Model: ' + providersUsed[ 0 ] + ' ' + modelsUsed[ 0 ] + ); + } else if ( + modelsUsed.length > 0 && + providersUsed.length > 0 + ) { + aiParts.push( + 'Model: ' + + providersUsed.join( ', ' ) + + ' ' + + modelsUsed.join( ', ' ) + ); + } else if ( modelsUsed.length > 0 ) { + aiParts.push( 'Model: ' + modelsUsed.join( ', ' ) ); + } else { + aiParts.push( 'Model: ' + providersUsed.join( ', ' ) ); + } + } + if ( aiParts.length > 0 ) { + messageText += /[.!?]\s*$/.test( messageText ) ? ' ' : '. '; + messageText += aiParts.join( '. ' ); + } + } + resultsContainer.innerHTML = renderTemplate( 'plugin-check-results-complete', { type: messageType, @@ -703,6 +956,7 @@ '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 ) { @@ -725,6 +979,20 @@ 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; } ); } @@ -763,20 +1031,29 @@ * @param {Object} results The results object. */ function renderResults( results ) { - const { errors, warnings } = results; + const { ai_analysis: aiAnalysis } = results || {}; + const splitResults = splitResultsByFalsePositive( results ); + const errors = splitResults.actionable.errors; + const warnings = splitResults.actionable.warnings; + // 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 ], + aiAnalysis + ); delete warnings[ file ]; } else { - renderFileResults( file, errors[ file ], [] ); + renderFileResults( file, errors[ file ], [], aiAnalysis ); } } // Render remaining files with only warnings. for ( const file in warnings ) { - renderFileResults( file, [], warnings[ file ] ); + renderFileResults( file, [], warnings[ file ], aiAnalysis ); } } @@ -785,11 +1062,19 @@ * * @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} aiAnalysis AI analysis results. */ - function renderFileResults( file, errors, warnings ) { + function renderFileResults( file, errors, warnings, aiAnalysis ) { + if ( + ! hasEntries( { [ file ]: errors } ) && + ! hasEntries( { [ file ]: warnings } ) + ) { + return; + } + const index = Date.now().toString( 36 ) + Math.random().toString( 36 ).substr( 2 ); @@ -808,8 +1093,196 @@ ); // Render results to the table. - renderResultRows( 'ERROR', errors, resultsTable, hasLinks ); - renderResultRows( 'WARNING', warnings, resultsTable, hasLinks ); + renderResultRows( + 'ERROR', + errors, + resultsTable, + hasLinks, + aiAnalysis, + file + ); + renderResultRows( + 'WARNING', + warnings, + resultsTable, + hasLinks, + aiAnalysis, + file + ); + } + + /** + * Renders the possible false positives at the end of the results. + * + * @since 2.0.0 + */ + function renderFalsePositiveResults() { + if ( + ! hasEntries( falsePositiveResults.errors ) && + ! hasEntries( falsePositiveResults.warnings ) + ) { + return; + } + + const index = + Date.now().toString( 36 ) + + Math.random().toString( 36 ).substr( 2 ); + const falsePositiveCount = + countResultTree( falsePositiveResults.errors ) + + countResultTree( falsePositiveResults.warnings ); + + resultsContainer.innerHTML += renderTemplate( + 'plugin-check-results-false-positives', + { + index, + count: falsePositiveCount, + } + ); + + const falsePositiveContainer = document.getElementById( + 'plugin-check__false-positive-results-' + index + ); + + if ( ! falsePositiveContainer ) { + return; + } + + renderResultCollection( + falsePositiveResults.errors, + falsePositiveResults.warnings, + falsePositiveContainer + ); + } + + /** + * Renders a result collection into a specific container. + * + * @since 2.0.0 + * + * @param {Object} containerErrors Error results. + * @param {Object} containerWarnings Warning results. + * @param {Object} container Container element. + */ + function renderResultCollection( + containerErrors, + containerWarnings, + container + ) { + const errors = cloneResultTree( containerErrors ); + const warnings = cloneResultTree( containerWarnings ); + + for ( const file in errors ) { + if ( warnings[ file ] ) { + renderFileResultsInContainer( + file, + errors[ file ], + warnings[ file ], + container + ); + delete warnings[ file ]; + } else { + renderFileResultsInContainer( + file, + errors[ file ], + [], + container + ); + } + } + + for ( const file in warnings ) { + renderFileResultsInContainer( + file, + [], + warnings[ file ], + container + ); + } + } + + /** + * Renders one file's results into a specific container. + * + * @since 2.0.0 + * + * @param {string} file File name. + * @param {Object} errors Error results. + * @param {Object} warnings Warning results. + * @param {Object} container Container element. + */ + function renderFileResultsInContainer( file, errors, warnings, container ) { + if ( + ! hasEntries( { [ file ]: errors } ) && + ! hasEntries( { [ file ]: warnings } ) + ) { + return; + } + + const index = + Date.now().toString( 36 ) + + Math.random().toString( 36 ).substr( 2 ); + const hasLinks = + hasLinksInResults( errors ) || hasLinksInResults( warnings ); + + container.innerHTML += renderTemplate( 'plugin-check-results-table', { + file, + index, + hasLinks, + } ); + + const resultsTable = document.getElementById( + 'plugin-check__results-body-' + index + ); + + renderResultRows( 'ERROR', errors, resultsTable, hasLinks, {}, file ); + renderResultRows( + 'WARNING', + warnings, + resultsTable, + hasLinks, + {}, + file + ); + } + + /** + * Clones a result tree. + * + * @since 2.0.0 + * + * @param {Object} tree Result tree. + * @return {Object} Cloned result tree. + */ + function cloneResultTree( tree ) { + const clone = {}; + mergeResultTree( clone, tree || {} ); + return clone; + } + + /** + * Counts all results in a result tree. + * + * @since 2.0.0 + * + * @param {Object} tree Result tree. + * @return {number} Result count. + */ + function countResultTree( tree ) { + let count = 0; + + for ( const file of Object.keys( tree || {} ) ) { + const lines = tree[ file ] || {}; + + for ( const line of Object.keys( lines ) ) { + const columns = lines[ line ] || {}; + + for ( const column of Object.keys( columns ) ) { + count += ( columns[ column ] || [] ).length; + } + } + } + + return count; } /** @@ -838,12 +1311,21 @@ * * @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} aiAnalysis AI analysis results. + * @param {string} file The file path. */ - function renderResultRows( type, results, table, hasLinks ) { + function renderResultRows( + type, + results, + table, + hasLinks, + aiAnalysis, + file + ) { // Loop over each result by the line, column and messages. for ( const line in results ) { for ( const column in results[ line ] ) { @@ -852,19 +1334,39 @@ const docs = results[ line ][ column ][ i ].docs; const code = results[ line ][ column ][ i ].code; const link = results[ line ][ column ][ i ].link; - - table.innerHTML += renderTemplate( - 'plugin-check-results-row', - { + const storedAiData = + results[ line ][ column ][ i ].ai_analysis || null; + + // Find AI analysis for this issue. + const aiData = + storedAiData || + findAiAnalysisForIssue( + file, line, column, - type, - message, - docs, code, - link, - hasLinks, - } + aiAnalysis + ); + + 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', + rowData ); } } diff --git a/includes/Admin/Admin_AJAX.php b/includes/Admin/Admin_AJAX.php index 435401289..eef695078 100644 --- a/includes/Admin/Admin_AJAX.php +++ b/includes/Admin/Admin_AJAX.php @@ -228,16 +228,19 @@ 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(); + $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, 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(); @@ -260,6 +263,8 @@ public function get_checks_to_run() { * Run checks. * * @since 1.0.0 + * + * @SuppressWarnings(PHPMD.NPathComplexity) */ public function run_checks() { $this->check_request_validity(); @@ -270,11 +275,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 +313,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 +355,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 b31e83b8f..5dd4881e8 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' ), /* translators: %d: Number of errors found. */ 'errorString' => __( '%d error', 'plugin-check' ), /* translators: %d: Number of errors found. */ @@ -389,6 +390,17 @@ public function admin_footer() { ) ); + ob_start(); + require WP_PLUGIN_CHECK_PLUGIN_DIR_PATH . 'templates/results-false-positives.php'; + $results_false_positives_template = ob_get_clean(); + wp_print_inline_script_tag( + $results_false_positives_template, + array( + 'id' => 'tmpl-plugin-check-results-false-positives', + 'type' => 'text/template', + ) + ); + ob_start(); require WP_PLUGIN_CHECK_PLUGIN_DIR_PATH . 'templates/results-complete.php'; $results_row_template = ob_get_clean(); diff --git a/includes/Admin/Settings_Page.php b/includes/Admin/Settings_Page.php new file mode 100644 index 000000000..5d14fdcbb --- /dev/null +++ b/includes/Admin/Settings_Page.php @@ -0,0 +1,390 @@ +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 2.0.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 2.0.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' ), + esc_url( admin_url( 'options-general.php' ) ) + ); + + echo wp_kses( + $configured_connector_message, + array( 'a' => array( 'href' => array() ) ) + ); + ?> +

+
+ + +

+ +

+ 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 2.0.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 2.0.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 2.0.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 2.0.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 --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 --ai + * wp plugin check akismet --ai --ai-model=openai::gpt-4o * * @subcommand check * @@ -181,6 +189,8 @@ public function check( $args, $assoc_args ) { 'slug' => '', 'ignore-codes' => '', 'mode' => 'new', + 'ai' => false, + 'ai-model' => '', ) ); @@ -241,6 +251,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['ai'] ); + if ( ! empty( $options['ai-model'] ) ) { + $runner->set_ai_model_preference( $options['ai-model'] ); + } } catch ( Exception $error ) { WP_CLI::error( $error->getMessage() ); } @@ -267,8 +281,40 @@ static function ( $dirs ) use ( $excluded_files ) { $warnings = $result->get_warnings(); } + // Get AI analysis results if available. + $ai_analysis = array(); + if ( $result && $options['ai'] ) { + $ai_analysis = $result->get_ai_analysis(); + } + + // Get AI statistics if available. + $ai_stats = array(); + if ( $result && $options['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 possible false positives, %2$s: "possible false positive(s)" label. + __( ' AI detected %1$d %2$s', 'plugin-check' ), + $ai_stats['false_positives'], + _n( 'possible false positive', 'possible 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; } @@ -353,6 +399,13 @@ static function ( $dirs ) use ( $excluded_files ) { return; } + $false_positive_results = array(); + if ( ! empty( $ai_analysis ) ) { + $split_results = $this->split_false_positive_results( $all_results, $ai_analysis ); + $all_results = $split_results['actionable']; + $false_positive_results = $split_results['false_positives']; + } + // Group results by file. $results_by_file = array(); @@ -363,6 +416,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, $false_positive_results ); + } } /** @@ -643,6 +701,140 @@ private function display_results( $formatter, $file_name, $file_results ) { WP_CLI::line(); } + /** + * Splits likely false positives out of the main check results. + * + * @since 2.0.0 + * + * @param array $results Check results. + * @param array $ai_analysis AI analysis results. + * @return array Results split into actionable and false positive groups. + */ + private function split_false_positive_results( array $results, array $ai_analysis ) { + $split_results = array( + 'actionable' => array(), + 'false_positives' => array(), + ); + + foreach ( $results as $item ) { + $analysis = $this->find_ai_analysis_for_result( $item, $ai_analysis ); + + if ( ! empty( $analysis['is_false_positive'] ) ) { + if ( ! empty( $analysis['reasoning'] ) ) { + $item['reasoning'] = $analysis['reasoning']; + } + $split_results['false_positives'][] = $item; + continue; + } + + $split_results['actionable'][] = $item; + } + + return $split_results; + } + + /** + * Finds the AI analysis entry for a result item. + * + * @since 2.0.0 + * + * @param array $item Result item. + * @param array $ai_analysis AI analysis results. + * @return array|null AI analysis entry, or null if none is found. + */ + private function find_ai_analysis_for_result( array $item, array $ai_analysis ) { + foreach ( $ai_analysis as $analysis ) { + if ( ! is_array( $analysis ) ) { + continue; + } + + if ( + (string) ( $analysis['file'] ?? '' ) === (string) ( $item['file'] ?? '' ) && + (int) ( $analysis['line'] ?? 0 ) === (int) ( $item['line'] ?? 0 ) && + (int) ( $analysis['column'] ?? 0 ) === (int) ( $item['column'] ?? 0 ) && + (string) ( $analysis['code'] ?? '' ) === (string) ( $item['code'] ?? '' ) + ) { + return $analysis; + } + } + + return null; + } + + /** + * Displays AI analysis summary. + * + * @since 2.0.0 + * + * @param array $ai_analysis AI analysis results. + * @param array $ai_stats AI statistics. + * @param array $false_positive_results False positive results. + */ + private function display_ai_summary( + array $ai_analysis, + array $ai_stats, + array $false_positive_results = array() + ) { + WP_CLI::line( '' ); + WP_CLI::line( str_repeat( '─', 60 ) ); + WP_CLI::line( '✨ ' . __( 'AI Possible 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 possible false positives detected. */ + __( 'Possible 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. + if ( ! empty( $false_positive_results ) ) { + WP_CLI::line( '' ); + WP_CLI::line( __( 'Possible false positives:', 'plugin-check' ) ); + + foreach ( $false_positive_results 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'] : $item['message'] + ) + ); + } + } + + 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..8f66010ac 100644 --- a/includes/Checker/Abstract_Check_Runner.php +++ b/includes/Checker/Abstract_Check_Runner.php @@ -8,8 +8,10 @@ namespace WordPress\Plugin_Check\Checker; use Exception; +use WordPress\Plugin_Check\Admin\Settings_Page; 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 +24,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 +34,22 @@ abstract class Abstract_Check_Runner implements Check_Runner { */ protected $initialized_early; + /** + * Whether to use AI analysis for false positive detection. + * + * @since 2.0.0 + * @var bool + */ + protected $use_ai = false; + + /** + * AI model preference for analysis. + * + * @since 2.0.0 + * @var string + */ + protected $ai_model_preference = ''; + /** * The check slugs to run. * @@ -293,6 +313,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 2.0.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 2.0.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 2.0.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 +444,26 @@ final public function run() { $results = $this->get_checks_instance()->run_checks( $this->get_check_context(), $checks, $this ); + $ai_analysis = array(); + $ai_stats = array(); + + // 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( Settings_Page::class ) ) { + $model_preference = 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..b2f43e033 100644 --- a/includes/Checker/Check_Result.php +++ b/includes/Checker/Check_Result.php @@ -11,6 +11,8 @@ * Result for running checks on a plugin. * * @since 1.0.0 + * + * @SuppressWarnings(PHPMD.TooManyPublicMethods) */ final class Check_Result { @@ -54,6 +56,22 @@ final class Check_Result { */ protected $warning_count = 0; + /** + * AI analysis results for false positives. + * + * @since 2.0.0 + * @var array + */ + protected $ai_analysis = array(); + + /** + * AI statistics (tokens spent, false positives count, etc.). + * + * @since 2.0.0 + * @var array + */ + protected $ai_stats = array(); + /** * Sets the context for the plugin to check. * @@ -144,6 +162,60 @@ public function add_message( $error, $message, $args = array() ) { } } + /** + * Transforms existing messages. + * + * The callback receives the message data and location. Return an array with + * updated data to keep the message, or false/null to remove it. The returned + * array may include `error`, `file`, `line`, or `column` to move the message. + * + * @since 2.0.0 + * + * @param callable $callback Callback to transform each message. + */ + public function transform_messages( callable $callback ) { + $collections = array( + true => $this->errors, + false => $this->warnings, + ); + + $this->errors = array(); + $this->warnings = array(); + $this->error_count = 0; + $this->warning_count = 0; + + foreach ( $collections as $is_error => $collection ) { + foreach ( $collection as $file => $lines ) { + foreach ( $lines as $line => $columns ) { + foreach ( $columns as $column => $messages ) { + foreach ( $messages as $message ) { + $updated = $callback( $message, (bool) $is_error, $file, $line, $column ); + if ( empty( $updated ) || ! is_array( $updated ) ) { + continue; + } + + if ( empty( $updated['message'] ) ) { + continue; + } + + $new_error = array_key_exists( 'error', $updated ) ? (bool) $updated['error'] : (bool) $is_error; + $new_file = array_key_exists( 'file', $updated ) ? (string) $updated['file'] : (string) $file; + $new_line = array_key_exists( 'line', $updated ) ? (int) $updated['line'] : (int) $line; + $new_column = array_key_exists( 'column', $updated ) ? (int) $updated['column'] : (int) $column; + + unset( $updated['error'], $updated['file'], $updated['line'], $updated['column'] ); + $updated['file'] = $new_file; + $updated['line'] = $new_line; + $updated['column'] = $new_column; + + $this->add_message( $new_error, $updated['message'], $updated ); + } + } + } + } + } + } + /** * Returns all errors. * @@ -187,4 +259,48 @@ public function get_error_count() { public function get_warning_count() { return $this->warning_count; } + + /** + * Sets AI analysis results. + * + * @since 2.0.0 + * + * @param array $analysis AI analysis results. + */ + public function set_ai_analysis( array $analysis ) { + $this->ai_analysis = $analysis; + } + + /** + * Returns AI analysis results. + * + * @since 2.0.0 + * + * @return array AI analysis results. + */ + public function get_ai_analysis() { + return $this->ai_analysis; + } + + /** + * Sets AI statistics. + * + * @since 2.0.0 + * + * @param array $stats AI statistics. + */ + public function set_ai_stats( array $stats ) { + $this->ai_stats = $stats; + } + + /** + * Returns AI statistics. + * + * @since 2.0.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..6dfb0a540 --- /dev/null +++ b/includes/Traits/AI_Analyzer.php @@ -0,0 +1,923 @@ + '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', + ); + } + + /** + * 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 2.0.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. + * + * @SuppressWarnings(PHPMD.NPathComplexity) + */ + 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; + $input_tokens = 0; + $output_tokens = 0; + $false_positives = 0; + $issues_analyzed = 0; + $models_used = array(); + $providers_used = array(); + + 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']; + } + if ( isset( $batch_result['token_usage']['prompt_tokens'] ) ) { + $input_tokens += (int) $batch_result['token_usage']['prompt_tokens']; + } + if ( isset( $batch_result['token_usage']['completion_tokens'] ) ) { + $output_tokens += (int) $batch_result['token_usage']['completion_tokens']; + } + if ( ! empty( $batch_result['model_used'] ) ) { + $models_used[] = (string) $batch_result['model_used']; + } + if ( ! empty( $batch_result['provider_used'] ) ) { + $providers_used[] = (string) $batch_result['provider_used']; + } + } + + return array( + 'analysis' => $analysis_results, + 'stats' => array( + 'tokens_spent' => $total_tokens, + 'input_tokens' => $input_tokens, + 'output_tokens' => $output_tokens, + 'false_positives' => $false_positives, + 'issues_analyzed' => $issues_analyzed, + 'model_used' => implode( ', ', array_unique( $models_used ) ), + 'provider_used' => implode( ', ', array_unique( $providers_used ) ), + ), + ); + } + + /** + * Collects issues eligible for AI review, grouped by prompt template. + * + * Only issues with severity below the configured threshold are included. + * + * @since 2.0.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 2.0.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 ] >= $this->get_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 the configured batch size, it is split into sub-batches + * and each sub-batch is sent as a separate AI request. + * + * @since 2.0.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. + * + * @SuppressWarnings(PHPMD.NPathComplexity) + */ + 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, $this->get_ai_batch_size(), true ); + $all_cases = array(); + $total_tokens = 0; + $input_tokens = 0; + $output_tokens = 0; + $models_used = array(); + $providers_used = array(); + + 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']; + } + if ( isset( $result['token_usage']['prompt_tokens'] ) ) { + $input_tokens += (int) $result['token_usage']['prompt_tokens']; + } + if ( isset( $result['token_usage']['completion_tokens'] ) ) { + $output_tokens += (int) $result['token_usage']['completion_tokens']; + } + if ( ! empty( $result['model_used'] ) ) { + $models_used[] = (string) $result['model_used']; + } + if ( ! empty( $result['provider_used'] ) ) { + $providers_used[] = (string) $result['provider_used']; + } + } + + return array( + 'cases' => $all_cases, + 'token_usage' => array( + 'prompt_tokens' => $input_tokens, + 'completion_tokens' => $output_tokens, + 'total_tokens' => $total_tokens, + ), + 'model_used' => implode( ', ', array_unique( $models_used ) ), + 'provider_used' => implode( ', ', array_unique( $providers_used ) ), + ); + } + + /** + * 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 2.0.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. + * + * @SuppressWarnings(PHPMD.NPathComplexity) + */ + 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(), + 'model_used' => $this->normalize_ai_model_used( $model_preference ), + 'provider_used' => $this->normalize_ai_provider_used( $model_preference ), + ); + } + + $text = method_exists( $result, 'to_text' ) ? $result->to_text() : ( method_exists( $result, 'toText' ) ? $result->toText() : '' ); + $usage = $this->extract_ai_token_usage( $result ); + $model = $this->extract_ai_model_used( $result ); + $provider = $this->extract_ai_provider_used( $result ); + + return array( + 'cases' => $this->parse_batch_response( $text ), + 'token_usage' => $usage ? $usage : array(), + 'model_used' => $model ? $model : $this->normalize_ai_model_used( $model_preference ), + 'provider_used' => $provider ? $provider : $this->normalize_ai_provider_used( $model_preference ), + ); + } 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 2.0.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 2.0.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 2.0.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 2.0.0 + * + * @param string $response_text AI response text. + * @return array Array of case results. + * + * @SuppressWarnings(PHPMD.NPathComplexity) + */ + 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 2.0.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 ( $this->get_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 2.0.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 2.0.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 2.0.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 2.0.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 2.0.0 + * + * @param object $result Result object. + * @return array|null Token usage array or null. + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + */ + 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 ); + $prompt_tokens = null === $prompt_tokens && method_exists( $usage, 'get_input_tokens' ) ? $usage->get_input_tokens() : $prompt_tokens; + $prompt_tokens = null === $prompt_tokens && method_exists( $usage, 'getInputTokens' ) ? $usage->getInputTokens() : $prompt_tokens; + $completion_tokens = method_exists( $usage, 'get_completion_tokens' ) ? $usage->get_completion_tokens() : ( method_exists( $usage, 'getCompletionTokens' ) ? $usage->getCompletionTokens() : null ); + $completion_tokens = null === $completion_tokens && method_exists( $usage, 'get_output_tokens' ) ? $usage->get_output_tokens() : $completion_tokens; + $completion_tokens = null === $completion_tokens && method_exists( $usage, 'getOutputTokens' ) ? $usage->getOutputTokens() : $completion_tokens; + $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; + } + ); + } + + /** + * Extracts the model used from an AI result object. + * + * @since 2.0.0 + * + * @param object $result Result object. + * @return string Model identifier or empty string. + */ + protected function extract_ai_model_used( $result ) { + foreach ( array( 'get_model_metadata', 'getModelMetadata', 'get_model', 'getModel', 'get_model_id', 'getModelId', 'get_model_name', 'getModelName' ) as $method ) { + if ( ! method_exists( $result, $method ) ) { + continue; + } + + $model = $result->$method(); + if ( is_string( $model ) && '' !== trim( $model ) ) { + return trim( $model ); + } + + if ( is_object( $model ) ) { + foreach ( array( 'get_id', 'getId', 'get_name', 'getName' ) as $model_method ) { + if ( method_exists( $model, $model_method ) ) { + $value = $model->$model_method(); + if ( is_string( $value ) && '' !== trim( $value ) ) { + return trim( $value ); + } + } + } + } + } + + return ''; + } + + /** + * Extracts the provider used from an AI result object. + * + * @since 2.0.0 + * + * @param object $result Result object. + * @return string Provider identifier or empty string. + */ + protected function extract_ai_provider_used( $result ) { + foreach ( array( 'get_provider_metadata', 'getProviderMetadata', 'get_provider', 'getProvider', 'get_provider_id', 'getProviderId', 'get_provider_name', 'getProviderName' ) as $method ) { + if ( ! method_exists( $result, $method ) ) { + continue; + } + + $provider = $result->$method(); + if ( is_string( $provider ) && '' !== trim( $provider ) ) { + return trim( $provider ); + } + + if ( is_object( $provider ) ) { + foreach ( array( 'get_id', 'getId', 'get_name', 'getName' ) as $provider_method ) { + if ( method_exists( $provider, $provider_method ) ) { + $value = $provider->$provider_method(); + if ( is_string( $value ) && '' !== trim( $value ) ) { + return trim( $value ); + } + } + } + } + } + + return ''; + } + + /** + * Normalizes a configured model preference for display. + * + * @since 2.0.0 + * + * @param string $model_preference Model preference. + * @return string Model identifier or empty string. + */ + protected function normalize_ai_model_used( $model_preference ) { + $model_preference = trim( (string) $model_preference ); + if ( '' === $model_preference ) { + return ''; + } + + foreach ( array( '::', '|', ':' ) as $separator ) { + if ( false !== strpos( $model_preference, $separator ) ) { + $parts = array_map( 'trim', explode( $separator, $model_preference, 2 ) ); + return isset( $parts[1] ) && '' !== $parts[1] ? $parts[1] : $model_preference; + } + } + + return $model_preference; + } + + /** + * Normalizes a configured model preference provider for display. + * + * @since 2.0.0 + * + * @param string $model_preference Model preference. + * @return string Provider identifier or empty string. + */ + protected function normalize_ai_provider_used( $model_preference ) { + $model_preference = trim( (string) $model_preference ); + if ( '' === $model_preference ) { + return ''; + } + + foreach ( array( '::', '|', ':' ) as $separator ) { + if ( false !== strpos( $model_preference, $separator ) ) { + $parts = array_map( 'trim', explode( $separator, $model_preference, 2 ) ); + return isset( $parts[0] ) && '' !== $parts[0] ? $parts[0] : ''; + } + } + + return ''; + } + + /** + * Returns an empty AI result structure. + * + * @since 2.0.0 + * + * @return array Empty result with zeroed stats. + */ + protected function empty_ai_result() { + return array( + 'analysis' => array(), + 'stats' => array( + 'tokens_spent' => 0, + 'input_tokens' => 0, + 'output_tokens' => 0, + 'false_positives' => 0, + 'issues_analyzed' => 0, + 'model_used' => '', + 'provider_used' => '', + ), + ); + } + + /** + * Generates a unique key for an issue. + * + * @since 2.0.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/includes/Traits/AI_Check_Names.php b/includes/Traits/AI_Check_Names.php index 0fadb9756..e34817a3f 100644 --- a/includes/Traits/AI_Check_Names.php +++ b/includes/Traits/AI_Check_Names.php @@ -198,64 +198,6 @@ protected function execute_ai_request( $prompt, $model_preference = '', $builder ); } - /** - * Applies a model preference to the prompt builder if supported. - * - * @since 1.9.0 - * - * @param object $builder Prompt builder instance. - * @param string $model_preference Model preference. - * @return object|WP_Error Updated builder or WP_Error. - */ - protected function apply_model_preference( $builder, $model_preference ) { - if ( empty( $model_preference ) ) { - return $builder; - } - - $preference = $this->normalize_model_preference( $model_preference ); - - try { - $result = $builder->using_model_preference( $preference ); - return $result ? $result : $builder; - } catch ( \Exception $e ) { - // If method doesn't exist or fails, return WP_Error. - return new WP_Error( - 'model_preference_error', - sprintf( - /* translators: %s: Exception message */ - __( 'Failed to apply model preference: %s', 'plugin-check' ), - $e->getMessage() - ) - ); - } - } - - /** - * Normalizes a model preference string into a supported preference format. - * - * @since 1.9.0 - * - * @param string $model_preference Model preference string. - * @return string|array Normalized preference. - */ - protected function normalize_model_preference( $model_preference ) { - $trimmed = trim( (string) $model_preference ); - if ( '' === $trimmed ) { - return ''; - } - - foreach ( array( '::', '|', ':' ) as $separator ) { - if ( false !== strpos( $trimmed, $separator ) ) { - list( $provider, $model ) = array_map( 'trim', explode( $separator, $trimmed, 2 ) ); - if ( '' !== $provider && '' !== $model ) { - return array( $provider, $model ); - } - } - } - - return $trimmed; - } - /** * Extracts token usage from a result object, if available. * diff --git a/includes/Traits/AI_Utils.php b/includes/Traits/AI_Utils.php index e053df315..e9d448a28 100644 --- a/includes/Traits/AI_Utils.php +++ b/includes/Traits/AI_Utils.php @@ -124,6 +124,63 @@ protected function get_ai_config( $model_preference = '' ) { ); } + /** + * Applies a model preference to the prompt builder if supported. + * + * @since 2.0.0 + * + * @param object $builder Prompt builder instance. + * @param string $model_preference Model preference. + * @return object|WP_Error Updated builder or WP_Error. + */ + protected function apply_model_preference( $builder, $model_preference ) { + if ( empty( $model_preference ) ) { + return $builder; + } + + $preference = $this->normalize_model_preference( $model_preference ); + + 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() + ) + ); + } + } + + /** + * Normalizes a model preference string into a supported preference format. + * + * @since 2.0.0 + * + * @param string $model_preference Model preference string. + * @return string|array Normalized preference. + */ + protected function normalize_model_preference( $model_preference ) { + $trimmed = trim( (string) $model_preference ); + if ( '' === $trimmed ) { + return ''; + } + + foreach ( array( '::', '|', ':' ) as $separator ) { + if ( false !== strpos( $trimmed, $separator ) ) { + list( $provider, $model ) = array_map( 'trim', explode( $separator, $trimmed, 2 ) ); + if ( '' !== $provider && '' !== $model ) { + return array( $provider, $model ); + } + } + } + + return $trimmed; + } + /** * Gets raw output string from parsed result or analysis. * 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-false-positives.php b/templates/results-false-positives.php new file mode 100644 index 000000000..d149c96b8 --- /dev/null +++ b/templates/results-false-positives.php @@ -0,0 +1,14 @@ + +
+ + ({{ data.count }}) + +
+
diff --git a/templates/results-row.php b/templates/results-row.php index fba068492..eb6fda833 100644 --- a/templates/results-row.php +++ b/templates/results-row.php @@ -10,6 +10,26 @@ {{data.code}} + <# 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)}}%) + <# } #> + + <# } #> + <# } #> {{{data.message}}} @@ -21,6 +41,16 @@ <# } #> + <# if ( data.ai_analysis ) { #> + <# if ( data.ai_analysis.reasoning ) { #> +
+ {{{data.ai_analysis.reasoning}}} + <# } #> + <# if ( data.ai_analysis.recommendation ) { #> +
+ : {{{data.ai_analysis.recommendation}}} + <# } #> + <# } #> <# if ( data.hasLinks ) { #> @@ -34,4 +64,3 @@ <# } #> -