branchproof 0.6.0 → 0.8.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 +45 -0
- data/README.md +279 -27
- data/doc/Branchproof/Analyzer.md +2 -2
- data/doc/Branchproof/CLI.md +4 -0
- data/doc/Branchproof/ComparisonReport.md +4 -0
- data/doc/Branchproof/Constraints/Solver.md +21 -0
- data/doc/Branchproof/Constraints.md +98 -0
- data/doc/Branchproof/CoverageIndex.md +6 -0
- data/doc/Branchproof/DecisionSyntax.md +22 -0
- data/doc/Branchproof/DecisionTable.md +153 -0
- data/doc/Branchproof/FlowInstrumentation.md +7 -0
- data/doc/Branchproof/FocusedReport.md +5 -2
- data/doc/Branchproof/Instrumenter.md +1 -0
- data/doc/Branchproof/Loader.md +0 -4
- data/doc/Branchproof/MinitestAdapter.md +0 -3
- data/doc/Branchproof/Report.md +18 -0
- data/doc/Branchproof/Runtime.md +23 -0
- data/doc/Branchproof/RuntimeFlow.md +27 -0
- data/doc/Branchproof/SavedReport.md +21 -0
- data/doc/Branchproof/Source.md +1 -0
- data/doc/Branchproof.md +9 -2
- data/doc/CHANGELOG.md +45 -0
- data/doc/README.md +279 -27
- data/lib/branchproof/analyzer.rb +222 -54
- data/lib/branchproof/cli.rb +28 -19
- data/lib/branchproof/comparison.rb +207 -19
- data/lib/branchproof/comparison_report.rb +49 -1
- data/lib/branchproof/constraints.rb +363 -0
- data/lib/branchproof/coverage_index.rb +120 -3
- data/lib/branchproof/decision_syntax.rb +310 -0
- data/lib/branchproof/decision_table.rb +377 -0
- data/lib/branchproof/evidence.rb +101 -36
- data/lib/branchproof/flow_instrumentation.rb +107 -0
- data/lib/branchproof/focused_report.rb +161 -26
- data/lib/branchproof/instrumenter.rb +65 -39
- data/lib/branchproof/limits.rb +4 -1
- data/lib/branchproof/loader.rb +18 -6
- data/lib/branchproof/minimizer.rb +18 -13
- data/lib/branchproof/minitest_adapter.rb +11 -16
- data/lib/branchproof/records.rb +2 -0
- data/lib/branchproof/report.rb +470 -66
- data/lib/branchproof/runtime.rb +25 -30
- data/lib/branchproof/runtime_flow.rb +58 -0
- data/lib/branchproof/saved_report.rb +438 -13
- data/lib/branchproof/source.rb +239 -41
- data/lib/branchproof/version.rb +1 -1
- data/lib/branchproof/worker.rb +1 -4
- data/lib/branchproof.rb +2 -0
- data/llms.txt +9 -2
- data/sig/branchproof.rbs +54 -1
- metadata +12 -1
data/lib/branchproof/report.rb
CHANGED
|
@@ -1,19 +1,26 @@
|
|
|
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.3"
|
|
10
12
|
CRITERION_VERSION = "masking_occurrence_v1"
|
|
13
|
+
VIEWS = %i[decisions conditions tests decision_tables].freeze
|
|
14
|
+
DECISION_TABLE_LABELS = { "true" => "T", "false" => "F", "dont_care" => "-" }.freeze
|
|
15
|
+
DECISION_TABLE_STATUS_LABELS = { "covered" => "COVERED", "missing" => "MISSING",
|
|
16
|
+
"excluded" => "EXCLUDED" }.freeze
|
|
11
17
|
|
|
12
18
|
def initialize(inventory:, evidence:, analysis:, minima:, baseline:, diagnostics:, level: 3, missing_only: false,
|
|
13
19
|
view: :decisions, run_metadata: {}, saved_document: nil)
|
|
14
20
|
raise ArgumentError, "level must be 1, 2, or 3" unless [1, 2, 3].include?(level.to_i)
|
|
15
|
-
|
|
16
|
-
|
|
21
|
+
unless VIEWS.include?(view.to_sym)
|
|
22
|
+
raise ArgumentError, "view must be :decisions, :conditions, :tests, or :decision_tables"
|
|
23
|
+
end
|
|
17
24
|
|
|
18
25
|
@inventory = inventory || {}
|
|
19
26
|
@evidence = evidence || {}
|
|
@@ -30,8 +37,8 @@ module Branchproof
|
|
|
30
37
|
|
|
31
38
|
def self.from_document(document:, level: nil, view: :decisions, missing_only: false)
|
|
32
39
|
data = document || {}
|
|
33
|
-
new(inventory: data[:source_inventory] || data["source_inventory"]
|
|
34
|
-
evidence: data[:observations] || data["observations"]
|
|
40
|
+
new(inventory: data[:source_inventory] || data["source_inventory"],
|
|
41
|
+
evidence: data[:observations] || data["observations"],
|
|
35
42
|
analysis: data[:analysis] || data["analysis"], minima: data[:minima] || data["minima"],
|
|
36
43
|
baseline: data[:baseline] || data["baseline"], diagnostics: data[:diagnostics] || data["diagnostics"],
|
|
37
44
|
level: level || (data[:analysis] || data["analysis"] ? 3 : 1), view: view, missing_only: missing_only,
|
|
@@ -103,7 +110,9 @@ module Branchproof
|
|
|
103
110
|
return 1 if status == "FAILED"
|
|
104
111
|
return 2 unless status == "PASSED" && value(@baseline, :finalized) == true
|
|
105
112
|
return 2 if @saved_document && (value(@saved_document, :completeness) || {}).values.include?(false)
|
|
106
|
-
|
|
113
|
+
|
|
114
|
+
eligible = metrics[:eligible_conditions].positive? || metrics[:eligible_alternatives].positive?
|
|
115
|
+
return 2 unless eligible
|
|
107
116
|
return 2 unless valid_for_requested_level?
|
|
108
117
|
|
|
109
118
|
0
|
|
@@ -118,7 +127,7 @@ module Branchproof
|
|
|
118
127
|
tool_version: (defined?(Branchproof::VERSION) ? Branchproof::VERSION : "unknown"),
|
|
119
128
|
criterion_version: CRITERION_VERSION, runtime: RUBY_DESCRIPTION,
|
|
120
129
|
run_ids: Array(value(@evidence, :run_ids)),
|
|
121
|
-
source_inventory:
|
|
130
|
+
source_inventory: inventory_with_default_kinds, baseline: @baseline, observations: @evidence,
|
|
122
131
|
analysis: @analysis, minima: @minima, metrics: metrics,
|
|
123
132
|
diagnostics: @diagnostics, completeness: completeness,
|
|
124
133
|
run_metadata: @run_metadata)
|
|
@@ -127,7 +136,7 @@ module Branchproof
|
|
|
127
136
|
def terminal_document
|
|
128
137
|
unless @view == :decisions
|
|
129
138
|
return FocusedReport.new(document: json_document, view: @view, level: @level,
|
|
130
|
-
missing_only: @missing_only).render
|
|
139
|
+
missing_only: @missing_only, coordinator: self).render
|
|
131
140
|
end
|
|
132
141
|
|
|
133
142
|
@terminal_ids = terminal_ids
|
|
@@ -137,12 +146,13 @@ module Branchproof
|
|
|
137
146
|
"Analysis: #{terminal_analysis_status}",
|
|
138
147
|
"Decisions: #{metrics[:supported]} supported, #{metrics[:unsupported]} excluded, " \
|
|
139
148
|
"#{metrics[:unexecuted]} unexecuted (#{metrics[:discovered]} discovered)",
|
|
149
|
+
decision_kind_summary_line,
|
|
140
150
|
"Observations: #{metrics[:completed]} completed, #{metrics[:aborted]} aborted, " \
|
|
141
151
|
"#{metrics[:unattributed]} unattributed",
|
|
142
|
-
|
|
152
|
+
values_legend]
|
|
143
153
|
lines.concat(coverage_ladder_lines)
|
|
144
154
|
lines << missing_summary_line if @missing_only
|
|
145
|
-
lines << "Scope: supported decisions and
|
|
155
|
+
lines << "Scope: supported decisions, conditions, and alternatives"
|
|
146
156
|
lines << ""
|
|
147
157
|
decisions_to_render.each { |decision| render_decision(lines, decision) }
|
|
148
158
|
render_minima(lines) unless @missing_only
|
|
@@ -159,20 +169,26 @@ module Branchproof
|
|
|
159
169
|
decision_label = "Decision #{short_id(value(decision, :id))} #{filename}:#{value(decision, :line)}"
|
|
160
170
|
lines << decision_label
|
|
161
171
|
lines << " Decision: #{value(decision, :expression)}"
|
|
172
|
+
lines << " Kind: #{decision_kind(decision)}"
|
|
173
|
+
context = value(decision, :context)
|
|
174
|
+
lines << " Context: #{context}" unless context.nil? || context.to_s.empty?
|
|
162
175
|
lines << " Status: #{value(decision, :support_status) || "SUPPORTED"}"
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
176
|
+
unless nonboolean_decision?(decision)
|
|
177
|
+
conditions_to_render(decision).each do |condition|
|
|
178
|
+
result = condition_result(decision, condition)
|
|
179
|
+
detail = condition_detail(decision, condition, result)
|
|
180
|
+
status = if @analysis.nil? || (@level == 1 && !coverage_available?)
|
|
181
|
+
"NOT CALCULATED"
|
|
182
|
+
else
|
|
183
|
+
value(result, :status) || "NOT_PROVEN"
|
|
184
|
+
end
|
|
185
|
+
lines << " Condition #{value(condition, :index)}: #{value(condition, :expression)}"
|
|
186
|
+
lines << " #{status}#{detail}"
|
|
187
|
+
render_condition_coverage(lines, decision, condition, result) if @level >= 2 && coverage_available?
|
|
188
|
+
end
|
|
174
189
|
end
|
|
175
190
|
render_decision_coverage(lines, decision) if coverage_available?
|
|
191
|
+
render_decision_table(lines, decision) if analysis_available? && !nonboolean_decision?(decision)
|
|
176
192
|
vectors_to_render(decision).each do |vector|
|
|
177
193
|
values = Array(value(vector, :values)).map do |item|
|
|
178
194
|
if item.nil?
|
|
@@ -183,8 +199,23 @@ module Branchproof
|
|
|
183
199
|
end.join
|
|
184
200
|
owners = Array(value(vector, :test_ids)).map { |test_id| test_label(test_id) }
|
|
185
201
|
owners << "unattributed" if value(vector, :unattributed_count).to_i.positive?
|
|
186
|
-
vector_label =
|
|
187
|
-
|
|
202
|
+
vector_label = if nonboolean_decision?(decision)
|
|
203
|
+
selected = Array(value(vector, :values)).each_with_index.filter_map do |item, index|
|
|
204
|
+
next unless item
|
|
205
|
+
|
|
206
|
+
alternative = Array(value(decision, :alternatives))[index]
|
|
207
|
+
value(alternative, :expression) || "alternative #{index}"
|
|
208
|
+
end
|
|
209
|
+
" Vector #{short_id(value(vector, :id))} [#{values}] => selected: #{selected.join(", ")}"
|
|
210
|
+
else
|
|
211
|
+
" Vector #{short_id(value(vector,
|
|
212
|
+
:id))} [#{values}] => " + (if value(vector,
|
|
213
|
+
:outcome)
|
|
214
|
+
"T"
|
|
215
|
+
else
|
|
216
|
+
"F"
|
|
217
|
+
end).to_s
|
|
218
|
+
end
|
|
188
219
|
lines << "#{vector_label} owners=#{owners.join(", ")}"
|
|
189
220
|
end
|
|
190
221
|
lines << ""
|
|
@@ -197,10 +228,15 @@ module Branchproof
|
|
|
197
228
|
end
|
|
198
229
|
|
|
199
230
|
def render_decision_coverage(lines, decision)
|
|
231
|
+
if nonboolean_decision?(decision)
|
|
232
|
+
render_alternative_coverage(lines, decision)
|
|
233
|
+
return
|
|
234
|
+
end
|
|
235
|
+
|
|
200
236
|
decision_coverage = value(value(analysis_for(decision), :coverage), :decision)
|
|
201
237
|
all_coverage = value(analysis_for(decision), :coverage) || {}
|
|
202
238
|
statuses = [["D", :decision], ["C", :condition], ["C/D", :condition_decision],
|
|
203
|
-
["MC/DC", :mcdc]].filter_map do |label, key|
|
|
239
|
+
["MC/DC", :mcdc], ["DT", :decision_table]].filter_map do |label, key|
|
|
204
240
|
row = value(all_coverage, key)
|
|
205
241
|
status = value(row, :status)
|
|
206
242
|
status ? criterion_status_text(label, row) : nil
|
|
@@ -212,11 +248,11 @@ module Branchproof
|
|
|
212
248
|
return unless @level >= 2
|
|
213
249
|
|
|
214
250
|
Array(value(coverage, :outcomes)).each do |outcome|
|
|
215
|
-
value_label = value(outcome, :value)
|
|
251
|
+
value_label = decision_outcome_label(decision, value(outcome, :value))
|
|
216
252
|
owners = Array(value(outcome, :test_ids)).map { |id| test_label(id) }
|
|
217
253
|
owners << "unattributed" if value(outcome, :unattributed_count).to_i.positive?
|
|
218
254
|
evidence = owners.empty? ? "none recorded" : owners.uniq.join(", ")
|
|
219
|
-
lines << "
|
|
255
|
+
lines << " #{value_label}: #{value(outcome, :observed) ? "observed" : "missing"}; tests: #{evidence}"
|
|
220
256
|
end
|
|
221
257
|
missing = Array(value(coverage, :missing_outcomes))
|
|
222
258
|
return if missing.empty?
|
|
@@ -226,6 +262,209 @@ module Branchproof
|
|
|
226
262
|
end.join(", ")}"
|
|
227
263
|
end
|
|
228
264
|
|
|
265
|
+
# Renders the reduced decision table, its runtime overlay, and the
|
|
266
|
+
# actionable detail for rules that no observation reached.
|
|
267
|
+
def render_decision_table(lines, decision)
|
|
268
|
+
table = decision_table_for(decision)
|
|
269
|
+
return unless table
|
|
270
|
+
|
|
271
|
+
unless value(table, :status).to_s == "calculated"
|
|
272
|
+
return if value(table, :reason).to_s == "unsupported_decision" && unsupported?(decision)
|
|
273
|
+
|
|
274
|
+
lines << " Decision Table: NOT CALCULATED"
|
|
275
|
+
lines << " Reason: #{value(table, :reason)}"
|
|
276
|
+
return
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
covered = value(table, :covered_rules).to_i
|
|
280
|
+
required = value(table, :required_rules).to_i
|
|
281
|
+
percentage = value(table, :percentage)
|
|
282
|
+
lines << " Decision Table: #{covered}/#{required} rules covered" \
|
|
283
|
+
"#{" (#{percentage}%)" unless percentage.nil?}"
|
|
284
|
+
impossible = value(table, :impossible_rules).to_i
|
|
285
|
+
lines << " Statically impossible rules excluded: #{impossible}" if impossible.positive?
|
|
286
|
+
lines << " Reachability: not analyzed" unless value(table, :reachability_analyzed)
|
|
287
|
+
rules = decision_table_rules_to_render(table)
|
|
288
|
+
lines.concat(decision_table_rows(decision, rules))
|
|
289
|
+
return unless @level >= 2
|
|
290
|
+
|
|
291
|
+
rules.each { |rule| render_decision_table_rule(lines, decision, rule) }
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def decision_table_rows(decision, rules)
|
|
295
|
+
return [] if rules.empty?
|
|
296
|
+
|
|
297
|
+
conditions = Array(value(decision, :conditions))
|
|
298
|
+
header = ["Rule"] + conditions.map { |condition| truncate(value(condition, :expression).to_s, 24) } +
|
|
299
|
+
%w[Result Status]
|
|
300
|
+
body = rules.map do |rule|
|
|
301
|
+
[value(rule, :label).to_s] +
|
|
302
|
+
Array(value(rule, :conditions)).map { |item| DECISION_TABLE_LABELS.fetch(item.to_s, item.to_s) } +
|
|
303
|
+
[value(rule, :outcome) ? "T" : "F", decision_table_rule_status(rule)]
|
|
304
|
+
end
|
|
305
|
+
return [] unless body.all? { |row| row.length == header.length }
|
|
306
|
+
|
|
307
|
+
widths = ([header] + body).transpose.map { |column| column.map(&:length).max }
|
|
308
|
+
([header] + body).map do |row|
|
|
309
|
+
" #{row.each_with_index.map { |cell, index| cell.ljust(widths[index]) }.join(" ").rstrip}"
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def decision_table_rule_status(rule)
|
|
314
|
+
DECISION_TABLE_STATUS_LABELS.fetch(value(rule, :coverage).to_s, value(rule, :coverage).to_s.upcase)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def render_decision_table_rule(lines, decision, rule)
|
|
318
|
+
case value(rule, :coverage).to_s
|
|
319
|
+
when "covered" then render_covered_rule(lines, rule)
|
|
320
|
+
when "excluded" then lines.concat(impossible_rule_lines(rule))
|
|
321
|
+
else lines.concat(missing_rule_lines(decision, rule))
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def render_covered_rule(lines, rule)
|
|
326
|
+
owners = Array(value(rule, :tests)).map { |id| test_label(id) }
|
|
327
|
+
owners << "unattributed" if value(rule, :unattributed_count).to_i.positive?
|
|
328
|
+
lines << " #{value(rule, :label)} tests: #{owners.empty? ? "none recorded" : owners.uniq.join(", ")}"
|
|
329
|
+
return unless value(rule, :impossible_withdrawn)
|
|
330
|
+
|
|
331
|
+
lines << " #{value(rule, :label)} note: runtime evidence overrides the static impossibility claim " \
|
|
332
|
+
"(#{value(rule, :withdrawn_reason)}); diagnostic constraint_model_conflict"
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Impossible rules stay visible even though they leave the denominator.
|
|
336
|
+
def impossible_rule_lines(rule)
|
|
337
|
+
[" #{value(rule, :label)} #{rule_signature(rule)}",
|
|
338
|
+
" Status:", " EXCLUDED",
|
|
339
|
+
" Reachability:", " STATICALLY IMPOSSIBLE",
|
|
340
|
+
" Reason:", " #{Constraints.message(value(rule, :reachability_reason))}"]
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Condition values only: Branchproof never claims which application inputs
|
|
344
|
+
# would produce them.
|
|
345
|
+
def missing_rule_lines(decision, rule)
|
|
346
|
+
requirements = decision_table_requirements(decision, rule)
|
|
347
|
+
width = requirements.map { |name, _| name.length }.max.to_i
|
|
348
|
+
lines = [" #{value(rule, :label)}", " Need:"]
|
|
349
|
+
requirements.each { |name, requirement| lines << " #{name.ljust(width)} #{requirement}" }
|
|
350
|
+
lines << " #{decision_table_expected_heading(decision)}"
|
|
351
|
+
lines << " #{value(rule, :outcome) ? "true" : "false"}"
|
|
352
|
+
lines << " Reachability:"
|
|
353
|
+
lines << " #{decision_table_reachability(rule)}"
|
|
354
|
+
lines
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def decision_table_requirements(decision, rule)
|
|
358
|
+
Array(value(rule, :conditions)).each_with_index.map do |required, index|
|
|
359
|
+
expression = condition_expression(decision, index) || "condition #{index}"
|
|
360
|
+
requirement = decision_table_requirement(required) || "not evaluated (short-circuited)"
|
|
361
|
+
[expression.to_s, "= #{requirement}"]
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def decision_table_requirement(value)
|
|
366
|
+
case value.to_s
|
|
367
|
+
when "true" then "truthy"
|
|
368
|
+
when "false" then "falsey"
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def decision_table_reachability(rule)
|
|
373
|
+
return "STATICALLY IMPOSSIBLE" if value(rule, :reachability).to_s == "statically_impossible"
|
|
374
|
+
|
|
375
|
+
value(rule, :reachability).to_s
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def decision_outcome_label(decision, outcome)
|
|
379
|
+
prefix = %w[unless until].include?(value(decision, :context).to_s) ? "Predicate" : "Outcome"
|
|
380
|
+
"#{prefix} #{outcome ? "true" : "false"}"
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def decision_table_expected_heading(decision)
|
|
384
|
+
%w[unless until].include?(value(decision, :context).to_s) ? "Expected predicate:" : "Expected decision:"
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def rule_signature(rule)
|
|
388
|
+
values = Array(value(rule, :conditions)).map { |item| DECISION_TABLE_LABELS.fetch(item.to_s, item.to_s) }
|
|
389
|
+
"#{values.join} => #{value(rule, :outcome) ? "T" : "F"}"
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def decision_table_rules_to_render(table)
|
|
393
|
+
rules = Array(value(table, :rules))
|
|
394
|
+
return rules unless @missing_only
|
|
395
|
+
|
|
396
|
+
rules.select { |rule| value(rule, :coverage).to_s == "missing" }
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def decision_table_for(decision)
|
|
400
|
+
value(analysis_for(decision), :decision_table)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def missing_decision_table_rules(decision)
|
|
404
|
+
table = decision_table_for(decision)
|
|
405
|
+
return [] unless table && value(table, :status).to_s == "calculated"
|
|
406
|
+
|
|
407
|
+
Array(value(table, :rules)).select { |rule| value(rule, :coverage).to_s == "missing" }
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def decision_table_unavailable?(decision)
|
|
411
|
+
table = decision_table_for(decision)
|
|
412
|
+
table && value(table, :status).to_s != "calculated"
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def unavailable_decision_table_count
|
|
416
|
+
inventory_decisions.count { |decision| decision_table_unavailable?(decision) }
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def truncate(text, width)
|
|
420
|
+
text.length <= width ? text : "#{text[0, width - 3]}..."
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def render_alternative_coverage(lines, decision)
|
|
424
|
+
coverage = value(value(analysis_for(decision), :coverage), :alternative) || {}
|
|
425
|
+
status = value(coverage, :status)
|
|
426
|
+
required = value(coverage, :required_alternatives) || Array(value(decision, :alternatives)).length
|
|
427
|
+
covered = value(coverage, :covered_alternatives) || 0
|
|
428
|
+
lines << " Alternatives: #{status || "NOT_CALCULATED"} (#{covered}/#{required})"
|
|
429
|
+
lines << " MC/DC: N/A (not applicable)"
|
|
430
|
+
rows = Array(value(coverage, :alternatives))
|
|
431
|
+
rows_by_id = rows.to_h { |row| [value(row, :alternative_id).to_s, row] }
|
|
432
|
+
alternatives_to_render(decision).each do |alternative|
|
|
433
|
+
id = value(alternative, :id)
|
|
434
|
+
row = rows_by_id[id.to_s] || {}
|
|
435
|
+
lines << " Alternative #{value(alternative, :index)}: #{value(alternative, :expression)}"
|
|
436
|
+
render_alternative_state(lines, "Selected", value(row, :selected)) if @level >= 2
|
|
437
|
+
render_alternative_state(lines, "Not selected", value(row, :not_selected)) if @level >= 2
|
|
438
|
+
render_alternative_state(lines, "Skipped", value(row, :skipped)) if @level >= 2
|
|
439
|
+
end
|
|
440
|
+
missing = Array(value(coverage, :missing_alternatives))
|
|
441
|
+
return if missing.empty?
|
|
442
|
+
|
|
443
|
+
labels = missing.map do |id|
|
|
444
|
+
alternative = Array(value(decision, :alternatives)).find { |item| value(item, :id).to_s == id.to_s }
|
|
445
|
+
value(alternative, :expression) || short_id(id)
|
|
446
|
+
end
|
|
447
|
+
label = labels.join(", ")
|
|
448
|
+
lines << " Missing alternatives: #{label}"
|
|
449
|
+
lines << " Need selection of: #{label}"
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def render_alternative_state(lines, label, evidence)
|
|
453
|
+
evidence ||= {}
|
|
454
|
+
owners = Array(value(evidence, :test_ids)).map { |id| test_label(id) }
|
|
455
|
+
owners << "unattributed" if value(evidence, :unattributed_count).to_i.positive?
|
|
456
|
+
owners = owners.uniq
|
|
457
|
+
status = if value(evidence, :observed)
|
|
458
|
+
"observed"
|
|
459
|
+
elsif label == "Selected"
|
|
460
|
+
"missing"
|
|
461
|
+
else
|
|
462
|
+
"none recorded"
|
|
463
|
+
end
|
|
464
|
+
detail = owners.empty? ? "none recorded" : owners.join(", ")
|
|
465
|
+
lines << " #{label}: #{status}; tests: #{detail}"
|
|
466
|
+
end
|
|
467
|
+
|
|
229
468
|
def render_condition_coverage(lines, _decision, condition, result)
|
|
230
469
|
coverage = value(result, :coverage)
|
|
231
470
|
return unless coverage
|
|
@@ -417,8 +656,15 @@ module Branchproof
|
|
|
417
656
|
end
|
|
418
657
|
|
|
419
658
|
def condition_result(decision, condition)
|
|
420
|
-
|
|
421
|
-
|
|
659
|
+
condition_results_index.dig(value(decision, :id).to_s, value(condition, :id).to_s)
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
def condition_results_index
|
|
663
|
+
@condition_results_index ||= Array(value(@analysis, :decisions)).each_with_object({}) do |item, index|
|
|
664
|
+
by_condition_id = index[value(item, :decision_id).to_s] ||= {}
|
|
665
|
+
Array(value(item, :condition_results)).each do |result|
|
|
666
|
+
by_condition_id[value(result, :condition_id).to_s] = result
|
|
667
|
+
end
|
|
422
668
|
end
|
|
423
669
|
end
|
|
424
670
|
|
|
@@ -517,34 +763,92 @@ module Branchproof
|
|
|
517
763
|
id = value(constraint, :existing_vector_id).to_s
|
|
518
764
|
return if id.empty?
|
|
519
765
|
|
|
520
|
-
|
|
766
|
+
vectors_by_id[id]
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
def vectors_by_id
|
|
770
|
+
@vectors_by_id ||= vectors.to_h { |vector| [value(vector, :id).to_s, vector] }
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
def values_legend
|
|
774
|
+
return "Values: T=true, F=false, -=short-circuited" unless inventory_decisions.any? do |decision|
|
|
775
|
+
nonboolean_decision?(decision)
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
"Values: Boolean T=true/F=false; flow T=selected, F=not-selected, -=skipped"
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
def inventory_with_default_kinds
|
|
782
|
+
decisions = inventory_decisions.map do |decision|
|
|
783
|
+
next decision if value(decision, :kind)
|
|
784
|
+
|
|
785
|
+
decision.respond_to?(:key?) ? decision.merge(kind: "boolean") : decision
|
|
786
|
+
end
|
|
787
|
+
return @inventory unless value(@inventory, :decisions)
|
|
788
|
+
|
|
789
|
+
@inventory.merge(decisions: decisions)
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
def decision_kind(decision)
|
|
793
|
+
kind = value(decision, :kind).to_s
|
|
794
|
+
kind.empty? ? "boolean" : kind
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
def nonboolean_decision?(decision)
|
|
798
|
+
%w[implicit multiway pattern exception].include?(decision_kind(decision))
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
def decision_kind_summary_line
|
|
802
|
+
counts = metrics[:kind_counts]
|
|
803
|
+
return "Decision kinds: none" if counts.empty?
|
|
804
|
+
|
|
805
|
+
contexts = metrics[:context_counts]
|
|
806
|
+
suffix = if contexts.empty?
|
|
807
|
+
""
|
|
808
|
+
else
|
|
809
|
+
"; contexts: #{contexts.map do |context, count|
|
|
810
|
+
"#{context}=#{count}"
|
|
811
|
+
end.join(", ")}"
|
|
812
|
+
end
|
|
813
|
+
"Decision kinds: #{counts.map { |kind, count| "#{kind}=#{count}" }.join(", ")}#{suffix}"
|
|
521
814
|
end
|
|
522
815
|
|
|
523
816
|
def metrics
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
817
|
+
@metrics ||= begin
|
|
818
|
+
decisions = inventory_decisions
|
|
819
|
+
unsupported, supported = decisions.partition { |decision| unsupported?(decision) }
|
|
820
|
+
eligible = supported.sum { |decision| Array(value(decision, :conditions)).length }
|
|
821
|
+
observed = vectors.map { |vector| value(vector, :decision_id).to_s }.uniq
|
|
822
|
+
proven = @analysis ? value(@analysis, :proven_count).to_i : 0
|
|
823
|
+
kind_counts = decisions.group_by { |decision| decision_kind(decision) }.transform_values(&:length)
|
|
824
|
+
context_counts = decisions.group_by { |decision| value(decision, :context).to_s }
|
|
825
|
+
context_counts = context_counts.reject { |context, _| context.empty? }.transform_values(&:length)
|
|
826
|
+
{ discovered: decisions.length, supported: supported.length, unsupported: unsupported.length,
|
|
827
|
+
unsupported_conditions: unsupported.sum do |decision|
|
|
828
|
+
discovered_conditions(decision)
|
|
829
|
+
end, eligible_conditions: eligible,
|
|
830
|
+
opaque: decisions.sum { |decision| Array(value(decision, :opaque_ranges)).length },
|
|
831
|
+
unexecuted: supported.count { |decision| !observed.include?(value(decision, :id).to_s) },
|
|
832
|
+
eligible_alternatives: supported.sum do |decision|
|
|
833
|
+
nonboolean_decision?(decision) ? Array(value(decision, :alternatives)).length : 0
|
|
834
|
+
end,
|
|
835
|
+
completed: vectors.sum do |vector|
|
|
836
|
+
value(vector, :count).to_i
|
|
837
|
+
end, aborted: numeric_hash_value(@evidence, :abort_counts),
|
|
838
|
+
unattributed: vectors.sum { |vector| value(vector, :unattributed_count).to_i }, limited: incomplete? ? 1 : 0,
|
|
839
|
+
proven: proven, percentage: percentage(eligible, proven), kind_counts: kind_counts,
|
|
840
|
+
decision_kinds: kind_counts, context_counts: context_counts }
|
|
841
|
+
end
|
|
540
842
|
end
|
|
541
843
|
|
|
542
844
|
def completeness
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
845
|
+
@completeness ||= begin
|
|
846
|
+
evidence = value(@evidence, :completeness) || {}
|
|
847
|
+
analysis = value(@analysis, :completeness) || {}
|
|
848
|
+
{ observation: completeness_value?(evidence, analysis, :observation),
|
|
849
|
+
attribution: completeness_value?(evidence, analysis, :attribution),
|
|
850
|
+
analysis: @analysis ? value(analysis, :analysis) == true : value(evidence, :analysis) == true }
|
|
851
|
+
end
|
|
548
852
|
end
|
|
549
853
|
|
|
550
854
|
def completeness_value?(evidence, analysis, key)
|
|
@@ -583,9 +887,11 @@ module Branchproof
|
|
|
583
887
|
end
|
|
584
888
|
|
|
585
889
|
def vectors_for(decision)
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
890
|
+
vectors_by_decision_id[value(decision, :id).to_s] || []
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
def vectors_by_decision_id
|
|
894
|
+
@vectors_by_decision_id ||= vectors.group_by { |vector| value(vector, :decision_id).to_s }
|
|
589
895
|
end
|
|
590
896
|
|
|
591
897
|
def decisions_to_render
|
|
@@ -594,14 +900,36 @@ module Branchproof
|
|
|
594
900
|
|
|
595
901
|
inventory_decisions
|
|
596
902
|
.reject { |decision| unsupported?(decision) }
|
|
597
|
-
.select
|
|
903
|
+
.select do |decision|
|
|
904
|
+
if nonboolean_decision?(decision)
|
|
905
|
+
missing_alternatives_for(decision).any?
|
|
906
|
+
else
|
|
907
|
+
conditions_to_render(decision).any? || missing_decision_table_rules(decision).any? ||
|
|
908
|
+
decision_table_unavailable?(decision)
|
|
909
|
+
end
|
|
910
|
+
end
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
def alternatives_to_render(decision)
|
|
914
|
+
alternatives = Array(value(decision, :alternatives))
|
|
915
|
+
return alternatives unless @missing_only && analysis_available?
|
|
916
|
+
|
|
917
|
+
missing_ids = missing_alternatives_for(decision).map(&:to_s)
|
|
918
|
+
alternatives.select { |alternative| missing_ids.include?(value(alternative, :id).to_s) }
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
def missing_alternatives_for(decision)
|
|
922
|
+
coverage = value(value(analysis_for(decision), :coverage), :alternative)
|
|
923
|
+
Array(value(coverage, :missing_alternatives))
|
|
598
924
|
end
|
|
599
925
|
|
|
600
926
|
def conditions_to_render(decision)
|
|
601
927
|
conditions = Array(value(decision, :conditions))
|
|
602
928
|
return conditions unless @missing_only && analysis_available?
|
|
603
929
|
|
|
604
|
-
conditions.reject
|
|
930
|
+
conditions.reject do |condition|
|
|
931
|
+
value(condition_result(decision, condition), :status).to_s.upcase == "PROVEN"
|
|
932
|
+
end
|
|
605
933
|
end
|
|
606
934
|
|
|
607
935
|
def vectors_to_render(decision)
|
|
@@ -650,6 +978,32 @@ module Branchproof
|
|
|
650
978
|
end
|
|
651
979
|
lines << " #{label} (#{description}): #{shown} (#{numerator}/#{denominator} #{denominator_label})#{qualifier}"
|
|
652
980
|
end
|
|
981
|
+
table = value(aggregate, :decision_table)
|
|
982
|
+
if table
|
|
983
|
+
covered = value(table, :covered_rules) || 0
|
|
984
|
+
required = value(table, :required_rules) || 0
|
|
985
|
+
percentage = value(table, :percentage)
|
|
986
|
+
shown = percentage.nil? ? "N/A" : "#{percentage}%"
|
|
987
|
+
qualifier = incomplete? ? " (lower bound)" : ""
|
|
988
|
+
lines << " DT (Decision table coverage): #{shown} (#{covered}/#{required} rules)#{qualifier}"
|
|
989
|
+
fully_covered = value(table, :fully_covered_decisions) || 0
|
|
990
|
+
analyzed = value(table, :decisions_analyzed) || 0
|
|
991
|
+
decision_percentage = value(table, :decision_percentage)
|
|
992
|
+
lines << " Decision tables fully covered: #{fully_covered}/#{analyzed} decisions" \
|
|
993
|
+
"#{" (#{decision_percentage}%)" unless decision_percentage.nil?}"
|
|
994
|
+
impossible = value(table, :impossible_rules).to_i
|
|
995
|
+
lines << " Statically impossible rules excluded: #{impossible}" if impossible.positive?
|
|
996
|
+
not_calculated = value(table, :not_calculated_decisions).to_i
|
|
997
|
+
lines << " Decision tables not calculated: #{not_calculated} decisions" if not_calculated.positive?
|
|
998
|
+
end
|
|
999
|
+
alternative = value(aggregate, :alternative)
|
|
1000
|
+
if alternative
|
|
1001
|
+
covered = value(alternative, :covered_alternatives) || 0
|
|
1002
|
+
required = value(alternative, :required_alternatives) || 0
|
|
1003
|
+
percentage = value(alternative, :percentage)
|
|
1004
|
+
shown = percentage.nil? ? "N/A" : "#{percentage}%"
|
|
1005
|
+
lines << " Alternative coverage: #{covered}/#{required} alternatives (#{shown})"
|
|
1006
|
+
end
|
|
653
1007
|
lines << ""
|
|
654
1008
|
lines
|
|
655
1009
|
end
|
|
@@ -667,6 +1021,10 @@ module Branchproof
|
|
|
667
1021
|
"#{value(row, :covered_conditions) || 0}/#{value(row, :condition_count) || 0} conditions fully covered)"
|
|
668
1022
|
when "MC/DC"
|
|
669
1023
|
"#{label}=#{status} (#{value(row, :proven_conditions) || 0}/#{value(row, :condition_count) || 0} conditions)"
|
|
1024
|
+
when "DT"
|
|
1025
|
+
return "#{label}=NOT CALCULATED (#{value(row, :reason)})" if raw_status == "not_calculated"
|
|
1026
|
+
|
|
1027
|
+
"#{label}=#{status} (#{value(row, :covered_rules) || 0}/#{value(row, :required_rules) || 0} rules)"
|
|
670
1028
|
else
|
|
671
1029
|
"#{label}=#{status}"
|
|
672
1030
|
end
|
|
@@ -683,27 +1041,70 @@ module Branchproof
|
|
|
683
1041
|
def missing_summary_line
|
|
684
1042
|
return "Missing conditions: Cannot identify missing conditions (analysis unavailable)" unless analysis_available?
|
|
685
1043
|
return "Missing conditions: Cannot identify missing conditions (analysis incomplete)" unless analysis_complete?
|
|
686
|
-
|
|
687
|
-
|
|
1044
|
+
|
|
1045
|
+
unavailable = unavailable_decision_table_count
|
|
1046
|
+
unavailable_suffix = if unavailable.positive?
|
|
1047
|
+
"; decision-table analysis unavailable for #{unavailable} " \
|
|
1048
|
+
"#{unavailable == 1 ? "decision" : "decisions"}"
|
|
1049
|
+
else
|
|
1050
|
+
""
|
|
1051
|
+
end
|
|
1052
|
+
if missing_condition_count.zero? && missing_alternatives_count.zero? && missing_rule_count.zero?
|
|
1053
|
+
return "No missing conditions, alternatives, or decision-table rules#{unavailable_suffix}"
|
|
1054
|
+
end
|
|
688
1055
|
|
|
689
1056
|
count = decisions_to_render.length
|
|
690
|
-
|
|
1057
|
+
label = count == 1 ? "decision" : "decisions"
|
|
1058
|
+
suffix = missing_rule_count.positive? ? "; decision-table rules: #{missing_rule_count}" : ""
|
|
1059
|
+
summary = if missing_condition_count.positive? && missing_alternatives_count.positive?
|
|
1060
|
+
"Missing conditions: #{missing_condition_count}; alternatives: #{missing_alternatives_count} " \
|
|
1061
|
+
"across #{count} #{label}#{suffix}"
|
|
1062
|
+
elsif missing_condition_count.positive?
|
|
1063
|
+
"Missing conditions: #{missing_condition_count} across #{count} #{label}#{suffix}"
|
|
1064
|
+
elsif missing_alternatives_count.positive?
|
|
1065
|
+
"Missing alternatives: #{missing_alternatives_count} across #{count} #{label}#{suffix}"
|
|
1066
|
+
else
|
|
1067
|
+
"Missing decision-table rules: #{missing_rule_count} across #{count} #{label}"
|
|
1068
|
+
end
|
|
1069
|
+
"#{summary}#{unavailable_suffix}"
|
|
1070
|
+
end
|
|
1071
|
+
|
|
1072
|
+
def missing_rule_count
|
|
1073
|
+
decisions_to_render.sum { |decision| missing_decision_table_rules(decision).length }
|
|
1074
|
+
end
|
|
1075
|
+
|
|
1076
|
+
def missing_alternatives_count
|
|
1077
|
+
decisions_to_render.sum { |decision| missing_alternatives_for(decision).length }
|
|
691
1078
|
end
|
|
692
1079
|
|
|
693
1080
|
def analysis_for(decision)
|
|
694
|
-
|
|
695
|
-
|
|
1081
|
+
analysis_by_decision_id[value(decision, :id).to_s]
|
|
1082
|
+
end
|
|
1083
|
+
|
|
1084
|
+
def analysis_by_decision_id
|
|
1085
|
+
@analysis_by_decision_id ||= Array(value(@analysis, :decisions)).to_h do |item|
|
|
1086
|
+
[value(item, :decision_id).to_s, item]
|
|
696
1087
|
end
|
|
697
1088
|
end
|
|
698
1089
|
|
|
699
1090
|
def source_for(decision)
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
1091
|
+
source_by_source_id[value(decision, :source_id).to_s] || decision
|
|
1092
|
+
end
|
|
1093
|
+
|
|
1094
|
+
def source_by_source_id
|
|
1095
|
+
@source_by_source_id ||= Array(value(@inventory, :source_units)).to_h do |source|
|
|
1096
|
+
[value(source, :source_id).to_s, source]
|
|
1097
|
+
end
|
|
1098
|
+
end
|
|
1099
|
+
|
|
1100
|
+
def inventory_decisions
|
|
1101
|
+
@inventory_decisions ||= Array(value(@inventory, :decisions)).freeze
|
|
1102
|
+
end
|
|
1103
|
+
|
|
1104
|
+
def vectors
|
|
1105
|
+
@vectors ||= Array(value(@evidence, :vectors)).freeze
|
|
703
1106
|
end
|
|
704
1107
|
|
|
705
|
-
def inventory_decisions = Array(value(@inventory, :decisions))
|
|
706
|
-
def vectors = Array(value(@evidence, :vectors))
|
|
707
1108
|
def unsupported?(decision) = value(decision, :support_status).to_s.upcase == "UNSUPPORTED"
|
|
708
1109
|
|
|
709
1110
|
def discovered_conditions(decision)
|
|
@@ -749,6 +1150,9 @@ module Branchproof
|
|
|
749
1150
|
def normalize_unknown(object)
|
|
750
1151
|
object.to_s
|
|
751
1152
|
end
|
|
752
|
-
public :condition_coverage_evidence, :coverage_ladder_lines, :coverage_status_label
|
|
1153
|
+
public :condition_coverage_evidence, :coverage_ladder_lines, :coverage_status_label,
|
|
1154
|
+
:decision_table_requirement, :decision_table_reachability, :decision_table_expected_heading
|
|
753
1155
|
end
|
|
754
1156
|
end
|
|
1157
|
+
|
|
1158
|
+
# rubocop:enable Metrics/BlockLength
|