branchproof 0.7.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/README.md +212 -11
  4. data/doc/Branchproof/Analyzer.md +2 -2
  5. data/doc/Branchproof/CLI.md +4 -0
  6. data/doc/Branchproof/ComparisonReport.md +4 -0
  7. data/doc/Branchproof/Constraints/Solver.md +21 -0
  8. data/doc/Branchproof/Constraints.md +98 -0
  9. data/doc/Branchproof/CoverageIndex.md +3 -0
  10. data/doc/Branchproof/DecisionSyntax.md +6 -4
  11. data/doc/Branchproof/DecisionTable.md +153 -0
  12. data/doc/Branchproof/FocusedReport.md +5 -2
  13. data/doc/Branchproof/Loader.md +0 -4
  14. data/doc/Branchproof/MinitestAdapter.md +0 -3
  15. data/doc/Branchproof/Report.md +18 -0
  16. data/doc/Branchproof/Runtime.md +4 -0
  17. data/doc/Branchproof/SavedReport.md +15 -0
  18. data/doc/Branchproof.md +4 -1
  19. data/doc/CHANGELOG.md +35 -0
  20. data/doc/README.md +212 -11
  21. data/lib/branchproof/analyzer.rb +80 -44
  22. data/lib/branchproof/cli.rb +28 -19
  23. data/lib/branchproof/comparison.rb +207 -19
  24. data/lib/branchproof/comparison_report.rb +49 -1
  25. data/lib/branchproof/constraints.rb +363 -0
  26. data/lib/branchproof/coverage_index.rb +30 -1
  27. data/lib/branchproof/decision_syntax.rb +31 -15
  28. data/lib/branchproof/decision_table.rb +377 -0
  29. data/lib/branchproof/evidence.rb +51 -31
  30. data/lib/branchproof/flow_instrumentation.rb +17 -17
  31. data/lib/branchproof/focused_report.rb +77 -9
  32. data/lib/branchproof/instrumenter.rb +45 -34
  33. data/lib/branchproof/limits.rb +4 -1
  34. data/lib/branchproof/loader.rb +18 -6
  35. data/lib/branchproof/minimizer.rb +18 -13
  36. data/lib/branchproof/minitest_adapter.rb +11 -16
  37. data/lib/branchproof/records.rb +2 -0
  38. data/lib/branchproof/report.rb +301 -68
  39. data/lib/branchproof/runtime.rb +22 -30
  40. data/lib/branchproof/saved_report.rb +200 -14
  41. data/lib/branchproof/source.rb +90 -48
  42. data/lib/branchproof/version.rb +1 -1
  43. data/lib/branchproof/worker.rb +1 -4
  44. data/lib/branchproof.rb +2 -0
  45. data/llms.txt +4 -1
  46. data/sig/branchproof.rbs +47 -1
  47. metadata +6 -1
@@ -7,13 +7,14 @@ module Branchproof
7
7
  # Compares two complete report documents without loading or executing the project.
8
8
  class Comparison
9
9
  SCHEMA_VERSION = "1.0"
10
- SUPPORTED_REPORT_SCHEMAS = %w[1.0 1.1 1.2].freeze
10
+ SUPPORTED_REPORT_SCHEMAS = %w[1.0 1.1 1.2 1.3].freeze
11
11
  CRITERION_VERSION = "masking_occurrence_v1"
12
12
 
13
13
  def initialize(before:, after:)
14
14
  @before = before
15
15
  @after = after
16
16
  @contexts = {}.compare_by_identity
17
+ @decision_table_indexes = {}.compare_by_identity
17
18
  end
18
19
 
19
20
  def call
@@ -27,10 +28,17 @@ module Branchproof
27
28
  before_conditions = condition_map(@before)
28
29
  after_conditions = condition_map(@after)
29
30
  matched_ids = before_conditions.keys & after_conditions.keys
