branchproof 0.7.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/README.md +255 -21
  4. data/doc/Branchproof/Analyzer.md +2 -2
  5. data/doc/Branchproof/CLI.md +4 -0
  6. data/doc/Branchproof/ComparisonReport.md +4 -0
  7. data/doc/Branchproof/Constraints/Solver.md +21 -0
  8. data/doc/Branchproof/Constraints.md +98 -0
  9. data/doc/Branchproof/CoverageIndex.md +3 -0
  10. data/doc/Branchproof/DecisionSyntax.md +6 -4
  11. data/doc/Branchproof/DecisionTable.md +153 -0
  12. data/doc/Branchproof/DefaultInstrumentation.md +13 -0
  13. data/doc/Branchproof/DefaultRuntime.md +11 -0
  14. data/doc/Branchproof/DefaultSyntax.md +7 -0
  15. data/doc/Branchproof/ExceptionInstrumentation.md +9 -0
  16. data/doc/Branchproof/ExceptionRuntime.md +27 -0
  17. data/doc/Branchproof/ExceptionSyntax.md +9 -0
  18. data/doc/Branchproof/ExtendedAlternativeRuntime.md +19 -0
  19. data/doc/Branchproof/FocusedReport.md +5 -2
  20. data/doc/Branchproof/Instrumenter.md +1 -1
  21. data/doc/Branchproof/IterationInstrumentation.md +7 -0
  22. data/doc/Branchproof/IterationRuntime.md +21 -0
  23. data/doc/Branchproof/IterationSyntax.md +11 -0
  24. data/doc/Branchproof/Loader.md +0 -4
  25. data/doc/Branchproof/MinitestAdapter.md +0 -3
  26. data/doc/Branchproof/Report.md +18 -0
  27. data/doc/Branchproof/Runtime.md +60 -1
  28. data/doc/Branchproof/SavedReport.md +15 -0
  29. data/doc/Branchproof/Source.md +7 -1
  30. data/doc/Branchproof/ValueInstrumentation.md +7 -0
  31. data/doc/Branchproof/ValueRuntime.md +26 -0
  32. data/doc/Branchproof/ValueSyntax.md +26 -0
  33. data/doc/Branchproof.md +20 -3
  34. data/doc/CHANGELOG.md +53 -0
  35. data/doc/README.md +255 -21
  36. data/lib/branchproof/analyzer.rb +80 -44
  37. data/lib/branchproof/cli.rb +28 -19
  38. data/lib/branchproof/comparison.rb +207 -19
  39. data/lib/branchproof/comparison_report.rb +49 -1
  40. data/lib/branchproof/constraints.rb +363 -0
  41. data/lib/branchproof/coverage_index.rb +30 -1
  42. data/lib/branchproof/decision_syntax.rb +50 -24
  43. data/lib/branchproof/decision_table.rb +377 -0
  44. data/lib/branchproof/default_instrumentation.rb +140 -0
  45. data/lib/branchproof/default_runtime.rb +16 -0
  46. data/lib/branchproof/default_syntax.rb +94 -0
  47. data/lib/branchproof/evidence.rb +133 -31
  48. data/lib/branchproof/exception_instrumentation.rb +96 -0
  49. data/lib/branchproof/exception_runtime.rb +41 -0
  50. data/lib/branchproof/exception_syntax.rb +157 -0
  51. data/lib/branchproof/extended_alternative_runtime.rb +23 -0
  52. data/lib/branchproof/flow_instrumentation.rb +52 -23
  53. data/lib/branchproof/focused_report.rb +77 -9
  54. data/lib/branchproof/instrumenter.rb +93 -37
  55. data/lib/branchproof/iteration_instrumentation.rb +66 -0
  56. data/lib/branchproof/iteration_runtime.rb +79 -0
  57. data/lib/branchproof/iteration_syntax.rb +75 -0
  58. data/lib/branchproof/limits.rb +4 -1
  59. data/lib/branchproof/loader.rb +24 -7
  60. data/lib/branchproof/minimizer.rb +18 -13
  61. data/lib/branchproof/minitest_adapter.rb +11 -16
  62. data/lib/branchproof/records.rb +2 -0
  63. data/lib/branchproof/report.rb +301 -68
  64. data/lib/branchproof/runtime.rb +32 -30
  65. data/lib/branchproof/saved_report.rb +200 -14
  66. data/lib/branchproof/source.rb +140 -71
  67. data/lib/branchproof/value_instrumentation.rb +35 -0
  68. data/lib/branchproof/value_runtime.rb +73 -0
  69. data/lib/branchproof/value_syntax.rb +116 -0
  70. data/lib/branchproof/version.rb +1 -1
  71. data/lib/branchproof/worker.rb +1 -4
  72. data/lib/branchproof.rb +2 -0
  73. data/llms.txt +20 -3
  74. data/sig/branchproof.rbs +64 -1
  75. metadata +32 -1
