branchproof 0.4.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a76f20e6504affb4e614eeca901b0ef515f8bbb0785a641cf437263cac2df418
4
- data.tar.gz: 87373206990831eeba3c1a2317049a4679343b4e4770015fb3e322710c7035e9
3
+ metadata.gz: b5d48afceb9c6608abf5ebdb2aa6f402dd11b75f43421b49686cc49fd95641c3
4
+ data.tar.gz: 647789d869c3a01f1809eacc871df99e4e0f81c7d8e70ffdb27228f4fd5c797e
5
5
  SHA512:
6
- metadata.gz: 97b1aca56ec2e2ed60f683e7647ce91ec85f3b8a2bbe457cf6844b0bd7db9b6117a126bce8ec74f04e4805dc147beef6b58fa003f74d73bd6e18884973115833
7
- data.tar.gz: 1aac59c7cc767f9ef805cdcfecbe8e5e6628e25ea09cc8d862b32b0518f9aad89f6f8204b69d4183c75af1938e94a61e3ba980f6fa396bbff542b464bf371896
6
+ metadata.gz: b5f87cd4aee8588f7a86ba6a91730b7b4a787c256a0485f6dc278acbd948a5a1399669d0e2903371b28f1b7058e5339efb85fdb13d31110944e6462bdfe55b6a
7
+ data.tar.gz: fb81f01ba2e42014364959afcdce9c8cd218611299d8bbd27a0a9fd5ed1ce1f2d311993f834b9f960934deadab659848257010717c2bdcc6814ea01870f985e2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.6.0] - 2026-09-10
4
+
5
+ - Report Decision, Condition, and Condition/Decision Coverage alongside
6
+ existing MC/DC evidence from one test execution.
7
+ - Show explicit coverage counts, supporting tests, and missing Boolean
8
+ observations in decision, condition, and test views and JSON reports.
9
+ - Calculate the same coverage criteria at every reporting level and retain
10
+ analysis in level-1 snapshots for offline inspection at higher detail.
11
+ Continue to support legacy snapshots without analysis at level 1.
12
+
13
+ ## [0.5.0] - 2026-09-10
14
+
15
+ - Show source filenames beside diagnostics in decision, condition, and test
16
+ views, including reports rendered from saved JSON.
17
+ - Explain when a selected file has no supported conditions to instrument and
18
+ include unsupported syntax reasons when its decisions cannot be instrumented.
19
+
3
20
  ## [0.4.0] - 2026-09-10
4
21
 
5
22
  - Added `branchproof` as the primary CLI command while retaining `mcdc` as a
data/README.md CHANGED
@@ -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.4.0
100
+ Branchproof 0.5.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,50 @@ 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
+ ### Coverage ladder
130
+
131
+ Each successful `analyze` run calculates four criteria from the same completed
132
+ observations. The report shows their status for each supported decision and
133
+ aggregate counts with explicit denominators:
134
+
135
+ | Criterion | Requirement | Aggregate denominator |
136
+ | --- | --- | --- |
137
+ | Decision | The decision produced both true and false | Supported decisions |
138
+ | Condition | Every atomic condition evaluated both true and false | Two required truth values per supported condition |
139
+ | Condition/Decision | Both Decision and Condition Coverage hold for a decision | Supported decisions |
140
+ | MC/DC | Every condition has an independence witness pair | Supported conditions |
141
+
142
+ For `logged_in? && admin?`, observations `[F-] => F` and `[TT] => T` give:
143
+
144
+ ```text
145
+ Decision PASS
146
+ Condition FAIL (3/4 values observed)
147
+ Condition/Decision FAIL
148
+ MC/DC FAIL (1/2 conditions proven)
149
+ ```
150
+
151
+ The skipped `admin?` in `[F-]` counts as neither true nor false. Conditions
152
+ that did evaluate count toward Condition Coverage even when their value was
153
+ masked by another condition. Here, the two observations prove independence
154
+ for `logged_in?`; `admin?` still needs `[TF] => F`.
155
+
156
+ Decision and Condition Coverage are calculated independently from the captured
157
+ evidence. Condition/Decision requires both; MC/DC adds independence evidence.
158
+ Unsupported decisions are excluded from every denominator, while
159
+ unexecuted supported decisions remain in scope. Empty denominators are N/A.
160
+
161
+ Reports retain the observations and owning tests for each decision outcome
162
+ and condition value. Missing values describe required runtime observations,
163
+ not application inputs or a guarantee that the path is reachable. Unattributed
164
+ observations can provide truth-value evidence without identifying a test;
165
+ the report's completeness and diagnostics still apply.
166
+
167
+ JSON stores aggregate counts under `analysis.coverage`, per-decision results
168
+ under `analysis.decisions[].coverage`, and condition value evidence under
169
+ `analysis.decisions[].condition_results[].coverage`. Existing MC/DC witness
170
+ pairs, counterpart constraints, and raw vectors remain available. Statuses
171
+ distinguish `covered`, `partial`, `unexecuted`, and `unsupported` results.
172
+
129
173
  ### Find missing cases
130
174
 
131
175
  Use `--missing-only` to focus the terminal report on conditions that still
@@ -175,15 +219,17 @@ test inputs; they do not by themselves prove a bug in the application.
175
219
 
176
220
  The levels select how much of the one-run result is displayed:
