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