30
- matched_ids.select! { |id| compatible_condition?(before_conditions[id], after_conditions[id], changed_paths) }
31
+ matched_ids.select! do |id|
32
+ compatible_condition?(before_conditions[id], after_conditions[id], changed_paths, before_sources,
33
+ after_sources)
34
+ end
31
35
  changes = matched_ids.filter_map { |id| condition_change(id, before_conditions[id], after_conditions[id]) }
36
+ comparable_decisions = comparable_decision_ids(changed_paths, before_sources, after_sources)
37
+ table_changes = decision_table_changes(comparable_decisions)
38
+ table_context = decision_table_context_changes(comparable_decisions, changed_paths)
32
39
  reasons = comparability_reasons(changed_paths)
33
40
  reasons.concat(metadata_requirements)
41
+ reasons.concat(decision_table_comparability_reasons(comparable_decisions))
34
42
  reasons << "legacy report is missing comparison context" if [@before, @after].any? do |document|
35
43
  value(document, :schema_version).to_s == "1.0" && value(document, :run_metadata).nil?
36
44
  end
@@ -72,8 +80,13 @@ module Branchproof
72
80
  "changes" => changes.sort_by do |change|
73
81
  [change["relative_path"].to_s, change["line"].to_i, change["condition_id"]]
74
82
  end,
83
+ "decision_table_changes" => table_changes,
84
+ "decision_table_context_changes" => table_context,
85
+ "decision_table_matching" => decision_table_matching(comparable_decisions, table_changes),
86
+ "decision_table_regressions" => table_changes.count { |change| change["change"] == "rule coverage lost" },
75
87
  "regressions" => lost,
76
- "regression" => lost.positive? && status == "complete"
88
+ "regression" => (lost.positive? || table_changes.any? { |change| change["change"] == "rule coverage lost" }) &&
89
+ status == "complete"
77
90
  }
78
91
  end
79
92
 
@@ -101,7 +114,9 @@ module Branchproof
101
114
  "matching" => { "before_conditions" => 0, "after_conditions" => 0,
102
115
  "matched_conditions" => 0, "denominator" => 0 },
103
116
  "changed_sources" => [], "newly_in_report" => [], "no_longer_in_report" => [],
104
- "changes" => [], "regressions" => 0, "regression" => false }
117
+ "changes" => [], "decision_table_changes" => [], "decision_table_context_changes" => [],
118
+ "decision_table_matching" => { "matched_rules" => 0, "compared_decisions" => 0 },
119
+ "decision_table_regressions" => 0, "regressions" => 0, "regression" => false }
105
120
  end
106
121
 
107
122
  def sources(document)
@@ -127,13 +142,13 @@ module Branchproof
127
142
  end
128
143
  end
129
144
 
130
- def compatible_condition?(before, after, changed_paths)
145
+ def compatible_condition?(before, after, changed_paths, before_sources, after_sources)
131
146
  before_path = source_path(@before, before[:decision])
132
147
  after_path = source_path(@after, after[:decision])
133
148
  return false if before_path.nil? || changed_paths.include?(before_path) || before_path != after_path
134
149
 
135
- before_digest = digest(sources(@before)[before_path])
136
- after_digest = digest(sources(@after)[after_path])
150
+ before_digest = digest(before_sources[before_path])
151
+ after_digest = digest(after_sources[after_path])
137
152
  return false unless before_digest && before_digest == after_digest
138
153
 
139
154
  %i[index expression byte_start byte_length].all? do |key|
@@ -159,7 +174,6 @@ module Branchproof
159
174
  else
160
175
  "unknown"
161
176
  end
162
- return if kind.nil?
163
177
 
164
178
  decision = after[:decision]
165
179
  source_path = source_path(@after, decision)
@@ -175,6 +189,180 @@ module Branchproof
175
189
  row
176
190
  end
177
191
 