177
221
 
178
- * Level 1 reports observed vectors grouped by their raw decision outcomes.
222
+ * Level 1 reports the coverage ladder and observed vectors grouped by their
223
+ raw decision outcomes.
179
224
  * Level 2 includes the smallest supporting sets found for vectors and tests.
180
225
  * Level 3 (the default) includes condition independence witnesses and
181
226
  counterpart constraints, as well as the Level 1 and 2 evidence.
182
227
 
183
- Level 2 and Level 3 compute from the same captured run. They do not rerun the
184
- test suite. A failed, unsupported, or incomplete run is reported with its
185
- status and diagnostics and cannot become a successful coverage result by
186
- changing the display level.
228
+ All levels calculate the same criteria from one captured run. The level
229
+ controls displayed evidence, not the coverage criterion; JSON retains the
230
+ analysis even at Level 1. A failed, unsupported, or incomplete run is reported
231
+ with its status and diagnostics and cannot become a successful coverage
232
+ result by changing the display level.
187
233
 
188
234
  ### Focused condition and test views
189
235
 
@@ -232,7 +278,8 @@ artifacts when you need to retain multiple runs.
232
278
  The `report` command reads the saved document without loading the application
233
279
  or running tests. It uses locations and metadata captured in the report, so
234
280
  rendering remains useful after the original checkout has moved or been
235
- removed. Level 1 reports observations; levels 2 and 3 require corresponding
281
+ removed. New snapshots retain the ladder at every level. Legacy snapshots
282
+ without analysis can still be rendered at Level 1; levels 2 and 3 require
236
283
  analysis in the saved report. The repository ignores `.branchproof/`; choose a
237
284
  different path and CI artifact policy when a project needs to retain reports.
238
285
  Saved JSON includes existing raw metadata such as test names and expressions;
@@ -19,9 +19,22 @@ Not documented.
19
19
  Not documented.
20
20
 
21
21
  ## Public Instance Methods
22
+ ### `condition_coverage_evidence(decision_id:, condition_id:)` <a id="method-i-condition_coverage_evidence"></a> <a id="condition_coverage_evidence-instance_method"></a>
23
+ Returns condition-value evidence for focused renderers without exposing the
24
+ report's internal document traversal or mutating saved records.
25
+
22
26
  ### `condition_explanation(decision_id:, condition_id:)` <a id="method-i-condition_explanation"></a> <a id="condition_explanation-instance_method"></a>
23
27
  Shares the existing missing-case wording with focused terminal views.
24
28
 
29
+ ### `coverage_ladder_lines()` <a id="method-i-coverage_ladder_lines"></a> <a id="coverage_ladder_lines-instance_method"></a>
30
+ Render the shared ladder in every terminal view.
31
+
32
+ ### `coverage_status_label(status)` <a id="method-i-coverage_status_label"></a> <a id="coverage_status_label-instance_method"></a>
33
+ Not documented.
34
+
35
+ ### `diagnostic_message(diagnostic)` <a id="method-i-diagnostic_message"></a> <a id="diagnostic_message-instance_method"></a>
36
+ Formats source context consistently in live and saved terminal views.
37
+
25
38
  ### `exit_code()` <a id="method-i-exit_code"></a> <a id="exit_code-instance_method"></a>
26
39
  Not documented.
27
40
 
data/doc/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.6.0] - 2026-09-10
4
+
5
+ - Report Decision, Condition, and Condition/Decision Coverage alongside
6
+ existing MC/DC evidence from one test execution.
7
+ - Show explicit coverage counts, supporting tests, and missing Boolean
8
+ observations in decision, condition, and test views and JSON reports.
9
+ - Calculate the same coverage criteria at every reporting level and retain
10
+ analysis in level-1 snapshots for offline inspection at higher detail.
11
+ Continue to support legacy snapshots without analysis at level 1.
12
+
13
+ ## [0.5.0] - 2026-09-10
14
+
15
+ - Show source filenames beside diagnostics in decision, condition, and test
16
+ views, including reports rendered from saved JSON.
17
+ - Explain when a selected file has no supported conditions to instrument and
18
+ include unsupported syntax reasons when its decisions cannot be instrumented.
19
+
3
20
  ## [0.4.0] - 2026-09-10
4
21
 
5
22
  - Added `branchproof` as the primary CLI command while retaining `mcdc` as a
