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 +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +5 -2
- data/docs/contracts/contracts.md +58 -58
- data/docs/structured-constraints.md +52 -0
- data/exe/contracts +105 -105
- data/lib/contracts/rails.rb +11 -11
- data/lib/contracts/rspec.rb +33 -33
- data/lib/contracts/structured.rb +84 -0
- data/lib/contracts/version.rb +1 -1
- data/lib/contracts.rb +1055 -963
- metadata +10 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3c50991fa2ffc01a3c3a213d34a813be80939f9c5086cba3e8d542ba61982602
|
|
4
|
+
data.tar.gz: 90babd1110a562e0921ca036d85789a693bc7bf3ed0d76340769ff8f1f62812e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
| `
|
|
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
|
|
data/docs/contracts/contracts.md
CHANGED
|
@@ -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
|
data/lib/contracts/rails.rb
CHANGED
|
@@ -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
|
data/lib/contracts/rspec.rb
CHANGED
|
@@ -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
|
data/lib/contracts/version.rb
CHANGED