branchproof 0.8.0 → 0.9.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +18 -0
  3. data/README.md +43 -10
  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/ExceptionInstrumentation.md +9 -0
  8. data/doc/Branchproof/ExceptionRuntime.md +27 -0
  9. data/doc/Branchproof/ExceptionSyntax.md +9 -0
  10. data/doc/Branchproof/ExtendedAlternativeRuntime.md +19 -0
  11. data/doc/Branchproof/Instrumenter.md +1 -1
  12. data/doc/Branchproof/IterationInstrumentation.md +7 -0
  13. data/doc/Branchproof/IterationRuntime.md +21 -0
  14. data/doc/Branchproof/IterationSyntax.md +11 -0
  15. data/doc/Branchproof/Runtime.md +56 -1
  16. data/doc/Branchproof/Source.md +7 -1
  17. data/doc/Branchproof/ValueInstrumentation.md +7 -0
  18. data/doc/Branchproof/ValueRuntime.md +26 -0
  19. data/doc/Branchproof/ValueSyntax.md +26 -0
  20. data/doc/Branchproof.md +17 -3
  21. data/doc/CHANGELOG.md +18 -0
  22. data/doc/README.md +43 -10
  23. data/lib/branchproof/decision_syntax.rb +19 -9
  24. data/lib/branchproof/default_instrumentation.rb +140 -0
  25. data/lib/branchproof/default_runtime.rb +16 -0
  26. data/lib/branchproof/default_syntax.rb +94 -0
  27. data/lib/branchproof/evidence.rb +83 -1
  28. data/lib/branchproof/exception_instrumentation.rb +96 -0
  29. data/lib/branchproof/exception_runtime.rb +41 -0
  30. data/lib/branchproof/exception_syntax.rb +157 -0
  31. data/lib/branchproof/extended_alternative_runtime.rb +23 -0
  32. data/lib/branchproof/flow_instrumentation.rb +36 -7
  33. data/lib/branchproof/instrumenter.rb +50 -5
  34. data/lib/branchproof/iteration_instrumentation.rb +66 -0
  35. data/lib/branchproof/iteration_runtime.rb +79 -0
  36. data/lib/branchproof/iteration_syntax.rb +75 -0
  37. data/lib/branchproof/loader.rb +6 -1
  38. data/lib/branchproof/runtime.rb +10 -0
  39. data/lib/branchproof/source.rb +59 -32
  40. data/lib/branchproof/value_instrumentation.rb +35 -0
  41. data/lib/branchproof/value_runtime.rb +73 -0
  42. data/lib/branchproof/value_syntax.rb +116 -0
  43. data/lib/branchproof/version.rb +1 -1
  44. data/llms.txt +17 -3
  45. data/sig/branchproof.rbs +17 -0
  46. metadata +27 -1
