From 9f16af8c7d4c7ecec36e5e3bd33de5f045152a09 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 d1e5740e4..d4d5c548d 100644 --- a/includes/Checker/Default_Check_Repository.php +++ b/includes/Checker/Default_Check_Repository.php @@ -104,6 +104,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..4b5d608cb --- /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 = 'Direct integration with a third-party AI provider (%s) detected. Consider the WordPress AI Client (wp_ai_client_prompt()) introduced in WordPress 7.0.'; + $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 205d4f4df..3c5b50d2d 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'] + ); + } +}