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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -0
- data/README.md +255 -21
- data/doc/Branchproof/Analyzer.md +2 -2
- data/doc/Branchproof/CLI.md +4 -0
- data/doc/Branchproof/ComparisonReport.md +4 -0
- data/doc/Branchproof/Constraints/Solver.md +21 -0
- data/doc/Branchproof/Constraints.md +98 -0
- data/doc/Branchproof/CoverageIndex.md +3 -0
- data/doc/Branchproof/DecisionSyntax.md +6 -4
- data/doc/Branchproof/DecisionTable.md +153 -0
- data/doc/Branchproof/DefaultInstrumentation.md +13 -0
- data/doc/Branchproof/DefaultRuntime.md +11 -0
- data/doc/Branchproof/DefaultSyntax.md +7 -0
- data/doc/Branchproof/ExceptionInstrumentation.md +9 -0
- data/doc/Branchproof/ExceptionRuntime.md +27 -0
- data/doc/Branchproof/ExceptionSyntax.md +9 -0
- data/doc/Branchproof/ExtendedAlternativeRuntime.md +19 -0
- data/doc/Branchproof/FocusedReport.md +5 -2
- data/doc/Branchproof/Instrumenter.md +1 -1
- data/doc/Branchproof/IterationInstrumentation.md +7 -0
- data/doc/Branchproof/IterationRuntime.md +21 -0
- data/doc/Branchproof/IterationSyntax.md +11 -0
- data/doc/Branchproof/Loader.md +0 -4
- data/doc/Branchproof/MinitestAdapter.md +0 -3
- data/doc/Branchproof/Report.md +18 -0
- data/doc/Branchproof/Runtime.md +60 -1
- data/doc/Branchproof/SavedReport.md +15 -0
- data/doc/Branchproof/Source.md +7 -1
- data/doc/Branchproof/ValueInstrumentation.md +7 -0
- data/doc/Branchproof/ValueRuntime.md +26 -0
- data/doc/Branchproof/ValueSyntax.md +26 -0
- data/doc/Branchproof.md +20 -3
- data/doc/CHANGELOG.md +53 -0
- data/doc/README.md +255 -21
- data/lib/branchproof/analyzer.rb +80 -44
- data/lib/branchproof/cli.rb +28 -19
- data/lib/branchproof/comparison.rb +207 -19
- data/lib/branchproof/comparison_report.rb +49 -1
- data/lib/branchproof/constraints.rb +363 -0
- data/lib/branchproof/coverage_index.rb +30 -1
- data/lib/branchproof/decision_syntax.rb +50 -24
- data/lib/branchproof/decision_table.rb +377 -0
- data/lib/branchproof/default_instrumentation.rb +140 -0
- data/lib/branchproof/default_runtime.rb +16 -0
- data/lib/branchproof/default_syntax.rb +94 -0
- data/lib/branchproof/evidence.rb +133 -31
- data/lib/branchproof/exception_instrumentation.rb +96 -0
- data/lib/branchproof/exception_runtime.rb +41 -0
- data/lib/branchproof/exception_syntax.rb +157 -0
- data/lib/branchproof/extended_alternative_runtime.rb +23 -0
- data/lib/branchproof/flow_instrumentation.rb +52 -23
- data/lib/branchproof/focused_report.rb +77 -9
- data/lib/branchproof/instrumenter.rb +93 -37
- data/lib/branchproof/iteration_instrumentation.rb +66 -0
- data/lib/branchproof/iteration_runtime.rb +79 -0
- data/lib/branchproof/iteration_syntax.rb +75 -0
- data/lib/branchproof/limits.rb +4 -1
- data/lib/branchproof/loader.rb +24 -7
- data/lib/branchproof/minimizer.rb +18 -13
- data/lib/branchproof/minitest_adapter.rb +11 -16
- data/lib/branchproof/records.rb +2 -0
- data/lib/branchproof/report.rb +301 -68
- data/lib/branchproof/runtime.rb +32 -30
- data/lib/branchproof/saved_report.rb +200 -14
- data/lib/branchproof/source.rb +140 -71
- data/lib/branchproof/value_instrumentation.rb +35 -0
- data/lib/branchproof/value_runtime.rb +73 -0
- data/lib/branchproof/value_syntax.rb +116 -0
- data/lib/branchproof/version.rb +1 -1
- data/lib/branchproof/worker.rb +1 -4
- data/lib/branchproof.rb +2 -0
- data/llms.txt +20 -3
- data/sig/branchproof.rbs +64 -1
- metadata +32 -1
data/lib/branchproof/evidence.rb
CHANGED
|
@@ -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
|
-
|
|
72
|
-
|
|
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,
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
310
|
+
id = vector[:id].to_s
|
|
311
|
+
existing = @vectors[id]
|
|
292
312
|
if existing
|
|
293
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 :
|
|
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
|
|
424
|
-
def
|
|
425
|
-
def
|
|
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
|