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..7720f25f2 --- /dev/null +++ b/docs/plugin-check-info.md @@ -0,0 +1,16 @@ +# 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. diff --git a/includes/Checker/Abstract_Check_Runner.php b/includes/Checker/Abstract_Check_Runner.php index 8f66010ac..8ca38efa5 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; /** @@ -444,6 +445,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 +476,33 @@ 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 ) { + $third_party_paths = Plugin_Config::get_third_party_paths( $this->get_check_context()->path() ); + + if ( empty( $third_party_paths ) ) { + return; + } + + $results->transform_messages( + function ( $message, $is_error, $file ) use ( $third_party_paths ) { + if ( ! $is_error && Plugin_Config::is_third_party_file( $file, $third_party_paths ) ) { + return false; + } + + return $message; + } + ); + } + /** * Determines if any of the checks are a runtime check. * 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() ); + } + 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..95466ba32 100644 --- a/tests/phpunit/tests/Checker/Check_Result_Tests.php +++ b/tests/phpunit/tests/Checker/Check_Result_Tests.php @@ -188,4 +188,32 @@ 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() ); + } } 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 ), + ); + } +}