@@ -20,14 +20,21 @@ module Branchproof
20
20
  raise ArgumentError, "run_id is required" if run_id.nil? || run_id.to_s.empty?
21
21
 
22
22
  @inventory = inventory
23
+ @decisions_by_id = Array(fetch_value(@inventory, :decisions)).each_with_object({}) do |raw, index|
24
+ decision = symbolize(raw)
25
+ index[decision[:id].to_s] = decision
26
+ end
23
27
  @limits = normalize_limits(limits)
24
28
  @run_id = run_id.to_s
25
29
  @run_ids = [@run_id]
26
30
  @vectors = {}
31
+ @vector_counts_by_decision = Hash.new(0)
32
+ @owner_associations_count = 0
27
33
  @tests = {}
28
34
  @run_payloads = {}
29
35
  @abort_counts = Hash.new(0)
30
36
  @diagnostics = []
37
+ @repetition_cache = {}
31
38
  @limited = false
32
39
  @attribution_complete = true
33
40
  end
@@ -49,6 +56,10 @@ module Branchproof
49
56
  end
50
57
 
51
58
  def record(execution:)
59
+ if (cached = cached_execution(execution))
60
+ return record_cached(cached)
61
+ end
62
+
52
63
  value = symbolize(execution)
53
64
  reason = validate_execution(value)
54
65
  return reject_record(reason) if reason
@@ -68,12 +79,13 @@ module Branchproof
68
79
  end
69
80
  decision_id = value[:decision_id].to_s
70
81
  vector_values = condition_values(decision_id, value[:observations])
71
- if new_vector?(decision_id, vector_values,
72
- value[:outcome]) && vector_count(decision_id) >= @limits[:vectors_per_decision]
82
+ outcome = value[:outcome] ? true : false
83
+ vector_id = Branchproof::Records.id([decision_id, vector_values, outcome])
84
+ if new_vector?(vector_id) && vector_count(decision_id) >= @limits[:vectors_per_decision]
73
85
  @limited = true
74
86
  return status("limited", "vectors_per_decision reached")
75
87
  end
76
- vector = vector_for(decision_id, vector_values, value[:outcome] ? true : false)
88
+ vector = vector_for(decision_id, vector_values, outcome, vector_id)
77
89
  new_owner = if value[:test_id]
78
90
  !vector[:test_ids].include?(value[:test_id].to_s)
79
91
  else
@@ -89,11 +101,14 @@ module Branchproof
89
101
  phase_map << value[:phase].to_s if phase_map && !phase_map.include?(value[:phase].to_s)
90
102
  vector[:unattributed_count] += 1 unless value[:test_id]
91
103
  vector[:count] += 1
104
+ @owner_associations_count += 1 if new_owner
92
105
  if value[:test_id] && @tests.key?(value[:test_id].to_s)
93
106
  test = @tests[value[:test_id].to_s]
94
107
  phase = value[:phase].to_s
95
108
  test[:phase_counts][phase] = test[:phase_counts].fetch(phase, 0) + 1
96
109
  end
110
+ cache_key = execution_cache_key(value)
111
+ @repetition_cache[cache_key[1]] = { key: cache_key, vector_id: vector_id }
97
112
  status("recorded", nil)
98
113
  rescue StandardError => e
99
114
  diagnose(diagnostic: { code: "record_failure", severity: "error", message: e.message })
@@ -159,6 +174,7 @@ module Branchproof
159
174
  @abort_counts.merge!(incoming.fetch(:abort_counts, {})) { |_k, a, b| a.to_i + b.to_i }
160
175
  @limited ||= !incoming.dig(:completeness, :observation)
161
176
  @attribution_complete &&= incoming.dig(:completeness, :attribution) ? true : false
177
+ @repetition_cache.clear
162
178
  status("merged", nil)
163
179
  rescue StandardError => e
164
180
  restore_state(backup) if backup
@@ -194,18 +210,17 @@ module Branchproof
194
210
  def validate_execution(value)
195
211
  return "execution must be a hash" unless value.is_a?(Hash)
196
212
 
197
- %i[run_id execution_id decision_id test_id phase owner observations outcome status].each do |key|
213
+ %i[run_id decision_id test_id phase observations outcome status].each do |key|
198
214
  return "missing #{key}" unless value.key?(key)
199
215
  end
200
216
  return "run mismatch" unless value[:run_id].to_s == @run_id
201
- return "invalid owner" unless value[:owner].is_a?(Hash)
202
217
  return "invalid status" unless %w[completed aborted invalid].include?(value[:status].to_s)
