The Contract Semver Advisor
The Contract Semver Advisor: two versions of a capability's contract, every change classified as breaking or compatible - FROM THE CALLER'S and THE CONSUMER'S seats, which disagree about what "breaking" means. The verdict is the version bump you owe your users.
Developer Experience
Round 8
Piotr Solnica
exit 0
bundle exec ruby examples/contract_semver.rb
a real captured run
CONTRACT SEMVER ADVISOR: quote_shipping v1.4.0 -> v? BREAKING input :weight max tightened 10000 -> 5000 - previously legal calls now rejected BREAKING input :customs_code added as REQUIRED - existing callers don't send it BREAKING output :carrier removed - consumers reading it get nil COMPATIBLE input :mode enum widened (road) COMPATIBLE output :eta_days added (consumers ignore unknown keys) verdict: 3 breaking change(s) -> ship as v2.0.0 note the asymmetry: INPUTS break when tightened (callers rejected), OUTPUTS break when narrowed (consumers starved). the same edit is breaking on one side and compatible on the other - semver for contracts is a two-seat calculation, and both seats are customers.
source
# frozen_string_literal: true # The Contract Semver Advisor: two versions of a capability's contract, # every change classified as breaking or compatible - FROM THE CALLER'S # and THE CONSUMER'S seats, which disagree about what "breaking" means. # The verdict is the version bump you owe your users. # # bundle exec ruby examples/contract_semver.rb # # Runs offline; v2 contains one of every interesting change. require class="s">"bundler/setup" require class="s">"agentic" V1 = Agentic:class="y">:CapabilitySpecification.new( name: class="s">"quote_shipping", description: class="s">"Quote a shipment", version: class="s">"1.4.0", inputs: { mode: {type: class="s">"string", required: true, enum: %w[air sea]}, weight: {type: class="s">"number", required: true, min: 1, max: 10_000}, notes: {type: class="s">"string"} }, outputs: { price_cents: {type: class="s">"number", required: true}, carrier: {type: class="s">"string", required: true} } ) V2 = Agentic:class="y">:CapabilitySpecification.new( name: class="s">"quote_shipping", description: class="s">"Quote a shipment", version: class="s">"?", inputs: { mode: {type: class="s">"string", required: true, enum: %w[air sea road]}, # enum widened weight: {type: class="s">"number", required: true, min: 1, max: 5_000}, # max tightened customs_code: {type: class="s">"string", required: true}, # new required input notes: {type: class="s">"string"} }, outputs: { price_cents: {type: class="s">"number", required: true}, eta_days: {type: class="s">"number", required: true} # new output # carrier: removed } ) def classify_inputs(v1, v2) changes = [] v2.inputs.each do |key, decl| old = v1.inputs[key] if old.nil? changes << [decl[class="y">:required] ? class="y">:breaking : class="y">:compatible, class="s">"input :#{key} added#{decl[class="y">:required] ? " as REQUIRED - existing callers don't send itclass="s">" : " (optional)class="s">"}"] next end changes << [class="y">:breaking, class="s">"input :#{key} type changed #{old[class="y">:type]} -> #{decl[class="y">:type]}"] if old[class="y">:type] != decl[class="y">:type] changes << [class="y">:breaking, class="s">"input :#{key} became required"] if decl[class="y">:required] && !old[class="y">:required] if old[class="y">:enum] && decl[class="y">:enum] changes << [class="y">:compatible, class="s">"input :#{key} enum widened (#{(decl[class="y">:enum] - old[class="y">:enum]).join(", class="s">")})"] if (old[class="y">:enum] - decl[class="y">:enum]).empty? && decl[class="y">:enum] != old[class="y">:enum] changes << [class="y">:breaking, class="s">"input :#{key} enum narrowed (removed #{(old[class="y">:enum] - decl[class="y">:enum]).join(", class="s">")})"] unless (old[class="y">:enum] - decl[class="y">:enum]).empty? end changes << [class="y">:breaking, class="s">"input :#{key} max tightened #{old[class="y">:max]} -> #{decl[class="y">:max]} - previously legal calls now rejected"] if old[class="y">:max] && decl[class="y">:max] && decl[class="y">:max] < old[class="y">:max] changes << [class="y">:breaking, class="s">"input :#{key} min tightened #{old[class="y">:min]} -> #{decl[class="y">:min]}"] if old[class="y">:min] && decl[class="y">:min] && decl[class="y">:min] > old[class="y">:min] end (v1.inputs.keys - v2.inputs.keys).each do |key| changes << [class="y">:compatible, class="s">"input :#{key} no longer declared (extra keys were always permitted)"] end changes end def classify_outputs(v1, v2) changes = [] (v1.outputs.keys - v2.outputs.keys).each do |key| changes << [class="y">:breaking, class="s">"output :#{key} removed - consumers reading it get nil"] end (v2.outputs.keys - v1.outputs.keys).each do |key| changes << [class="y">:compatible, class="s">"output :#{key} added (consumers ignore unknown keys)"] end changes end changes = classify_inputs(V1, V2) + classify_outputs(V1, V2) breaking = changes.count { |kind, _| kind == class="y">:breaking } puts class="s">"CONTRACT SEMVER ADVISOR: #{V1.name} v#{V1.version} -> v?" puts changes.sort_by { |kind, _| (kind == class="y">:breaking) ? 0 : 1 }.each do |kind, message| puts format(class="s">" %-10s %s", kind.to_s.upcase, message) end major, minor, = V1.version.split(class="s">".").map(&class="y">:to_i) suggested = breaking.positive? ? class="s">"#{major + 1}.0.0" : class="s">"#{major}.#{minor + 1}.0" puts puts class="s">" verdict: #{breaking} breaking change(s) -> ship as v#{suggested}" puts puts class="s">" note the asymmetry: INPUTS break when tightened (callers rejected)," puts class="s">" OUTPUTS break when narrowed (consumers starved). the same edit is" puts class="s">" breaking on one side and compatible on the other - semver for" puts class="s">" contracts is a two-seat calculation, and both seats are customers."