Commit bb495884aa for wordpress.org

commit bb495884aac290907a606c381257d74f8056e5d7
Author: westonruter <westonruter@git.wordpress.org>
Date:   Tue Nov 10 04:52:57 2026 +0000

    REST API: Accept a post object in global styles `prepare_links()`.

    The `WP_REST_Global_Styles_Controller::prepare_links()` override took a post ID while the parent method takes a `WP_Post`, which PHPStan reports as an incompatible child parameter type. It now accepts either, and `prepare_item_for_response()` continues to pass the ID so that subclasses overriding the method and expecting an ID are not broken. The method also gains a precise return type, which in turn required narrowing the types it relies on: REST URLs are documented as non-empty, and `count` is declared as a non-negative link attribute.

    The `WP_Query::$found_posts` count is now clamped to zero, both from the `found_posts_query` query and after the `found_posts` filter, since a negative post count has no valid meaning and would otherwise surface as a negative revisions count. In r65281 only `$max_num_pages` was clamped, and its test asserted a negative `found_posts` merely as a precondition.

    Developed in https://github.com/WordPress/wordpress-develop/pull/15826.
    Follow-up to r48328, r52051, r56082, r65281.

    Props westonruter, huzaifaalmesbah, dpknauss.
    See #65817.

    Built from https://develop.svn.wordpress.org/trunk@65369


    git-svn-id: http://core.svn.wordpress.org/trunk@64508 1a063a9b-81f0-0310-95a4-ce76da25c4cd

