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,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "json"
|
|
7
|
+
require "yaml"
|
|
8
|
+
|
|
9
|
+
module Bparity
|
|
10
|
+
module SpecBundle
|
|
11
|
+
VERSION = 2
|
|
12
|
+
|
|
13
|
+
module Checksum
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def calculate(bundle)
|
|
17
|
+
payload = deep_sort(bundle.reject { |key, _| key.to_s == "checksum" })
|
|
18
|
+
"sha256:#{Digest::SHA256.hexdigest(JSON.generate(payload))}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def deep_sort(value)
|
|
22
|
+
case value
|
|
23
|
+
when Hash then value.sort_by { |key, _| key.to_s }.to_h { |key, item| [key.to_s, deep_sort(item)] }
|
|
24
|
+
when Array then value.map { |item| deep_sort(item) }
|
|
25
|
+
else value
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
module Validator
|
|
31
|
+
PROVENANCE_LEVELS = %w[A B C D].freeze
|
|
32
|
+
FORMAL_LEVELS = %w[F0 F1 F2 F3 F4].freeze
|
|
33
|
+
CONFORMANCE_MODES = %w[strict refinement contract].freeze
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
def validate!(bundle, verify_checksum: true)
|
|
38
|
+
unless bundle.is_a?(Hash) && bundle["spec_bundle_version"] == VERSION
|
|
39
|
+
raise InvalidBundleError, "Spec Bundle version 2 is required. Run `bparity synthesize` again."
|
|
40
|
+
end
|
|
41
|
+
unless bundle["subjects"].is_a?(Array) && !bundle["subjects"].empty?
|
|
42
|
+
raise InvalidBundleError,
|
|
43
|
+
"The Spec Bundle has no subjects. Record a corpus and run `bparity synthesize`."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
mode = bundle.fetch("conformance_mode", "refinement")
|
|
47
|
+
invalid!("conformance_mode must be strict, refinement, or contract") unless CONFORMANCE_MODES.include?(mode)
|
|
48
|
+
invalid!("canonicalization must be an object") unless bundle.fetch("canonicalization", {}).is_a?(Hash)
|
|
49
|
+
validate_subjects!(bundle.fetch("subjects"))
|
|
50
|
+
validate_lts!(bundle)
|
|
51
|
+
return bundle unless verify_checksum
|
|
52
|
+
unless bundle["checksum"]
|
|
53
|
+
raise InvalidBundleError,
|
|
54
|
+
"The Spec Bundle has no checksum. Run `bparity synthesize` again."
|
|
55
|
+
end
|
|
56
|
+
return bundle if bundle["checksum"] == Checksum.calculate(bundle)
|
|
57
|
+
|
|
58
|
+
raise InvalidBundleError,
|
|
59
|
+
"The Spec Bundle checksum does not match. Do not edit it by hand; run `bparity synthesize` again."
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validate_subjects!(subjects)
|
|
63
|
+
ids = []
|
|
64
|
+
subject_names = []
|
|
65
|
+
subjects.each do |subject|
|
|
66
|
+
invalid!("each subject needs a non-empty name and an operations array") unless
|
|
67
|
+
subject.is_a?(Hash) && present?(subject["name"]) && subject["operations"].is_a?(Array)
|
|
68
|
+
subject_names << subject["name"]
|
|
69
|
+
operation_names = []
|
|
70
|
+
subject["operations"].each do |operation|
|
|
71
|
+
validate_operation!(operation, ids)
|
|
72
|
+
operation_names << operation["name"]
|
|
73
|
+
end
|
|
74
|
+
invalid!("operation names must be unique within #{subject['name']}") unless unique?(operation_names)
|
|
75
|
+
end
|
|
76
|
+
invalid!("subject names must be unique") unless unique?(subject_names)
|
|
77
|
+
invalid!("example IDs must be unique") unless unique?(ids)
|
|
78
|
+
end
|
|
79
|
+
private_class_method :validate_subjects!
|
|
80
|
+
|
|
81
|
+
def validate_lts!(bundle)
|
|
82
|
+
models = bundle.fetch("lts", [])
|
|
83
|
+
invalid!("lts must be an array") unless models.is_a?(Array)
|
|
84
|
+
models.each { |model| validate_lts_model!(model) }
|
|
85
|
+
ids = models.filter_map { |model| model["id"] if model.is_a?(Hash) }
|
|
86
|
+
invalid!("LTS model IDs must be unique") unless unique?(ids)
|
|
87
|
+
references = bundle.fetch("subjects").filter_map { |subject| subject["lts_ref"] }
|
|
88
|
+
missing = references - ids
|
|
89
|
+
invalid!("LTS references are missing models: #{missing.join(', ')}") unless missing.empty?
|
|
90
|
+
end
|
|
91
|
+
private_class_method :validate_lts!
|
|
92
|
+
|
|
93
|
+
def validate_lts_model!(model)
|
|
94
|
+
invalid!("each LTS needs an ID, initial state, and transitions array") unless
|
|
95
|
+
model.is_a?(Hash) && present?(model["id"]) && present?(model["initial"]) &&
|
|
96
|
+
model["transitions"].is_a?(Array)
|
|
97
|
+
model.fetch("transitions").each do |transition|
|
|
98
|
+
required = %w[from input output to]
|
|
99
|
+
invalid!("each LTS transition needs #{required.join(', ')}") unless
|
|
100
|
+
transition.is_a?(Hash) && required.all? { |field| transition.key?(field) }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
private_class_method :validate_lts_model!
|
|
104
|
+
|
|
105
|
+
def validate_operation!(operation, ids)
|
|
106
|
+
invalid!("each operation needs a non-empty name and an examples array") unless
|
|
107
|
+
operation.is_a?(Hash) && present?(operation["name"]) && operation["examples"].is_a?(Array)
|
|
108
|
+
%w[params preconditions postconditions invariants].each do |field|
|
|
109
|
+
invalid!("operation #{field} must be an array") if operation.key?(field) && !operation[field].is_a?(Array)
|
|
110
|
+
end
|
|
111
|
+
validate_parameters!(operation.fetch("params", []))
|
|
112
|
+
validate_contracts!(operation)
|
|
113
|
+
operation.fetch("examples").each { |example| validate_example!(example, ids) }
|
|
114
|
+
end
|
|
115
|
+
private_class_method :validate_operation!
|
|
116
|
+
|
|
117
|
+
def validate_parameters!(params)
|
|
118
|
+
params.each do |param|
|
|
119
|
+
invalid!("each operation parameter needs a name, types, and observed_values") unless
|
|
120
|
+
param.is_a?(Hash) && present?(param["name"]) && param["types"].is_a?(Array) &&
|
|
121
|
+
param["observed_values"].is_a?(Array)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
private_class_method :validate_parameters!
|
|
125
|
+
|
|
126
|
+
def validate_contracts!(operation)
|
|
127
|
+
ids = %w[preconditions postconditions invariants].flat_map do |field|
|
|
128
|
+
operation.fetch(field, []).map { |contract| validate_contract!(contract, field) }
|
|
129
|
+
end
|
|
130
|
+
invalid!("contract IDs must be unique within #{operation['name']}") unless unique?(ids)
|
|
131
|
+
end
|
|
132
|
+
private_class_method :validate_contracts!
|
|
133
|
+
|
|
134
|
+
def validate_contract!(contract, field)
|
|
135
|
+
invalid!("each #{field} entry needs a non-empty ID and expression") unless
|
|
136
|
+
contract.is_a?(Hash) && present?(contract["id"]) && present?(contract["expr"])
|
|
137
|
+
if contract["provenance_level"] && !PROVENANCE_LEVELS.include?(contract["provenance_level"])
|
|
138
|
+
invalid!("contract #{contract['id']} has an invalid provenance level")
|
|
139
|
+
end
|
|
140
|
+
if contract["formal_level"] && !FORMAL_LEVELS.include?(contract["formal_level"])
|
|
141
|
+
invalid!("contract #{contract['id']} has an invalid formal level")
|
|
142
|
+
end
|
|
143
|
+
contract["id"]
|
|
144
|
+
end
|
|
145
|
+
private_class_method :validate_contract!
|
|
146
|
+
|
|
147
|
+
def validate_example!(example, ids)
|
|
148
|
+
required = %w[id provenance_level formal_level provenance given expect]
|
|
149
|
+
missing = required.reject { |field| example.is_a?(Hash) && example.key?(field) }
|
|
150
|
+
invalid!("each example needs #{required.join(', ')}") unless missing.empty?
|
|
151
|
+
invalid!("each example needs a non-empty ID") unless present?(example["id"])
|
|
152
|
+
invalid!("example #{example['id']} has an invalid provenance level") unless
|
|
153
|
+
PROVENANCE_LEVELS.include?(example["provenance_level"])
|
|
154
|
+
invalid!("example #{example['id']} has an invalid formal level") unless
|
|
155
|
+
FORMAL_LEVELS.include?(example["formal_level"])
|
|
156
|
+
invalid!("example #{example['id']} provenance, given, and expect must be objects") unless
|
|
157
|
+
%w[provenance given expect].all? { |field| example[field].is_a?(Hash) }
|
|
158
|
+
ids << example["id"]
|
|
159
|
+
end
|
|
160
|
+
private_class_method :validate_example!
|
|
161
|
+
|
|
162
|
+
def unique?(values) = values.length == values.uniq.length
|
|
163
|
+
private_class_method :unique?
|
|
164
|
+
|
|
165
|
+
def present?(value) = !value.to_s.strip.empty?
|
|
166
|
+
private_class_method :present?
|
|
167
|
+
|
|
168
|
+
def invalid!(detail)
|
|
169
|
+
raise InvalidBundleError, "Invalid Spec Bundle: #{detail}. Run `bparity synthesize` again."
|
|
170
|
+
end
|
|
171
|
+
private_class_method :invalid!
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
module Writer
|
|
175
|
+
module_function
|
|
176
|
+
|
|
177
|
+
def write(path, bundle)
|
|
178
|
+
data = stringify(bundle)
|
|
179
|
+
Validator.validate!(data, verify_checksum: false)
|
|
180
|
+
data["checksum"] = Checksum.calculate(data)
|
|
181
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
182
|
+
File.write(path, YAML.dump(data))
|
|
183
|
+
data
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def stringify(value)
|
|
187
|
+
case value
|
|
188
|
+
when Hash then value.to_h { |key, item| [key.to_s, stringify(item)] }
|
|
189
|
+
when Array then value.map { |item| stringify(item) }
|
|
190
|
+
else value
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
module Loader
|
|
196
|
+
module_function
|
|
197
|
+
|
|
198
|
+
def load(path, verify_checksum: true)
|
|
199
|
+
data = YAML.safe_load_file(path, permitted_classes: [Date, Time], aliases: false)
|
|
200
|
+
Validator.validate!(data, verify_checksum:)
|
|
201
|
+
rescue Errno::ENOENT
|
|
202
|
+
raise InvalidBundleError, "Cannot read #{path}. Run `bparity synthesize` or check the path."
|
|
203
|
+
rescue Psych::Exception => e
|
|
204
|
+
raise InvalidBundleError, "Cannot read #{path}: #{e.message}. Fix the YAML or regenerate the bundle."
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|