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
data/lib/bparity/cli.rb
ADDED
|
@@ -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
|