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
@@ -117,7 +117,8 @@ module Branchproof
117
117
  encoding),
118
118
  range: byte_range(candidate.location, splat_node?(candidate) ? "splat" : nil),
119
119
  byte_start: candidate.location.start_offset,
120
- byte_length: candidate.location.length
120
+ byte_length: candidate.location.length,
121
+ splat: splat_node?(candidate)
121
122
  }
122
123
  end
123
124
  branches << {
@@ -142,9 +143,6 @@ module Branchproof
142
143
  empty: statements_empty?(else_clause.statements) }
143
144
  end
144
145
  reasons = unsupported_reasons(node, bytes)
145
- reasons << "unsupported_case_splat" if candidates.any? do |candidate|
146
- candidate[:range] && candidate[:range][:kind] == "splat"
147
- end
148
146
  instrumentation = {
149
147
  type: "case",
150
148
  range: byte_range(node.location),
@@ -171,7 +169,6 @@ module Branchproof
171
169
  guarded = pattern.is_a?(Prism::IfNode) || pattern.is_a?(Prism::UnlessNode)
172
170
  guard = guarded ? pattern.predicate : nil
173
171
  pattern_node = guarded ? pattern.statements&.body&.first : pattern
174
- reasons << "unsupported_pattern_guard" if guard
175
172
  candidate_node = pattern_node || pattern
176
173
  candidates << {
177
174
  expression: text_value(bytes.byteslice(candidate_node.location.start_offset, candidate_node.location.length),
@@ -233,16 +230,28 @@ module Branchproof
233
230
  rhs = node.value
234
231
  operator = bytes.byteslice(node.operator_loc.start_offset, node.operator_loc.length)
235
232
  assignment_context = operator == "||=" ? "or_assignment" : "and_assignment"
236
- reasons = safe_navigation_assignment?(node) ? ["unsupported_assignment_target"] : []
233
+ safe_navigation = safe_navigation_assignment?(node)
234
+ reasons = []
237
235
  instrumentation = {
238
236
  type: "assignment",
239
237
  range: byte_range(node.location),
240
238
  rhs: byte_range(rhs.location),
241
239
  operator: operator,
242
240
  rhs_path: 1,
243
- skipped_path: 0
241
+ skipped_path: 0,
242
+ receiver: safe_navigation && byte_range(node.receiver.location),
243
+ alternative_count: safe_navigation ? 3 : 2
244
244
  }
245
- alternatives = if operator == "||="
245
+ alternatives = if safe_navigation
246
+ skipped = operator == "||=" ? "LHS truthy; RHS skipped" : "LHS falsey; RHS skipped"
247
+ executed = operator == "||=" ? "LHS falsey; RHS executed" : "LHS truthy; RHS executed"
248
+ [{ expression: "receiver nil", byte_start: node.receiver.location.start_offset,
249
+ byte_length: node.receiver.location.length },
250
+ { expression: skipped, byte_start: node.location.start_offset,
251
+ byte_length: node.location.length },
252
+ { expression: executed, byte_start: node.location.start_offset,
253
+ byte_length: node.location.length }]
254
+ elsif operator == "||="
246
255
  [{ expression: "LHS truthy; RHS skipped", byte_start: node.location.start_offset,
247
256
  byte_length: node.location.length },
248
257
  { expression: "LHS falsey; RHS executed", byte_start: node.location.start_offset,
@@ -253,7 +262,8 @@ module Branchproof
253
262
  { expression: "LHS truthy; RHS executed", byte_start: node.location.start_offset,
254
263
  byte_length: node.location.length }]
255
264
  end
256
- ["implicit", assignment_context, alternatives, instrumentation, unsupported_reasons(node, bytes) + reasons]
265
+ [safe_navigation ? "multiway" : "implicit", assignment_context, alternatives,
266
+ instrumentation, unsupported_reasons(node, bytes) + reasons]
257
267
  end
258
268
 
259
269
  def rescue_details(node, bytes, encoding)
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Keep source-boundary and parameter-binding rules together for auditing.
4
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ModuleLength, Metrics/PerceivedComplexity
5
+
6
+ module Branchproof
7
+ # Defaults stay inline in their original lexical scope. The owner decision
8
+ # makes body entry edits part of the same original AST edit tree as the
9
+ # parameter expression edits.
10
+ module DefaultInstrumentation
11
+ def rewrite(unit:)
12
+ decisions = Array(unit[:decisions])
13
+ defaults = decisions.select do |decision|
14
+ decision.dig(:instrumentation, :type) == "default" && supported?(decision)
15
+ end
16
+ return super unless defaults.any?
17
+
18
+ prepared = decisions.map do |decision|
19
+ next decision unless defaults.include?(decision)
20
+
21
+ instrumentation = decision[:instrumentation].merge(flag: default_flag(decision, unit[:original_bytes]))
22
+ decision.merge(instrumentation: instrumentation)
23
+ end
24
+ supported_ids = defaults.map { |decision| decision[:id] }
25
+ prepared_defaults = prepared.select do |decision|
26
+ decision.dig(:instrumentation, :type) == "default" && supported_ids.include?(decision[:id])
27
+ end
28
+ super(unit: unit.merge(decisions: owner_decisions(prepared, prepared_defaults)))
29
+ end
30
+
31
+ private
32
+
33
+ # An enclosing flow renderer can split a callable range at its own
34
+ # insertion point (for example an iteration callback inside a block). In
35
+ # that case the owner decision cannot be rendered as one child. Preserve
36
+ # the original offset by placing its supplied markers at the chunk that
37
+ # begins at the body entry.
38
+ def render_children(bytes, start, length, nested, encloses)
39
+ output = super
40
+ finish = start + length
41
+ crossing = nested.select do |child|
42
+ next false unless child.dig(:instrumentation, :type) == "default_owner"
43
+
44
+ owner = child.dig(:instrumentation, :owner)
45
+ owner && owner[:body_start] == start &&
46
+ (owner[:byte_start] < start || owner[:byte_start] + owner[:byte_length] > finish)
47
+ end
48
+ return output if crossing.empty?
49
+
50
+ marker = crossing.flat_map { |owner| owner_markers(owner, encloses[owner] || [], bytes) }.join
51
+ marker.empty? ? output : "#{marker}#{output}"
52
+ end
53
+
54
+ def encloses_decision?(outer, inner)
55
+ same_range = outer[:byte_start] == inner[:byte_start] && outer[:byte_length] == inner[:byte_length]
56
+ if same_range && inner.dig(:instrumentation, :type) == "default_owner"
57
+ return true unless outer.dig(:instrumentation, :type) == "default_owner"
58
+ elsif same_range && outer.dig(:instrumentation, :type) == "default_owner"
59
+ return false
60
+ end
61
+ super
62
+ end
63
+
64
+ def render_flow(bytes, decision, nested, encloses)
65
+ if decision.dig(:instrumentation, :type) == "default_owner"
66
+ return render_default_owner(bytes, decision, nested, encloses)
67
+ end
68
+ return super unless decision.dig(:instrumentation, :type) == "default"
69
+
70
+ value = decision.dig(:instrumentation, :value_range)
71
+ flag = decision.dig(:instrumentation, :flag) || default_flag(decision, bytes)
72
+ replacement = flow_replacement(bytes, value, nested, encloses) do |expression|
73
+ "(begin; #{flag} = true; #{self.class::RUNTIME}.default_binding(#{decision[:id].inspect}, 1); " \
74
+ "#{expression}; end)"
75
+ end
76
+ flow_fragments(bytes, decision, nested, [replacement], encloses)
77
+ end
78
+
79
+ def owner_decisions(decisions, defaults)
80
+ owners = defaults.group_by { |decision| decision.dig(:instrumentation, :owner) }
81
+ additions = owners.filter_map do |owner, grouped|
82
+ next unless owner
83
+
84
+ first = grouped.min_by { |decision| decision[:byte_start] }
85
+ next unless first
86
+
87
+ first.merge(
88
+ byte_start: owner[:byte_start], byte_length: owner[:byte_length],
89
+ expression: nil,
90
+ instrumentation: first[:instrumentation].merge(type: "default_owner")
91
+ )
92
+ end
93
+ (decisions + additions).sort_by { |decision| [decision[:byte_start], decision[:byte_length]] }
94
+ end
95
+
96
+ def render_default_owner(bytes, decision, nested, encloses)
97
+ owner = decision.dig(:instrumentation, :owner)
98
+ return super unless owner
99
+
100
+ bindings = nested.filter_map do |child|
101
+ next unless child.dig(:instrumentation, :type) == "default"
102
+ next unless child.dig(:instrumentation, :owner) == owner
103
+
104
+ [child.dig(:instrumentation, :flag) || default_flag(child, bytes), child[:id]]
105
+ end
106
+ marker = bindings.map do |flag, id|
107
+ "#{self.class::RUNTIME}.default_binding(#{id.inspect}, 0) unless #{flag}; "
108
+ end.join
109
+ return flow_fragments(bytes, decision, nested, [], encloses) if marker.empty?
110
+
111
+ start = owner[:body_start] || owner[:closing_start]
112
+ finish = (owner[:body_start] + owner[:body_length] if owner[:equal] && owner[:body_start] && owner[:body_length])
113
+ replacements = if finish
114
+ [{ start: start, length: 0, text: "(begin; #{marker}" },
115
+ { start: finish, length: 0, text: "; end)" }]
116
+ else
117
+ [{ start: start, length: 0, text: marker }]
118
+ end
119
+ flow_fragments(bytes, decision, nested, replacements, encloses)
120
+ end
121
+
122
+ def owner_markers(owner, nested, bytes)
123
+ nested.filter_map do |child|
124
+ next unless child.dig(:instrumentation, :type) == "default"
125
+ next unless child.dig(:instrumentation, :owner) == owner.dig(:instrumentation, :owner)
126
+
127
+ flag = child.dig(:instrumentation, :flag) || default_flag(child, bytes)
128
+ "#{self.class::RUNTIME}.default_binding(#{child[:id].inspect}, 0) unless #{flag}; "
129
+ end
130
+ end
131
+
132
+ def default_flag(decision, bytes)
133
+ name = "__branchproof_default_#{decision[:id]}"
134
+ name += "_" while bytes.include?(name)
135
+ name
136
+ end
137
+ end
138
+ end
139
+
140
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ModuleLength, Metrics/PerceivedComplexity
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Branchproof
4
+ # Binding events are immediate; no pending state survives a failed default.
5
+ module DefaultRuntime
6
+ def default_binding(decision_id, index)
7
+ enter(decision_id)
8
+ begin
9
+ flow_path(decision_id, index)
10
+ ensure
11
+ leave(decision_id)
12
+ end
13
+ nil
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Branchproof
4
+ # Each optional parameter has an independent supplied/defaulted obligation.
5
+ module DefaultSyntax
6
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
7
+
8
+ private
9
+
10
+ def flow_decision_node?(node)
11
+ @default_owner_by_node = {}.compare_by_identity if node.is_a?(Prism::ProgramNode)
12
+ register_default_owner(node) if default_owner_node?(node)
13
+ default_parameter_node?(node) || super
14
+ end
15
+
16
+ def flow_details(node, bytes, encoding)
17
+ return super unless default_parameter_node?(node)
18
+
19
+ value = node.value
20
+ alternatives = [
21
+ { expression: "#{node.name} supplied", byte_start: node.location.start_offset,
22
+ byte_length: node.location.length },
23
+ { expression: "#{node.name} default evaluated", byte_start: value.location.start_offset,
24
+ byte_length: value.location.length }
25
+ ]
26
+ owner = @default_owner_by_node && @default_owner_by_node[node]
27
+ metadata = { type: "default", value_range: byte_range(value.location) }
28
+ metadata[:owner] = owner if owner
29
+ ["implicit", "default_argument", alternatives, metadata, unsupported_reasons(node, bytes)]
30
+ end
31
+
32
+ def register_default_owner(owner)
33
+ @default_owner_by_node ||= {}.compare_by_identity
34
+ parameters = owner.parameters if owner.respond_to?(:parameters)
35
+ parameters = parameters.parameters if parameters.is_a?(Prism::BlockParametersNode)
36
+ return unless parameters.is_a?(Prism::ParametersNode)
37
+
38
+ details = default_owner_details(owner)
39
+ (Array(parameters.optionals) + Array(parameters.keywords)).each do |parameter|
40
+ next unless default_parameter_node?(parameter)
41
+
42
+ @default_owner_by_node[parameter] = details
43
+ end
44
+ end
45
+
46
+ def default_parameter_node?(node)
47
+ node.is_a?(Prism::OptionalParameterNode) || node.is_a?(Prism::OptionalKeywordParameterNode)
48
+ end
49
+
50
+ def default_owner_node?(node)
51
+ node.is_a?(Prism::DefNode) || node.is_a?(Prism::LambdaNode) || node.is_a?(Prism::BlockNode)
52
+ end
53
+
54
+ def default_owner_details(owner)
55
+ return nil unless owner
56
+
57
+ # Only endless `def name(...) = expr` has a body that can be wrapped as
58
+ # one expression. Lambda/block `closing_loc` values are delimiters, not
59
+ # endless-definition markers, and must stay on the statement path.
60
+ equal_loc = owner.is_a?(Prism::DefNode) && owner.equal_loc
61
+ closing = if owner.is_a?(Prism::DefNode)
62
+ owner.end_keyword_loc
63
+ else
64
+ owner.closing_loc
65
+ end
66
+ body_start, body_length = default_owner_body_span(owner, closing)
67
+ details = { byte_start: owner.location.start_offset, byte_length: owner.location.length,
68
+ equal: equal_loc ? true : false, body_start: body_start,
69
+ body_length: body_length, closing_start: closing&.start_offset }
70
+ details.freeze
71
+ end
72
+
73
+ # Prism represents a method-level `rescue`/`ensure` as an implicit
74
+ # BeginNode whose location covers the whole def. Its insertion point is
75
+ # the actual statements (or rescue/ensure clause), not the def keyword.
76
+ def default_owner_body_span(owner, closing)
77
+ body = owner.body
78
+ if body.is_a?(Prism::BeginNode) && body.begin_keyword_loc.nil?
79
+ statements = body.statements
80
+ start = implicit_begin_start(statements, body, closing)
81
+ finish = closing&.start_offset || (body.location.start_offset + body.location.length)
82
+ return [start, start && finish ? [finish - start, 0].max : nil]
83
+ end
84
+
85
+ [body&.location&.start_offset, body&.location&.length]
86
+ end
87
+
88
+ def implicit_begin_start(statements, body, closing)
89
+ [statements&.location&.start_offset, body.rescue_clause&.location&.start_offset,
90
+ body.ensure_clause&.location&.start_offset, closing&.start_offset].compact.first
91
+ end
92
+ end
93
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
94
+ end
@@ -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 })
@@ -167,6 +174,7 @@ module Branchproof
167
174
  @abort_counts.merge!(incoming.fetch(:abort_counts, {})) { |_k, a, b| a.to_i + b.to_i }
168
175
  @limited ||= !incoming.dig(:completeness, :observation)
169
176
  @attribution_complete &&= incoming.dig(:completeness, :attribution) ? true : false
177
+ @repetition_cache.clear
170
178
  status("merged", nil)
171
179
  rescue StandardError => e
172
180
  restore_state(backup) if backup
@@ -518,7 +526,8 @@ module Branchproof
518
526
  { vectors: deep_dup(@vectors), tests: deep_dup(@tests), run_ids: @run_ids.dup,
519
527
  run_payloads: @run_payloads.dup, diagnostics: deep_dup(@diagnostics),
520
528
  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 }
529
+ vector_counts_by_decision: @vector_counts_by_decision.dup, owner_associations_count: @owner_associations_count,
530
+ repetition_cache: deep_dup(@repetition_cache) }
522
531
  end
523
532
 
524
533
  def restore_state(state)
@@ -530,10 +539,83 @@ module Branchproof
530
539
  @abort_counts = state[:abort_counts]
531
540
  @vector_counts_by_decision = state[:vector_counts_by_decision]
532
541
  @owner_associations_count = state[:owner_associations_count]
542
+ @repetition_cache = state[:repetition_cache]
533
543
  @limited = state[:limited]
534
544
  @attribution_complete = state[:attribution_complete]
535
545
  end
536
546
 
547
+ def cached_execution(execution)
548
+ return nil unless execution.is_a?(Hash)
549
+
550
+ key = raw_execution_cache_key(execution)
551
+ return nil unless key
552
+
553
+ entry = @repetition_cache[key[1]]
554
+ return nil unless entry && entry[:key] == key
555
+
556
+ vector = @vectors[entry[:vector_id]]
557
+ return nil unless vector
558
+
559
+ [key, vector, raw_execution_attribution(execution)]
560
+ end
561
+
562
+ def record_cached(cached)
563
+ _key, vector, value = cached
564
+ vector[:count] += 1
565
+ if value[:test_id] && @tests.key?(value[:test_id].to_s)
566
+ test = @tests[value[:test_id].to_s]
567
+ phase = value[:phase].to_s
568
+ test[:phase_counts][phase] = test[:phase_counts].fetch(phase, 0) + 1
569
+ end
570
+ vector[:unattributed_count] += 1 unless value[:test_id]
571
+ status("recorded", nil)
572
+ end
573
+
574
+ def raw_execution_cache_key(execution)
575
+ required = %i[run_id decision_id test_id phase observations outcome status]
576
+ return nil unless required.all? { |key| execution.key?(key) || execution.key?(key.to_s) }
577
+
578
+ status = raw_value(execution, :status)
579
+ outcome = raw_value(execution, :outcome)
580
+ return nil unless status.to_s == "completed" && [true, false].include?(outcome)
581
+
582
+ observations = raw_value(execution, :observations)
583
+ return nil unless observations.is_a?(Array)
584
+
585
+ test_id = raw_value(execution, :test_id)
586
+ [raw_value(execution, :run_id).to_s, raw_value(execution, :decision_id).to_s,
587
+ test_id_marker(test_id), test_id&.to_s, raw_value(execution, :phase).to_s,
588
+ observations, outcome, status.to_s]
589
+ end
590
+
591
+ def execution_cache_key(value)
592
+ key = [copy_string(value[:run_id]), copy_string(value[:decision_id]), test_id_marker(value[:test_id]),
593
+ copy_string(value[:test_id]),
594
+ copy_string(value[:phase]), deep_dup(value[:observations]), value[:outcome], copy_string(value[:status])]
595
+ deep_freeze(key)
596
+ end
597
+
598
+ def raw_execution_attribution(execution)
599
+ { test_id: raw_value(execution, :test_id), phase: raw_value(execution, :phase) }
600
+ end
601
+
602
+ def copy_string(value)
603
+ value.nil? ? nil : value.to_s.dup
604
+ end
605
+
606
+ def test_id_marker(value)
607
+ return :nil if value.nil?
608
+ return :false_test_id if value == false
609
+
610
+ :present
611
+ end
612
+
613
+ def raw_value(hash, key)
614
+ return hash[key] if hash.key?(key)
615
+
616
+ hash[key.to_s]
617
+ end
618
+
537
619
  def deep_freeze(value)
538
620
  case value
539
621
  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