branchproof 0.11.0 → 0.12.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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -0
  3. data/README.md +440 -29
  4. data/doc/Branchproof/CLI.md +1 -1
  5. data/doc/Branchproof/ChangedCoverage.md +19 -0
  6. data/doc/Branchproof/ChangedCoveragePolicy.md +24 -0
  7. data/doc/Branchproof/ChangedDecisionMap.md +20 -0
  8. data/doc/Branchproof/ChangedScope.md +18 -0
  9. data/doc/Branchproof/Collation.md +23 -0
  10. data/doc/Branchproof/CollationManifest.md +29 -0
  11. data/doc/Branchproof/CoverageSummary.md +20 -0
  12. data/doc/Branchproof/Doctor/TerminalRenderer.md +15 -0
  13. data/doc/Branchproof/Doctor.md +34 -0
  14. data/doc/Branchproof/FallbackInstrumentation.md +8 -0
  15. data/doc/Branchproof/FallbackRuntime.md +12 -0
  16. data/doc/Branchproof/FallbackSyntax.md +13 -0
  17. data/doc/Branchproof/GitChanges.md +19 -0
  18. data/doc/Branchproof/GithubReport.md +25 -0
  19. data/doc/Branchproof/HtmlReport.md +22 -0
  20. data/doc/Branchproof/Instrumenter.md +1 -1
  21. data/doc/Branchproof/MinitestAdapter.md +4 -0
  22. data/doc/Branchproof/RakeTask.md +121 -0
  23. data/doc/Branchproof/Report.md +41 -3
  24. data/doc/Branchproof/ReportSelection.md +10 -1
  25. data/doc/Branchproof/Runtime.md +4 -1
  26. data/doc/Branchproof/Source.md +6 -1
  27. data/doc/Branchproof/SummaryRanking/Decision.md +86 -0
  28. data/doc/Branchproof/SummaryRanking/SourceFile.md +43 -0
  29. data/doc/Branchproof/SummaryRanking.md +36 -0
  30. data/doc/Branchproof/SummaryReport.md +42 -0
  31. data/doc/Branchproof/Worker.md +1 -1
  32. data/doc/Branchproof.md +21 -1
  33. data/doc/CHANGELOG.md +89 -0
  34. data/doc/README.md +440 -29
  35. data/lib/branchproof/analyzer.rb +2 -52
  36. data/lib/branchproof/changed_coverage.rb +177 -0
  37. data/lib/branchproof/changed_decision_map.rb +359 -0
  38. data/lib/branchproof/changed_scope.rb +93 -0
  39. data/lib/branchproof/cli.rb +242 -21
  40. data/lib/branchproof/collation.rb +525 -0
  41. data/lib/branchproof/collation_manifest.rb +129 -0
  42. data/lib/branchproof/comparison.rb +11 -3
  43. data/lib/branchproof/comparison_report.rb +2 -0
  44. data/lib/branchproof/configuration.rb +12 -2
  45. data/lib/branchproof/coverage_policy.rb +122 -0
  46. data/lib/branchproof/coverage_summary.rb +94 -0
  47. data/lib/branchproof/doctor.rb +241 -0
  48. data/lib/branchproof/evidence.rb +5 -1
  49. data/lib/branchproof/exception_instrumentation.rb +4 -3
  50. data/lib/branchproof/exception_syntax.rb +15 -1
  51. data/lib/branchproof/fallback_instrumentation.rb +25 -0
  52. data/lib/branchproof/fallback_runtime.rb +14 -0
  53. data/lib/branchproof/fallback_syntax.rb +90 -0
  54. data/lib/branchproof/focused_report.rb +81 -22
  55. data/lib/branchproof/git_changes.rb +188 -0
  56. data/lib/branchproof/github_report.rb +292 -0
  57. data/lib/branchproof/html_report.rb +412 -0
  58. data/lib/branchproof/instrumenter.rb +2 -0
  59. data/lib/branchproof/minimizer.rb +4 -2
  60. data/lib/branchproof/minitest_adapter.rb +45 -5
  61. data/lib/branchproof/rails_support.rb +2 -2
  62. data/lib/branchproof/rake_task.rb +186 -0
  63. data/lib/branchproof/report.rb +383 -71
  64. data/lib/branchproof/report_selection.rb +26 -5
  65. data/lib/branchproof/runtime.rb +2 -0
  66. data/lib/branchproof/saved_report.rb +323 -5
  67. data/lib/branchproof/source.rb +25 -4
  68. data/lib/branchproof/summary_ranking.rb +165 -0
  69. data/lib/branchproof/summary_report.rb +241 -0
  70. data/lib/branchproof/version.rb +1 -1
  71. data/lib/branchproof/worker.rb +14 -9
  72. data/lib/branchproof.rb +12 -0
  73. data/llms.txt +21 -1
  74. data/sig/branchproof.rbs +206 -5
  75. metadata +38 -22
