contracts-rb 0.1.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f9993a3ee0ba0579a550417f72325feb368a093f39508ad4b2a6c3ae516bf4fe
4
- data.tar.gz: 84923376eab19c456ba1a5a783f44c5aab228915f6e80db7d0b7f3adac17946c
3
+ metadata.gz: 3c50991fa2ffc01a3c3a213d34a813be80939f9c5086cba3e8d542ba61982602
4
+ data.tar.gz: 90babd1110a562e0921ca036d85789a693bc7bf3ed0d76340769ff8f1f62812e
5
5
  SHA512:
6
- metadata.gz: 2d7c3695fada564090c026e1cc9e6add5e538c772e9c317ee7fdfea0486f42355193107202b27a9057b80c61c42644cd6676cd7a2a9f1bea04f4e8c00bf1fb46
7
- data.tar.gz: 16be4ea8a83af309effbd863cd8efe26e5b50d2d10847caba139a49d240580ca2278371950eeb2a2e1a24da74b9481c9372134d45abca4cb2c64698de071e3c8
6
+ metadata.gz: fdf4bc427ec2d091068cc7a8bc934376e964236b0c448b5c64ef5fe3e3be3f85cf5833335c370840c596ee5cb1e197d59c80ade8955d84a14966919421631a29
7
+ data.tar.gz: fe9d806bbc89fed14613f1773a31a86392764e040e6f0384bee065d97fffa141cb9cd0de01e51eeaf9c53a10635505d3ffd0aa022a0776cf4d26167da5c9c56f
data/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.4.0 - 2026-08-14
6
+
7
+ - Added `Contracts.all` to require every nested constraint to match.
8
+ - Added `Contracts.length(min:, max:, exactly:)` for String, Array, Hash, and other sized values.
9
+
10
+ ## 0.3.0 - 2026-08-11
11
+
12
+ - Enforce `must_change` `from:` and `to:` bounds for scalar and array values.
13
+ - Raise `Contracts::MutationViolation` when required change bounds fail.
14
+
15
+ ## 0.2.0 - 2026-08-04
16
+
17
+ - Added `Contracts::Constraints::Tuple` for fixed-length heterogeneous arrays.
18
+ - Added `Contracts::Constraints::Shape` for required and optional hash-key contracts.
19
+ - Added `Contracts::Constraints.tuple` and `.shape` constructors.
20
+ - Added strict extra-key handling with an opt-in `allow_extra` mode.
21
+ - Added structured constraint serialization through `to_h`.
22
+ - Corrected gem metadata links to the current `theworker02/contracts-rb` repository.
23
+
5
24
  ## 0.1.2 - 2026-07-30
6
25
 
7
26
  - Corrected package metadata links to use the repository's `master` branch.
data/README.md CHANGED
@@ -23,6 +23,8 @@ Ruby tests describe expected examples; contracts keep critical method promises e
23
23
 
24
24
  ## Install
25
25
 