192
+ # Decision identity already covers context, byte range, and Boolean tree, so
193
+ # a structurally changed decision never matches an old one. Comparison is
194
+ # additionally restricted to sources whose digest did not move.
195
+ def comparable_decision_ids(changed_paths, before_sources, after_sources)
196
+ before = context(@before)[:decisions]
197
+ after = context(@after)[:decisions]
198
+ (before.keys & after.keys).select do |id|
199
+ path = source_path(@before, before[id])
200
+ next false if path.nil? || changed_paths.include?(path)
201
+ next false unless path == source_path(@after, after[id])
202
+
203
+ digest(before_sources[path]) && digest(before_sources[path]) == digest(after_sources[path])
204
+ end.sort
205
+ end
206
+
207
+ def decision_tables(document, decision_ids)
208
+ @decision_table_indexes[document] ||= begin
209
+ results = context(document)[:results_by_decision]
210
+ results.each_with_object({}) do |(id, decision), index|
211
+ table = value(decision, :decision_table)
212
+ next unless table && value(table, :status).to_s == "calculated"
213
+
214
+ index[id] = Array(value(table, :rules)).to_h { |rule| [value(rule, :id).to_s, rule] }
215
+ end
216
+ end.slice(*decision_ids)
217
+ end
218
+
219
+ # Coverage movement and analysis movement are reported as distinct kinds:
220
+ # a rule that became statically impossible is not a coverage gain.
221
+ def decision_table_changes(decision_ids)
222
+ before = decision_tables(@before, decision_ids)
223
+ after = decision_tables(@after, decision_ids)
224
+ decision_table_comparable_ids(decision_ids, before, after).flat_map do |decision_id|
225
+ (before[decision_id].keys & after[decision_id].keys).sort.filter_map do |rule_id|
226
+ decision_table_change(decision_id, before[decision_id][rule_id], after[decision_id][rule_id])
227
+ end
228
+ end
229
+ end
230
+
231
+ def decision_table_comparable_ids(decision_ids, before, after)
232
+ decision_ids.select do |id|
233
+ before.key?(id) && after.key?(id) &&
234
+ supported_decision_table_schema?(before_document_table(id)) &&
235
+ supported_decision_table_schema?(after_document_table(id))
236
+ end
237
+ end
238
+
239
+ def before_document_table(decision_id)
240
+ value(context(@before)[:results_by_decision][decision_id], :decision_table)
241
+ end
242
+
243
+ def after_document_table(decision_id)
244
+ value(context(@after)[:results_by_decision][decision_id], :decision_table)
245
+ end
246
+
247
+ def decision_table_schema(table)
248
+ value(table, :schema_version)
249
+ end
250
+
251
+ def supported_decision_table_schema?(table)
252
+ decision_table_schema(table) == Branchproof::DecisionTable::SCHEMA_VERSION
253
+ end
254
+
255
+ def decision_table_comparability_reasons(decision_ids)
256
+ decision_ids.filter_map do |id|
257
+ before = before_document_table(id)
258
+ after = after_document_table(id)
259
+ next if !before || !after ||
260
+ (supported_decision_table_schema?(before) && supported_decision_table_schema?(after))
261
+
262
+ "decision-table schema is incompatible for decision #{id}"
263
+ end
264
+ end
265
+
266
+ def decision_table_metadata_changes(decision_id)
267
+ before = before_document_table(decision_id)
268
+ after = after_document_table(decision_id)
269
+ return [] unless before && after
270
+
271
+ changes = []
272
+ if value(before, :schema_version) != value(after, :schema_version)
273
+ changes << "decision-table schema version changed"
274
+ end
275
+ unless [before, after].all? { |table| supported_decision_table_schema?(table) }
276
+ changes << "decision-table schema version unsupported"
277
+ end
278
+ if value(before, :constraint_analysis_version) != value(after, :constraint_analysis_version)
279
+ changes << "constraint analysis version changed"
280
+ end
281
+ before_mode = value(before, :reachability_analyzed)
282
+ after_mode = value(after, :reachability_analyzed)
283
+ if !before_mode.nil? && !after_mode.nil? && before_mode != after_mode
284
+ changes << "reachability analysis mode changed"
285
+ end
286
+ changes
287
+ end
288
+
289
+ def decision_table_change(decision_id, before, after)
290
+ previous = { "coverage" => value(before, :coverage).to_s, "reachability" => value(before, :reachability).to_s,
291
+ "reachability_reason" => value(before, :reachability_reason) }
292
+ current = { "coverage" => value(after, :coverage).to_s, "reachability" => value(after, :reachability).to_s,
293
+ "reachability_reason" => value(after, :reachability_reason) }
294
+ kind = if previous["coverage"] == "covered" && current["coverage"] != "covered"
295
+ "rule coverage lost"
296
+ elsif previous["coverage"] != "covered" && current["coverage"] == "covered"
297
+ "rule coverage gained"
298
+ elsif previous["reachability"] != current["reachability"]
299
+ "rule reachability changed"
300
+ else
301
+ "unchanged"
302
+ end
303
+ decision = context(@after)[:decisions][decision_id]
304
+ { "decision_id" => decision_id, "rule_id" => value(after, :id).to_s, "label" => value(after, :label).to_s,
305
+ "change" => kind, "previous" => previous, "current" => current,
306
+ "conditions" => Array(value(after, :conditions)), "outcome" => value(after, :outcome),
307
+ "relative_path" => source_path(@after, decision), "line" => value(decision, :line),
308
+ "expression" => value(decision, :expression) }
309
+ end
310
+
311
+ def decision_table_context_changes(decision_ids, changed_paths)
312
+ before = decision_tables(@before, decision_ids)
313
+ after = decision_tables(@after, decision_ids)
314
+ changes = decision_ids.each_with_object([]) do |id, result|
315
+ next unless before.key?(id) || after.key?(id)
316
+
317
+ decision = context(@after)[:decisions][id] || context(@before)[:decisions][id]
318
+ location = { "decision_id" => id, "relative_path" => source_path(@after, decision),
319
+ "line" => value(decision, :line), "expression" => value(decision, :expression) }
320
+ decision_table_metadata_changes(id).each { |reason| result << location.merge("reason" => reason) }
321
+ if before.key?(id) != after.key?(id) ||
322
+ (before.key?(id) && after.key?(id) && before[id].keys.sort != after[id].keys.sort)
323
+ result << location.merge("reason" => "decision-table rule set changed")
324
+ end
325
+ next unless before.key?(id) && after.key?(id) && decision_table_comparable_ids([id], before, after).include?(id)
326
+
327
+ before[id].each do |rule_id, previous|
328
+ current = after[id][rule_id]
329
+ next unless current
330
+ next unless value(previous, :coverage).to_s != value(current, :coverage).to_s &&
331
+ value(previous, :reachability).to_s != value(current, :reachability).to_s
332
+
333
+ previous_reachability = value(previous, :reachability).to_s
334
+ current_reachability = value(current, :reachability).to_s
335
+ previous_coverage = value(previous, :coverage).to_s
336
+ current_coverage = value(current, :coverage).to_s
337
+ analysis_change = [previous_reachability, current_reachability].include?("statically_impossible") ||
338
+ [previous_coverage, current_coverage].include?("excluded")
339
+ next unless analysis_change
340
+
341
+ result << location.merge("reason" => "rule reachability changed alongside coverage",
342
+ "rule_id" => rule_id)
343
+ end
344
+ end
345
+ changed_paths.sort.each do |path|
346
+ changes << { "decision_id" => nil, "reason" => "source changed / decision-table comparison unavailable",
347
+ "relative_path" => path, "line" => nil, "expression" => nil }
348
+ end
349
+ if !metadata(@before, :reachability).nil? && !metadata(@after, :reachability).nil? &&
350
+ metadata(@before, :reachability) != metadata(@after, :reachability)
351
+ changes << { "decision_id" => nil, "reason" => "reachability analysis mode changed",
352
+ "relative_path" => nil, "line" => nil, "expression" => nil }
353
+ end
354
+ changes
355
+ end
356
+
357
+ def decision_table_matching(decision_ids, table_changes)
358
+ before = decision_tables(@before, decision_ids)
359
+ after = decision_tables(@after, decision_ids)
360
+ { "compared_decisions" => decision_table_comparable_ids(decision_ids, before, after).length,
361
+ "matched_rules" => table_changes.length,
362
+ "before_covered_rules" => table_changes.count { |change| change["previous"]["coverage"] == "covered" },
363
+ "after_covered_rules" => table_changes.count { |change| change["current"]["coverage"] == "covered" } }
364
+ end
365
+
178
366
  def context(document)
