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
@@ -34,6 +34,7 @@ module Branchproof
34
34
  @run_payloads = {}
35
35
  @abort_counts = Hash.new(0)
36
36
  @diagnostics = []
37
+ @repetition_cache = {}
37
38
  @limited = false
38
39
  @attribution_complete = true
39
40
  end
@@ -55,6 +56,10 @@ module Branchproof
55
56
  end
56
57
 
57
58
  def record(execution:)
59
+ if (cached = cached_execution(execution))
60
+ return record_cached(cached)
61
+ end
62
+
58
63
  value = symbolize(execution)
59
64
  reason = validate_execution(value)
60
65
  return reject_record(reason) if reason
@@ -102,6 +107,8 @@ module Branchproof
102
107
  phase = value[:phase].to_s
103
108
  test[:phase_counts][phase] = test[:phase_counts].fetch(phase, 0) + 1
104
109
  end
110
+ cache_key = execution_cache_key(value)
111
+ @repetition_cache[cache_key[1]] = { key: cache_key, vector_id: vector_id }
105
112
  status("recorded", nil)
106
113
  rescue StandardError => e
107
114
  diagnose(diagnostic: { code: "record_failure", severity: "error", message: e.message })
@@ -113,6 +120,10 @@ module Branchproof
113
120
  nil
114
121
  end
115
122
 
123
+ def test_phase_counts
124
+ @tests.transform_values { |test| test[:phase_counts].dup.freeze }.freeze
125
+ end
126
+
116
127
  def snapshot