@@ -6,22 +6,33 @@ require "json"
6
6
  require "pathname"
7
7
  require_relative "coverage_policy"
8
8
  require_relative "report_selection"
9
+ require_relative "summary_report"
10
+ require_relative "github_report"
11
+ require_relative "html_report"
12
+ require_relative "changed_coverage"
9
13
 
10
14
  module Branchproof
11
15
  # Renders versioned terminal and JSON analysis reports.
12
16
  class Report
13
17
  SCHEMA_VERSION = "1.4"
18
+ CHANGED_SCOPE_SCHEMA_VERSION = "1.5"
19
+ CHANGED_POLICY_SCHEMA_VERSION = "1.6"
20
+ COLLATION_SCHEMA_VERSION = "1.7"
14
21
  CRITERION_VERSION = "masking_occurrence_v1"
15
- VIEWS = %i[decisions conditions tests decision_tables].freeze
22
+ VIEWS = %i[decisions conditions tests decision_tables summary].freeze
23
+ FORMATS = %i[terminal json github html].freeze
16
24
  DECISION_TABLE_LABELS = { "true" => "T", "false" => "F", "dont_care" => "-" }.freeze
17
25
  DECISION_TABLE_STATUS_LABELS = { "covered" => "COVERED", "missing" => "MISSING",
18
26
  "excluded" => "EXCLUDED" }.freeze
27
+ CRITERIA_LABELS = { decision: "Decision", condition: "Condition", condition_decision: "Condition/decision",
28
+ mcdc: "MC/DC", decision_table: "Decision table", alternative: "Alternative" }.freeze
19
29
 
20
30
  def initialize(inventory:, evidence:, analysis:, minima:, baseline:, diagnostics:, level: 3, missing_only: false,
21
- view: :decisions, run_metadata: {}, saved_document: nil, focus: nil, top: nil, minimum: nil)
31
+ view: :decisions, run_metadata: {}, saved_document: nil, focus: nil, top: nil, minimum: nil,
32
+ changed_scope: nil, minimum_changed: nil)
22
33
  raise ArgumentError, "level must be 1, 2, or 3" unless [1, 2, 3].include?(level.to_i)
23
34
  unless VIEWS.include?(view.to_sym)
24
- raise ArgumentError, "view must be :decisions, :conditions, :tests, or :decision_tables"
35
+ raise ArgumentError, "view must be :decisions, :conditions, :tests, :decision_tables, or :summary"
25
36
  end
26
37
 
27
38
  @inventory = inventory || {}
@@ -35,37 +46,65 @@ module Branchproof
35
46
  @view = view.to_sym
36
47
  @run_metadata = run_metadata || {}
37
48
  @saved_document = saved_document
49
+ raise ArgumentError, "changed_scope must be a hash" if !changed_scope.nil? && !changed_scope.is_a?(Hash)
50
+
51
+ @changed_scope = changed_scope
38
52
  raise ArgumentError, "minimum must be a hash" if !minimum.nil? && !minimum.is_a?(Hash)
39
53
 
40
54
  @minimum_override = minimum.is_a?(Hash) && !minimum.empty? ? CoveragePolicy.normalize(minimum) : nil
41
55
  @minimum = effective_minimum
42
- @selection = ReportSelection.new(focus: focus, top: top)
56
+ raise ArgumentError, "minimum_changed must be a hash" if !minimum_changed.nil? && !minimum_changed.is_a?(Hash)
57
+
58
+ @minimum_changed_override = normalize_minimum_changed(minimum_changed)
59
+ @minimum_changed = effective_minimum_changed
60
+ if !@minimum_changed.empty? && !@changed_scope
61
+ raise ArgumentError, "minimum-changed requires a captured changed scope"
62
+ end
63
+
64
+ @selection = ReportSelection.new(focus: focus, top: top,
65
+ decision_ids: value(@changed_scope, :decision_ids))
43
66
  end
44
67
 
45
68
  def self.from_document(document:, level: nil, view: :decisions, missing_only: false, focus: nil, top: nil,
46
- minimum: nil)
69
+ minimum: nil, minimum_changed: nil)
47
70
  data = document || {}
71
+ captured_scope = data[:changed_scope] || data["changed_scope"]
48
72
  new(inventory: data[:source_inventory] || data["source_inventory"],
49
73
  evidence: data[:observations] || data["observations"],
50
74
  analysis: data[:analysis] || data["analysis"], minima: data[:minima] || data["minima"],
51
75
  baseline: data[:baseline] || data["baseline"], diagnostics: data[:diagnostics] || data["diagnostics"],
52
76
  level: level || (data[:analysis] || data["analysis"] ? 3 : 1), view: view, missing_only: missing_only,
53
77
  focus: focus, top: top,
54
- run_metadata: data[:run_metadata] || data["run_metadata"], saved_document: data, minimum: minimum)
78
+ run_metadata: data[:run_metadata] || data["run_metadata"], saved_document: data, minimum: minimum,
79
+ minimum_changed: minimum_changed,
80
+ changed_scope: captured_scope)
55
81
  end
