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,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
|