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,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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Branchproof
4
+ VERSION = "0.2.0"
5
+ 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
@@ -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
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "branchproof"
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)