branchproof 0.2.0 → 0.4.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.
data/doc/README.md CHANGED
@@ -6,8 +6,8 @@ modifier, and ordinary ternary (`?:`) decisions, records observed vectors,
6
6
  and reports independence evidence, missing counterpart constraints, and
7
7
  smaller supporting test sets.
8
8
 
9
- The gem is named `branchproof`; its command and compatibility namespace are
10
- `mcdc` and `MCDC`.
9
+ The gem and primary command are named `branchproof`. The `mcdc` command and
10
+ `MCDC` namespace remain compatibility aliases with the same behavior.
11
11
 
12
12
  ## Installation
13
13
 
@@ -30,12 +30,12 @@ instead of being counted as coverage.
30
30
 
31
31
  ## Analyze a test run
32
32
 
33
- Run `mcdc analyze` with the source files or globs to inspect, followed by
33
+ Run `branchproof analyze` with the source files or globs to inspect, followed by
34
34
  options. A second `--` separates Branchproof options from arguments passed to
35
35
  Minitest unchanged:
36
36
 
37
37
  ```sh
38
- mcdc analyze 'lib/**/*.rb' --test 'test/**/*_test.rb' \
38
+ branchproof analyze 'lib/**/*.rb' --test 'test/**/*_test.rb' \
39
39
  --level 3 \
40
40
  --format terminal \
41
41
  --output tmp/branchproof.txt \
@@ -47,14 +47,14 @@ For a plain Ruby application, select the project policy explicitly when
47
47
  running from the application root:
48
48
 
49
49
  ```sh
50
- bundle exec mcdc analyze 'lib/**/*.rb' --project ruby --test 'test/**/*_test.rb'
50
+ bundle exec branchproof analyze 'lib/**/*.rb' --project ruby --test 'test/**/*_test.rb'
51
51
  ```
52
52
 
53
53
  For a Rails application, run the same command from the application root so
54
54
  the application's bundle and Rails version remain in effect:
55
55
 
56
56
  ```sh
57
- bundle exec mcdc analyze 'app/**/*.rb' --project rails --test 'test/**/*_test.rb'
57
+ bundle exec branchproof analyze 'app/**/*.rb' --project rails --test 'test/**/*_test.rb'
58
58
  ```
59
59
 
60
60
  For a project rooted at the current directory, `--project auto` is the
@@ -97,7 +97,7 @@ For example, running the contents of the small `decision.rb` /
97
97
  with the terminal format produces a summary like this:
98
98
 
99
99
  ```text
100
- Branchproof 0.2.0
100
+ Branchproof 0.4.0
101
101
  Tests: PASSED (3 tests, 0 failed, 0 skipped)
102
102
  MC/DC: 100.0% (2/2 conditions proven)
103
103
  Analysis: COMPLETE
@@ -126,6 +126,53 @@ Additional tests outside this MC/DC evidence set may improve coverage.
126
126
  The test execution still runs once for the selected level. Use `--format
127
127
  json` when a consumer needs the complete identifiers and versioned schema.
128
128
 
129
+ ### Find missing cases
130
+
131
+ Use `--missing-only` to focus the terminal report on conditions that still
132
+ lack independence evidence:
133
+
134
+ ```sh
135
+ bundle exec branchproof analyze 'lib/**/*.rb' --missing-only
136
+ ```
137
+
138
+ This keeps the overall summary and diagnostics, hides proven conditions and
139
+ supporting-set lists, and shows missing scenarios using the source expressions.
140
+ It works with levels 2 and 3 (the default). JSON remains the complete report;
141
+ `--missing-only` cannot be combined with `--format json` or `--level 1`.
142
+
143
+ For example, given this decision:
144
+
145
+ ```ruby
146
+ content && Instruction.installed?(content)
147
+ ```
148
+
149
+ Observing `[TT]` and `[F-]` proves the effect of `content`, but does not prove
150
+ the effect of `Instruction.installed?(content)`. The missing case is `[TF]`:
151
+ `content` must be truthy and `Instruction.installed?(content)` must be falsey,
152
+ making the decision false. The missing condition is presented as:
153
+
154
+ ```text
155
+ Condition 1: Instruction.installed?(content)
156
+ NOT_PROVEN — missing observation
157
+ Need an observation where:
158
+ content is truthy
159
+ Instruction.installed?(content) is falsey
160
+ Expected decision: false [TF]
161
+ ```
162
+
163
+ An existing file without the instruction block
164
+ may produce this case; the test must reach the reported line. The report
165
+ describes required truth values, not application inputs or guaranteed
166
+ reachable paths. In Ruby, only `false` and `nil` are falsey; an empty string
167
+ is truthy.
168
+
169
+ `NOT_PROVEN` means analysis ran but did not find the required pair of
170
+ observations. `NOT CALCULATED` means analysis was not available or was not
171
+ requested; check the test status and diagnostics before adding tests.
172
+ `Analysis: COMPLETE` means the analysis finished, not that every condition
173
+ was proven. Missing conditions may require new tests or changes to existing
174
+ test inputs; they do not by themselves prove a bug in the application.
175
+
129
176
  The levels select how much of the one-run result is displayed:
