Commit f56906cf5d for wordpress.org

commit f56906cf5db83357acca2d456d7d05ca36726548
Author: gziolo <gziolo@git.wordpress.org>
Date:   Tue Oct 20 09:10:57 2026 +0000

    Abilities API: Preserve empty objects in JSON results

    Empty PHP arrays representing objects are incorrectly encoded as []
    instead of {}. Add wp_prepare_json_value_for_client() to prepare ability
    results according to their output schemas before JSON encoding.

    Apply it to AI client results and associative REST results, preserving
    compatibility with _fields and _embed processing.

    Props sukhendu2002, gziolo.
    See #66267.


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


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

diff --git a/wp-includes/ai-client/class-wp-ai-client-ability-function-resolver.php b/wp-includes/ai-client/class-wp-ai-client-ability-function-resolver.php
index 596656d36f..3c2bceed80 100644
--- a/wp-includes/ai-client/class-wp-ai-client-ability-function-resolver.php
+++ b/wp-includes/ai-client/class-wp-ai-client-ability-function-resolver.php
@@ -148,6 +148,8 @@ class WP_AI_Client_Ability_Function_Resolver {
 			);
 		}

+		$result = wp_prepare_json_value_for_client( $result, $ability->get_output_schema() );
+
 		return new FunctionResponse(
 			$function_id,
 			$function_name,
diff --git a/wp-includes/json-schema.php b/wp-includes/json-schema.php
index bec3656f66..5b613ae46a 100644
--- a/wp-includes/json-schema.php
+++ b/wp-includes/json-schema.php
@@ -93,6 +93,116 @@ function wp_prepare_json_schema_for_client( array $schema, string $schema_profil
 	return _wp_prepare_json_schema_for_client_with_allowed_keywords( $schema, $allowed_keywords );
 }

+/**
+ * Prepares a JSON value for clients according to its schema.
+ *
+ * Empty PHP arrays are converted to objects when the schema allows objects but
+ * not arrays. Nested values are prepared using matching anyOf or oneOf branches
+ * and their property or item schemas.
+ *
+ * @since 7.2.0
+ *
+ * @param mixed                $value  The value to prepare.
+ * @param array<string, mixed> $schema The schema describing the value.
+ * @return mixed The prepared value.
+ */
+function wp_prepare_json_value_for_client( $value, array $schema ) {
+	if ( $value instanceof JsonSerializable ) {
+		$value = $value->jsonSerialize();
+	}
+
+	if ( ! is_array( $value ) && ! $value instanceof stdClass ) {
+		return $value;
+	}
+
+	foreach ( array( 'anyOf', 'oneOf' ) as $keyword ) {
+		if ( ! isset( $schema[ $keyword ] ) ) {
+			continue;
+		}
+
+		$matching_schema = null;
+
+		/*
+		 * Prefer an array schema for PHP lists. An empty PHP array also matches an
+		 * object schema, but should remain [] when an array schema accepts it.
+		 */
+		if ( is_array( $value ) && array_is_list( $value ) ) {
+			foreach ( $schema[ $keyword ] as $candidate_schema ) {
+				if ( ! isset( $candidate_schema['type'] ) && isset( $schema['type'] ) ) {
+					$candidate_schema['type'] = $schema['type'];
+				}
+
+				if ( _wp_json_schema_matches_type( $value, $candidate_schema, 'array' ) ) {
+					$matching_schema = $candidate_schema;
+					break;
+				}
+			}
+		}
+
+		// Like rest_filter_response_by_context(), use the first matching oneOf branch.
+		if ( null === $matching_schema ) {
+			$matching_schema = 'anyOf' === $keyword
+				? rest_find_any_matching_schema( $value, $schema, '' )
+				: rest_find_one_matching_schema( $value, $schema, '', true );
+		}
+
+		if ( is_wp_error( $matching_schema ) ) {
+			continue;
+		}
+
+		if ( ! isset( $schema['type'] ) && isset( $matching_schema['type'] ) ) {
+			$schema['type'] = $matching_schema['type'];
+		}
+
+		$value = wp_prepare_json_value_for_client( $value, $matching_schema );
+	}
+
+	$is_array_value = is_array( $value );
+	$types          = isset( $schema['type'] ) ? (array) $schema['type'] : array();
+	$allows_object  = in_array( 'object', $types, true );
+	$allows_array   = in_array( 'array', $types, true );
+
+	if ( $is_array_value && empty( $value ) && $allows_object && ! $allows_array ) {
+		return (object) $value;
+	}
+
+	$is_object_value = $value instanceof stdClass || ( $is_array_value && $allows_object && ( ! $allows_array || ! array_is_list( $value ) ) );
+	if ( $allows_object && $is_object_value ) {
+		if ( $value instanceof stdClass ) {
+			$value = clone $value;
+		}
+
+		foreach ( $value as $property => $property_value ) {
+			$property_schema = null;
+			if ( isset( $schema['properties'][ $property ] ) && is_array( $schema['properties'][ $property ] ) ) {
+				$property_schema = $schema['properties'][ $property ];
+			} else {
+				$property_schema = rest_find_matching_pattern_property_schema( $property, $schema );
+				if ( null === $property_schema && isset( $schema['additionalProperties'] ) && is_array( $schema['additionalProperties'] ) ) {
+					$property_schema = $schema['additionalProperties'];
+				}
+			}
+
+			if ( null === $property_schema ) {
+				continue;
+			}
+
+			$property_value = wp_prepare_json_value_for_client( $property_value, $property_schema );
+			if ( is_array( $value ) ) {
+				$value[ $property ] = $property_value;
+			} else {
+				$value->$property = $property_value;
+			}
+		}
+	} elseif ( $allows_array && $is_array_value && isset( $schema['items'] ) && is_array( $schema['items'] ) ) {
+		foreach ( $value as $index => $item ) {
+			$value[ $index ] = wp_prepare_json_value_for_client( $item, $schema['items'] );
+		}
+	}
+
+	return $value;
+}
+
 /**
  * Prepares a JSON Schema for clients using a given keyword lookup.
  *
@@ -199,3 +309,56 @@ function _wp_prepare_json_schema_for_client_with_allowed_keywords( array $schema

 	return $schema;
 }
+
+/**
+ * Determines whether a value matches a JSON Schema through a given type.
+ *
+ * Combining schemas must have a matching branch that allows the requested type.
+ * Finding the type in one branch and validating the value against another branch
+ * is not sufficient.
+ *
+ * @since 7.2.0
+ * @access private
+ *
+ * @param mixed                $value  The value to validate.
+ * @param array<string, mixed> $schema The schema to inspect.
+ * @param string               $type   The type to match.
+ * @return bool Whether the value matches the schema through the requested type.
+ */
+function _wp_json_schema_matches_type( $value, array $schema, string $type ): bool {
+	if ( isset( $schema['type'] ) && ! in_array( $type, (array) $schema['type'], true ) ) {
+		return false;
+	}
+
+	foreach ( array( 'anyOf', 'oneOf' ) as $keyword ) {
+		if ( ! isset( $schema[ $keyword ] ) ) {
+			continue;
+		}
+
+		foreach ( $schema[ $keyword ] as $sub_schema ) {
+			if ( ! is_array( $sub_schema ) ) {
+				continue;
+			}
+
+			// Match rest_find_any_matching_schema(), which applies the parent type to untyped branches.
+			if ( ! isset( $sub_schema['type'] ) && isset( $schema['type'] ) ) {
+				$sub_schema['type'] = $schema['type'];
+			}
+
+			if ( _wp_json_schema_matches_type( $value, $sub_schema, $type ) ) {
+				continue 2;
+			}
+		}
+
+		return false;
+	}
+
+	if ( ! isset( $schema['type'] ) && ! isset( $schema['anyOf'] ) && ! isset( $schema['oneOf'] ) ) {
+		return false;
+	}
+
+	// Explicit and inferred types must both validate through the requested type only.
+	$schema['type'] = $type;
+
+	return ! is_wp_error( rest_validate_value_from_schema( $value, $schema ) );
+}
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php
index 9ba76fc240..12f6a75083 100644
--- a/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php
@@ -95,6 +95,20 @@ class WP_REST_Abilities_V1_Run_Controller extends WP_REST_Controller {
 			return $result;
 		}

+		/*
+		 * Preserve empty objects in nested properties when encoding an associative
+		 * result as JSON. Preparation can convert empty PHP arrays to stdClass.
+		 *
+		 * The REST server's `_fields` and `_embed` processing expects arrays at the
+		 * response root and for numeric collection items. Converting those values
+		 * to objects would cause type errors, so skip root empty arrays and numeric
+		 * collections until that processing supports objects. An empty object result
+		 * therefore still encodes as [], and a list containing one encodes as [[]].
+		 */
+		if ( is_array( $result ) && ! wp_is_numeric_array( $result ) ) {
+			$result = wp_prepare_json_value_for_client( $result, $ability->get_output_schema() );
+		}
+
 		return rest_ensure_response( $result );
 	}

diff --git a/wp-includes/version.php b/wp-includes/version.php
index 86ff5de376..cd076fb42c 100644
--- a/wp-includes/version.php
+++ b/wp-includes/version.php
@@ -16,7 +16,7 @@
  *
  * @global string $wp_version
  */
-$wp_version = '7.2-alpha-64591';
+$wp_version = '7.2-alpha-64592';

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