203
218
  return "invalid phase" unless %w[setup body teardown suite unattributed].include?(value[:phase].to_s)
204
219
  return "invalid observations" unless value[:observations].is_a?(Array) && value[:observations].all? do |pair|
205
220
  pair.is_a?(Array) && pair.length == 2 && pair[0].is_a?(Integer) && [true, false].include?(pair[1])
206
221
  end
207
222
 
208
- decision = decisions.find { |d| d[:id].to_s == value[:decision_id].to_s }
223
+ decision = @decisions_by_id[value[:decision_id].to_s]
209
224
  return "unknown decision" unless decision
210
225
 
211
226
  dimensions = alternative_decision?(decision) ? Array(decision[:alternatives]) : Array(decision[:conditions])
@@ -224,7 +239,7 @@ module Branchproof
224
239
  def valid_trace?(decision, observations, outcome)
225
240
  return valid_alternative_trace?(decision, observations, outcome) if alternative_decision?(decision)
226
241
 
227
- tree = symbolize(decision[:tree])
242
+ tree = decision[:tree]
228
243
  unless tree
229
244
  return observations.map(&:first) == observations.map(&:first).sort &&
230
245
  (outcome ? true : false) == evaluate_fallback(observations)
@@ -239,7 +254,6 @@ module Branchproof
239
254
  end
240
255
 
241
256
  def replay_tree(node, observations, cursor)
242
- node = symbolize(node)
243
257
  type = node[:type].to_s
244
258
  if type == "atom"
245
259
  pair = observations[cursor]
@@ -280,26 +294,42 @@ module Branchproof
280
294
  values
281
295
  end
282
296
 
283
- def vector_for(decision_id, values, outcome)
284
- id = Branchproof::Records.id([decision_id, values, outcome])
285
- @vectors[id] ||= { id: id, decision_id: decision_id, values: values.dup, outcome: outcome,
286
- test_ids: [], phases_by_test: {}, unattributed_count: 0, count: 0 }
297
+ def vector_for(decision_id, values, outcome, id)
298
+ existing = @vectors[id]
299
+ return existing if existing
300
+
301
+ vector = { id: id, decision_id: decision_id, values: values.dup, outcome: outcome,
302
+ test_ids: [], phases_by_test: {}, unattributed_count: 0, count: 0 }
303
+ @vectors[id] = vector
304
+ @vector_counts_by_decision[decision_id] += 1
305
+ vector
287
306
  end
288
307
 
289
308
  def merge_vector(raw)
290
309
  vector = symbolize(raw)
291
- existing = @vectors[vector[:id].to_s]
310
+ id = vector[:id].to_s
311
+ existing = @vectors[id]
292
312
  if existing
293
- existing[:test_ids] |= Array(vector[:test_ids]).map(&:to_s)
313
+ new_test_ids = Array(vector[:test_ids]).map(&:to_s) - existing[:test_ids]
314
+ existing[:test_ids] |= new_test_ids
294
315
  vector.fetch(:phases_by_test, {}).each do |test, phases|
295
316
  existing[:phases_by_test][test.to_s] = (existing[:phases_by_test][test.to_s] || []) | phases
296
317
  end
318
+ was_unattributed = existing[:unattributed_count].positive?
297
319
  existing[:count] += vector[:count].to_i
298
320
  existing[:unattributed_count] += vector[:unattributed_count].to_i
321
+ @owner_associations_count += new_test_ids.length
322
+ @owner_associations_count += 1 if !was_unattributed && existing[:unattributed_count].positive?
299
323
  else
300
- @vectors[vector[:id].to_s] = { id: vector[:id].to_s, decision_id: vector[:decision_id].to_s,
301
- values: Array(vector[:values]).dup, outcome: !!vector[:outcome], test_ids: Array(vector[:test_ids]).map(&:to_s),
302
- phases_by_test: vector.fetch(:phases_by_test, {}).transform_keys(&:to_s), unattributed_count: vector[:unattributed_count].to_i, count: vector[:count].to_i }
324
+ decision_id = vector[:decision_id].to_s
325
+ test_ids = Array(vector[:test_ids]).map(&:to_s)
326
+ unattributed_count = vector[:unattributed_count].to_i
327
+ @vectors[id] = { id: id, decision_id: decision_id,
328
+ values: Array(vector[:values]).dup, outcome: !!vector[:outcome], test_ids: test_ids,
329
+ phases_by_test: vector.fetch(:phases_by_test, {}).transform_keys(&:to_s), unattributed_count: unattributed_count, count: vector[:count].to_i }
330
+ @vector_counts_by_decision[decision_id] += 1
331
+ @owner_associations_count += test_ids.length
332
+ @owner_associations_count += 1 if unattributed_count.positive?
303
333
  end
