branchproof 0.7.0 → 0.9.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 +53 -0
- data/README.md +255 -21
- 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 +3 -0
- data/doc/Branchproof/DecisionSyntax.md +6 -4
- data/doc/Branchproof/DecisionTable.md +153 -0
- data/doc/Branchproof/DefaultInstrumentation.md +13 -0
- data/doc/Branchproof/DefaultRuntime.md +11 -0
- data/doc/Branchproof/DefaultSyntax.md +7 -0
- data/doc/Branchproof/ExceptionInstrumentation.md +9 -0
- data/doc/Branchproof/ExceptionRuntime.md +27 -0
- data/doc/Branchproof/ExceptionSyntax.md +9 -0
- data/doc/Branchproof/ExtendedAlternativeRuntime.md +19 -0
- data/doc/Branchproof/FocusedReport.md +5 -2
- data/doc/Branchproof/Instrumenter.md +1 -1
- data/doc/Branchproof/IterationInstrumentation.md +7 -0
- data/doc/Branchproof/IterationRuntime.md +21 -0
- data/doc/Branchproof/IterationSyntax.md +11 -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 +60 -1
- data/doc/Branchproof/SavedReport.md +15 -0
- data/doc/Branchproof/Source.md +7 -1
- data/doc/Branchproof/ValueInstrumentation.md +7 -0
- data/doc/Branchproof/ValueRuntime.md +26 -0
- data/doc/Branchproof/ValueSyntax.md +26 -0
- data/doc/Branchproof.md +20 -3
- data/doc/CHANGELOG.md +53 -0
- data/doc/README.md +255 -21
- data/lib/branchproof/analyzer.rb +80 -44
- 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 +30 -1
- data/lib/branchproof/decision_syntax.rb +50 -24
- data/lib/branchproof/decision_table.rb +377 -0
- data/lib/branchproof/default_instrumentation.rb +140 -0
- data/lib/branchproof/default_runtime.rb +16 -0
- data/lib/branchproof/default_syntax.rb +94 -0
- data/lib/branchproof/evidence.rb +133 -31
- data/lib/branchproof/exception_instrumentation.rb +96 -0
- data/lib/branchproof/exception_runtime.rb +41 -0
- data/lib/branchproof/exception_syntax.rb +157 -0
- data/lib/branchproof/extended_alternative_runtime.rb +23 -0
- data/lib/branchproof/flow_instrumentation.rb +52 -23
- data/lib/branchproof/focused_report.rb +77 -9
- data/lib/branchproof/instrumenter.rb +93 -37
- data/lib/branchproof/iteration_instrumentation.rb +66 -0
- data/lib/branchproof/iteration_runtime.rb +79 -0
- data/lib/branchproof/iteration_syntax.rb +75 -0
- data/lib/branchproof/limits.rb +4 -1
- data/lib/branchproof/loader.rb +24 -7
- 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 +301 -68
- data/lib/branchproof/runtime.rb +32 -30
- data/lib/branchproof/saved_report.rb +200 -14
- data/lib/branchproof/source.rb +140 -71
- data/lib/branchproof/value_instrumentation.rb +35 -0
- data/lib/branchproof/value_runtime.rb +73 -0
- data/lib/branchproof/value_syntax.rb +116 -0
- data/lib/branchproof/version.rb +1 -1
- data/lib/branchproof/worker.rb +1 -4
- data/lib/branchproof.rb +2 -0
- data/llms.txt +20 -3
- data/sig/branchproof.rbs +64 -1
- metadata +32 -1
data/lib/branchproof/report.rb
CHANGED
|
@@ -8,14 +8,19 @@ require "pathname"
|
|
|
8
8
|
module Branchproof
|
|
9
9
|
# Renders versioned terminal and JSON analysis reports.
|
|
10
10
|
class Report
|
|
11
|
-
SCHEMA_VERSION = "1.
|
|
11
|
+
SCHEMA_VERSION = "1.3"
|
|
12
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
|
|
13
17
|
|
|
14
18
|
def initialize(inventory:, evidence:, analysis:, minima:, baseline:, diagnostics:, level: 3, missing_only: false,
|
|
15
19
|
view: :decisions, run_metadata: {}, saved_document: nil)
|
|
16
20
|
raise ArgumentError, "level must be 1, 2, or 3" unless [1, 2, 3].include?(level.to_i)
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
unless VIEWS.include?(view.to_sym)
|
|
22
|
+
raise ArgumentError, "view must be :decisions, :conditions, :tests, or :decision_tables"
|
|
23
|
+
end
|
|
19
24
|
|
|
20
25
|
@inventory = inventory || {}
|
|
21
26
|
@evidence = evidence || {}
|
|
@@ -32,8 +37,8 @@ module Branchproof
|
|
|
32
37
|
|
|
33
38
|
def self.from_document(document:, level: nil, view: :decisions, missing_only: false)
|
|
34
39
|
data = document || {}
|
|
35
|
-
new(inventory: data[:source_inventory] || data["source_inventory"]
|
|
36
|
-
evidence: data[:observations] || data["observations"]
|
|
40
|
+
new(inventory: data[:source_inventory] || data["source_inventory"],
|
|
41
|
+
evidence: data[:observations] || data["observations"],
|
|
37
42
|
analysis: data[:analysis] || data["analysis"], minima: data[:minima] || data["minima"],
|
|
38
43
|
baseline: data[:baseline] || data["baseline"], diagnostics: data[:diagnostics] || data["diagnostics"],
|
|
39
44
|
level: level || (data[:analysis] || data["analysis"] ? 3 : 1), view: view, missing_only: missing_only,
|
|
@@ -131,7 +136,7 @@ module Branchproof
|
|
|
131
136
|
def terminal_document
|
|
132
137
|
unless @view == :decisions
|
|
133
138
|
return FocusedReport.new(document: json_document, view: @view, level: @level,
|
|
134
|
-
missing_only: @missing_only).render
|
|
139
|
+
missing_only: @missing_only, coordinator: self).render
|
|
135
140
|
end
|
|
136
141
|
|
|
137
142
|
@terminal_ids = terminal_ids
|
|
@@ -183,6 +188,7 @@ module Branchproof
|
|
|
183
188
|
end
|
|
184
189
|
end
|
|
185
190
|
render_decision_coverage(lines, decision) if coverage_available?
|
|
191
|
+
render_decision_table(lines, decision) if analysis_available? && !nonboolean_decision?(decision)
|
|
186
192
|
vectors_to_render(decision).each do |vector|
|
|
187
193
|
values = Array(value(vector, :values)).map do |item|
|
|
188
194
|
if item.nil?
|
|
@@ -230,7 +236,7 @@ module Branchproof
|
|
|
230
236
|
decision_coverage = value(value(analysis_for(decision), :coverage), :decision)
|
|
231
237
|
all_coverage = value(analysis_for(decision), :coverage) || {}
|
|
232
238
|
statuses = [["D", :decision], ["C", :condition], ["C/D", :condition_decision],
|
|
233
|
-
["MC/DC", :mcdc]].filter_map do |label, key|
|
|
239
|
+
["MC/DC", :mcdc], ["DT", :decision_table]].filter_map do |label, key|
|
|
234
240
|
row = value(all_coverage, key)
|
|
235
241
|
status = value(row, :status)
|
|
236
242
|
status ? criterion_status_text(label, row) : nil
|
|
@@ -242,11 +248,11 @@ module Branchproof
|
|
|
242
248
|
return unless @level >= 2
|
|
243
249
|
|
|
244
250
|
Array(value(coverage, :outcomes)).each do |outcome|
|
|
245
|
-
value_label = value(outcome, :value)
|
|
251
|
+
value_label = decision_outcome_label(decision, value(outcome, :value))
|
|
246
252
|
owners = Array(value(outcome, :test_ids)).map { |id| test_label(id) }
|
|
247
253
|
owners << "unattributed" if value(outcome, :unattributed_count).to_i.positive?
|
|
248
254
|
evidence = owners.empty? ? "none recorded" : owners.uniq.join(", ")
|
|
249
|
-
lines << "
|
|
255
|
+
lines << " #{value_label}: #{value(outcome, :observed) ? "observed" : "missing"}; tests: #{evidence}"
|
|
250
256
|
end
|
|
251
257
|
missing = Array(value(coverage, :missing_outcomes))
|
|
252
258
|
return if missing.empty?
|
|
@@ -256,6 +262,164 @@ module Branchproof
|
|
|
256
262
|
end.join(", ")}"
|
|
257
263
|
end
|
|
258
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
|
+
|
|
259
423
|
def render_alternative_coverage(lines, decision)
|
|
260
424
|
coverage = value(value(analysis_for(decision), :coverage), :alternative) || {}
|
|
261
425
|
status = value(coverage, :status)
|
|
@@ -492,8 +656,15 @@ module Branchproof
|
|
|
492
656
|
end
|
|
493
657
|
|
|
494
658
|
def condition_result(decision, condition)
|
|
495
|
-
|
|
496
|
-
|
|
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
|
|
497
668
|
end
|
|
498
669
|
end
|
|
499
670
|
|
|
@@ -592,7 +763,11 @@ module Branchproof
|
|
|
592
763
|
id = value(constraint, :existing_vector_id).to_s
|
|
593
764
|
return if id.empty?
|
|
594
765
|
|
|
595
|
-
|
|
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] }
|
|
596
771
|
end
|
|
597
772
|
|
|
598
773
|
def values_legend
|
|
@@ -639,37 +814,41 @@ module Branchproof
|
|
|
639
814
|
end
|
|
640
815
|
|
|
641
816
|
def metrics
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
|
665
842
|
end
|
|
666
843
|
|
|
667
844
|
def completeness
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
|
673
852
|
end
|
|
674
853
|
|
|
675
854
|
def completeness_value?(evidence, analysis, key)
|
|
@@ -708,9 +887,11 @@ module Branchproof
|
|
|
708
887
|
end
|
|
709
888
|
|
|
710
889
|
def vectors_for(decision)
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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 }
|
|
714
895
|
end
|
|
715
896
|
|
|
716
897
|
def decisions_to_render
|
|
@@ -720,7 +901,12 @@ module Branchproof
|
|
|
720
901
|
inventory_decisions
|
|
721
902
|
.reject { |decision| unsupported?(decision) }
|
|
722
903
|
.select do |decision|
|
|
723
|
-
nonboolean_decision?(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
|
|
724
910
|
end
|
|
725
911
|
end
|
|
726
912
|
|
|
@@ -748,7 +934,6 @@ module Branchproof
|
|
|
748
934
|
|
|
749
935
|
def vectors_to_render(decision)
|
|
750
936
|
return vectors_for(decision) unless @missing_only
|
|
751
|
-
return [] if nonboolean_decision?(decision)
|
|
752
937
|
|
|
753
938
|
[]
|
|
754
939
|
end
|
|
@@ -793,6 +978,24 @@ module Branchproof
|
|
|
793
978
|
end
|
|
794
979
|
lines << " #{label} (#{description}): #{shown} (#{numerator}/#{denominator} #{denominator_label})#{qualifier}"
|
|
795
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
|
|
796
999
|
alternative = value(aggregate, :alternative)
|
|
797
1000
|
if alternative
|
|
798
1001
|
covered = value(alternative, :covered_alternatives) || 0
|
|
@@ -818,6 +1021,10 @@ module Branchproof
|
|
|
818
1021
|
"#{value(row, :covered_conditions) || 0}/#{value(row, :condition_count) || 0} conditions fully covered)"
|
|
819
1022
|
when "MC/DC"
|
|
820
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)"
|
|
821
1028
|
else
|
|
822
1029
|
"#{label}=#{status}"
|
|
823
1030
|
end
|
|
@@ -834,25 +1041,36 @@ module Branchproof
|
|
|
834
1041
|
def missing_summary_line
|
|
835
1042
|
return "Missing conditions: Cannot identify missing conditions (analysis unavailable)" unless analysis_available?
|
|
836
1043
|
return "Missing conditions: Cannot identify missing conditions (analysis incomplete)" unless analysis_complete?
|
|
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?
|
|
840
1044
|
|
|
841
|
-
|
|
842
|
-
if
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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}"
|
|
849
1054
|
end
|
|
850
1055
|
|
|
1056
|
+
count = decisions_to_render.length
|
|
851
1057
|
label = count == 1 ? "decision" : "decisions"
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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 }
|
|
856
1074
|
end
|
|
857
1075
|
|
|
858
1076
|
def missing_alternatives_count
|
|
@@ -860,19 +1078,33 @@ module Branchproof
|
|
|
860
1078
|
end
|
|
861
1079
|
|
|
862
1080
|
def analysis_for(decision)
|
|
863
|
-
|
|
864
|
-
|
|
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]
|
|
865
1087
|
end
|
|
866
1088
|
end
|
|
867
1089
|
|
|
868
1090
|
def source_for(decision)
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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
|
|
872
1106
|
end
|
|
873
1107
|
|
|
874
|
-
def inventory_decisions = Array(value(@inventory, :decisions))
|
|
875
|
-
def vectors = Array(value(@evidence, :vectors))
|
|
876
1108
|
def unsupported?(decision) = value(decision, :support_status).to_s.upcase == "UNSUPPORTED"
|
|
877
1109
|
|
|
878
1110
|
def discovered_conditions(decision)
|
|
@@ -918,7 +1150,8 @@ module Branchproof
|
|
|
918
1150
|
def normalize_unknown(object)
|
|
919
1151
|
object.to_s
|
|
920
1152
|
end
|
|
921
|
-
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
|
|
922
1155
|
end
|
|
923
1156
|
end
|
|
924
1157
|
|
data/lib/branchproof/runtime.rb
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
4
|
require_relative "runtime_flow"
|
|
5
|
+
require_relative "extended_alternative_runtime"
|
|
6
|
+
require_relative "exception_runtime"
|
|
7
|
+
require_relative "default_runtime"
|
|
8
|
+
require_relative "value_runtime"
|
|
9
|
+
require_relative "iteration_runtime"
|
|
5
10
|
|
|
6
11
|
module Branchproof
|
|
7
12
|
# Process-local execution recorder. It deliberately never coerces or stores
|
|
@@ -9,6 +14,13 @@ module Branchproof
|
|
|
9
14
|
# Captures condition evaluations while preserving application values.
|
|
10
15
|
module Runtime
|
|
11
16
|
extend RuntimeFlow
|
|
17
|
+
extend ExtendedAlternativeRuntime
|
|
18
|
+
extend ExceptionRuntime
|
|
19
|
+
extend DefaultRuntime
|
|
20
|
+
extend ValueRuntime
|
|
21
|
+
extend IterationRuntime
|
|
22
|
+
|
|
23
|
+
FRAME_STATE_KEY = :branchproof_runtime_frame_state
|
|
12
24
|
|
|
13
25
|
class << self
|
|
14
26
|
def boot(evidence:)
|
|
@@ -17,8 +29,6 @@ module Branchproof
|
|
|
17
29
|
@evidence = evidence
|
|
18
30
|
@run_id = evidence.respond_to?(:run_id) ? evidence.run_id.to_s : SecureRandom.hex(16)
|
|
19
31
|
@process_id = Process.pid
|
|
20
|
-
@frames = {}
|
|
21
|
-
@contexts = {}
|
|
22
32
|
@diagnostics = []
|
|
23
33
|
@storage_disabled = false
|
|
24
34
|
@booted = true
|
|
@@ -26,9 +36,8 @@ module Branchproof
|
|
|
26
36
|
end
|
|
27
37
|
|
|
28
38
|
def enter(decision_id)
|
|
29
|
-
state[:frames] << { decision_id:
|
|
30
|
-
observations: [], outcome: nil, finished: false
|
|
31
|
-
execution_id: SecureRandom.hex(12) }
|
|
39
|
+
state[:frames] << { decision_id: decision_id, context: state[:context]&.dup,
|
|
40
|
+
observations: [], outcome: nil, finished: false }
|
|
32
41
|
nil
|
|
33
42
|
end
|
|
34
43
|
|
|
@@ -36,7 +45,7 @@ module Branchproof
|
|
|
36
45
|
frame = current_frame(decision_id)
|
|
37
46
|
if frame
|
|
38
47
|
truth = value ? true : false
|
|
39
|
-
frame[:observations] << [
|
|
48
|
+
frame[:observations] << [index, truth]
|
|
40
49
|
end
|
|
41
50
|
value
|
|
42
51
|
end
|
|
@@ -51,9 +60,10 @@ module Branchproof
|
|
|
51
60
|
end
|
|
52
61
|
|
|
53
62
|
def leave(decision_id)
|
|
63
|
+
detect_fork
|
|
54
64
|
frames = state[:frames]
|
|
55
65
|
frame = frames.pop
|
|
56
|
-
unless frame && frame[:decision_id] ==
|
|
66
|
+
unless frame && frame[:decision_id] == decision_id
|
|
57
67
|
latch("runtime_frame_mismatch", "decision frame stack is not balanced")
|
|
58
68
|
cleanup_state
|
|
59
69
|
return nil
|
|
@@ -62,12 +72,10 @@ module Branchproof
|
|
|
62
72
|
|
|
63
73
|
execution = {
|
|
64
74
|
run_id: @run_id,
|
|
65
|
-
execution_id: frame[:execution_id],
|
|
66
75
|
decision_id: frame[:decision_id],
|
|
67
76
|
test_id: frame[:context]&.fetch(:test_id, nil),
|
|
68
77
|
phase: frame[:context]&.fetch(:phase, "unattributed") || "unattributed",
|
|
69
|
-
|
|
70
|
-
observations: frame[:observations].map(&:dup),
|
|
78
|
+
observations: frame[:observations],
|
|
71
79
|
outcome: frame[:finished] ? frame[:outcome] : nil,
|
|
72
80
|
status: frame[:finished] ? "completed" : "aborted"
|
|
73
81
|
}
|
|
@@ -120,38 +128,32 @@ module Branchproof
|
|
|
120
128
|
private
|
|
121
129
|
|
|
122
130
|
def state
|
|
123
|
-
|
|
124
|
-
if @process_id && Process.pid != @process_id
|
|
125
|
-
@process_id = Process.pid
|
|
126
|
-
@run_id = SecureRandom.hex(16)
|
|
127
|
-
@frames = {}
|
|
128
|
-
@diagnostics = [{ code: "forked_process", severity: "error",
|
|
129
|
-
message: "runtime process identity changed; evidence storage disabled",
|
|
130
|
-
source_id: nil, decision_id: nil, execution_id: nil, test_id: nil, details: {} }]
|
|
131
|
-
@storage_disabled = true
|
|
132
|
-
end
|
|
133
|
-
key = [Process.pid, Thread.current, Fiber.current]
|
|
134
|
-
@frames[key] ||= { frames: [], context: nil }
|
|
131
|
+
Thread.current[FRAME_STATE_KEY] ||= { frames: [], context: nil }
|
|
135
132
|
end
|
|
136
133
|
|
|
137
134
|
def cleanup_state
|
|
138
|
-
|
|
139
|
-
current =
|
|
140
|
-
|
|
135
|
+
current = Thread.current[FRAME_STATE_KEY]
|
|
136
|
+
Thread.current[FRAME_STATE_KEY] = nil if current && current[:frames].empty? && current[:context].nil?
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def detect_fork
|
|
140
|
+
return unless @process_id && Process.pid != @process_id
|
|
141
|
+
|
|
142
|
+
@process_id = Process.pid
|
|
143
|
+
@diagnostics = [{ code: "forked_process", severity: "error",
|
|
144
|
+
message: "runtime process identity changed; evidence storage disabled",
|
|
145
|
+
source_id: nil, decision_id: nil, execution_id: nil, test_id: nil, details: {} }]
|
|
146
|
+
@storage_disabled = true
|
|
141
147
|
end
|
|
142
148
|
|
|
143
149
|
def current_frame(decision_id)
|
|
144
150
|
frame = state[:frames].last
|
|
145
|
-
return frame if frame && frame[:decision_id] ==
|
|
151
|
+
return frame if frame && frame[:decision_id] == decision_id
|
|
146
152
|
|
|
147
153
|
latch("runtime_frame_mismatch", "no active frame for #{decision_id}") if frame
|
|
148
154
|
nil
|
|
149
155
|
end
|
|
150
156
|
|
|
151
|
-
def owner_tuple
|
|
152
|
-
{ process_id: Process.pid, thread_id: Thread.current.object_id, fiber_id: Fiber.current.object_id }
|
|
153
|
-
end
|
|
154
|
-
|
|
155
157
|
def safely_record(execution)
|
|
156
158
|
result = @evidence.record(execution: execution)
|
|
157
159
|
unless result.is_a?(Hash) && result[:status].to_s == "recorded"
|