From 1ae3aafa1eaa23f9821662dac0179984679e5e00 Mon Sep 17 00:00:00 2001 From: davidperezgar Date: Wed, 19 Nov 2025 20:54:22 +0100 Subject: [PATCH 1/8] start --- assets/css/plugin-check-admin.css | 53 ++ assets/js/plugin-check-admin.js | 188 +++++- composer.json | 4 +- composer.lock | 620 ++++++++++++++++++- includes/Admin/Admin_AJAX.php | 28 +- includes/Admin/Admin_Page.php | 1 + includes/Admin/Settings_Page.php | 670 +++++++++++++++++++++ includes/CLI/Plugin_Check_Command.php | 42 +- includes/Checker/Abstract_Check_Runner.php | 46 ++ includes/Checker/Check_Result.php | 60 ++ includes/Plugin_Main.php | 5 + includes/Traits/AI_Analyzer.php | 399 ++++++++++++ templates/admin-page.php | 9 +- templates/results-row.php | 28 + 14 files changed, 2107 insertions(+), 46 deletions(-) create mode 100644 includes/Admin/Settings_Page.php create mode 100644 includes/Traits/AI_Analyzer.php diff --git a/assets/css/plugin-check-admin.css b/assets/css/plugin-check-admin.css index 7445f5511..e70712bb5 100644 --- a/assets/css/plugin-check-admin.css +++ b/assets/css/plugin-check-admin.css @@ -60,4 +60,57 @@ table.plugin-check__results-table td:last-child { border-bottom: 0; } +} + +/* AI Analysis Styles */ +.plugin-check__ai-analysis { + display: inline-flex; + align-items: center; + gap: 5px; + margin-top: 8px; + padding: 6px 10px; + border-radius: 3px; + font-size: 0.9em; + line-height: 1.4; +} + +.plugin-check__ai-analysis--false-positive { + background-color: #fff3cd; + border-left: 3px solid #ffc107; + color: #856404; +} + +.plugin-check__ai-analysis--valid { + background-color: #d1ecf1; + border-left: 3px solid #17a2b8; + color: #0c5460; +} + +.plugin-check__ai-analysis .dashicons { + font-size: 16px; + width: 16px; + height: 16px; +} + +.plugin-check__ai-reasoning { + display: block; + margin-top: 5px; + padding: 8px; + background-color: #f8f9fa; + border-left: 3px solid #6c757d; + font-size: 0.9em; + font-style: normal; + color: #495057; + line-height: 1.5; +} + +.plugin-check__ai-recommendation { + display: block; + margin-top: 5px; + padding: 8px; + background-color: #e7f3ff; + border-left: 3px solid #0066cc; + font-size: 0.9em; + color: #004085; + line-height: 1.5; } \ No newline at end of file diff --git a/assets/js/plugin-check-admin.js b/assets/js/plugin-check-admin.js index 7e2092cbe..1bc901256 100644 --- a/assets/js/plugin-check-admin.js +++ b/assets/js/plugin-check-admin.js @@ -22,9 +22,10 @@ return; } - const includeExperimental = document.getElementById( - 'plugin-check__include-experimental' - ); + 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() { @@ -133,6 +134,10 @@ 'include-experimental', includeExperimental && includeExperimental.checked ? 1 : 0 ); + pluginCheckData.append( + 'use-ai', + useAi && useAi.checked ? 1 : 0 + ); for ( let i = 0; i < data.checks.length; i++ ) { pluginCheckData.append( 'checks[]', data.checks[ i ] ); @@ -205,6 +210,10 @@ 'include-experimental', includeExperimental && includeExperimental.checked ? 1 : 0 ); + pluginCheckData.append( + 'use-ai', + useAi && useAi.checked ? 1 : 0 + ); for ( let i = 0; i < categoriesList.length; i++ ) { if ( categoriesList[ i ].checked ) { @@ -248,6 +257,7 @@ */ async function runChecks( data ) { let isSuccessMessage = true; + let aiStats = null; for ( let i = 0; i < data.checks.length; i++ ) { try { const results = await runCheck( data.plugin, data.checks[ i ] ); @@ -260,12 +270,27 @@ isSuccessMessage = false; } renderResults( results ); + + // Collect AI stats from the last check. + if ( results.ai_stats ) { + // Merge stats if multiple checks. + if ( ! aiStats ) { + aiStats = { + tokens_spent: 0, + false_positives: 0, + issues_analyzed: 0, + }; + } + aiStats.tokens_spent += results.ai_stats.tokens_spent || 0; + aiStats.false_positives += results.ai_stats.false_positives || 0; + aiStats.issues_analyzed += results.ai_stats.issues_analyzed || 0; + } } catch ( e ) { // Ignore for now. } } - renderResultsMessage( isSuccessMessage ); + renderResultsMessage( isSuccessMessage, aiStats ); } /** @@ -274,13 +299,24 @@ * @since 1.0.0 * * @param {boolean} isSuccessMessage Whether the message is a success message. + * @param {Object} aiStats AI statistics. */ - function renderResultsMessage( isSuccessMessage ) { + function renderResultsMessage( isSuccessMessage, aiStats ) { const messageType = isSuccessMessage ? 'success' : 'error'; - const messageText = isSuccessMessage + let messageText = isSuccessMessage ? pluginCheck.successMessage : pluginCheck.errorMessage; + // Add AI statistics to the message if available. + if ( aiStats && aiStats.false_positives > 0 ) { + let aiInfo = ' AI detected ' + aiStats.false_positives + ' '; + aiInfo += ( 1 === aiStats.false_positives ) ? 'false positive' : 'false positives'; + if ( aiStats.tokens_spent > 0 ) { + aiInfo += ' (Tokens spent: ' + aiStats.tokens_spent.toLocaleString() + ')'; + } + messageText += '.' + aiInfo; + } + resultsContainer.innerHTML = renderTemplate( 'plugin-check-results-complete', { type: messageType, @@ -307,6 +343,10 @@ 'include-experimental', includeExperimental && includeExperimental.checked ? 1 : 0 ); + pluginCheckData.append( + 'use-ai', + useAi && useAi.checked ? 1 : 0 + ); return fetch( ajaxurl, { method: 'POST', @@ -323,6 +363,14 @@ throw new Error( 'Response contains no data' ); } + // Debug: Log AI data if present. + if ( responseData.data.ai_analysis ) { + console.log( 'AI Analysis received:', responseData.data.ai_analysis ); + } + if ( responseData.data.ai_stats ) { + console.log( 'AI Stats received:', responseData.data.ai_stats ); + } + return responseData.data; } ); } @@ -361,20 +409,25 @@ * @param {Object} results The results object. */ function renderResults( results ) { - const { errors, warnings } = results; + const { errors, warnings, ai_analysis } = results || {}; + + // Debug: Log AI analysis data if available. + if ( ai_analysis && typeof ai_analysis === 'object' && Object.keys( ai_analysis ).length > 0 ) { + console.log( 'AI Analysis data in renderResults:', ai_analysis ); + } // Render errors and warnings for files. for ( const file in errors ) { if ( warnings[ file ] ) { - renderFileResults( file, errors[ file ], warnings[ file ] ); + renderFileResults( file, errors[ file ], warnings[ file ], ai_analysis ); delete warnings[ file ]; } else { - renderFileResults( file, errors[ file ], [] ); + renderFileResults( file, errors[ file ], [], ai_analysis ); } } // Render remaining files with only warnings. for ( const file in warnings ) { - renderFileResults( file, [], warnings[ file ] ); + renderFileResults( file, [], warnings[ file ], ai_analysis ); } } @@ -383,11 +436,12 @@ * * @since 1.0.0 * - * @param {string} file The file name for the results. - * @param {Object} errors The file errors. - * @param {Object} warnings The file warnings. + * @param {string} file The file name for the results. + * @param {Object} errors The file errors. + * @param {Object} warnings The file warnings. + * @param {Object} ai_analysis AI analysis results. */ - function renderFileResults( file, errors, warnings ) { + function renderFileResults( file, errors, warnings, ai_analysis ) { const index = Date.now().toString( 36 ) + Math.random().toString( 36 ).substr( 2 ); @@ -406,8 +460,8 @@ ); // Render results to the table. - renderResultRows( 'ERROR', errors, resultsTable, hasLinks ); - renderResultRows( 'WARNING', warnings, resultsTable, hasLinks ); + renderResultRows( 'ERROR', errors, resultsTable, hasLinks, ai_analysis, file ); + renderResultRows( 'WARNING', warnings, resultsTable, hasLinks, ai_analysis, file ); } /** @@ -436,12 +490,14 @@ * * @since 1.0.0 * - * @param {string} type The result type. Either ERROR or WARNING. - * @param {Object} results The results object. - * @param {Object} table The HTML table to append a result row to. - * @param {boolean} hasLinks Whether any result has links. + * @param {string} type The result type. Either ERROR or WARNING. + * @param {Object} results The results object. + * @param {Object} table The HTML table to append a result row to. + * @param {boolean} hasLinks Whether any result has links. + * @param {Object} ai_analysis AI analysis results. + * @param {string} file The file path. */ - function renderResultRows( type, results, table, hasLinks ) { + function renderResultRows( type, results, table, hasLinks, ai_analysis, file ) { // Loop over each result by the line, column and messages. for ( const line in results ) { for ( const column in results[ line ] ) { @@ -451,24 +507,94 @@ const code = results[ line ][ column ][ i ].code; const link = results[ line ][ column ][ i ].link; + // Find AI analysis for this issue. + let aiData = null; + if ( ai_analysis && typeof ai_analysis === 'object' ) { + // Try to find by file, line, column, and code match. + // ai_analysis is an object where keys are MD5 hashes and values are analysis data. + const analysisEntries = Object.values( ai_analysis ); + aiData = analysisEntries.find( function( analysis ) { + if ( ! analysis || typeof analysis !== 'object' ) { + return false; + } + // Normalize values for comparison. + const analysisFile = String( analysis.file || '' ); + const currentFile = String( file || '' ); + const analysisLine = parseInt( analysis.line, 10 ); + const currentLine = parseInt( line, 10 ); + const analysisColumn = parseInt( analysis.column, 10 ); + const currentColumn = parseInt( column, 10 ); + const analysisCode = String( analysis.code || '' ); + const currentCode = String( code || '' ); + + const fileMatch = analysisFile === currentFile; + const lineMatch = analysisLine === currentLine; + const columnMatch = analysisColumn === currentColumn; + const codeMatch = analysisCode === currentCode; + + if ( fileMatch && lineMatch && columnMatch && codeMatch ) { + console.log( 'AI match found:', { + file: currentFile, + line: currentLine, + column: currentColumn, + code: currentCode, + analysis: analysis, + } ); + return true; + } + + return false; + } ) || null; + } + + const rowData = { + line, + column, + type, + message, + docs, + code, + link, + hasLinks, + }; + + // Add AI analysis data if available. + if ( aiData ) { + rowData.ai_analysis = aiData; + } + table.innerHTML += renderTemplate( 'plugin-check-results-row', - { - line, - column, - type, - message, - docs, - code, - link, - hasLinks, - } + rowData ); } } } } + /** + * Generates a unique key for an issue. + * + * @since 1.8.0 + * + * @param {string} file File path. + * @param {number} line Line number. + * @param {number} column Column number. + * @param {string} code Issue code. + * @return {string} Unique key. + */ + function getIssueKey( file, line, column, code ) { + const str = file + ':' + line + ':' + column + ':' + code; + // Simple MD5-like hash (using built-in hash if available, otherwise a simple hash). + let hash = 0; + for ( let i = 0; i < str.length; i++ ) { + const char = str.charCodeAt( i ); + hash = ( hash << 5 ) - hash + char; + hash = hash & hash; // Convert to 32bit integer. + } + return hash.toString( 36 ); + } + /** * Renders the template with data. * diff --git a/composer.json b/composer.json index 32af76615..4e8783093 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0", "nikic/php-parser": "^4", "plugin-check/phpcs-sniffs": "@dev", + "wordpress/wp-ai-client": "^0.1.0", "wp-coding-standards/wpcs": "^3.2.0" }, "require-dev": { @@ -55,7 +56,8 @@ "composer/installers": true, "cweagans/composer-patches": false, "dealerdirect/phpcodesniffer-composer-installer": true, - "phpstan/extension-installer": true + "phpstan/extension-installer": true, + "php-http/discovery": true }, "platform": { "php": "7.4" diff --git a/composer.lock b/composer.lock index b02237892..be14f1587 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2173de658966f1fdbc4338e3ed6aa47f", + "content-hash": "2c7c6b4ac82aecf2137ad556276aae8f", "packages": [ { "name": "automattic/vipwpcs", @@ -358,6 +358,327 @@ }, "time": "2024-09-29T15:01:53+00:00" }, + { + "name": "nyholm/psr7", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0", + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "symfony/error-handler": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.2" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2024-09-09T07:06:30+00:00" + }, + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" + }, + "time": "2024-10-02T11:20:13+00:00" + }, + { + "name": "php-http/httplug", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/php-http/httplug.git", + "reference": "5cad731844891a4c282f3f3e1b582c46839d22f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/httplug/zipball/5cad731844891a4c282f3f3e1b582c46839d22f4", + "reference": "5cad731844891a4c282f3f3e1b582c46839d22f4", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "php-http/promise": "^1.1", + "psr/http-client": "^1.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "require-dev": { + "friends-of-phpspec/phpspec-code-coverage": "^4.1 || ^5.0 || ^6.0", + "phpspec/phpspec": "^5.1 || ^6.0 || ^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eric GELOEN", + "email": "geloen.eric@gmail.com" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "HTTPlug, the HTTP client abstraction for PHP", + "homepage": "http://httplug.io", + "keywords": [ + "client", + "http" + ], + "support": { + "issues": "https://github.com/php-http/httplug/issues", + "source": "https://github.com/php-http/httplug/tree/2.4.1" + }, + "time": "2024-09-23T11:39:58+00:00" + }, + { + "name": "php-http/message-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/message-factory.git", + "reference": "4d8778e1c7d405cbb471574821c1ff5b68cc8f57" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/message-factory/zipball/4d8778e1c7d405cbb471574821c1ff5b68cc8f57", + "reference": "4d8778e1c7d405cbb471574821c1ff5b68cc8f57", + "shasum": "" + }, + "require": { + "php": ">=5.4", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Factory interfaces for PSR-7 HTTP Message", + "homepage": "http://php-http.org", + "keywords": [ + "factory", + "http", + "message", + "stream", + "uri" + ], + "support": { + "issues": "https://github.com/php-http/message-factory/issues", + "source": "https://github.com/php-http/message-factory/tree/1.1.0" + }, + "abandoned": "psr/http-factory", + "time": "2023-04-14T14:16:17+00:00" + }, + { + "name": "php-http/promise", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/php-http/promise.git", + "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/promise/zipball/fc85b1fba37c169a69a07ef0d5a8075770cc1f83", + "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "friends-of-phpspec/phpspec-code-coverage": "^4.3.2 || ^6.3", + "phpspec/phpspec": "^5.1.2 || ^6.2 || ^7.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joel Wurtz", + "email": "joel.wurtz@gmail.com" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Promise used for asynchronous HTTP requests", + "homepage": "http://httplug.io", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/php-http/promise/issues", + "source": "https://github.com/php-http/promise/tree/1.3.1" + }, + "time": "2024-03-15T13:55:21+00:00" + }, { "name": "phpcsstandards/phpcsextra", "version": "1.4.1", @@ -600,6 +921,166 @@ "relative": true } }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, { "name": "sirbrillig/phpcs-variable-analysis", "version": "v2.11.21", @@ -742,6 +1223,135 @@ ], "time": "2025-09-05T05:47:09+00:00" }, + { + "name": "wordpress/php-ai-client", + "version": "0.2.1", + "source": { + "type": "git", + "url": "https://github.com/WordPress/php-ai-client.git", + "reference": "61ecd7c86329d0cc3d17567891f363d8f3fc3be6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/php-ai-client/zipball/61ecd7c86329d0cc3d17567891f363d8f3fc3be6", + "reference": "61ecd7c86329d0cc3d17567891f363d8f3fc3be6", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=7.4", + "php-http/discovery": "^1.0", + "php-http/httplug": "^2.0", + "php-http/message-factory": "^1.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "guzzlehttp/psr7": "^1.0 || ^2.0", + "php-http/curl-client": "^2.0", + "php-http/mock-client": "^1.0", + "phpcompatibility/php-compatibility": "dev-develop", + "phpstan/phpstan": "~2.1", + "phpunit/phpunit": "^9.5 || ^10.0", + "slevomat/coding-standard": "^8.20", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "files": [ + "src/polyfills.php" + ], + "psr-4": { + "WordPress\\AiClient\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "WordPress AI Team", + "homepage": "https://make.wordpress.org/ai/" + } + ], + "description": "A provider agnostic PHP AI client SDK to communicate with any generative AI models of various capabilities using a uniform API.", + "homepage": "https://github.com/WordPress/php-ai-client", + "keywords": [ + "ai", + "api", + "llm" + ], + "support": { + "issues": "https://github.com/WordPress/php-ai-client/issues", + "source": "https://github.com/WordPress/php-ai-client" + }, + "time": "2025-11-17T18:04:50+00:00" + }, + { + "name": "wordpress/wp-ai-client", + "version": "0.1.0", + "source": { + "type": "git", + "url": "https://github.com/WordPress/wp-ai-client.git", + "reference": "9037ede3f20e65ba3820e0d2e514cbffb891d04b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/wp-ai-client/zipball/9037ede3f20e65ba3820e0d2e514cbffb891d04b", + "reference": "9037ede3f20e65ba3820e0d2e514cbffb891d04b", + "shasum": "" + }, + "require": { + "ext-json": "*", + "nyholm/psr7": "^1.5", + "php": ">=7.4", + "wordpress/php-ai-client": "^0.2" + }, + "require-dev": { + "automattic/vipwpcs": "^3.0", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "phpcompatibility/phpcompatibility-wp": "^2.1", + "phpstan/phpstan": "^1.10 | ^2.1", + "slevomat/coding-standard": "^8.0", + "squizlabs/php_codesniffer": "^3.7", + "szepeviktor/phpstan-wordpress": "^2.0", + "wp-coding-standards/wpcs": "^3.0", + "wp-phpunit/wp-phpunit": "^6.8", + "yoast/phpunit-polyfills": "^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "WordPress\\AI_Client\\": "includes/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "WordPress AI Team", + "homepage": "https://make.wordpress.org/ai/" + } + ], + "description": "An AI client and API for WordPress to communicate with any generative AI models of various capabilities using a uniform API.", + "homepage": "https://github.com/WordPress/wp-ai-client", + "keywords": [ + "ai", + "api", + "llm", + "wordpress" + ], + "support": { + "issues": "https://github.com/WordPress/wp-ai-client/issues", + "source": "https://github.com/WordPress/wp-ai-client" + }, + "time": "2025-11-14T21:08:54+00:00" + }, { "name": "wp-coding-standards/wpcs", "version": "3.2.0", @@ -7113,8 +7723,8 @@ "aliases": [], "minimum-stability": "stable", "stability-flags": { - "phpcompatibility/php-compatibility": 20, - "plugin-check/phpcs-sniffs": 20 + "plugin-check/phpcs-sniffs": 20, + "phpcompatibility/php-compatibility": 20 }, "prefer-stable": false, "prefer-lowest": false, @@ -7122,9 +7732,9 @@ "php": ">=7.4", "ext-json": "*" }, - "platform-dev": {}, + "platform-dev": [], "platform-overrides": { "php": "7.4" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.3.0" } diff --git a/includes/Admin/Admin_AJAX.php b/includes/Admin/Admin_AJAX.php index fc5d648e7..f32800b62 100644 --- a/includes/Admin/Admin_AJAX.php +++ b/includes/Admin/Admin_AJAX.php @@ -192,6 +192,7 @@ public function get_checks_to_run() { $checks = is_null( $checks ) ? array() : $checks; $plugin = filter_input( INPUT_POST, 'plugin', FILTER_SANITIZE_FULL_SPECIAL_CHARS ); $include_experimental = 1 === filter_input( INPUT_POST, 'include-experimental', FILTER_VALIDATE_INT ); + $use_ai = 1 === filter_input( INPUT_POST, 'use-ai', FILTER_VALIDATE_INT ); $runner = Plugin_Request_Utility::get_runner(); if ( is_null( $runner ) ) { @@ -211,6 +212,7 @@ public function get_checks_to_run() { $runner->set_check_slugs( $checks ); $runner->set_plugin( $plugin ); $runner->set_categories( $categories ); + $runner->set_use_ai( $use_ai ); $plugin_basename = $runner->get_plugin_basename(); $checks_to_run = $runner->get_checks_to_run(); @@ -261,11 +263,13 @@ public function run_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 ); try { $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( @@ -274,13 +278,25 @@ public function run_checks() { ); } - wp_send_json_success( - array( - 'message' => __( 'Checks run successfully', 'plugin-check' ), - 'errors' => $results->get_errors(), - 'warnings' => $results->get_warnings(), - ) + $response = array( + 'message' => __( 'Checks run successfully', 'plugin-check' ), + 'errors' => $results->get_errors(), + 'warnings' => $results->get_warnings(), ); + + // Include AI analysis results if available. + $ai_analysis = $results->get_ai_analysis(); + if ( ! empty( $ai_analysis ) ) { + $response['ai_analysis'] = $ai_analysis; + } + + // Include AI statistics if available. + $ai_stats = $results->get_ai_stats(); + if ( ! empty( $ai_stats ) ) { + $response['ai_stats'] = $ai_stats; + } + + wp_send_json_success( $response ); } /** diff --git a/includes/Admin/Admin_Page.php b/includes/Admin/Admin_Page.php index 612d68cb9..e0da33e77 100644 --- a/includes/Admin/Admin_Page.php +++ b/includes/Admin/Admin_Page.php @@ -202,6 +202,7 @@ public function enqueue_scripts() { 'actionCleanUpRuntimeEnvironment' => Admin_AJAX::ACTION_CLEAN_UP_ENVIRONMENT, 'successMessage' => __( 'No errors found.', 'plugin-check' ), 'errorMessage' => __( 'Errors were found.', 'plugin-check' ), + 'settingsPageUrl' => admin_url( 'options-general.php?page=plugin-check-settings' ), ) ), 'before' diff --git a/includes/Admin/Settings_Page.php b/includes/Admin/Settings_Page.php new file mode 100644 index 000000000..9f68e58cd --- /dev/null +++ b/includes/Admin/Settings_Page.php @@ -0,0 +1,670 @@ +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_provider' => '', + 'ai_api_key' => '', + 'ai_model' => '', + ), + ) + ); + + add_settings_section( + 'ai_settings_section', + __( 'AI Integration', 'plugin-check' ), + array( $this, 'render_ai_section_description' ), + self::PAGE_SLUG + ); + + add_settings_field( + 'ai_provider', + __( 'AI Provider', 'plugin-check' ), + array( $this, 'render_provider_field' ), + self::PAGE_SLUG, + 'ai_settings_section', + array( + 'label_for' => 'ai_provider', + ) + ); + + add_settings_field( + 'ai_api_key', + __( 'API Key / Credentials', 'plugin-check' ), + array( $this, 'render_api_key_field' ), + self::PAGE_SLUG, + 'ai_settings_section', + array( + 'label_for' => 'ai_api_key', + ) + ); + + add_settings_field( + 'ai_model', + __( 'AI Model', 'plugin-check' ), + array( $this, 'render_model_field' ), + self::PAGE_SLUG, + 'ai_settings_section', + array( + 'label_for' => 'ai_model', + ) + ); + } + + /** + * Renders the AI settings section description. + * + * @since 1.8.0 + */ + public function render_ai_section_description() { + ?> +

+ +

+ get_available_providers(); + ?> + +

+ +

+ + + /> + +

+ + +

+ +

+ get_provider_label( $provider ) ) + ); + } + ?> +

