Module: McpDiff::Core::SchemaDiff
- Defined in:
- lib/mcp_diff/core/schema_diff.rb
Overview
Pragmatic structural diff of a JSON-Schema object schema (D9). Recognizes
type / enum / required / properties / description / default; anything it
cannot prove compatible surfaces as an *-opaque change so the incompatible
catch-all rules fire loudly instead of silently passing. Port of
core/src/schema-diff.ts. SchemaChange hashes use symbol keys (internal).
Constant Summary collapse
- PARAM_HANDLED =
%w[type enum description default title].freeze
- TOP_HANDLED =
%w[properties required description title $schema].freeze
Class Method Summary collapse
- .as_obj(value) ⇒ Object
- .diff_object_schema(before_raw, after_raw) ⇒ Object
- .diff_param(param, before_raw, after_raw, changes) ⇒ Object
- .diff_param_enum(param, before, after, changes) ⇒ Object
- .diff_param_type(param, before, after, changes) ⇒ Object
- .enum_values(schema) ⇒ Object
- .format_values(values) ⇒ Object
- .required_set(schema) ⇒ Object
- .strip_keys(obj, keys) ⇒ Object
- .subset?(a, b) ⇒ Boolean
- .type_label(set) ⇒ Object
-
.type_set(schema) ⇒ Object
typeas a set of names; nil = unspecified (accepts anything).
Class Method Details
.as_obj(value) ⇒ Object
20 21 22 |
# File 'lib/mcp_diff/core/schema_diff.rb', line 20 def as_obj(value) value.is_a?(Hash) ? value : nil end |
.diff_object_schema(before_raw, after_raw) ⇒ Object
108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 |
# File 'lib/mcp_diff/core/schema_diff.rb', line 108 def diff_object_schema(before_raw, after_raw) return [] if Canonical.deep_equal(before_raw, after_raw) before = as_obj(before_raw) after = as_obj(after_raw) return [{ kind: "schema-changed-opaque" }] unless before && after changes = [] b_props = as_obj(before["properties"]) || {} a_props = as_obj(after["properties"]) || {} b_req = required_set(before) a_req = required_set(after) b_props.keys.sort.each { |p| changes << { kind: "param-removed", param: p } unless a_props.key?(p) } a_props.keys.sort.each do |p| changes << { kind: "param-added", param: p, required: a_req.include?(p) } unless b_props.key?(p) end a_req.to_a.sort.each do |p| changes << { kind: "required-added", param: p, existed_before: b_props.key?(p) } unless b_req.include?(p) end b_req.to_a.sort.each do |p| changes << { kind: "required-removed", param: p } if !a_req.include?(p) && a_props.key?(p) end b_props.keys.sort.each { |p| diff_param(p, b_props[p], a_props[p], changes) if a_props.key?(p) } unless Canonical.deep_equal(strip_keys(before, TOP_HANDLED), strip_keys(after, TOP_HANDLED)) changes << { kind: "schema-changed-opaque" } end changes end |
.diff_param(param, before_raw, after_raw, changes) ⇒ Object
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
# File 'lib/mcp_diff/core/schema_diff.rb', line 55 def diff_param(param, before_raw, after_raw, changes) return if Canonical.deep_equal(before_raw, after_raw) before = as_obj(before_raw) after = as_obj(after_raw) unless before && after changes << { kind: "param-changed-opaque", param: param } return end diff_param_type(param, before, after, changes) diff_param_enum(param, before, after, changes) changes << { kind: "description-changed", param: param } if before["description"] != after["description"] changes << { kind: "default-changed", param: param } unless Canonical.deep_equal(before["default"], after["default"]) unless Canonical.deep_equal(strip_keys(before, PARAM_HANDLED), strip_keys(after, PARAM_HANDLED)) changes << { kind: "param-changed-opaque", param: param } end end |
.diff_param_enum(param, before, after, changes) ⇒ Object
93 94 95 96 97 98 99 100 101 102 103 104 105 106 |
# File 'lib/mcp_diff/core/schema_diff.rb', line 93 def diff_param_enum(param, before, after, changes) b_e = enum_values(before) a_e = enum_values(after) if !b_e.nil? && !a_e.nil? removed = b_e.reject { |v| a_e.any? { |w| Canonical.deep_equal(v, w) } } added = a_e.reject { |v| b_e.any? { |w| Canonical.deep_equal(v, w) } } changes << { kind: "enum-value-removed", param: param, values: removed } unless removed.empty? changes << { kind: "enum-value-added", param: param, values: added } unless added.empty? elsif !b_e.nil? && a_e.nil? changes << { kind: "enum-constraint-removed", param: param } elsif b_e.nil? && !a_e.nil? changes << { kind: "enum-constraint-added", param: param } end end |
.diff_param_type(param, before, after, changes) ⇒ Object
76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 |
# File 'lib/mcp_diff/core/schema_diff.rb', line 76 def diff_param_type(param, before, after, changes) b_t = type_set(before) a_t = type_set(after) types_equal = (b_t.nil? && a_t.nil?) || (!b_t.nil? && !a_t.nil? && subset?(b_t, a_t) && subset?(a_t, b_t)) return if types_equal label = { before: type_label(b_t), after: type_label(a_t) } if b_t.nil? || (!a_t.nil? && subset?(a_t, b_t)) changes << { kind: "type-narrowed", param: param }.merge(label) elsif a_t.nil? || subset?(b_t, a_t) changes << { kind: "type-widened", param: param }.merge(label) else changes << { kind: "param-changed-opaque", param: param } end end |
.enum_values(schema) ⇒ Object
46 47 48 49 |
# File 'lib/mcp_diff/core/schema_diff.rb', line 46 def enum_values(schema) e = schema["enum"] e.is_a?(Array) ? e : nil end |
.format_values(values) ⇒ Object
140 141 142 |
# File 'lib/mcp_diff/core/schema_diff.rb', line 140 def format_values(values) values.map(&:to_json).join(", ") end |
.required_set(schema) ⇒ Object
24 25 26 27 |
# File 'lib/mcp_diff/core/schema_diff.rb', line 24 def required_set(schema) req = schema["required"] Set.new(req.is_a?(Array) ? req.select { |r| r.is_a?(String) } : []) end |
.strip_keys(obj, keys) ⇒ Object
51 52 53 |
# File 'lib/mcp_diff/core/schema_diff.rb', line 51 def strip_keys(obj, keys) obj.reject { |k, _| keys.include?(k) } end |
.subset?(a, b) ⇒ Boolean
42 43 44 |
# File 'lib/mcp_diff/core/schema_diff.rb', line 42 def subset?(a, b) a.all? { |x| b.include?(x) } end |
.type_label(set) ⇒ Object
38 39 40 |
# File 'lib/mcp_diff/core/schema_diff.rb', line 38 def type_label(set) set.nil? ? "(any)" : set.to_a.sort.join("|") end |
.type_set(schema) ⇒ Object
type as a set of names; nil = unspecified (accepts anything).
30 31 32 33 34 35 36 |
# File 'lib/mcp_diff/core/schema_diff.rb', line 30 def type_set(schema) t = schema["type"] return Set[t] if t.is_a?(String) return Set.new(t.select { |x| x.is_a?(String) }) if t.is_a?(Array) nil end |