diff --git a/docs/README.md b/docs/README.md index 0e509769f..23e57419b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ * [Available Checks](checks.md) * [AI-Powered Features & Configuration](ai-features.md) * [WordPress Functions Compatibility Data](wp-functions-compatibility-data.md) +* [Plugin Check manifest](plugin-check-info.md) * [CLI Commands](CLI.md) * [Running Unit tests](running-unit-tests.md) * [Releasing a New Version of Plugin](releasing.md) diff --git a/docs/plugin-check-info.md b/docs/plugin-check-info.md new file mode 100644 index 000000000..460a66ce1 --- /dev/null +++ b/docs/plugin-check-info.md @@ -0,0 +1,34 @@ +[Back to overview](./README.md) + +# Plugin Check manifest + +Plugin authors can add `plugin-check-info.json` to plugin root to identify bundled third-party code. + +```json +{ + "third_parties": [ + "vendor/phpseclib", + "libraries/legacy" + ] +} +``` + +Plugin Check keeps errors from declared paths, but hides warning-level findings for those paths. This reduces recommendations intended for plugin authors, such as replacing a library's native PHP function with a WordPress wrapper, without hiding possible errors. Findings outside declared paths remain unchanged. + +Manifest is committed with plugin code, so reviewers can inspect declarations. Missing, malformed, or invalid manifest entries are ignored. Paths are relative to plugin root and use `/` separators. Entries match their declared path and files below it, not similarly named paths. + +## Visibility + +Suppressed warnings are reported, not silently dropped. In CLI table output, Plugin Check shows a notice with the number of suppressed warnings. The AJAX check response includes the `suppressed_warnings` count. + +## Opting out + +You can disable manifest-based suppression to show all warnings, including from declared third-party paths: + +- **CLI**: `wp plugin check --ignore-third-party-warnings`. +- **REST/AJAX**: pass `ignore-third-party-warnings=1` in the check request body. +- **Programmatic**: return `true` from the `wp_plugin_check_ignore_third_party_warnings` filter after the runner is created: + +```php +add_filter( 'wp_plugin_check_ignore_third_party_warnings', '__return_true' ); +``` diff --git a/includes/Admin/Admin_AJAX.php b/includes/Admin/Admin_AJAX.php index eef695078..69374019a 100644 --- a/includes/Admin/Admin_AJAX.php +++ b/includes/Admin/Admin_AJAX.php @@ -295,6 +295,7 @@ public function run_checks() { $include_experimental = 1 === filter_input( INPUT_POST, 'include-experimental', FILTER_VALIDATE_INT ); $use_ai = 1 === filter_input( INPUT_POST, 'use-ai', FILTER_VALIDATE_INT ); + $ignore_third_party = 1 === filter_input( INPUT_POST, 'ignore-third-party-warnings', FILTER_VALIDATE_INT ); $types = filter_input( INPUT_POST, 'types', FILTER_DEFAULT, FILTER_FORCE_ARRAY ); $types = is_null( $types ) ? array( 'error', 'warning' ) : $types; @@ -303,6 +304,7 @@ public function run_checks() { $runner->set_check_slugs( $checks ); $runner->set_plugin( $plugin ); $runner->set_use_ai( $use_ai ); + $runner->set_ignore_third_party_warnings( $ignore_third_party ); $results = $runner->run(); } catch ( Exception $error ) { wp_send_json_error( @@ -339,9 +341,10 @@ public function run_checks() { */ private function prepare_results_response( $results, array $types ) { $response = array( - 'message' => __( 'Checks run successfully', 'plugin-check' ), - 'errors' => array(), - 'warnings' => array(), + 'message' => __( 'Checks run successfully', 'plugin-check' ), + 'errors' => array(), + 'warnings' => array(), + 'suppressed_warnings' => $results->get_third_party_warning_filtered_count(), ); if ( in_array( 'error', $types, true ) ) { diff --git a/includes/CLI/Plugin_Check_Command.php b/includes/CLI/Plugin_Check_Command.php index fd10abbef..bc56b8e76 100644 --- a/includes/CLI/Plugin_Check_Command.php +++ b/includes/CLI/Plugin_Check_Command.php @@ -110,6 +110,9 @@ public function __construct( Plugin_Context $plugin_context ) { * [--ignore-errors] * : Limit displayed results to exclude errors. * + * [--ignore-third-party-warnings] + * : Do not suppress warnings from paths declared in plugin-check-info.json. + * * [--include-experimental] * : Include experimental checks. * @@ -190,6 +193,7 @@ public function check( $args, $assoc_args ) { 'format' => 'table', 'ignore-warnings' => false, 'ignore-errors' => false, + 'ignore-third-party-warnings' => false, 'include-experimental' => false, 'severity' => '', 'error-severity' => '', @@ -262,6 +266,7 @@ static function ( $dirs ) use ( $excluded_files ) { $runner->set_slug( $options['slug'] ); $runner->set_mode( $options['mode'] ); $runner->set_use_ai( $options['ai'] ); + $runner->set_ignore_third_party_warnings( (bool) $options['ignore-third-party-warnings'] ); if ( ! empty( $options['ai-model'] ) ) { $runner->set_ai_model_preference( $options['ai-model'] ); } @@ -281,6 +286,25 @@ static function ( $dirs ) use ( $excluded_files ) { Plugin_Request_Utility::destroy_runner(); + // Warn about third-party warnings suppressed by the manifest in human-readable table output. + if ( $result && 'table' === $options['format'] && empty( $options['ignore-third-party-warnings'] ) ) { + $filtered_count = $result->get_third_party_warning_filtered_count(); + if ( $filtered_count > 0 ) { + WP_CLI::warning( + sprintf( + /* translators: %d: number of suppressed warnings. */ + _n( + '%d warning was suppressed from paths declared in plugin-check-info.json (use --ignore-third-party-warnings to show it).', + '%d warnings were suppressed from paths declared in plugin-check-info.json (use --ignore-third-party-warnings to show them).', + $filtered_count, + 'plugin-check' + ), + $filtered_count + ) + ); + } + } + // Get errors and warnings from the results. $errors = array(); if ( $result && empty( $assoc_args['ignore-errors'] ) ) { diff --git a/includes/Checker/Abstract_Check_Runner.php b/includes/Checker/Abstract_Check_Runner.php index 8f66010ac..c4b54ecde 100644 --- a/includes/Checker/Abstract_Check_Runner.php +++ b/includes/Checker/Abstract_Check_Runner.php @@ -12,6 +12,7 @@ use WordPress\Plugin_Check\Checker\Exception\Invalid_Check_Slug_Exception; use WordPress\Plugin_Check\Checker\Preparations\Universal_Runtime_Preparation; use WordPress\Plugin_Check\Traits\AI_Analyzer; +use WordPress\Plugin_Check\Utilities\Plugin_Config; use WordPress\Plugin_Check\Utilities\Plugin_Request_Utility; /** @@ -42,6 +43,14 @@ abstract class Abstract_Check_Runner implements Check_Runner { */ protected $use_ai = false; + /** + * Whether to ignore manifest-based suppression of third-party warnings. + * + * @since 2.1.0 + * @var bool + */ + protected $ignore_third_party_warnings = false; + /** * AI model preference for analysis. * @@ -324,6 +333,17 @@ final public function set_use_ai( $use_ai ) { $this->use_ai = (bool) $use_ai; } + /** + * Sets whether to ignore manifest-based suppression of third-party warnings. + * + * @since 2.1.0 + * + * @param bool $ignore True to ignore the suppression and show all warnings, false to apply it. + */ + final public function set_ignore_third_party_warnings( $ignore ) { + $this->ignore_third_party_warnings = (bool) $ignore; + } + /** * Sets the AI model preference for analysis. * @@ -444,6 +464,8 @@ final public function run() { $results = $this->get_checks_instance()->run_checks( $this->get_check_context(), $checks, $this ); + $this->filter_third_party_warnings( $results ); + $ai_analysis = array(); $ai_stats = array(); @@ -473,6 +495,59 @@ final public function run() { return $results; } + /** + * Removes warning-level findings from declared third-party paths. + * + * Errors and findings outside declared paths are kept unchanged. + * + * @since 2.1.0 + * + * @param Check_Result $results Check results to filter, modified in place. + */ + private function filter_third_party_warnings( Check_Result $results ) { + if ( $this->get_ignore_third_party_warnings() ) { + return; + } + + $third_party_paths = Plugin_Config::get_third_party_paths( $this->get_check_context()->path() ); + + if ( empty( $third_party_paths ) ) { + return; + } + + $filtered = 0; + + $results->transform_messages( + function ( $message, $is_error, $file ) use ( $third_party_paths, &$filtered ) { + if ( ! $is_error && Plugin_Config::is_third_party_file( $file, $third_party_paths ) ) { + ++$filtered; + return false; + } + + return $message; + } + ); + + if ( $filtered > 0 ) { + $results->increment_third_party_warning_filtered_count( $filtered ); + } + } + + /** + * Determines whether manifest-based suppression of third-party warnings should be ignored. + * + * The suppression can be disabled via the runner setting or the + * `wp_plugin_check_ignore_third_party_warnings` filter. + * + * @since 2.1.0 + * + * @return bool True to ignore the suppression and show all warnings, false to apply it. + */ + private function get_ignore_third_party_warnings() { + return $this->ignore_third_party_warnings + || (bool) apply_filters( 'wp_plugin_check_ignore_third_party_warnings', false ); + } + /** * Determines if any of the checks are a runtime check. * diff --git a/includes/Checker/Check_Result.php b/includes/Checker/Check_Result.php index b2f43e033..9a868a382 100644 --- a/includes/Checker/Check_Result.php +++ b/includes/Checker/Check_Result.php @@ -56,6 +56,14 @@ final class Check_Result { */ protected $warning_count = 0; + /** + * Number of warnings suppressed from declared third-party paths. + * + * @since 2.1.0 + * @var int + */ + protected $third_party_warning_filtered_count = 0; + /** * AI analysis results for false positives. * @@ -260,6 +268,28 @@ public function get_warning_count() { return $this->warning_count; } + /** + * Increments the number of warnings suppressed from declared third-party paths. + * + * @since 2.1.0 + * + * @param int $count Number of suppressed warnings to add. Default 1. + */ + public function increment_third_party_warning_filtered_count( $count = 1 ) { + $this->third_party_warning_filtered_count += (int) $count; + } + + /** + * Returns the number of warnings suppressed from declared third-party paths. + * + * @since 2.1.0 + * + * @return int Number of suppressed warnings. + */ + public function get_third_party_warning_filtered_count() { + return $this->third_party_warning_filtered_count; + } + /** * Sets AI analysis results. * diff --git a/includes/Utilities/Plugin_Config.php b/includes/Utilities/Plugin_Config.php new file mode 100644 index 000000000..b738601ae --- /dev/null +++ b/includes/Utilities/Plugin_Config.php @@ -0,0 +1,113 @@ +add_message( + false, + 'Warning message', + array( + 'code' => 'check_warning', + 'file' => 'vendor/phpseclib/file.php', + ) + ); + $check_result->add_message( + false, + 'Outside warning message', + array( + 'code' => 'check_warning_outside', + 'file' => 'includes/file.php', + ) + ); + $check_result->add_message( + true, + 'Error message', + array( + 'code' => 'check_error', + 'file' => 'vendor/phpseclib/file.php', + ) + ); + } + + public function get_categories() { + return array( Check_Categories::CATEGORY_GENERAL ); + } + + public function get_description(): string { + return ''; + } + + public function get_documentation_url(): string { + return ''; + } +} diff --git a/tests/phpunit/testdata/plugins/test-plugin-plugin-check-info-invalid/load.php b/tests/phpunit/testdata/plugins/test-plugin-plugin-check-info-invalid/load.php new file mode 100644 index 000000000..51c490e05 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-plugin-check-info-invalid/load.php @@ -0,0 +1,4 @@ +assertNotEmpty( $results->get_errors() ); } + public function test_run_filters_third_party_warnings() { + $_SERVER['argv'] = array( + 'wp', + 'plugin', + 'check', + UNIT_TESTS_PLUGIN_DIR . 'test-plugin-plugin-check-info', + '--checks=warning-check', + ); + + add_filter( + 'wp_plugin_check_checks', + function () { + return array( 'warning-check' => new Warning_Check() ); + } + ); + + $runner = new CLI_Runner(); + $cleanup = $runner->prepare(); + $this->cleanups[] = $cleanup; + $results = $runner->run(); + + $this->assertArrayNotHasKey( 'vendor/phpseclib/file.php', $results->get_warnings() ); + $this->assertArrayHasKey( 'includes/file.php', $results->get_warnings() ); + $this->assertArrayHasKey( 'vendor/phpseclib/file.php', $results->get_errors() ); + $this->assertSame( 1, $results->get_third_party_warning_filtered_count() ); + } + + public function test_run_ignores_third_party_filter_with_setter() { + $_SERVER['argv'] = array( + 'wp', + 'plugin', + 'check', + UNIT_TESTS_PLUGIN_DIR . 'test-plugin-plugin-check-info', + '--checks=warning-check', + ); + + add_filter( + 'wp_plugin_check_checks', + function () { + return array( 'warning-check' => new Warning_Check() ); + } + ); + + $runner = new CLI_Runner(); + $cleanup = $runner->prepare(); + $this->cleanups[] = $cleanup; + + $runner->set_ignore_third_party_warnings( true ); + $results = $runner->run(); + + $this->assertArrayHasKey( 'vendor/phpseclib/file.php', $results->get_warnings() ); + $this->assertArrayHasKey( 'includes/file.php', $results->get_warnings() ); + $this->assertArrayHasKey( 'vendor/phpseclib/file.php', $results->get_errors() ); + $this->assertSame( 0, $results->get_third_party_warning_filtered_count() ); + } + + public function test_run_ignores_third_party_filter_with_hook() { + $_SERVER['argv'] = array( + 'wp', + 'plugin', + 'check', + UNIT_TESTS_PLUGIN_DIR . 'test-plugin-plugin-check-info', + '--checks=warning-check', + ); + + add_filter( + 'wp_plugin_check_checks', + function () { + return array( 'warning-check' => new Warning_Check() ); + } + ); + + add_filter( 'wp_plugin_check_ignore_third_party_warnings', '__return_true' ); + + $runner = new CLI_Runner(); + $cleanup = $runner->prepare(); + $this->cleanups[] = $cleanup; + $results = $runner->run(); + + $this->assertArrayHasKey( 'vendor/phpseclib/file.php', $results->get_warnings() ); + $this->assertArrayHasKey( 'includes/file.php', $results->get_warnings() ); + $this->assertArrayHasKey( 'vendor/phpseclib/file.php', $results->get_errors() ); + $this->assertSame( 0, $results->get_third_party_warning_filtered_count() ); + } + public function test_runner_initialized_early_throws_plugin_basename_exception() { global $wp_actions; diff --git a/tests/phpunit/tests/Checker/Check_Result_Tests.php b/tests/phpunit/tests/Checker/Check_Result_Tests.php index 3015eddc9..fc13e5e0a 100644 --- a/tests/phpunit/tests/Checker/Check_Result_Tests.php +++ b/tests/phpunit/tests/Checker/Check_Result_Tests.php @@ -188,4 +188,41 @@ public function test_get_error_count_with_message() { $this->assertEquals( 1, $this->check_result->get_error_count() ); } + + public function test_transform_messages_removes_messages_and_updates_counts() { + $this->check_result->add_message( + false, + 'Third-party warning', + array( + 'file' => 'test-plugin/vendor/library/file.php', + ) + ); + $this->check_result->add_message( + true, + 'Third-party error', + array( + 'file' => 'test-plugin/vendor/library/file.php', + ) + ); + + $this->check_result->transform_messages( + function ( $message, $is_error, $file ) { + return $is_error || 0 !== strpos( $file, 'vendor/library/' ) ? $message : false; + } + ); + + $this->assertSame( 0, $this->check_result->get_warning_count() ); + $this->assertSame( 1, $this->check_result->get_error_count() ); + $this->assertEmpty( $this->check_result->get_warnings() ); + $this->assertNotEmpty( $this->check_result->get_errors() ); + } + + public function test_third_party_warning_filtered_count_increments() { + $this->assertSame( 0, $this->check_result->get_third_party_warning_filtered_count() ); + + $this->check_result->increment_third_party_warning_filtered_count(); + $this->check_result->increment_third_party_warning_filtered_count(); + + $this->assertSame( 2, $this->check_result->get_third_party_warning_filtered_count() ); + } } diff --git a/tests/phpunit/tests/Utilities/Plugin_Config_Tests.php b/tests/phpunit/tests/Utilities/Plugin_Config_Tests.php new file mode 100644 index 000000000..9ab1b2ef3 --- /dev/null +++ b/tests/phpunit/tests/Utilities/Plugin_Config_Tests.php @@ -0,0 +1,43 @@ +assertSame( array( 'vendor/phpseclib', 'libraries/legacy' ), $paths ); + } + + public function test_get_third_party_paths_ignores_missing_config() { + $this->assertSame( array(), Plugin_Config::get_third_party_paths( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-wp-functions-compatibility-with-errors' ) ); + } + + public function test_get_third_party_paths_ignores_invalid_config() { + $this->assertSame( array(), Plugin_Config::get_third_party_paths( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-plugin-check-info-invalid' ) ); + } + + /** + * @dataProvider third_party_file_provider + */ + public function test_is_third_party_file( $file, $expected ) { + $this->assertSame( $expected, Plugin_Config::is_third_party_file( $file, array( 'vendor/phpseclib' ) ) ); + } + + public function third_party_file_provider() { + return array( + 'in declared directory' => array( 'vendor/phpseclib/Crypt/Hash.php', true ), + 'declared file' => array( 'vendor/phpseclib', true ), + 'near matching directory' => array( 'vendor/phpseclib2/Crypt/Hash.php', false ), + 'outside directory' => array( 'includes/Plugin.php', false ), + 'normalizes backslashes' => array( 'vendor\\phpseclib\\Crypt\\Hash.php', true ), + 'rejects traversal' => array( 'vendor/phpseclib/../other.php', false ), + ); + } +}