data/doc/README.md CHANGED
@@ -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.4.0
100
+ Branchproof 0.5.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,50 @@ 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
+ ### Coverage ladder
130
+
131
+ Each successful `analyze` run calculates four criteria from the same completed
132
+ observations. The report shows their status for each supported decision and
133
+ aggregate counts with explicit denominators:
134
+
135
+ | Criterion | Requirement | Aggregate denominator |
136
+ | --- | --- | --- |
137
+ | Decision | The decision produced both true and false | Supported decisions |
138
+ | Condition | Every atomic condition evaluated both true and false | Two required truth values per supported condition |
139
+ | Condition/Decision | Both Decision and Condition Coverage hold for a decision | Supported decisions |
140
+ | MC/DC | Every condition has an independence witness pair | Supported conditions |
141
+
142
+ For `logged_in? && admin?`, observations `[F-] => F` and `[TT] => T` give:
143
+
144
+ ```text
145
+ Decision PASS
146
+ Condition FAIL (3/4 values observed)
147
+ Condition/Decision FAIL
148
+ MC/DC FAIL (1/2 conditions proven)
149
+ ```
150
+
151
+ The skipped `admin?` in `[F-]` counts as neither true nor false. Conditions
152
+ that did evaluate count toward Condition Coverage even when their value was
153
+ masked by another condition. Here, the two observations prove independence
154
+ for `logged_in?`; `admin?` still needs `[TF] => F`.
155
+
156
+ Decision and Condition Coverage are calculated independently from the captured
157
+ evidence. Condition/Decision requires both; MC/DC adds independence evidence.
158
+ Unsupported decisions are excluded from every denominator, while
159
+ unexecuted supported decisions remain in scope. Empty denominators are N/A.
160
+
161
+ Reports retain the observations and owning tests for each decision outcome
162
+ and condition value. Missing values describe required runtime observations,
163
+ not application inputs or a guarantee that the path is reachable. Unattributed
164
+ observations can provide truth-value evidence without identifying a test;
165
+ the report's completeness and diagnostics still apply.
166
+
167
+ JSON stores aggregate counts under `analysis.coverage`, per-decision results
168
+ under `analysis.decisions[].coverage`, and condition value evidence under
169
+ `analysis.decisions[].condition_results[].coverage`. Existing MC/DC witness
170
+ pairs, counterpart constraints, and raw vectors remain available. Statuses
171
+ distinguish `covered`, `partial`, `unexecuted`, and `unsupported` results.
172
+
129
173
  ### Find missing cases
130
174
 
131
175
  Use `--missing-only` to focus the terminal report on conditions that still
@@ -175,15 +219,17 @@ test inputs; they do not by themselves prove a bug in the application.
175
219
 
176
220
  The levels select how much of the one-run result is displayed:
177
221
 
178
- * Level 1 reports observed vectors grouped by their raw decision outcomes.
222
+ * Level 1 reports the coverage ladder and observed vectors grouped by their
223
+ raw decision outcomes.
179
224
  * Level 2 includes the smallest supporting sets found for vectors and tests.
180
225
  * Level 3 (the default) includes condition independence witnesses and
181
226
  counterpart constraints, as well as the Level 1 and 2 evidence.
182
227
 
183
- Level 2 and Level 3 compute from the same captured run. They do not rerun the
184
- test suite. A failed, unsupported, or incomplete run is reported with its
185
- status and diagnostics and cannot become a successful coverage result by
186
- changing the display level.
228
+ All levels calculate the same criteria from one captured run. The level
229
+ controls displayed evidence, not the coverage criterion; JSON retains the
230
+ analysis even at Level 1. A failed, unsupported, or incomplete run is reported
231
+ with its status and diagnostics and cannot become a successful coverage
232
+ result by changing the display level.
187
233
 
188
234
  ### Focused condition and test views
189
235
 
@@ -232,7 +278,8 @@ artifacts when you need to retain multiple runs.
232
278
  The `report` command reads the saved document without loading the application
233
279
  or running tests. It uses locations and metadata captured in the report, so
234
280
  rendering remains useful after the original checkout has moved or been
235
- removed. Level 1 reports observations; levels 2 and 3 require corresponding
281
+ removed. New snapshots retain the ladder at every level. Legacy snapshots
282
+ without analysis can still be rendered at Level 1; levels 2 and 3 require
236
283
  analysis in the saved report. The repository ignores `.branchproof/`; choose a
237
284
  different path and CI artifact policy when a project needs to retain reports.
238
285
  Saved JSON includes existing raw metadata such as test names and expressions;
@@ -30,7 +30,8 @@ module Branchproof
30
30
  proven_count: proven,
31
31
  eligible_count: eligible,
32
32
  completeness: completeness(decisions),
33
- diagnostics: decisions.flat_map { |decision| decision[:diagnostics] } + @invalid_diagnostics
33
+ diagnostics: decisions.flat_map { |decision| decision[:diagnostics] } + @invalid_diagnostics,
34
+ coverage: aggregate_coverage(decisions)
34
35
  }.freeze
35
36
  end
36
37
 
@@ -97,7 +98,7 @@ module Branchproof
97
98
  unless id(decision, :support_status).to_s.empty? || id(decision, :support_status).to_s.upcase == "SUPPORTED"
98
99
  return {
99
100
  decision_id: decision_id, effective_masks_by_vector: {}, condition_results: [], witness_buckets: {},
100
- conditions: [], unsupported: true,
101
+ conditions: [], unsupported: true, coverage: unsupported_coverage,
101
102
  completeness: { observation: true, attribution: true, analysis: true },
102
103
  diagnostics: [diagnostic("unsupported_decision", "warning", decision_id, nil)]
103
104
  }
@@ -136,9 +137,13 @@ module Branchproof
136
137
  nil
137
138
  else
138
139
  (true_ids.empty? || false_ids.empty? ? "missing_effective_sign" : "no_independent_pair")
139
- end
140
+ end,
141
+ coverage: condition_coverage(index, vectors)
140
142
  }
141
143
  end
