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.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +61 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +146 -0
  5. data/Rakefile +36 -0
  6. data/docs/application_example.md +25 -0
  7. data/docs/formal_assurance_limits.md +21 -0
  8. data/exe/bparity +7 -0
  9. data/fixtures/scenarios/01_pure_function/adapter.rb +13 -0
  10. data/fixtures/scenarios/01_pure_function/boundary.rb +17 -0
  11. data/fixtures/scenarios/01_pure_function/legacy/dead_gem.rb +9 -0
  12. data/fixtures/scenarios/01_pure_function/legacy/slugifier.rb +17 -0
  13. data/fixtures/scenarios/01_pure_function/replacement/broken.rb +15 -0
  14. data/fixtures/scenarios/01_pure_function/replacement/good.rb +19 -0
  15. data/fixtures/scenarios/01_pure_function/spec/slugifier_spec.rb +20 -0
  16. data/fixtures/scenarios/01_pure_function/test/slugifier_test.rb +10 -0
  17. data/fixtures/scenarios/02_stateful_client/adapter.rb +19 -0
  18. data/fixtures/scenarios/02_stateful_client/boundary.rb +10 -0
  19. data/fixtures/scenarios/02_stateful_client/legacy/client.rb +29 -0
  20. data/fixtures/scenarios/02_stateful_client/replacement/broken.rb +14 -0
  21. data/fixtures/scenarios/02_stateful_client/replacement/good.rb +23 -0
  22. data/fixtures/scenarios/02_stateful_client/spec/client_spec.rb +31 -0
  23. data/fixtures/scenarios/03_external_boundary/adapter.rb +13 -0
  24. data/fixtures/scenarios/03_external_boundary/boundary.rb +11 -0
  25. data/fixtures/scenarios/03_external_boundary/legacy/dead_formatter.rb +7 -0
  26. data/fixtures/scenarios/03_external_boundary/legacy/receipt.rb +11 -0
  27. data/fixtures/scenarios/03_external_boundary/replacement/broken.rb +11 -0
  28. data/fixtures/scenarios/03_external_boundary/replacement/good.rb +11 -0
  29. data/fixtures/scenarios/03_external_boundary/spec/receipt_spec.rb +10 -0
  30. data/fixtures/scenarios/04_intentional_divergence/adapter.rb +12 -0
  31. data/fixtures/scenarios/04_intentional_divergence/adapter_unwaived.rb +10 -0
  32. data/fixtures/scenarios/04_intentional_divergence/boundary.rb +8 -0
  33. data/fixtures/scenarios/04_intentional_divergence/legacy/dead_identity.rb +7 -0
  34. data/fixtures/scenarios/04_intentional_divergence/legacy/identity.rb +9 -0
  35. data/fixtures/scenarios/04_intentional_divergence/replacement/broken.rb +9 -0
  36. data/fixtures/scenarios/04_intentional_divergence/replacement/good.rb +9 -0
  37. data/fixtures/scenarios/04_intentional_divergence/spec/identity_spec.rb +10 -0
  38. data/fixtures/scenarios/05_formal_negative/adapter.rb +15 -0
  39. data/fixtures/scenarios/05_formal_negative/boundary.rb +16 -0
  40. data/fixtures/scenarios/05_formal_negative/legacy/dead_lock.rb +5 -0
  41. data/fixtures/scenarios/05_formal_negative/legacy/turnstile.rb +24 -0
  42. data/fixtures/scenarios/05_formal_negative/replacement/broken.rb +18 -0
  43. data/fixtures/scenarios/05_formal_negative/replacement/formal_broken.rb +24 -0
  44. data/fixtures/scenarios/05_formal_negative/replacement/good.rb +24 -0
  45. data/fixtures/scenarios/05_formal_negative/spec/turnstile_spec.rb +21 -0
  46. data/lib/bparity/adapter.rb +115 -0
  47. data/lib/bparity/adequacy.rb +78 -0
  48. data/lib/bparity/boundary.rb +97 -0
  49. data/lib/bparity/cli/formal_commands.rb +428 -0
  50. data/lib/bparity/cli/verification_commands.rb +242 -0
  51. data/lib/bparity/cli.rb +262 -0
  52. data/lib/bparity/corpus.rb +45 -0
  53. data/lib/bparity/errors.rb +12 -0
  54. data/lib/bparity/formal/assumptions.rb +131 -0
  55. data/lib/bparity/formal/bounded.rb +543 -0
  56. data/lib/bparity/formal/contract.rb +81 -0
  57. data/lib/bparity/formal/deductive.rb +481 -0
  58. data/lib/bparity/formal/lts.rb +344 -0
  59. data/lib/bparity/formal/result.rb +50 -0
  60. data/lib/bparity/formal.rb +8 -0
  61. data/lib/bparity/recording.rb +412 -0
  62. data/lib/bparity/reporting.rb +128 -0
  63. data/lib/bparity/spec_bundle.rb +208 -0
  64. data/lib/bparity/synthesis.rb +487 -0
  65. data/lib/bparity/verification.rb +310 -0
  66. data/lib/bparity/version.rb +5 -0
  67. data/lib/bparity.rb +42 -0
  68. 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