130
177
 
131
178
  * Level 1 reports observed vectors grouped by their raw decision outcomes.
@@ -138,6 +185,95 @@ test suite. A failed, unsupported, or incomplete run is reported with its
138
185
  status and diagnostics and cannot become a successful coverage result by
139
186
  changing the display level.
140
187
 
188
+ ### Focused condition and test views
189
+
190
+ Use `--view conditions` to group the report by condition. Each condition shows
191
+ its expression, decision, and project-relative source location with the
192
+ condition's own 1-based start line. The view separates tests that evaluated
193
+ the condition true or false from tests where it was short-circuited. Level 3
194
+ also shows the canonical witness observations selected by the analyzer and
195
+ their owning tests.
196
+
197
+ ```sh
198
+ bundle exec branchproof analyze 'lib/**/*.rb' --view conditions
199
+ bundle exec branchproof analyze 'lib/**/*.rb' --view conditions --missing-only
200
+ ```
201
+
202
+ Use `--view tests` to group the same evidence by test. Rows include each
203
+ exercised condition, its relative source location, observed values, and the
204
+ recorded `setup`, `body`, or `teardown` phases. Tests with no completed
205
+ condition observation remain visible, as do unattributed and unexecuted
206
+ conditions. A test that evaluates both Boolean values is evidence of execution;
207
+ it is a proof contributor only when the analyzer's independent witness pair
208
+ uses its observations.
209
+
210
+ `--view` changes terminal grouping and does not change instrumentation or test
211
+ execution. JSON output always contains the complete evidence document, so an
212
+ explicit view cannot be combined with `--format json`. The `mcdc` executable
213
+ accepts the same arguments for existing scripts.
214
+
215
+ ### Saved reports and offline comparison
216
+
217
+ Reports are saved only when requested. Create a local artifact directory and
218
+ write a complete JSON baseline with the existing atomic `--output` option:
219
+
220
+ ```sh
221
+ mkdir -p .branchproof
222
+ bundle exec branchproof analyze 'lib/**/*.rb' --format json \
223
+ --output .branchproof/baseline.json
224
+ bundle exec branchproof report .branchproof/baseline.json --view conditions
225
+ bundle exec branchproof report .branchproof/baseline.json --view tests
226
+ ```
227
+
228
+ The output's parent directory must already exist. Replacing a baseline is an
229
+ explicit `analyze --format json --output` operation; keep CI snapshots as
230
+ artifacts when you need to retain multiple runs.
231
+
232
+ The `report` command reads the saved document without loading the application
233
+ or running tests. It uses locations and metadata captured in the report, so
234
+ rendering remains useful after the original checkout has moved or been
235
+ removed. Level 1 reports observations; levels 2 and 3 require corresponding
236
+ analysis in the saved report. The repository ignores `.branchproof/`; choose a
237
+ different path and CI artifact policy when a project needs to retain reports.
238
+ Saved JSON includes existing raw metadata such as test names and expressions;
239
+ relative terminal labels do not mean every legacy JSON field is sanitized.
240
+
241
+ To compare two explicitly saved runs:
242
+
243
+ ```sh
244
+ bundle exec branchproof analyze 'lib/**/*.rb' --format json \
245
+ --output .branchproof/current.json
246
+ bundle exec branchproof compare .branchproof/baseline.json \
247
+ .branchproof/current.json
248
+ bundle exec branchproof compare .branchproof/baseline.json \
249
+ .branchproof/current.json --fail-on-regression
250
+ ```
251
+
252
+ Comparison matches exact condition identities from unchanged source files.
253
+ Changed source files, changed selection or runtime context, incomplete runs,
254
+ and legacy reports missing comparison metadata are reported as partial or
255
+ incomplete context rather than guessed regressions. The output distinguishes
256
+ gained proof, lost proof, changed sources, newly selected files, and files no
257
+ longer present in a report. Previous witness values and owner names are shown
258
+ for lost proof; an absent owner is described as `not observed in current run`,
259
+ not as a deleted test. A different test population under unchanged discovery
260
+ patterns is valid comparison context, and seed differences are disclosed.
261
+
262
+ `compare` exits 0 for a complete comparison, including one with coverage
263
+ changes; `--fail-on-regression` exits 1 when a complete comparable run loses
264
+ proof. Invalid input or an incomplete comparison exits 2, which takes
265
+ precedence. Reports are explicit snapshots: comparison never creates history,
266
+ promotes a baseline, or overwrites either input.
267
+
268
+ MC/DC has two related questions. Evaluation asks whether a condition was
269
+ observed with a value, including short-circuiting. Independent proof asks
270
+ whether the analyzer found a pair of observations where that condition changes
271
+ the decision outcome under the masking criterion. Other conditions may be
272
+ short-circuited or masked rather than fixed to the same observed values. For example,
273
+ `left && right` observed as `[TT]` and `[F-]` evaluates `right` once and
274
+ short-circuits it once, but does not prove `right`; `[TF]` is also required.
275
+ The condition and test views preserve that distinction.
276
+
141
277
  ### Supported conditional forms
