From 43a45785e168ff1dd704905892cca8102c30caef Mon Sep 17 00:00:00 2001 From: davidperezgar Date: Tue, 1 Sep 2026 17:19:41 +0200 Subject: [PATCH 1/5] feat: add opt-in pcpignore exclusions --- assets/js/plugin-check-admin.js | 42 ++++++++-- docs/CLI.md | 7 ++ includes/Admin/Admin_AJAX.php | 4 + includes/CLI/Plugin_Check_Command.php | 9 +++ includes/Utilities/Plugin_Request_Utility.php | 81 +++++++++++++++++++ templates/admin-page.php | 6 ++ tests/behat/features/plugin-check.feature | 36 +++++++++ .../plugins/test-plugin-pcpignore/.pcpignore | 4 + .../plugins/test-plugin-pcpignore/load.php | 6 ++ .../Plugin_Request_Utility_Tests.php | 29 +++++++ 10 files changed, 216 insertions(+), 8 deletions(-) create mode 100644 tests/phpunit/testdata/plugins/test-plugin-pcpignore/.pcpignore create mode 100644 tests/phpunit/testdata/plugins/test-plugin-pcpignore/load.php diff --git a/assets/js/plugin-check-admin.js b/assets/js/plugin-check-admin.js index e75220958..9e79555ff 100644 --- a/assets/js/plugin-check-admin.js +++ b/assets/js/plugin-check-admin.js @@ -38,6 +38,9 @@ 'plugin-check__include-experimental' ); const useAi = document.getElementById( 'plugin-check__use-ai' ); + const usePcpignore = document.getElementById( + 'plugin-check__use-pcpignore' + ); // Handle disabling the Check it button when a plugin is not selected. function canRunChecks() { @@ -141,6 +144,9 @@ if ( useAi ) { useAi.disabled = true; } + if ( usePcpignore ) { + usePcpignore.disabled = true; + } if ( includeExperimental ) { includeExperimental.disabled = true; } @@ -149,6 +155,8 @@ const categories = getSelectedValues( categoriesList ); const types = getSelectedValues( typesList ); const useAiChecked = useAi && useAi.checked ? 1 : 0; + const usePcpignoreChecked = + usePcpignore && usePcpignore.checked ? 1 : 0; const includeExperimentalChecked = includeExperimental && includeExperimental.checked ? 1 : 0; let currentChecks; @@ -157,7 +165,8 @@ plugin, categories, includeExperimentalChecked, - useAiChecked + useAiChecked, + usePcpignoreChecked ) .then( ( data ) => { currentChecks = data.checks; @@ -165,7 +174,8 @@ plugin, currentChecks, includeExperimentalChecked, - useAiChecked + useAiChecked, + usePcpignoreChecked ); } ) .then( () => @@ -174,7 +184,8 @@ currentChecks, types, includeExperimentalChecked, - useAiChecked + useAiChecked, + usePcpignoreChecked ) ) .then( () => cleanUpEnvironment() ) @@ -224,6 +235,9 @@ if ( useAi ) { useAi.disabled = false; } + if ( usePcpignore ) { + usePcpignore.disabled = false; + } if ( includeExperimental ) { includeExperimental.disabled = false; } @@ -639,13 +653,15 @@ * @param {Array} checks Check slugs that will run. * @param {number} includeExperimentalInput Whether to include experimental checks. * @param {number} useAiInput Whether to enable AI analysis. + * @param {number} usePcpignoreInput Whether to apply .pcpignore exclusions. * @return {Promise} Resolves with the response message. */ function setUpEnvironment( plugin, checks, includeExperimentalInput, - useAiInput + useAiInput, + usePcpignoreInput ) { const pluginCheckData = new FormData(); pluginCheckData.append( 'plugin', plugin ); @@ -658,6 +674,7 @@ includeExperimentalInput ); pluginCheckData.append( 'use-ai', useAiInput ); + pluginCheckData.append( 'use-pcpignore', usePcpignoreInput ); for ( let i = 0; i < checks.length; i++ ) { pluginCheckData.append( 'checks[]', checks[ i ] ); @@ -706,13 +723,15 @@ * @param {Array} categories Selected category slugs. * @param {number} includeExperimentalInput Whether to include experimental checks. * @param {number} useAiInput Whether to enable AI analysis. + * @param {number} usePcpignoreInput Whether to apply .pcpignore exclusions. * @return {Promise} Resolves with the response containing plugin and checks. */ function getChecksToRun( plugin, categories, includeExperimentalInput, - useAiInput + useAiInput, + usePcpignoreInput ) { const pluginCheckData = new FormData(); pluginCheckData.append( 'plugin', plugin ); @@ -722,6 +741,7 @@ includeExperimentalInput ); pluginCheckData.append( 'use-ai', useAiInput ); + pluginCheckData.append( 'use-pcpignore', usePcpignoreInput ); for ( let i = 0; i < categories.length; i++ ) { pluginCheckData.append( 'categories[]', categories[ i ] ); @@ -746,13 +766,15 @@ * @param {Array} types Result types to include (error, warning). * @param {number} includeExperimentalInput Whether to include experimental checks. * @param {number} useAiInput Whether to enable AI analysis. + * @param {number} usePcpignoreInput Whether to apply .pcpignore exclusions. */ async function runChecks( plugin, checks, types, includeExperimentalInput, - useAiInput + useAiInput, + usePcpignoreInput ) { let isSuccessMessage = true; let aiStats = null; @@ -763,7 +785,8 @@ checks[ i ], types, includeExperimentalInput, - useAiInput + useAiInput, + usePcpignoreInput ); const splitResults = splitResultsByFalsePositive( results ); const errorsLength = countResultTree( @@ -1009,6 +1032,7 @@ * @param {Array} types Result types to include (error, warning). * @param {number} includeExperimentalInput Whether to include experimental checks. * @param {number} useAiInput Whether to enable AI analysis. + * @param {number} usePcpignoreInput Whether to apply .pcpignore exclusions. * @return {Promise} The check results. */ function runCheck( @@ -1016,7 +1040,8 @@ check, types, includeExperimentalInput, - useAiInput + useAiInput, + usePcpignoreInput ) { const pluginCheckData = new FormData(); pluginCheckData.append( 'plugin', plugin ); @@ -1027,6 +1052,7 @@ includeExperimentalInput ); pluginCheckData.append( 'use-ai', useAiInput ); + pluginCheckData.append( 'use-pcpignore', usePcpignoreInput ); for ( let i = 0; i < types.length; i++ ) { pluginCheckData.append( 'types[]', types[ i ] ); diff --git a/docs/CLI.md b/docs/CLI.md index f88cb410b..6fd66c31a 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -64,6 +64,12 @@ missing_composer_json_file; use `--ignore-codes` for specific result codes. This only excludes files from file-based scans. It does not suppress plugin-level findings such as missing_composer_json_file; use `--ignore-codes` for specific result codes. +[--use-pcpignore] +: Apply custom file and directory exclusions from a `.pcpignore` file in the plugin root. +Each non-empty, non-comment line is a path relative to that root. A trailing slash excludes a directory; +all other entries exclude files. This option is disabled by default and is intended for local and CI scans. +WordPress.org scans must not use this option. + [--severity=] : Severity level. @@ -106,6 +112,7 @@ wp plugin check akismet --ignore-codes=missing_composer_json_file wp plugin check akismet --format=json wp plugin check akismet --format=ctrf wp plugin check akismet --mode=update +wp plugin check akismet --use-pcpignore wp plugin check akismet --ai wp plugin check akismet --ai --ai-model=openai::gpt-4o ``` diff --git a/includes/Admin/Admin_AJAX.php b/includes/Admin/Admin_AJAX.php index 3d2b42216..8bd74e10c 100644 --- a/includes/Admin/Admin_AJAX.php +++ b/includes/Admin/Admin_AJAX.php @@ -285,6 +285,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 ); + $use_pcpignore = 1 === filter_input( INPUT_POST, 'use-pcpignore', FILTER_VALIDATE_INT ); $types = filter_input( INPUT_POST, 'types', FILTER_DEFAULT, FILTER_FORCE_ARRAY ); $types = is_null( $types ) ? array( 'error', 'warning' ) : $types; @@ -292,6 +293,9 @@ public function run_checks() { $runner->set_experimental_flag( $include_experimental ); $runner->set_check_slugs( $checks ); $runner->set_plugin( $plugin ); + if ( $use_pcpignore ) { + Plugin_Request_Utility::apply_pcpignore_exclusions( $runner->get_plugin_basename() ); + } $runner->set_use_ai( $use_ai ); $results = $runner->run(); } catch ( Exception $error ) { diff --git a/includes/CLI/Plugin_Check_Command.php b/includes/CLI/Plugin_Check_Command.php index fd10abbef..92f592311 100644 --- a/includes/CLI/Plugin_Check_Command.php +++ b/includes/CLI/Plugin_Check_Command.php @@ -124,6 +124,10 @@ public function __construct( Plugin_Context $plugin_context ) { * This only excludes files from file-based scans. It does not suppress plugin-level findings such as * missing_composer_json_file; use `--ignore-codes` for specific result codes. * + * [--use-pcpignore] + * : Apply custom file and directory exclusions from a .pcpignore file in the plugin root. + * This is intended for local and CI scans only and is disabled by default. + * * [--severity=] * : Severity level. * @@ -165,6 +169,7 @@ public function __construct( Plugin_Context $plugin_context ) { * wp plugin check akismet --ignore-codes=missing_composer_json_file * wp plugin check akismet --format=json * wp plugin check akismet --mode=update + * wp plugin check akismet --use-pcpignore * wp plugin check akismet --ai * wp plugin check akismet --ai --ai-model=openai::gpt-4o * @@ -201,6 +206,7 @@ public function check( $args, $assoc_args ) { 'mode' => 'new', 'ai' => false, 'ai-model' => '', + 'use-pcpignore' => false, ) ); @@ -258,6 +264,9 @@ static function ( $dirs ) use ( $excluded_files ) { $runner->set_experimental_flag( $options['include-experimental'] ); $runner->set_check_slugs( $checks ); $runner->set_plugin( $plugin ); + if ( $options['use-pcpignore'] ) { + Plugin_Request_Utility::apply_pcpignore_exclusions( $runner->get_plugin_basename() ); + } $runner->set_categories( $categories ); $runner->set_slug( $options['slug'] ); $runner->set_mode( $options['mode'] ); diff --git a/includes/Utilities/Plugin_Request_Utility.php b/includes/Utilities/Plugin_Request_Utility.php index 3451f8052..75c9f4f49 100644 --- a/includes/Utilities/Plugin_Request_Utility.php +++ b/includes/Utilities/Plugin_Request_Utility.php @@ -204,6 +204,87 @@ public static function get_files_to_ignore() { return $files_to_ignore; } + /** + * Gets the custom file and directory exclusions from a .pcpignore file. + * + * Each non-empty, non-comment line is treated as a path relative to the + * plugin root. A trailing slash denotes a directory; all other entries + * denote files. + * + * @since n.e.x.t + * + * @param string $plugin_path Plugin directory or main plugin file path. + * @return array{directories: array, files: array} Custom exclusions. + */ + public static function get_pcpignore_exclusions( $plugin_path ) { + $plugin_directory = is_dir( $plugin_path ) ? $plugin_path : dirname( $plugin_path ); + $ignore_file = trailingslashit( $plugin_directory ) . '.pcpignore'; + $exclusions = array( + 'directories' => array(), + 'files' => array(), + ); + + if ( ! is_readable( $ignore_file ) ) { + return $exclusions; + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $lines = file( $ignore_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY ); + + if ( false === $lines ) { + return $exclusions; + } + + foreach ( $lines as $line ) { + $line = trim( $line ); + + if ( '' === $line || '#' === substr( $line, 0, 1 ) ) { + continue; + } + + $line = ltrim( wp_normalize_path( $line ), '/' ); + + if ( '/' === substr( $line, -1 ) ) { + $exclusions['directories'][] = untrailingslashit( $line ); + } else { + $exclusions['files'][] = $line; + } + } + + $exclusions['directories'] = array_unique( $exclusions['directories'] ); + $exclusions['files'] = array_unique( $exclusions['files'] ); + + return $exclusions; + } + + /** + * Adds .pcpignore exclusions to the current scan. + * + * This method must only be called by an explicit local or CI opt-in. It + * does not run automatically, so WordPress.org scans retain every file. + * + * @since n.e.x.t + * + * @param string $plugin_path Plugin directory or main plugin file path. + */ + public static function apply_pcpignore_exclusions( $plugin_path ) { + $exclusions = self::get_pcpignore_exclusions( $plugin_path ); + + add_filter( + 'wp_plugin_check_ignore_directories', + static function ( $directories ) use ( $exclusions ) { + return array_unique( array_merge( $directories, $exclusions['directories'] ) ); + } + ); + + add_filter( + 'wp_plugin_check_ignore_files', + static function ( $files ) use ( $exclusions ) { + return array_unique( array_merge( $files, $exclusions['files'] ) ); + } + ); + } + /** * Returns the plugin basename after downloading and installing the plugin. * diff --git a/templates/admin-page.php b/templates/admin-page.php index c05968a74..6e3702589 100644 --- a/templates/admin-page.php +++ b/templates/admin-page.php @@ -86,6 +86,12 @@

