branchproof 0.5.0 → 0.7.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 +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +129 -31
- data/doc/Branchproof/CoverageIndex.md +3 -0
- data/doc/Branchproof/DecisionSyntax.md +20 -0
- data/doc/Branchproof/FlowInstrumentation.md +7 -0
- data/doc/Branchproof/Instrumenter.md +1 -0
- data/doc/Branchproof/Report.md +10 -0
- data/doc/Branchproof/Runtime.md +19 -0
- data/doc/Branchproof/RuntimeFlow.md +27 -0
- data/doc/Branchproof/SavedReport.md +6 -0
- data/doc/Branchproof/Source.md +1 -0
- data/doc/Branchproof.md +6 -2
- data/doc/CHANGELOG.md +20 -0
- data/doc/README.md +129 -31
- data/lib/branchproof/analyzer.rb +273 -13
- data/lib/branchproof/cli.rb +2 -2
- data/lib/branchproof/comparison.rb +1 -1
- data/lib/branchproof/coverage_index.rb +90 -2
- data/lib/branchproof/decision_syntax.rb +294 -0
- data/lib/branchproof/evidence.rb +54 -9
- data/lib/branchproof/flow_instrumentation.rb +107 -0
- data/lib/branchproof/focused_report.rb +102 -21
- data/lib/branchproof/instrumenter.rb +28 -13
- data/lib/branchproof/report.rb +348 -31
- data/lib/branchproof/runtime.rb +3 -0
- data/lib/branchproof/runtime_flow.rb +58 -0
- data/lib/branchproof/saved_report.rb +245 -6
- data/lib/branchproof/source.rb +191 -35
- data/lib/branchproof/version.rb +1 -1
- data/llms.txt +6 -2
- data/sig/branchproof.rbs +7 -0
- metadata +7 -1
data/lib/branchproof/report.rb
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# rubocop:disable Metrics/BlockLength
|
|
4
|
+
|
|
3
5
|
require "json"
|
|
4
6
|
require "pathname"
|
|
5
7
|
|
|
6
8
|
module Branchproof
|
|
7
9
|
# Renders versioned terminal and JSON analysis reports.
|
|
8
10
|
class Report
|
|
9
|
-
SCHEMA_VERSION = "1.
|
|
11
|
+
SCHEMA_VERSION = "1.2"
|
|
10
12
|
CRITERION_VERSION = "masking_occurrence_v1"
|
|
11
13
|
|
|
12
14
|
def initialize(inventory:, evidence:, analysis:, minima:, baseline:, diagnostics:, level: 3, missing_only: false,
|
|
@@ -70,6 +72,31 @@ module Branchproof
|
|
|
70
72
|
"#{prefix}#{path}: #{message}"
|
|
71
73
|
end
|
|
72
74
|
|
|
75
|
+
# Returns condition-value evidence for focused renderers without exposing
|
|
76
|
+
# the report's internal document traversal or mutating saved records.
|
|
77
|
+
def condition_coverage_evidence(decision_id:, condition_id:)
|
|
78
|
+
decision = inventory_decisions.find { |item| value(item, :id).to_s == decision_id.to_s }
|
|
79
|
+
condition = Array(value(decision, :conditions)).find do |item|
|
|
80
|
+
value(item, :id).to_s == condition_id.to_s
|
|
81
|
+
end
|
|
82
|
+
coverage = value(condition_result(decision, condition), :coverage)
|
|
83
|
+
return [] unless coverage
|
|
84
|
+
|
|
85
|
+
@terminal_ids ||= terminal_ids
|
|
86
|
+
lines = Array(value(coverage, :values)).map do |entry|
|
|
87
|
+
value_label = value(entry, :value) ? "true" : "false"
|
|
88
|
+
owners = Array(value(entry, :test_ids)).map { |id| test_label(id) }
|
|
89
|
+
owners << "unattributed" if value(entry, :unattributed_count).to_i.positive?
|
|
90
|
+
evidence = owners.empty? ? "none recorded" : owners.uniq.join(", ")
|
|
91
|
+
" Value #{value_label}: #{value(entry, :observed) ? "observed" : "missing"}; tests: #{evidence}"
|
|
92
|
+
end
|
|
93
|
+
missing = Array(value(coverage, :missing_values))
|
|
94
|
+
unless missing.empty?
|
|
95
|
+
lines << " Missing values: #{missing.map { |item| item ? "true" : "false" }.join(", ")}"
|
|
96
|
+
end
|
|
97
|
+
lines
|
|
98
|
+
end
|
|
99
|
+
|
|
73
100
|
def exit_code
|
|
74
101
|
return 2 unless usage_valid?
|
|
75
102
|
|
|
@@ -78,7 +105,9 @@ module Branchproof
|
|
|
78
105
|
return 1 if status == "FAILED"
|
|
79
106
|
return 2 unless status == "PASSED" && value(@baseline, :finalized) == true
|
|
80
107
|
return 2 if @saved_document && (value(@saved_document, :completeness) || {}).values.include?(false)
|
|
81
|
-
|
|
108
|
+
|
|
109
|
+
eligible = metrics[:eligible_conditions].positive? || metrics[:eligible_alternatives].positive?
|
|
110
|
+
return 2 unless eligible
|
|
82
111
|
return 2 unless valid_for_requested_level?
|
|
83
112
|
|
|
84
113
|
0
|
|
@@ -93,8 +122,8 @@ module Branchproof
|
|
|
93
122
|
tool_version: (defined?(Branchproof::VERSION) ? Branchproof::VERSION : "unknown"),
|
|
94
123
|
criterion_version: CRITERION_VERSION, runtime: RUBY_DESCRIPTION,
|
|
95
124
|
run_ids: Array(value(@evidence, :run_ids)),
|
|
96
|
-
source_inventory:
|
|
97
|
-
analysis: @
|
|
125
|
+
source_inventory: inventory_with_default_kinds, baseline: @baseline, observations: @evidence,
|
|
126
|
+
analysis: @analysis, minima: @minima, metrics: metrics,
|
|
98
127
|
diagnostics: @diagnostics, completeness: completeness,
|
|
99
128
|
run_metadata: @run_metadata)
|
|
100
129
|
end
|
|
@@ -112,11 +141,13 @@ module Branchproof
|
|
|
112
141
|
"Analysis: #{terminal_analysis_status}",
|
|
113
142
|
"Decisions: #{metrics[:supported]} supported, #{metrics[:unsupported]} excluded, " \
|
|
114
143
|
"#{metrics[:unexecuted]} unexecuted (#{metrics[:discovered]} discovered)",
|
|
144
|
+
decision_kind_summary_line,
|
|
115
145
|
"Observations: #{metrics[:completed]} completed, #{metrics[:aborted]} aborted, " \
|
|
116
146
|
"#{metrics[:unattributed]} unattributed",
|
|
117
|
-
|
|
147
|
+
values_legend]
|
|
148
|
+
lines.concat(coverage_ladder_lines)
|
|
118
149
|
lines << missing_summary_line if @missing_only
|
|
119
|
-
lines << "Scope: supported decisions and
|
|
150
|
+
lines << "Scope: supported decisions, conditions, and alternatives"
|
|
120
151
|
lines << ""
|
|
121
152
|
decisions_to_render.each { |decision| render_decision(lines, decision) }
|
|
122
153
|
render_minima(lines) unless @missing_only
|
|
@@ -133,18 +164,25 @@ module Branchproof
|
|
|
133
164
|
decision_label = "Decision #{short_id(value(decision, :id))} #{filename}:#{value(decision, :line)}"
|
|
134
165
|
lines << decision_label
|
|
135
166
|
lines << " Decision: #{value(decision, :expression)}"
|
|
167
|
+
lines << " Kind: #{decision_kind(decision)}"
|
|
168
|
+
context = value(decision, :context)
|
|
169
|
+
lines << " Context: #{context}" unless context.nil? || context.to_s.empty?
|
|
136
170
|
lines << " Status: #{value(decision, :support_status) || "SUPPORTED"}"
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
171
|
+
unless nonboolean_decision?(decision)
|
|
172
|
+
conditions_to_render(decision).each do |condition|
|
|
173
|
+
result = condition_result(decision, condition)
|
|
174
|
+
detail = condition_detail(decision, condition, result)
|
|
175
|
+
status = if @analysis.nil? || (@level == 1 && !coverage_available?)
|
|
176
|
+
"NOT CALCULATED"
|
|
177
|
+
else
|
|
178
|
+
value(result, :status) || "NOT_PROVEN"
|
|
179
|
+
end
|
|
180
|
+
lines << " Condition #{value(condition, :index)}: #{value(condition, :expression)}"
|
|
181
|
+
lines << " #{status}#{detail}"
|
|
182
|
+
render_condition_coverage(lines, decision, condition, result) if @level >= 2 && coverage_available?
|
|
183
|
+
end
|
|
147
184
|
end
|
|
185
|
+
render_decision_coverage(lines, decision) if coverage_available?
|
|
148
186
|
vectors_to_render(decision).each do |vector|
|
|
149
187
|
values = Array(value(vector, :values)).map do |item|
|
|
150
188
|
if item.nil?
|
|
@@ -155,13 +193,136 @@ module Branchproof
|
|
|
155
193
|
end.join
|
|
156
194
|
owners = Array(value(vector, :test_ids)).map { |test_id| test_label(test_id) }
|
|
157
195
|
owners << "unattributed" if value(vector, :unattributed_count).to_i.positive?
|
|
158
|
-
vector_label =
|
|
159
|
-
|
|
196
|
+
vector_label = if nonboolean_decision?(decision)
|
|
197
|
+
selected = Array(value(vector, :values)).each_with_index.filter_map do |item, index|
|
|
198
|
+
next unless item
|
|
199
|
+
|
|
200
|
+
alternative = Array(value(decision, :alternatives))[index]
|
|
201
|
+
value(alternative, :expression) || "alternative #{index}"
|
|
202
|
+
end
|
|
203
|
+
" Vector #{short_id(value(vector, :id))} [#{values}] => selected: #{selected.join(", ")}"
|
|
204
|
+
else
|
|
205
|
+
" Vector #{short_id(value(vector,
|
|
206
|
+
:id))} [#{values}] => " + (if value(vector,
|
|
207
|
+
:outcome)
|
|
208
|
+
"T"
|
|
209
|
+
else
|
|
210
|
+
"F"
|
|
211
|
+
end).to_s
|
|
212
|
+
end
|
|
160
213
|
lines << "#{vector_label} owners=#{owners.join(", ")}"
|
|
161
214
|
end
|
|
162
215
|
lines << ""
|
|
163
216
|
end
|
|
164
217
|
|
|
218
|
+
def coverage_status_label(status)
|
|
219
|
+
{ "covered" => "PASS", "partial" => "FAIL", "unexecuted" => "UNEXECUTED", "unsupported" => "EXCLUDED" }.fetch(
|
|
220
|
+
status.to_s.downcase, status.to_s.upcase
|
|
221
|
+
)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def render_decision_coverage(lines, decision)
|
|
225
|
+
if nonboolean_decision?(decision)
|
|
226
|
+
render_alternative_coverage(lines, decision)
|
|
227
|
+
return
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
decision_coverage = value(value(analysis_for(decision), :coverage), :decision)
|
|
231
|
+
all_coverage = value(analysis_for(decision), :coverage) || {}
|
|
232
|
+
statuses = [["D", :decision], ["C", :condition], ["C/D", :condition_decision],
|
|
233
|
+
["MC/DC", :mcdc]].filter_map do |label, key|
|
|
234
|
+
row = value(all_coverage, key)
|
|
235
|
+
status = value(row, :status)
|
|
236
|
+
status ? criterion_status_text(label, row) : nil
|
|
237
|
+
end
|
|
238
|
+
lines << " Coverage: #{statuses.join(", ")}" unless statuses.empty?
|
|
239
|
+
coverage = decision_coverage
|
|
240
|
+
return unless coverage
|
|
241
|
+
|
|
242
|
+
return unless @level >= 2
|
|
243
|
+
|
|
244
|
+
Array(value(coverage, :outcomes)).each do |outcome|
|
|
245
|
+
value_label = value(outcome, :value) ? "true" : "false"
|
|
246
|
+
owners = Array(value(outcome, :test_ids)).map { |id| test_label(id) }
|
|
247
|
+
owners << "unattributed" if value(outcome, :unattributed_count).to_i.positive?
|
|
248
|
+
evidence = owners.empty? ? "none recorded" : owners.uniq.join(", ")
|
|
249
|
+
lines << " Outcome #{value_label}: #{value(outcome, :observed) ? "observed" : "missing"}; tests: #{evidence}"
|
|
250
|
+
end
|
|
251
|
+
missing = Array(value(coverage, :missing_outcomes))
|
|
252
|
+
return if missing.empty?
|
|
253
|
+
|
|
254
|
+
lines << " Missing decision outcomes: #{missing.map do |item|
|
|
255
|
+
item ? "true" : "false"
|
|
256
|
+
end.join(", ")}"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def render_alternative_coverage(lines, decision)
|
|
260
|
+
coverage = value(value(analysis_for(decision), :coverage), :alternative) || {}
|
|
261
|
+
status = value(coverage, :status)
|
|
262
|
+
required = value(coverage, :required_alternatives) || Array(value(decision, :alternatives)).length
|
|
263
|
+
covered = value(coverage, :covered_alternatives) || 0
|
|
264
|
+
lines << " Alternatives: #{status || "NOT_CALCULATED"} (#{covered}/#{required})"
|
|
265
|
+
lines << " MC/DC: N/A (not applicable)"
|
|
266
|
+
rows = Array(value(coverage, :alternatives))
|
|
267
|
+
rows_by_id = rows.to_h { |row| [value(row, :alternative_id).to_s, row] }
|
|
268
|
+
alternatives_to_render(decision).each do |alternative|
|
|
269
|
+
id = value(alternative, :id)
|
|
270
|
+
row = rows_by_id[id.to_s] || {}
|
|
271
|
+
lines << " Alternative #{value(alternative, :index)}: #{value(alternative, :expression)}"
|
|
272
|
+
render_alternative_state(lines, "Selected", value(row, :selected)) if @level >= 2
|
|
273
|
+
render_alternative_state(lines, "Not selected", value(row, :not_selected)) if @level >= 2
|
|
274
|
+
render_alternative_state(lines, "Skipped", value(row, :skipped)) if @level >= 2
|
|
275
|
+
end
|
|
276
|
+
missing = Array(value(coverage, :missing_alternatives))
|
|
277
|
+
return if missing.empty?
|
|
278
|
+
|
|
279
|
+
labels = missing.map do |id|
|
|
280
|
+
alternative = Array(value(decision, :alternatives)).find { |item| value(item, :id).to_s == id.to_s }
|
|
281
|
+
value(alternative, :expression) || short_id(id)
|
|
282
|
+
end
|
|
283
|
+
label = labels.join(", ")
|
|
284
|
+
lines << " Missing alternatives: #{label}"
|
|
285
|
+
lines << " Need selection of: #{label}"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def render_alternative_state(lines, label, evidence)
|
|
289
|
+
evidence ||= {}
|
|
290
|
+
owners = Array(value(evidence, :test_ids)).map { |id| test_label(id) }
|
|
291
|
+
owners << "unattributed" if value(evidence, :unattributed_count).to_i.positive?
|
|
292
|
+
owners = owners.uniq
|
|
293
|
+
status = if value(evidence, :observed)
|
|
294
|
+
"observed"
|
|
295
|
+
elsif label == "Selected"
|
|
296
|
+
"missing"
|
|
297
|
+
else
|
|
298
|
+
"none recorded"
|
|
299
|
+
end
|
|
300
|
+
detail = owners.empty? ? "none recorded" : owners.join(", ")
|
|
301
|
+
lines << " #{label}: #{status}; tests: #{detail}"
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def render_condition_coverage(lines, _decision, condition, result)
|
|
305
|
+
coverage = value(result, :coverage)
|
|
306
|
+
return unless coverage
|
|
307
|
+
|
|
308
|
+
values = Array(value(coverage, :values))
|
|
309
|
+
return if values.empty?
|
|
310
|
+
|
|
311
|
+
values.each do |entry|
|
|
312
|
+
value_label = value(entry, :value) ? "true" : "false"
|
|
313
|
+
owners = Array(value(entry, :test_ids)).map { |id| test_label(id) }
|
|
314
|
+
owners << "unattributed" if value(entry, :unattributed_count).to_i.positive?
|
|
315
|
+
evidence = owners.empty? ? "none recorded" : owners.uniq.join(", ")
|
|
316
|
+
lines << " Value #{value_label}: #{value(entry, :observed) ? "observed" : "missing"}; tests: #{evidence}"
|
|
317
|
+
end
|
|
318
|
+
missing = Array(value(coverage, :missing_values))
|
|
319
|
+
return if missing.empty?
|
|
320
|
+
|
|
321
|
+
lines << " Missing values for #{value(condition, :expression)}: #{missing.map do |item|
|
|
322
|
+
item ? "true" : "false"
|
|
323
|
+
end.join(", ")}"
|
|
324
|
+
end
|
|
325
|
+
|
|
165
326
|
def render_minima(lines)
|
|
166
327
|
rows = Array(@minima).map do |minimum|
|
|
167
328
|
objective = value(minimum, :objective)
|
|
@@ -189,19 +350,19 @@ module Branchproof
|
|
|
189
350
|
end
|
|
190
351
|
|
|
191
352
|
def terminal_coverage_label
|
|
192
|
-
return "not calculated" if @analysis.nil?
|
|
353
|
+
return "not calculated" if @analysis.nil?
|
|
193
354
|
|
|
194
355
|
coverage_label
|
|
195
356
|
end
|
|
196
357
|
|
|
197
358
|
def terminal_coverage_line
|
|
198
|
-
return "MC/DC: not calculated" if @analysis.nil?
|
|
359
|
+
return "MC/DC: not calculated" if @analysis.nil?
|
|
199
360
|
|
|
200
361
|
"MC/DC: #{terminal_coverage_label} (#{metrics[:proven]}/#{metrics[:eligible_conditions]} conditions proven)"
|
|
201
362
|
end
|
|
202
363
|
|
|
203
364
|
def terminal_analysis_status
|
|
204
|
-
return "NOT CALCULATED" if @analysis.nil?
|
|
365
|
+
return "NOT CALCULATED" if @analysis.nil?
|
|
205
366
|
|
|
206
367
|
analysis_status
|
|
207
368
|
end
|
|
@@ -434,23 +595,73 @@ module Branchproof
|
|
|
434
595
|
vectors.find { |vector| value(vector, :id).to_s == id }
|
|
435
596
|
end
|
|
436
597
|
|
|
598
|
+
def values_legend
|
|
599
|
+
return "Values: T=true, F=false, -=short-circuited" unless inventory_decisions.any? do |decision|
|
|
600
|
+
nonboolean_decision?(decision)
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
"Values: Boolean T=true/F=false; flow T=selected, F=not-selected, -=skipped"
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
def inventory_with_default_kinds
|
|
607
|
+
decisions = inventory_decisions.map do |decision|
|
|
608
|
+
next decision if value(decision, :kind)
|
|
609
|
+
|
|
610
|
+
decision.respond_to?(:key?) ? decision.merge(kind: "boolean") : decision
|
|
611
|
+
end
|
|
612
|
+
return @inventory unless value(@inventory, :decisions)
|
|
613
|
+
|
|
614
|
+
@inventory.merge(decisions: decisions)
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
def decision_kind(decision)
|
|
618
|
+
kind = value(decision, :kind).to_s
|
|
619
|
+
kind.empty? ? "boolean" : kind
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
def nonboolean_decision?(decision)
|
|
623
|
+
%w[implicit multiway pattern exception].include?(decision_kind(decision))
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def decision_kind_summary_line
|
|
627
|
+
counts = metrics[:kind_counts]
|
|
628
|
+
return "Decision kinds: none" if counts.empty?
|
|
629
|
+
|
|
630
|
+
contexts = metrics[:context_counts]
|
|
631
|
+
suffix = if contexts.empty?
|
|
632
|
+
""
|
|
633
|
+
else
|
|
634
|
+
"; contexts: #{contexts.map do |context, count|
|
|
635
|
+
"#{context}=#{count}"
|
|
636
|
+
end.join(", ")}"
|
|
637
|
+
end
|
|
638
|
+
"Decision kinds: #{counts.map { |kind, count| "#{kind}=#{count}" }.join(", ")}#{suffix}"
|
|
639
|
+
end
|
|
640
|
+
|
|
437
641
|
def metrics
|
|
438
642
|
decisions = inventory_decisions
|
|
439
643
|
unsupported, supported = decisions.partition { |decision| unsupported?(decision) }
|
|
440
644
|
eligible = supported.sum { |decision| Array(value(decision, :conditions)).length }
|
|
441
645
|
observed = vectors.map { |vector| value(vector, :decision_id).to_s }.uniq
|
|
442
646
|
proven = @analysis ? value(@analysis, :proven_count).to_i : 0
|
|
647
|
+
kind_counts = decisions.group_by { |decision| decision_kind(decision) }.transform_values(&:length)
|
|
648
|
+
context_counts = decisions.group_by { |decision| value(decision, :context).to_s }
|
|
649
|
+
context_counts = context_counts.reject { |context, _| context.empty? }.transform_values(&:length)
|
|
443
650
|
{ discovered: decisions.length, supported: supported.length, unsupported: unsupported.length,
|
|
444
651
|
unsupported_conditions: unsupported.sum do |decision|
|
|
445
652
|
discovered_conditions(decision)
|
|
446
653
|
end, eligible_conditions: eligible,
|
|
447
654
|
opaque: decisions.sum { |decision| Array(value(decision, :opaque_ranges)).length },
|
|
448
655
|
unexecuted: supported.count { |decision| !observed.include?(value(decision, :id).to_s) },
|
|
656
|
+
eligible_alternatives: supported.sum do |decision|
|
|
657
|
+
nonboolean_decision?(decision) ? Array(value(decision, :alternatives)).length : 0
|
|
658
|
+
end,
|
|
449
659
|
completed: vectors.sum do |vector|
|
|
450
660
|
value(vector, :count).to_i
|
|
451
661
|
end, aborted: numeric_hash_value(@evidence, :abort_counts),
|
|
452
662
|
unattributed: vectors.sum { |vector| value(vector, :unattributed_count).to_i }, limited: incomplete? ? 1 : 0,
|
|
453
|
-
proven: proven, percentage: percentage(eligible, proven)
|
|
663
|
+
proven: proven, percentage: percentage(eligible, proven), kind_counts: kind_counts,
|
|
664
|
+
decision_kinds: kind_counts, context_counts: context_counts }
|
|
454
665
|
end
|
|
455
666
|
|
|
456
667
|
def completeness
|
|
@@ -473,7 +684,7 @@ module Branchproof
|
|
|
473
684
|
end
|
|
474
685
|
|
|
475
686
|
def valid_for_requested_level?
|
|
476
|
-
return completeness[:observation] && completeness[:attribution] if @level == 1
|
|
687
|
+
return completeness[:observation] && completeness[:attribution] if @level == 1 && @analysis.nil?
|
|
477
688
|
|
|
478
689
|
completeness.values.all? { |item| item == true }
|
|
479
690
|
end
|
|
@@ -486,13 +697,14 @@ module Branchproof
|
|
|
486
697
|
end
|
|
487
698
|
|
|
488
699
|
def analysis_status
|
|
489
|
-
return "NOT_REQUESTED" if @analysis.nil?
|
|
700
|
+
return "NOT_REQUESTED" if @analysis.nil?
|
|
490
701
|
|
|
491
702
|
valid_for_requested_level? ? "COMPLETE" : "PARTIAL"
|
|
492
703
|
end
|
|
493
704
|
|
|
494
705
|
def incomplete?
|
|
495
|
-
!completeness[:observation] || !completeness[:attribution] ||
|
|
706
|
+
!completeness[:observation] || !completeness[:attribution] ||
|
|
707
|
+
(analysis_available? && !completeness[:analysis])
|
|
496
708
|
end
|
|
497
709
|
|
|
498
710
|
def vectors_for(decision)
|
|
@@ -507,24 +719,108 @@ module Branchproof
|
|
|
507
719
|
|
|
508
720
|
inventory_decisions
|
|
509
721
|
.reject { |decision| unsupported?(decision) }
|
|
510
|
-
.select
|
|
722
|
+
.select do |decision|
|
|
723
|
+
nonboolean_decision?(decision) ? missing_alternatives_for(decision).any? : conditions_to_render(decision).any?
|
|
724
|
+
end
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
def alternatives_to_render(decision)
|
|
728
|
+
alternatives = Array(value(decision, :alternatives))
|
|
729
|
+
return alternatives unless @missing_only && analysis_available?
|
|
730
|
+
|
|
731
|
+
missing_ids = missing_alternatives_for(decision).map(&:to_s)
|
|
732
|
+
alternatives.select { |alternative| missing_ids.include?(value(alternative, :id).to_s) }
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def missing_alternatives_for(decision)
|
|
736
|
+
coverage = value(value(analysis_for(decision), :coverage), :alternative)
|
|
737
|
+
Array(value(coverage, :missing_alternatives))
|
|
511
738
|
end
|
|
512
739
|
|
|
513
740
|
def conditions_to_render(decision)
|
|
514
741
|
conditions = Array(value(decision, :conditions))
|
|
515
742
|
return conditions unless @missing_only && analysis_available?
|
|
516
743
|
|
|
517
|
-
conditions.reject
|
|
744
|
+
conditions.reject do |condition|
|
|
745
|
+
value(condition_result(decision, condition), :status).to_s.upcase == "PROVEN"
|
|
746
|
+
end
|
|
518
747
|
end
|
|
519
748
|
|
|
520
749
|
def vectors_to_render(decision)
|
|
521
750
|
return vectors_for(decision) unless @missing_only
|
|
751
|
+
return [] if nonboolean_decision?(decision)
|
|
522
752
|
|
|
523
753
|
[]
|
|
524
754
|
end
|
|
525
755
|
|
|
526
756
|
def analysis_available?
|
|
527
|
-
!@analysis.nil?
|
|
757
|
+
!@analysis.nil?
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
def coverage_available?
|
|
761
|
+
analysis_available? && !value(@analysis, :coverage).nil?
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
# Render the shared ladder in every terminal view.
|
|
765
|
+
def coverage_ladder_lines
|
|
766
|
+
return [] unless coverage_available?
|
|
767
|
+
|
|
768
|
+
aggregate = value(@analysis, :coverage) || {}
|
|
769
|
+
rows = [["D", :decision, :covered_decisions, :supported_decisions],
|
|
770
|
+
["C", :condition, :covered_values, :required_values],
|
|
771
|
+
["C/D", :condition_decision, :covered_decisions, :supported_decisions],
|
|
772
|
+
["MC/DC", :mcdc, :proven_conditions, :supported_conditions]]
|
|
773
|
+
lines = ["Coverage ladder:"]
|
|
774
|
+
rows.each do |label, key, numerator_key, denominator_key|
|
|
775
|
+
row = value(aggregate, key)
|
|
776
|
+
next unless row
|
|
777
|
+
|
|
778
|
+
percentage = value(row, :percentage)
|
|
779
|
+
numerator = value(row, numerator_key) || 0
|
|
780
|
+
denominator = value(row, denominator_key) || 0
|
|
781
|
+
shown = percentage.nil? ? "N/A" : "#{percentage}%"
|
|
782
|
+
denominator_label = if ["D", "C/D"].include?(label)
|
|
783
|
+
"decisions"
|
|
784
|
+
else
|
|
785
|
+
label == "C" ? "truth values" : "conditions"
|
|
786
|
+
end
|
|
787
|
+
qualifier = incomplete? ? " (lower bound)" : ""
|
|
788
|
+
description = case label
|
|
789
|
+
when "D" then "Decision coverage"
|
|
790
|
+
when "C" then "Condition coverage"
|
|
791
|
+
when "C/D" then "Condition/decision coverage"
|
|
792
|
+
else "MC/DC coverage"
|
|
793
|
+
end
|
|
794
|
+
lines << " #{label} (#{description}): #{shown} (#{numerator}/#{denominator} #{denominator_label})#{qualifier}"
|
|
795
|
+
end
|
|
796
|
+
alternative = value(aggregate, :alternative)
|
|
797
|
+
if alternative
|
|
798
|
+
covered = value(alternative, :covered_alternatives) || 0
|
|
799
|
+
required = value(alternative, :required_alternatives) || 0
|
|
800
|
+
percentage = value(alternative, :percentage)
|
|
801
|
+
shown = percentage.nil? ? "N/A" : "#{percentage}%"
|
|
802
|
+
lines << " Alternative coverage: #{covered}/#{required} alternatives (#{shown})"
|
|
803
|
+
end
|
|
804
|
+
lines << ""
|
|
805
|
+
lines
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
def criterion_status_text(label, row)
|
|
809
|
+
raw_status = value(row, :status).to_s.downcase
|
|
810
|
+
status = coverage_status_label(raw_status)
|
|
811
|
+
return "#{label}=#{status}" if raw_status == "unsupported"
|
|
812
|
+
|
|
813
|
+
case label
|
|
814
|
+
when "D"
|
|
815
|
+
"#{label}=#{status} (#{value(row, :covered_outcomes) || 0}/#{value(row, :required_outcomes) || 0} outcomes)"
|
|
816
|
+
when "C"
|
|
817
|
+
"#{label}=#{status} (#{value(row, :covered_values) || 0}/#{value(row, :required_values) || 0} values; " \
|
|
818
|
+
"#{value(row, :covered_conditions) || 0}/#{value(row, :condition_count) || 0} conditions fully covered)"
|
|
819
|
+
when "MC/DC"
|
|
820
|
+
"#{label}=#{status} (#{value(row, :proven_conditions) || 0}/#{value(row, :condition_count) || 0} conditions)"
|
|
821
|
+
else
|
|
822
|
+
"#{label}=#{status}"
|
|
823
|
+
end
|
|
528
824
|
end
|
|
529
825
|
|
|
530
826
|
def analysis_complete?
|
|
@@ -538,11 +834,29 @@ module Branchproof
|
|
|
538
834
|
def missing_summary_line
|
|
539
835
|
return "Missing conditions: Cannot identify missing conditions (analysis unavailable)" unless analysis_available?
|
|
540
836
|
return "Missing conditions: Cannot identify missing conditions (analysis incomplete)" unless analysis_complete?
|
|
541
|
-
return "No
|
|
542
|
-
|
|
837
|
+
return "No missing conditions or alternatives" if analysis_complete? && missing_condition_count.zero? &&
|
|
838
|
+
missing_alternatives_count.zero?
|
|
839
|
+
return "No eligible conditions" if metrics[:eligible_conditions].zero? && metrics[:eligible_alternatives].zero?
|
|
543
840
|
|
|
544
841
|
count = decisions_to_render.length
|
|
545
|
-
|
|
842
|
+
if missing_alternatives_count.zero?
|
|
843
|
+
label = count == 1 ? "decision" : "decisions"
|
|
844
|
+
return "Missing conditions: #{missing_condition_count} across #{count} #{label}"
|
|
845
|
+
end
|
|
846
|
+
if missing_condition_count.zero?
|
|
847
|
+
label = count == 1 ? "decision" : "decisions"
|
|
848
|
+
return "Missing alternatives: #{missing_alternatives_count} across #{count} #{label}"
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
label = count == 1 ? "decision" : "decisions"
|
|
852
|
+
conditions = missing_condition_count
|
|
853
|
+
alternatives = missing_alternatives_count
|
|
854
|
+
"Missing conditions: #{conditions}; alternatives: #{alternatives} " \
|
|
855
|
+
"across #{count} #{label}"
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
def missing_alternatives_count
|
|
859
|
+
decisions_to_render.sum { |decision| missing_alternatives_for(decision).length }
|
|
546
860
|
end
|
|
547
861
|
|
|
548
862
|
def analysis_for(decision)
|
|
@@ -604,5 +918,8 @@ module Branchproof
|
|
|
604
918
|
def normalize_unknown(object)
|
|
605
919
|
object.to_s
|
|
606
920
|
end
|
|
921
|
+
public :condition_coverage_evidence, :coverage_ladder_lines, :coverage_status_label
|
|
607
922
|
end
|
|
608
923
|
end
|
|
924
|
+
|
|
925
|
+
# rubocop:enable Metrics/BlockLength
|
data/lib/branchproof/runtime.rb
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
|
+
require_relative "runtime_flow"
|
|
4
5
|
|
|
5
6
|
module Branchproof
|
|
6
7
|
# Process-local execution recorder. It deliberately never coerces or stores
|
|
7
8
|
# application values: Ruby's conditional expression is used for truthiness.
|
|
8
9
|
# Captures condition evaluations while preserving application values.
|
|
9
10
|
module Runtime
|
|
11
|
+
extend RuntimeFlow
|
|
12
|
+
|
|
10
13
|
class << self
|
|
11
14
|
def boot(evidence:)
|
|
12
15
|
return nil if defined?(@booted) && @booted && @evidence.equal?(evidence) && Process.pid == @process_id
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Branchproof
|
|
4
|
+
# Records native Ruby path selection without evaluating application values twice.
|
|
5
|
+
module RuntimeFlow
|
|
6
|
+
def flow_candidate(decision_id, index)
|
|
7
|
+
frame = current_frame(decision_id)
|
|
8
|
+
frame[:observations] << [index, false] if frame
|
|
9
|
+
nil
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def flow_selected(decision_id)
|
|
13
|
+
frame = current_frame(decision_id)
|
|
14
|
+
if frame && !frame[:observations].empty?
|
|
15
|
+
frame[:observations].last[1] = true
|
|
16
|
+
frame[:outcome] = true
|
|
17
|
+
frame[:finished] = true
|
|
18
|
+
end
|
|
19
|
+
nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def flow_select(decision_id, index)
|
|
23
|
+
frame = current_frame(decision_id)
|
|
24
|
+
if frame
|
|
25
|
+
frame[:observations] = (0..index).map { |position| [position, position == index] }
|
|
26
|
+
frame[:outcome] = true
|
|
27
|
+
frame[:finished] = true
|
|
28
|
+
end
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def flow_path(decision_id, index)
|
|
33
|
+
frame = current_frame(decision_id)
|
|
34
|
+
if frame
|
|
35
|
+
frame[:observations] = [[0, index.zero?], [1, index == 1]]
|
|
36
|
+
frame[:outcome] = true
|
|
37
|
+
frame[:finished] = true
|
|
38
|
+
end
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def flow_finish(decision_id, value, default_path = nil)
|
|
43
|
+
frame = current_frame(decision_id)
|
|
44
|
+
flow_path(decision_id, default_path) if frame && !frame[:finished] && !default_path.nil?
|
|
45
|
+
value
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def flow_receiver(decision_id, receiver)
|
|
49
|
+
enter(decision_id)
|
|
50
|
+
begin
|
|
51
|
+
flow_path(decision_id, nil.equal?(receiver) ? 0 : 1)
|
|
52
|
+
ensure
|
|
53
|
+
leave(decision_id)
|
|
54
|
+
end
|
|
55
|
+
receiver
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|