diff --git a/src/wp-admin/includes/class-wp-automatic-updater.php b/src/wp-admin/includes/class-wp-automatic-updater.php
index 2facbeb1d522f..cd9426c6ef88b 100644
--- a/src/wp-admin/includes/class-wp-automatic-updater.php
+++ b/src/wp-admin/includes/class-wp-automatic-updater.php
@@ -1785,9 +1785,6 @@ protected function has_fatal_error() {
'Cache-Control' => 'no-cache',
);
- /** This filter is documented in wp-includes/class-wp-http-streams.php */
- $sslverify = apply_filters( 'https_local_ssl_verify', false );
-
// Include Basic auth in the loopback request.
if ( isset( $_SERVER['PHP_AUTH_USER'] ) && isset( $_SERVER['PHP_AUTH_PW'] ) ) {
$headers['Authorization'] = 'Basic ' . base64_encode( wp_unslash( $_SERVER['PHP_AUTH_USER'] ) . ':' . wp_unslash( $_SERVER['PHP_AUTH_PW'] ) );
@@ -1804,7 +1801,10 @@ protected function has_fatal_error() {
$needle_start = "###### wp_scraping_result_start:$scrape_key ######";
$needle_end = "###### wp_scraping_result_end:$scrape_key ######";
$url = add_query_arg( $scrape_params, home_url( '/' ) );
- $response = wp_remote_get( $url, compact( 'cookies', 'headers', 'timeout', 'sslverify' ) );
+
+ /** This filter is documented in wp-includes/class-wp-http-streams.php */
+ $sslverify = apply_filters( 'https_local_ssl_verify', false, $url );
+ $response = wp_remote_get( $url, compact( 'cookies', 'headers', 'timeout', 'sslverify' ) );
if ( is_wp_error( $response ) ) {
if ( $is_debug ) {
diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php
index 75e046ef8ffa7..98a6fa6a00ed4 100644
--- a/src/wp-admin/includes/class-wp-site-health.php
+++ b/src/wp-admin/includes/class-wp-site-health.php
@@ -2212,9 +2212,6 @@ public function get_test_rest_availability() {
'Cache-Control' => 'no-cache',
'X-WP-Nonce' => wp_create_nonce( 'wp_rest' ),
);
- /** This filter is documented in wp-includes/class-wp-http-streams.php */
- $sslverify = apply_filters( 'https_local_ssl_verify', false );
-
// Include Basic auth in loopback requests.
if ( isset( $_SERVER['PHP_AUTH_USER'] ) && isset( $_SERVER['PHP_AUTH_PW'] ) ) {
$headers['Authorization'] = 'Basic ' . base64_encode( wp_unslash( $_SERVER['PHP_AUTH_USER'] ) . ':' . wp_unslash( $_SERVER['PHP_AUTH_PW'] ) );
@@ -2230,6 +2227,9 @@ public function get_test_rest_availability() {
$url
);
+ /** This filter is documented in wp-includes/class-wp-http-streams.php */
+ $sslverify = apply_filters( 'https_local_ssl_verify', false, $url );
+
$r = wp_remote_get( $url, compact( 'cookies', 'headers', 'timeout', 'sslverify' ) );
if ( is_wp_error( $r ) ) {
@@ -3289,8 +3289,6 @@ public function can_perform_loopback() {
$headers = array(
'Cache-Control' => 'no-cache',
);
- /** This filter is documented in wp-includes/class-wp-http-streams.php */
- $sslverify = apply_filters( 'https_local_ssl_verify', false );
// Include Basic auth in loopback requests.
if ( isset( $_SERVER['PHP_AUTH_USER'] ) && isset( $_SERVER['PHP_AUTH_PW'] ) ) {
@@ -3299,6 +3297,9 @@ public function can_perform_loopback() {
$url = site_url( 'wp-cron.php' );
+ /** This filter is documented in wp-includes/class-wp-http-streams.php */
+ $sslverify = apply_filters( 'https_local_ssl_verify', false, $url );
+
/*
* A post request is used for the wp-cron.php loopback test to cause the file
* to finish early without triggering cron jobs. This has two benefits:
@@ -3621,7 +3622,7 @@ public function get_page_cache_headers(): array {
private function check_for_page_caching() {
/** This filter is documented in wp-includes/class-wp-http-streams.php */
- $sslverify = apply_filters( 'https_local_ssl_verify', false );
+ $sslverify = apply_filters( 'https_local_ssl_verify', false, home_url( '/' ) );
$headers = array();
diff --git a/src/wp-admin/includes/file.php b/src/wp-admin/includes/file.php
index 0c6d968ea02d3..f2009f2acbb5d 100644
--- a/src/wp-admin/includes/file.php
+++ b/src/wp-admin/includes/file.php
@@ -541,9 +541,6 @@ function wp_edit_theme_plugin_file( $args ) {
'Cache-Control' => 'no-cache',
);
- /** This filter is documented in wp-includes/class-wp-http-streams.php */
- $sslverify = apply_filters( 'https_local_ssl_verify', false );
-
// Include Basic auth in loopback requests.
if ( isset( $_SERVER['PHP_AUTH_USER'] ) && isset( $_SERVER['PHP_AUTH_PW'] ) ) {
$headers['Authorization'] = 'Basic ' . base64_encode( wp_unslash( $_SERVER['PHP_AUTH_USER'] ) . ':' . wp_unslash( $_SERVER['PHP_AUTH_PW'] ) );
@@ -583,7 +580,11 @@ function wp_edit_theme_plugin_file( $args ) {
session_write_close();
}
- $url = add_query_arg( $scrape_params, $url );
+ $url = add_query_arg( $scrape_params, $url );
+
+ /** This filter is documented in wp-includes/class-wp-http-streams.php */
+ $sslverify = apply_filters( 'https_local_ssl_verify', false, $url );
+
$r = wp_remote_get( $url, compact( 'cookies', 'headers', 'timeout', 'sslverify' ) );
$body = wp_remote_retrieve_body( $r );
$scrape_result_position = strpos( $body, $needle_start );
diff --git a/src/wp-admin/includes/nav-menu.php b/src/wp-admin/includes/nav-menu.php
index 70263a2034807..f26d63d528e78 100644
--- a/src/wp-admin/includes/nav-menu.php
+++ b/src/wp-admin/includes/nav-menu.php
@@ -1509,7 +1509,7 @@ function wp_nav_menu_update_menu_items( $nav_menu_selected_id, $nav_menu_selecte
wp_defer_term_counting( false );
/** This action is documented in wp-includes/nav-menu.php */
- do_action( 'wp_update_nav_menu', $nav_menu_selected_id );
+ do_action( 'wp_update_nav_menu', $nav_menu_selected_id, array() );
/* translators: %s: Nav menu title. */
$message = sprintf( __( '%s has been updated.' ), '' . $nav_menu_selected_title . '' );
diff --git a/src/wp-admin/includes/template.php b/src/wp-admin/includes/template.php
index 6680bca89691a..691af38bc5a2d 100644
--- a/src/wp-admin/includes/template.php
+++ b/src/wp-admin/includes/template.php
@@ -316,11 +316,13 @@ function get_inline_data( $post ) {
$title = esc_textarea( trim( $post->post_title ) );
+ /** This filter is documented in wp-admin/edit-tag-form.php */
+ $editable_slug = apply_filters( 'editable_slug', $post->post_name, $post );
+
echo '
-
' . $title . '
' .
- /** This filter is documented in wp-admin/edit-tag-form.php */
- '
' . apply_filters( 'editable_slug', $post->post_name, $post ) . '
+
' . $title . '
+
' . $editable_slug . '
' . $post->post_author . '
' . esc_html( $post->ping_status ) . '
diff --git a/src/wp-admin/network/themes.php b/src/wp-admin/network/themes.php
index 763a13712a59b..8a27669f73b67 100644
--- a/src/wp-admin/network/themes.php
+++ b/src/wp-admin/network/themes.php
@@ -293,7 +293,7 @@
check_admin_referer( 'bulk-themes' );
/** This action is documented in wp-admin/network/site-themes.php */
- $referer = apply_filters( 'handle_network_bulk_actions-' . get_current_screen()->id, $referer, $action, $themes ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
+ $referer = apply_filters( 'handle_network_bulk_actions-' . get_current_screen()->id, $referer, $action, $themes, 0 ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
wp_safe_redirect( $referer );
exit;
diff --git a/src/wp-admin/network/users.php b/src/wp-admin/network/users.php
index 29238cd887034..eb279a804bdb7 100644
--- a/src/wp-admin/network/users.php
+++ b/src/wp-admin/network/users.php
@@ -157,7 +157,7 @@
$user_ids = (array) $_POST['allusers'];
/** This action is documented in wp-admin/network/site-themes.php */
- $sendback = apply_filters( 'handle_network_bulk_actions-' . get_current_screen()->id, $sendback, $doaction, $user_ids ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
+ $sendback = apply_filters( 'handle_network_bulk_actions-' . get_current_screen()->id, $sendback, $doaction, $user_ids, 0 ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
wp_safe_redirect( $sendback );
exit;
diff --git a/src/wp-admin/plugin-install.php b/src/wp-admin/plugin-install.php
index 5c8be143bf332..395e55b8eca30 100644
--- a/src/wp-admin/plugin-install.php
+++ b/src/wp-admin/plugin-install.php
@@ -40,6 +40,15 @@
$wp_list_table->prepare_items();
+/**
+ * WP_Plugin_Install_List_Table::prepare_items() populates these globals, which
+ * are used throughout the rest of this file.
+ *
+ * @global string $tab The current tab of the Install Plugins screen.
+ * @global int $paged The current page number of the plugins list.
+ */
+global $tab, $paged;
+
$total_pages = $wp_list_table->get_pagination_arg( 'total_pages' );
if ( $pagenum > $total_pages && $total_pages > 0 ) {
@@ -169,7 +178,7 @@
diff --git a/src/wp-content/themes/twentyeleven/functions.php b/src/wp-content/themes/twentyeleven/functions.php
index 900c1f2cf23c0..98f8bd0a73ceb 100644
--- a/src/wp-content/themes/twentyeleven/functions.php
+++ b/src/wp-content/themes/twentyeleven/functions.php
@@ -685,7 +685,7 @@ function twentyeleven_get_first_url() {
}
/** This filter is documented in wp-includes/link-template.php */
- return ( $has_url ) ? $has_url : apply_filters( 'the_permalink', get_permalink() );
+ return ( $has_url ) ? $has_url : apply_filters( 'the_permalink', get_permalink(), get_post() );
}
/**
diff --git a/src/wp-content/themes/twentyeleven/image.php b/src/wp-content/themes/twentyeleven/image.php
index 54bfb17498fe7..1bab57b581259 100644
--- a/src/wp-content/themes/twentyeleven/image.php
+++ b/src/wp-content/themes/twentyeleven/image.php
@@ -99,7 +99,7 @@
*
* @since Twenty Eleven 1.0
*
- * @param int The width for the image attachment size in pixels. Default 848.
+ * @param int $size The width for the image attachment size in pixels. Default 848.
*/
$attachment_size = apply_filters( 'twentyeleven_attachment_size', 848 );
echo wp_get_attachment_image( $post->ID, array( $attachment_size, 1024 ) );
diff --git a/src/wp-content/themes/twentyeleven/inc/widgets.php b/src/wp-content/themes/twentyeleven/inc/widgets.php
index 4e82cdf6a055e..72411d01a8064 100644
--- a/src/wp-content/themes/twentyeleven/inc/widgets.php
+++ b/src/wp-content/themes/twentyeleven/inc/widgets.php
@@ -70,7 +70,7 @@ public function widget( $args, $instance ) {
ob_start();
- /** This filter is documented in wp-includes/default-widgets.php */
+ /** This filter is documented in wp-includes/widgets/class-wp-widget-pages.php */
$args['title'] = apply_filters( 'widget_title', empty( $instance['title'] ) ? __( 'Ephemera', 'twentyeleven' ) : $instance['title'], $instance, $this->id_base );
if ( ! isset( $instance['number'] ) ) {
diff --git a/src/wp-content/themes/twentyeleven/tag.php b/src/wp-content/themes/twentyeleven/tag.php
index 23517622f0cb6..966dc7c603a93 100644
--- a/src/wp-content/themes/twentyeleven/tag.php
+++ b/src/wp-content/themes/twentyeleven/tag.php
@@ -30,7 +30,7 @@
*
* @since Twenty Eleven 1.0
*
- * @param string The default tag description.
+ * @param string $tag_archive_meta The default tag description.
*/
echo apply_filters( 'tag_archive_meta', '
' . $tag_description . '
' );
}
diff --git a/src/wp-content/themes/twentyfifteen/inc/template-tags.php b/src/wp-content/themes/twentyfifteen/inc/template-tags.php
index 7f39cdc194a7c..f77e13250965f 100644
--- a/src/wp-content/themes/twentyfifteen/inc/template-tags.php
+++ b/src/wp-content/themes/twentyfifteen/inc/template-tags.php
@@ -246,7 +246,8 @@ function twentyfifteen_post_thumbnail() {
function twentyfifteen_get_link_url() {
$has_url = get_url_in_content( get_the_content() );
- return $has_url ? $has_url : apply_filters( 'the_permalink', get_permalink() );
+ /** This filter is documented in wp-includes/link-template.php */
+ return $has_url ? $has_url : apply_filters( 'the_permalink', get_permalink(), get_post() );
}
endif;
diff --git a/src/wp-content/themes/twentyfourteen/inc/widgets.php b/src/wp-content/themes/twentyfourteen/inc/widgets.php
index 7c4f237294b3a..93099318d05f6 100644
--- a/src/wp-content/themes/twentyfourteen/inc/widgets.php
+++ b/src/wp-content/themes/twentyfourteen/inc/widgets.php
@@ -110,7 +110,8 @@ public function widget( $args, $instance ) {
$number = ! empty( $instance['number'] ) ? absint( $instance['number'] ) : 2;
$title = ! empty( $instance['title'] ) ? $instance['title'] : $format_string;
- $title = apply_filters( 'widget_title', $title, $instance, $this->id_base );
+ /** This filter is documented in wp-includes/widgets/class-wp-widget-pages.php */
+ $title = apply_filters( 'widget_title', $title, $instance, $this->id_base );
$ephemera = new WP_Query(
array(
diff --git a/src/wp-content/themes/twentyten/functions.php b/src/wp-content/themes/twentyten/functions.php
index baf9e13121e99..fbdf5581f0633 100644
--- a/src/wp-content/themes/twentyten/functions.php
+++ b/src/wp-content/themes/twentyten/functions.php
@@ -168,7 +168,7 @@ function twentyten_setup() {
*
* @since Twenty Ten 1.0
*
- * @param int The default header image width in pixels. Default 940.
+ * @param int $width The default header image width in pixels. Default 940.
*/
'width' => apply_filters( 'twentyten_header_image_width', 940 ),
/**
@@ -176,7 +176,7 @@ function twentyten_setup() {
*
* @since Twenty Ten 1.0
*
- * @param int The default header image height in pixels. Default 198.
+ * @param int $height The default header image height in pixels. Default 198.
*/
'height' => apply_filters( 'twentyten_header_image_height', 198 ),
// Support flexible heights.
diff --git a/src/wp-content/themes/twentythirteen/functions.php b/src/wp-content/themes/twentythirteen/functions.php
index d59a1989eaaab..c7b48ba06aaec 100644
--- a/src/wp-content/themes/twentythirteen/functions.php
+++ b/src/wp-content/themes/twentythirteen/functions.php
@@ -731,7 +731,8 @@ function twentythirteen_get_link_url() {
$content = get_the_content();
$has_url = get_url_in_content( $content );
- return ( $has_url ) ? $has_url : apply_filters( 'the_permalink', get_permalink() );
+ /** This filter is documented in wp-includes/link-template.php */
+ return ( $has_url ) ? $has_url : apply_filters( 'the_permalink', get_permalink(), get_post() );
}
if ( ! function_exists( 'twentythirteen_excerpt_more' ) && ! is_admin() ) :
diff --git a/src/wp-content/themes/twentytwentyone/functions.php b/src/wp-content/themes/twentytwentyone/functions.php
index 01a97e597e78a..4483716e27ef2 100644
--- a/src/wp-content/themes/twentytwentyone/functions.php
+++ b/src/wp-content/themes/twentytwentyone/functions.php
@@ -577,7 +577,7 @@ function twentytwentyone_the_html_classes() {
*
* @since Twenty Twenty-One 1.0
*
- * @param string The list of classes. Default empty string.
+ * @param string $classes The list of classes. Default empty string.
*/
$classes = apply_filters( 'twentytwentyone_html_classes', '' );
if ( ! $classes ) {
diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php
index fd1eefc1534b0..651f2e63df046 100644
--- a/src/wp-includes/abilities-api/class-wp-ability.php
+++ b/src/wp-includes/abilities-api/class-wp-ability.php
@@ -747,6 +747,8 @@ public function execute( $input = null ) {
*/
do_action( 'wp_ability_invoked', $this->name, $input, $this );
+ $pre_execute_sentinel = new WP_Filter_Sentinel();
+
/**
* Filters whether to short-circuit ability execution.
*
@@ -769,8 +771,7 @@ public function execute( $input = null ) {
* @param mixed $input The raw input passed to `execute()`.
* @param WP_Ability $ability The ability instance.
*/
- $pre_execute_sentinel = new WP_Filter_Sentinel();
- $pre = apply_filters( 'wp_pre_execute_ability', $pre_execute_sentinel, $this->name, $input, $this );
+ $pre = apply_filters( 'wp_pre_execute_ability', $pre_execute_sentinel, $this->name, $input, $this );
if ( $pre !== $pre_execute_sentinel ) {
return $pre;
}
diff --git a/src/wp-includes/class-wp-xmlrpc-server.php b/src/wp-includes/class-wp-xmlrpc-server.php
index 8cbf6d977f5a2..e38cddb8587f4 100644
--- a/src/wp-includes/class-wp-xmlrpc-server.php
+++ b/src/wp-includes/class-wp-xmlrpc-server.php
@@ -191,9 +191,11 @@ private function set_is_enabled() {
* Respect old get_option() filters left for back-compat when the 'enable_xmlrpc'
* option was deprecated in 3.5.0. Use the {@see 'xmlrpc_enabled'} hook instead.
*/
- $is_enabled = apply_filters( 'pre_option_enable_xmlrpc', false );
+ /** This filter is documented in wp-includes/option.php */
+ $is_enabled = apply_filters( 'pre_option_enable_xmlrpc', false, 'enable_xmlrpc', false );
if ( false === $is_enabled ) {
- $is_enabled = apply_filters( 'option_enable_xmlrpc', true );
+ /** This filter is documented in wp-includes/option.php */
+ $is_enabled = apply_filters( 'option_enable_xmlrpc', true, 'enable_xmlrpc' );
}
/**
diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php
index 70d5c03b378f4..5febda87107e7 100644
--- a/src/wp-includes/comment.php
+++ b/src/wp-includes/comment.php
@@ -3111,7 +3111,7 @@ function do_trackbacks( $post ) {
if ( empty( $post->post_excerpt ) ) {
/** This filter is documented in wp-includes/post-template.php */
- $excerpt = apply_filters( 'the_content', $post->post_content, $post->ID );
+ $excerpt = apply_filters( 'the_content', $post->post_content );
} else {
/** This filter is documented in wp-includes/post-template.php */
$excerpt = apply_filters( 'the_excerpt', $post->post_excerpt );
diff --git a/src/wp-includes/cron.php b/src/wp-includes/cron.php
index 7bbb0036f1c02..b1f2ad6e3a527 100644
--- a/src/wp-includes/cron.php
+++ b/src/wp-includes/cron.php
@@ -958,6 +958,8 @@ function spawn_cron( $gmt_time = 0 ) {
$doing_wp_cron = sprintf( '%.22F', $gmt_time );
set_transient( 'doing_cron', $doing_wp_cron );
+ $cron_url = add_query_arg( 'doing_wp_cron', $doing_wp_cron, site_url( 'wp-cron.php' ) );
+
/**
* Filters the cron request arguments.
*
@@ -982,13 +984,13 @@ function spawn_cron( $gmt_time = 0 ) {
$cron_request = apply_filters(
'cron_request',
array(
- 'url' => add_query_arg( 'doing_wp_cron', $doing_wp_cron, site_url( 'wp-cron.php' ) ),
+ 'url' => $cron_url,
'key' => $doing_wp_cron,
'args' => array(
'timeout' => 0.01,
'blocking' => false,
/** This filter is documented in wp-includes/class-wp-http-streams.php */
- 'sslverify' => apply_filters( 'https_local_ssl_verify', false ),
+ 'sslverify' => apply_filters( 'https_local_ssl_verify', false, $cron_url ),
),
),
$doing_wp_cron
diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php
index d318a275a9607..8596c7f7d0e6d 100644
--- a/src/wp-includes/media.php
+++ b/src/wp-includes/media.php
@@ -6413,7 +6413,7 @@ function wp_high_priority_element_flag( $value = null ): bool {
*
* @param string $filename Path to the image.
* @param string $mime_type The source image mime type.
- * @return string[] An array of mime type mappings.
+ * @return array
An array of mime type mappings.
*/
function wp_get_image_editor_output_format( $filename, $mime_type ) {
$output_format = array(
@@ -6435,14 +6435,10 @@ function wp_get_image_editor_output_format( $filename, $mime_type ) {
* @since 6.7.0 The default was changed from an empty array to an array
* containing the HEIC/HEIF images mime types.
*
- * @param string[] $output_format {
- * An array of mime type mappings. Maps a source mime type to a new
- * destination mime type. By default maps HEIC/HEIF input to JPEG output.
- *
- * @type string ...$0 The new mime type.
- * }
- * @param string $filename Path to the image.
- * @param string $mime_type The source image mime type.
+ * @param array $output_format An array of mime type mappings. Maps a source mime type to a new
+ * destination mime type. By default maps HEIC/HEIF input to JPEG output.
+ * @param string $filename Path to the image.
+ * @param string $mime_type The source image mime type.
*/
return apply_filters( 'image_editor_output_format', $output_format, $filename, $mime_type );
}
diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php
index 65ca4e0018cb6..c0d58160467b2 100644
--- a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php
+++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php
@@ -425,7 +425,7 @@ public function create_post_autosave( $post_data, array $meta = array() ) {
$new_autosave['post_author'] = $user_id;
/** This action is documented in wp-admin/includes/post.php */
- do_action( 'wp_creating_autosave', $new_autosave );
+ do_action( 'wp_creating_autosave', $new_autosave, true );
// wp_update_post() expects escaped array.
$revision_id = wp_update_post( wp_slash( $new_autosave ) );
diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-menus-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-menus-controller.php
index 3947bfd6107ce..706e36fb6cc66 100644
--- a/src/wp-includes/rest-api/endpoints/class-wp-rest-menus-controller.php
+++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-menus-controller.php
@@ -453,7 +453,7 @@ protected function handle_auto_add( $menu_id, $request ) {
$update = update_option( 'nav_menu_options', $nav_menu_option );
/** This action is documented in wp-includes/nav-menu.php */
- do_action( 'wp_update_nav_menu', $menu_id );
+ do_action( 'wp_update_nav_menu', $menu_id, array() );
return $update;
}
diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php
index 73a888d6eac48..f4c5cb483d105 100644
--- a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php
+++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php
@@ -911,7 +911,7 @@ public function get_collection_params() {
protected function prepare_excerpt_response( $excerpt, $post ) {
/** This filter is documented in wp-includes/post-template.php */
- $excerpt = apply_filters( 'the_excerpt', $excerpt, $post );
+ $excerpt = apply_filters( 'the_excerpt', $excerpt );
if ( empty( $excerpt ) ) {
return '';
diff --git a/src/wp-includes/taxonomy.php b/src/wp-includes/taxonomy.php
index 80f457de0e6f7..9faddca11ba32 100644
--- a/src/wp-includes/taxonomy.php
+++ b/src/wp-includes/taxonomy.php
@@ -2602,11 +2602,11 @@ function wp_insert_term( $term, $taxonomy, $args = array() ) {
$slug = sanitize_title( $slug, $term_id );
/** This action is documented in wp-includes/taxonomy.php */
- do_action( 'edit_terms', $term_id, $taxonomy );
+ do_action( 'edit_terms', $term_id, $taxonomy, $args );
$wpdb->update( $wpdb->terms, compact( 'slug' ), compact( 'term_id' ) );
/** This action is documented in wp-includes/taxonomy.php */
- do_action( 'edited_terms', $term_id, $taxonomy );
+ do_action( 'edited_terms', $term_id, $taxonomy, $args );
}
$tt_id = $wpdb->get_var( $wpdb->prepare( "SELECT tt.term_taxonomy_id FROM $wpdb->term_taxonomy AS tt INNER JOIN $wpdb->terms AS t ON tt.term_id = t.term_id WHERE tt.taxonomy = %s AND t.term_id = %d", $taxonomy, $term_id ) );
@@ -3452,7 +3452,7 @@ function wp_update_term( $term_id, $taxonomy, $args = array() ) {
do_action( "edit_{$taxonomy}", $term_id, $tt_id, $args );
/** This filter is documented in wp-includes/taxonomy.php */
- $term_id = apply_filters( 'term_id_filter', $term_id, $tt_id );
+ $term_id = apply_filters( 'term_id_filter', $term_id, $tt_id, $args );
clean_term_cache( $term_id, $taxonomy );
@@ -4207,11 +4207,11 @@ function _update_post_term_count( $terms, $taxonomy ) {
do_action( 'update_term_count', $tt_id, $taxonomy->name, $count );
/** This action is documented in wp-includes/taxonomy.php */
- do_action( 'edit_term_taxonomy', $tt_id, $taxonomy->name );
+ do_action( 'edit_term_taxonomy', $tt_id, $taxonomy->name, array() );
$wpdb->update( $wpdb->term_taxonomy, compact( 'count' ), array( 'term_taxonomy_id' => $tt_id ) );
/** This action is documented in wp-includes/taxonomy.php */
- do_action( 'edited_term_taxonomy', $tt_id, $taxonomy->name );
+ do_action( 'edited_term_taxonomy', $tt_id, $taxonomy->name, array() );
}
}
@@ -4237,11 +4237,11 @@ function _update_generic_term_count( $terms, $taxonomy ) {
do_action( 'update_term_count', $term, $taxonomy->name, $count );
/** This action is documented in wp-includes/taxonomy.php */
- do_action( 'edit_term_taxonomy', $term, $taxonomy->name );
+ do_action( 'edit_term_taxonomy', $term, $taxonomy->name, array() );
$wpdb->update( $wpdb->term_taxonomy, compact( 'count' ), array( 'term_taxonomy_id' => $term ) );
/** This action is documented in wp-includes/taxonomy.php */
- do_action( 'edited_term_taxonomy', $term, $taxonomy->name );
+ do_action( 'edited_term_taxonomy', $term, $taxonomy->name, array() );
}
}
diff --git a/tests/phpstan/ApplyFiltersDynamicFunctionReturnTypeExtension.php b/tests/phpstan/ApplyFiltersDynamicFunctionReturnTypeExtension.php
new file mode 100644
index 0000000000000..0b3e31bf467a3
--- /dev/null
+++ b/tests/phpstan/ApplyFiltersDynamicFunctionReturnTypeExtension.php
@@ -0,0 +1,109 @@
+hookDocBlock = $hook_doc_block;
+ }
+
+ /**
+ * Determines whether this extension applies to the given function.
+ *
+ * @param FunctionReflection $functionReflection Function being analyzed.
+ * @return bool
+ */
+ public function isFunctionSupported( FunctionReflection $functionReflection ): bool {
+ return in_array(
+ $functionReflection->getName(),
+ array(
+ 'apply_filters',
+ 'apply_filters_deprecated',
+ 'apply_filters_ref_array',
+ ),
+ true
+ );
+ }
+
+ /**
+ * Resolves the return type of the filter call from its preceding docblock.
+ *
+ * @see https://developer.wordpress.org/reference/functions/apply_filters/
+ * @see https://developer.wordpress.org/reference/functions/apply_filters_deprecated/
+ * @see https://developer.wordpress.org/reference/functions/apply_filters_ref_array/
+ *
+ * @param FunctionReflection $functionReflection Function being analyzed.
+ * @param FuncCall $functionCall The function call node.
+ * @param Scope $scope Analysis scope.
+ * @return Type
+ * @throws ShouldNotHappenException
+ */
+ public function getTypeFromFunctionCall( FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope ): Type {
+ unset( $functionReflection );
+
+ $default = new MixedType();
+ $resolved_php_doc = $this->hookDocBlock->getNullableHookDocBlock( $functionCall, $scope );
+
+ if ( null === $resolved_php_doc ) {
+ return $default;
+ }
+
+ // Fetch the `@param` values from the docblock; the first describes the filtered value.
+ $params = $resolved_php_doc->getParamTags();
+
+ foreach ( $params as $param ) {
+ return $param->getType();
+ }
+
+ return $default;
+ }
+}
diff --git a/tests/phpstan/GlobalDocBlockVisitor.php b/tests/phpstan/GlobalDocBlockVisitor.php
index c5fbabfd336ee..9db6adf6f4e40 100644
--- a/tests/phpstan/GlobalDocBlockVisitor.php
+++ b/tests/phpstan/GlobalDocBlockVisitor.php
@@ -4,6 +4,7 @@
* convention to PHPStan's variable type resolution.
*
* @package WordPress
+ * @noinspection PhpUnused
*/
declare(strict_types=1);
@@ -15,9 +16,11 @@
use PhpParser\NodeVisitorAbstract;
/**
- * Reads `@global Type $varname` tags from function and method docblocks and
- * injects equivalent inline `@var` docblocks onto matching `global $foo;`
- * statements inside the function body.
+ * Reads `@global array $varname` tags and injects equivalent inline `@var`
+ * docblocks onto matching `global $foo;` statements. The tags may be documented
+ * on the enclosing function/method docblock (applying to `global` statements in
+ * its body) or, for a file-scope `global` statement with no enclosing function,
+ * directly on the statement itself.
*
* PHPStan does not consult bootstrap- or stub-declared variable types when
* resolving `global $foo;` inside functions. It only honors `@var`
@@ -31,7 +34,7 @@
* resolve as `mixed` — preserving PHPStan's safety guarantees.
*
* Hand-written `@var` annotations on a `global` statement are honored
- * per-variable: in `global $a, $b;`, an existing `@var Foo $a` is left
+ * per-variable: in `global $a, $b;`, an existing `@var array $a` is left
* alone, but `$b` will still receive a synthetic `@var` if the function
* documents it via `@global`.
*
@@ -61,7 +64,7 @@ public function beforeTraverse( array $nodes ): ?array {
/**
* Pushes a frame when entering a function/method, and injects synthetic
- * `@var` doc comments on `global` statements that match a documented tag.
+ * `var` doc comments on `global` statements that match a documented tag.
*
* @param Node $node The node being entered.
* @return null
@@ -73,11 +76,24 @@ public function enterNode( Node $node ): ?Node {
return null;
}
- if ( ! ( $node instanceof Node\Stmt\Global_ ) || $this->stack === array() ) {
+ if ( ! ( $node instanceof Node\Stmt\Global_ ) ) {
return null;
}
- $map = $this->stack[ count( $this->stack ) - 1 ];
+ /*
+ * The `@global` tags may be documented on the enclosing function (the top
+ * stack frame) or, for a file-scope `global` statement that has no enclosing
+ * function, directly on the statement itself. Merge both, with tags on the
+ * statement taking precedence.
+ */
+ $existing = $node->getDocComment();
+ $existing_text = $existing !== null ? $existing->getText() : '';
+
+ $map = $this->stack !== array() ? $this->stack[ count( $this->stack ) - 1 ] : array();
+ if ( $existing_text !== '' ) {
+ $map = array_merge( $map, $this->parse_global_tags( $existing_text ) );
+ }
+
if ( $map === array() ) {
return null;
}
@@ -87,9 +103,7 @@ public function enterNode( Node $node ): ?Node {
* statement so we can leave them alone but still inject `@var` lines for
* the remaining variables in a multi-variable `global $a, $b;` statement.
*/
- $existing = $node->getDocComment();
- $existing_text = $existing !== null ? $existing->getText() : '';
- $already_typed = array();
+ $already_typed = array();
if ( $existing_text !== '' && preg_match_all( '/@(?:phpstan-)?var\s+[^\n]*?\$(\w+)/', $existing_text, $existing_matches ) > 0 ) {
$already_typed = array_flip( $existing_matches[1] );
}
@@ -134,7 +148,7 @@ public function leaveNode( Node $node ): ?Node {
}
/**
- * Extracts `@global Type $varname` tags from a docblock.
+ * Extracts `global` tags from a docblock.
*
* Handles union types (`A|B`) and namespaced/array forms (`A\B`, `A[]`).
* Whitespace inside the type is collapsed.
diff --git a/tests/phpstan/HookDocBlock.php b/tests/phpstan/HookDocBlock.php
new file mode 100644
index 0000000000000..87974d2d3a018
--- /dev/null
+++ b/tests/phpstan/HookDocBlock.php
@@ -0,0 +1,642 @@
+, patterns: list}
+ */
+class HookDocBlock {
+
+ /**
+ * Hook functions that carry a documenting docblock for their first argument.
+ */
+ public const HOOK_FUNCTIONS = array(
+ 'apply_filters',
+ 'apply_filters_deprecated',
+ 'apply_filters_ref_array',
+ 'do_action',
+ 'do_action_deprecated',
+ 'do_action_ref_array',
+ );
+
+ /**
+ * Problem code: the referenced file does not exist.
+ */
+ public const PROBLEM_FILE_MISSING = 'fileMissing';
+
+ /**
+ * Problem code: the hook is not documented in the referenced file.
+ */
+ public const PROBLEM_HOOK_MISSING = 'hookMissing';
+
+ /**
+ * Pattern matching WordPress core's "documented elsewhere" reference comment.
+ * Captures the referenced root-relative file path.
+ */
+ private const REFERENCE_PATTERN = '#This (?:filter|action) is documented in (\S+)#';
+
+ /**
+ * File type mapper used to resolve docblocks in scope.
+ *
+ * @var FileTypeMapper
+ */
+ protected FileTypeMapper $fileTypeMapper;
+
+ /**
+ * Cache of parsed hook documentation, keyed by absolute file path.
+ *
+ * @var array
+ */
+ private array $fileHookDocs = array();
+
+ /**
+ * Constructor.
+ *
+ * @param FileTypeMapper $file_type_mapper File type mapper.
+ */
+ public function __construct( FileTypeMapper $file_type_mapper ) {
+ $this->fileTypeMapper = $file_type_mapper;
+ }
+
+ /**
+ * Resolves the docblock preceding the given function call, if any.
+ *
+ * @param FuncCall $function_call Hook function call node.
+ * @param Scope $scope Analysis scope.
+ * @return ResolvedPhpDocBlock|null Resolved docblock, or null when none precedes the call.
+ * @throws ShouldNotHappenException
+ */
+ public function getNullableHookDocBlock( FuncCall $function_call, Scope $scope ): ?ResolvedPhpDocBlock {
+ $comment = self::getNullableNodeComment( $function_call );
+
+ if ( null === $comment ) {
+ return null;
+ }
+
+ // Fetch the docblock contents.
+ $code = $comment->getText();
+
+ // Handle the "This filter/action is documented in " convention by
+ // substituting the canonical docblock from the referenced file.
+ $referenced = $this->resolveDocumentedInReference( $code, $function_call, $scope );
+ if ( null !== $referenced ) {
+ return $referenced;
+ }
+
+ // Resolve the docblock in the current scope.
+ $class_reflection = $scope->getClassReflection();
+ $trait_reflection = $scope->getTraitReflection();
+
+ return $this->fileTypeMapper->getResolvedPhpDoc(
+ $scope->getFile(),
+ ( $scope->isInClass() && null !== $class_reflection ) ? $class_reflection->getName() : null,
+ ( $scope->isInTrait() && null !== $trait_reflection ) ? $trait_reflection->getName() : null,
+ $scope->getFunctionName(),
+ $code
+ );
+ }
+
+ /**
+ * Returns the docblock preceding a hook call, classifying it as either an
+ * inline docblock or a "documented elsewhere" reference.
+ *
+ * @param FuncCall $function_call Hook function call node.
+ * @return array{type: 'inline'|'reference', text: string}|null
+ * Null when no docblock precedes the call.
+ */
+ public function getPrecedingDocBlock( FuncCall $function_call ): ?array {
+ $comment = self::getNullableNodeComment( $function_call );
+ if ( null === $comment ) {
+ return null;
+ }
+
+ $text = $comment->getText();
+
+ return array(
+ 'type' => preg_match( self::REFERENCE_PATTERN, $text ) ? 'reference' : 'inline',
+ 'text' => $text,
+ );
+ }
+
+ /**
+ * Returns the number of parameters documented for a hook call, resolving the
+ * docblock the same way as the return-type extension (inline or via a
+ * "documented in" reference).
+ *
+ * Unlike getNullableHookDocBlock(), this does NOT fall back to the reference
+ * comment when a reference cannot be resolved to a canonical docblock; it
+ * returns null instead, so callers do not mistake an unresolved reference
+ * (which has no `param` tags) for a genuine zero-parameter hook.
+ *
+ * @param FuncCall $function_call Hook function call node.
+ * @param Scope $scope Analysis scope.
+ * @return int|null Documented parameter count, or null when there is no
+ * docblock or a reference cannot be resolved.
+ * @throws ShouldNotHappenException
+ */
+ public function getDocumentedParamCount( FuncCall $function_call, Scope $scope ): ?int {
+ $comment = self::getNullableNodeComment( $function_call );
+ if ( null === $comment ) {
+ return null;
+ }
+
+ $code = $comment->getText();
+
+ if ( preg_match( self::REFERENCE_PATTERN, $code ) ) {
+ $referenced = $this->resolveDocumentedInReference( $code, $function_call, $scope );
+ if ( null === $referenced ) {
+ return null;
+ }
+ return count( $referenced->getParamTags() );
+ }
+
+ $class_reflection = $scope->getClassReflection();
+ $trait_reflection = $scope->getTraitReflection();
+
+ $resolved = $this->fileTypeMapper->getResolvedPhpDoc(
+ $scope->getFile(),
+ ( $scope->isInClass() && null !== $class_reflection ) ? $class_reflection->getName() : null,
+ ( $scope->isInTrait() && null !== $trait_reflection ) ? $trait_reflection->getName() : null,
+ $scope->getFunctionName(),
+ $code
+ );
+
+ return count( $resolved->getParamTags() );
+ }
+
+ /**
+ * Determines whether a hook call's name can be identified well enough to
+ * require or locate documentation.
+ *
+ * Calls whose hook name carries no literal text (e.g. the generic
+ * `apply_filters_ref_array( $hook_name, $args )` forwarders in plugin.php)
+ * cannot be meaningfully documented at the call site and are excluded.
+ *
+ * @param FuncCall $function_call Hook function call node.
+ * @return bool
+ */
+ public static function hasIdentifiableHookName( FuncCall $function_call ): bool {
+ $args = $function_call->getArgs();
+ if ( ! isset( $args[0] ) ) {
+ return false;
+ }
+
+ $value = $args[0]->value;
+ if ( $value instanceof String_ ) {
+ return true;
+ }
+
+ return null !== self::buildHookNameRegex( $value );
+ }
+
+ /**
+ * Validates a "documented elsewhere" reference comment preceding a hook call.
+ *
+ * Returns null when the comment is not such a reference, when the hook name is
+ * not a literal, when the WordPress root cannot be determined, or when the
+ * reference is valid. Otherwise, it returns the problem details.
+ *
+ * @param FuncCall $function_call Hook function call node.
+ * @param Scope $scope Analysis scope.
+ * @return array{path: string, hook: string, problem: string}|null
+ */
+ public function getReferenceProblem( FuncCall $function_call, Scope $scope ): ?array {
+ $comment = self::getNullableNodeComment( $function_call );
+ if ( null === $comment ) {
+ return null;
+ }
+
+ if ( ! preg_match( self::REFERENCE_PATTERN, $comment->getText(), $matches ) ) {
+ return null;
+ }
+
+ $matcher = self::getHookNameMatcher( $function_call );
+ if ( null === $matcher ) {
+ return null;
+ }
+
+ $reference_path = $matches[1];
+ $target_file = self::resolveReferencePath( $scope->getFile(), $reference_path );
+
+ // The referenced file could not be located up the directory tree.
+ if ( null === $target_file ) {
+ return array(
+ 'path' => $reference_path,
+ 'hook' => self::getHookNameDisplay( $function_call ),
+ 'problem' => self::PROBLEM_FILE_MISSING,
+ );
+ }
+
+ if ( null === $this->findHookDoc( $target_file, $matcher ) ) {
+ return array(
+ 'path' => $reference_path,
+ 'hook' => self::getHookNameDisplay( $function_call ),
+ 'problem' => self::PROBLEM_HOOK_MISSING,
+ );
+ }
+
+ return null;
+ }
+
+ /**
+ * Resolves the canonical docblock referenced by a "This filter/action is
+ * documented in " comment.
+ *
+ * @param string $comment_text Raw comment text preceding the hook call.
+ * @param FuncCall $function_call Hook function call node.
+ * @param Scope $scope Analysis scope.
+ *
+ * @return ResolvedPhpDocBlock|null Resolved canonical docblock, or null when it cannot be located.
+ * @throws ShouldNotHappenException
+ */
+ private function resolveDocumentedInReference( string $comment_text, FuncCall $function_call, Scope $scope ): ?ResolvedPhpDocBlock {
+ if ( ! preg_match( self::REFERENCE_PATTERN, $comment_text, $matches ) ) {
+ return null;
+ }
+
+ $matcher = self::getHookNameMatcher( $function_call );
+ if ( null === $matcher ) {
+ return null;
+ }
+
+ $target_file = self::resolveReferencePath( $scope->getFile(), $matches[1] );
+ if ( null === $target_file ) {
+ return null;
+ }
+
+ $doc_text = $this->findHookDoc( $target_file, $matcher );
+ if ( null === $doc_text ) {
+ return null;
+ }
+
+ // Resolve the canonical docblock in the global namespace, with no file
+ // context. Hook docblocks describe global/plain types (e.g. string[],
+ // WP_REST_Response), so the referenced file's `use` imports are not needed.
+ // Passing the referenced file here would also re-enter PHPStan's name-scope
+ // builder while that file is itself being analyzed, which makes
+ // getResolvedPhpDoc return an empty docblock (NameScopeAlreadyBeingCreated).
+ return $this->fileTypeMapper->getResolvedPhpDoc( null, null, null, null, $doc_text );
+ }
+
+ /**
+ * Returns the canonical docblock text for a hook documented in the given file.
+ *
+ * @param string $file Absolute path to the file declaring the hook.
+ * @param array{kind: 'literal'|'pattern', value: string} $matcher Hook name matcher from getHookNameMatcher().
+ * @return string|null Docblock text, or null when no documented invocation is found.
+ */
+ private function findHookDoc( string $file, array $matcher ): ?string {
+ if ( ! isset( $this->fileHookDocs[ $file ] ) ) {
+ $this->fileHookDocs[ $file ] = self::parseHookDocs( $file );
+ }
+
+ $docs = $this->fileHookDocs[ $file ];
+
+ if ( 'literal' === $matcher['kind'] ) {
+ $name = $matcher['value'];
+
+ if ( isset( $docs['exact'][ $name ] ) ) {
+ return $docs['exact'][ $name ];
+ }
+
+ // A literal name may be an instance of a dynamic canonical hook
+ // (e.g. "index_template_hierarchy" matching "{$type}_template_hierarchy").
+ foreach ( $docs['patterns'] as $pattern ) {
+ if ( preg_match( $pattern['regex'], $name ) ) {
+ return $pattern['text'];
+ }
+ }
+
+ return null;
+ }
+
+ // A dynamic referencing name matches the same dynamic canonical (identical
+ // regex), or a literal canonical the pattern covers.
+ $regex = $matcher['value'];
+
+ foreach ( $docs['patterns'] as $pattern ) {
+ if ( $pattern['regex'] === $regex ) {
+ return $pattern['text'];
+ }
+ }
+
+ foreach ( $docs['exact'] as $name => $text ) {
+ if ( preg_match( $regex, $name ) ) {
+ return $text;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Parses a file and collects the canonical docblock text for each hook
+ * invocation it documents.
+ *
+ * A docblock is treated as canonical when it is not itself a "documented
+ * elsewhere" reference, so referencing call sites do not count as the source
+ * of documentation. Hooks with a literal name are indexed exactly; hooks with
+ * a dynamic name that contains literal text are indexed as a regex.
+ *
+ * @param string $file Absolute path to the file.
+ * @return HookDocs
+ */
+ private static function parseHookDocs( string $file ): array {
+ $docs = array(
+ 'exact' => array(),
+ 'patterns' => array(),
+ );
+
+ if ( ! is_file( $file ) || ! is_readable( $file ) ) {
+ return $docs;
+ }
+
+ $code = file_get_contents( $file );
+ if ( false === $code ) {
+ return $docs;
+ }
+
+ $parser = ( new ParserFactory() )->createForHostVersion();
+ $stmts = $parser->parse( $code );
+ if ( null === $stmts ) {
+ return $docs;
+ }
+
+ // Propagate each docblock down to the nested hook-call node.
+ $traverser = new NodeTraverser();
+ $traverser->addVisitor( new HookDocsVisitor() );
+ $stmts = $traverser->traverse( $stmts );
+
+ $seen = array();
+ $calls = ( new NodeFinder() )->findInstanceOf( $stmts, FuncCall::class );
+ foreach ( $calls as $call ) {
+ if ( ! $call instanceof FuncCall || ! $call->name instanceof Name ) {
+ continue;
+ }
+
+ if ( ! in_array( $call->name->toString(), self::HOOK_FUNCTIONS, true ) ) {
+ continue;
+ }
+
+ $args = $call->getArgs();
+ if ( ! isset( $args[0] ) ) {
+ continue;
+ }
+
+ $doc = $call->getAttribute( 'latestDocComment' );
+
+ // Skip reference comments so only the canonical documentation counts.
+ if ( ! $doc instanceof Doc || preg_match( self::REFERENCE_PATTERN, $doc->getText() ) ) {
+ continue;
+ }
+
+ $name_expr = $args[0]->value;
+
+ if ( $name_expr instanceof String_ ) {
+ if ( ! isset( $docs['exact'][ $name_expr->value ] ) ) {
+ $docs['exact'][ $name_expr->value ] = $doc->getText();
+ }
+ continue;
+ }
+
+ $regex = self::buildHookNameRegex( $name_expr );
+ if ( null !== $regex && ! isset( $seen[ $regex ] ) ) {
+ $seen[ $regex ] = true;
+ $docs['patterns'][] = array(
+ 'regex' => $regex,
+ 'text' => $doc->getText(),
+ );
+ }
+ }
+
+ return $docs;
+ }
+
+ /**
+ * Builds an anchored regex matching a dynamic hook name expression, or null
+ * when the expression carries no literal text to anchor on.
+ *
+ * @param Expr $expr Hook name expression.
+ * @return string|null
+ */
+ private static function buildHookNameRegex( Expr $expr ): ?string {
+ $parts = self::hookNameRegexParts( $expr );
+ if ( null === $parts || ! $parts[1] ) {
+ return null;
+ }
+
+ return '#^' . $parts[0] . '$#';
+ }
+
+ /**
+ * Recursively converts a hook name expression into a regex fragment.
+ *
+ * @param Expr $expr Hook name expression.
+ * @return array{0: string, 1: bool}|null Fragment and whether it contains literal text, or null if unsupported.
+ */
+ private static function hookNameRegexParts( Expr $expr ): ?array {
+ if ( $expr instanceof String_ ) {
+ return array( preg_quote( $expr->value, '#' ), true );
+ }
+
+ if ( $expr instanceof Concat ) {
+ $left = self::hookNameRegexParts( $expr->left );
+ $right = self::hookNameRegexParts( $expr->right );
+ if ( null === $left || null === $right ) {
+ return null;
+ }
+ return array( $left[0] . $right[0], $left[1] || $right[1] );
+ }
+
+ if ( $expr instanceof InterpolatedString ) {
+ $fragment = '';
+ $has_literal = false;
+ foreach ( $expr->parts as $part ) {
+ if ( $part instanceof InterpolatedStringPart ) {
+ $fragment .= preg_quote( $part->value, '#' );
+ $has_literal = true;
+ } else {
+ $fragment .= '.+';
+ }
+ }
+ return array( $fragment, $has_literal );
+ }
+
+ // Variables, property fetches, etc.: a wildcard with no literal anchor.
+ return array( '.+', false );
+ }
+
+ /**
+ * Resolves a WordPress-root-relative reference path against the file
+ * containing the reference comment.
+ *
+ * The reference comment names the exact file (e.g. "wp-includes/media.php"), so
+ * resolution simply walks up from the current file's directory until that
+ * relative path resolves to a real file. This works regardless of where the
+ * referencing file lives (core, a bundled theme, the install root, ...) and
+ * only ever touches the single named file — no directory is enumerated.
+ *
+ * @param string $current_file Absolute path to the file with the reference comment.
+ * @param string $reference_path Root-relative path (e.g. "wp-includes/media.php").
+ * @return string|null Absolute path to the referenced file, or null when it cannot be located.
+ */
+ private static function resolveReferencePath( string $current_file, string $reference_path ): ?string {
+ $reference_path = ltrim( $reference_path, '/' );
+ $dir = dirname( $current_file );
+
+ while ( true ) {
+ $candidate = $dir . '/' . $reference_path;
+ if ( is_file( $candidate ) ) {
+ return $candidate;
+ }
+
+ $parent = dirname( $dir );
+ if ( $parent === $dir ) {
+ return null;
+ }
+ $dir = $parent;
+ }
+ }
+
+ /**
+ * Returns a matcher describing a hook call's name: a literal string to look up
+ * exactly, or a regex for a dynamic name (e.g. "{$type}_template_hierarchy").
+ *
+ * @param FuncCall $call Hook function call node.
+ * @return array{kind: 'literal'|'pattern', value: string}|null
+ * Null when the name carries no identifiable text (e.g. a bare variable).
+ */
+ private static function getHookNameMatcher( FuncCall $call ): ?array {
+ $args = $call->getArgs();
+ if ( ! isset( $args[0] ) ) {
+ return null;
+ }
+
+ $expr = $args[0]->value;
+
+ if ( $expr instanceof String_ ) {
+ return array(
+ 'kind' => 'literal',
+ 'value' => $expr->value,
+ );
+ }
+
+ $regex = self::buildHookNameRegex( $expr );
+ if ( null !== $regex ) {
+ return array(
+ 'kind' => 'pattern',
+ 'value' => $regex,
+ );
+ }
+
+ return null;
+ }
+
+ /**
+ * Renders a hook name expression to a readable string for diagnostics, e.g.
+ * "default_option_{$option}".
+ *
+ * @param FuncCall $call Hook function call node.
+ * @return string
+ */
+ private static function getHookNameDisplay( FuncCall $call ): string {
+ $args = $call->getArgs();
+ if ( ! isset( $args[0] ) ) {
+ return '';
+ }
+
+ return self::renderHookName( $args[0]->value );
+ }
+
+ /**
+ * Recursively renders a hook name expression to a readable string.
+ *
+ * @param Expr $expr Hook name expression.
+ * @return string
+ */
+ private static function renderHookName( Expr $expr ): string {
+ if ( $expr instanceof String_ ) {
+ return $expr->value;
+ }
+
+ if ( $expr instanceof Concat ) {
+ return self::renderHookName( $expr->left ) . self::renderHookName( $expr->right );
+ }
+
+ if ( $expr instanceof InterpolatedString ) {
+ $out = '';
+ foreach ( $expr->parts as $part ) {
+ if ( $part instanceof InterpolatedStringPart ) {
+ $out .= $part->value;
+ } elseif ( $part instanceof Variable && is_string( $part->name ) ) {
+ $out .= '{$' . $part->name . '}';
+ } else {
+ $out .= '{...}';
+ }
+ }
+ return $out;
+ }
+
+ if ( $expr instanceof Variable && is_string( $expr->name ) ) {
+ return '$' . $expr->name;
+ }
+
+ return '...';
+ }
+
+ /**
+ * Returns the docblock attached to the node by HookDocsVisitor, if present.
+ *
+ * @param FuncCall $node Function call node.
+ * @return Doc|null
+ */
+ private static function getNullableNodeComment( FuncCall $node ): ?Doc {
+ /** @var Doc|null $doc */
+ $doc = $node->getAttribute( 'latestDocComment' );
+ return $doc;
+ }
+}
diff --git a/tests/phpstan/HookDocsVisitor.php b/tests/phpstan/HookDocsVisitor.php
new file mode 100644
index 0000000000000..34a7485e438ba
--- /dev/null
+++ b/tests/phpstan/HookDocsVisitor.php
@@ -0,0 +1,115 @@
+
+ */
+ private array $stack = array();
+
+ /**
+ * Resets state before traversing a new set of nodes.
+ *
+ * @param Node[] $nodes Nodes about to be traversed.
+ * @return Node[]|null
+ */
+ public function beforeTraverse( array $nodes ): ?array {
+ unset( $nodes );
+
+ $this->latestDocComment = null;
+ $this->stack = array();
+
+ return null;
+ }
+
+ /**
+ * Tracks the applicable docblock and attaches it to the node.
+ *
+ * @param Node $node Node being entered.
+ * @return Node|null
+ */
+ public function enterNode( Node $node ): ?Node {
+ $doc = $node->getDocComment();
+
+ if ( null !== $doc ) {
+ // A docblock here documents this node and everything nested within it.
+ $this->stack[] = array( $node, $this->latestDocComment );
+ $this->latestDocComment = $doc;
+ } elseif ( $node instanceof Stmt ) {
+ // A new statement without its own docblock starts an undocumented scope
+ // for its subtree, so a preceding docblock does not carry into it.
+ $this->stack[] = array( $node, $this->latestDocComment );
+ $this->latestDocComment = null;
+ }
+
+ $node->setAttribute( 'latestDocComment', $this->latestDocComment );
+
+ return null;
+ }
+
+ /**
+ * Restores the docblock that applied before this node was entered, bounding a
+ * docblock's reach to the node that introduced it.
+ *
+ * @param Node $node Node being left.
+ * @return Node|null
+ */
+ public function leaveNode( Node $node ): ?Node {
+ $top = end( $this->stack );
+
+ if ( false !== $top && $top[0] === $node ) {
+ $this->latestDocComment = $top[1];
+ array_pop( $this->stack );
+ }
+
+ return null;
+ }
+}
diff --git a/tests/phpstan/HookDocumentationRule.php b/tests/phpstan/HookDocumentationRule.php
new file mode 100644
index 0000000000000..cd1c225477aa8
--- /dev/null
+++ b/tests/phpstan/HookDocumentationRule.php
@@ -0,0 +1,149 @@
+ *\/` reference comment.
+ *
+ * When a reference comment is used, the referenced file must exist and must
+ * actually document a hook of the same name; otherwise an error is reported.
+ *
+ * @package WordPress
+ */
+
+declare(strict_types=1);
+
+namespace WordPress\PHPStan;
+
+use PhpParser\Node;
+use PhpParser\Node\Expr\FuncCall;
+use PhpParser\Node\Name;
+use PHPStan\Analyser\Scope;
+use PHPStan\Rules\IdentifierRuleError;
+use PHPStan\Rules\Rule;
+use PHPStan\Rules\RuleErrorBuilder;
+use PHPStan\ShouldNotHappenException;
+
+/**
+ * Reports undocumented hooks and broken "documented elsewhere" references.
+ *
+ * @implements Rule
+ */
+class HookDocumentationRule implements Rule {
+
+ /**
+ * Hook docblock resolver.
+ *
+ * @var HookDocBlock
+ */
+ private HookDocBlock $hookDocBlock;
+
+ /**
+ * Constructor.
+ *
+ * @param HookDocBlock $hook_doc_block Hook docblock resolver.
+ */
+ public function __construct( HookDocBlock $hook_doc_block ) {
+ $this->hookDocBlock = $hook_doc_block;
+ }
+
+ /**
+ * Returns the node type this rule processes.
+ *
+ * @return string
+ */
+ public function getNodeType(): string {
+ return FuncCall::class;
+ }
+
+ /**
+ * Processes a function call node.
+ *
+ * @param Node $node Function call node.
+ * @param Scope $scope Analysis scope.
+ * @return list
+ * @throws ShouldNotHappenException
+ */
+ public function processNode( Node $node, Scope $scope ): array {
+ if ( ! $node instanceof FuncCall || ! $node->name instanceof Name ) {
+ return array();
+ }
+
+ if ( ! in_array( $node->name->toString(), HookDocBlock::HOOK_FUNCTIONS, true ) ) {
+ return array();
+ }
+
+ // Skip calls whose hook name carries no literal text, i.e. a bare variable
+ // such as the generic apply_filters_ref_array( $hook_name, $args )
+ // re-dispatch in plugin.php. There is no concrete hook to document or look
+ // up. Calls naming a hook literally (e.g. apply_filters_ref_array( 'the_posts',
+ // ... )) or dynamically with literal text (e.g. "{$type}_template_hierarchy")
+ // remain subject to the documentation requirement.
+ if ( ! HookDocBlock::hasIdentifiableHookName( $node ) ) {
+ return array();
+ }
+
+ $function_name = $node->name->toString();
+ $doc_block = $this->hookDocBlock->getPrecedingDocBlock( $node );
+
+ // No preceding docblock at all: the hook is undocumented.
+ if ( null === $doc_block ) {
+ return array(
+ RuleErrorBuilder::message(
+ sprintf(
+ '%s() call is not preceded by a docblock documenting the hook, nor by a "This filter is documented in " reference comment.',
+ $function_name
+ )
+ )
+ ->identifier( 'wordpress.hookDocMissing' )
+ ->line( $node->getStartLine() )
+ ->build(),
+ );
+ }
+
+ // An inline docblock documents the hook in place; nothing more to check.
+ if ( 'reference' !== $doc_block['type'] ) {
+ return array();
+ }
+
+ // A reference comment must point at a file that documents this hook.
+ $problem = $this->hookDocBlock->getReferenceProblem( $node, $scope );
+ if ( null === $problem ) {
+ return array();
+ }
+
+ if ( HookDocBlock::PROBLEM_FILE_MISSING === $problem['problem'] ) {
+ return array(
+ RuleErrorBuilder::message(
+ sprintf(
+ '%s() call for hook "%s" references documentation in "%s", but that file does not exist.',
+ $function_name,
+ $problem['hook'],
+ $problem['path']
+ )
+ )
+ ->identifier( 'wordpress.hookDocReferenceFileMissing' )
+ ->line( $node->getStartLine() )
+ ->build(),
+ );
+ }
+
+ return array(
+ RuleErrorBuilder::message(
+ sprintf(
+ '%s() call for hook "%s" references documentation in "%s", but no documented "%s" hook is found there.',
+ $function_name,
+ $problem['hook'],
+ $problem['path'],
+ $problem['hook']
+ )
+ )
+ ->identifier( 'wordpress.hookDocReferenceHookMissing' )
+ ->line( $node->getStartLine() )
+ ->build(),
+ );
+ }
+}
diff --git a/tests/phpstan/HookParamCountRule.php b/tests/phpstan/HookParamCountRule.php
new file mode 100644
index 0000000000000..85b1ff8d0502e
--- /dev/null
+++ b/tests/phpstan/HookParamCountRule.php
@@ -0,0 +1,215 @@
+"
+ * reference is checked against its canonical docblock.
+ *
+ * @package WordPress
+ */
+
+declare(strict_types=1);
+
+namespace WordPress\PHPStan;
+
+use PhpParser\Node;
+use PhpParser\Node\Expr\FuncCall;
+use PhpParser\Node\Name;
+use PhpParser\Node\Scalar\String_;
+use PHPStan\Analyser\Scope;
+use PHPStan\Rules\IdentifierRuleError;
+use PHPStan\Rules\Rule;
+use PHPStan\Rules\RuleErrorBuilder;
+use PHPStan\ShouldNotHappenException;
+use PHPStan\Type\Constant\ConstantIntegerType;
+
+/**
+ * Reports hook invocations whose argument count does not match the number of
+ * documented parameters.
+ *
+ * @implements Rule
+ */
+class HookParamCountRule implements Rule {
+
+ /**
+ * Hook functions that receive the hook arguments as variadic parameters.
+ */
+ private const VARIADIC_FUNCTIONS = array(
+ 'apply_filters',
+ 'do_action',
+ );
+
+ /**
+ * Hook functions that receive the hook arguments as an array in their second
+ * parameter.
+ */
+ private const ARRAY_ARG_FUNCTIONS = array(
+ 'apply_filters_ref_array',
+ 'apply_filters_deprecated',
+ 'do_action_ref_array',
+ 'do_action_deprecated',
+ );
+
+ /**
+ * Hook docblock resolver.
+ *
+ * @var HookDocBlock
+ */
+ private HookDocBlock $hookDocBlock;
+
+ /**
+ * Constructor.
+ *
+ * @param HookDocBlock $hook_doc_block Hook docblock resolver.
+ */
+ public function __construct( HookDocBlock $hook_doc_block ) {
+ $this->hookDocBlock = $hook_doc_block;
+ }
+
+ /**
+ * Returns the node type this rule processes.
+ *
+ * @return string
+ */
+ public function getNodeType(): string {
+ return FuncCall::class;
+ }
+
+ /**
+ * Processes a function call node.
+ *
+ * @param Node $node Function call node.
+ * @param Scope $scope Analysis scope.
+ * @return list
+ * @throws ShouldNotHappenException
+ */
+ public function processNode( Node $node, Scope $scope ): array {
+ if ( ! $node instanceof FuncCall || ! $node->name instanceof Name ) {
+ return array();
+ }
+
+ $function_name = $node->name->toString();
+ $is_variadic = in_array( $function_name, self::VARIADIC_FUNCTIONS, true );
+ if ( ! $is_variadic && ! in_array( $function_name, self::ARRAY_ARG_FUNCTIONS, true ) ) {
+ return array();
+ }
+
+ // Without an identifiable hook name there is nothing to document or look up.
+ if ( ! HookDocBlock::hasIdentifiableHookName( $node ) ) {
+ return array();
+ }
+
+ // Only compare against documentation that actually resolves. Missing docs and
+ // unresolvable/broken references (reported by HookDocumentationRule) yield
+ // null here and are skipped rather than compared against a bogus zero count.
+ $documented = $this->hookDocBlock->getDocumentedParamCount( $node, $scope );
+ if ( null === $documented ) {
+ return array();
+ }
+
+ $provided = $is_variadic
+ ? self::countVariadicArguments( $node, $scope )
+ : self::countArrayArguments( $node, $scope );
+
+ // The provided count could not be determined statically; skip rather than
+ // guess (e.g. arguments spread from a variable of unknown size).
+ if ( null === $provided || $provided === $documented ) {
+ return array();
+ }
+
+ return array(
+ RuleErrorBuilder::message(
+ sprintf(
+ '%s() %sprovides %d argument%s, but the hook is documented with %d parameter%s.',
+ $function_name,
+ self::hookLabel( $node ),
+ $provided,
+ 1 === $provided ? '' : 's',
+ $documented,
+ 1 === $documented ? '' : 's'
+ )
+ )
+ ->identifier( 'wordpress.hookParamCountMismatch' )
+ ->line( $node->getStartLine() )
+ ->build(),
+ );
+ }
+
+ /**
+ * Counts the arguments a variadic hook call passes after the hook name.
+ *
+ * @param FuncCall $node Hook function call node.
+ * @param Scope $scope Analysis scope.
+ * @return int|null Argument count, or null when it cannot be determined statically.
+ */
+ private static function countVariadicArguments( FuncCall $node, Scope $scope ): ?int {
+ $args = $node->getArgs();
+ $count = 0;
+
+ // Skip index 0, the hook name.
+ for ( $i = 1, $len = count( $args ); $i < $len; $i++ ) {
+ $arg = $args[ $i ];
+
+ if ( $arg->unpack ) {
+ $size = $scope->getType( $arg->value )->getArraySize();
+ if ( ! $size instanceof ConstantIntegerType ) {
+ return null;
+ }
+ $count += $size->getValue();
+ continue;
+ }
+
+ ++$count;
+ }
+
+ return $count;
+ }
+
+ /**
+ * Counts the arguments a hook call passes via its array argument.
+ *
+ * @param FuncCall $node Hook function call node.
+ * @param Scope $scope Analysis scope.
+ * @return int|null Argument count, or null when it cannot be determined statically.
+ */
+ private static function countArrayArguments( FuncCall $node, Scope $scope ): ?int {
+ $args = $node->getArgs();
+ if ( ! isset( $args[1] ) ) {
+ return null;
+ }
+
+ $size = $scope->getType( $args[1]->value )->getArraySize();
+ if ( ! $size instanceof ConstantIntegerType ) {
+ return null;
+ }
+
+ return $size->getValue();
+ }
+
+ /**
+ * Builds a `for hook "name" ` label fragment when the hook name is a literal.
+ *
+ * @param FuncCall $node Hook function call node.
+ * @return string
+ */
+ private static function hookLabel( FuncCall $node ): string {
+ $args = $node->getArgs();
+ if ( isset( $args[0] ) ) {
+ $name = $args[0]->value;
+ if ( $name instanceof String_ ) {
+ return sprintf( 'for hook "%s" ', $name->value );
+ }
+ }
+
+ return '';
+ }
+}
diff --git a/tests/phpstan/base.neon b/tests/phpstan/base.neon
index b790d318e110c..f24d1ad1c9587 100644
--- a/tests/phpstan/base.neon
+++ b/tests/phpstan/base.neon
@@ -12,6 +12,34 @@ services:
tags:
- phpstan.parser.richParserNodeVisitor
+ # Types the return value of apply_filters() (and variants) from the `@param`
+ # type in the docblock preceding the call, including the "This filter is
+ # documented in " convention. Adapted from szepeviktor/phpstan-wordpress.
+ -
+ class: WordPress\PHPStan\HookDocsVisitor
+ tags:
+ - phpstan.parser.richParserNodeVisitor
+ -
+ class: WordPress\PHPStan\HookDocBlock
+ -
+ class: WordPress\PHPStan\ApplyFiltersDynamicFunctionReturnTypeExtension
+ tags:
+ - phpstan.broker.dynamicFunctionReturnTypeExtension
+
+ # Enforces that every hook invocation is preceded by a documenting docblock or
+ # a valid "This filter is documented in " reference comment.
+ -
+ class: WordPress\PHPStan\HookDocumentationRule
+ tags:
+ - phpstan.rules.rule
+
+ # Enforces that a hook invocation passes as many arguments as its documentation
+ # (inline or referenced) describes.
+ -
+ class: WordPress\PHPStan\HookParamCountRule
+ tags:
+ - phpstan.rules.rule
+
parameters:
# Cache is stored locally, so it's available for CI.
tmpDir: ../../.cache
@@ -99,6 +127,11 @@ parameters:
- ../../src/wp-trackback.php
- ../../src/xmlrpc.php
- GlobalDocBlockVisitor.php
+ - HookDocsVisitor.php
+ - HookDocBlock.php
+ - ApplyFiltersDynamicFunctionReturnTypeExtension.php
+ - HookDocumentationRule.php
+ - HookParamCountRule.php
bootstrapFiles:
- bootstrap.php
scanFiles:
@@ -116,6 +149,8 @@ parameters:
- ../../src/wp-includes/pluggable-deprecated.php
# These files are autogenerated by tools/gutenberg/copy.js.
- ../../src/wp-includes/blocks
+ # Generated output from the Gutenberg plugin's wp-build templates.
+ - ../../src/wp-includes/build
# Third-party libraries.
- ../../src/wp-admin/includes/class-ftp-pure.php
- ../../src/wp-admin/includes/class-ftp-sockets.php