179
367
  @contexts[document] ||= begin
180
368
  index = CoverageIndex.new(document: document)
@@ -188,15 +376,18 @@ module Branchproof
188
376
  results = Array(value(value(document, :analysis), :decisions)).flat_map do |decision|
189
377
  Array(value(decision, :condition_results))
190
378
  end
379
+ vectors = Array(value(value(document, :observations), :vectors))
191
380
  { conditions: index.conditions.to_h { |row| [row[:id].to_s, row] }, tests: tests, test_rows: rows,
192
381
  test_keys: keys, key_owners: keys.keys.group_by { |id| keys[id] },
193
- vectors: Array(value(value(document, :observations), :vectors)).to_h do |vector|
194
- [value(vector, :id).to_s, vector]
195
- end,
382
+ vectors: vectors.to_h { |vector| [value(vector, :id).to_s, vector] },
383
+ vectors_by_decision: vectors.group_by { |vector| value(vector, :decision_id).to_s },
196
384
  decisions: Array(value(value(document, :source_inventory), :decisions)).to_h do |decision|
197
385
  [value(decision, :id).to_s, decision]
198
386
  end,
199
- results: results.to_h { |result| [value(result, :condition_id).to_s, result] } }
387
+ results: results.to_h { |result| [value(result, :condition_id).to_s, result] },
388
+ results_by_decision: Array(value(value(document, :analysis), :decisions)).to_h do |decision|
389
+ [value(decision, :decision_id).to_s, decision]
390
+ end }
200
391
  end