142
278
 
143
279
  Ordinary Ruby ternaries use the same predicate instrumentation and `&&`/`||`
@@ -183,7 +319,7 @@ Everything after the argument separator is passed as individual arguments to
183
319
  the serial Minitest runner. This is useful for seeds and name filters:
184
320
 
185
321
  ```sh
186
- mcdc analyze 'lib/**/*.rb' --level 1 -- --seed 9001 -n /checkout/
322
+ branchproof analyze 'lib/**/*.rb' --level 1 -- --seed 9001 -n /checkout/
187
323
  ```
188
324
 
189
325
  The 0.2 release supports serial Minitest execution in plain Ruby projects and
data/exe/branchproof ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+ require "branchproof"
6
+ exit Branchproof::CLI.new(stdout: $stdout, stderr: $stderr).call(ARGV)
@@ -181,7 +181,7 @@ module Branchproof
181
181
  end
182
182
 
183
183
  effective_mask(vector, id(decision, :id), decision[:tree])
184
- vector
184
+ decision_source.nil? ? vector : vector.merge(source_id: decision_source)
185
185
  rescue ArgumentError
186
186
  @analysis_invalid = true
187
187
  add_diagnostic("invalid_vector", "error", id(vector, :id))
@@ -10,6 +10,7 @@ require "rbconfig"
10
10
  require "stringio"
11
11
  require "fileutils"
12
12
  require "securerandom"
13
+ require "time"
13
14
 
14
15
  module Branchproof
15
16
  # Coordinates source inventory, isolated test execution, and report output.
@@ -20,8 +21,12 @@ module Branchproof
20
21
  end
21
22
 
22
23
  def call(argv)
24
+ argv = Array(argv)
25
+ return help if [["--help"], ["help"], ["analyze", "--help"]].include?(argv)
26
+ return offline(argv) if %w[report compare].include?(argv.first)
27
+
23
28
  options = parse(Array(argv))
24
- return usage_error("analyze is the only supported command") unless options
29
+ return usage_error("expected analyze, report, or compare; use branchproof --help") unless options
25
30
 
26
31
  inventory = build_inventory(options)
27
32
  evidence = empty_evidence(inventory, options)
@@ -69,17 +74,145 @@ module Branchproof
69
74
  minima = options[:level] == 1 ? [] : Array(value(baseline, :minima))
70
75
  report = Report.new(inventory: inventory, evidence: value(baseline, :evidence) || evidence.snapshot,
71
76
  analysis: analysis, minima: minima, baseline: baseline, diagnostics: diagnostics,
72
- level: options[:level])
77
+ level: options[:level], missing_only: options[:missing_only], view: options[:view],
78
+ run_metadata: run_metadata(options, baseline))
73
79
  output_report(report, options)
74
80
  report.exit_code
75
81
  rescue ArgumentError => e
76
82
  usage_error(e.message)
77
83
  rescue JSON::ParserError => e
78
84
  usage_error("invalid JSON limits: #{e.message}")
85
+ rescue SystemCallError, IOError => e
86
+ usage_error("report IO failed: #{e.message}")
79
87
  end
80
88
 
81
89
  private
82
90
 
