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..180086158 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -64,6 +64,15 @@ 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 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=] : Severity level. @@ -106,6 +115,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..9df5ab783 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; @@ -285,13 +286,24 @@ 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; + $pcpignore_warning = ''; + try { $runner->set_experimental_flag( $include_experimental ); $runner->set_check_slugs( $checks ); $runner->set_plugin( $plugin ); + if ( $use_pcpignore ) { + $plugin_path = $runner->get_plugin_basename(); + $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(); } catch ( Exception $error ) { @@ -303,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 fd10abbef..f88c71b6e 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; @@ -124,6 +125,11 @@ 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. + * 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. * @@ -165,6 +171,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 +208,7 @@ public function check( $args, $assoc_args ) { 'mode' => 'new', 'ai' => false, 'ai-model' => '', + 'use-pcpignore' => false, ) ); @@ -258,6 +266,16 @@ 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_path = $runner->get_plugin_basename(); + $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'] ); $runner->set_mode( $options['mode'] ); diff --git a/includes/Checker/Checks/Abstract_File_Check.php b/includes/Checker/Checks/Abstract_File_Check.php index 70ebc1f88..d40425a68 100644 --- a/includes/Checker/Checks/Abstract_File_Check.php +++ b/includes/Checker/Checks/Abstract_File_Check.php @@ -13,6 +13,7 @@ use WordPress\Plugin_Check\Checker\Check_Context; use WordPress\Plugin_Check\Checker\Check_Result; use WordPress\Plugin_Check\Checker\Static_Check; +use WordPress\Plugin_Check\Utilities\Ignore_Matcher; use WordPress\Plugin_Check\Utilities\Plugin_Request_Utility; /** @@ -282,9 +283,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 +294,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 ( Ignore_Matcher::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 ( Ignore_Matcher::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..ba3e5e7b0 100644 --- a/includes/Checker/Checks/Abstract_PHP_CodeSniffer_Check.php +++ b/includes/Checker/Checks/Abstract_PHP_CodeSniffer_Check.php @@ -13,6 +13,7 @@ use WordPress\Plugin_Check\Checker\Check_Result; use WordPress\Plugin_Check\Checker\Static_Check; use WordPress\Plugin_Check\Traits\Amend_Check_Result; +use WordPress\Plugin_Check\Utilities\Ignore_Matcher; use WordPress\Plugin_Check\Utilities\Plugin_Request_Utility; /** @@ -160,7 +161,7 @@ final public function run( Check_Result $result ) { /** * Runs PHP_CodeSniffer. * - * @since n.e.x.t + * @since 2.2.0 */ protected function run_php_codesniffer() { /* @@ -218,20 +219,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,10 +239,56 @@ 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 2.2.0 + * + * @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 ) = Ignore_Matcher::split_anchored_entries( $directories_to_ignore ); + list( $anchored_files, $unanchored_files ) = Ignore_Matcher::split_anchored_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[] = Ignore_Matcher::get_php_codesniffer_directory_ignore_pattern( $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[] = Ignore_Matcher::get_php_codesniffer_file_ignore_pattern( $plugin_root, $file ); + } + + return $ignore_patterns; + } + /** * Registers an error handler for known PHPCS notices. * - * @since n.e.x.t + * @since 2.2.0 */ private function register_php_codesniffer_error_handler() { $previous_error_handler = null; @@ -291,7 +328,7 @@ private function register_php_codesniffer_error_handler() { * processing throws, PHP_CodeSniffer does not restore that handler, so it must * be removed before the temporary deprecation handler can be restored. * - * @since n.e.x.t + * @since 2.2.0 * * @param callable $php_codesniffer_error_handler The temporary error handler registered before running PHP_CodeSniffer. */ diff --git a/includes/Traits/Prefix_Utils.php b/includes/Traits/Prefix_Utils.php index 10a4d4ade..5d73e1b0b 100644 --- a/includes/Traits/Prefix_Utils.php +++ b/includes/Traits/Prefix_Utils.php @@ -12,6 +12,7 @@ use WordPress\Plugin_Check\Checker\Check_Context; use WordPress\Plugin_Check\Checker\Check_Result; use WordPress\Plugin_Check\Scanner\Prefix_Scanner; +use WordPress\Plugin_Check\Utilities\Ignore_Matcher; use WordPress\Plugin_Check\Utilities\Plugin_Request_Utility; /** @@ -74,6 +75,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 +93,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 ( Ignore_Matcher::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 ( Ignore_Matcher::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/Ignore_Matcher.php b/includes/Utilities/Ignore_Matcher.php new file mode 100644 index 000000000..86fdc3ace --- /dev/null +++ b/includes/Utilities/Ignore_Matcher.php @@ -0,0 +1,222 @@ + array(), + 'files' => array(), + ); + + $plugin_directory = self::resolve_plugin_directory( $plugin_path ); + + if ( null === $plugin_directory ) { + return $empty_exclusions; + } + + $lines = self::read_ignore_file_lines( trailingslashit( $plugin_directory ) . '.pcpignore' ); + + if ( null === $lines ) { + return $empty_exclusions; + } + + return self::parse_exclusions( $lines ); + } + + /** + * Resolves the plugin directory that may hold a `.pcpignore` file. + * + * @since 2.2.0 + * + * @param string $plugin_path Plugin directory or main plugin file path. + * @return string|null The normalized, untrailingslashed plugin directory, or null + * if the plugin is a single-file plugin, which is not supported. + */ + private static function resolve_plugin_directory( $plugin_path ) { + $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 null; + } + + return $plugin_directory; + } + + /** + * Reads and returns the non-empty lines of a `.pcpignore` file. + * + * Records a warning, retrievable via {@see self::get_warning()}, if the + * file exists but could not be read or parsed. + * + * @since 2.2.0 + * + * @param string $ignore_file Absolute path to the `.pcpignore` file. + * @return array|null The file lines, or null if the file does not exist, + * could not be read, or could not be parsed. + */ + private static function read_ignore_file_lines( $ignore_file ) { + if ( ! file_exists( $ignore_file ) ) { + return null; + } + + /** + * Filters whether a `.pcpignore` file is readable. + * + * @since 2.2.0 + * + * @param bool $is_readable Whether the file is readable. + * @param string $ignore_file Absolute path to the `.pcpignore` file. + */ + $is_readable = (bool) apply_filters( 'wp_plugin_check_pcpignore_is_readable', is_file( $ignore_file ) && is_readable( $ignore_file ), $ignore_file ); + + if ( ! $is_readable ) { + 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 null; + } + + $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 null; + } + + return $lines; + } + + /** + * Parses `.pcpignore` file lines into directory and file exclusions. + * + * @since 2.2.0 + * + * @param array $lines The `.pcpignore` file lines. + * @return array{directories: array, files: array} Custom exclusions, anchored to the plugin root. + */ + private static function parse_exclusions( array $lines ) { + $exclusions = array( + 'directories' => array(), + 'files' => array(), + ); + + foreach ( $lines as $line ) { + $line = trim( $line ); + + if ( '' === $line || '#' === substr( $line, 0, 1 ) ) { + continue; + } + + $is_directory = '/' === substr( $line, -1 ); + $line = self::ROOT_ANCHOR . 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( self::ROOT_ANCHOR . '.pcpignore' ) ) ); + + return $exclusions; + } + + /** + * Gets the warning generated by the most recent call to {@see self::get_exclusions()}. + * + * @since 2.2.0 + * + * @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. + * + * 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 2.2.0 + * + * @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/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..a3d188a23 100644 --- a/tests/behat/features/plugin-check.feature +++ b/tests/behat/features/plugin-check.feature @@ -314,6 +314,140 @@ 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 = PCP_Ignore_Utility::get_exclusions( $plugin_directory ); + + $this->assertSame( + array( '/docs', '/tests/fixtures' ), + $exclusions['directories'] + ); + $this->assertSame( + array( '/development-only.php', '/*.map', '/.pcpignore' ), + $exclusions['files'] + ); + $this->assertSame( '', PCP_Ignore_Utility::get_warning() ); + } + + public function test_get_pcpignore_exclusions_without_ignore_file() { + $plugin_directory = UNIT_TESTS_PLUGIN_DIR . 'test-plugin-ignore-files'; + + $exclusions = PCP_Ignore_Utility::get_exclusions( $plugin_directory ); + + $this->assertSame( + array( + 'directories' => array(), + 'files' => array(), + ), + $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'; + + $filter = static function ( $is_readable, $file ) use ( $ignore_file ) { + return $ignore_file === $file ? false : $is_readable; + }; + add_filter( 'wp_plugin_check_pcpignore_is_readable', $filter, 10, 2 ); + $this->cleanups[] = static function () use ( $filter ) { + remove_filter( 'wp_plugin_check_pcpignore_is_readable', $filter ); + }; + + $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_wildcards_do_not_cross_directory_boundaries() { + $plugin_root = '/plugin'; + + $this->assertTrue( Ignore_Matcher::is_file_ignored( '/plugin/app.js.map', $plugin_root, array( '/*.map' ) ) ); + $this->assertFalse( Ignore_Matcher::is_file_ignored( '/plugin/assets/app.js.map', $plugin_root, array( '/*.map' ) ) ); + $this->assertTrue( Ignore_Matcher::is_file_in_ignored_directory( '/plugin/docs-new/readme.txt', $plugin_root, array( '/docs*' ) ) ); + $this->assertFalse( Ignore_Matcher::is_file_in_ignored_directory( '/plugin/includes/docs-new/readme.txt', $plugin_root, array( '/docs*' ) ) ); + } + + public function test_pcpignore_php_codesniffer_patterns_match_glob_semantics() { + $plugin_root = '/plugin'; + + $this->assertSame( '^/plugin/[^/]{0,}\\.map$', Ignore_Matcher::get_php_codesniffer_file_ignore_pattern( $plugin_root, '/*.map' ) ); + $this->assertSame( '^/plugin/file[^/]\\.php$', Ignore_Matcher::get_php_codesniffer_file_ignore_pattern( $plugin_root, '/file?.php' ) ); + $this->assertSame( '^/plugin/data\\[1\\]\\.php$', Ignore_Matcher::get_php_codesniffer_file_ignore_pattern( $plugin_root, '/data[1].php' ) ); + $this->assertSame( '^/plugin/docs[^/]{0,}/*', Ignore_Matcher::get_php_codesniffer_directory_ignore_pattern( $plugin_root, '/docs*' ) ); + } + + 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 an error. + $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_error_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_error_count() ); + } + public function test_plugin_without_error_for_ignore_directories() { $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-ignore-directories/load.php' );