From 43a45785e168ff1dd704905892cca8102c30caef Mon Sep 17 00:00:00 2001
From: davidperezgar
Date: Tue, 1 Sep 2026 17:19:41 +0200
Subject: [PATCH 01/12] 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
+
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 02/12] 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 03/12] 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 04/12] 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 05/12] 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() {
From 6cea1ceabfd189ff273857a9aa2d7b5e0360f7f8 Mon Sep 17 00:00:00 2001
From: davidperezgar
Date: Sun, 6 Sep 2026 14:11:23 +0200
Subject: [PATCH 06/12] fix: resolve phpmd violations for pcpignore matching
- Extract split_anchored_entries(), is_file_in_ignored_directory() and
is_file_ignored() into a new Ignore_Matcher class to keep
Plugin_Request_Utility under the TooManyPublicMethods threshold.
- Split PCP_Ignore_Utility::get_exclusions() into smaller
resolve_plugin_directory(), read_ignore_file_lines() and
parse_exclusions() helpers to reduce NPathComplexity.
---
.../Checker/Checks/Abstract_File_Check.php | 5 +-
.../Checks/Abstract_PHP_CodeSniffer_Check.php | 5 +-
includes/Traits/Prefix_Utils.php | 5 +-
includes/Utilities/Ignore_Matcher.php | 147 ++++++++++++++++++
includes/Utilities/PCP_Ignore_Utility.php | 68 +++++++-
includes/Utilities/Plugin_Request_Utility.php | 138 ----------------
6 files changed, 218 insertions(+), 150 deletions(-)
create mode 100644 includes/Utilities/Ignore_Matcher.php
diff --git a/includes/Checker/Checks/Abstract_File_Check.php b/includes/Checker/Checks/Abstract_File_Check.php
index 379ff38f4..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;
/**
@@ -293,11 +294,11 @@ private static function get_files( Check_Context $plugin ) {
$file_path = wp_normalize_path( $file->getPathname() );
- if ( Plugin_Request_Utility::is_file_in_ignored_directory( $file_path, $plugin_root, $directories_to_ignore ) ) {
+ if ( Ignore_Matcher::is_file_in_ignored_directory( $file_path, $plugin_root, $directories_to_ignore ) ) {
continue;
}
- if ( Plugin_Request_Utility::is_file_ignored( $file_path, $plugin_root, $files_to_ignore ) ) {
+ if ( Ignore_Matcher::is_file_ignored( $file_path, $plugin_root, $files_to_ignore ) ) {
continue;
}
diff --git a/includes/Checker/Checks/Abstract_PHP_CodeSniffer_Check.php b/includes/Checker/Checks/Abstract_PHP_CodeSniffer_Check.php
index 055ec48f4..1d2cd32db 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;
/**
@@ -258,8 +259,8 @@ private function get_ignore_patterns( $plugin_root ) {
$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 );
+ 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 ) ) {
diff --git a/includes/Traits/Prefix_Utils.php b/includes/Traits/Prefix_Utils.php
index d9a63e27d..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;
/**
@@ -92,11 +93,11 @@ private static function get_files( Check_Context $plugin ) {
$file_path = wp_normalize_path( $file->getPathname() );
- if ( Plugin_Request_Utility::is_file_in_ignored_directory( $file_path, $plugin_root, $directories_to_ignore ) ) {
+ if ( Ignore_Matcher::is_file_in_ignored_directory( $file_path, $plugin_root, $directories_to_ignore ) ) {
continue;
}
- if ( Plugin_Request_Utility::is_file_ignored( $file_path, $plugin_root, $files_to_ignore ) ) {
+ if ( Ignore_Matcher::is_file_ignored( $file_path, $plugin_root, $files_to_ignore ) ) {
continue;
}
diff --git a/includes/Utilities/Ignore_Matcher.php b/includes/Utilities/Ignore_Matcher.php
new file mode 100644
index 000000000..e7948726a
--- /dev/null
+++ b/includes/Utilities/Ignore_Matcher.php
@@ -0,0 +1,147 @@
+ 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 n.e.x.t
+ *
+ * @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 ) );
@@ -71,13 +96,27 @@ public static function get_exclusions( $plugin_path ) {
// 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;
+ return null;
}
- $ignore_file = trailingslashit( $plugin_directory ) . '.pcpignore';
+ 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 n.e.x.t
+ *
+ * @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 $exclusions;
+ return null;
}
if ( ! is_readable( $ignore_file ) ) {
@@ -87,7 +126,7 @@ public static function get_exclusions( $plugin_path ) {
$ignore_file
);
- return $exclusions;
+ return null;
}
$lines = file( $ignore_file, FILE_IGNORE_NEW_LINES );
@@ -99,9 +138,26 @@ public static function get_exclusions( $plugin_path ) {
$ignore_file
);
- return $exclusions;
+ return null;
}
+ return $lines;
+ }
+
+ /**
+ * Parses `.pcpignore` file lines into directory and file exclusions.
+ *
+ * @since n.e.x.t
+ *
+ * @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 );
diff --git a/includes/Utilities/Plugin_Request_Utility.php b/includes/Utilities/Plugin_Request_Utility.php
index 00ad12b10..3451f8052 100644
--- a/includes/Utilities/Plugin_Request_Utility.php
+++ b/includes/Utilities/Plugin_Request_Utility.php
@@ -204,144 +204,6 @@ 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.
*
From 81d672e1460ce43daacca224085300f6a14709c9 Mon Sep 17 00:00:00 2001
From: davidperezgar
Date: Thu, 10 Sep 2026 22:00:19 +0200
Subject: [PATCH 07/12] fix comments nilambar
---
.../Checks/Abstract_PHP_CodeSniffer_Check.php | 8 ++++----
includes/Utilities/Ignore_Matcher.php | 10 +++++-----
includes/Utilities/PCP_Ignore_Utility.php | 18 +++++++++---------
3 files changed, 18 insertions(+), 18 deletions(-)
diff --git a/includes/Checker/Checks/Abstract_PHP_CodeSniffer_Check.php b/includes/Checker/Checks/Abstract_PHP_CodeSniffer_Check.php
index 1d2cd32db..513c49f98 100644
--- a/includes/Checker/Checks/Abstract_PHP_CodeSniffer_Check.php
+++ b/includes/Checker/Checks/Abstract_PHP_CodeSniffer_Check.php
@@ -161,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() {
/*
@@ -248,7 +248,7 @@ private function get_argv_defaults( Check_Result $result ): array {
* them at that exact location, and may include `*` / `?` wildcards.
* Unanchored entries retain the historical any-depth pattern.
*
- * @since n.e.x.t
+ * @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.
@@ -288,7 +288,7 @@ private function get_ignore_patterns( $plugin_root ) {
/**
* 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;
@@ -328,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/Utilities/Ignore_Matcher.php b/includes/Utilities/Ignore_Matcher.php
index e7948726a..7a752340f 100644
--- a/includes/Utilities/Ignore_Matcher.php
+++ b/includes/Utilities/Ignore_Matcher.php
@@ -16,14 +16,14 @@
* historical, any-depth matching behavior of the default exclusions and the
* `--exclude-directories` / `--exclude-files` CLI options.
*
- * @since n.e.x.t
+ * @since 2.2.0
*/
class Ignore_Matcher {
/**
* Splits ignore entries into those anchored to the plugin root and those that are not.
*
- * @since n.e.x.t
+ * @since 2.2.0
*
* @param array $entries Ignore entries.
* @return array An indexed array with the anchored entries first, followed by the unanchored entries.
@@ -53,7 +53,7 @@ public static function split_anchored_entries( array $entries ) {
* Anchored directories only match a directory at that exact location
* relative to the plugin root. Unanchored directories match at any depth.
*
- * @since n.e.x.t
+ * @since 2.2.0
*
* @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.
@@ -95,7 +95,7 @@ public static function is_file_in_ignored_directory( $file_path, $plugin_root, a
* that exact location relative to the plugin root. Unanchored files
* match at any depth, by filename suffix.
*
- * @since n.e.x.t
+ * @since 2.2.0
*
* @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.
@@ -133,7 +133,7 @@ public static function is_file_ignored( $file_path, $plugin_root, array $files_t
/**
* Converts a glob-style pattern (using `*` and `?` wildcards) to a regular expression.
*
- * @since n.e.x.t
+ * @since 2.2.0
*
* @param string $pattern The glob-style pattern.
* @return string The equivalent case-insensitive, fully anchored regular expression.
diff --git a/includes/Utilities/PCP_Ignore_Utility.php b/includes/Utilities/PCP_Ignore_Utility.php
index 761fe5afa..b3c970b10 100644
--- a/includes/Utilities/PCP_Ignore_Utility.php
+++ b/includes/Utilities/PCP_Ignore_Utility.php
@@ -10,7 +10,7 @@
/**
* Class providing opt-in .pcpignore exclusions for local and CI scans.
*
- * @since n.e.x.t
+ * @since 2.2.0
*/
class PCP_Ignore_Utility {
@@ -22,7 +22,7 @@ class PCP_Ignore_Utility {
* for built-in default exclusions and the `--exclude-directories` /
* `--exclude-files` CLI options, which intentionally match at any depth.
*
- * @since n.e.x.t
+ * @since 2.2.0
* @var string
*/
const ROOT_ANCHOR = '/';
@@ -30,7 +30,7 @@ class PCP_Ignore_Utility {
/**
* The most recent warning generated while parsing a `.pcpignore` file, if any.
*
- * @since n.e.x.t
+ * @since 2.2.0
* @var string
*/
private static $warning = '';
@@ -49,7 +49,7 @@ class PCP_Ignore_Utility {
* 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
+ * @since 2.2.0
*
* @param string $plugin_path Plugin directory or main plugin file path.
* @return array{directories: array, files: array} Custom exclusions, anchored to the plugin root.
@@ -80,7 +80,7 @@ public static function get_exclusions( $plugin_path ) {
/**
* Resolves the plugin directory that may hold a `.pcpignore` file.
*
- * @since n.e.x.t
+ * @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
@@ -108,7 +108,7 @@ private static function resolve_plugin_directory( $plugin_path ) {
* Records a warning, retrievable via {@see self::get_warning()}, if the
* file exists but could not be read or parsed.
*
- * @since n.e.x.t
+ * @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,
@@ -147,7 +147,7 @@ private static function read_ignore_file_lines( $ignore_file ) {
/**
* Parses `.pcpignore` file lines into directory and file exclusions.
*
- * @since n.e.x.t
+ * @since 2.2.0
*
* @param array $lines The `.pcpignore` file lines.
* @return array{directories: array, files: array} Custom exclusions, anchored to the plugin root.
@@ -184,7 +184,7 @@ private static function parse_exclusions( array $lines ) {
/**
* Gets the warning generated by the most recent call to {@see self::get_exclusions()}.
*
- * @since n.e.x.t
+ * @since 2.2.0
*
* @return string The warning message, or an empty string if none was generated.
*/
@@ -198,7 +198,7 @@ public static function get_warning() {
* 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
+ * @since 2.2.0
*
* @param string $plugin_path Plugin directory or main plugin file path.
*/
From 3e6f0fac205a548705d0e30791577e100c313d0b Mon Sep 17 00:00:00 2001
From: davidperezgar
Date: Fri, 11 Sep 2026 22:57:11 +0200
Subject: [PATCH 08/12] fix
---
tests/behat/features/plugin-check.feature | 24 ++++++++++++-----------
1 file changed, 13 insertions(+), 11 deletions(-)
diff --git a/tests/behat/features/plugin-check.feature b/tests/behat/features/plugin-check.feature
index 6d7dcbf9a..68ddc061b 100644
--- a/tests/behat/features/plugin-check.feature
+++ b/tests/behat/features/plugin-check.feature
@@ -394,6 +394,7 @@ Feature: Test that the WP-CLI command works.
Scenario: .pcpignore exclusions support wildcard patterns
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/assets directory
And a wp-content/plugins/foo-plugin/foo-plugin.php file:
"""
Date: Fri, 11 Sep 2026 23:11:11 +0200
Subject: [PATCH 09/12] fix tests
---
.../tests/Utilities/Plugin_Request_Utility_Tests.php | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/tests/phpunit/tests/Utilities/Plugin_Request_Utility_Tests.php b/tests/phpunit/tests/Utilities/Plugin_Request_Utility_Tests.php
index 2129ae7a9..53ce14753 100644
--- a/tests/phpunit/tests/Utilities/Plugin_Request_Utility_Tests.php
+++ b/tests/phpunit/tests/Utilities/Plugin_Request_Utility_Tests.php
@@ -389,11 +389,11 @@ function () {
);
// Without exclusions, both docs/example.php (root) and
- // includes/docs/real-code.php (nested) trigger a warning.
+ // 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_warning_count() );
+ $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
@@ -403,7 +403,7 @@ function () {
$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() );
+ $this->assertSame( 1, $results_with->get_error_count() );
}
public function test_plugin_without_error_for_ignore_directories() {
From 9fb79d975dfbf0cc3653809837d062f9407c665d Mon Sep 17 00:00:00 2001
From: davidperezgar
Date: Mon, 14 Sep 2026 08:15:35 +0200
Subject: [PATCH 10/12] fix nilambar comments
---
.../Checks/Abstract_PHP_CodeSniffer_Check.php | 4 +-
includes/Utilities/Ignore_Matcher.php | 72 +++++++++++++++++--
includes/Utilities/PCP_Ignore_Utility.php | 12 +++-
tests/behat/features/plugin-check.feature | 15 ++--
.../Plugin_Request_Utility_Tests.php | 28 +++++++-
5 files changed, 110 insertions(+), 21 deletions(-)
diff --git a/includes/Checker/Checks/Abstract_PHP_CodeSniffer_Check.php b/includes/Checker/Checks/Abstract_PHP_CodeSniffer_Check.php
index 513c49f98..1bc0bd5d6 100644
--- a/includes/Checker/Checks/Abstract_PHP_CodeSniffer_Check.php
+++ b/includes/Checker/Checks/Abstract_PHP_CodeSniffer_Check.php
@@ -269,7 +269,7 @@ private function get_ignore_patterns( $plugin_root ) {
// Ignore directories anchored to the plugin root (e.g. from .pcpignore).
foreach ( $anchored_directories as $directory ) {
- $ignore_patterns[] = $plugin_root . $directory . '/*';
+ $ignore_patterns[] = Ignore_Matcher::get_php_codesniffer_ignore_pattern( $plugin_root, $directory, true );
}
// Ignore files at any depth (--exclude-files).
@@ -279,7 +279,7 @@ private function get_ignore_patterns( $plugin_root ) {
// Ignore files anchored to the plugin root (e.g. from .pcpignore).
foreach ( $anchored_files as $file ) {
- $ignore_patterns[] = $plugin_root . $file;
+ $ignore_patterns[] = Ignore_Matcher::get_php_codesniffer_ignore_pattern( $plugin_root, $file, false );
}
return $ignore_patterns;
diff --git a/includes/Utilities/Ignore_Matcher.php b/includes/Utilities/Ignore_Matcher.php
index 7a752340f..841494a34 100644
--- a/includes/Utilities/Ignore_Matcher.php
+++ b/includes/Utilities/Ignore_Matcher.php
@@ -70,7 +70,7 @@ public static function is_file_in_ignored_directory( $file_path, $plugin_root, a
$anchored_directory = $plugin_root . $directory;
if ( false !== strpbrk( $directory, '*?' ) ) {
- if ( preg_match( self::glob_to_regex( $anchored_directory . '/*' ), $file_path ) ) {
+ if ( preg_match( self::glob_to_regex( $anchored_directory, true ), $file_path ) ) {
return true;
}
} elseif ( 0 === strpos( $file_path, $anchored_directory . '/' ) ) {
@@ -130,18 +130,80 @@ public static function is_file_ignored( $file_path, $plugin_root, array $files_t
return false;
}
+ /**
+ * Builds an anchored PHP_CodeSniffer ignore pattern.
+ *
+ * PHP_CodeSniffer treats ignore entries as regular expressions and expands
+ * literal asterisks itself. This method translates glob wildcards before
+ * passing the pattern to PHP_CodeSniffer so they have the same semantics as
+ * the file-based checks.
+ *
+ * @since 2.2.0
+ *
+ * @param string $plugin_root Absolute, normalized path to the plugin root directory, without a trailing slash.
+ * @param string $entry Anchored ignore entry.
+ * @param bool $is_directory Whether the entry refers to a directory.
+ * @return string The PHP_CodeSniffer-compatible regular expression pattern.
+ */
+ public static function get_php_codesniffer_ignore_pattern( $plugin_root, $entry, $is_directory ) {
+ $pattern = '^' . self::glob_to_regex_fragment( $plugin_root . $entry, '`' );
+ $pattern = str_replace( ',', '\\,', $pattern );
+
+ if ( $is_directory ) {
+ return $pattern . '/*';
+ }
+
+ return $pattern . '$';
+ }
+
/**
* Converts a glob-style pattern (using `*` and `?` wildcards) to a regular expression.
*
+ * Unlike a typical glob implementation, `*` and `?` never match a `/`,
+ * so a pattern such as `*.map` only matches files directly inside the
+ * anchored location, not at any nested depth.
+ *
* @since 2.2.0
*
- * @param string $pattern The glob-style pattern.
+ * @param string $pattern The glob-style pattern.
+ * @param bool $is_directory Whether the pattern refers to a directory, in which case the
+ * regular expression also matches any file within that directory,
+ * at any depth.
* @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 );
+ private static function glob_to_regex( $pattern, $is_directory = false ) {
+ $regex = self::glob_to_regex_fragment( $pattern, '#' );
+
+ if ( $is_directory ) {
+ return '#^' . $regex . '/.*$#i';
+ }
return '#^' . $regex . '$#i';
}
+
+ /**
+ * Converts glob-style wildcards to a regular expression fragment.
+ *
+ * @since 2.2.0
+ *
+ * @param string $pattern The glob-style pattern.
+ * @param string $delimiter The regular expression delimiter to escape.
+ * @return string The regular expression fragment.
+ */
+ private static function glob_to_regex_fragment( $pattern, $delimiter ) {
+ $parts = preg_split( '/([*?])/', $pattern, -1, PREG_SPLIT_DELIM_CAPTURE );
+
+ $regex = '';
+ foreach ( $parts as $part ) {
+ if ( '*' === $part ) {
+ $regex .= '[^/]{0,}';
+ } elseif ( '?' === $part ) {
+ $regex .= '[^/]';
+ } else {
+ $regex .= preg_quote( $part, $delimiter );
+ }
+ }
+
+ return $regex;
+ }
}
diff --git a/includes/Utilities/PCP_Ignore_Utility.php b/includes/Utilities/PCP_Ignore_Utility.php
index b3c970b10..b9b5dc68c 100644
--- a/includes/Utilities/PCP_Ignore_Utility.php
+++ b/includes/Utilities/PCP_Ignore_Utility.php
@@ -119,7 +119,17 @@ private static function read_ignore_file_lines( $ignore_file ) {
return null;
}
- if ( ! is_readable( $ignore_file ) ) {
+ /**
+ * 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' ),
diff --git a/tests/behat/features/plugin-check.feature b/tests/behat/features/plugin-check.feature
index 68ddc061b..a3d188a23 100644
--- a/tests/behat/features/plugin-check.feature
+++ b/tests/behat/features/plugin-check.feature
@@ -394,7 +394,6 @@ Feature: Test that the WP-CLI command works.
Scenario: .pcpignore exclusions support wildcard patterns
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/assets directory
And a wp-content/plugins/foo-plugin/foo-plugin.php file:
"""
cleanups[] = function () use ( $ignore_file ) {
- chmod( $ignore_file, 0644 );
+ $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 );
@@ -374,6 +378,24 @@ public function test_get_pcpignore_exclusions_with_unreadable_file() {
$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_ignore_pattern( $plugin_root, '/*.map', false ) );
+ $this->assertSame( '^/plugin/file[^/]\\.php$', Ignore_Matcher::get_php_codesniffer_ignore_pattern( $plugin_root, '/file?.php', false ) );
+ $this->assertSame( '^/plugin/data\\[1\\]\\.php$', Ignore_Matcher::get_php_codesniffer_ignore_pattern( $plugin_root, '/data[1].php', false ) );
+ $this->assertSame( '^/plugin/docs[^/]{0,}/*', Ignore_Matcher::get_php_codesniffer_ignore_pattern( $plugin_root, '/docs*', true ) );
+ }
+
public function test_pcpignore_directory_exclusion_is_anchored_to_plugin_root() {
$checks_to_run = array(
new I18n_Usage_Check(),
From 672d677e4d19db31fd530280e80dfa89e2fce82e Mon Sep 17 00:00:00 2001
From: davidperezgar
Date: Mon, 14 Sep 2026 18:46:10 +0200
Subject: [PATCH 11/12] fixes
---
includes/Utilities/Ignore_Matcher.php | 13 +++++++------
.../Utilities/Plugin_Request_Utility_Tests.php | 4 ++--
2 files changed, 9 insertions(+), 8 deletions(-)
diff --git a/includes/Utilities/Ignore_Matcher.php b/includes/Utilities/Ignore_Matcher.php
index 841494a34..27ac17278 100644
--- a/includes/Utilities/Ignore_Matcher.php
+++ b/includes/Utilities/Ignore_Matcher.php
@@ -146,8 +146,7 @@ public static function is_file_ignored( $file_path, $plugin_root, array $files_t
* @return string The PHP_CodeSniffer-compatible regular expression pattern.
*/
public static function get_php_codesniffer_ignore_pattern( $plugin_root, $entry, $is_directory ) {
- $pattern = '^' . self::glob_to_regex_fragment( $plugin_root . $entry, '`' );
- $pattern = str_replace( ',', '\\,', $pattern );
+ $pattern = '^' . self::glob_to_regex_fragment( $plugin_root . $entry, '`', true );
if ( $is_directory ) {
return $pattern . '/*';
@@ -186,11 +185,12 @@ private static function glob_to_regex( $pattern, $is_directory = false ) {
*
* @since 2.2.0
*
- * @param string $pattern The glob-style pattern.
- * @param string $delimiter The regular expression delimiter to escape.
+ * @param string $pattern The glob-style pattern.
+ * @param string $delimiter The regular expression delimiter to escape.
+ * @param bool $escape_comma Whether literal commas need escaping for PHP_CodeSniffer.
* @return string The regular expression fragment.
*/
- private static function glob_to_regex_fragment( $pattern, $delimiter ) {
+ private static function glob_to_regex_fragment( $pattern, $delimiter, $escape_comma = false ) {
$parts = preg_split( '/([*?])/', $pattern, -1, PREG_SPLIT_DELIM_CAPTURE );
$regex = '';
@@ -200,7 +200,8 @@ private static function glob_to_regex_fragment( $pattern, $delimiter ) {
} elseif ( '?' === $part ) {
$regex .= '[^/]';
} else {
- $regex .= preg_quote( $part, $delimiter );
+ $part = preg_quote( $part, $delimiter );
+ $regex .= $escape_comma ? str_replace( ',', '\\,', $part ) : $part;
}
}
diff --git a/tests/phpunit/tests/Utilities/Plugin_Request_Utility_Tests.php b/tests/phpunit/tests/Utilities/Plugin_Request_Utility_Tests.php
index 99f9fedf1..11bff0e1d 100644
--- a/tests/phpunit/tests/Utilities/Plugin_Request_Utility_Tests.php
+++ b/tests/phpunit/tests/Utilities/Plugin_Request_Utility_Tests.php
@@ -383,8 +383,8 @@ public function test_pcpignore_wildcards_do_not_cross_directory_boundaries() {
$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*/' ) ) );
+ $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() {
From b5e6c037e8417c08b77058a58ba30794a72cb2fb Mon Sep 17 00:00:00 2001
From: davidperezgar
Date: Mon, 14 Sep 2026 19:01:59 +0200
Subject: [PATCH 12/12] fix phpmd
---
.../Checks/Abstract_PHP_CodeSniffer_Check.php | 4 +-
includes/Utilities/Ignore_Matcher.php | 70 +++++++++++--------
.../Plugin_Request_Utility_Tests.php | 8 +--
3 files changed, 47 insertions(+), 35 deletions(-)
diff --git a/includes/Checker/Checks/Abstract_PHP_CodeSniffer_Check.php b/includes/Checker/Checks/Abstract_PHP_CodeSniffer_Check.php
index 1bc0bd5d6..ba3e5e7b0 100644
--- a/includes/Checker/Checks/Abstract_PHP_CodeSniffer_Check.php
+++ b/includes/Checker/Checks/Abstract_PHP_CodeSniffer_Check.php
@@ -269,7 +269,7 @@ private function get_ignore_patterns( $plugin_root ) {
// Ignore directories anchored to the plugin root (e.g. from .pcpignore).
foreach ( $anchored_directories as $directory ) {
- $ignore_patterns[] = Ignore_Matcher::get_php_codesniffer_ignore_pattern( $plugin_root, $directory, true );
+ $ignore_patterns[] = Ignore_Matcher::get_php_codesniffer_directory_ignore_pattern( $plugin_root, $directory );
}
// Ignore files at any depth (--exclude-files).
@@ -279,7 +279,7 @@ private function get_ignore_patterns( $plugin_root ) {
// Ignore files anchored to the plugin root (e.g. from .pcpignore).
foreach ( $anchored_files as $file ) {
- $ignore_patterns[] = Ignore_Matcher::get_php_codesniffer_ignore_pattern( $plugin_root, $file, false );
+ $ignore_patterns[] = Ignore_Matcher::get_php_codesniffer_file_ignore_pattern( $plugin_root, $file );
}
return $ignore_patterns;
diff --git a/includes/Utilities/Ignore_Matcher.php b/includes/Utilities/Ignore_Matcher.php
index 27ac17278..86fdc3ace 100644
--- a/includes/Utilities/Ignore_Matcher.php
+++ b/includes/Utilities/Ignore_Matcher.php
@@ -70,7 +70,7 @@ public static function is_file_in_ignored_directory( $file_path, $plugin_root, a
$anchored_directory = $plugin_root . $directory;
if ( false !== strpbrk( $directory, '*?' ) ) {
- if ( preg_match( self::glob_to_regex( $anchored_directory, true ), $file_path ) ) {
+ if ( preg_match( self::glob_directory_to_regex( $anchored_directory ), $file_path ) ) {
return true;
}
} elseif ( 0 === strpos( $file_path, $anchored_directory . '/' ) ) {
@@ -112,7 +112,7 @@ public static function is_file_ignored( $file_path, $plugin_root, array $files_t
$anchored_file = $plugin_root . $file;
if ( false !== strpbrk( $file, '*?' ) ) {
- if ( preg_match( self::glob_to_regex( $anchored_file ), $file_path ) ) {
+ if ( preg_match( self::glob_file_to_regex( $anchored_file ), $file_path ) ) {
return true;
}
} elseif ( $file_path === $anchored_file ) {
@@ -131,7 +131,7 @@ public static function is_file_ignored( $file_path, $plugin_root, array $files_t
}
/**
- * Builds an anchored PHP_CodeSniffer ignore pattern.
+ * Builds an anchored PHP_CodeSniffer directory ignore pattern.
*
* PHP_CodeSniffer treats ignore entries as regular expressions and expands
* literal asterisks itself. This method translates glob wildcards before
@@ -141,18 +141,29 @@ public static function is_file_ignored( $file_path, $plugin_root, array $files_t
* @since 2.2.0
*
* @param string $plugin_root Absolute, normalized path to the plugin root directory, without a trailing slash.
- * @param string $entry Anchored ignore entry.
- * @param bool $is_directory Whether the entry refers to a directory.
+ * @param string $entry Anchored directory ignore entry.
* @return string The PHP_CodeSniffer-compatible regular expression pattern.
*/
- public static function get_php_codesniffer_ignore_pattern( $plugin_root, $entry, $is_directory ) {
- $pattern = '^' . self::glob_to_regex_fragment( $plugin_root . $entry, '`', true );
-
- if ( $is_directory ) {
- return $pattern . '/*';
- }
+ public static function get_php_codesniffer_directory_ignore_pattern( $plugin_root, $entry ) {
+ return '^' . self::glob_to_regex_fragment( $plugin_root . $entry, '`' ) . '/*';
+ }
- return $pattern . '$';
+ /**
+ * Builds an anchored PHP_CodeSniffer file ignore pattern.
+ *
+ * PHP_CodeSniffer treats ignore entries as regular expressions and expands
+ * literal asterisks itself. This method translates glob wildcards before
+ * passing the pattern to PHP_CodeSniffer so they have the same semantics as
+ * the file-based checks.
+ *
+ * @since 2.2.0
+ *
+ * @param string $plugin_root Absolute, normalized path to the plugin root directory, without a trailing slash.
+ * @param string $entry Anchored file ignore entry.
+ * @return string The PHP_CodeSniffer-compatible regular expression pattern.
+ */
+ public static function get_php_codesniffer_file_ignore_pattern( $plugin_root, $entry ) {
+ return '^' . self::glob_to_regex_fragment( $plugin_root . $entry, '`' ) . '$';
}
/**
@@ -164,20 +175,23 @@ public static function get_php_codesniffer_ignore_pattern( $plugin_root, $entry,
*
* @since 2.2.0
*
- * @param string $pattern The glob-style pattern.
- * @param bool $is_directory Whether the pattern refers to a directory, in which case the
- * regular expression also matches any file within that directory,
- * at any depth.
+ * @param string $pattern The glob-style pattern.
* @return string The equivalent case-insensitive, fully anchored regular expression.
*/
- private static function glob_to_regex( $pattern, $is_directory = false ) {
- $regex = self::glob_to_regex_fragment( $pattern, '#' );
-
- if ( $is_directory ) {
- return '#^' . $regex . '/.*$#i';
- }
+ private static function glob_directory_to_regex( $pattern ) {
+ return '#^' . self::glob_to_regex_fragment( $pattern, '#' ) . '/.*$#i';
+ }
- return '#^' . $regex . '$#i';
+ /**
+ * Converts a glob-style file pattern (using `*` and `?` wildcards) to a regular expression.
+ *
+ * @since 2.2.0
+ *
+ * @param string $pattern The glob-style pattern.
+ * @return string The equivalent case-insensitive, fully anchored regular expression.
+ */
+ private static function glob_file_to_regex( $pattern ) {
+ return '#^' . self::glob_to_regex_fragment( $pattern, '#' ) . '$#i';
}
/**
@@ -185,12 +199,11 @@ private static function glob_to_regex( $pattern, $is_directory = false ) {
*
* @since 2.2.0
*
- * @param string $pattern The glob-style pattern.
- * @param string $delimiter The regular expression delimiter to escape.
- * @param bool $escape_comma Whether literal commas need escaping for PHP_CodeSniffer.
+ * @param string $pattern The glob-style pattern.
+ * @param string $delimiter The regular expression delimiter to escape.
* @return string The regular expression fragment.
*/
- private static function glob_to_regex_fragment( $pattern, $delimiter, $escape_comma = false ) {
+ private static function glob_to_regex_fragment( $pattern, $delimiter ) {
$parts = preg_split( '/([*?])/', $pattern, -1, PREG_SPLIT_DELIM_CAPTURE );
$regex = '';
@@ -200,8 +213,7 @@ private static function glob_to_regex_fragment( $pattern, $delimiter, $escape_co
} elseif ( '?' === $part ) {
$regex .= '[^/]';
} else {
- $part = preg_quote( $part, $delimiter );
- $regex .= $escape_comma ? str_replace( ',', '\\,', $part ) : $part;
+ $regex .= str_replace( ',', '\\,', preg_quote( $part, $delimiter ) );
}
}
diff --git a/tests/phpunit/tests/Utilities/Plugin_Request_Utility_Tests.php b/tests/phpunit/tests/Utilities/Plugin_Request_Utility_Tests.php
index 11bff0e1d..c76bbe22a 100644
--- a/tests/phpunit/tests/Utilities/Plugin_Request_Utility_Tests.php
+++ b/tests/phpunit/tests/Utilities/Plugin_Request_Utility_Tests.php
@@ -390,10 +390,10 @@ public function test_pcpignore_wildcards_do_not_cross_directory_boundaries() {
public function test_pcpignore_php_codesniffer_patterns_match_glob_semantics() {
$plugin_root = '/plugin';
- $this->assertSame( '^/plugin/[^/]{0,}\\.map$', Ignore_Matcher::get_php_codesniffer_ignore_pattern( $plugin_root, '/*.map', false ) );
- $this->assertSame( '^/plugin/file[^/]\\.php$', Ignore_Matcher::get_php_codesniffer_ignore_pattern( $plugin_root, '/file?.php', false ) );
- $this->assertSame( '^/plugin/data\\[1\\]\\.php$', Ignore_Matcher::get_php_codesniffer_ignore_pattern( $plugin_root, '/data[1].php', false ) );
- $this->assertSame( '^/plugin/docs[^/]{0,}/*', Ignore_Matcher::get_php_codesniffer_ignore_pattern( $plugin_root, '/docs*', true ) );
+ $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() {