*/ private $component_source; private ComponentEvidenceSanitizer $sanitizer; private string $wordpress_version; private string $php_version; /** * @param callable():array|null $component_source Testable local component-header adapter. */ public function __construct( FindingFactory $findings, ?callable $component_source = null, ?string $hash_key = null, ?string $wordpress_version = null, ?string $php_version = null ) { $this->findings = $findings; $this->component_source = $component_source ?? array( $this, 'wordpress_components' ); $this->sanitizer = new ComponentEvidenceSanitizer( $hash_key ?? ( new LocalHashKey() )->derive() ); $this->wordpress_version = $wordpress_version ?? get_bloginfo( 'version' ); $this->php_version = $php_version ?? PHP_VERSION; } public function id(): string { return 'components.compatibility'; } /** * @return array */ public function execute( string $timestamp ): array { try { $components = call_user_func( $this->component_source ); } catch ( Throwable $exception ) { return $this->error_finding( $timestamp ); } if ( ! is_array( $components ) ) { return $this->error_finding( $timestamp ); } $total_components = count( $components ); $truncated = $total_components > self::MAX_COMPONENTS; $components = array_slice( $components, 0, self::MAX_COMPONENTS ); $objects = array(); $observed_headers = 0; $active_failures = 0; $inactive_failures = 0; foreach ( $components as $component ) { if ( ! is_array( $component ) || ! isset( $component['type'], $component['identifier'], $component['name'], $component['version'], $component['state'], $component['requires_wp'], $component['requires_php'] ) || ! is_string( $component['type'] ) || ! is_string( $component['identifier'] ) || ! is_string( $component['name'] ) || ! is_string( $component['version'] ) || ! is_string( $component['state'] ) || ! is_string( $component['requires_wp'] ) || ! is_string( $component['requires_php'] ) || '' === $component['identifier'] ) { continue; } if ( ! in_array( $component['type'], self::TYPES, true ) || ! in_array( $component['state'], self::STATES, true ) ) { continue; } $requires_wp = $this->declared_version( $component['requires_wp'] ); $requires_php = $this->declared_version( $component['requires_php'] ); $wp_state = $this->state( $this->wordpress_version, $requires_wp ); $php_state = $this->state( $this->php_version, $requires_php ); $observed_headers += 'not_declared' === $wp_state ? 0 : 1; $observed_headers += 'not_declared' === $php_state ? 0 : 1; $incompatible = 'incompatible' === $wp_state || 'incompatible' === $php_state; if ( $incompatible && in_array( $component['state'], self::ACTIVE_STATES, true ) ) { ++$active_failures; } elseif ( $incompatible ) { ++$inactive_failures; } $objects[] = array( 'type' => $component['type'], 'reference' => $this->sanitizer->reference( $component['identifier'] ), 'name' => $this->sanitizer->text( $component['name'] ), 'version' => $this->sanitizer->version( $component['version'] ), 'state' => $component['state'], 'requires_wordpress' => $requires_wp, 'requires_php' => $requires_php, 'wordpress_state' => $wp_state, 'php_state' => $php_state, ); } $total_headers = $total_components * 2; if ( 0 === count( $objects ) || 0 === $observed_headers ) { $status = 'not_tested'; $coverage = 'none'; } elseif ( $active_failures > 0 ) { $status = 'fail'; $coverage = $observed_headers === $total_headers ? 'complete' : 'partial'; } elseif ( $inactive_failures > 0 ) { $status = 'warning'; $coverage = $observed_headers === $total_headers ? 'complete' : 'partial'; } elseif ( $truncated || $observed_headers < $total_headers ) { $status = 'not_tested'; $coverage = 'partial'; } else { $status = 'pass'; $coverage = 'complete'; } return $this->findings->build( $this->id(), $status, array( 'objects' => $objects, 'observed_count' => $observed_headers, 'total_count' => $total_headers, 'truncated' => $truncated, 'limitations' => array( 'Only local Requires at least and Requires PHP headers were compared. Missing headers remain not tested; compatibility was not exercised.' ), ), $observed_headers, $total_headers, $coverage, $timestamp ); } /** * @return array */ private function wordpress_components(): array { if ( ! function_exists( 'get_plugins' ) ) { $plugin_api = ( new WordPressPathProvider() )->core_file( 'wp-admin/includes/plugin.php' ); require_once $plugin_api; } $components = array(); $active_plugins = array_map( 'strval', (array) get_option( 'active_plugins', array() ) ); $network_active = is_multisite() ? array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) : array(); foreach ( get_plugins() as $identifier => $headers ) { $state = in_array( $identifier, $network_active, true ) ? 'network_active' : ( in_array( $identifier, $active_plugins, true ) ? 'active' : 'inactive' ); $components[] = $this->headers( 'plugin', (string) $identifier, $headers, $state ); } foreach ( get_mu_plugins() as $identifier => $headers ) { $components[] = $this->headers( 'mu_plugin', (string) $identifier, $headers, 'must_use' ); } foreach ( ( new LocalThemeAdapter() )->themes() as $theme ) { $components[] = array( 'type' => 'theme', 'identifier' => $theme['identifier'], 'name' => $theme['name'], 'version' => $theme['version'], 'state' => $theme['state'], 'requires_wp' => $theme['requires_wp'], 'requires_php' => $theme['requires_php'], ); } return $components; } /** * @param array $headers WordPress component headers. * @return array{type:string,identifier:string,name:string,version:string,state:string,requires_wp:string,requires_php:string} */ private function headers( string $type, string $identifier, array $headers, string $state ): array { return array( 'type' => $type, 'identifier' => $identifier, 'name' => isset( $headers['Name'] ) && is_string( $headers['Name'] ) ? $headers['Name'] : $type, 'version' => isset( $headers['Version'] ) && is_string( $headers['Version'] ) ? $headers['Version'] : 'unknown', 'state' => $state, 'requires_wp' => isset( $headers['RequiresWP'] ) && is_string( $headers['RequiresWP'] ) ? $headers['RequiresWP'] : '', 'requires_php' => isset( $headers['RequiresPHP'] ) && is_string( $headers['RequiresPHP'] ) ? $headers['RequiresPHP'] : '', ); } private function declared_version( string $value ): string { $value = $this->sanitizer->version( $value ); return 'unknown' === $value ? 'not_declared' : $value; } private function state( string $installed, string $required ): string { if ( 'not_declared' === $required ) { return 'not_declared'; } return version_compare( $installed, $required, '>=' ) ? 'compatible' : 'incompatible'; } /** * @return array */ private function error_finding( string $timestamp ): array { return $this->findings->build( $this->id(), 'error', array( 'objects' => array(), 'observed_count' => 0, 'total_count' => 0, 'truncated' => false, 'limitations' => array( 'The local compatibility adapter failed; no exception, path or component value was stored.' ), ), 0, 0, 'error', $timestamp ); } }