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,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecProof
4
+ # Enumerates bounded pairs over finite values and declared integer categories.
5
+ class PartitionSpace
6
+ MAX_ASSIGNMENTS = 10_000
7
+ MAX_PAIR_ROWS = 10_000
8
+ INVALID_CATEGORY = Object.new.freeze
9
+
10
+ attr_reader :pairs, :excluded_pairs, :allowed_count, :sources
11
+
12
+ def self.for_specification(specification, criterion: nil, validate_geometry: true)
13
+ group = specification.combination_group
14
+ has_integer_input = group&.inputs&.any? do |name|
15
+ specification.inputs[name]&.domain.is_a?(IntegerDomain)
16
+ end
17
+ has_partitions = group&.inputs&.any? { |name| specification.inputs[name]&.partitions&.any? }
18
+ use_partition_space = criterion == Planner::PARTITION_PAIRWISE_CRITERION ||
19
+ (criterion != Planner::PAIRWISE_CRITERION && has_integer_input && has_partitions)
20
+ return new(specification, validate_geometry: validate_geometry) if use_partition_space
21
+
22
+ CombinationSpace.new(specification)
23
+ end
24
+
25
+ def initialize(specification, validate_geometry: true)
26
+ @specification = specification
27
+ @group = specification.combination_group
28
+ unless @group
29
+ raise ConfigurationError.new("target has no combinations declaration: #{specification.target}",
30
+ code: "combinations_required", source: specification.source)
31
+ end
32
+
33
+ validate_group
34
+ validate_partition_geometry if validate_geometry
35
+ check_limits
36
+ enumerate
37
+ end
38
+
39
+ def allowed?(arguments)
40
+ normalized = arguments.each_with_object({}) do |(name, value), values|
41
+ values[name.to_sym] = canonical_value(value)
42
+ end
43
+ categories = @group.inputs.to_h do |name|
44
+ value = normalized.fetch(name, INVALID_CATEGORY)
45
+ [name, value.equal?(INVALID_CATEGORY) ? INVALID_CATEGORY : category_value(name, value)]
46
+ end
47
+ return false if categories.values.any? { |value| value.equal?(INVALID_CATEGORY) }
48
+
49
+ restrictions_allow?(categories)
50
+ end
51
+
52
+ def allowed_assignments
53
+ @allowed_assignments ||= complete_assignments.select { |assignment| restrictions_allow?(assignment) }.freeze
54
+ end
55
+
56
+ private
57
+
58
+ def validate_group
59
+ if @group.inputs.length < 2
60
+ raise ConfigurationError.new("a combinations group requires at least two inputs",
61
+ code: "invalid_combination_inputs", source: @group.source)
62
+ end
63
+
64
+ @inputs = group_inputs.freeze
65
+ validate_forbidden_assignments
66
+ collect_sources
67
+ end
68
+
69
+ def validate_forbidden_assignments
70
+ @group.forbidden.each do |rule|
71
+ if rule.assignments.empty?
72
+ raise ConfigurationError.new("forbid requires at least one assignment",
73
+ code: "empty_forbidden_assignment", source: rule.source)
74
+ end
75
+ rule.assignments.each do |name, value|
76
+ input = @inputs[name]
77
+ unless input
78
+ raise ConfigurationError.new("forbidden assignment names non-group input #{name.inspect}",
79
+ code: "unknown_forbidden_input", source: rule.source)
80
+ end
81
+ if input.domain.is_a?(IntegerDomain)
82
+ unless value.is_a?(PartitionReference)
83
+ raise ConfigurationError.new("integer input #{name.inspect} restrictions must use in_partition(:name)",
84
+ code: "partition_selector_required", source: rule.source)
85
+ end
86
+ next if input.partitions.any? { |partition| partition.name == value.name }
87
+
88
+ raise ConfigurationError.new("unknown partition #{value.name.inspect} for #{name.inspect}",
89
+ code: "unknown_partition_reference", source: rule.source)
90
+ end
91
+
92
+ if value.is_a?(PartitionReference)
93
+ raise ConfigurationError.new("partition reference for #{name.inspect} requires an integer input",
94
+ code: "partition_reference_requires_integer_input", source: rule.source)
95
+ end
96
+ unless Domains.primitive?(value) && Domains.include?(input.domain, value)
97
+ raise ConfigurationError.new("forbidden value #{value.inspect} is outside #{name.inspect}'s finite domain",
98
+ code: "invalid_combination_value", source: rule.source)
99
+ end
100
+ end
101
+ end
102
+ end
103
+
104
+ def group_inputs
105
+ @group.inputs.to_h do |name|
106
+ input = @specification.inputs[name]
107
+ if @specification.exclusions.key?(name)
108
+ raise ConfigurationError.new("excluded input #{name.inspect} cannot be in a combinations group",
109
+ code: "excluded_combination_input", source: @group.source)
110
+ end
111
+ unless input
112
+ raise ConfigurationError.new("unknown combinations input #{name.inspect}",
113
+ code: "unknown_combination_input", source: @group.source)
114
+ end
115
+
116
+ if input.domain.is_a?(IntegerDomain)
117
+ validate_integer_input(input)
118
+ else
119
+ validate_finite_input(input)
120
+ end
121
+ [name, input]
122
+ end
123
+ end
124
+
125
+ def validate_integer_input(input)
126
+ if input.partitions.empty? || !input.exhaustive || !input.disjoint
127
+ raise ConfigurationError.new(
128
+ "integer input needs partitions with allow_gaps: false, allow_overlaps: false",
129
+ code: "partition_policy_required", source: input.source
130
+ )
131
+ end
132
+ return if input.partitions.all? { |partition| partition.domain.is_a?(IntegerDomain) }
133
+
134
+ raise ConfigurationError.new("integer input partitions must use integer ranges",
135
+ code: "partition_domain_kind", source: input.source)
136
+ end
137
+
138
+ def validate_finite_input(input)
139
+ return if input.partitions.empty?
140
+
141
+ raise ConfigurationError.new("finite combinations input #{input.name.inspect} cannot declare partitions",
142
+ code: "finite_partitions_unsupported", source: input.source)
143
+ end
144
+
145
+ def validate_partition_geometry
146
+ findings = Checker.new([@specification], check_combinations: false).findings
147
+ return if findings.empty?
148
+
149
+ raise InvalidSpecificationError, findings
150
+ end
151
+
152
+ def collect_sources
153
+ @sources = ([@group.source] + @inputs.values.flat_map(&:sources) +
154
+ @inputs.values.flat_map { |input| input.partitions.map(&:source) } +
155
+ @group.forbidden.map(&:source))
156
+ .compact.uniq.sort_by { |source| [source.path, source.line, source.kind] }.freeze
157
+ end
158
+
159
+ def check_limits
160
+ assignment_count = @inputs.values.reduce(1) { |count, input| count * values_for(input).length }
161
+ raise limit_error("complete assignments", assignment_count) if assignment_count > MAX_ASSIGNMENTS
162
+
163
+ pair_rows = 0
164
+ @group.inputs.each_with_index do |left, index|
165
+ @group.inputs.drop(index + 1).each do |right|
166
+ pair_rows += values_for(@inputs.fetch(left)).length * values_for(@inputs.fetch(right)).length
167
+ raise limit_error("possible pair rows", pair_rows) if pair_rows > MAX_PAIR_ROWS
168
+ end
169
+ end
170
+ end
171
+
172
+ def limit_error(kind, count)
173
+ ConfigurationError.new("combinations declaration has #{count} #{kind}; limit is #{MAX_ASSIGNMENTS}",
174
+ code: "combination_limit_exceeded", source: @group.source)
175
+ end
176
+
177
+ def enumerate
178
+ assignments = allowed_assignments
179
+ @allowed_count = assignments.length
180
+ pair_rows = {}
181
+ assignments.each do |assignment|
182
+ each_input_pair do |left, right|
183
+ row = { left => assignment.fetch(left), right => assignment.fetch(right) }.freeze
184
+ pair_rows[pair_key(row)] ||= row
185
+ end
186
+ end
187
+ @pairs = pair_rows.values.sort_by { |row| row_key(row) }.freeze
188
+ all_rows = []
189
+ each_input_pair do |left, right|
190
+ values_for(@inputs.fetch(left)).product(values_for(@inputs.fetch(right))).each do |left_value, right_value|
191
+ all_rows << { left => left_value, right => right_value }.freeze
192
+ end
193
+ end
194
+ feasible = pair_rows.keys.to_h { |key| [key, true] }
195
+ @excluded_pairs = all_rows.reject { |row| feasible.key?(pair_key(row)) }.sort_by { |row| row_key(row) }.freeze
196
+ end
197
+
198
+ def complete_assignments
199
+ return [] if @group.inputs.any? { |name| values_for(@inputs.fetch(name)).empty? }
200
+
201
+ @group.inputs.sort_by(&:to_s).reduce([{}]) do |rows, name|
202
+ rows.flat_map do |row|
203
+ values_for(@inputs.fetch(name)).map { |value| row.merge(name => value) }
204
+ end
205
+ end.map(&:freeze).freeze
206
+ end
207
+
208
+ def values_for(input)
209
+ if input.domain.is_a?(IntegerDomain)
210
+ input.partitions.map do |partition|
211
+ PartitionLevel.new(name: partition.name, lower: partition.domain.lower, upper: partition.domain.upper)
212
+ end.sort_by(&:sort_key)
213
+ else
214
+ input.domain.values
215
+ end
216
+ end
217
+
218
+ def category_value(name, value)
219
+ input = @inputs.fetch(name)
220
+ if input.domain.is_a?(IntegerDomain)
221
+ return INVALID_CATEGORY unless value.instance_of?(Integer) && input.domain.include?(value)
222
+
223
+ matching = input.partitions.select { |partition| partition.domain.include?(value) }
224
+ return INVALID_CATEGORY unless matching.length == 1
225
+
226
+ partition = matching.first
227
+ PartitionLevel.new(name: partition.name, lower: partition.domain.lower, upper: partition.domain.upper)
228
+ else
229
+ Domains.include?(input.domain, value) ? value : INVALID_CATEGORY
230
+ end
231
+ end
232
+
233
+ def restrictions_allow?(assignment)
234
+ @group.forbidden.none? do |rule|
235
+ rule.assignments.all? do |name, value|
236
+ assigned = assignment[name]
237
+ if value.is_a?(PartitionReference)
238
+ assigned.is_a?(PartitionLevel) && assigned.name == value.name
239
+ else
240
+ assigned.instance_of?(value.class) && assigned.eql?(value)
241
+ end
242
+ end
243
+ end
244
+ end
245
+
246
+ def each_input_pair
247
+ @group.inputs.each_with_index do |left, index|
248
+ @group.inputs.drop(index + 1).each { |right| yield left, right }
249
+ end
250
+ end
251
+
252
+ def pair_key(row)
253
+ row.map { |name, value| [name.to_s, Report.value(value)] }
254
+ end
255
+
256
+ def row_key(row)
257
+ row.flat_map { |name, value| [name.to_s, sort_key(value)] }
258
+ end
259
+
260
+ def sort_key(value)
261
+ value.is_a?(PartitionLevel) ? value.sort_key : Domains.sort_key(value)
262
+ end
263
+
264
+ def canonical_value(value)
265
+ Domains.supported_string?(value) ? Domains.canonical_string(value) : value
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module SpecProof
7
+ # Builds deterministic input candidates from valid specifications.
8
+ class Planner
9
+ CRITERION = "input-basics-v1"
10
+ PAIRWISE_CRITERION = "pairwise-finite-v1"
11
+ PARTITION_PAIRWISE_CRITERION = "pairwise-partitions-v1"
12
+
13
+ def initialize(specifications, criterion: CRITERION)
14
+ unless [CRITERION, PAIRWISE_CRITERION, PARTITION_PAIRWISE_CRITERION].include?(criterion)
15
+ raise ConfigurationError.new("unsupported criterion #{criterion.inspect}", code: "unknown_criterion")
16
+ end
17
+
18
+ @specifications = specifications
19
+ @criterion = criterion
20
+ end
21
+
22
+ def obligations
23
+ findings = Checker.new(@specifications, criterion: @criterion).findings
24
+ raise InvalidSpecificationError, findings unless findings.empty?
25
+
26
+ return pair_obligations if [PAIRWISE_CRITERION, PARTITION_PAIRWISE_CRITERION].include?(@criterion)
27
+
28
+ candidates = {}
29
+ @specifications.each do |specification|
30
+ specification.inputs.each_value do |input|
31
+ input_candidates(specification, input).each do |value, reason, source|
32
+ key = [specification.target, input.name, value]
33
+ candidate = (candidates[key] ||= { reasons: [], sources: [] })
34
+ candidate[:reasons] << reason
35
+ if source.is_a?(Array)
36
+ candidate[:sources].concat(source)
37
+ else
38
+ candidate[:sources] << source
39
+ end
40
+ end
41
+ end
42
+ end
43
+ obligations = candidates.map do |(target, input, value), details|
44
+ id = Digest::SHA256.hexdigest(JSON.generate([target, input.to_s, Report.value(value), @criterion]))
45
+ Obligation.new(id: id, target: target, input: input, value: value,
46
+ reasons: details[:reasons], sources: details[:sources])
47
+ end
48
+ obligations.sort_by do |obligation|
49
+ [obligation.target, obligation.input.to_s, Domains.sort_key(obligation.value)]
50
+ end.freeze
51
+ end
52
+
53
+ private
54
+
55
+ def pair_obligations
56
+ spaces = []
57
+ count = 0
58
+ @specifications.each do |specification|
59
+ space = pairwise_space(specification)
60
+ next_count = count + space.pairs.length
61
+ if next_count > CombinationSpace::MAX_PAIR_ROWS
62
+ raise ConfigurationError.new("pairwise plan has #{next_count} pair obligations; limit is 10000",
63
+ code: "combination_limit_exceeded", source: space.sources.first)
64
+ end
65
+
66
+ count = next_count
67
+ spaces << [specification, space]
68
+ end
69
+
70
+ obligations = spaces.flat_map do |specification, space|
71
+ space.pairs.map do |inputs|
72
+ canonical_inputs = inputs.sort_by { |name, _value| name.to_s }.to_h
73
+ id_data = canonical_inputs.map { |name, value| [name.to_s, Report.value(value)] }
74
+ id = Digest::SHA256.hexdigest(JSON.generate([specification.target, id_data, @criterion]))
75
+ PairObligation.new(id: id, target: specification.target, inputs: canonical_inputs,
76
+ reasons: ["feasible input pair"], sources: space.sources)
77
+ end
78
+ end
79
+ obligations.sort_by do |obligation|
80
+ [obligation.target, obligation.inputs.keys.map(&:to_s),
81
+ obligation.inputs.values.map { |value| sort_key(value) }]
82
+ end.freeze
83
+ end
84
+
85
+ def pairwise_space(specification)
86
+ if @criterion == PARTITION_PAIRWISE_CRITERION
87
+ PartitionSpace.new(specification)
88
+ else
89
+ CombinationSpace.new(specification)
90
+ end
91
+ end
92
+
93
+ def sort_key(value)
94
+ value.is_a?(PartitionLevel) ? value.sort_key : Domains.sort_key(value)
95
+ end
96
+
97
+ def input_candidates(_specification, input)
98
+ domain = input.domain
99
+ if domain.is_a?(IntegerDomain)
100
+ base_integer_candidates(domain).map do |value, reason|
101
+ [value, reason, input.sources]
102
+ end + input.partitions.flat_map { |partition| partition_candidates(partition) }
103
+ else
104
+ domain.values.map { |value| [value, "finite input value", input.sources] }
105
+ end
106
+ end
107
+
108
+ def base_integer_candidates(domain)
109
+ return [] if Domains.empty?(domain)
110
+
111
+ # These are candidate tuples, so filter by the value's domain membership.
112
+ [[domain.lower, "input lower endpoint"], [domain.lower + 1, "input lower neighbor"],
113
+ [domain.upper - 1, "input upper neighbor"], [domain.upper, "input upper endpoint"]]
114
+ .select { |value, _reason| domain.include?(value) } # rubocop:disable Style/HashSlice
115
+ end
116
+
117
+ def partition_candidates(partition)
118
+ domain = partition.domain
119
+ return [] unless domain.is_a?(IntegerDomain) && !Domains.empty?(domain)
120
+
121
+ [[domain.lower, "#{partition.name} lower endpoint"], [domain.upper, "#{partition.name} upper endpoint"]]
122
+ .map { |value, reason| [value, reason, partition.source] }
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module SpecProof
7
+ # Builds stable machine and human report representations.
8
+ module Report
9
+ module_function
10
+
11
+ # Renders primitive and partition pair obligations for human plans.
12
+ module PairwisePlanRows
13
+ module_function
14
+
15
+ def render(obligations, criterion:)
16
+ grouped_rows = obligations.group_by { |item| [item.target, item.inputs.keys.map(&:to_s)] }
17
+ grouped_rows.map do |(target, names), items|
18
+ lines = ["#{target} / #{names.join(" × ")}", "Criterion: #{criterion}", "Pair obligations: #{items.length}"]
19
+ items.each do |item|
20
+ values = item.inputs.map { |name, item_value| "#{name}=#{Report.human_value(item_value)}" }.join(", ")
21
+ locations = item.sources.map { |item_source| "#{item_source.path}:#{item_source.line}" }.join(", ")
22
+ lines << "#{values}: #{item.reasons.join("; ")}\n #{locations}"
23
+ end
24
+ lines.join("\n")
25
+ end
26
+ .join("\n\n")
27
+ end
28
+ end
29
+
30
+ def value(value)
31
+ case value
32
+ when PartitionLevel then { kind: "partition", name: value.name.to_s, lower: value.lower.to_s,
33
+ upper: value.upper.to_s }
34
+ when PartitionReference then { kind: "partition_reference", name: value.name.to_s }
35
+ when NilClass then { kind: "nil", value: nil }
36
+ when TrueClass, FalseClass then { kind: "boolean", value: value }
37
+ when Integer then { kind: "integer", value: value.to_s }
38
+ when Symbol then { kind: "symbol", value: value.to_s }
39
+ when String
40
+ raise ArgumentError, "unsupported report value String" unless Domains.supported_string?(value)
41
+
42
+ { kind: "string", value: Domains.canonical_string(value) }
43
+ else
44
+ raise ArgumentError, "unsupported report value #{value.class}"
45
+ end
46
+ end
47
+
48
+ def source(source)
49
+ { path: source.path, line: source.line, kind: source.kind }
50
+ end
51
+
52
+ def obligation(obligation)
53
+ if obligation.respond_to?(:inputs) && obligation.inputs
54
+ return { id: obligation.id, target: obligation.target,
55
+ inputs: obligation.inputs.to_h { |name, item| [name.to_s, value(item)] },
56
+ reasons: obligation.reasons, sources: obligation.sources.map { |item| source(item) } }
57
+ end
58
+
59
+ { id: obligation.id, target: obligation.target, input: obligation.input.to_s,
60
+ value: value(obligation.value), reasons: obligation.reasons,
61
+ sources: obligation.sources.map { |item| source(item) } }
62
+ end
63
+
64
+ def digest(specifications)
65
+ declarations = specifications.flat_map do |specification|
66
+ specification.inputs.values.map do |input|
67
+ partitions = input.partitions.map do |partition|
68
+ { name: partition.name.to_s, domain: normalized_domain(partition.domain) }
69
+ end
70
+ partitions = partitions.sort_by { |partition| partition[:name] }
71
+ { target: specification.target, input: input.name.to_s, domain: normalized_domain(input.domain),
72
+ exhaustive: input.exhaustive, disjoint: input.disjoint,
73
+ partitions: partitions }
74
+ end
75
+ end
76
+ declarations = declarations.sort_by { |entry| [entry[:target], entry[:input]] }
77
+ exclusions = specifications.flat_map do |specification|
78
+ names = specification.exclusions.keys.sort_by(&:to_s).map(&:to_s)
79
+ names.empty? ? [] : [{ target: specification.target, excluded_inputs: names }]
80
+ end
81
+ exclusions.sort_by! { |entry| [entry[:target], entry[:excluded_inputs]] }
82
+ declarations.concat(exclusions)
83
+ declarations.concat(group_declarations(specifications))
84
+ Digest::SHA256.hexdigest(JSON.generate(declarations))
85
+ end
86
+
87
+ def group_declarations(specifications)
88
+ groups = specifications.filter_map do |specification|
89
+ group = specification.respond_to?(:combination_group) && specification.combination_group
90
+ next unless group
91
+
92
+ forbidden = group.forbidden.map { |rule| canonical_assignments(rule) }
93
+ forbidden.uniq!
94
+ forbidden.sort_by! { |assignments| JSON.generate(assignments) }
95
+ { target: specification.target, combination_group: group.inputs.map(&:to_s), forbidden: forbidden }
96
+ end
97
+ groups.sort_by { |entry| entry[:target] }
98
+ end
99
+
100
+ def canonical_assignments(rule)
101
+ rule.assignments.sort_by { |name, _item| name.to_s }
102
+ .map { |name, item| [name.to_s, value(item)] }
103
+ end
104
+
105
+ def input_scope(specifications)
106
+ {
107
+ completeness_scope: "analyzed_inputs",
108
+ input_scope: specifications.sort_by(&:target).map do |specification|
109
+ scope = {
110
+ target: specification.target,
111
+ analyzed_inputs: specification.inputs.keys.sort_by(&:to_s).map(&:to_s),
112
+ excluded_inputs: specification.exclusions.values.sort_by { |item| item.name.to_s }.map do |item|
113
+ { name: item.name.to_s, reason: item.reason, source: source(item.source) }
114
+ end
115
+ }
116
+ group = specification.respond_to?(:combination_group) && specification.combination_group
117
+ scope[:combination_group] = { inputs: group.inputs.map(&:to_s) } if group
118
+ scope
119
+ end
120
+ }
121
+ end
122
+
123
+ def check(specifications:, findings:, errors: [])
124
+ envelope(kind: "check", errors: errors).merge(
125
+ contract_digest: digest(specifications),
126
+ scope: input_scope(specifications),
127
+ findings: findings.map { |finding| finding_hash(finding) }
128
+ )
129
+ end
130
+
131
+ def plan(specifications:, obligations:, criterion: "input-basics-v1", errors: [])
132
+ result = envelope(kind: "plan", errors: errors).merge(
133
+ contract_digest: digest(specifications), criterion: criterion,
134
+ scope: input_scope(specifications),
135
+ obligations: obligations.map { |item| obligation(item) }
136
+ )
137
+ if %w[pairwise-finite-v1 pairwise-partitions-v1].include?(criterion)
138
+ result[:excluded_pairs] = specifications.flat_map do |specification|
139
+ next [] unless specification.respond_to?(:combination_group) && specification.combination_group
140
+
141
+ space = PartitionSpace.for_specification(specification, criterion: criterion)
142
+ space.excluded_pairs.map do |inputs|
143
+ { target: specification.target,
144
+ inputs: inputs.to_h { |name, item| [name.to_s, value(item)] },
145
+ reason: "no_allowed_completion", sources: space.sources.map { |item| source(item) } }
146
+ end
147
+ end
148
+ end
149
+ result
150
+ end
151
+
152
+ def envelope(kind:, errors: [])
153
+ { schema_version: 1, specproof_version: VERSION, kind: kind, errors: errors }
154
+ end
155
+
156
+ def finding_hash(finding)
157
+ if finding.respond_to?(:inputs) && finding.inputs
158
+ return { code: finding.code.to_s, target: finding.target,
159
+ inputs: finding.inputs.map(&:to_s), witness: [],
160
+ sources: finding.sources.map { |item| source(item) } }
161
+ end
162
+
163
+ { code: finding.code.to_s, target: finding.target, input: finding.input.to_s,
164
+ witness: if finding.witness.is_a?(Array)
165
+ finding.witness.map do |item|
166
+ witness_value(item)
167
+ end
168
+ else
169
+ witness_value(finding.witness)
170
+ end,
171
+ sources: finding.sources.map { |item| source(item) } }
172
+ end
173
+
174
+ def finding(finding)
175
+ if finding.respond_to?(:inputs) && finding.inputs
176
+ names = finding.inputs.join(", ")
177
+ return "#{finding.code} #{finding.target} / #{names} (#{finding.sources.map do |item|
178
+ "#{item.path}:#{item.line}"
179
+ end.join(", ")})"
180
+ end
181
+
182
+ "#{finding.code} #{finding.target} / #{finding.input}: #{finding.witness.inspect} (#{finding.sources.map do |item|
183
+ "#{item.path}:#{item.line}"
184
+ end.join(", ")})"
185
+ end
186
+
187
+ def human_scope(specifications)
188
+ lines = ["Completeness scope: analyzed inputs only"]
189
+ specifications.sort_by(&:target).each do |specification|
190
+ group = specification.respond_to?(:combination_group) && specification.combination_group
191
+ lines << "Pairwise group for #{specification.target}: #{group.inputs.join(", ")}" if group
192
+ exclusions = specification.exclusions.values.sort_by { |item| item.name.to_s }
193
+ next if exclusions.empty?
194
+
195
+ details = exclusions.map { |item| "#{item.name} (#{item.reason})" }.join(", ")
196
+ lines << "Excluded for #{specification.target}: #{details}"
197
+ end
198
+ lines.join("\n")
199
+ end
200
+
201
+ def human_plan(obligations, criterion: "input-basics-v1", specifications: [])
202
+ return human_pairwise_plan(obligations, specifications, criterion: criterion) if
203
+ %w[pairwise-finite-v1 pairwise-partitions-v1].include?(criterion)
204
+
205
+ rows = obligations.group_by { |item| [item.target, item.input] }.map do |(target, input), items|
206
+ values = items.map { |item| human_value(item.value) }.join(", ")
207
+ ["#{target} / #{input}", "Criterion: #{criterion}", "Candidates: #{values}"]
208
+ .concat(items.map do |item|
209
+ locations = item.sources.map { |item_source| "#{item_source.path}:#{item_source.line}" }.join(", ")
210
+ "#{human_value(item.value)}: #{item.reasons.join("; ")}\n #{locations}"
211
+ end).join("\n")
212
+ end.join("\n\n")
213
+ [rows, human_scope(specifications)].reject(&:empty?).join("\n\n")
214
+ end
215
+
216
+ def human_pairwise_plan(obligations, specifications, criterion:)
217
+ rows = PairwisePlanRows.render(obligations, criterion: criterion)
218
+ excluded_count = specifications.sum do |specification|
219
+ next 0 unless specification.respond_to?(:combination_group) && specification.combination_group
220
+
221
+ space = PartitionSpace.for_specification(specification, criterion: criterion)
222
+ space.excluded_pairs.length
223
+ end
224
+ rows = "Total pair obligations: #{obligations.length}\n\n#{rows}\n\nExcluded pairs: #{excluded_count}"
225
+ [rows, human_scope(specifications)].reject(&:empty?).join("\n\n")
226
+ end
227
+
228
+ def human_value(value)
229
+ return "#{value.name}[#{value.lower}..#{value.upper}]" if value.is_a?(PartitionLevel)
230
+ return ":#{value}" if value.is_a?(Symbol)
231
+ return Domains.canonical_string(value).inspect if Domains.supported_string?(value)
232
+
233
+ value.inspect
234
+ end
235
+
236
+ def normalized_domain(domain)
237
+ if domain.is_a?(IntegerDomain)
238
+ { kind: "integer", lower: domain.lower.to_s, upper: domain.upper.to_s }
239
+ else
240
+ { kind: "finite", values: domain.values.map { |value| value(value) } }
241
+ end
242
+ end
243
+
244
+ def witness_value(value)
245
+ value(value)
246
+ end
247
+ end
248
+ end