branchproof 0.8.0 → 0.10.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 +4 -4
- data/CHANGELOG.md +37 -0
- data/README.md +120 -28
- data/doc/Branchproof/DefaultInstrumentation.md +13 -0
- data/doc/Branchproof/DefaultRuntime.md +11 -0
- data/doc/Branchproof/DefaultSyntax.md +7 -0
- data/doc/Branchproof/Evidence.md +3 -0
- data/doc/Branchproof/ExceptionInstrumentation.md +9 -0
- data/doc/Branchproof/ExceptionRuntime.md +27 -0
- data/doc/Branchproof/ExceptionSyntax.md +9 -0
- data/doc/Branchproof/ExtendedAlternativeRuntime.md +19 -0
- data/doc/Branchproof/Instrumenter.md +1 -1
- data/doc/Branchproof/IterationInstrumentation.md +7 -0
- data/doc/Branchproof/IterationRuntime.md +21 -0
- data/doc/Branchproof/IterationSyntax.md +11 -0
- data/doc/Branchproof/Project.md +4 -1
- data/doc/Branchproof/RSpecAdapter/ClassRunnerGuard.md +11 -0
- data/doc/Branchproof/RSpecAdapter/ContextLifecycle.md +14 -0
- data/doc/Branchproof/RSpecAdapter/DefaultDiscovery.md +11 -0
- data/doc/Branchproof/RSpecAdapter/ExampleLifecycle.md +11 -0
- data/doc/Branchproof/RSpecAdapter/RunnerGuard.md +11 -0
- data/doc/Branchproof/RSpecAdapter/UnsupportedRunner.md +12 -0
- data/doc/Branchproof/RSpecAdapter.md +47 -0
- data/doc/Branchproof/RailsSupport.md +35 -2
- data/doc/Branchproof/Report.md +3 -0
- data/doc/Branchproof/Runtime.md +59 -1
- data/doc/Branchproof/Source.md +7 -1
- data/doc/Branchproof/ValueInstrumentation.md +7 -0
- data/doc/Branchproof/ValueRuntime.md +26 -0
- data/doc/Branchproof/ValueSyntax.md +26 -0
- data/doc/Branchproof/Worker.md +8 -1
- data/doc/Branchproof.md +24 -3
- data/doc/CHANGELOG.md +37 -0
- data/doc/README.md +120 -28
- data/lib/branchproof/cli.rb +74 -28
- data/lib/branchproof/comparison.rb +52 -6
- data/lib/branchproof/decision_syntax.rb +19 -9
- data/lib/branchproof/default_instrumentation.rb +140 -0
- data/lib/branchproof/default_runtime.rb +16 -0
- data/lib/branchproof/default_syntax.rb +94 -0
- data/lib/branchproof/evidence.rb +87 -1
- data/lib/branchproof/exception_instrumentation.rb +96 -0
- data/lib/branchproof/exception_runtime.rb +41 -0
- data/lib/branchproof/exception_syntax.rb +157 -0
- data/lib/branchproof/extended_alternative_runtime.rb +23 -0
- data/lib/branchproof/flow_instrumentation.rb +36 -7
- data/lib/branchproof/focused_report.rb +5 -0
- data/lib/branchproof/instrumenter.rb +50 -5
- data/lib/branchproof/iteration_instrumentation.rb +66 -0
- data/lib/branchproof/iteration_runtime.rb +79 -0
- data/lib/branchproof/iteration_syntax.rb +75 -0
- data/lib/branchproof/loader.rb +6 -1
- data/lib/branchproof/project.rb +49 -2
- data/lib/branchproof/rails_support.rb +101 -0
- data/lib/branchproof/report.rb +21 -1
- data/lib/branchproof/rspec_adapter.rb +423 -0
- data/lib/branchproof/runtime.rb +14 -0
- data/lib/branchproof/saved_report.rb +9 -2
- data/lib/branchproof/source.rb +59 -32
- data/lib/branchproof/value_instrumentation.rb +35 -0
- data/lib/branchproof/value_runtime.rb +73 -0
- data/lib/branchproof/value_syntax.rb +116 -0
- data/lib/branchproof/version.rb +1 -1
- data/lib/branchproof/worker.rb +75 -15
- data/lib/branchproof.rb +1 -0
- data/llms.txt +24 -3
- data/sig/branchproof.rbs +45 -1
- metadata +36 -2
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "records" unless defined?(Branchproof::Records)
|
|
4
|
+
|
|
5
|
+
module Branchproof
|
|
6
|
+
# Bridges one serial RSpec run to Runtime lifecycle ownership.
|
|
7
|
+
class RSpecAdapter
|
|
8
|
+
# Identifies runner capabilities that cannot produce supported evidence.
|
|
9
|
+
class UnsupportedRunner < ArgumentError
|
|
10
|
+
def diagnostic_code = "unsupported_runner"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Restores CLI directory discovery when RSpec runs inside the worker.
|
|
14
|
+
module DefaultDiscovery
|
|
15
|
+
def configure(configuration)
|
|
16
|
+
options[:files_or_directories_to_run] = [options[:default_path] || configuration.default_path].compact
|
|
17
|
+
super
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Rejects additional class-level runner entry points.
|
|
22
|
+
module ClassRunnerGuard
|
|
23
|
+
def run(*args, &)
|
|
24
|
+
RSpecAdapter.runner_adapter&.reject_execution!("nested or repeated RSpec runs are unsupported")
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Allows only the worker-owned native runner instance.
|
|
30
|
+
module RunnerGuard
|
|
31
|
+
def run(*args, &)
|
|
32
|
+
RSpecAdapter.runner_adapter&.enter_runner!(self)
|
|
33
|
+
super
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Assigns observation phases while the reporter owns test registration.
|
|
38
|
+
module ExampleLifecycle
|
|
39
|
+
def run(*args, &)
|
|
40
|
+
adapter = RSpecAdapter.active_adapter
|
|
41
|
+
return super unless adapter
|
|
42
|
+
|
|
43
|
+
adapter.send(:begin_example, self)
|
|
44
|
+
super
|
|
45
|
+
ensure
|
|
46
|
+
adapter&.send(:unattributed)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def run_before_example(*args, &)
|
|
52
|
+
adapter = RSpecAdapter.active_adapter
|
|
53
|
+
adapter&.send(:begin_attempt, self)
|
|
54
|
+
result = super
|
|
55
|
+
adapter&.send(:phase, self, "body")
|
|
56
|
+
result
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def run_after_example(*args, &)
|
|
60
|
+
RSpecAdapter.active_adapter&.send(:phase, self, "teardown")
|
|
61
|
+
super
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Keeps group-level hooks outside individual example ownership.
|
|
66
|
+
module ContextLifecycle
|
|
67
|
+
def run_before_context_hooks(*args, &)
|
|
68
|
+
adapter = RSpecAdapter.active_adapter
|
|
69
|
+
return super unless adapter
|
|
70
|
+
|
|
71
|
+
adapter.send(:without_owner) { super }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def run_after_context_hooks(*args, &)
|
|
75
|
+
adapter = RSpecAdapter.active_adapter
|
|
76
|
+
return super unless adapter
|
|
77
|
+
|
|
78
|
+
adapter.send(:without_owner) { super }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
class << self
|
|
83
|
+
attr_accessor :active_adapter, :runner_adapter
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
FORBIDDEN_OPTIONS = {
|
|
87
|
+
dry_run: "dry-run",
|
|
88
|
+
bisect: "bisect",
|
|
89
|
+
drb: "DRb",
|
|
90
|
+
runner: "custom runner"
|
|
91
|
+
}.freeze
|
|
92
|
+
|
|
93
|
+
attr_reader :tests, :late_execution_error
|
|
94
|
+
|
|
95
|
+
def initialize(runtime:)
|
|
96
|
+
@runtime = runtime
|
|
97
|
+
@tests = {}
|
|
98
|
+
@example_records = {}.compare_by_identity
|
|
99
|
+
@file_digests = {}
|
|
100
|
+
@running = false
|
|
101
|
+
@completed = false
|
|
102
|
+
@completion_started = false
|
|
103
|
+
@registered = false
|
|
104
|
+
@current_context = nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
108
|
+
def run(test_files:, runner_args:, on_complete:, before_load: nil, after_load: nil,
|
|
109
|
+
test_selection_explicit: false)
|
|
110
|
+
raise ArgumentError, "test_files must be an Array" unless test_files.is_a?(Array)
|
|
111
|
+
raise ArgumentError, "runner_args must be an Array" unless runner_args.is_a?(Array)
|
|
112
|
+
raise ArgumentError, "on_complete must respond to call" unless on_complete.respond_to?(:call)
|
|
113
|
+
raise UnsupportedRunner, "RSpec adapter cannot run more than once" if @running || @registered
|
|
114
|
+
raise UnsupportedRunner, "nested RSpec runs are unsupported" if self.class.active_adapter
|
|
115
|
+
raise UnsupportedRunner, "TEST_ENV_NUMBER is unsupported" if ENV.key?("TEST_ENV_NUMBER")
|
|
116
|
+
|
|
117
|
+
require "rspec/core"
|
|
118
|
+
require "rspec/core/runner"
|
|
119
|
+
unless Gem::Requirement.new("~> 3.13.0").satisfied_by?(Gem::Version.new(RSpec::Core::Version::STRING))
|
|
120
|
+
raise UnsupportedRunner, "unsupported RSpec version #{RSpec::Core::Version::STRING}; use 3.13.x"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
RSpec::Core::Runner.disable_autorun!
|
|
124
|
+
options = RSpec::Core::ConfigurationOptions.new(runner_args)
|
|
125
|
+
configured_files = Array(options.options[:files_or_directories_to_run]).map(&:to_s)
|
|
126
|
+
if test_selection_explicit && configured_files.any?
|
|
127
|
+
raise ArgumentError, "explicit test selection conflicts with configured RSpec selectors"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
options.options[:files_or_directories_to_run] = test_files.map(&:to_s) if test_selection_explicit
|
|
131
|
+
options.singleton_class.prepend(DefaultDiscovery) if !test_selection_explicit && configured_files.empty?
|
|
132
|
+
reject_options!(options)
|
|
133
|
+
|
|
134
|
+
@running = true
|
|
135
|
+
|
|
136
|
+
@registered = true
|
|
137
|
+
@completion_callback = on_complete
|
|
138
|
+
@unmatched_test_selection = test_selection_explicit && test_files.empty?
|
|
139
|
+
if @unmatched_test_selection
|
|
140
|
+
complete(status: 0, finalized: true)
|
|
141
|
+
return 0
|
|
142
|
+
end
|
|
143
|
+
self.class.active_adapter = self
|
|
144
|
+
self.class.runner_adapter = self
|
|
145
|
+
install_lifecycle_hooks
|
|
146
|
+
install_runner_guard
|
|
147
|
+
runner = RSpec::Core::Runner.new(options)
|
|
148
|
+
@native_runner = runner
|
|
149
|
+
adapter = self
|
|
150
|
+
runner.define_singleton_method(:setup) do |*setup_args|
|
|
151
|
+
super(*setup_args)
|
|
152
|
+
return if world.wants_to_quit
|
|
153
|
+
|
|
154
|
+
adapter.validate_runner!
|
|
155
|
+
after_load&.call
|
|
156
|
+
adapter.send(:install_reporter_listener, configuration.reporter)
|
|
157
|
+
end
|
|
158
|
+
before_load&.call
|
|
159
|
+
status = runner.run($stderr, $stdout)
|
|
160
|
+
capture_runner_state(runner)
|
|
161
|
+
complete(status: status, finalized: true)
|
|
162
|
+
status
|
|
163
|
+
rescue StandardError => e
|
|
164
|
+
raise unless @running
|
|
165
|
+
raise if @completion_started
|
|
166
|
+
|
|
167
|
+
@run_error = e
|
|
168
|
+
capture_runner_state(runner) if runner
|
|
169
|
+
status = 2
|
|
170
|
+
complete(status: status, finalized: false)
|
|
171
|
+
status
|
|
172
|
+
ensure
|
|
173
|
+
if self.class.active_adapter.equal?(self)
|
|
174
|
+
unattributed
|
|
175
|
+
self.class.active_adapter = nil
|
|
176
|
+
end
|
|
177
|
+
@running = false
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def validate_runner!
|
|
181
|
+
return nil unless defined?(RSpec.configuration)
|
|
182
|
+
|
|
183
|
+
configuration = RSpec.configuration
|
|
184
|
+
reject_configuration!(configuration)
|
|
185
|
+
if configuration.respond_to?(:parallelize) && configuration.parallelize
|
|
186
|
+
raise UnsupportedRunner,
|
|
187
|
+
"parallel test scheduling is unsupported"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def example_started(notification)
|
|
194
|
+
example = notification.example
|
|
195
|
+
record = test_record(example)
|
|
196
|
+
record[:status] = "running"
|
|
197
|
+
register_test(record)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def example_finished(notification)
|
|
201
|
+
example = notification.example
|
|
202
|
+
record = test_record(example)
|
|
203
|
+
record[:name] = example.full_description.to_s
|
|
204
|
+
record[:status] = status_for(example)
|
|
205
|
+
register_test(record)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def reject_execution!(message)
|
|
209
|
+
@run_error ||= UnsupportedRunner.new(message)
|
|
210
|
+
@late_execution_error = @run_error if @completion_started
|
|
211
|
+
raise @run_error
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def enter_runner!(runner)
|
|
215
|
+
unless runner.equal?(@native_runner) && !@runner_entered
|
|
216
|
+
reject_execution!("nested or repeated RSpec runs are unsupported")
|
|
217
|
+
end
|
|
218
|
+
@runner_entered = true
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
private
|
|
222
|
+
|
|
223
|
+
def capture_runner_state(runner)
|
|
224
|
+
@selected_test_files = runner.configuration.loaded_spec_files.map { |path| File.expand_path(path.to_s) }
|
|
225
|
+
@selected_example_ids = runner.world.filtered_examples.values.flatten.map(&:id)
|
|
226
|
+
@seed = runner.configuration.seed
|
|
227
|
+
@non_example_failure = runner.world.non_example_failure
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def reject_options!(options)
|
|
231
|
+
FORBIDDEN_OPTIONS.each do |key, label|
|
|
232
|
+
value = options.options[key]
|
|
233
|
+
next unless value && value != false && value != []
|
|
234
|
+
|
|
235
|
+
raise UnsupportedRunner, "#{label} is unsupported"
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def reject_configuration!(configuration)
|
|
240
|
+
if configuration.respond_to?(:dry_run?) && configuration.dry_run?
|
|
241
|
+
raise UnsupportedRunner,
|
|
242
|
+
"dry-run is unsupported"
|
|
243
|
+
end
|
|
244
|
+
raise UnsupportedRunner, "bisect is unsupported" if configuration.respond_to?(:bisect) && configuration.bisect
|
|
245
|
+
raise UnsupportedRunner, "DRb is unsupported" if defined?(DRb) && RSpec::Core::Runner.send(:running_in_drb?)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def install_reporter_listener(reporter)
|
|
249
|
+
@defined_example_count = RSpec.world.filtered_examples.keys.sum { |group| group.examples.length }
|
|
250
|
+
listener = self
|
|
251
|
+
reporter.register_listener(listener, :example_started, :example_finished)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def install_runner_guard
|
|
255
|
+
return if RSpec::Core::Runner.singleton_class.instance_variable_defined?(:@branchproof_runner_guard)
|
|
256
|
+
|
|
257
|
+
RSpec::Core::Runner.singleton_class.prepend(ClassRunnerGuard)
|
|
258
|
+
RSpec::Core::Runner.prepend(RunnerGuard)
|
|
259
|
+
RSpec::Core::Runner.singleton_class.instance_variable_set(:@branchproof_runner_guard, true)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def install_lifecycle_hooks
|
|
263
|
+
return if RSpec::Core::Example.instance_variable_defined?(:@branchproof_lifecycle_hooks)
|
|
264
|
+
|
|
265
|
+
RSpec::Core::Example.prepend(ExampleLifecycle)
|
|
266
|
+
RSpec::Core::ExampleGroup.singleton_class.prepend(ContextLifecycle)
|
|
267
|
+
RSpec::Core::Example.instance_variable_set(:@branchproof_lifecycle_hooks, true)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def begin_example(example)
|
|
271
|
+
validate_runner!
|
|
272
|
+
seen = (@seen_examples ||= {}.compare_by_identity)
|
|
273
|
+
reject_execution!("repeated RSpec example attempts are unsupported") if seen[example]
|
|
274
|
+
|
|
275
|
+
seen[example] = true
|
|
276
|
+
phase(example, "setup")
|
|
277
|
+
rescue ArgumentError => e
|
|
278
|
+
reject_execution!(e.message)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def begin_attempt(example)
|
|
282
|
+
@attempts ||= {}
|
|
283
|
+
id = test_id(example)
|
|
284
|
+
if @attempts[id]
|
|
285
|
+
@run_error ||= UnsupportedRunner.new("repeated RSpec example attempts are unsupported")
|
|
286
|
+
raise @run_error
|
|
287
|
+
end
|
|
288
|
+
@attempts[id] = true
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def without_owner
|
|
292
|
+
previous = @current_context
|
|
293
|
+
unattributed
|
|
294
|
+
yield
|
|
295
|
+
ensure
|
|
296
|
+
@current_context = previous
|
|
297
|
+
@runtime.context(test_id: previous&.first, phase: previous&.last || "unattributed")
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def status_for(example)
|
|
301
|
+
result = example.execution_result
|
|
302
|
+
status = result.status&.to_sym
|
|
303
|
+
return "failed" if (result.respond_to?(:pending_fixed?) && result.pending_fixed?) ||
|
|
304
|
+
(example.respond_to?(:pending?) && example.pending? && status == :failed)
|
|
305
|
+
return "failed" if status == :failed
|
|
306
|
+
return "skipped" if example.respond_to?(:skipped?) && example.skipped?
|
|
307
|
+
return "skipped" if status == :pending
|
|
308
|
+
|
|
309
|
+
status == :passed ? "passed" : "running"
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def test_record(example)
|
|
313
|
+
@example_records[example] ||= begin
|
|
314
|
+
source = source_for(example)
|
|
315
|
+
id = Branchproof::Records.id(adapter: "rspec", example_id: example.id.to_s, source: source)
|
|
316
|
+
@tests[id] = {
|
|
317
|
+
id: id, adapter: "rspec", name: example.full_description.to_s,
|
|
318
|
+
example_id: example.id.to_s, class_name: nil, method_name: nil,
|
|
319
|
+
spec_digest: file_digest(example.id.to_s.sub(/\[[\d:]+\]\z/, "")),
|
|
320
|
+
source_digest: file_digest(source[:path]),
|
|
321
|
+
source: source, status: "running", phase_counts: {}
|
|
322
|
+
}
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def test_id(example)
|
|
327
|
+
test_record(example)[:id]
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def file_digest(path)
|
|
331
|
+
expanded = File.expand_path(path)
|
|
332
|
+
return @file_digests[expanded] if @file_digests.key?(expanded)
|
|
333
|
+
|
|
334
|
+
@file_digests[expanded] = File.file?(expanded) ? Digest::SHA256.file(expanded).hexdigest : nil
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def source_for(example)
|
|
338
|
+
path = example.metadata[:absolute_file_path] || example.file_path
|
|
339
|
+
{ path: File.expand_path(path.to_s), line: example.metadata[:line_number].to_i }
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def phase(example, name)
|
|
343
|
+
id = test_id(example)
|
|
344
|
+
@current_context = [id, name]
|
|
345
|
+
@runtime.context(test_id: id, phase: name)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def unattributed
|
|
349
|
+
@current_context = nil
|
|
350
|
+
@runtime.context(test_id: nil, phase: "unattributed")
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def register_test(test)
|
|
354
|
+
@runtime.register_test(test: test)
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def complete(status:, finalized:)
|
|
358
|
+
return if @completed || @completion_started
|
|
359
|
+
|
|
360
|
+
@completion_started = true
|
|
361
|
+
apply_canonical_phase_counts
|
|
362
|
+
tests = @tests.values
|
|
363
|
+
failed = tests.count { |test| test[:status] == "failed" }
|
|
364
|
+
skipped = tests.count { |test| test[:status] == "skipped" }
|
|
365
|
+
diagnostics = completion_diagnostics(status)
|
|
366
|
+
errored = diagnostics.any? { |diagnostic| diagnostic[:severity] == "error" }
|
|
367
|
+
result = {
|
|
368
|
+
status: if errored
|
|
369
|
+
"ERROR"
|
|
370
|
+
elsif tests.empty?
|
|
371
|
+
"INCOMPLETE"
|
|
372
|
+
elsif failed.zero?
|
|
373
|
+
"PASSED"
|
|
374
|
+
else
|
|
375
|
+
"FAILED"
|
|
376
|
+
end,
|
|
377
|
+
executed_tests: tests.length, failed_tests: failed, skipped_tests: skipped,
|
|
378
|
+
exit_status: status.to_i, finalized: finalized && !errored, seed: @seed, diagnostics: diagnostics,
|
|
379
|
+
selected_test_files: @selected_test_files || [], selected_example_ids: @selected_example_ids || [], tests: tests
|
|
380
|
+
}
|
|
381
|
+
@completion_callback.call(result)
|
|
382
|
+
@completed = true
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def completion_diagnostics(native_status)
|
|
386
|
+
if @run_error
|
|
387
|
+
code = if defined?(Branchproof::RailsSupport::Error) && @run_error.is_a?(Branchproof::RailsSupport::Error)
|
|
388
|
+
"rails_boot"
|
|
389
|
+
elsif @run_error.respond_to?(:diagnostic_code)
|
|
390
|
+
@run_error.diagnostic_code
|
|
391
|
+
else
|
|
392
|
+
"rspec_run"
|
|
393
|
+
end
|
|
394
|
+
return [{ code: code, severity: "error", message: "#{@run_error.class}: #{@run_error.message}" }]
|
|
395
|
+
end
|
|
396
|
+
if @non_example_failure || @tests.values.any? { |test| test[:status] == "running" } ||
|
|
397
|
+
(native_status.to_i != 0 && @tests.values.none? { |test| test[:status] == "failed" })
|
|
398
|
+
return [{ code: "rspec_run", severity: "error",
|
|
399
|
+
message: "RSpec failed outside examples; see runner output" }]
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
return [] unless @tests.empty?
|
|
403
|
+
|
|
404
|
+
code, message = if @unmatched_test_selection
|
|
405
|
+
["unmatched_test_selection", "No files matched --test; check the path or glob"]
|
|
406
|
+
elsif @defined_example_count.to_i.positive?
|
|
407
|
+
["empty_example_selection",
|
|
408
|
+
"RSpec filters selected zero examples; check tags, descriptions, and selectors"]
|
|
409
|
+
else
|
|
410
|
+
["empty_suite", "RSpec discovered no examples; check the default path and configured patterns"]
|
|
411
|
+
end
|
|
412
|
+
[{ code: code, severity: "warning", message: message }]
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def apply_canonical_phase_counts
|
|
416
|
+
canonical_by_id = @runtime.test_phase_counts
|
|
417
|
+
@tests.each do |id, test|
|
|
418
|
+
counts = canonical_by_id[id]
|
|
419
|
+
test[:phase_counts] = counts unless counts.nil?
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
end
|
data/lib/branchproof/runtime.rb
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
4
|
require_relative "runtime_flow"
|
|
5
|
+
require_relative "extended_alternative_runtime"
|
|
6
|
+
require_relative "exception_runtime"
|
|
7
|
+
require_relative "default_runtime"
|
|
8
|
+
require_relative "value_runtime"
|
|
9
|
+
require_relative "iteration_runtime"
|
|
5
10
|
|
|
6
11
|
module Branchproof
|
|
7
12
|
# Process-local execution recorder. It deliberately never coerces or stores
|
|
@@ -9,6 +14,11 @@ module Branchproof
|
|
|
9
14
|
# Captures condition evaluations while preserving application values.
|
|
10
15
|
module Runtime
|
|
11
16
|
extend RuntimeFlow
|
|
17
|
+
extend ExtendedAlternativeRuntime
|
|
18
|
+
extend ExceptionRuntime
|
|
19
|
+
extend DefaultRuntime
|
|
20
|
+
extend ValueRuntime
|
|
21
|
+
extend IterationRuntime
|
|
12
22
|
|
|
13
23
|
FRAME_STATE_KEY = :branchproof_runtime_frame_state
|
|
14
24
|
|
|
@@ -97,6 +107,10 @@ module Branchproof
|
|
|
97
107
|
nil
|
|
98
108
|
end
|
|
99
109
|
|
|
110
|
+
def test_phase_counts
|
|
111
|
+
@evidence.test_phase_counts
|
|
112
|
+
end
|
|
113
|
+
|
|
100
114
|
def snapshot
|
|
101
115
|
if @evidence.respond_to?(:snapshot)
|
|
102
116
|
result = @evidence.snapshot
|
|
@@ -638,7 +638,9 @@ module Branchproof
|
|
|
638
638
|
end
|
|
639
639
|
|
|
640
640
|
def validate_test(test)
|
|
641
|
-
%w[name class_name method_name source_path].each
|
|
641
|
+
%w[adapter name class_name method_name source_path example_id].each do |field|
|
|
642
|
+
validate_string_field(test, field, nullable: true)
|
|
643
|
+
end
|
|
642
644
|
validate_integer_field(test, "line", nullable: true)
|
|
643
645
|
phase_counts = test["phase_counts"]
|
|
644
646
|
if phase_counts
|
|
@@ -726,7 +728,12 @@ module Branchproof
|
|
|
726
728
|
string_fields.each do |field|
|
|
727
729
|
fail_with("run_metadata #{field} must be a string") if metadata.key?(field) && !metadata[field].is_a?(String)
|
|
728
730
|
end
|
|
729
|
-
|
|
731
|
+
nullable_string_fields = %w[framework framework_version rspec_rails_version rails_version]
|
|
732
|
+
nullable_string_fields.each do |field|
|
|
733
|
+
fail_with("run_metadata #{field} must be a string") if metadata.key?(field) &&
|
|
734
|
+
!metadata[field].nil? && !metadata[field].is_a?(String)
|
|
735
|
+
end
|
|
736
|
+
array_fields = %w[source_patterns test_patterns test_files runner_args selected_test_files selected_example_ids]
|
|
730
737
|
array_fields.each do |field|
|
|
731
738
|
if metadata.key?(field) && !strings?(metadata[field])
|
|
732
739
|
fail_with("run_metadata #{field} must be an array of strings")
|
data/lib/branchproof/source.rb
CHANGED
|
@@ -4,12 +4,23 @@ require "digest"
|
|
|
4
4
|
require "pathname"
|
|
5
5
|
require "prism"
|
|
6
6
|
require_relative "decision_syntax"
|
|
7
|
+
require_relative "iteration_syntax"
|
|
8
|
+
require_relative "exception_syntax"
|
|
9
|
+
require_relative "default_syntax"
|
|
10
|
+
require_relative "value_syntax"
|
|
7
11
|
require_relative "constraints"
|
|
8
12
|
|
|
9
13
|
module Branchproof
|
|
10
14
|
# Inventories supported condition and decision occurrences from Ruby files.
|
|
11
15
|
class Source
|
|
12
16
|
include DecisionSyntax
|
|
17
|
+
# Contextual syntax modules are layered after the baseline classifier so
|
|
18
|
+
# each can extend discovery without coupling this walker to every syntax
|
|
19
|
+
# family. Additional modules can follow the same seam.
|
|
20
|
+
include IterationSyntax
|
|
21
|
+
include ExceptionSyntax
|
|
22
|
+
prepend DefaultSyntax
|
|
23
|
+
prepend ValueSyntax
|
|
13
24
|
|
|
14
25
|
attr_reader :root, :limits
|
|
15
26
|
|
|
@@ -114,8 +125,9 @@ module Branchproof
|
|
|
114
125
|
phase_one_nodes = []
|
|
115
126
|
phase_two_nodes = []
|
|
116
127
|
flow_nodes = []
|
|
128
|
+
value_nodes = []
|
|
117
129
|
|
|
118
|
-
|
|
130
|
+
walk_skipping_defined_operands(program) do |node|
|
|
119
131
|
if node.is_a?(Prism::DefinedNode)
|
|
120
132
|
defined_ranges << node.location
|
|
121
133
|
elsif node.is_a?(Prism::InNode)
|
|
@@ -123,12 +135,14 @@ module Branchproof
|
|
|
123
135
|
guard_patterns[pattern] = true if pattern.is_a?(Prism::IfNode) || pattern.is_a?(Prism::UnlessNode)
|
|
124
136
|
end
|
|
125
137
|
phase_one_nodes << node if decision_node?(node) || subjectless_case?(node)
|
|
126
|
-
phase_two_nodes << node if boolean_node?(node) || node.is_a?(Prism::MatchPredicateNode)
|
|
138
|
+
phase_two_nodes << node if boolean_node?(node) || node.is_a?(Prism::MatchPredicateNode) ||
|
|
139
|
+
node.is_a?(Prism::DefinedNode)
|
|
127
140
|
flow_nodes << node if flow_decision_node?(node)
|
|
141
|
+
value_nodes << node if respond_to?(:value_candidate_node?, true) && value_candidate_node?(node)
|
|
128
142
|
end
|
|
129
143
|
|
|
130
144
|
{ defined_ranges: defined_ranges, guard_patterns: guard_patterns, phase_one_nodes: phase_one_nodes,
|
|
131
|
-
phase_two_nodes: phase_two_nodes, flow_nodes: flow_nodes }
|
|
145
|
+
phase_two_nodes: phase_two_nodes, flow_nodes: flow_nodes, value_nodes: value_nodes }
|
|
132
146
|
end
|
|
133
147
|
|
|
134
148
|
def decisions_for(program, bytes, source_id, file_reasons = [], encoding = "UTF-8")
|
|
@@ -162,15 +176,17 @@ module Branchproof
|
|
|
162
176
|
# argument) are separate decisions when tree_for did not decompose them.
|
|
163
177
|
collected[:phase_two_nodes].each do |node|
|
|
164
178
|
next if inventoried_boolean_nodes[node]
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
179
|
+
next if within_defined_expression?(node, defined_ranges) && !node.is_a?(Prism::DefinedNode)
|
|
180
|
+
|
|
181
|
+
context = if node.is_a?(Prism::MatchPredicateNode)
|
|
182
|
+
"pattern_in"
|
|
183
|
+
elsif node.is_a?(Prism::DefinedNode)
|
|
184
|
+
"defined"
|
|
185
|
+
else
|
|
186
|
+
"short_circuit"
|
|
187
|
+
end
|
|
172
188
|
specs << { node: nil, predicate: node, context: context,
|
|
173
|
-
additional_reasons:
|
|
189
|
+
additional_reasons: [] }
|
|
174
190
|
mark_semantic_boolean_nodes(node, inventoried_boolean_nodes)
|
|
175
191
|
end
|
|
176
192
|
|
|
@@ -188,22 +204,24 @@ module Branchproof
|
|
|
188
204
|
predicate: predicate, context: spec[:context],
|
|
189
205
|
additional_reasons: spec[:additional_reasons] || [])
|
|
190
206
|
end
|
|
191
|
-
boolean_decisions + flow_decisions_for(program, bytes, source_id, file_reasons, encoding,
|
|
192
|
-
|
|
207
|
+
decisions = boolean_decisions + flow_decisions_for(program, bytes, source_id, file_reasons, encoding,
|
|
208
|
+
defined_ranges: defined_ranges,
|
|
209
|
+
nodes: collected[:flow_nodes])
|
|
210
|
+
decisions + additional_decisions_for(program, bytes, source_id, file_reasons, encoding,
|
|
211
|
+
collected: collected, decisions: decisions)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def additional_decisions_for(_program, _bytes, _source_id, _file_reasons, _encoding, **_options)
|
|
215
|
+
[]
|
|
193
216
|
end
|
|
194
217
|
|
|
195
218
|
def flow_decisions_for(program, bytes, source_id, file_reasons = [], encoding = "UTF-8",
|
|
196
219
|
defined_ranges: nil, nodes: nil)
|
|
197
220
|
defined_ranges ||= collect_defined_ranges(program)
|
|
198
221
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
decision.merge(
|
|
203
|
-
support_status: "UNSUPPORTED",
|
|
204
|
-
support_reasons: (Array(decision[:support_reasons]) + ["unsupported_defined_expression"]).uniq
|
|
205
|
-
)
|
|
206
|
-
end
|
|
222
|
+
nodes ||= collect_flow_decision_nodes(program)
|
|
223
|
+
nodes = nodes.reject { |node| within_defined_expression?(node, defined_ranges) }
|
|
224
|
+
super(program, bytes, source_id, file_reasons, encoding, nodes: nodes)
|
|
207
225
|
end
|
|
208
226
|
|
|
209
227
|
def collect_defined_ranges(program)
|
|
@@ -271,10 +289,14 @@ module Branchproof
|
|
|
271
289
|
literal_truth = leaf.delete(:_literal_truth)
|
|
272
290
|
constraint = leaf.delete(:_constraint)
|
|
273
291
|
constraint_safe = leaf.delete(:_constraint_safe)
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
292
|
+
contextual = leaf.delete(:_contextual)
|
|
293
|
+
condition = Records.build(id: nil, index: index, byte_start: location.start_offset,
|
|
294
|
+
byte_length: location.length,
|
|
295
|
+
line: location.start_line, column: location.start_column,
|
|
296
|
+
expression: expression, literal_truth: literal_truth, coupling: "unknown",
|
|
297
|
+
constraint: constraint,
|
|
298
|
+
constraint_safe: decision_constraint_safe && constraint_safe == true)
|
|
299
|
+
contextual ? condition.merge(contextual: contextual) : condition
|
|
278
300
|
end
|
|
279
301
|
decision_id = Records.decision_id(source_id: source_id, context: context, byte_start: start_offset,
|
|
280
302
|
byte_length: length, tree: tree)
|
|
@@ -319,7 +341,7 @@ module Branchproof
|
|
|
319
341
|
inventoried_boolean_nodes[node] = true
|
|
320
342
|
mark_semantic_boolean_nodes(node.left, inventoried_boolean_nodes)
|
|
321
343
|
mark_semantic_boolean_nodes(node.right, inventoried_boolean_nodes)
|
|
322
|
-
when Prism::MatchPredicateNode
|
|
344
|
+
when Prism::DefinedNode, Prism::MatchPredicateNode
|
|
323
345
|
inventoried_boolean_nodes[node] = true
|
|
324
346
|
when Prism::CallNode
|
|
325
347
|
mark_semantic_boolean_nodes(node.receiver, inventoried_boolean_nodes) if unary_not?(node)
|
|
@@ -355,6 +377,9 @@ module Branchproof
|
|
|
355
377
|
{ start: location.start_offset, length: location.length }
|
|
356
378
|
end
|
|
357
379
|
}
|
|
380
|
+
leaf[:_contextual] = "implicit_regexp" if node.is_a?(Prism::MatchLastLineNode) ||
|
|
381
|
+
node.is_a?(Prism::InterpolatedMatchLastLineNode)
|
|
382
|
+
leaf[:_contextual] = "flip_flop" if node.is_a?(Prism::FlipFlopNode)
|
|
358
383
|
leaves << leaf
|
|
359
384
|
Records.build(type: :atom, index: leaves.length - 1)
|
|
360
385
|
end
|
|
@@ -393,12 +418,7 @@ module Branchproof
|
|
|
393
418
|
|
|
394
419
|
def unsupported_reasons(predicate, bytes)
|
|
395
420
|
reasons = []
|
|
396
|
-
|
|
397
|
-
if node.is_a?(Prism::MatchLastLineNode) || node.is_a?(Prism::InterpolatedMatchLastLineNode)
|
|
398
|
-
reasons << "unsupported_implicit_regexp"
|
|
399
|
-
end
|
|
400
|
-
reasons << "unsupported_flip_flop" if node.is_a?(Prism::FlipFlopNode)
|
|
401
|
-
reasons << "unsupported_defined_expression" if node.is_a?(Prism::DefinedNode)
|
|
421
|
+
walk_skipping_defined_operands(predicate) do |node|
|
|
402
422
|
reasons << "unsupported_heredoc" if node.respond_to?(:opening_loc) && node.opening_loc &&
|
|
403
423
|
bytes.byteslice(node.opening_loc.start_offset,
|
|
404
424
|
node.opening_loc.length).start_with?("<<")
|
|
@@ -406,6 +426,13 @@ module Branchproof
|
|
|
406
426
|
reasons
|
|
407
427
|
end
|
|
408
428
|
|
|
429
|
+
def walk_skipping_defined_operands(node, &block)
|
|
430
|
+
yield node
|
|
431
|
+
return if node.is_a?(Prism::DefinedNode)
|
|
432
|
+
|
|
433
|
+
node.child_nodes.each { |child| walk_skipping_defined_operands(child, &block) if child }
|
|
434
|
+
end
|
|
435
|
+
|
|
409
436
|
def unwrap_predicate(node)
|
|
410
437
|
while node.is_a?(Prism::ParenthesesNode)
|
|
411
438
|
statements = node.body
|