56
82
 
57
- def write(io:, format:)
83
+ # path_prefix is only used by :github, to make annotation paths repository-relative.
84
+ def write(io:, format:, path_prefix: nil)
58
85
  format = format.to_sym
59
- raise ArgumentError, "format must be :terminal or :json" unless %i[terminal json].include?(format)
60
- raise ArgumentError, "focus and top filters are terminal-only" if format == :json && @selection.active?
61
-
62
- io.write(format == :json ? JSON.generate(json_document) : terminal_document)
86
+ raise ArgumentError, "format must be :terminal, :json, :github, or :html" unless FORMATS.include?(format)
87
+ raise ArgumentError, "focus and top filters are terminal-only" if format == :json &&
88
+ (@selection.focus_active? || @selection.top)
89
+
90
+ io.write(case format
91
+ when :json then JSON.generate(json_document)
92
+ when :github then github_report.annotations(path_prefix: path_prefix)
93
+ when :html then HtmlReport.new(document: json_document, level: @level, coordinator: self,
94
+ missing_only: @missing_only, selection: @selection).render
95
+ else terminal_document
96
+ end)
63
97
  nil
64
98
  end
65
99
 
100
+ # Markdown job summary for GitHub Actions ($GITHUB_STEP_SUMMARY).
101
+ def step_summary
102
+ github_report.step_summary
103
+ end
104
+
66
105
  # Shares the existing missing-case wording with focused terminal views.
67
106
  def condition_explanation(decision_id:, condition_id:)
68
- decision = inventory_decisions.find { |item| value(item, :id).to_s == decision_id.to_s }
107
+ decision = inventory_decisions_by_id[decision_id.to_s]
69
108
  return "" unless decision
70
109
 
71
110
  condition = Array(value(decision, :conditions)).find { |item| value(item, :id).to_s == condition_id.to_s }
@@ -90,7 +129,7 @@ module Branchproof
90
129
  # Returns condition-value evidence for focused renderers without exposing
91
130
  # the report's internal document traversal or mutating saved records.
92
131
  def condition_coverage_evidence(decision_id:, condition_id:)
93
- decision = inventory_decisions.find { |item| value(item, :id).to_s == decision_id.to_s }
132
+ decision = inventory_decisions_by_id[decision_id.to_s]
94
133
  condition = Array(value(decision, :conditions)).find do |item|
95
134
  value(item, :id).to_s == condition_id.to_s
96
135
  end
@@ -112,12 +151,108 @@ module Branchproof
112
151
  lines
113
152
  end
114
153
 
154
+ # Shared informational changed-scope header for all terminal and GitHub views.
155
+ def changed_scope_lines
156
+ return [] unless @changed_scope
157
+
158
+ files = value(@changed_scope, :files)
159
+ deleted_paths = Array(files).filter_map do |file|
160
+ scope_text(value(file, :path)) if value(file, :status).to_s == "D"
161
+ end
162
+ ref = scope_text(value(@changed_scope, :requested_ref))
163
+ base = scope_text(value(@changed_scope, :base_commit))
164
+ lines = ["Changed scope: #{ref} (resolved #{base})",
165
+ "Comparison: tracked worktree; untracked files excluded"]
166
+ unless deleted_paths.empty?
167
+ lines << "Deleted files: #{deleted_paths.join(", ")} (no current decisions to display)"
168
+ end
169
+ header = @minimum_changed.empty? ? "Changed coverage (informational):" : "Changed coverage:"
170
+ lines << header
171
+ coverage = value(json_document, :changed_coverage) || {}
172
+ overall_unavailable = value(coverage, :reason)
173
+ if overall_unavailable && overall_unavailable.to_s != "zero_denominator"
174
+ lines << " Changed coverage unavailable: #{changed_coverage_reason(coverage)}"
175
+ end
176
+ CRITERIA_LABELS.each do |criterion, label|
177
+ row = value(value(coverage, :coverage), criterion) || {}
178
+ denominator = value(row, :denominator)
179
+ percentage = value(row, :percentage)
180
+ lines << if value(row, :status).to_s == "available"
181
+ " #{label}: #{value(row, :numerator)}/#{denominator} (#{percentage}%)"
182
+ elsif value(row, :reason).to_s == "zero_denominator"
183
+ " #{label}: N/A (not applicable)"
184
+ else
185
+ " #{label}: unavailable (#{changed_coverage_reason(row)})"
186
+ end
187
+ end
188
+ lines.concat(changed_coverage_policy_lines)
189
+ lines
190
+ end
191
+
192
+ # Provenance summary for offline reports assembled from multiple shards.
193
+ def collation_lines
194
+ collation = value(@saved_document, :collation)
195
+ return [] unless collation.is_a?(Hash)
196
+
197
+ shards = Array(value(collation, :shards))
198
+ expected = value(collation, :expected_shards)
199
+ count = if expected.is_a?(Array)
200
+ "#{shards.length}/#{expected.length} expected"
201
+ else
202
+ "#{shards.length} (expected count unknown)"
203
+ end
204
+ missing = Array(value(collation, :missing_shards)).map { |id| scope_text(id) }
205
+ lines = ["Collation: #{scope_text(value(collation, :collection_status))}",
206
+ "Shards supplied: #{count}",
207
+ "Missing shards: #{missing.empty? ? "none" : missing.join(", ")}",
208
+ "Contributing shards:"]
209
+ shards.each do |shard|
210
+ id = scope_text(value(shard, :id))
211
+ paths = Array(value(shard, :paths)).map { |path| scope_text(path) }
212
+ lines << " #{id}: #{paths.empty? ? "path unavailable" : paths.join(", ")}"
213
+ end
214
+ lines
215
+ end
216
+
217
+ def changed_coverage_policy_lines
218
+ policy = changed_coverage_policy
219
+ return [] if (value(policy, :minimum_changed) || {}).empty?
220
+
221
+ lines = ["Changed coverage policy: #{value(policy, :status).to_s.upcase}"]
222
+ Array(value(policy, :gates)).each do |gate|
223
+ numerator = value(gate, :numerator)
224
+ denominator = value(gate, :denominator)
225
+ count = denominator.nil? || denominator.zero? ? "N/A" : "#{numerator || "N/A"}/#{denominator}"
226
+ suffix = value(gate, :reason) ? "; reason: #{value(gate, :reason)}" : ""
227
+ lines << " #{value(gate, :criterion)}: #{count}, threshold #{value(gate, :minimum)}, " \
228
+ "#{value(gate, :status).to_s.upcase}#{suffix}"
229
+ end
230
+ lines
231
+ end
232
+
233
+ def changed_coverage_available?
234
+ return true unless @changed_scope
235
+
236
+ coverage = value(json_document, :changed_coverage)
237
+ return false unless value(coverage, :status).to_s == "available"
238
+
239
+ rows = value(coverage, :coverage) || {}
240
+ rows.values.all? do |row|
241
+ value(row, :status).to_s == "available" || value(row, :reason).to_s == "zero_denominator"
242
+ end
243
+ end
244
+
245
+ def display_scope_path(path)
246
+ @changed_scope ? scope_text(path) : path
247
+ end
248
+
115
249
  def exit_code
116
250
  return 2 unless usage_valid?
117
251
 
118
252
  policy_status = value(coverage_policy, :status).to_s
119
- return 2 if policy_status == "unavailable"
120
- return 1 if policy_status == "failed"
253
+ changed_policy_status = value(changed_coverage_policy, :status).to_s
254
+ return 2 if [policy_status, changed_policy_status].include?("unavailable")
255
+ return 1 if [policy_status, changed_policy_status].include?("failed")
121
256
 
122
257
  status = value(@baseline, :status).to_s.upcase
123
258
  return 2 if %w[ERROR INCOMPLETE].include?(status)
@@ -148,20 +283,51 @@ module Branchproof
148
283
  if @saved_document
149
284
  document = normalize(@saved_document)
150
285
  document["coverage_policy"] = normalize(coverage_policy) if @minimum_override
286
+ if @minimum_changed_override
287
+ document["schema_version"] = if document["schema_version"] == COLLATION_SCHEMA_VERSION
288
+ COLLATION_SCHEMA_VERSION
289
+ else
290
+ CHANGED_POLICY_SCHEMA_VERSION
291
+ end
292
+ document["changed_coverage_policy"] = normalize(changed_coverage_policy)
293
+ end
151
294
  return document
152
295
  end
153
296
 
154
- normalize(schema_version: SCHEMA_VERSION,
155
- tool_version: (defined?(Branchproof::VERSION) ? Branchproof::VERSION : "unknown"),
156
- criterion_version: CRITERION_VERSION, runtime: RUBY_DESCRIPTION,
157
- run_ids: Array(value(@evidence, :run_ids)),
158
- source_inventory: inventory_with_default_kinds, baseline: @baseline, observations: @evidence,
159
- analysis: @analysis, minima: @minima, metrics: metrics,
160
- diagnostics: @diagnostics, completeness: completeness, coverage_policy: coverage_policy,
161
- run_metadata: @run_metadata)
297
+ schema_version = if !@minimum_changed.empty?
298
+ CHANGED_POLICY_SCHEMA_VERSION
299
+ elsif @changed_scope
300
+ CHANGED_SCOPE_SCHEMA_VERSION
301
+ else
302
+ SCHEMA_VERSION
303
+ end
304
+ document = normalize(schema_version: schema_version,
305
+ tool_version: (defined?(Branchproof::VERSION) ? Branchproof::VERSION : "unknown"),
306
+ criterion_version: CRITERION_VERSION, runtime: RUBY_DESCRIPTION,
307
+ run_ids: Array(value(@evidence, :run_ids)),
308
+ source_inventory: inventory_with_default_kinds, baseline: @baseline, observations: @evidence,
309
+ analysis: @analysis, minima: @minima, metrics: metrics,
310
+ diagnostics: @diagnostics, completeness: completeness, coverage_policy: coverage_policy,
311
+ run_metadata: @run_metadata)
312
+ if @changed_scope
313
+ document["changed_scope"] = normalize(@changed_scope)
314
+ document["changed_coverage"] = normalize(ChangedCoverage.call(document: document,
315
+ scope: @changed_scope))
316
+ document["changed_coverage_policy"] = normalize(changed_coverage_policy) unless @minimum_changed.empty?
317
+ end
318
+ document
319
+ end
320
+
321
+ def github_report
322
+ @github_report ||= GithubReport.new(document: json_document, level: @level, coordinator: self,
323
+ selection: @selection)
162
324
  end
163
325
 
164
326
  def terminal_document
327
+ if @view == :summary
328
+ return SummaryReport.new(document: json_document, level: @level, coordinator: self,
329
+ missing_only: @missing_only, selection: @selection).render
330
+ end
165
331
  unless @view == :decisions
166
332
  return FocusedReport.new(document: json_document, view: @view, level: @level,
167
333
  missing_only: @missing_only, coordinator: self,
@@ -179,8 +345,12 @@ module Branchproof
179
345
  "Observations: #{metrics[:completed]} completed, #{metrics[:aborted]} aborted, " \
180
346
  "#{metrics[:unattributed]} unattributed",
181
347
  values_legend]
348
+ lines.insert(1, "Whole-run metrics:") if @changed_scope
349
+ lines << "Whole-run coverage and policy:" if @changed_scope
182
350
  lines.concat(coverage_ladder_lines)
183
351
  lines.concat(coverage_policy_lines)
352
+ lines.concat(changed_scope_lines)
353
+ lines.concat(collation_lines)
184
354
  lines << missing_summary_line if @missing_only
185
355
  selected_decisions, hidden = selected_decisions_for_display
186
356
  append_selection_lines(lines, hidden, "decision")
@@ -189,7 +359,7 @@ module Branchproof
189
359
  selected_decisions.each { |decision| render_decision(lines, decision) }
190
360
  render_minima(lines) unless @missing_only
191
361
  unless @diagnostics.empty?
192
- lines << "Diagnostics:"
362
+ lines << (@changed_scope ? "Run-wide diagnostics:" : "Diagnostics:")
193
363
  @diagnostics.each { |diagnostic| lines << " - #{diagnostic_message(diagnostic)}" }
194
364
  end
195
365
  lines.join("\n") << "\n"
@@ -198,6 +368,7 @@ module Branchproof
198
368
  def render_decision(lines, decision)
199
369
  source = source_for(decision)
200
370
  filename = value(source, :relative_path) || value(source, :absolute_path) || value(decision, :source_id)
371
+ filename = display_scope_path(filename)
201
372
  decision_label = "Decision #{short_id(value(decision, :id))} #{filename}:#{value(decision, :line)}"
202
373
  lines << decision_label
203
374
  lines << " Decision: #{value(decision, :expression)}"
@@ -528,7 +699,7 @@ module Branchproof
528
699
  " #{label} (#{value(minimum, :status)}, decisions: [#{scope}]): #{selected}"
529
700
  end.uniq
530
701
  unless rows.empty?
531
- lines << "Supporting sets:"
702
+ lines << (@changed_scope ? "Whole-run supporting sets:" : "Supporting sets:")
532
703
  lines.concat(rows)
533
704
  end
534
705
  lines << "Additional tests outside this MC/DC evidence set may improve coverage." unless @minima.empty?
@@ -710,7 +881,7 @@ module Branchproof
710
881
  end
711
882
  end
712
883
 
713
- def condition_detail(decision, _condition, result)
884
+ def condition_detail(decision, condition, result)
714
885
  return "" unless result && (@level == 3 || @missing_only)
715
886
 
716
887
  pair = value(result, :canonical_pair)
@@ -725,15 +896,15 @@ module Branchproof
725
896
  candidates = Array(value(constraint, :candidate_vectors))
726
897
  existing = existing_vector_for(constraint)
727
898
  unless candidates.empty?
728
- detail_lines = [" — missing observation"]
899
+ detail_lines = []
900
+ status = value(constraint, :status).to_s
901
+ if !status.empty? && status != "CANDIDATE"
902
+ detail_lines << " — Constraint status: #{status}; candidate suggestions follow"
903
+ end
729
904
  candidates.each do |candidate|
730
- detail_lines << " Need an observation where:"
731
- detail_lines.concat(candidate_requirements(decision, candidate).map { |item| " #{item}" })
732
- outcome = value(candidate, :outcome) ? "true" : "false"
733
- detail_lines << " Expected decision: #{outcome} [#{vector_values(candidate)}]"
905
+ detail_lines.concat(missing_evidence_lines(decision, condition, candidate, existing))
734
906
  end
735
907
  detail_lines << " #{statement}" unless statement.to_s.empty?
736
- detail_lines << " Compare with: #{existing_vector_context(existing)}"
737
908
  return detail_lines.join("\n")
738
909
  end
739
910
  unless existing.nil?
@@ -749,6 +920,95 @@ module Branchproof
749
920
  ""
750
921
  end
751
922
 
923
+ def missing_evidence_lines(decision, condition, candidate, existing)
924
+ target_index = value(condition, :index).to_i
925
+ target_expression = value(condition, :expression) || "condition #{target_index}"
926
+ lines = [" — Missing evidence for: #{target_expression}"]
927
+ unless existing
928
+ lines << " Candidate requirements (not observed):"
929
+ lines << " Need an observation where:"
930
+ Array(value(candidate, :values)).each_with_index do |required, index|
931
+ expression = condition_expression(decision, index) || "condition #{index}"
932
+ requirement = if required.nil?
933
+ "is not evaluated (short-circuited)"
934
+ else
935
+ "is #{required ? "truthy" : "falsey"}"
936
+ end
937
+ lines << " #{expression} #{requirement}"
938
+ end
939
+ expected_outcome = value(candidate, :outcome)
940
+ unless expected_outcome.nil?
941
+ lines << " Expected decision: #{boolean_word(expected_outcome)} [#{vector_values(candidate)}]"
942
+ end
943
+ lines << " Candidate combination (not observed): #{candidate_values_text(decision, candidate)}"
944
+ return lines
945
+ end
946
+
947
+ existing_values = Array(value(existing, :values))
948
+ candidate_values = Array(value(candidate, :values))
949
+ observed_target = existing_values[target_index]
950
+ candidate_target = candidate_values[target_index]
951
+ lines << " Observed decision expression: #{value(decision, :expression)}"
952
+ lines << " Find two executions where:"
953
+ target_requirement = if observed_target.nil? || candidate_target.nil?
954
+ "#{target_expression} is not evaluated in one execution (short-circuited); " \
955
+ "no Boolean flip can be claimed"
956
+ else
957
+ "#{target_expression} changes between #{boolean_word(observed_target)} and " \
958
+ "#{boolean_word(candidate_target)}"
959
+ end
960
+ lines << " #{target_requirement}"
961
+ Array(value(decision, :conditions)).each_with_index do |item, index|
962
+ next if index == target_index
963
+
964
+ before = existing_values[index]
965
+ after = candidate_values[index]
966
+ expression = value(item, :expression) || "condition #{index}"
967
+ if !before.nil? && before == after
968
+ lines << " #{expression} remains #{boolean_word(before)}"
969
+ elsif !before.nil? && !after.nil? && before != after
970
+ lines << " #{expression} also changes from #{boolean_word(before)} to #{boolean_word(after)}"
971
+ elsif before.nil? || after.nil?
972
+ lines << " #{expression} is not evaluated (short-circuited) in one execution"
973
+ end
974
+ end
975
+ before_outcome = value(existing, :outcome)
976
+ after_outcome = value(candidate, :outcome)
977
+ outcome_requirement = if before_outcome.nil? || after_outcome.nil?
978
+ "decision outcome is unavailable for one execution"
979
+ elsif before_outcome != after_outcome
980
+ "decision outcome changes between #{boolean_word(before_outcome)} and " \
981
+ "#{boolean_word(after_outcome)}"
982
+ else
983
+ "decision outcome remains #{boolean_word(before_outcome)}"
984
+ end
985
+ lines << " #{outcome_requirement}"
986
+ lines << " Observed: #{candidate_values_text(decision, existing)}#{existing_owner_suffix(existing)}"
987
+ lines << " Missing counterpart: #{candidate_values_text(decision, candidate)} (candidate; not observed)"
988
+ lines
989
+ end
990
+
991
+ def existing_owner_suffix(existing)
992
+ owner_ids = Array(value(existing, :test_ids))
993
+ owners = owner_ids.first(2).map { |id| test_label(id) }
994
+ owners << "#{owner_ids.length - 2} more" if owner_ids.length > 2
995
+ owners.empty? ? "" : " (#{owners.join(", ")})"
996
+ end
997
+
998
+ def candidate_values_text(decision, vector)
999
+ values = Array(value(vector, :values)).each_with_index.map do |item, index|
1000
+ expression = condition_expression(decision, index) || "condition #{index}"
1001
+ "#{expression} = #{item.nil? ? "not evaluated (short-circuited)" : boolean_word(item)}"
1002
+ end
1003
+ outcome = value(vector, :outcome)
1004
+ values << "decision = #{outcome.nil? ? "unavailable" : boolean_word(outcome)}"
1005
+ values.join("; ")
1006
+ end
1007
+
1008
+ def boolean_word(value)
1009
+ value ? "true" : "false"
1010
+ end
1011
+
752
1012
  def readable_constraints(decision, constraint)
753
1013
  Array(value(constraint, :constraints)).filter_map do |item|
754
1014
  if item.respond_to?(:key?)
@@ -768,17 +1028,6 @@ module Branchproof
768
1028
  value(condition, :expression)
769
1029
  end
770
1030
 
771
- def candidate_requirements(decision, candidate)
772
- Array(value(candidate, :values)).each_with_index.map do |required, index|
773
- expression = condition_expression(decision, index) || "condition #{index}"
774
- if required.nil?
775
- "#{expression} is not evaluated (short-circuited)"
776
- else
777
- "#{expression} is #{required ? "truthy" : "falsey"}"
778
- end
779
- end
780
- end
781
-
782
1031
  def vector_values(vector)
783
1032
  Array(value(vector, :values)).map do |item|
784
1033
  if item.nil?
@@ -791,16 +1040,6 @@ module Branchproof
791
1040
  end.join
792
1041
  end
793
1042
 
794
- def existing_vector_context(existing)
795
- return "no existing effective observation" unless existing
796
-
797
- owner_ids = Array(value(existing, :test_ids))
798
- owners = owner_ids.first(2).map { |id| test_label(id) }
799
- owners << "#{owner_ids.length - 2} more" if owner_ids.length > 2
800
- context = "[#{vector_values(existing)}] => #{value(existing, :outcome) ? "true" : "false"}"
801
- owners.empty? ? context : "#{context} (#{owners.join(", ")})"
802
- end
803
-
804
1043
  def existing_vector_for(constraint)
805
1044
  id = value(constraint, :existing_vector_id).to_s
806
1045
  return if id.empty?
@@ -937,19 +1176,26 @@ module Branchproof
937
1176
  end
938
1177
 
939
1178
  def decisions_to_render
940
- return inventory_decisions unless @missing_only
941
- return [] unless analysis_available?
942
-
943
- inventory_decisions
944
- .reject { |decision| unsupported?(decision) }
945
- .select do |decision|
946
- if nonboolean_decision?(decision)
947
- missing_alternatives_for(decision).any?
948
- else
949
- conditions_to_render(decision).any? || missing_decision_table_rules(decision).any? ||
950
- decision_table_unavailable?(decision)
951
- end
952
- end
1179
+ decisions = if !@missing_only
1180
+ inventory_decisions
1181
+ elsif !analysis_available?
1182
+ []
1183
+ else
1184
+ inventory_decisions
1185
+ .reject { |decision| unsupported?(decision) }
1186
+ .select do |decision|
1187
+ if nonboolean_decision?(decision)
1188
+ missing_alternatives_for(decision).any?
1189
+ else
1190
+ conditions_to_render(decision).any? || missing_decision_table_rules(decision).any? ||
1191
+ decision_table_unavailable?(decision)
1192
+ end
1193
+ end
1194
+ end
1195
+ return decisions unless @selection.decision_ids
1196
+
1197
+ inventory = value(@inventory, :source_units) ? @inventory : { source_units: [], decisions: inventory_decisions }
1198
+ @selection.filter_decisions(decisions, inventory: inventory)
953
1199
  end
954
1200
 
955
1201
  def selected_decisions_for_display
@@ -964,11 +1210,17 @@ module Branchproof
964
1210
 
965
1211
  def append_selection_lines(lines, hidden, unit)
966
1212
  if @selection.focus_active?
967
- matching = @selection.matching_decision_ids(json_document)
1213
+ matching = if @changed_scope
1214
+ @selection.selected_decision_ids(json_document)
1215
+ else
1216
+ @selection.matching_decision_ids(json_document)
1217
+ end
1218
+ focus_label = display_scope_path(@selection.focus_label)
1219
+ no_match = @changed_scope ? "no matching changed decisions" : "no matching decisions"
968
1220
  lines << if matching.empty?
969
- "Focus: no matching decisions for #{@selection.focus_label}"
1221
+ "Focus: #{no_match} for #{focus_label}"
970
1222
  else
971
- "Focus: #{@selection.focus_label}"
1223
+ "Focus: #{focus_label}"
972
1224
  end
973
1225
  end
974
1226
  return unless @selection.top
@@ -1049,6 +1301,32 @@ module Branchproof
1049
1301
  base.merge(@minimum_override || {})
1050
1302
  end
1051
1303
 
1304
+ def normalize_minimum_changed(minimum)
1305
+ return nil if minimum.nil? || minimum.empty?
1306
+
1307
+ CoveragePolicy.normalize(minimum)
1308
+ end
1309
+
1310
+ def effective_minimum_changed
1311
+ inherited = value(value(@saved_document, :changed_coverage_policy), :minimum_changed)
1312
+ base = inherited.is_a?(Hash) ? normalize_minimum_changed(inherited) : {}
1313
+ base.merge(@minimum_changed_override || {})
1314
+ end
1315
+
1316
+ def changed_coverage_policy
1317
+ @changed_coverage_policy ||= ChangedCoveragePolicy.new(minimum_changed: @minimum_changed).call(
1318
+ document: policy_document, scope: @changed_scope
1319
+ )
1320
+ end
1321
+
1322
+ def changed_coverage_summary
1323
+ @changed_coverage_summary ||= if @saved_document
1324
+ value(@saved_document, :changed_coverage)
1325
+ else
1326
+ ChangedCoverage.call(document: policy_document, scope: @changed_scope)
1327
+ end
1328
+ end
1329
+
1052
1330
  def coverage_available?
1053
1331
  analysis_available? && !value(@analysis, :coverage).nil?
1054
1332
  end
@@ -1148,6 +1426,10 @@ module Branchproof
1148
1426
  def missing_summary_line
1149
1427
  return "Missing conditions: Cannot identify missing conditions (analysis unavailable)" unless analysis_available?
1150
1428
  return "Missing conditions: Cannot identify missing conditions (analysis incomplete)" unless analysis_complete?
1429
+ if @changed_scope && (!changed_coverage_available? ||
1430
+ (@selection.focus_active? && @selection.selected_decision_ids(json_document).empty?))
1431
+ return "Changed-scope missing coverage unavailable from the captured evidence"
1432
+ end
1151
1433
 
1152
1434
  unavailable = unavailable_decision_table_count
1153
1435
  unavailable_suffix = if unavailable.positive?
@@ -1208,6 +1490,13 @@ module Branchproof
1208
1490
  @inventory_decisions ||= Array(value(@inventory, :decisions)).freeze
1209
1491
  end
1210
1492
 
1493
+ def inventory_decisions_by_id
1494
+ @inventory_decisions_by_id ||= inventory_decisions.each_with_object({}) do |decision, index|
1495
+ id = value(decision, :id).to_s
1496
+ index[id] = decision unless index.key?(id)
1497
+ end
1498
+ end
1499
+
1211
1500
  def vectors
1212
1501
  @vectors ||= Array(value(@evidence, :vectors)).freeze
1213
1502
  end
@@ -1257,8 +1546,31 @@ module Branchproof
1257
1546
  def normalize_unknown(object)
1258
1547
  object.to_s
1259
1548
  end
1260
- public :condition_coverage_evidence, :coverage_ladder_lines, :coverage_policy_lines, :coverage_policy,
1261
- :coverage_status_label,
1549
+
1550
+ def changed_coverage_reason(row)
1551
+ reason = value(row, :reason)
1552
+ {
1553
+ "empty_scope" => "no decisions matched the changed files",
1554
+ "unsupported_only" => "all changed decisions are unsupported",
1555
+ "baseline_unavailable" => "the test run is incomplete",
1556
+ "incomplete_document" => "the report is incomplete",
1557
+ "incomplete_observations" => "observation evidence is incomplete",
1558
+ "incomplete_analysis" => "analysis evidence is incomplete",
1559
+ "analysis_unavailable" => "analysis is unavailable",
1560
+ "missing_selected_analysis" => "analysis is missing for changed decisions",
1561
+ "missing_selected_coverage" => "coverage is missing for changed decisions",
1562
+ "decision_table_not_calculated" => "decision table analysis was not calculated",
1563
+ "zero_denominator" => "not applicable"
1564
+ }.fetch(reason.to_s, "the captured evidence is unavailable")
1565
+ end
1566
+
1567
+ def scope_text(text)
1568
+ text.to_s.scrub.gsub(/[[:cntrl:]]/) { |character| "\\u{#{character.ord.to_s(16).upcase}}" }
1569
+ end
1570
+ public :condition_coverage_evidence, :changed_scope_lines, :collation_lines, :coverage_ladder_lines,
1571
+ :coverage_policy_lines, :coverage_policy, :changed_coverage_policy, :coverage_status_label,
1572
+ :changed_coverage_available?,
1573
+ :display_scope_path,
1262
1574
  :decision_table_requirement, :decision_table_reachability, :decision_table_expected_heading
1263
1575
  end
1264
1576
  end