From 1ae3aafa1eaa23f9821662dac0179984679e5e00 Mon Sep 17 00:00:00 2001 From: davidperezgar Date: Wed, 19 Nov 2025 20:54:22 +0100 Subject: [PATCH 01/14] 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 02/14] 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 04/14] 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 05/14] 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 06/14] 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 07/14] 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 08/14] 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' => '', ), ); } From 99f5a9c5397a74afa8288d9e8b01b72db9046915 Mon Sep 17 00:00:00 2001 From: davidperezgar Date: Sat, 23 May 2026 08:26:45 +0200 Subject: [PATCH 09/14] trialware checks and AI --- assets/css/plugin-check-admin.css | 7 +- docs/checks.md | 1 + includes/Checker/Abstract_Check_Runner.php | 87 +++++++- includes/Checker/Check_Result.php | 54 +++++ .../Checks/Plugin_Repo/Trialware_Check.php | 203 ++++++++++++++++++ includes/Checker/Default_Check_Repository.php | 1 + includes/Traits/AI_Analyzer.php | 82 +++---- prompts/ai-review-trialware.md | 14 ++ templates/results-row.php | 29 +-- .../test-plugin-trialware-errors/load.php | 32 +++ .../load.php | 19 ++ .../Checker/Checks/Trialware_Check_Tests.php | 35 +++ 12 files changed, 506 insertions(+), 58 deletions(-) create mode 100644 includes/Checker/Checks/Plugin_Repo/Trialware_Check.php create mode 100644 prompts/ai-review-trialware.md create mode 100644 tests/phpunit/testdata/plugins/test-plugin-trialware-errors/load.php create mode 100644 tests/phpunit/testdata/plugins/test-plugin-trialware-without-errors/load.php create mode 100644 tests/phpunit/tests/Checker/Checks/Trialware_Check_Tests.php diff --git a/assets/css/plugin-check-admin.css b/assets/css/plugin-check-admin.css index 7575ed52b..5406754a1 100644 --- a/assets/css/plugin-check-admin.css +++ b/assets/css/plugin-check-admin.css @@ -86,10 +86,9 @@ color: #0c5460; } -.plugin-check__ai-analysis .dashicons { - font-size: 16px; - width: 16px; - height: 16px; +.plugin-check__ai-analysis-icon { + font-size: 14px; + line-height: 1; } .plugin-check__ai-reasoning { diff --git a/docs/checks.md b/docs/checks.md index 11662a607..ecea9738b 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -7,6 +7,7 @@ | i18n_usage | general, plugin_repo | Checks for various internationalization best practices. | [Learn more](https://developer.wordpress.org/plugins/internationalization/how-to-internationalize-your-plugin/) | | code_obfuscation | plugin_repo | Detects the usage of code obfuscation tools. | [Learn more](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/) | | plugin_content | plugin_repo | Detects content that does not comply with the WordPress.org plugin guidelines. | [Learn more](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/) | +| trialware | plugin_repo | Uses AI to detect trialware and locked built-in features. | [Learn more](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/) | | direct_file_access | security, plugin_repo | Checks that plugin files include proper security validation using the ABSPATH constant to prevent direct file access. | [Learn more](https://developer.wordpress.org/plugins/plugin-basics/best-practices/#file-security) | | file_type | plugin_repo | Detects the usage of hidden and compressed files, VCS directories, application files, badly named files, AI development directories (.cursor, .claude, .aider, .continue, .windsurf, .ai, .github), and unexpected markdown files in plugin root. | [Learn more](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/) | | plugin_header_fields | plugin_repo | Checks adherence to the Headers requirements, including validation of "Tested up to" header matching between plugin file and readme.txt. | [Learn more](https://developer.wordpress.org/plugins/plugin-basics/header-requirements/) | diff --git a/includes/Checker/Abstract_Check_Runner.php b/includes/Checker/Abstract_Check_Runner.php index 1a4bb4116..7a42d9e77 100644 --- a/includes/Checker/Abstract_Check_Runner.php +++ b/includes/Checker/Abstract_Check_Runner.php @@ -8,6 +8,8 @@ namespace WordPress\Plugin_Check\Checker; use Exception; +use WordPress\Plugin_Check\Admin\Settings_Page; +use WordPress\Plugin_Check\Checker\Checks\Plugin_Repo\Trialware_Check; use WordPress\Plugin_Check\Checker\Exception\Invalid_Check_Slug_Exception; use WordPress\Plugin_Check\Checker\Preparations\Universal_Runtime_Preparation; use WordPress\Plugin_Check\Traits\AI_Analyzer; @@ -443,22 +445,27 @@ final public function run() { $results = $this->get_checks_instance()->run_checks( $this->get_check_context(), $checks, $this ); + $ai_analysis = array(); + $ai_stats = array(); + // Run AI analysis if enabled. if ( $this->should_use_ai() ) { // Use CLI model preference, or fall back to saved settings. $model_preference = $this->ai_model_preference; - if ( empty( $model_preference ) && class_exists( '\WordPress\Plugin_Check\Admin\Settings_Page' ) ) { - $model_preference = \WordPress\Plugin_Check\Admin\Settings_Page::get_model_preference(); + if ( empty( $model_preference ) && class_exists( Settings_Page::class ) ) { + $model_preference = Settings_Page::get_model_preference(); } $ai_result = $this->analyze_results_with_ai( $results, $this->get_check_context(), $model_preference ); if ( ! is_wp_error( $ai_result ) ) { $ai_analysis = isset( $ai_result['analysis'] ) ? $ai_result['analysis'] : array(); $ai_stats = isset( $ai_result['stats'] ) ? $ai_result['stats'] : array(); - $results->set_ai_analysis( $ai_analysis ); - $results->set_ai_stats( $ai_stats ); } } + $ai_analysis = $this->finalize_ai_confirmed_issues( $results, $ai_analysis ); + $results->set_ai_analysis( $ai_analysis ); + $results->set_ai_stats( $ai_stats ); + if ( ! empty( $cleanups ) ) { foreach ( $cleanups as $cleanup ) { $cleanup(); @@ -468,6 +475,78 @@ final public function run() { return $results; } + /** + * Promotes AI-confirmed candidate issues and removes unconfirmed AI-only candidates. + * + * @since 2.0.0 + * + * @param Check_Result $results Check result. + * @param array $ai_analysis AI analysis results. + * @return array Updated AI analysis results. + */ + private function finalize_ai_confirmed_issues( Check_Result $results, array $ai_analysis ) { + $confirmed_trialware = array(); + $updated_analysis = array(); + + foreach ( $ai_analysis as $key => $analysis ) { + if ( ! is_array( $analysis ) || Trialware_Check::CANDIDATE_CODE !== ( $analysis['code'] ?? '' ) ) { + $updated_analysis[ $key ] = $analysis; + continue; + } + + if ( ! empty( $analysis['is_false_positive'] ) ) { + continue; + } + + $issue_key = $this->get_ai_issue_location_key( $analysis ); + $analysis['code'] = Trialware_Check::CONFIRMED_CODE; + $analysis['type'] = 'error'; + $updated_analysis[ $key ] = $analysis; + $confirmed_trialware[ $issue_key ] = true; + } + + $results->transform_messages( + function ( array $message, $is_error, $file, $line, $column ) use ( $confirmed_trialware ) { + if ( Trialware_Check::CANDIDATE_CODE !== ( $message['code'] ?? '' ) ) { + return $message; + } + + $issue_key = $this->get_ai_issue_location_key( + array( + 'file' => $file, + 'line' => $line, + 'column' => $column, + ) + ); + + if ( empty( $confirmed_trialware[ $issue_key ] ) ) { + return null; + } + + $message['error'] = true; + $message['code'] = Trialware_Check::CONFIRMED_CODE; + $message['severity'] = 7; + $message['message'] = __( 'Trialware or locked built-in feature detected. Plugins hosted on WordPress.org must not restrict functionality already included in the plugin behind license keys, trials, quotas, payments, or other artificial limits.', 'plugin-check' ); + + return $message; + } + ); + + return $updated_analysis; + } + + /** + * Gets a stable location key for AI issue matching. + * + * @since 2.0.0 + * + * @param array $issue Issue data. + * @return string Location key. + */ + private function get_ai_issue_location_key( array $issue ) { + return ( $issue['file'] ?? '' ) . ':' . (int) ( $issue['line'] ?? 0 ) . ':' . (int) ( $issue['column'] ?? 0 ); + } + /** * Determines if any of the checks are a runtime check. * diff --git a/includes/Checker/Check_Result.php b/includes/Checker/Check_Result.php index a5073f63f..8f0aec305 100644 --- a/includes/Checker/Check_Result.php +++ b/includes/Checker/Check_Result.php @@ -160,6 +160,60 @@ public function add_message( $error, $message, $args = array() ) { } } + /** + * Transforms existing messages. + * + * The callback receives the message data and location. Return an array with + * updated data to keep the message, or false/null to remove it. The returned + * array may include `error`, `file`, `line`, or `column` to move the message. + * + * @since 2.0.0 + * + * @param callable $callback Callback to transform each message. + */ + public function transform_messages( callable $callback ) { + $collections = array( + true => $this->errors, + false => $this->warnings, + ); + + $this->errors = array(); + $this->warnings = array(); + $this->error_count = 0; + $this->warning_count = 0; + + foreach ( $collections as $is_error => $collection ) { + foreach ( $collection as $file => $lines ) { + foreach ( $lines as $line => $columns ) { + foreach ( $columns as $column => $messages ) { + foreach ( $messages as $message ) { + $updated = $callback( $message, (bool) $is_error, $file, $line, $column ); + if ( empty( $updated ) || ! is_array( $updated ) ) { + continue; + } + + if ( empty( $updated['message'] ) ) { + continue; + } + + $new_error = array_key_exists( 'error', $updated ) ? (bool) $updated['error'] : (bool) $is_error; + $new_file = array_key_exists( 'file', $updated ) ? (string) $updated['file'] : (string) $file; + $new_line = array_key_exists( 'line', $updated ) ? (int) $updated['line'] : (int) $line; + $new_column = array_key_exists( 'column', $updated ) ? (int) $updated['column'] : (int) $column; + + unset( $updated['error'], $updated['file'], $updated['line'], $updated['column'] ); + $updated['file'] = $new_file; + $updated['line'] = $new_line; + $updated['column'] = $new_column; + + $this->add_message( $new_error, $updated['message'], $updated ); + } + } + } + } + } + } + /** * Returns all errors. * diff --git a/includes/Checker/Checks/Plugin_Repo/Trialware_Check.php b/includes/Checker/Checks/Plugin_Repo/Trialware_Check.php new file mode 100644 index 000000000..bdafdaef2 --- /dev/null +++ b/includes/Checker/Checks/Plugin_Repo/Trialware_Check.php @@ -0,0 +1,203 @@ +filter_files_to_scan( $files ); + if ( empty( $files ) ) { + return; + } + + $matches = self::files_preg_match_all( $this->get_locked_features_pattern(), $files ); + if ( empty( $matches ) ) { + return; + } + + $reported = array(); + foreach ( $matches as $match ) { + $key = $match['file'] . ':' . $match['line'] . ':' . $match['column']; + if ( isset( $reported[ $key ] ) ) { + continue; + } + + $reported[ $key ] = true; + $this->add_result_error_for_file( + $result, + __( 'Potential trialware or locked built-in feature candidate. AI analysis must confirm whether the plugin restricts built-in functionality behind a license key, trial, quota, payment, or other artificial limit.', 'plugin-check' ), + self::CANDIDATE_CODE, + $match['file'], + $match['line'], + $match['column'], + $this->get_documentation_url(), + 5 + ); + } + } + + /** + * Filters the file list to code-like files and known low-signal exclusions. + * + * @since 2.0.0 + * + * @param array $files List of absolute file paths. + * @return array Files to scan. + */ + private function filter_files_to_scan( array $files ) { + return array_values( + array_filter( + $files, + static function ( $file ) { + $normalized = wp_normalize_path( $file ); + $extension = strtolower( pathinfo( $normalized, PATHINFO_EXTENSION ) ); + $basename = strtolower( basename( $normalized ) ); + + if ( ! in_array( $extension, self::CODE_EXTENSIONS, true ) ) { + return false; + } + + if ( 'composer.json' === $basename ) { + return false; + } + + return false === strpos( $normalized, '/stripe-php/lib/' ); + } + ) + ); + } + + /** + * Builds the combined regular expression for locked feature indicators. + * + * @since 2.0.0 + * + * @return string Regular expression. + */ + private function get_locked_features_pattern() { + $patterns = array_map( + static function ( $pattern ) { + return '(?:' . $pattern . ')'; + }, + self::LOCKED_FEATURE_PATTERNS + ); + + return '~' . implode( '|', $patterns ) . '~i'; + } + + /** + * Gets the description for the check. + * + * @since 2.0.0 + * + * @return string Description. + */ + public function get_description(): string { + return __( 'Uses AI to detect trialware and locked built-in features.', 'plugin-check' ); + } + + /** + * Gets the documentation URL for the check. + * + * @since 2.0.0 + * + * @return string The documentation URL. + */ + public function get_documentation_url(): string { + return __( 'https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/', 'plugin-check' ); + } +} diff --git a/includes/Checker/Default_Check_Repository.php b/includes/Checker/Default_Check_Repository.php index c22371044..23779db6a 100644 --- a/includes/Checker/Default_Check_Repository.php +++ b/includes/Checker/Default_Check_Repository.php @@ -76,6 +76,7 @@ private function register_default_checks() { 'enqueued_styles_size' => new Checks\Performance\Enqueued_Styles_Size_Check(), 'code_obfuscation' => new Checks\Plugin_Repo\Code_Obfuscation_Check(), 'plugin_content' => new Checks\Plugin_Repo\Plugin_Content_Check(), + 'trialware' => new Checks\Plugin_Repo\Trialware_Check(), 'file_type' => new Checks\Plugin_Repo\File_Type_Check(), 'plugin_header_fields' => new Checks\Plugin_Repo\Plugin_Header_Fields_Check(), 'late_escaping' => new Checks\Security\Late_Escaping_Check(), diff --git a/includes/Traits/AI_Analyzer.php b/includes/Traits/AI_Analyzer.php index 933840e1c..29ae01af0 100644 --- a/includes/Traits/AI_Analyzer.php +++ b/includes/Traits/AI_Analyzer.php @@ -25,57 +25,67 @@ trait AI_Analyzer { /** - * Maximum number of cases to send per AI batch request. + * Checks if AI analysis is available via WordPress core AI client. * * @since 1.8.0 - * @var int + * + * @return bool True if AI client is available, false otherwise. */ - const AI_BATCH_SIZE = 12; + protected function is_ai_available() { + if ( ! function_exists( 'wp_ai_client_prompt' ) ) { + return false; + } + + // Check WP 7.0+ AI support. + if ( function_exists( 'wp_supports_ai' ) && ! wp_supports_ai() ) { + return false; + } + + return true; + } /** - * Maximum number of cases to analyze per check type. + * Gets the maximum number of cases to send per AI batch request. * * @since 1.8.0 - * @var int + * + * @return int Batch size. */ - const AI_MAX_CASES_PER_CHECK = 24; + protected function get_ai_batch_size() { + return 12; + } /** - * Mapping of check code prefixes to their prompt template filenames. + * Gets the maximum number of cases to analyze per check type. * * @since 1.8.0 - * @var array + * + * @return int Maximum case count. */ - 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', - ); + protected function get_ai_max_cases_per_check() { + return 24; + } /** - * Checks if AI analysis is available via WordPress core AI client. + * Gets the mapping of check code prefixes to their prompt template filenames. * * @since 1.8.0 * - * @return bool True if AI client is available, false otherwise. + * @return array Prompt map. */ - protected function is_ai_available() { - if ( ! function_exists( 'wp_ai_client_prompt' ) ) { - return false; - } - - // Check WP 7.0+ AI support. - if ( function_exists( 'wp_supports_ai' ) && ! wp_supports_ai() ) { - return false; - } - - return true; + protected function get_ai_prompt_map() { + return 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', + 'trialware_locked_feature_candidate' => 'ai-review-trialware.md', + ); } /** @@ -242,7 +252,7 @@ protected function collect_issues_from_collection( array $collection, $type, $th $counts[ $prompt_file ] = 0; } - if ( $counts[ $prompt_file ] >= self::AI_MAX_CASES_PER_CHECK ) { + if ( $counts[ $prompt_file ] >= $this->get_ai_max_cases_per_check() ) { continue; } @@ -271,7 +281,7 @@ protected function collect_issues_from_collection( array $collection, $type, $th /** * Analyzes a batch of issues with a specific prompt template. * - * If the batch exceeds AI_BATCH_SIZE, it is split into sub-batches + * If the batch exceeds the configured batch size, it is split into sub-batches * and each sub-batch is sent as a separate AI request. * * @since 1.8.0 @@ -289,7 +299,7 @@ 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 ); + $batches = array_chunk( $cases, $this->get_ai_batch_size(), true ); $all_cases = array(); $total_tokens = 0; $input_tokens = 0; @@ -586,7 +596,7 @@ protected function parse_batch_response( $response_text ) { * @return string Prompt template filename. */ protected function get_prompt_for_code( $code ) { - foreach ( self::AI_PROMPT_MAP as $prefix => $prompt_file ) { + foreach ( $this->get_ai_prompt_map() as $prefix => $prompt_file ) { if ( 0 === strpos( $code, $prefix ) ) { return $prompt_file; } diff --git a/prompts/ai-review-trialware.md b/prompts/ai-review-trialware.md new file mode 100644 index 000000000..0f438cdd9 --- /dev/null +++ b/prompts/ai-review-trialware.md @@ -0,0 +1,14 @@ +## Trialware and Locked Feature Issues + +A trialware or locked feature issue occurs when a plugin includes functionality that its code can perform, but intentionally restricts that functionality behind a license key, payment, trial period, usage quota, time limit, plan limit, or other artificial limitation. + +Using the case as a reference, check the code to determine whether the plugin genuinely restricts built-in functionality. + +Details: +- Plugins hosted on WordPress.org must be fully functional. +- It is an issue when functionality already present in the plugin code only works after a license check, payment, activation key, trial, quota, or similar restriction. +- It is an issue when the plugin code intentionally limits built-in functionality, such as only allowing a fixed number of items until the user upgrades. +- It is acceptable for a plugin to display informational references to features available in a separate pro/premium plugin when the locked feature code is not included in this plugin. +- It is acceptable for a plugin to depend on an external service when that service provides meaningful external processing that cannot reasonably be performed locally by the plugin. +- A service that only checks a license key or unlocks local functionality is not a meaningful external service. +- Asking for a license to receive software updates is not acceptable for WordPress.org-hosted plugins, since updates are expected to be served through WordPress.org. diff --git a/templates/results-row.php b/templates/results-row.php index a2ef2cd2d..eb6fda833 100644 --- a/templates/results-row.php +++ b/templates/results-row.php @@ -10,22 +10,11 @@ {{data.code}} - - - {{{data.message}}} - <# if ( data.docs ) { #> -
- - - - - - <# } #> <# if ( data.ai_analysis ) { #>
<# if ( data.ai_analysis.is_false_positive ) { #> - + <# if ( data.ai_analysis.confidence ) { #> (: {{Math.round(data.ai_analysis.confidence * 100)}}%) @@ -33,13 +22,26 @@ <# } else { #> - + <# if ( data.ai_analysis.confidence ) { #> (: {{Math.round(data.ai_analysis.confidence * 100)}}%) <# } #> <# } #> + <# } #> + + + {{{data.message}}} + <# if ( data.docs ) { #> +
+ + + + + + <# } #> + <# if ( data.ai_analysis ) { #> <# if ( data.ai_analysis.reasoning ) { #>
{{{data.ai_analysis.reasoning}}} @@ -62,4 +64,3 @@ <# } #> - diff --git a/tests/phpunit/testdata/plugins/test-plugin-trialware-errors/load.php b/tests/phpunit/testdata/plugins/test-plugin-trialware-errors/load.php new file mode 100644 index 000000000..3dd01db2a --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-trialware-errors/load.php @@ -0,0 +1,32 @@ + 'exported' ); +} + +function test_plugin_trialware_can_add_item( $items ) { + if ( count( $items ) >= 3 ) { + wp_die( 'Limit reached. Upgrade to add unlimited items.' ); + } + + return true; +} diff --git a/tests/phpunit/testdata/plugins/test-plugin-trialware-without-errors/load.php b/tests/phpunit/testdata/plugins/test-plugin-trialware-without-errors/load.php new file mode 100644 index 000000000..4260ae496 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-trialware-without-errors/load.php @@ -0,0 +1,19 @@ + 'exported' ); +} diff --git a/tests/phpunit/tests/Checker/Checks/Trialware_Check_Tests.php b/tests/phpunit/tests/Checker/Checks/Trialware_Check_Tests.php new file mode 100644 index 000000000..04f563b1e --- /dev/null +++ b/tests/phpunit/tests/Checker/Checks/Trialware_Check_Tests.php @@ -0,0 +1,35 @@ +run( $check_result ); + + $errors = $check_result->get_errors(); + + $this->assertNotEmpty( $errors ); + $this->assertArrayHasKey( 'load.php', $errors ); + $this->assertGreaterThanOrEqual( 2, $check_result->get_error_count() ); + $this->assertCount( 1, wp_list_filter( $errors['load.php'][17][3], array( 'code' => 'trialware_locked_feature_candidate' ) ) ); + $this->assertSame( 5, $errors['load.php'][17][3][0]['severity'] ); + } + + public function test_run_without_locked_feature_errors() { + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-trialware-without-errors/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check = new Trialware_Check(); + $check->run( $check_result ); + + $this->assertEmpty( $check_result->get_errors() ); + $this->assertSame( 0, $check_result->get_error_count() ); + } +} From 148c394ba9fc13c32726bf98e7e55871c0e3c443 Mon Sep 17 00:00:00 2001 From: davidperezgar Date: Sat, 23 May 2026 12:14:59 +0200 Subject: [PATCH 10/14] section for false positives --- assets/css/plugin-check-admin.css | 25 ++ assets/js/plugin-check-admin.js | 507 ++++++++++++++++++++------ includes/Admin/Admin_Page.php | 11 + includes/CLI/Plugin_Check_Command.php | 97 ++++- templates/results-false-positives.php | 14 + 5 files changed, 533 insertions(+), 121 deletions(-) create mode 100644 templates/results-false-positives.php diff --git a/assets/css/plugin-check-admin.css b/assets/css/plugin-check-admin.css index 5406754a1..115a348f7 100644 --- a/assets/css/plugin-check-admin.css +++ b/assets/css/plugin-check-admin.css @@ -113,6 +113,31 @@ color: #004085; line-height: 1.5; } + +.plugin-check__false-positives { + margin: 1.5em 0; + border: 1px solid #dcdcde; + background: #fff; +} + +.plugin-check__false-positives summary { + padding: 12px 14px; + font-weight: 600; + cursor: pointer; +} + +.plugin-check__false-positive-results { + padding: 0 14px 14px; +} + +#plugin-check__results .plugin-check__false-positive-results h4:first-child { + margin-top: 12px; +} + +.plugin-check__false-positive-results table.plugin-check__results-table { + margin-bottom: 1em; +} + .plugin-check__options { display: flex; } diff --git a/assets/js/plugin-check-admin.js b/assets/js/plugin-check-admin.js index 9fa8aa8dc..475662a43 100644 --- a/assets/js/plugin-check-admin.js +++ b/assets/js/plugin-check-admin.js @@ -29,6 +29,7 @@ } let aggregatedResults = createEmptyAggregatedResults(); + let falsePositiveResults = createEmptyAggregatedResults(); let checksCompleted = false; exportContainer.classList.add( 'is-hidden' ); exportContainer.addEventListener( 'click', onExportContainerClick ); @@ -157,14 +158,35 @@ function resetAggregatedResults() { aggregatedResults = createEmptyAggregatedResults(); + falsePositiveResults = createEmptyAggregatedResults(); } function mergeAggregatedResults( results ) { - if ( results.errors ) { - mergeResultTree( aggregatedResults.errors, results.errors ); + const splitResults = splitResultsByFalsePositive( results ); + + if ( splitResults.actionable.errors ) { + mergeResultTree( + aggregatedResults.errors, + splitResults.actionable.errors + ); + } + if ( splitResults.actionable.warnings ) { + mergeResultTree( + aggregatedResults.warnings, + splitResults.actionable.warnings + ); } - if ( results.warnings ) { - mergeResultTree( aggregatedResults.warnings, results.warnings ); + if ( splitResults.falsePositive.errors ) { + mergeResultTree( + falsePositiveResults.errors, + splitResults.falsePositive.errors + ); + } + if ( splitResults.falsePositive.warnings ) { + mergeResultTree( + falsePositiveResults.warnings, + splitResults.falsePositive.warnings + ); } } @@ -202,6 +224,106 @@ } } + function splitResultsByFalsePositive( results ) { + const splitResults = { + actionable: createEmptyAggregatedResults(), + falsePositive: createEmptyAggregatedResults(), + }; + const aiAnalysis = + results && results.ai_analysis ? results.ai_analysis : {}; + + splitResultType( + results && results.errors ? results.errors : {}, + splitResults.actionable.errors, + splitResults.falsePositive.errors, + aiAnalysis + ); + splitResultType( + results && results.warnings ? results.warnings : {}, + splitResults.actionable.warnings, + splitResults.falsePositive.warnings, + aiAnalysis + ); + + return splitResults; + } + + function splitResultType( results, actionable, falsePositive, aiAnalysis ) { + for ( const file of Object.keys( results ) ) { + const lines = results[ file ] || {}; + + for ( const line of Object.keys( lines ) ) { + const columns = lines[ line ] || {}; + + for ( const column of Object.keys( columns ) ) { + for ( const entry of columns[ column ] || [] ) { + const aiData = findAiAnalysisForIssue( + file, + line, + column, + entry.code, + aiAnalysis + ); + const target = + aiData && aiData.is_false_positive + ? falsePositive + : actionable; + const targetEntry = cloneResultEntry( entry ); + + if ( aiData ) { + targetEntry.ai_analysis = aiData; + } + + addResultEntry( + target, + file, + line, + column, + targetEntry + ); + } + } + } + } + } + + function addResultEntry( target, file, line, column, entry ) { + if ( ! hasOwn( target, file ) ) { + target[ file ] = {}; + } + if ( ! hasOwn( target[ file ], line ) ) { + target[ file ][ line ] = {}; + } + if ( ! hasOwn( target[ file ][ line ], column ) ) { + target[ file ][ line ][ column ] = []; + } + + target[ file ][ line ][ column ].push( entry ); + } + + function findAiAnalysisForIssue( file, line, column, code, aiAnalysis ) { + if ( ! aiAnalysis || typeof aiAnalysis !== 'object' ) { + return null; + } + + const analysisEntries = Object.values( aiAnalysis ); + return ( + analysisEntries.find( function ( analysis ) { + if ( ! analysis || typeof analysis !== 'object' ) { + return false; + } + + return ( + String( analysis.file || '' ) === String( file || '' ) && + parseInt( analysis.line, 10 ) === parseInt( line, 10 ) && + parseInt( analysis.column, 10 ) === + parseInt( column, 10 ) && + String( analysis.code || '' ) === String( code || '' ) + ); + } ) || null + ); + } + function cloneResultEntry( entry ) { return { ...entry }; } @@ -209,8 +331,24 @@ function hasAggregatedResults() { return ( hasEntries( aggregatedResults.errors ) || - hasEntries( aggregatedResults.warnings ) + hasEntries( aggregatedResults.warnings ) || + hasEntries( falsePositiveResults.errors ) || + hasEntries( falsePositiveResults.warnings ) + ); + } + + function getExportResults() { + const exportResults = createEmptyAggregatedResults(); + + mergeResultTree( exportResults.errors, aggregatedResults.errors ); + mergeResultTree( exportResults.warnings, aggregatedResults.warnings ); + mergeResultTree( exportResults.errors, falsePositiveResults.errors ); + mergeResultTree( + exportResults.warnings, + falsePositiveResults.warnings ); + + return exportResults; } function hasEntries( tree ) { @@ -382,7 +520,7 @@ payload.append( 'plugin', pluginsList.value ); } payload.append( 'plugin_label', getSelectedPluginLabel() ); - payload.append( 'results', JSON.stringify( aggregatedResults ) ); + payload.append( 'results', JSON.stringify( getExportResults() ) ); return fetch( ajaxurl, { method: 'POST', @@ -457,10 +595,7 @@ 'include-experimental', includeExperimental && includeExperimental.checked ? 1 : 0 ); - pluginCheckData.append( - 'use-ai', - useAi && useAi.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 ] ); @@ -533,10 +668,7 @@ 'include-experimental', includeExperimental && includeExperimental.checked ? 1 : 0 ); - pluginCheckData.append( - 'use-ai', - useAi && useAi.checked ? 1 : 0 - ); + pluginCheckData.append( 'use-ai', useAi && useAi.checked ? 1 : 0 ); for ( let i = 0; i < categoriesList.length; i++ ) { if ( categoriesList[ i ].checked ) { @@ -584,8 +716,13 @@ for ( let i = 0; i < data.checks.length; i++ ) { try { const results = await runCheck( data.plugin, data.checks[ i ] ); - const errorsLength = Object.values( results.errors ).length; - const warningsLength = Object.values( results.warnings ).length; + const splitResults = splitResultsByFalsePositive( results ); + const errorsLength = countResultTree( + splitResults.actionable.errors + ); + const warningsLength = countResultTree( + splitResults.actionable.warnings + ); if ( isSuccessMessage && ( errorsLength > 0 || warningsLength > 0 ) @@ -630,6 +767,7 @@ } } + renderFalsePositiveResults(); renderResultsMessage( isSuccessMessage, aiStats ); } @@ -818,10 +956,7 @@ 'include-experimental', includeExperimental && includeExperimental.checked ? 1 : 0 ); - pluginCheckData.append( - 'use-ai', - useAi && useAi.checked ? 1 : 0 - ); + pluginCheckData.append( 'use-ai', useAi && useAi.checked ? 1 : 0 ); for ( let i = 0; i < typesList.length; i++ ) { if ( typesList[ i ].checked ) { @@ -846,10 +981,16 @@ // Debug: Log AI data if present. if ( responseData.data.ai_analysis ) { - console.log( 'AI Analysis received:', 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 ); + console.log( + 'AI Stats received:', + responseData.data.ai_stats + ); } return responseData.data; @@ -890,25 +1031,29 @@ * @param {Object} results The results object. */ function renderResults( results ) { - const { errors, warnings, ai_analysis } = results || {}; + const { ai_analysis: aiAnalysis } = results || {}; + const splitResults = splitResultsByFalsePositive( results ); + const errors = splitResults.actionable.errors; + const warnings = splitResults.actionable.warnings; - // 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 ], ai_analysis ); + renderFileResults( + file, + errors[ file ], + warnings[ file ], + aiAnalysis + ); delete warnings[ file ]; } else { - renderFileResults( file, errors[ file ], [], ai_analysis ); + renderFileResults( file, errors[ file ], [], aiAnalysis ); } } // Render remaining files with only warnings. for ( const file in warnings ) { - renderFileResults( file, [], warnings[ file ], ai_analysis ); + renderFileResults( file, [], warnings[ file ], aiAnalysis ); } } @@ -917,12 +1062,19 @@ * * @since 1.0.0 * - * @param {string} file The file name for the results. - * @param {Object} errors The file errors. - * @param {Object} warnings The file warnings. - * @param {Object} ai_analysis AI analysis results. + * @param {string} file The file name for the results. + * @param {Object} errors The file errors. + * @param {Object} warnings The file warnings. + * @param {Object} aiAnalysis AI analysis results. */ - function renderFileResults( file, errors, warnings, ai_analysis ) { + function renderFileResults( file, errors, warnings, aiAnalysis ) { + if ( + ! hasEntries( { [ file ]: errors } ) && + ! hasEntries( { [ file ]: warnings } ) + ) { + return; + } + const index = Date.now().toString( 36 ) + Math.random().toString( 36 ).substr( 2 ); @@ -941,8 +1093,196 @@ ); // Render results to the table. - renderResultRows( 'ERROR', errors, resultsTable, hasLinks, ai_analysis, file ); - renderResultRows( 'WARNING', warnings, resultsTable, hasLinks, ai_analysis, file ); + renderResultRows( + 'ERROR', + errors, + resultsTable, + hasLinks, + aiAnalysis, + file + ); + renderResultRows( + 'WARNING', + warnings, + resultsTable, + hasLinks, + aiAnalysis, + file + ); + } + + /** + * Renders the possible false positives at the end of the results. + * + * @since 2.0.0 + */ + function renderFalsePositiveResults() { + if ( + ! hasEntries( falsePositiveResults.errors ) && + ! hasEntries( falsePositiveResults.warnings ) + ) { + return; + } + + const index = + Date.now().toString( 36 ) + + Math.random().toString( 36 ).substr( 2 ); + const falsePositiveCount = + countResultTree( falsePositiveResults.errors ) + + countResultTree( falsePositiveResults.warnings ); + + resultsContainer.innerHTML += renderTemplate( + 'plugin-check-results-false-positives', + { + index, + count: falsePositiveCount, + } + ); + + const falsePositiveContainer = document.getElementById( + 'plugin-check__false-positive-results-' + index + ); + + if ( ! falsePositiveContainer ) { + return; + } + + renderResultCollection( + falsePositiveResults.errors, + falsePositiveResults.warnings, + falsePositiveContainer + ); + } + + /** + * Renders a result collection into a specific container. + * + * @since 2.0.0 + * + * @param {Object} containerErrors Error results. + * @param {Object} containerWarnings Warning results. + * @param {Object} container Container element. + */ + function renderResultCollection( + containerErrors, + containerWarnings, + container + ) { + const errors = cloneResultTree( containerErrors ); + const warnings = cloneResultTree( containerWarnings ); + + for ( const file in errors ) { + if ( warnings[ file ] ) { + renderFileResultsInContainer( + file, + errors[ file ], + warnings[ file ], + container + ); + delete warnings[ file ]; + } else { + renderFileResultsInContainer( + file, + errors[ file ], + [], + container + ); + } + } + + for ( const file in warnings ) { + renderFileResultsInContainer( + file, + [], + warnings[ file ], + container + ); + } + } + + /** + * Renders one file's results into a specific container. + * + * @since 2.0.0 + * + * @param {string} file File name. + * @param {Object} errors Error results. + * @param {Object} warnings Warning results. + * @param {Object} container Container element. + */ + function renderFileResultsInContainer( file, errors, warnings, container ) { + if ( + ! hasEntries( { [ file ]: errors } ) && + ! hasEntries( { [ file ]: warnings } ) + ) { + return; + } + + const index = + Date.now().toString( 36 ) + + Math.random().toString( 36 ).substr( 2 ); + const hasLinks = + hasLinksInResults( errors ) || hasLinksInResults( warnings ); + + container.innerHTML += renderTemplate( 'plugin-check-results-table', { + file, + index, + hasLinks, + } ); + + const resultsTable = document.getElementById( + 'plugin-check__results-body-' + index + ); + + renderResultRows( 'ERROR', errors, resultsTable, hasLinks, {}, file ); + renderResultRows( + 'WARNING', + warnings, + resultsTable, + hasLinks, + {}, + file + ); + } + + /** + * Clones a result tree. + * + * @since 2.0.0 + * + * @param {Object} tree Result tree. + * @return {Object} Cloned result tree. + */ + function cloneResultTree( tree ) { + const clone = {}; + mergeResultTree( clone, tree || {} ); + return clone; + } + + /** + * Counts all results in a result tree. + * + * @since 2.0.0 + * + * @param {Object} tree Result tree. + * @return {number} Result count. + */ + function countResultTree( tree ) { + let count = 0; + + for ( const file of Object.keys( tree || {} ) ) { + const lines = tree[ file ] || {}; + + for ( const line of Object.keys( lines ) ) { + const columns = lines[ line ] || {}; + + for ( const column of Object.keys( columns ) ) { + count += ( columns[ column ] || [] ).length; + } + } + } + + return count; } /** @@ -971,14 +1311,21 @@ * * @since 1.0.0 * - * @param {string} type The result type. Either ERROR or WARNING. - * @param {Object} results The results object. - * @param {Object} table The HTML table to append a result row to. - * @param {boolean} hasLinks Whether any result has links. - * @param {Object} ai_analysis AI analysis results. - * @param {string} file The file path. + * @param {string} type The result type. Either ERROR or WARNING. + * @param {Object} results The results object. + * @param {Object} table The HTML table to append a result row to. + * @param {boolean} hasLinks Whether any result has links. + * @param {Object} aiAnalysis AI analysis results. + * @param {string} file The file path. */ - function renderResultRows( type, results, table, hasLinks, ai_analysis, file ) { + function renderResultRows( + type, + results, + table, + hasLinks, + aiAnalysis, + file + ) { // Loop over each result by the line, column and messages. for ( const line in results ) { for ( const column in results[ line ] ) { @@ -987,46 +1334,19 @@ const docs = results[ line ][ column ][ i ].docs; const code = results[ line ][ column ][ i ].code; const link = results[ line ][ column ][ i ].link; + const storedAiData = + results[ line ][ column ][ i ].ai_analysis || null; // 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 aiData = + storedAiData || + findAiAnalysisForIssue( + file, + line, + column, + code, + aiAnalysis + ); const rowData = { line, @@ -1053,29 +1373,6 @@ } } - /** - * Generates a unique key for an issue. - * - * @since 1.8.0 - * - * @param {string} file File path. - * @param {number} line Line number. - * @param {number} column Column number. - * @param {string} code Issue code. - * @return {string} Unique key. - */ - function getIssueKey( file, line, column, code ) { - const str = file + ':' + line + ':' + column + ':' + code; - // Simple MD5-like hash (using built-in hash if available, otherwise a simple hash). - let hash = 0; - for ( let i = 0; i < str.length; i++ ) { - const char = str.charCodeAt( i ); - hash = ( hash << 5 ) - hash + char; - hash = hash & hash; // Convert to 32bit integer. - } - return hash.toString( 36 ); - } - /** * Renders the template with data. * diff --git a/includes/Admin/Admin_Page.php b/includes/Admin/Admin_Page.php index fd122f09b..5dd4881e8 100644 --- a/includes/Admin/Admin_Page.php +++ b/includes/Admin/Admin_Page.php @@ -390,6 +390,17 @@ public function admin_footer() { ) ); + ob_start(); + require WP_PLUGIN_CHECK_PLUGIN_DIR_PATH . 'templates/results-false-positives.php'; + $results_false_positives_template = ob_get_clean(); + wp_print_inline_script_tag( + $results_false_positives_template, + array( + 'id' => 'tmpl-plugin-check-results-false-positives', + 'type' => 'text/template', + ) + ); + ob_start(); require WP_PLUGIN_CHECK_PLUGIN_DIR_PATH . 'templates/results-complete.php'; $results_row_template = ob_get_clean(); diff --git a/includes/CLI/Plugin_Check_Command.php b/includes/CLI/Plugin_Check_Command.php index e8dabb746..d078cf9cf 100644 --- a/includes/CLI/Plugin_Check_Command.php +++ b/includes/CLI/Plugin_Check_Command.php @@ -173,7 +173,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' => '', @@ -399,6 +399,13 @@ static function ( $dirs ) use ( $excluded_files ) { return; } + $false_positive_results = array(); + if ( ! empty( $ai_analysis ) ) { + $split_results = $this->split_false_positive_results( $all_results, $ai_analysis ); + $all_results = $split_results['actionable']; + $false_positive_results = $split_results['false_positives']; + } + // Group results by file. $results_by_file = array(); @@ -412,7 +419,7 @@ static function ( $dirs ) use ( $excluded_files ) { // Display AI analysis summary if available. if ( ! empty( $ai_analysis ) || ! empty( $ai_stats ) ) { - $this->display_ai_summary( $ai_analysis, $ai_stats ); + $this->display_ai_summary( $ai_analysis, $ai_stats, $false_positive_results ); } } @@ -694,15 +701,80 @@ private function display_results( $formatter, $file_name, $file_results ) { WP_CLI::line(); } + /** + * Splits likely false positives out of the main check results. + * + * @since 2.0.0 + * + * @param array $results Check results. + * @param array $ai_analysis AI analysis results. + * @return array Results split into actionable and false positive groups. + */ + private function split_false_positive_results( array $results, array $ai_analysis ) { + $split_results = array( + 'actionable' => array(), + 'false_positives' => array(), + ); + + foreach ( $results as $item ) { + $analysis = $this->find_ai_analysis_for_result( $item, $ai_analysis ); + + if ( ! empty( $analysis['is_false_positive'] ) ) { + if ( ! empty( $analysis['reasoning'] ) ) { + $item['reasoning'] = $analysis['reasoning']; + } + $split_results['false_positives'][] = $item; + continue; + } + + $split_results['actionable'][] = $item; + } + + return $split_results; + } + + /** + * Finds the AI analysis entry for a result item. + * + * @since 2.0.0 + * + * @param array $item Result item. + * @param array $ai_analysis AI analysis results. + * @return array|null AI analysis entry, or null if none is found. + */ + private function find_ai_analysis_for_result( array $item, array $ai_analysis ) { + foreach ( $ai_analysis as $analysis ) { + if ( ! is_array( $analysis ) ) { + continue; + } + + if ( + (string) ( $analysis['file'] ?? '' ) === (string) ( $item['file'] ?? '' ) && + (int) ( $analysis['line'] ?? 0 ) === (int) ( $item['line'] ?? 0 ) && + (int) ( $analysis['column'] ?? 0 ) === (int) ( $item['column'] ?? 0 ) && + (string) ( $analysis['code'] ?? '' ) === (string) ( $item['code'] ?? '' ) + ) { + return $analysis; + } + } + + return null; + } + /** * Displays AI analysis summary. * * @since 1.8.0 * - * @param array $ai_analysis AI analysis results. - * @param array $ai_stats AI statistics. + * @param array $ai_analysis AI analysis results. + * @param array $ai_stats AI statistics. + * @param array $false_positive_results False positive results. */ - private function display_ai_summary( array $ai_analysis, array $ai_stats ) { + private function display_ai_summary( + array $ai_analysis, + array $ai_stats, + array $false_positive_results = array() + ) { WP_CLI::line( '' ); WP_CLI::line( str_repeat( '─', 60 ) ); WP_CLI::line( '✨ ' . __( 'AI False Positive Analysis', 'plugin-check' ) ); @@ -740,18 +812,11 @@ private function display_ai_summary( array $ai_analysis, array $ai_stats ) { } // 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 ) ) { + if ( ! empty( $false_positive_results ) ) { WP_CLI::line( '' ); WP_CLI::line( __( 'Likely false positives:', 'plugin-check' ) ); - foreach ( $fp_items as $item ) { + foreach ( $false_positive_results as $item ) { $location = isset( $item['file'] ) ? $item['file'] : ''; if ( isset( $item['line'] ) ) { $location .= ':' . $item['line']; @@ -759,9 +824,9 @@ private function display_ai_summary( array $ai_analysis, array $ai_stats ) { WP_CLI::line( sprintf( - ' ✨ %s — %s', + ' %s - %s', $location, - isset( $item['reasoning'] ) ? $item['reasoning'] : '' + isset( $item['reasoning'] ) ? $item['reasoning'] : $item['message'] ) ); } diff --git a/templates/results-false-positives.php b/templates/results-false-positives.php new file mode 100644 index 000000000..d149c96b8 --- /dev/null +++ b/templates/results-false-positives.php @@ -0,0 +1,14 @@ + +
+ + ({{ data.count }}) + +
+
From 14a19697c218d132e41b826eb43c9abc05c779d5 Mon Sep 17 00:00:00 2001 From: davidperezgar Date: Sat, 23 May 2026 12:33:01 +0200 Subject: [PATCH 11/14] lint --- includes/Admin/Settings_Page.php | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/includes/Admin/Settings_Page.php b/includes/Admin/Settings_Page.php index 8d8e63ce8..2be5d37c3 100644 --- a/includes/Admin/Settings_Page.php +++ b/includes/Admin/Settings_Page.php @@ -163,14 +163,16 @@ public function render_ai_section_description() {

configure an AI connector in WordPress settings first.', 'plugin-check' ), - array( 'a' => array( 'href' => array() ) ) - ), + __( 'No AI connectors are configured. Please configure an AI connector in WordPress settings first.', 'plugin-check' ), esc_url( admin_url( 'options-general.php' ) ) ); + + echo wp_kses( + $configured_connector_message, + array( 'a' => array( 'href' => array() ) ) + ); ?>

@@ -201,10 +203,10 @@ public function render_severity_section_description() { * @param array $args Field arguments. */ 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 ); + $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 ); ?>

@@ -275,13 +275,13 @@ public function render_severity_warnings_field( $args ) { $value = isset( $settings['ai_severity_warnings'] ) ? intval( $settings['ai_severity_warnings'] ) : 6; ?>

diff --git a/includes/Traits/AI_Check_Names.php b/includes/Traits/AI_Check_Names.php index 0fadb9756..e34817a3f 100644 --- a/includes/Traits/AI_Check_Names.php +++ b/includes/Traits/AI_Check_Names.php @@ -198,64 +198,6 @@ protected function execute_ai_request( $prompt, $model_preference = '', $builder ); } - /** - * Applies a model preference to the prompt builder if supported. - * - * @since 1.9.0 - * - * @param object $builder Prompt builder instance. - * @param string $model_preference Model preference. - * @return object|WP_Error Updated builder or WP_Error. - */ - protected function apply_model_preference( $builder, $model_preference ) { - if ( empty( $model_preference ) ) { - return $builder; - } - - $preference = $this->normalize_model_preference( $model_preference ); - - try { - $result = $builder->using_model_preference( $preference ); - return $result ? $result : $builder; - } catch ( \Exception $e ) { - // If method doesn't exist or fails, return WP_Error. - return new WP_Error( - 'model_preference_error', - sprintf( - /* translators: %s: Exception message */ - __( 'Failed to apply model preference: %s', 'plugin-check' ), - $e->getMessage() - ) - ); - } - } - - /** - * Normalizes a model preference string into a supported preference format. - * - * @since 1.9.0 - * - * @param string $model_preference Model preference string. - * @return string|array Normalized preference. - */ - protected function normalize_model_preference( $model_preference ) { - $trimmed = trim( (string) $model_preference ); - if ( '' === $trimmed ) { - return ''; - } - - foreach ( array( '::', '|', ':' ) as $separator ) { - if ( false !== strpos( $trimmed, $separator ) ) { - list( $provider, $model ) = array_map( 'trim', explode( $separator, $trimmed, 2 ) ); - if ( '' !== $provider && '' !== $model ) { - return array( $provider, $model ); - } - } - } - - return $trimmed; - } - /** * Extracts token usage from a result object, if available. * diff --git a/includes/Traits/AI_Utils.php b/includes/Traits/AI_Utils.php index e053df315..1adfeba1c 100644 --- a/includes/Traits/AI_Utils.php +++ b/includes/Traits/AI_Utils.php @@ -124,6 +124,63 @@ protected function get_ai_config( $model_preference = '' ) { ); } + /** + * Applies a model preference to the prompt builder if supported. + * + * @since 1.9.0 + * + * @param object $builder Prompt builder instance. + * @param string $model_preference Model preference. + * @return object|WP_Error Updated builder or WP_Error. + */ + protected function apply_model_preference( $builder, $model_preference ) { + if ( empty( $model_preference ) ) { + return $builder; + } + + $preference = $this->normalize_model_preference( $model_preference ); + + try { + $result = $builder->using_model_preference( $preference ); + return $result ? $result : $builder; + } catch ( \Exception $e ) { + return new WP_Error( + 'model_preference_error', + sprintf( + /* translators: %s: Exception message */ + __( 'Failed to apply model preference: %s', 'plugin-check' ), + $e->getMessage() + ) + ); + } + } + + /** + * Normalizes a model preference string into a supported preference format. + * + * @since 1.9.0 + * + * @param string $model_preference Model preference string. + * @return string|array Normalized preference. + */ + protected function normalize_model_preference( $model_preference ) { + $trimmed = trim( (string) $model_preference ); + if ( '' === $trimmed ) { + return ''; + } + + foreach ( array( '::', '|', ':' ) as $separator ) { + if ( false !== strpos( $trimmed, $separator ) ) { + list( $provider, $model ) = array_map( 'trim', explode( $separator, $trimmed, 2 ) ); + if ( '' !== $provider && '' !== $model ) { + return array( $provider, $model ); + } + } + } + + return $trimmed; + } + /** * Gets raw output string from parsed result or analysis. * From b6279aec1a25b40f878ec03833e6f4bb6550e99f Mon Sep 17 00:00:00 2001 From: davidperezgar Date: Sat, 23 May 2026 12:51:28 +0200 Subject: [PATCH 13/14] phpmd --- includes/Admin/Admin_AJAX.php | 15 ++++++--------- includes/Admin/Settings_Page.php | 2 ++ includes/Checker/Check_Result.php | 2 ++ includes/Traits/AI_Analyzer.php | 11 +++++++++++ 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/includes/Admin/Admin_AJAX.php b/includes/Admin/Admin_AJAX.php index 12b6aed35..eef695078 100644 --- a/includes/Admin/Admin_AJAX.php +++ b/includes/Admin/Admin_AJAX.php @@ -228,15 +228,10 @@ public function get_checks_to_run() { $categories = filter_input( INPUT_POST, 'categories', FILTER_DEFAULT, FILTER_FORCE_ARRAY ); $categories = is_null( $categories ) ? array() : $categories; - - $categories = filter_input( INPUT_POST, 'categories', FILTER_DEFAULT, FILTER_FORCE_ARRAY ); - $categories = is_null( $categories ) ? array() : $categories; - $checks = filter_input( INPUT_POST, 'checks', FILTER_DEFAULT, FILTER_FORCE_ARRAY ); - $checks = is_null( $checks ) ? array() : $checks; - $plugin = filter_input( INPUT_POST, 'plugin', FILTER_SANITIZE_FULL_SPECIAL_CHARS ); - $include_experimental = 1 === filter_input( INPUT_POST, 'include-experimental', FILTER_VALIDATE_INT ); - $use_ai = 1 === filter_input( INPUT_POST, 'use-ai', FILTER_VALIDATE_INT ); - $runner = $this->get_ajax_runner(); + $checks = filter_input( INPUT_POST, 'checks', FILTER_DEFAULT, FILTER_FORCE_ARRAY ); + $checks = is_null( $checks ) ? array() : $checks; + $use_ai = 1 === filter_input( INPUT_POST, 'use-ai', FILTER_VALIDATE_INT ); + $runner = $this->get_ajax_runner(); if ( is_wp_error( $runner ) ) { wp_send_json_error( $runner, 500 ); @@ -268,6 +263,8 @@ public function get_checks_to_run() { * Run checks. * * @since 1.0.0 + * + * @SuppressWarnings(PHPMD.NPathComplexity) */ public function run_checks() { $this->check_request_validity(); diff --git a/includes/Admin/Settings_Page.php b/includes/Admin/Settings_Page.php index fab4afe2c..9fbbcf44d 100644 --- a/includes/Admin/Settings_Page.php +++ b/includes/Admin/Settings_Page.php @@ -16,6 +16,8 @@ * and severity threshold configuration for AI false positive detection. * * @since 1.8.0 + * + * @SuppressWarnings(PHPMD.TooManyPublicMethods) */ final class Settings_Page { diff --git a/includes/Checker/Check_Result.php b/includes/Checker/Check_Result.php index 8f0aec305..0f5a540ae 100644 --- a/includes/Checker/Check_Result.php +++ b/includes/Checker/Check_Result.php @@ -11,6 +11,8 @@ * Result for running checks on a plugin. * * @since 1.0.0 + * + * @SuppressWarnings(PHPMD.TooManyPublicMethods) */ final class Check_Result { diff --git a/includes/Traits/AI_Analyzer.php b/includes/Traits/AI_Analyzer.php index 29ae01af0..8e8015b5f 100644 --- a/includes/Traits/AI_Analyzer.php +++ b/includes/Traits/AI_Analyzer.php @@ -101,6 +101,8 @@ protected function get_ai_prompt_map() { * @param Check_Context $check_context Check context instance. * @param string $model_preference Optional model preference. * @return array|WP_Error Array with 'analysis' and 'stats' keys, or WP_Error on failure. + * + * @SuppressWarnings(PHPMD.NPathComplexity) */ protected function analyze_results_with_ai( Check_Result $result, Check_Context $check_context, $model_preference = '' ) { if ( ! $this->is_ai_available() ) { @@ -291,6 +293,8 @@ protected function collect_issues_from_collection( array $collection, $type, $th * @param Check_Context $check_context Check context instance. * @param string $model_preference Optional model preference. * @return array|WP_Error Array with 'cases' and 'token_usage' keys, or WP_Error. + * + * @SuppressWarnings(PHPMD.NPathComplexity) */ protected function analyze_batch( $prompt_file, array $cases, Check_Context $check_context, $model_preference = '' ) { $issue_description = $this->load_prompt_template( $prompt_file ); @@ -360,6 +364,8 @@ protected function analyze_batch( $prompt_file, array $cases, Check_Context $che * @param Check_Context $check_context Check context instance. * @param string $model_preference Optional model preference. * @return array|WP_Error Array with 'cases' and 'token_usage', or WP_Error. + * + * @SuppressWarnings(PHPMD.NPathComplexity) */ protected function execute_batch_ai_request( $issue_description, array $cases, Check_Context $check_context, $model_preference = '' ) { $prompt = $this->build_batch_prompt( $issue_description, $cases, $check_context ); @@ -541,6 +547,8 @@ protected function get_code_context( $file_content, $line, $context = 10 ) { * * @param string $response_text AI response text. * @return array Array of case results. + * + * @SuppressWarnings(PHPMD.NPathComplexity) */ protected function parse_batch_response( $response_text ) { if ( empty( $response_text ) ) { @@ -716,6 +724,9 @@ protected function apply_ai_model_preference( $builder, $model_preference ) { * * @param object $result Result object. * @return array|null Token usage array or null. + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) */ protected function extract_ai_token_usage( $result ) { $usage = null; From f592e3bef7a5845a0b8ee743db0f2d628a397c09 Mon Sep 17 00:00:00 2001 From: davidperezgar Date: Sun, 24 May 2026 10:40:08 +0200 Subject: [PATCH 14/14] remove trialware --- docs/checks.md | 1 - includes/Checker/Abstract_Check_Runner.php | 74 ------- .../Checks/Plugin_Repo/Trialware_Check.php | 203 ------------------ includes/Checker/Default_Check_Repository.php | 1 - includes/Traits/AI_Analyzer.php | 1 - prompts/ai-review-trialware.md | 14 -- .../test-plugin-trialware-errors/load.php | 32 --- .../load.php | 19 -- .../Checker/Checks/Trialware_Check_Tests.php | 35 --- 9 files changed, 380 deletions(-) delete mode 100644 includes/Checker/Checks/Plugin_Repo/Trialware_Check.php delete mode 100644 prompts/ai-review-trialware.md delete mode 100644 tests/phpunit/testdata/plugins/test-plugin-trialware-errors/load.php delete mode 100644 tests/phpunit/testdata/plugins/test-plugin-trialware-without-errors/load.php delete mode 100644 tests/phpunit/tests/Checker/Checks/Trialware_Check_Tests.php diff --git a/docs/checks.md b/docs/checks.md index ecea9738b..11662a607 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -7,7 +7,6 @@ | i18n_usage | general, plugin_repo | Checks for various internationalization best practices. | [Learn more](https://developer.wordpress.org/plugins/internationalization/how-to-internationalize-your-plugin/) | | code_obfuscation | plugin_repo | Detects the usage of code obfuscation tools. | [Learn more](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/) | | plugin_content | plugin_repo | Detects content that does not comply with the WordPress.org plugin guidelines. | [Learn more](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/) | -| trialware | plugin_repo | Uses AI to detect trialware and locked built-in features. | [Learn more](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/) | | direct_file_access | security, plugin_repo | Checks that plugin files include proper security validation using the ABSPATH constant to prevent direct file access. | [Learn more](https://developer.wordpress.org/plugins/plugin-basics/best-practices/#file-security) | | file_type | plugin_repo | Detects the usage of hidden and compressed files, VCS directories, application files, badly named files, AI development directories (.cursor, .claude, .aider, .continue, .windsurf, .ai, .github), and unexpected markdown files in plugin root. | [Learn more](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/) | | plugin_header_fields | plugin_repo | Checks adherence to the Headers requirements, including validation of "Tested up to" header matching between plugin file and readme.txt. | [Learn more](https://developer.wordpress.org/plugins/plugin-basics/header-requirements/) | diff --git a/includes/Checker/Abstract_Check_Runner.php b/includes/Checker/Abstract_Check_Runner.php index 7a42d9e77..3213b86c6 100644 --- a/includes/Checker/Abstract_Check_Runner.php +++ b/includes/Checker/Abstract_Check_Runner.php @@ -9,7 +9,6 @@ use Exception; use WordPress\Plugin_Check\Admin\Settings_Page; -use WordPress\Plugin_Check\Checker\Checks\Plugin_Repo\Trialware_Check; use WordPress\Plugin_Check\Checker\Exception\Invalid_Check_Slug_Exception; use WordPress\Plugin_Check\Checker\Preparations\Universal_Runtime_Preparation; use WordPress\Plugin_Check\Traits\AI_Analyzer; @@ -462,7 +461,6 @@ final public function run() { } } - $ai_analysis = $this->finalize_ai_confirmed_issues( $results, $ai_analysis ); $results->set_ai_analysis( $ai_analysis ); $results->set_ai_stats( $ai_stats ); @@ -475,78 +473,6 @@ final public function run() { return $results; } - /** - * Promotes AI-confirmed candidate issues and removes unconfirmed AI-only candidates. - * - * @since 2.0.0 - * - * @param Check_Result $results Check result. - * @param array $ai_analysis AI analysis results. - * @return array Updated AI analysis results. - */ - private function finalize_ai_confirmed_issues( Check_Result $results, array $ai_analysis ) { - $confirmed_trialware = array(); - $updated_analysis = array(); - - foreach ( $ai_analysis as $key => $analysis ) { - if ( ! is_array( $analysis ) || Trialware_Check::CANDIDATE_CODE !== ( $analysis['code'] ?? '' ) ) { - $updated_analysis[ $key ] = $analysis; - continue; - } - - if ( ! empty( $analysis['is_false_positive'] ) ) { - continue; - } - - $issue_key = $this->get_ai_issue_location_key( $analysis ); - $analysis['code'] = Trialware_Check::CONFIRMED_CODE; - $analysis['type'] = 'error'; - $updated_analysis[ $key ] = $analysis; - $confirmed_trialware[ $issue_key ] = true; - } - - $results->transform_messages( - function ( array $message, $is_error, $file, $line, $column ) use ( $confirmed_trialware ) { - if ( Trialware_Check::CANDIDATE_CODE !== ( $message['code'] ?? '' ) ) { - return $message; - } - - $issue_key = $this->get_ai_issue_location_key( - array( - 'file' => $file, - 'line' => $line, - 'column' => $column, - ) - ); - - if ( empty( $confirmed_trialware[ $issue_key ] ) ) { - return null; - } - - $message['error'] = true; - $message['code'] = Trialware_Check::CONFIRMED_CODE; - $message['severity'] = 7; - $message['message'] = __( 'Trialware or locked built-in feature detected. Plugins hosted on WordPress.org must not restrict functionality already included in the plugin behind license keys, trials, quotas, payments, or other artificial limits.', 'plugin-check' ); - - return $message; - } - ); - - return $updated_analysis; - } - - /** - * Gets a stable location key for AI issue matching. - * - * @since 2.0.0 - * - * @param array $issue Issue data. - * @return string Location key. - */ - private function get_ai_issue_location_key( array $issue ) { - return ( $issue['file'] ?? '' ) . ':' . (int) ( $issue['line'] ?? 0 ) . ':' . (int) ( $issue['column'] ?? 0 ); - } - /** * Determines if any of the checks are a runtime check. * diff --git a/includes/Checker/Checks/Plugin_Repo/Trialware_Check.php b/includes/Checker/Checks/Plugin_Repo/Trialware_Check.php deleted file mode 100644 index bdafdaef2..000000000 --- a/includes/Checker/Checks/Plugin_Repo/Trialware_Check.php +++ /dev/null @@ -1,203 +0,0 @@ -filter_files_to_scan( $files ); - if ( empty( $files ) ) { - return; - } - - $matches = self::files_preg_match_all( $this->get_locked_features_pattern(), $files ); - if ( empty( $matches ) ) { - return; - } - - $reported = array(); - foreach ( $matches as $match ) { - $key = $match['file'] . ':' . $match['line'] . ':' . $match['column']; - if ( isset( $reported[ $key ] ) ) { - continue; - } - - $reported[ $key ] = true; - $this->add_result_error_for_file( - $result, - __( 'Potential trialware or locked built-in feature candidate. AI analysis must confirm whether the plugin restricts built-in functionality behind a license key, trial, quota, payment, or other artificial limit.', 'plugin-check' ), - self::CANDIDATE_CODE, - $match['file'], - $match['line'], - $match['column'], - $this->get_documentation_url(), - 5 - ); - } - } - - /** - * Filters the file list to code-like files and known low-signal exclusions. - * - * @since 2.0.0 - * - * @param array $files List of absolute file paths. - * @return array Files to scan. - */ - private function filter_files_to_scan( array $files ) { - return array_values( - array_filter( - $files, - static function ( $file ) { - $normalized = wp_normalize_path( $file ); - $extension = strtolower( pathinfo( $normalized, PATHINFO_EXTENSION ) ); - $basename = strtolower( basename( $normalized ) ); - - if ( ! in_array( $extension, self::CODE_EXTENSIONS, true ) ) { - return false; - } - - if ( 'composer.json' === $basename ) { - return false; - } - - return false === strpos( $normalized, '/stripe-php/lib/' ); - } - ) - ); - } - - /** - * Builds the combined regular expression for locked feature indicators. - * - * @since 2.0.0 - * - * @return string Regular expression. - */ - private function get_locked_features_pattern() { - $patterns = array_map( - static function ( $pattern ) { - return '(?:' . $pattern . ')'; - }, - self::LOCKED_FEATURE_PATTERNS - ); - - return '~' . implode( '|', $patterns ) . '~i'; - } - - /** - * Gets the description for the check. - * - * @since 2.0.0 - * - * @return string Description. - */ - public function get_description(): string { - return __( 'Uses AI to detect trialware and locked built-in features.', 'plugin-check' ); - } - - /** - * Gets the documentation URL for the check. - * - * @since 2.0.0 - * - * @return string The documentation URL. - */ - public function get_documentation_url(): string { - return __( 'https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/', 'plugin-check' ); - } -} diff --git a/includes/Checker/Default_Check_Repository.php b/includes/Checker/Default_Check_Repository.php index 626c7640d..c1b7d420c 100644 --- a/includes/Checker/Default_Check_Repository.php +++ b/includes/Checker/Default_Check_Repository.php @@ -76,7 +76,6 @@ private function register_default_checks() { 'enqueued_styles_size' => new Checks\Performance\Enqueued_Styles_Size_Check(), 'code_obfuscation' => new Checks\Plugin_Repo\Code_Obfuscation_Check(), 'plugin_content' => new Checks\Plugin_Repo\Plugin_Content_Check(), - 'trialware' => new Checks\Plugin_Repo\Trialware_Check(), 'file_type' => new Checks\Plugin_Repo\File_Type_Check(), 'plugin_header_fields' => new Checks\Plugin_Repo\Plugin_Header_Fields_Check(), 'late_escaping' => new Checks\Security\Late_Escaping_Check(), diff --git a/includes/Traits/AI_Analyzer.php b/includes/Traits/AI_Analyzer.php index 8e8015b5f..d5d62d26a 100644 --- a/includes/Traits/AI_Analyzer.php +++ b/includes/Traits/AI_Analyzer.php @@ -84,7 +84,6 @@ protected function get_ai_prompt_map() { 'PluginCheck.CodeAnalysis.Obfuscation' => 'ai-review-code-obfuscation.md', 'PluginCheck.CodeAnalysis.SettingSanitization' => 'ai-review-setting-sanitization.md', 'PluginCheck.CodeAnalysis.PluginUpdater' => 'ai-review-plugin-updater.md', - 'trialware_locked_feature_candidate' => 'ai-review-trialware.md', ); } diff --git a/prompts/ai-review-trialware.md b/prompts/ai-review-trialware.md deleted file mode 100644 index 0f438cdd9..000000000 --- a/prompts/ai-review-trialware.md +++ /dev/null @@ -1,14 +0,0 @@ -## Trialware and Locked Feature Issues - -A trialware or locked feature issue occurs when a plugin includes functionality that its code can perform, but intentionally restricts that functionality behind a license key, payment, trial period, usage quota, time limit, plan limit, or other artificial limitation. - -Using the case as a reference, check the code to determine whether the plugin genuinely restricts built-in functionality. - -Details: -- Plugins hosted on WordPress.org must be fully functional. -- It is an issue when functionality already present in the plugin code only works after a license check, payment, activation key, trial, quota, or similar restriction. -- It is an issue when the plugin code intentionally limits built-in functionality, such as only allowing a fixed number of items until the user upgrades. -- It is acceptable for a plugin to display informational references to features available in a separate pro/premium plugin when the locked feature code is not included in this plugin. -- It is acceptable for a plugin to depend on an external service when that service provides meaningful external processing that cannot reasonably be performed locally by the plugin. -- A service that only checks a license key or unlocks local functionality is not a meaningful external service. -- Asking for a license to receive software updates is not acceptable for WordPress.org-hosted plugins, since updates are expected to be served through WordPress.org. diff --git a/tests/phpunit/testdata/plugins/test-plugin-trialware-errors/load.php b/tests/phpunit/testdata/plugins/test-plugin-trialware-errors/load.php deleted file mode 100644 index 3dd01db2a..000000000 --- a/tests/phpunit/testdata/plugins/test-plugin-trialware-errors/load.php +++ /dev/null @@ -1,32 +0,0 @@ - 'exported' ); -} - -function test_plugin_trialware_can_add_item( $items ) { - if ( count( $items ) >= 3 ) { - wp_die( 'Limit reached. Upgrade to add unlimited items.' ); - } - - return true; -} diff --git a/tests/phpunit/testdata/plugins/test-plugin-trialware-without-errors/load.php b/tests/phpunit/testdata/plugins/test-plugin-trialware-without-errors/load.php deleted file mode 100644 index 4260ae496..000000000 --- a/tests/phpunit/testdata/plugins/test-plugin-trialware-without-errors/load.php +++ /dev/null @@ -1,19 +0,0 @@ - 'exported' ); -} diff --git a/tests/phpunit/tests/Checker/Checks/Trialware_Check_Tests.php b/tests/phpunit/tests/Checker/Checks/Trialware_Check_Tests.php deleted file mode 100644 index 04f563b1e..000000000 --- a/tests/phpunit/tests/Checker/Checks/Trialware_Check_Tests.php +++ /dev/null @@ -1,35 +0,0 @@ -run( $check_result ); - - $errors = $check_result->get_errors(); - - $this->assertNotEmpty( $errors ); - $this->assertArrayHasKey( 'load.php', $errors ); - $this->assertGreaterThanOrEqual( 2, $check_result->get_error_count() ); - $this->assertCount( 1, wp_list_filter( $errors['load.php'][17][3], array( 'code' => 'trialware_locked_feature_candidate' ) ) ); - $this->assertSame( 5, $errors['load.php'][17][3][0]['severity'] ); - } - - public function test_run_without_locked_feature_errors() { - $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-trialware-without-errors/load.php' ); - $check_result = new Check_Result( $check_context ); - - $check = new Trialware_Check(); - $check->run( $check_result ); - - $this->assertEmpty( $check_result->get_errors() ); - $this->assertSame( 0, $check_result->get_error_count() ); - } -}