diff --git a/CHANGELOG.md b/CHANGELOG.md index 96e26ee..727c2e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +## 0.3.0 - 2026-08-11 + +- Enforce `must_change` `from:` and `to:` bounds for scalar and array values. +- Raise `Contracts::MutationViolation` when required change bounds fail. + ## 0.2.0 - 2026-08-04 - Added `Contracts::Constraints::Tuple` for fixed-length heterogeneous arrays. diff --git a/Gemfile.lock b/Gemfile.lock index baef5a2..c280fe3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - contracts-rb (0.2.0) + contracts-rb (0.3.0) GEM remote: https://rubygems.org/ diff --git a/contracts-rb.gemspec b/contracts-rb.gemspec index 6b18063..88e86f3 100644 --- a/contracts-rb.gemspec +++ b/contracts-rb.gemspec @@ -1,6 +1,6 @@ Gem::Specification.new do |spec| spec.name = "contracts-rb" - spec.version = "0.2.0" + spec.version = "0.3.0" spec.authors = ["Magnexis"] spec.summary = "Behavioral contracts for Ruby methods and objects" spec.description = "Expressive runtime contracts for parameters, results, state, invariants, exceptions, tuples, and structured hash shapes." diff --git a/lib/contracts.rb b/lib/contracts.rb index c101e3e..f1025a4 100644 --- a/lib/contracts.rb +++ b/lib/contracts.rb @@ -345,6 +345,10 @@ def inherited_invariants = all_invariants - own_invariants def permitted_changes=(values) @permitted_changes = values.map(&:to_sym).freeze end + + def required_change_bounds + @required_change_bounds || {}.freeze + end end class ContractBuilder @@ -386,9 +390,12 @@ def observe(*attributes, deep: false, compare_with: nil, &reader) end def must_change(*attributes, from: nil, to: nil) - # Retain range constraints for the public DSL; enforcement is intentionally deferred. validate_mutation_mode!(:must_change) - @contract.instance_variable_set(:@required_change_bounds, { attributes: attributes.map(&:to_sym), from: from, to: to }.freeze) + bounds = @contract.instance_variable_get(:@required_change_bounds) || {} + attributes.each do |attribute| + bounds[attribute.to_sym] = { from: from, to: to }.freeze + end + @contract.instance_variable_set(:@required_change_bounds, bounds.freeze) observe(*attributes.reject do |attribute| @contract.observed.any? do |item| item.name == attribute.to_sym @@ -752,10 +759,40 @@ def validate_mutation(contract, context) report = MutationReport.new(before: context.before, after: after, permitted: permitted, required: contract.required_changes, observations: contract.observed.to_h do |item| [item.name, item] end) - return if report.passed? + bounds_violations = required_change_bound_violations(contract, context.before, after, report) + return if report.passed? && bounds_violations.empty? + parts = [] + parts << "unexpected changes: #{report.unexpected_changes.join(', ')}; missing changes: #{report.missing_required_changes.join(', ')}" unless report.passed? + parts.concat(bounds_violations) fail!(MutationViolation, context, - description: "unexpected changes: #{report.unexpected_changes.join(', ')}; missing changes: #{report.missing_required_changes.join(', ')}", expected: permitted, actual: report.to_h) + description: parts.join("; "), expected: permitted, actual: report.to_h) + end + + def required_change_bound_violations(contract, before, after, report) + contract.required_change_bounds.each_with_object([]) do |(field, bounds), violations| + next unless report.changed_fields.include?(field) + + from = bounds[:from] + to = bounds[:to] + violations << "#{field} must change from #{bound_description(from)} (was #{bound_value_label(before[field])})" if from && !bound_value_matches?(before[field], from) + violations << "#{field} must change to #{bound_description(to)} (got #{bound_value_label(after[field])})" if to && !bound_value_matches?(after[field], to) + end + end + + def bound_value_matches?(value, bound) + case bound + when Array then bound.any? { |item| equal_state?(value, item) } + else equal_state?(value, bound) + end + end + + def bound_description(bound) + bound.inspect + end + + def bound_value_label(value) + value.inspect end def check_contract_invariants(receiver, _contract, context, _phase) diff --git a/lib/contracts/version.rb b/lib/contracts/version.rb index 58acfb8..5475b91 100644 --- a/lib/contracts/version.rb +++ b/lib/contracts/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Contracts - VERSION = "0.2.0" + VERSION = "0.3.0" end diff --git a/spec/contracts/stateful_spec.rb b/spec/contracts/stateful_spec.rb index 633f73e..f3c4753 100644 --- a/spec/contracts/stateful_spec.rb +++ b/spec/contracts/stateful_spec.rb @@ -32,4 +32,103 @@ def call = (@balance += 1; @status = :closed) end expect { klass.new.call }.to raise_error(Contracts::MutationViolation) end + + it "enforces must_change from bounds" do + klass = Class.new do + include Contracts + attr_reader :status + contract(:transition) do + observe :status + changes :status + must_change :status, from: :active + end + def initialize = @status = :active + def transition = @status = :closed + end + expect(klass.new.transition).to eq(:closed) + + invalid = Class.new do + include Contracts + attr_reader :status + contract(:transition) do + observe :status + changes :status + must_change :status, from: :active + end + def initialize = @status = :pending + def transition = @status = :closed + end + expect { invalid.new.transition }.to raise_error(Contracts::MutationViolation, /from/) + end + + it "enforces must_change to bounds with scalar and array values" do + klass = Class.new do + include Contracts + attr_reader :status + contract(:close) do + observe :status + changes :status + must_change :status, to: :closed + end + def initialize = @status = :active + def close = @status = :closed + end + expect(klass.new.close).to eq(:closed) + + array_bound = Class.new do + include Contracts + attr_reader :status + contract(:archive) do + observe :status + changes :status + must_change :status, to: [:archived, :deleted] + end + def initialize = @status = :active + def archive = @status = :archived + end + expect(array_bound.new.archive).to eq(:archived) + + expect do + Class.new do + include Contracts + attr_reader :status + contract(:close) do + observe :status + changes :status + must_change :status, to: :closed + end + def initialize = @status = :active + def close = @status = :pending + end.new.close + end.to raise_error(Contracts::MutationViolation, /to/) + end + + it "enforces must_change from and to bounds together" do + klass = Class.new do + include Contracts + attr_reader :balance + contract(:withdraw) do + observe :balance + changes :balance + must_change :balance, from: 100, to: 50 + end + def initialize = @balance = 100 + def withdraw = @balance = 50 + end + expect(klass.new.withdraw).to eq(50) + + expect do + Class.new do + include Contracts + attr_reader :balance + contract(:withdraw) do + observe :balance + changes :balance + must_change :balance, from: 100, to: 50 + end + def initialize = @balance = 200 + def withdraw = @balance = 50 + end.new.withdraw + end.to raise_error(Contracts::MutationViolation, /from/) + end end