+
+

+

+ +

+

diff --git a/tests/behat/features/plugin-check.feature b/tests/behat/features/plugin-check.feature index 903b132bc..761a54459 100644 --- a/tests/behat/features/plugin-check.feature +++ b/tests/behat/features/plugin-check.feature @@ -314,6 +314,42 @@ Feature: Test that the WP-CLI command works. FILE: subdirectory/error.php """ + Scenario: Apply .pcpignore exclusions only when requested + Given a WP install with the Plugin Check plugin + And an empty wp-content/plugins/foo-plugin directory + And an empty wp-content/plugins/foo-plugin/docs directory + And a wp-content/plugins/foo-plugin/foo-plugin.php file: + """ + assertEquals( $custom_ignore_files, $result ); } + public function test_get_pcpignore_exclusions() { + $plugin_directory = UNIT_TESTS_PLUGIN_DIR . 'test-plugin-pcpignore'; + + $exclusions = Plugin_Request_Utility::get_pcpignore_exclusions( $plugin_directory ); + + $this->assertSame( + array( 'docs', 'tests/fixtures' ), + $exclusions['directories'] + ); + $this->assertSame( + array( 'development-only.php' ), + $exclusions['files'] + ); + } + + public function test_get_pcpignore_exclusions_without_ignore_file() { + $plugin_directory = UNIT_TESTS_PLUGIN_DIR . 'test-plugin-ignore-files'; + + $exclusions = Plugin_Request_Utility::get_pcpignore_exclusions( $plugin_directory ); + + $this->assertSame( + array( + 'directories' => array(), + 'files' => array(), + ), + $exclusions + ); + } + public function test_plugin_without_error_for_ignore_directories() { $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-ignore-directories/load.php' ); From 7a1e46f51700829b037f544b19f2dcc8e3852df9 Mon Sep 17 00:00:00 2001 From: davidperezgar Date: Tue, 1 Sep 2026 17:24:41 +0200 Subject: [PATCH 2/5] fix: parse pcpignore directory entries --- includes/Utilities/Plugin_Request_Utility.php | 9 +++++---- .../tests/Utilities/Plugin_Request_Utility_Tests.php | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/includes/Utilities/Plugin_Request_Utility.php b/includes/Utilities/Plugin_Request_Utility.php index 75c9f4f49..526c6d89a 100644 --- a/includes/Utilities/Plugin_Request_Utility.php +++ b/includes/Utilities/Plugin_Request_Utility.php @@ -229,7 +229,7 @@ public static function get_pcpignore_exclusions( $plugin_path ) { } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents - $lines = file( $ignore_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY ); + $lines = file( $ignore_file, FILE_IGNORE_NEW_LINES ); if ( false === $lines ) { return $exclusions; @@ -242,9 +242,10 @@ public static function get_pcpignore_exclusions( $plugin_path ) { continue; } - $line = ltrim( wp_normalize_path( $line ), '/' ); + $is_directory = '/' === substr( $line, -1 ); + $line = ltrim( wp_normalize_path( $line ), '/' ); - if ( '/' === substr( $line, -1 ) ) { + if ( $is_directory ) { $exclusions['directories'][] = untrailingslashit( $line ); } else { $exclusions['files'][] = $line; @@ -252,7 +253,7 @@ public static function get_pcpignore_exclusions( $plugin_path ) { } $exclusions['directories'] = array_unique( $exclusions['directories'] ); - $exclusions['files'] = array_unique( $exclusions['files'] ); + $exclusions['files'] = array_unique( array_merge( $exclusions['files'], array( '.pcpignore' ) ) ); return $exclusions; } diff --git a/tests/phpunit/tests/Utilities/Plugin_Request_Utility_Tests.php b/tests/phpunit/tests/Utilities/Plugin_Request_Utility_Tests.php index b1a7ab83d..c073bd66f 100644 --- a/tests/phpunit/tests/Utilities/Plugin_Request_Utility_Tests.php +++ b/tests/phpunit/tests/Utilities/Plugin_Request_Utility_Tests.php @@ -318,7 +318,7 @@ public function test_get_pcpignore_exclusions() { $exclusions['directories'] ); $this->assertSame( - array( 'development-only.php' ), + array( 'development-only.php', '.pcpignore' ), $exclusions['files'] ); } From f90b5b64b4b09955565e7b26e8064c96dc3e6a11 Mon Sep 17 00:00:00 2001 From: davidperezgar Date: Tue, 1 Sep 2026 17:41:40 +0200 Subject: [PATCH 3/5] fix: resolve pcpignore plugin path --- includes/Admin/Admin_AJAX.php | 5 ++++- includes/CLI/Plugin_Check_Command.php | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/includes/Admin/Admin_AJAX.php b/includes/Admin/Admin_AJAX.php index 8bd74e10c..94d65d892 100644 --- a/includes/Admin/Admin_AJAX.php +++ b/includes/Admin/Admin_AJAX.php @@ -294,7 +294,10 @@ public function run_checks() { $runner->set_check_slugs( $checks ); $runner->set_plugin( $plugin ); if ( $use_pcpignore ) { - Plugin_Request_Utility::apply_pcpignore_exclusions( $runner->get_plugin_basename() ); + $plugin_path = $runner->get_plugin_basename(); + $plugin_path = is_dir( $plugin_path ) ? $plugin_path : WP_PLUGIN_DIR . '/' . $plugin_path; + + Plugin_Request_Utility::apply_pcpignore_exclusions( $plugin_path ); } $runner->set_use_ai( $use_ai ); $results = $runner->run(); diff --git a/includes/CLI/Plugin_Check_Command.php b/includes/CLI/Plugin_Check_Command.php index 92f592311..7973699e3 100644 --- a/includes/CLI/Plugin_Check_Command.php +++ b/includes/CLI/Plugin_Check_Command.php @@ -265,7 +265,10 @@ static function ( $dirs ) use ( $excluded_files ) { $runner->set_check_slugs( $checks ); $runner->set_plugin( $plugin ); if ( $options['use-pcpignore'] ) { - Plugin_Request_Utility::apply_pcpignore_exclusions( $runner->get_plugin_basename() ); + $plugin_path = $runner->get_plugin_basename(); + $plugin_path = is_dir( $plugin_path ) ? $plugin_path : WP_PLUGIN_DIR . '/' . $plugin_path; + + Plugin_Request_Utility::apply_pcpignore_exclusions( $plugin_path ); } $runner->set_categories( $categories ); $runner->set_slug( $options['slug'] ); From a9aa9f1c91d6c7076b6e415d8a494bfcc0bc9115 Mon Sep 17 00:00:00 2001 From: davidperezgar Date: Tue, 1 Sep 2026 17:42:57 +0200 Subject: [PATCH 4/5] refactor: isolate pcpignore exclusions --- includes/Admin/Admin_AJAX.php | 3 +- includes/CLI/Plugin_Check_Command.php | 3 +- includes/Utilities/PCP_Ignore_Utility.php | 98 +++++++++++++++++++ includes/Utilities/Plugin_Request_Utility.php | 82 ---------------- .../Plugin_Request_Utility_Tests.php | 5 +- 5 files changed, 105 insertions(+), 86 deletions(-) create mode 100644 includes/Utilities/PCP_Ignore_Utility.php diff --git a/includes/Admin/Admin_AJAX.php b/includes/Admin/Admin_AJAX.php index 94d65d892..7dd16ae35 100644 --- a/includes/Admin/Admin_AJAX.php +++ b/includes/Admin/Admin_AJAX.php @@ -12,6 +12,7 @@ use WordPress\Plugin_Check\Checker\AJAX_Runner; use WordPress\Plugin_Check\Checker\Runtime_Check; use WordPress\Plugin_Check\Checker\Runtime_Environment_Setup; +use WordPress\Plugin_Check\Utilities\PCP_Ignore_Utility; use WordPress\Plugin_Check\Utilities\Plugin_Request_Utility; use WordPress\Plugin_Check\Utilities\Results_Exporter; use WP_Error; @@ -297,7 +298,7 @@ public function run_checks() { $plugin_path = $runner->get_plugin_basename(); $plugin_path = is_dir( $plugin_path ) ? $plugin_path : WP_PLUGIN_DIR . '/' . $plugin_path; - Plugin_Request_Utility::apply_pcpignore_exclusions( $plugin_path ); + PCP_Ignore_Utility::apply_exclusions( $plugin_path ); } $runner->set_use_ai( $use_ai ); $results = $runner->run(); diff --git a/includes/CLI/Plugin_Check_Command.php b/includes/CLI/Plugin_Check_Command.php index 7973699e3..5cc35295b 100644 --- a/includes/CLI/Plugin_Check_Command.php +++ b/includes/CLI/Plugin_Check_Command.php @@ -14,6 +14,7 @@ use WordPress\Plugin_Check\Checker\CLI_Runner; use WordPress\Plugin_Check\Checker\Default_Check_Repository; use WordPress\Plugin_Check\Plugin_Context; +use WordPress\Plugin_Check\Utilities\PCP_Ignore_Utility; use WordPress\Plugin_Check\Utilities\Plugin_Request_Utility; use WordPress\Plugin_Check\Utilities\Results_Exporter; use WP_CLI; @@ -268,7 +269,7 @@ static function ( $dirs ) use ( $excluded_files ) { $plugin_path = $runner->get_plugin_basename(); $plugin_path = is_dir( $plugin_path ) ? $plugin_path : WP_PLUGIN_DIR . '/' . $plugin_path; - Plugin_Request_Utility::apply_pcpignore_exclusions( $plugin_path ); + PCP_Ignore_Utility::apply_exclusions( $plugin_path ); } $runner->set_categories( $categories ); $runner->set_slug( $options['slug'] ); diff --git a/includes/Utilities/PCP_Ignore_Utility.php b/includes/Utilities/PCP_Ignore_Utility.php new file mode 100644 index 000000000..deddc2e2a --- /dev/null +++ b/includes/Utilities/PCP_Ignore_Utility.php @@ -0,0 +1,98 @@ + array(), + 'files' => array(), + ); + + if ( ! is_readable( $ignore_file ) ) { + return $exclusions; + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $lines = file( $ignore_file, FILE_IGNORE_NEW_LINES ); + + if ( false === $lines ) { + return $exclusions; + } + + foreach ( $lines as $line ) { + $line = trim( $line ); + + if ( '' === $line || '#' === substr( $line, 0, 1 ) ) { + continue; + } + + $is_directory = '/' === substr( $line, -1 ); + $line = ltrim( wp_normalize_path( $line ), '/' ); + + if ( $is_directory ) { + $exclusions['directories'][] = untrailingslashit( $line ); + } else { + $exclusions['files'][] = $line; + } + } + + $exclusions['directories'] = array_unique( $exclusions['directories'] ); + $exclusions['files'] = array_unique( array_merge( $exclusions['files'], array( '.pcpignore' ) ) ); + + return $exclusions; + } + + /** + * Adds .pcpignore exclusions to the current scan. + * + * This method must only be called by an explicit local or CI opt-in. It + * does not run automatically, so WordPress.org scans retain every file. + * + * @since n.e.x.t + * + * @param string $plugin_path Plugin directory or main plugin file path. + */ + public static function apply_exclusions( $plugin_path ) { + $exclusions = self::get_exclusions( $plugin_path ); + + add_filter( + 'wp_plugin_check_ignore_directories', + static function ( $directories ) use ( $exclusions ) { + return array_unique( array_merge( $directories, $exclusions['directories'] ) ); + } + ); + + add_filter( + 'wp_plugin_check_ignore_files', + static function ( $files ) use ( $exclusions ) { + return array_unique( array_merge( $files, $exclusions['files'] ) ); + } + ); + } +} diff --git a/includes/Utilities/Plugin_Request_Utility.php b/includes/Utilities/Plugin_Request_Utility.php index 526c6d89a..3451f8052 100644 --- a/includes/Utilities/Plugin_Request_Utility.php +++ b/includes/Utilities/Plugin_Request_Utility.php @@ -204,88 +204,6 @@ public static function get_files_to_ignore() { return $files_to_ignore; } - /** - * Gets the custom file and directory exclusions from a .pcpignore file. - * - * Each non-empty, non-comment line is treated as a path relative to the - * plugin root. A trailing slash denotes a directory; all other entries - * denote files. - * - * @since n.e.x.t - * - * @param string $plugin_path Plugin directory or main plugin file path. - * @return array{directories: array, files: array} Custom exclusions. - */ - public static function get_pcpignore_exclusions( $plugin_path ) { - $plugin_directory = is_dir( $plugin_path ) ? $plugin_path : dirname( $plugin_path ); - $ignore_file = trailingslashit( $plugin_directory ) . '.pcpignore'; - $exclusions = array( - 'directories' => array(), - 'files' => array(), - ); - - if ( ! is_readable( $ignore_file ) ) { - return $exclusions; - } - - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents - $lines = file( $ignore_file, FILE_IGNORE_NEW_LINES ); - - if ( false === $lines ) { - return $exclusions; - } - - foreach ( $lines as $line ) { - $line = trim( $line ); - - if ( '' === $line || '#' === substr( $line, 0, 1 ) ) { - continue; - } - - $is_directory = '/' === substr( $line, -1 ); - $line = ltrim( wp_normalize_path( $line ), '/' ); - - if ( $is_directory ) { - $exclusions['directories'][] = untrailingslashit( $line ); - } else { - $exclusions['files'][] = $line; - } - } - - $exclusions['directories'] = array_unique( $exclusions['directories'] ); - $exclusions['files'] = array_unique( array_merge( $exclusions['files'], array( '.pcpignore' ) ) ); - - return $exclusions; - } - - /** - * Adds .pcpignore exclusions to the current scan. - * - * This method must only be called by an explicit local or CI opt-in. It - * does not run automatically, so WordPress.org scans retain every file. - * - * @since n.e.x.t - * - * @param string $plugin_path Plugin directory or main plugin file path. - */ - public static function apply_pcpignore_exclusions( $plugin_path ) { - $exclusions = self::get_pcpignore_exclusions( $plugin_path ); - - add_filter( - 'wp_plugin_check_ignore_directories', - static function ( $directories ) use ( $exclusions ) { - return array_unique( array_merge( $directories, $exclusions['directories'] ) ); - } - ); - - add_filter( - 'wp_plugin_check_ignore_files', - static function ( $files ) use ( $exclusions ) { - return array_unique( array_merge( $files, $exclusions['files'] ) ); - } - ); - } - /** * Returns the plugin basename after downloading and installing the plugin. * diff --git a/tests/phpunit/tests/Utilities/Plugin_Request_Utility_Tests.php b/tests/phpunit/tests/Utilities/Plugin_Request_Utility_Tests.php index c073bd66f..4afe692e2 100644 --- a/tests/phpunit/tests/Utilities/Plugin_Request_Utility_Tests.php +++ b/tests/phpunit/tests/Utilities/Plugin_Request_Utility_Tests.php @@ -14,6 +14,7 @@ use WordPress\Plugin_Check\Checker\Runtime_Environment_Setup; use WordPress\Plugin_Check\Test_Data\Runtime_Check; use WordPress\Plugin_Check\Test_Utils\Traits\With_Mock_Filesystem; +use WordPress\Plugin_Check\Utilities\PCP_Ignore_Utility; use WordPress\Plugin_Check\Utilities\Plugin_Request_Utility; class Plugin_Request_Utility_Tests extends WP_UnitTestCase { @@ -311,7 +312,7 @@ static function () use ( $custom_ignore_files ) { public function test_get_pcpignore_exclusions() { $plugin_directory = UNIT_TESTS_PLUGIN_DIR . 'test-plugin-pcpignore'; - $exclusions = Plugin_Request_Utility::get_pcpignore_exclusions( $plugin_directory ); + $exclusions = PCP_Ignore_Utility::get_exclusions( $plugin_directory ); $this->assertSame( array( 'docs', 'tests/fixtures' ), @@ -326,7 +327,7 @@ public function test_get_pcpignore_exclusions() { public function test_get_pcpignore_exclusions_without_ignore_file() { $plugin_directory = UNIT_TESTS_PLUGIN_DIR . 'test-plugin-ignore-files'; - $exclusions = Plugin_Request_Utility::get_pcpignore_exclusions( $plugin_directory ); + $exclusions = PCP_Ignore_Utility::get_exclusions( $plugin_directory ); $this->assertSame( array( From 836d421534630e9b9f3c82a7470e44e5851b4149 Mon Sep 17 00:00:00 2001 From: davidperezgar Date: Sun, 6 Sep 2026 13:32:46 +0200 Subject: [PATCH 5/5] fixes --- docs/CLI.md | 7 +- includes/Admin/Admin_AJAX.php | 8 + includes/CLI/Plugin_Check_Command.php | 7 +- .../Checker/Checks/Abstract_File_Check.php | 26 +--- .../Checks/Abstract_PHP_CodeSniffer_Check.php | 62 ++++++-- includes/Traits/Prefix_Utils.php | 31 ++-- includes/Utilities/PCP_Ignore_Utility.php | 84 ++++++++++- includes/Utilities/Plugin_Request_Utility.php | 138 ++++++++++++++++++ tests/behat/features/plugin-check.feature | 101 +++++++++++++ .../plugins/test-plugin-pcpignore/.pcpignore | 1 + .../test-plugin-pcpignore/assets/app.js.map | 1 + .../test-plugin-pcpignore/docs/example.php | 16 ++ .../includes/docs/real-code.php | 15 ++ .../Plugin_Request_Utility_Tests.php | 72 ++++++++- 14 files changed, 503 insertions(+), 66 deletions(-) create mode 100644 tests/phpunit/testdata/plugins/test-plugin-pcpignore/assets/app.js.map create mode 100644 tests/phpunit/testdata/plugins/test-plugin-pcpignore/docs/example.php create mode 100644 tests/phpunit/testdata/plugins/test-plugin-pcpignore/includes/docs/real-code.php diff --git a/docs/CLI.md b/docs/CLI.md index 6fd66c31a..180086158 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -66,8 +66,11 @@ missing_composer_json_file; use `--ignore-codes` for specific result codes. [--use-pcpignore] : Apply custom file and directory exclusions from a `.pcpignore` file in the plugin root. -Each non-empty, non-comment line is a path relative to that root. A trailing slash excludes a directory; -all other entries exclude files. This option is disabled by default and is intended for local and CI scans. +Each non-empty, non-comment line is a path anchored to that root (not matched at any depth), so `docs/` +only excludes a top-level `docs` directory, not a `docs` directory nested elsewhere. A trailing slash +excludes a directory; all other entries exclude files. Entries may include `*` and `?` wildcards, e.g. +`*.map`. Single-file plugins are not supported, since they have no dedicated plugin directory to hold a +`.pcpignore` file. This option is disabled by default and is intended for local and CI scans. WordPress.org scans must not use this option. [--severity=] diff --git a/includes/Admin/Admin_AJAX.php b/includes/Admin/Admin_AJAX.php index 7dd16ae35..9df5ab783 100644 --- a/includes/Admin/Admin_AJAX.php +++ b/includes/Admin/Admin_AJAX.php @@ -290,6 +290,8 @@ public function run_checks() { $types = filter_input( INPUT_POST, 'types', FILTER_DEFAULT, FILTER_FORCE_ARRAY ); $types = is_null( $types ) ? array( 'error', 'warning' ) : $types; + $pcpignore_warning = ''; + try { $runner->set_experimental_flag( $include_experimental ); $runner->set_check_slugs( $checks ); @@ -299,6 +301,8 @@ public function run_checks() { $plugin_path = is_dir( $plugin_path ) ? $plugin_path : WP_PLUGIN_DIR . '/' . $plugin_path; PCP_Ignore_Utility::apply_exclusions( $plugin_path ); + + $pcpignore_warning = PCP_Ignore_Utility::get_warning(); } $runner->set_use_ai( $use_ai ); $results = $runner->run(); @@ -311,6 +315,10 @@ public function run_checks() { $response_data = $this->prepare_results_response( $results, $types ); + if ( '' !== $pcpignore_warning ) { + $response_data['pcpignore_warning'] = $pcpignore_warning; + } + // Include AI analysis results if available. $ai_analysis = $results->get_ai_analysis(); if ( ! empty( $ai_analysis ) ) { diff --git a/includes/CLI/Plugin_Check_Command.php b/includes/CLI/Plugin_Check_Command.php index 5cc35295b..f88c71b6e 100644 --- a/includes/CLI/Plugin_Check_Command.php +++ b/includes/CLI/Plugin_Check_Command.php @@ -127,7 +127,8 @@ public function __construct( Plugin_Context $plugin_context ) { * * [--use-pcpignore] * : Apply custom file and directory exclusions from a .pcpignore file in the plugin root. - * This is intended for local and CI scans only and is disabled by default. + * Entries are anchored to the plugin root and may include * and ? wildcards. Not supported for + * single-file plugins. This is intended for local and CI scans only and is disabled by default. * * [--severity=] * : Severity level. @@ -270,6 +271,10 @@ static function ( $dirs ) use ( $excluded_files ) { $plugin_path = is_dir( $plugin_path ) ? $plugin_path : WP_PLUGIN_DIR . '/' . $plugin_path; PCP_Ignore_Utility::apply_exclusions( $plugin_path ); + + if ( '' !== PCP_Ignore_Utility::get_warning() ) { + WP_CLI::warning( PCP_Ignore_Utility::get_warning() ); + } } $runner->set_categories( $categories ); $runner->set_slug( $options['slug'] ); diff --git a/includes/Checker/Checks/Abstract_File_Check.php b/includes/Checker/Checks/Abstract_File_Check.php index 70ebc1f88..379ff38f4 100644 --- a/includes/Checker/Checks/Abstract_File_Check.php +++ b/includes/Checker/Checks/Abstract_File_Check.php @@ -282,9 +282,9 @@ private static function get_files( Check_Context $plugin ) { } else { $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $location ) ); + $plugin_root = untrailingslashit( $location ); $directories_to_ignore = Plugin_Request_Utility::get_directories_to_ignore(); - - $files_to_ignore = Plugin_Request_Utility::get_files_to_ignore(); + $files_to_ignore = Plugin_Request_Utility::get_files_to_ignore(); foreach ( $iterator as $file ) { if ( ! $file->isFile() ) { @@ -293,27 +293,15 @@ private static function get_files( Check_Context $plugin ) { $file_path = wp_normalize_path( $file->getPathname() ); - // Flag to check if the file should be included or not. - $include_file = true; - - foreach ( $directories_to_ignore as $directory ) { - // Check if the current file belongs to the directory you want to ignore. - if ( false !== strpos( $file_path, '/' . $directory . '/' ) ) { - $include_file = false; - break; // Skip the file if it matches any ignored directory. - } + if ( Plugin_Request_Utility::is_file_in_ignored_directory( $file_path, $plugin_root, $directories_to_ignore ) ) { + continue; } - foreach ( $files_to_ignore as $ignore_file ) { - if ( str_ends_with( $file_path, "/$ignore_file" ) ) { - $include_file = false; - break; - } + if ( Plugin_Request_Utility::is_file_ignored( $file_path, $plugin_root, $files_to_ignore ) ) { + continue; } - if ( $include_file ) { - self::$file_list_cache[ $location ][] = $file_path; - } + self::$file_list_cache[ $location ][] = $file_path; } } diff --git a/includes/Checker/Checks/Abstract_PHP_CodeSniffer_Check.php b/includes/Checker/Checks/Abstract_PHP_CodeSniffer_Check.php index 910c139fd..055ec48f4 100644 --- a/includes/Checker/Checks/Abstract_PHP_CodeSniffer_Check.php +++ b/includes/Checker/Checks/Abstract_PHP_CodeSniffer_Check.php @@ -218,20 +218,10 @@ private function get_argv_defaults( Check_Result $result ): array { '--report-width=9999', ); - $ignore_patterns = array(); - - $directories_to_ignore = Plugin_Request_Utility::get_directories_to_ignore(); - $files_to_ignore = Plugin_Request_Utility::get_files_to_ignore(); + $location = wp_normalize_path( $result->plugin()->location() ); + $plugin_root = untrailingslashit( is_dir( $location ) ? $location : dirname( $location ) ); - // Ignore directories. - if ( ! empty( $directories_to_ignore ) ) { - $ignore_patterns[] = '*/' . implode( '/*,*/', $directories_to_ignore ) . '/*'; - } - - // Ignore files. - if ( ! empty( $files_to_ignore ) ) { - $ignore_patterns[] = '/' . implode( ',/', $files_to_ignore ); - } + $ignore_patterns = $this->get_ignore_patterns( $plugin_root ); if ( ! empty( $ignore_patterns ) ) { $defaults[] = '--ignore=' . implode( ',', $ignore_patterns ); @@ -248,6 +238,52 @@ private function get_argv_defaults( Check_Result $result ): array { return $defaults; } + /** + * Builds the PHPCS `--ignore` patterns for the given directories and files to ignore. + * + * Entries anchored to the plugin root (i.e. beginning with a forward + * slash, such as those sourced from a `.pcpignore` file) are translated + * into absolute, plugin-root-relative patterns so PHPCS only matches + * them at that exact location, and may include `*` / `?` wildcards. + * Unanchored entries retain the historical any-depth pattern. + * + * @since n.e.x.t + * + * @param string $plugin_root Absolute, normalized path to the plugin root directory, without a trailing slash. + * @return array An indexed array of PHPCS `--ignore` patterns. + */ + private function get_ignore_patterns( $plugin_root ) { + $ignore_patterns = array(); + + $directories_to_ignore = Plugin_Request_Utility::get_directories_to_ignore(); + $files_to_ignore = Plugin_Request_Utility::get_files_to_ignore(); + + list( $anchored_directories, $unanchored_directories ) = Plugin_Request_Utility::split_anchored_ignore_entries( $directories_to_ignore ); + list( $anchored_files, $unanchored_files ) = Plugin_Request_Utility::split_anchored_ignore_entries( $files_to_ignore ); + + // Ignore directories at any depth (default exclusions and --exclude-directories). + if ( ! empty( $unanchored_directories ) ) { + $ignore_patterns[] = '*/' . implode( '/*,*/', $unanchored_directories ) . '/*'; + } + + // Ignore directories anchored to the plugin root (e.g. from .pcpignore). + foreach ( $anchored_directories as $directory ) { + $ignore_patterns[] = $plugin_root . $directory . '/*'; + } + + // Ignore files at any depth (--exclude-files). + if ( ! empty( $unanchored_files ) ) { + $ignore_patterns[] = '/' . implode( ',/', $unanchored_files ); + } + + // Ignore files anchored to the plugin root (e.g. from .pcpignore). + foreach ( $anchored_files as $file ) { + $ignore_patterns[] = $plugin_root . $file; + } + + return $ignore_patterns; + } + /** * Registers an error handler for known PHPCS notices. * diff --git a/includes/Traits/Prefix_Utils.php b/includes/Traits/Prefix_Utils.php index 10a4d4ade..d9a63e27d 100644 --- a/includes/Traits/Prefix_Utils.php +++ b/includes/Traits/Prefix_Utils.php @@ -74,6 +74,11 @@ private static function get_files( Check_Context $plugin ) { self::$file_list_cache[ $location ][] = $location; } else { $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $location ) ); + + $plugin_root = untrailingslashit( $location ); + $directories_to_ignore = Plugin_Request_Utility::get_directories_to_ignore(); + $files_to_ignore = Plugin_Request_Utility::get_files_to_ignore(); + foreach ( $iterator as $file ) { if ( ! $file->isFile() ) { continue; @@ -87,31 +92,15 @@ private static function get_files( Check_Context $plugin ) { $file_path = wp_normalize_path( $file->getPathname() ); - $directories_to_ignore = Plugin_Request_Utility::get_directories_to_ignore(); - - // Flag to check if the file should be included or not. - $include_file = true; - - foreach ( $directories_to_ignore as $directory ) { - // Check if the current file belongs to the directory you want to ignore. - if ( false !== strpos( $file_path, '/' . $directory . '/' ) ) { - $include_file = false; - break; // Skip the file if it matches any ignored directory. - } + if ( Plugin_Request_Utility::is_file_in_ignored_directory( $file_path, $plugin_root, $directories_to_ignore ) ) { + continue; } - $files_to_ignore = Plugin_Request_Utility::get_files_to_ignore(); - - foreach ( $files_to_ignore as $ignore_file ) { - if ( str_ends_with( $file_path, "/$ignore_file" ) ) { - $include_file = false; - break; - } + if ( Plugin_Request_Utility::is_file_ignored( $file_path, $plugin_root, $files_to_ignore ) ) { + continue; } - if ( $include_file ) { - self::$file_list_cache[ $location ][] = $file_path; - } + self::$file_list_cache[ $location ][] = $file_path; } } diff --git a/includes/Utilities/PCP_Ignore_Utility.php b/includes/Utilities/PCP_Ignore_Utility.php index deddc2e2a..6d923ff6c 100644 --- a/includes/Utilities/PCP_Ignore_Utility.php +++ b/includes/Utilities/PCP_Ignore_Utility.php @@ -14,34 +14,91 @@ */ class PCP_Ignore_Utility { + /** + * Marker prefix used to flag an exclusion entry as anchored to the plugin root. + * + * Entries parsed from a `.pcpignore` file are always anchored to the plugin + * root, as documented. This is distinct from the unanchored matching used + * for built-in default exclusions and the `--exclude-directories` / + * `--exclude-files` CLI options, which intentionally match at any depth. + * + * @since n.e.x.t + * @var string + */ + const ROOT_ANCHOR = '/'; + + /** + * The most recent warning generated while parsing a `.pcpignore` file, if any. + * + * @since n.e.x.t + * @var string + */ + private static $warning = ''; + /** * Gets the custom file and directory exclusions from a .pcpignore file. * * Each non-empty, non-comment line is treated as a path relative to the * plugin root. A trailing slash denotes a directory; all other entries - * denote files. + * denote files. Entries may include `*` and `?` wildcards. All entries + * are anchored to the plugin root, meaning `docs/` only excludes a + * top-level `docs` directory, not any directory named `docs` at another + * depth. + * + * If the file exists but cannot be read or parsed, a warning is recorded + * and can be retrieved via {@see self::get_warning()}. The scan itself is + * never interrupted by an invalid or unreadable `.pcpignore` file. * * @since n.e.x.t * * @param string $plugin_path Plugin directory or main plugin file path. - * @return array{directories: array, files: array} Custom exclusions. + * @return array{directories: array, files: array} Custom exclusions, anchored to the plugin root. */ public static function get_exclusions( $plugin_path ) { - $plugin_directory = is_dir( $plugin_path ) ? $plugin_path : dirname( $plugin_path ); - $ignore_file = trailingslashit( $plugin_directory ) . '.pcpignore'; - $exclusions = array( + self::$warning = ''; + + $exclusions = array( 'directories' => array(), 'files' => array(), ); + $plugin_directory = is_dir( $plugin_path ) ? $plugin_path : dirname( $plugin_path ); + $plugin_directory = untrailingslashit( wp_normalize_path( $plugin_directory ) ); + + // Single-file plugins live directly inside the shared plugins directory + // and have no dedicated directory of their own to hold a .pcpignore + // file. Resolving to WP_PLUGIN_DIR would incorrectly apply exclusions + // meant for one plugin to every other plugin scanned from that shared + // location, so .pcpignore is not supported for single-file plugins. + if ( untrailingslashit( wp_normalize_path( WP_PLUGIN_DIR ) ) === $plugin_directory ) { + return $exclusions; + } + + $ignore_file = trailingslashit( $plugin_directory ) . '.pcpignore'; + + if ( ! file_exists( $ignore_file ) ) { + return $exclusions; + } + if ( ! is_readable( $ignore_file ) ) { + self::$warning = sprintf( + /* translators: %s: Path to the .pcpignore file. */ + __( 'The .pcpignore file at %s could not be read and was ignored.', 'plugin-check' ), + $ignore_file + ); + return $exclusions; } - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents $lines = file( $ignore_file, FILE_IGNORE_NEW_LINES ); if ( false === $lines ) { + self::$warning = sprintf( + /* translators: %s: Path to the .pcpignore file. */ + __( 'The .pcpignore file at %s could not be parsed and was ignored.', 'plugin-check' ), + $ignore_file + ); + return $exclusions; } @@ -53,7 +110,7 @@ public static function get_exclusions( $plugin_path ) { } $is_directory = '/' === substr( $line, -1 ); - $line = ltrim( wp_normalize_path( $line ), '/' ); + $line = self::ROOT_ANCHOR . ltrim( wp_normalize_path( $line ), '/' ); if ( $is_directory ) { $exclusions['directories'][] = untrailingslashit( $line ); @@ -63,11 +120,22 @@ public static function get_exclusions( $plugin_path ) { } $exclusions['directories'] = array_unique( $exclusions['directories'] ); - $exclusions['files'] = array_unique( array_merge( $exclusions['files'], array( '.pcpignore' ) ) ); + $exclusions['files'] = array_unique( array_merge( $exclusions['files'], array( self::ROOT_ANCHOR . '.pcpignore' ) ) ); return $exclusions; } + /** + * Gets the warning generated by the most recent call to {@see self::get_exclusions()}. + * + * @since n.e.x.t + * + * @return string The warning message, or an empty string if none was generated. + */ + public static function get_warning() { + return self::$warning; + } + /** * Adds .pcpignore exclusions to the current scan. * diff --git a/includes/Utilities/Plugin_Request_Utility.php b/includes/Utilities/Plugin_Request_Utility.php index 3451f8052..00ad12b10 100644 --- a/includes/Utilities/Plugin_Request_Utility.php +++ b/includes/Utilities/Plugin_Request_Utility.php @@ -204,6 +204,144 @@ public static function get_files_to_ignore() { return $files_to_ignore; } + /** + * Splits ignore entries into those anchored to the plugin root and those that are not. + * + * Entries beginning with a forward slash are anchored to the plugin root, + * such as entries sourced from a `.pcpignore` file. All other entries are + * unanchored, preserving the historical, any-depth matching behavior of + * the default exclusions and the `--exclude-directories` / + * `--exclude-files` CLI options. + * + * @since n.e.x.t + * + * @param array $entries Ignore entries. + * @return array An indexed array with the anchored entries first, followed by the unanchored entries. + */ + public static function split_anchored_ignore_entries( array $entries ) { + $anchored = array(); + $unanchored = array(); + + foreach ( $entries as $entry ) { + if ( '' === $entry ) { + continue; + } + + if ( '/' === $entry[0] ) { + $anchored[] = $entry; + } else { + $unanchored[] = $entry; + } + } + + return array( $anchored, $unanchored ); + } + + /** + * Converts a glob-style pattern (using `*` and `?` wildcards) to a regular expression. + * + * @since n.e.x.t + * + * @param string $pattern The glob-style pattern. + * @return string The equivalent case-insensitive, fully anchored regular expression. + */ + private static function glob_to_regex( $pattern ) { + $regex = preg_quote( $pattern, '#' ); + $regex = str_replace( array( '\*', '\?' ), array( '.*', '.' ), $regex ); + + return '#^' . $regex . '$#i'; + } + + /** + * Determines whether a file is inside a directory that should be ignored. + * + * Directories anchored to the plugin root (i.e. entries beginning with a + * forward slash, such as those sourced from a `.pcpignore` file) only + * match a directory at that exact location relative to the plugin root. + * All other directories match unanchored, at any depth, preserving the + * historical behavior of the default exclusions and the + * `--exclude-directories` CLI option. + * + * @since n.e.x.t + * + * @param string $file_path Absolute, normalized path to the file being checked. + * @param string $plugin_root Absolute, normalized path to the plugin root directory, without a trailing slash. + * @param array $directories_to_ignore Directories to ignore. + * @return bool True if the file is inside an ignored directory. + */ + public static function is_file_in_ignored_directory( $file_path, $plugin_root, array $directories_to_ignore ) { + foreach ( $directories_to_ignore as $directory ) { + if ( '' === $directory ) { + continue; + } + + if ( '/' === $directory[0] ) { + $anchored_directory = $plugin_root . $directory; + + if ( false !== strpbrk( $directory, '*?' ) ) { + if ( preg_match( self::glob_to_regex( $anchored_directory . '/*' ), $file_path ) ) { + return true; + } + } elseif ( 0 === strpos( $file_path, $anchored_directory . '/' ) ) { + return true; + } + + continue; + } + + if ( false !== strpos( $file_path, '/' . $directory . '/' ) ) { + return true; + } + } + + return false; + } + + /** + * Determines whether a file should be ignored. + * + * Files anchored to the plugin root (i.e. entries beginning with a + * forward slash, such as those sourced from a `.pcpignore` file) may + * include `*` and `?` wildcards and only match at that exact location + * relative to the plugin root. All other files match unanchored, by + * filename suffix, preserving the historical behavior of the + * `--exclude-files` CLI option. + * + * @since n.e.x.t + * + * @param string $file_path Absolute, normalized path to the file being checked. + * @param string $plugin_root Absolute, normalized path to the plugin root directory, without a trailing slash. + * @param array $files_to_ignore Files to ignore. + * @return bool True if the file should be ignored. + */ + public static function is_file_ignored( $file_path, $plugin_root, array $files_to_ignore ) { + foreach ( $files_to_ignore as $file ) { + if ( '' === $file ) { + continue; + } + + if ( '/' === $file[0] ) { + $anchored_file = $plugin_root . $file; + + if ( false !== strpbrk( $file, '*?' ) ) { + if ( preg_match( self::glob_to_regex( $anchored_file ), $file_path ) ) { + return true; + } + } elseif ( $file_path === $anchored_file ) { + return true; + } + + continue; + } + + if ( str_ends_with( $file_path, '/' . $file ) ) { + return true; + } + } + + return false; + } + /** * Returns the plugin basename after downloading and installing the plugin. * diff --git a/tests/behat/features/plugin-check.feature b/tests/behat/features/plugin-check.feature index 761a54459..6d7dcbf9a 100644 --- a/tests/behat/features/plugin-check.feature +++ b/tests/behat/features/plugin-check.feature @@ -350,6 +350,107 @@ Feature: Test that the WP-CLI command works. FILE: docs/example.php """ + Scenario: .pcpignore exclusions are anchored to the plugin root + Given a WP install with the Plugin Check plugin + And an empty wp-content/plugins/foo-plugin directory + And an empty wp-content/plugins/foo-plugin/docs directory + And an empty wp-content/plugins/foo-plugin/includes/docs directory + And a wp-content/plugins/foo-plugin/foo-plugin.php file: + """ + assertSame( - array( 'docs', 'tests/fixtures' ), + array( '/docs', '/tests/fixtures' ), $exclusions['directories'] ); $this->assertSame( - array( 'development-only.php', '.pcpignore' ), + array( '/development-only.php', '/*.map', '/.pcpignore' ), $exclusions['files'] ); + $this->assertSame( '', PCP_Ignore_Utility::get_warning() ); } public function test_get_pcpignore_exclusions_without_ignore_file() { @@ -336,6 +337,73 @@ public function test_get_pcpignore_exclusions_without_ignore_file() { ), $exclusions ); + $this->assertSame( '', PCP_Ignore_Utility::get_warning() ); + } + + public function test_get_pcpignore_exclusions_for_single_file_plugin() { + $exclusions = PCP_Ignore_Utility::get_exclusions( WP_PLUGIN_DIR . '/foo-single.php' ); + + $this->assertSame( + array( + 'directories' => array(), + 'files' => array(), + ), + $exclusions + ); + $this->assertSame( '', PCP_Ignore_Utility::get_warning() ); + } + + public function test_get_pcpignore_exclusions_with_unreadable_file() { + $plugin_directory = UNIT_TESTS_PLUGIN_DIR . 'test-plugin-pcpignore'; + $ignore_file = trailingslashit( $plugin_directory ) . '.pcpignore'; + + chmod( $ignore_file, 0000 ); + $this->cleanups[] = function () use ( $ignore_file ) { + chmod( $ignore_file, 0644 ); + }; + + $exclusions = PCP_Ignore_Utility::get_exclusions( $plugin_directory ); + + $this->assertSame( + array( + 'directories' => array(), + 'files' => array(), + ), + $exclusions + ); + $this->assertNotSame( '', PCP_Ignore_Utility::get_warning() ); + } + + public function test_pcpignore_directory_exclusion_is_anchored_to_plugin_root() { + $checks_to_run = array( + new I18n_Usage_Check(), + ); + + add_filter( + 'wp_plugin_check_checks', + function () { + return array( + 'i18n_usage_check' => new I18n_Usage_Check(), + ); + } + ); + + // Without exclusions, both docs/example.php (root) and + // includes/docs/real-code.php (nested) trigger a warning. + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-pcpignore/load.php' ); + $results_without = ( new Checks() )->run_checks( $check_context, $checks_to_run ); + + $this->assertSame( 2, $results_without->get_warning_count() ); + + // With .pcpignore exclusions applied, only the root-level docs/ + // directory is excluded; the nested includes/docs/ directory, which + // merely shares the same directory name, must still be scanned. + PCP_Ignore_Utility::apply_exclusions( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-pcpignore' ); + + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-pcpignore/load.php' ); + $results_with = ( new Checks() )->run_checks( $check_context, $checks_to_run ); + + $this->assertSame( 1, $results_with->get_warning_count() ); } public function test_plugin_without_error_for_ignore_directories() {