+ get_models_for_provider( $provider ); + ?> + +

+ +

+ __( 'OpenAI (ChatGPT)', 'plugin-check' ), + 'anthropic' => __( 'Anthropic (Claude)', 'plugin-check' ), + 'google' => __( 'Google (Gemini)', 'plugin-check' ), + 'azure' => __( 'Microsoft Azure OpenAI', 'plugin-check' ), + ); + } + + /** + * Gets available models for a provider. + * + * @since 1.8.0 + * + * @param string $provider Provider key. + * @return array Array of model keys and labels. + */ + protected function get_models_for_provider( $provider ) { + $models = array(); + + switch ( $provider ) { + case 'openai': + $models = array( + 'gpt-4o' => __( 'GPT-4o', 'plugin-check' ), + 'gpt-4-turbo' => __( 'GPT-4 Turbo', 'plugin-check' ), + 'gpt-4' => __( 'GPT-4', 'plugin-check' ), + 'gpt-3.5-turbo' => __( 'GPT-3.5 Turbo', 'plugin-check' ), + ); + break; + + case 'anthropic': + $models = array( + 'claude-3-5-sonnet-20241022' => __( 'Claude 3.5 Sonnet', 'plugin-check' ), + 'claude-3-opus-20240229' => __( 'Claude 3 Opus', 'plugin-check' ), + 'claude-3-sonnet-20240229' => __( 'Claude 3 Sonnet', 'plugin-check' ), + 'claude-3-haiku-20240307' => __( 'Claude 3 Haiku', 'plugin-check' ), + ); + break; + + case 'google': + $models = array( + 'gemini-1.5-pro' => __( 'Gemini 1.5 Pro', 'plugin-check' ), + 'gemini-1.5-flash' => __( 'Gemini 1.5 Flash', 'plugin-check' ), + 'gemini-pro' => __( 'Gemini Pro', 'plugin-check' ), + ); + break; + + case 'azure': + $models = array( + 'gpt-4o' => __( 'GPT-4o (Azure)', 'plugin-check' ), + 'gpt-4-turbo' => __( 'GPT-4 Turbo (Azure)', 'plugin-check' ), + 'gpt-4' => __( 'GPT-4 (Azure)', 'plugin-check' ), + 'gpt-35-turbo' => __( 'GPT-3.5 Turbo (Azure)', 'plugin-check' ), + ); + break; + } + + return $models; + } + + /** + * Gets the label for a provider. + * + * @since 1.8.0 + * + * @param string $provider Provider key. + * @return string Provider label. + */ + protected function get_provider_label( $provider ) { + $providers = $this->get_available_providers(); + return isset( $providers[ $provider ] ) ? $providers[ $provider ] : $provider; + } + + /** + * Sanitizes settings input. + * + * @since 1.8.0 + * + * @param array $input Settings input. + * @return array Sanitized settings. + */ + public function sanitize_settings( $input ) { + $sanitized = array(); + + if ( isset( $input['ai_provider'] ) ) { + $providers = array_keys( $this->get_available_providers() ); + $sanitized['ai_provider'] = in_array( $input['ai_provider'], $providers, true ) ? $input['ai_provider'] : ''; + } + + // Get current settings to handle password field behavior. + $current_settings = get_option( self::OPTION_NAME, array() ); + + if ( isset( $input['ai_api_key'] ) ) { + // If empty, keep existing key (password field unchanged). + if ( ! empty( $input['ai_api_key'] ) ) { + $sanitized['ai_api_key'] = sanitize_text_field( $input['ai_api_key'] ); + } elseif ( isset( $current_settings['ai_api_key'] ) && ! empty( $current_settings['ai_api_key'] ) ) { + // Keep existing if not explicitly changed. + $sanitized['ai_api_key'] = $current_settings['ai_api_key']; + } else { + $sanitized['ai_api_key'] = ''; + } + } elseif ( isset( $current_settings['ai_api_key'] ) ) { + // Keep existing if not in input. + $sanitized['ai_api_key'] = $current_settings['ai_api_key']; + } + + if ( isset( $input['ai_model'] ) ) { + $provider = isset( $sanitized['ai_provider'] ) ? $sanitized['ai_provider'] : ( isset( $input['ai_provider'] ) ? $input['ai_provider'] : '' ); + $models = array_keys( $this->get_models_for_provider( $provider ) ); + $sanitized['ai_model'] = in_array( $input['ai_model'], $models, true ) ? $input['ai_model'] : ''; + } + + // Test AI connection if all required fields are provided and settings have changed. + $provider_changed = ! isset( $current_settings['ai_provider'] ) || $current_settings['ai_provider'] !== $sanitized['ai_provider']; + $api_key_changed = ! isset( $current_settings['ai_api_key'] ) || $current_settings['ai_api_key'] !== $sanitized['ai_api_key']; + $model_changed = ! isset( $current_settings['ai_model'] ) || $current_settings['ai_model'] !== $sanitized['ai_model']; + + if ( ! empty( $sanitized['ai_provider'] ) && ! empty( $sanitized['ai_api_key'] ) && ! empty( $sanitized['ai_model'] ) && ( $provider_changed || $api_key_changed || $model_changed ) ) { + $connection_test = $this->test_ai_connection( $sanitized['ai_provider'], $sanitized['ai_api_key'], $sanitized['ai_model'] ); + if ( is_wp_error( $connection_test ) ) { + // Add settings error to prevent saving. + add_settings_error( + self::OPTION_NAME, + 'ai_connection_failed', + sprintf( + /* translators: %s: Error message */ + __( 'AI connection test failed: %s. Settings were not saved.', 'plugin-check' ), + $connection_test->get_error_message() + ), + 'error' + ); + // Return current settings instead of new ones to prevent saving invalid settings. + return $current_settings; + } + } + + return $sanitized; + } + + /** + * Tests the AI connection with provided credentials. + * + * @since 1.8.0 + * + * @param string $provider Provider key. + * @param string $api_key API key. + * @param string $model Model name. + * @return bool|WP_Error True if connection successful, WP_Error on failure. + */ + protected function test_ai_connection( $provider, $api_key, $model ) { + if ( ! class_exists( '\WordPress\AI_Client\Client' ) ) { + return new WP_Error( + 'ai_client_not_available', + __( 'AI client library is not available. Please ensure wp-ai-client is installed.', 'plugin-check' ) + ); + } + + // Validate required parameters. + if ( empty( $provider ) || empty( $api_key ) || empty( $model ) ) { + return new WP_Error( + 'ai_missing_parameters', + __( 'Provider, API key, and model are required to test the connection.', 'plugin-check' ) + ); + } + + try { + $ai_client = new \WordPress\AI_Client\Client( + array( + 'provider' => $provider, + 'api_key' => $api_key, + 'model' => $model, + ) + ); + + // Test with a simple prompt to verify connection works. + $test_prompt = __( 'Test connection. Respond with "OK" only.', 'plugin-check' ); + $response = $ai_client->request( + $test_prompt, + array( + 'temperature' => 0.3, + 'max_tokens' => 10, + ) + ); + + // Check if we got a valid response. + if ( is_wp_error( $response ) ) { + return $response; + } + + // If we got a response (array or string), the connection works. + if ( is_array( $response ) || is_string( $response ) ) { + return true; + } + + return new WP_Error( + 'ai_invalid_response', + __( 'Received invalid response from AI service. Please check your API key and model.', 'plugin-check' ) + ); + } catch ( \Exception $e ) { + $error_message = $e->getMessage(); + + // Provide more user-friendly error messages for common issues. + if ( false !== strpos( strtolower( $error_message ), 'authentication' ) || false !== strpos( strtolower( $error_message ), 'unauthorized' ) ) { + return new WP_Error( + 'ai_authentication_failed', + __( 'Authentication failed. Please check your API key.', 'plugin-check' ) + ); + } + + if ( false !== strpos( strtolower( $error_message ), 'model' ) || false !== strpos( strtolower( $error_message ), 'not found' ) ) { + return new WP_Error( + 'ai_model_not_found', + __( 'The selected model is not available. Please check your model selection.', 'plugin-check' ) + ); + } + + return new WP_Error( + 'ai_connection_error', + sprintf( + /* translators: %s: Error message */ + __( 'Connection error: %s', 'plugin-check' ), + $error_message + ) + ); + } + } + + /** + * Gets the AI provider. + * + * @since 1.8.0 + * + * @return string AI provider. + */ + public static function get_provider() { + $settings = get_option( self::OPTION_NAME, array() ); + return isset( $settings['ai_provider'] ) ? $settings['ai_provider'] : ''; + } + + /** + * Gets the AI API key. + * + * @since 1.8.0 + * + * @return string AI API key. + */ + public static function get_api_key() { + $settings = get_option( self::OPTION_NAME, array() ); + return isset( $settings['ai_api_key'] ) ? $settings['ai_api_key'] : ''; + } + + /** + * Gets the AI model. + * + * @since 1.8.0 + * + * @return string AI model. + */ + public static function get_model() { + $settings = get_option( self::OPTION_NAME, array() ); + return isset( $settings['ai_model'] ) ? $settings['ai_model'] : ''; + } + + /** + * 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' ) ); + } + + // Show updated message. + if ( isset( $_GET['settings-updated'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + // Check if there are any error messages already set. + $settings_errors = get_settings_errors( self::OPTION_NAME ); + $has_errors = false; + if ( ! empty( $settings_errors ) ) { + foreach ( $settings_errors as $error ) { + if ( 'error' === $error['type'] ) { + $has_errors = true; + break; + } + } + } + + // Only show success message if no errors. + if ( ! $has_errors ) { + // Check if AI settings are configured. + $settings = get_option( self::OPTION_NAME, array() ); + if ( ! empty( $settings['ai_provider'] ) && ! empty( $settings['ai_api_key'] ) && ! empty( $settings['ai_model'] ) ) { + add_settings_error( + self::OPTION_NAME, + 'settings_updated', + __( 'Settings saved successfully. AI connection verified.', 'plugin-check' ), + 'success' + ); + } else { + add_settings_error( + self::OPTION_NAME, + 'settings_updated', + __( 'Settings saved.', 'plugin-check' ), + 'success' + ); + } + } + } + + settings_errors( self::OPTION_NAME ); + + // Enqueue script for dynamic model selection. + wp_enqueue_script( 'jquery' ); + ?> +
+

+
+ +
+
+ + hook_suffix; + } + +} + diff --git a/includes/CLI/Plugin_Check_Command.php b/includes/CLI/Plugin_Check_Command.php index 9fec44ddb..03eb62fc9 100644 --- a/includes/CLI/Plugin_Check_Command.php +++ b/includes/CLI/Plugin_Check_Command.php @@ -138,12 +138,16 @@ public function __construct( Plugin_Context $plugin_context ) { * - update * --- * + * [--use-ai] + * : Enable AI-based analysis to detect false positives in check results. + * * ## 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 * * @subcommand check * @@ -160,7 +164,7 @@ public function __construct( Plugin_Context $plugin_context ) { */ public function check( $args, $assoc_args ) { // Get options based on the CLI arguments. - $options = $this->get_options( + $options = $this->get_options( $assoc_args, array( 'checks' => '', @@ -176,6 +180,7 @@ public function check( $args, $assoc_args ) { 'slug' => '', 'ignore-codes' => '', 'mode' => 'new', + 'use-ai' => false, ) ); @@ -229,6 +234,7 @@ 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'] ); } catch ( Exception $error ) { WP_CLI::error( $error->getMessage() ); } @@ -255,8 +261,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; } diff --git a/includes/Checker/Abstract_Check_Runner.php b/includes/Checker/Abstract_Check_Runner.php index ff568686d..ccf56058c 100644 --- a/includes/Checker/Abstract_Check_Runner.php +++ b/includes/Checker/Abstract_Check_Runner.php @@ -8,8 +8,10 @@ namespace WordPress\Plugin_Check\Checker; use Exception; +use WordPress\Plugin_Check\Admin\Settings_Page; use WordPress\Plugin_Check\Checker\Exception\Invalid_Check_Slug_Exception; use WordPress\Plugin_Check\Checker\Preparations\Universal_Runtime_Preparation; +use WordPress\Plugin_Check\Traits\AI_Analyzer; use WordPress\Plugin_Check\Utilities\Plugin_Request_Utility; /** @@ -22,6 +24,8 @@ */ abstract class Abstract_Check_Runner implements Check_Runner { + use AI_Analyzer; + /** * True if the class was initialized early in the WordPress load process. * @@ -30,6 +34,14 @@ 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; + /** * The check slugs to run. * @@ -293,6 +305,29 @@ 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; + } + + /** + * 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 +425,17 @@ final public function run() { $results = $this->get_checks_instance()->run_checks( $this->get_check_context(), $checks, $this ); + // Run AI analysis if enabled. + if ( $this->should_use_ai() ) { + $ai_result = $this->analyze_results_with_ai( $results, $this->get_check_context() ); + if ( ! is_wp_error( $ai_result ) ) { + $ai_analysis = isset( $ai_result['analysis'] ) ? $ai_result['analysis'] : array(); + $ai_stats = isset( $ai_result['stats'] ) ? $ai_result['stats'] : array(); + $results->set_ai_analysis( $ai_analysis ); + $results->set_ai_stats( $ai_stats ); + } + } + if ( ! empty( $cleanups ) ) { foreach ( $cleanups as $cleanup ) { $cleanup(); diff --git a/includes/Checker/Check_Result.php b/includes/Checker/Check_Result.php index 389cb8217..a5073f63f 100644 --- a/includes/Checker/Check_Result.php +++ b/includes/Checker/Check_Result.php @@ -54,6 +54,22 @@ final class Check_Result { */ protected $warning_count = 0; + /** + * AI analysis results for false positives. + * + * @since 1.8.0 + * @var array + */ + protected $ai_analysis = array(); + + /** + * AI statistics (tokens spent, false positives count, etc.). + * + * @since 1.8.0 + * @var array + */ + protected $ai_stats = array(); + /** * Sets the context for the plugin to check. * @@ -187,4 +203,48 @@ public function get_error_count() { public function get_warning_count() { return $this->warning_count; } + + /** + * Sets AI analysis results. + * + * @since 1.8.0 + * + * @param array $analysis AI analysis results. + */ + public function set_ai_analysis( array $analysis ) { + $this->ai_analysis = $analysis; + } + + /** + * Returns AI analysis results. + * + * @since 1.8.0 + * + * @return array AI analysis results. + */ + public function get_ai_analysis() { + return $this->ai_analysis; + } + + /** + * Sets AI statistics. + * + * @since 1.8.0 + * + * @param array $stats AI statistics. + */ + public function set_ai_stats( array $stats ) { + $this->ai_stats = $stats; + } + + /** + * Returns AI statistics. + * + * @since 1.8.0 + * + * @return array AI statistics. + */ + public function get_ai_stats() { + return $this->ai_stats; + } } diff --git a/includes/Plugin_Main.php b/includes/Plugin_Main.php index 2c7edf984..67c85386b 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. @@ -67,5 +68,9 @@ public function add_hooks() { // Create the Admin page. $admin_page = new Admin_Page( $admin_ajax ); $admin_page->add_hooks(); + + // Create the Settings page. + $settings_page = new Settings_Page(); + $settings_page->add_hooks(); } } diff --git a/includes/Traits/AI_Analyzer.php b/includes/Traits/AI_Analyzer.php new file mode 100644 index 000000000..6cd31e563 --- /dev/null +++ b/includes/Traits/AI_Analyzer.php @@ -0,0 +1,399 @@ +ai_client ) { + return; + } + + // Get provider, API key, and model from settings. + $provider = ''; + $api_key = ''; + $model = ''; + if ( class_exists( Settings_Page::class ) ) { + $provider = Settings_Page::get_provider(); + $api_key = Settings_Page::get_api_key(); + $model = Settings_Page::get_model(); + } + + // If provider, API key, or model is not configured, don't initialize the client. + if ( empty( $provider ) || empty( $api_key ) || empty( $model ) ) { + return; + } + + try { + $this->ai_client = new \WordPress\AI_Client\Client( + array( + 'provider' => $provider, + 'api_key' => $api_key, + 'model' => $model, + ) + ); + } catch ( \Exception $e ) { + // AI client initialization failed, continue without AI. + $this->ai_client = null; + } + } + + /** + * Checks if AI client is available and ready. + * + * @since 1.8.0 + * + * @return bool True if AI client is available, false otherwise. + */ + protected function is_ai_available() { + $this->init_ai_client(); + return null !== $this->ai_client; + } + + /** + * Analyzes check results for false positives. + * + * @since 1.8.0 + * + * @param Check_Result $result Check result to analyze. + * @param Check_Context $check_context Check context instance. + * @return array|WP_Error Array of AI analysis results and stats or WP_Error on failure. + */ + protected function analyze_results_with_ai( Check_Result $result, Check_Context $check_context ) { + if ( ! $this->is_ai_available() ) { + return new WP_Error( + 'ai_not_available', + __( 'AI client is not available.', 'plugin-check' ) + ); + } + + $errors = $result->get_errors(); + $warnings = $result->get_warnings(); + + // If no errors or warnings, nothing to analyze. + if ( empty( $errors ) && empty( $warnings ) ) { + return array( + 'analysis' => array(), + 'stats' => array( + 'tokens_spent' => 0, + 'false_positives' => 0, + 'issues_analyzed' => 0, + ), + ); + } + + $analysis_results = array(); + $tokens_spent = 0; + $false_positives = 0; + $issues_analyzed = 0; + + // Analyze errors (only those with severity less than 7). + foreach ( $errors as $file => $file_errors ) { + foreach ( $file_errors as $line => $line_errors ) { + foreach ( $line_errors as $column => $column_errors ) { + foreach ( $column_errors as $error ) { + // Only analyze errors with severity less than 7. + $severity = isset( $error['severity'] ) ? (int) $error['severity'] : 5; + if ( $severity >= 7 ) { + continue; + } + + $analysis = $this->analyze_issue_with_ai( $file, $line, $column, $error, 'error', $check_context ); + if ( ! is_wp_error( $analysis ) ) { + $key = $this->get_issue_key( $file, $line, $column, $error['code'] ); + // Include file, line, column, and code in the analysis for easier matching in JS. + $analysis['file'] = $file; + $analysis['line'] = $line; + $analysis['column'] = $column; + $analysis['code'] = $error['code']; + $analysis_results[ $key ] = $analysis; + $issues_analyzed++; + + // Track tokens spent. + if ( isset( $analysis['tokens_spent'] ) ) { + $tokens_spent += (int) $analysis['tokens_spent']; + } + + // Count false positives. + if ( isset( $analysis['is_false_positive'] ) && $analysis['is_false_positive'] ) { + $false_positives++; + } + } + } + } + } + } + + // Analyze warnings. + foreach ( $warnings as $file => $file_warnings ) { + foreach ( $file_warnings as $line => $line_warnings ) { + foreach ( $line_warnings as $column => $column_warnings ) { + foreach ( $column_warnings as $warning ) { + $analysis = $this->analyze_issue_with_ai( $file, $line, $column, $warning, 'warning', $check_context ); + if ( ! is_wp_error( $analysis ) ) { + $key = $this->get_issue_key( $file, $line, $column, $warning['code'] ); + // Include file, line, column, and code in the analysis for easier matching in JS. + $analysis['file'] = $file; + $analysis['line'] = $line; + $analysis['column'] = $column; + $analysis['code'] = $warning['code']; + $analysis_results[ $key ] = $analysis; + $issues_analyzed++; + + // Track tokens spent. + if ( isset( $analysis['tokens_spent'] ) ) { + $tokens_spent += (int) $analysis['tokens_spent']; + } + + // Count false positives. + if ( isset( $analysis['is_false_positive'] ) && $analysis['is_false_positive'] ) { + $false_positives++; + } + } + } + } + } + } + + return array( + 'analysis' => $analysis_results, + 'stats' => array( + 'tokens_spent' => $tokens_spent, + 'false_positives' => $false_positives, + 'issues_analyzed' => $issues_analyzed, + ), + ); + } + + /** + * Analyzes a single issue for false positive potential. + * + * @since 1.8.0 + * + * @param string $file File path where the issue was found. + * @param int $line Line number where the issue was found. + * @param int $column Column number where the issue was found. + * @param array $issue Issue data (message, code, etc.). + * @param string $type Issue type ('error' or 'warning'). + * @param Check_Context $check_context Check context instance. + * @return array|WP_Error Analysis result or WP_Error on failure. + */ + protected function analyze_issue_with_ai( $file, $line, $column, $issue, $type, Check_Context $check_context ) { + $file_path = $check_context->path( '/' ) . $file; + $file_content = ''; + + // Read the file content if it exists. + if ( file_exists( $file_path ) && is_readable( $file_path ) ) { + $file_content = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + } + + // Get context around the line. + $context_lines = $this->get_code_context( $file_content, $line ); + + // Build the prompt for AI analysis. + $prompt = $this->build_analysis_prompt( $file, $line, $column, $issue, $type, $context_lines ); + + try { + $response = $this->ai_client->request( + $prompt, + array( + 'temperature' => 0.3, + 'max_tokens' => 500, + ) + ); + + $analysis = $this->parse_ai_response( $response, $issue ); + + // Track tokens spent if available in response. + if ( is_array( $response ) && isset( $response['usage'] ) ) { + $usage = $response['usage']; + $tokens = 0; + if ( isset( $usage['total_tokens'] ) ) { + $tokens = (int) $usage['total_tokens']; + } elseif ( isset( $usage['prompt_tokens'] ) && isset( $usage['completion_tokens'] ) ) { + $tokens = (int) $usage['prompt_tokens'] + (int) $usage['completion_tokens']; + } + $analysis['tokens_spent'] = $tokens; + } + + return $analysis; + } catch ( \Exception $e ) { + return new WP_Error( + 'ai_request_failed', + sprintf( + // translators: %s: Error message. + __( 'AI analysis failed: %s', 'plugin-check' ), + $e->getMessage() + ) + ); + } + } + + /** + * Gets code context around a specific line. + * + * @since 1.8.0 + * + * @param string $file_content Full file content. + * @param int $line Line number. + * @param int $context Number of lines before and after. + * @return string Code context. + */ + 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_slice( $lines, $start, $end - $start ); + + return implode( "\n", $context_lines ); + } + + /** + * Builds the prompt for AI analysis. + * + * @since 1.8.0 + * + * @param string $file File path. + * @param int $line Line number. + * @param int $column Column number. + * @param array $issue Issue data. + * @param string $type Issue type. + * @param string $code_context Code context. + * @return string AI prompt. + */ + protected function build_analysis_prompt( $file, $line, $column, $issue, $type, $code_context ) { + $prompt = sprintf( + // translators: %1$s: Issue type, %2$s: File path, %3$s: Line number, %4$s: Issue code, %5$s: Issue message. + __( + 'You are analyzing a WordPress plugin check result for potential false positives. + +Issue Type: %1$s +File: %2$s +Line: %3$d +Column: %4$d +Issue Code: %5$s +Issue Message: %6$s + +Code Context: +``` +%7$s +``` + +Please analyze if this is likely a false positive or a legitimate issue. Consider: +- Whether the code is actually problematic +- If there are legitimate exceptions or edge cases +- Whether the check might be too strict +- The context and intent of the code + +Provide your analysis in JSON format with the following structure: +{ + "is_false_positive": boolean, + "confidence": float (0.0 to 1.0), + "reasoning": "string explanation", + "recommendation": "string recommendation" +} + +Respond ONLY with valid JSON, no other text.', + 'plugin-check' + ), + $type, + $file, + $line, + $column, + $issue['code'], + $issue['message'], + $code_context + ); + + return $prompt; + } + + /** + * Parses the AI response into a structured format. + * + * @since 1.8.0 + * + * @param string|array $response AI response. + * @param array $issue Original issue data. + * @return array Parsed analysis result. + */ + protected function parse_ai_response( $response, $issue ) { + $response_text = is_array( $response ) && isset( $response['content'] ) ? $response['content'] : (string) $response; + + // Try to extract JSON from the response. + if ( preg_match( '/\{[^}]+\}/s', $response_text, $matches ) ) { + $json = json_decode( $matches[0], true ); + if ( json_last_error() === JSON_ERROR_NONE && is_array( $json ) ) { + return array( + 'is_false_positive' => isset( $json['is_false_positive'] ) ? (bool) $json['is_false_positive'] : false, + 'confidence' => isset( $json['confidence'] ) ? floatval( $json['confidence'] ) : 0.5, + 'reasoning' => isset( $json['reasoning'] ) ? sanitize_text_field( $json['reasoning'] ) : '', + 'recommendation' => isset( $json['recommendation'] ) ? sanitize_text_field( $json['recommendation'] ) : '', + 'original_issue' => $issue, + ); + } + } + + // Fallback if JSON parsing fails. + return array( + 'is_false_positive' => false, + 'confidence' => 0.5, + 'reasoning' => __( 'Unable to parse AI response.', 'plugin-check' ), + 'recommendation' => __( 'Manual review recommended.', 'plugin-check' ), + 'original_issue' => $issue, + ); + } + + /** + * 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/templates/admin-page.php b/templates/admin-page.php index 534785bc8..5c26010b7 100644 --- a/templates/admin-page.php +++ b/templates/admin-page.php @@ -61,12 +61,19 @@ +

-

+

+ +

+

+ + . +

diff --git a/templates/results-row.php b/templates/results-row.php index fba068492..a2ef2cd2d 100644 --- a/templates/results-row.php +++ b/templates/results-row.php @@ -21,6 +21,34 @@ <# } #> + <# if ( data.ai_analysis ) { #> +
+ <# if ( data.ai_analysis.is_false_positive ) { #> + + + + <# if ( data.ai_analysis.confidence ) { #> + (: {{Math.round(data.ai_analysis.confidence * 100)}}%) + <# } #> + + <# } else { #> + + + + <# if ( data.ai_analysis.confidence ) { #> + (: {{Math.round(data.ai_analysis.confidence * 100)}}%) + <# } #> + + <# } #> + <# if ( data.ai_analysis.reasoning ) { #> +
+ {{{data.ai_analysis.reasoning}}} + <# } #> + <# if ( data.ai_analysis.recommendation ) { #> +
+ : {{{data.ai_analysis.recommendation}}} + <# } #> + <# } #> <# if ( data.hasLinks ) { #> From eefbd210c74df2858be22cdf09df19108505e826 Mon Sep 17 00:00:00 2001 From: davidperezgar Date: Sun, 30 Nov 2025 16:52:37 +0100 Subject: [PATCH 2/8] phpcs --- includes/Admin/Settings_Page.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/Admin/Settings_Page.php b/includes/Admin/Settings_Page.php index 9f68e58cd..d339952c3 100644 --- a/includes/Admin/Settings_Page.php +++ b/includes/Admin/Settings_Page.php @@ -155,8 +155,8 @@ public function render_ai_section_description() { * @param array $args Field arguments. */ public function render_provider_field( $args ) { - $settings = get_option( self::OPTION_NAME, array() ); - $value = isset( $settings['ai_provider'] ) ? esc_attr( $settings['ai_provider'] ) : ''; + $settings = get_option( self::OPTION_NAME, array() ); + $value = isset( $settings['ai_provider'] ) ? esc_attr( $settings['ai_provider'] ) : ''; $providers = $this->get_available_providers(); ?> +

+ - -

-

- -

-

- - . -

From b72e8e06035bc6c99303092b149ad354de00707f Mon Sep 17 00:00:00 2001 From: davidperezgar Date: Sun, 7 Dec 2025 12:55:36 +0100 Subject: [PATCH 4/8] finished settings --- includes/Admin/Settings_Page.php | 140 ++++++++++++++++++++++++++++++- 1 file changed, 137 insertions(+), 3 deletions(-) diff --git a/includes/Admin/Settings_Page.php b/includes/Admin/Settings_Page.php index d339952c3..2cff8c852 100644 --- a/includes/Admin/Settings_Page.php +++ b/includes/Admin/Settings_Page.php @@ -86,9 +86,11 @@ public function register_settings() { array( 'sanitize_callback' => array( $this, 'sanitize_settings' ), 'default' => array( - 'ai_provider' => '', - 'ai_api_key' => '', - 'ai_model' => '', + 'ai_provider' => '', + 'ai_api_key' => '', + 'ai_model' => '', + 'ai_severity_errors' => 7, + 'ai_severity_warnings' => 6, ), ) ); @@ -132,6 +134,35 @@ public function register_settings() { 'label_for' => 'ai_model', ) ); + + 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', + ) + ); } /** @@ -147,6 +178,19 @@ public function render_ai_section_description() { +