@@ -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
@@ -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)
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Branchproof
4
+ # Keep matching and iteration in the original lexical scope.
5
+ module IterationInstrumentation
6
+ private
7
+
8
+ # rubocop:disable Metrics/MethodLength
9
+ def render_flow(bytes, decision, nested, encloses)
10
+ metadata = decision.fetch(:instrumentation)
11
+ case metadata[:type]
12
+ when "iteration"
13
+ render_iteration(bytes, decision, nested, encloses)
14
+ when "required_pattern"
15
+ expression = render_children(bytes, decision[:byte_start], decision[:byte_length], nested, encloses)
16
+ runtime = self.class::RUNTIME
17
+ identifier = decision[:id].inspect
18
+ "(begin; #{runtime}.enter(#{identifier}); begin; #{expression}; " \
19
+ "#{runtime}.flow_select(#{identifier}, 0); nil; rescue ::NoMatchingPatternError; " \
20
+ "#{runtime}.flow_select(#{identifier}, 1); raise; ensure; #{runtime}.leave(#{identifier}); end; end)"
21
+ else
22
+ super
23
+ end
24
+ end
25
+
26
+ # rubocop:enable Metrics/MethodLength
27
+
28
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- nested exception callback seam
29
+ def render_iteration(bytes, decision, nested, encloses)
30
+ metadata = decision.fetch(:instrumentation)
31
+ identifier = decision[:id].inspect
32
+ runtime = self.class::RUNTIME
33
+ alternative_count = metadata[:lazy] ? 1 : 2
34
+ marker = "#{runtime}.flow_iteration_callback(#{identifier}, #{alternative_count})"
35
+ suffix = metadata[:empty] ? "nil; " : ""
36
+ callback = "; #{marker}; #{suffix}"
37
+ exception_child = nested.find do |child|
38
+ child[:instrumentation]&.fetch(:type, nil) == "exception" &&
39
+ child[:instrumentation].fetch(:implicit, false) &&
40
+ contains?(child[:byte_start], child[:byte_length], metadata[:insert_at], 0)
41
+ end
42
+ if exception_child
43
+ instrumentation = exception_child.fetch(:instrumentation)
44
+ callback_child = exception_child.merge(instrumentation: instrumentation.merge(
45
+ iteration_callback: { insert_at: metadata[:insert_at], text: callback }
46
+ ))
47
+ nested = nested.map { |child| child.equal?(exception_child) ? callback_child : child }
48
+ encloses = encloses.merge(callback_child => encloses[exception_child])
49
+ end
50
+ edits = exception_child ? [] : [{ start: metadata[:insert_at], length: 0, text: callback }]
51
+ if metadata[:receiver]
52
+ receiver = metadata.fetch(:receiver)
53
+ edits << flow_replacement(bytes, receiver, nested, encloses) do |expression|
54
+ "#{runtime}.flow_iteration_begin(#{identifier}, (#{expression}), #{alternative_count})"
55
+ end
56
+ end
57
+ expression = flow_fragments(bytes, decision, nested, edits, encloses)
58
+ if metadata[:receiver]
59
+ "(begin; begin; #{runtime}.flow_iteration_finish(#{identifier}, (#{expression}), 0); ensure; " \
60
+ "#{runtime}.flow_iteration_leave(#{identifier}); end; end)"
61
+ else
62
+ flow_frame(decision[:id], expression, default_path: 0)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Branchproof
4
+ # Runtime support for callback based iteration, including lazy receivers
5
+ # whose callbacks execute after the constructing call has returned.
6
+ module IterationRuntime
7
+ def flow_iteration_begin(decision_id, receiver, alternative_count = 2)
8
+ # Case equality bypasses an application's overridden `is_a?` method.
9
+ # rubocop:disable-next Style/CaseEquality
10
+ return receiver if alternative_count == 1 || ::Enumerator::Lazy === receiver
11
+
12
+ enter(decision_id)
13
+ receiver
14
+ end
15
+
16
+ # rubocop:disable-next Metrics/MethodLength
17
+ def flow_iteration_callback(decision_id, alternative_count = 2)
18
+ frame = iteration_frame(decision_id)
19
+ if frame
20
+ return nil if frame[:finished]
21
+
22
+ iteration_select(frame, alternative_count.to_i == 1 ? 0 : 1, alternative_count)
23
+ else
24
+ enter(decision_id)
25
+ begin
26
+ if alternative_count.to_i == 1
27
+ flow_select(decision_id, 0)
28
+ else
29
+ flow_path(decision_id, 1)
30
+ end
31
+ ensure
32
+ leave(decision_id)
33
+ end
34
+ end
35
+ nil
36
+ end
37
+
38
+ def flow_iteration_finish(decision_id, value, default_path = nil)
39
+ frame = iteration_frame(decision_id)
40
+ iteration_select(frame, default_path, 2) if frame && !frame[:finished] && !default_path.nil?
41
+ value
42
+ end
43
+
44
+ def flow_iteration_leave(decision_id)
45
+ frame = iteration_frame(decision_id)
46
+ frames = Thread.current[Branchproof::Runtime::FRAME_STATE_KEY]&.fetch(:frames, nil)
47
+ return nil unless frame && frames&.last.equal?(frame)
48
+
49
+ leave(decision_id)
50
+ end
51
+
52
+ private
53
+
54
+ # `current_frame` deliberately latches a diagnostic when a helper probes
55
+ # while another decision is active. Iteration callbacks may be deferred or
56
+ # nested, so an absent matching frame is an ordinary state here.
57
+ def iteration_frame(decision_id)
58
+ frames = Thread.current[Branchproof::Runtime::FRAME_STATE_KEY]&.fetch(:frames, nil)
59
+ index = frames ? frames.length - 1 : -1
60
+ while index >= 0
61
+ frame = frames[index]
62
+ return frame if frame[:decision_id] == decision_id
63
+
64
+ index -= 1
65
+ end
66
+ nil
67
+ end
68
+
69
+ def iteration_select(frame, index, alternative_count)
70
+ frame[:observations] = if alternative_count == 1
71
+ [[0, true]]
72
+ else
73
+ [[0, index.zero?], [1, index == 1]]
74
+ end
75
+ frame[:outcome] = true
76
+ frame[:finished] = true
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Branchproof
4
+ # Observe entry into source-visible callback bodies, not library internals.
5
+ module IterationSyntax
6
+ ITERATORS = %i[each each_with_index times upto downto step map collect select filter reject filter_map
7
+ find detect find_index take_while drop_while partition count all? any? none? one? fetch].freeze
8
+
9
+ private
10
+
11
+ def flow_decision_node?(node)
12
+ iteration_node?(node) || node.is_a?(Prism::MatchRequiredNode) || super
13
+ end
14
+
15
+ def iteration_node?(node)
16
+ node.is_a?(Prism::ForNode) ||
17
+ (node.is_a?(Prism::CallNode) && ITERATORS.include?(node.name) &&
18
+ node.block.is_a?(Prism::BlockNode) && !node.safe_navigation?)
19
+ end
20
+
21
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
22
+ def flow_details(node, bytes, encoding)
23
+ if node.is_a?(Prism::MatchRequiredNode)
24
+ alternatives = %w[matched mismatch].map { |label| iteration_alternative(node, label) }
25
+ return ["pattern", "required_pattern", alternatives,
26
+ { type: "required_pattern", range: byte_range(node.location) }, unsupported_reasons(node, bytes)]
27
+ end
28
+ return super unless iteration_node?(node)
29
+
30
+ body = node.is_a?(Prism::ForNode) ? node.statements : node.block.body
31
+ closing = node.is_a?(Prism::ForNode) ? node.end_keyword_loc : node.block.closing_loc
32
+ lazy = node.is_a?(Prism::CallNode) && lazy_receiver?(node.receiver)
33
+ fallback = node.is_a?(Prism::CallNode) && node.name == :fetch
34
+ context = if lazy
35
+ "lazy_callback"
36
+ else
37
+ (fallback ? "fetch_fallback" : "iteration")
38
+ end
39
+ labels = if lazy
40
+ ["callback entered"]
41
+ else
42
+ (fallback ? ["value present", "fallback entered"] : %w[empty entered])
43
+ end
44
+ metadata = { type: "iteration", range: byte_range(node.location), lazy: lazy,
45
+ insert_at: iteration_insert_at(body, closing),
46
+ empty: body.nil? }
47
+ metadata[:receiver] = byte_range(node.receiver.location) if node.is_a?(Prism::CallNode) && node.receiver
48
+ [lazy ? "multiway" : "implicit", context, labels.map { |label| iteration_alternative(node, label) },
49
+ metadata, unsupported_reasons(node, bytes)]
50
+ end
51
+
52
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
53
+
54
+ def lazy_receiver?(node)
55
+ node.is_a?(Prism::CallNode) && (node.name == :lazy || lazy_receiver?(node.receiver))
56
+ end
57
+
58
+ # Prism gives a block-level rescue/ensure BeginNode a location beginning at
59
+ # the `do` keyword. Inserting there would produce `call; callback; do`, so
60
+ # anchor the callback after the block parameters and before its statements.
61
+ def iteration_insert_at(body, closing)
62
+ return closing.start_offset unless body
63
+
64
+ if body.is_a?(Prism::BeginNode)
65
+ statements = body.statements
66
+ return statements.location.start_offset if statements&.location
67
+ end
68
+ body.location.start_offset
69
+ end
70
+
71
+ def iteration_alternative(node, label)
72
+ { expression: label, byte_start: node.location.start_offset, byte_length: node.location.length }
73
+ end
74
+ end
75
+ end
@@ -47,7 +47,12 @@ module Branchproof
47
47
  add_diagnostic("source_drift", "selected source changed after inventory", unit[:source_id])
48
48
  return nil
49
49
  end
50
- rewritten = @instrumenter.rewrite(unit: unit.merge(original_bytes: bytes))
50
+ begin
51
+ rewritten = @instrumenter.rewrite(unit: unit.merge(original_bytes: bytes))
52
+ rescue StandardError => e
53
+ add_diagnostic("rewrite_failure", "#{e.class}: #{e.message}", unit[:source_id], severity: "error")
54
+ return nil
55
+ end
51
56
  Array(rewritten[:diagnostics]).each do |diagnostic|
52
57
  add_diagnostic(diagnostic[:code], diagnostic[:message], unit[:source_id], severity: diagnostic[:severity])
53
58
  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