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,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
|