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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +36 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +202 -0
  5. data/NOTICE +3 -0
  6. data/README.md +242 -0
  7. data/doc/Branchproof/Analyzer.md +26 -0
  8. data/doc/Branchproof/CLI.md +15 -0
  9. data/doc/Branchproof/Error.md +6 -0
  10. data/doc/Branchproof/Evidence.md +46 -0
  11. data/doc/Branchproof/Instrumenter.md +18 -0
  12. data/doc/Branchproof/Limits.md +21 -0
  13. data/doc/Branchproof/Loader.md +25 -0
  14. data/doc/Branchproof/Minimizer.md +15 -0
  15. data/doc/Branchproof/MinitestAdapter.md +28 -0
  16. data/doc/Branchproof/Project.md +23 -0
  17. data/doc/Branchproof/RailsSupport/Error.md +6 -0
  18. data/doc/Branchproof/RailsSupport.md +32 -0
  19. data/doc/Branchproof/Records.md +38 -0
  20. data/doc/Branchproof/Report.md +26 -0
  21. data/doc/Branchproof/Runtime.md +37 -0
  22. data/doc/Branchproof/Source.md +23 -0
  23. data/doc/Branchproof/Worker.md +45 -0
  24. data/doc/Branchproof.md +33 -0
  25. data/doc/CHANGELOG.md +36 -0
  26. data/doc/README.md +242 -0
  27. data/exe/mcdc +6 -0
  28. data/lib/branchproof/analyzer.rb +454 -0
  29. data/lib/branchproof/cli.rb +266 -0
  30. data/lib/branchproof/evidence.rb +484 -0
  31. data/lib/branchproof/instrumenter.rb +150 -0
  32. data/lib/branchproof/limits.rb +44 -0
  33. data/lib/branchproof/loader.rb +140 -0
  34. data/lib/branchproof/minimizer.rb +198 -0
  35. data/lib/branchproof/minitest_adapter.rb +245 -0
  36. data/lib/branchproof/project.rb +53 -0
  37. data/lib/branchproof/rails_support.rb +74 -0
  38. data/lib/branchproof/records.rb +76 -0
  39. data/lib/branchproof/report.rb +412 -0
  40. data/lib/branchproof/runtime.rb +171 -0
  41. data/lib/branchproof/source.rb +238 -0
  42. data/lib/branchproof/version.rb +5 -0
  43. data/lib/branchproof/worker.rb +145 -0
  44. data/lib/branchproof.rb +24 -0
  45. data/lib/mcdc.rb +3 -0
  46. data/llms.txt +33 -0
  47. data/sig/branchproof.rbs +116 -0
  48. 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