91
+ def help
92
+ @stdout.write(<<~HELP)
93
+ Usage:
94
+ branchproof analyze [SOURCE_GLOB ...] [--test TEST_GLOB] [--project auto|ruby|rails]
95
+ [--view decisions|conditions|tests] [--level 1|2|3] [--missing-only]
96
+ [--format terminal|json] [--output PATH] [--limits PATH] [-- RUNNER_ARGS]
97
+ branchproof report SNAPSHOT [--view decisions|conditions|tests] [--level 1|2|3]
98
+ [--missing-only] [--format terminal|json] [--output PATH]
99
+ branchproof compare BEFORE AFTER [--format terminal|json] [--output PATH] [--fail-on-regression]
100
+ mcdc accepts the same commands as a compatibility alias.
101
+ JSON always contains full evidence; --view requires terminal output.
102
+ HELP
103
+ 0
104
+ end
105
+
106
+ def parse_view(view)
107
+ raise ArgumentError, "view must be decisions, conditions, or tests" unless %w[decisions conditions tests].include?(view)
108
+
109
+ view.to_sym
110
+ end
111
+
112
+ def validate_view!(options)
113
+ return unless options[:explicit_view] && options[:format] == :json
114
+
115
+ raise ArgumentError, "--view requires terminal format; JSON contains full evidence"
116
+ end
117
+
118
+ def offline(argv)
119
+ command = argv.first
120
+ return help if argv.drop(1) == ["--help"]
121
+
122
+ options, paths = parse_offline(command, argv.drop(1))
123
+ reject_input_output_collision!(paths, options[:output]) if options[:output]
124
+ documents = paths.map { |path| SavedReport.read(path) }
125
+ if command == "compare"
126
+ report = ComparisonReport.new(document: Comparison.new(before: documents[0], after: documents[1]).call)
127
+ output_report(report, options)
128
+ report.exit_code(fail_on_regression: options[:fail_on_regression])
129
+ else
130
+ document = documents.first
131
+ options[:level] ||= document["analysis"] ? 3 : 1
132
+ if options[:level] > 1 && !document["analysis"]
133
+ raise ArgumentError, "saved report has no analysis; use --level 1"
134
+ end
135
+ if options[:missing_only] && (options[:format] != :terminal || options[:level] == 1)
136
+ raise ArgumentError, "--missing-only requires terminal format and level 2 or 3"
137
+ end
138
+
139
+ report = Report.from_document(document: document, level: options[:level], view: options[:view],
140
+ missing_only: options[:missing_only])
141
+ output_report(report, options)
142
+ report.exit_code
143
+ end
144
+ end
145
+
146
+ def parse_offline(command, args)
147
+ options = { format: :terminal, view: :decisions, missing_only: false }
148
+ paths = []
149
+ until args.empty?
150
+ token = args.shift
151
+ case token
152
+ when "--format"
153
+ format = args.shift
154
+ raise ArgumentError, "format must be terminal or json" unless %w[terminal json].include?(format)
155
+
156
+ options[:format] = format.to_sym
157
+ when "--output"
158
+ options[:output] = args.shift
159
+ raise ArgumentError, "--output requires a path" if options[:output].to_s.empty?
160
+ when "--fail-on-regression"
161
+ raise ArgumentError, "--fail-on-regression requires compare" unless command == "compare"
162
+
163
+ options[:fail_on_regression] = true
164
+ when "--view", "--level", "--missing-only"
165
+ raise ArgumentError, "#{token} requires report" unless command == "report"
166
+
167
+ case token
168
+ when "--view"
169
+ options[:view] = parse_view(args.shift)
170
+ options[:explicit_view] = true
171
+ when "--level"
172
+ options[:level] = Integer(args.shift.to_s, 10)
173
+ raise ArgumentError, "level must be 1, 2, or 3" unless (1..3).cover?(options[:level])
174
+ else options[:missing_only] = true
175
+ end
176
+ else
177
+ raise ArgumentError, "unknown option: #{token}" if token.start_with?("-")
178
+
179
+ paths << token
180
+ end
181
+ end
182
+ expected = command == "compare" ? 2 : 1
183
+ raise ArgumentError, "#{command} requires #{expected} saved report #{expected == 1 ? "path" : "paths"}" unless paths.length == expected
184
+
185
+ validate_view!(options)
186
+ [options, paths]
187
+ end
188
+
189
+ def reject_input_output_collision!(paths, output)
190
+ collision = paths.any? do |input|
191
+ File.expand_path(input) == File.expand_path(output) ||
192
+ (File.exist?(input) && File.exist?(output) && File.identical?(input, output))
193
+ end
194
+ raise ArgumentError, "output must not overwrite an input report" if collision
195
+ end
196
+
197
+ def run_metadata(options, baseline)
198
+ root = options[:project][:root]
199
+ locations = Array(value(baseline, :tests)).to_h do |test|
200
+ source = value(test, :source) || {}
201
+ [value(test, :id), { relative_path: relative_path(value(source, :path), root), line: value(source, :line) }]
202
+ end
203
+ { captured_at: Time.now.utc.iso8601, requested_level: options[:level], project_kind: options[:project][:kind],
204
+ project_root: root, source_patterns: options[:source_patterns].map { |path| relative_path(path, root) },
205
+ test_patterns: options[:test_patterns].map { |path| relative_path(path, root) },
206
+ test_files: options[:tests].map { |path| relative_path(path, root) }, runner_args: options[:runner_args],
207
+ seed: value(baseline, :seed), limits: options[:limits], test_locations: locations }
208
+ end
209
+
210
+ def relative_path(path, root)
211
+ return nil if path.to_s.empty?
212
+
213
+ Pathname.new(File.expand_path(path, root)).relative_path_from(Pathname.new(root)).to_s
214
+ end
215
+
83
216
  def parse(argv)
