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 @@
|
<# } #>
-