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..475662a43 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
+ ? 'false positive'
+ : '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/docs/checks.md b/docs/checks.md
index 11662a607..ecea9738b 100644
--- a/docs/checks.md
+++ b/docs/checks.md
@@ -7,6 +7,7 @@
| i18n_usage | general, plugin_repo | Checks for various internationalization best practices. | [Learn more](https://developer.wordpress.org/plugins/internationalization/how-to-internationalize-your-plugin/) |
| code_obfuscation | plugin_repo | Detects the usage of code obfuscation tools. | [Learn more](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/) |
| plugin_content | plugin_repo | Detects content that does not comply with the WordPress.org plugin guidelines. | [Learn more](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/) |
+| trialware | plugin_repo | Uses AI to detect trialware and locked built-in features. | [Learn more](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/) |
| direct_file_access | security, plugin_repo | Checks that plugin files include proper security validation using the ABSPATH constant to prevent direct file access. | [Learn more](https://developer.wordpress.org/plugins/plugin-basics/best-practices/#file-security) |
| file_type | plugin_repo | Detects the usage of hidden and compressed files, VCS directories, application files, badly named files, AI development directories (.cursor, .claude, .aider, .continue, .windsurf, .ai, .github), and unexpected markdown files in plugin root. | [Learn more](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/) |
| plugin_header_fields | plugin_repo | Checks adherence to the Headers requirements, including validation of "Tested up to" header matching between plugin file and readme.txt. | [Learn more](https://developer.wordpress.org/plugins/plugin-basics/header-requirements/) |
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..9fbbcf44d
--- /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 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' ),
+ 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 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
*
@@ -181,6 +189,8 @@ public function check( $args, $assoc_args ) {
'slug' => '',
'ignore-codes' => '',
'mode' => 'new',
+ 'use-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['use-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['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;
}
@@ -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 1.8.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 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.
+ if ( ! empty( $false_positive_results ) ) {
+ WP_CLI::line( '' );
+ WP_CLI::line( __( 'Likely 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..7a42d9e77 100644
--- a/includes/Checker/Abstract_Check_Runner.php
+++ b/includes/Checker/Abstract_Check_Runner.php
@@ -8,8 +8,11 @@
namespace WordPress\Plugin_Check\Checker;
use Exception;
+use WordPress\Plugin_Check\Admin\Settings_Page;
+use WordPress\Plugin_Check\Checker\Checks\Plugin_Repo\Trialware_Check;
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 +25,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 +35,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 +314,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 +445,27 @@ 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();
+ }
+ }
+
+ $ai_analysis = $this->finalize_ai_confirmed_issues( $results, $ai_analysis );
+ $results->set_ai_analysis( $ai_analysis );
+ $results->set_ai_stats( $ai_stats );
+
if ( ! empty( $cleanups ) ) {
foreach ( $cleanups as $cleanup ) {
$cleanup();
@@ -399,6 +475,78 @@ final public function run() {
return $results;
}
+ /**
+ * Promotes AI-confirmed candidate issues and removes unconfirmed AI-only candidates.
+ *
+ * @since 2.0.0
+ *
+ * @param Check_Result $results Check result.
+ * @param array $ai_analysis AI analysis results.
+ * @return array Updated AI analysis results.
+ */
+ private function finalize_ai_confirmed_issues( Check_Result $results, array $ai_analysis ) {
+ $confirmed_trialware = array();
+ $updated_analysis = array();
+
+ foreach ( $ai_analysis as $key => $analysis ) {
+ if ( ! is_array( $analysis ) || Trialware_Check::CANDIDATE_CODE !== ( $analysis['code'] ?? '' ) ) {
+ $updated_analysis[ $key ] = $analysis;
+ continue;
+ }
+
+ if ( ! empty( $analysis['is_false_positive'] ) ) {
+ continue;
+ }
+
+ $issue_key = $this->get_ai_issue_location_key( $analysis );
+ $analysis['code'] = Trialware_Check::CONFIRMED_CODE;
+ $analysis['type'] = 'error';
+ $updated_analysis[ $key ] = $analysis;
+ $confirmed_trialware[ $issue_key ] = true;
+ }
+
+ $results->transform_messages(
+ function ( array $message, $is_error, $file, $line, $column ) use ( $confirmed_trialware ) {
+ if ( Trialware_Check::CANDIDATE_CODE !== ( $message['code'] ?? '' ) ) {
+ return $message;
+ }
+
+ $issue_key = $this->get_ai_issue_location_key(
+ array(
+ 'file' => $file,
+ 'line' => $line,
+ 'column' => $column,
+ )
+ );
+
+ if ( empty( $confirmed_trialware[ $issue_key ] ) ) {
+ return null;
+ }
+
+ $message['error'] = true;
+ $message['code'] = Trialware_Check::CONFIRMED_CODE;
+ $message['severity'] = 7;
+ $message['message'] = __( 'Trialware or locked built-in feature detected. Plugins hosted on WordPress.org must not restrict functionality already included in the plugin behind license keys, trials, quotas, payments, or other artificial limits.', 'plugin-check' );
+
+ return $message;
+ }
+ );
+
+ return $updated_analysis;
+ }
+
+ /**
+ * Gets a stable location key for AI issue matching.
+ *
+ * @since 2.0.0
+ *
+ * @param array $issue Issue data.
+ * @return string Location key.
+ */
+ private function get_ai_issue_location_key( array $issue ) {
+ return ( $issue['file'] ?? '' ) . ':' . (int) ( $issue['line'] ?? 0 ) . ':' . (int) ( $issue['column'] ?? 0 );
+ }
+
/**
* Determines if any of the checks are a runtime check.
*
diff --git a/includes/Checker/Check_Result.php b/includes/Checker/Check_Result.php
index 389cb8217..0f5a540ae 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 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.
*
@@ -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 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/Checker/Checks/Plugin_Repo/Trialware_Check.php b/includes/Checker/Checks/Plugin_Repo/Trialware_Check.php
new file mode 100644
index 000000000..bdafdaef2
--- /dev/null
+++ b/includes/Checker/Checks/Plugin_Repo/Trialware_Check.php
@@ -0,0 +1,203 @@
+filter_files_to_scan( $files );
+ if ( empty( $files ) ) {
+ return;
+ }
+
+ $matches = self::files_preg_match_all( $this->get_locked_features_pattern(), $files );
+ if ( empty( $matches ) ) {
+ return;
+ }
+
+ $reported = array();
+ foreach ( $matches as $match ) {
+ $key = $match['file'] . ':' . $match['line'] . ':' . $match['column'];
+ if ( isset( $reported[ $key ] ) ) {
+ continue;
+ }
+
+ $reported[ $key ] = true;
+ $this->add_result_error_for_file(
+ $result,
+ __( 'Potential trialware or locked built-in feature candidate. AI analysis must confirm whether the plugin restricts built-in functionality behind a license key, trial, quota, payment, or other artificial limit.', 'plugin-check' ),
+ self::CANDIDATE_CODE,
+ $match['file'],
+ $match['line'],
+ $match['column'],
+ $this->get_documentation_url(),
+ 5
+ );
+ }
+ }
+
+ /**
+ * Filters the file list to code-like files and known low-signal exclusions.
+ *
+ * @since 2.0.0
+ *
+ * @param array $files List of absolute file paths.
+ * @return array Files to scan.
+ */
+ private function filter_files_to_scan( array $files ) {
+ return array_values(
+ array_filter(
+ $files,
+ static function ( $file ) {
+ $normalized = wp_normalize_path( $file );
+ $extension = strtolower( pathinfo( $normalized, PATHINFO_EXTENSION ) );
+ $basename = strtolower( basename( $normalized ) );
+
+ if ( ! in_array( $extension, self::CODE_EXTENSIONS, true ) ) {
+ return false;
+ }
+
+ if ( 'composer.json' === $basename ) {
+ return false;
+ }
+
+ return false === strpos( $normalized, '/stripe-php/lib/' );
+ }
+ )
+ );
+ }
+
+ /**
+ * Builds the combined regular expression for locked feature indicators.
+ *
+ * @since 2.0.0
+ *
+ * @return string Regular expression.
+ */
+ private function get_locked_features_pattern() {
+ $patterns = array_map(
+ static function ( $pattern ) {
+ return '(?:' . $pattern . ')';
+ },
+ self::LOCKED_FEATURE_PATTERNS
+ );
+
+ return '~' . implode( '|', $patterns ) . '~i';
+ }
+
+ /**
+ * Gets the description for the check.
+ *
+ * @since 2.0.0
+ *
+ * @return string Description.
+ */
+ public function get_description(): string {
+ return __( 'Uses AI to detect trialware and locked built-in features.', 'plugin-check' );
+ }
+
+ /**
+ * Gets the documentation URL for the check.
+ *
+ * @since 2.0.0
+ *
+ * @return string The documentation URL.
+ */
+ public function get_documentation_url(): string {
+ return __( 'https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/', 'plugin-check' );
+ }
+}
diff --git a/includes/Checker/Default_Check_Repository.php b/includes/Checker/Default_Check_Repository.php
index c1b7d420c..626c7640d 100644
--- a/includes/Checker/Default_Check_Repository.php
+++ b/includes/Checker/Default_Check_Repository.php
@@ -76,6 +76,7 @@ private function register_default_checks() {
'enqueued_styles_size' => new Checks\Performance\Enqueued_Styles_Size_Check(),
'code_obfuscation' => new Checks\Plugin_Repo\Code_Obfuscation_Check(),
'plugin_content' => new Checks\Plugin_Repo\Plugin_Content_Check(),
+ 'trialware' => new Checks\Plugin_Repo\Trialware_Check(),
'file_type' => new Checks\Plugin_Repo\File_Type_Check(),
'plugin_header_fields' => new Checks\Plugin_Repo\Plugin_Header_Fields_Check(),
'late_escaping' => new Checks\Security\Late_Escaping_Check(),
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..8e8015b5f
--- /dev/null
+++ b/includes/Traits/AI_Analyzer.php
@@ -0,0 +1,924 @@
+ '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',
+ 'trialware_locked_feature_candidate' => 'ai-review-trialware.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 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.
+ *
+ * @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 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 ] >= $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 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.
+ *
+ * @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 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.
+ *
+ * @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 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.
+ *
+ * @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 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 ( $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 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.
+ *
+ * @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 1.8.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 1.8.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 1.8.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 1.8.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 1.8.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 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/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..1adfeba1c 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 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 ) {
+ 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;
+ }
+
/**
* 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/prompts/ai-review-trialware.md b/prompts/ai-review-trialware.md
new file mode 100644
index 000000000..0f438cdd9
--- /dev/null
+++ b/prompts/ai-review-trialware.md
@@ -0,0 +1,14 @@
+## Trialware and Locked Feature Issues
+
+A trialware or locked feature issue occurs when a plugin includes functionality that its code can perform, but intentionally restricts that functionality behind a license key, payment, trial period, usage quota, time limit, plan limit, or other artificial limitation.
+
+Using the case as a reference, check the code to determine whether the plugin genuinely restricts built-in functionality.
+
+Details:
+- Plugins hosted on WordPress.org must be fully functional.
+- It is an issue when functionality already present in the plugin code only works after a license check, payment, activation key, trial, quota, or similar restriction.
+- It is an issue when the plugin code intentionally limits built-in functionality, such as only allowing a fixed number of items until the user upgrades.
+- It is acceptable for a plugin to display informational references to features available in a separate pro/premium plugin when the locked feature code is not included in this plugin.
+- It is acceptable for a plugin to depend on an external service when that service provides meaningful external processing that cannot reasonably be performed locally by the plugin.
+- A service that only checks a license key or unlocks local functionality is not a meaningful external service.
+- Asking for a license to receive software updates is not acceptable for WordPress.org-hosted plugins, since updates are expected to be served through WordPress.org.
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 @@
|
<# } #>
-
diff --git a/tests/phpunit/testdata/plugins/test-plugin-trialware-errors/load.php b/tests/phpunit/testdata/plugins/test-plugin-trialware-errors/load.php
new file mode 100644
index 000000000..3dd01db2a
--- /dev/null
+++ b/tests/phpunit/testdata/plugins/test-plugin-trialware-errors/load.php
@@ -0,0 +1,32 @@
+ 'exported' );
+}
+
+function test_plugin_trialware_can_add_item( $items ) {
+ if ( count( $items ) >= 3 ) {
+ wp_die( 'Limit reached. Upgrade to add unlimited items.' );
+ }
+
+ return true;
+}
diff --git a/tests/phpunit/testdata/plugins/test-plugin-trialware-without-errors/load.php b/tests/phpunit/testdata/plugins/test-plugin-trialware-without-errors/load.php
new file mode 100644
index 000000000..4260ae496
--- /dev/null
+++ b/tests/phpunit/testdata/plugins/test-plugin-trialware-without-errors/load.php
@@ -0,0 +1,19 @@
+ 'exported' );
+}
diff --git a/tests/phpunit/tests/Checker/Checks/Trialware_Check_Tests.php b/tests/phpunit/tests/Checker/Checks/Trialware_Check_Tests.php
new file mode 100644
index 000000000..04f563b1e
--- /dev/null
+++ b/tests/phpunit/tests/Checker/Checks/Trialware_Check_Tests.php
@@ -0,0 +1,35 @@
+run( $check_result );
+
+ $errors = $check_result->get_errors();
+
+ $this->assertNotEmpty( $errors );
+ $this->assertArrayHasKey( 'load.php', $errors );
+ $this->assertGreaterThanOrEqual( 2, $check_result->get_error_count() );
+ $this->assertCount( 1, wp_list_filter( $errors['load.php'][17][3], array( 'code' => 'trialware_locked_feature_candidate' ) ) );
+ $this->assertSame( 5, $errors['load.php'][17][3][0]['severity'] );
+ }
+
+ public function test_run_without_locked_feature_errors() {
+ $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-trialware-without-errors/load.php' );
+ $check_result = new Check_Result( $check_context );
+
+ $check = new Trialware_Check();
+ $check->run( $check_result );
+
+ $this->assertEmpty( $check_result->get_errors() );
+ $this->assertSame( 0, $check_result->get_error_count() );
+ }
+}