84
217
  return nil if argv.empty? || argv.first != "analyze"
85
218
 
@@ -88,10 +221,15 @@ module Branchproof
88
221
  runner_args = delimiter ? args[(delimiter + 1)..] : []
89
222
  args = args[0...delimiter] if delimiter
90
223
  options = { level: 3, format: :terminal, output: nil, tests: [], source_paths: [], limits: Limits.default,
91
- runner_args: runner_args, project: nil }
224
+ runner_args: runner_args, project: nil, missing_only: false, view: :decisions }
92
225
  until args.empty?
93
226
  token = args.shift
94
227
  case token
228
+ when "--view"
229
+ options[:view] = parse_view(args.shift)
230
+ options[:explicit_view] = true
231
+ when "--missing-only"
232
+ options[:missing_only] = true
95
233
  when "--level"
96
234
  level = Integer(args.shift.to_s, 10)
97
235
  raise ArgumentError, "level must be 1, 2, or 3" unless (1..3).cover?(level)
@@ -126,9 +264,16 @@ module Branchproof
126
264
  options[:source_paths] << token
127
265
  end
128
266
  end
267
+ validate_view!(options)
268
+ if options[:missing_only] && (options[:format] != :terminal || options[:level] == 1)
269
+ raise ArgumentError, "--missing-only requires terminal format and level 2 or 3"
270
+ end
271
+
129
272
  options[:project] ||= Project.new(root: Dir.pwd, mode: "auto").to_h
130
273
  options[:source_paths] = default_sources if options[:source_paths].empty?
131
274
  options[:tests] = default_tests if options[:tests].empty?
275
+ options[:source_patterns] = options[:source_paths].dup
276
+ options[:test_patterns] = argv.include?("--test") ? options[:tests].dup : %w[test/**/*_test.rb test/**/test_*.rb]
132
277
  options[:tests] = expand_paths(options[:tests], root: options[:project][:root])
133
278
  options
134
279
  end
@@ -193,13 +338,19 @@ module Branchproof
193
338
  end
194
339
 
195
340
  def output_report(report, options)
341
+ created = false
196
342
  if options[:output]
197
- temporary = "#{options[:output]}.tmp-#{Process.pid}"
198
- File.binwrite(temporary, report_string(report, options[:format]))
343
+ temporary = "#{options[:output]}.tmp-#{SecureRandom.hex(12)}"
344
+ File.open(temporary, "wx") do |file|
345
+ created = true
346
+ file.write(report_string(report, options[:format]))
347
+ end
199
348
  File.rename(temporary, options[:output])
200
349
  else
201
350
  @stdout.write(report_string(report, options[:format]))
202
351
  end
352
+ ensure
353
+ File.unlink(temporary) if created && temporary && File.file?(temporary)
203
354
  end
204
355
 
205
356
  def report_string(report, format)
@@ -209,7 +360,7 @@ module Branchproof
209
360
  end
210
361
 
211
362
  def usage_error(message)
212
- @stderr.write("mcdc: #{message}\n")
363
+ @stderr.write("branchproof: #{message}\n")
213
364
  2
214
365
  end
215
366
 
@@ -239,7 +390,7 @@ module Branchproof
239
390
  def value(hash, key)
240
391
  return nil unless hash.respond_to?(:key?)
241
392
 
242
- hash[key] || hash[key.to_s]
393
+ hash.key?(key) ? hash[key] : hash[key.to_s]
243
394
  end
244
395
 
245
396
  def normalize(value)