Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
contracts-rb (0.2.0)
contracts-rb (0.3.0)

GEM
remote: https://rubygems.org/
Expand Down
2 changes: 1 addition & 1 deletion contracts-rb.gemspec
Original file line number Diff line number Diff line change
@@ -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."
Expand Down
45 changes: 41 additions & 4 deletions lib/contracts.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion lib/contracts/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Contracts
VERSION = "0.2.0"
VERSION = "0.3.0"
end
99 changes: 99 additions & 0 deletions spec/contracts/stateful_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading