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
|
@@ -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
|
|
@@ -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)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Branchproof
|
|
4
|
+
# Keep matching and iteration in the original lexical scope.
|
|
5
|
+
module IterationInstrumentation
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
# rubocop:disable Metrics/MethodLength
|
|
9
|
+
def render_flow(bytes, decision, nested, encloses)
|
|
10
|
+
metadata = decision.fetch(:instrumentation)
|
|
11
|
+
case metadata[:type]
|
|
12
|
+
when "iteration"
|
|
13
|
+
render_iteration(bytes, decision, nested, encloses)
|
|
14
|
+
when "required_pattern"
|
|
15
|
+
expression = render_children(bytes, decision[:byte_start], decision[:byte_length], nested, encloses)
|
|
16
|
+
runtime = self.class::RUNTIME
|
|
17
|
+
identifier = decision[:id].inspect
|
|
18
|
+
"(begin; #{runtime}.enter(#{identifier}); begin; #{expression}; " \
|
|
19
|
+
"#{runtime}.flow_select(#{identifier}, 0); nil; rescue ::NoMatchingPatternError; " \
|
|
20
|
+
"#{runtime}.flow_select(#{identifier}, 1); raise; ensure; #{runtime}.leave(#{identifier}); end; end)"
|
|
21
|
+
else
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# rubocop:enable Metrics/MethodLength
|
|
27
|
+
|
|
28
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- nested exception callback seam
|
|
29
|
+
def render_iteration(bytes, decision, nested, encloses)
|
|
30
|
+
metadata = decision.fetch(:instrumentation)
|
|
31
|
+
identifier = decision[:id].inspect
|
|
32
|
+
runtime = self.class::RUNTIME
|
|
33
|
+
alternative_count = metadata[:lazy] ? 1 : 2
|
|
34
|
+
marker = "#{runtime}.flow_iteration_callback(#{identifier}, #{alternative_count})"
|
|
35
|
+
suffix = metadata[:empty] ? "nil; " : ""
|
|
36
|
+
callback = "; #{marker}; #{suffix}"
|
|
37
|
+
exception_child = nested.find do |child|
|
|
38
|
+
child[:instrumentation]&.fetch(:type, nil) == "exception" &&
|
|
39
|
+
child[:instrumentation].fetch(:implicit, false) &&
|
|
40
|
+
contains?(child[:byte_start], child[:byte_length], metadata[:insert_at], 0)
|
|
41
|
+
end
|
|
42
|
+
if exception_child
|
|
43
|
+
instrumentation = exception_child.fetch(:instrumentation)
|
|
44
|
+
callback_child = exception_child.merge(instrumentation: instrumentation.merge(
|
|
45
|
+
iteration_callback: { insert_at: metadata[:insert_at], text: callback }
|
|
46
|
+
))
|
|
47
|
+
nested = nested.map { |child| child.equal?(exception_child) ? callback_child : child }
|
|
48
|
+
encloses = encloses.merge(callback_child => encloses[exception_child])
|
|
49
|
+
end
|
|
50
|
+
edits = exception_child ? [] : [{ start: metadata[:insert_at], length: 0, text: callback }]
|
|
51
|
+
if metadata[:receiver]
|
|
52
|
+
receiver = metadata.fetch(:receiver)
|
|
53
|
+
edits << flow_replacement(bytes, receiver, nested, encloses) do |expression|
|
|
54
|
+
"#{runtime}.flow_iteration_begin(#{identifier}, (#{expression}), #{alternative_count})"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
expression = flow_fragments(bytes, decision, nested, edits, encloses)
|
|
58
|
+
if metadata[:receiver]
|
|
59
|
+
"(begin; begin; #{runtime}.flow_iteration_finish(#{identifier}, (#{expression}), 0); ensure; " \
|
|
60
|
+
"#{runtime}.flow_iteration_leave(#{identifier}); end; end)"
|
|
61
|
+
else
|
|
62
|
+
flow_frame(decision[:id], expression, default_path: 0)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Branchproof
|
|
4
|
+
# Runtime support for callback based iteration, including lazy receivers
|
|
5
|
+
# whose callbacks execute after the constructing call has returned.
|
|
6
|
+
module IterationRuntime
|
|
7
|
+
def flow_iteration_begin(decision_id, receiver, alternative_count = 2)
|
|
8
|
+
# Case equality bypasses an application's overridden `is_a?` method.
|
|
9
|
+
# rubocop:disable-next Style/CaseEquality
|
|
10
|
+
return receiver if alternative_count == 1 || ::Enumerator::Lazy === receiver
|
|
11
|
+
|
|
12
|
+
enter(decision_id)
|
|
13
|
+
receiver
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# rubocop:disable-next Metrics/MethodLength
|
|
17
|
+
def flow_iteration_callback(decision_id, alternative_count = 2)
|
|
18
|
+
frame = iteration_frame(decision_id)
|
|
19
|
+
if frame
|
|
20
|
+
return nil if frame[:finished]
|
|
21
|
+
|
|
22
|
+
iteration_select(frame, alternative_count.to_i == 1 ? 0 : 1, alternative_count)
|
|
23
|
+
else
|
|
24
|
+
enter(decision_id)
|
|
25
|
+
begin
|
|
26
|
+
if alternative_count.to_i == 1
|
|
27
|
+
flow_select(decision_id, 0)
|
|
28
|
+
else
|
|
29
|
+
flow_path(decision_id, 1)
|
|
30
|
+
end
|
|
31
|
+
ensure
|
|
32
|
+
leave(decision_id)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def flow_iteration_finish(decision_id, value, default_path = nil)
|
|
39
|
+
frame = iteration_frame(decision_id)
|
|
40
|
+
iteration_select(frame, default_path, 2) if frame && !frame[:finished] && !default_path.nil?
|
|
41
|
+
value
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def flow_iteration_leave(decision_id)
|
|
45
|
+
frame = iteration_frame(decision_id)
|
|
46
|
+
frames = Thread.current[Branchproof::Runtime::FRAME_STATE_KEY]&.fetch(:frames, nil)
|
|
47
|
+
return nil unless frame && frames&.last.equal?(frame)
|
|
48
|
+
|
|
49
|
+
leave(decision_id)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# `current_frame` deliberately latches a diagnostic when a helper probes
|
|
55
|
+
# while another decision is active. Iteration callbacks may be deferred or
|
|
56
|
+
# nested, so an absent matching frame is an ordinary state here.
|
|
57
|
+
def iteration_frame(decision_id)
|
|
58
|
+
frames = Thread.current[Branchproof::Runtime::FRAME_STATE_KEY]&.fetch(:frames, nil)
|
|
59
|
+
index = frames ? frames.length - 1 : -1
|
|
60
|
+
while index >= 0
|
|
61
|
+
frame = frames[index]
|
|
62
|
+
return frame if frame[:decision_id] == decision_id
|
|
63
|
+
|
|
64
|
+
index -= 1
|
|
65
|
+
end
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def iteration_select(frame, index, alternative_count)
|
|
70
|
+
frame[:observations] = if alternative_count == 1
|
|
71
|
+
[[0, true]]
|
|
72
|
+
else
|
|
73
|
+
[[0, index.zero?], [1, index == 1]]
|
|
74
|
+
end
|
|
75
|
+
frame[:outcome] = true
|
|
76
|
+
frame[:finished] = true
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Branchproof
|
|
4
|
+
# Observe entry into source-visible callback bodies, not library internals.
|
|
5
|
+
module IterationSyntax
|
|
6
|
+
ITERATORS = %i[each each_with_index times upto downto step map collect select filter reject filter_map
|
|
7
|
+
find detect find_index take_while drop_while partition count all? any? none? one? fetch].freeze
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def flow_decision_node?(node)
|
|
12
|
+
iteration_node?(node) || node.is_a?(Prism::MatchRequiredNode) || super
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def iteration_node?(node)
|
|
16
|
+
node.is_a?(Prism::ForNode) ||
|
|
17
|
+
(node.is_a?(Prism::CallNode) && ITERATORS.include?(node.name) &&
|
|
18
|
+
node.block.is_a?(Prism::BlockNode) && !node.safe_navigation?)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
22
|
+
def flow_details(node, bytes, encoding)
|
|
23
|
+
if node.is_a?(Prism::MatchRequiredNode)
|
|
24
|
+
alternatives = %w[matched mismatch].map { |label| iteration_alternative(node, label) }
|
|
25
|
+
return ["pattern", "required_pattern", alternatives,
|
|
26
|
+
{ type: "required_pattern", range: byte_range(node.location) }, unsupported_reasons(node, bytes)]
|
|
27
|
+
end
|
|
28
|
+
return super unless iteration_node?(node)
|
|
29
|
+
|
|
30
|
+
body = node.is_a?(Prism::ForNode) ? node.statements : node.block.body
|
|
31
|
+
closing = node.is_a?(Prism::ForNode) ? node.end_keyword_loc : node.block.closing_loc
|
|
32
|
+
lazy = node.is_a?(Prism::CallNode) && lazy_receiver?(node.receiver)
|
|
33
|
+
fallback = node.is_a?(Prism::CallNode) && node.name == :fetch
|
|
34
|
+
context = if lazy
|
|
35
|
+
"lazy_callback"
|
|
36
|
+
else
|
|
37
|
+
(fallback ? "fetch_fallback" : "iteration")
|
|
38
|
+
end
|
|
39
|
+
labels = if lazy
|
|
40
|
+
["callback entered"]
|
|
41
|
+
else
|
|
42
|
+
(fallback ? ["value present", "fallback entered"] : %w[empty entered])
|
|
43
|
+
end
|
|
44
|
+
metadata = { type: "iteration", range: byte_range(node.location), lazy: lazy,
|
|
45
|
+
insert_at: iteration_insert_at(body, closing),
|
|
46
|
+
empty: body.nil? }
|
|
47
|
+
metadata[:receiver] = byte_range(node.receiver.location) if node.is_a?(Prism::CallNode) && node.receiver
|
|
48
|
+
[lazy ? "multiway" : "implicit", context, labels.map { |label| iteration_alternative(node, label) },
|
|
49
|
+
metadata, unsupported_reasons(node, bytes)]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
53
|
+
|
|
54
|
+
def lazy_receiver?(node)
|
|
55
|
+
node.is_a?(Prism::CallNode) && (node.name == :lazy || lazy_receiver?(node.receiver))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Prism gives a block-level rescue/ensure BeginNode a location beginning at
|
|
59
|
+
# the `do` keyword. Inserting there would produce `call; callback; do`, so
|
|
60
|
+
# anchor the callback after the block parameters and before its statements.
|
|
61
|
+
def iteration_insert_at(body, closing)
|
|
62
|
+
return closing.start_offset unless body
|
|
63
|
+
|
|
64
|
+
if body.is_a?(Prism::BeginNode)
|
|
65
|
+
statements = body.statements
|
|
66
|
+
return statements.location.start_offset if statements&.location
|
|
67
|
+
end
|
|
68
|
+
body.location.start_offset
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def iteration_alternative(node, label)
|
|
72
|
+
{ expression: label, byte_start: node.location.start_offset, byte_length: node.location.length }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/branchproof/loader.rb
CHANGED
|
@@ -47,7 +47,12 @@ module Branchproof
|
|
|
47
47
|
add_diagnostic("source_drift", "selected source changed after inventory", unit[:source_id])
|
|
48
48
|
return nil
|
|
49
49
|
end
|
|
50
|
-
|
|
50
|
+
begin
|
|
51
|
+
rewritten = @instrumenter.rewrite(unit: unit.merge(original_bytes: bytes))
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
add_diagnostic("rewrite_failure", "#{e.class}: #{e.message}", unit[:source_id], severity: "error")
|
|
54
|
+
return nil
|
|
55
|
+
end
|
|
51
56
|
Array(rewritten[:diagnostics]).each do |diagnostic|
|
|
52
57
|
add_diagnostic(diagnostic[:code], diagnostic[:message], unit[:source_id], severity: diagnostic[:severity])
|
|
53
58
|
end
|
data/lib/branchproof/runtime.rb
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
4
|
require_relative "runtime_flow"
|
|
5
|
+
require_relative "extended_alternative_runtime"
|
|
6
|
+
require_relative "exception_runtime"
|
|
7
|
+
require_relative "default_runtime"
|
|
8
|
+
require_relative "value_runtime"
|
|
9
|
+
require_relative "iteration_runtime"
|
|
5
10
|
|
|
6
11
|
module Branchproof
|
|
7
12
|
# Process-local execution recorder. It deliberately never coerces or stores
|
|
@@ -9,6 +14,11 @@ module Branchproof
|
|
|
9
14
|
# Captures condition evaluations while preserving application values.
|
|
10
15
|
module Runtime
|
|
11
16
|
extend RuntimeFlow
|
|
17
|
+
extend ExtendedAlternativeRuntime
|
|
18
|
+
extend ExceptionRuntime
|
|
19
|
+
extend DefaultRuntime
|
|
20
|
+
extend ValueRuntime
|
|
21
|
+
extend IterationRuntime
|
|
12
22
|
|
|
13
23
|
FRAME_STATE_KEY = :branchproof_runtime_frame_state
|
|
14
24
|
|