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,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Layout/LineLength, Lint/DuplicateBranch
4
+
5
+ require "json"
6
+ require "pathname"
7
+ require "tmpdir"
8
+ require "open3"
9
+ require "rbconfig"
10
+ require "stringio"
11
+ require "fileutils"
12
+ require "securerandom"
13
+
14
+ module Branchproof
15
+ # Coordinates source inventory, isolated test execution, and report output.
16
+ class CLI
17
+ def initialize(stdout:, stderr:)
18
+ @stdout = stdout
19
+ @stderr = stderr
20
+ end
21
+
22
+ def call(argv)
23
+ options = parse(Array(argv))
24
+ return usage_error("analyze is the only supported command") unless options
25
+
26
+ inventory = build_inventory(options)
27
+ evidence = empty_evidence(inventory, options)
28
+ baseline = if options[:tests].empty?
29
+ { status: "INCOMPLETE", executed_tests: 0, failed_tests: 0, skipped_tests: 0, finalized: false }
30
+ else
31
+ run_worker(options, inventory, evidence)
32
+ end
33
+ if value(baseline, :evidence)
34
+ merge_status = evidence.merge(snapshot: value(baseline, :evidence))
35
+ baseline[:merge_status] = merge_status
36
+ if merge_status[:status].to_s == "merged"
37
+ baseline[:evidence] = evidence.snapshot
38
+ else
39
+ baseline[:status] = "ERROR"
40
+ baseline[:finalized] = false
41
+ baseline[:diagnostics] =
42
+ Array(value(baseline,
43
+ :diagnostics)) + [{ code: "evidence_merge", severity: "error",
44
+ message: merge_status[:reason].to_s }]
45
+ end
46
+ end
47
+ if options[:level] >= 2 && value(baseline, :status).to_s == "PASSED"
48
+ analysis = Analyzer.new(inventory: inventory, evidence: evidence.snapshot, limits: options[:limits]).call
49
+ baseline[:analysis] = analysis
50
+ if options[:level] >= 2
51
+ ids = Array(value(inventory, :decisions)).map { |decision| value(decision, :id) }
52
+ baseline[:minima] = ids.filter_map do |decision_id|
53
+ Minimizer.new(analysis: analysis, evidence: evidence.snapshot, limits: options[:limits]).call(objective: :vectors, decision_ids: [decision_id])
54
+ end
55
+ baseline[:minima] += ids.filter_map do |decision_id|
56
+ Minimizer.new(analysis: analysis, evidence: evidence.snapshot, limits: options[:limits]).call(
57
+ objective: :tests, decision_ids: [decision_id]
58
+ )
59
+ end
60
+ baseline[:minima] << Minimizer.new(analysis: analysis, evidence: evidence.snapshot, limits: options[:limits]).call(
61
+ objective: :tests, decision_ids: ids
62
+ )
63
+ end
64
+ end
65
+ diagnostics = Array(value(inventory, :diagnostics)) + Array(value(baseline, :diagnostics)) +
66
+ Array(value(evidence.snapshot,
67
+ :diagnostics)) + Array(value(value(baseline, :analysis), :diagnostics))
68
+ analysis = options[:level] == 1 ? nil : value(baseline, :analysis)
69
+ minima = options[:level] == 1 ? [] : Array(value(baseline, :minima))
70
+ report = Report.new(inventory: inventory, evidence: value(baseline, :evidence) || evidence.snapshot,
71
+ analysis: analysis, minima: minima, baseline: baseline, diagnostics: diagnostics,
72
+ level: options[:level])
73
+ output_report(report, options)
74
+ report.exit_code
75
+ rescue ArgumentError => e
76
+ usage_error(e.message)
77
+ rescue JSON::ParserError => e
78
+ usage_error("invalid JSON limits: #{e.message}")
79
+ end
80
+
81
+ private
82
+
83
+ def parse(argv)
84
+ return nil if argv.empty? || argv.first != "analyze"
85
+
86
+ args = argv.drop(1)
87
+ delimiter = args.index("--")
88
+ runner_args = delimiter ? args[(delimiter + 1)..] : []
89
+ args = args[0...delimiter] if delimiter
90
+ options = { level: 3, format: :terminal, output: nil, tests: [], source_paths: [], limits: Limits.default,
91
+ runner_args: runner_args, project: nil }
92
+ until args.empty?
93
+ token = args.shift
94
+ case token
95
+ when "--level"
96
+ level = Integer(args.shift.to_s, 10)
97
+ raise ArgumentError, "level must be 1, 2, or 3" unless (1..3).cover?(level)
98
+
99
+ options[:level] = level
100
+ when "--format"
101
+ format = args.shift.to_s
102
+ raise ArgumentError, "format must be terminal or json" unless %w[terminal json].include?(format)
103
+
104
+ options[:format] = format.to_sym
105
+ when "--output"
106
+ options[:output] = args.shift
107
+ raise ArgumentError, "--output requires a path" if options[:output].nil? || options[:output].empty?
108
+ when "--test"
109
+ options[:tests] << args.shift
110
+ raise ArgumentError, "--test requires a glob" if options[:tests].last.nil? || options[:tests].last.empty?
111
+ when "--project"
112
+ mode = args.shift
113
+ raise ArgumentError, "--project requires auto, ruby, or rails" if mode.nil? || mode.empty?
114
+
115
+ options[:project] = Project.new(root: Dir.pwd, mode: mode).to_h
116
+ when "--limits"
117
+ limits_path = args.shift
118
+ raise ArgumentError, "--limits requires a readable JSON path" unless limits_path && File.file?(limits_path)
119
+
120
+ payload = JSON.parse(File.read(limits_path))
121
+ options[:limits] = Limits.normalize(payload.transform_keys(&:to_sym))
122
+ when "--help" then return nil
123
+ else
124
+ raise ArgumentError, "unknown option: #{token}" if token.start_with?("-")
125
+
126
+ options[:source_paths] << token
127
+ end
128
+ end
129
+ options[:project] ||= Project.new(root: Dir.pwd, mode: "auto").to_h
130
+ options[:source_paths] = default_sources if options[:source_paths].empty?
131
+ options[:tests] = default_tests if options[:tests].empty?
132
+ options[:tests] = expand_paths(options[:tests], root: options[:project][:root])
133
+ options
134
+ end
135
+
136
+ def build_inventory(options)
137
+ test_paths = options[:tests].filter_map do |path|
138
+ File.realpath(path)
139
+ rescue StandardError
140
+ nil
141
+ end
142
+ loaded_paths = $LOADED_FEATURES.filter_map do |path|
143
+ File.realpath(path)
144
+ rescue StandardError
145
+ nil
146
+ end
147
+ selected = expand_paths(options[:source_paths]).reject do |path|
148
+ relative = Pathname.new(path).relative_path_from(Pathname.new(Dir.pwd)).to_s
149
+ canonical = File.realpath(path)
150
+ relative.match?(%r{\A(?:test|spec|tool|vendor)(?:/|\z)}) ||
151
+ test_paths.include?(canonical) || loaded_paths.include?(canonical)
152
+ end
153
+ Source.new(root: Dir.pwd, limits: options[:limits]).inventory(paths: selected)
154
+ end
155
+
156
+ def empty_evidence(inventory, options)
157
+ Evidence.new(inventory: inventory, limits: options[:limits], run_id: SecureRandom.uuid)
158
+ end
159
+
160
+ def run_worker(options, inventory, evidence)
161
+ begin
162
+ directory = Dir.mktmpdir("branchproof-run-")
163
+ config = File.join(directory, "request.json")
164
+ payload = {
165
+ inventory: inventory, limits: options[:limits], run_id: evidence.run_id,
166
+ test_files: options[:tests], runner_args: options[:runner_args], project: options[:project],
167
+ result_path: File.join(directory, "result.json"), marker_path: File.join(directory, "complete.marker")
168
+ }
169
+ File.binwrite(config, JSON.generate(normalize(payload)))
170
+ script = "require 'branchproof'; exit(Branchproof::Worker.child_process(ARGV.fetch(0)).to_i)"
171
+ child_stdout, stderr, child_status = Open3.capture3(options[:project][:environment], RbConfig.ruby, "-I",
172
+ File.expand_path("..", __dir__), "-e", script, config,
173
+ chdir: options[:project][:root])
174
+ @stderr.write(child_stdout) unless child_stdout.to_s.empty?
175
+ @stderr.write(stderr) unless stderr.to_s.empty?
176
+ unless File.file?(payload[:marker_path]) && File.file?(payload[:result_path])
177
+ return { status: "ERROR", executed_tests: 0, failed_tests: 0, skipped_tests: 0, finalized: false,
178
+ diagnostics: [{ code: "worker_incomplete", severity: "error", message: stderr.to_s.strip }] }
179
+ end
180
+ result = JSON.parse(File.binread(payload[:result_path]))
181
+ result["exit_status"] = child_status.exitstatus
182
+ if child_status.exitstatus != 0 && result["status"] == "PASSED"
183
+ result["status"] = child_status.exitstatus == 1 ? "FAILED" : "ERROR"
184
+ result["finalized"] = false
185
+ end
186
+ symbolize(result)
187
+ ensure
188
+ FileUtils.remove_entry(directory) if directory && File.directory?(directory)
189
+ end
190
+ rescue StandardError => e
191
+ { status: "ERROR", executed_tests: 0, failed_tests: 0, skipped_tests: 0, finalized: false,
192
+ diagnostics: [{ code: "runner", severity: "error", message: e.message }] }
193
+ end
194
+
195
+ def output_report(report, options)
196
+ if options[:output]
197
+ temporary = "#{options[:output]}.tmp-#{Process.pid}"
198
+ File.binwrite(temporary, report_string(report, options[:format]))
199
+ File.rename(temporary, options[:output])
200
+ else
201
+ @stdout.write(report_string(report, options[:format]))
202
+ end
203
+ end
204
+
205
+ def report_string(report, format)
206
+ buffer = StringIO.new
207
+ report.write(io: buffer, format: format)
208
+ buffer.string
209
+ end
210
+
211
+ def usage_error(message)
212
+ @stderr.write("mcdc: #{message}\n")
213
+ 2
214
+ end
215
+
216
+ def default_sources
217
+ %w[lib/**/*.rb app/**/*.rb]
218
+ end
219
+
220
+ def default_tests
221
+ candidates = Dir.glob("test/**/*_test.rb", base: Dir.pwd) + Dir.glob("test/**/test_*.rb", base: Dir.pwd)
222
+ candidates.map! { |path| File.expand_path(path, Dir.pwd) }
223
+ candidates.reject { |path| default_test_excluded?(path) }.uniq.sort
224
+ end
225
+
226
+ def default_test_excluded?(path)
227
+ relative = Pathname.new(path).relative_path_from(Pathname.new(Dir.pwd)).to_s
228
+ segments = relative.split(File::SEPARATOR)
229
+ basename = File.basename(path)
230
+ basename == "test_helper.rb" || segments.include?("support") || segments.include?("fixtures")
231
+ end
232
+
233
+ def expand_paths(paths, root: Dir.pwd)
234
+ paths.flat_map do |path|
235
+ Dir.glob(path, base: root).map { |item| File.expand_path(item, root) }
236
+ end.uniq.sort
237
+ end
238
+
239
+ def value(hash, key)
240
+ return nil unless hash.respond_to?(:key?)
241
+
242
+ hash[key] || hash[key.to_s]
243
+ end
244
+
245
+ def normalize(value)
246
+ case value
247
+ when Hash then value.each_with_object({}) do |(key, item), result|
248
+ result[key.to_s] = normalize(item) unless key.to_s == "original_bytes"
249
+ end
250
+ when Array then value.map { normalize(_1) }
251
+ when Symbol then value.to_s
252
+ when String then value.encode("UTF-8", invalid: :replace, undef: :replace, replace: "�")
253
+ when Numeric, TrueClass, FalseClass, NilClass then value
254
+ else value.to_s
255
+ end
256
+ end
257
+
258
+ def symbolize(value)
259
+ return value.map { symbolize(_1) } if value.is_a?(Array)
260
+ return value.transform_keys(&:to_sym).transform_values { symbolize(_1) } if value.is_a?(Hash)
261
+
262
+ value
263
+ end
264
+ end
265
+ end
266
+ # rubocop:enable Layout/LineLength, Lint/DuplicateBranch