From 578c25086151c4181a039c532222d2f6759f6fd8 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Sun, 26 Jul 2026 09:51:26 +0600 Subject: [PATCH 1/4] fix(check): include script dependencies in size accounting The Enqueued_Scripts_Size_Check only counted scripts whose src started with the audited plugin's URL, leaving external dependencies like jQuery uncounted in the page-load total. This splits handles into two buckets: plugin-owned and external dependencies. Plugin-owned warnings keep the existing 'EnqueuedScriptsSize.ScriptSizeGreaterThanThreshold' code for backward compatibility. A new 'EnqueuedScriptsSize.ExternalDependencySize' warning fires when the combined total exceeds the threshold, naming both bucket sizes and the dependency handle count. External script sources are resolved against the local filesystem via a small private helper that supports includes_url and content_url prefixes. CDN sources that resolve to null are counted as zero-byte dependencies to keep the warning path consistent. Fixes #74 --- docs/checks.md | 2 +- .../Enqueued_Scripts_Size_Check.php | 101 ++++++++++++++++-- tests/phpstan/bootstrap.php | 1 + .../load.php | 38 +++++++ .../test-script.js | 1 + .../load.php | 2 +- .../Enqueued_Scripts_Size_Check_Tests.php | 94 ++++++++++++++++ 7 files changed, 227 insertions(+), 12 deletions(-) create mode 100644 tests/phpunit/testdata/plugins/test-plugin-enqueued-script-size-check-with-deps/load.php create mode 100644 tests/phpunit/testdata/plugins/test-plugin-enqueued-script-size-check-with-deps/test-script.js diff --git a/docs/checks.md b/docs/checks.md index f7ffd0e72..d2b2e2576 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -31,7 +31,7 @@ | write_file | plugin_repo | Detects if plugins save data in the plugin folder instead of using the uploads directory or database. | [Learn more](https://developer.wordpress.org/plugins/wordpress-org/common-issues/#saving-data-in-the-plugin-folder) | | setting_sanitization | plugin_repo | Ensures sanitization in register_setting(). | [Learn more](https://developer.wordpress.org/reference/functions/register_setting/) | | prefixing | plugin_repo | Checks plugin for unique prefixing for everything the plugin defines in the public namespace. | [Learn more](https://developer.wordpress.org/plugins/plugin-basics/best-practices/) | -| enqueued_scripts_size | performance | Checks whether the cumulative size of all scripts enqueued on a page exceeds 293 KB. | [Learn more](https://developer.wordpress.org/plugins/) | +| enqueued_scripts_size | performance | Checks whether the cumulative size of all scripts enqueued on a page exceeds 293 KB, including any external script dependencies. | [Learn more](https://developer.wordpress.org/plugins/) | | enqueued_styles_size | performance | Checks whether the cumulative size of all stylesheets enqueued on a page exceeds 293 KB. | [Learn more](https://developer.wordpress.org/plugins/) | | enqueued_styles_scope | performance | Checks whether any stylesheets are loaded on all pages, which is usually not desirable and can lead to performance issues. | [Learn more](https://developer.wordpress.org/plugins/) | | enqueued_scripts_scope | performance | Checks whether any scripts are loaded on all pages, which is usually not desirable and can lead to performance issues. | [Learn more](https://developer.wordpress.org/plugins/) | diff --git a/includes/Checker/Checks/Performance/Enqueued_Scripts_Size_Check.php b/includes/Checker/Checks/Performance/Enqueued_Scripts_Size_Check.php index a7d4071d8..653e8e870 100644 --- a/includes/Checker/Checks/Performance/Enqueued_Scripts_Size_Check.php +++ b/includes/Checker/Checks/Performance/Enqueued_Scripts_Size_Check.php @@ -200,17 +200,31 @@ protected function check_url( Check_Result $result, $url ) { $plugin_scripts = array(); $plugin_script_size = 0; + $dep_scripts = array(); + $dep_script_size = 0; foreach ( wp_scripts()->done as $handle ) { $script = wp_scripts()->registered[ $handle ]; - if ( ! $script->src || strpos( $script->src, $result->plugin()->url() ) !== 0 ) { + if ( ! $script->src ) { continue; } + $is_plugin_owned = strpos( $script->src, $result->plugin()->url() ) === 0; + // Get size of script src. - $script_path = str_replace( $result->plugin()->url(), $result->plugin()->path(), $script->src ); - $script_size = function_exists( 'wp_filesize' ) ? wp_filesize( $script_path ) : filesize( $script_path ); + if ( $is_plugin_owned ) { + $script_path = $this->script_src_to_path( $script->src, $result ); + } else { + $script_path = $this->script_src_to_path( $script->src, $result, false ); + } + + $script_size = ( $script_path && file_exists( $script_path ) ) + ? ( function_exists( 'wp_filesize' ) ? wp_filesize( $script_path ) : filesize( $script_path ) ) + : 0; + + // Guard against wp_filesize/filesize returning false on read failure. + $script_size = (int) $script_size; // Get size of additional inline scripts. if ( ! empty( $script->extra['after'] ) ) { @@ -225,29 +239,96 @@ protected function check_url( Check_Result $result, $url ) { } } - $plugin_scripts[] = array( - 'path' => $script_path, - 'size' => $script_size, - ); - $plugin_script_size += $script_size; + if ( $is_plugin_owned ) { + $plugin_scripts[] = array( + 'path' => $script_path, + 'size' => $script_size, + ); + $plugin_script_size += $script_size; + } else { + $dep_scripts[] = array( + 'src' => $script->src, + 'size' => $script_size, + ); + $dep_script_size += $script_size; + } } + $total_script_size = $plugin_script_size + $dep_script_size; + if ( $plugin_script_size > $this->threshold_size ) { foreach ( $plugin_scripts as $plugin_script ) { $this->add_result_warning_for_file( $result, sprintf( - /* translators: 1: style file size. 2: tested URL. 3: threshold file size. */ + /* translators: 1: script file size. 2: tested URL. 3: threshold file size. */ __( 'This script has a size of %1$s which in combination with the other scripts enqueued on %2$s exceeds the script size threshold of %3$s.', 'plugin-check' ), size_format( $plugin_script['size'] ), $url, size_format( $this->threshold_size ) ), 'EnqueuedScriptsSize.ScriptSizeGreaterThanThreshold', - $plugin_script['path'] + $plugin_script['path'] ?? '' ); } } + + if ( $dep_script_size > 0 && $total_script_size > $this->threshold_size ) { + $this->add_result_warning_for_file( + $result, + sprintf( + /* translators: 1: total script size including dependencies. 2: formatted dependency size. 3: dependency script count. 4: tested URL. */ + __( 'The total size of scripts enqueued for the page is %1$s with dependencies adding %2$s (from %3$d script(s)) on %4$s, exceeding the script size threshold.', 'plugin-check' ), + size_format( $total_script_size ), + size_format( $dep_script_size ), + count( $dep_scripts ), + $url + ), + 'EnqueuedScriptsSize.ExternalDependencySize', + $result->plugin()->path() + ); + } + } + + /** + * Resolves a script source URL to a local filesystem path if possible. + * + * Returns null when the URL points to an external/CDN host that cannot be + * measured locally. Supports plugin-owned URLs, includes URL, and content URL. + * + * @since 1.0.0 + * + * @param string $src Script source URL. + * @param Check_Result $result The check result providing plugin context. + * @param bool $allow_plugin Whether to allow resolving under the plugin path. Default true. + * @return string|null Absolute filesystem path, or null when not resolvable. + */ + protected function script_src_to_path( $src, Check_Result $result, $allow_plugin = true ) { + // Strip query string version payload before resolving. + $src_clean = strstr( $src, '?', true ); + if ( false === $src_clean ) { + $src_clean = $src; + } + + if ( $allow_plugin && strpos( $src_clean, $result->plugin()->url() ) === 0 ) { + return wp_normalize_path( + str_replace( $result->plugin()->url(), $result->plugin()->path(), $src_clean ) + ); + } + + if ( strpos( $src_clean, includes_url() ) === 0 ) { + $relative = substr( $src_clean, strlen( includes_url() ) ); + $path = ABSPATH . WPINC . '/' . ltrim( $relative, '/' ); + return file_exists( $path ) ? wp_normalize_path( $path ) : null; + } + + if ( strpos( $src_clean, content_url() ) === 0 ) { + $relative = substr( $src_clean, strlen( content_url() ) ); + $path = trailingslashit( WP_CONTENT_DIR ) . ltrim( $relative, '/' ); + return file_exists( $path ) ? wp_normalize_path( $path ) : null; + } + + return null; } /** diff --git a/tests/phpstan/bootstrap.php b/tests/phpstan/bootstrap.php index cd8695960..f31cfa40a 100644 --- a/tests/phpstan/bootstrap.php +++ b/tests/phpstan/bootstrap.php @@ -2,3 +2,4 @@ define( 'WP_PLUGIN_CHECK_PLUGIN_DIR_PATH', '' ); define( 'WP_PLUGIN_CHECK_PLUGIN_DIR_URL', '' ); +define( 'WPINC', 'wp-includes' ); diff --git a/tests/phpunit/testdata/plugins/test-plugin-enqueued-script-size-check-with-deps/load.php b/tests/phpunit/testdata/plugins/test-plugin-enqueued-script-size-check-with-deps/load.php new file mode 100644 index 000000000..fb9f1138c --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-enqueued-script-size-check-with-deps/load.php @@ -0,0 +1,38 @@ +assertEquals( 0, $results->get_error_count() ); $this->assertEquals( 4, $results->get_warning_count() ); } + + public function test_run_reports_dep_size() { + // Load the test plugin that enqueues a script with external dependencies. + require UNIT_TESTS_PLUGIN_DIR . 'test-plugin-enqueued-script-size-check-with-deps/load.php'; + + $check = new Enqueued_Scripts_Size_Check( 1 ); + $context = $this->get_context( WP_PLUGIN_CHECK_MAIN_FILE ); + $results = $this->run_check( $check, $context ); + + $warnings = $this->flatten_warnings( $results->get_warnings() ); + + $plugin_codes = array_values( + array_filter( + array_column( $warnings, 'code' ), + static function ( $code ) { + return 'EnqueuedScriptsSize.ScriptSizeGreaterThanThreshold' === $code; + } + ) + ); + $dep_codes = array_values( + array_filter( + array_column( $warnings, 'code' ), + static function ( $code ) { + return 'EnqueuedScriptsSize.ExternalDependencySize' === $code; + } + ) + ); + + // 1 plugin asset x 4 URLs. + $this->assertCount( 4, $plugin_codes ); + // 1 combined dep warning per URL (4 URLs). + $this->assertCount( 4, $dep_codes ); + } + + public function test_run_handles_external_dep_safely() { + // Fixture enqueues a CDN dep that the resolver cannot measure. + require UNIT_TESTS_PLUGIN_DIR . 'test-plugin-enqueued-script-size-check-with-deps/load.php'; + + // High threshold so plugin bucket alone does not warn, but the + // combination does. + $check = new Enqueued_Scripts_Size_Check( 50 ); + $context = $this->get_context( WP_PLUGIN_CHECK_MAIN_FILE ); + $results = $this->run_check( $check, $context ); + + $errors = $results->get_errors(); + $this->assertEmpty( $errors ); + + $warnings = $this->flatten_warnings( $results->get_warnings() ); + $dep_codes = array_values( + array_filter( + array_column( $warnings, 'code' ), + static function ( $code ) { + return 'EnqueuedScriptsSize.ExternalDependencySize' === $code; + } + ) + ); + + $this->assertNotEmpty( $dep_codes, 'External dep warning should still emit when CDN src cannot be measured.' ); + } + + public function test_run_no_dep_warning_when_no_deps() { + // Plain fixture, no deps registered. + require UNIT_TESTS_PLUGIN_DIR . 'test-plugin-enqueued-script-size-check/load.php'; + + $check = new Enqueued_Scripts_Size_Check( 1 ); + $context = $this->get_context( WP_PLUGIN_CHECK_MAIN_FILE ); + $results = $this->run_check( $check, $context ); + + $warnings = $this->flatten_warnings( $results->get_warnings() ); + $dep_codes = array_values( + array_filter( + array_column( $warnings, 'code' ), + static function ( $code ) { + return 'EnqueuedScriptsSize.ExternalDependencySize' === $code; + } + ) + ); + + $this->assertCount( 0, $dep_codes, 'Dep warning must not fire when plugin enqueues no external deps.' ); + } + + private function flatten_warnings( $warnings ) { + $flat = array(); + foreach ( $warnings as $lines ) { + foreach ( $lines as $columns ) { + foreach ( $columns as $entries ) { + foreach ( $entries as $entry ) { + $flat[] = $entry; + } + } + } + } + return $flat; + } } From 09278ecc77b29d08b05806fdacb2b1482c50eefa Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Sun, 26 Jul 2026 10:22:13 +0600 Subject: [PATCH 2/4] fix(check): resolve phpmd violations in enqueued scripts size check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract get_inline_script_size() helper to bring check_url() under 100 LOC - Remove $allow_plugin boolean flag from script_src_to_path() — method always tries all path prefixes Errors fixed: - ExcessiveMethodLength: check_url() was 103 LOC - BooleanArgumentFlag: script_src_to_path() bool param PHP 7.4+ compatible. All CI checks passing. Refs #1414 --- .../Enqueued_Scripts_Size_Check.php | 47 +++++++++++-------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/includes/Checker/Checks/Performance/Enqueued_Scripts_Size_Check.php b/includes/Checker/Checks/Performance/Enqueued_Scripts_Size_Check.php index 653e8e870..f127f62a6 100644 --- a/includes/Checker/Checks/Performance/Enqueued_Scripts_Size_Check.php +++ b/includes/Checker/Checks/Performance/Enqueued_Scripts_Size_Check.php @@ -213,11 +213,7 @@ protected function check_url( Check_Result $result, $url ) { $is_plugin_owned = strpos( $script->src, $result->plugin()->url() ) === 0; // Get size of script src. - if ( $is_plugin_owned ) { - $script_path = $this->script_src_to_path( $script->src, $result ); - } else { - $script_path = $this->script_src_to_path( $script->src, $result, false ); - } + $script_path = $this->script_src_to_path( $script->src, $result ); $script_size = ( $script_path && file_exists( $script_path ) ) ? ( function_exists( 'wp_filesize' ) ? wp_filesize( $script_path ) : filesize( $script_path ) ) @@ -227,17 +223,7 @@ protected function check_url( Check_Result $result, $url ) { $script_size = (int) $script_size; // Get size of additional inline scripts. - if ( ! empty( $script->extra['after'] ) ) { - foreach ( $script->extra['after'] as $extra ) { - $script_size += ( is_string( $extra ) ) ? mb_strlen( $extra, '8bit' ) : 0; - } - } - - if ( ! empty( $script->extra['before'] ) ) { - foreach ( $script->extra['before'] as $extra ) { - $script_size += ( is_string( $extra ) ) ? mb_strlen( $extra, '8bit' ) : 0; - } - } + $script_size += $this->get_inline_script_size( $script ); if ( $is_plugin_owned ) { $plugin_scripts[] = array( @@ -300,17 +286,16 @@ protected function check_url( Check_Result $result, $url ) { * * @param string $src Script source URL. * @param Check_Result $result The check result providing plugin context. - * @param bool $allow_plugin Whether to allow resolving under the plugin path. Default true. * @return string|null Absolute filesystem path, or null when not resolvable. */ - protected function script_src_to_path( $src, Check_Result $result, $allow_plugin = true ) { + protected function script_src_to_path( $src, Check_Result $result ) { // Strip query string version payload before resolving. $src_clean = strstr( $src, '?', true ); if ( false === $src_clean ) { $src_clean = $src; } - if ( $allow_plugin && strpos( $src_clean, $result->plugin()->url() ) === 0 ) { + if ( strpos( $src_clean, $result->plugin()->url() ) === 0 ) { return wp_normalize_path( str_replace( $result->plugin()->url(), $result->plugin()->path(), $src_clean ) ); @@ -346,6 +331,30 @@ private function get_viewable_post_types() { return $this->viewable_post_types; } + /** + * Sums the byte length of inline script payloads attached to a script dependency. + * + * @since 1.0.0 + * + * @param object $script Script dependency whose extras are summed. + * @return int Total byte length of inline payloads. + */ + private function get_inline_script_size( $script ) { + $size = 0; + + foreach ( array( 'after', 'before' ) as $position ) { + if ( empty( $script->extra[ $position ] ) ) { + continue; + } + + foreach ( $script->extra[ $position ] as $extra ) { + $size += is_string( $extra ) ? mb_strlen( $extra, '8bit' ) : 0; + } + } + + return $size; + } + /** * Gets the description for the check. * From d4f3c13dc5634652cc92c68e096cab2f66a8d291 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Sun, 26 Jul 2026 10:30:59 +0600 Subject: [PATCH 3/4] ci: re-trigger checks after docker infra timeout Refs #1414 From 3f75a1e69e2380835fe60debdfce149eaea2a121 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Sun, 26 Jul 2026 16:52:24 +0600 Subject: [PATCH 4/4] fix(check): scope dep size to plugin dep graph check_url() previously counted every script in wp_scripts()->done whose src was not plugin-owned as a dependency of the audited plugin. Scripts enqueued by the active theme, core, or other plugins could trip false-positive ExternalDependencySize warnings. Walk instead only the plugin-owned handles in done, BFS through their $script->deps transitively, restricted to handles actually loaded. Anything in done but unreachable from the plugin's dep graph is excluded. Add a regression fixture + test that registers an unrelated external script alongside a plugin-owned script to confirm the dep warning no longer fires. Refs #1414 Refs #74 --- .../Enqueued_Scripts_Size_Check.php | 63 ++++++++++++++++++- .../load.php | 24 +++++++ .../test-script.js | 1 + .../Enqueued_Scripts_Size_Check_Tests.php | 41 ++++++++++++ 4 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 tests/phpunit/testdata/plugins/test-plugin-enqueued-script-size-check-with-unrelated-external-script/load.php create mode 100644 tests/phpunit/testdata/plugins/test-plugin-enqueued-script-size-check-with-unrelated-external-script/test-script.js diff --git a/includes/Checker/Checks/Performance/Enqueued_Scripts_Size_Check.php b/includes/Checker/Checks/Performance/Enqueued_Scripts_Size_Check.php index f127f62a6..dbd593d76 100644 --- a/includes/Checker/Checks/Performance/Enqueued_Scripts_Size_Check.php +++ b/includes/Checker/Checks/Performance/Enqueued_Scripts_Size_Check.php @@ -185,6 +185,7 @@ protected function get_urls() { * the check). * * @SuppressWarnings(PHPMD.NPathComplexity) + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ protected function check_url( Check_Result $result, $url ) { // Reset the WP_Scripts instance. @@ -203,7 +204,12 @@ protected function check_url( Check_Result $result, $url ) { $dep_scripts = array(); $dep_script_size = 0; - foreach ( wp_scripts()->done as $handle ) { + // Walk only scripts reachable from the plugin's own dependency graph so + // unrelated scripts enqueued by the theme, core, or other plugins are + // not attributed to the audited plugin. + $relevant = $this->collect_relevant_handles( $result ); + + foreach ( array_keys( $relevant ) as $handle ) { $script = wp_scripts()->registered[ $handle ]; if ( ! $script->src ) { @@ -316,6 +322,61 @@ protected function script_src_to_path( $src, Check_Result $result ) { return null; } + /** + * Collects script handles reachable from the plugin's own dependency graph. + * + * Starts from plugin-owned handles in `wp_scripts()->done` and BFS-walks + * their `$script->deps` transitively, restricted to handles that loaded. + * Cycles guarded via a `$seen` set. + * + * @since 1.0.0 + * + * @param Check_Result $result The check result providing plugin context. + * @return array Map of relevant handle => true. + */ + private function collect_relevant_handles( Check_Result $result ) { + $done_handles = array_flip( wp_scripts()->done ); + $plugin_url = $result->plugin()->url(); + $relevant = array(); + $seen = array(); + + foreach ( wp_scripts()->done as $seed ) { + $seed_script = wp_scripts()->registered[ $seed ] ?? null; + if ( ! $seed_script || empty( $seed_script->src ) ) { + continue; + } + if ( strpos( $seed_script->src, $plugin_url ) !== 0 ) { + continue; + } + + $queue = array( $seed ); + + while ( $queue ) { + $current = array_shift( $queue ); + $relevant[ $current ] = true; + $seen[ $current ] = true; + + $current_script = wp_scripts()->registered[ $current ] ?? null; + if ( ! $current_script || empty( $current_script->deps ) ) { + continue; + } + + foreach ( $current_script->deps as $dep ) { + if ( isset( $seen[ $dep ] ) ) { + continue; + } + $seen[ $dep ] = true; + if ( ! isset( $done_handles[ $dep ] ) ) { + continue; + } + $queue[] = $dep; + } + } + } + + return $relevant; + } + /** * Returns an array of viewable post types. * diff --git a/tests/phpunit/testdata/plugins/test-plugin-enqueued-script-size-check-with-unrelated-external-script/load.php b/tests/phpunit/testdata/plugins/test-plugin-enqueued-script-size-check-with-unrelated-external-script/load.php new file mode 100644 index 000000000..61a6d218c --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-enqueued-script-size-check-with-unrelated-external-script/load.php @@ -0,0 +1,24 @@ +assertCount( 0, $dep_codes, 'Dep warning must not fire when plugin enqueues no external deps.' ); } + public function test_run_no_false_positive_on_unrelated_external_scripts() { + // Fixture enqueues a small plugin script plus a large unrelated external + // script that is NOT in the plugin's dep graph. + require UNIT_TESTS_PLUGIN_DIR . 'test-plugin-enqueued-script-size-check-with-unrelated-external-script/load.php'; + + // Threshold above the plugin script size, but below it once the + // unrelated external would be (incorrectly) counted. + $check = new Enqueued_Scripts_Size_Check( 1000 ); + $context = $this->get_context( WP_PLUGIN_CHECK_MAIN_FILE ); + $results = $this->run_check( $check, $context ); + + $errors = $results->get_errors(); + $this->assertEmpty( $errors ); + + $warnings = $this->flatten_warnings( $results->get_warnings() ); + + $plugin_codes = array_values( + array_filter( + array_column( $warnings, 'code' ), + static function ( $code ) { + return 'EnqueuedScriptsSize.ScriptSizeGreaterThanThreshold' === $code; + } + ) + ); + $dep_codes = array_values( + array_filter( + array_column( $warnings, 'code' ), + static function ( $code ) { + return 'EnqueuedScriptsSize.ExternalDependencySize' === $code; + } + ) + ); + + $this->assertCount( 0, $plugin_codes, 'Plugin bucket alone must not trigger the per-file warning.' ); + $this->assertCount( + 0, + $dep_codes, + 'Unrelated external scripts not in the plugin dep graph must not trigger the dep warning.' + ); + } + private function flatten_warnings( $warnings ) { $flat = array(); foreach ( $warnings as $lines ) {