> */ public static function rules() { // A name only counts when it is used as code: registered as a hook, called, or extended. // A bare mention in a string, a list or a comment (for example a rule table, a changelog, // or a compatibility list) is not evidence that the package uses it. $hook = '\b(?:add_action|add_filter|do_action|apply_filters|remove_action|remove_filter|has_action|has_filter)\s*\(\s*[\'"]'; return array( // Payments and WooCommerce commerce. array( 'id' => 'woocommerce_gateway_class', 'capability' => 'payments', 'label' => __( 'Defines a WooCommerce payment gateway class', 'shouldcheck' ), 'strength' => 'strong', 'dependency' => 'woocommerce', 'pattern' => '~\bextends\s+[^\s{;]{0,160}\bWC_(?:Abstract_)?(?:Payment_)?Gateway(?:_[A-Za-z0-9_]+)?\b~i', ), array( 'id' => 'woocommerce_gateway_hook', 'capability' => 'payments', 'label' => __( 'Registers a gateway with WooCommerce', 'shouldcheck' ), 'strength' => 'strong', 'dependency' => 'woocommerce', 'pattern' => '~' . $hook . 'woocommerce_payment_gateways[\'"]~i', ), array( 'id' => 'woocommerce_blocks_gateway', 'capability' => 'payments', 'label' => __( 'Registers a Checkout Blocks payment method', 'shouldcheck' ), 'strength' => 'strong', 'dependency' => 'woocommerce', 'pattern' => '~' . $hook . 'woocommerce_blocks_payment_method_type_registration[\'"]|\bextends\s+[^\s{;]{0,160}\bAbstractPaymentMethodType\b~i', ), array( 'id' => 'woocommerce_checkout_hook', 'capability' => 'commerce', 'label' => __( 'Hooks into WooCommerce checkout or payment flow', 'shouldcheck' ), 'strength' => 'weak', 'dependency' => 'woocommerce', 'pattern' => '~' . $hook . '(?:woocommerce_available_payment_gateways|woocommerce_checkout_(?:create_order|process_payment|order_processed|fields))[\'"]~i', ), array( 'id' => 'woocommerce_order_integration', 'capability' => 'commerce', 'label' => __( 'Reacts to WooCommerce orders or cart events', 'shouldcheck' ), 'strength' => 'weak', 'dependency' => 'woocommerce', 'pattern' => '~' . $hook . 'woocommerce_(?:order_status_[a-z_]+|new_order|payment_complete|thankyou|add_to_cart|before_calculate_totals|cart_calculate_fees)[\'"]|\bwc_get_order\s*\(|\bnew\s+WC_Order\b|\bextends\s+WC_Order\b~i', ), array( 'id' => 'legacy_order_storage', 'capability' => 'commerce', 'label' => __( 'Reads or writes order data through post APIs', 'shouldcheck' ), 'strength' => 'weak', 'dependency' => 'woocommerce', 'pattern' => '~\b(?:get|update|add|delete)_post_meta\s*\(\s*\$(?:order_id|order)\b|[\'"]post_type[\'"]\s*=>\s*[\'"]shop_order[\'"]~i', ), // Page builders. array( 'id' => 'elementor_integration', 'capability' => 'page_builder', 'builder' => 'elementor', 'label' => __( 'Extends Elementor', 'shouldcheck' ), 'strength' => 'strong', 'dependency' => 'elementor', 'pattern' => '~\bextends\s+\\\\?Elementor\\\\(?:Widget_Base|Controls_Manager)\b|\\\\Elementor\\\\Plugin::\$instance|' . $hook . 'elementor/(?:widgets|elements|controls|frontend|editor|init)/?[a-z_/]*[\'"]~i', ), array( 'id' => 'divi_integration', 'capability' => 'page_builder', 'builder' => 'divi', 'label' => __( 'Extends the Divi builder', 'shouldcheck' ), 'strength' => 'strong', 'pattern' => '~\bextends\s+ET_Builder_(?:Module|Element)\w*|' . $hook . 'et_builder_ready[\'"]~', ), array( 'id' => 'beaver_builder_integration', 'capability' => 'page_builder', 'builder' => 'beaver-builder', 'label' => __( 'Extends Beaver Builder', 'shouldcheck' ), 'strength' => 'strong', 'pattern' => '~\bFLBuilder::register_module\b|\bextends\s+FLBuilderModule\b~', ), array( 'id' => 'wpbakery_integration', 'capability' => 'page_builder', 'builder' => 'wpbakery', 'label' => __( 'Extends WPBakery Page Builder', 'shouldcheck' ), 'strength' => 'strong', 'pattern' => '~\bvc_map\s*\(|\bWPBakeryShortCode\b~', ), array( 'id' => 'bricks_integration', 'capability' => 'page_builder', 'builder' => 'bricks', 'label' => __( 'Extends Bricks', 'shouldcheck' ), 'strength' => 'strong', 'pattern' => '~\bextends\s+\\\\?Bricks\\\\Element\b|' . $hook . 'bricks/elements/[a-z_]+[\'"]~i', ), array( 'id' => 'gutenberg_blocks', 'capability' => 'blocks', 'label' => __( 'Registers block editor blocks', 'shouldcheck' ), 'strength' => 'strong', 'pattern' => '~\bregister_block_type(?:_from_metadata)?\s*\(~i', ), array( 'id' => 'block_json', 'capability' => 'blocks', 'label' => __( 'Ships block.json block definitions', 'shouldcheck' ), 'strength' => 'strong', 'path_pattern' => '~(?:^|/)block\.json$~i', ), // Forms. array( 'id' => 'contact_form_7_integration', 'capability' => 'forms', 'form_system' => 'contact-form-7', 'label' => __( 'Extends Contact Form 7', 'shouldcheck' ), 'strength' => 'strong', 'dependency' => 'contact-form-7', 'pattern' => '~' . $hook . 'wpcf7_[a-z_]+[\'"]|\bwpcf7_[a-z_]+\s*\(|\b(?:extends|new)\s+WPCF7_[A-Za-z_]+|\bWPCF7_[A-Za-z_]+::~', ), array( 'id' => 'wpforms_integration', 'capability' => 'forms', 'form_system' => 'wpforms', 'label' => __( 'Extends WPForms', 'shouldcheck' ), 'strength' => 'strong', 'dependency' => 'wpforms-lite', 'pattern' => '~' . $hook . 'wpforms_[a-z_]+[\'"]|\bwpforms_[a-z_]+\s*\(|\bextends\s+\\\\?WPForms(?:_[A-Za-z]+)?\b|\bWPForms(?:_[A-Za-z]+)?::|\bwpforms\(\)~', ), array( 'id' => 'gravity_forms_integration', 'capability' => 'forms', 'form_system' => 'gravity-forms', 'label' => __( 'Extends Gravity Forms', 'shouldcheck' ), 'strength' => 'strong', 'pattern' => '~' . $hook . 'gform_[a-z_]+[\'"]|\bextends\s+GFAddOn\b|\b(?:GFForms|GFAPI)::~', ), array( 'id' => 'ninja_forms_integration', 'capability' => 'forms', 'form_system' => 'ninja-forms', 'label' => __( 'Extends Ninja Forms', 'shouldcheck' ), 'strength' => 'strong', 'dependency' => 'ninja-forms', 'pattern' => '~' . $hook . 'ninja_forms_[a-z_]+[\'"]|\bNinja_Forms\(\)|\bextends\s+NF_[A-Za-z_]+|\bextends\s+Ninja_Forms[A-Za-z_]*~', ), array( 'id' => 'fluent_forms_integration', 'capability' => 'forms', 'form_system' => 'fluent-forms', 'label' => __( 'Extends Fluent Forms', 'shouldcheck' ), 'strength' => 'strong', 'dependency' => 'fluentform', 'pattern' => '~' . $hook . 'fluentform[a-z_/]*[\'"]|\bnamespace\s+FluentForm\\\\|\buse\s+FluentForm\\\\|\bextends\s+\\\\?FluentForm\\\\~', ), array( 'id' => 'form_shortcode', 'capability' => 'forms', 'label' => __( 'Registers a form-style shortcode', 'shouldcheck' ), 'strength' => 'weak', 'pattern' => '~\badd_shortcode\s*\(\s*[\'"][a-z0-9_-]*form[a-z0-9_-]*[\'"]~i', ), array( 'id' => 'public_request_handler', 'capability' => 'public_requests', 'label' => __( 'Handles requests from logged-out visitors', 'shouldcheck' ), 'strength' => 'weak', 'pattern' => '~' . $hook . '(?:wp_ajax_nopriv_|admin_post_nopriv_)[a-z0-9_]+[\'"]~i', ), // Infrastructure. array( 'id' => 'rest_endpoints', 'capability' => 'rest_api', 'label' => __( 'Registers REST API routes', 'shouldcheck' ), 'strength' => 'strong', 'pattern' => '~\bregister_rest_route\s*\(~i', ), array( 'id' => 'scheduled_tasks', 'capability' => 'scheduled_tasks', 'label' => __( 'Schedules background or recurring tasks', 'shouldcheck' ), 'strength' => 'strong', 'pattern' => '~\b(?:wp_schedule_event|wp_schedule_single_event|as_schedule_(?:single|recurring|cron)_action|as_enqueue_async_action)\s*\(~i', ), array( 'id' => 'schema_changes', 'capability' => 'database', 'label' => __( 'Creates or alters database tables', 'shouldcheck' ), 'strength' => 'strong', 'pattern' => '~\bdbDelta\s*\(|\bCREATE\s+TABLE\b|\bALTER\s+TABLE\b~i', ), array( 'id' => 'external_requests', 'capability' => 'external_requests', 'label' => __( 'Makes outbound HTTP requests', 'shouldcheck' ), 'strength' => 'weak', 'pattern' => '~\bwp_remote_(?:get|post|request|head)\s*\(|\bcurl_init\s*\(~i', ), array( 'id' => 'sends_email', 'capability' => 'email', 'label' => __( 'Sends email', 'shouldcheck' ), 'strength' => 'weak', 'pattern' => '~\bwp_mail\s*\(~i', ), ); } /** * Source with PHP comments blanked out. Line numbers are kept. A name that only appears in a * comment is not evidence of use. Without the tokenizer extension the source is returned as is. * * @param string $content PHP source. * @return string */ public static function without_comments( $content ) { if ( ! function_exists( 'token_get_all' ) || ( false === strpos( $content, '/' ) && false === strpos( $content, '#' ) ) ) { return $content; } $out = ''; foreach ( @token_get_all( $content ) as $t ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors if ( is_array( $t ) ) { if ( T_COMMENT === $t[0] || T_DOC_COMMENT === $t[0] ) { $out .= str_repeat( "\n", substr_count( $t[1], "\n" ) ); continue; } $out .= $t[1]; } else { $out .= $t; } } return $out; } /** * Empty profile used when a package cannot be inspected. * * @param string $reason Why inspection was not possible. * @return array */ public static function empty_profile( $reason = 'Package source unavailable' ) { return array( 'schema' => self::SCHEMA, 'category' => 'unknown', 'categories' => array(), 'builders' => array(), 'form_systems' => array(), 'signals' => array(), 'features' => array(), 'woo' => array( 'related' => false, 'hpos' => 'unknown', 'blocks' => 'unknown', ), 'suggested_dependencies' => array(), 'optional_integrations' => array(), 'symbols' => ShouldCheck_Symbols::none(), 'findings' => ShouldCheck_Findings::none(), 'coverage' => 'unavailable', 'scan' => array( 'php_files' => 0, 'files_scanned' => 0, 'bytes' => 0, 'truncated_files' => 0, 'skipped_files' => 0, 'complete' => false, ), 'notes' => $reason, ); } /** * Inspect a plugin ZIP on disk. * * @param string $path Path to the ZIP. * @param string $main Archive path of the plugin main file (ranked first). * @param array $meta Parsed plugin header (may be empty). * @return array */ public static function inspect_zip( $path, $main = '', array $meta = array() ) { $zip = ShouldCheck_Zip::open( $path ); if ( ! $zip instanceof ZipArchive ) { return self::empty_profile( ShouldCheck_Zip::error_message( (string) $zip ) ); } if ( $zip->count() > self::MAX_ENTRIES ) { $zip->close(); return self::empty_profile( 'Package exceeds the inspection entry limit.' ); } $candidates = array(); $paths = array(); $zip_total = $zip->count(); for ( $i = 0; $i < $zip_total; ++$i ) { $info = $zip->statIndex( $i ); $name = (string) ( $info['name'] ?? '' ); $size = (int) ( $info['size'] ?? 0 ); if ( ! ShouldCheck_Zip::is_safe_name( $name ) ) { continue; } $paths[] = $name; if ( ! preg_match( '/\.php$/i', $name ) || $size < 12 ) { continue; } $candidates[] = array( 'index' => $i, 'path' => $name, 'size' => $size, ); } $sources = array(); $bytes = 0; $skipped = 0; usort( $candidates, function ( $a, $b ) use ( $main ) { $ra = self::rank_path( $a['path'], $main ); $rb = self::rank_path( $b['path'], $main ); return $ra === $rb ? strcmp( $a['path'], $b['path'] ) : ( $ra < $rb ? -1 : 1 ); } ); $skipped_low = 0; foreach ( $candidates as $c ) { $low = self::is_low_value_path( $c['path'] ); if ( count( $sources ) >= self::MAX_FILES || $bytes >= self::MAX_TOTAL_BYTES || $c['size'] > self::SKIP_FILE_BYTES ) { ++$skipped; $skipped_low += $low ? 1 : 0; continue; } $slice = (int) min( $c['size'], self::MAX_FILE_BYTES, self::MAX_TOTAL_BYTES - $bytes ); if ( $slice < 12 ) { ++$skipped; $skipped_low += $low ? 1 : 0; continue; } $raw = $zip->getFromIndex( $c['index'], $slice ); if ( ! is_string( $raw ) ) { ++$skipped; $skipped_low += $low ? 1 : 0; continue; } $bytes += strlen( $raw ); $source = array( 'path' => $c['path'], 'content' => $raw, 'truncated' => $c['size'] > strlen( $raw ), ); // A long file is often a data table. Keep the rest so it can be checked for code by pattern. if ( $source['truncated'] && $c['size'] <= self::SKIP_FILE_BYTES && $bytes + $c['size'] <= self::MAX_TOTAL_BYTES ) { $full = $zip->getFromIndex( $c['index'], $c['size'] ); if ( is_string( $full ) && strlen( $full ) > strlen( $raw ) ) { $source['tail'] = substr( $full, strlen( $raw ) ); $bytes += strlen( $source['tail'] ); } } $sources[] = $source; } $zip->close(); $meta['skipped_low'] = $skipped_low; return self::scan( $sources, $paths, $meta, $skipped, count( $candidates ) ); } /** * Order in which files are read: the main file, then application code, then vendor code. * * @param string $path Archive path. * @param string $main Main file archive path. * @return int */ public static function rank_path( $path, $main ) { $rank = 0; if ( '' !== $main && $path === $main ) { $rank -= 1000; } // Code the plugin runs as its own comes first: checkout, payment and block code, then includes and src. if ( preg_match( '~(?:gateway|payment|checkout|block|includes|src)/~i', $path ) ) { $rank -= 100; } // Files near the top of the package matter more than deeply nested ones. $rank += min( 20, substr_count( (string) $path, '/' ) ); // Bundled libraries, tests, examples, docs and build output come last. if ( self::is_low_value_path( $path ) ) { $rank += 1000; } return $rank; } /** * Whether a path is a bundled library, a test, an example, documentation, a language file or build output. * These are read last, and not reading them does not stop a plugin's own code from being judged. * * @param string $path Path inside the package. * @return bool */ public static function is_low_value_path( $path ) { return (bool) preg_match( '~(?:^|/)(?:vendor|vendor_prefixed|vendor-prefixed|jetpack_vendor|node_modules|third[-_]?party|libraries|tests?|examples?|docs?|languages|stubs|build|dist|\.github)/~i', (string) $path ); } /** * How long a scan may run: eight seconds, and never more than half of what the server allows a request. * * @return float */ public static function time_budget() { $budget = null === self::$time_budget ? 8.0 : (float) self::$time_budget; $max = (int) ini_get( 'max_execution_time' ); return $max > 0 ? min( $budget, $max / 2 ) : $budget; } /** * For each long file whose unread rest declares nothing (no function, class, constant or namespace), add that rest as a * pattern-only source and count the file as read. A rest that declares something stays unread. * * @param array $sources Sources as given to scan(). * @return array */ private static function with_data_tails( array $sources ) { $out = array(); foreach ( $sources as $src ) { $tail = (string) ( $src['tail'] ?? '' ); unset( $src['tail'] ); if ( ! empty( $src['truncated'] ) && '' !== $tail && ! preg_match( '/(?:^|[;{}]|<\?php|<\?|\?>)\s*(?:(?:abstract|final|readonly)\s+)*(?:function\s*&?\s*[A-Za-z_]|(?:class|interface|trait|enum)\s+[A-Za-z_]|define\s*\(|const\s+[A-Za-z_]|namespace\s+[A-Za-z_\\\\])/im', $tail ) ) { $src['truncated'] = false; $out[] = $src; // Check the rest in pieces, so a long file never needs much memory. for ( $at = 0, $len = strlen( $tail ); $at < $len; $at += self::MAX_FILE_BYTES / 2 ) { $out[] = array( 'path' => $src['path'] ?? '', 'content' => substr( $tail, $at, self::MAX_FILE_BYTES / 2 + 2048 ), 'truncated' => false, 'raw_tail' => true, ); } continue; } $out[] = $src; } return $out; } /** * Pure scan over already-read sources. Unit-testable without a ZIP. * * @param array $sources Each: path, content, truncated. * @param array $paths All safe archive paths (for path-based rules). * @param array $meta Parsed plugin header. * @param int $skipped PHP files not read because of limits. * @param int $php_total PHP files present in the archive. * @return array */ public static function scan( array $sources, array $paths = array(), array $meta = array(), $skipped = 0, $php_total = null ) { $rules = self::rules(); $signals = array(); $features = array(); $truncated = 0; $cut_paths = array(); $bundled = array(); $bytes = 0; $woo_decl = array(); $builders = array(); $forms = array(); $deps = array(); $caps = array(); $self = strtolower( (string) strtok( str_replace( '\\', '/', (string) ( $meta['file'] ?? '' ) ), '/' ) ); $symbols = ShouldCheck_Symbols::none(); $findings = ShouldCheck_Findings::none(); $sources = self::with_data_tails( $sources ); $deadline = microtime( true ) + self::time_budget(); $processed = 0; $unread = 0; $unread_own = 0; $syntax = null; $cut_own = 0; foreach ( $sources as $src ) { $raw_tail = ! empty( $src['raw_tail'] ); if ( microtime( true ) > $deadline ) { if ( $raw_tail ) { ++$truncated; // The rest of a long file was not checked after all. if ( count( $cut_paths ) < 5 ) { $cut_paths[] = (string) ( $src['path'] ?? '' ); } } else { ++$unread; if ( ! self::is_low_value_path( (string) ( $src['path'] ?? '' ) ) ) { ++$unread_own; } } continue; } if ( ! $raw_tail ) { ++$processed; } $content = (string) ( $src['content'] ?? '' ); $path = (string) ( $src['path'] ?? '' ); $bytes += strlen( $content ); if ( ! $raw_tail ) { $content = self::without_comments( $content ); } if ( ! empty( $src['truncated'] ) ) { ++$truncated; if ( ! self::is_low_value_path( $path ) ) { ++$cut_own; } if ( count( $cut_paths ) < 5 ) { $cut_paths[] = $path; } } $found = ( $raw_tail || ShouldCheck_Symbols::is_nonruntime_path( $path ) ) ? ShouldCheck_Symbols::none() : ShouldCheck_Symbols::extract( $content ); $symbols = ShouldCheck_Symbols::merge( $symbols, $found ); if ( ShouldCheck_Symbols::is_bundled_path( $path ) ) { foreach ( array( 'functions', 'classes' ) as $kind ) { foreach ( $found[ $kind ] as $n ) { $bundled[ $n ] = true; } } } $findings = ShouldCheck_Findings::merge( $findings, ShouldCheck_Findings::extract( $content, $path ) ); if ( ! $raw_tail && ! self::is_low_value_path( $path ) ) { $lvl = self::syntax_level( $content ); if ( $lvl && ( null === $syntax || version_compare( $lvl['version'], $syntax['version'], '>' ) ) ) { $syntax = $lvl + array( 'path' => $path ); } } foreach ( $rules as $rule ) { if ( empty( $rule['pattern'] ) || isset( $features[ $rule['id'] ] ) ) { continue; } // A plugin is not an add-on of itself. if ( ! empty( $rule['dependency'] ) && '' !== $self && self::same_family( (string) $rule['dependency'], $self ) ) { continue; } if ( preg_match( $rule['pattern'], $content, $m, PREG_OFFSET_CAPTURE ) ) { $line = 1 + substr_count( substr( $content, 0, (int) $m[0][1] ), "\n" ); $features[ $rule['id'] ] = true; self::record( $rule, $path, $line, $signals, $caps, $builders, $forms, $deps ); } } foreach ( self::parse_woo_declarations( $content ) as $feature => $state ) { if ( ! isset( $woo_decl[ $feature ] ) || 'declared_incompatible' === $state ) { $woo_decl[ $feature ] = $state; } } } foreach ( $rules as $rule ) { if ( empty( $rule['path_pattern'] ) || isset( $features[ $rule['id'] ] ) ) { continue; } foreach ( $paths as $p ) { if ( preg_match( $rule['path_pattern'], $p ) ) { $features[ $rule['id'] ] = true; self::record( $rule, $p, 0, $signals, $caps, $builders, $forms, $deps ); break; } } } $symbols['bundled'] = array_slice( array_keys( $bundled ), 0, ShouldCheck_Symbols::MAX_NAMES ); $files_scanned = $processed; $skipped += $unread; $php_total = null === $php_total ? $files_scanned + (int) $skipped : (int) $php_total; $complete = ( 0 === (int) $skipped && 0 === $truncated && $files_scanned === $php_total && $files_scanned > 0 ); // Own code: everything outside bundled libraries, tests, examples and docs was read in full. $own_skipped = max( 0, (int) $skipped - (int) ( $meta['skipped_low'] ?? 0 ) - $unread ) + $unread_own; $own_complete = ( $files_scanned > 0 && 0 === $own_skipped && 0 === $cut_own ); $woo_related = isset( $caps['payments'] ) || isset( $caps['commerce'] ) || ! empty( $woo_decl ) || ! empty( $meta['requires_woo'] ) || self::header_lists( $meta, 'woocommerce' ); $woo = array( 'related' => $woo_related, 'hpos' => self::declaration_state( $woo_decl, 'custom_order_tables', $complete, $own_complete ), 'blocks' => self::declaration_state( $woo_decl, 'cart_checkout_blocks', $complete, $own_complete ), ); if ( $woo_related ) { $deps['woocommerce'] = true; } // A plugin that references several page builders or form plugins integrates with them. // It is not an add-on of any one of them, and none of them is required to run it. $optional = array(); if ( count( $builders ) + count( $forms ) >= 2 ) { foreach ( array_keys( $deps ) as $dep ) { if ( 'woocommerce' !== $dep ) { $optional[] = $dep; unset( $deps[ $dep ] ); } } } $categories = array_keys( $caps ); usort( $categories, function ( $a, $b ) { return self::capability_order( $a ) <=> self::capability_order( $b ); } ); $primary = 'general'; foreach ( self::$priority as $candidate ) { if ( isset( $caps[ $candidate ] ) ) { $primary = $candidate; break; } } if ( $optional && 'payments' !== $primary ) { $primary = 'general'; } // A strong payment signal always wins; weak-only commerce signals stay "commerce". if ( 'payments' === $primary && ! self::has_strong( $signals, 'payments' ) ) { $primary = 'commerce'; } $coverage = 0 === $files_scanned ? 'no-readable-php' : ( $complete ? 'complete-static-scan' : 'partial-static-scan' ); return array( 'schema' => self::SCHEMA, 'category' => $primary, 'categories' => $categories, 'builders' => array_keys( $builders ), 'form_systems' => array_keys( $forms ), 'signals' => array_slice( $signals, 0, 40 ), 'features' => $features, 'woo' => $woo, 'suggested_dependencies' => array_keys( $deps ), 'optional_integrations' => $optional, 'symbols' => $symbols, 'findings' => $findings, 'odd_files' => self::odd_files( $paths ), 'php_syntax' => $syntax, 'coverage' => $coverage, 'scan' => array( 'php_files' => $php_total, 'files_scanned' => $files_scanned, 'bytes' => $bytes, 'truncated_files' => $truncated, 'truncated_paths' => $cut_paths, 'skipped_files' => (int) $skipped, 'complete' => $complete, ), 'notes' => sprintf( 'Examined %1$d of %2$d PHP files (up to %3$d KB each). Signals describe code patterns, not working features.', $files_scanned, $php_total, (int) ( self::MAX_FILE_BYTES / 1024 ) ), ); } /** * The newest PHP language feature a file uses, read by pattern. A pattern read can be wrong, so the result is a lead. * * @param string $code PHP source with comments removed. * @return array{version:string,feature:string}|null */ public static function syntax_level( $code ) { $code = (string) $code; if ( strlen( $code ) < 20 ) { return null; } // Text inside quotes is not code. Blank it so a pattern in a string is not mistaken for syntax. if ( function_exists( 'token_get_all' ) && strlen( $code ) < 600000 ) { $plain = ''; foreach ( @token_get_all( $code ) as $t ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors if ( is_array( $t ) ) { $plain .= in_array( $t[0], array( T_CONSTANT_ENCAPSED_STRING, T_ENCAPSED_AND_WHITESPACE, T_INLINE_HTML ), true ) ? '""' : $t[1]; } else { $plain .= $t; } } $code = $plain; } $code = preg_replace( '/function\s+&?\s*(?:match|fn|enum)\b/i', 'function x', $code ); $tests = array( array( '8.1', 'enum', '~^\s*(?:final\s+)?enum\s+[A-Za-z_]\w*\s*(?::\s*\w+\s*)?(?:implements\s+[\w\\\\,\s]+)?\{~mi' ), array( '8.1', 'readonly property', '~\b(?:public|protected|private)\s+(?:static\s+)?readonly\s+[\w\\\\?|]+\s+\$~i' ), array( '8.1', 'first-class callable', '~[A-Za-z_]\w*\s*\(\s*\.\.\.\s*\)~' ), array( '8.0', 'match expression', '~(?:$])match\s*\((?:[^()]|\([^()]*\))*\)\s*\{~i' ), array( '8.0', 'nullsafe operator', '~\?->~' ), array( '8.0', 'constructor promotion', '~function\s+__construct\s*\([^)]*\b(?:public|protected|private)\s+(?:readonly\s+)?[\w\\\\?|]*\s*\$~i' ), array( '7.4', 'arrow function', '~(?:$])fn\s*\([^)]*\)\s*(?::\s*\??[\w\\\\]+\s*)?=>~i' ), array( '7.4', 'typed property', '~\b(?:public|protected|private)\s+(?:static\s+)?\??(?:int|string|array|bool|float)\s+\$~i' ), array( '7.4', 'null-coalescing assignment', '~\?\?=~' ), ); foreach ( $tests as $t ) { if ( preg_match( $t[2], $code ) ) { return array( 'version' => $t[0], 'feature' => $t[1], ); } } return null; } /** * Files in the package that a WordPress plugin does not normally contain: programs and scripts. * * @param array $paths Archive paths. * @return string[] Up to five paths. */ public static function odd_files( array $paths ) { $out = array(); foreach ( $paths as $p ) { if ( preg_match( '~\.(?:exe|dll|so|dylib|bat|cmd|com|scr|msi|sh|bash|ps1|vbs|py|rb|pl|jar)$~i', (string) $p ) && ! preg_match( '~(?:^|/)(?:node_modules|vendor|tests?|examples?|docs?)/~i', (string) $p ) ) { $out[] = (string) $p; if ( count( $out ) >= 5 ) { break; } } } return $out; } /** * Parse WooCommerce FeaturesUtil::declare_compatibility() calls. * * @param string $content PHP source. * @return array feature id => declared_compatible|declared_incompatible */ public static function parse_woo_declarations( $content ) { $out = array(); if ( false === stripos( (string) $content, 'declare_compatibility' ) ) { return $out; } $pattern = '~declare_compatibility\s*\(\s*([\'"])([a-z_]+)\1\s*,\s*((?:[^,()]|\([^()]*\))+?)\s*(?:,\s*(true|false)\s*)?\)~i'; if ( preg_match_all( $pattern, (string) $content, $matches, PREG_SET_ORDER ) ) { foreach ( $matches as $m ) { $feature = strtolower( $m[2] ); $flag = isset( $m[4] ) ? strtolower( $m[4] ) : 'true'; $state = ( 'false' === $flag ) ? 'declared_incompatible' : 'declared_compatible'; if ( ! isset( $out[ $feature ] ) || 'declared_incompatible' === $state ) { $out[ $feature ] = $state; } } } return $out; } /** * Convert parsed declarations to a state that is honest about scan completeness. * * @param array $decl Parsed declarations. * @param string $feature Feature id. * @param bool $complete Whether every PHP file was fully read. * @param bool $own_complete Whether all of the plugin's own files (not bundled libraries) were read. * @return string declared_compatible|declared_incompatible|not_found|not_found_in_own_files|not_found_partial_scan */ private static function declaration_state( array $decl, $feature, $complete, $own_complete = false ) { if ( isset( $decl[ $feature ] ) ) { return $decl[ $feature ]; } if ( $complete ) { return 'not_found'; } return $own_complete ? 'not_found_in_own_files' : 'not_found_partial_scan'; } /** * Whether the "Requires Plugins" header lists a slug. * * @param array $meta Parsed header. * @param string $slug Slug to look for. * @return bool */ private static function header_lists( array $meta, $slug ) { $list = array_map( 'trim', explode( ',', strtolower( (string) ( $meta['requires_plugins'] ?? '' ) ) ) ); return in_array( $slug, $list, true ); } /** * Record a matched rule. * * @param array $rule The rule that matched. * @param string $file File where it matched. * @param int $line Line where it matched. * @param array $signals Recorded signals. This list is added to. * @param array $caps Capabilities found. This list is added to. * @param array $builders Page builders found. This list is added to. * @param array $forms Form systems found. This list is added to. * @param array $deps Dependencies found. This list is added to. */ private static function record( array $rule, $file, $line, array &$signals, array &$caps, array &$builders, array &$forms, array &$deps ) { if ( count( $signals ) < 40 ) { $signals[] = array( 'feature' => $rule['id'], 'capability' => $rule['capability'], 'label' => $rule['label'], 'strength' => $rule['strength'], 'file' => $file, 'line' => (int) $line, 'evidence' => __( 'Static code pattern; runtime behaviour not established', 'shouldcheck' ), ); } $caps[ $rule['capability'] ] = true; if ( ! empty( $rule['builder'] ) ) { $builders[ $rule['builder'] ] = true; } if ( ! empty( $rule['form_system'] ) ) { $forms[ $rule['form_system'] ] = true; } if ( ! empty( $rule['dependency'] ) ) { $deps[ $rule['dependency'] ] = true; } } /** * Whether two plugin slugs belong to the same product (free and pro editions). * * @param string $a Slug. * @param string $b Slug. * @return bool */ private static function same_family( $a, $b ) { $family = array( 'wpforms-lite' => 'wpforms', 'wpforms' => 'wpforms', 'fluentform' => 'fluentform', 'fluentformpro' => 'fluentform', 'elementor' => 'elementor', 'elementor-pro' => 'elementor', ); $a = strtolower( $a ); $b = strtolower( $b ); return ( $family[ $a ] ?? $a ) === ( $family[ $b ] ?? $b ); } /** * Whether any recorded signal for a capability is strong. * * @param array $signals Recorded signals. * @param string $capability Capability name. */ private static function has_strong( array $signals, $capability ) { foreach ( $signals as $s ) { if ( $s['capability'] === $capability && 'strong' === $s['strength'] ) { return true; } } return false; } /** * Display ordering for capabilities. * * @param string $capability Capability name. */ private static function capability_order( $capability ) { $order = array( 'payments' => 1, 'commerce' => 2, 'page_builder' => 3, 'blocks' => 4, 'forms' => 5, 'rest_api' => 6, 'scheduled_tasks' => 7, 'database' => 8, 'public_requests' => 9, 'external_requests' => 10, 'email' => 11, ); return $order[ $capability ] ?? 99; } /** * Plain-language names for capabilities. * * @return array */ public static function capability_labels() { return array( 'payments' => 'Payments', 'commerce' => 'WooCommerce integration', 'page_builder' => 'Page builder', 'blocks' => 'Block editor blocks', 'forms' => 'Forms', 'rest_api' => 'REST API', 'scheduled_tasks' => 'Background tasks', 'database' => 'Database changes', 'public_requests' => 'Public request handlers', 'external_requests' => 'Outbound requests', 'email' => 'Email', ); } }