specproof 0.1.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 +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +283 -0
- data/docs/agent-skills.md +60 -0
- data/docs/benchmark.md +24 -0
- data/docs/constrained-partition-pairs-results.md +44 -0
- data/docs/finite-strings-results.md +75 -0
- data/docs/implementation-status.md +65 -0
- data/docs/input-selection-results.md +123 -0
- data/docs/lint-cleanup-plan.md +12 -0
- data/docs/method-targets-results.md +68 -0
- data/docs/minitest-compatibility.md +33 -0
- data/docs/optional-arguments-plan.md +25 -0
- data/docs/optional-arguments-results.md +45 -0
- data/docs/pairwise-partitions-results.md +53 -0
- data/docs/pairwise-results.md +77 -0
- data/docs/pairwise-suggestions-results.md +104 -0
- data/docs/rbs-implementation-status.md +115 -0
- data/docs/rbs-string-literals-results.md +58 -0
- data/docs/real-project-pilot.md +84 -0
- data/docs/report-format.md +244 -0
- data/examples/age_policy/README.md +39 -0
- data/examples/age_policy/faulty/lib/age_policy.rb +9 -0
- data/examples/age_policy/faulty/test/age_policy_examples_test.rb +17 -0
- data/examples/age_policy/faulty/test/age_policy_test.rb +21 -0
- data/examples/age_policy/lib/age_policy.rb +8 -0
- data/examples/age_policy/run.rb +23 -0
- data/examples/age_policy/specification.rb +8 -0
- data/examples/age_policy/test/age_policy_test.rb +18 -0
- data/examples/constrained_partition_pairs/README.md +40 -0
- data/examples/constrained_partition_pairs/lib/account_eligibility.rb +8 -0
- data/examples/constrained_partition_pairs/requirements.md +11 -0
- data/examples/constrained_partition_pairs/run.rb +22 -0
- data/examples/constrained_partition_pairs/specification.rb +17 -0
- data/examples/constrained_partition_pairs/test_account_eligibility.rb +34 -0
- data/examples/input_selection/README.md +22 -0
- data/examples/input_selection/delivery_policy.rb +11 -0
- data/examples/input_selection/requirements.md +9 -0
- data/examples/input_selection/specification.rb +8 -0
- data/examples/input_selection/test_delivery_policy.rb +38 -0
- data/examples/method_targets/README.md +30 -0
- data/examples/method_targets/lib/delivery_defaults.rb +8 -0
- data/examples/method_targets/lib/delivery_options.rb +12 -0
- data/examples/method_targets/requirements.md +20 -0
- data/examples/method_targets/specification_objects.rb +17 -0
- data/examples/method_targets/specification_strings.rb +15 -0
- data/examples/pairwise/README.md +46 -0
- data/examples/pairwise/constrained_requirements.md +6 -0
- data/examples/pairwise/constrained_specification.rb +11 -0
- data/examples/pairwise/corrected/lib/delivery_options.rb +11 -0
- data/examples/pairwise/corrected/test/delivery_options_test.rb +28 -0
- data/examples/pairwise/faulty/lib/delivery_options.rb +11 -0
- data/examples/pairwise/faulty/test/gap_delivery_options_test.rb +10 -0
- data/examples/pairwise/faulty/test/initial_delivery_options_test.rb +23 -0
- data/examples/pairwise/requirements.md +11 -0
- data/examples/pairwise/run.rb +36 -0
- data/examples/pairwise/specification.rb +9 -0
- data/examples/pairwise/three_boolean/lib/boolean_batch.rb +8 -0
- data/examples/pairwise/three_boolean/test/boolean_batch_test.rb +28 -0
- data/examples/pairwise/three_boolean_specification.rb +10 -0
- data/examples/pairwise_partitions/README.md +34 -0
- data/examples/pairwise_partitions/lib/age_policy.rb +8 -0
- data/examples/pairwise_partitions/requirements.md +8 -0
- data/examples/pairwise_partitions/run.rb +22 -0
- data/examples/pairwise_partitions/specification.rb +13 -0
- data/examples/pairwise_partitions/test_age_policy.rb +29 -0
- data/examples/pairwise_suggestions/README.md +48 -0
- data/examples/pairwise_suggestions/run.rb +55 -0
- data/examples/pairwise_suggestions/three_boolean/lib/suggested_boolean_batch.rb +8 -0
- data/examples/pairwise_suggestions/three_boolean/requirements.md +7 -0
- data/examples/pairwise_suggestions/three_boolean/specification.rb +11 -0
- data/examples/pairwise_suggestions/three_boolean/test/baseline_test.rb +9 -0
- data/examples/pairwise_suggestions/three_boolean/test/complete_test.rb +22 -0
- data/examples/partition_gap.rb +8 -0
- data/examples/rbs_registration/README.md +58 -0
- data/examples/rbs_registration/implementations/age_gt/registration.rb +8 -0
- data/examples/rbs_registration/implementations/age_gte/registration.rb +8 -0
- data/examples/rbs_registration/requirements.md +9 -0
- data/examples/rbs_registration/sig/registration.rbs +3 -0
- data/examples/rbs_registration/specification.rb +10 -0
- data/examples/rbs_registration/test_registration.rb +43 -0
- data/examples/rbs_string_literals/README.md +34 -0
- data/examples/rbs_string_literals/lib/delivery_options.rb +9 -0
- data/examples/rbs_string_literals/requirements.md +11 -0
- data/examples/rbs_string_literals/sig/delivery_options.rbs +3 -0
- data/examples/rbs_string_literals/specification.rb +7 -0
- data/examples/rbs_string_literals/test_delivery_options.rb +46 -0
- data/examples/shipping.rb +12 -0
- data/exe/specproof +7 -0
- data/lib/specproof/checker.rb +144 -0
- data/lib/specproof/cli.rb +292 -0
- data/lib/specproof/collector.rb +304 -0
- data/lib/specproof/combinations.rb +226 -0
- data/lib/specproof/composer.rb +97 -0
- data/lib/specproof/coverage.rb +229 -0
- data/lib/specproof/coverage_report.rb +334 -0
- data/lib/specproof/domains.rb +124 -0
- data/lib/specproof/loader.rb +285 -0
- data/lib/specproof/minitest.rb +443 -0
- data/lib/specproof/model.rb +198 -0
- data/lib/specproof/partition_space.rb +268 -0
- data/lib/specproof/planner.rb +125 -0
- data/lib/specproof/report.rb +248 -0
- data/lib/specproof/skills.rb +188 -0
- data/lib/specproof/sources/rbs.rb +137 -0
- data/lib/specproof/sources/rbs_projector.rb +268 -0
- data/lib/specproof/suggestion_report.rb +159 -0
- data/lib/specproof/suggestions.rb +352 -0
- data/lib/specproof/target_name.rb +100 -0
- data/lib/specproof/type_constraints.rb +76 -0
- data/lib/specproof/version.rb +7 -0
- data/lib/specproof.rb +100 -0
- data/sig/specproof.rbs +222 -0
- data/skills/specproof-specify/SKILL.md +282 -0
- data/skills/specproof-specify/references/usage.md +265 -0
- data/skills/specproof-test-gaps/SKILL.md +214 -0
- data/skills/specproof-test-gaps/references/usage.md +231 -0
- metadata +165 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "target_name"
|
|
4
|
+
|
|
5
|
+
module SpecProof
|
|
6
|
+
# Captures method calls during a test run and matches them to obligations.
|
|
7
|
+
class Collector
|
|
8
|
+
# `method` is part of the collector's internal target record.
|
|
9
|
+
# rubocop:disable-next Lint/StructNewOverride
|
|
10
|
+
Target = Struct.new(:name, :owner, :constant_names, :method_owner, :method_name, :method, :method_ids, :input_names,
|
|
11
|
+
keyword_init: true)
|
|
12
|
+
|
|
13
|
+
def initialize(specifications:, observer:)
|
|
14
|
+
@specifications = specifications
|
|
15
|
+
@observer = observer
|
|
16
|
+
@issues = []
|
|
17
|
+
@issue_keys = {}
|
|
18
|
+
@disabled_targets = {}
|
|
19
|
+
@tracepoint = nil
|
|
20
|
+
@pid = nil
|
|
21
|
+
@thread = nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def start
|
|
25
|
+
raise_configuration_error("collector is already started", code: "collector_already_started") if enabled?
|
|
26
|
+
|
|
27
|
+
targets = @specifications.map { |specification| build_target(specification) }
|
|
28
|
+
duplicate = targets.group_by(&:name).find { |_name, entries| entries.length > 1 }
|
|
29
|
+
if duplicate
|
|
30
|
+
raise_configuration_error("duplicate target #{duplicate.first}", target: duplicate.first,
|
|
31
|
+
code: "duplicate_target")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
duplicate_binding = {}
|
|
35
|
+
targets.each do |target|
|
|
36
|
+
target.method_ids.each do |method_id|
|
|
37
|
+
binding = [target.method_owner, method_id]
|
|
38
|
+
if duplicate_binding.key?(binding)
|
|
39
|
+
raise_configuration_error("multiple targets resolve to the same runtime method binding",
|
|
40
|
+
target: target.name, code: "duplicate_binding")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
duplicate_binding[binding] = target
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
@targets = targets.to_h { |target| [target.name, target] }
|
|
48
|
+
@target_methods = duplicate_binding
|
|
49
|
+
@disabled_targets = {}
|
|
50
|
+
@pid = Process.pid
|
|
51
|
+
@thread = Thread.current
|
|
52
|
+
@tracepoint = TracePoint.new(:call) { |trace| collect(trace) }
|
|
53
|
+
@tracepoint.enable
|
|
54
|
+
self
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
@tracepoint&.disable
|
|
57
|
+
@tracepoint = nil
|
|
58
|
+
@targets = nil
|
|
59
|
+
@target_methods = nil
|
|
60
|
+
raise if configuration_error?(e)
|
|
61
|
+
|
|
62
|
+
raise_configuration_error("collector could not start: #{e.class}", code: "collector_start_failed")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def stop
|
|
66
|
+
tracepoint = @tracepoint
|
|
67
|
+
return nil unless tracepoint
|
|
68
|
+
|
|
69
|
+
begin
|
|
70
|
+
tracepoint.disable
|
|
71
|
+
ensure
|
|
72
|
+
@tracepoint = nil
|
|
73
|
+
@targets&.each_value do |target|
|
|
74
|
+
report_redefinition(target)
|
|
75
|
+
report_binding_change(target)
|
|
76
|
+
end
|
|
77
|
+
@target_methods = nil
|
|
78
|
+
@pid = nil
|
|
79
|
+
@thread = nil
|
|
80
|
+
end
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def enabled?
|
|
85
|
+
@tracepoint&.enabled? || false
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def issues
|
|
89
|
+
@issues.map { |issue| issue.dup.freeze }.freeze
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def build_target(specification)
|
|
95
|
+
target_name = specification.target
|
|
96
|
+
unless target_name.is_a?(String) && !target_name.empty?
|
|
97
|
+
raise_configuration_error("target must be a non-empty String", code: "invalid_target")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
owner, owner_names, method_owner, method_symbol, method = resolve_target(target_name)
|
|
101
|
+
|
|
102
|
+
input_names, exclusion_names = declaration_names(specification, target_name)
|
|
103
|
+
|
|
104
|
+
parameters = method.parameters
|
|
105
|
+
unless parameters.all? { |kind, _name| %i[req opt keyreq key].include?(kind) }
|
|
106
|
+
raise_configuration_error("#{target_name} may only use named positional or keyword parameters",
|
|
107
|
+
target: target_name, code: "unsupported_parameters")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
declared_names = parameters.map(&:last)
|
|
111
|
+
input_names_match = specification.inputs.all? { |name, input| input.name == name }
|
|
112
|
+
names_match = valid_parameter_names?(declared_names, input_names, exclusion_names)
|
|
113
|
+
unless names_match && input_names_match
|
|
114
|
+
raise_configuration_error("#{target_name} parameters must exactly match analyzed and excluded input names",
|
|
115
|
+
target: target_name, code: "parameter_mismatch")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
method_ids = method_owner.public_instance_methods(false).select do |candidate|
|
|
119
|
+
method_owner.instance_method(candidate) == method
|
|
120
|
+
rescue NameError
|
|
121
|
+
false
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
Target.new(
|
|
125
|
+
name: target_name.freeze,
|
|
126
|
+
owner: owner,
|
|
127
|
+
constant_names: owner_names.map(&:freeze).freeze,
|
|
128
|
+
method_owner: method_owner,
|
|
129
|
+
method_name: method_symbol,
|
|
130
|
+
method: method,
|
|
131
|
+
method_ids: method_ids.freeze,
|
|
132
|
+
input_names: declared_names.select { |name| input_names.include?(name) }.freeze
|
|
133
|
+
).freeze
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def resolve_target(target_name)
|
|
137
|
+
owner_name, method_name, kind = TargetName.parse(target_name)
|
|
138
|
+
owner_names = owner_name.delete_prefix("::").split("::")
|
|
139
|
+
owner = resolve_class(owner_names, target_name)
|
|
140
|
+
unless owner.is_a?(Module) && (kind == :singleton || owner.is_a?(Class))
|
|
141
|
+
raise_configuration_error("target owner must be a named Class or Module",
|
|
142
|
+
target: target_name, code: "invalid_target_owner")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
method_owner = kind == :singleton ? owner.singleton_class : owner
|
|
146
|
+
method_symbol = method_name.to_sym
|
|
147
|
+
unless method_owner.public_method_defined?(method_symbol, false)
|
|
148
|
+
visibility = kind == :singleton ? "public singleton method" : "public instance method"
|
|
149
|
+
raise_configuration_error("#{target_name} must name a directly defined #{visibility}",
|
|
150
|
+
target: target_name, code: "invalid_method")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
method = method_owner.instance_method(method_symbol)
|
|
154
|
+
unless method.owner == method_owner && method.original_name == method_symbol
|
|
155
|
+
raise_configuration_error("#{target_name} cannot name an inherited or aliased method", target: target_name,
|
|
156
|
+
code: "invalid_method")
|
|
157
|
+
end
|
|
158
|
+
if method.source_location.nil?
|
|
159
|
+
raise_configuration_error("#{target_name} must have a Ruby source location", target: target_name,
|
|
160
|
+
code: "invalid_method")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
[owner, owner_names, method_owner, method_symbol, method]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def declaration_names(specification, target_name)
|
|
167
|
+
input_names = specification.inputs.keys
|
|
168
|
+
exclusions = specification.respond_to?(:exclusions) ? specification.exclusions : {}
|
|
169
|
+
exclusion_names = exclusions.keys
|
|
170
|
+
unless input_names.all?(Symbol)
|
|
171
|
+
raise_configuration_error("#{target_name} inputs must use symbol names", target: target_name,
|
|
172
|
+
code: "invalid_inputs")
|
|
173
|
+
end
|
|
174
|
+
unless exclusion_names.all?(Symbol) && exclusions.all? do |name, item|
|
|
175
|
+
item.name == name && item.reason.is_a?(String) && !item.reason.strip.empty?
|
|
176
|
+
end
|
|
177
|
+
raise_configuration_error("#{target_name} exclusions must have valid names and reasons",
|
|
178
|
+
target: target_name, code: "invalid_exclusions")
|
|
179
|
+
end
|
|
180
|
+
[input_names, exclusion_names]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def valid_parameter_names?(declared_names, input_names, exclusion_names)
|
|
184
|
+
declared_names.none?(&:nil?) && declared_names.sort == (input_names | exclusion_names).sort &&
|
|
185
|
+
!input_names.intersect?(exclusion_names)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def resolve_class(constant_names, target_name)
|
|
189
|
+
value = Object
|
|
190
|
+
constant_names.each do |constant_name|
|
|
191
|
+
symbol = constant_name.to_sym
|
|
192
|
+
if value.autoload?(symbol)
|
|
193
|
+
raise_configuration_error("#{target_name} cannot resolve an autoloaded constant", target: target_name,
|
|
194
|
+
code: "autoloaded_target")
|
|
195
|
+
end
|
|
196
|
+
unless value.const_defined?(symbol, false)
|
|
197
|
+
raise_configuration_error("#{target_name} references an undefined constant", target: target_name,
|
|
198
|
+
code: "undefined_target")
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
value = value.const_get(symbol, false)
|
|
202
|
+
end
|
|
203
|
+
value
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def collect(trace)
|
|
207
|
+
return unless Process.pid == @pid && Thread.current.equal?(@thread)
|
|
208
|
+
|
|
209
|
+
target = @target_methods[[trace.defined_class, trace.method_id]]
|
|
210
|
+
return unless target
|
|
211
|
+
return if target.method_owner != trace.defined_class
|
|
212
|
+
return if @disabled_targets[target.name]
|
|
213
|
+
|
|
214
|
+
unless method_unchanged?(target)
|
|
215
|
+
disable_target(target, "target_redefined")
|
|
216
|
+
return
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
arguments = copy_arguments(trace, target)
|
|
220
|
+
@observer.observe(target: target.name, arguments: arguments)
|
|
221
|
+
rescue StandardError => e
|
|
222
|
+
disable_target(target, error_code(e)) if target
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def copy_arguments(trace, target)
|
|
226
|
+
binding = trace.binding
|
|
227
|
+
target.input_names.to_h do |name|
|
|
228
|
+
value = binding.local_variable_get(name)
|
|
229
|
+
raise ArgumentError, "non_primitive_argument" unless primitive?(value)
|
|
230
|
+
|
|
231
|
+
[name, Domains.supported_string?(value) ? Domains.canonical_string(value) : value]
|
|
232
|
+
end.freeze
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def primitive?(value)
|
|
236
|
+
Domains.primitive?(value)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def error_code(error)
|
|
240
|
+
return "non_primitive_argument" if error.message == "non_primitive_argument"
|
|
241
|
+
return "binding_failure" if error.is_a?(NameError)
|
|
242
|
+
|
|
243
|
+
"observer_error"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def disable_target(target, code)
|
|
247
|
+
@disabled_targets[target.name] = true
|
|
248
|
+
add_issue(target.name, code, "collector disabled this target after an observation failure")
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def report_redefinition(target)
|
|
252
|
+
return if method_unchanged?(target)
|
|
253
|
+
|
|
254
|
+
add_issue(target.name, "target_redefined", "target method changed while collection was active")
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def report_binding_change(target)
|
|
258
|
+
return if target_binding_unchanged?(target)
|
|
259
|
+
|
|
260
|
+
add_issue(target.name, "target_binding_changed", "target constant binding changed while collection was active")
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def target_binding_unchanged?(target)
|
|
264
|
+
value = Object
|
|
265
|
+
target.constant_names.each do |constant_name|
|
|
266
|
+
return false unless value.is_a?(Module)
|
|
267
|
+
|
|
268
|
+
symbol = constant_name.to_sym
|
|
269
|
+
return false if value.autoload?(symbol) || !value.const_defined?(symbol, false)
|
|
270
|
+
|
|
271
|
+
value = value.const_get(symbol, false)
|
|
272
|
+
end
|
|
273
|
+
value.equal?(target.owner)
|
|
274
|
+
rescue NameError
|
|
275
|
+
false
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def method_unchanged?(target)
|
|
279
|
+
target.method_owner.instance_method(target.method_name) == target.method
|
|
280
|
+
rescue NameError
|
|
281
|
+
false
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def add_issue(target, code, message)
|
|
285
|
+
key = [target, code]
|
|
286
|
+
return if @issue_keys[key]
|
|
287
|
+
|
|
288
|
+
@issue_keys[key] = true
|
|
289
|
+
@issues << { target: target, code: code, message: message }.freeze
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def raise_configuration_error(message, target: nil, code: "configuration_error")
|
|
293
|
+
add_issue(target, code, message)
|
|
294
|
+
error_class = SpecProof.const_get(:ConfigurationError, false)
|
|
295
|
+
raise error_class.new(message, code: code)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def configuration_error?(error)
|
|
299
|
+
error.is_a?(SpecProof.const_get(:ConfigurationError, false))
|
|
300
|
+
rescue NameError
|
|
301
|
+
false
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecProof
|
|
4
|
+
# Collects static partial assignments declared in a combinations block.
|
|
5
|
+
class CombinationBuilder
|
|
6
|
+
attr_reader :forbidden
|
|
7
|
+
|
|
8
|
+
def initialize(names, source)
|
|
9
|
+
@names = names.freeze
|
|
10
|
+
@source = source
|
|
11
|
+
@forbidden = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def in_partition(name)
|
|
15
|
+
source = Loader.call_source
|
|
16
|
+
unless name.is_a?(Symbol) && !name.to_s.empty?
|
|
17
|
+
raise ConfigurationError.new("partition reference name must be a non-empty symbol",
|
|
18
|
+
code: "invalid_partition_reference", source: source || @source)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
PartitionReference.new(name)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def forbid(**assignments)
|
|
25
|
+
source = Loader.call_source
|
|
26
|
+
if assignments.empty?
|
|
27
|
+
raise ConfigurationError.new("forbid requires at least one assignment", code: "empty_forbidden_assignment",
|
|
28
|
+
source: source || @source)
|
|
29
|
+
end
|
|
30
|
+
unknown = assignments.keys - @names
|
|
31
|
+
unless unknown.empty?
|
|
32
|
+
raise ConfigurationError.new("forbidden assignment names non-group input #{unknown.first.inspect}",
|
|
33
|
+
code: "unknown_forbidden_input", source: source || @source)
|
|
34
|
+
end
|
|
35
|
+
unless assignments.values.all? { |value| Domains.primitive?(value) || value.is_a?(PartitionReference) }
|
|
36
|
+
raise ConfigurationError.new("forbidden values must be primitive values or whole partition references",
|
|
37
|
+
code: "unsupported_value", source: source || @source)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
forbidden << ForbiddenAssignment.new(assignments: assignments, source: source || @source)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Enumerates a bounded finite joint domain and the pair rows it permits.
|
|
45
|
+
class CombinationSpace
|
|
46
|
+
MAX_ASSIGNMENTS = 10_000
|
|
47
|
+
MAX_PAIR_ROWS = 10_000
|
|
48
|
+
|
|
49
|
+
attr_reader :pairs, :excluded_pairs, :allowed_count, :sources
|
|
50
|
+
|
|
51
|
+
def initialize(specification)
|
|
52
|
+
@specification = specification
|
|
53
|
+
@group = specification.combination_group
|
|
54
|
+
unless @group
|
|
55
|
+
raise ConfigurationError.new("target has no combinations declaration: #{specification.target}",
|
|
56
|
+
code: "combinations_required", source: specification.source)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
validate_group
|
|
60
|
+
check_limits
|
|
61
|
+
enumerate
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def allowed?(arguments)
|
|
65
|
+
normalized = arguments.each_with_object({}) do |(name, value), values|
|
|
66
|
+
values[name.to_sym] = canonical_value(value)
|
|
67
|
+
end
|
|
68
|
+
return false unless @group.inputs.all? do |name|
|
|
69
|
+
normalized.key?(name) && Domains.include?(@inputs.fetch(name).domain, normalized.fetch(name))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
@group.forbidden.none? do |rule|
|
|
73
|
+
rule.assignments.all? do |name, value|
|
|
74
|
+
normalized.key?(name) && same_value?(normalized.fetch(name), value)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def allowed_assignments
|
|
80
|
+
@allowed_assignments ||= canonical_allowed_assignments
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def validate_group
|
|
86
|
+
if @group.inputs.length < 2
|
|
87
|
+
raise ConfigurationError.new("a combinations group requires at least two inputs",
|
|
88
|
+
code: "invalid_combination_inputs", source: @group.source)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
@inputs = group_inputs.freeze
|
|
92
|
+
validate_forbidden_assignments
|
|
93
|
+
collect_sources
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def group_inputs
|
|
97
|
+
@group.inputs.to_h do |name|
|
|
98
|
+
input = @specification.inputs[name]
|
|
99
|
+
if @specification.exclusions.key?(name)
|
|
100
|
+
raise ConfigurationError.new("excluded input #{name.inspect} cannot be in a combinations group",
|
|
101
|
+
code: "excluded_combination_input", source: @group.source)
|
|
102
|
+
end
|
|
103
|
+
unless input
|
|
104
|
+
raise ConfigurationError.new("unknown combinations input #{name.inspect}",
|
|
105
|
+
code: "unknown_combination_input", source: @group.source)
|
|
106
|
+
end
|
|
107
|
+
unless input.domain.is_a?(FiniteDomain)
|
|
108
|
+
raise ConfigurationError.new("combinations input #{name.inspect} must have finite values",
|
|
109
|
+
code: "finite_combinations_required", source: input.source)
|
|
110
|
+
end
|
|
111
|
+
[name, input]
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def validate_forbidden_assignments
|
|
116
|
+
@group.forbidden.each do |rule|
|
|
117
|
+
if rule.assignments.empty?
|
|
118
|
+
raise ConfigurationError.new("forbid requires at least one assignment",
|
|
119
|
+
code: "empty_forbidden_assignment", source: rule.source)
|
|
120
|
+
end
|
|
121
|
+
rule.assignments.each do |name, value|
|
|
122
|
+
input = @inputs[name]
|
|
123
|
+
unless input
|
|
124
|
+
raise ConfigurationError.new("forbidden assignment names non-group input #{name.inspect}",
|
|
125
|
+
code: "unknown_forbidden_input", source: rule.source)
|
|
126
|
+
end
|
|
127
|
+
if value.is_a?(PartitionReference)
|
|
128
|
+
raise ConfigurationError.new("partition reference for #{name.inspect} requires an integer input",
|
|
129
|
+
code: "partition_reference_requires_integer_input", source: rule.source)
|
|
130
|
+
end
|
|
131
|
+
unless Domains.primitive?(value) && Domains.include?(input.domain, value)
|
|
132
|
+
raise ConfigurationError.new("forbidden value #{value.inspect} is outside #{name.inspect}'s finite domain",
|
|
133
|
+
code: "invalid_combination_value", source: rule.source)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def collect_sources
|
|
140
|
+
@sources = ([@group.source] + @inputs.values.flat_map(&:sources) + @group.forbidden.map(&:source))
|
|
141
|
+
.compact.uniq.sort_by { |source| [source.path, source.line, source.kind] }.freeze
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def check_limits
|
|
145
|
+
assignment_count = @inputs.values.reduce(1) { |count, input| count * input.domain.values.length }
|
|
146
|
+
raise limit_error("complete assignments", assignment_count, @group.source) if assignment_count > MAX_ASSIGNMENTS
|
|
147
|
+
|
|
148
|
+
pair_rows = 0
|
|
149
|
+
@group.inputs.each_with_index do |left, index|
|
|
150
|
+
@group.inputs.drop(index + 1).each do |right|
|
|
151
|
+
pair_rows += @inputs.fetch(left).domain.values.length * @inputs.fetch(right).domain.values.length
|
|
152
|
+
raise limit_error("possible pair rows", pair_rows, @group.source) if pair_rows > MAX_PAIR_ROWS
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def limit_error(kind, count, source)
|
|
158
|
+
ConfigurationError.new("combinations declaration has #{count} #{kind}; limit is #{MAX_ASSIGNMENTS}",
|
|
159
|
+
code: "combination_limit_exceeded", source: source)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def enumerate
|
|
163
|
+
allowed = canonical_allowed_assignments
|
|
164
|
+
@allowed_count = allowed.length
|
|
165
|
+
pair_rows = {}
|
|
166
|
+
allowed.each do |assignment|
|
|
167
|
+
each_input_pair do |left, right|
|
|
168
|
+
row = { left => assignment.fetch(left), right => assignment.fetch(right) }.freeze
|
|
169
|
+
pair_rows[pair_key(row)] ||= row
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
@pairs = pair_rows.values.sort_by { |row| row_key(row) }.freeze
|
|
173
|
+
all_rows = []
|
|
174
|
+
each_input_pair do |left, right|
|
|
175
|
+
left_values = @inputs.fetch(left).domain.values
|
|
176
|
+
right_values = @inputs.fetch(right).domain.values
|
|
177
|
+
left_values.each do |left_value|
|
|
178
|
+
right_values.each do |right_value|
|
|
179
|
+
all_rows << { left => left_value, right => right_value }.freeze
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
feasible_keys = pair_rows.keys.to_h { |key| [key, true] }
|
|
184
|
+
@excluded_pairs = all_rows.reject { |row| feasible_keys.key?(pair_key(row)) }
|
|
185
|
+
.sort_by { |row| row_key(row) }.freeze
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def canonical_allowed_assignments
|
|
189
|
+
complete_assignments.select { |assignment| allowed?(assignment) }.map(&:freeze).freeze
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def complete_assignments
|
|
193
|
+
return [] if @group.inputs.any? { |name| @inputs.fetch(name).domain.values.empty? }
|
|
194
|
+
|
|
195
|
+
rows = [{}]
|
|
196
|
+
@group.inputs.sort_by(&:to_s).each do |name|
|
|
197
|
+
rows = rows.flat_map do |row|
|
|
198
|
+
@inputs.fetch(name).domain.values.map { |value| row.merge(name => value) }
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
rows
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def each_input_pair
|
|
205
|
+
@group.inputs.each_with_index do |left, index|
|
|
206
|
+
@group.inputs.drop(index + 1).each { |right| yield left, right }
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def pair_key(row)
|
|
211
|
+
row.map { |name, value| [name.to_s, Report.value(value)] }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def row_key(row)
|
|
215
|
+
row.flat_map { |name, value| [name.to_s, Domains.sort_key(value)] }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def canonical_value(value)
|
|
219
|
+
Domains.supported_string?(value) ? Domains.canonical_string(value) : value
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def same_value?(left, right)
|
|
223
|
+
left.instance_of?(right.class) && left.eql?(right)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecProof
|
|
4
|
+
# Combines behavioral declarations with imported scalar input constraints.
|
|
5
|
+
module Composer
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def compose(specifications:, type_facts:)
|
|
9
|
+
specifications_by_target = index_specifications(specifications)
|
|
10
|
+
facts_by_target = index_type_facts(type_facts)
|
|
11
|
+
|
|
12
|
+
targets = (specifications_by_target.keys | facts_by_target.keys).sort
|
|
13
|
+
targets.map do |target|
|
|
14
|
+
specification = specifications_by_target[target]
|
|
15
|
+
target_facts = facts_by_target.fetch(target, {})
|
|
16
|
+
exclusions = specification&.exclusions || {}
|
|
17
|
+
conflict = target_facts.keys & exclusions.keys
|
|
18
|
+
unless conflict.empty?
|
|
19
|
+
name = conflict.min_by(&:to_s)
|
|
20
|
+
raise ConfigurationError.new("type fact collides with excluded input #{target} / #{name}",
|
|
21
|
+
code: "input_exclusion_overlap", source: target_facts.fetch(name).source)
|
|
22
|
+
end
|
|
23
|
+
input_names = ((specification&.inputs&.keys || []) | target_facts.keys).sort_by(&:to_s)
|
|
24
|
+
inputs = input_names.to_h do |name|
|
|
25
|
+
[name, compose_input(target, name, specification&.inputs&.[](name), target_facts[name])]
|
|
26
|
+
end
|
|
27
|
+
source = specification&.source || canonical_source(target_facts.values.map(&:source))
|
|
28
|
+
if inputs.empty?
|
|
29
|
+
raise ConfigurationError.new(
|
|
30
|
+
"target must declare at least one analyzed input: #{target}", code: "inputs_required", source: source
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
composed = Specification.new(target: target, inputs: inputs, exclusions: exclusions,
|
|
34
|
+
combination_group: specification&.combination_group, source: source)
|
|
35
|
+
PartitionSpace.for_specification(composed, validate_geometry: false) if composed.combination_group
|
|
36
|
+
composed
|
|
37
|
+
end.freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def index_specifications(specifications)
|
|
41
|
+
specifications.each_with_object({}) do |specification, index|
|
|
42
|
+
previous = index[specification.target]
|
|
43
|
+
next index[specification.target] = specification unless previous
|
|
44
|
+
|
|
45
|
+
sources = [previous.source, specification.source].compact.uniq.sort_by { |source| source_key(source) }
|
|
46
|
+
locations = sources.map { |source| "#{source.path}:#{source.line}" }.join(", ")
|
|
47
|
+
raise ConfigurationError.new("duplicate target #{specification.target} (#{locations})",
|
|
48
|
+
code: "duplicate_target", source: specification.source)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
private_class_method :index_specifications
|
|
52
|
+
|
|
53
|
+
def index_type_facts(type_facts)
|
|
54
|
+
type_facts.each_with_object(Hash.new { |targets, target| targets[target] = {} }) do |fact, index|
|
|
55
|
+
target_facts = index[fact.target]
|
|
56
|
+
if target_facts.key?(fact.input)
|
|
57
|
+
previous = target_facts.fetch(fact.input)
|
|
58
|
+
sources = [previous.source, fact.source].compact.uniq.sort_by { |source| source_key(source) }
|
|
59
|
+
locations = sources.map { |source| "#{source.path}:#{source.line}" }.join(", ")
|
|
60
|
+
raise ConfigurationError.new("duplicate type fact for #{fact.target} / #{fact.input} (#{locations})",
|
|
61
|
+
code: "duplicate_type_fact", source: fact.source)
|
|
62
|
+
end
|
|
63
|
+
target_facts[fact.input] = fact
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
private_class_method :index_type_facts
|
|
67
|
+
|
|
68
|
+
def compose_input(target, name, input, fact)
|
|
69
|
+
return input unless fact
|
|
70
|
+
|
|
71
|
+
sources = [fact.source].compact.uniq.sort_by { |source| source_key(source) }
|
|
72
|
+
domain = input&.domain || TypeConstraints.finite_domain(fact.constraint)
|
|
73
|
+
unless domain
|
|
74
|
+
source = canonical_source(sources)
|
|
75
|
+
location = source ? " at #{source.path}:#{source.line}" : ""
|
|
76
|
+
message = "integer input needs a finite DSL domain refinement: #{target} / #{name}#{location}"
|
|
77
|
+
raise ConfigurationError.new(message, code: "domain_refinement_required", source: source)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
Input.new(name: name, domain: domain, partitions: input&.partitions || [],
|
|
81
|
+
exhaustive: input&.exhaustive || false, disjoint: input&.disjoint || false,
|
|
82
|
+
source: input&.source || canonical_source(sources), type_constraint: fact.constraint,
|
|
83
|
+
type_sources: sources)
|
|
84
|
+
end
|
|
85
|
+
private_class_method :compose_input
|
|
86
|
+
|
|
87
|
+
def canonical_source(sources)
|
|
88
|
+
sources.compact.min_by { |source| source_key(source) }
|
|
89
|
+
end
|
|
90
|
+
private_class_method :canonical_source
|
|
91
|
+
|
|
92
|
+
def source_key(source)
|
|
93
|
+
[source.path, source.line, source.kind]
|
|
94
|
+
end
|
|
95
|
+
private_class_method :source_key
|
|
96
|
+
end
|
|
97
|
+
end
|