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'] + ); + } +}