+ +

+ + +

+ +

+ + +

+ +

+ = 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; + } + // Test AI connection if all required fields are provided and settings have changed. $provider_changed = ! isset( $current_settings['ai_provider'] ) || $current_settings['ai_provider'] !== $sanitized['ai_provider']; $api_key_changed = ! isset( $current_settings['ai_api_key'] ) || $current_settings['ai_api_key'] !== $sanitized['ai_api_key']; @@ -530,6 +640,30 @@ public static function get_model() { return isset( $settings['ai_model'] ) ? $settings['ai_model'] : ''; } + /** + * 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. * From df5c9a6ed18b8c8baa786c5eee93cc2a920c4757 Mon Sep 17 00:00:00 2001 From: davidperezgar Date: Sun, 7 Dec 2025 20:04:03 +0100 Subject: [PATCH 5/8] fixes --- composer.json | 1 + composer.lock | 605 +++++++++++++++++++++++++------ includes/Admin/Admin_AJAX.php | 4 +- includes/Admin/Settings_Page.php | 80 +++- includes/Plugin_Main.php | 5 + includes/Traits/AI_Analyzer.php | 175 ++++----- 6 files changed, 659 insertions(+), 211 deletions(-) diff --git a/composer.json b/composer.json index 56e3ceb6a..240dd31e1 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,7 @@ "automattic/vipwpcs": "^3.0.0", "composer/installers": "^2.2", "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0", + "guzzlehttp/guzzle": "^7.8", "nikic/php-parser": "^4", "plugin-check/phpcs-sniffs": "@dev", "wordpress/wp-ai-client": "^0.1.0", diff --git a/composer.lock b/composer.lock index d3c7c66cc..ec9f9449b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c04c7f592e6fd46ca5db240abdeed74a", + "content-hash": "c6e4dfa6e4133178885b83f4d2aa3fe6", "packages": [ { "name": "automattic/vipwpcs", @@ -302,6 +302,331 @@ ], "time": "2025-11-11T04:32:07+00:00" }, + { + "name": "guzzlehttp/guzzle", + "version": "7.10.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2025-08-23T22:36:01+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "481557b130ef3790cf82b713667b43030dc9c957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.3.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2025-08-22T14:34:08+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "21dc724a0583619cd1652f673303492272778051" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", + "reference": "21dc724a0583619cd1652f673303492272778051", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.8.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2025-08-23T21:21:41+00:00" + }, { "name": "nikic/php-parser", "version": "v4.19.4", @@ -436,6 +761,57 @@ ], "time": "2024-09-09T07:06:30+00:00" }, + { + "name": "patrickschur/language-detection", + "version": "v5.3.1", + "source": { + "type": "git", + "url": "https://github.com/patrickschur/language-detection.git", + "reference": "df8d32021b2ef9fde52e6fcccb83e3806822c9c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/patrickschur/language-detection/zipball/df8d32021b2ef9fde52e6fcccb83e3806822c9c6", + "reference": "df8d32021b2ef9fde52e6fcccb83e3806822c9c6", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "LanguageDetection\\": "src/LanguageDetection" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Patrick Schur", + "email": "patrick_schur@outlook.de" + } + ], + "description": "A language detection library for PHP. Detects the language from a given text string.", + "homepage": "https://github.com/patrickschur/language-detection", + "keywords": [ + "detect", + "detection", + "language" + ], + "support": { + "issues": "https://github.com/patrickschur/language-detection/issues", + "source": "https://github.com/patrickschur/language-detection/tree/v5.3.1" + }, + "time": "2025-03-25T22:47:08+00:00" + }, { "name": "php-http/discovery", "version": "1.20.0", @@ -538,32 +914,11 @@ "require-dev": { "friends-of-phpspec/phpspec-code-coverage": "^4.1 || ^5.0 || ^6.0", "phpspec/phpspec": "^5.1 || ^6.0 || ^7.0" - "name": "patrickschur/language-detection", - "version": "v5.3.1", - "source": { - "type": "git", - "url": "https://github.com/patrickschur/language-detection.git", - "reference": "df8d32021b2ef9fde52e6fcccb83e3806822c9c6" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/patrickschur/language-detection/zipball/df8d32021b2ef9fde52e6fcccb83e3806822c9c6", - "reference": "df8d32021b2ef9fde52e6fcccb83e3806822c9c6", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-mbstring": "*", - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "phpunit/phpunit": "^9.5.0" }, "type": "library", "autoload": { "psr-4": { - "Http\\Client\\": "src/", - "LanguageDetection\\": "src/LanguageDetection" + "Http\\Client\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -699,22 +1054,6 @@ "source": "https://github.com/php-http/promise/tree/1.3.1" }, "time": "2024-03-15T13:55:21+00:00" - "name": "Patrick Schur", - "email": "patrick_schur@outlook.de" - } - ], - "description": "A language detection library for PHP. Detects the language from a given text string.", - "homepage": "https://github.com/patrickschur/language-detection", - "keywords": [ - "detect", - "detection", - "language" - ], - "support": { - "issues": "https://github.com/patrickschur/language-detection/issues", - "source": "https://github.com/patrickschur/language-detection/tree/v5.3.1" - }, - "time": "2025-03-25T22:47:08+00:00" }, { "name": "phpcsstandards/phpcsextra", @@ -1119,6 +1458,50 @@ }, "time": "2023-04-04T09:54:51+00:00" }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, { "name": "sirbrillig/phpcs-variable-analysis", "version": "v2.11.21", @@ -1256,6 +1639,73 @@ ], "time": "2025-11-04T16:30:35+00:00" }, + { + "name": "symfony/deprecation-contracts", + "version": "v2.5.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "605389f2a7e5625f273b53960dc46aeaf9c62918" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/605389f2a7e5625f273b53960dc46aeaf9c62918", + "reference": "605389f2a7e5625f273b53960dc46aeaf9c62918", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "2.5-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:11:13+00:00" + }, { "name": "wordpress/php-ai-client", "version": "0.2.1", @@ -1325,16 +1775,16 @@ }, { "name": "wordpress/wp-ai-client", - "version": "0.1.0", + "version": "0.1.1", "source": { "type": "git", "url": "https://github.com/WordPress/wp-ai-client.git", - "reference": "9037ede3f20e65ba3820e0d2e514cbffb891d04b" + "reference": "3c1f14f11f13753d13e58c12902d40f02a5c8207" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/WordPress/wp-ai-client/zipball/9037ede3f20e65ba3820e0d2e514cbffb891d04b", - "reference": "9037ede3f20e65ba3820e0d2e514cbffb891d04b", + "url": "https://api.github.com/repos/WordPress/wp-ai-client/zipball/3c1f14f11f13753d13e58c12902d40f02a5c8207", + "reference": "3c1f14f11f13753d13e58c12902d40f02a5c8207", "shasum": "" }, "require": { @@ -1383,7 +1833,7 @@ "issues": "https://github.com/WordPress/wp-ai-client/issues", "source": "https://github.com/WordPress/wp-ai-client" }, - "time": "2025-11-14T21:08:54+00:00" + "time": "2025-11-21T22:57:02+00:00" }, { "name": "wp-coding-standards/wpcs", @@ -5106,73 +5556,6 @@ ], "time": "2024-11-20T10:51:57+00:00" }, - { - "name": "symfony/deprecation-contracts", - "version": "v2.5.4", - "source": { - "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "605389f2a7e5625f273b53960dc46aeaf9c62918" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/605389f2a7e5625f273b53960dc46aeaf9c62918", - "reference": "605389f2a7e5625f273b53960dc46aeaf9c62918", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "2.5-dev" - } - }, - "autoload": { - "files": [ - "function.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "A generic function and convention to trigger deprecation notices", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.4" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-25T14:11:13+00:00" - }, { "name": "symfony/event-dispatcher", "version": "v5.4.45", diff --git a/includes/Admin/Admin_AJAX.php b/includes/Admin/Admin_AJAX.php index 5201181c6..12b6aed35 100644 --- a/includes/Admin/Admin_AJAX.php +++ b/includes/Admin/Admin_AJAX.php @@ -236,10 +236,10 @@ public function get_checks_to_run() { $plugin = filter_input( INPUT_POST, 'plugin', FILTER_SANITIZE_FULL_SPECIAL_CHARS ); $include_experimental = 1 === filter_input( INPUT_POST, 'include-experimental', FILTER_VALIDATE_INT ); $use_ai = 1 === filter_input( INPUT_POST, 'use-ai', FILTER_VALIDATE_INT ); - $runner = Plugin_Request_Utility::get_runner(); + $runner = $this->get_ajax_runner(); if ( is_wp_error( $runner ) ) { - wp_send_json_error( $runner, 403 ); + wp_send_json_error( $runner, 500 ); } try { diff --git a/includes/Admin/Settings_Page.php b/includes/Admin/Settings_Page.php index 2cff8c852..8b59e0e69 100644 --- a/includes/Admin/Settings_Page.php +++ b/includes/Admin/Settings_Page.php @@ -56,6 +56,28 @@ final class Settings_Page { public function add_hooks() { add_action( 'admin_menu', array( $this, 'add_page' ) ); add_action( 'admin_init', array( $this, 'register_settings' ) ); + add_action( 'admin_init', array( $this, 'maybe_sync_existing_credentials' ) ); + } + + /** + * Syncs existing credentials to wp-ai-client on init if not already synced. + * + * @since 1.8.0 + */ + public function maybe_sync_existing_credentials() { + $settings = get_option( self::OPTION_NAME, array() ); + + // Only sync if we have credentials configured. + if ( ! empty( $settings['ai_provider'] ) && ! empty( $settings['ai_api_key'] ) ) { + // Check if credentials are already in wp-ai-client format. + $ai_client_credentials = get_option( 'wp_ai_client_provider_credentials', array() ); + + // If our provider's credentials are missing or different, sync them. + if ( ! isset( $ai_client_credentials[ $settings['ai_provider'] ] ) || + $ai_client_credentials[ $settings['ai_provider'] ] !== $settings['ai_api_key'] ) { + $this->sync_credentials_to_ai_client( $settings ); + } + } } /** @@ -186,7 +208,7 @@ public function render_ai_section_description() { public function render_severity_section_description() { ?>