144
+ decision_coverage = decision_coverage(vectors)
145
+ condition_coverage_summary = condition_summary(results)
146
+ mcdc = mcdc_coverage(results)
142
147
  {
143
148
  decision_id: decision_id,
144
149
  effective_masks_by_vector: masks,
@@ -146,11 +151,134 @@ module Branchproof
146
151
  witness_buckets: buckets,
147
152
  edge_table: edge_table(decision[:tree]),
148
153
  conditions: conditions(decision),
154
+ coverage: { decision: decision_coverage, condition: condition_coverage_summary,
155
+ condition_decision: conjunction_coverage(decision_coverage, condition_coverage_summary),
156
+ mcdc: mcdc },
149
157
  completeness: { observation: true, attribution: true, analysis: !@analysis_invalid },
150
158
  diagnostics: []
151
159
  }
152
160
  end
153
161
 
162
+ def unsupported_coverage
163
+ { decision: { status: "unsupported", true_observed: false, false_observed: false,
164
+ covered_outcomes: 0, required_outcomes: 2, outcomes: [], missing_outcomes: [] },
165
+ condition: { status: "unsupported", covered_values: 0, required_values: 0,
166
+ covered_conditions: 0, condition_count: 0 },
167
+ condition_decision: { status: "unsupported" },
168
+ mcdc: { status: "unsupported", proven_conditions: 0, condition_count: 0 } }
169
+ end
170
+
171
+ def provenance(vector)
172
+ { vector_id: id(vector, :id).to_s,
173
+ test_ids: Array(id(vector, :test_ids)).map(&:to_s).uniq.sort,
174
+ unattributed_count: id(vector, :unattributed_count).to_i }
175
+ end
176
+
177
+ def evidence_bucket(value, vectors)
178
+ matching = vectors.select do |vector|
179
+ observed?(vector, value[:index]) && self.value(vector, value[:index]) == value[:value]
180
+ end
181
+ evidence = matching.map { |vector| provenance(vector) }
182
+ { value: value[:value], observed: !matching.empty?,
183
+ vector_ids: evidence.flat_map { |item| item[:vector_id] }.uniq.sort,
184
+ test_ids: evidence.flat_map { |item| item[:test_ids] }.uniq.sort,
185
+ unattributed_count: evidence.sum { |item| item[:unattributed_count] } }
186
+ end
187
+
188
+ def condition_coverage(index, vectors)
189
+ observed_vectors = vectors.select { |vector| observed?(vector, index) }
190
+ values = [true, false].map do |item|
191
+ evidence_bucket({ index: index, value: item }, observed_vectors)
192
+ end
193
+ covered = values.count { |entry| entry[:observed] }
194
+ { status: coverage_status(covered, 2), true_observed: values[0][:observed],
195
+ false_observed: values[1][:observed], covered_values: covered, required_values: 2,
196
+ values: values, missing_values: values.reject { |entry| entry[:observed] }.map { |entry| entry[:value] } }
197
+ end
198
+
199
+ def decision_coverage(vectors)
200
+ outcomes = [false, true].map do |value|
201
+ matching = vectors.select { |vector| outcome(vector) == value }
202
+ evidence = matching.map { |vector| provenance(vector) }
203
+ { value: value, observed: !matching.empty?,
204
+ vector_ids: evidence.flat_map { |item| item[:vector_id] }.uniq.sort,
205
+ test_ids: evidence.flat_map { |item| item[:test_ids] }.uniq.sort,
206
+ unattributed_count: evidence.sum { |item| item[:unattributed_count] } }
207
+ end
208
+ covered = outcomes.count { |entry| entry[:observed] }
209
+ { status: coverage_status(covered, 2), true_observed: outcomes[1][:observed],
210
+ false_observed: outcomes[0][:observed], covered_outcomes: covered, required_outcomes: 2,
211
+ outcomes: outcomes,
212
+ missing_outcomes: outcomes.reject { |entry| entry[:observed] }.map { |entry| entry[:value] } }
213
+ end
214
+
215
+ def condition_summary(results)
216
+ values = results.sum { |result| result[:coverage][:covered_values] }
217
+ count = results.length
218
+ { status: coverage_status(values, count * 2), covered_values: values,
219
+ required_values: count * 2,
220
+ covered_conditions: results.count { |result| result[:coverage][:status] == "covered" },
221
+ condition_count: count }
222
+ end
223
+
224
+ def mcdc_coverage(results)
225
+ proven = results.count { |result| result[:status] == "PROVEN" }
226
+ observed = results.any? { |result| result[:coverage][:covered_values].positive? }
227
+ status = if !observed
228
+ "unexecuted"
229
+ elsif proven == results.length
230
+ "covered"
231
+ else
232
+ "partial"
233
+ end
234
+ { status: status, proven_conditions: proven, condition_count: results.length }
235
+ end
236
+
237
+ def conjunction_coverage(decision, condition)
238
+ status = if decision[:status] == "covered" && condition[:status] == "covered"
239
+ "covered"
240
+ elsif decision[:status] == "unexecuted" && condition[:status] == "unexecuted"
241
+ "unexecuted"
242
+ else
243
+ "partial"
244
+ end
245
+ { status: status }
246
+ end
247
+
248
+ def coverage_status(covered, required)
249
+ return "unexecuted" if covered.zero?
250
+ return "covered" if covered == required
251
+
252
+ "partial"
253
+ end
254
+
255
+ def aggregate_coverage(decisions)
256
+ supported = decisions.reject { |decision| decision[:unsupported] }
257
+ decision_covered = supported.count do |decision|
258
+ decision[:coverage][:decision][:status] == "covered"
259
+ end
260
+ condition_decision_covered = supported.count do |decision|
261
+ decision[:coverage][:condition_decision][:status] == "covered"
262
+ end
263
+ condition_count = supported.sum { |decision| decision[:coverage][:condition][:condition_count] }
264
+ condition_values = supported.sum { |decision| decision[:coverage][:condition][:covered_values] }
265
+ covered_conditions = supported.sum do |decision|
266
+ decision[:coverage][:condition][:covered_conditions]
267
+ end
268
+ proven = supported.sum { |decision| decision[:coverage][:mcdc][:proven_conditions] }
269
+ percentage = ->(covered, required) { required.zero? ? nil : (covered.to_f / required * 100).round(2) }
270
+ { decision: { covered_decisions: decision_covered, supported_decisions: supported.length,
271
+ percentage: percentage.call(decision_covered, supported.length) },
272
+ condition: { covered_values: condition_values, required_values: condition_count * 2,
273
+ covered_conditions: covered_conditions, condition_count: condition_count,
274
+ percentage: percentage.call(condition_values, condition_count * 2) },
275
+ condition_decision: { covered_decisions: condition_decision_covered,
276
+ supported_decisions: supported.length,
277
+ percentage: percentage.call(condition_decision_covered, supported.length) },
278
+ mcdc: { proven_conditions: proven, supported_conditions: condition_count,
279
+ percentage: percentage.call(proven, condition_count) } }
280
+ end
281
+
154
282
  def valid_vector(vector, decision)
