From 5e5a130cbfd17e56e34d3c431b295dda527aebd6 Mon Sep 17 00:00:00 2001 From: Albert Suntic Date: Thu, 4 Jun 2026 13:20:04 +0200 Subject: [PATCH] Add AI Provider check to recommend the WordPress AI Client Adds a new static check (ai_provider) that warns when a plugin integrates directly with a third-party AI provider API (OpenAI, Anthropic, Google Gemini, Grok, Mistral, Cohere, Groq, Perplexity, DeepSeek, OpenRouter) instead of using the WordPress AI Client and Connectors infrastructure introduced in WordPress 7.0. Detection is implemented as a tokenized PHPCS sniff (PluginCheck.CodeAnalysis.AIProvider) that only inspects string literals and requires an explicit http(s) scheme before a known provider host, so mentions in comments, docblocks or unrelated URLs are not flagged. The check reports a warning (not an error), matching the recommendation-only intent. Includes the sniff, the AI_Provider_Check class registered under the general category, sniff unit tests with positive and negative cases, a check-level PHPUnit test with test data, and a docs/checks.md entry. Closes #1341 --- docs/checks.md | 1 + .../Checks/General/AI_Provider_Check.php | 88 +++++++++++++ includes/Checker/Default_Check_Repository.php | 1 + .../Sniffs/CodeAnalysis/AIProviderSniff.php | 116 ++++++++++++++++++ .../Tests/CodeAnalysis/AIProviderUnitTest.inc | 75 +++++++++++ .../Tests/CodeAnalysis/AIProviderUnitTest.php | 70 +++++++++++ phpcs-sniffs/PluginCheck/ruleset.xml | 1 + .../load.php | 32 +++++ .../Checker/Checks/AI_Provider_Check_Test.php | 42 +++++++ 9 files changed, 426 insertions(+) create mode 100644 includes/Checker/Checks/General/AI_Provider_Check.php create mode 100644 phpcs-sniffs/PluginCheck/Sniffs/CodeAnalysis/AIProviderSniff.php create mode 100644 phpcs-sniffs/PluginCheck/Tests/CodeAnalysis/AIProviderUnitTest.inc create mode 100644 phpcs-sniffs/PluginCheck/Tests/CodeAnalysis/AIProviderUnitTest.php create mode 100644 tests/phpunit/testdata/plugins/test-plugin-ai-provider-check-with-errors/load.php create mode 100644 tests/phpunit/tests/Checker/Checks/AI_Provider_Check_Test.php diff --git a/docs/checks.md b/docs/checks.md index d52ba1f90..f7ffd0e72 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -36,3 +36,4 @@ | enqueued_styles_scope | performance | Checks whether any stylesheets are loaded on all pages, which is usually not desirable and can lead to performance issues. | [Learn more](https://developer.wordpress.org/plugins/) | | enqueued_scripts_scope | performance | Checks whether any scripts are loaded on all pages, which is usually not desirable and can lead to performance issues. | [Learn more](https://developer.wordpress.org/plugins/) | | non_blocking_scripts | performance | Checks whether scripts and styles are enqueued using a recommended loading strategy. | [Learn more](https://developer.wordpress.org/plugins/) | +| ai_provider | general | Recommends the WordPress AI Client when a plugin integrates directly with a third-party AI provider. | [Learn more](https://developer.wordpress.org/plugins/) | diff --git a/includes/Checker/Checks/General/AI_Provider_Check.php b/includes/Checker/Checks/General/AI_Provider_Check.php new file mode 100644 index 000000000..5fee28e47 --- /dev/null +++ b/includes/Checker/Checks/General/AI_Provider_Check.php @@ -0,0 +1,88 @@ + 'php', + 'standard' => 'PluginCheck', + 'sniffs' => 'PluginCheck.CodeAnalysis.AIProvider', + ); + } + + /** + * Gets the description for the check. + * + * Every check must have a short description explaining what the check does. + * + * @since 2.1.0 + * + * @return string Description. + */ + public function get_description(): string { + return __( 'Recommends the WordPress AI Client when a plugin integrates directly with a third-party AI provider.', 'plugin-check' ); + } + + /** + * Gets the documentation URL for the check. + * + * Every check must have a URL with further information about the check. + * + * @since 2.1.0 + * + * @return string The documentation URL. + */ + public function get_documentation_url(): string { + return __( 'https://developer.wordpress.org/plugins/', 'plugin-check' ); + } +} diff --git a/includes/Checker/Default_Check_Repository.php b/includes/Checker/Default_Check_Repository.php index c1b7d420c..d0f0e55ec 100644 --- a/includes/Checker/Default_Check_Repository.php +++ b/includes/Checker/Default_Check_Repository.php @@ -103,6 +103,7 @@ private function register_default_checks() { 'direct_file_access' => new Checks\Plugin_Repo\Direct_File_Access_Check(), 'external_admin_menu_links' => new Checks\Plugin_Repo\External_Admin_Menu_Links_Check(), 'wp_functions_compatibility' => new Checks\Plugin_Repo\WP_Functions_Compatibility_Check(), + 'ai_provider' => new Checks\General\AI_Provider_Check(), ) ); diff --git a/phpcs-sniffs/PluginCheck/Sniffs/CodeAnalysis/AIProviderSniff.php b/phpcs-sniffs/PluginCheck/Sniffs/CodeAnalysis/AIProviderSniff.php new file mode 100644 index 000000000..9792c11f6 --- /dev/null +++ b/phpcs-sniffs/PluginCheck/Sniffs/CodeAnalysis/AIProviderSniff.php @@ -0,0 +1,116 @@ + + */ + protected $ai_provider_hosts = array( + 'api.openai.com', + 'api.anthropic.com', + 'generativelanguage.googleapis.com', + 'api.x.ai', + 'api.mistral.ai', + 'api.cohere.ai', + 'api.cohere.com', + 'api.groq.com', + 'api.perplexity.ai', + 'api.deepseek.com', + 'openrouter.ai', + ); + + /** + * Compiled regex pattern for detecting AI provider hosts. + * + * @since 2.1.0 + * + * @var string|null + */ + private $pattern = null; + + /** + * Returns an array of tokens this test wants to listen for. + * + * Only string literals are inspected; mentions inside comments or docblocks + * are intentionally ignored, as they do not represent a direct integration. + * + * @since 2.1.0 + * + * @return array + */ + public function register() { + return array( + T_CONSTANT_ENCAPSED_STRING, + T_DOUBLE_QUOTED_STRING, + T_HEREDOC, + T_NOWDOC, + ); + } + + /** + * Processes this test, when one of its tokens is encountered. + * + * @since 2.1.0 + * + * @param int $stackPtr The position of the current token in the stack. + * @return void + */ + public function process_token( $stackPtr ) { + $content = $this->tokens[ $stackPtr ]['content']; + $token_code = $this->tokens[ $stackPtr ]['code']; + + // Heredoc/nowdoc bodies are used as-is; quoted strings have their quotes removed. + if ( T_HEREDOC === $token_code || T_NOWDOC === $token_code ) { + $string_content = $content; + } else { + $string_content = TextStrings::stripQuotes( $content ); + } + + // Compile the regex pattern on first use. + if ( null === $this->pattern ) { + $escaped_hosts = array_map( + 'preg_quote', + $this->ai_provider_hosts, + array_fill( 0, count( $this->ai_provider_hosts ), '/' ) + ); + + // Require an explicit scheme directly before the host to avoid matching + // unrelated text and to target actual request URLs. + $this->pattern = '/https?:\/\/(' . implode( '|', $escaped_hosts ) . ')\b/i'; + } + + if ( preg_match( $this->pattern, $string_content, $matches ) ) { + $error = 'Plugin appears to integrate directly with a third-party AI provider (%s). Since WordPress 7.0, consider using the WordPress AI Client and Connectors infrastructure (wp_ai_client_prompt()) where it fits your use case, so the site owner can configure their preferred provider once without the plugin managing provider credentials directly.'; + $this->phpcsFile->addWarning( $error, $stackPtr, 'DirectIntegration', array( $matches[1] ) ); + } + } +} diff --git a/phpcs-sniffs/PluginCheck/Tests/CodeAnalysis/AIProviderUnitTest.inc b/phpcs-sniffs/PluginCheck/Tests/CodeAnalysis/AIProviderUnitTest.inc new file mode 100644 index 000000000..578cbce90 --- /dev/null +++ b/phpcs-sniffs/PluginCheck/Tests/CodeAnalysis/AIProviderUnitTest.inc @@ -0,0 +1,75 @@ +generate_text(); diff --git a/phpcs-sniffs/PluginCheck/Tests/CodeAnalysis/AIProviderUnitTest.php b/phpcs-sniffs/PluginCheck/Tests/CodeAnalysis/AIProviderUnitTest.php new file mode 100644 index 000000000..ba55689cf --- /dev/null +++ b/phpcs-sniffs/PluginCheck/Tests/CodeAnalysis/AIProviderUnitTest.php @@ -0,0 +1,70 @@ + Key is the line number and value is the number of expected errors. + */ + public function getErrorList() { + return array(); + } + + /** + * Returns the lines where warnings should occur. + * + * @return array Key is the line number and value is the number of expected warnings. + */ + public function getWarningList() { + return array( + 4 => 1, // Case: testOpenAiInSingleQuotedString. + 7 => 1, // Case: testAnthropicInSingleQuotedString. + 10 => 1, // Case: testGeminiInDoubleQuotedString. + 13 => 1, // Case: testGrokInSingleQuotedString. + 16 => 1, // Case: testMistralInSingleQuotedString. + 19 => 1, // Case: testCohereAiInSingleQuotedString. + 22 => 1, // Case: testCohereComInSingleQuotedString. + 25 => 1, // Case: testGroqInSingleQuotedString. + 28 => 1, // Case: testPerplexityInSingleQuotedString. + 31 => 1, // Case: testDeepSeekInSingleQuotedString. + 34 => 1, // Case: testOpenRouterInSingleQuotedString. + 37 => 1, // Case: testHttpSchemeIsMatched. + 41 => 1, // Case: testProviderInHeredoc. + 46 => 1, // Case: testProviderInNowdoc. + ); + } + + /** + * Returns the fully qualified class name (FQCN) of the sniff. + * + * @return string The fully qualified class name of the sniff. + */ + protected function get_sniff_fqcn() { + return AIProviderSniff::class; + } + + /** + * Sets the parameters for the sniff. + * + * @throws \RuntimeException If unable to set the ruleset parameters required for the test. + * + * @param Sniff $sniff The sniff being tested. + */ + public function set_sniff_parameters( Sniff $sniff ) { + } +} diff --git a/phpcs-sniffs/PluginCheck/ruleset.xml b/phpcs-sniffs/PluginCheck/ruleset.xml index 047e63acf..90fb7f308 100644 --- a/phpcs-sniffs/PluginCheck/ruleset.xml +++ b/phpcs-sniffs/PluginCheck/ruleset.xml @@ -3,6 +3,7 @@ Plugin Check Sniffs + diff --git a/tests/phpunit/testdata/plugins/test-plugin-ai-provider-check-with-errors/load.php b/tests/phpunit/testdata/plugins/test-plugin-ai-provider-check-with-errors/load.php new file mode 100644 index 000000000..aa34ffbc2 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-ai-provider-check-with-errors/load.php @@ -0,0 +1,32 @@ + array( 'Content-Type' => 'application/json' ), + 'body' => '{}', + ) +); + +// Another provider host in a double-quoted string (should be flagged). +$endpoint = "https://api.anthropic.com/v1/messages"; + +// A bare host without scheme and an unrelated URL (should NOT be flagged). +$host = 'api.openai.com'; +$unrelated = 'https://example.com/v1/chat/completions'; diff --git a/tests/phpunit/tests/Checker/Checks/AI_Provider_Check_Test.php b/tests/phpunit/tests/Checker/Checks/AI_Provider_Check_Test.php new file mode 100644 index 000000000..0fbf7669e --- /dev/null +++ b/tests/phpunit/tests/Checker/Checks/AI_Provider_Check_Test.php @@ -0,0 +1,42 @@ +run( $check_result ); + + $warnings = $check_result->get_warnings(); + $errors = $check_result->get_errors(); + + $this->assertEmpty( $errors ); + $this->assertNotEmpty( $warnings ); + $this->assertArrayHasKey( 'load.php', $warnings ); + + // Only the two actual provider integrations should be flagged. + $this->assertSame( 2, $check_result->get_warning_count() ); + $this->assertArrayHasKey( 20, $warnings['load.php'] ); + $this->assertArrayHasKey( 28, $warnings['load.php'] ); + + $column = key( $warnings['load.php'][20] ); + $this->assertSame( + 'PluginCheck.CodeAnalysis.AIProvider.DirectIntegration', + $warnings['load.php'][20][ $column ][0]['code'] + ); + } +}