diff --git a/wp-includes/class-wp-query.php b/wp-includes/class-wp-query.php
index 2b593c0add..998fe477d4 100644
--- a/wp-includes/class-wp-query.php
+++ b/wp-includes/class-wp-query.php
@@ -178,6 +178,7 @@ class WP_Query {
 	 *
 	 * @since 2.1.0
 	 * @var int
+	 * @phpstan-var int<0, max>
 	 */
 	public $found_posts = 0;

@@ -3709,7 +3710,7 @@ class WP_Query {
 			 */
 			$found_posts_query = apply_filters_ref_array( 'found_posts_query', array( 'SELECT FOUND_ROWS()', &$this ) );

-			$this->found_posts = (int) $wpdb->get_var( $found_posts_query );
+			$this->found_posts = max( 0, (int) $wpdb->get_var( $found_posts_query ) );
 		} else {
 			if ( is_array( $this->posts ) ) {
 				$this->found_posts = count( $this->posts );
@@ -3726,11 +3727,15 @@ class WP_Query {
 		 * Filters the number of found posts for the query.
 		 *
 		 * @since 2.1.0
+		 * @since 7.2.0 A negative return value is clamped to zero, as is a negative count from the
+		 *              {@see 'found_posts_query'} query before it is passed to this filter.
 		 *
 		 * @param int      $found_posts The number of posts found.
 		 * @param WP_Query $query       The WP_Query instance (passed by reference).
+		 *
+		 * @phpstan-param int<0, max> $found_posts
 		 */
-		$this->found_posts = (int) apply_filters_ref_array( 'found_posts', array( $this->found_posts, &$this ) );
+		$this->found_posts = max( 0, (int) apply_filters_ref_array( 'found_posts', array( $this->found_posts, &$this ) ) );

 		if ( ! empty( $limits ) ) {
 			$this->max_num_pages = max( 0, (int) ceil( $this->found_posts / (int) $query_vars['posts_per_page'] ) );
diff --git a/wp-includes/rest-api.php b/wp-includes/rest-api.php
index f40021f4d0..f8d5e0a383 100644
--- a/wp-includes/rest-api.php
+++ b/wp-includes/rest-api.php
@@ -531,6 +531,8 @@ function rest_get_url_prefix() {
  * @param string   $path    Optional. REST route. Default '/'.
  * @param string   $scheme  Optional. Sanitization scheme. Default 'rest'.
  * @return string Full URL to the endpoint.
+ *
+ * @phpstan-return non-falsy-string
  */
 function get_rest_url( $blog_id = null, $path = '/', $scheme = 'rest' ) {
 	if ( empty( $path ) ) {
@@ -589,6 +591,8 @@ function get_rest_url( $blog_id = null, $path = '/', $scheme = 'rest' ) {
 	 * @param string   $path    REST route.
 	 * @param int|null $blog_id Blog ID.
 	 * @param string   $scheme  Sanitization scheme.
+	 *
+	 * @phpstan-param non-falsy-string $url
 	 */
 	return apply_filters( 'rest_url', $url, $path, $blog_id, $scheme );
 }
@@ -603,6 +607,8 @@ function get_rest_url( $blog_id = null, $path = '/', $scheme = 'rest' ) {
  * @param string $path   Optional. REST route. Default empty.
  * @param string $scheme Optional. Sanitization scheme. Default 'rest'.
  * @return string Full URL to the endpoint.
+ *
+ * @phpstan-return non-falsy-string
  */
 function rest_url( $path = '', $scheme = 'rest' ) {
 	return get_rest_url( null, $path, $scheme );
diff --git a/wp-includes/rest-api/class-wp-rest-response.php b/wp-includes/rest-api/class-wp-rest-response.php
index f86976e94d..7ec735a8f7 100644
--- a/wp-includes/rest-api/class-wp-rest-response.php
+++ b/wp-includes/rest-api/class-wp-rest-response.php
@@ -24,6 +24,7 @@
  * @phpstan-type Link_Attributes array{
  *     embeddable?: bool,
  *     targetHints?: Target_Hints,
+ *     count?: int<0, max>,
  *     ...<string, mixed>
  * }
  * @phpstan-type Link array{
@@ -34,6 +35,7 @@
  *     href: non-falsy-string,
  *     embeddable?: bool,
  *     targetHints?: array<string, mixed>, // Not narrowed like in Link_Attributes, since PHPStan merges this shape with its list form in add_links(). The merged attributes passed to add_link() there are only accepted while they include `mixed`; with the nested shape, the call would be an argument.type error even when `mixed` is not checked.
+ *     count?: int<0, max>, // Declared rather than left to the unsealed keys, since that merge also drops them: an attribute not named here is rejected when a caller passes a precise shape.
  *     ...<string, mixed>
  * }
  * @phpstan-type Curie array{
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php
index e9f3b281ed..daa83ba352 100644
--- a/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php
@@ -359,6 +359,7 @@ class WP_REST_Global_Styles_Controller extends WP_REST_Posts_Controller {
 		$response = rest_ensure_response( $data );

 		if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
+			// The ID is passed on purpose, since subclasses that override prepare_links() may expect an ID rather than a post object.
 			$links = $this->prepare_links( $post->ID );

 			// Only return resolved URIs for get requests to user theme JSON.
@@ -387,11 +388,19 @@ class WP_REST_Global_Styles_Controller extends WP_REST_Posts_Controller {
 	 *
 	 * @since 5.9.0
 	 * @since 6.3.0 Adds revisions count and rest URL href to version-history.
+	 * @since 7.2.0 Accepts a post object, matching the parent method.
 	 *
-	 * @param int $id ID.
-	 * @return array Links for the given post.
+	 * @param WP_Post|int $post Post object or ID.
+	 * @return array<string, array<string, string|int>> Links for the given post.
+	 *
+	 * @phpstan-return array{
+	 *     self: array{ href: non-falsy-string },
+	 *     about: array{ href: non-falsy-string },
+	 *     version-history?: array{ href: non-falsy-string, count: int<0, max> },
+	 * }
 	 */
-	protected function prepare_links( $id ) {
+	protected function prepare_links( $post ) {
+		$id   = $post instanceof WP_Post ? $post->ID : $post;
 		$base = sprintf( '%s/%s', $this->namespace, $this->rest_base );

 		$links = array(
@@ -404,7 +413,7 @@ class WP_REST_Global_Styles_Controller extends WP_REST_Posts_Controller {
 		);

 		if ( post_type_supports( $this->post_type, 'revisions' ) ) {
-			$revisions                = wp_get_latest_revision_id_and_total_count( $id );
+			$revisions                = wp_get_latest_revision_id_and_total_count( $post );
 			$revisions_count          = ! is_wp_error( $revisions ) ? $revisions['count'] : 0;
 			$revisions_base           = sprintf( '/%s/%d/revisions', $base, $id );
 			$links['version-history'] = array(
diff --git a/wp-includes/revision.php b/wp-includes/revision.php
index 179a32c283..77b0b6425e 100644
--- a/wp-includes/revision.php
+++ b/wp-includes/revision.php
@@ -716,6 +716,8 @@ function wp_get_post_revisions( $post = 0, $args = null ) {
  *     @type int $latest_id The latest revision post ID or 0 if no revisions exist.
  *     @type int $count     The total count of revisions for the given post.
  * }
+ *
+ * @phpstan-return array{ latest_id: int, count: int<0, max> }|WP_Error
  */
 function wp_get_latest_revision_id_and_total_count( $post = 0 ) {
 	$post = get_post( $post );
diff --git a/wp-includes/version.php b/wp-includes/version.php
index eb651d0c42..b020f0e145 100644
--- a/wp-includes/version.php
+++ b/wp-includes/version.php
@@ -16,7 +16,7 @@
  *
  * @global string $wp_version
  */
-$wp_version = '7.2-beta4-65368';
+$wp_version = '7.2-beta4-65369';

 /**
  * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.