bparity 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/.rubocop.yml +61 -0
- data/LICENSE.txt +21 -0
- data/README.md +146 -0
- data/Rakefile +36 -0
- data/docs/application_example.md +25 -0
- data/docs/formal_assurance_limits.md +21 -0
- data/exe/bparity +7 -0
- data/fixtures/scenarios/01_pure_function/adapter.rb +13 -0
- data/fixtures/scenarios/01_pure_function/boundary.rb +17 -0
- data/fixtures/scenarios/01_pure_function/legacy/dead_gem.rb +9 -0
- data/fixtures/scenarios/01_pure_function/legacy/slugifier.rb +17 -0
- data/fixtures/scenarios/01_pure_function/replacement/broken.rb +15 -0
- data/fixtures/scenarios/01_pure_function/replacement/good.rb +19 -0
- data/fixtures/scenarios/01_pure_function/spec/slugifier_spec.rb +20 -0
- data/fixtures/scenarios/01_pure_function/test/slugifier_test.rb +10 -0
- data/fixtures/scenarios/02_stateful_client/adapter.rb +19 -0
- data/fixtures/scenarios/02_stateful_client/boundary.rb +10 -0
- data/fixtures/scenarios/02_stateful_client/legacy/client.rb +29 -0
- data/fixtures/scenarios/02_stateful_client/replacement/broken.rb +14 -0
- data/fixtures/scenarios/02_stateful_client/replacement/good.rb +23 -0
- data/fixtures/scenarios/02_stateful_client/spec/client_spec.rb +31 -0
- data/fixtures/scenarios/03_external_boundary/adapter.rb +13 -0
- data/fixtures/scenarios/03_external_boundary/boundary.rb +11 -0
- data/fixtures/scenarios/03_external_boundary/legacy/dead_formatter.rb +7 -0
- data/fixtures/scenarios/03_external_boundary/legacy/receipt.rb +11 -0
- data/fixtures/scenarios/03_external_boundary/replacement/broken.rb +11 -0
- data/fixtures/scenarios/03_external_boundary/replacement/good.rb +11 -0
- data/fixtures/scenarios/03_external_boundary/spec/receipt_spec.rb +10 -0
- data/fixtures/scenarios/04_intentional_divergence/adapter.rb +12 -0
- data/fixtures/scenarios/04_intentional_divergence/adapter_unwaived.rb +10 -0
- data/fixtures/scenarios/04_intentional_divergence/boundary.rb +8 -0
- data/fixtures/scenarios/04_intentional_divergence/legacy/dead_identity.rb +7 -0
- data/fixtures/scenarios/04_intentional_divergence/legacy/identity.rb +9 -0
- data/fixtures/scenarios/04_intentional_divergence/replacement/broken.rb +9 -0
- data/fixtures/scenarios/04_intentional_divergence/replacement/good.rb +9 -0
- data/fixtures/scenarios/04_intentional_divergence/spec/identity_spec.rb +10 -0
- data/fixtures/scenarios/05_formal_negative/adapter.rb +15 -0
- data/fixtures/scenarios/05_formal_negative/boundary.rb +16 -0
- data/fixtures/scenarios/05_formal_negative/legacy/dead_lock.rb +5 -0
- data/fixtures/scenarios/05_formal_negative/legacy/turnstile.rb +24 -0
- data/fixtures/scenarios/05_formal_negative/replacement/broken.rb +18 -0
- data/fixtures/scenarios/05_formal_negative/replacement/formal_broken.rb +24 -0
- data/fixtures/scenarios/05_formal_negative/replacement/good.rb +24 -0
- data/fixtures/scenarios/05_formal_negative/spec/turnstile_spec.rb +21 -0
- data/lib/bparity/adapter.rb +115 -0
- data/lib/bparity/adequacy.rb +78 -0
- data/lib/bparity/boundary.rb +97 -0
- data/lib/bparity/cli/formal_commands.rb +428 -0
- data/lib/bparity/cli/verification_commands.rb +242 -0
- data/lib/bparity/cli.rb +262 -0
- data/lib/bparity/corpus.rb +45 -0
- data/lib/bparity/errors.rb +12 -0
- data/lib/bparity/formal/assumptions.rb +131 -0
- data/lib/bparity/formal/bounded.rb +543 -0
- data/lib/bparity/formal/contract.rb +81 -0
- data/lib/bparity/formal/deductive.rb +481 -0
- data/lib/bparity/formal/lts.rb +344 -0
- data/lib/bparity/formal/result.rb +50 -0
- data/lib/bparity/formal.rb +8 -0
- data/lib/bparity/recording.rb +412 -0
- data/lib/bparity/reporting.rb +128 -0
- data/lib/bparity/spec_bundle.rb +208 -0
- data/lib/bparity/synthesis.rb +487 -0
- data/lib/bparity/verification.rb +310 -0
- data/lib/bparity/version.rb +5 -0
- data/lib/bparity.rb +42 -0
- metadata +125 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Bparity
|
|
8
|
+
module Synthesis
|
|
9
|
+
class InvariantMiner
|
|
10
|
+
def mine(records)
|
|
11
|
+
successful = records.filter_map do |record|
|
|
12
|
+
outcome = record["outcome"]
|
|
13
|
+
outcome["value"] if outcome&.fetch("kind", nil) == "return"
|
|
14
|
+
end
|
|
15
|
+
return [] if successful.length < 2
|
|
16
|
+
|
|
17
|
+
candidates(successful).map.with_index(1) do |expression, index|
|
|
18
|
+
{ "id" => format("inv-%04d", index), "expr" => expression, "support" => successful.length,
|
|
19
|
+
"confidence" => 1.0, "provenance_level" => "B", "formal_level" => "F0" }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def candidates(values)
|
|
26
|
+
items = []
|
|
27
|
+
items << "return != nil" if values.none?(&:nil?)
|
|
28
|
+
classes = values.map(&:class).uniq
|
|
29
|
+
items << "return.is_a?(#{classes.first})" if classes.one? && %w[String Integer Array
|
|
30
|
+
Hash].include?(classes.first.name)
|
|
31
|
+
if values.all? { |value| value.is_a?(String) && value.match?(/\A[a-z0-9-]*\z/) }
|
|
32
|
+
items << "return.match?(/\\A[a-z0-9-]*\\z/)"
|
|
33
|
+
end
|
|
34
|
+
items
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class MetamorphicDetector
|
|
39
|
+
def detect(callable, samples)
|
|
40
|
+
return [] if samples.empty?
|
|
41
|
+
|
|
42
|
+
relations = []
|
|
43
|
+
relations << "idempotent" if holds? do
|
|
44
|
+
samples.all? do |sample|
|
|
45
|
+
callable.call(callable.call(sample)) == callable.call(sample)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
relations << "permutation_invariant" if samples.all?(Array) && holds? do
|
|
49
|
+
samples.all? { |sample| callable.call(sample.reverse) == callable.call(sample) }
|
|
50
|
+
end
|
|
51
|
+
if samples.all?(Numeric)
|
|
52
|
+
relations << "additive" if holds? do
|
|
53
|
+
samples.product(samples).all? do |left, right|
|
|
54
|
+
callable.call(left + right) == callable.call(left) + callable.call(right)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
relations << "monotonic" if holds? do
|
|
58
|
+
samples.sort.each_cons(2).all? do |left, right|
|
|
59
|
+
callable.call(left) <= callable.call(right)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
relations
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def holds?
|
|
69
|
+
yield
|
|
70
|
+
rescue StandardError
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
class StaticExtractor
|
|
76
|
+
SPEC_CALLS = %i[describe context it specify].freeze
|
|
77
|
+
ASSERTIONS = %i[eq eql equal raise_error assert assert_equal assert_raises].freeze
|
|
78
|
+
UNSUPPORTED = Object.new.freeze
|
|
79
|
+
|
|
80
|
+
def extract_tests(paths)
|
|
81
|
+
Array(paths).flat_map { |path| extract_test_file(path) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def extract_source(paths)
|
|
85
|
+
Array(paths).flat_map do |path|
|
|
86
|
+
result = Prism.parse_file(path)
|
|
87
|
+
raise Error, "Cannot parse #{path}. Fix its Ruby syntax and try again." unless result.success?
|
|
88
|
+
|
|
89
|
+
source_facts(result.value, path)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def extract_test_file(path)
|
|
96
|
+
result = Prism.parse_file(path)
|
|
97
|
+
raise Error, "Cannot parse #{path}. Fix its Ruby syntax and try again." unless result.success?
|
|
98
|
+
|
|
99
|
+
extract_spec_nodes(result.value, path, []) + extract_minitest_nodes(result.value, path)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def extract_minitest_nodes(node, path, owner = nil)
|
|
103
|
+
owner = node.constant_path.location.slice if node.type == :class_node
|
|
104
|
+
item = minitest_item(node, path, owner) if node.type == :def_node && node.name.to_s.start_with?("test_")
|
|
105
|
+
[item, *node.compact_child_nodes.flat_map { |child| extract_minitest_nodes(child, path, owner) }].compact
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def minitest_item(node, path, owner)
|
|
109
|
+
assertions = nodes(node.body).filter_map do |child|
|
|
110
|
+
child.name.to_s if child.type == :call_node && ASSERTIONS.include?(child.name)
|
|
111
|
+
end.uniq
|
|
112
|
+
description = node.name.to_s.delete_prefix("test_").tr("_", " ")
|
|
113
|
+
{ "subject" => owner, "description" => [owner, description].compact.join(" "),
|
|
114
|
+
"location" => "#{path}:#{node.location.start_line}", "assertions" => assertions }
|
|
115
|
+
.merge(extract_minitest_expectation(node.body) || {})
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def extract_minitest_expectation(body)
|
|
119
|
+
nodes(body).filter_map { |node| minitest_expectation(node) }.first
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def minitest_expectation(node)
|
|
123
|
+
return unless minitest_assertion?(node)
|
|
124
|
+
|
|
125
|
+
expected, invocation = minitest_parts(node)
|
|
126
|
+
return unless expected && invocation&.type == :call_node
|
|
127
|
+
|
|
128
|
+
arguments = invocation.arguments&.arguments || []
|
|
129
|
+
values = arguments.map { |argument| static_literal(argument) }
|
|
130
|
+
return if values.any? { |value| value.equal?(UNSUPPORTED) }
|
|
131
|
+
|
|
132
|
+
{ "subject" => invocation_subject(invocation), "operation" => "##{invocation.name}",
|
|
133
|
+
"arguments" => values, "expected_outcome" => expected }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def minitest_assertion?(node)
|
|
137
|
+
node.type == :call_node && %i[assert_equal assert_raises].include?(node.name)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def minitest_parts(node)
|
|
141
|
+
arguments = node.arguments&.arguments || []
|
|
142
|
+
return [static_outcome_for(arguments.first), arguments.at(1)] if node.name == :assert_equal
|
|
143
|
+
|
|
144
|
+
[{ "kind" => "raise", "class" => arguments.first&.location&.slice }, block_call(node)]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def static_outcome_for(node)
|
|
148
|
+
value = node && static_literal(node)
|
|
149
|
+
return if value.equal?(UNSUPPORTED)
|
|
150
|
+
|
|
151
|
+
{ "kind" => "return", "value" => Recording::Serializer.dump(value) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def block_call(node)
|
|
155
|
+
body = node.block&.body
|
|
156
|
+
body&.type == :statements_node ? body.body.last : body
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def invocation_subject(invocation)
|
|
160
|
+
receiver = invocation.receiver
|
|
161
|
+
receiver = receiver.receiver if receiver&.type == :call_node && receiver.name == :new
|
|
162
|
+
receiver&.location&.slice
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def extract_spec_nodes(node, path, context)
|
|
166
|
+
return [] unless node
|
|
167
|
+
|
|
168
|
+
if node.type == :call_node && SPEC_CALLS.include?(node.name) && node.block
|
|
169
|
+
description = literal(node.arguments&.arguments&.first) || node.name.to_s
|
|
170
|
+
nested = context + [description]
|
|
171
|
+
return [spec_item(node, path, nested)] if %i[it specify].include?(node.name)
|
|
172
|
+
|
|
173
|
+
return node.block.body ? extract_spec_nodes(node.block.body, path, nested) : []
|
|
174
|
+
end
|
|
175
|
+
node.compact_child_nodes.flat_map { |child| extract_spec_nodes(child, path, context) }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def spec_item(node, path, context)
|
|
179
|
+
assertions = nodes(node.block.body).filter_map do |child|
|
|
180
|
+
child.name.to_s if child.type == :call_node && ASSERTIONS.include?(child.name)
|
|
181
|
+
end.uniq
|
|
182
|
+
{ "subject" => context.first, "description" => context.join(" "),
|
|
183
|
+
"location" => "#{path}:#{node.location.start_line}", "assertions" => assertions }
|
|
184
|
+
.merge(extract_expectation(node.block.body) || {})
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def extract_expectation(body)
|
|
188
|
+
nodes(body).filter_map { |node| expectation(node) }.first
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def expectation(node)
|
|
192
|
+
return unless rspec_expectation?(node)
|
|
193
|
+
|
|
194
|
+
expect_call = node.receiver
|
|
195
|
+
matcher = node.arguments&.arguments&.first
|
|
196
|
+
return unless matcher&.type == :call_node
|
|
197
|
+
|
|
198
|
+
invocation = expected_invocation(expect_call)
|
|
199
|
+
return unless invocation&.type == :call_node
|
|
200
|
+
|
|
201
|
+
arguments = invocation.arguments&.arguments || []
|
|
202
|
+
values = arguments.map { |argument| static_literal(argument) }
|
|
203
|
+
return if values.any? { |value| value.equal?(UNSUPPORTED) }
|
|
204
|
+
|
|
205
|
+
outcome = static_outcome(matcher)
|
|
206
|
+
return unless outcome
|
|
207
|
+
|
|
208
|
+
{ "operation" => "##{invocation.name}", "arguments" => values, "expected_outcome" => outcome }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def rspec_expectation?(node)
|
|
212
|
+
node.type == :call_node && node.name == :to && node.receiver&.type == :call_node &&
|
|
213
|
+
node.receiver.name == :expect
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def expected_invocation(expect_call)
|
|
217
|
+
direct = expect_call.arguments&.arguments&.first
|
|
218
|
+
return direct if direct
|
|
219
|
+
|
|
220
|
+
body = expect_call.block&.body
|
|
221
|
+
body&.type == :statements_node ? body.body.last : body
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def static_literal(node)
|
|
225
|
+
case node.type
|
|
226
|
+
when :string_node then node.unescaped
|
|
227
|
+
when :integer_node, :float_node then node.value
|
|
228
|
+
when :nil_node then nil
|
|
229
|
+
when :true_node then true
|
|
230
|
+
when :false_node then false
|
|
231
|
+
when :symbol_node then node.unescaped.to_sym
|
|
232
|
+
else UNSUPPORTED
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def static_outcome(matcher)
|
|
237
|
+
argument = matcher.arguments&.arguments&.first
|
|
238
|
+
case matcher.name
|
|
239
|
+
when :eq, :eql, :equal
|
|
240
|
+
value = argument && static_literal(argument)
|
|
241
|
+
return if value.equal?(UNSUPPORTED)
|
|
242
|
+
|
|
243
|
+
{ "kind" => "return", "value" => Recording::Serializer.dump(value) }
|
|
244
|
+
when :raise_error
|
|
245
|
+
return unless argument
|
|
246
|
+
|
|
247
|
+
{ "kind" => "raise", "class" => argument.location.slice }
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def nodes(root, &block)
|
|
252
|
+
return enum_for(__method__, root) unless block_given?
|
|
253
|
+
|
|
254
|
+
yield root
|
|
255
|
+
root.compact_child_nodes.each { |child| nodes(child, &block) }
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def source_facts(node, path, owner = nil, method_name = nil, parameters = [])
|
|
259
|
+
return [] unless node
|
|
260
|
+
|
|
261
|
+
owner = source_owner(node, owner)
|
|
262
|
+
if node.type == :def_node
|
|
263
|
+
method_name = node.name
|
|
264
|
+
parameters = node.parameters ? node.parameters.requireds.map(&:name) : []
|
|
265
|
+
end
|
|
266
|
+
fact = guard_fact(node, path, owner, method_name, parameters)
|
|
267
|
+
[fact, *node.compact_child_nodes.flat_map do |child|
|
|
268
|
+
source_facts(child, path, owner, method_name, parameters)
|
|
269
|
+
end].compact
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def source_owner(node, owner)
|
|
273
|
+
return owner unless %i[class_node module_node].include?(node.type)
|
|
274
|
+
|
|
275
|
+
name = node.constant_path.location.slice
|
|
276
|
+
name.include?("::") ? name : [owner, name].compact.join("::")
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def guard_fact(node, path, owner, method_name, parameters)
|
|
280
|
+
return unless node.type == :if_node && method_name && contains_raise?(node.statements)
|
|
281
|
+
|
|
282
|
+
predicate = node.predicate.location.slice
|
|
283
|
+
parameters.each_with_index do |name, index|
|
|
284
|
+
predicate = predicate.gsub(/\b#{Regexp.escape(name.to_s)}\b/, "args[#{index}]")
|
|
285
|
+
end
|
|
286
|
+
keyword = node.if_keyword_loc&.slice
|
|
287
|
+
expression = keyword == "unless" ? predicate : "!(#{predicate})"
|
|
288
|
+
{ "kind" => "precondition", "owner" => owner, "operation" => "##{method_name}",
|
|
289
|
+
"expr" => expression, "location" => "#{path}:#{node.location.start_line}",
|
|
290
|
+
"source" => node.location.slice }
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def contains_raise?(node)
|
|
294
|
+
node && nodes(node).any? { |child| child.type == :call_node && child.name == :raise }
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def literal(node)
|
|
298
|
+
return unless node
|
|
299
|
+
return node.unescaped if node.respond_to?(:unescaped)
|
|
300
|
+
return node.value.to_s if node.respond_to?(:value)
|
|
301
|
+
|
|
302
|
+
node.location.slice
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
class Synthesizer
|
|
307
|
+
MAX_EXAMPLES_PER_OPERATION = 50
|
|
308
|
+
ASSUMPTIONS = [
|
|
309
|
+
{ "id" => "H1", "name" => "world_freeze", "enforced_by" => "runtime monitor" },
|
|
310
|
+
{ "id" => "H2", "name" => "dynamic_methods_declared", "enforced_by" => "discovery" },
|
|
311
|
+
{ "id" => "H3", "name" => "no_eval", "enforced_by" => "Prism static detection" },
|
|
312
|
+
{ "id" => "H4", "name" => "runtime_identity_unobserved", "enforced_by" => "serializer" },
|
|
313
|
+
{ "id" => "H5", "name" => "ieee_754", "enforced_by" => "declaration only" },
|
|
314
|
+
{ "id" => "H6", "name" => "exceptions_are_outputs", "enforced_by" => "recorder" },
|
|
315
|
+
{ "id" => "H7", "name" => "single_thread", "enforced_by" => "declaration only" }
|
|
316
|
+
].freeze
|
|
317
|
+
|
|
318
|
+
def initialize(records:, static_examples: [], source_facts: [])
|
|
319
|
+
@records = records
|
|
320
|
+
@static_examples = static_examples
|
|
321
|
+
@source_facts = source_facts
|
|
322
|
+
@invariant_miner = InvariantMiner.new
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def call
|
|
326
|
+
subject_items = subjects
|
|
327
|
+
{
|
|
328
|
+
"spec_bundle_version" => SpecBundle::VERSION,
|
|
329
|
+
"generated_at" => Time.now.utc.iso8601,
|
|
330
|
+
"conformance_mode" => "refinement",
|
|
331
|
+
"canonicalization" => canonicalization,
|
|
332
|
+
"verification_assumptions" => ASSUMPTIONS,
|
|
333
|
+
"subjects" => subject_items,
|
|
334
|
+
"lts" => learned_models,
|
|
335
|
+
"static_facts" => @source_facts.reject { |fact| fact["kind"] == "uncovered_branch" },
|
|
336
|
+
"gaps" => synthesis_gaps,
|
|
337
|
+
"waivers" => []
|
|
338
|
+
}
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
private
|
|
342
|
+
|
|
343
|
+
def canonicalization
|
|
344
|
+
configurations = @records.filter_map { |record| record["canonicalization"] }.uniq
|
|
345
|
+
if configurations.length > 1
|
|
346
|
+
raise Error, "The corpus contains conflicting canonicalization settings. Record the corpus again."
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
configurations.first || {}
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def subjects
|
|
353
|
+
recorded = @records.group_by { |record| record["subject"] }.map do |name, records|
|
|
354
|
+
item = { "name" => short_name(name), "old_class" => name, "operations" => operations(name, records) }
|
|
355
|
+
item["lts_ref"] = lts_id(name) if stateful?(records)
|
|
356
|
+
item
|
|
357
|
+
end
|
|
358
|
+
recorded + static_subjects(recorded.map { |subject| subject["old_class"] })
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def static_subjects(recorded_names)
|
|
362
|
+
executable = @static_examples.select { |example| example["operation"] && example["subject"] }
|
|
363
|
+
executable.group_by { |example| example["subject"] }.filter_map do |name, examples|
|
|
364
|
+
next if recorded_names.any? { |recorded| short_name(recorded) == short_name(name) }
|
|
365
|
+
|
|
366
|
+
{ "name" => short_name(name), "old_class" => name, "operations" => static_operations(examples) }
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def static_operations(examples)
|
|
371
|
+
examples.group_by { |example| example.fetch("operation") }.map do |name, operation_examples|
|
|
372
|
+
{ "name" => name,
|
|
373
|
+
"params" => static_params(operation_examples),
|
|
374
|
+
"preconditions" => [], "invariants" => [],
|
|
375
|
+
"examples" => operation_examples.map { |example| static_example(example) } }
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def static_params(examples)
|
|
380
|
+
count = examples.map { |example| example.fetch("arguments").length }.max || 0
|
|
381
|
+
count.times.map do |index|
|
|
382
|
+
values = examples.select { |example| example.fetch("arguments").length > index }
|
|
383
|
+
.map { |example| example.fetch("arguments")[index] }
|
|
384
|
+
{ "name" => "arg#{index}", "types" => values.map { |value| value.class.name }.uniq,
|
|
385
|
+
"observed_values" => values.map { |value| Recording::Serializer.dump(value) }.uniq }
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def static_example(example)
|
|
390
|
+
digest = Digest::SHA256.hexdigest(example.values_at("location", "description", "operation").join("\0"))[0, 12]
|
|
391
|
+
{ "id" => "static-#{digest}", "provenance_level" => "C", "formal_level" => "F0",
|
|
392
|
+
"provenance" => example.slice("description", "location").merge("derived_from" => ["static_assertion"]),
|
|
393
|
+
"given" => { "args" => Recording::Serializer.dump(example.fetch("arguments")),
|
|
394
|
+
"kwargs" => Recording::Serializer.dump({}), "block_given" => false },
|
|
395
|
+
"expect" => { "outcome" => example.fetch("expected_outcome") } }
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def synthesis_gaps
|
|
399
|
+
gaps = @source_facts.select { |fact| fact["kind"] == "uncovered_branch" }
|
|
400
|
+
return gaps unless @records.empty?
|
|
401
|
+
|
|
402
|
+
gaps + [{ "kind" => "no_runtime_recording",
|
|
403
|
+
"note" => "Only static assertions were extracted; unasserted legacy behavior is unknown." }]
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def learned_models
|
|
407
|
+
@records.group_by { |record| record["subject"] }.filter_map do |name, records|
|
|
408
|
+
next unless stateful?(records)
|
|
409
|
+
|
|
410
|
+
Formal::PassiveLearner.new.learn(records).to_h.merge("id" => lts_id(name),
|
|
411
|
+
"learned_by" => "passive corpus projection")
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def stateful?(records) = records.any? { |record| record["pre_state"] || record["post_state"] }
|
|
416
|
+
def lts_id(name) = "lts-#{short_name(name).downcase}"
|
|
417
|
+
|
|
418
|
+
def operations(subject_name, records)
|
|
419
|
+
records.group_by { |record| record["operation"] }.map do |name, calls|
|
|
420
|
+
sampled = representative_sample(calls)
|
|
421
|
+
{ "name" => name, "params" => params(calls), "examples" => sampled.map { |call| example(call) },
|
|
422
|
+
"preconditions" => preconditions(subject_name, name), "invariants" => @invariant_miner.mine(calls) }
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def representative_sample(calls)
|
|
427
|
+
signatures = {}
|
|
428
|
+
diverse = calls.select do |call|
|
|
429
|
+
signature = JSON.generate(call.values_at("args", "kwargs", "outcome", "post_state", "external_calls"))
|
|
430
|
+
!signatures.key?(signature) && (signatures[signature] = true)
|
|
431
|
+
end
|
|
432
|
+
(diverse + calls).uniq { |call| call["id"] }.first(MAX_EXAMPLES_PER_OPERATION)
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def preconditions(subject_name, operation_name)
|
|
436
|
+
@source_facts.each_with_index.filter_map do |fact, index|
|
|
437
|
+
next unless fact["kind"] == "precondition" && fact["owner"] == subject_name &&
|
|
438
|
+
fact["operation"] == operation_name
|
|
439
|
+
|
|
440
|
+
fact.slice("expr", "location", "source").merge("id" => format("pre-%04d", index + 1),
|
|
441
|
+
"provenance_level" => "C", "formal_level" => "F1")
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def params(calls)
|
|
446
|
+
count = calls.map { |call| call.fetch("args", []).length }.max || 0
|
|
447
|
+
count.times.map do |index|
|
|
448
|
+
serialized = calls.filter { |call| call.fetch("args", []).length > index }
|
|
449
|
+
.map { |call| call.fetch("args")[index] }
|
|
450
|
+
observed = serialized.map { |value| Recording::Serializer.load(value) }
|
|
451
|
+
{ "name" => "arg#{index}", "types" => observed.map { |value| value.class.name }.uniq,
|
|
452
|
+
"observed_values" => serialized.uniq }
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def example(call)
|
|
457
|
+
provenance = call["provenance"] || {}
|
|
458
|
+
static = @static_examples.find { |item| matching_static_evidence?(item, call, provenance) }
|
|
459
|
+
{
|
|
460
|
+
"id" => call["id"], "provenance_level" => static ? "A" : "B", "formal_level" => "F0",
|
|
461
|
+
"provenance" => provenance.merge("static_assertions" => static&.fetch("assertions", nil)).compact,
|
|
462
|
+
"given" => call.slice("pre_state", "args", "kwargs", "block_given"),
|
|
463
|
+
"expect" => call.slice("outcome", "post_state", "yields", "external_calls", "mutated_args")
|
|
464
|
+
}
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def matching_static_evidence?(item, call, provenance)
|
|
468
|
+
item["description"] == provenance["description"] && item["operation"] == call["operation"] &&
|
|
469
|
+
matching_subject?(item["subject"].to_s, call["subject"]) &&
|
|
470
|
+
Recording::Serializer.dump(item.fetch("arguments", [])) == call.fetch("args", []) &&
|
|
471
|
+
matching_outcome?(item, call)
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def matching_subject?(static_name, recorded_name)
|
|
475
|
+
static_name == recorded_name || (!static_name.include?("::") && static_name == short_name(recorded_name))
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def matching_outcome?(item, call)
|
|
479
|
+
expected = item["expected_outcome"]
|
|
480
|
+
actual = call["outcome"]
|
|
481
|
+
expected.is_a?(Hash) && actual.is_a?(Hash) && expected == actual.slice(*expected.keys)
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def short_name(name) = name.split("::").last
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
end
|