- +

- +

- +

sync_credentials_to_ai_client( $sanitized ); + return $sanitized; } + /** + * Syncs our credentials to the wp-ai-client credential storage. + * + * @since 1.8.0 + * + * @param array $settings Settings array with provider and api_key. + */ + protected function sync_credentials_to_ai_client( $settings ) { + // Get current wp-ai-client credentials. + $ai_client_credentials = get_option( 'wp_ai_client_provider_credentials', array() ); + + if ( ! is_array( $ai_client_credentials ) ) { + $ai_client_credentials = array(); + } + + // Update credentials for our provider. + if ( ! empty( $settings['ai_provider'] ) && ! empty( $settings['ai_api_key'] ) ) { + $ai_client_credentials[ $settings['ai_provider'] ] = $settings['ai_api_key']; + } elseif ( ! empty( $settings['ai_provider'] ) && empty( $settings['ai_api_key'] ) ) { + // Remove credentials if API key is empty. + unset( $ai_client_credentials[ $settings['ai_provider'] ] ); + } + + // Save updated credentials. + update_option( 'wp_ai_client_provider_credentials', $ai_client_credentials ); + } + /** * Tests the AI connection with provided credentials. * @@ -527,7 +579,7 @@ public function sanitize_settings( $input ) { * @return bool|WP_Error True if connection successful, WP_Error on failure. */ protected function test_ai_connection( $provider, $api_key, $model ) { - if ( ! class_exists( '\WordPress\AI_Client\Client' ) ) { + if ( ! class_exists( '\WordPress\AI_Client\AI_Client' ) ) { return new WP_Error( 'ai_client_not_available', __( 'AI client library is not available. Please ensure wp-ai-client is installed.', 'plugin-check' ) @@ -543,7 +595,7 @@ protected function test_ai_connection( $provider, $api_key, $model ) { } try { - $ai_client = new \WordPress\AI_Client\Client( + $ai_client = new \WordPress\AI_Client\AI_Client( array( 'provider' => $provider, 'api_key' => $api_key, @@ -551,15 +603,15 @@ protected function test_ai_connection( $provider, $api_key, $model ) { ) ); - // Test with a simple prompt to verify connection works. - $test_prompt = __( 'Test connection. Respond with "OK" only.', 'plugin-check' ); - $response = $ai_client->request( - $test_prompt, - array( - 'temperature' => 0.3, - 'max_tokens' => 10, - ) - ); + // Test with a simple prompt to verify connection works. + $test_prompt = __( 'Test connection. Respond with "OK" only.', 'plugin-check' ); + $response = $ai_client->request( + $test_prompt, + array( + 'temperature' => 0.3, + 'max_completion_tokens' => 10, + ) + ); // Check if we got a valid response. if ( is_wp_error( $response ) ) { diff --git a/includes/Plugin_Main.php b/includes/Plugin_Main.php index 67c85386b..d211a1afd 100644 --- a/includes/Plugin_Main.php +++ b/includes/Plugin_Main.php @@ -56,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; diff --git a/includes/Traits/AI_Analyzer.php b/includes/Traits/AI_Analyzer.php index 6cd31e563..ffb08cc39 100644 --- a/includes/Traits/AI_Analyzer.php +++ b/includes/Traits/AI_Analyzer.php @@ -20,66 +20,33 @@ trait AI_Analyzer { /** - * AI client instance. + * Checks if AI client is available and ready. * * @since 1.8.0 - * @var object|null - */ - protected $ai_client; - - /** - * Initializes the AI client if wp-ai-client is available. * - * @since 1.8.0 + * @return bool True if AI client is available, false otherwise. */ - protected function init_ai_client() { - if ( ! class_exists( '\WordPress\AI_Client\Client' ) ) { - return; - } - - if ( null !== $this->ai_client ) { - return; + protected function is_ai_available() { + // Check if AI_Client class exists. + if ( ! class_exists( '\WordPress\AI_Client\AI_Client' ) ) { + return false; } // Get provider, API key, and model from settings. - $provider = ''; - $api_key = ''; - $model = ''; - if ( class_exists( Settings_Page::class ) ) { - $provider = Settings_Page::get_provider(); - $api_key = Settings_Page::get_api_key(); - $model = Settings_Page::get_model(); + if ( ! class_exists( Settings_Page::class ) ) { + return false; } - // If provider, API key, or model is not configured, don't initialize the client. - if ( empty( $provider ) || empty( $api_key ) || empty( $model ) ) { - return; - } + $provider = Settings_Page::get_provider(); + $api_key = Settings_Page::get_api_key(); + $model = Settings_Page::get_model(); - try { - $this->ai_client = new \WordPress\AI_Client\Client( - array( - 'provider' => $provider, - 'api_key' => $api_key, - 'model' => $model, - ) - ); - } catch ( \Exception $e ) { - // AI client initialization failed, continue without AI. - $this->ai_client = null; + // If provider, API key, or model is not configured, AI is not available. + if ( empty( $provider ) || empty( $api_key ) || empty( $model ) ) { + return false; } - } - /** - * Checks if AI client is available and ready. - * - * @since 1.8.0 - * - * @return bool True if AI client is available, false otherwise. - */ - protected function is_ai_available() { - $this->init_ai_client(); - return null !== $this->ai_client; + return true; } /** @@ -107,9 +74,9 @@ protected function analyze_results_with_ai( Check_Result $result, Check_Context return array( 'analysis' => array(), 'stats' => array( - 'tokens_spent' => 0, - 'false_positives' => 0, - 'issues_analyzed' => 0, + 'tokens_spent' => 0, + 'false_positives' => 0, + 'issues_analyzed' => 0, ), ); } @@ -119,25 +86,29 @@ protected function analyze_results_with_ai( Check_Result $result, Check_Context $false_positives = 0; $issues_analyzed = 0; - // Analyze errors (only those with severity less than 7). + // Get severity thresholds from settings. + $error_threshold = Settings_Page::get_severity_errors(); + $warning_threshold = Settings_Page::get_severity_warnings(); + + // Analyze errors (only those with severity less than threshold). foreach ( $errors as $file => $file_errors ) { foreach ( $file_errors as $line => $line_errors ) { foreach ( $line_errors as $column => $column_errors ) { foreach ( $column_errors as $error ) { - // Only analyze errors with severity less than 7. + // Only analyze errors with severity < threshold (low severity = more likely false positive). $severity = isset( $error['severity'] ) ? (int) $error['severity'] : 5; - if ( $severity >= 7 ) { + if ( $severity >= $error_threshold ) { continue; } $analysis = $this->analyze_issue_with_ai( $file, $line, $column, $error, 'error', $check_context ); if ( ! is_wp_error( $analysis ) ) { - $key = $this->get_issue_key( $file, $line, $column, $error['code'] ); + $key = $this->get_issue_key( $file, $line, $column, $error['code'] ); // Include file, line, column, and code in the analysis for easier matching in JS. - $analysis['file'] = $file; - $analysis['line'] = $line; - $analysis['column'] = $column; - $analysis['code'] = $error['code']; + $analysis['file'] = $file; + $analysis['line'] = $line; + $analysis['column'] = $column; + $analysis['code'] = $error['code']; $analysis_results[ $key ] = $analysis; $issues_analyzed++; @@ -156,11 +127,17 @@ protected function analyze_results_with_ai( Check_Result $result, Check_Context } } - // Analyze warnings. + // Analyze warnings (only those with severity less than threshold). foreach ( $warnings as $file => $file_warnings ) { foreach ( $file_warnings as $line => $line_warnings ) { foreach ( $line_warnings as $column => $column_warnings ) { foreach ( $column_warnings as $warning ) { + // Only analyze warnings with severity < threshold (low severity = more likely false positive). + $severity = isset( $warning['severity'] ) ? (int) $warning['severity'] : 5; + if ( $severity >= $warning_threshold ) { + continue; + } + $analysis = $this->analyze_issue_with_ai( $file, $line, $column, $warning, 'warning', $check_context ); if ( ! is_wp_error( $analysis ) ) { $key = $this->get_issue_key( $file, $line, $column, $warning['code'] ); @@ -211,39 +188,71 @@ protected function analyze_results_with_ai( Check_Result $result, Check_Context * @return array|WP_Error Analysis result or WP_Error on failure. */ protected function analyze_issue_with_ai( $file, $line, $column, $issue, $type, Check_Context $check_context ) { + // Ensure AI is available before proceeding. + if ( ! $this->is_ai_available() ) { + return new WP_Error( + 'ai_not_available', + __( 'AI client is not available.', 'plugin-check' ) + ); + } + $file_path = $check_context->path( '/' ) . $file; $file_content = ''; - // Read the file content if it exists. if ( file_exists( $file_path ) && is_readable( $file_path ) ) { $file_content = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents } - // Get context around the line. $context_lines = $this->get_code_context( $file_content, $line ); - // Build the prompt for AI analysis. $prompt = $this->build_analysis_prompt( $file, $line, $column, $issue, $type, $context_lines ); try { - $response = $this->ai_client->request( - $prompt, - array( - 'temperature' => 0.3, - 'max_tokens' => 500, - ) - ); + $provider = Settings_Page::get_provider(); + $api_key = Settings_Page::get_api_key(); + $model = Settings_Page::get_model(); + + // Ensure credentials are registered with the provider registry. + if ( class_exists( '\WordPress\AiClient\AiClient' ) ) { + $registry = \WordPress\AiClient\AiClient::defaultRegistry(); + + if ( $registry->hasProvider( $provider ) ) { + $registry->setProviderRequestAuthentication( + $provider, + new \WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication( $api_key ) + ); + } + } + + // Build prompt with provider and model configuration. + $prompt_builder = \WordPress\AI_Client\AI_Client::prompt( $prompt ) + ->using_temperature( 0.3 ) + ->using_max_completion_tokens( 500 ); - $analysis = $this->parse_ai_response( $response, $issue ); + // Set provider and model using config. + if ( ! empty( $provider ) ) { + $prompt_builder->using_provider( $provider ); - // Track tokens spent if available in response. - if ( is_array( $response ) && isset( $response['usage'] ) ) { - $usage = $response['usage']; + if ( ! empty( $model ) && class_exists( '\WordPress\AiClient\Providers\Models\DTO\ModelConfig' ) ) { + $model_config = new \WordPress\AiClient\Providers\Models\DTO\ModelConfig( $model ); + $prompt_builder->using_model_config( $model_config ); + } + } + + $result = $prompt_builder->generate_text_result(); + + $response_text = $result->text(); + + $analysis = $this->parse_ai_response( $response_text, $issue ); + + // Track tokens spent if available in result. + $usage = $result->usage(); + if ( null !== $usage ) { $tokens = 0; - if ( isset( $usage['total_tokens'] ) ) { - $tokens = (int) $usage['total_tokens']; - } elseif ( isset( $usage['prompt_tokens'] ) && isset( $usage['completion_tokens'] ) ) { - $tokens = (int) $usage['prompt_tokens'] + (int) $usage['completion_tokens']; + if ( null !== $usage->totalTokens() ) { + $tokens = $usage->totalTokens(); + } elseif ( null !== $usage->promptTokens() && null !== $usage->completionTokens() ) { + $tokens = $usage->promptTokens() + $usage->completionTokens(); } $analysis['tokens_spent'] = $tokens; } @@ -350,17 +359,15 @@ protected function build_analysis_prompt( $file, $line, $column, $issue, $type, * * @since 1.8.0 * - * @param string|array $response AI response. - * @param array $issue Original issue data. + * @param string $response_text AI response text. + * @param array $issue Original issue data. * @return array Parsed analysis result. */ - protected function parse_ai_response( $response, $issue ) { - $response_text = is_array( $response ) && isset( $response['content'] ) ? $response['content'] : (string) $response; - + protected function parse_ai_response( $response_text, $issue ) { // Try to extract JSON from the response. if ( preg_match( '/\{[^}]+\}/s', $response_text, $matches ) ) { $json = json_decode( $matches[0], true ); - if ( json_last_error() === JSON_ERROR_NONE && is_array( $json ) ) { + if ( JSON_ERROR_NONE === json_last_error() && is_array( $json ) ) { return array( 'is_false_positive' => isset( $json['is_false_positive'] ) ? (bool) $json['is_false_positive'] : false, 'confidence' => isset( $json['confidence'] ) ? floatval( $json['confidence'] ) : 0.5, From 7346d425b739fbbac53a3fdbab4c6b321992961b Mon Sep 17 00:00:00 2001 From: davidperezgar Date: Sun, 7 Dec 2025 20:04:40 +0100 Subject: [PATCH 6/8] phpcs --- includes/Traits/AI_Analyzer.php | 1 - 1 file changed, 1 deletion(-) diff --git a/includes/Traits/AI_Analyzer.php b/includes/Traits/AI_Analyzer.php index ffb08cc39..840b017ba 100644 --- a/includes/Traits/AI_Analyzer.php +++ b/includes/Traits/AI_Analyzer.php @@ -403,4 +403,3 @@ protected function get_issue_key( $file, $line, $column, $code ) { return md5( $file . ':' . $line . ':' . $column . ':' . $code ); } } - From 0fcb61466400f97008b02afff11ba3cf7c3f307e Mon Sep 17 00:00:00 2001 From: davidperezgar Date: Sun, 26 Apr 2026 17:24:34 +0200 Subject: [PATCH 7/8] updated page Co-authored-by: Copilot --- assets/js/plugin-check-admin.js | 12 + includes/Admin/Settings_Page.php | 615 ++-------------- includes/CLI/Plugin_Check_Command.php | 89 +++ includes/Checker/Abstract_Check_Runner.php | 27 +- includes/Traits/AI_Analyzer.php | 804 +++++++++++++++------ prompts/ai-review-code-obfuscation.md | 15 + prompts/ai-review-direct-db-queries.md | 15 + prompts/ai-review-generic.md | 12 + prompts/ai-review-late-escaping.md | 16 + prompts/ai-review-nonce-verification.md | 16 + prompts/ai-review-plugin-updater.md | 13 + prompts/ai-review-sanitization.md | 15 + prompts/ai-review-setting-sanitization.md | 13 + 13 files changed, 880 insertions(+), 782 deletions(-) create mode 100644 prompts/ai-review-code-obfuscation.md create mode 100644 prompts/ai-review-direct-db-queries.md create mode 100644 prompts/ai-review-generic.md create mode 100644 prompts/ai-review-late-escaping.md create mode 100644 prompts/ai-review-nonce-verification.md create mode 100644 prompts/ai-review-plugin-updater.md create mode 100644 prompts/ai-review-sanitization.md create mode 100644 prompts/ai-review-setting-sanitization.md diff --git a/assets/js/plugin-check-admin.js b/assets/js/plugin-check-admin.js index e3df32974..f8834879d 100644 --- a/assets/js/plugin-check-admin.js +++ b/assets/js/plugin-check-admin.js @@ -88,6 +88,12 @@ for ( let i = 0; i < typesList.length; i++ ) { typesList[ i ].disabled = true; } + if ( useAi ) { + useAi.disabled = true; + } + if ( includeExperimental ) { + includeExperimental.disabled = true; + } getChecksToRun() .then( setUpEnvironment ) @@ -134,6 +140,12 @@ for ( let i = 0; i < typesList.length; i++ ) { typesList[ i ].disabled = false; } + if ( useAi ) { + useAi.disabled = false; + } + if ( includeExperimental ) { + includeExperimental.disabled = false; + } } function createEmptyAggregatedResults() { diff --git a/includes/Admin/Settings_Page.php b/includes/Admin/Settings_Page.php index 8b59e0e69..8d8e63ce8 100644 --- a/includes/Admin/Settings_Page.php +++ b/includes/Admin/Settings_Page.php @@ -7,15 +7,20 @@ namespace WordPress\Plugin_Check\Admin; -use WP_Error; +use WordPress\Plugin_Check\Traits\AI_Utils; /** * Class to handle the Settings page for Plugin Check. * + * Provides AI model selection (from WordPress 7.0 core AI connectors) + * and severity threshold configuration for AI false positive detection. + * * @since 1.8.0 */ final class Settings_Page { + use AI_Utils; + /** * Option group name. * @@ -56,28 +61,6 @@ final class Settings_Page { public function add_hooks() { add_action( 'admin_menu', array( $this, 'add_page' ) ); add_action( 'admin_init', array( $this, 'register_settings' ) ); - add_action( 'admin_init', array( $this, 'maybe_sync_existing_credentials' ) ); - } - - /** - * Syncs existing credentials to wp-ai-client on init if not already synced. - * - * @since 1.8.0 - */ - public function maybe_sync_existing_credentials() { - $settings = get_option( self::OPTION_NAME, array() ); - - // Only sync if we have credentials configured. - if ( ! empty( $settings['ai_provider'] ) && ! empty( $settings['ai_api_key'] ) ) { - // Check if credentials are already in wp-ai-client format. - $ai_client_credentials = get_option( 'wp_ai_client_provider_credentials', array() ); - - // If our provider's credentials are missing or different, sync them. - if ( ! isset( $ai_client_credentials[ $settings['ai_provider'] ] ) || - $ai_client_credentials[ $settings['ai_provider'] ] !== $settings['ai_api_key'] ) { - $this->sync_credentials_to_ai_client( $settings ); - } - } } /** @@ -108,55 +91,33 @@ public function register_settings() { array( 'sanitize_callback' => array( $this, 'sanitize_settings' ), 'default' => array( - 'ai_provider' => '', - 'ai_api_key' => '', - 'ai_model' => '', + 'ai_model_preference' => '', 'ai_severity_errors' => 7, 'ai_severity_warnings' => 6, ), ) ); + // AI Code Review section. add_settings_section( - 'ai_settings_section', - __( 'AI Integration', 'plugin-check' ), + 'ai_code_review_section', + __( 'AI Code Review', 'plugin-check' ), array( $this, 'render_ai_section_description' ), self::PAGE_SLUG ); add_settings_field( - 'ai_provider', - __( 'AI Provider', 'plugin-check' ), - array( $this, 'render_provider_field' ), - self::PAGE_SLUG, - 'ai_settings_section', - array( - 'label_for' => 'ai_provider', - ) - ); - - add_settings_field( - 'ai_api_key', - __( 'API Key / Credentials', 'plugin-check' ), - array( $this, 'render_api_key_field' ), - self::PAGE_SLUG, - 'ai_settings_section', - array( - 'label_for' => 'ai_api_key', - ) - ); - - add_settings_field( - 'ai_model', + 'ai_model_preference', __( 'AI Model', 'plugin-check' ), - array( $this, 'render_model_field' ), + array( $this, 'render_model_preference_field' ), self::PAGE_SLUG, - 'ai_settings_section', + 'ai_code_review_section', array( - 'label_for' => 'ai_model', + 'label_for' => 'ai_model_preference', ) ); + // Severity threshold section. add_settings_section( 'ai_severity_section', __( 'Severity Threshold', 'plugin-check' ), @@ -193,10 +154,27 @@ public function register_settings() { * @since 1.8.0 */ public function render_ai_section_description() { + $has_connectors = ! $this->has_no_active_ai_connectors(); ?>

- +

+ +
+

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

+
+ get_available_providers(); + public function render_model_preference_field( $args ) { + $settings = get_option( self::OPTION_NAME, array() ); + $value = isset( $settings['ai_model_preference'] ) ? $settings['ai_model_preference'] : ''; + $grouped_models = $this->get_available_model_preferences(); + $has_models = ! empty( $grouped_models ); ?> -

- -

- - - /> - -

- - + +

+ +

+ +

+

-

- get_provider_label( $provider ) ) - ); - } - ?> -

