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,428 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bparity
|
|
4
|
+
module FormalCommands # rubocop:disable Metrics/ModuleLength -- one private CLI command family
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def command_prove(argv)
|
|
8
|
+
options = formal_options(argv)
|
|
9
|
+
validate_formal_options!(options)
|
|
10
|
+
options[:requires].each { |path| require File.expand_path(path) }
|
|
11
|
+
bundle = SpecBundle::Loader.load(options[:spec])
|
|
12
|
+
Bparity.reset!
|
|
13
|
+
load File.expand_path(options[:adapter])
|
|
14
|
+
raise ConfigurationError, "The adapter file did not call Bparity.adapter." unless Bparity.adapter_definition
|
|
15
|
+
|
|
16
|
+
result = case options[:level]
|
|
17
|
+
when "f2" then run_f2(bundle, Bparity.adapter_definition, options)
|
|
18
|
+
when "f3" then run_f3(bundle, Bparity.adapter_definition, options)
|
|
19
|
+
else run_f4(bundle, Bparity.adapter_definition, options)
|
|
20
|
+
end
|
|
21
|
+
promote_f2_invariants(bundle, options, result)
|
|
22
|
+
@out.puts(JSON.pretty_generate(result.to_h))
|
|
23
|
+
result.success? || result.details["skipped"] ? 0 : 1
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def formal_options(argv)
|
|
27
|
+
options = { spec: ".bparity/spec_bundle.yml", adapter: ".bparity/adapter.rb", level: "f2",
|
|
28
|
+
size: 3, depth: 2, max_cases: 100_000, timebox: 300, requires: [], relation: "trace" }
|
|
29
|
+
OptionParser.new do |opts|
|
|
30
|
+
opts.on("--spec PATH") { |value| options[:spec] = value }
|
|
31
|
+
opts.on("--adapter PATH") { |value| options[:adapter] = value }
|
|
32
|
+
opts.on("--level LEVEL") { |value| options[:level] = value }
|
|
33
|
+
add_formal_target_options(opts, options)
|
|
34
|
+
opts.on("--scope SCOPE") { |value| parse_scope(value, options) }
|
|
35
|
+
opts.on("--max-cases N", Integer) { |value| options[:max_cases] = value }
|
|
36
|
+
opts.on("--timebox SECONDS", Integer) { |value| options[:timebox] = value }
|
|
37
|
+
opts.on("--state-limit N", Integer) { |value| options[:state_limit] = value }
|
|
38
|
+
opts.on("--equivalence RELATION") { |value| options[:relation] = value }
|
|
39
|
+
opts.on("--export-lts PREFIX") { |value| options[:export_lts] = value }
|
|
40
|
+
opts.on("--counterexample-out PATH") { |value| options[:counterexample_out] = value }
|
|
41
|
+
add_formal_validation_options(opts, options)
|
|
42
|
+
opts.on("--old-source PATH") { |value| options[:old_source] = value }
|
|
43
|
+
opts.on("--new-source PATH") { |value| options[:new_source] = value }
|
|
44
|
+
opts.on("--old-method NAME") { |value| options[:old_method] = value }
|
|
45
|
+
opts.on("--new-method NAME") { |value| options[:new_method] = value }
|
|
46
|
+
opts.on("--types TYPES") { |value| options[:types] = value.split(",") }
|
|
47
|
+
opts.on("--solver NAME") { |value| options[:solver] = value }
|
|
48
|
+
opts.on("--require PATH") { |value| options[:requires] << value }
|
|
49
|
+
end.parse!(argv)
|
|
50
|
+
options
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def promote_f2_invariants(bundle, options, result)
|
|
54
|
+
return unless options[:level] == "f2" && options[:promote_invariants] && result.success? &&
|
|
55
|
+
result.scope.exhaustive
|
|
56
|
+
|
|
57
|
+
_subject, operation = select_formal_operation(bundle, options)
|
|
58
|
+
invariants = operation.fetch("invariants", [])
|
|
59
|
+
invariants.each { |invariant| invariant["formal_level"] = "F2" }
|
|
60
|
+
result.details["promoted_invariants"] = invariants.map { |invariant| invariant["id"] }
|
|
61
|
+
SpecBundle::Writer.write(options[:spec], bundle)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def add_formal_target_options(parser, options)
|
|
65
|
+
parser.on("--subject NAME") { |value| options[:subject] = value }
|
|
66
|
+
parser.on("--operation NAME") { |value| options[:operation] = value }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def add_formal_validation_options(parser, options)
|
|
70
|
+
parser.on("--validate-translation") { options[:validate_translation] = true }
|
|
71
|
+
parser.on("--promote-invariants") { options[:promote_invariants] = true }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate_formal_options!(options)
|
|
75
|
+
unless %w[f2 f3 f4].include?(options[:level])
|
|
76
|
+
raise ConfigurationError, "Formal level #{options[:level]} is not available. Use f2, f3, or f4."
|
|
77
|
+
end
|
|
78
|
+
unless options.values_at(:size, :depth).all? { |value| value >= 0 } &&
|
|
79
|
+
options.values_at(:max_cases, :timebox).all?(&:positive?) &&
|
|
80
|
+
(!options[:state_limit] || options[:state_limit].positive?)
|
|
81
|
+
raise ConfigurationError,
|
|
82
|
+
"Formal limits are invalid. Use non-negative size/depth and positive case, time, and state limits."
|
|
83
|
+
end
|
|
84
|
+
return unless options[:level] == "f4" && !options[:validate_translation]
|
|
85
|
+
|
|
86
|
+
raise ConfigurationError, "F4 requires --validate-translation. Run F2 first and enable translation validation."
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def run_f2(bundle, adapter, options)
|
|
90
|
+
spec_subject, operation_spec = select_formal_operation(bundle, options)
|
|
91
|
+
binding, operation = formal_binding(adapter, spec_subject, operation_spec)
|
|
92
|
+
old_class = legacy_class(spec_subject)
|
|
93
|
+
domains = f2_domains(operation_spec, options)
|
|
94
|
+
method_name = operation_spec.fetch("name").delete_prefix("#")
|
|
95
|
+
validate_legacy_operation!(old_class, method_name) if old_class
|
|
96
|
+
old_callable = ->(*args) { old_class.new.public_send(method_name, *args) } if old_class
|
|
97
|
+
new_callable = ->(*args) { operation.invoke(binding.build({}), args, {}) }
|
|
98
|
+
runner = build_f2_runner(old_callable, new_callable, domains, operation, operation_spec, options)
|
|
99
|
+
violations = f2_static_violations(old_class, method_name, operation)
|
|
100
|
+
unless violations.empty?
|
|
101
|
+
return Formal::Result.new(level: :f2, verdict: :inconclusive,
|
|
102
|
+
scope: Formal::Scope.new(size: options[:size], depth: options[:depth],
|
|
103
|
+
cases: 0, exhaustive: false,
|
|
104
|
+
timebox: options[:timebox]),
|
|
105
|
+
assumptions: %i[h1 h3 h7], out_of_scope: ["dynamic Ruby"],
|
|
106
|
+
details: { "assumption_violations" => violations })
|
|
107
|
+
end
|
|
108
|
+
target = binding.build({})
|
|
109
|
+
monitored_class = target.is_a?(Module) ? target.singleton_class : target.class
|
|
110
|
+
result, violations = Formal::Assumptions::WorldFreeze.new([old_class, monitored_class].compact).check do
|
|
111
|
+
runner.run
|
|
112
|
+
end
|
|
113
|
+
if violations.empty?
|
|
114
|
+
write_f2_counterexample(result, options, spec_subject, operation_spec)
|
|
115
|
+
return result
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
Formal::Result.new(level: :f2, verdict: :inconclusive, scope: result.scope,
|
|
119
|
+
assumptions: result.assumptions, out_of_scope: result.out_of_scope,
|
|
120
|
+
details: { "assumption_violations" => violations })
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def legacy_class(subject)
|
|
124
|
+
Bparity.constantize(subject.fetch("old_class"))
|
|
125
|
+
rescue ConfigurationError => e
|
|
126
|
+
raise unless e.message.start_with?("Cannot find ")
|
|
127
|
+
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def validate_legacy_operation!(old_class, method_name)
|
|
132
|
+
unless old_class.public_method_defined?(method_name)
|
|
133
|
+
raise ConfigurationError,
|
|
134
|
+
"Legacy class #{old_class} has no public method #{method_name}. Fix the Spec Bundle target."
|
|
135
|
+
end
|
|
136
|
+
old_class.new
|
|
137
|
+
rescue ConfigurationError
|
|
138
|
+
raise
|
|
139
|
+
rescue StandardError => e
|
|
140
|
+
raise ConfigurationError,
|
|
141
|
+
"Legacy class #{old_class} cannot be constructed without arguments: #{e.message}. " \
|
|
142
|
+
"Use contract-only F2 or record a construct mapping."
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def f2_static_violations(old_class, method_name, operation)
|
|
146
|
+
old_source = old_class.instance_method(method_name).source_location&.first if old_class
|
|
147
|
+
paths = [old_source, operation.invoker&.source_location&.first].compact.uniq
|
|
148
|
+
Formal::Assumptions::DynamicCodeDetector.new.scan(paths)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def write_f2_counterexample(result, options, subject, operation)
|
|
152
|
+
return unless result.counterexample && options[:counterexample_out]
|
|
153
|
+
|
|
154
|
+
content = Formal::BoundedCounterexampleRSpec.call(result:, subject_name: subject.fetch("name"),
|
|
155
|
+
operation_name: operation.fetch("name"))
|
|
156
|
+
FileUtils.mkdir_p(File.dirname(options[:counterexample_out]))
|
|
157
|
+
File.write(options[:counterexample_out], content)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def f2_domains(operation_spec, options)
|
|
161
|
+
limit = options.fetch(:max_cases, Formal::ExhaustiveRunner::DEFAULT_MAX_CASES)
|
|
162
|
+
operation_spec.fetch("params", []).map do |param|
|
|
163
|
+
observed = param.fetch("observed_values", []).map { |value| Recording::Serializer.load(value) }
|
|
164
|
+
alphabet = observed.grep(String).flat_map(&:chars).uniq.first(8)
|
|
165
|
+
enumerator = Formal::ValueEnumerator.new(size: options[:size], depth: options[:depth],
|
|
166
|
+
alphabet: alphabet.empty? ? %w[a b] : alphabet, observed:,
|
|
167
|
+
limit:)
|
|
168
|
+
types = param.fetch("types", [])
|
|
169
|
+
if types.empty?
|
|
170
|
+
raise ConfigurationError,
|
|
171
|
+
"F2 parameter #{param['name']} has no inferred type. Record values or add an explicit input domain."
|
|
172
|
+
end
|
|
173
|
+
type_domains = types.map { |type| enumerator.values(type) }
|
|
174
|
+
values = (type_domains.flat_map(&:to_a) + [nil]).uniq
|
|
175
|
+
truncated = type_domains.any?(&:truncated) || values.length > limit
|
|
176
|
+
Formal::Domain.new(values.first(limit), truncated:)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def select_formal_operation(bundle, options)
|
|
181
|
+
subjects = bundle.fetch("subjects")
|
|
182
|
+
subjects = subjects.select { |subject| subject["name"] == options[:subject] } if options[:subject]
|
|
183
|
+
if subjects.length != 1
|
|
184
|
+
names = bundle.fetch("subjects").map { |subject| subject.fetch("name") }.join(", ")
|
|
185
|
+
raise ConfigurationError, "Select one formal subject with --subject NAME. Available subjects: #{names}."
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
subject = subjects.first
|
|
189
|
+
operations = subject.fetch("operations")
|
|
190
|
+
operations = operations.select { |operation| operation["name"] == options[:operation] } if options[:operation]
|
|
191
|
+
if operations.length != 1
|
|
192
|
+
names = subject.fetch("operations").map { |operation| operation.fetch("name") }.join(", ")
|
|
193
|
+
raise ConfigurationError, "Select one formal operation with --operation NAME. Available operations: #{names}."
|
|
194
|
+
end
|
|
195
|
+
[subject, operations.first]
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def formal_binding(adapter, subject, operation = nil)
|
|
199
|
+
binding = adapter.subjects[subject.fetch("name")]
|
|
200
|
+
unless binding
|
|
201
|
+
raise ConfigurationError,
|
|
202
|
+
"Adapter subject #{subject.fetch('name')} is missing. Add it to the adapter file."
|
|
203
|
+
end
|
|
204
|
+
return binding unless operation
|
|
205
|
+
|
|
206
|
+
mapped = binding.operations[operation.fetch("name")]
|
|
207
|
+
unless mapped
|
|
208
|
+
raise ConfigurationError,
|
|
209
|
+
"Adapter operation #{subject.fetch('name')}#{operation.fetch('name')} is missing. Add it to the adapter."
|
|
210
|
+
end
|
|
211
|
+
[binding, mapped]
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def build_f2_runner(old_callable, new_callable, domains, operation, operation_spec, options)
|
|
215
|
+
contracts = operation_spec.fetch("postconditions", []) + operation_spec.fetch("invariants", [])
|
|
216
|
+
Formal::ExhaustiveRunner.new(old_callable:, new_callable:, domains:, size: options[:size],
|
|
217
|
+
depth: options[:depth], assumptions: %i[h1 h3 h7],
|
|
218
|
+
contracts:, preconditions: operation_spec.fetch("preconditions", []),
|
|
219
|
+
max_cases: options[:max_cases], timebox: options[:timebox],
|
|
220
|
+
new_error_mapper: operation.method(:map_error))
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def parse_scope(value, options)
|
|
224
|
+
value.split(",").each do |entry|
|
|
225
|
+
key, number = entry.split("=", 2)
|
|
226
|
+
raise ConfigurationError, "Invalid scope #{entry}. Use size=N,depth=N." unless %w[size depth].include?(key)
|
|
227
|
+
|
|
228
|
+
options[key.to_sym] = Integer(number, 10)
|
|
229
|
+
end
|
|
230
|
+
rescue ArgumentError
|
|
231
|
+
raise ConfigurationError, "Invalid scope #{value}. Use size=N,depth=N."
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def run_f3(bundle, adapter, options)
|
|
235
|
+
spec_subject = select_f3_subject(bundle, options)
|
|
236
|
+
old_data = bundle.fetch("lts").find { |model| model["id"] == spec_subject["lts_ref"] }
|
|
237
|
+
old_lts = Formal::LTS.from_h(old_data)
|
|
238
|
+
binding = formal_binding(adapter, spec_subject)
|
|
239
|
+
unless binding.state_projection
|
|
240
|
+
raise ConfigurationError,
|
|
241
|
+
"Adapter subject #{binding.name} needs a state block for F3."
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
static_violations = f3_static_violations(spec_subject, binding)
|
|
245
|
+
return inconclusive_f3(old_lts, options, static_violations) unless static_violations.empty?
|
|
246
|
+
|
|
247
|
+
learned, runtime_violations = learn_f3(binding, options)
|
|
248
|
+
export_lts(options[:export_lts], old_lts, learned.lts) if options[:export_lts]
|
|
249
|
+
result = Formal::LtsEquivalence.new.compare(old_lts, learned.lts, relation: options[:relation],
|
|
250
|
+
exact: learned.complete)
|
|
251
|
+
unless runtime_violations.empty?
|
|
252
|
+
return Formal::Result.new(level: :f3, verdict: :inconclusive, scope: result.scope,
|
|
253
|
+
assumptions: result.assumptions, out_of_scope: result.out_of_scope,
|
|
254
|
+
details: result.details.merge("assumption_violations" => runtime_violations))
|
|
255
|
+
end
|
|
256
|
+
result.details["skipped"] = "state limit exceeded" unless learned.complete || result.counterexample
|
|
257
|
+
write_lts_counterexample(result, options, old_lts, spec_subject)
|
|
258
|
+
result
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def select_f3_subject(bundle, options)
|
|
262
|
+
stateful = bundle.fetch("subjects").select { |subject| subject["lts_ref"] }
|
|
263
|
+
stateful.select! { |subject| subject["name"] == options[:subject] } if options[:subject]
|
|
264
|
+
if stateful.empty?
|
|
265
|
+
raise ConfigurationError, "The Spec Bundle has no stateful subject. Record with a state projection first."
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
if stateful.length > 1
|
|
269
|
+
names = stateful.map { |subject| subject.fetch("name") }.join(", ")
|
|
270
|
+
raise ConfigurationError, "Select one F3 subject with --subject NAME. Available subjects: #{names}."
|
|
271
|
+
end
|
|
272
|
+
stateful.first
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def f3_static_violations(spec_subject, binding)
|
|
276
|
+
parameterized = spec_subject.fetch("operations").filter_map do |operation|
|
|
277
|
+
next if operation.fetch("params", []).empty?
|
|
278
|
+
|
|
279
|
+
{ "assumption" => "H6", "location" => operation.fetch("name"),
|
|
280
|
+
"reason" => "F3 requires a declared finite input alphabet for parameterized operations" }
|
|
281
|
+
end
|
|
282
|
+
paths = binding.operations.values.filter_map { |operation| operation.invoker&.source_location&.first }.uniq
|
|
283
|
+
parameterized + Formal::Assumptions::DynamicCodeDetector.new.scan(paths)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def learn_f3(binding, options)
|
|
287
|
+
operations = binding.operations.to_h { |name, operation| [name, observed_operation(operation)] }
|
|
288
|
+
learner = Formal::ActiveLearner.new(factory: -> { binding.build({}) },
|
|
289
|
+
state_projection: binding.state_projection, operations:,
|
|
290
|
+
state_limit: options.fetch(:state_limit, 500))
|
|
291
|
+
target = binding.build({})
|
|
292
|
+
monitored_class = target.is_a?(Module) ? target.singleton_class : target.class
|
|
293
|
+
Formal::Assumptions::WorldFreeze.new([monitored_class]).check { learner.learn }
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def inconclusive_f3(old_lts, options, violations)
|
|
297
|
+
Formal::Result.new(level: :f3, verdict: :inconclusive,
|
|
298
|
+
scope: Formal::Scope.new(size: old_lts.states.length, depth: nil, cases: 0,
|
|
299
|
+
exhaustive: false, timebox: options[:timebox]),
|
|
300
|
+
assumptions: %i[h1 h3 h6 h7],
|
|
301
|
+
out_of_scope: ["replacement model exploration"],
|
|
302
|
+
details: { "assumption_violations" => violations })
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def observed_operation(operation)
|
|
306
|
+
lambda do |subject|
|
|
307
|
+
value = operation.invoke(subject, [], {})
|
|
308
|
+
Formal::ObservedOutput.new({ "kind" => "return",
|
|
309
|
+
"value" => Recording::Serializer.dump(operation.map_return(value)) })
|
|
310
|
+
rescue StandardError => e
|
|
311
|
+
Formal::ObservedOutput.new({ "kind" => "raise", **operation.map_error(e) })
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def write_lts_counterexample(result, options, old_lts, spec_subject)
|
|
316
|
+
return unless result.counterexample && options[:counterexample_out]
|
|
317
|
+
|
|
318
|
+
spec = Formal::CounterexampleRSpec.call(lts: old_lts,
|
|
319
|
+
sequence: result.counterexample.fetch("sequence"),
|
|
320
|
+
subject_name: spec_subject.fetch("name"))
|
|
321
|
+
FileUtils.mkdir_p(File.dirname(options[:counterexample_out]))
|
|
322
|
+
File.write(options[:counterexample_out], spec)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def export_lts(prefix, old_lts, new_lts)
|
|
326
|
+
FileUtils.mkdir_p(File.dirname(prefix))
|
|
327
|
+
File.write("#{prefix}_old.aut", Formal::AldebaranExporter.call(old_lts))
|
|
328
|
+
File.write("#{prefix}_new.aut", Formal::AldebaranExporter.call(new_lts))
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def run_f4(bundle, adapter, options)
|
|
332
|
+
validate_f4_options!(options)
|
|
333
|
+
fragment_violations = Formal::Deductive::FragmentChecker.new.then do |checker|
|
|
334
|
+
checker.check_file(options[:old_source], options[:old_method]) +
|
|
335
|
+
checker.check_file(options[:new_source], options[:new_method])
|
|
336
|
+
end
|
|
337
|
+
unless fragment_violations.empty?
|
|
338
|
+
return inconclusive_f4(options, "verifiable fragment rejected",
|
|
339
|
+
fragment_violations)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
subject_spec, operation_spec = select_formal_operation(bundle, options)
|
|
343
|
+
binding, operation = formal_binding(adapter, subject_spec, operation_spec)
|
|
344
|
+
runner, old_class = build_f4_runner(subject_spec, binding, operation, options)
|
|
345
|
+
target = binding.build({})
|
|
346
|
+
target_class = target.is_a?(Module) ? target.singleton_class : target.class
|
|
347
|
+
result, violations = Formal::Assumptions::WorldFreeze.new([old_class, target_class]).check { runner.run }
|
|
348
|
+
if violations.empty?
|
|
349
|
+
write_f4_counterexample(result, options, subject_spec, operation_spec)
|
|
350
|
+
return result
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
Formal::Result.new(level: :f4, verdict: :inconclusive, scope: result.scope,
|
|
354
|
+
assumptions: result.assumptions, out_of_scope: result.out_of_scope,
|
|
355
|
+
details: result.details.merge("assumption_violations" => violations))
|
|
356
|
+
rescue ConfigurationError => e
|
|
357
|
+
raise unless e.message.start_with?("F4 does not support")
|
|
358
|
+
|
|
359
|
+
inconclusive_f4(options, e.message, [])
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def build_f4_runner(subject, binding, operation, options)
|
|
363
|
+
old_class = Bparity.constantize(subject.fetch("old_class"))
|
|
364
|
+
translator = Formal::Deductive::RubyToSmt.new
|
|
365
|
+
old_translation = translator.translate_file(options[:old_source], options[:old_method],
|
|
366
|
+
parameter_types: options[:types])
|
|
367
|
+
new_translation = translator.translate_file(options[:new_source], options[:new_method],
|
|
368
|
+
parameter_types: options[:types])
|
|
369
|
+
validation_inputs = f4_inputs(options[:types], options)
|
|
370
|
+
runner = Formal::Deductive::Runner.new(
|
|
371
|
+
old_translation:, new_translation:,
|
|
372
|
+
old_callable: ->(*args) { old_class.new.public_send(options[:old_method], *args) },
|
|
373
|
+
new_callable: ->(*args) { operation.invoke(binding.build({}), args, {}) },
|
|
374
|
+
validation_inputs:,
|
|
375
|
+
validation_scope: { "size" => options[:size], "depth" => options[:depth],
|
|
376
|
+
"cases" => validation_inputs.length, "exhaustive" => true },
|
|
377
|
+
solver: Formal::Deductive::Z3.new(timeout: options[:timebox])
|
|
378
|
+
)
|
|
379
|
+
[runner, old_class]
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def write_f4_counterexample(result, options, subject, operation)
|
|
383
|
+
return unless result.counterexample&.key?("input") && options[:counterexample_out]
|
|
384
|
+
|
|
385
|
+
content = Formal::Deductive::CounterexampleRSpec.call(
|
|
386
|
+
result:, subject_name: subject.fetch("name"), operation_name: operation.fetch("name")
|
|
387
|
+
)
|
|
388
|
+
FileUtils.mkdir_p(File.dirname(options[:counterexample_out]))
|
|
389
|
+
File.write(options[:counterexample_out], content)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def inconclusive_f4(options, reason, violations)
|
|
393
|
+
Formal::Result.new(level: :f4, verdict: :inconclusive,
|
|
394
|
+
scope: Formal::Scope.new(size: options[:size], depth: options[:depth], cases: 0,
|
|
395
|
+
exhaustive: false, timebox: options[:timebox]),
|
|
396
|
+
assumptions: %i[h1 h3], out_of_scope: ["Ruby outside the declared pure fragment"],
|
|
397
|
+
details: { "reason" => reason, "fragment_violations" => violations })
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def validate_f4_options!(options)
|
|
401
|
+
required = %i[old_source new_source old_method new_method types]
|
|
402
|
+
missing = required.reject { |key| options[key] }
|
|
403
|
+
unless missing.empty?
|
|
404
|
+
raise ConfigurationError,
|
|
405
|
+
"F4 is missing #{missing.join(', ')}. Provide both sources, method names, and --types."
|
|
406
|
+
end
|
|
407
|
+
return unless options[:solver] && options[:solver] != "z3"
|
|
408
|
+
|
|
409
|
+
raise ConfigurationError, "Unsupported F4 solver #{options[:solver]}. Use z3."
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def f4_inputs(types, options)
|
|
413
|
+
domains = types.map do |type|
|
|
414
|
+
Formal::ValueEnumerator.new(size: options[:size], depth: options[:depth]).values(type)
|
|
415
|
+
end
|
|
416
|
+
return [[]] if domains.empty?
|
|
417
|
+
|
|
418
|
+
cases = domains.reduce(1) { |count, domain| count * domain.length }
|
|
419
|
+
if cases > options[:max_cases]
|
|
420
|
+
raise ConfigurationError,
|
|
421
|
+
"F4 does not support truncated translation validation. Increase --max-cases above #{cases} or " \
|
|
422
|
+
"reduce --scope."
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
domains.first.product(*domains.drop(1))
|
|
426
|
+
end
|
|
427
|
+
end # rubocop:enable Metrics/ModuleLength
|
|
428
|
+
end
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module Bparity
|
|
6
|
+
module VerificationCommands
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def command_verify(argv)
|
|
10
|
+
options = verify_options(argv)
|
|
11
|
+
validate_verify_options!(options)
|
|
12
|
+
options[:requires].each { |path| require File.expand_path(path) }
|
|
13
|
+
bundle = SpecBundle::Loader.load(options[:spec])
|
|
14
|
+
adapter = load_verify_adapter(bundle, options[:adapter], options[:adapter_explicit])
|
|
15
|
+
results = options[:runners].flat_map { |runner| run_verify_runner(runner, bundle, adapter, options) }
|
|
16
|
+
write_verification_report(results, bundle, options)
|
|
17
|
+
verify_success?(results, options[:fail_under]) ? 0 : 1
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def verify_options(argv)
|
|
21
|
+
options = { spec: ".bparity/spec_bundle.yml", adapter: ".bparity/adapter.rb", format: "markdown",
|
|
22
|
+
runners: ["replay"], fail_under: 100.0, size: 3, depth: 2, max_cases: 1_000,
|
|
23
|
+
timebox: 300, state_limit: 500, relation: "trace", requires: [] }
|
|
24
|
+
OptionParser.new do |opts|
|
|
25
|
+
add_verify_core_options(opts, options)
|
|
26
|
+
add_verify_generation_options(opts, options)
|
|
27
|
+
end.parse!(argv)
|
|
28
|
+
options
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def add_verify_core_options(parser, options)
|
|
32
|
+
parser.on("--spec PATH") { |value| options[:spec] = value }
|
|
33
|
+
parser.on("--adapter PATH") do |value|
|
|
34
|
+
options[:adapter] = value
|
|
35
|
+
options[:adapter_explicit] = true
|
|
36
|
+
end
|
|
37
|
+
parser.on("--mode MODE") { |value| options[:mode] = value }
|
|
38
|
+
parser.on("--runners LIST") { |value| options[:runners] = value.split(",") }
|
|
39
|
+
parser.on("--fail-under PERCENT", Float) { |value| options[:fail_under] = value }
|
|
40
|
+
parser.on("--format FORMAT") { |value| options[:format] = value }
|
|
41
|
+
parser.on("--out PATH") { |value| options[:out] = value }
|
|
42
|
+
parser.on("--require PATH") { |value| options[:requires] << value }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def add_verify_generation_options(parser, options)
|
|
46
|
+
parser.on("--scope SCOPE") { |value| parse_scope(value, options) }
|
|
47
|
+
parser.on("--max-cases N", Integer) { |value| options[:max_cases] = value }
|
|
48
|
+
parser.on("--timebox SECONDS", Integer) { |value| options[:timebox] = value }
|
|
49
|
+
parser.on("--state-limit N", Integer) { |value| options[:state_limit] = value }
|
|
50
|
+
parser.on("--equivalence RELATION") { |value| options[:relation] = value }
|
|
51
|
+
parser.on("--subject NAME") { |value| options[:subject] = value }
|
|
52
|
+
parser.on("--operation NAME") { |value| options[:operation] = value }
|
|
53
|
+
parser.on("--old-command COMMAND") { |value| options[:old_command] = Shellwords.split(value) }
|
|
54
|
+
parser.on("--new-command COMMAND") { |value| options[:new_command] = Shellwords.split(value) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_verify_options!(options)
|
|
58
|
+
unless Reporting::Reporter::FORMATS.include?(options[:format])
|
|
59
|
+
raise ConfigurationError,
|
|
60
|
+
"Unknown report format #{options[:format]}. Use markdown, json, junit, or html."
|
|
61
|
+
end
|
|
62
|
+
allowed = %w[replay property model differential]
|
|
63
|
+
unknown = options[:runners] - allowed
|
|
64
|
+
unless unknown.empty?
|
|
65
|
+
raise ConfigurationError,
|
|
66
|
+
"Unknown verification runner #{unknown.join(', ')}. Use replay, property, model, or differential."
|
|
67
|
+
end
|
|
68
|
+
raise ConfigurationError, "At least one verification runner is required." if options[:runners].empty?
|
|
69
|
+
unless (0.0..100.0).cover?(options[:fail_under])
|
|
70
|
+
raise ConfigurationError, "Verification threshold must be between 0 and 100."
|
|
71
|
+
end
|
|
72
|
+
unless options.values_at(:size, :depth).all? { |value| value >= 0 } &&
|
|
73
|
+
options.values_at(:max_cases, :timebox, :state_limit).all?(&:positive?)
|
|
74
|
+
raise ConfigurationError,
|
|
75
|
+
"Verification limits are invalid. Use non-negative size/depth and positive case, time, and state limits."
|
|
76
|
+
end
|
|
77
|
+
return unless options[:runners].include?("differential") &&
|
|
78
|
+
(!options[:old_command] || !options[:new_command])
|
|
79
|
+
|
|
80
|
+
raise ConfigurationError,
|
|
81
|
+
"Differential verification requires --old-command and --new-command. " \
|
|
82
|
+
"Each command must read JSON from stdin."
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def load_verify_adapter(bundle, path, explicit)
|
|
86
|
+
if File.exist?(path)
|
|
87
|
+
Bparity.reset!
|
|
88
|
+
load File.expand_path(path)
|
|
89
|
+
return Bparity.adapter_definition || raise(ConfigurationError,
|
|
90
|
+
"The adapter file did not call Bparity.adapter.")
|
|
91
|
+
end
|
|
92
|
+
if explicit
|
|
93
|
+
raise ConfigurationError,
|
|
94
|
+
"Adapter file #{path} was not found. Correct the path or omit --adapter for direct replay."
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
direct_adapter(bundle)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def direct_adapter(bundle)
|
|
101
|
+
Adapter::Definition.new.tap do |definition|
|
|
102
|
+
bundle.fetch("subjects").each do |spec_subject|
|
|
103
|
+
name = spec_subject.fetch("name")
|
|
104
|
+
definition.subject(name) do
|
|
105
|
+
construct do
|
|
106
|
+
target = Bparity.constantize(name)
|
|
107
|
+
target.is_a?(Class) ? target.new : target
|
|
108
|
+
end
|
|
109
|
+
spec_subject.fetch("operations").each do |spec_operation|
|
|
110
|
+
operation(spec_operation.fetch("name"))
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def run_verify_runner(name, bundle, adapter, options)
|
|
118
|
+
case name
|
|
119
|
+
when "replay" then Verification::Runner.new(bundle:, adapter:, mode: options[:mode]).run
|
|
120
|
+
when "property" then run_property_verification(bundle, adapter, options)
|
|
121
|
+
when "model" then [formal_verification_result(run_f3(bundle, adapter, options), "model")]
|
|
122
|
+
else run_differential_verification(bundle, options)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def run_property_verification(bundle, adapter, options)
|
|
127
|
+
checks = bundle.fetch("subjects").flat_map do |subject|
|
|
128
|
+
subject.fetch("operations").filter_map do |operation_spec|
|
|
129
|
+
contracts = operation_spec.fetch("postconditions", []) + operation_spec.fetch("invariants", [])
|
|
130
|
+
next if contracts.empty?
|
|
131
|
+
|
|
132
|
+
binding, operation = formal_binding(adapter, subject, operation_spec)
|
|
133
|
+
callable = lambda do |*args|
|
|
134
|
+
operation.map_return(operation.invoke(binding.build({}), args, {}))
|
|
135
|
+
end
|
|
136
|
+
counterexample = Formal::PropertyRunner.new(
|
|
137
|
+
callable:, invariants: contracts, inputs: generated_inputs(operation_spec, options),
|
|
138
|
+
preconditions: operation_spec.fetch("preconditions", [])
|
|
139
|
+
).run
|
|
140
|
+
property_result(subject, operation_spec, counterexample)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
return checks unless checks.empty?
|
|
144
|
+
|
|
145
|
+
raise ConfigurationError,
|
|
146
|
+
"Property verification found no contracts. Synthesize invariants or select the replay runner."
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def run_differential_verification(bundle, options)
|
|
150
|
+
subject, operation = select_formal_operation(bundle, options)
|
|
151
|
+
differences = Formal::DifferentialRunner.new(
|
|
152
|
+
old_command: options[:old_command], new_command: options[:new_command],
|
|
153
|
+
inputs: generated_inputs(operation, options)
|
|
154
|
+
).run
|
|
155
|
+
if differences.empty?
|
|
156
|
+
return [Verification::Result.new(id: "differential:#{subject['name']}#{operation['name']}",
|
|
157
|
+
status: :pass, description: "Generated differential inputs matched",
|
|
158
|
+
differences: [], provenance: { "runner" => "differential" })]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
differences.each_with_index.map do |difference, index|
|
|
162
|
+
Verification::Result.new(id: "differential:#{subject['name']}#{operation['name']}:#{index + 1}",
|
|
163
|
+
status: :fail, description: "Generated differential input differed",
|
|
164
|
+
differences: difference.fetch("differences"),
|
|
165
|
+
provenance: { "runner" => "differential",
|
|
166
|
+
"input" => Recording::Serializer.dump(difference.fetch("input")) })
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def generated_inputs(operation, options)
|
|
171
|
+
domains = operation.fetch("params", []).map { |param| generated_domain(param, options) }
|
|
172
|
+
Enumerator.new do |items|
|
|
173
|
+
if domains.empty?
|
|
174
|
+
items << []
|
|
175
|
+
else
|
|
176
|
+
domains.first.product(*domains.drop(1)) { |input| items << input }
|
|
177
|
+
end
|
|
178
|
+
end.take(options[:max_cases])
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def generated_domain(param, options)
|
|
182
|
+
observed = param.fetch("observed_values", []).map { |value| Recording::Serializer.load(value) }
|
|
183
|
+
alphabet = observed.grep(String).flat_map(&:chars).uniq.first(8)
|
|
184
|
+
generator = Formal::InputGenerator.new(size: options[:size], depth: options[:depth], observed:,
|
|
185
|
+
alphabet: alphabet.empty? ? %w[a b] : alphabet)
|
|
186
|
+
types = param.fetch("types", [])
|
|
187
|
+
if types.empty?
|
|
188
|
+
raise ConfigurationError,
|
|
189
|
+
"Property parameter #{param['name']} has no inferred type. Record values or add an input domain."
|
|
190
|
+
end
|
|
191
|
+
types.flat_map { |type| generator.values(type) }.uniq
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def property_result(subject, operation, counterexample)
|
|
195
|
+
differences = []
|
|
196
|
+
if counterexample
|
|
197
|
+
differences = counterexample.fetch("violations").map do |violation|
|
|
198
|
+
{ "path" => "$.contracts.#{violation['id']}", "expected" => true,
|
|
199
|
+
"actual" => violation["error"] || false }
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
Verification::Result.new(id: "property:#{subject['name']}#{operation['name']}",
|
|
203
|
+
status: differences.empty? ? :pass : :fail,
|
|
204
|
+
description: "Generated property inputs satisfy declared contracts",
|
|
205
|
+
differences:, provenance: { "runner" => "property",
|
|
206
|
+
"input" => Recording::Serializer.dump(
|
|
207
|
+
counterexample&.fetch("input", nil)
|
|
208
|
+
) })
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def formal_verification_result(result, name)
|
|
212
|
+
skipped = result.details["skipped"]
|
|
213
|
+
differences = if result.success? || skipped
|
|
214
|
+
[]
|
|
215
|
+
else
|
|
216
|
+
[{ "path" => "$.#{name}", "expected" => "no difference",
|
|
217
|
+
"actual" => result.counterexample || result.details }]
|
|
218
|
+
end
|
|
219
|
+
status = if skipped then :skipped
|
|
220
|
+
elsif differences.empty? then :pass
|
|
221
|
+
else :fail
|
|
222
|
+
end
|
|
223
|
+
Verification::Result.new(id: name, status:, description: skipped || "Finite-state model conformance",
|
|
224
|
+
differences:, provenance: { "runner" => name, "formal_result" => result.to_h })
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def write_verification_report(results, bundle, options)
|
|
228
|
+
report = Reporting::Reporter.new(results, bundle:).public_send(options[:format])
|
|
229
|
+
return @out.puts(report) unless options[:out]
|
|
230
|
+
|
|
231
|
+
FileUtils.mkdir_p(File.dirname(options[:out]))
|
|
232
|
+
File.write(options[:out], report)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def verify_success?(results, threshold)
|
|
236
|
+
eligible = results.reject { |result| result.status == :skipped }
|
|
237
|
+
return false if eligible.empty? || eligible.any? { |result| result.status == :fail }
|
|
238
|
+
|
|
239
|
+
100.0 * eligible.count { |result| %i[pass waived].include?(result.status) } / eligible.length >= threshold
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|