From 2ce9b026cbe00dfd3887ccefa20437bcd5ffcb4c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 30 Apr 2026 11:58:48 -0700 Subject: [PATCH 1/6] Add types for wp_parse_url() and dependent functions --- src/wp-includes/http.php | 53 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/wp-includes/http.php b/src/wp-includes/http.php index 643efc0a16930..8280f424934dd 100644 --- a/src/wp-includes/http.php +++ b/src/wp-includes/http.php @@ -716,6 +716,26 @@ function ms_allowed_http_request_hosts( $is_external, $host ) { * When a specific component has been requested: null if the component * doesn't exist in the given URL; a string or - in the case of * PHP_URL_PORT - integer when it does. See parse_url()'s return values. + * + * @phpstan-param int<-1, 7> $component + * @phpstan-return ( + * $component is -1 + * ? false|array{ + * scheme?: string, + * host?: string, + * port?: int<0, 65535>, + * user?: string, + * pass?: string, + * path?: string, + * query?: string, + * fragment?: string, + * } + * : ( + * $component is 2 + * ? int<0, 65535>|null + * : string|null + * ) + * ) */ function wp_parse_url( $url, $component = -1 ) { $to_unset = array(); @@ -763,6 +783,36 @@ function wp_parse_url( $url, $component = -1 ) { * When a specific component has been requested: null if the component * doesn't exist in the given URL; a string or - in the case of * PHP_URL_PORT - integer when it does. See parse_url()'s return values. + * + * @phpstan-param false|array{ + * scheme?: string, + * host?: string, + * port?: int<0, 65535>, + * user?: string, + * pass?: string, + * path?: string, + * query?: string, + * fragment?: string, + * } $url_parts + * @phpstan-param int<-1, 7> $component + * @phpstan-return ( + * $component is -1 + * ? false|array{ + * scheme?: string, + * host?: string, + * port?: int<0, 65535>, + * user?: string, + * pass?: string, + * path?: string, + * query?: string, + * fragment?: string, + * } + * : ( + * $component is 2 + * ? int<0, 65535>|null + * : string|null + * ) + * ) */ function _get_component_from_parsed_url_array( $url_parts, $component = -1 ) { if ( -1 === $component ) { @@ -789,6 +839,9 @@ function _get_component_from_parsed_url_array( $url_parts, $component = -1 ) { * * @param int $constant PHP_URL_* constant. * @return string|false The named key or false. + * + * @phpstan-param int<-1, 7> $constant + * @phpstan-return 'scheme'|'host'|'port'|'user'|'pass'|'path'|'query'|'fragment'|false */ function _wp_translate_php_url_constant_to_key( $constant ) { $translation = array( From bfe0eca6b7e0adfed39fdf24aecc846d313d8869 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 30 Apr 2026 11:59:51 -0700 Subject: [PATCH 2/6] Handle possible undefined path index on src_url and fix type issues in _load_script_textdomain_from_src() --- src/wp-includes/l10n.php | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/l10n.php b/src/wp-includes/l10n.php index ee2dfc5dd308b..d4b910bc8c88a 100644 --- a/src/wp-includes/l10n.php +++ b/src/wp-includes/l10n.php @@ -1206,6 +1206,7 @@ function load_script_module_textdomain( string $id, string $domain = 'default', * @return string|false The JSON-encoded translated strings on success, false otherwise. */ function _load_script_textdomain_from_src( string $handle, string $src, string $domain, string $path, bool $is_module ) { + /** @var WP_Textdomain_Registry $wp_textdomain_registry */ global $wp_textdomain_registry; $locale = determine_locale(); @@ -1214,7 +1215,9 @@ function _load_script_textdomain_from_src( string $handle, string $src, string $ $path = $wp_textdomain_registry->get( $domain, $locale ); } - $path = untrailingslashit( $path ); + if ( $path ) { + $path = untrailingslashit( $path ); + } // If a path was given and the handle file exists simply return it. $file_base = 'default' === $domain ? $locale : $domain . '-' . $locale; @@ -1231,8 +1234,17 @@ function _load_script_textdomain_from_src( string $handle, string $src, string $ $relative = false; $languages_path = WP_LANG_DIR; - $src_url = wp_parse_url( $src ); + $src_url = wp_parse_url( $src ); + if ( ! $src_url ) { + return false; + } + $src_url['path'] ??= ''; + $content_url = wp_parse_url( content_url() ); + if ( ! $content_url ) { + return false; + } + $plugins_url = wp_parse_url( plugins_url() ); $site_url = wp_parse_url( site_url() ); $theme_root = get_theme_root(); @@ -1304,7 +1316,7 @@ function _load_script_textdomain_from_src( string $handle, string $src, string $ $relative = apply_filters( 'load_script_textdomain_relative_path', $relative, $src, $is_module ); // If the source is not from WP. - if ( false === $relative ) { + if ( ! is_string( $relative ) ) { return load_script_translations( false, $handle, $domain ); } From 8f38b3635d2fe90c17ccb68431436baa73c4a72e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 30 Apr 2026 12:58:17 -0700 Subject: [PATCH 3/6] Fall back to load_script_translations() when failing to parse src or content URL Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/wp-includes/l10n.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/l10n.php b/src/wp-includes/l10n.php index d4b910bc8c88a..2f27c2180037a 100644 --- a/src/wp-includes/l10n.php +++ b/src/wp-includes/l10n.php @@ -1236,13 +1236,13 @@ function _load_script_textdomain_from_src( string $handle, string $src, string $ $src_url = wp_parse_url( $src ); if ( ! $src_url ) { - return false; + return load_script_translations( false, $handle, $domain ); } $src_url['path'] ??= ''; $content_url = wp_parse_url( content_url() ); if ( ! $content_url ) { - return false; + return load_script_translations( false, $handle, $domain ); } $plugins_url = wp_parse_url( plugins_url() ); From b23045035672c0ee551b51594e26c2a9300b6ec9 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 1 May 2026 14:40:12 -0700 Subject: [PATCH 4/6] Add unit tests for new fallback returns in _load_script_textdomain_from_src() Cover the three new short-circuit branches added in 8f38b3635d (unparseable $src, unparseable content_url(), non-string $relative filter return) plus the $src_url['path'] default added in bfe0eca6b7. Each test uses a pre_load_script_translations spy to assert the early-return branch was the one actually taken, rather than the result coincidentally matching the fallback-path behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tests/l10n/loadScriptTextdomain.php | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/tests/phpunit/tests/l10n/loadScriptTextdomain.php b/tests/phpunit/tests/l10n/loadScriptTextdomain.php index 7aedd92cc666c..cbbd505dedb7b 100644 --- a/tests/phpunit/tests/l10n/loadScriptTextdomain.php +++ b/tests/phpunit/tests/l10n/loadScriptTextdomain.php @@ -172,4 +172,160 @@ public function test_does_not_throw_deprecation_notice_for_rtrim_with_default_pa $expected = file_get_contents( DIR_TESTDATA . '/languages/en_US-813e104eb47e13dd4cc5af844c618754.json' ); $this->assertSame( $expected, load_script_textdomain( $handle ) ); } + + /** + * Records every `$file` value passed to `load_script_translations()` + * so individual tests can assert which code path produced the result. + * + * @return list Reference to the array updated by the spy filter. + */ + private function &spy_load_script_translations_files(): array { + /** @var list $files_seen */ + $files_seen = array(); + add_filter( + 'pre_load_script_translations', + static function ( $translations, $file ) use ( &$files_seen ) { + assert( is_string( $file ) || false === $file ); + $files_seen[] = $file; + return $translations; + }, + 10, + 2 + ); + return $files_seen; + } + + /** + * Tests that an unparseable script source URL short-circuits to + * `load_script_translations( false, ... )` instead of falling through + * to the relative-path computation. + * + * @ticket 65015 + */ + public function test_unparseable_src_returns_false(): void { + $handle = 'test-unparseable-src'; + $src = 'http:///example'; + + $this->assertFalse( wp_parse_url( $src ), 'Test prerequisite failed: the test src should be unparseable.' ); + + $files_seen = &$this->spy_load_script_translations_files(); + + wp_enqueue_script( $handle, $src, array(), null ); + + $this->assertFalse( load_script_textdomain( $handle, 'default', DIR_TESTDATA . '/languages' ) ); + $this->assertSame( + array( + DIR_TESTDATA . '/languages/en_US-' . $handle . '.json', + false, + ), + $files_seen, + 'Expected the unparseable $src branch to short-circuit before any relative-path lookup.' + ); + } + + /** + * Tests that an unparseable `content_url()` return value short-circuits + * to `load_script_translations( false, ... )` instead of computing + * `$relative` from a corrupted parsed-URL array. + * + * @ticket 65015 + */ + public function test_unparseable_content_url_returns_false(): void { + $handle = 'test-unparseable-content-url'; + $src = '/wp-includes/js/script.js'; + + add_filter( + 'content_url', + static function () { + return 'http:///example'; + } + ); + + $files_seen = &$this->spy_load_script_translations_files(); + + wp_enqueue_script( $handle, $src, array(), null ); + + $this->assertFalse( load_script_textdomain( $handle, 'default', DIR_TESTDATA . '/languages' ) ); + $this->assertSame( + array( + DIR_TESTDATA . '/languages/en_US-' . $handle . '.json', + false, + ), + $files_seen, + 'Expected the unparseable content_url branch to short-circuit before any relative-path lookup.' + ); + } + + /** + * Tests that the `load_script_textdomain_relative_path` filter returning + * a non-string, non-false value short-circuits via the + * `! is_string( $relative )` guard rather than falling through to + * string functions like `str_ends_with()` and `md5()`. + * + * @ticket 65015 + * + * @dataProvider data_non_string_relative_path_filter_values + * + * @param mixed $filter_value Value returned from the filter. + */ + public function test_non_string_relative_path_filter_returns_false( $filter_value ): void { + $handle = 'test-non-string-relative-path'; + $src = '/wp-includes/js/script.js'; + + add_filter( + 'load_script_textdomain_relative_path', + static function () use ( $filter_value ) { + return $filter_value; + } + ); + + $files_seen = &$this->spy_load_script_translations_files(); + + wp_enqueue_script( $handle, $src, array(), null ); + + $this->assertFalse( load_script_textdomain( $handle, 'default', DIR_TESTDATA . '/languages' ) ); + $this->assertSame( + array( + DIR_TESTDATA . '/languages/en_US-' . $handle . '.json', + false, + ), + $files_seen, + 'Expected the non-string $relative branch to short-circuit before md5 path computation.' + ); + } + + /** + * Provides data for {@see self::test_non_string_relative_path_filter_returns_false()}. + * + * @return array + */ + public static function data_non_string_relative_path_filter_values(): array { + return array( + 'null' => array( null ), + 'true' => array( true ), + 'array' => array( array( 'wp-includes/js/script.js' ) ), + 'int' => array( 0 ), + ); + } + + /** + * Tests that a script source URL with no path component does not trigger + * an undefined index warning when the path is read further down in the + * function. The result is reached via the regular fallback path + * (no host/path match) rather than an early return. + * + * @ticket 65015 + */ + public function test_src_without_path_component_does_not_warn(): void { + $handle = 'test-src-without-path'; + $src = 'https://example.com'; + + $parsed = wp_parse_url( $src ); + $this->assertIsArray( $parsed, 'Test prerequisite failed: the test src should parse.' ); + $this->assertArrayNotHasKey( 'path', $parsed, 'Test prerequisite failed: the test src should have no path component.' ); + + wp_enqueue_script( $handle, $src, array(), null ); + + $this->assertFalse( load_script_textdomain( $handle, 'default', DIR_TESTDATA . '/languages' ) ); + } } From f3e294ebdf90cacdb4c8746e062fc2fea1fdb859 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 1 May 2026 16:44:34 -0700 Subject: [PATCH 5/6] Simplify test for non-string relative path filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the data-provider variants (null/true/array/int) into a single test using `__return_null` — all four hit the same one-line guard, so one case is enough to catch a regression. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tests/l10n/loadScriptTextdomain.php | 33 +++---------------- 1 file changed, 5 insertions(+), 28 deletions(-) diff --git a/tests/phpunit/tests/l10n/loadScriptTextdomain.php b/tests/phpunit/tests/l10n/loadScriptTextdomain.php index cbbd505dedb7b..7749a2094daea 100644 --- a/tests/phpunit/tests/l10n/loadScriptTextdomain.php +++ b/tests/phpunit/tests/l10n/loadScriptTextdomain.php @@ -258,26 +258,17 @@ static function () { /** * Tests that the `load_script_textdomain_relative_path` filter returning - * a non-string, non-false value short-circuits via the - * `! is_string( $relative )` guard rather than falling through to - * string functions like `str_ends_with()` and `md5()`. + * a non-string, non-false value (e.g., a callback that forgets to return) + * short-circuits via the `! is_string( $relative )` guard rather than + * falling through to string functions like `str_ends_with()` and `md5()`. * * @ticket 65015 - * - * @dataProvider data_non_string_relative_path_filter_values - * - * @param mixed $filter_value Value returned from the filter. */ - public function test_non_string_relative_path_filter_returns_false( $filter_value ): void { + public function test_non_string_relative_path_filter_returns_false(): void { $handle = 'test-non-string-relative-path'; $src = '/wp-includes/js/script.js'; - add_filter( - 'load_script_textdomain_relative_path', - static function () use ( $filter_value ) { - return $filter_value; - } - ); + add_filter( 'load_script_textdomain_relative_path', '__return_null' ); $files_seen = &$this->spy_load_script_translations_files(); @@ -294,20 +285,6 @@ static function () use ( $filter_value ) { ); } - /** - * Provides data for {@see self::test_non_string_relative_path_filter_returns_false()}. - * - * @return array - */ - public static function data_non_string_relative_path_filter_values(): array { - return array( - 'null' => array( null ), - 'true' => array( true ), - 'array' => array( array( 'wp-includes/js/script.js' ) ), - 'int' => array( 0 ), - ); - } - /** * Tests that a script source URL with no path component does not trigger * an undefined index warning when the path is read further down in the From 9dcef48dabdb735ea76e98d75cb6cbbcb4094956 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 1 May 2026 16:51:28 -0700 Subject: [PATCH 6/6] Use MockAction for content_url test, drop spy from the others Replace the bespoke by-ref-closure spy (not used elsewhere in tests/phpunit/) with the idiomatic MockAction helper. Keep the spy only on test_unparseable_content_url_returns_false, where the early return is the only thing distinguishing the new branch from the fallback path tail-end load_script_translations(false, ...) call. The other two tests do not need a spy: removing the early return for unparseable src triggers the "Automatic conversion of false to array" deprecation, and reverting the is_string guard triggers a TypeError in str_ends_with() -- both surface as test failures already. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tests/l10n/loadScriptTextdomain.php | 53 ++++--------------- 1 file changed, 9 insertions(+), 44 deletions(-) diff --git a/tests/phpunit/tests/l10n/loadScriptTextdomain.php b/tests/phpunit/tests/l10n/loadScriptTextdomain.php index 7749a2094daea..b84527e1f1757 100644 --- a/tests/phpunit/tests/l10n/loadScriptTextdomain.php +++ b/tests/phpunit/tests/l10n/loadScriptTextdomain.php @@ -173,28 +173,6 @@ public function test_does_not_throw_deprecation_notice_for_rtrim_with_default_pa $this->assertSame( $expected, load_script_textdomain( $handle ) ); } - /** - * Records every `$file` value passed to `load_script_translations()` - * so individual tests can assert which code path produced the result. - * - * @return list Reference to the array updated by the spy filter. - */ - private function &spy_load_script_translations_files(): array { - /** @var list $files_seen */ - $files_seen = array(); - add_filter( - 'pre_load_script_translations', - static function ( $translations, $file ) use ( &$files_seen ) { - assert( is_string( $file ) || false === $file ); - $files_seen[] = $file; - return $translations; - }, - 10, - 2 - ); - return $files_seen; - } - /** * Tests that an unparseable script source URL short-circuits to * `load_script_translations( false, ... )` instead of falling through @@ -208,19 +186,9 @@ public function test_unparseable_src_returns_false(): void { $this->assertFalse( wp_parse_url( $src ), 'Test prerequisite failed: the test src should be unparseable.' ); - $files_seen = &$this->spy_load_script_translations_files(); - wp_enqueue_script( $handle, $src, array(), null ); $this->assertFalse( load_script_textdomain( $handle, 'default', DIR_TESTDATA . '/languages' ) ); - $this->assertSame( - array( - DIR_TESTDATA . '/languages/en_US-' . $handle . '.json', - false, - ), - $files_seen, - 'Expected the unparseable $src branch to short-circuit before any relative-path lookup.' - ); } /** @@ -228,6 +196,12 @@ public function test_unparseable_src_returns_false(): void { * to `load_script_translations( false, ... )` instead of computing * `$relative` from a corrupted parsed-URL array. * + * The `MockAction` spy on `pre_load_script_translations` is necessary + * here because the function's tail end also calls `load_script_translations( false, ... )`, + * so a regression that bypasses the early return would still return false + * via the fallback path. Asserting on the recorded `$file` arguments pins + * the test to the intended branch. + * * @ticket 65015 */ public function test_unparseable_content_url_returns_false(): void { @@ -241,7 +215,8 @@ static function () { } ); - $files_seen = &$this->spy_load_script_translations_files(); + $mock = new MockAction(); + add_filter( 'pre_load_script_translations', array( $mock, 'filter' ), 10, 4 ); wp_enqueue_script( $handle, $src, array(), null ); @@ -251,7 +226,7 @@ static function () { DIR_TESTDATA . '/languages/en_US-' . $handle . '.json', false, ), - $files_seen, + array_column( $mock->get_args(), 1 ), 'Expected the unparseable content_url branch to short-circuit before any relative-path lookup.' ); } @@ -270,19 +245,9 @@ public function test_non_string_relative_path_filter_returns_false(): void { add_filter( 'load_script_textdomain_relative_path', '__return_null' ); - $files_seen = &$this->spy_load_script_translations_files(); - wp_enqueue_script( $handle, $src, array(), null ); $this->assertFalse( load_script_textdomain( $handle, 'default', DIR_TESTDATA . '/languages' ) ); - $this->assertSame( - array( - DIR_TESTDATA . '/languages/en_US-' . $handle . '.json', - false, - ), - $files_seen, - 'Expected the non-string $relative branch to short-circuit before md5 path computation.' - ); } /**