155
283
  return nil unless id(vector, :decision_id) == id(decision, :id)
156
284
 
@@ -49,7 +49,7 @@ module Branchproof
49
49
  message: merge_status[:reason].to_s }]
50
50
  end
51
51
  end
52
- if options[:level] >= 2 && value(baseline, :status).to_s == "PASSED"
52
+ if value(baseline, :status).to_s == "PASSED"
53
53
  analysis = Analyzer.new(inventory: inventory, evidence: evidence.snapshot, limits: options[:limits]).call
54
54
  baseline[:analysis] = analysis
55
55
  if options[:level] >= 2
@@ -70,7 +70,7 @@ module Branchproof
70
70
  diagnostics = Array(value(inventory, :diagnostics)) + Array(value(baseline, :diagnostics)) +
71
71
  Array(value(evidence.snapshot,
72
72
  :diagnostics)) + Array(value(value(baseline, :analysis), :diagnostics))
73
- analysis = options[:level] == 1 ? nil : value(baseline, :analysis)
73
+ analysis = value(baseline, :analysis)
74
74
  minima = options[:level] == 1 ? [] : Array(value(baseline, :minima))
75
75
  report = Report.new(inventory: inventory, evidence: value(baseline, :evidence) || evidence.snapshot,
76
76
  analysis: analysis, minima: minima, baseline: baseline, diagnostics: diagnostics,
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
3
+ # rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
4
4
  require "pathname"
5
5
 
6
6
  module Branchproof
@@ -34,11 +34,12 @@ module Branchproof
34
34
  end
35
35
  lines << "Empty groups mean no recorded completed observation."
36
36
  lines << ""
37
+ lines.concat(@coordinator.coverage_ladder_lines)
37
38
  @view == :conditions ? render_conditions(lines) : render_tests(lines)
38
39
  render_unowned(lines)
39
40
  render_unsupported(lines)
40
41
  Array(fetch(@document, :diagnostics)).each do |diagnostic|
41
- lines << "Diagnostic: #{fetch(diagnostic, :message) || fetch(diagnostic, :code)}"
42
+ lines << "Diagnostic: #{@coordinator.diagnostic_message(diagnostic)}"
42
43
  end
43
44
  lines.join("\n") << "\n"
44
45
  end
@@ -52,7 +53,15 @@ module Branchproof
52
53
  lines << "Condition: #{row[:expression]}"
53
54
  lines << "Location: #{location(row[:relative_path], row[:line], unavailable: "condition line unavailable")}"
54
55
  lines << "Decision: #{row[:decision_expression]} (condition #{row[:index]})"
55
- lines << "MC/DC: #{@level == 1 ? "NOT CALCULATED" : (row[:status] || "NOT_PROVEN")}"
56
+ status = if @level == 1 && !coverage_available?
57
+ "NOT CALCULATED"
58
+ else
59
+ row[:status] || "NOT_PROVEN"
60
+ end
61
+ lines << "MC/DC: #{status}"
62
+ if @level >= 2
63
+ lines.concat(@coordinator.condition_coverage_evidence(decision_id: row[:decision_id], condition_id: row[:id]))
64
+ end
56
65
  render_group(lines, "Evaluated true by", row[:observed_true], row)
57
66
  render_group(lines, "Evaluated false by", row[:observed_false], row)
58
67
  render_group(lines, "Short-circuited in", row[:short_circuited], row)
@@ -79,6 +88,11 @@ module Branchproof
79
88
  lines << "No missing conditions" if @missing_only && rows.empty?
80
89
  end
81
90
 
91
+ def coverage_available?
92
+ analysis = fetch(@document, :analysis)
93
+ analysis && fetch(analysis, :coverage)
94
+ end
95
+
82
96
  def render_constraint(lines, row)
83
97
  lines << @coordinator.condition_explanation(decision_id: row[:decision_id], condition_id: row[:id])
84
98
  end
@@ -202,4 +216,4 @@ module Branchproof
202
216
  end
203
217
  end
204
218
 
205
- # rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
219
+ # rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
@@ -55,7 +55,7 @@ module Branchproof
55
55
  end
56
56
  unless rewritten[:changed]
57
57
  if Array(rewritten[:diagnostics]).empty?
58
- add_diagnostic("not_instrumented", "selected source had no safe edits", unit[:source_id],
58
+ add_diagnostic("not_instrumented", unchanged_reason(unit), unit[:source_id],
59
59
  severity: "info")
60
60
  end
61
61
  return nil
@@ -80,6 +80,14 @@ module Branchproof
80
80
 
81
81
  private
82
82
 
83
+ def unchanged_reason(unit)
84
+ decisions = Array(unit[:decisions])
85
+ reasons = decisions.flat_map { |decision| Array(decision[:support_reasons]) }.uniq
86
+ return "conditions cannot be instrumented: #{reasons.join(", ")}" unless reasons.empty?
87
+
88
+ "no supported conditions to instrument"
89
+ end
90
+
83
91
  def hook_supported?
84
92
  defined?(RubyVM::InstructionSequence) && RubyVM::InstructionSequence.respond_to?(:compile)
85
93
  end
@@ -58,6 +58,43 @@ module Branchproof
58
58
  condition_detail(decision, condition, condition_result(decision, condition))
59
59
  end
60
60
 
61
+ # Formats source context consistently in live and saved terminal views.
62
+ def diagnostic_message(diagnostic)
63
+ message = value(diagnostic, :message) || value(diagnostic, :code)
64
+ source_id = value(diagnostic, :source_id)
65
+ return message unless source_id
66
+
67
+ source = source_for(diagnostic)
68
+ path = value(source, :relative_path) || source_id
69
+ prefix = %w[not_instrumented unsupported_source].include?(value(diagnostic, :code).to_s) ? "Skipped " : ""
70
+ "#{prefix}#{path}: #{message}"
71
+ end
72
+
73
+ # Returns condition-value evidence for focused renderers without exposing
74
+ # the report's internal document traversal or mutating saved records.
75
+ def condition_coverage_evidence(decision_id:, condition_id:)
76
+ decision = inventory_decisions.find { |item| value(item, :id).to_s == decision_id.to_s }
77
+ condition = Array(value(decision, :conditions)).find do |item|
78
+ value(item, :id).to_s == condition_id.to_s
79
+ end
80
+ coverage = value(condition_result(decision, condition), :coverage)
81
+ return [] unless coverage
82
+
83
+ @terminal_ids ||= terminal_ids
84
+ lines = Array(value(coverage, :values)).map do |entry|
85
+ value_label = value(entry, :value) ? "true" : "false"
86
+ owners = Array(value(entry, :test_ids)).map { |id| test_label(id) }
87
+ owners << "unattributed" if value(entry, :unattributed_count).to_i.positive?
88
+ evidence = owners.empty? ? "none recorded" : owners.uniq.join(", ")
89
+ " Value #{value_label}: #{value(entry, :observed) ? "observed" : "missing"}; tests: #{evidence}"
90
+ end
91
+ missing = Array(value(coverage, :missing_values))
92
+ unless missing.empty?
93
+ lines << " Missing values: #{missing.map { |item| item ? "true" : "false" }.join(", ")}"
94
+ end
95
+ lines
96
+ end
97
+
61
98
  def exit_code
62
99
  return 2 unless usage_valid?
63
100
 
@@ -82,7 +119,7 @@ module Branchproof
82
119
  criterion_version: CRITERION_VERSION, runtime: RUBY_DESCRIPTION,
83
120
  run_ids: Array(value(@evidence, :run_ids)),
84
121
  source_inventory: @inventory, baseline: @baseline, observations: @evidence,
85
- analysis: @level == 1 ? nil : @analysis, minima: @minima, metrics: metrics,
122
+ analysis: @analysis, minima: @minima, metrics: metrics,
86
123
  diagnostics: @diagnostics, completeness: completeness,
87
124
  run_metadata: @run_metadata)
88
125
  end
@@ -103,6 +140,7 @@ module Branchproof
103
140
  "Observations: #{metrics[:completed]} completed, #{metrics[:aborted]} aborted, " \
104
141
  "#{metrics[:unattributed]} unattributed",
105
142
  "Values: T=true, F=false, -=short-circuited"]
