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.
Files changed (119) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +10 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +283 -0
  5. data/docs/agent-skills.md +60 -0
  6. data/docs/benchmark.md +24 -0
  7. data/docs/constrained-partition-pairs-results.md +44 -0
  8. data/docs/finite-strings-results.md +75 -0
  9. data/docs/implementation-status.md +65 -0
  10. data/docs/input-selection-results.md +123 -0
  11. data/docs/lint-cleanup-plan.md +12 -0
  12. data/docs/method-targets-results.md +68 -0
  13. data/docs/minitest-compatibility.md +33 -0
  14. data/docs/optional-arguments-plan.md +25 -0
  15. data/docs/optional-arguments-results.md +45 -0
  16. data/docs/pairwise-partitions-results.md +53 -0
  17. data/docs/pairwise-results.md +77 -0
  18. data/docs/pairwise-suggestions-results.md +104 -0
  19. data/docs/rbs-implementation-status.md +115 -0
  20. data/docs/rbs-string-literals-results.md +58 -0
  21. data/docs/real-project-pilot.md +84 -0
  22. data/docs/report-format.md +244 -0
  23. data/examples/age_policy/README.md +39 -0
  24. data/examples/age_policy/faulty/lib/age_policy.rb +9 -0
  25. data/examples/age_policy/faulty/test/age_policy_examples_test.rb +17 -0
  26. data/examples/age_policy/faulty/test/age_policy_test.rb +21 -0
  27. data/examples/age_policy/lib/age_policy.rb +8 -0
  28. data/examples/age_policy/run.rb +23 -0
  29. data/examples/age_policy/specification.rb +8 -0
  30. data/examples/age_policy/test/age_policy_test.rb +18 -0
  31. data/examples/constrained_partition_pairs/README.md +40 -0
  32. data/examples/constrained_partition_pairs/lib/account_eligibility.rb +8 -0
  33. data/examples/constrained_partition_pairs/requirements.md +11 -0
  34. data/examples/constrained_partition_pairs/run.rb +22 -0
  35. data/examples/constrained_partition_pairs/specification.rb +17 -0
  36. data/examples/constrained_partition_pairs/test_account_eligibility.rb +34 -0
  37. data/examples/input_selection/README.md +22 -0
  38. data/examples/input_selection/delivery_policy.rb +11 -0
  39. data/examples/input_selection/requirements.md +9 -0
  40. data/examples/input_selection/specification.rb +8 -0
  41. data/examples/input_selection/test_delivery_policy.rb +38 -0
  42. data/examples/method_targets/README.md +30 -0
  43. data/examples/method_targets/lib/delivery_defaults.rb +8 -0
  44. data/examples/method_targets/lib/delivery_options.rb +12 -0
  45. data/examples/method_targets/requirements.md +20 -0
  46. data/examples/method_targets/specification_objects.rb +17 -0
  47. data/examples/method_targets/specification_strings.rb +15 -0
  48. data/examples/pairwise/README.md +46 -0
  49. data/examples/pairwise/constrained_requirements.md +6 -0
  50. data/examples/pairwise/constrained_specification.rb +11 -0
  51. data/examples/pairwise/corrected/lib/delivery_options.rb +11 -0
  52. data/examples/pairwise/corrected/test/delivery_options_test.rb +28 -0
  53. data/examples/pairwise/faulty/lib/delivery_options.rb +11 -0
  54. data/examples/pairwise/faulty/test/gap_delivery_options_test.rb +10 -0
  55. data/examples/pairwise/faulty/test/initial_delivery_options_test.rb +23 -0
  56. data/examples/pairwise/requirements.md +11 -0
  57. data/examples/pairwise/run.rb +36 -0
  58. data/examples/pairwise/specification.rb +9 -0
  59. data/examples/pairwise/three_boolean/lib/boolean_batch.rb +8 -0
  60. data/examples/pairwise/three_boolean/test/boolean_batch_test.rb +28 -0
  61. data/examples/pairwise/three_boolean_specification.rb +10 -0
  62. data/examples/pairwise_partitions/README.md +34 -0
  63. data/examples/pairwise_partitions/lib/age_policy.rb +8 -0
  64. data/examples/pairwise_partitions/requirements.md +8 -0
  65. data/examples/pairwise_partitions/run.rb +22 -0
  66. data/examples/pairwise_partitions/specification.rb +13 -0
  67. data/examples/pairwise_partitions/test_age_policy.rb +29 -0
  68. data/examples/pairwise_suggestions/README.md +48 -0
  69. data/examples/pairwise_suggestions/run.rb +55 -0
  70. data/examples/pairwise_suggestions/three_boolean/lib/suggested_boolean_batch.rb +8 -0
  71. data/examples/pairwise_suggestions/three_boolean/requirements.md +7 -0
  72. data/examples/pairwise_suggestions/three_boolean/specification.rb +11 -0
  73. data/examples/pairwise_suggestions/three_boolean/test/baseline_test.rb +9 -0
  74. data/examples/pairwise_suggestions/three_boolean/test/complete_test.rb +22 -0
  75. data/examples/partition_gap.rb +8 -0
  76. data/examples/rbs_registration/README.md +58 -0
  77. data/examples/rbs_registration/implementations/age_gt/registration.rb +8 -0
  78. data/examples/rbs_registration/implementations/age_gte/registration.rb +8 -0
  79. data/examples/rbs_registration/requirements.md +9 -0
  80. data/examples/rbs_registration/sig/registration.rbs +3 -0
  81. data/examples/rbs_registration/specification.rb +10 -0
  82. data/examples/rbs_registration/test_registration.rb +43 -0
  83. data/examples/rbs_string_literals/README.md +34 -0
  84. data/examples/rbs_string_literals/lib/delivery_options.rb +9 -0
  85. data/examples/rbs_string_literals/requirements.md +11 -0
  86. data/examples/rbs_string_literals/sig/delivery_options.rbs +3 -0
  87. data/examples/rbs_string_literals/specification.rb +7 -0
  88. data/examples/rbs_string_literals/test_delivery_options.rb +46 -0
  89. data/examples/shipping.rb +12 -0
  90. data/exe/specproof +7 -0
  91. data/lib/specproof/checker.rb +144 -0
  92. data/lib/specproof/cli.rb +292 -0
  93. data/lib/specproof/collector.rb +304 -0
  94. data/lib/specproof/combinations.rb +226 -0
  95. data/lib/specproof/composer.rb +97 -0
  96. data/lib/specproof/coverage.rb +229 -0
  97. data/lib/specproof/coverage_report.rb +334 -0
  98. data/lib/specproof/domains.rb +124 -0
  99. data/lib/specproof/loader.rb +285 -0
  100. data/lib/specproof/minitest.rb +443 -0
  101. data/lib/specproof/model.rb +198 -0
  102. data/lib/specproof/partition_space.rb +268 -0
  103. data/lib/specproof/planner.rb +125 -0
  104. data/lib/specproof/report.rb +248 -0
  105. data/lib/specproof/skills.rb +188 -0
  106. data/lib/specproof/sources/rbs.rb +137 -0
  107. data/lib/specproof/sources/rbs_projector.rb +268 -0
  108. data/lib/specproof/suggestion_report.rb +159 -0
  109. data/lib/specproof/suggestions.rb +352 -0
  110. data/lib/specproof/target_name.rb +100 -0
  111. data/lib/specproof/type_constraints.rb +76 -0
  112. data/lib/specproof/version.rb +7 -0
  113. data/lib/specproof.rb +100 -0
  114. data/sig/specproof.rbs +222 -0
  115. data/skills/specproof-specify/SKILL.md +282 -0
  116. data/skills/specproof-specify/references/usage.md +265 -0
  117. data/skills/specproof-test-gaps/SKILL.md +214 -0
  118. data/skills/specproof-test-gaps/references/usage.md +231 -0
  119. 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