Commit 67e770f4eb for wordpress.org

commit 67e770f4eb381da514a8790834e5de27e7c70bea
Author: gziolo <gziolo@git.wordpress.org>
Date:   Tue Oct 20 10:08:53 2026 +0000

    JSON Schema: Prepare default values according to their schemas

    Use wp_prepare_json_value_for_client() to normalize existing schema defaults, preserving empty objects within nested values and resolving matching anyOf and oneOf branches. Defaults remain arrays when a matching schema allows arrays.

    Add regression coverage for nested defaults and clarify the schema and value preparation PHPDoc.

    Follow-up to [64592], [64315].
    Developed in https://github.com/WordPress/wordpress-develop/pull/14542.

    Fix #66267.



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


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

diff --git a/wp-includes/json-schema.php b/wp-includes/json-schema.php
index 5b613ae46a..74206bfcf3 100644
--- a/wp-includes/json-schema.php
+++ b/wp-includes/json-schema.php
@@ -71,13 +71,12 @@ function wp_get_json_schema_allowed_keywords( string $schema_profile = 'rest-api
 /**
  * Prepares a JSON Schema for clients.
  *
- * Use this before exposing a schema outside of WordPress's server-side
- * validation, for example in REST responses, Ability metadata, or AI provider
- * requests. The prepared schema uses forms that JSON Schema draft-04 clients
- * can understand.
+ * Converts WordPress-specific schema conventions and removes unsupported
+ * keywords so clients can interpret the schema using the selected profile.
  *
- * WordPress-internal schema conveniences are converted or removed only where
- * needed to keep the exposed schema valid for the selected profile.
+ * Applies the same preparation to nested schemas. Existing default values
+ * are normalized according to their schemas so empty objects encode as `{}`
+ * rather than `[]`, including within nested default values.
  *
  * @since 7.1.0
  *
@@ -94,17 +93,130 @@ function wp_prepare_json_schema_for_client( array $schema, string $schema_profil
 }

 /**
- * Prepares a JSON value for clients according to its schema.
+ * Prepares a JSON Schema for clients using a given keyword lookup.
+ *
+ * Normalizes schema conventions and default values throughout nested schemas
+ * while retaining only the allowed keywords.
+ *
+ * @since 7.1.0
+ * @access private
+ *
+ * @param array<string, mixed> $schema           The schema array.
+ * @param array<string, true>  $allowed_keywords Lookup map of allowed JSON Schema keywords.
+ * @return array<string, mixed> The prepared schema.
+ */
+function _wp_prepare_json_schema_for_client_with_allowed_keywords( array $schema, array $allowed_keywords ): array {
+	if ( array_key_exists( 'default', $schema ) ) {
+		$schema['default'] = wp_prepare_json_value_for_client( $schema['default'], $schema );
+	}
+
+	$schema = array_intersect_key( $schema, $allowed_keywords );
+
+	/*
+	 * Collect draft-03 per-property `required: true` flags into a draft-04
+	 * `required` array of property names on the parent object schema.
+	 *
+	 * This mirrors rest_validate_object_value_from_schema(), where a draft-04
+	 * `required` array takes precedence: when one is present, per-property
+	 * booleans are ignored during validation. They are therefore left out of
+	 * the array here as well (but still stripped from the output) so the
+	 * published schema describes exactly what gets enforced.
+	 */
+	if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) {
+		$has_required_array = isset( $schema['required'] ) && is_array( $schema['required'] );
+		$required           = array();
+		foreach ( $schema['properties'] as $property => &$property_schema ) {
+			if ( is_array( $property_schema ) && ! wp_is_numeric_array( $property_schema ) && isset( $property_schema['required'] ) && is_bool( $property_schema['required'] ) ) {
+				if ( ! $has_required_array && true === $property_schema['required'] ) {
+					$required[] = (string) $property;
+				}
+				unset( $property_schema['required'] );
+			}
+		}
+		unset( $property_schema );
+
+		/*
+		 * Property keys are unique, so the collected list needs no deduplication.
+		 * When a draft-04 array is already present, leave it untouched.
+		 */
+		if ( ! $has_required_array && count( $required ) > 0 ) {
+			$schema['required'] = $required;
+		}
+	}
+
+	/*
+	 * A boolean `required` outside of an object's property list has no draft-04
+	 * equivalent, so drop it rather than emit an invalid keyword.
+	 */
+	if ( isset( $schema['required'] ) && is_bool( $schema['required'] ) ) {
+		unset( $schema['required'] );
+	}
+
+	/*
+	 * Sub-schema maps: keys are user-defined, values are sub-schemas.
+	 * Note: 'dependencies' values can also be property-dependency arrays
+	 * (numeric arrays of strings) which are skipped via wp_is_numeric_array().
+	 */
+	foreach ( array( 'properties', 'patternProperties', 'definitions', 'dependencies' ) as $keyword ) {
+		if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) ) {
+			foreach ( $schema[ $keyword ] as $key => $child_schema ) {
+				if ( is_array( $child_schema ) && ! wp_is_numeric_array( $child_schema ) ) {
+					$schema[ $keyword ][ $key ] = _wp_prepare_json_schema_for_client_with_allowed_keywords( $child_schema, $allowed_keywords );
+				}
+			}
+		}
+	}
+
+	// Single sub-schema keywords.
+	foreach ( array( 'not', 'additionalProperties', 'additionalItems' ) as $keyword ) {
+		if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) && ! wp_is_numeric_array( $schema[ $keyword ] ) ) {
+			$schema[ $keyword ] = _wp_prepare_json_schema_for_client_with_allowed_keywords( $schema[ $keyword ], $allowed_keywords );
+		}
+	}
+
+	// Items: single schema or tuple array of schemas.
+	if ( isset( $schema['items'] ) && is_array( $schema['items'] ) ) {
+		if ( ! wp_is_numeric_array( $schema['items'] ) ) {
+			$schema['items'] = _wp_prepare_json_schema_for_client_with_allowed_keywords( $schema['items'], $allowed_keywords );
+		} else {
+			foreach ( $schema['items'] as $index => $item_schema ) {
+				if ( is_array( $item_schema ) && ! wp_is_numeric_array( $item_schema ) ) {
+					$schema['items'][ $index ] = _wp_prepare_json_schema_for_client_with_allowed_keywords( $item_schema, $allowed_keywords );
+				}
+			}
+		}
+	}
+
+	// Array-of-schemas keywords.
+	foreach ( array( 'anyOf', 'oneOf', 'allOf' ) as $keyword ) {
+		if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) ) {
+			foreach ( $schema[ $keyword ] as $index => $sub_schema ) {
+				if ( is_array( $sub_schema ) && ! wp_is_numeric_array( $sub_schema ) ) {
+					$schema[ $keyword ][ $index ] = _wp_prepare_json_schema_for_client_with_allowed_keywords( $sub_schema, $allowed_keywords );
+				}
+			}
+		}
+	}
+
+	return $schema;
+}
+
+/**
+ * Prepares a value for JSON encoding 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.
+ * Preserves the distinction between JSON objects and arrays by converting
+ * empty PHP arrays to objects where the schema identifies them as objects.
+ * Nested properties and items are prepared according to their schemas.
+ * Empty arrays remain arrays when a matching schema allows them.
+ *
+ * Prepares the representation of existing values without inserting defaults
+ * or performing full JSON Schema validation.
  *
  * @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.
+ * @return mixed The value prepared for JSON encoding.
  */
 function wp_prepare_json_value_for_client( $value, array $schema ) {
 	if ( $value instanceof JsonSerializable ) {
@@ -203,113 +315,6 @@ function wp_prepare_json_value_for_client( $value, array $schema ) {
 	return $value;
 }

-/**
- * Prepares a JSON Schema for clients using a given keyword lookup.
- *
- * @since 7.1.0
- * @access private
- *
- * @param array<string, mixed> $schema           The schema array.
- * @param array<string, true>  $allowed_keywords Lookup map of allowed JSON Schema keywords.
- * @return array<string, mixed> The prepared schema.
- */
-function _wp_prepare_json_schema_for_client_with_allowed_keywords( array $schema, array $allowed_keywords ): array {
-	$types = isset( $schema['type'] ) ? (array) $schema['type'] : array();
-	if ( isset( $schema['default'] ) && array() === $schema['default'] && in_array( 'object', $types, true ) && ! in_array( 'array', $types, true ) ) {
-		$schema['default'] = new stdClass();
-	}
-
-	$schema = array_intersect_key( $schema, $allowed_keywords );
-
-	/*
-	 * Collect draft-03 per-property `required: true` flags into a draft-04
-	 * `required` array of property names on the parent object schema.
-	 *
-	 * This mirrors rest_validate_object_value_from_schema(), where a draft-04
-	 * `required` array takes precedence: when one is present, per-property
-	 * booleans are ignored during validation. They are therefore left out of
-	 * the array here as well (but still stripped from the output) so the
-	 * published schema describes exactly what gets enforced.
-	 */
-	if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) {
-		$has_required_array = isset( $schema['required'] ) && is_array( $schema['required'] );
-		$required           = array();
-		foreach ( $schema['properties'] as $property => &$property_schema ) {
-			if ( is_array( $property_schema ) && ! wp_is_numeric_array( $property_schema ) && isset( $property_schema['required'] ) && is_bool( $property_schema['required'] ) ) {
-				if ( ! $has_required_array && true === $property_schema['required'] ) {
-					$required[] = (string) $property;
-				}
-				unset( $property_schema['required'] );
-			}
-		}
-		unset( $property_schema );
-
-		/*
-		 * Property keys are unique, so the collected list needs no deduplication.
-		 * When a draft-04 array is already present, leave it untouched.
-		 */
-		if ( ! $has_required_array && count( $required ) > 0 ) {
-			$schema['required'] = $required;
-		}
-	}
-
-	/*
-	 * A boolean `required` outside of an object's property list has no draft-04
-	 * equivalent, so drop it rather than emit an invalid keyword.
-	 */
-	if ( isset( $schema['required'] ) && is_bool( $schema['required'] ) ) {
-		unset( $schema['required'] );
-	}
-
-	/*
-	 * Sub-schema maps: keys are user-defined, values are sub-schemas.
-	 * Note: 'dependencies' values can also be property-dependency arrays
-	 * (numeric arrays of strings) which are skipped via wp_is_numeric_array().
-	 */
-	foreach ( array( 'properties', 'patternProperties', 'definitions', 'dependencies' ) as $keyword ) {
-		if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) ) {
-			foreach ( $schema[ $keyword ] as $key => $child_schema ) {
-				if ( is_array( $child_schema ) && ! wp_is_numeric_array( $child_schema ) ) {
-					$schema[ $keyword ][ $key ] = _wp_prepare_json_schema_for_client_with_allowed_keywords( $child_schema, $allowed_keywords );
-				}
-			}
-		}
-	}
-
-	// Single sub-schema keywords.
-	foreach ( array( 'not', 'additionalProperties', 'additionalItems' ) as $keyword ) {
-		if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) && ! wp_is_numeric_array( $schema[ $keyword ] ) ) {
-			$schema[ $keyword ] = _wp_prepare_json_schema_for_client_with_allowed_keywords( $schema[ $keyword ], $allowed_keywords );
-		}
-	}
-
-	// Items: single schema or tuple array of schemas.
-	if ( isset( $schema['items'] ) && is_array( $schema['items'] ) ) {
-		if ( ! wp_is_numeric_array( $schema['items'] ) ) {
-			$schema['items'] = _wp_prepare_json_schema_for_client_with_allowed_keywords( $schema['items'], $allowed_keywords );
-		} else {
-			foreach ( $schema['items'] as $index => $item_schema ) {
-				if ( is_array( $item_schema ) && ! wp_is_numeric_array( $item_schema ) ) {
-					$schema['items'][ $index ] = _wp_prepare_json_schema_for_client_with_allowed_keywords( $item_schema, $allowed_keywords );
-				}
-			}
-		}
-	}
-
-	// Array-of-schemas keywords.
-	foreach ( array( 'anyOf', 'oneOf', 'allOf' ) as $keyword ) {
-		if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) ) {
-			foreach ( $schema[ $keyword ] as $index => $sub_schema ) {
-				if ( is_array( $sub_schema ) && ! wp_is_numeric_array( $sub_schema ) ) {
-					$schema[ $keyword ][ $index ] = _wp_prepare_json_schema_for_client_with_allowed_keywords( $sub_schema, $allowed_keywords );
-				}
-			}
-		}
-	}
-
-	return $schema;
-}
-
 /**
  * Determines whether a value matches a JSON Schema through a given type.
  *
diff --git a/wp-includes/version.php b/wp-includes/version.php
index b6a87b7295..5d977dbe38 100644
--- a/wp-includes/version.php
+++ b/wp-includes/version.php
@@ -16,7 +16,7 @@
  *
  * @global string $wp_version
  */
-$wp_version = '7.2-alpha-64593';
+$wp_version = '7.2-alpha-64594';

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