contracts-rb 0.1.2

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f9993a3ee0ba0579a550417f72325feb368a093f39508ad4b2a6c3ae516bf4fe
4
+ data.tar.gz: 84923376eab19c456ba1a5a783f44c5aab228915f6e80db7d0b7f3adac17946c
5
+ SHA512:
6
+ metadata.gz: 2d7c3695fada564090c026e1cc9e6add5e538c772e9c317ee7fdfea0486f42355193107202b27a9057b80c61c42644cd6676cd7a2a9f1bea04f4e8c00bf1fb46
7
+ data.tar.gz: 16be4ea8a83af309effbd863cd8efe26e5b50d2d10847caba139a49d240580ca2278371950eeb2a2e1a24da74b9481c9372134d45abca4cb2c64698de071e3c8
data/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ ## 0.1.2 - 2026-07-30
6
+
7
+ - Corrected package metadata links to use the repository's `master` branch.
8
+
9
+ ## 0.1.1 - 2026-07-30
10
+
11
+ - Restored Ruby 3.1 compatibility for snapshot argument handling and `Set` support.
12
+ - Added the Linux platform to the lockfile and constrained development dependencies for the supported Ruby matrix.
13
+ - Prevented RuboCop from scanning Bundler-installed dependencies in CI.
14
+
15
+ ## 0.1.0
16
+
17
+ - Added release-quality package metadata, build task, generated contract documentation, and release workflow.
18
+ - Added the official contracts-rb logo asset.
19
+ - Initial release: core behavioral contracts, constraints, invariants, snapshots, registry, CLI, and optional integrations.
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,3 @@
1
+ # Contributing
2
+
3
+ Run `bundle exec rake spec` before submitting changes. Keep public APIs documented, preserve Ruby 3.1 compatibility, and add regression coverage for bugs.
data/LICENSE.txt ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Magnexis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,140 @@
1
+ # contracts-rb
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/contracts-rb.svg)](https://rubygems.org/gems/contracts-rb)
4
+ [![CI](https://github.com/magnexis/contracts-rb/actions/workflows/ci.yml/badge.svg)](https://github.com/magnexis/contracts-rb/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.txt)
6
+ [![Ruby 3.1+](https://img.shields.io/badge/Ruby-3.1%2B-CC342D.svg)](https://www.ruby-lang.org/)
7
+
8
+ <p align="center">
9
+ <a href="https://github.com/magnexis/contracts-rb">
10
+ <img src="assets/contracts-rb-logo.png" alt="contracts-rb" width="760">
11
+ </a>
12
+ </p>
13
+
14
+ `contracts-rb` adds small, explicit behavioral contracts to Ruby without imposing a type system. It supports parameter and return constraints, preconditions, postconditions, object invariants, state snapshots, mutation policies, exception declarations, introspection, and a dependency-free CLI.
15
+
16
+ **Official repository:** [github.com/magnexis/contracts-rb](https://github.com/magnexis/contracts-rb)
17
+
18
+ **RubyGems:** [rubygems.org/gems/contracts-rb](https://rubygems.org/gems/contracts-rb)
19
+
20
+ ## Why contracts-rb?
21
+
22
+ Ruby tests describe expected examples; contracts keep critical method promises executable at runtime. Use them for domain boundaries, money movement, state transitions, service objects, and public library APIs. They are not a replacement for ordinary tests, schema validation, or authorization.
23
+
24
+ ## Install
25
+
26
+ ```ruby
27
+ gem "contracts-rb", "~> 0.1"
28
+ require "contracts"
29
+ ```
30
+
31
+ ## Five-minute example
32
+
33
+ ```ruby
34
+ class Account
35
+ include Contracts
36
+ attr_reader :balance
37
+ invariant("balance is non-negative") { balance >= 0 }
38
+
39
+ contract :withdraw do
40
+ params amount: Numeric
41
+ requires("amount is positive") { |amount:| amount.positive? }
42
+ requires("enough funds") { |amount:| amount <= balance }
43
+ changes :balance
44
+ returns Numeric
45
+ ensures { |result, before:| balance == before.balance - result }
46
+ end
47
+
48
+ def initialize(balance:) = @balance = balance
49
+ def withdraw(amount:) = (@balance -= amount)
50
+ end
51
+ ```
52
+
53
+ Constraints include `Contracts.nilable`, `any`, `matching`, `range`, `one_of`, `array_of`, `hash_of`, `respond_to`, `duck_type`, `anything`, `nothing`, and `predicate`.
54
+
55
+ ## Stateful contracts
56
+
57
+ Use `observe` to capture relevant receiver fields; `changes` permits a subset, `must_change` requires a transition, and `pure`/`changes_nothing` permits none. Snapshots are immutable (`before.balance`, `before[:balance]`, `before.metadata`) and `deep: true` protects nested arrays and hashes. Invariants run after initialization and around contracted methods by default. `Contracts.check_invariants!(object)` is available for explicit checks.
58
+
59
+ ## Constraint reference
60
+
61
+ | Factory | Example | Meaning |
62
+ |---|---|---|
63
+ | `nilable` | `Contracts.nilable(String)` | `String` or `nil` |
64
+ | `any` | `Contracts.any(String, Symbol)` | any supplied constraint |
65
+ | `matching` | `Contracts.matching(/\A[a-z]+\z/i)` | string matching a regex |
66
+ | `range` | `Contracts.range(18..120)` | value inside a range |
67
+ | `one_of` | `Contracts.one_of(:draft, :published)` | one literal value |
68
+ | `array_of` | `Contracts.array_of(String)` | array whose elements match |
69
+ | `hash_of` | `Contracts.hash_of(Symbol, Numeric)` | hash with matching keys/values |
70
+ | `predicate` | `Contracts.predicate("valid ID") { ... }` | custom runtime rule |
71
+
72
+ ## Runtime configuration
73
+
74
+ ```ruby
75
+ Contracts.configure do |config|
76
+ config.enabled = true
77
+ config.failure_mode = :raise # :raise, :warn, :log, :collect
78
+ config.sample_rate = 1.0
79
+ config.invariant_checking = :contracted_methods
80
+ config.snapshot_strategy = :declared
81
+ config.undeclared_exceptions = :ignore
82
+ end
83
+ ```
84
+
85
+ For production, use sampling and a `:log` or `:collect` failure mode where an exception would be too disruptive. Sensitive parameter names are redacted from errors by default.
86
+
87
+ ## Exceptions and inheritance
88
+
89
+ Declare exceptions with `raises PaymentError`; use `on_raise(PaymentError) { |error, before:| ... }` for rollback guarantees. Set `undeclared_exceptions` to `:warn` or `:violate` to enforce exception declarations. Inheritance mode defaults to `:merge`, applying parent parameter, precondition, postcondition, return, and observation rules to overriding child contracts. `:strict` rejects structural parameter constraint changes.
90
+
91
+ ## Configuration and production use
92
+
93
+ ```ruby
94
+ Contracts.configure do |config|
95
+ config.enabled = true
96
+ config.failure_mode = :log # :raise, :warn, :log, :collect
97
+ config.sample_rate = 0.05
98
+ config.undeclared_exceptions = :violate # :ignore, :warn, :violate
99
+ end
100
+ ```
101
+
102
+ Errors avoid parameter values by default. Set `include_values_in_errors` only in trusted environments. Contracts complement tests and domain validation; use them for durable API promises and high-value invariants, not every local assertion.
103
+
104
+ ## Tooling
105
+
106
+ `bundle exec contracts version`, `inspect`, `validate`, `doctor`, and `docs --format markdown|json --output PATH` are available. Load application code with `--require ./lib/my_app`.
107
+
108
+ ```sh
109
+ bundle exec contracts inspect BankAccount#withdraw --require ./examples/banking/bank_account
110
+ bundle exec contracts docs --require ./examples/banking/bank_account --format markdown --output docs/contracts
111
+ bundle exec contracts doctor --require ./lib/my_app
112
+ ```
113
+
114
+ `require "contracts/rspec"` provides basic RSpec matchers when RSpec is installed. `require "contracts/rails"` installs a lightweight optional Railtie.
115
+
116
+ ## Compatibility
117
+
118
+ Ruby 3.1+. Rails is optional. The core is plain Ruby and has no runtime dependencies.
119
+
120
+ ## Limitations
121
+
122
+ 0.1.0 wraps declared instance methods directly; its explicit singleton API is experimental. Deep snapshots, static implication proofs for inheritance, and Rails instrumentation are intentionally deferred.
123
+
124
+ ## Development
125
+
126
+ ```sh
127
+ bundle install
128
+ bundle exec rake spec
129
+ gem build contracts-rb.gemspec
130
+ ```
131
+
132
+ MIT licensed. See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md).
133
+
134
+ ## Release process
135
+
136
+ Maintainers validate with `bundle exec rubocop .`, `bundle exec rspec`, and `bundle exec rake build`. The resulting `contracts-rb-<version>.gem` is attached to the matching GitHub release and published through an authenticated RubyGems session.
137
+
138
+ ## Benchmark methodology
139
+
140
+ Run `bundle exec ruby benchmark/runtime_overhead.rb` on the deployment Ruby and hardware. Compare the included uncontracted and parameter/return-contracted methods; do not extrapolate those measurements to contracts that capture state or execute expensive predicates.
data/SECURITY.md ADDED
@@ -0,0 +1,3 @@
1
+ # Security Policy
2
+
3
+ Please report vulnerabilities privately to the repository maintainers. Do not include secrets in reports. Contract values are redacted by default.
Binary file
data/docs/_config.yml ADDED
@@ -0,0 +1,3 @@
1
+ title: contracts-rb
2
+ description: Behavioral contracts for Ruby methods and objects.
3
+ theme: null
Binary file
@@ -0,0 +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
+
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
+
data/docs/index.html ADDED
@@ -0,0 +1,79 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="description" content="Behavioral contracts for Ruby methods and objects.">
7
+ <title>contracts-rb · Behavioral contracts for Ruby</title>
8
+ <style>
9
+ :root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #14171d; color: #f3f5f7; }
10
+ * { box-sizing: border-box; } body { margin: 0; line-height: 1.55; }
11
+ a { color: #ff5362; text-decoration: none; } a:hover { text-decoration: underline; }
12
+ .wrap { width: min(1100px, calc(100% - 48px)); margin: 0 auto; }
13
+ header { padding: 28px 0; display: flex; align-items: center; justify-content: space-between; }
14
+ .wordmark { font-weight: 750; letter-spacing: -.04em; font-size: 1.15rem; color: #fff; }
15
+ nav { display: flex; gap: 22px; font-size: .95rem; }
16
+ .hero { padding: 52px 0 70px; display: grid; grid-template-columns: 1.12fr .88fr; align-items: center; gap: 34px; }
17
+ h1 { font-size: clamp(2.9rem, 8vw, 5.7rem); line-height: .95; letter-spacing: -.07em; margin: 0 0 24px; }
18
+ h2 { font-size: 1.7rem; letter-spacing: -.04em; margin: 0 0 12px; }
19
+ .lede { color: #b8c0ca; font-size: 1.22rem; max-width: 620px; }
20
+ .actions { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 28px; }
21
+ .button { padding: 11px 16px; border-radius: 8px; background: #eb2538; color: #fff; font-weight: 700; }
22
+ .button.secondary { background: #252b35; color: #fff; }
23
+ .button:hover { text-decoration: none; filter: brightness(1.08); }
24
+ .logo { width: 100%; border-radius: 20px; box-shadow: 0 24px 80px rgba(0,0,0,.48); }
25
+ .bar { border-top: 1px solid #303744; border-bottom: 1px solid #303744; padding: 18px 0; color: #aab3c0; }
26
+ .bar strong { color: #fff; margin-right: 30px; }
27
+ section { padding: 70px 0; }
28
+ .grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 28px; }
29
+ .card { background: #1b2028; border: 1px solid #303744; padding: 22px; border-radius: 12px; }
30
+ .card h3 { margin: 0 0 8px; font-size: 1.05rem; } .card p { margin: 0; color: #b8c0ca; }
31
+ pre { overflow: auto; padding: 24px; background: #0f1217; border: 1px solid #303744; border-radius: 12px; color: #d9e1ec; }
32
+ footer { border-top: 1px solid #303744; padding: 30px 0 48px; color: #aab3c0; font-size: .93rem; }
33
+ @media (max-width: 760px) { .hero, .grid { grid-template-columns: 1fr; } header { align-items: flex-start; gap: 18px; flex-direction: column; } nav { gap: 14px; flex-wrap: wrap; } }
34
+ </style>
35
+ </head>
36
+ <body>
37
+ <div class="wrap">
38
+ <header>
39
+ <a class="wordmark" href="./">contracts-rb</a>
40
+ <nav><a href="#features">Features</a><a href="#start">Get started</a><a href="contracts/contracts.md">Contract docs</a><a href="https://github.com/magnexis/contracts-rb">GitHub</a></nav>
41
+ </header>
42
+
43
+ <main>
44
+ <section class="hero">
45
+ <div>
46
+ <h1>Make Ruby promises executable.</h1>
47
+ <p class="lede">contracts-rb adds lightweight behavioral contracts to Ruby methods and objects—without turning Ruby into a static type system.</p>
48
+ <div class="actions"><a class="button" href="https://rubygems.org/gems/contracts-rb">Install from RubyGems</a><a class="button secondary" href="https://github.com/magnexis/contracts-rb">View source</a></div>
49
+ </div>
50
+ <img class="logo" src="assets/contracts-rb-logo.png" alt="contracts-rb ruby gem logo">
51
+ </section>
52
+ </main>
53
+ </div>
54
+ <div class="bar"><div class="wrap"><strong>Ruby 3.1+</strong> Runtime contracts for methods, services, and stateful domain objects.</div></div>
55
+ <div class="wrap">
56
+ <section id="features"><h2>Behavior you can state, inspect, and enforce.</h2>
57
+ <div class="grid"><article class="card"><h3>Method contracts</h3><p>Validate parameters, preconditions, return values, and postconditions with idiomatic Ruby blocks.</p></article><article class="card"><h3>State safety</h3><p>Capture immutable before-state snapshots, preserve invariants, and declare permitted changes.</p></article><article class="card"><h3>Production control</h3><p>Sample enforcement, redact sensitive values, configure failure modes, and inspect contracts through the CLI.</p></article></div>
58
+ </section>
59
+ <section id="start"><h2>Five-minute start</h2><pre><code>gem "contracts-rb"
60
+
61
+ class Account
62
+ include Contracts
63
+ attr_reader :balance
64
+
65
+ invariant { balance >= 0 }
66
+
67
+ contract :withdraw do
68
+ params amount: Numeric
69
+ requires { |amount:| amount.positive? }
70
+ observe :balance
71
+ changes :balance
72
+ returns Numeric
73
+ end
74
+ end</code></pre>
75
+ <p>Read the <a href="https://github.com/magnexis/contracts-rb#readme">README</a> for snapshots, exceptions, inheritance, RSpec, Rails, and CLI usage.</p></section>
76
+ </div>
77
+ <footer><div class="wrap">contracts-rb is an MIT-licensed Magnexis project. <a href="https://github.com/magnexis/contracts-rb">Source and issues</a>.</div></footer>
78
+ </body>
79
+ </html>
@@ -0,0 +1,3 @@
1
+ # Banking example
2
+
3
+ Run `ruby -Ilib examples/banking/demo.rb`. It demonstrates initialization invariants, immutable before snapshots, deep observed audit state, permitted mutations, and nested contract calls.
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+ require "contracts"
3
+
4
+ class BankAccount
5
+ include Contracts
6
+ attr_reader :balance, :status, :audit_log
7
+ invariant("balance cannot be negative") { balance >= 0 }
8
+ invariant("status is valid") { %i[active frozen closed].include?(status) }
9
+
10
+ contract :withdraw do
11
+ params amount: Numeric
12
+ observe :balance
13
+ observe :audit_log, deep: true
14
+ changes :balance, :audit_log
15
+ requires("positive amount") { |amount:| amount.positive? }
16
+ requires("active account") { status == :active }
17
+ ensures { |result, amount:, before:| result == amount && balance == before.balance - amount }
18
+ end
19
+ def initialize(balance:, status: :active) = (@balance = balance; @status = status; @audit_log = [])
20
+ def withdraw(amount:) = (@balance -= amount; @audit_log << { action: :withdraw, amount: amount }; amount)
21
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+ require_relative "transfer_service"
3
+ source = BankAccount.new(balance: 100)
4
+ destination = BankAccount.new(balance: 25)
5
+ puts TransferService.new.call(source: source, destination: destination, amount: 10)
6
+ puts Contracts.contract_for(BankAccount, :withdraw).to_json
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+ require_relative "bank_account"
3
+ class TransferService
4
+ include Contracts
5
+ contract :call do
6
+ params source: BankAccount, destination: BankAccount, amount: Numeric
7
+ requires { |source:, destination:, amount:| source != destination && amount.positive? }
8
+ returns TrueClass
9
+ end
10
+ def call(source:, destination:, amount:)
11
+ source.withdraw(amount: amount)
12
+ destination.instance_variable_set(:@balance, destination.balance + amount)
13
+ true
14
+ end
15
+ end
data/exe/contracts ADDED
@@ -0,0 +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
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "contracts"
4
+ begin
5
+ require "rails/railtie"
6
+ module Contracts
7
+ class Railtie < Rails::Railtie
8
+ initializer "contracts.logger" do
9
+ Contracts.configuration.logger ||= Rails.logger
10
+ end
11
+
12
+ config.to_prepare do
13
+ # Contract wrapping is idempotent, making reloads safe for existing contracts.
14
+ Contracts.configuration.enabled = true if Contracts.configuration.enabled.nil?
15
+ end
16
+ end
17
+ end
18
+ rescue LoadError
19
+ # Rails remains an optional dependency.
20
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "contracts"
4
+
5
+ module Contracts
6
+ module RSpec
7
+ module_function
8
+
9
+ def verify_all!
10
+ failures = Contracts.registry.all.filter_map do |contract|
11
+ next if contract.method_name == :__invariant__
12
+
13
+ owner = contract.owner
14
+ next if owner.method_defined?(contract.method_name) ||
15
+ owner.private_method_defined?(contract.method_name) ||
16
+ owner.protected_method_defined?(contract.method_name)
17
+
18
+ "#{contract.id} references a missing method"
19
+ end
20
+ raise Contracts::DefinitionError, failures.join("\n") unless failures.empty?
21
+
22
+ true
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "contracts"
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
33
+ RSpec::Matchers.define :have_pure_contract do |method_name|
34
+ match { |owner| (contract = Contracts.contract_for(owner, method_name)) && contract.pure? }
35
+ end
36
+
37
+ RSpec::Core::ExampleGroup.define_singleton_method(:describe_contract) do |owner, method_name, &block|
38
+ describe("#{owner}##{method_name}", &block)
39
+ end
40
+ rescue LoadError
41
+ # RSpec is optional at runtime.
42
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Contracts
4
+ VERSION = "0.1.2"
5
+ end