143
+ lines.concat(coverage_ladder_lines)
106
144
  lines << missing_summary_line if @missing_only
107
145
  lines << "Scope: supported decisions and conditions"
108
146
  lines << ""
@@ -110,7 +148,7 @@ module Branchproof
110
148
  render_minima(lines) unless @missing_only
111
149
  unless @diagnostics.empty?
112
150
  lines << "Diagnostics:"
113
- @diagnostics.each { |diagnostic| lines << " - #{value(diagnostic, :message) || value(diagnostic, :code)}" }
151
+ @diagnostics.each { |diagnostic| lines << " - #{diagnostic_message(diagnostic)}" }
114
152
  end
115
153
  lines.join("\n") << "\n"
116
154
  end
@@ -125,14 +163,16 @@ module Branchproof
125
163
  conditions_to_render(decision).each do |condition|
126
164
  result = condition_result(decision, condition)
127
165
  detail = condition_detail(decision, condition, result)
128
- status = if @analysis.nil? || @level == 1
166
+ status = if @analysis.nil? || (@level == 1 && !coverage_available?)
129
167
  "NOT CALCULATED"
130
168
  else
131
169
  value(result, :status) || "NOT_PROVEN"
132
170
  end
133
171
  lines << " Condition #{value(condition, :index)}: #{value(condition, :expression)}"
