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 ) { #>
|