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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +120 -28
  4. data/doc/Branchproof/DefaultInstrumentation.md +13 -0
  5. data/doc/Branchproof/DefaultRuntime.md +11 -0
  6. data/doc/Branchproof/DefaultSyntax.md +7 -0
  7. data/doc/Branchproof/Evidence.md +3 -0
  8. data/doc/Branchproof/ExceptionInstrumentation.md +9 -0
  9. data/doc/Branchproof/ExceptionRuntime.md +27 -0
  10. data/doc/Branchproof/ExceptionSyntax.md +9 -0
  11. data/doc/Branchproof/ExtendedAlternativeRuntime.md +19 -0
  12. data/doc/Branchproof/Instrumenter.md +1 -1
  13. data/doc/Branchproof/IterationInstrumentation.md +7 -0
  14. data/doc/Branchproof/IterationRuntime.md +21 -0
  15. data/doc/Branchproof/IterationSyntax.md +11 -0
  16. data/doc/Branchproof/Project.md +4 -1
  17. data/doc/Branchproof/RSpecAdapter/ClassRunnerGuard.md +11 -0
  18. data/doc/Branchproof/RSpecAdapter/ContextLifecycle.md +14 -0
  19. data/doc/Branchproof/RSpecAdapter/DefaultDiscovery.md +11 -0
  20. data/doc/Branchproof/RSpecAdapter/ExampleLifecycle.md +11 -0
  21. data/doc/Branchproof/RSpecAdapter/RunnerGuard.md +11 -0
  22. data/doc/Branchproof/RSpecAdapter/UnsupportedRunner.md +12 -0
  23. data/doc/Branchproof/RSpecAdapter.md +47 -0
  24. data/doc/Branchproof/RailsSupport.md +35 -2
  25. data/doc/Branchproof/Report.md +3 -0
  26. data/doc/Branchproof/Runtime.md +59 -1
  27. data/doc/Branchproof/Source.md +7 -1
  28. data/doc/Branchproof/ValueInstrumentation.md +7 -0
  29. data/doc/Branchproof/ValueRuntime.md +26 -0
  30. data/doc/Branchproof/ValueSyntax.md +26 -0
  31. data/doc/Branchproof/Worker.md +8 -1
  32. data/doc/Branchproof.md +24 -3
  33. data/doc/CHANGELOG.md +37 -0
  34. data/doc/README.md +120 -28
  35. data/lib/branchproof/cli.rb +74 -28
  36. data/lib/branchproof/comparison.rb +52 -6
  37. data/lib/branchproof/decision_syntax.rb +19 -9
  38. data/lib/branchproof/default_instrumentation.rb +140 -0
  39. data/lib/branchproof/default_runtime.rb +16 -0
  40. data/lib/branchproof/default_syntax.rb +94 -0
  41. data/lib/branchproof/evidence.rb +87 -1
  42. data/lib/branchproof/exception_instrumentation.rb +96 -0
  43. data/lib/branchproof/exception_runtime.rb +41 -0
  44. data/lib/branchproof/exception_syntax.rb +157 -0
  45. data/lib/branchproof/extended_alternative_runtime.rb +23 -0
  46. data/lib/branchproof/flow_instrumentation.rb +36 -7
  47. data/lib/branchproof/focused_report.rb +5 -0
  48. data/lib/branchproof/instrumenter.rb +50 -5
  49. data/lib/branchproof/iteration_instrumentation.rb +66 -0
  50. data/lib/branchproof/iteration_runtime.rb +79 -0
  51. data/lib/branchproof/iteration_syntax.rb +75 -0
  52. data/lib/branchproof/loader.rb +6 -1
  53. data/lib/branchproof/project.rb +49 -2
  54. data/lib/branchproof/rails_support.rb +101 -0
  55. data/lib/branchproof/report.rb +21 -1
  56. data/lib/branchproof/rspec_adapter.rb +423 -0
  57. data/lib/branchproof/runtime.rb +14 -0
  58. data/lib/branchproof/saved_report.rb +9 -2
  59. data/lib/branchproof/source.rb +59 -32
  60. data/lib/branchproof/value_instrumentation.rb +35 -0
  61. data/lib/branchproof/value_runtime.rb +73 -0
  62. data/lib/branchproof/value_syntax.rb +116 -0
  63. data/lib/branchproof/version.rb +1 -1
  64. data/lib/branchproof/worker.rb +75 -15
  65. data/lib/branchproof.rb +1 -0
  66. data/llms.txt +24 -3
  67. data/sig/branchproof.rbs +45 -1
  68. 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
@@ -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 { |field| validate_string_field(test, field, nullable: true) }
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
- array_fields = %w[source_patterns test_patterns test_files runner_args]
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")
@@ -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
- walk(program) do |node|
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
- additional_reasons = if within_defined_expression?(node, defined_ranges)
167
- ["unsupported_defined_expression"]
168
- else
169
- []
170
- end
171
- context = node.is_a?(Prism::MatchPredicateNode) ? "pattern_in" : "short_circuit"
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: 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
- defined_ranges: defined_ranges, nodes: collected[:flow_nodes])
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
- super(program, bytes, source_id, file_reasons, encoding, nodes: nodes).map do |decision|
200
- next decision unless range_within_defined_expression?(decision, defined_ranges)
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
- Records.build(id: nil, index: index, byte_start: location.start_offset, byte_length: location.length,
275
- line: location.start_line, column: location.start_column,
276
- expression: expression, literal_truth: literal_truth, coupling: "unknown",
277
- constraint: constraint, constraint_safe: decision_constraint_safe && constraint_safe == true)
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
- walk(predicate) do |node|
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