117
128
  deep_freeze(deep_dup({ schema_version: SCHEMA_VERSION, tool_version: TOOL_VERSION,
118
129
  criterion_version: CRITERION_VERSION,
@@ -167,6 +178,7 @@ module Branchproof
167
178
  @abort_counts.merge!(incoming.fetch(:abort_counts, {})) { |_k, a, b| a.to_i + b.to_i }
168
179
  @limited ||= !incoming.dig(:completeness, :observation)
169
180
  @attribution_complete &&= incoming.dig(:completeness, :attribution) ? true : false
181
+ @repetition_cache.clear
170
182
  status("merged", nil)
171
183
  rescue StandardError => e
172
184
  restore_state(backup) if backup
@@ -518,7 +530,8 @@ module Branchproof
518
530
  { vectors: deep_dup(@vectors), tests: deep_dup(@tests), run_ids: @run_ids.dup,
519
531
  run_payloads: @run_payloads.dup, diagnostics: deep_dup(@diagnostics),
520
532
  abort_counts: @abort_counts.dup, limited: @limited, attribution_complete: @attribution_complete,
521
- vector_counts_by_decision: @vector_counts_by_decision.dup, owner_associations_count: @owner_associations_count }
533
+ vector_counts_by_decision: @vector_counts_by_decision.dup, owner_associations_count: @owner_associations_count,
534
+ repetition_cache: deep_dup(@repetition_cache) }
522
535
  end
523
536
 
524
537
  def restore_state(state)
@@ -530,10 +543,83 @@ module Branchproof
530
543
  @abort_counts = state[:abort_counts]
531
544
  @vector_counts_by_decision = state[:vector_counts_by_decision]
532
545
  @owner_associations_count = state[:owner_associations_count]
546
+ @repetition_cache = state[:repetition_cache]
533
547
  @limited = state[:limited]
534
548
  @attribution_complete = state[:attribution_complete]
535
549
  end
536
550
 
551
+ def cached_execution(execution)
552
+ return nil unless execution.is_a?(Hash)
553
+
554
+ key = raw_execution_cache_key(execution)
555
+ return nil unless key
556
+
557
+ entry = @repetition_cache[key[1]]
558
+ return nil unless entry && entry[:key] == key
559
+
560
+ vector = @vectors[entry[:vector_id]]
561
+ return nil unless vector
562
+
563
+ [key, vector, raw_execution_attribution(execution)]
564
+ end
565
+
566
+ def record_cached(cached)
567
+ _key, vector, value = cached
568
+ vector[:count] += 1
569
+ if value[:test_id] && @tests.key?(value[:test_id].to_s)
570
+ test = @tests[value[:test_id].to_s]
571
+ phase = value[:phase].to_s
572
+ test[:phase_counts][phase] = test[:phase_counts].fetch(phase, 0) + 1
573
+ end
574
+ vector[:unattributed_count] += 1 unless value[:test_id]
575
+ status("recorded", nil)
576
+ end
577
+
578
+ def raw_execution_cache_key(execution)
579
+ required = %i[run_id decision_id test_id phase observations outcome status]
580
+ return nil unless required.all? { |key| execution.key?(key) || execution.key?(key.to_s) }
581
+
582
+ status = raw_value(execution, :status)
583
+ outcome = raw_value(execution, :outcome)
584
+ return nil unless status.to_s == "completed" && [true, false].include?(outcome)
585
+
586
+ observations = raw_value(execution, :observations)
587
+ return nil unless observations.is_a?(Array)
588
+
589
+ test_id = raw_value(execution, :test_id)
590
+ [raw_value(execution, :run_id).to_s, raw_value(execution, :decision_id).to_s,
591
+ test_id_marker(test_id), test_id&.to_s, raw_value(execution, :phase).to_s,
592
+ observations, outcome, status.to_s]
593
+ end
594
+
595
+ def execution_cache_key(value)
596
+ key = [copy_string(value[:run_id]), copy_string(value[:decision_id]), test_id_marker(value[:test_id]),
597
+ copy_string(value[:test_id]),
598
+ copy_string(value[:phase]), deep_dup(value[:observations]), value[:outcome], copy_string(value[:status])]
599
+ deep_freeze(key)
600
+ end
601
+
602
+ def raw_execution_attribution(execution)
603
+ { test_id: raw_value(execution, :test_id), phase: raw_value(execution, :phase) }
604
+ end
605
+
606
+ def copy_string(value)
607
+ value.nil? ? nil : value.to_s.dup
608
+ end
609
+
610
+ def test_id_marker(value)
611
+ return :nil if value.nil?
612
+ return :false_test_id if value == false
613
+
614
+ :present
615
+ end
616
+
617
+ def raw_value(hash, key)
618
+ return hash[key] if hash.key?(key)
619
+
620
+ hash[key.to_s]
621
+ end
622
+
537
623
  def deep_freeze(value)
538
624
  case value
539
625
  when Hash then value.each do |key, item|
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Style/ConditionalAssignment
4
+
5
+ require_relative "flow_instrumentation"
6
+
7
+ module Branchproof
8
+ # Textual edits for native rescue control flow. The edits only add calls at
9
+ # Ruby's own protected-region and handler boundaries; exception matching,
10
+ # `$!`, retry, ensure ordering, and nonlocal transfers remain Ruby-owned.
11
+ module ExceptionInstrumentation
12
+ private
13
+
14
+ def render_flow(bytes, decision, nested, encloses)
15
+ metadata = decision.fetch(:instrumentation)
16
+ return super unless %w[exception rescue_modifier].include?(metadata[:type])
17
+
18
+ if metadata[:type] == "exception"
19
+ render_exception_flow(bytes, decision, nested, encloses)
20
+ else
21
+ render_rescue_modifier_flow(bytes, decision, nested, encloses)
22
+ end
23
+ end
24
+
25
+ def render_exception_flow(bytes, decision, nested, encloses)
26
+ identifier = decision[:id].inspect
27
+ runtime = self.class::RUNTIME
28
+ metadata = decision.fetch(:instrumentation)
29
+ replacements = []
30
+ if metadata[:normal_insert_at] && !metadata[:normal_empty]
31
+ replacements << { start: metadata.fetch(:normal_insert_at), length: 0,
32
+ text: "; #{runtime}.exception_path(#{identifier}, 0); " }
33
+ elsif metadata[:normal_transfer]
34
+ transfer = metadata.fetch(:normal_transfer)
35
+ if transfer[:arguments]
36
+ replacements << flow_replacement(bytes, transfer.fetch(:arguments), nested, encloses) do |arguments|
37
+ value = transfer[:argument_mode] == :array ? "[#{arguments}]" : "(#{arguments})"
38
+ "#{runtime}.exception_value(#{identifier}, #{value}, 0)"
39
+ end
40
+ else
41
+ replacements << { start: transfer.fetch(:insert_at), length: 0,
42
+ text: "#{runtime}.exception_path(#{identifier}, 0); " }
43
+ end
44
+ elsif metadata[:normal_body] && metadata[:normal_body][:byte_length].positive?
45
+ replacements << flow_replacement(bytes, metadata.fetch(:normal_body), nested, encloses) do |body|
46
+ "#{runtime}.exception_value(#{identifier}, (begin; #{body}; end), 0)"
47
+ end
48
+ end
49
+ metadata.fetch(:clauses).each do |clause|
50
+ replacements << { start: clause.fetch(:insert_at), length: 0,
51
+ text: "; #{runtime}.exception_path(#{identifier}, #{clause.fetch(:index)}); " }
52
+ end
53
+ if metadata[:implicit]
54
+ entry = "begin; #{runtime}.exception_enter(#{identifier}, #{metadata.fetch(:unhandled_index)}); begin; "
55
+ entry += "#{runtime}.exception_path(#{identifier}, 0); " if metadata[:normal_empty]
56
+ entry += metadata[:iteration_callback].fetch(:text) if metadata[:iteration_callback]
57
+ exit = "; end; rescue ::Exception; #{runtime}.exception_unhandled(#{identifier}); raise; ensure; " \
58
+ "#{runtime}.exception_leave(#{identifier}); end; "
59
+ replacements << { start: metadata.fetch(:entry_insert_at), length: 0, text: entry }
60
+ replacements << { start: metadata.fetch(:exit_insert_at), length: 0, text: exit }
61
+ flow_fragments(bytes, decision, nested, replacements, encloses)
62
+ else
63
+ expression = flow_fragments(bytes, decision, nested, replacements, encloses)
64
+ exception_frame(decision[:id], expression, metadata.fetch(:unhandled_index))
65
+ end
66
+ end
67
+
68
+ def render_rescue_modifier_flow(bytes, decision, nested, encloses)
69
+ identifier = decision[:id].inspect
70
+ runtime = self.class::RUNTIME
71
+ metadata = decision.fetch(:instrumentation)
72
+ lhs = metadata.fetch(:expression)
73
+ rhs = metadata.fetch(:rescue_expression)
74
+ replacements = [
75
+ flow_replacement(bytes, lhs, nested, encloses) do |expression|
76
+ "#{runtime}.exception_value(#{identifier}, (#{expression}), 0)"
77
+ end,
78
+ flow_replacement(bytes, rhs, nested, encloses) do |expression|
79
+ "(begin; #{runtime}.exception_path(#{identifier}, 1); (#{expression}); end)"
80
+ end
81
+ ]
82
+ exception_frame(decision[:id], flow_fragments(bytes, decision, nested, replacements, encloses),
83
+ metadata.fetch(:unhandled_index))
84
+ end
85
+
86
+ def exception_frame(decision_id, expression, unhandled_index)
87
+ runtime = self.class::RUNTIME
88
+ "(begin; #{runtime}.exception_enter(#{decision_id.inspect}, #{unhandled_index}); begin; " \
89
+ "#{expression}; " \
90
+ "rescue ::Exception; #{runtime}.exception_unhandled(#{decision_id.inspect}); raise; ensure; " \
91
+ "#{runtime}.exception_leave(#{decision_id.inspect}); end; end)"
92
+ end
93
+ end
94
+ end
95
+
96
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Style/ConditionalAssignment
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Branchproof
4
+ # Record native clause selection and escaping exceptions. Generated wrappers
5
+ # re-raise the same exception; nonlocal transfers remain aborted observations.
6
+ module ExceptionRuntime
7
+ def exception_enter(decision_id, unhandled_index)
8
+ enter(decision_id)
9
+ frame = current_frame(decision_id)
10
+ frame[:exception_unhandled_index] = unhandled_index if frame
11
+ nil
12
+ end
13
+
14
+ def exception_path(decision_id, index)
15
+ frame = current_frame(decision_id)
16
+ flow_select(decision_id, index) if frame && !frame[:finished]
17
+ nil
18
+ end
19
+
20
+ def exception_value(decision_id, value, index)
21
+ exception_path(decision_id, index)
22
+ value
23
+ end
24
+
25
+ def exception_finish(decision_id, value)
26
+ frame = current_frame(decision_id)
27
+ exception_path(decision_id, 0) if frame && !frame[:finished]
28
+ value
29
+ end
30
+
31
+ def exception_unhandled(decision_id)
32
+ frame = current_frame(decision_id)
33
+ exception_path(decision_id, frame[:exception_unhandled_index]) if frame && !frame[:finished]
34
+ nil
35
+ end
36
+
37
+ def exception_leave(decision_id)
38
+ leave(decision_id)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ModuleLength
4
+
5
+ require "prism"
6
+
7
+ module Branchproof
8
+ # Replaces unsupported standalone rescue-clause records with decisions for
9
+ # Ruby's enclosing protected region. Ruby chooses a rescue clause as part
10
+ # of executing BeginNode; a RescueNode by itself is not executable syntax.
11
+ module ExceptionSyntax
12
+ private
13
+
14
+ def flow_decision_node?(node)
15
+ return true if exception_region_node?(node)
16
+ return false if node.is_a?(Prism::RescueNode)
17
+
18
+ super
19
+ end
20
+
21
+ def flow_details(node, bytes, encoding)
22
+ return exception_region_details(node, bytes, encoding) if exception_region_node?(node)
23
+
24
+ super
25
+ end
26
+
27
+ def exception_region_node?(node)
28
+ (node.is_a?(Prism::BeginNode) && (node.rescue_clause || node.ensure_clause)) ||
29
+ node.is_a?(Prism::RescueModifierNode)
30
+ end
31
+
32
+ def exception_region_details(node, bytes, encoding)
33
+ return rescue_modifier_details(node, bytes, encoding) if node.is_a?(Prism::RescueModifierNode)
34
+
35
+ clauses = []
36
+ clause = node.rescue_clause
37
+ while clause
38
+ clauses << clause
39
+ clause = clause.child_nodes.find { |child| child.is_a?(Prism::RescueNode) }
40
+ end
41
+
42
+ alternatives = [{ expression: "normal", byte_start: node.location.start_offset,
43
+ byte_length: node.location.length }]
44
+ clauses.each do |clause_node|
45
+ alternatives << { expression: rescue_label(clause_node, bytes, encoding),
46
+ byte_start: clause_node.location.start_offset,
47
+ byte_length: clause_node.location.length }
48
+ end
49
+ alternatives << { expression: "unhandled", byte_start: node.location.start_offset,
50
+ byte_length: node.location.length }
51
+
52
+ instrumentation = {
53
+ type: "exception",
54
+ range: byte_range(node.location),
55
+ normal_insert_at: exception_normal_insert_at(node),
56
+ normal_body: node.statements && byte_range(node.statements.location),
57
+ normal_transfer: exception_transfer_metadata(node.statements),
58
+ implicit: node.begin_keyword_loc.nil?,
59
+ normal_empty: node.begin_keyword_loc.nil? && exception_body_empty?(node),
60
+ entry_insert_at: node.begin_keyword_loc.nil? && exception_entry_insert_at(node),
61
+ exit_insert_at: node.begin_keyword_loc.nil? && node.end_keyword_loc&.start_offset,
62
+ clauses: clauses.each_with_index.map do |clause_node, index|
63
+ { index: index + 1, insert_at: exception_clause_insert_at(clause_node, node) }
64
+ end,
65
+ unhandled_index: alternatives.length - 1
66
+ }
67
+ reasons = unsupported_reasons(node, bytes)
68
+ ["exception", "rescue", alternatives, instrumentation, reasons]
69
+ end
70
+
71
+ def rescue_modifier_details(node, bytes, _encoding)
72
+ lhs = node.expression
73
+ rhs = node.rescue_expression
74
+ alternatives = [
75
+ { expression: "normal", byte_start: lhs.location.start_offset, byte_length: lhs.location.length },
76
+ { expression: "rescue", byte_start: rhs.location.start_offset, byte_length: rhs.location.length },
77
+ { expression: "unhandled", byte_start: node.location.start_offset, byte_length: node.location.length }
78
+ ]
79
+ instrumentation = {
80
+ type: "rescue_modifier",
81
+ range: byte_range(node.location),
82
+ expression: byte_range(lhs.location),
83
+ rescue_expression: byte_range(rhs.location),
84
+ normal_index: 0,
85
+ rescue_index: 1,
86
+ unhandled_index: 2
87
+ }
88
+ ["exception", "rescue", alternatives, instrumentation, unsupported_reasons(node, bytes)]
89
+ end
90
+
91
+ def rescue_label(clause, bytes, encoding)
92
+ exceptions = Array(clause.exceptions)
93
+ return "rescue" if exceptions.empty?
94
+
95
+ names = exceptions.map do |exception|
96
+ text_value(bytes.byteslice(exception.location.start_offset, exception.location.length), encoding)
97
+ end
98
+ "rescue #{names.join(", ")}"
99
+ end
100
+
101
+ def exception_clause_insert_at(clause, node)
102
+ return clause.statements.location.start_offset unless statements_empty?(clause.statements)
103
+
104
+ next_clause = clause.child_nodes.find { |child| child.is_a?(Prism::RescueNode) }
105
+ return next_clause.keyword_loc.start_offset if next_clause
106
+ return node.else_clause.else_keyword_loc.start_offset if node.else_clause
107
+ return node.ensure_clause.ensure_keyword_loc.start_offset if node.ensure_clause
108
+
109
+ node.end_keyword_loc.start_offset
110
+ end
111
+
112
+ def exception_else_insert_at(node)
113
+ clause = node.else_clause
114
+ return clause.statements.location.start_offset unless statements_empty?(clause.statements)
115
+
116
+ node.ensure_clause&.ensure_keyword_loc&.start_offset || node.end_keyword_loc.start_offset
117
+ end
118
+
119
+ def exception_transfer_metadata(statements)
120
+ transfer = statements && Array(statements.body).last
121
+ return nil unless transfer_node?(transfer)
122
+
123
+ arguments = transfer.respond_to?(:arguments) ? transfer.arguments : nil
124
+ argument_nodes = arguments && Array(arguments.arguments)
125
+ scalar_argument = argument_nodes&.length == 1 && !argument_nodes.first.is_a?(Prism::SplatNode)
126
+ argument_mode = if scalar_argument
127
+ :scalar
128
+ elsif argument_nodes&.any?
129
+ :array
130
+ end
131
+ { insert_at: transfer.location.start_offset,
132
+ arguments: argument_mode ? byte_range(arguments.location) : nil,
133
+ argument_mode: argument_mode }
134
+ end
135
+
136
+ def transfer_node?(node)
137
+ [Prism::ReturnNode, Prism::BreakNode, Prism::NextNode, Prism::RedoNode].any? { |klass| node.is_a?(klass) }
138
+ end
139
+
140
+ def exception_body_empty?(node)
141
+ node.statements.nil? || statements_empty?(node.statements)
142
+ end
143
+
144
+ def exception_normal_insert_at(node)
145
+ return exception_else_insert_at(node) if node.else_clause
146
+ return nil unless exception_body_empty?(node)
147
+
148
+ node.rescue_clause&.keyword_loc&.start_offset || node.ensure_clause&.ensure_keyword_loc&.start_offset
149
+ end
150
+
151
+ def exception_entry_insert_at(node)
152
+ node.statements&.location&.start_offset || exception_normal_insert_at(node) || node.end_keyword_loc.start_offset
153
+ end
154
+ end
155
+ end
156
+
157
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ModuleLength
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Branchproof
4
+ # Runtime hooks for decisions whose native evaluation has a third path.
5
+ # Runtime includes this module explicitly; it is kept separate so the
6
+ # ordinary two-path helpers remain unchanged.
7
+ module ExtendedAlternativeRuntime
8
+ def flow_assignment_receiver(decision_id, receiver)
9
+ flow_select(decision_id, 0) if nil.equal?(receiver)
10
+ receiver
11
+ end
12
+
13
+ def flow_assignment_path(decision_id, index)
14
+ flow_select(decision_id, index)
15
+ end
16
+
17
+ def flow_assignment_finish(decision_id, value, default_path = nil)
18
+ frame = current_frame(decision_id)
19
+ flow_select(decision_id, default_path) if frame && !frame[:finished] && !default_path.nil?
20
+ value
21
+ end
22
+ end
23
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Keep each bounded source rewrite together so its evaluation order can be audited.
4
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
4
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/ModuleLength
5
5
 
6
6
  module Branchproof
7
7
  # Source-location edits around Ruby's native matching and assignment operations.
@@ -19,11 +19,27 @@ module Branchproof
19
19
  flow_fragments(bytes, decision, nested, replacements, encloses)
20
20
  when "assignment"
21
21
  rhs = metadata.fetch(:rhs)
22
- replacements = [flow_replacement(bytes, rhs, nested, encloses) do |expression|
23
- "(begin; #{self.class::RUNTIME}.flow_path(#{decision[:id].inspect}, 1); (#{expression}); end)"
24
- end]
22
+ identifier = decision[:id].inspect
23
+ replacements = []
24
+ if metadata[:receiver]
25
+ receiver = metadata.fetch(:receiver)
26
+ replacements << flow_replacement(bytes, receiver, nested, encloses) do |expression|
27
+ "#{self.class::RUNTIME}.flow_assignment_receiver(#{identifier}, (#{expression}))"
28
+ end
29
+ replacements << flow_replacement(bytes, rhs, nested, encloses) do |expression|
30
+ "(begin; #{self.class::RUNTIME}.flow_assignment_path(#{identifier}, 2); (#{expression}); end)"
31
+ end
32
+ else
33
+ replacements << flow_replacement(bytes, rhs, nested, encloses) do |expression|
34
+ "(begin; #{self.class::RUNTIME}.flow_path(#{identifier}, 1); (#{expression}); end)"
35
+ end
36
+ end
25
37
  expression = flow_fragments(bytes, decision, nested, replacements, encloses)
26
- flow_frame(decision[:id], expression, default_path: 0)
38
+ if metadata[:receiver]
39
+ flow_assignment_frame(decision[:id], expression, default_path: 1)
40
+ else
41
+ flow_frame(decision[:id], expression, default_path: 0)
42
+ end
27
43
  when "case"
28
44
  render_case_flow(bytes, decision, nested, metadata, encloses)
29
45
  when "case_match"
@@ -38,7 +54,12 @@ module Branchproof
38
54
  runtime = self.class::RUNTIME
39
55
  replacements = metadata.fetch(:candidates).map do |candidate|
40
56
  flow_replacement(bytes, candidate, nested, encloses) do |expression|
41
- "(begin; #{runtime}.flow_candidate(#{identifier}, #{candidate[:index]}); (#{expression}); end)"
57
+ if candidate[:splat]
58
+ splat_expression = expression.sub(/\A\*/, "")
59
+ "*(begin; #{runtime}.flow_candidate(#{identifier}, #{candidate[:index]}); (#{splat_expression}); end)"
60
+ else
61
+ "(begin; #{runtime}.flow_candidate(#{identifier}, #{candidate[:index]}); (#{expression}); end)"
62
+ end
42
63
  end
43
64
  end
44
65
  metadata.fetch(:branches).each do |branch|
@@ -101,7 +122,15 @@ module Branchproof
101
122
  "#{runtime}.flow_finish(#{decision_id.inspect}, (#{expression}), #{default_path.inspect}); ensure; " \
102
123
  "#{runtime}.leave(#{decision_id.inspect}); end; end)"
103
124
  end
125
+
126
+ def flow_assignment_frame(decision_id, expression, default_path: nil)
127
+ runtime = self.class::RUNTIME
128
+ "(begin; #{runtime}.enter(#{decision_id.inspect}); begin; " \
129
+ "#{runtime}.flow_assignment_finish(#{decision_id.inspect}, (#{expression}), " \
130
+ "#{default_path.inspect}); ensure; " \
131
+ "#{runtime}.leave(#{decision_id.inspect}); end; end)"
132
+ end
104
133
  end
105
134
  end
106
135
 
107
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
136
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/ModuleLength
@@ -238,6 +238,8 @@ module Branchproof
238
238
  lines << "Test: #{row[:name]}"
239
239
  lines << "Location: #{location(row[:relative_path], row[:line], unavailable: "location unavailable")}"
240
240
  lines << "Status: #{row[:status] || "unknown"}"
241
+ command = @coordinator.rerun_command(row[:id])
242
+ lines << "Rerun: #{command}" if command
241
243
  lines << "Phases: #{row[:phases].join(", ")}" unless row[:phases].empty?
242
244
  render_test_observations(lines, observations)
243
245
  lines << ""
@@ -341,6 +343,9 @@ module Branchproof
341
343
  return id.to_s unless test
342
344
 
343
345
  label = "#{test[:name]} (#{location(test[:relative_path], test[:line])})"
346
+ command = @coordinator.rerun_command(id)
347
+ return "#{label} (rerun: #{command})" if command
348
+
344
349
  duplicates = @test_name_counts[[test[:name], test[:relative_path], test[:line]]]
345
350
  duplicates > 1 ? "#{label} [#{id}]" : label
346
351
  end
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "flow_instrumentation"
4
+ require_relative "iteration_instrumentation"
5
+ require_relative "exception_instrumentation"
6
+ require_relative "default_instrumentation"
7
+ require_relative "value_instrumentation"
4
8
 
5
9
  module Branchproof
6
10
  # Applies the smallest possible source edits around inventoried expressions.
@@ -8,6 +12,10 @@ module Branchproof
8
12
  # never evaluates application code or introduces a Ruby scope.
9
13
  class Instrumenter
10
14
  include FlowInstrumentation
15
+ include IterationInstrumentation
16
+ include ExceptionInstrumentation
17
+ prepend DefaultInstrumentation
18
+ prepend ValueInstrumentation
11
19
 
12
20
  RUNTIME = "::Branchproof::Runtime"
13
21
 
@@ -54,8 +62,29 @@ module Branchproof
54
62
  def build_enclosures(decisions)
55
63
  encloses = {}.compare_by_identity
56
64
  enclosed = {}.compare_by_identity
57
- decisions.each do |outer|
58
- contained = decisions.select { |inner| encloses_decision?(outer, inner) }
65
+ indexed = decisions.each_index.group_by { |index| decisions[index][:byte_start] }
66
+ active = []
67
+ relations = Array.new(decisions.length) { [] }
68
+
69
+ indexed.sort_by { |start, _indices| start }.each do |start, indices|
70
+ active.reject! do |index|
71
+ decision = decisions[index]
72
+ decision[:byte_start] + decision[:byte_length] < start
73
+ end
74
+ active.concat(indices)
75
+ indices.each do |inner_index|
76
+ inner = decisions[inner_index]
77
+ active.each do |outer_index|
78
+ outer = decisions[outer_index]
79
+ next unless encloses_decision?(outer, inner)
80
+
81
+ relations[outer_index] << inner_index
82
+ end
83
+ end
84
+ end
85
+
86
+ decisions.each_with_index do |outer, outer_index|
87
+ contained = relations[outer_index].sort.map { |inner_index| decisions[inner_index] }
59
88
  encloses[outer] = contained
60
89
  contained.each { |inner| enclosed[inner] = true }
61
90
  end
@@ -89,7 +118,7 @@ module Branchproof
89
118
 
90
119
  chunks << bytes.byteslice(cursor, cstart - cursor)
91
120
  original = render_children(bytes, cstart, clen, nested, encloses)
92
- chunks << condition_wrapper(decision[:id], condition[:index], original)
121
+ chunks << condition_wrapper(decision, condition, original)
93
122
  cursor = cstart + clen
94
123
  end
95
124
  chunks << render_children(bytes, cursor, length - (cursor - start), nested, encloses)
@@ -131,8 +160,24 @@ module Branchproof
131
160
  end
132
161
  end
133
162
 
134
- def condition_wrapper(decision_id, index, expression)
135
- "#{RUNTIME}.condition(#{decision_id.inspect}, #{index}, (#{expression}))"
163
+ def condition_wrapper(decision, condition, expression)
164
+ return "(begin; #{expression}; end)" if nonlocal_transfer?(expression)
165
+
166
+ identifier = decision[:id].inspect
167
+ index = condition[:index]
168
+ case condition[:contextual]
169
+ when "implicit_regexp", "flip_flop"
170
+ # These operations require Ruby's conditional context: a bare regexp
171
+ # implicitly matches $_ and a flip-flop retains its state across calls.
172
+ "(if (#{expression}) then #{RUNTIME}.condition(#{identifier}, #{index}, true) " \
173
+ "else #{RUNTIME}.condition(#{identifier}, #{index}, false) end)"
174
+ else
175
+ "#{RUNTIME}.condition(#{identifier}, #{index}, (#{expression}))"
176
+ end
177
+ end
178
+
179
+ def nonlocal_transfer?(expression)
180
+ expression.lstrip.match?(/\A(?:return|break|next|redo|retry)\b/n)
136
181
  end
137
182
 
138
183
  def frame(decision_id, expression)