Contract Fixtures
Contract Fixtures: example payloads in docs rot the day the contract changes. So don't write them - DERIVE them. This generator reads a capability's declarations and produces a minimal fixture (required keys only) and a maximal one (everything), then a referee proves every generated fixture passes its own contract, and that a mutated fixture still fails. Docs that compile, effectively.
Developer Experience
Round 9
Piotr Solnica
exit 0
bundle exec ruby examples/contract_fixtures.rb
a real captured run
CONTRACT FIXTURES (derived from declarations, then proved)
quote_shipping v2.0.0:
minimal {"mode":"air","weight":2500} valid
maximal {"mode":"air","weight":2000,"volume":2000,"customs_code":"example-customs_code","express":true,"api_key":"example-api_key"} valid
mutant dropped :mode rejected, as it must be
classify_ticket v1.1.0:
minimal {"text":"example-text"} valid
maximal {"text":"example-text","urgency":5} valid
mutant dropped :text rejected, as it must be
every derived fixture passed its own contract, and every mutant
failed. paste these into your README - when the contract changes,
rerun and they change with it. handwritten examples are promises;
derived ones are consequences.
and the round-9 blind spot has closed for the declarable
majority: relation-typed rules (sum_lte, requires,
mutually_exclusive) are data, so the generator SATISFIED them -
scaled the weights under the limit, added what express required,
kept one credential of the exclusive pair - and the validator,
which now enforces relations too, countersigned the result.
lambdas remain for the exotic tail, and remain opaque.
source
# frozen_string_literal: true # Contract Fixtures: example payloads in docs rot the day the contract # changes. So don't write them - DERIVE them. This generator reads a # capability's declarations and produces a minimal fixture (required # keys only) and a maximal one (everything), then a referee proves # every generated fixture passes its own contract, and that a mutated # fixture still fails. Docs that compile, effectively. # # bundle exec ruby examples/contract_fixtures.rb # # Runs offline; exits 1 if the generator and validator disagree. require class="s">"bundler/setup" require class="s">"agentic" require class="s">"json" SPECS = [ Agentic:class="y">:CapabilitySpecification.new( name: class="s">"quote_shipping", description: class="s">"Quote a shipment", version: class="s">"2.0.0", inputs: { mode: {type: class="s">"string", required: true, enum: %w[air sea road]}, weight: {type: class="s">"number", required: true, min: 1, max: 5_000}, volume: {type: class="s">"number", min: 0, max: 5_000}, customs_code: {type: class="s">"string"}, express: {type: class="s">"boolean"}, api_key: {type: class="s">"string"}, oauth_token: {type: class="s">"string"} }, outputs: {price_cents: {type: class="s">"number", required: true}}, rules: { fits: {relation: class="y">:sum_lte, fields: [class="y">:weight, class="y">:volume], limit: 4_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]} } ), Agentic:class="y">:CapabilitySpecification.new( name: class="s">"classify_ticket", description: class="s">"Route a support ticket", version: class="s">"1.1.0", inputs: { text: {type: class="s">"string", required: true, non_empty: true}, urgency: {type: class="s">"number", min: 0, max: 10} }, outputs: {queue: {type: class="s">"string", required: true, enum: %w[billing tech general]}} ) ].freeze # One value per declaration, derived - never invented def value_for(key, decl) return decl[class="y">:enum].first if decl[class="y">:enum] case decl[class="y">:type] when class="s">"number" low = decl[class="y">:min] || 0 high = decl[class="y">:max] || 100 low + (high - low) / 2 when class="s">"boolean" then true else class="s">"example-#{key}" end end # Relation-typed rules are data, so the generator can SATISFY them # instead of hoping: scale sums under their limit, add what a present # trigger requires, keep only the first of an exclusive group def satisfy_relations(fixture, spec) spec.rules.each_value do |definition| next if definition.respond_to?(class="y">:call) || !definition[class="y">:relation] fields = definition[class="y">:fields] case definition[class="y">:relation] when class="y">:sum_lte given = fields.select { |f| fixture[f].is_a?(Numeric) } total = given.sum { |f| fixture[f] } if total > definition[class="y">:limit] given.each { |f| fixture[f] = definition[class="y">:limit] / given.size } end when class="y">:requires trigger, *needed = fields if !fixture[trigger].nil? needed.each { |f| fixture[f] ||= value_for(f, spec.inputs[f]) } end when class="y">:mutually_exclusive fields.select { |f| fixture.key?(f) }.drop(1).each { |f| fixture.delete(f) } end end fixture end def fixtures_for(spec) required = spec.inputs.select { |_, decl| decl[class="y">:required] } { class="s">"minimal" => satisfy_relations(required.to_h { |key, decl| [key, value_for(key, decl)] }, spec), class="s">"maximal" => satisfy_relations(spec.inputs.to_h { |key, decl| [key, value_for(key, decl)] }, spec) } end failures = 0 puts class="s">"CONTRACT FIXTURES (derived from declarations, then proved)" SPECS.each do |spec| validator = Agentic:class="y">:CapabilityValidator.new(spec) puts puts class="s">" #{spec.name} v#{spec.version}:" fixtures_for(spec).each do |flavor, fixture| verdict = begin validator.validate_inputs!(fixture) class="s">"valid" rescue Agentic:class="y">:Errors:class="y">:ValidationError => e failures += 1 class="s">"REJECTED BY OWN CONTRACT: #{e.message}" end puts format(class="s">" %-8s %-60s %s", flavor, JSON.generate(fixture), verdict) end # The referee's teeth: a mutant fixture (first required key removed) # must still FAIL - a validator that accepts everything would make # the proofs above worthless mutant = fixtures_for(spec)[class="s">"minimal"].dup removed = mutant.keys.first mutant.delete(removed) begin validator.validate_inputs!(mutant) failures += 1 puts format(class="s">" %-8s dropped :%-20s ACCEPTED - validator has no teeth", class="s">"mutant", removed) rescue Agentic:class="y">:Errors:class="y">:ValidationError puts format(class="s">" %-8s dropped :%-20s rejected, as it must be", class="s">"mutant", removed) end end puts if failures.zero? puts class="s">" every derived fixture passed its own contract, and every mutant" puts class="s">" failed. paste these into your README - when the contract changes," puts class="s">" rerun and they change with it. handwritten examples are promises;" puts class="s">" derived ones are consequences." puts puts class="s">" and the round-9 blind spot has closed for the declarable" puts class="s">" majority: relation-typed rules (sum_lte, requires," puts class="s">" mutually_exclusive) are data, so the generator SATISFIED them -" puts class="s">" scaled the weights under the limit, added what express required," puts class="s">" kept one credential of the exclusive pair - and the validator," puts class="s">" which now enforces relations too, countersigned the result." puts class="s">" lambdas remain for the exotic tail, and remain opaque." else puts class="s">" #{failures} DISAGREEMENT(S) between generator and validator." end exit(failures.zero? ? 0 : 1)