304
334
  end
305
335
 
@@ -314,14 +344,14 @@ module Branchproof
314
344
  end
315
345
 
316
346
  def condition_shapes
317
- decisions.to_h do |decision|
347
+ @decisions_by_id.transform_values do |decision|
318
348
  shape = { conditions: Array(decision[:conditions]).map { |condition| symbolize(condition) },
319
349
  tree: symbolize(decision[:tree]) }
320
350
  if alternative_decision?(decision)
321
351
  shape[:kind] = decision[:kind].to_s
322
352
  shape[:alternatives] = Array(decision[:alternatives]).map { |alternative| symbolize(alternative) }
323
353
  end
324
- [decision[:id].to_s, shape]
354
+ shape
325
355
  end
326
356
  end
327
357
 
@@ -348,7 +378,7 @@ module Branchproof
348
378
  vector = symbolize(raw)
349
379
  return "invalid vector" unless vector[:id] && vector[:decision_id] && vector[:values].is_a?(Array)
350
380
 
351
- decision = decisions.find { |item| item[:id].to_s == vector[:decision_id].to_s }
381
+ decision = @decisions_by_id[vector[:decision_id].to_s]
352
382
  return "unknown decision" unless decision
353
383
 
354
384
  expected_values = decision_dimension_count(decision)
@@ -396,7 +426,7 @@ module Branchproof
396
426
  end
397
427
 
398
428
  def decision_dimension_count(decision_or_id)
399
- decision = decision_or_id.is_a?(Hash) ? decision_or_id : decisions.find { |item| item[:id].to_s == decision_or_id.to_s }
429
+ decision = decision_or_id.is_a?(Hash) ? decision_or_id : @decisions_by_id[decision_or_id.to_s]
400
430
  return 0 unless decision
401
431
 
402
432
  alternative_decision?(decision) ? Array(decision[:alternatives]).length : Array(decision[:conditions]).length
@@ -420,16 +450,11 @@ module Branchproof
420
450
  end
421
451
  end
422
452
 
423
- def decisions = Array(fetch_value(@inventory, :decisions)).map { symbolize(_1) }
424
- def decision_conditions(id) = (decisions.find { |d| d[:id].to_s == id.to_s } || {}).fetch(:conditions, [])
425
- def vector_count(id) = @vectors.values.count { |v| v[:decision_id] == id.to_s }
426
- def new_vector?(id, values, outcome) = !@vectors.key?(Branchproof::Records.id([id, values, outcome]))
453
+ def decision_conditions(id) = (@decisions_by_id[id.to_s] || {}).fetch(:conditions, [])
454
+ def vector_count(id) = @vector_counts_by_decision[id.to_s]
455
+ def new_vector?(id) = !@vectors.key?(id)
427
456
 
428
- def owner_associations
429
- @vectors.values.sum do |v|
430
- v[:test_ids].length + (v[:unattributed_count].positive? ? 1 : 0)
431
- end
432
- end
457
+ def owner_associations = @owner_associations_count
433
458
 
434
459
  def incoming_owner_associations(incoming)
435
460
  owners = @vectors.each_with_object({}) do |(_id, vector), result|
@@ -500,7 +525,9 @@ module Branchproof
500
525
  def capture_state
501
526
  { vectors: deep_dup(@vectors), tests: deep_dup(@tests), run_ids: @run_ids.dup,
502
527
  run_payloads: @run_payloads.dup, diagnostics: deep_dup(@diagnostics),
503
- abort_counts: @abort_counts.dup, limited: @limited, attribution_complete: @attribution_complete }
528
+ abort_counts: @abort_counts.dup, limited: @limited, attribution_complete: @attribution_complete,
529
+ vector_counts_by_decision: @vector_counts_by_decision.dup, owner_associations_count: @owner_associations_count,
530
+ repetition_cache: deep_dup(@repetition_cache) }
504
531
  end
505
532
 
506
533
  def restore_state(state)
@@ -510,10 +537,85 @@ module Branchproof
510
537
  @run_payloads = state[:run_payloads]
511
538
  @diagnostics = state[:diagnostics]
512
539
  @abort_counts = state[:abort_counts]
540
+ @vector_counts_by_decision = state[:vector_counts_by_decision]
541
+ @owner_associations_count = state[:owner_associations_count]
542
+ @repetition_cache = state[:repetition_cache]
513
543
  @limited = state[:limited]
514
544
  @attribution_complete = state[:attribution_complete]
515
545
  end
516
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
+
517
619
  def deep_freeze(value)
518
620
  case value
519
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
@@ -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