201
392
  end
202
393
 
@@ -211,10 +402,9 @@ module Branchproof
211
402
  key = before[:test_keys][id.to_s]
212
403
  unique = key && before[:key_owners][key]&.length == 1 && after[:key_owners][key]&.length == 1
213
404
  current_id = unique ? after[:key_owners][key].first : nil
214
- observed = current_id && after[:vectors].values.any? do |current|
215
- value(current, :decision_id) == value(decision, :id) &&
216
- value(current,
217
- :values) == value(vector, :values) && value(current, :outcome) == value(vector, :outcome) &&
405
+ decision_vectors = after[:vectors_by_decision][value(decision, :id).to_s]
406
+ observed = current_id && Array(decision_vectors).any? do |current|
407
+ value(current, :values) == value(vector, :values) && value(current, :outcome) == value(vector, :outcome) &&
218
408
  Array(value(current, :test_ids)).include?(current_id)
219
409
  end
220
410
  test_status = current_id ? "present in current run" : "not observed in current run (no unique test match)"
@@ -264,9 +454,7 @@ module Branchproof
264
454
  def comparability_reasons(changed_paths)
265
455
  reasons = []
266
456
  reasons << "source files changed: #{changed_paths.sort.join(", ")}" unless changed_paths.empty?
267
- %i[criterion_version project_kind runner_args source_patterns test_patterns limits runtime].each do |key|
268
- next if key == :criterion_version
269
-
457
+ %i[project_kind runner_args source_patterns test_patterns limits runtime].each do |key|
270
458
  left = metadata(@before, key)
271
459
  right = metadata(@after, key)
272
460
  if key == :runner_args
@@ -7,6 +7,8 @@ require "json"
7
7
  module Branchproof
