diff --git a/.typos.toml b/.typos.toml index 64ba3ac11..54fa41a65 100644 --- a/.typos.toml +++ b/.typos.toml @@ -1,15 +1,38 @@ [default] extend-ignore-re = [ - "avail_post_stati", - "ba", - "MAGPIE_INITALIZED", - "setted_site_transient", - "setted_transient", - "stati", - "wheres", + # Ignore TLD names file - contains legitimate domain extensions that look like typos ] [files] +# Ignore spell checking in TLD names file as it contains legitimate domain extensions extend-exclude = [ - "includes/Lib/Readme/*.php" + "includes/Traits/TLD_Names.php" ] + +[default.extend-words] +# Brand names and legitimate TLDs +chanel = "chanel" +marshalls = "marshalls" +immobilien = "immobilien" +lolipop = "lolipop" +onl = "onl" +passagens = "passagens" + +# WordPress core function names (intentional naming) +stati = "stati" # WordPress function: get_post_stati +wheres = "wheres" # WordPress variable: $wheres + +# WordPress core action/hook names (must match exactly) +setted = "setted" # WordPress hooks: setted_site_transient, setted_transient + +# WordPress core constants (legacy naming) +INITALIZED = "INITALIZED" # WordPress constant: MAGPIE_INITALIZED + +# Third-party library names +ba = "ba" # jQuery plugin: jquery.ba-serializeobject + +# Creative Commons license abbreviations +ND = "ND" # CC BY-ND (No Derivatives) + +# Parser checks for invalid licenses (intentionally includes common misspellings) +Proprietery = "Proprietery" # Intentional check in Parser.php for common license typo diff --git a/composer.json b/composer.json index 4b4e78d96..c14311be8 100644 --- a/composer.json +++ b/composer.json @@ -56,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/includes/Checker/Checks/Plugin_Repo/Plugin_Readme_Check.php b/includes/Checker/Checks/Plugin_Repo/Plugin_Readme_Check.php index 39f179e1f..55c3f6a20 100644 --- a/includes/Checker/Checks/Plugin_Repo/Plugin_Readme_Check.php +++ b/includes/Checker/Checks/Plugin_Repo/Plugin_Readme_Check.php @@ -12,10 +12,12 @@ use WordPress\Plugin_Check\Checker\Checks\Abstract_File_Check; use WordPress\Plugin_Check\Lib\Readme\Parser as PCPParser; use WordPress\Plugin_Check\Traits\Amend_Check_Result; +use WordPress\Plugin_Check\Traits\External_Utils; use WordPress\Plugin_Check\Traits\Find_Readme; use WordPress\Plugin_Check\Traits\Language_Utils; use WordPress\Plugin_Check\Traits\License_Utils; use WordPress\Plugin_Check\Traits\Stable_Check; +use WordPress\Plugin_Check\Traits\TLD_Names; use WordPress\Plugin_Check\Traits\URL_Utils; use WordPress\Plugin_Check\Traits\Version_Utils; use WordPressdotorg\Plugin_Directory\Readme\Parser as DotorgParser; @@ -32,6 +34,8 @@ class Plugin_Readme_Check extends Abstract_File_Check { use Amend_Check_Result; use Find_Readme; + use TLD_Names; + use External_Utils; use Stable_Check; use License_Utils; use URL_Utils; @@ -118,6 +122,9 @@ protected function check_files( Check_Result $result, array $files ) { // Check the readme file for contributors. $this->check_for_contributors( $result, $readme_file ); + // Check for third parties privacy notes. + $this->check_for_privacy_notes( $result, $readme_file, $parser, $files ); + // Check the readme file for requires headers. $this->check_requires_headers( $result, $readme_file, $parser ); @@ -849,6 +856,87 @@ function ( $value ) { } /** + * Checks the readme file for external privacy notes. + * + * @since 1.4.0 + * + * @param Check_Result $result The Check Result to amend. + * @param string $readme_file Readme file. + * @param DotorgParser|PCPParser $parser The Parser object. + * @param array $files Array of plugin files. + */ + private function check_for_privacy_notes( Check_Result $result, string $readme_file, $parser, array $files ) { + $existing_tld_names = $this->get_tld_names(); + + // Load domains mentioned in readme. + $this->domains_mentioned_readme = $this->load_domains_mentioned_in_readme( $readme_file, $existing_tld_names ); + + // Filter files to check (PHP, JS, CSS). + $files_ext = self::filter_files_by_extensions( $files, array( 'php', 'css', 'js' ) ); + + // Collect all external domains found in the plugin files. + $found_domains = array(); + foreach ( $files_ext as $file ) { + $domains_in_file = $this->find_external_domains_in_file( $file ); + if ( ! empty( $domains_in_file ) ) { + foreach ( $domains_in_file as $domain ) { + $found_domains[ $domain ] = $file; + } + } + } + + // Skip if no external domains found. + if ( empty( $found_domains ) ) { + return; + } + + // Check each found domain. + foreach ( $found_domains as $domain => $file ) { + // Extract base domain using TLD list. + $base_domain = $this->extract_domain_from_host( $domain, $existing_tld_names ); + + // Check if domain is mentioned in readme. + if ( ! $this->is_domain_mentioned_in_readme( $base_domain ) ) { + $this->add_result_error_for_file( + $result, + sprintf( + /* translators: 1: domain, 2: file where found */ + __( 'Undocumented use of a 3rd party or external service.
We permit plugins to require the use of 3rd party (external) services, provided they are properly documented in a clear manner. Please update your readme with documentation for the external service "%1$s" found in file %2$s. In order to do so, you must update your readme to: clearly explain that your plugin is relying on a 3rd party as a service and under what circumstances, provide a link to the service, and provide a link to the service\'s terms of use and/or privacy policies.', 'plugin-check' ), + esc_html( $domain ), + esc_html( basename( $file ) ) + ), + 'undocumented_third_party_service', + $readme_file, + 0, + 0, + 'https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/#8-plugins-may-not-send-executable-code-via-third-party-systems', + 7 + ); + continue; + } + + // Domain is mentioned, check if it's documented with privacy/terms. + if ( ! $this->is_domain_documented_readme( $base_domain ) ) { + $this->add_result_warning_for_file( + $result, + sprintf( + /* translators: %s: domain */ + __( 'Third-party service may not be properly documented.
The external service "%s" is mentioned in your readme, but we could not find clear links to privacy policy or terms of service. Please ensure you provide links to the service\'s terms of use and/or privacy policies.', 'plugin-check' ), + esc_html( $domain ) + ), + 'incomplete_third_party_service_documentation', + $readme_file, + 0, + 0, + 'https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/#8-plugins-may-not-send-executable-code-via-third-party-systems', + 6 + ); + } + } + } + + /** + * Returns current major WordPress version. * Checks the readme file for requires headers. * * @since 1.5.0 diff --git a/includes/Lib/Readme/Parser.php b/includes/Lib/Readme/Parser.php index b81e583d7..091662ab5 100644 --- a/includes/Lib/Readme/Parser.php +++ b/includes/Lib/Readme/Parser.php @@ -88,7 +88,7 @@ class Parser { public $faq = array(); /** - * Warning flags which indicate specific parsing failures have occured. + * Warning flags which indicate specific parsing failures have occurred. * * @var array */ @@ -830,7 +830,7 @@ protected function sanitize_requires_version( $version ) { if ( // x.y or x.y.z ! preg_match( '!^\d+\.\d(\.\d+)?$!', $version ) || - // Allow plugins to mark themselves as requireing Stable+0.1 (trunk/master) but not higher + // Allow plugins to mark themselves as requiring Stable+0.1 (trunk/master) but not higher defined( 'WP_CORE_STABLE_BRANCH' ) && ( (float) $version > (float) WP_CORE_STABLE_BRANCH + 0.1 ) ) { $this->warnings['requires_header_ignored'] = true; @@ -945,7 +945,7 @@ public function validate_license( $license ) { $probably_compatible = array( 'GPL', 'General Public License', - // 'GNU 2', 'GNU Public', 'GNU Version 2' explicitely not included, as it's not a specific license. + // 'GNU 2', 'GNU Public', 'GNU Version 2' explicitly not included, as it's not a specific license. 'MIT', 'ISC', 'Expat', diff --git a/includes/Traits/External_Utils.php b/includes/Traits/External_Utils.php new file mode 100644 index 000000000..1e236aebf --- /dev/null +++ b/includes/Traits/External_Utils.php @@ -0,0 +1,559 @@ +extract_urls_from_readme_lines( $lines ); + if ( empty( $urls ) ) { + return $domains_mentioned; + } + + $domains_mentioned = $this->process_urls_for_domains( $urls, $existing_tld_names ); + $domains_mentioned = $this->cleanup_domain_urls( $domains_mentioned ); + + return $domains_mentioned; + } + + /** + * Extract URLs from readme file lines. + * + * @since 1.8.0 + * + * @param array $lines Lines from readme file. + * @return array Array of unique URLs. + */ + private function extract_urls_from_readme_lines( $lines ) { + $urls = array(); + foreach ( $lines as $line ) { + preg_match_all( '/@?(https?:\/\/)?(www\.)?[-a-zA-Z0-9:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9(:%_\+~#?&\/=]*)/', $line, $result ); + foreach ( $result[0] as $url ) { + $url = strtolower( $url ); + // Remove domains in email addresses. + if ( ! str_starts_with( $url, '@' ) ) { + // Add protocol if domain taken without protocol. + if ( ! str_starts_with( $url, 'http' ) ) { + $url = 'http://' . $url; + } + $urls[] = $url; + } + } + } + return array_unique( $urls ); + } + + /** + * Process URLs to extract domains. + * + * @since 1.8.0 + * + * @param array $urls Array of URLs. + * @param array $existing_tld_names Array of existing TLD names. + * @return array Array of domain information. + */ + private function process_urls_for_domains( $urls, $existing_tld_names ) { + $domains_mentioned = array(); + + foreach ( $urls as $url ) { + $parsed_url = parse_url( $url ); + if ( false === $parsed_url || empty( $parsed_url['host'] ) ) { + continue; + } + + $path = ! empty( $parsed_url['path'] ) ? $parsed_url['path'] : ''; + $this->extract_domain_from_url( $url, $parsed_url['host'], $path, $existing_tld_names, $domains_mentioned ); + } + + return $domains_mentioned; + } + + /** + * Extract domain from URL and add to domains array. + * + * @since 1.8.0 + * + * @param string $url The URL. + * @param string $host The host from parsed URL. + * @param string $path The path from parsed URL. + * @param array $existing_tld_names Array of existing TLD names. + * @param array &$domains_mentioned Reference to domains array to update. + */ + private function extract_domain_from_url( $url, $host, $path, $existing_tld_names, &$domains_mentioned ) { + preg_match_all( '/(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]/', $url, $result ); + + foreach ( $result[0] as $domain ) { + $domain = strtolower( $domain ); + + if ( $this->is_invalid_domain( $domain ) ) { + continue; + } + + $domain_tld = $this->get_longest_matching_tld( $host, $existing_tld_names ); + if ( empty( $domain_tld ) ) { + continue; + } + + $domain = $this->extract_root_domain( $host, $domain_tld ); + $this->add_or_update_domain( $domain, $url, $path, $domains_mentioned ); + } + } + + /** + * Check if domain is invalid (numeric TLD or file extension). + * + * @since 1.8.0 + * + * @param string $domain Domain to check. + * @return bool True if invalid, false otherwise. + */ + private function is_invalid_domain( $domain ) { + $typical_off_loading_extensions = array( 'css', 'svg', 'jpg', 'jpeg', 'gif', 'png', 'webm', 'mp4', 'mpg', 'mpeg', 'mp3' ); + $domain_elements = explode( '.', $domain ); + $tld = end( $domain_elements ); + + // Invalid TLD, numeric, looks like detected a version. + if ( $tld === (int) $tld ) { + return true; + } + + // Invalid, looks like detected a file. + return in_array( + $tld, + array_merge( $typical_off_loading_extensions, array( 'php', 'html', 'zip' ) ), + true + ); + } + + /** + * Get the longest matching TLD from host. + * + * @since 1.8.0 + * + * @param string $host The host. + * @param array $existing_tld_names Array of existing TLD names. + * @return string The longest matching TLD or empty string. + */ + private function get_longest_matching_tld( $host, $existing_tld_names ) { + $domain_tld = ''; + foreach ( $existing_tld_names as $tld ) { + if ( str_ends_with( $host, $tld ) && strlen( $tld ) > strlen( $domain_tld ) ) { + $domain_tld = $tld; + } + } + return $domain_tld; + } + + /** + * Extract root domain from host and TLD. + * + * @since 1.8.0 + * + * @param string $host The host. + * @param string $domain_tld The TLD. + * @return string The root domain. + */ + private function extract_root_domain( $host, $domain_tld ) { + $domain = str_replace( '.' . $domain_tld, '', $host ); + $parts = explode( '.', $domain ); + return end( $parts ) . '.' . $domain_tld; + } + + /** + * Add or update domain in domains array. + * + * @since 1.8.0 + * + * @param string $domain The domain. + * @param string $url The URL. + * @param string $path The path. + * @param array &$domains_mentioned Reference to domains array to update. + */ + private function add_or_update_domain( $domain, $url, $path, &$domains_mentioned ) { + $key = $this->get_key_domain_mentioned_in_readme( $domain ); + + if ( false !== $key ) { + // Domain exists, add URL and path. + $domains_mentioned[ $key ]['urls'][] = $url; + if ( ! empty( $path ) ) { + $domains_mentioned[ $key ]['paths'][] = $path; + } + } else { + // Create new domain entry. + $domain_mentioned = array( + 'domains' => $this->add_domains_of_same_service( $domain ), + 'urls' => array( $url ), + 'paths' => ! empty( $path ) ? array( $path ) : array(), + ); + $domains_mentioned[] = $domain_mentioned; + } + } + + /** + * Cleanup domain URLs by removing duplicates. + * + * @since 1.8.0 + * + * @param array $domains_mentioned Array of domain information. + * @return array Cleaned array. + */ + private function cleanup_domain_urls( $domains_mentioned ) { + if ( empty( $domains_mentioned ) ) { + return $domains_mentioned; + } + + return array_map( + function ( $domain ) { + $domain['urls'] = array_unique( $domain['urls'] ); + return $domain; + }, + $domains_mentioned + ); + } + + /** + * Get key domain mentioned in readme file. + * + * @since 1.4.0 + * + * @param string $domain_search Domain search string. + * @return string|int|bool Key of domain mentioned in readme file, or false if not found. + */ + protected function get_key_domain_mentioned_in_readme( $domain_search ) { + if ( ! empty( $this->domains_mentioned_readme ) ) { + foreach ( $this->domains_mentioned_readme as $key => $domains ) { + if ( ! empty( $domains['domains'] ) ) { + foreach ( $domains['domains'] as $domain ) { + if ( str_contains( $domain_search, $domain ) || str_contains( $domain, $domain_search ) ) { + return $key; + } + } + } + } + } + + return false; + } + + /** + * Add domains of the same service. + * + * @since 1.4.0 + * + * @param string $domain Domain. + * @return array An array containing domains of the same service. + */ + protected function add_domains_of_same_service( $domain ) { + $domains = array( $domain ); + $domains_of_the_same_service = array( + 'paypal.com' => array( 'paypal.com', 'paypalobjects.com' ), + 'google.com' => array( 'google.com', 'googleapis.com', 'googletagmanager.com' ), + 'microsoft.com' => array( 'microsoft.com', 'outlook.com', 'live.com' ), + 'atlassian.net' => array( 'atlassian.com', 'trello.com' ), + 'dropbox.com' => array( 'dropbox.com', 'dropboxapi.com' ), + 'tiktok.com' => array( 'tiktok.com', 'tiktokapis.com' ), + 'zendesk.com' => array( 'zendesk.com', 'zdassets.com' ), + ); + foreach ( $domains_of_the_same_service as $key => $service ) { + foreach ( $service as $service_domain ) { + if ( $service_domain === $domain ) { + $domains = array_merge( $domains, $domains_of_the_same_service[ $key ] ); + $domains = array_unique( $domains ); + } + } + } + + return $domains; + } + + /** + * Check if domain is mentioned in readme file. + * + * @since 1.4.0 + * + * @param string $domain Domain. + * @return bool True if domain is mentioned in readme file, false otherwise. + */ + protected function is_domain_mentioned_in_readme( $domain ) { + $key = $this->get_key_domain_mentioned_in_readme( $domain ); + if ( false !== $key ) { + return true; + } + + return false; + } + + /** + * Check if domain is documented in readme file. + * + * @since 1.4.0 + * + * @param string $domain Domain. + * @return bool True if domain is documented in readme file, false otherwise. + */ + protected function is_domain_documented_readme( $domain ) { + $key = $this->get_key_domain_mentioned_in_readme( $domain ); + $privacy = false; + $terms = false; + + if ( ! empty( $this->domains_mentioned_readme[ $key ]['paths'] ) ) { + foreach ( $this->domains_mentioned_readme[ $key ]['paths'] as $path ) { + foreach ( $this->privacy_common_uris_paths as $privacy_str ) { + if ( str_contains( $path, $privacy_str ) ) { + $privacy = $path; + break; + } + } + foreach ( $this->terms_common_uris_paths as $terms_str ) { + if ( str_contains( $path, $terms_str ) ) { + $terms = $path; + break; + } + } + } + } + + if ( $privacy || $terms ) { // To lower down false positives while keeping the check we are ok to have just one of them. + return true; + } + + return false; + } + + /** + * Common privacy URI paths. + * + * @since 1.4.0 + * + * @var array + */ + private $privacy_common_uris_paths = array( 'privacy', 'legal' ); + + /** + * Common terms URI paths. + * + * @since 1.4.0 + * + * @var array + */ + private $terms_common_uris_paths = array( 'terms', 'tos', 'conditions', 'legal' ); + + /** + * Domains mentioned in readme. + * + * @since 1.4.0 + * + * @var array + */ + private $domains_mentioned_readme = array(); + + /** + * Find external domains in a file. + * + * @since 1.4.0 + * + * @param string $file File path. + * @return array Array of domains found in the file. + */ + protected function find_external_domains_in_file( $file ) { + $domains = array(); + + // Skip if file doesn't exist or is not readable. + if ( ! file_exists( $file ) || ! is_readable( $file ) ) { + return $domains; + } + + $content = file_get_contents( $file ); + if ( false === $content ) { + return $domains; + } + + // Skip plugin header section in PHP files to avoid flagging metadata URLs. + $extension = pathinfo( $file, PATHINFO_EXTENSION ); + if ( 'php' === $extension ) { + // Remove the plugin header block (first DocBlock in the file). + $content = preg_replace( '#^<\?php\s*/\*\*.*?\*/\s*#s', '', $content, 1 ); + } + + // Pattern to match URLs in function calls that indicate actual service usage. + $service_patterns = array( + // Remote HTTP functions. + '#(?:wp_remote_get|wp_remote_post|wp_remote_request|wp_safe_remote_get|wp_safe_remote_post|wp_safe_remote_request|file_get_contents|fopen|curl_init)\s*\(\s*["\'](?:https?:)?//([a-zA-Z0-9][-a-zA-Z0-9]*(?:\.[a-zA-Z0-9][-a-zA-Z0-9]*)+)[^"\']*["\']#i', + // Enqueue functions. + '#(?:wp_enqueue_script|wp_register_script|wp_enqueue_style|wp_register_style)\s*\([^,]+,\s*["\'](?:https?:)?//([a-zA-Z0-9][-a-zA-Z0-9]*(?:\.[a-zA-Z0-9][-a-zA-Z0-9]*)+)[^"\']*["\']#i', + // JavaScript fetch. + '#fetch\s*\(\s*["\'](?:https?:)?//([a-zA-Z0-9][-a-zA-Z0-9]*(?:\.[a-zA-Z0-9][-a-zA-Z0-9]*)+)[^"\']*["\']#i', + ); + + foreach ( $service_patterns as $pattern ) { + if ( preg_match_all( $pattern, $content, $matches ) ) { + foreach ( $matches[1] as $domain ) { + $domain = strtolower( trim( $domain ) ); + + // Skip common WordPress and localhost domains. + if ( $this->is_common_wordpress_domain( $domain ) ) { + continue; + } + + if ( $this->is_localhost_domain( $domain ) ) { + continue; + } + + if ( $this->is_known_safe_domain( $domain ) ) { + continue; + } + + $domains[] = $domain; + } + } + } + + return array_unique( $domains ); + } + + /** + * Check if domain is a common WordPress domain. + * + * @since 1.4.0 + * + * @param string $domain Domain to check. + * @return bool True if it's a common WordPress domain, false otherwise. + */ + private function is_common_wordpress_domain( $domain ) { + $wordpress_domains = array( + 'wordpress.org', + 'w.org', + 'wordpress.com', + 'gravatar.com', + 'wp.com', + ); + + foreach ( $wordpress_domains as $wp_domain ) { + if ( str_contains( $domain, $wp_domain ) ) { + return true; + } + } + + return false; + } + + /** + * Check if domain is a common/known safe domain that should be ignored. + * + * @since 1.4.0 + * + * @param string $domain Domain to check. + * @return bool True if it's a known safe domain, false otherwise. + */ + private function is_known_safe_domain( $domain ) { + $safe_domains = array( + 'github.com', + 'gitlab.com', + 'bitbucket.org', + 'gnu.org', // GNU licenses. + 'opensource.org', // OSI licenses. + 'creativecommons.org', + 'fsf.org', // Free Software Foundation. + ); + + foreach ( $safe_domains as $safe_domain ) { + if ( str_contains( $domain, $safe_domain ) ) { + return true; + } + } + + return false; + } + + /** + * Check if domain is a localhost/staging domain. + * + * @since 1.4.0 + * + * @param string $domain Domain to check. + * @return bool True if it's a localhost domain, false otherwise. + */ + private function is_localhost_domain( $domain ) { + $patterns = array( + 'localhost', + '127.0.0.1', + 'example.com', + 'example.org', + '.local', + '.test', + '.localhost', + ); + + foreach ( $patterns as $pattern ) { + if ( str_contains( $domain, $pattern ) ) { + return true; + } + } + + return false; + } + + /** + * Extract domain from URL or hostname. + * + * @since 1.4.0 + * + * @param string $host Hostname to extract domain from. + * @param array $existing_tld_names Array of TLD names. + * @return string Extracted domain. + */ + private function extract_domain_from_host( $host, $existing_tld_names ) { + $host = strtolower( $host ); + $domain_tld = ''; + + // Get domain biggest TLD. + foreach ( $existing_tld_names as $tld ) { + if ( str_ends_with( $host, $tld ) ) { + if ( strlen( $tld ) > strlen( $domain_tld ) ) { + $domain_tld = $tld; + } + } + } + + if ( empty( $domain_tld ) ) { + // Fallback: assume last two parts are the domain. + $parts = explode( '.', $host ); + if ( count( $parts ) >= 2 ) { + return $parts[ count( $parts ) - 2 ] . '.' . $parts[ count( $parts ) - 1 ]; + } + return $host; + } + + // Get domain from host and TLD. + $domain = str_replace( '.' . $domain_tld, '', $host ); + $parts = explode( '.', $domain ); + $domain = end( $parts ) . '.' . $domain_tld; + + return $domain; + } +} diff --git a/includes/Traits/TLD_Names.php b/includes/Traits/TLD_Names.php new file mode 100644 index 000000000..25ad0d7ee --- /dev/null +++ b/includes/Traits/TLD_Names.php @@ -0,0 +1,9202 @@ +assertCount( 1, wp_list_filter( $errors['readme.txt'][0][0], array( 'code' => 'readme_invalid_donate_link_domain' ) ) ); } + public function test_run_with_undocumented_third_party_services() { + $check = new Plugin_Readme_Check(); + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-third-party-undocumented/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check->run( $check_result ); + + $errors = $check_result->get_errors(); + + $this->assertNotEmpty( $errors ); + $this->assertArrayHasKey( 'readme.txt', $errors ); + + // Check for undocumented third-party service errors. + $undocumented_errors = wp_list_filter( $errors['readme.txt'][0][0], array( 'code' => 'undocumented_third_party_service' ) ); + $this->assertNotEmpty( $undocumented_errors, 'Should detect undocumented third-party services' ); + } + + public function test_run_with_documented_third_party_services() { + $check = new Plugin_Readme_Check(); + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-third-party-documented/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check->run( $check_result ); + + $errors = $check_result->get_errors(); + + // Check that short description error exists. + $this->assertNotEmpty( $errors ); + $this->assertArrayHasKey( 'readme.txt', $errors ); + $this->assertCount( 1, wp_list_filter( $errors['readme.txt'][0][0], array( 'code' => 'readme_short_description_non_official_language' ) ) ); + + // Check that description error exists. + $this->assertCount( 1, wp_list_filter( $errors['readme.txt'][0][0], array( 'code' => 'readme_description_non_official_language' ) ) ); + } + public function test_run_language_detection_with_non_english_content() { $check = new Plugin_Readme_Check(); $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-plugin-readme-errors-language/load.php' );