134
172
  lines << " #{status}#{detail}"
173
+ render_condition_coverage(lines, decision, condition, result) if @level >= 2 && coverage_available?
135
174
  end
175
+ render_decision_coverage(lines, decision) if coverage_available?
136
176
  vectors_to_render(decision).each do |vector|
137
177
  values = Array(value(vector, :values)).map do |item|
138
178
  if item.nil?
@@ -150,6 +190,64 @@ module Branchproof
150
190
  lines << ""
151
191
  end
152
192
 
193
+ def coverage_status_label(status)
194
+ { "covered" => "PASS", "partial" => "FAIL", "unexecuted" => "UNEXECUTED", "unsupported" => "EXCLUDED" }.fetch(
195
+ status.to_s.downcase, status.to_s.upcase
196
+ )
197
+ end
198
+
199
+ def render_decision_coverage(lines, decision)
200
+ decision_coverage = value(value(analysis_for(decision), :coverage), :decision)
201
+ all_coverage = value(analysis_for(decision), :coverage) || {}
202
+ statuses = [["D", :decision], ["C", :condition], ["C/D", :condition_decision],
203
+ ["MC/DC", :mcdc]].filter_map do |label, key|
204
+ row = value(all_coverage, key)
205
+ status = value(row, :status)
206
+ status ? criterion_status_text(label, row) : nil
207
+ end
208
+ lines << " Coverage: #{statuses.join(", ")}" unless statuses.empty?
209
+ coverage = decision_coverage
210
+ return unless coverage
211
+
212
+ return unless @level >= 2
213
+
214
+ Array(value(coverage, :outcomes)).each do |outcome|
215
+ value_label = value(outcome, :value) ? "true" : "false"
216
+ owners = Array(value(outcome, :test_ids)).map { |id| test_label(id) }
217
+ owners << "unattributed" if value(outcome, :unattributed_count).to_i.positive?
218
+ evidence = owners.empty? ? "none recorded" : owners.uniq.join(", ")
219
+ lines << " Outcome #{value_label}: #{value(outcome, :observed) ? "observed" : "missing"}; tests: #{evidence}"
220
+ end
221
+ missing = Array(value(coverage, :missing_outcomes))
222
+ return if missing.empty?
223
+
224
+ lines << " Missing decision outcomes: #{missing.map do |item|
225
+ item ? "true" : "false"
226
+ end.join(", ")}"
227
+ end
228
+
229
+ def render_condition_coverage(lines, _decision, condition, result)
230
+ coverage = value(result, :coverage)
231
+ return unless coverage
232
+
233
+ values = Array(value(coverage, :values))
234
+ return if values.empty?
235
+
236
+ values.each do |entry|
237
+ value_label = value(entry, :value) ? "true" : "false"
238
+ owners = Array(value(entry, :test_ids)).map { |id| test_label(id) }
239
+ owners << "unattributed" if value(entry, :unattributed_count).to_i.positive?
240
+ evidence = owners.empty? ? "none recorded" : owners.uniq.join(", ")
241
+ lines << " Value #{value_label}: #{value(entry, :observed) ? "observed" : "missing"}; tests: #{evidence}"
242
+ end
243
+ missing = Array(value(coverage, :missing_values))
244
+ return if missing.empty?
245
+
246
+ lines << " Missing values for #{value(condition, :expression)}: #{missing.map do |item|
247
+ item ? "true" : "false"
248
+ end.join(", ")}"
249
+ end
250
+
153
251
  def render_minima(lines)
154
252
  rows = Array(@minima).map do |minimum|
155
253
  objective = value(minimum, :objective)
@@ -177,19 +275,19 @@ module Branchproof
177
275
  end
178
276
 
179
277
  def terminal_coverage_label
180
- return "not calculated" if @analysis.nil? || @level == 1
278
+ return "not calculated" if @analysis.nil?
181
279
 
182
280
  coverage_label
183
281
  end
184
282
 
185
283
  def terminal_coverage_line
186
- return "MC/DC: not calculated" if @analysis.nil? || @level == 1
284
+ return "MC/DC: not calculated" if @analysis.nil?
187
285
 
188
286
  "MC/DC: #{terminal_coverage_label} (#{metrics[:proven]}/#{metrics[:eligible_conditions]} conditions proven)"
189
287
  end
190
288
 
191
289
  def terminal_analysis_status
192
- return "NOT CALCULATED" if @analysis.nil? || @level == 1
290
+ return "NOT CALCULATED" if @analysis.nil?
193
291
 
194
292
  analysis_status
195
293
  end
@@ -461,7 +559,7 @@ module Branchproof
461
559
  end