8
8
  # Renders the offline document returned by Comparison.
9
9
  class ComparisonReport
10
+ RULE_LABELS = Branchproof::DecisionTable::VALUE_LABELS.freeze
11
+
10
12
  def initialize(document:)
11
13
  raise ArgumentError, "comparison document must be a Hash" unless document.is_a?(Hash)
12
14
 
@@ -23,7 +25,7 @@ module Branchproof
23
25
 
24
26
  def exit_code(fail_on_regression: false)
25
27
  return 2 if status != "complete"
26
- return 1 if fail_on_regression && regression?
28
+ return 1 if fail_on_regression && (regression? || decision_table_regression?)
27
29
 
28
30
  0
29
31
  end
@@ -72,9 +74,51 @@ module Branchproof
72
74
  lines << " #{context.empty? ? "not observed in current run" : context.join(", ")}"
73
75
  end
74
76
  end
77
+ render_decision_tables(lines)
75
78
  lines.join("\n") << "\n"
76
79
  end
77
80
 
81
+ # Coverage movement and analysis movement stay separate: a rule that became
82
+ # statically impossible is an analysis change, never a coverage gain.
83
+ def render_decision_tables(lines)
84
+ matching = decision_table_matching
85
+ lines << "Decision-table rules: #{matching.fetch("matched_rules", 0)} matched across " \
86
+ "#{matching.fetch("compared_decisions", 0)} decisions"
87
+ lines << "Decision-table rules covered: before #{matching.fetch("before_covered_rules",
88
+ 0)}; after #{matching.fetch(
89
+ "after_covered_rules", 0
90
+ )}"
91
+ decision_table_context_changes.each do |change|
92
+ location = change["relative_path"].to_s.empty? ? "location unavailable" : change["relative_path"].to_s
93
+ lines << "Decision-table context: #{location} #{change["reason"]}"
94
+ end
95
+ rendered = decision_table_changes.reject { |change| change["change"] == "unchanged" }
96
+ return lines << "No comparable decision-table rule changes." if rendered.empty?
97
+
98
+ rendered.each { |change| render_decision_table_change(lines, change) }
99
+ end
100
+
101
+ def render_decision_table_change(lines, change)
102
+ location = if change["relative_path"].to_s.empty?
103
+ "location unavailable"
104
+ else
105
+ "#{change["relative_path"]}:#{change["line"] || "decision line unavailable"}"
106
+ end
107
+ signature = Array(change["conditions"]).map { |item| RULE_LABELS.fetch(item.to_s, item.to_s) }.join
108
+ lines << "#{change["change"].capitalize}: #{location} rule #{change["label"]} " \
109
+ "[#{signature}] => #{change["outcome"] ? "T" : "F"} #{change["expression"]}"
110
+ lines << " Previous: #{state_label(change["previous"])}"
111
+ lines << " Current: #{state_label(change["current"])}"
112
+ reason = change.dig("current", "reachability_reason")
113
+ lines << " Reason: #{reason}" unless reason.to_s.empty?
114
+ end
115
+
116
+ def state_label(state)
117
+ state ||= {}
118
+ "#{state["coverage"] == "covered" ? "covered" : "uncovered"}, " \
119
+ "reachability #{state["reachability"]}"
120
+ end
121
+
78
122
  def status = value(:status).to_s
79
123
 
80
124
  def signs(values)
@@ -94,6 +138,10 @@ module Branchproof
94
138
  end
95
139
 
96
140
  def regression? = value(:regression) == true || value(:regressions).to_i.positive?
141
+ def decision_table_regression? = value(:decision_table_regressions).to_i.positive?
142
+ def decision_table_changes = Array(value(:decision_table_changes))
143
+ def decision_table_context_changes = Array(value(:decision_table_context_changes))
144
+ def decision_table_matching = value(:decision_table_matching) || {}
97
145
  def reasons = Array(value(:reasons))
98
146
  def matching = value(:matching) || {}
99
147
  def before = value(:before) || {}