- get_models_for_provider( $provider ); - ?> - -

- -

__( 'OpenAI (ChatGPT)', 'plugin-check' ), - 'anthropic' => __( 'Anthropic (Claude)', 'plugin-check' ), - 'google' => __( 'Google (Gemini)', 'plugin-check' ), - 'azure' => __( 'Microsoft Azure OpenAI', 'plugin-check' ), - ); - } - - /** - * Gets available models for a provider. - * - * @since 1.8.0 - * - * @param string $provider Provider key. - * @return array Array of model keys and labels. - */ - protected function get_models_for_provider( $provider ) { - $models = array(); - - switch ( $provider ) { - case 'openai': - $models = array( - 'gpt-4o' => __( 'GPT-4o', 'plugin-check' ), - 'gpt-4-turbo' => __( 'GPT-4 Turbo', 'plugin-check' ), - 'gpt-4' => __( 'GPT-4', 'plugin-check' ), - 'gpt-3.5-turbo' => __( 'GPT-3.5 Turbo', 'plugin-check' ), - ); - break; - - case 'anthropic': - $models = array( - 'claude-3-5-sonnet-20241022' => __( 'Claude 3.5 Sonnet', 'plugin-check' ), - 'claude-3-opus-20240229' => __( 'Claude 3 Opus', 'plugin-check' ), - 'claude-3-sonnet-20240229' => __( 'Claude 3 Sonnet', 'plugin-check' ), - 'claude-3-haiku-20240307' => __( 'Claude 3 Haiku', 'plugin-check' ), - ); - break; - - case 'google': - $models = array( - 'gemini-1.5-pro' => __( 'Gemini 1.5 Pro', 'plugin-check' ), - 'gemini-1.5-flash' => __( 'Gemini 1.5 Flash', 'plugin-check' ), - 'gemini-pro' => __( 'Gemini Pro', 'plugin-check' ), - ); - break; - - case 'azure': - $models = array( - 'gpt-4o' => __( 'GPT-4o (Azure)', 'plugin-check' ), - 'gpt-4-turbo' => __( 'GPT-4 Turbo (Azure)', 'plugin-check' ), - 'gpt-4' => __( 'GPT-4 (Azure)', 'plugin-check' ), - 'gpt-35-turbo' => __( 'GPT-3.5 Turbo (Azure)', 'plugin-check' ), - ); - break; - } - - return $models; - } - - /** - * Gets the label for a provider. - * - * @since 1.8.0 - * - * @param string $provider Provider key. - * @return string Provider label. - */ - protected function get_provider_label( $provider ) { - $providers = $this->get_available_providers(); - return isset( $providers[ $provider ] ) ? $providers[ $provider ] : $provider; - } - /** * Sanitizes settings input. * @@ -468,228 +298,39 @@ protected function get_provider_label( $provider ) { public function sanitize_settings( $input ) { $sanitized = array(); - if ( isset( $input['ai_provider'] ) ) { - $providers = array_keys( $this->get_available_providers() ); - $sanitized['ai_provider'] = in_array( $input['ai_provider'], $providers, true ) ? $input['ai_provider'] : ''; - } - - // Get current settings to handle password field behavior. - $current_settings = get_option( self::OPTION_NAME, array() ); - - if ( isset( $input['ai_api_key'] ) ) { - // If empty, keep existing key (password field unchanged). - if ( ! empty( $input['ai_api_key'] ) ) { - $sanitized['ai_api_key'] = sanitize_text_field( $input['ai_api_key'] ); - } elseif ( isset( $current_settings['ai_api_key'] ) && ! empty( $current_settings['ai_api_key'] ) ) { - // Keep existing if not explicitly changed. - $sanitized['ai_api_key'] = $current_settings['ai_api_key']; - } else { - $sanitized['ai_api_key'] = ''; - } - } elseif ( isset( $current_settings['ai_api_key'] ) ) { - // Keep existing if not in input. - $sanitized['ai_api_key'] = $current_settings['ai_api_key']; - } - - if ( isset( $input['ai_model'] ) ) { - $provider = isset( $sanitized['ai_provider'] ) ? $sanitized['ai_provider'] : ( isset( $input['ai_provider'] ) ? $input['ai_provider'] : '' ); - $models = array_keys( $this->get_models_for_provider( $provider ) ); - $sanitized['ai_model'] = in_array( $input['ai_model'], $models, true ) ? $input['ai_model'] : ''; + if ( isset( $input['ai_model_preference'] ) ) { + $sanitized['ai_model_preference'] = sanitize_text_field( $input['ai_model_preference'] ); + } else { + $sanitized['ai_model_preference'] = ''; } if ( isset( $input['ai_severity_errors'] ) ) { - $value = intval( $input['ai_severity_errors'] ); + $value = intval( $input['ai_severity_errors'] ); $sanitized['ai_severity_errors'] = ( $value >= 1 && $value <= 10 ) ? $value : 7; } else { $sanitized['ai_severity_errors'] = 7; } if ( isset( $input['ai_severity_warnings'] ) ) { - $value = intval( $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; } - // Test AI connection if all required fields are provided and settings have changed. - $provider_changed = ! isset( $current_settings['ai_provider'] ) || $current_settings['ai_provider'] !== $sanitized['ai_provider']; - $api_key_changed = ! isset( $current_settings['ai_api_key'] ) || $current_settings['ai_api_key'] !== $sanitized['ai_api_key']; - $model_changed = ! isset( $current_settings['ai_model'] ) || $current_settings['ai_model'] !== $sanitized['ai_model']; - - if ( ! empty( $sanitized['ai_provider'] ) && ! empty( $sanitized['ai_api_key'] ) && ! empty( $sanitized['ai_model'] ) && ( $provider_changed || $api_key_changed || $model_changed ) ) { - $connection_test = $this->test_ai_connection( $sanitized['ai_provider'], $sanitized['ai_api_key'], $sanitized['ai_model'] ); - if ( is_wp_error( $connection_test ) ) { - // Add settings error to prevent saving. - add_settings_error( - self::OPTION_NAME, - 'ai_connection_failed', - sprintf( - /* translators: %s: Error message */ - __( 'AI connection test failed: %s. Settings were not saved.', 'plugin-check' ), - $connection_test->get_error_message() - ), - 'error' - ); - // Return current settings instead of new ones to prevent saving invalid settings. - return $current_settings; - } - } - - // Sync credentials to wp-ai-client's credential storage. - $this->sync_credentials_to_ai_client( $sanitized ); - return $sanitized; } /** - * Syncs our credentials to the wp-ai-client credential storage. - * - * @since 1.8.0 - * - * @param array $settings Settings array with provider and api_key. - */ - protected function sync_credentials_to_ai_client( $settings ) { - // Get current wp-ai-client credentials. - $ai_client_credentials = get_option( 'wp_ai_client_provider_credentials', array() ); - - if ( ! is_array( $ai_client_credentials ) ) { - $ai_client_credentials = array(); - } - - // Update credentials for our provider. - if ( ! empty( $settings['ai_provider'] ) && ! empty( $settings['ai_api_key'] ) ) { - $ai_client_credentials[ $settings['ai_provider'] ] = $settings['ai_api_key']; - } elseif ( ! empty( $settings['ai_provider'] ) && empty( $settings['ai_api_key'] ) ) { - // Remove credentials if API key is empty. - unset( $ai_client_credentials[ $settings['ai_provider'] ] ); - } - - // Save updated credentials. - update_option( 'wp_ai_client_provider_credentials', $ai_client_credentials ); - } - - /** - * Tests the AI connection with provided credentials. - * - * @since 1.8.0 - * - * @param string $provider Provider key. - * @param string $api_key API key. - * @param string $model Model name. - * @return bool|WP_Error True if connection successful, WP_Error on failure. - */ - protected function test_ai_connection( $provider, $api_key, $model ) { - if ( ! class_exists( '\WordPress\AI_Client\AI_Client' ) ) { - return new WP_Error( - 'ai_client_not_available', - __( 'AI client library is not available. Please ensure wp-ai-client is installed.', 'plugin-check' ) - ); - } - - // Validate required parameters. - if ( empty( $provider ) || empty( $api_key ) || empty( $model ) ) { - return new WP_Error( - 'ai_missing_parameters', - __( 'Provider, API key, and model are required to test the connection.', 'plugin-check' ) - ); - } - - try { - $ai_client = new \WordPress\AI_Client\AI_Client( - array( - 'provider' => $provider, - 'api_key' => $api_key, - 'model' => $model, - ) - ); - - // Test with a simple prompt to verify connection works. - $test_prompt = __( 'Test connection. Respond with "OK" only.', 'plugin-check' ); - $response = $ai_client->request( - $test_prompt, - array( - 'temperature' => 0.3, - 'max_completion_tokens' => 10, - ) - ); - - // Check if we got a valid response. - if ( is_wp_error( $response ) ) { - return $response; - } - - // If we got a response (array or string), the connection works. - if ( is_array( $response ) || is_string( $response ) ) { - return true; - } - - return new WP_Error( - 'ai_invalid_response', - __( 'Received invalid response from AI service. Please check your API key and model.', 'plugin-check' ) - ); - } catch ( \Exception $e ) { - $error_message = $e->getMessage(); - - // Provide more user-friendly error messages for common issues. - if ( false !== strpos( strtolower( $error_message ), 'authentication' ) || false !== strpos( strtolower( $error_message ), 'unauthorized' ) ) { - return new WP_Error( - 'ai_authentication_failed', - __( 'Authentication failed. Please check your API key.', 'plugin-check' ) - ); - } - - if ( false !== strpos( strtolower( $error_message ), 'model' ) || false !== strpos( strtolower( $error_message ), 'not found' ) ) { - return new WP_Error( - 'ai_model_not_found', - __( 'The selected model is not available. Please check your model selection.', 'plugin-check' ) - ); - } - - return new WP_Error( - 'ai_connection_error', - sprintf( - /* translators: %s: Error message */ - __( 'Connection error: %s', 'plugin-check' ), - $error_message - ) - ); - } - } - - /** - * Gets the AI provider. - * - * @since 1.8.0 - * - * @return string AI provider. - */ - public static function get_provider() { - $settings = get_option( self::OPTION_NAME, array() ); - return isset( $settings['ai_provider'] ) ? $settings['ai_provider'] : ''; - } - - /** - * Gets the AI API key. + * Gets the saved AI model preference. * * @since 1.8.0 * - * @return string AI API key. + * @return string AI model preference (e.g., 'openai::gpt-4o') or empty for auto. */ - public static function get_api_key() { + public static function get_model_preference() { $settings = get_option( self::OPTION_NAME, array() ); - return isset( $settings['ai_api_key'] ) ? $settings['ai_api_key'] : ''; - } - - /** - * Gets the AI model. - * - * @since 1.8.0 - * - * @return string AI model. - */ - public static function get_model() { - $settings = get_option( self::OPTION_NAME, array() ); - return isset( $settings['ai_model'] ) ? $settings['ai_model'] : ''; + return isset( $settings['ai_model_preference'] ) ? $settings['ai_model_preference'] : ''; } /** @@ -726,49 +367,12 @@ public function render_page() { wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'plugin-check' ) ); } - // Show updated message. - if ( isset( $_GET['settings-updated'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended - // Check if there are any error messages already set. - $settings_errors = get_settings_errors( self::OPTION_NAME ); - $has_errors = false; - if ( ! empty( $settings_errors ) ) { - foreach ( $settings_errors as $error ) { - if ( 'error' === $error['type'] ) { - $has_errors = true; - break; - } - } - } - - // Only show success message if no errors. - if ( ! $has_errors ) { - // Check if AI settings are configured. - $settings = get_option( self::OPTION_NAME, array() ); - if ( ! empty( $settings['ai_provider'] ) && ! empty( $settings['ai_api_key'] ) && ! empty( $settings['ai_model'] ) ) { - add_settings_error( - self::OPTION_NAME, - 'settings_updated', - __( 'Settings saved successfully. AI connection verified.', 'plugin-check' ), - 'success' - ); - } else { - add_settings_error( - self::OPTION_NAME, - 'settings_updated', - __( 'Settings saved.', 'plugin-check' ), - 'success' - ); - } - } - } - - settings_errors( self::OPTION_NAME ); - - // Enqueue script for dynamic model selection. - wp_enqueue_script( 'jquery' ); ?>
-

