@@ -985,6 +985,110 @@ def _diff_schema_details(
985985 )
986986 )
987987
988+ # Deep structural diff of NESTED object / array-of-object properties.
989+ # Previously _diff_schema_details only compared a component's DIRECT
990+ # properties (type/format/enum); a nested object property whose sub-fields
991+ # changed (e.g. a required field added deep inside) was reported as "no
992+ # change" -- the silent-green failure class this tool exists to catch.
993+ # Reuse the proven recursive inline-schema walker (conservative: treats all
994+ # required/presence changes as BREAKING, since a component may back a request
995+ # body). Only the nested sub-schemas are walked here, so this never
996+ # double-reports the component's own top-level properties already checked above.
997+ for prop_name in old_props :
998+ if prop_name not in new_props :
999+ continue
1000+ old_sub = old_props [prop_name ] or {}
1001+ new_sub = new_props [prop_name ] or {}
1002+ if not isinstance (old_sub , dict ) or not isinstance (new_sub , dict ):
1003+ continue
1004+ is_nested_object = (
1005+ (old_sub .get ("type" ) == "object" and new_sub .get ("type" ) == "object" )
1006+ or ("properties" in old_sub and "properties" in new_sub )
1007+ )
1008+ if is_nested_object :
1009+ _diff_inline_schema (
1010+ f"{ schema_path } .properties.{ prop_name } " ,
1011+ old_sub ,
1012+ new_sub ,
1013+ result ,
1014+ is_request = True ,
1015+ depth = 1 ,
1016+ )
1017+ continue
1018+ if old_sub .get ("type" ) == "array" and new_sub .get ("type" ) == "array" :
1019+ old_items = old_sub .get ("items" ) or {}
1020+ new_items = new_sub .get ("items" ) or {}
1021+ if isinstance (old_items , dict ) and isinstance (new_items , dict ) and (
1022+ (old_items .get ("type" ) == "object" and new_items .get ("type" ) == "object" )
1023+ or ("properties" in old_items and "properties" in new_items )
1024+ ):
1025+ _diff_inline_schema (
1026+ f"{ schema_path } .properties.{ prop_name } .items" ,
1027+ old_items ,
1028+ new_items ,
1029+ result ,
1030+ is_request = True ,
1031+ depth = 1 ,
1032+ )
1033+
1034+ # A property that is a $ref (or array of $ref) into another component was
1035+ # previously ignored entirely: retargeting or dropping the ref produced no
1036+ # change record. Flag ref-target changes at the property's OWN path. The
1037+ # referenced target's internals are already diffed by _diff_schemas at
1038+ # components.schemas.<Target>, so this never double-reports them -- it only
1039+ # surfaces that THIS property's reference changed.
1040+ for prop_name in set (old_props ) | set (new_props ):
1041+ _flag_schema_ref_prop (
1042+ schema_path , prop_name , old_props .get (prop_name ), new_props .get (prop_name ), result
1043+ )
1044+
1045+
1046+ def _flag_schema_ref_prop (
1047+ schema_path : str ,
1048+ prop_name : str ,
1049+ old_prop : Any ,
1050+ new_prop : Any ,
1051+ result : DiffResult ,
1052+ ) -> None :
1053+ """Flag a component property whose ``$ref`` target changed or was dropped.
1054+
1055+ Refs into ``components.schemas`` are intentionally NOT followed into the
1056+ target's internals (``_diff_schemas`` already diffs the target at its own
1057+ path); we only report that the reference here changed, which is independent
1058+ information not otherwise surfaced.
1059+ """
1060+
1061+ def ref_of (sub : Any ) -> Any :
1062+ if isinstance (sub , dict ):
1063+ return sub .get ("$ref" )
1064+ return None
1065+
1066+ old_ref = ref_of (old_prop )
1067+ new_ref = ref_of (new_prop )
1068+ # Array whose items are a $ref.
1069+ if old_ref is None and isinstance (old_prop , dict ) and old_prop .get ("type" ) == "array" :
1070+ old_ref = ref_of (old_prop .get ("items" ))
1071+ if new_ref is None and isinstance (new_prop , dict ) and new_prop .get ("type" ) == "array" :
1072+ new_ref = ref_of (new_prop .get ("items" ))
1073+
1074+ if old_ref is None and new_ref is None :
1075+ return
1076+
1077+ prop_path = f"{ schema_path } .properties.{ prop_name } "
1078+ if old_ref != new_ref :
1079+ result .changes .append (
1080+ Change (
1081+ kind = "schema_ref_changed" ,
1082+ severity = Severity .DANGEROUS ,
1083+ path = prop_path ,
1084+ description = (
1085+ f"$ref property '{ prop_name } ' target changed from '{ old_ref } ' to '{ new_ref } '"
1086+ ),
1087+ old_value = old_ref ,
1088+ new_value = new_ref ,
1089+ )
1090+ )
1091+
9881092
9891093def _diff_security_schemes (
9901094 old : dict [str , Any ],
0 commit comments