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,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Branchproof
|
|
6
|
+
# Owns the process-local CRuby compilation hook when the VM exposes it.
|
|
7
|
+
class Loader
|
|
8
|
+
STATUS_KEYS = %i[status reason].freeze
|
|
9
|
+
|
|
10
|
+
def initialize(inventory:, instrumenter:)
|
|
11
|
+
@inventory = inventory
|
|
12
|
+
@instrumenter = instrumenter
|
|
13
|
+
@diagnostics = []
|
|
14
|
+
@installed = false
|
|
15
|
+
@units = index_units(inventory)
|
|
16
|
+
@hook_source_location = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def install
|
|
20
|
+
return status(:rejected, :preloaded_target) if preloaded_target?
|
|
21
|
+
|
|
22
|
+
unless hook_supported?
|
|
23
|
+
add_diagnostic("unsupported_runtime", "RubyVM::InstructionSequence cannot install a load_iseq hook")
|
|
24
|
+
return status(:rejected, :unsupported_runtime)
|
|
25
|
+
end
|
|
26
|
+
if competing_owner?
|
|
27
|
+
add_diagnostic("loader_conflict", "another load_iseq owner is already installed")
|
|
28
|
+
return status(:rejected, :loader_conflict)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
@installed = true
|
|
32
|
+
owner = self
|
|
33
|
+
RubyVM::InstructionSequence.define_singleton_method(:load_iseq) do |path|
|
|
34
|
+
owner.load_iseq(path)
|
|
35
|
+
end
|
|
36
|
+
RubyVM::InstructionSequence.instance_variable_set(:@branchproof_load_iseq_owner, self)
|
|
37
|
+
@hook_source_location = RubyVM::InstructionSequence.method(:load_iseq).source_location
|
|
38
|
+
status(:installed, nil)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def load_iseq(path)
|
|
42
|
+
return nil unless @installed
|
|
43
|
+
|
|
44
|
+
unit = @units[canonical(path)]
|
|
45
|
+
return nil unless unit
|
|
46
|
+
|
|
47
|
+
bytes = File.binread(path)
|
|
48
|
+
if digest_for(bytes) != expected_digest(unit)
|
|
49
|
+
add_diagnostic("source_drift", "selected source changed after inventory", unit[:source_id])
|
|
50
|
+
return nil
|
|
51
|
+
end
|
|
52
|
+
rewritten = @instrumenter.rewrite(unit: unit.merge(original_bytes: bytes))
|
|
53
|
+
Array(rewritten[:diagnostics]).each do |diagnostic|
|
|
54
|
+
add_diagnostic(diagnostic[:code], diagnostic[:message], unit[:source_id], severity: diagnostic[:severity])
|
|
55
|
+
end
|
|
56
|
+
unless rewritten[:changed]
|
|
57
|
+
if Array(rewritten[:diagnostics]).empty?
|
|
58
|
+
add_diagnostic("not_instrumented", "selected source had no safe edits", unit[:source_id],
|
|
59
|
+
severity: "info")
|
|
60
|
+
end
|
|
61
|
+
return nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
RubyVM::InstructionSequence.compile(
|
|
65
|
+
rewritten[:bytes], unit[:real_path] || canonical(path), unit[:real_path] || canonical(path), 1,
|
|
66
|
+
compile_options(unit)
|
|
67
|
+
)
|
|
68
|
+
rescue Errno::ENOENT => e
|
|
69
|
+
add_diagnostic("load_error", e.message, unit && unit[:source_id])
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def diagnostics
|
|
74
|
+
if @installed && !hook_owned?
|
|
75
|
+
add_diagnostic("loader_conflict", "load_iseq hook ownership changed after installation")
|
|
76
|
+
@installed = false
|
|
77
|
+
end
|
|
78
|
+
@diagnostics.dup.freeze
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def hook_supported?
|
|
84
|
+
defined?(RubyVM::InstructionSequence) && RubyVM::InstructionSequence.respond_to?(:compile)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def competing_owner?
|
|
88
|
+
return false unless RubyVM::InstructionSequence.respond_to?(:load_iseq)
|
|
89
|
+
|
|
90
|
+
owner = RubyVM::InstructionSequence.instance_variable_get(:@branchproof_load_iseq_owner)
|
|
91
|
+
return false if owner.equal?(self) && hook_owned?
|
|
92
|
+
|
|
93
|
+
true
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def hook_owned?
|
|
97
|
+
RubyVM::InstructionSequence.respond_to?(:load_iseq) &&
|
|
98
|
+
RubyVM::InstructionSequence.instance_variable_get(:@branchproof_load_iseq_owner).equal?(self) &&
|
|
99
|
+
RubyVM::InstructionSequence.method(:load_iseq).source_location == @hook_source_location
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def preloaded_target?
|
|
103
|
+
@units.keys.any? { |path| $LOADED_FEATURES.any? { |feature| canonical(feature) == path } }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def index_units(inventory)
|
|
107
|
+
Array(inventory[:source_units]).each_with_object({}) do |unit, index|
|
|
108
|
+
path = unit[:absolute_path] || unit[:real_path]
|
|
109
|
+
index[canonical(path)] = unit if path
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def canonical(path)
|
|
114
|
+
File.realpath(path.to_s)
|
|
115
|
+
rescue Errno::ENOENT
|
|
116
|
+
File.expand_path(path.to_s)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def expected_digest(unit)
|
|
120
|
+
unit[:digest] || digest_for(unit[:original_bytes].to_s)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def digest_for(bytes)
|
|
124
|
+
Digest::SHA256.hexdigest(bytes)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def compile_options(unit)
|
|
128
|
+
unit[:compile_options] || {}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def status(state, reason)
|
|
132
|
+
{ status: state.to_s, reason: reason&.to_s }.freeze
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def add_diagnostic(code, message, source_id = nil, severity: "error")
|
|
136
|
+
@diagnostics << { code: code.to_s, severity: severity.to_s, message: message, source_id: source_id,
|
|
137
|
+
decision_id: nil, execution_id: nil, test_id: nil, details: {} }.freeze
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable-next Lint/RedundantRequireStatement -- supports standalone core entry
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
module Branchproof
|
|
7
|
+
# Computes minimum vector or test covers for proven obligations.
|
|
8
|
+
class Minimizer
|
|
9
|
+
def initialize(analysis:, evidence:, limits:)
|
|
10
|
+
@analysis = analysis || {}
|
|
11
|
+
@evidence = evidence || {}
|
|
12
|
+
@limits = limits || {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(objective:, decision_ids:)
|
|
16
|
+
raise ArgumentError, "objective must be :vectors or :tests" unless %i[vectors tests].include?(objective)
|
|
17
|
+
|
|
18
|
+
scope = Array(decision_ids).map(&:to_s).sort
|
|
19
|
+
if objective == :vectors && scope.length != 1
|
|
20
|
+
raise ArgumentError,
|
|
21
|
+
"vector objective requires exactly one decision"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
target = target_obligations(scope)
|
|
25
|
+
return result(objective, scope, target, [], "EXACT_MINIMUM", nil, 0, []) if target.empty?
|
|
26
|
+
|
|
27
|
+
candidates = objective == :tests ? test_candidates(scope) : vector_candidates(scope)
|
|
28
|
+
candidates = candidates.transform_values { |signs| signs & target }
|
|
29
|
+
union = candidates.values.reduce(Set.new) { |set, signs| set | signs }
|
|
30
|
+
missing = target - union
|
|
31
|
+
unless missing.empty?
|
|
32
|
+
return result(objective, scope, target, [], "NOT_AVAILABLE", nil, 0,
|
|
33
|
+
["missing ownership: #{format_obligations(missing)}"])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
candidate_cap = integer_limit(:exact_candidates, 32)
|
|
37
|
+
exact_candidate_set = candidates.keys.length <= candidate_cap
|
|
38
|
+
unless exact_candidate_set
|
|
39
|
+
selected = greedy(candidates.keys.sort, candidates, target)
|
|
40
|
+
covered = selected.reduce(Set.new) { |set, candidate| set | candidates.fetch(candidate, Set.new) }
|
|
41
|
+
status = covered >= target ? "BEST_FOUND" : "NOT_AVAILABLE"
|
|
42
|
+
reasons = if status == "BEST_FOUND"
|
|
43
|
+
["candidate count exceeds exact search limit"]
|
|
44
|
+
else
|
|
45
|
+
["candidate union is incomplete"]
|
|
46
|
+
end
|
|
47
|
+
return result(objective, scope, target, selected, status, nil, 0, reasons, candidates)
|
|
48
|
+
end
|
|
49
|
+
selected, exact, lower, visited = search(candidates, target)
|
|
50
|
+
exact &&= exact_candidate_set
|
|
51
|
+
covered = selected.reduce(Set.new) { |set, candidate| set | candidates.fetch(candidate, Set.new) }
|
|
52
|
+
status = exact && covered >= target ? "EXACT_MINIMUM" : "BEST_FOUND"
|
|
53
|
+
result(objective, scope, target, selected, status, lower, visited,
|
|
54
|
+
status == "EXACT_MINIMUM" ? [] : ["exact search budget exhausted"], candidates)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def target_obligations(scope)
|
|
60
|
+
records(@analysis, :decisions).select { scope.include?(id(_1, :decision_id).to_s) }.flat_map do |decision|
|
|
61
|
+
records(decision, :condition_results).filter_map do |condition|
|
|
62
|
+
next unless id(condition, :status).to_s == "PROVEN"
|
|
63
|
+
|
|
64
|
+
condition_id = id(condition, :condition_id).to_s
|
|
65
|
+
index = records(decision, :conditions).find { id(_1, :id).to_s == condition_id }
|
|
66
|
+
idx = index && id(index, :index)
|
|
67
|
+
[true, false].map { |sign| [id(decision, :decision_id).to_s, idx, sign] }
|
|
68
|
+
end
|
|
69
|
+
end.flatten(1).to_set
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def vector_candidates(scope)
|
|
73
|
+
selected_decisions = records(@analysis, :decisions).select do |decision|
|
|
74
|
+
scope.include?(id(decision, :decision_id).to_s)
|
|
75
|
+
end
|
|
76
|
+
masks = selected_decisions.to_h do |decision|
|
|
77
|
+
[id(decision, :decision_id).to_s, id(decision, :effective_masks_by_vector) || {}]
|
|
78
|
+
end
|
|
79
|
+
records(@evidence, :vectors).each_with_object({}) do |vector, result|
|
|
80
|
+
decision_id = id(vector, :decision_id).to_s
|
|
81
|
+
next unless masks.key?(decision_id)
|
|
82
|
+
|
|
83
|
+
mask = masks[decision_id][id(vector, :id)] || masks[decision_id][id(vector, :id).to_s]
|
|
84
|
+
next unless mask
|
|
85
|
+
|
|
86
|
+
indexes = mask.to_i.digits(2).each_index.select { |index| mask.to_i[index] == 1 }
|
|
87
|
+
signs = indexes.flat_map do |index|
|
|
88
|
+
[[decision_id, index, true], [decision_id, index, false]].select do |obligation|
|
|
89
|
+
value(vector, index) == obligation[2]
|
|
90
|
+
end
|
|
91
|
+
end.to_set
|
|
92
|
+
result[id(vector, :id).to_s] = signs
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def test_candidates(scope)
|
|
97
|
+
vectors = vector_candidates(scope)
|
|
98
|
+
result = Hash.new { |hash, key| hash[key] = Set.new }
|
|
99
|
+
records(@evidence, :vectors).each do |vector|
|
|
100
|
+
test_ids = records(vector, :test_ids)
|
|
101
|
+
next if test_ids.empty?
|
|
102
|
+
|
|
103
|
+
signs = vectors[id(vector, :id).to_s]
|
|
104
|
+
next unless signs
|
|
105
|
+
|
|
106
|
+
test_ids.each { |test_id| result[test_id.to_s] |= signs }
|
|
107
|
+
end
|
|
108
|
+
result
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def search(candidates, target)
|
|
112
|
+
ids = candidates.keys.sort
|
|
113
|
+
best = greedy(ids, candidates, target)
|
|
114
|
+
nodes = 0
|
|
115
|
+
limit = integer_limit(:exact_search_nodes, 100_000)
|
|
116
|
+
exact = true
|
|
117
|
+
visit = lambda do |position, chosen, covered|
|
|
118
|
+
if nodes >= limit
|
|
119
|
+
exact = false
|
|
120
|
+
return
|
|
121
|
+
end
|
|
122
|
+
nodes += 1
|
|
123
|
+
if covered >= target
|
|
124
|
+
candidate = chosen.sort
|
|
125
|
+
if candidate.length < best.length || (candidate.length == best.length && (candidate <=> best) == -1)
|
|
126
|
+
best = candidate
|
|
127
|
+
end
|
|
128
|
+
return
|
|
129
|
+
end
|
|
130
|
+
return if position >= ids.length || chosen.length >= best.length
|
|
131
|
+
|
|
132
|
+
gain = candidates[ids[position]] - covered
|
|
133
|
+
visit.call(position + 1, chosen, covered)
|
|
134
|
+
visit.call(position + 1, chosen + [ids[position]], covered | gain) unless gain.empty?
|
|
135
|
+
end
|
|
136
|
+
visit.call(0, [], Set.new)
|
|
137
|
+
greatest_gain = candidates.values.map(&:length).max.to_i
|
|
138
|
+
lower_bound = greatest_gain.zero? ? nil : ((target.length + greatest_gain - 1) / greatest_gain)
|
|
139
|
+
[best, exact, lower_bound, nodes]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def greedy(ids, candidates, target)
|
|
143
|
+
chosen = []
|
|
144
|
+
covered = Set.new
|
|
145
|
+
until covered >= target
|
|
146
|
+
available = ids.reject { |candidate| chosen.include?(candidate) }
|
|
147
|
+
id = available.max_by { |candidate| [(candidates[candidate] - covered).length, -ids.index(candidate)] }
|
|
148
|
+
break unless id
|
|
149
|
+
break if (candidates[id] - covered).empty?
|
|
150
|
+
|
|
151
|
+
chosen << id
|
|
152
|
+
covered |= candidates[id]
|
|
153
|
+
end
|
|
154
|
+
chosen.sort.reverse_each do |candidate|
|
|
155
|
+
trial = chosen - [candidate]
|
|
156
|
+
trial_covered = trial.reduce(Set.new) { |set, item| set | candidates[item] }
|
|
157
|
+
chosen = trial if trial_covered >= target
|
|
158
|
+
end
|
|
159
|
+
chosen.sort
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def result(objective, scope, target, selected, status, lower, visited, reasons, candidates = {})
|
|
163
|
+
necessary = candidates.keys.reject do |candidate|
|
|
164
|
+
others = candidates.reject { |key, _| key == candidate }.values.reduce(Set.new, :|)
|
|
165
|
+
covers?(others, target)
|
|
166
|
+
end
|
|
167
|
+
interchangeable = candidates.keys.select do |candidate|
|
|
168
|
+
candidates.fetch(candidate).intersect?(target) && candidates.reject do |key, _|
|
|
169
|
+
key == candidate
|
|
170
|
+
end.values.reduce(Set.new, :|) >= target
|
|
171
|
+
end
|
|
172
|
+
{ objective: objective, scope_decision_ids: scope,
|
|
173
|
+
target_obligations: target.to_a.sort_by do |decision, index, sign|
|
|
174
|
+
[decision.to_s, index.to_i, sign ? 1 : 0]
|
|
175
|
+
end, selected_ids: selected,
|
|
176
|
+
status: status, lower_bound: lower, visited_nodes: visited, reasons: reasons,
|
|
177
|
+
necessary_ids: necessary, interchangeable_ids: interchangeable,
|
|
178
|
+
additional_ids: candidates.keys.sort - selected }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def format_obligations(obligations)
|
|
182
|
+
obligations.to_a.sort_by do |decision, index, sign|
|
|
183
|
+
[decision.to_s, index.to_i, sign ? 1 : 0]
|
|
184
|
+
end.map(&:inspect).join(", ")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def integer_limit(key, default)
|
|
188
|
+
value = id(@limits, key)
|
|
189
|
+
value&.to_i&.positive? ? value.to_i : default
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def covers?(set, target) = set >= target
|
|
193
|
+
|
|
194
|
+
def value(vector, index) = (vector[:values] || vector["values"])[index]
|
|
195
|
+
def id(record, key) = record[key].nil? ? record[key.to_s] : record[key]
|
|
196
|
+
def records(record, key) = Array(id(record, key))
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Branchproof
|
|
4
|
+
# Bridges one serial Minitest run to Runtime lifecycle ownership.
|
|
5
|
+
class MinitestAdapter
|
|
6
|
+
class << self
|
|
7
|
+
attr_accessor :active_adapter
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(runtime:)
|
|
11
|
+
@runtime = runtime
|
|
12
|
+
@registered = false
|
|
13
|
+
@tests = {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def capabilities
|
|
17
|
+
{ serial: true, phases: true }.freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run(test_files:, runner_args:, on_complete:, before_load: nil)
|
|
21
|
+
raise ArgumentError, "test_files must be an Array" unless test_files.is_a?(Array)
|
|
22
|
+
raise ArgumentError, "on_complete must respond to call" unless on_complete.respond_to?(:call)
|
|
23
|
+
|
|
24
|
+
reject_runner_args!(runner_args)
|
|
25
|
+
@runner_args = Array(runner_args).dup.freeze
|
|
26
|
+
require "minitest"
|
|
27
|
+
require "minitest/test"
|
|
28
|
+
self.class.active_adapter = self
|
|
29
|
+
install_lifecycle_hooks
|
|
30
|
+
install_runner_guard
|
|
31
|
+
Minitest.after_run do
|
|
32
|
+
on_complete.call(baseline_result)
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
@completion_error = e
|
|
35
|
+
ensure
|
|
36
|
+
self.class.active_adapter = nil
|
|
37
|
+
end
|
|
38
|
+
@loading_test_files = true
|
|
39
|
+
before_load&.call
|
|
40
|
+
test_files.each { |path| require path }
|
|
41
|
+
@registered = true
|
|
42
|
+
nil
|
|
43
|
+
ensure
|
|
44
|
+
@loading_test_files = false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
attr_reader :tests
|
|
48
|
+
|
|
49
|
+
def validate_runner!
|
|
50
|
+
runnables = defined?(Minitest::Runnable) ? Minitest::Runnable.runnables : []
|
|
51
|
+
parallel = parallel_executor_active? || runnables.any? { |runnable| parallel_runnable?(runnable) }
|
|
52
|
+
raise ArgumentError, "parallel test scheduling is unsupported" if parallel
|
|
53
|
+
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def install_lifecycle_hooks
|
|
60
|
+
return if self.class.instance_variable_defined?(:@hooks_installed)
|
|
61
|
+
|
|
62
|
+
Minitest::Test.prepend(Module.new do
|
|
63
|
+
define_method(:run) do |*args, &block|
|
|
64
|
+
adapter = Branchproof::MinitestAdapter.active_adapter
|
|
65
|
+
return super(*args, &block) unless adapter
|
|
66
|
+
|
|
67
|
+
begin
|
|
68
|
+
adapter.send(:begin_test, self)
|
|
69
|
+
adapter.send(:install_phase_hooks, self.class)
|
|
70
|
+
super(*args, &block)
|
|
71
|
+
ensure
|
|
72
|
+
adapter.send(:end_test, self)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end)
|
|
76
|
+
self.class.instance_variable_set(:@hooks_installed, true)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def install_runner_guard
|
|
80
|
+
return if Minitest.singleton_class.instance_variable_defined?(:@branchproof_runner_guard)
|
|
81
|
+
|
|
82
|
+
Minitest.singleton_class.prepend(Module.new do
|
|
83
|
+
define_method(:run) do |*args, &block|
|
|
84
|
+
adapter = Branchproof::MinitestAdapter.active_adapter
|
|
85
|
+
return super(*args, &block) unless adapter
|
|
86
|
+
|
|
87
|
+
if adapter.instance_variable_get(:@loading_test_files)
|
|
88
|
+
raise ArgumentError, "custom Minitest runners are unsupported"
|
|
89
|
+
end
|
|
90
|
+
if adapter.instance_variable_get(:@branchproof_native_run)
|
|
91
|
+
raise ArgumentError, "Minitest runner invoked more than once"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
adapter.instance_variable_set(:@branchproof_native_run, true)
|
|
95
|
+
super(*args, &block)
|
|
96
|
+
end
|
|
97
|
+
end)
|
|
98
|
+
Minitest.singleton_class.instance_variable_set(:@branchproof_runner_guard, true)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def parallel_executor_active?
|
|
102
|
+
return false unless Minitest.respond_to?(:parallel_executor)
|
|
103
|
+
|
|
104
|
+
executor = Minitest.parallel_executor
|
|
105
|
+
return false unless executor
|
|
106
|
+
|
|
107
|
+
rails_executor = defined?(ActiveSupport::Testing::ParallelizeExecutor) &&
|
|
108
|
+
executor.is_a?(ActiveSupport::Testing::ParallelizeExecutor)
|
|
109
|
+
return false unless rails_executor
|
|
110
|
+
|
|
111
|
+
size = executor.respond_to?(:size) ? executor.size : nil
|
|
112
|
+
size.nil? || size.to_i > 1
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def parallel_runnable?(runnable)
|
|
116
|
+
parallel_module = defined?(Minitest::Parallel::Test) && Minitest::Parallel::Test
|
|
117
|
+
return true if parallel_module && runnable.ancestors.include?(parallel_module)
|
|
118
|
+
|
|
119
|
+
runnable.respond_to?(:test_order) && runnable.test_order == :parallel
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def begin_test(test)
|
|
123
|
+
test_id = test_id_for(test)
|
|
124
|
+
source = begin
|
|
125
|
+
test.method(test.name).source_location
|
|
126
|
+
rescue StandardError
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
@tests[test_id] = { id: test_id, adapter: "minitest", name: test.name,
|
|
130
|
+
source: source && { path: source[0], line: source[1] }, class_name: test.class.name,
|
|
131
|
+
method_name: test.name, status: "running", phase_counts: {} }
|
|
132
|
+
register_test(@tests[test_id])
|
|
133
|
+
context(test_id, "setup")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def install_phase_hooks(test_class)
|
|
137
|
+
return if test_class.instance_variable_defined?(:@branchproof_phase_hooks)
|
|
138
|
+
|
|
139
|
+
adapter = self
|
|
140
|
+
test_class.prepend(Module.new do
|
|
141
|
+
define_method(:after_setup) do |*args, &block|
|
|
142
|
+
result = super(*args, &block)
|
|
143
|
+
adapter.send(:context, adapter.send(:test_id_for, self), "body")
|
|
144
|
+
result
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
define_method(:before_teardown) do |*args, &block|
|
|
148
|
+
adapter.send(:context, adapter.send(:test_id_for, self), "teardown")
|
|
149
|
+
super(*args, &block)
|
|
150
|
+
end
|
|
151
|
+
end)
|
|
152
|
+
test_class.instance_variable_set(:@branchproof_phase_hooks, true)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def end_test(test)
|
|
156
|
+
test_id = test_id_for(test)
|
|
157
|
+
context(nil, "unattributed")
|
|
158
|
+
record = @tests[test_id]
|
|
159
|
+
return unless record
|
|
160
|
+
|
|
161
|
+
skipped = test.failures.any? do |failure|
|
|
162
|
+
(failure.respond_to?(:skipped?) && failure.skipped?) ||
|
|
163
|
+
(defined?(Minitest::Skip) && failure.respond_to?(:error) && failure.error.is_a?(Minitest::Skip)) ||
|
|
164
|
+
failure.class.name.to_s.include?("Skip")
|
|
165
|
+
end
|
|
166
|
+
record[:status] = if skipped
|
|
167
|
+
"skipped"
|
|
168
|
+
else
|
|
169
|
+
(test.failures.empty? ? "passed" : "failed")
|
|
170
|
+
end
|
|
171
|
+
register_test(record)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def context(test_id, phase)
|
|
175
|
+
if test_id && @tests[test_id]
|
|
176
|
+
counts = @tests[test_id][:phase_counts]
|
|
177
|
+
counts[phase] = counts.fetch(phase, 0) + 1
|
|
178
|
+
end
|
|
179
|
+
return unless @runtime.respond_to?(:context)
|
|
180
|
+
|
|
181
|
+
@runtime.context(test_id: test_id, phase: phase)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def test_id_for(test)
|
|
185
|
+
source = begin
|
|
186
|
+
test.method(test.name).source_location
|
|
187
|
+
rescue NameError
|
|
188
|
+
nil
|
|
189
|
+
end
|
|
190
|
+
if defined?(Branchproof::Records)
|
|
191
|
+
Branchproof::Records.id(adapter: "minitest", class_name: test.class.name, method_name: test.name,
|
|
192
|
+
source: source)
|
|
193
|
+
else
|
|
194
|
+
"minitest:#{test.class}:#{test.name}:#{source}"
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def register_test(test)
|
|
199
|
+
if @runtime.respond_to?(:register_test)
|
|
200
|
+
@runtime.register_test(test: test)
|
|
201
|
+
else
|
|
202
|
+
evidence = @runtime.instance_variable_get(:@evidence)
|
|
203
|
+
evidence.register_test(test: test) if evidence.respond_to?(:register_test)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def baseline_result
|
|
208
|
+
if @completion_error
|
|
209
|
+
return { status: "ERROR", executed_tests: @tests.length, failed_tests: 0, skipped_tests: 0,
|
|
210
|
+
exit_status: 2, finalized: false,
|
|
211
|
+
diagnostics: [{ code: "completion_callback", severity: "error", message: @completion_error.message }] }
|
|
212
|
+
end
|
|
213
|
+
tests = @tests.values
|
|
214
|
+
failed = tests.count { |test| test[:status] == "failed" }
|
|
215
|
+
skipped = tests.count { |test| test[:status] == "skipped" }
|
|
216
|
+
{
|
|
217
|
+
status: if tests.empty?
|
|
218
|
+
"INCOMPLETE"
|
|
219
|
+
else
|
|
220
|
+
(failed.zero? ? "PASSED" : "FAILED")
|
|
221
|
+
end,
|
|
222
|
+
executed_tests: tests.length, failed_tests: failed, skipped_tests: skipped,
|
|
223
|
+
seed: seed_value, exit_status: failed.zero? ? 0 : 1,
|
|
224
|
+
finalized: true, tests: tests
|
|
225
|
+
}
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def seed_value
|
|
229
|
+
index = @runner_args.index("--seed")
|
|
230
|
+
if index && @runner_args[index + 1]
|
|
231
|
+
@runner_args[index + 1].to_i
|
|
232
|
+
else
|
|
233
|
+
(Minitest.respond_to?(:seed) ? Minitest.seed : nil)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def reject_runner_args!(args)
|
|
238
|
+
forbidden = Array(args).select do |arg|
|
|
239
|
+
%w[--parallel --parallelize --fork --processes
|
|
240
|
+
--runner].include?(arg.to_s) || arg.to_s.start_with?("--parallel=", "--fork=", "--processes=")
|
|
241
|
+
end
|
|
242
|
+
raise ArgumentError, "parallel, forked, and custom runners are unsupported" unless forbidden.empty?
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Branchproof
|
|
4
|
+
# Resolves the project policy used by the isolated analysis worker.
|
|
5
|
+
class Project
|
|
6
|
+
MODES = %w[auto ruby rails].freeze
|
|
7
|
+
RAILS_ENVIRONMENT = {
|
|
8
|
+
"RAILS_ENV" => "test",
|
|
9
|
+
"RACK_ENV" => "test",
|
|
10
|
+
"PARALLEL_WORKERS" => "1",
|
|
11
|
+
"DISABLE_BOOTSNAP" => "1",
|
|
12
|
+
"DISABLE_SPRING" => "1"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def initialize(root:, mode: "auto")
|
|
16
|
+
@root = File.expand_path(root)
|
|
17
|
+
@mode = mode.to_s
|
|
18
|
+
raise ArgumentError, "project must be auto, ruby, or rails" unless MODES.include?(@mode)
|
|
19
|
+
|
|
20
|
+
validate_root!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_h
|
|
24
|
+
{
|
|
25
|
+
kind: kind,
|
|
26
|
+
root: @root,
|
|
27
|
+
load_paths: [File.join(@root, "lib"), File.join(@root, "test")],
|
|
28
|
+
environment: kind == "rails" ? RAILS_ENVIRONMENT.dup : {}
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def kind
|
|
35
|
+
return "rails" if @mode == "rails"
|
|
36
|
+
return "ruby" if @mode == "ruby"
|
|
37
|
+
|
|
38
|
+
rails_files? ? "rails" : "ruby"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def rails_files?
|
|
42
|
+
File.file?(File.join(@root, "config", "application.rb")) &&
|
|
43
|
+
File.file?(File.join(@root, "config", "environment.rb"))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate_root!
|
|
47
|
+
raise ArgumentError, "project root does not exist: #{@root}" unless File.directory?(@root)
|
|
48
|
+
return unless @mode == "rails" && !rails_files?
|
|
49
|
+
|
|
50
|
+
raise ArgumentError, "Rails project requires config/application.rb and config/environment.rb"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|