abstracta-contracts 0.1.0 → 0.1.1
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 +15 -1
- data/README.md +18 -1
- data/lib/abstracta_contracts/version.rb +3 -1
- data/lib/abstracta_contracts.rb +171 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e53d4729e866578c1bf2c42041f2d31e6342014dd9e7630ed8b20a3a61d7ed7d
|
|
4
|
+
data.tar.gz: 5a41643d7443f7a2d615b85a0f356f4d29ea2d657c29bc0574a7aaff147a7d08
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ba6cacb1bf1693a2ef04cd0b21eec16c42493b4e5a4509f0afd4ac09845b7acb1bd0a8ee1be23beae3bda1110e22a8e1187350f715e97d91a89aa80a535d8463
|
|
7
|
+
data.tar.gz: 6873cef89d4ca90309737095debf5b0f03d0db8d3a02b7da2eb13423909558e98157f8894745d31dff54e790dd2276a5a1f53610b3e00f2deea9d2be63dc1c6b
|
data/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to AbstractaContracts will be documented in this file.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Unreleased
|
|
6
|
+
|
|
7
|
+
## [0.1.1] - 2026-09-08
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Public YARD reference, generation task, and documentation check in CI.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- Group interface specs by definition, implementation, inheritance, and introspection.
|
|
16
|
+
- Make each spec explicitly load its helper for standalone execution.
|
|
17
|
+
- Correct the reusable-contract README example to preserve local variable scope.
|
|
18
|
+
|
|
19
|
+
## [0.1.0] - 2026-09-04
|
|
6
20
|
|
|
7
21
|
### Added
|
|
8
22
|
|
data/README.md
CHANGED
|
@@ -89,7 +89,7 @@ end
|
|
|
89
89
|
```ruby
|
|
90
90
|
cache_contract = AbstractaContracts.with_methods(:read, :write, :delete)
|
|
91
91
|
|
|
92
|
-
|
|
92
|
+
RedisCache = Class.new do
|
|
93
93
|
include cache_contract
|
|
94
94
|
|
|
95
95
|
def read(key) = nil
|
|
@@ -186,6 +186,23 @@ RedisCache.missing_interface_class_methods
|
|
|
186
186
|
- Interface requirements can be satisfied by the class, inherited implementations, or interface defaults.
|
|
187
187
|
- AbstractaContracts has no runtime dependencies.
|
|
188
188
|
|
|
189
|
+
## API reference
|
|
190
|
+
|
|
191
|
+
Run `bundle exec rake yard` and open `doc/index.html` for the public API.
|
|
192
|
+
The DSL entries on the AbstractaContracts page are methods installed on
|
|
193
|
+
consuming classes. Implementation details under `Internal` are unsupported.
|
|
194
|
+
Interface modules expose `interface?` (always true), `interface_methods`, and
|
|
195
|
+
`interface_class_methods` (frozen, deduplicated symbol arrays including parent
|
|
196
|
+
interfaces). Use `implements` to register an interface for validation; ordinary
|
|
197
|
+
Ruby inclusion alone does not register it as a class requirement.
|
|
198
|
+
|
|
199
|
+
Validation checks presence, not arity, argument types, or return values.
|
|
200
|
+
The constructor guard validates before forwarding arguments and blocks to
|
|
201
|
+
`new`; it does not prevent lower-level allocation or arbitrary Ruby overrides.
|
|
202
|
+
Rescue `AbstractaContracts::Error` for contract failures. Empty factory inputs
|
|
203
|
+
raise `ArgumentError`; including a factory result in the wrong kind of object
|
|
204
|
+
raises `TypeError`.
|
|
205
|
+
|
|
189
206
|
## Development
|
|
190
207
|
|
|
191
208
|
```bash
|
data/lib/abstracta_contracts.rb
CHANGED
|
@@ -7,15 +7,141 @@ require_relative "abstracta_contracts/internal/constructor_guard"
|
|
|
7
7
|
require_relative "abstracta_contracts/internal/contract"
|
|
8
8
|
require_relative "abstracta_contracts/internal/interface"
|
|
9
9
|
|
|
10
|
+
# Declarative abstract classes and reusable interfaces.
|
|
11
|
+
#
|
|
12
|
+
# Include this module in a class to install the class DSL documented below.
|
|
13
|
+
# The instance-method entries on this page describe methods installed on the
|
|
14
|
+
# consuming class, not methods on its instances or on AbstractaContracts itself.
|
|
15
|
+
# Contracts check method presence and ownership, not signatures or return types.
|
|
16
|
+
# Private and protected implementations count as present.
|
|
17
|
+
#
|
|
18
|
+
# @example Declare and implement a contract
|
|
19
|
+
# class Reader
|
|
20
|
+
# include AbstractaContracts.with_methods(:read)
|
|
21
|
+
# end
|
|
22
|
+
# class FileReader < Reader
|
|
23
|
+
# def read = :contents
|
|
24
|
+
# end
|
|
25
|
+
# FileReader.new.read # => :contents
|
|
10
26
|
module AbstractaContracts
|
|
27
|
+
# @!method abstract_class!
|
|
28
|
+
# Mark only this class explicitly abstract; descendants do not inherit the marker.
|
|
29
|
+
# @return [Class] self
|
|
30
|
+
#
|
|
31
|
+
# @!method abstract_method(*names)
|
|
32
|
+
# Declare inherited instance requirements. Redeclaration requires a fresh implementation.
|
|
33
|
+
# @return [Class] self
|
|
34
|
+
# @param names [Array<String, Symbol, Array>] names to flatten and deduplicate
|
|
35
|
+
# @raise [AbstractaContracts::Error] for invalid method names
|
|
36
|
+
#
|
|
37
|
+
# @!method abstract_class_method(*names)
|
|
38
|
+
# Declare inherited class requirements. Redeclaration requires a fresh implementation.
|
|
39
|
+
# @return [Class] self
|
|
40
|
+
# @param names [Array<String, Symbol, Array>] names to flatten and deduplicate
|
|
41
|
+
# @raise [AbstractaContracts::Error] for invalid method names
|
|
42
|
+
#
|
|
43
|
+
# @!method implements(*interfaces)
|
|
44
|
+
# Register interfaces and include their modules. Repeated registrations are deduplicated.
|
|
45
|
+
# @return [Class] self
|
|
46
|
+
# @param interfaces [Array<Module, Array>] interface modules, optionally nested
|
|
47
|
+
# @raise [AbstractaContracts::Error] if any module is not an interface
|
|
48
|
+
#
|
|
49
|
+
# @!method explicitly_abstract?
|
|
50
|
+
# Whether this class itself was marked explicitly abstract.
|
|
51
|
+
# @return [Boolean]
|
|
52
|
+
#
|
|
53
|
+
# @!method abstract?
|
|
54
|
+
# Whether explicitly abstract or missing any abstract or interface requirement.
|
|
55
|
+
# @return [Boolean]
|
|
56
|
+
#
|
|
57
|
+
# @!method concrete?
|
|
58
|
+
# Whether this class can pass contract validation.
|
|
59
|
+
# @return [Boolean]
|
|
60
|
+
#
|
|
61
|
+
# @!method valid_implementation?
|
|
62
|
+
# Whether this class is concrete, including its explicit abstract marker.
|
|
63
|
+
# @return [Boolean]
|
|
64
|
+
#
|
|
65
|
+
# @!method validate_implementation!
|
|
66
|
+
# Validate all requirements and the explicit abstract marker without constructing an instance.
|
|
67
|
+
# @return [true] when concrete
|
|
68
|
+
# @raise [AbstractaContracts::Error] if methods are missing or the class is explicitly abstract
|
|
69
|
+
#
|
|
70
|
+
# @!method abstract_methods
|
|
71
|
+
# Required abstract instance names, including inherited declarations.
|
|
72
|
+
# @return [Array<Symbol>] frozen names
|
|
73
|
+
#
|
|
74
|
+
# @!method abstract_class_methods
|
|
75
|
+
# Required abstract class names, including inherited declarations.
|
|
76
|
+
# @return [Array<Symbol>] frozen names
|
|
77
|
+
#
|
|
78
|
+
# @!method direct_interfaces
|
|
79
|
+
# Interfaces registered directly on this class.
|
|
80
|
+
# @return [Array<Module>] frozen snapshot
|
|
81
|
+
#
|
|
82
|
+
# @!method interfaces
|
|
83
|
+
# Registered interfaces including class inheritance and interface ancestry.
|
|
84
|
+
# @return [Array<Module>] deduplicated frozen snapshot
|
|
85
|
+
#
|
|
86
|
+
# @!method implements?(interface)
|
|
87
|
+
# Whether an interface occurs in the complete registered interface hierarchy.
|
|
88
|
+
# @return [Boolean]
|
|
89
|
+
# @param interface [Module] an interface module
|
|
90
|
+
# @raise [AbstractaContracts::Error] if the argument is not an interface
|
|
91
|
+
#
|
|
92
|
+
# @!method interface_methods
|
|
93
|
+
# Required instance names from all registered interfaces.
|
|
94
|
+
# @return [Array<Symbol>] deduplicated frozen names
|
|
95
|
+
#
|
|
96
|
+
# @!method interface_class_methods
|
|
97
|
+
# Required class names from all registered interfaces.
|
|
98
|
+
# @return [Array<Symbol>] deduplicated frozen names
|
|
99
|
+
#
|
|
100
|
+
# @!method missing_abstract_methods
|
|
101
|
+
# Unresolved abstract instance requirements.
|
|
102
|
+
# @return [Array<Symbol>] frozen names
|
|
103
|
+
#
|
|
104
|
+
# @!method missing_abstract_class_methods
|
|
105
|
+
# Unresolved abstract class requirements.
|
|
106
|
+
# @return [Array<Symbol>] frozen names
|
|
107
|
+
#
|
|
108
|
+
# @!method missing_interface_methods
|
|
109
|
+
# Unresolved interface instance requirements.
|
|
110
|
+
# @return [Array<Symbol>] frozen names
|
|
111
|
+
#
|
|
112
|
+
# @!method missing_interface_class_methods
|
|
113
|
+
# Unresolved interface class requirements.
|
|
114
|
+
# @return [Array<Symbol>] frozen names
|
|
115
|
+
#
|
|
116
|
+
# @!method missing_methods
|
|
117
|
+
# Unresolved combined abstract and interface instance requirements.
|
|
118
|
+
# @return [Array<Symbol>] frozen names
|
|
119
|
+
#
|
|
120
|
+
# @!method missing_class_methods
|
|
121
|
+
# Unresolved combined abstract and interface class requirements.
|
|
122
|
+
# @return [Array<Symbol>] frozen names
|
|
123
|
+
#
|
|
124
|
+
# @private
|
|
11
125
|
MUTEX_CREATION_LOCK = Mutex.new
|
|
12
126
|
private_constant :MUTEX_CREATION_LOCK
|
|
13
127
|
|
|
14
128
|
class << self
|
|
129
|
+
# @private
|
|
15
130
|
def included(base)
|
|
16
131
|
install(base)
|
|
17
132
|
end
|
|
18
133
|
|
|
134
|
+
# Build a reusable abstract contract for inclusion in classes.
|
|
135
|
+
# Names are flattened, converted to symbols, and deduplicated. Requirements
|
|
136
|
+
# are inherited; redeclarations require an implementation at or below the
|
|
137
|
+
# new declaration in the method lookup chain.
|
|
138
|
+
# @param methods [Array<String, Symbol, Array>] instance method names
|
|
139
|
+
# @param class_methods [Array<String, Symbol>] class method names
|
|
140
|
+
# @return [Module] reusable contract; include it in each consuming class
|
|
141
|
+
# @raise [ArgumentError] if both lists are empty
|
|
142
|
+
# @raise [AbstractaContracts::Error] if a name is neither a string nor a symbol,
|
|
143
|
+
# is empty, or contains whitespace
|
|
144
|
+
# @raise [TypeError] when the returned contract is included in a module
|
|
19
145
|
def with_methods(*methods, class_methods: [])
|
|
20
146
|
instance_methods = normalize_method_names(methods)
|
|
21
147
|
singleton_methods = normalize_method_names(Array(class_methods))
|
|
@@ -27,6 +153,24 @@ module AbstractaContracts
|
|
|
27
153
|
Internal::Contract.new(instance_methods: instance_methods, class_methods: singleton_methods)
|
|
28
154
|
end
|
|
29
155
|
|
|
156
|
+
# Build an interface definition for inclusion in a module.
|
|
157
|
+
# Include the result once in a module, then use the class DSL's implements
|
|
158
|
+
# method to register that module. Interfaces may include other interfaces
|
|
159
|
+
# and supply default instance methods. Inherited implementations also count.
|
|
160
|
+
# @param methods [Array<String, Symbol, Array>] instance method names
|
|
161
|
+
# @param class_methods [Array<String, Symbol>] class method names
|
|
162
|
+
# @return [Module] definition to include in an interface module
|
|
163
|
+
# @raise [ArgumentError] if both lists are empty
|
|
164
|
+
# @raise [AbstractaContracts::Error] for invalid names or repeated definition
|
|
165
|
+
# @raise [TypeError] when the definition is included in a class
|
|
166
|
+
# @example Define and implement an interface
|
|
167
|
+
# readable = Module.new { include AbstractaContracts.interface(:read) }
|
|
168
|
+
# reader = Class.new do
|
|
169
|
+
# include AbstractaContracts
|
|
170
|
+
# implements readable
|
|
171
|
+
# def read = :contents
|
|
172
|
+
# end
|
|
173
|
+
# reader.new.read # => :contents
|
|
30
174
|
def interface(*methods, class_methods: [])
|
|
31
175
|
instance_methods = normalize_method_names(methods)
|
|
32
176
|
singleton_methods = normalize_method_names(Array(class_methods))
|
|
@@ -38,6 +182,7 @@ module AbstractaContracts
|
|
|
38
182
|
Internal::Interface.new(instance_methods: instance_methods, class_methods: singleton_methods)
|
|
39
183
|
end
|
|
40
184
|
|
|
185
|
+
# @private
|
|
41
186
|
def install(base)
|
|
42
187
|
raise TypeError, "AbstractaContracts can only be included in classes" unless base.is_a?(Class)
|
|
43
188
|
|
|
@@ -47,6 +192,7 @@ module AbstractaContracts
|
|
|
47
192
|
base
|
|
48
193
|
end
|
|
49
194
|
|
|
195
|
+
# @private
|
|
50
196
|
def define_interface(base, instance_methods:, class_methods:)
|
|
51
197
|
if base.instance_variable_defined?(:@abstracta_contracts_interface_defined)
|
|
52
198
|
raise Internal::InterfaceAlreadyDefinedError,
|
|
@@ -60,20 +206,24 @@ module AbstractaContracts
|
|
|
60
206
|
base
|
|
61
207
|
end
|
|
62
208
|
|
|
209
|
+
# @private
|
|
63
210
|
def interface?(object)
|
|
64
211
|
object.is_a?(Module) && object.respond_to?(:interface?) && object.interface?
|
|
65
212
|
end
|
|
66
213
|
|
|
214
|
+
# @private
|
|
67
215
|
def validate_interface!(interface)
|
|
68
216
|
return interface if interface?(interface)
|
|
69
217
|
|
|
70
218
|
raise Internal::InvalidInterfaceError, "#{interface.inspect} is not an AbstractaContracts interface"
|
|
71
219
|
end
|
|
72
220
|
|
|
221
|
+
# @private
|
|
73
222
|
def normalize_interfaces(interfaces)
|
|
74
223
|
interfaces.flatten.map { |interface| validate_interface!(interface) }.uniq.freeze
|
|
75
224
|
end
|
|
76
225
|
|
|
226
|
+
# @private
|
|
77
227
|
def normalize_method_names(names)
|
|
78
228
|
names.flatten.map do |name|
|
|
79
229
|
unless name.is_a?(String) || name.is_a?(Symbol)
|
|
@@ -89,6 +239,7 @@ module AbstractaContracts
|
|
|
89
239
|
end.uniq.freeze
|
|
90
240
|
end
|
|
91
241
|
|
|
242
|
+
# @private
|
|
92
243
|
def register_instance_methods(base, names)
|
|
93
244
|
install(base)
|
|
94
245
|
synchronize(base) do
|
|
@@ -97,6 +248,7 @@ module AbstractaContracts
|
|
|
97
248
|
end
|
|
98
249
|
end
|
|
99
250
|
|
|
251
|
+
# @private
|
|
100
252
|
def register_class_methods(base, names)
|
|
101
253
|
install(base)
|
|
102
254
|
synchronize(base) do
|
|
@@ -105,26 +257,31 @@ module AbstractaContracts
|
|
|
105
257
|
end
|
|
106
258
|
end
|
|
107
259
|
|
|
260
|
+
# @private
|
|
108
261
|
def required_instance_methods_for(klass)
|
|
109
262
|
required_methods_for(klass, :abstracta_contracts_declared_instance_methods)
|
|
110
263
|
end
|
|
111
264
|
|
|
265
|
+
# @private
|
|
112
266
|
def required_class_methods_for(klass)
|
|
113
267
|
required_methods_for(klass, :abstracta_contracts_declared_class_methods)
|
|
114
268
|
end
|
|
115
269
|
|
|
270
|
+
# @private
|
|
116
271
|
def missing_instance_methods_for(klass)
|
|
117
272
|
required_instance_methods_for(klass).filter_map do |name, declaration_owner|
|
|
118
273
|
name unless implemented_after_declaration?(klass, name, declaration_owner, singleton: false)
|
|
119
274
|
end
|
|
120
275
|
end
|
|
121
276
|
|
|
277
|
+
# @private
|
|
122
278
|
def missing_class_methods_for(klass)
|
|
123
279
|
required_class_methods_for(klass).filter_map do |name, declaration_owner|
|
|
124
280
|
name unless implemented_after_declaration?(klass, name, declaration_owner, singleton: true)
|
|
125
281
|
end
|
|
126
282
|
end
|
|
127
283
|
|
|
284
|
+
# @private
|
|
128
285
|
def interfaces_for(klass)
|
|
129
286
|
result = []
|
|
130
287
|
|
|
@@ -140,6 +297,7 @@ module AbstractaContracts
|
|
|
140
297
|
result
|
|
141
298
|
end
|
|
142
299
|
|
|
300
|
+
# @private
|
|
143
301
|
def interface_instance_methods_for(interface)
|
|
144
302
|
validate_interface!(interface)
|
|
145
303
|
interface_hierarchy(interface).flat_map do |candidate|
|
|
@@ -147,6 +305,7 @@ module AbstractaContracts
|
|
|
147
305
|
end.uniq.freeze
|
|
148
306
|
end
|
|
149
307
|
|
|
308
|
+
# @private
|
|
150
309
|
def interface_class_methods_for(interface)
|
|
151
310
|
validate_interface!(interface)
|
|
152
311
|
interface_hierarchy(interface).flat_map do |candidate|
|
|
@@ -154,40 +313,48 @@ module AbstractaContracts
|
|
|
154
313
|
end.uniq.freeze
|
|
155
314
|
end
|
|
156
315
|
|
|
316
|
+
# @private
|
|
157
317
|
def required_interface_instance_methods_for(klass)
|
|
158
318
|
interfaces_for(klass).flat_map { |interface| interface_instance_methods_for(interface) }.uniq
|
|
159
319
|
end
|
|
160
320
|
|
|
321
|
+
# @private
|
|
161
322
|
def required_interface_class_methods_for(klass)
|
|
162
323
|
interfaces_for(klass).flat_map { |interface| interface_class_methods_for(interface) }.uniq
|
|
163
324
|
end
|
|
164
325
|
|
|
326
|
+
# @private
|
|
165
327
|
def missing_interface_instance_methods_for(klass)
|
|
166
328
|
required_interface_instance_methods_for(klass).reject do |name|
|
|
167
329
|
method_available?(klass, name, singleton: false)
|
|
168
330
|
end
|
|
169
331
|
end
|
|
170
332
|
|
|
333
|
+
# @private
|
|
171
334
|
def missing_interface_class_methods_for(klass)
|
|
172
335
|
required_interface_class_methods_for(klass).reject do |name|
|
|
173
336
|
method_available?(klass, name, singleton: true)
|
|
174
337
|
end
|
|
175
338
|
end
|
|
176
339
|
|
|
340
|
+
# @private
|
|
177
341
|
def synchronize(base, &)
|
|
178
342
|
mutex_for(base).synchronize(&)
|
|
179
343
|
end
|
|
180
344
|
|
|
345
|
+
# @private
|
|
181
346
|
def class_name(klass)
|
|
182
347
|
klass.name || klass.inspect
|
|
183
348
|
end
|
|
184
349
|
|
|
350
|
+
# @private
|
|
185
351
|
def interface_name(interface)
|
|
186
352
|
interface.name || interface.inspect
|
|
187
353
|
end
|
|
188
354
|
|
|
189
355
|
private
|
|
190
356
|
|
|
357
|
+
# @private
|
|
191
358
|
def mutex_for(base)
|
|
192
359
|
if base.instance_variable_defined?(:@abstracta_contracts_mutex)
|
|
193
360
|
return base.instance_variable_get(:@abstracta_contracts_mutex)
|
|
@@ -199,6 +366,7 @@ module AbstractaContracts
|
|
|
199
366
|
end
|
|
200
367
|
end
|
|
201
368
|
|
|
369
|
+
# @private
|
|
202
370
|
def required_methods_for(klass, reader)
|
|
203
371
|
declarations = {}
|
|
204
372
|
|
|
@@ -212,6 +380,7 @@ module AbstractaContracts
|
|
|
212
380
|
declarations.freeze
|
|
213
381
|
end
|
|
214
382
|
|
|
383
|
+
# @private
|
|
215
384
|
def implemented_after_declaration?(klass, name, declaration_owner, singleton:)
|
|
216
385
|
lookup_class = singleton ? klass.singleton_class : klass
|
|
217
386
|
declaration_lookup_owner = singleton ? declaration_owner.singleton_class : declaration_owner
|
|
@@ -228,6 +397,7 @@ module AbstractaContracts
|
|
|
228
397
|
false
|
|
229
398
|
end
|
|
230
399
|
|
|
400
|
+
# @private
|
|
231
401
|
def method_available?(klass, name, singleton:)
|
|
232
402
|
lookup = singleton ? klass.singleton_class : klass
|
|
233
403
|
lookup.instance_method(name)
|
|
@@ -236,6 +406,7 @@ module AbstractaContracts
|
|
|
236
406
|
false
|
|
237
407
|
end
|
|
238
408
|
|
|
409
|
+
# @private
|
|
239
410
|
def interface_hierarchy(interface)
|
|
240
411
|
validate_interface!(interface)
|
|
241
412
|
|