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