26
+ Install the official package from [RubyGems: `contracts-rb`](https://rubygems.org/gems/contracts-rb).
27
+
26
28
  ```ruby
27
29
  gem "contracts-rb", "~> 0.1"
28
30
  require "contracts"
@@ -50,7 +52,7 @@ class Account
50
52
  end
51
53
  ```
52
54
 
53
- Constraints include `Contracts.nilable`, `any`, `matching`, `range`, `one_of`, `array_of`, `hash_of`, `respond_to`, `duck_type`, `anything`, `nothing`, and `predicate`.
55
+ Constraints include `Contracts.nilable`, `any`, `all`, `matching`, `range`, `one_of`, `array_of`, `hash_of`, `length`, `respond_to`, `duck_type`, `anything`, `nothing`, and `predicate`.
54
56
 
55
57
  ## Stateful contracts
56
58
 
@@ -67,7 +69,8 @@ Use `observe` to capture relevant receiver fields; `changes` permits a subset, `
67
69
  | `one_of` | `Contracts.one_of(:draft, :published)` | one literal value |
68
70
  | `array_of` | `Contracts.array_of(String)` | array whose elements match |
69
71
  | `hash_of` | `Contracts.hash_of(Symbol, Numeric)` | hash with matching keys/values |
70
- | `predicate` | `Contracts.predicate("valid ID") { ... }` | custom runtime rule |
72
+ | `all` | `Contracts.all(String, Contracts.matching(/A/))` | every nested constraint matches |
73
+ | `length` | `Contracts.length(min: 1, max: 80)` | `size`/`length` within bounds |
71
74
 
72
75
  ## Runtime configuration
73
76
 
@@ -1,59 +1,59 @@
1
- ## `BankAccount#__invariant__`
2
-
3
- ### Parameters
4
-
5
-
6
- ### Preconditions
7
-
8
-
9
- ### Returns
10
- `unspecified`
11
-
12
- ### Observed State
13
- | Field | Deep | Comparator |
14
- |---|---:|---|
15
-
16
-
17
- ### Permitted Changes
18
-
19
-
20
- ### Required Changes
21
- None.
22
-
23
- ### Invariants
24
- - balance cannot be negative
25
- - status is valid
26
-
1
+ ## `BankAccount#__invariant__`
2
+
3
+ ### Parameters
4
+
5
+
6
+ ### Preconditions
7
+
8
+
9
+ ### Returns
10
+ `unspecified`
11
+
12
+ ### Observed State
13
+ | Field | Deep | Comparator |
14
+ |---|---:|---|
15
+
16
+
17
+ ### Permitted Changes
18
+
19
+
20
+ ### Required Changes
21
+ None.
22
+
23
+ ### Invariants
24
+ - balance cannot be negative
25
+ - status is valid
26
+
27
27
  ### Allowed Exceptions
28
-
29
- ## `BankAccount#withdraw`
30
-
31
- ### Parameters
32
- - `amount`: `Numeric`
33
-
34
- ### Preconditions
35
- - positive amount
36
- - active account
37
-
38
- ### Returns
39
- `unspecified`
40
-
41
- ### Observed State
42
- | Field | Deep | Comparator |
43
- |---|---:|---|
44
- | `balance` | No | `eql?` |
45
- | `audit_log` | Yes | `eql?` |
46
-
47
- ### Permitted Changes
48
- - `balance`
49
- - `audit_log`
50
-
51
- ### Required Changes
52
- None.
53
-
54
- ### Invariants
55
- - balance cannot be negative
56
- - status is valid
57
-
58
- ### Allowed Exceptions
59
-
28
+
29
+ ## `BankAccount#withdraw`
30
+
31
+ ### Parameters
32
+ - `amount`: `Numeric`
33
+
34
+ ### Preconditions
35
+ - positive amount
36
+ - active account
37
+
38
+ ### Returns
39
+ `unspecified`
40
+
41
+ ### Observed State
42
+ | Field | Deep | Comparator |
43
+ |---|---:|---|
44
+ | `balance` | No | `eql?` |
45
+ | `audit_log` | Yes | `eql?` |
46
+
47
+ ### Permitted Changes
48
+ - `balance`
49
+ - `audit_log`
50
+
51
+ ### Required Changes
52
+ None.
53
+
54
+ ### Invariants
55
+ - balance cannot be negative
56
+ - status is valid
57
+
58
+ ### Allowed Exceptions
59
+
@@ -0,0 +1,52 @@
1
+ # Structured constraints
2
+
3
+ Contracts-rb 0.2.0 adds optional structured constraints for fixed tuples and hash-shaped payloads.
4
+
5
+ Load the extension after the main gem:
6
+
7
+ ```ruby
8
+ require "contracts"
9
+ require "contracts/structured"
10
+ ```
11
+
12
+ ## Tuple
13
+
14
+ ```ruby
15
+ Coordinates = Contracts::Constraints.tuple(Float, Float)
16
+ Coordinates.matches?([41.3, -72.9]) # => true
17
+ ```
18
+
19
+ A tuple requires an `Array` with the exact declared length and validates every position independently.
20
+
21
+ ## Shape
22
+
23
+ ```ruby
24
+ Payload = Contracts::Constraints.shape(
25
+ required: {
26
+ name: String,
27
+ retries: Integer
28
+ },
29
+ optional: {
30
+ tags: Contracts::Constraints::ArrayOf.new(String)
31
+ }
32
+ )
33
+ ```
34
+
35
+ Shapes reject missing required keys, invalid optional values, and unknown keys. Set `allow_extra: true` when additional keys are part of the contract.
36
+
37
+ ```ruby
38
+ OpenPayload = Contracts::Constraints.shape(
39
+ required: {name: String},
40
+ allow_extra: true
41
+ )
42
+ ```
43
+
44
+ Both constraints implement the normal Contracts-rb constraint interface: `matches?`, `description`, and `to_h`.
45
+
46
+ `Contracts.all` and `Contracts.length` live on the core gem (no extra require) for conjunctions and sized values:
47
+
48
+ ```ruby
49
+ Contracts.all(String, Contracts.matching(/\A[A-Z]/)).matches?("OK")
50
+ Contracts.length(min: 2, max: 4).matches?("ab")
51
+ ```
52
+
data/exe/contracts CHANGED
@@ -1,105 +1,105 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- require "cgi"
5
- require "fileutils"
6
- require "json"
7
- require "optparse"
8
- require "contracts"
9
-
10
- command = ARGV.shift || "help"
11
- options = { requires: [], format: "text", output: nil }
12
- OptionParser.new do |parser|
13
- parser.on("--require PATH") { |path| options[:requires] << path }
14
- parser.on("--format FORMAT") { |format| options[:format] = format }
15
- parser.on("--output PATH") { |path| options[:output] = path }
16
- end.parse!
17
- options[:requires].each { |path| require File.expand_path(path) }
18
-
19
- def contracts_for(target)
20
- return Contracts.registry.all unless target
21
-
22
- owner_name, method_name = target.split("#", 2)
23
- owner = owner_name.split("::").reject(&:empty?).inject(Object) { |scope, name| scope.const_get(name) }
24
- method_name ? [Contracts.contract_for(owner, method_name)].compact : Contracts.registry.for_class(owner)
25
- end
26
-
27
- def markdown(contracts)
28
- contracts.map do |contract|
29
- item = contract.to_h
30
- observed = item[:observed].map do |field|
31
- "| `#{field[:name]}` | #{field[:deep] ? 'Yes' : 'No'} | `#{field[:comparator] || 'eql?'}` |"
32
- end.join("\n")
33
- <<~DOC
34
- ## `#{item[:owner]}##{item[:method_name]}`
35
-
36
- ### Parameters
37
- #{item[:parameters].map { |name, rule| "- `#{name}`: `#{rule}`" }.join("\n")}
38
-
39
- ### Preconditions
40
- #{item[:preconditions].map { |rule| "- #{rule}" }.join("\n")}
41
-
42
- ### Returns
43
- `#{item[:return_constraint] || 'unspecified'}`
44
-
45
- ### Observed State
46
- | Field | Deep | Comparator |
47
- |---|---:|---|
48
- #{observed}
49
-
50
- ### Permitted Changes
51
- #{item[:permitted_changes].map { |field| "- `#{field}`" }.join("\n")}
52
-
53
- ### Required Changes
54
- #{item[:required_changes].empty? ? 'None.' : item[:required_changes].map { |field| "- `#{field}`" }.join("\n")}
55
-
56
- ### Invariants
57
- #{item[:invariants].map { |rule| "- #{rule}" }.join("\n")}
58
-
59
- ### Allowed Exceptions
60
- #{item[:allowed_exceptions].map { |rule| "- `#{rule}`" }.join("\n")}
61
- DOC
62
- end.join("\n")
63
- end
64
-
65
- case command
66
- when "version" then puts Contracts::VERSION
67
- when "inspect"
68
- list = contracts_for(ARGV.shift)
69
- puts(options[:format] == "json" ? JSON.pretty_generate(list.map(&:to_h)) : markdown(list))
70
- when "docs"
71
- list = Contracts.registry.all
72
- body = case options[:format]
73
- when "json" then JSON.pretty_generate(list.map(&:to_h))
74
- when "html" then "<html><body><pre>#{CGI.escapeHTML(markdown(list))}</pre></body></html>"
75
- else markdown(list)
76
- end
77
- if options[:output]
78
- path = options[:output]
79
- if File.extname(path).empty?
80
- FileUtils.mkdir_p(path)
81
- path = File.join(path,
82
- "contracts.#{if options[:format] == 'json'
83
- 'json'
84
- else
85
- options[:format] == 'html' ? 'html' : 'md'
86
- end}")
87
- end
88
- FileUtils.mkdir_p(File.dirname(path))
89
- File.write(path, body)
90
- puts path
91
- else
92
- puts body
93
- end
94
- when "validate", "doctor"
95
- failures = Contracts.registry.all.filter_map do |contract|
96
- next if contract.method_name == :__invariant__
97
-
98
- owner = contract.owner
99
- "#{contract.id}: method missing" unless owner.method_defined?(contract.method_name) || owner.private_method_defined?(contract.method_name) || owner.protected_method_defined?(contract.method_name)
100
- end
101
- abort("Contract diagnostics failed:\n#{failures.join("\n")}") unless failures.empty?
102
- puts(command == "doctor" ? "No contract wrapper or registry problems detected." : "Contracts are valid.")
103
- else
104
- puts "Usage: contracts <version|inspect|validate|doctor|docs> [--require PATH] [--format FORMAT] [--output PATH]"
105
- end
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "cgi"
5
+ require "fileutils"
6
+ require "json"
7
+ require "optparse"
8
+ require "contracts"
9
+
10
+ command = ARGV.shift || "help"
11
+ options = { requires: [], format: "text", output: nil }
12
+ OptionParser.new do |parser|
13
+ parser.on("--require PATH") { |path| options[:requires] << path }
14
+ parser.on("--format FORMAT") { |format| options[:format] = format }
15
+ parser.on("--output PATH") { |path| options[:output] = path }
16
+ end.parse!
17
+ options[:requires].each { |path| require File.expand_path(path) }
18
+
19
+ def contracts_for(target)
20
+ return Contracts.registry.all unless target
21
+
22
+ owner_name, method_name = target.split("#", 2)
23
+ owner = owner_name.split("::").reject(&:empty?).inject(Object) { |scope, name| scope.const_get(name) }
24
+ method_name ? [Contracts.contract_for(owner, method_name)].compact : Contracts.registry.for_class(owner)
25
+ end
26
+
27
+ def markdown(contracts)
28
+ contracts.map do |contract|
29
+ item = contract.to_h
30
+ observed = item[:observed].map do |field|
31
+ "| `#{field[:name]}` | #{field[:deep] ? 'Yes' : 'No'} | `#{field[:comparator] || 'eql?'}` |"
32
+ end.join("\n")
33
+ <<~DOC
34
+ ## `#{item[:owner]}##{item[:method_name]}`
35
+
36
+ ### Parameters
37
+ #{item[:parameters].map { |name, rule| "- `#{name}`: `#{rule}`" }.join("\n")}
38
+
39
+ ### Preconditions
40
+ #{item[:preconditions].map { |rule| "- #{rule}" }.join("\n")}
41
+
42
+ ### Returns
43
+ `#{item[:return_constraint] || 'unspecified'}`
44
+
45
+ ### Observed State
46
+ | Field | Deep | Comparator |
47
+ |---|---:|---|
48
+ #{observed}
49
+
50
+ ### Permitted Changes
51
+ #{item[:permitted_changes].map { |field| "- `#{field}`" }.join("\n")}
52
+
53
+ ### Required Changes
54
+ #{item[:required_changes].empty? ? 'None.' : item[:required_changes].map { |field| "- `#{field}`" }.join("\n")}
55
+
56
+ ### Invariants
57
+ #{item[:invariants].map { |rule| "- #{rule}" }.join("\n")}
58
+
59
+ ### Allowed Exceptions
60
+ #{item[:allowed_exceptions].map { |rule| "- `#{rule}`" }.join("\n")}
61
+ DOC
62
+ end.join("\n")
63
+ end
64
+
65
+ case command
66
+ when "version" then puts Contracts::VERSION
67
+ when "inspect"
68
+ list = contracts_for(ARGV.shift)
69
+ puts(options[:format] == "json" ? JSON.pretty_generate(list.map(&:to_h)) : markdown(list))
70
+ when "docs"
71
+ list = Contracts.registry.all
72
+ body = case options[:format]
73
+ when "json" then JSON.pretty_generate(list.map(&:to_h))
74
+ when "html" then "<html><body><pre>#{CGI.escapeHTML(markdown(list))}</pre></body></html>"
75
+ else markdown(list)
76
+ end
77
+ if options[:output]
78
+ path = options[:output]
79
+ if File.extname(path).empty?
80
+ FileUtils.mkdir_p(path)
81
+ path = File.join(path,
82
+ "contracts.#{if options[:format] == 'json'
83
+ 'json'
84
+ else
85
+ options[:format] == 'html' ? 'html' : 'md'
86
+ end}")
87
+ end
88
+ FileUtils.mkdir_p(File.dirname(path))
89
+ File.write(path, body)
90
+ puts path
91
+ else
92
+ puts body
93
+ end
94
+ when "validate", "doctor"
95
+ failures = Contracts.registry.all.filter_map do |contract|
96
+ next if contract.method_name == :__invariant__
97
+
98
+ owner = contract.owner
99
+ "#{contract.id}: method missing" unless owner.method_defined?(contract.method_name) || owner.private_method_defined?(contract.method_name) || owner.protected_method_defined?(contract.method_name)
100
+ end
101
+ abort("Contract diagnostics failed:\n#{failures.join("\n")}") unless failures.empty?
102
+ puts(command == "doctor" ? "No contract wrapper or registry problems detected." : "Contracts are valid.")
103
+ else
104
+ puts "Usage: contracts <version|inspect|validate|doctor|docs> [--require PATH] [--format FORMAT] [--output PATH]"
105
+ end
@@ -1,9 +1,9 @@
1
- # frozen_string_literal: true
2
-
3
- require "contracts"
4
- begin
5
- require "rails/railtie"
6
- module Contracts
1
+ # frozen_string_literal: true
2
+
3
+ require "contracts"
4
+ begin
5
+ require "rails/railtie"
6
+ module Contracts
7
7
  class Railtie < Rails::Railtie
8
8
  initializer "contracts.logger" do
9
9
  Contracts.configuration.logger ||= Rails.logger
@@ -13,8 +13,8 @@ begin
13
13
  # Contract wrapping is idempotent, making reloads safe for existing contracts.
14
14
  Contracts.configuration.enabled = true if Contracts.configuration.enabled.nil?
15
15
  end
16
- end
17
- end
18
- rescue LoadError
19
- # Rails remains an optional dependency.
20
- end
16
+ end
17
+ end
18
+ rescue LoadError
19
+ # Rails remains an optional dependency.
20
+ end
@@ -1,42 +1,42 @@
1
- # frozen_string_literal: true
2
-
1
+ # frozen_string_literal: true
2
+
3
3
  require "contracts"
4
4
  require_relative "rspec/verifier"
5
- begin
6
- require "rspec/expectations"
7
- RSpec::Matchers.define :have_contract do |method_name|
8
- match { |owner| !Contracts.contract_for(owner, method_name).nil? }
9
- end
10
- RSpec::Matchers.define :have_precondition do |method_name|
11
- match { |owner| (contract = Contracts.contract_for(owner, method_name)) && !contract.preconditions.empty? }
12
- end
13
- RSpec::Matchers.define :return_contract do |method_name, constraint|
14
- match do |owner|
15
- (contract = Contracts.contract_for(owner,
16
- method_name)) && contract.return_constraint.description == Contracts::Constraints.coerce(constraint).description
17
- end
18
- end
19
- RSpec::Matchers.define :have_invariant do |description|
20
- match { |owner| Contracts.invariants_for(owner).any? { |invariant| invariant.description == description } }
21
- end
22
- RSpec::Matchers.define :observe_state do |field, on:|
23
- match { |owner| (contract = Contracts.contract_for(owner, on)) && contract.observed_fields.include?(field.to_sym) }
24
- end
25
- RSpec::Matchers.define :permit_change do |field, on:|
26
- match do |owner|
27
- (contract = Contracts.contract_for(owner, on)) && contract.permitted_changes.include?(field.to_sym)
28
- end
29
- end
30
- RSpec::Matchers.define :require_change do |field, on:|
31
- match { |owner| (contract = Contracts.contract_for(owner, on)) && contract.required_changes.include?(field.to_sym) }
32
- end
5
+ begin
6
+ require "rspec/expectations"
7
+ RSpec::Matchers.define :have_contract do |method_name|
8
+ match { |owner| !Contracts.contract_for(owner, method_name).nil? }
9
+ end
10
+ RSpec::Matchers.define :have_precondition do |method_name|
11
+ match { |owner| (contract = Contracts.contract_for(owner, method_name)) && !contract.preconditions.empty? }
12
+ end
13
+ RSpec::Matchers.define :return_contract do |method_name, constraint|
14
+ match do |owner|
15
+ (contract = Contracts.contract_for(owner,
16
+ method_name)) && contract.return_constraint.description == Contracts::Constraints.coerce(constraint).description
17
+ end
18
+ end
19
+ RSpec::Matchers.define :have_invariant do |description|
20
+ match { |owner| Contracts.invariants_for(owner).any? { |invariant| invariant.description == description } }
21
+ end
22
+ RSpec::Matchers.define :observe_state do |field, on:|
23
+ match { |owner| (contract = Contracts.contract_for(owner, on)) && contract.observed_fields.include?(field.to_sym) }
24
+ end
25
+ RSpec::Matchers.define :permit_change do |field, on:|
26
+ match do |owner|
27
+ (contract = Contracts.contract_for(owner, on)) && contract.permitted_changes.include?(field.to_sym)
28
+ end
29
+ end
30
+ RSpec::Matchers.define :require_change do |field, on:|
31
+ match { |owner| (contract = Contracts.contract_for(owner, on)) && contract.required_changes.include?(field.to_sym) }
32
+ end
33
33
  RSpec::Matchers.define :have_pure_contract do |method_name|
34
- match { |owner| (contract = Contracts.contract_for(owner, method_name)) && contract.pure? }
34
+ match { |owner| (contract = Contracts.contract_for(owner, method_name)) && contract.pure? }
35
35
  end
36
36
 
37
37
  RSpec::Core::ExampleGroup.define_singleton_method(:describe_contract) do |owner, method_name, &block|
38
38
  describe("#{owner}##{method_name}", &block)
39
39
  end
40
40
  rescue LoadError
41
- # RSpec is optional at runtime.
42
- end
41
+ # RSpec is optional at runtime.
42
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "contracts"
4
+
5
+ module Contracts
6
+ module Constraints
7
+ # Matches a fixed-length Array whose positions use independent constraints.
8
+ class Tuple < Base
9
+ def initialize(*items)
10
+ @items = items.map { |item| Constraints.coerce(item) }.freeze
11
+ end
12
+
13
+ def matches?(value)
14
+ value.is_a?(Array) && value.length == @items.length &&
15
+ @items.each_with_index.all? { |constraint, index| constraint.matches?(value[index]) }
16
+ end
17
+
18
+ def description
19
+ "Tuple<#{@items.map(&:description).join(', ')}>"
20
+ end
21
+
22
+ def to_h
23
+ super.merge(items: @items.map(&:description))
24
+ end
25
+ end
26
+
27
+ # Matches a Hash with required and optional key contracts.
28
+ class Shape < Base
29
+ attr_reader :required, :optional, :allow_extra
30
+
31
+ def initialize(required: {}, optional: {}, allow_extra: false)
32
+ @required = coerce_map(required)
33
+ @optional = coerce_map(optional)
34
+ overlap = @required.keys & @optional.keys
35
+ raise DefinitionError, "shape keys cannot be both required and optional: #{overlap.join(', ')}" unless overlap.empty?
36
+
37
+ @allow_extra = allow_extra
38
+ freeze
39
+ end
40
+
41
+ def matches?(value)
42
+ return false unless value.is_a?(Hash)
43
+ return false unless required.all? { |key, constraint| value.key?(key) && constraint.matches?(value[key]) }
44
+ return false unless optional.all? { |key, constraint| !value.key?(key) || constraint.matches?(value[key]) }
45
+ return true if allow_extra
46
+
47
+ (value.keys - required.keys - optional.keys).empty?
48
+ end
49
+
50
+ def description
51
+ required_text = required.map { |key, constraint| "#{key}: #{constraint.description}" }
52
+ optional_text = optional.map { |key, constraint| "#{key}?: #{constraint.description}" }
53
+ suffix = allow_extra ? ", ..." : ""
54
+ "Shape<{#{(required_text + optional_text).join(', ')}#{suffix}}>"
55
+ end
56
+
57
+ def to_h
58
+ super.merge(
59
+ required: required.transform_values(&:description),
60
+ optional: optional.transform_values(&:description),
61
+ allow_extra: allow_extra
62
+ )
63
+ end
64
+
65
+ private
66
+
67
+ def coerce_map(values)
68
+ values.to_h.transform_values { |constraint| Constraints.coerce(constraint) }.freeze
69
+ end
70
+ end
71
+
72
+ module_function
73
+
74
+ # Creates a fixed-length heterogeneous Array constraint.
75
+ def tuple(*items)
76
+ Tuple.new(*items)
77
+ end
78
+
79
+ # Creates a required/optional Hash shape constraint.
80
+ def shape(required: {}, optional: {}, allow_extra: false)
81
+ Shape.new(required:, optional:, allow_extra:)
82
+ end
83
+ end
84
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Contracts
4
- VERSION = "0.1.2"
4
+ VERSION = "0.4.0"
5
5
  end