branchproof 0.2.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 +7 -0
- data/CHANGELOG.md +36 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +202 -0
- data/NOTICE +3 -0
- data/README.md +242 -0
- data/doc/Branchproof/Analyzer.md +26 -0
- data/doc/Branchproof/CLI.md +15 -0
- data/doc/Branchproof/Error.md +6 -0
- data/doc/Branchproof/Evidence.md +46 -0
- data/doc/Branchproof/Instrumenter.md +18 -0
- data/doc/Branchproof/Limits.md +21 -0
- data/doc/Branchproof/Loader.md +25 -0
- data/doc/Branchproof/Minimizer.md +15 -0
- data/doc/Branchproof/MinitestAdapter.md +28 -0
- data/doc/Branchproof/Project.md +23 -0
- data/doc/Branchproof/RailsSupport/Error.md +6 -0
- data/doc/Branchproof/RailsSupport.md +32 -0
- data/doc/Branchproof/Records.md +38 -0
- data/doc/Branchproof/Report.md +26 -0
- data/doc/Branchproof/Runtime.md +37 -0
- data/doc/Branchproof/Source.md +23 -0
- data/doc/Branchproof/Worker.md +45 -0
- data/doc/Branchproof.md +33 -0
- data/doc/CHANGELOG.md +36 -0
- data/doc/README.md +242 -0
- data/exe/mcdc +6 -0
- data/lib/branchproof/analyzer.rb +454 -0
- data/lib/branchproof/cli.rb +266 -0
- data/lib/branchproof/evidence.rb +484 -0
- data/lib/branchproof/instrumenter.rb +150 -0
- data/lib/branchproof/limits.rb +44 -0
- data/lib/branchproof/loader.rb +140 -0
- data/lib/branchproof/minimizer.rb +198 -0
- data/lib/branchproof/minitest_adapter.rb +245 -0
- data/lib/branchproof/project.rb +53 -0
- data/lib/branchproof/rails_support.rb +74 -0
- data/lib/branchproof/records.rb +76 -0
- data/lib/branchproof/report.rb +412 -0
- data/lib/branchproof/runtime.rb +171 -0
- data/lib/branchproof/source.rb +238 -0
- data/lib/branchproof/version.rb +5 -0
- data/lib/branchproof/worker.rb +145 -0
- data/lib/branchproof.rb +24 -0
- data/lib/mcdc.rb +3 -0
- data/llms.txt +33 -0
- data/sig/branchproof.rbs +116 -0
- metadata +132 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Branchproof
|
|
6
|
+
# Process-local execution recorder. It deliberately never coerces or stores
|
|
7
|
+
# application values: Ruby's conditional expression is used for truthiness.
|
|
8
|
+
# Captures condition evaluations while preserving application values.
|
|
9
|
+
module Runtime
|
|
10
|
+
class << self
|
|
11
|
+
def boot(evidence:)
|
|
12
|
+
return nil if defined?(@booted) && @booted && @evidence.equal?(evidence) && Process.pid == @process_id
|
|
13
|
+
|
|
14
|
+
@evidence = evidence
|
|
15
|
+
@run_id = evidence.respond_to?(:run_id) ? evidence.run_id.to_s : SecureRandom.hex(16)
|
|
16
|
+
@process_id = Process.pid
|
|
17
|
+
@frames = {}
|
|
18
|
+
@contexts = {}
|
|
19
|
+
@diagnostics = []
|
|
20
|
+
@storage_disabled = false
|
|
21
|
+
@booted = true
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def enter(decision_id)
|
|
26
|
+
state[:frames] << { decision_id: String(decision_id), context: state[:context]&.dup,
|
|
27
|
+
observations: [], outcome: nil, finished: false,
|
|
28
|
+
execution_id: SecureRandom.hex(12) }
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def condition(decision_id, index, value)
|
|
33
|
+
frame = current_frame(decision_id)
|
|
34
|
+
if frame
|
|
35
|
+
truth = value ? true : false
|
|
36
|
+
frame[:observations] << [Integer(index), truth]
|
|
37
|
+
end
|
|
38
|
+
value
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def finish(decision_id, value)
|
|
42
|
+
frame = current_frame(decision_id)
|
|
43
|
+
if frame
|
|
44
|
+
frame[:outcome] = (value ? true : false)
|
|
45
|
+
frame[:finished] = true
|
|
46
|
+
end
|
|
47
|
+
value
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def leave(decision_id)
|
|
51
|
+
frames = state[:frames]
|
|
52
|
+
frame = frames.pop
|
|
53
|
+
unless frame && frame[:decision_id] == String(decision_id)
|
|
54
|
+
latch("runtime_frame_mismatch", "decision frame stack is not balanced")
|
|
55
|
+
cleanup_state
|
|
56
|
+
return nil
|
|
57
|
+
end
|
|
58
|
+
return nil unless @evidence && !@storage_disabled
|
|
59
|
+
|
|
60
|
+
execution = {
|
|
61
|
+
run_id: @run_id,
|
|
62
|
+
execution_id: frame[:execution_id],
|
|
63
|
+
decision_id: frame[:decision_id],
|
|
64
|
+
test_id: frame[:context]&.fetch(:test_id, nil),
|
|
65
|
+
phase: frame[:context]&.fetch(:phase, "unattributed") || "unattributed",
|
|
66
|
+
owner: owner_tuple,
|
|
67
|
+
observations: frame[:observations].map(&:dup),
|
|
68
|
+
outcome: frame[:finished] ? frame[:outcome] : nil,
|
|
69
|
+
status: frame[:finished] ? "completed" : "aborted"
|
|
70
|
+
}
|
|
71
|
+
safely_record(execution)
|
|
72
|
+
cleanup_state
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def context(test_id:, phase:)
|
|
77
|
+
state[:context] = if test_id.nil? || phase.to_s == "unattributed"
|
|
78
|
+
nil
|
|
79
|
+
else
|
|
80
|
+
{ test_id: test_id, phase: phase }
|
|
81
|
+
end
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def register_test(test:)
|
|
86
|
+
return nil unless @evidence.respond_to?(:register_test)
|
|
87
|
+
|
|
88
|
+
result = @evidence.register_test(test: test)
|
|
89
|
+
unless result.is_a?(Hash) && result[:status].to_s == "registered"
|
|
90
|
+
reported = result.is_a?(Hash) ? result[:status] : result.class
|
|
91
|
+
latch("recorder_status", "test registration returned #{reported}")
|
|
92
|
+
end
|
|
93
|
+
result
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
latch("recorder_failure", "test registration failed: #{e.class}: #{e.message}")
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def snapshot
|
|
100
|
+
if @evidence.respond_to?(:snapshot)
|
|
101
|
+
result = @evidence.snapshot
|
|
102
|
+
return result if @diagnostics.nil? || @diagnostics.empty?
|
|
103
|
+
|
|
104
|
+
copy = Marshal.load(Marshal.dump(result))
|
|
105
|
+
copy[:diagnostics] = Array(copy[:diagnostics]) + @diagnostics
|
|
106
|
+
copy[:completeness] = (copy[:completeness] || {}).merge(observation: false)
|
|
107
|
+
return copy
|
|
108
|
+
end
|
|
109
|
+
{ observations: [], diagnostics: @diagnostics.dup,
|
|
110
|
+
completeness: { observation: true, attribution: true, analysis: true } }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def diagnostics
|
|
114
|
+
@diagnostics&.map(&:dup) || []
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def state
|
|
120
|
+
@frames ||= {}
|
|
121
|
+
if @process_id && Process.pid != @process_id
|
|
122
|
+
@process_id = Process.pid
|
|
123
|
+
@run_id = SecureRandom.hex(16)
|
|
124
|
+
@frames = {}
|
|
125
|
+
@diagnostics = [{ code: "forked_process", severity: "error",
|
|
126
|
+
message: "runtime process identity changed; evidence storage disabled",
|
|
127
|
+
source_id: nil, decision_id: nil, execution_id: nil, test_id: nil, details: {} }]
|
|
128
|
+
@storage_disabled = true
|
|
129
|
+
end
|
|
130
|
+
key = [Process.pid, Thread.current, Fiber.current]
|
|
131
|
+
@frames[key] ||= { frames: [], context: nil }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def cleanup_state
|
|
135
|
+
key = [Process.pid, Thread.current, Fiber.current]
|
|
136
|
+
current = @frames[key]
|
|
137
|
+
@frames.delete(key) if current && current[:frames].empty? && current[:context].nil?
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def current_frame(decision_id)
|
|
141
|
+
frame = state[:frames].last
|
|
142
|
+
return frame if frame && frame[:decision_id] == String(decision_id)
|
|
143
|
+
|
|
144
|
+
latch("runtime_frame_mismatch", "no active frame for #{decision_id}") if frame
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def owner_tuple
|
|
149
|
+
{ process_id: Process.pid, thread_id: Thread.current.object_id, fiber_id: Fiber.current.object_id }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def safely_record(execution)
|
|
153
|
+
result = @evidence.record(execution: execution)
|
|
154
|
+
unless result.is_a?(Hash) && result[:status].to_s == "recorded"
|
|
155
|
+
reported = result.is_a?(Hash) ? result[:status] : result.class
|
|
156
|
+
latch("recorder_status", "evidence recorder returned #{reported}")
|
|
157
|
+
end
|
|
158
|
+
result
|
|
159
|
+
rescue StandardError => e
|
|
160
|
+
latch("recorder_failure", "evidence recorder failed: #{e.class}: #{e.message}")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def latch(code, message)
|
|
164
|
+
@storage_disabled = true
|
|
165
|
+
@diagnostics ||= []
|
|
166
|
+
@diagnostics << { code: code, severity: "error", message: message,
|
|
167
|
+
source_id: nil, decision_id: nil, execution_id: nil, test_id: nil, details: {} }
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "prism"
|
|
6
|
+
|
|
7
|
+
module Branchproof
|
|
8
|
+
# Inventories supported condition and decision occurrences from Ruby files.
|
|
9
|
+
class Source
|
|
10
|
+
attr_reader :root, :limits
|
|
11
|
+
|
|
12
|
+
def initialize(root:, limits:)
|
|
13
|
+
raise ArgumentError, "root must be an absolute path" unless root.is_a?(String) && Pathname.new(root).absolute?
|
|
14
|
+
raise ArgumentError, "limits must be a Limits record" unless limits.is_a?(Hash)
|
|
15
|
+
|
|
16
|
+
@root = File.realpath(root)
|
|
17
|
+
@limits = Limits.normalize(limits)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def inventory(paths:)
|
|
21
|
+
raise ArgumentError, "paths must be an Array" unless paths.is_a?(Array)
|
|
22
|
+
raise ArgumentError, "paths must contain only Strings" unless paths.all?(String)
|
|
23
|
+
|
|
24
|
+
units = paths.flat_map { |path| expand(path) }.uniq.sort.filter_map { |path| read_unit(path) }
|
|
25
|
+
decisions = units.flat_map { |unit| unit[:decisions] }
|
|
26
|
+
.sort_by { |decision| [decision[:source_id], decision[:byte_start]] }
|
|
27
|
+
diagnostics = units.flat_map { |unit| unit[:diagnostics] }
|
|
28
|
+
diagnostics += decisions.flat_map do |decision|
|
|
29
|
+
decision[:support_reasons].map do |reason|
|
|
30
|
+
Records.diagnostic(code: reason, message: "Unsupported source syntax: #{reason}",
|
|
31
|
+
source_id: decision[:source_id], decision_id: decision[:id])
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
supported = decisions.count { |decision| decision[:support_status] == "SUPPORTED" }
|
|
35
|
+
opaque = decisions.sum { |decision| decision[:opaque_ranges].length }
|
|
36
|
+
limited = diagnostics.count { |diagnostic| diagnostic[:code] == "limit_reached" }
|
|
37
|
+
scope = Records.build(discovered: decisions.length, supported: supported,
|
|
38
|
+
unsupported: decisions.length - supported, opaque: opaque,
|
|
39
|
+
unexecuted: 0, completed: 0, aborted: 0, unattributed: 0, limited: limited)
|
|
40
|
+
Records.build(root: @root, source_units: units.map { |unit| unit.except(:diagnostics) },
|
|
41
|
+
decisions: decisions, diagnostics: diagnostics, scope: scope)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def expand(path)
|
|
47
|
+
pattern = File.expand_path(path.to_s, @root)
|
|
48
|
+
matches = Dir[pattern].select { |candidate| File.file?(candidate) }
|
|
49
|
+
matches = [pattern] if matches.empty? && File.file?(pattern)
|
|
50
|
+
matches.filter_map do |candidate|
|
|
51
|
+
File.realpath(candidate)
|
|
52
|
+
rescue SystemCallError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def read_unit(path)
|
|
58
|
+
bytes = File.binread(path)
|
|
59
|
+
parsed = Prism.parse(bytes)
|
|
60
|
+
encoding = source_encoding(bytes, parsed)
|
|
61
|
+
source_id = Records.source_id(relative_path: relative(path), digest: Digest::SHA256.hexdigest(bytes),
|
|
62
|
+
encoding: encoding)
|
|
63
|
+
diagnostics = parsed.errors.map do |error|
|
|
64
|
+
Records.diagnostic(code: "parse_error", severity: "error", message: error.message, source_id: source_id,
|
|
65
|
+
details: { byte_start: error.location.start_offset, byte_length: error.location.length })
|
|
66
|
+
end
|
|
67
|
+
if parsed.respond_to?(:data_loc) && parsed.data_loc
|
|
68
|
+
diagnostics << Records.diagnostic(
|
|
69
|
+
code: "unsupported_data_section",
|
|
70
|
+
message: "__END__ data is outside the supported source scope",
|
|
71
|
+
source_id: source_id
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
file_reasons = []
|
|
75
|
+
file_reasons << "unsupported_data_section" if parsed.respond_to?(:data_loc) && parsed.data_loc
|
|
76
|
+
file_reasons << "parse_error" unless parsed.errors.empty?
|
|
77
|
+
decisions = parsed.value ? decisions_for(parsed.value, bytes, source_id, file_reasons, encoding) : []
|
|
78
|
+
Records.build(source_id: source_id, relative_path: relative(path), absolute_path: File.expand_path(path),
|
|
79
|
+
real_path: File.realpath(path), digest: Digest::SHA256.hexdigest(bytes), encoding: encoding,
|
|
80
|
+
original_bytes: bytes, decisions: decisions, diagnostics: diagnostics)
|
|
81
|
+
rescue SystemCallError => e
|
|
82
|
+
Records.build(source_id: nil, relative_path: relative(path), absolute_path: File.expand_path(path),
|
|
83
|
+
real_path: nil, digest: nil, encoding: nil, original_bytes: nil, decisions: [],
|
|
84
|
+
diagnostics: [Records.diagnostic(code: "source_unreadable", severity: "error", message: e.message)])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def source_encoding(bytes, parsed)
|
|
88
|
+
source = parsed.source
|
|
89
|
+
return source.encoding.name if source.respond_to?(:encoding)
|
|
90
|
+
|
|
91
|
+
header = bytes.lines.first(2).join
|
|
92
|
+
match = header.match(/coding\s*[:=]\s*([A-Za-z0-9._-]+)/)
|
|
93
|
+
return Encoding.find(match[1]).name if match
|
|
94
|
+
|
|
95
|
+
Encoding::UTF_8.name
|
|
96
|
+
rescue ArgumentError
|
|
97
|
+
Encoding::UTF_8.name
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def decisions_for(program, bytes, source_id, file_reasons = [], encoding = "UTF-8")
|
|
101
|
+
nodes = []
|
|
102
|
+
walk(program) { |node| nodes << node if decision_node?(node) }
|
|
103
|
+
nodes.sort_by { |node| node.location.start_offset }.map.with_index do |node, _|
|
|
104
|
+
build_decision(node, bytes, source_id, file_reasons, encoding)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def walk(node, &block)
|
|
109
|
+
yield node
|
|
110
|
+
node.child_nodes.each { |child| walk(child, &block) if child }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def decision_node?(node)
|
|
114
|
+
node.is_a?(Prism::IfNode) || node.is_a?(Prism::UnlessNode)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def build_decision(node, bytes, source_id, file_reasons = [], encoding = "UTF-8")
|
|
118
|
+
predicate = unwrap_predicate(node.predicate)
|
|
119
|
+
leaves = []
|
|
120
|
+
tree = tree_for(predicate, bytes, leaves)
|
|
121
|
+
start_offset = predicate.location.start_offset
|
|
122
|
+
length = predicate.location.length
|
|
123
|
+
context = if node.is_a?(Prism::UnlessNode)
|
|
124
|
+
"unless"
|
|
125
|
+
elsif node.if_keyword_loc.nil?
|
|
126
|
+
"ternary"
|
|
127
|
+
else
|
|
128
|
+
token = bytes.byteslice(node.if_keyword_loc.start_offset, node.if_keyword_loc.length)
|
|
129
|
+
token == "elsif" ? "elsif" : "if"
|
|
130
|
+
end
|
|
131
|
+
opaque_ranges = leaves.filter_map { |leaf| leaf.delete(:_opaque_range) }
|
|
132
|
+
conditions = leaves.each_with_index.map do |leaf, index|
|
|
133
|
+
expression = text_value(leaf.delete(:_expression), "UTF-8")
|
|
134
|
+
location = leaf.delete(:_location)
|
|
135
|
+
literal_truth = leaf.delete(:_literal_truth)
|
|
136
|
+
Records.build(id: nil, index: index, byte_start: location.start_offset, byte_length: location.length,
|
|
137
|
+
expression: expression, literal_truth: literal_truth, coupling: "unknown")
|
|
138
|
+
end
|
|
139
|
+
decision_id = Records.decision_id(source_id: source_id, context: context, byte_start: start_offset,
|
|
140
|
+
byte_length: length, tree: tree)
|
|
141
|
+
conditions = conditions.map do |condition|
|
|
142
|
+
condition.merge(id: Records.condition_id(decision_id, condition[:index]))
|
|
143
|
+
end
|
|
144
|
+
reasons = unsupported_reasons(predicate, bytes) + file_reasons
|
|
145
|
+
reasons << "unsupported_control_expression" if ambiguous_parentheses?(node.predicate)
|
|
146
|
+
reasons << "condition_limit_exceeded" if conditions.length > @limits[:conditions_per_decision]
|
|
147
|
+
discovered_condition_count = conditions.length
|
|
148
|
+
if discovered_condition_count > @limits[:conditions_per_decision]
|
|
149
|
+
conditions = conditions.first(@limits[:conditions_per_decision])
|
|
150
|
+
tree = nil
|
|
151
|
+
end
|
|
152
|
+
support = reasons.empty? ? "SUPPORTED" : "UNSUPPORTED"
|
|
153
|
+
expression = text_value(bytes.byteslice(predicate.location.start_offset, predicate.location.length), encoding)
|
|
154
|
+
Records.build(id: decision_id, source_id: source_id, context: context, byte_start: start_offset,
|
|
155
|
+
byte_length: length, line: predicate.location.start_line, column: predicate.location.start_column,
|
|
156
|
+
expression: expression,
|
|
157
|
+
tree: tree,
|
|
158
|
+
conditions: conditions, discovered_condition_count: discovered_condition_count,
|
|
159
|
+
support_status: support, support_reasons: reasons.uniq, opaque_ranges: opaque_ranges)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def tree_for(node, bytes, leaves)
|
|
163
|
+
node = unwrap_predicate(node)
|
|
164
|
+
case node
|
|
165
|
+
when Prism::AndNode
|
|
166
|
+
Records.build(type: :and, left: tree_for(node.left, bytes, leaves), right: tree_for(node.right, bytes, leaves))
|
|
167
|
+
when Prism::OrNode
|
|
168
|
+
Records.build(type: :or, left: tree_for(node.left, bytes, leaves), right: tree_for(node.right, bytes, leaves))
|
|
169
|
+
else
|
|
170
|
+
location = node.location
|
|
171
|
+
leaf = {
|
|
172
|
+
_expression: bytes.byteslice(location.start_offset, location.length), _location: location,
|
|
173
|
+
_literal_truth: literal_truth(node),
|
|
174
|
+
_opaque_range: opaque?(node) ? { start: location.start_offset, length: location.length } : nil
|
|
175
|
+
}
|
|
176
|
+
leaves << leaf
|
|
177
|
+
Records.build(type: :atom, index: leaves.length - 1)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def literal_truth(node)
|
|
182
|
+
return true if node.is_a?(Prism::TrueNode)
|
|
183
|
+
return false if node.is_a?(Prism::FalseNode) || node.is_a?(Prism::NilNode)
|
|
184
|
+
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def opaque?(node)
|
|
189
|
+
node.is_a?(Prism::CallNode) && node.name == :!
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def unsupported_reasons(predicate, bytes)
|
|
193
|
+
reasons = []
|
|
194
|
+
walk(predicate) do |node|
|
|
195
|
+
if node.is_a?(Prism::MatchLastLineNode) || node.is_a?(Prism::InterpolatedMatchLastLineNode)
|
|
196
|
+
reasons << "unsupported_implicit_regexp"
|
|
197
|
+
end
|
|
198
|
+
reasons << "unsupported_flip_flop" if node.is_a?(Prism::FlipFlopNode)
|
|
199
|
+
reasons << "unsupported_heredoc" if node.respond_to?(:opening_loc) && node.opening_loc &&
|
|
200
|
+
bytes.byteslice(node.opening_loc.start_offset,
|
|
201
|
+
node.opening_loc.length).start_with?("<<")
|
|
202
|
+
if (node.is_a?(Prism::AndNode) || node.is_a?(Prism::OrNode)) && node.respond_to?(:operator_loc)
|
|
203
|
+
operator = bytes.byteslice(node.operator_loc.start_offset, node.operator_loc.length)
|
|
204
|
+
reasons << "unsupported_keyword_boolean" if %w[and or].include?(operator)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
reasons
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def unwrap_predicate(node)
|
|
211
|
+
while node.is_a?(Prism::ParenthesesNode)
|
|
212
|
+
statements = node.body
|
|
213
|
+
body = statements&.body
|
|
214
|
+
return node unless body&.length == 1
|
|
215
|
+
|
|
216
|
+
node = body.first
|
|
217
|
+
end
|
|
218
|
+
node
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def ambiguous_parentheses?(node)
|
|
222
|
+
return false unless node.is_a?(Prism::ParenthesesNode)
|
|
223
|
+
|
|
224
|
+
body = node.body&.body
|
|
225
|
+
!body || body.length != 1
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def text_value(value, encoding)
|
|
229
|
+
value.dup.force_encoding(encoding).encode("UTF-8")
|
|
230
|
+
rescue EncodingError
|
|
231
|
+
value.dup.force_encoding(Encoding::BINARY)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def relative(path)
|
|
235
|
+
Pathname.new(File.expand_path(path)).relative_path_from(Pathname.new(@root)).to_s
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Layout/LineLength, Metrics/ModuleLength
|
|
4
|
+
|
|
5
|
+
require "json"
|
|
6
|
+
require "tmpdir"
|
|
7
|
+
require "stringio"
|
|
8
|
+
require "fileutils"
|
|
9
|
+
|
|
10
|
+
module Branchproof
|
|
11
|
+
# Runs one isolated Minitest worker and atomically exports its result.
|
|
12
|
+
module Worker
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def child_process(config_path)
|
|
16
|
+
payload = JSON.parse(File.binread(config_path))
|
|
17
|
+
project = symbolize(payload.fetch("project", legacy_project))
|
|
18
|
+
prepend_load_paths(project)
|
|
19
|
+
inventory = symbolize(payload.fetch("inventory"))
|
|
20
|
+
Array(inventory[:source_units]).each do |unit|
|
|
21
|
+
unit[:original_bytes] = File.binread(unit[:absolute_path]) if unit[:absolute_path]
|
|
22
|
+
end
|
|
23
|
+
limits = symbolize(payload.fetch("limits"))
|
|
24
|
+
require "minitest"
|
|
25
|
+
require "minitest/test"
|
|
26
|
+
require_relative "minitest_adapter"
|
|
27
|
+
evidence = Branchproof::Evidence.new(inventory: inventory, limits: limits, run_id: payload.fetch("run_id"))
|
|
28
|
+
runtime = Branchproof::Runtime
|
|
29
|
+
runtime.boot(evidence: evidence)
|
|
30
|
+
loader = Branchproof::Loader.new(inventory: inventory, instrumenter: Branchproof::Instrumenter.new)
|
|
31
|
+
status = loader.install
|
|
32
|
+
return write_failure(payload, "loader", status) unless status[:status].to_sym == :installed
|
|
33
|
+
|
|
34
|
+
adapter = Branchproof::MinitestAdapter.new(runtime: runtime)
|
|
35
|
+
rails_metadata = nil
|
|
36
|
+
ARGV.replace(payload.fetch("runner_args"))
|
|
37
|
+
adapter.run(test_files: payload.fetch("test_files"), runner_args: payload.fetch("runner_args"),
|
|
38
|
+
before_load: lambda {
|
|
39
|
+
rails_metadata = boot_project(project)
|
|
40
|
+
}, on_complete: lambda { |baseline|
|
|
41
|
+
result = completion_result(baseline: baseline, project: project, rails_metadata: rails_metadata,
|
|
42
|
+
evidence: runtime.snapshot, tests: adapter.tests.values,
|
|
43
|
+
diagnostics: loader.diagnostics)
|
|
44
|
+
write_completion(payload, result)
|
|
45
|
+
})
|
|
46
|
+
adapter.validate_runner!
|
|
47
|
+
Minitest.autorun
|
|
48
|
+
nil
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
code = if e.message.include?("parallel")
|
|
51
|
+
"unsupported_runner"
|
|
52
|
+
elsif defined?(Branchproof::RailsSupport::Error) && e.is_a?(Branchproof::RailsSupport::Error)
|
|
53
|
+
"rails_boot"
|
|
54
|
+
else
|
|
55
|
+
"worker"
|
|
56
|
+
end
|
|
57
|
+
write_failure(payload || {}, code, { message: e.message }) if payload
|
|
58
|
+
2
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def legacy_project
|
|
62
|
+
{ "kind" => "ruby", "root" => Dir.pwd,
|
|
63
|
+
"load_paths" => [File.join(Dir.pwd, "lib"), File.join(Dir.pwd, "test")],
|
|
64
|
+
"environment" => {} }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def prepend_load_paths(project)
|
|
68
|
+
paths = Array(project[:load_paths])
|
|
69
|
+
paths = legacy_project.fetch("load_paths") if paths.empty?
|
|
70
|
+
paths.reverse_each { |path| $LOAD_PATH.unshift(File.expand_path(path.to_s, project[:root].to_s)) }
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def boot_project(project)
|
|
75
|
+
return nil unless project[:kind].to_s == "rails"
|
|
76
|
+
|
|
77
|
+
require_relative "rails_support"
|
|
78
|
+
Branchproof::RailsSupport.boot(project: project)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def project_metadata(project, rails_metadata)
|
|
82
|
+
metadata = { kind: project[:kind].to_s, root: project[:root].to_s,
|
|
83
|
+
load_paths: Array(project[:load_paths]).map(&:to_s), serial_policy: { mode: "single_process", workers: 1 } }
|
|
84
|
+
metadata.merge!(rails_metadata) if rails_metadata
|
|
85
|
+
metadata
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# This result boundary intentionally carries the complete worker payload.
|
|
89
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
90
|
+
def completion_result(baseline:, project:, rails_metadata:, evidence:, tests:, diagnostics:)
|
|
91
|
+
result = baseline.merge(project: project_metadata(project, rails_metadata), evidence: evidence,
|
|
92
|
+
tests: tests, diagnostics: diagnostics)
|
|
93
|
+
return result unless diagnostics.any? { |diagnostic| diagnostic[:severity].to_s == "error" }
|
|
94
|
+
|
|
95
|
+
result.merge(status: "ERROR", finalized: false, exit_status: 2,
|
|
96
|
+
evidence: incomplete_evidence(evidence, diagnostics))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def incomplete_evidence(evidence, diagnostics)
|
|
100
|
+
copy = Marshal.load(Marshal.dump(evidence))
|
|
101
|
+
copy[:diagnostics] = Array(copy[:diagnostics]) + diagnostics
|
|
102
|
+
copy[:completeness] = (copy[:completeness] || {}).merge(observation: false, analysis: false)
|
|
103
|
+
copy
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def write_completion(payload, result)
|
|
107
|
+
path = payload.fetch("result_path")
|
|
108
|
+
temporary = "#{path}.tmp-#{Process.pid}"
|
|
109
|
+
File.binwrite(temporary, JSON.generate(normalize(result)))
|
|
110
|
+
File.rename(temporary, path)
|
|
111
|
+
File.binwrite(payload.fetch("marker_path"), "complete\n")
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def write_failure(payload, code, details)
|
|
116
|
+
return unless payload["result_path"]
|
|
117
|
+
|
|
118
|
+
write_completion(payload, { status: "ERROR", executed_tests: 0, failed_tests: 0, skipped_tests: 0,
|
|
119
|
+
finalized: false, exit_status: 2, project: project_metadata_for(payload),
|
|
120
|
+
diagnostics: [{ code: code, severity: "error", message: details.to_s }] })
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def project_metadata_for(payload)
|
|
124
|
+
project = symbolize(payload.fetch("project", legacy_project))
|
|
125
|
+
project_metadata(project, nil)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def symbolize(value)
|
|
129
|
+
return value.map { symbolize(_1) } if value.is_a?(Array)
|
|
130
|
+
return value.transform_keys(&:to_sym).transform_values { symbolize(_1) } if value.is_a?(Hash)
|
|
131
|
+
|
|
132
|
+
value
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def normalize(value)
|
|
136
|
+
case value
|
|
137
|
+
when Hash then value.each_with_object({}) { |(key, item), result| result[key.to_s] = normalize(item) }
|
|
138
|
+
when Array then value.map { normalize(_1) }
|
|
139
|
+
when Symbol then value.to_s
|
|
140
|
+
else value
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
# rubocop:enable Layout/LineLength, Metrics/ModuleLength
|
data/lib/branchproof.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "branchproof/version"
|
|
4
|
+
require_relative "branchproof/records"
|
|
5
|
+
require_relative "branchproof/limits"
|
|
6
|
+
require_relative "branchproof/source"
|
|
7
|
+
require_relative "branchproof/project"
|
|
8
|
+
|
|
9
|
+
# Public namespace for source inventory and one-run MC/DC reporting.
|
|
10
|
+
module Branchproof
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
autoload :CLI, "branchproof/cli"
|
|
13
|
+
autoload :Instrumenter, "branchproof/instrumenter"
|
|
14
|
+
autoload :Loader, "branchproof/loader"
|
|
15
|
+
autoload :Runtime, "branchproof/runtime"
|
|
16
|
+
autoload :MinitestAdapter, "branchproof/minitest_adapter"
|
|
17
|
+
autoload :Evidence, "branchproof/evidence"
|
|
18
|
+
autoload :Analyzer, "branchproof/analyzer"
|
|
19
|
+
autoload :Minimizer, "branchproof/minimizer"
|
|
20
|
+
autoload :Report, "branchproof/report"
|
|
21
|
+
autoload :Worker, "branchproof/worker"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
MCDC = Branchproof unless defined?(MCDC)
|
data/lib/mcdc.rb
ADDED
data/llms.txt
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Module Branchproof <a id="module-Branchproof"></a>
|
|
2
|
+
|
|
3
|
+
| | |
|
|
4
|
+
| --- | --- |
|
|
5
|
+
| **Defined in** | lib/branchproof.rb, lib/branchproof/cli.rb, lib/branchproof/limits.rb, lib/branchproof/loader.rb, lib/branchproof/report.rb, lib/branchproof/source.rb, lib/branchproof/worker.rb, lib/branchproof/project.rb, lib/branchproof/records.rb, lib/branchproof/runtime.rb, lib/branchproof/version.rb, lib/branchproof/analyzer.rb, lib/branchproof/evidence.rb, lib/branchproof/minimizer.rb, lib/branchproof/instrumenter.rb, lib/branchproof/rails_support.rb, lib/branchproof/minitest_adapter.rb |
|
|
6
|
+
|
|
7
|
+
Public namespace for source inventory and one-run MC/DC reporting.
|
|
8
|
+
|
|
9
|
+
## Constants
|
|
10
|
+
### `VERSION` <a id="constant-VERSION"></a> <a id="VERSION-constant"></a>
|
|
11
|
+
Not documented.
|
|
12
|
+
|
|
13
|
+
# Documentation
|
|
14
|
+
|
|
15
|
+
- [Branchproof/Analyzer.md](doc/Branchproof/Analyzer.md)
|
|
16
|
+
- [Branchproof/CLI.md](doc/Branchproof/CLI.md)
|
|
17
|
+
- [Branchproof/Error.md](doc/Branchproof/Error.md)
|
|
18
|
+
- [Branchproof/Evidence.md](doc/Branchproof/Evidence.md)
|
|
19
|
+
- [Branchproof/Instrumenter.md](doc/Branchproof/Instrumenter.md)
|
|
20
|
+
- [Branchproof/Limits.md](doc/Branchproof/Limits.md)
|
|
21
|
+
- [Branchproof/Loader.md](doc/Branchproof/Loader.md)
|
|
22
|
+
- [Branchproof/Minimizer.md](doc/Branchproof/Minimizer.md)
|
|
23
|
+
- [Branchproof/MinitestAdapter.md](doc/Branchproof/MinitestAdapter.md)
|
|
24
|
+
- [Branchproof/Project.md](doc/Branchproof/Project.md)
|
|
25
|
+
- [Branchproof/RailsSupport/Error.md](doc/Branchproof/RailsSupport/Error.md)
|
|
26
|
+
- [Branchproof/RailsSupport.md](doc/Branchproof/RailsSupport.md)
|
|
27
|
+
- [Branchproof/Records.md](doc/Branchproof/Records.md)
|
|
28
|
+
- [Branchproof/Report.md](doc/Branchproof/Report.md)
|
|
29
|
+
- [Branchproof/Runtime.md](doc/Branchproof/Runtime.md)
|
|
30
|
+
- [Branchproof/Source.md](doc/Branchproof/Source.md)
|
|
31
|
+
- [Branchproof/Worker.md](doc/Branchproof/Worker.md)
|
|
32
|
+
- [CHANGELOG.md](doc/CHANGELOG.md)
|
|
33
|
+
- [README.md](doc/README.md)
|