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,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "cli/formal_commands"
5
+ require_relative "cli/verification_commands"
6
+
7
+ module Bparity
8
+ class CLI
9
+ include FormalCommands
10
+ include VerificationCommands
11
+
12
+ def self.start(argv = ARGV, out: $stdout, err: $stderr)
13
+ new(out:, err:).start(argv)
14
+ end
15
+
16
+ def initialize(out:, err:)
17
+ @out = out
18
+ @err = err
19
+ end
20
+
21
+ def start(argv)
22
+ command = argv.shift
23
+ return help if command.nil? || %w[-h --help help].include?(command)
24
+
25
+ send("command_#{command.tr('-', '_')}", argv)
26
+ rescue OptionParser::ParseError, Error => e
27
+ @err.puts("Error: #{e.message}")
28
+ 2
29
+ rescue LoadError, SystemCallError => e
30
+ @err.puts("Error: #{e.message}. Check the path and permissions, then try again.")
31
+ 2
32
+ rescue KeyError => e
33
+ @err.puts("Error: missing configuration entry #{e.key.inspect}. Regenerate the bundle or update the adapter.")
34
+ 2
35
+ rescue NoMethodError => e
36
+ raise unless e.name.to_s.start_with?("command_")
37
+
38
+ @err.puts("Error: unknown command #{command.inspect}. Run `bparity help` for usage.")
39
+ 2
40
+ end
41
+
42
+ private
43
+
44
+ def command_record(argv)
45
+ options = { boundary: ".bparity/boundary.rb", out: ".bparity/corpus/behavior.jsonl",
46
+ coverage: ".bparity/coverage.json", requires: [] }
47
+ parser = OptionParser.new do |opts|
48
+ opts.on("--boundary PATH") { |value| options[:boundary] = value }
49
+ opts.on("--out PATH") { |value| options[:out] = value }
50
+ opts.on("--require PATH") { |value| options[:requires] << value }
51
+ opts.on("--driver DRIVER") { |value| options[:driver] = value }
52
+ opts.on("--coverage PATH") { |value| options[:coverage] = value }
53
+ end
54
+ parser.parse!(argv)
55
+ coverage_started = !Recording::CoverageTracker.running?
56
+ Recording::CoverageTracker.start
57
+ Bparity.reset!
58
+ load File.expand_path(options[:boundary])
59
+ boundary = Bparity.boundary_definition || raise(ConfigurationError,
60
+ "The boundary file did not call Bparity.boundary.")
61
+ Recording::Determinism.apply(boundary.canonicalization)
62
+ options[:requires].each { |path| require File.expand_path(path) }
63
+ writer = Corpus::Writer.new(options[:out])
64
+ Recording::Recorder.new(boundary:, writer:).install!
65
+ run_driver(options[:driver] || boundary.driver_config&.fetch(:name, nil), argv, boundary)
66
+ ensure
67
+ writer&.close
68
+ Recording::Determinism.clear
69
+ Recording::CoverageTracker.finish(options[:coverage]) if coverage_started && options&.fetch(:coverage, nil)
70
+ end
71
+
72
+ def command_synthesize(argv)
73
+ options = { corpus: ".bparity/corpus/behavior.jsonl", out: ".bparity/spec_bundle.yml", tests: [], source: [],
74
+ coverage: nil }
75
+ OptionParser.new do |opts|
76
+ opts.on("--corpus PATH") { |value| options[:corpus] = value }
77
+ opts.on("--out PATH") { |value| options[:out] = value }
78
+ opts.on("--tests GLOB") { |value| options[:tests].concat(Dir.glob(value)) }
79
+ opts.on("--source GLOB") { |value| options[:source].concat(Dir.glob(value)) }
80
+ opts.on("--coverage PATH") { |value| options[:coverage] = value }
81
+ opts.on("--static-only") { options[:static_only] = true }
82
+ end.parse!(argv)
83
+ extractor = Synthesis::StaticExtractor.new
84
+ facts = extractor.extract_source(options[:source])
85
+ if options[:coverage]
86
+ facts.concat(Recording::CoverageTracker.gaps(options[:coverage],
87
+ source_paths: options[:source]))
88
+ end
89
+ records = options[:static_only] ? [] : Corpus::Reader.new(options[:corpus]).to_a
90
+ bundle = Synthesis::Synthesizer.new(records:,
91
+ static_examples: extractor.extract_tests(options[:tests]),
92
+ source_facts: facts).call
93
+ SpecBundle::Writer.write(options[:out], bundle)
94
+ @out.puts("Wrote #{options[:out]}")
95
+ 0
96
+ end
97
+
98
+ def command_diff(argv)
99
+ raise ConfigurationError, "Usage: bparity diff OLD.yml NEW.yml" unless argv.length == 2
100
+
101
+ old_bundle, new_bundle = argv.map { |path| SpecBundle::Loader.load(path, verify_checksum: false) }
102
+ differences = Verification::Differ.call(old_bundle, new_bundle)
103
+ @out.puts(JSON.pretty_generate(differences))
104
+ differences.empty? ? 0 : 1
105
+ end
106
+
107
+ def command_explain(argv)
108
+ options = { spec: ".bparity/spec_bundle.yml" }
109
+ OptionParser.new { |opts| opts.on("--spec PATH") { |value| options[:spec] = value } }.parse!(argv)
110
+ id = argv.shift || raise(ConfigurationError, "Usage: bparity explain ID [--spec PATH]")
111
+ bundle = SpecBundle::Loader.load(options[:spec])
112
+ example = bundle.fetch("subjects").flat_map { |subject| subject.fetch("operations") }
113
+ .flat_map { |operation| operation.fetch("examples") }.find { |item| item["id"] == id }
114
+ raise ConfigurationError, "Specification item #{id} was not found. Check the ID in the report." unless example
115
+
116
+ @out.puts(JSON.pretty_generate(example))
117
+ 0
118
+ end
119
+
120
+ def command_assumptions(argv)
121
+ options = { spec: ".bparity/spec_bundle.yml" }
122
+ OptionParser.new { |opts| opts.on("--spec PATH") { |value| options[:spec] = value } }.parse!(argv)
123
+ @out.puts(JSON.pretty_generate(SpecBundle::Loader.load(options[:spec]).fetch("verification_assumptions", [])))
124
+ 0
125
+ end
126
+
127
+ def command_discover(argv)
128
+ options = { requires: [], targets: [] }
129
+ OptionParser.new do |opts|
130
+ opts.on("--require PATH") { |value| options[:requires] << value }
131
+ opts.on("--target CLASS") { |value| options[:targets] << value }
132
+ end.parse!(argv)
133
+ options[:requires].each { |path| require File.expand_path(path) }
134
+ if options[:targets].empty?
135
+ message = "Discovery needs at least one --target CLASS. " \
136
+ "Add the public legacy classes to inspect."
137
+ raise ConfigurationError, message
138
+ end
139
+
140
+ targets = options[:targets].to_h { |name| [name, Bparity.constantize(name)] }
141
+ observed = targets.to_h { |name, _target| [name, []] }
142
+ trace = TracePoint.new(:call) do |event|
143
+ targets.each { |name, target| observed[name] << event.method_id if event.self.is_a?(target) }
144
+ end
145
+ trace.enable { argv.each { |path| load File.expand_path(path) } }
146
+ @out.puts(discovered_boundary(targets, observed))
147
+ 0
148
+ end
149
+
150
+ def command_adequacy(argv)
151
+ options = { spec: ".bparity/spec_bundle.yml", adapter: ".bparity/adapter.rb", requires: [] }
152
+ OptionParser.new do |opts|
153
+ opts.on("--spec PATH") { |value| options[:spec] = value }
154
+ opts.on("--adapter PATH") { |value| options[:adapter] = value }
155
+ opts.on("--require PATH") { |value| options[:requires] << value }
156
+ opts.on("--mutant") { options[:mutant] = true }
157
+ end.parse!(argv)
158
+ options[:requires].each { |path| require File.expand_path(path) }
159
+ bundle = SpecBundle::Loader.load(options[:spec])
160
+ Bparity.reset!
161
+ load File.expand_path(options[:adapter])
162
+ adapter = Bparity.adapter_definition || raise(ConfigurationError,
163
+ "The adapter file did not call Bparity.adapter.")
164
+ results = Verification::Runner.new(bundle:, adapter:).run
165
+ mutation = options[:mutant] ? Adequacy::MutantBridge.new.run : nil
166
+ @out.puts(JSON.pretty_generate(Adequacy::Analyzer.new(bundle:, results:, mutation:).call))
167
+ results.none? { |result| result.status == :fail } ? 0 : 1
168
+ end
169
+
170
+ def command_init(argv)
171
+ options = {}
172
+ OptionParser.new do |opts|
173
+ opts.on("--timecapsule") { options[:timecapsule] = true }
174
+ opts.on("--from-spec PATH") { |value| options[:from_spec] = value }
175
+ end.parse!(argv)
176
+ FileUtils.mkdir_p(".bparity")
177
+ write_unless_exists(".bparity/boundary.rb", "Bparity.boundary do\n # observe \"Legacy::Class\"\nend\n")
178
+ adapter = if options[:from_spec]
179
+ adapter_template(options[:from_spec])
180
+ else
181
+ "Bparity.adapter(spec: \".bparity/spec_bundle.yml\") do\n # subject \"Class\" do\n # end\nend\n"
182
+ end
183
+ write_unless_exists(".bparity/adapter.rb", adapter)
184
+ if options[:timecapsule]
185
+ FileUtils.mkdir_p(".bparity/timecapsule")
186
+ write_unless_exists(".bparity/timecapsule/Dockerfile", timecapsule)
187
+ end
188
+ @out.puts("Initialized .bparity")
189
+ 0
190
+ end
191
+
192
+ def run_driver(driver, argv, boundary)
193
+ case driver&.to_sym
194
+ when :rspec
195
+ require "rspec/core"
196
+ files = argv.empty? ? Dir.glob(boundary.driver_config&.fetch(:files, "spec/**/*_spec.rb")) : argv
197
+ RSpec::Core::Runner.run(files, @err, @out)
198
+ when :minitest
199
+ require "minitest"
200
+ Recording::MinitestDriver.install!
201
+ (argv.empty? ? Dir.glob(boundary.driver_config&.fetch(:files, "test/**/*_test.rb")) : argv).each do |file|
202
+ require File.expand_path(file)
203
+ end
204
+ Minitest.run ? 0 : 1
205
+ else
206
+ raise ConfigurationError, "A driver is required. Add `driver :rspec` or `driver :minitest` to the boundary."
207
+ end
208
+ rescue LoadError
209
+ raise ConfigurationError, "The #{driver} driver is not installed. Add it to the legacy environment and try again."
210
+ end
211
+
212
+ def write_unless_exists(path, content)
213
+ raise ConfigurationError, "#{path} already exists. Move it aside before running init." if File.exist?(path)
214
+
215
+ File.write(path, content)
216
+ end
217
+
218
+ def discovered_boundary(targets, observed)
219
+ body = targets.map do |name, target|
220
+ candidates = observed.fetch(name).uniq
221
+ candidates = target.public_instance_methods(false) if candidates.empty?
222
+ methods = candidates.sort.map { |method_name| ":#{method_name}" }.join(", ")
223
+ dynamic = target.instance_methods(false).grep(/method_missing|respond_to_missing/)
224
+ warning = dynamic.empty? ? "" : "\n # Review dynamic methods: #{dynamic.join(', ')}"
225
+ " observe #{name.inspect} do\n methods #{methods}#{warning}\n end"
226
+ end.join("\n\n")
227
+ "Bparity.boundary do\n#{body}\nend\n"
228
+ end
229
+
230
+ def adapter_template(path)
231
+ bundle = SpecBundle::Loader.load(path)
232
+ subjects = bundle.fetch("subjects").map do |subject|
233
+ operations = subject.fetch("operations").map do |operation|
234
+ " operation #{operation.fetch('name').inspect} do\n " \
235
+ "# invoke { |subject, args, kwargs| }\n end"
236
+ end.join("\n")
237
+ " subject #{subject.fetch('name').inspect} do\n # construct { }\n#{operations}\n end"
238
+ end.join("\n\n")
239
+ "Bparity.adapter(spec: #{path.inspect}) do\n#{subjects}\nend\n"
240
+ end
241
+
242
+ def timecapsule
243
+ <<~DOCKERFILE
244
+ FROM ruby:3.1-slim
245
+ COPY vendor/cache/ vendor/cache/
246
+ COPY Gemfile Gemfile.lock ./
247
+ RUN bundle install --local
248
+ COPY . .
249
+ CMD ["bundle", "exec", "bparity", "record"]
250
+ DOCKERFILE
251
+ end
252
+
253
+ def help
254
+ @out.puts <<~HELP
255
+ Usage: bparity COMMAND [options]
256
+
257
+ Commands: init, discover, record, synthesize, verify, diff, explain, assumptions, prove, adequacy
258
+ HELP
259
+ 0
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Bparity
7
+ module Corpus
8
+ class Writer
9
+ attr_reader :path
10
+
11
+ def initialize(path)
12
+ @path = path
13
+ FileUtils.mkdir_p(File.dirname(path))
14
+ @io = File.open(path, "w")
15
+ end
16
+
17
+ def write(record)
18
+ @io.puts(JSON.generate(record))
19
+ @io.flush
20
+ end
21
+
22
+ def close = @io.close
23
+ end
24
+
25
+ class Reader
26
+ include Enumerable
27
+
28
+ def initialize(path)
29
+ @path = path
30
+ end
31
+
32
+ def each
33
+ return enum_for(__method__) unless block_given?
34
+
35
+ File.foreach(@path).with_index(1) do |line, number|
36
+ yield JSON.parse(line)
37
+ rescue JSON::ParserError => e
38
+ raise Error, "Invalid JSONL at #{@path}:#{number}: #{e.message}. Record the corpus again."
39
+ end
40
+ rescue Errno::ENOENT
41
+ raise Error, "Cannot read corpus #{@path}. Run `bparity record` first."
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bparity
4
+ def self.exception_message(error)
5
+ error.respond_to?(:original_message) ? error.original_message : error.message
6
+ end
7
+
8
+ class Error < StandardError; end
9
+ class ConfigurationError < Error; end
10
+ class InvalidBundleError < Error; end
11
+ class VerificationError < Error; end
12
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Bparity
6
+ module Formal
7
+ module Assumptions
8
+ module RuntimeMonitor
9
+ THREAD_KEY = :bparity_assumption_monitor
10
+
11
+ module Hook
12
+ def method_added(name)
13
+ RuntimeMonitor.record(self, "method #{name} was redefined")
14
+ super
15
+ end
16
+
17
+ def prepend(*modules)
18
+ RuntimeMonitor.record(self, "module #{modules.map(&:name).join(', ')} was prepended")
19
+ super
20
+ end
21
+ end
22
+
23
+ module SingletonHook
24
+ def singleton_method_added(name)
25
+ RuntimeMonitor.record(singleton_class, "singleton method #{name} was redefined")
26
+ super
27
+ end
28
+ end
29
+
30
+ module_function
31
+
32
+ def capture(classes)
33
+ install!
34
+ previous = Thread.current[THREAD_KEY]
35
+ context = Thread.current[THREAD_KEY] = { classes:, violations: [] }
36
+ [yield, context.fetch(:violations)]
37
+ ensure
38
+ Thread.current[THREAD_KEY] = previous
39
+ end
40
+
41
+ def record(target, change)
42
+ context = Thread.current[THREAD_KEY]
43
+ return unless context&.fetch(:classes)&.include?(target)
44
+
45
+ context.fetch(:violations) << "H1: #{target.name || target.inspect} #{change} during verification"
46
+ end
47
+
48
+ def install!
49
+ return if Module.ancestors.include?(Hook)
50
+
51
+ Module.prepend(Hook)
52
+ Object.prepend(SingletonHook)
53
+ end
54
+ end
55
+
56
+ CATALOG = [
57
+ { id: :h1, name: "world_freeze", enforcement: "runtime snapshot" },
58
+ { id: :h2, name: "dynamic_methods_declared", enforcement: "discovery" },
59
+ { id: :h3, name: "no_dynamic_evaluation", enforcement: "Prism static analysis" },
60
+ { id: :h4, name: "runtime_identity_unobserved", enforcement: "serializer" },
61
+ { id: :h5, name: "ieee_754", enforcement: "declaration only" },
62
+ { id: :h6, name: "exceptions_are_outputs", enforcement: "recorder" },
63
+ { id: :h7, name: "single_thread", enforcement: "declaration only" }
64
+ ].freeze
65
+
66
+ class WorldFreeze
67
+ def initialize(classes)
68
+ @classes = classes
69
+ end
70
+
71
+ def check(&)
72
+ RuntimeMonitor.install!
73
+ before = fingerprint
74
+ value, violations = RuntimeMonitor.capture(@classes, &)
75
+ violations << "H1: a target class changed during verification" unless before == fingerprint
76
+ [value, violations.uniq]
77
+ end
78
+
79
+ private
80
+
81
+ def fingerprint
82
+ @classes.to_h do |klass|
83
+ methods = klass.instance_methods(false).sort.to_h do |name|
84
+ method = klass.instance_method(name)
85
+ [name, [method.owner.name, method.source_location, method.hash]]
86
+ end
87
+ [klass, [klass.ancestors.map(&:name), methods]]
88
+ end
89
+ end
90
+ end
91
+
92
+ class DynamicCodeDetector
93
+ FORBIDDEN = %i[eval binding class_eval module_eval instance_eval].freeze
94
+
95
+ def scan(paths)
96
+ Array(paths).flat_map { |path| scan_file(path) }
97
+ end
98
+
99
+ private
100
+
101
+ def scan_file(path)
102
+ result = Prism.parse_file(path)
103
+ return [{ "assumption" => "H3", "location" => path, "reason" => "syntax error" }] unless result.success?
104
+
105
+ nodes(result.value).filter_map do |node|
106
+ next unless forbidden?(node)
107
+
108
+ { "assumption" => "H3", "location" => "#{path}:#{node.location.start_line}",
109
+ "reason" => "dynamic call #{node.name}" }
110
+ end
111
+ end
112
+
113
+ def forbidden?(node)
114
+ return false unless node.type == :call_node
115
+ return true if FORBIDDEN.include?(node.name)
116
+ return false unless node.name == :send
117
+
118
+ argument = node.arguments&.arguments&.first
119
+ !argument || argument.type != :symbol_node
120
+ end
121
+
122
+ def nodes(root, &)
123
+ return enum_for(__method__, root) unless block_given?
124
+
125
+ yield root
126
+ root.compact_child_nodes.each { |child| nodes(child, &) }
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end