diff --git a/includes/Checker/Checks/Plugin_Repo/Runtime_Fatal_Error_Prevention_Check.php b/includes/Checker/Checks/Plugin_Repo/Runtime_Fatal_Error_Prevention_Check.php
new file mode 100644
index 000000000..e9d08666b
--- /dev/null
+++ b/includes/Checker/Checks/Plugin_Repo/Runtime_Fatal_Error_Prevention_Check.php
@@ -0,0 +1,80 @@
+ 'php',
+ 'standard' => 'PluginCheck',
+ 'sniffs' => 'PluginCheck.CodeAnalysis.RuntimeFatalErrorPrevention',
+ );
+ }
+
+ /**
+ * 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 __( 'Detects high-risk coding patterns that commonly cause runtime fatal errors or notices in plugins.', '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/wordpress-org/detailed-plugin-guidelines/', 'plugin-check' );
+ }
+}
diff --git a/includes/Checker/Default_Check_Repository.php b/includes/Checker/Default_Check_Repository.php
index c22371044..1841d8166 100644
--- a/includes/Checker/Default_Check_Repository.php
+++ b/includes/Checker/Default_Check_Repository.php
@@ -71,37 +71,38 @@ private function register_default_checks() {
$checks = apply_filters(
'wp_plugin_check_checks',
array(
- 'i18n_usage' => new Checks\General\I18n_Usage_Check(),
- 'enqueued_scripts_size' => new Checks\Performance\Enqueued_Scripts_Size_Check(),
- 'enqueued_styles_size' => new Checks\Performance\Enqueued_Styles_Size_Check(),
- 'code_obfuscation' => new Checks\Plugin_Repo\Code_Obfuscation_Check(),
- 'plugin_content' => new Checks\Plugin_Repo\Plugin_Content_Check(),
- 'file_type' => new Checks\Plugin_Repo\File_Type_Check(),
- 'plugin_header_fields' => new Checks\Plugin_Repo\Plugin_Header_Fields_Check(),
- 'late_escaping' => new Checks\Security\Late_Escaping_Check(),
- 'safe_redirect' => new Checks\Security\Safe_Redirect_Check(),
- 'plugin_updater' => new Checks\Plugin_Repo\Plugin_Updater_Check(),
- 'plugin_uninstall' => new Checks\Plugin_Repo\Plugin_Uninstall_Check(),
- 'plugin_review_phpcs' => new Checks\Plugin_Repo\Plugin_Review_PHPCS_Check(),
- 'direct_db_queries' => new Checks\Security\Direct_DB_Queries_Check(),
- 'performant_wp_query_params' => new Checks\Performance\Performant_WP_Query_Params_Check(),
- 'enqueued_scripts_in_footer' => new Checks\Performance\Enqueued_Scripts_In_Footer_Check(),
- 'enqueued_resources' => new Checks\Performance\Enqueued_Resources_Check(),
- 'plugin_readme' => new Checks\Plugin_Repo\Plugin_Readme_Check(),
- 'enqueued_styles_scope' => new Checks\Performance\Enqueued_Styles_Scope_Check(),
- 'enqueued_scripts_scope' => new Checks\Performance\Enqueued_Scripts_Scope_Check(),
- 'localhost' => new Checks\Plugin_Repo\Localhost_Check(),
- 'no_unfiltered_uploads' => new Checks\Plugin_Repo\No_Unfiltered_Uploads_Check(),
- 'trademarks' => new Checks\Plugin_Repo\Trademarks_Check(),
- 'non_blocking_scripts' => new Checks\Performance\Non_Blocking_Scripts_Check(),
- 'offloading_files' => new Checks\Plugin_Repo\Offloading_Files_Check(),
- 'setting_sanitization' => new Checks\Plugin_Repo\Setting_Sanitization_Check(),
- 'prefixing' => new Checks\Plugin_Repo\Prefixing_Check(),
- 'direct_db' => new Checks\Security\Direct_DB_Check(),
- 'minified_files' => new Checks\Plugin_Repo\Minified_Files_Check(),
- '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(),
+ 'i18n_usage' => new Checks\General\I18n_Usage_Check(),
+ 'enqueued_scripts_size' => new Checks\Performance\Enqueued_Scripts_Size_Check(),
+ 'enqueued_styles_size' => new Checks\Performance\Enqueued_Styles_Size_Check(),
+ 'code_obfuscation' => new Checks\Plugin_Repo\Code_Obfuscation_Check(),
+ 'plugin_content' => new Checks\Plugin_Repo\Plugin_Content_Check(),
+ 'file_type' => new Checks\Plugin_Repo\File_Type_Check(),
+ 'plugin_header_fields' => new Checks\Plugin_Repo\Plugin_Header_Fields_Check(),
+ 'late_escaping' => new Checks\Security\Late_Escaping_Check(),
+ 'safe_redirect' => new Checks\Security\Safe_Redirect_Check(),
+ 'plugin_updater' => new Checks\Plugin_Repo\Plugin_Updater_Check(),
+ 'plugin_uninstall' => new Checks\Plugin_Repo\Plugin_Uninstall_Check(),
+ 'plugin_review_phpcs' => new Checks\Plugin_Repo\Plugin_Review_PHPCS_Check(),
+ 'direct_db_queries' => new Checks\Security\Direct_DB_Queries_Check(),
+ 'performant_wp_query_params' => new Checks\Performance\Performant_WP_Query_Params_Check(),
+ 'enqueued_scripts_in_footer' => new Checks\Performance\Enqueued_Scripts_In_Footer_Check(),
+ 'enqueued_resources' => new Checks\Performance\Enqueued_Resources_Check(),
+ 'plugin_readme' => new Checks\Plugin_Repo\Plugin_Readme_Check(),
+ 'enqueued_styles_scope' => new Checks\Performance\Enqueued_Styles_Scope_Check(),
+ 'enqueued_scripts_scope' => new Checks\Performance\Enqueued_Scripts_Scope_Check(),
+ 'localhost' => new Checks\Plugin_Repo\Localhost_Check(),
+ 'no_unfiltered_uploads' => new Checks\Plugin_Repo\No_Unfiltered_Uploads_Check(),
+ 'trademarks' => new Checks\Plugin_Repo\Trademarks_Check(),
+ 'non_blocking_scripts' => new Checks\Performance\Non_Blocking_Scripts_Check(),
+ 'offloading_files' => new Checks\Plugin_Repo\Offloading_Files_Check(),
+ 'setting_sanitization' => new Checks\Plugin_Repo\Setting_Sanitization_Check(),
+ 'prefixing' => new Checks\Plugin_Repo\Prefixing_Check(),
+ 'direct_db' => new Checks\Security\Direct_DB_Check(),
+ 'minified_files' => new Checks\Plugin_Repo\Minified_Files_Check(),
+ '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(),
+ 'runtime_fatal_error_prevention' => new Checks\Plugin_Repo\Runtime_Fatal_Error_Prevention_Check(),
)
);
diff --git a/phpcs-rulesets/plugin-check.ruleset.xml b/phpcs-rulesets/plugin-check.ruleset.xml
index b8432c1bf..f05f709d3 100644
--- a/phpcs-rulesets/plugin-check.ruleset.xml
+++ b/phpcs-rulesets/plugin-check.ruleset.xml
@@ -40,6 +40,9 @@
6
+
+
+
error
diff --git a/phpcs-sniffs/PluginCheck/Sniffs/CodeAnalysis/RuntimeFatalErrorPreventionSniff.php b/phpcs-sniffs/PluginCheck/Sniffs/CodeAnalysis/RuntimeFatalErrorPreventionSniff.php
new file mode 100644
index 000000000..7f5e9d919
--- /dev/null
+++ b/phpcs-sniffs/PluginCheck/Sniffs/CodeAnalysis/RuntimeFatalErrorPreventionSniff.php
@@ -0,0 +1,490 @@
+
+ */
+ private $integration_functions = array(
+ // WooCommerce.
+ 'is_woocommerce',
+ 'wc_get_product',
+ 'wc_price',
+ 'wc_get_cart_url',
+ 'wc_get_checkout_url',
+ 'wc_get_page_id',
+ 'wc_get_template',
+ 'wc_add_notice',
+ 'wc_print_notices',
+ 'wc_get_cart',
+ 'wc_get_container',
+ 'is_cart',
+ 'is_checkout',
+ // ACF.
+ 'get_field',
+ 'the_field',
+ 'have_rows',
+ 'the_row',
+ 'get_sub_field',
+ 'update_field',
+ 'delete_field',
+ 'acf_register_block_type',
+ 'get_fields',
+ // Elementor.
+ 'elementor_pro_load_plugin',
+ 'elementor_load_plugin_textdomain',
+ // WPML.
+ 'wpml_object_id',
+ 'wpml_get_active_languages',
+ // Polylang.
+ 'pll_the_languages',
+ 'pll_current_language',
+ 'pll_default_language',
+ 'pll_get_post',
+ 'pll_get_term',
+ 'pll_translate_string',
+ // Contact Form 7.
+ 'wpcf7_contact_form',
+ 'wpcf7_add_form_tag',
+ // Yoast.
+ 'yoast_breadcrumb',
+ // Carbon Fields.
+ 'carbon_get_post_meta',
+ 'carbon_get_theme_option',
+ 'carbon_get_user_meta',
+ );
+
+ /**
+ * List of popular optional third-party classes.
+ *
+ * @var array
+ */
+ private $optional_classes = array(
+ // WooCommerce.
+ 'wc_product',
+ 'wc_order',
+ 'wc_cart',
+ 'wc_customer',
+ 'wc_coupon',
+ 'wc_gateway_paypal',
+ 'wc_payment_gateway',
+ // Elementor.
+ 'elementor\widget_base',
+ 'elementor\controls_manager',
+ 'elementor\plugin',
+ // ACF.
+ 'acf_field',
+ // Carbon Fields.
+ 'carbon_fields\container',
+ 'carbon_fields\field',
+ );
+
+ /**
+ * Returns an array of tokens this test wants to listen for.
+ *
+ * @since 2.1.0
+ *
+ * @return array
+ */
+ public function register() {
+ return array(
+ \T_REQUIRE,
+ \T_REQUIRE_ONCE,
+ \T_INCLUDE,
+ \T_INCLUDE_ONCE,
+ \T_NEW,
+ \T_VARIABLE,
+ \T_STRING,
+ );
+ }
+
+ /**
+ * 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 int|void Integer stack pointer to skip forward or void to continue normal file processing.
+ */
+ public function process_token( $stackPtr ) {
+ $token_code = $this->tokens[ $stackPtr ]['code'];
+
+ // Case 1: require / require_once / include / include_once on dynamic or unguarded paths.
+ if ( in_array( $token_code, array( \T_REQUIRE, \T_REQUIRE_ONCE, \T_INCLUDE, \T_INCLUDE_ONCE ), true ) ) {
+ $this->process_import( $stackPtr );
+ return;
+ }
+
+ // Case 3: Instantiating optional classes without class_exists guard.
+ if ( \T_NEW === $token_code ) {
+ $this->process_new( $stackPtr );
+ return;
+ }
+
+ // Case 4: Invoking dynamic callbacks directly without is_callable.
+ if ( \T_VARIABLE === $token_code ) {
+ $this->process_variable( $stackPtr );
+ return;
+ }
+
+ // Case 2 & 5: Function calls and Hook callback verification.
+ if ( \T_STRING === $token_code ) {
+ $this->process_string( $stackPtr );
+ return;
+ }
+ }
+
+ /**
+ * Process require/include statements.
+ *
+ * @param int $stackPtr Token pointer.
+ */
+ private function process_import( $stackPtr ) {
+ $end = $this->phpcsFile->findNext( \T_SEMICOLON, $stackPtr + 1 );
+ if ( false === $end ) {
+ return;
+ }
+
+ // Find any variables inside the require/include expression.
+ $has_dynamic = false;
+ $dynamic_var = '';
+ for ( $i = $stackPtr + 1; $i < $end; $i++ ) {
+ if ( \T_VARIABLE === $this->tokens[ $i ]['code'] ) {
+ $var_name = $this->tokens[ $i ]['content'];
+ // Ignore superglobals or clearly static environment variables if any, but standard variables are dynamic.
+ if ( ! in_array( $var_name, array( '$GLOBALS', '$_SERVER', '$_GET', '$_POST', '$_FILES', '$_COOKIE', '$_SESSION', '$_REQUEST', '$_ENV' ), true ) ) {
+ $has_dynamic = true;
+ $dynamic_var = $var_name;
+ break;
+ }
+ }
+ }
+
+ if ( $has_dynamic ) {
+ // Check if guarded by file_exists or is_readable.
+ if ( ! $this->is_guarded_by_condition( $stackPtr, array( 'file_exists', 'is_readable' ), $dynamic_var ) ) {
+ $this->phpcsFile->addWarning(
+ 'Dynamic include/require path detected without file_exists() or is_readable() guard. Found: %s',
+ $stackPtr,
+ 'DynamicImportUnguarded',
+ array( trim( $this->phpcsFile->getTokensAsString( $stackPtr, $end - $stackPtr ) ) )
+ );
+ }
+ }
+ }
+
+ /**
+ * Process T_NEW class instantiation.
+ *
+ * @param int $stackPtr Token pointer.
+ */
+ private function process_new( $stackPtr ) {
+ $next = $this->phpcsFile->findNext( \PHP_CodeSniffer\Util\Tokens::$emptyTokens, $stackPtr + 1, null, true );
+ if ( false === $next ) {
+ return;
+ }
+
+ $class_name = '';
+ // Build fully namespaced class name.
+ while ( false !== $next && in_array( $this->tokens[ $next ]['code'], array( \T_STRING, \T_NS_SEPARATOR, \T_NAME_QUALIFIED, \T_NAME_FULLY_QUALIFIED ), true ) ) {
+ $class_name .= $this->tokens[ $next ]['content'];
+ $next = $this->phpcsFile->findNext( \PHP_CodeSniffer\Util\Tokens::$emptyTokens, $next + 1, null, true );
+ }
+
+ if ( '' !== $class_name && in_array( strtolower( $class_name ), $this->optional_classes, true ) ) {
+ if ( ! $this->is_guarded_by_condition( $stackPtr, array( 'class_exists' ), $class_name ) ) {
+ $this->phpcsFile->addError(
+ 'Instantiating optional class "%s" without a class_exists() guard is risky and could cause a runtime fatal error.',
+ $stackPtr,
+ 'OptionalClassInstantiationUnguarded',
+ array( $class_name )
+ );
+ }
+ }
+ }
+
+ /**
+ * Process dynamic variable function calls.
+ *
+ * @param int $stackPtr Token pointer.
+ */
+ private function process_variable( $stackPtr ) {
+ $next = $this->phpcsFile->findNext( \PHP_CodeSniffer\Util\Tokens::$emptyTokens, $stackPtr + 1, null, true );
+ if ( false !== $next && \T_OPEN_PARENTHESIS === $this->tokens[ $next ]['code'] ) {
+ $var_name = $this->tokens[ $stackPtr ]['content'];
+ if ( ! $this->is_guarded_by_condition( $stackPtr, array( 'is_callable', 'function_exists' ), $var_name ) ) {
+ $this->phpcsFile->addError(
+ 'Invoking dynamic callback "%s()" without an is_callable() or function_exists() guard is risky and could cause a runtime fatal error.',
+ $stackPtr,
+ 'DynamicCallbackInvocationUnguarded',
+ array( $var_name )
+ );
+ }
+ }
+ }
+
+ /**
+ * Helper to get all top-level parameters of a function call.
+ *
+ * @param int $opener Parenthesis opener.
+ * @param int $closer Parenthesis closer.
+ * @return array> List of parameter token pointers.
+ */
+ private function get_function_parameters( $opener, $closer ) {
+ $params = array();
+ $current_param = array();
+ $depth = 0;
+
+ for ( $i = $opener + 1; $i < $closer; $i++ ) {
+ $token = $this->tokens[ $i ];
+
+ if ( \T_OPEN_PARENTHESIS === $token['code'] || \T_OPEN_SQUARE_BRACKET === $token['code'] || \T_OPEN_SHORT_ARRAY === $token['code'] ) {
+ $depth++;
+ } elseif ( \T_CLOSE_PARENTHESIS === $token['code'] || \T_CLOSE_SQUARE_BRACKET === $token['code'] || \T_CLOSE_SHORT_ARRAY === $token['code'] ) {
+ $depth--;
+ }
+
+ if ( 0 === $depth && \T_COMMA === $token['code'] ) {
+ if ( ! empty( $current_param ) ) {
+ $params[] = $current_param;
+ $current_param = array();
+ }
+ } else {
+ if ( ! in_array( $token['code'], \PHP_CodeSniffer\Util\Tokens::$emptyTokens, true ) ) {
+ $current_param[] = $i;
+ }
+ }
+ }
+
+ if ( ! empty( $current_param ) ) {
+ $params[] = $current_param;
+ }
+
+ return $params;
+ }
+
+ /**
+ * Process function calls and hooks.
+ *
+ * @param int $stackPtr Token pointer.
+ */
+ private function process_string( $stackPtr ) {
+ // Verify if this is a function call.
+ $prev = $this->phpcsFile->findPrevious( \PHP_CodeSniffer\Util\Tokens::$emptyTokens, $stackPtr - 1, null, true );
+ if ( false !== $prev && in_array( $this->tokens[ $prev ]['code'], array( \T_OBJECT_OPERATOR, \T_DOUBLE_COLON, \T_FUNCTION, \T_NEW ), true ) ) {
+ return;
+ }
+
+ $next = $this->phpcsFile->findNext( \PHP_CodeSniffer\Util\Tokens::$emptyTokens, $stackPtr + 1, null, true );
+ if ( false === $next || \T_OPEN_PARENTHESIS !== $this->tokens[ $next ]['code'] ) {
+ return;
+ }
+
+ $opener = $next;
+ $closer = $this->tokens[ $opener ]['parenthesis_closer'];
+ $function_name = strtolower( $this->tokens[ $stackPtr ]['content'] );
+
+ // Case 2: Calling plugin/theme integration functions without function_exists guard.
+ if ( in_array( $function_name, $this->integration_functions, true ) ) {
+ if ( ! $this->is_guarded_by_condition( $stackPtr, array( 'function_exists' ), $this->tokens[ $stackPtr ]['content'] ) ) {
+ $this->phpcsFile->addError(
+ 'Calling optional integration function "%s()" without a function_exists() guard is risky and could cause a runtime fatal error.',
+ $stackPtr,
+ 'OptionalFunctionCallUnguarded',
+ array( $this->tokens[ $stackPtr ]['content'] )
+ );
+ }
+ return;
+ }
+
+ // Case 4 extra: call_user_func / call_user_func_array with dynamic variables.
+ if ( in_array( $function_name, array( 'call_user_func', 'call_user_func_array' ), true ) ) {
+ $params = $this->get_function_parameters( $opener, $closer );
+ if ( isset( $params[0] ) && ! empty( $params[0] ) ) {
+ $first_param_ptr = $params[0][0];
+ if ( \T_VARIABLE === $this->tokens[ $first_param_ptr ]['code'] ) {
+ $var_name = $this->tokens[ $first_param_ptr ]['content'];
+ if ( ! $this->is_guarded_by_condition( $stackPtr, array( 'is_callable', 'function_exists' ), $var_name ) ) {
+ $this->phpcsFile->addError(
+ 'Invoking dynamic callback "%s" via %s() without an is_callable() or function_exists() guard is risky and could cause a runtime fatal error.',
+ $stackPtr,
+ 'DynamicCallbackCallUserFuncUnguarded',
+ array( $var_name, $this->tokens[ $stackPtr ]['content'] )
+ );
+ }
+ }
+ }
+ return;
+ }
+
+ // Case 5: Hooking callbacks that may not exist (array/class method) without method_exists / class guard.
+ if ( in_array( $function_name, array( 'add_action', 'add_filter' ), true ) ) {
+ $params = $this->get_function_parameters( $opener, $closer );
+ if ( isset( $params[1] ) && ! empty( $params[1] ) ) {
+ $callback_param_tokens = $params[1];
+ $first_token_ptr = $callback_param_tokens[0];
+ $start_token = $this->tokens[ $first_token_ptr ];
+
+ // Check if the callback parameter is an array definition.
+ $is_array = ( \T_ARRAY === $start_token['code'] || \T_OPEN_SHORT_ARRAY === $start_token['code'] );
+ if ( $is_array ) {
+ $elements = array();
+ // Extract significant tokens inside the array.
+ foreach ( $callback_param_tokens as $t_ptr ) {
+ if ( in_array( $this->tokens[ $t_ptr ]['code'], array( \T_VARIABLE, \T_CONSTANT_ENCAPSED_STRING, \T_STRING ), true ) ) {
+ $elements[] = $this->tokens[ $t_ptr ];
+ }
+ }
+
+ // A valid class/method hook array callback has exactly two elements.
+ if ( 2 === count( $elements ) ) {
+ $class_element = $elements[0];
+ $method_element = $elements[1];
+
+ // If the method is a string literal.
+ if ( \T_CONSTANT_ENCAPSED_STRING === $method_element['code'] ) {
+ $method_name = trim( $method_element['content'], '\'"' );
+ $class_var = $class_element['content'];
+
+ // If hooking onto $this.
+ if ( '$this' === $class_var ) {
+ $class_ptr = $this->phpcsFile->getCondition( $stackPtr, \T_CLASS );
+ if ( false !== $class_ptr ) {
+ $class_methods = $this->get_class_methods( $class_ptr );
+ if ( ! in_array( strtolower( $method_name ), $class_methods, true ) ) {
+ // If it's not guarded.
+ if ( ! $this->is_guarded_by_condition( $stackPtr, array( 'method_exists', 'is_callable' ), $method_name ) ) {
+ // Check if the class extends a parent class.
+ $extends = $this->class_extends( $class_ptr );
+ if ( $extends ) {
+ // Warning if it could be defined in a parent class.
+ $this->phpcsFile->addWarning(
+ 'Hooked callback method "%s" is not defined in this class (but the class extends a parent class). Ensure it exists or is guarded.',
+ $stackPtr,
+ 'HookedCallbackMethodNotFoundWarning',
+ array( $method_name )
+ );
+ } else {
+ // Error if the class definitely does not define the method.
+ $this->phpcsFile->addError(
+ 'Hooked callback method "%s" is not defined in this class. Hooking non-existent methods will cause runtime fatal errors or notices.',
+ $stackPtr,
+ 'HookedCallbackMethodNotFound',
+ array( $method_name )
+ );
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Helper to get all methods defined in the class.
+ *
+ * @param int $class_ptr The class token pointer.
+ * @return array Lowercase method names.
+ */
+ private function get_class_methods( $class_ptr ) {
+ $methods = array();
+ if ( ! isset( $this->tokens[ $class_ptr ]['scope_closer'] ) ) {
+ return $methods;
+ }
+
+ $closer = $this->tokens[ $class_ptr ]['scope_closer'];
+ $next = $class_ptr + 1;
+
+ while ( $next < $closer ) {
+ $next = $this->phpcsFile->findNext( \T_FUNCTION, $next, $closer );
+ if ( false === $next ) {
+ break;
+ }
+ $name = $this->phpcsFile->getDeclarationName( $next );
+ if ( $name ) {
+ $methods[] = strtolower( $name );
+ }
+ $next++;
+ }
+
+ return $methods;
+ }
+
+ /**
+ * Helper to check if a class extends a parent class.
+ *
+ * @param int $class_ptr Class token pointer.
+ * @return bool True if class extends another class.
+ */
+ private function class_extends( $class_ptr ) {
+ if ( ! isset( $this->tokens[ $class_ptr ]['scope_opener'] ) ) {
+ return false;
+ }
+ $opener = $this->tokens[ $class_ptr ]['scope_opener'];
+ $extends = $this->phpcsFile->findNext( \T_EXTENDS, $class_ptr + 1, $opener );
+ return ( false !== $extends );
+ }
+
+ /**
+ * Verify if the dynamic action is nested inside a conditional guard.
+ *
+ * @param int $stackPtr Token pointer.
+ * @param array $functions Allowed guard functions (e.g. file_exists, class_exists).
+ * @param string $identifier Target variable/class/function name.
+ * @return bool True if guarded.
+ */
+ private function is_guarded_by_condition( $stackPtr, array $functions, $identifier ) {
+ if ( empty( $this->tokens[ $stackPtr ]['conditions'] ) ) {
+ return false;
+ }
+
+ // Normalize identifier for comparison (e.g. remove backslashes, quotes, or dollar signs).
+ $clean_id = trim( str_replace( array( '\\', '$', '\'', '"' ), '', $identifier ) );
+ if ( '' === $clean_id ) {
+ return false;
+ }
+
+ foreach ( array_keys( $this->tokens[ $stackPtr ]['conditions'] ) as $condPtr ) {
+ if ( in_array( $this->tokens[ $condPtr ]['code'], array( \T_IF, \T_ELSEIF ), true ) ) {
+ if ( ! isset( $this->tokens[ $condPtr ]['parenthesis_opener'] ) || ! isset( $this->tokens[ $condPtr ]['parenthesis_closer'] ) ) {
+ continue;
+ }
+ $opener = $this->tokens[ $condPtr ]['parenthesis_opener'];
+ $closer = $this->tokens[ $condPtr ]['parenthesis_closer'];
+
+ $condText = $this->phpcsFile->getTokensAsString( $opener, $closer - $opener + 1 );
+
+ foreach ( $functions as $func ) {
+ if ( false !== stripos( $condText, $func ) ) {
+ // Ensure the condition matches our identifier as well.
+ if ( false !== stripos( str_replace( array( '\\', '$', '\'', '"' ), '', $condText ), $clean_id ) ) {
+ return true;
+ }
+ }
+ }
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/phpcs-sniffs/PluginCheck/Tests/CodeAnalysis/RuntimeFatalErrorPreventionUnitTest.inc b/phpcs-sniffs/PluginCheck/Tests/CodeAnalysis/RuntimeFatalErrorPreventionUnitTest.inc
new file mode 100644
index 000000000..4f541bbaf
--- /dev/null
+++ b/phpcs-sniffs/PluginCheck/Tests/CodeAnalysis/RuntimeFatalErrorPreventionUnitTest.inc
@@ -0,0 +1,65 @@
+ =>
+ */
+ public function getErrorList() {
+ return array(
+ 16 => 1,
+ 23 => 1,
+ 30 => 1,
+ 36 => 1,
+ 47 => 1,
+ );
+ }
+
+ /**
+ * Returns the lines where warnings should occur.
+ *
+ * @return array =>
+ */
+ public function getWarningList() {
+ return array(
+ 5 => 1,
+ 59 => 1,
+ );
+ }
+
+ /**
+ * 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 RuntimeFatalErrorPreventionSniff::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/tests/phpunit/testdata/plugins/test-plugin-runtime-fatal-error-prevention-with-errors/load.php b/tests/phpunit/testdata/plugins/test-plugin-runtime-fatal-error-prevention-with-errors/load.php
new file mode 100644
index 000000000..5c49ec62a
--- /dev/null
+++ b/tests/phpunit/testdata/plugins/test-plugin-runtime-fatal-error-prevention-with-errors/load.php
@@ -0,0 +1,38 @@
+run( $check_result );
+
+ $errors = $check_result->get_errors();
+ $warnings = $check_result->get_warnings();
+
+ $this->assertNotEmpty( $errors );
+ $this->assertArrayHasKey( 'load.php', $errors );
+ $this->assertNotEmpty( $warnings );
+ $this->assertArrayHasKey( 'load.php', $warnings );
+
+ // OptionalFunctionCallUnguarded error at line 20.
+ $line_20_errors = array();
+ if ( isset( $errors['load.php'][20] ) ) {
+ foreach ( $errors['load.php'][20] as $column => $msgs ) {
+ $line_20_errors = array_merge( $line_20_errors, $msgs );
+ }
+ }
+ $this->assertCount( 1, wp_list_filter( $line_20_errors, array( 'code' => 'PluginCheck.CodeAnalysis.RuntimeFatalErrorPrevention.OptionalFunctionCallUnguarded' ) ) );
+
+ // OptionalClassInstantiationUnguarded error at line 22.
+ $line_22_errors = array();
+ if ( isset( $errors['load.php'][22] ) ) {
+ foreach ( $errors['load.php'][22] as $column => $msgs ) {
+ $line_22_errors = array_merge( $line_22_errors, $msgs );
+ }
+ }
+ $this->assertCount( 1, wp_list_filter( $line_22_errors, array( 'code' => 'PluginCheck.CodeAnalysis.RuntimeFatalErrorPrevention.OptionalClassInstantiationUnguarded' ) ) );
+
+ // DynamicCallbackInvocationUnguarded error at line 24.
+ $line_24_errors = array();
+ if ( isset( $errors['load.php'][24] ) ) {
+ foreach ( $errors['load.php'][24] as $column => $msgs ) {
+ $line_24_errors = array_merge( $line_24_errors, $msgs );
+ }
+ }
+ $this->assertCount( 1, wp_list_filter( $line_24_errors, array( 'code' => 'PluginCheck.CodeAnalysis.RuntimeFatalErrorPrevention.DynamicCallbackInvocationUnguarded' ) ) );
+
+ // DynamicCallbackCallUserFuncUnguarded error at line 26.
+ $line_26_errors = array();
+ if ( isset( $errors['load.php'][26] ) ) {
+ foreach ( $errors['load.php'][26] as $column => $msgs ) {
+ $line_26_errors = array_merge( $line_26_errors, $msgs );
+ }
+ }
+ $this->assertCount( 1, wp_list_filter( $line_26_errors, array( 'code' => 'PluginCheck.CodeAnalysis.RuntimeFatalErrorPrevention.DynamicCallbackCallUserFuncUnguarded' ) ) );
+
+ // HookedCallbackMethodNotFound error at line 30.
+ $line_30_errors = array();
+ if ( isset( $errors['load.php'][30] ) ) {
+ foreach ( $errors['load.php'][30] as $column => $msgs ) {
+ $line_30_errors = array_merge( $line_30_errors, $msgs );
+ }
+ }
+ $this->assertCount( 1, wp_list_filter( $line_30_errors, array( 'code' => 'PluginCheck.CodeAnalysis.RuntimeFatalErrorPrevention.HookedCallbackMethodNotFound' ) ) );
+
+ // DynamicImportUnguarded warning at line 18.
+ $line_18_warnings = array();
+ if ( isset( $warnings['load.php'][18] ) ) {
+ foreach ( $warnings['load.php'][18] as $column => $msgs ) {
+ $line_18_warnings = array_merge( $line_18_warnings, $msgs );
+ }
+ }
+ $this->assertCount( 1, wp_list_filter( $line_18_warnings, array( 'code' => 'PluginCheck.CodeAnalysis.RuntimeFatalErrorPrevention.DynamicImportUnguarded' ) ) );
+
+ // HookedCallbackMethodNotFoundWarning warning at line 36.
+ $line_36_warnings = array();
+ if ( isset( $warnings['load.php'][36] ) ) {
+ foreach ( $warnings['load.php'][36] as $column => $msgs ) {
+ $line_36_warnings = array_merge( $line_36_warnings, $msgs );
+ }
+ }
+ $this->assertCount( 1, wp_list_filter( $line_36_warnings, array( 'code' => 'PluginCheck.CodeAnalysis.RuntimeFatalErrorPrevention.HookedCallbackMethodNotFoundWarning' ) ) );
+ }
+
+ public function test_run_without_errors() {
+ $check = new Runtime_Fatal_Error_Prevention_Check();
+ $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-runtime-fatal-error-prevention-without-errors/load.php' );
+ $check_result = new Check_Result( $check_context );
+
+ $check->run( $check_result );
+
+ $errors = $check_result->get_errors();
+ $warnings = $check_result->get_warnings();
+
+ $this->assertEmpty( $errors );
+ $this->assertEmpty( $warnings );
+ }
+}