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