The Relation Diff
The Relation Diff: round 8's semver advisor classified declaration changes but had to shrug at rules - lambdas can't be compared. Now relations are data, so the RULES diff too: a tightened limit is breaking, a loosened one compatible, a new rule breaking, a removed one compatible, and a changed relation TYPE is a different law entirely. The last opaque corner of the contract joins semver.
Developer Experience
Round 10
Piotr Solnica
exit 0
bundle exec ruby examples/relation_diff.rb
a real captured run
RELATION DIFF: quote_shipping rules, v1 -> v2 BREAKING rule :fits limit tightened 6000 -> 4000 - previously legal calls rejected BREAKING rule :customs now also demands incoterm - callers satisfying v1 fail v2 BREAKING rule :one_auth changed LAW: mutually_exclusive -> requires - not an edit, a replacement BREAKING rule :speedy added (sum_lte) - a new law existing callers never agreed to OPAQUE rule :audited is a lambda in both versions - the diff cannot see inside; treat as breaking unless proven COMPATIBLE rule :legacy removed - every v1-legal call remains legal verdict: 4 breaking rule change(s) -> major version bump. round 8's advisor ended every report with a shrug: "3 breaking changes IN THE DECLARATIONS" - rules were lambdas, invisible to any diff. relations closed that: the limit, the fields, and the law itself are data, so tightening 6000->4000 is as diffable as a max: change. note the one law-change row: same rule id, same fields, different relation - that's not an edit, it's a new contract wearing an old name, and the diff says so. the lambda rule still gets the shrug (OPAQUE, presumed breaking) - which is now a choice you make per rule, not a ceiling on the tool.
source
# frozen_string_literal: true # The Relation Diff: round 8's semver advisor classified declaration # changes but had to shrug at rules - lambdas can't be compared. Now # relations are data, so the RULES diff too: a tightened limit is # breaking, a loosened one compatible, a new rule breaking, a removed # one compatible, and a changed relation TYPE is a different law # entirely. The last opaque corner of the contract joins semver. # # bundle exec ruby examples/relation_diff.rb # # Runs offline; v2 contains one of every interesting rule change. require class="s">"bundler/setup" require class="s">"agentic" V1_RULES = { fits: {relation: class="y">:sum_lte, fields: [class="y">:weight, class="y">:volume], limit: 6_000}, customs: {relation: class="y">:requires, fields: [class="y">:express, class="y">:customs_code]}, one_auth: {relation: class="y">:mutually_exclusive, fields: [class="y">:api_key, class="y">:oauth_token]}, legacy: {relation: class="y">:requires, fields: [class="y">:fragile, class="y">:packaging]}, audited: {message: class="s">"audited accounts only", fields: [class="y">:account], check: ->(i) { true }} }.freeze V2_RULES = { fits: {relation: class="y">:sum_lte, fields: [class="y">:weight, class="y">:volume], limit: 4_000}, # tightened customs: {relation: class="y">:requires, fields: [class="y">:express, class="y">:customs_code, class="y">:incoterm]}, # widened scope one_auth: {relation: class="y">:requires, fields: [class="y">:api_key, class="y">:oauth_token]}, # DIFFERENT LAW speedy: {relation: class="y">:sum_lte, fields: [class="y">:weight], limit: 100}, # new rule # legacy: removed audited: {message: class="s">"audited accounts only", fields: [class="y">:account], check: ->(i) { i[class="y">:account] != class="s">"test" }} }.freeze def classify(v1, v2) changes = [] (v1.keys & v2.keys).each do |id| old_rule, new_rule = v1[id], v2[id] if old_rule[class="y">:relation] && new_rule[class="y">:relation] if old_rule[class="y">:relation] != new_rule[class="y">:relation] changes << [class="y">:breaking, class="s">"rule :#{id} changed LAW: #{old_rule[class="y">:relation]} -> #{new_rule[class="y">:relation]} - not an edit, a replacement"] next end case new_rule[class="y">:relation] when class="y">:sum_lte changes << [class="y">:breaking, class="s">"rule :#{id} limit tightened #{old_rule[class="y">:limit]} -> #{new_rule[class="y">:limit]} - previously legal calls rejected"] if new_rule[class="y">:limit] < old_rule[class="y">:limit] changes << [class="y">:compatible, class="s">"rule :#{id} limit loosened #{old_rule[class="y">:limit]} -> #{new_rule[class="y">:limit]}"] if new_rule[class="y">:limit] > old_rule[class="y">:limit] when class="y">:requires added = new_rule[class="y">:fields] - old_rule[class="y">:fields] removed = old_rule[class="y">:fields] - new_rule[class="y">:fields] changes << [class="y">:breaking, class="s">"rule :#{id} now also demands #{added.join(", class="s">")} - callers satisfying v1 fail v2"] if added.any? changes << [class="y">:compatible, class="s">"rule :#{id} no longer demands #{removed.join(", class="s">")}"] if removed.any? && added.none? when class="y">:mutually_exclusive changes << [class="y">:breaking, class="s">"rule :#{id} exclusion widened to #{new_rule[class="y">:fields].join(", class="s">")}"] if (new_rule[class="y">:fields] - old_rule[class="y">:fields]).any? end elsif old_rule[class="y">:relation].nil? && new_rule[class="y">:relation].nil? changes << [class="y">:opaque, class="s">"rule :#{id} is a lambda in both versions - the diff cannot see inside; treat as breaking unless proven"] end end (v2.keys - v1.keys).each do |id| changes << [class="y">:breaking, class="s">"rule :#{id} added (#{V2_RULES[id][class="y">:relation]}) - a new law existing callers never agreed to"] end (v1.keys - v2.keys).each do |id| changes << [class="y">:compatible, class="s">"rule :#{id} removed - every v1-legal call remains legal"] end changes end changes = classify(V1_RULES, V2_RULES) breaking = changes.count { |kind, _| kind == class="y">:breaking } puts class="s">"RELATION DIFF: quote_shipping rules, v1 -> v2" puts order = {breaking: 0, opaque: 1, compatible: 2} changes.sort_by { |kind, _| order[kind] }.each do |kind, message| puts format(class="s">" %-10s %s", kind.to_s.upcase, message) end puts puts class="s">" verdict: #{breaking} breaking rule change(s) -> major version bump." puts puts class="s">" round 8's advisor ended every report with a shrug: \"3 breaking" puts class="s">" changes IN THE DECLARATIONS\" - rules were lambdas, invisible to" puts class="s">" any diff. relations closed that: the limit, the fields, and the" puts class="s">" law itself are data, so tightening 6000->4000 is as diffable as" puts class="s">" a max: change. note the one law-change row: same rule id, same" puts class="s">" fields, different relation - that's not an edit, it's a new" puts class="s">" contract wearing an old name, and the diff says so. the lambda" puts class="s">" rule still gets the shrug (OPAQUE, presumed breaking) - which is" puts class="s">" now a choice you make per rule, not a ceiling on the tool."