+

+ + +
- hook_suffix; - } - } diff --git a/includes/CLI/Plugin_Check_Command.php b/includes/CLI/Plugin_Check_Command.php index c4cd21eb5..c14ef28fb 100644 --- a/includes/CLI/Plugin_Check_Command.php +++ b/includes/CLI/Plugin_Check_Command.php @@ -142,6 +142,9 @@ public function __construct( Plugin_Context $plugin_context ) { * [--use-ai] * : Enable AI-based analysis to detect false positives in check results. * + * [--ai-model=] + * : AI model preference for analysis (e.g., 'openai::gpt-4o'). Requires --use-ai. + * * ## EXAMPLES * * wp plugin check akismet @@ -149,6 +152,7 @@ public function __construct( Plugin_Context $plugin_context ) { * 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 * @@ -182,6 +186,7 @@ public function check( $args, $assoc_args ) { 'ignore-codes' => '', 'mode' => 'new', 'use-ai' => false, + 'ai-model' => '', ) ); @@ -243,6 +248,9 @@ static function ( $dirs ) use ( $excluded_files ) { $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() ); } @@ -384,6 +392,11 @@ static function ( $dirs ) use ( $excluded_files ) { foreach ( $results_by_file as $file_name => $file_results ) { $this->display_results( $formatter, $file_name, $file_results ); } + + // Display AI analysis summary if available. + if ( ! empty( $ai_analysis ) || ! empty( $ai_stats ) ) { + $this->display_ai_summary( $ai_analysis, $ai_stats ); + } } /** @@ -664,6 +677,82 @@ private function display_results( $formatter, $file_name, $file_results ) { WP_CLI::line(); } + /** + * Displays AI analysis summary. + * + * @since 1.8.0 + * + * @param array $ai_analysis AI analysis results. + * @param array $ai_stats AI statistics. + */ + private function display_ai_summary( array $ai_analysis, array $ai_stats ) { + WP_CLI::line( '' ); + WP_CLI::line( str_repeat( '─', 60 ) ); + WP_CLI::line( '✨ ' . __( 'AI False Positive Analysis', 'plugin-check' ) ); + WP_CLI::line( str_repeat( '─', 60 ) ); + + if ( ! empty( $ai_stats ) ) { + $issues_analyzed = isset( $ai_stats['issues_analyzed'] ) ? (int) $ai_stats['issues_analyzed'] : 0; + $false_positives = isset( $ai_stats['false_positives'] ) ? (int) $ai_stats['false_positives'] : 0; + $tokens_spent = isset( $ai_stats['tokens_spent'] ) ? (int) $ai_stats['tokens_spent'] : 0; + + WP_CLI::line( + sprintf( + /* translators: %d: Number of issues analyzed. */ + __( 'Issues analyzed: %d', 'plugin-check' ), + $issues_analyzed + ) + ); + WP_CLI::line( + sprintf( + /* translators: %d: Number of false positives detected. */ + __( 'False positives detected: %d', 'plugin-check' ), + $false_positives + ) + ); + + if ( $tokens_spent > 0 ) { + WP_CLI::line( + sprintf( + /* translators: %s: Number of tokens spent. */ + __( 'Tokens spent: %s', 'plugin-check' ), + number_format_i18n( $tokens_spent ) + ) + ); + } + } + + // Show individual false positive details. + $fp_items = array(); + foreach ( $ai_analysis as $key => $analysis ) { + if ( ! empty( $analysis['is_false_positive'] ) ) { + $fp_items[] = $analysis; + } + } + + if ( ! empty( $fp_items ) ) { + WP_CLI::line( '' ); + WP_CLI::line( __( 'Likely false positives:', 'plugin-check' ) ); + + foreach ( $fp_items as $item ) { + $location = isset( $item['file'] ) ? $item['file'] : ''; + if ( isset( $item['line'] ) ) { + $location .= ':' . $item['line']; + } + + WP_CLI::line( + sprintf( + ' ✨ %s — %s', + $location, + isset( $item['reasoning'] ) ? $item['reasoning'] : '' + ) + ); + } + } + + WP_CLI::line( '' ); + } + /** * Returns check results filtered by severity level. * diff --git a/includes/Checker/Abstract_Check_Runner.php b/includes/Checker/Abstract_Check_Runner.php index ccf56058c..1a4bb4116 100644 --- a/includes/Checker/Abstract_Check_Runner.php +++ b/includes/Checker/Abstract_Check_Runner.php @@ -8,7 +8,6 @@ namespace WordPress\Plugin_Check\Checker; use Exception; -use WordPress\Plugin_Check\Admin\Settings_Page; use WordPress\Plugin_Check\Checker\Exception\Invalid_Check_Slug_Exception; use WordPress\Plugin_Check\Checker\Preparations\Universal_Runtime_Preparation; use WordPress\Plugin_Check\Traits\AI_Analyzer; @@ -42,6 +41,14 @@ abstract class Abstract_Check_Runner implements Check_Runner { */ protected $use_ai = false; + /** + * AI model preference for analysis. + * + * @since 1.8.0 + * @var string + */ + protected $ai_model_preference = ''; + /** * The check slugs to run. * @@ -316,6 +323,17 @@ 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. * @@ -427,7 +445,12 @@ final public function run() { // Run AI analysis if enabled. if ( $this->should_use_ai() ) { - $ai_result = $this->analyze_results_with_ai( $results, $this->get_check_context() ); + // Use CLI model preference, or fall back to saved settings. + $model_preference = $this->ai_model_preference; + if ( empty( $model_preference ) && class_exists( '\WordPress\Plugin_Check\Admin\Settings_Page' ) ) { + $model_preference = \WordPress\Plugin_Check\Admin\Settings_Page::get_model_preference(); + } + $ai_result = $this->analyze_results_with_ai( $results, $this->get_check_context(), $model_preference ); if ( ! is_wp_error( $ai_result ) ) { $ai_analysis = isset( $ai_result['analysis'] ) ? $ai_result['analysis'] : array(); $ai_stats = isset( $ai_result['stats'] ) ? $ai_result['stats'] : array(); diff --git a/includes/Traits/AI_Analyzer.php b/includes/Traits/AI_Analyzer.php index 840b017ba..a9710c36d 100644 --- a/includes/Traits/AI_Analyzer.php +++ b/includes/Traits/AI_Analyzer.php @@ -15,34 +15,63 @@ /** * Trait for analyzing check results for false positives using AI. * + * Uses a batched approach inspired by the internal scanner: issues are grouped + * by check code prefix, each group gets a check-specific prompt loaded from + * the prompts/ directory, and all cases in a batch are sent in a single AI + * request for efficiency. + * * @since 1.8.0 */ trait AI_Analyzer { /** - * Checks if AI client is available and ready. + * Maximum number of cases to send per AI batch request. + * + * @since 1.8.0 + * @var int + */ + const AI_BATCH_SIZE = 12; + + /** + * Maximum number of cases to analyze per check type. + * + * @since 1.8.0 + * @var int + */ + const AI_MAX_CASES_PER_CHECK = 24; + + /** + * Mapping of check code prefixes to their prompt template filenames. + * + * @since 1.8.0 + * @var array + */ + const AI_PROMPT_MAP = array( + 'WordPress.Security.EscapeOutput' => 'ai-review-late-escaping.md', + 'PluginCheck.CodeAnalysis.EscapeOutput' => 'ai-review-late-escaping.md', + 'WordPress.Security.NonceVerification' => 'ai-review-nonce-verification.md', + 'WordPress.Security.ValidatedSanitizedInput' => 'ai-review-sanitization.md', + 'WordPress.DB.DirectDatabaseQuery' => 'ai-review-direct-db-queries.md', + 'WordPress.DB.PreparedSQL' => 'ai-review-direct-db-queries.md', + 'PluginCheck.CodeAnalysis.Obfuscation' => 'ai-review-code-obfuscation.md', + 'PluginCheck.CodeAnalysis.SettingSanitization' => 'ai-review-setting-sanitization.md', + 'PluginCheck.CodeAnalysis.PluginUpdater' => 'ai-review-plugin-updater.md', + ); + + /** + * Checks if AI analysis is available via WordPress core AI client. * * @since 1.8.0 * * @return bool True if AI client is available, false otherwise. */ protected function is_ai_available() { - // Check if AI_Client class exists. - if ( ! class_exists( '\WordPress\AI_Client\AI_Client' ) ) { + if ( ! function_exists( 'wp_ai_client_prompt' ) ) { return false; } - // Get provider, API key, and model from settings. - if ( ! class_exists( Settings_Page::class ) ) { - return false; - } - - $provider = Settings_Page::get_provider(); - $api_key = Settings_Page::get_api_key(); - $model = Settings_Page::get_model(); - - // If provider, API key, or model is not configured, AI is not available. - if ( empty( $provider ) || empty( $api_key ) || empty( $model ) ) { + // Check WP 7.0+ AI support. + if ( function_exists( 'wp_supports_ai' ) && ! wp_supports_ai() ) { return false; } @@ -50,124 +79,85 @@ protected function is_ai_available() { } /** - * Analyzes check results for false positives. + * 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. - * @return array|WP_Error Array of AI analysis results and stats or WP_Error on failure. + * @param Check_Result $result Check result to analyze. + * @param Check_Context $check_context Check context instance. + * @param string $model_preference Optional model preference. + * @return array|WP_Error Array with 'analysis' and 'stats' keys, or WP_Error on failure. */ - protected function analyze_results_with_ai( Check_Result $result, Check_Context $check_context ) { + 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 client is not available.', 'plugin-check' ) + __( 'AI analysis requires WordPress 7.0 or newer with AI support enabled.', 'plugin-check' ) ); } $errors = $result->get_errors(); $warnings = $result->get_warnings(); - // If no errors or warnings, nothing to analyze. if ( empty( $errors ) && empty( $warnings ) ) { - return array( - 'analysis' => array(), - 'stats' => array( - 'tokens_spent' => 0, - 'false_positives' => 0, - 'issues_analyzed' => 0, - ), - ); + 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(); - $tokens_spent = 0; + $total_tokens = 0; $false_positives = 0; $issues_analyzed = 0; - // Get severity thresholds from settings. - $error_threshold = Settings_Page::get_severity_errors(); - $warning_threshold = Settings_Page::get_severity_warnings(); - - // Analyze errors (only those with severity less than threshold). - foreach ( $errors as $file => $file_errors ) { - foreach ( $file_errors as $line => $line_errors ) { - foreach ( $line_errors as $column => $column_errors ) { - foreach ( $column_errors as $error ) { - // Only analyze errors with severity < threshold (low severity = more likely false positive). - $severity = isset( $error['severity'] ) ? (int) $error['severity'] : 5; - if ( $severity >= $error_threshold ) { - continue; - } + foreach ( $grouped_issues as $prompt_file => $cases ) { + $batch_result = $this->analyze_batch( $prompt_file, $cases, $check_context, $model_preference ); - $analysis = $this->analyze_issue_with_ai( $file, $line, $column, $error, 'error', $check_context ); - if ( ! is_wp_error( $analysis ) ) { - $key = $this->get_issue_key( $file, $line, $column, $error['code'] ); - // Include file, line, column, and code in the analysis for easier matching in JS. - $analysis['file'] = $file; - $analysis['line'] = $line; - $analysis['column'] = $column; - $analysis['code'] = $error['code']; - $analysis_results[ $key ] = $analysis; - $issues_analyzed++; - - // Track tokens spent. - if ( isset( $analysis['tokens_spent'] ) ) { - $tokens_spent += (int) $analysis['tokens_spent']; - } - - // Count false positives. - if ( isset( $analysis['is_false_positive'] ) && $analysis['is_false_positive'] ) { - $false_positives++; - } - } - } - } + if ( is_wp_error( $batch_result ) ) { + continue; } - } - // Analyze warnings (only those with severity less than threshold). - foreach ( $warnings as $file => $file_warnings ) { - foreach ( $file_warnings as $line => $line_warnings ) { - foreach ( $line_warnings as $column => $column_warnings ) { - foreach ( $column_warnings as $warning ) { - // Only analyze warnings with severity < threshold (low severity = more likely false positive). - $severity = isset( $warning['severity'] ) ? (int) $warning['severity'] : 5; - if ( $severity >= $warning_threshold ) { - 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'], + ); - $analysis = $this->analyze_issue_with_ai( $file, $line, $column, $warning, 'warning', $check_context ); - if ( ! is_wp_error( $analysis ) ) { - $key = $this->get_issue_key( $file, $line, $column, $warning['code'] ); - // Include file, line, column, and code in the analysis for easier matching in JS. - $analysis['file'] = $file; - $analysis['line'] = $line; - $analysis['column'] = $column; - $analysis['code'] = $warning['code']; - $analysis_results[ $key ] = $analysis; - $issues_analyzed++; - - // Track tokens spent. - if ( isset( $analysis['tokens_spent'] ) ) { - $tokens_spent += (int) $analysis['tokens_spent']; - } - - // Count false positives. - if ( isset( $analysis['is_false_positive'] ) && $analysis['is_false_positive'] ) { - $false_positives++; - } - } + ++$issues_analyzed; + + if ( false === $case_analysis['issue'] ) { + ++$false_positives; } } } + + if ( isset( $batch_result['token_usage']['total_tokens'] ) ) { + $total_tokens += (int) $batch_result['token_usage']['total_tokens']; + } } return array( 'analysis' => $analysis_results, 'stats' => array( - 'tokens_spent' => $tokens_spent, + 'tokens_spent' => $total_tokens, 'false_positives' => $false_positives, 'issues_analyzed' => $issues_analyzed, ), @@ -175,94 +165,209 @@ protected function analyze_results_with_ai( Check_Result $result, Check_Context } /** - * Analyzes a single issue for false positive potential. + * 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 string $file File path where the issue was found. - * @param int $line Line number where the issue was found. - * @param int $column Column number where the issue was found. - * @param array $issue Issue data (message, code, etc.). - * @param string $type Issue type ('error' or 'warning'). + * @param array $errors Errors from Check_Result. + * @param array $warnings Warnings from Check_Result. * @param Check_Context $check_context Check context instance. - * @return array|WP_Error Analysis result or WP_Error on failure. + * @return array Issues grouped by prompt filename. Each value is an associative + * array keyed by case_id with issue metadata. */ - protected function analyze_issue_with_ai( $file, $line, $column, $issue, $type, Check_Context $check_context ) { - // Ensure AI is available before proceeding. - if ( ! $this->is_ai_available() ) { - return new WP_Error( - 'ai_not_available', - __( 'AI client is not available.', 'plugin-check' ) - ); - } + 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' ); - $file_path = $check_context->path( '/' ) . $file; - $file_content = ''; + $grouped = array(); + $counts = array(); // Track count per prompt to enforce limit. - if ( file_exists( $file_path ) && is_readable( $file_path ) ) { - $file_content = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents - } + // Process errors. + $this->collect_issues_from_collection( $errors, 'error', $error_threshold, $check_context, $grouped, $counts ); - $context_lines = $this->get_code_context( $file_content, $line ); + // Process warnings. + $this->collect_issues_from_collection( $warnings, 'warning', $warning_threshold, $check_context, $grouped, $counts ); - $prompt = $this->build_analysis_prompt( $file, $line, $column, $issue, $type, $context_lines ); + return $grouped; + } - try { - $provider = Settings_Page::get_provider(); - $api_key = Settings_Page::get_api_key(); - $model = Settings_Page::get_model(); - - // Ensure credentials are registered with the provider registry. - if ( class_exists( '\WordPress\AiClient\AiClient' ) ) { - $registry = \WordPress\AiClient\AiClient::defaultRegistry(); - - if ( $registry->hasProvider( $provider ) ) { - $registry->setProviderRequestAuthentication( - $provider, - new \WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication( $api_key ) - ); + /** + * Collects issues from a single collection (errors or warnings). + * + * @since 1.8.0 + * + * @param array $collection The errors or warnings collection. + * @param string $type 'error' or 'warning'. + * @param int $threshold Severity threshold. + * @param Check_Context $check_context Check context instance. + * @param array $grouped Reference to grouped issues array. + * @param array $counts Reference to counts per prompt. + */ + protected function collect_issues_from_collection( array $collection, $type, $threshold, Check_Context $check_context, array &$grouped, array &$counts ) { + foreach ( $collection as $file => $file_issues ) { + foreach ( $file_issues as $line => $line_issues ) { + foreach ( $line_issues as $column => $column_issues ) { + foreach ( $column_issues as $issue ) { + $severity = isset( $issue['severity'] ) ? (int) $issue['severity'] : 5; + if ( $severity >= $threshold ) { + continue; + } + + $code = isset( $issue['code'] ) ? $issue['code'] : ''; + $prompt_file = $this->get_prompt_for_code( $code ); + + if ( ! isset( $counts[ $prompt_file ] ) ) { + $counts[ $prompt_file ] = 0; + } + + if ( $counts[ $prompt_file ] >= self::AI_MAX_CASES_PER_CHECK ) { + continue; + } + + $case_id = $this->get_issue_key( $file, $line, $column, $code ); + + if ( ! isset( $grouped[ $prompt_file ] ) ) { + $grouped[ $prompt_file ] = array(); + } + + $grouped[ $prompt_file ][ $case_id ] = array( + 'file' => $file, + 'line' => $line, + 'column' => $column, + 'code' => $code, + 'message' => isset( $issue['message'] ) ? $issue['message'] : '', + 'type' => $type, + ); + + ++$counts[ $prompt_file ]; + } } } + } + } - // Build prompt with provider and model configuration. - $prompt_builder = \WordPress\AI_Client\AI_Client::prompt( $prompt ) - ->using_temperature( 0.3 ) - ->using_max_completion_tokens( 500 ); + /** + * Analyzes a batch of issues with a specific prompt template. + * + * If the batch exceeds AI_BATCH_SIZE, it is split into sub-batches + * and each sub-batch is sent as a separate AI request. + * + * @since 1.8.0 + * + * @param string $prompt_file Prompt template filename. + * @param array $cases Cases to analyze, keyed by case_id. + * @param Check_Context $check_context Check context instance. + * @param string $model_preference Optional model preference. + * @return array|WP_Error Array with 'cases' and 'token_usage' keys, or WP_Error. + */ + protected function analyze_batch( $prompt_file, array $cases, Check_Context $check_context, $model_preference = '' ) { + $issue_description = $this->load_prompt_template( $prompt_file ); + if ( is_wp_error( $issue_description ) ) { + return $issue_description; + } - // Set provider and model using config. - if ( ! empty( $provider ) ) { - $prompt_builder->using_provider( $provider ); + // Split into sub-batches if needed. + $batches = array_chunk( $cases, self::AI_BATCH_SIZE, true ); + $all_cases = array(); + $total_tokens = 0; - if ( ! empty( $model ) && class_exists( '\WordPress\AiClient\Providers\Models\DTO\ModelConfig' ) ) { - $model_config = new \WordPress\AiClient\Providers\Models\DTO\ModelConfig( $model ); - $prompt_builder->using_model_config( $model_config ); - } + 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'] ); } - $result = $prompt_builder->generate_text_result(); + if ( isset( $result['token_usage']['total_tokens'] ) ) { + $total_tokens += (int) $result['token_usage']['total_tokens']; + } + } + + return array( + 'cases' => $all_cases, + 'token_usage' => array( + 'total_tokens' => $total_tokens, + ), + ); + } + + /** + * Executes a single batched AI request for a group of cases. + * + * Builds a prompt following the internal scanner pattern: + * system instructions + issue description + cases list + output format. + * + * @since 1.8.0 + * + * @param string $issue_description Issue description from prompt template. + * @param array $cases Cases to analyze, keyed by case_id. + * @param Check_Context $check_context Check context instance. + * @param string $model_preference Optional model preference. + * @return array|WP_Error Array with 'cases' and 'token_usage', or WP_Error. + */ + protected function execute_batch_ai_request( $issue_description, array $cases, Check_Context $check_context, $model_preference = '' ) { + $prompt = $this->build_batch_prompt( $issue_description, $cases, $check_context ); + + if ( ! function_exists( 'wp_ai_client_prompt' ) ) { + return new WP_Error( + 'ai_client_not_available', + __( 'AI client is not available. This feature requires WordPress 7.0 or newer.', 'plugin-check' ) + ); + } + + $builder = wp_ai_client_prompt( $prompt ); + if ( is_wp_error( $builder ) ) { + return $builder; + } - $response_text = $result->text(); + // 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; + } + } - $analysis = $this->parse_ai_response( $response_text, $issue ); + 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(); + } - // Track tokens spent if available in result. - $usage = $result->usage(); - if ( null !== $usage ) { - $tokens = 0; - if ( null !== $usage->totalTokens() ) { - $tokens = $usage->totalTokens(); - } elseif ( null !== $usage->promptTokens() && null !== $usage->completionTokens() ) { - $tokens = $usage->promptTokens() + $usage->completionTokens(); + if ( ! $result || is_wp_error( $result ) ) { + // Fallback to plain text generation. + $text = $builder->generate_text(); + if ( is_wp_error( $text ) ) { + return $text; } - $analysis['tokens_spent'] = $tokens; + + return array( + 'cases' => $this->parse_batch_response( (string) $text ), + 'token_usage' => array(), + ); } - return $analysis; - } catch ( \Exception $e ) { + $text = method_exists( $result, 'to_text' ) ? $result->to_text() : ( method_exists( $result, 'toText' ) ? $result->toText() : '' ); + $usage = $this->extract_ai_token_usage( $result ); + + return array( + 'cases' => $this->parse_batch_response( $text ), + 'token_usage' => $usage ? $usage : array(), + ); + } catch ( \Throwable $e ) { return new WP_Error( 'ai_request_failed', sprintf( - // translators: %s: Error message. + /* translators: %s: Error message. */ __( 'AI analysis failed: %s', 'plugin-check' ), $e->getMessage() ) @@ -270,15 +375,89 @@ protected function analyze_issue_with_ai( $file, $line, $column, $issue, $type, } } + /** + * 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. + * @param int $line Line number (1-based). * @param int $context Number of lines before and after. - * @return string Code context. + * @return string Code context with line numbers. */ protected function get_code_context( $file_content, $line, $context = 10 ) { if ( empty( $file_content ) ) { @@ -289,102 +468,251 @@ protected function get_code_context( $file_content, $line, $context = 10 ) { $start = max( 0, $line - $context - 1 ); $end = min( count( $lines ), $line + $context ); - $context_lines = array_slice( $lines, $start, $end - $start ); + $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 ); } /** - * Builds the prompt for AI analysis. + * Parses the batched AI response into individual case results. * * @since 1.8.0 * - * @param string $file File path. - * @param int $line Line number. - * @param int $column Column number. - * @param array $issue Issue data. - * @param string $type Issue type. - * @param string $code_context Code context. - * @return string AI prompt. + * @param string $response_text AI response text. + * @return array Array of case results. */ - protected function build_analysis_prompt( $file, $line, $column, $issue, $type, $code_context ) { - $prompt = sprintf( - // translators: %1$s: Issue type, %2$s: File path, %3$s: Line number, %4$s: Issue code, %5$s: Issue message. - __( - 'You are analyzing a WordPress plugin check result for potential false positives. - -Issue Type: %1$s -File: %2$s -Line: %3$d -Column: %4$d -Issue Code: %5$s -Issue Message: %6$s - -Code Context: -``` -%7$s -``` - -Please analyze if this is likely a false positive or a legitimate issue. Consider: -- Whether the code is actually problematic -- If there are legitimate exceptions or edge cases -- Whether the check might be too strict -- The context and intent of the code - -Provide your analysis in JSON format with the following structure: -{ - "is_false_positive": boolean, - "confidence": float (0.0 to 1.0), - "reasoning": "string explanation", - "recommendation": "string recommendation" -} + protected function parse_batch_response( $response_text ) { + if ( empty( $response_text ) ) { + return array(); + } -Respond ONLY with valid JSON, no other text.', - 'plugin-check' - ), - $type, - $file, - $line, - $column, - $issue['code'], - $issue['message'], - $code_context - ); + // 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 ); - return $prompt; + // 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; } /** - * Parses the AI response into a structured format. + * Determines the prompt template filename for a given check code. * * @since 1.8.0 * - * @param string $response_text AI response text. - * @param array $issue Original issue data. - * @return array Parsed analysis result. + * @param string $code The check code (e.g., 'WordPress.Security.EscapeOutput.OutputNotEscaped'). + * @return string Prompt template filename. */ - protected function parse_ai_response( $response_text, $issue ) { - // Try to extract JSON from the response. - if ( preg_match( '/\{[^}]+\}/s', $response_text, $matches ) ) { - $json = json_decode( $matches[0], true ); - if ( JSON_ERROR_NONE === json_last_error() && is_array( $json ) ) { - return array( - 'is_false_positive' => isset( $json['is_false_positive'] ) ? (bool) $json['is_false_positive'] : false, - 'confidence' => isset( $json['confidence'] ) ? floatval( $json['confidence'] ) : 0.5, - 'reasoning' => isset( $json['reasoning'] ) ? sanitize_text_field( $json['reasoning'] ) : '', - 'recommendation' => isset( $json['recommendation'] ) ? sanitize_text_field( $json['recommendation'] ) : '', - 'original_issue' => $issue, - ); + protected function get_prompt_for_code( $code ) { + foreach ( self::AI_PROMPT_MAP as $prefix => $prompt_file ) { + if ( 0 === strpos( $code, $prefix ) ) { + return $prompt_file; } } - // Fallback if JSON parsing fails. + return 'ai-review-generic.md'; + } + + /** + * Loads a prompt template from the prompts/ directory. + * + * @since 1.8.0 + * + * @param string $filename Prompt template filename. + * @return string|WP_Error Prompt content or WP_Error. + */ + protected function load_prompt_template( $filename ) { + if ( ! defined( 'WP_PLUGIN_CHECK_PLUGIN_DIR_PATH' ) ) { + return new WP_Error( 'plugin_constant_not_defined', __( 'Plugin constant not defined.', 'plugin-check' ) ); + } + + $path = WP_PLUGIN_CHECK_PLUGIN_DIR_PATH . 'prompts/' . $filename; + + if ( ! file_exists( $path ) ) { + return new WP_Error( + 'prompt_not_found', + sprintf( + /* translators: %s: Prompt filename. */ + __( 'AI prompt template not found: %s', 'plugin-check' ), + $filename + ) + ); + } + + $contents = (string) file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $contents = trim( $contents ); + + if ( empty( $contents ) ) { + return new WP_Error( 'prompt_empty', __( 'AI prompt template is empty.', 'plugin-check' ) ); + } + + return $contents; + } + + /** + * Gets the AI severity threshold for a given type. + * + * @since 1.8.0 + * + * @param string $type 'error' or 'warning'. + * @return int Severity threshold. + */ + protected function get_ai_severity_threshold( $type ) { + if ( class_exists( Settings_Page::class ) ) { + $default = 'error' === $type ? Settings_Page::get_severity_errors() : Settings_Page::get_severity_warnings(); + } else { + $default = 'error' === $type ? 7 : 6; + } + + /** + * Filters the AI severity threshold. + * + * @since 1.8.0 + * + * @param int $threshold Threshold from settings (7 for errors, 6 for warnings). + * @param string $type 'error' or 'warning'. + */ + return (int) apply_filters( 'wp_plugin_check_ai_severity_threshold', $default, $type ); + } + + /** + * Applies a model preference to the prompt builder. + * + * @since 1.8.0 + * + * @param object $builder Prompt builder instance. + * @param string $model_preference Model preference string. + * @return object|WP_Error Updated builder or WP_Error. + */ + protected function apply_ai_model_preference( $builder, $model_preference ) { + if ( empty( $model_preference ) ) { + return $builder; + } + + $preference = trim( (string) $model_preference ); + + // Parse provider::model format. + foreach ( array( '::', '|', ':' ) as $separator ) { + if ( false !== strpos( $preference, $separator ) ) { + list( $provider, $model ) = array_map( 'trim', explode( $separator, $preference, 2 ) ); + if ( '' !== $provider && '' !== $model ) { + $preference = array( $provider, $model ); + break; + } + } + } + + try { + $result = $builder->using_model_preference( $preference ); + return $result ? $result : $builder; + } catch ( \Exception $e ) { + return new WP_Error( + 'model_preference_error', + sprintf( + /* translators: %s: Exception message. */ + __( 'Failed to apply model preference: %s', 'plugin-check' ), + $e->getMessage() + ) + ); + } + } + + /** + * Extracts token usage from a result object. + * + * @since 1.8.0 + * + * @param object $result Result object. + * @return array|null Token usage array or null. + */ + protected function extract_ai_token_usage( $result ) { + $usage = null; + + if ( method_exists( $result, 'get_token_usage' ) ) { + $usage = $result->get_token_usage(); + } elseif ( method_exists( $result, 'getTokenUsage' ) ) { + $usage = $result->getTokenUsage(); + } + + if ( ! $usage || ! is_object( $usage ) ) { + return null; + } + + $prompt_tokens = method_exists( $usage, 'get_prompt_tokens' ) ? $usage->get_prompt_tokens() : ( method_exists( $usage, 'getPromptTokens' ) ? $usage->getPromptTokens() : null ); + $completion_tokens = method_exists( $usage, 'get_completion_tokens' ) ? $usage->get_completion_tokens() : ( method_exists( $usage, 'getCompletionTokens' ) ? $usage->getCompletionTokens() : null ); + $total_tokens = method_exists( $usage, 'get_total_tokens' ) ? $usage->get_total_tokens() : ( method_exists( $usage, 'getTotalTokens' ) ? $usage->getTotalTokens() : null ); + + if ( null === $total_tokens && null !== $prompt_tokens && null !== $completion_tokens ) { + $total_tokens = $prompt_tokens + $completion_tokens; + } + + if ( null === $prompt_tokens && null === $completion_tokens && null === $total_tokens ) { + return null; + } + + return array_filter( + array( + 'prompt_tokens' => $prompt_tokens, + 'completion_tokens' => $completion_tokens, + 'total_tokens' => $total_tokens, + ), + static function ( $value ) { + return null !== $value; + } + ); + } + + /** + * Returns an empty AI result structure. + * + * @since 1.8.0 + * + * @return array Empty result with zeroed stats. + */ + protected function empty_ai_result() { return array( - 'is_false_positive' => false, - 'confidence' => 0.5, - 'reasoning' => __( 'Unable to parse AI response.', 'plugin-check' ), - 'recommendation' => __( 'Manual review recommended.', 'plugin-check' ), - 'original_issue' => $issue, + 'analysis' => array(), + 'stats' => array( + 'tokens_spent' => 0, + 'false_positives' => 0, + 'issues_analyzed' => 0, + ), ); } 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. From 52694281db0fc059d38e2af9abb80fd90fd42ee9 Mon Sep 17 00:00:00 2001 From: davidperezgar Date: Sun, 17 May 2026 09:43:21 +0200 Subject: [PATCH 8/8] token usage --- assets/js/plugin-check-admin.js | 87 +++++++++++--- includes/Traits/AI_Analyzer.php | 194 ++++++++++++++++++++++++++++++-- 2 files changed, 255 insertions(+), 26 deletions(-) diff --git a/assets/js/plugin-check-admin.js b/assets/js/plugin-check-admin.js index 21ff747b0..9fa8aa8dc 100644 --- a/assets/js/plugin-check-admin.js +++ b/assets/js/plugin-check-admin.js @@ -595,21 +595,35 @@ mergeAggregatedResults( results ); renderResults( results ); - // Collect AI stats from the last check. + // Collect AI stats across checks. if ( results.ai_stats ) { - // Merge stats if multiple checks. 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. @@ -714,20 +728,65 @@ } } - // Add AI statistics to the message if available. - if ( aiStats && aiStats.false_positives > 0 ) { - let aiInfo = ' AI detected ' + aiStats.false_positives + ' '; - aiInfo += - 1 === aiStats.false_positives - ? 'false positive' - : 'false positives'; + if ( aiStats ) { + 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 ) { - aiInfo += - ' (Tokens spent: ' + - aiStats.tokens_spent.toLocaleString() + - ')'; + 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( '. ' ); } - messageText += '.' + aiInfo; } resultsContainer.innerHTML = diff --git a/includes/Traits/AI_Analyzer.php b/includes/Traits/AI_Analyzer.php index a9710c36d..933840e1c 100644 --- a/includes/Traits/AI_Analyzer.php +++ b/includes/Traits/AI_Analyzer.php @@ -117,8 +117,12 @@ protected function analyze_results_with_ai( Check_Result $result, Check_Context // 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 ); @@ -152,14 +156,30 @@ protected function analyze_results_with_ai( Check_Result $result, Check_Context 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 ) ), ), ); } @@ -269,9 +289,13 @@ protected function analyze_batch( $prompt_file, array $cases, Check_Context $che } // Split into sub-batches if needed. - $batches = array_chunk( $cases, self::AI_BATCH_SIZE, true ); - $all_cases = array(); - $total_tokens = 0; + $batches = array_chunk( $cases, self::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 ); @@ -287,13 +311,29 @@ protected function analyze_batch( $prompt_file, array $cases, Check_Context $che 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( - 'total_tokens' => $total_tokens, + '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 ) ), ); } @@ -351,17 +391,23 @@ protected function execute_batch_ai_request( $issue_description, array $cases, C } return array( - 'cases' => $this->parse_batch_response( (string) $text ), - 'token_usage' => 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 ); + $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(), + '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( @@ -675,7 +721,11 @@ protected function extract_ai_token_usage( $result ) { } $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 ) { @@ -698,6 +748,122 @@ static function ( $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. * @@ -710,8 +876,12 @@ protected function empty_ai_result() { 'analysis' => array(), 'stats' => array( 'tokens_spent' => 0, + 'input_tokens' => 0, + 'output_tokens' => 0, 'false_positives' => 0, 'issues_analyzed' => 0, + 'model_used' => '', + 'provider_used' => '', ), ); }