462
560
 
463
561
  def valid_for_requested_level?
464
- return completeness[:observation] && completeness[:attribution] if @level == 1
562
+ return completeness[:observation] && completeness[:attribution] if @level == 1 && @analysis.nil?
465
563
 
466
564
  completeness.values.all? { |item| item == true }
467
565
  end
@@ -474,13 +572,14 @@ module Branchproof
474
572
  end
475
573
 
476
574
  def analysis_status
477
- return "NOT_REQUESTED" if @analysis.nil? || @level == 1
575
+ return "NOT_REQUESTED" if @analysis.nil?
478
576
 
479
577
  valid_for_requested_level? ? "COMPLETE" : "PARTIAL"
480
578
  end
481
579
 
482
580
  def incomplete?
483
- !completeness[:observation] || !completeness[:attribution] || (@level > 1 && !completeness[:analysis])
581
+ !completeness[:observation] || !completeness[:attribution] ||
582
+ (analysis_available? && !completeness[:analysis])
484
583
  end
485
584
 
486
585
  def vectors_for(decision)
@@ -512,7 +611,65 @@ module Branchproof
512
611
  end
513
612
 
514
613
  def analysis_available?
515
- !@analysis.nil? && @level > 1
614
+ !@analysis.nil?
615
+ end
616
+
617
+ def coverage_available?
618
+ analysis_available? && !value(@analysis, :coverage).nil?
619
+ end
620
+
621
+ # Render the shared ladder in every terminal view.
622
+ def coverage_ladder_lines
623
+ return [] unless coverage_available?
624
+
625
+ aggregate = value(@analysis, :coverage) || {}
626
+ rows = [["D", :decision, :covered_decisions, :supported_decisions],
627
+ ["C", :condition, :covered_values, :required_values],
628
+ ["C/D", :condition_decision, :covered_decisions, :supported_decisions],
629
+ ["MC/DC", :mcdc, :proven_conditions, :supported_conditions]]
630
+ lines = ["Coverage ladder:"]
631
+ rows.each do |label, key, numerator_key, denominator_key|
632
+ row = value(aggregate, key)
633
+ next unless row
634
+
635
+ percentage = value(row, :percentage)
636
+ numerator = value(row, numerator_key) || 0
637
+ denominator = value(row, denominator_key) || 0
638
+ shown = percentage.nil? ? "N/A" : "#{percentage}%"
639
+ denominator_label = if ["D", "C/D"].include?(label)
640
+ "decisions"
641
+ else
642
+ label == "C" ? "truth values" : "conditions"
643
+ end
644
+ qualifier = incomplete? ? " (lower bound)" : ""
645
+ description = case label
646
+ when "D" then "Decision coverage"
647
+ when "C" then "Condition coverage"
648
+ when "C/D" then "Condition/decision coverage"
649
+ else "MC/DC coverage"
650
+ end
651
+ lines << " #{label} (#{description}): #{shown} (#{numerator}/#{denominator} #{denominator_label})#{qualifier}"
652
+ end
653
+ lines << ""
654
+ lines
655
+ end
656
+
657
+ def criterion_status_text(label, row)
658
+ raw_status = value(row, :status).to_s.downcase
659
+ status = coverage_status_label(raw_status)
660
+ return "#{label}=#{status}" if raw_status == "unsupported"
661
+
662
+ case label
663
+ when "D"
664
+ "#{label}=#{status} (#{value(row, :covered_outcomes) || 0}/#{value(row, :required_outcomes) || 0} outcomes)"
665
+ when "C"
666
+ "#{label}=#{status} (#{value(row, :covered_values) || 0}/#{value(row, :required_values) || 0} values; " \
667
+ "#{value(row, :covered_conditions) || 0}/#{value(row, :condition_count) || 0} conditions fully covered)"
668
+ when "MC/DC"
669
+ "#{label}=#{status} (#{value(row, :proven_conditions) || 0}/#{value(row, :condition_count) || 0} conditions)"
670
+ else
671
+ "#{label}=#{status}"
672
+ end
516
673
  end
517
674
 
518
675
  def analysis_complete?
@@ -592,5 +749,6 @@ module Branchproof
592
749
  def normalize_unknown(object)
593
750
  object.to_s
594
751
  end
752
+ public :condition_coverage_evidence, :coverage_ladder_lines, :coverage_status_label
595
753
  end
596
754
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Branchproof
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/sig/branchproof.rbs CHANGED
@@ -105,6 +105,7 @@ module Branchproof
105
105
  CRITERION_VERSION: String
106
106
  def initialize: (inventory: document, evidence: document, analysis: document?, minima: Array[document], baseline: document, diagnostics: Array[document], ?level: Integer, ?missing_only: bool, ?view: Symbol, ?run_metadata: document, ?saved_document: document?) -> void
107
107
  def self.from_document: (document: document, ?level: Integer?, ?view: Symbol, ?missing_only: bool) -> Report
108
+ def diagnostic_message: (Hash[Symbol | String, untyped] diagnostic) -> String
108
109
  def condition_explanation: (decision_id: String, condition_id: String) -> String
109
110
  def write: (io: _WriteIO, format: Symbol | String) -> nil
110
111
  def exit_code: () -> Integer
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: branchproof
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lucian Ghinda