branchproof 0.9.0 → 0.11.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/README.md +230 -28
  4. data/doc/Branchproof/Configuration.md +28 -0
  5. data/doc/Branchproof/CoveragePolicy.md +27 -0
  6. data/doc/Branchproof/Evidence.md +3 -0
  7. data/doc/Branchproof/FocusedReport.md +1 -1
  8. data/doc/Branchproof/Project.md +4 -1
  9. data/doc/Branchproof/RSpecAdapter/ClassRunnerGuard.md +11 -0
  10. data/doc/Branchproof/RSpecAdapter/ContextLifecycle.md +14 -0
  11. data/doc/Branchproof/RSpecAdapter/DefaultDiscovery.md +11 -0
  12. data/doc/Branchproof/RSpecAdapter/ExampleLifecycle.md +11 -0
  13. data/doc/Branchproof/RSpecAdapter/RunnerGuard.md +11 -0
  14. data/doc/Branchproof/RSpecAdapter/UnsupportedRunner.md +12 -0
  15. data/doc/Branchproof/RSpecAdapter.md +47 -0
  16. data/doc/Branchproof/RailsSupport.md +35 -2
  17. data/doc/Branchproof/Report.md +11 -2
  18. data/doc/Branchproof/ReportSelection.md +42 -0
  19. data/doc/Branchproof/Runtime.md +3 -0
  20. data/doc/Branchproof/Worker.md +8 -1
  21. data/doc/Branchproof.md +11 -1
  22. data/doc/CHANGELOG.md +42 -0
  23. data/doc/README.md +230 -28
  24. data/lib/branchproof/cli.rb +191 -38
  25. data/lib/branchproof/comparison.rb +58 -7
  26. data/lib/branchproof/configuration.rb +111 -0
  27. data/lib/branchproof/coverage_policy.rb +169 -0
  28. data/lib/branchproof/evidence.rb +41 -2
  29. data/lib/branchproof/focused_report.rb +149 -27
  30. data/lib/branchproof/project.rb +49 -2
  31. data/lib/branchproof/rails_support.rb +101 -0
  32. data/lib/branchproof/report.rb +118 -10
  33. data/lib/branchproof/report_selection.rb +158 -0
  34. data/lib/branchproof/rspec_adapter.rb +423 -0
  35. data/lib/branchproof/runtime.rb +4 -0
  36. data/lib/branchproof/saved_report.rb +38 -5
  37. data/lib/branchproof/version.rb +1 -1
  38. data/lib/branchproof/worker.rb +75 -15
  39. data/lib/branchproof.rb +4 -0
  40. data/llms.txt +11 -1
  41. data/sig/branchproof.rbs +63 -4
  42. metadata +16 -2
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists
4
+
5
+ module Branchproof
6
+ # Evaluates configured coverage thresholds against the aggregate Analyzer
7
+ # coverage counts without relying on rounded percentage fields.
8
+ class CoveragePolicy
9
+ CRITERIA = {
10
+ "decision" => { numerator: "covered_decisions", denominator: "supported_decisions" },
11
+ "condition" => { numerator: "covered_values", denominator: "required_values" },
12
+ "condition_decision" => { numerator: "covered_decisions", denominator: "supported_decisions" },
13
+ "mcdc" => { numerator: "proven_conditions", denominator: "supported_conditions" },
14
+ "decision_table" => { numerator: "covered_rules", denominator: "required_rules" }
15
+ }.freeze
16
+ COMPLETENESS_FIELDS = %w[observation attribution analysis].freeze
17
+
18
+ class << self
19
+ def normalize(minimum)
20
+ raise ArgumentError, "minimum must be a hash" unless minimum.is_a?(Hash)
21
+
22
+ minimum.each_with_object({}) do |(key, threshold), normalized|
23
+ unless key.is_a?(String) || key.is_a?(Symbol)
24
+ raise ArgumentError, "unknown coverage criterion: #{key.inspect}"
25
+ end
26
+
27
+ criterion = key.to_s
28
+ raise ArgumentError, "unknown coverage criterion: #{criterion}" unless CRITERIA.key?(criterion)
29
+ raise ArgumentError, "duplicate coverage criterion: #{criterion}" if normalized.key?(criterion)
30
+
31
+ validate_threshold!(criterion, threshold)
32
+ normalized[criterion] = threshold
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def validate_threshold!(criterion, threshold)
39
+ valid_type = threshold.is_a?(Integer) || threshold.is_a?(Float)
40
+ valid_float = !threshold.is_a?(Float) || threshold.finite?
41
+ unless valid_type && valid_float
42
+ raise ArgumentError, "coverage minimum for #{criterion} must be a finite number"
43
+ end
44
+ return if (0..100).cover?(threshold)
45
+
46
+ raise ArgumentError, "coverage minimum for #{criterion} must be between 0 and 100"
47
+ end
48
+ end
49
+
50
+ def initialize(minimum:)
51
+ @minimum = self.class.normalize(minimum)
52
+ end
53
+
54
+ def call(document:)
55
+ return { minimum: @minimum.dup, status: "passed", gates: [] } if @minimum.empty?
56
+
57
+ global_reason = availability_reason(document)
58
+ gates = @minimum.map do |criterion, threshold|
59
+ evaluate_gate(criterion, threshold, document, global_reason)
60
+ end
61
+
62
+ status = if gates.any? { |gate| gate[:status] == "unavailable" }
63
+ "unavailable"
64
+ elsif gates.any? { |gate| gate[:status] == "failed" }
65
+ "failed"
66
+ else
67
+ "passed"
68
+ end
69
+
70
+ { minimum: @minimum.dup, status: status, gates: gates }
71
+ end
72
+
73
+ private
74
+
75
+ def availability_reason(document)
76
+ return "document_unavailable" unless document.is_a?(Hash)
77
+
78
+ baseline = value(document, :baseline)
79
+ baseline_passed = baseline.is_a?(Hash) && value(baseline, :status).to_s.upcase == "PASSED"
80
+ return "baseline_unavailable" unless baseline_passed && value(baseline, :finalized) == true
81
+ return "incomplete_document" unless complete?(value(document, :completeness))
82
+
83
+ observations = value(document, :observations) || value(document, :evidence)
84
+ return "incomplete_observations" if incomplete_section?(observations)
85
+
86
+ analysis = value(document, :analysis)
87
+ return "incomplete_analysis" if incomplete_section?(analysis)
88
+
89
+ nil
90
+ end
91
+
92
+ def complete?(completeness)
93
+ return false unless completeness.is_a?(Hash)
94
+
95
+ COMPLETENESS_FIELDS.all? { |field| value(completeness, field) == true }
96
+ end
97
+
98
+ def incomplete_section?(section)
99
+ section.is_a?(Hash) && key_present?(section, :completeness) && !complete?(value(section, :completeness))
100
+ end
101
+
102
+ def evaluate_gate(criterion, threshold, document, global_reason)
103
+ return gate(criterion, threshold, nil, nil, "unavailable", global_reason) if global_reason
104
+
105
+ coverage = value(value(document, :analysis), :coverage)
106
+ row = value(coverage, criterion)
107
+ return gate(criterion, threshold, nil, nil, "unavailable", "missing_coverage_count") unless row.is_a?(Hash)
108
+
109
+ mapping = CRITERIA.fetch(criterion)
110
+ numerator = value(row, mapping.fetch(:numerator))
111
+ denominator = value(row, mapping.fetch(:denominator))
112
+
113
+ if criterion == "decision_table"
114
+ not_calculated = value(row, :not_calculated_decisions)
115
+ unless not_calculated.nil?
116
+ unless valid_count?(not_calculated)
117
+ return gate(criterion, threshold, numerator, denominator, "unavailable", "invalid_coverage_count")
118
+ end
119
+ if not_calculated.positive?
120
+ return gate(criterion, threshold, numerator, denominator, "unavailable", "decision_table_not_calculated")
121
+ end
122
+ end
123
+ end
124
+
125
+ if numerator.nil? || denominator.nil?
126
+ return unavailable_gate(criterion, threshold, numerator, denominator, "missing_coverage_count")
127
+ end
128
+ unless valid_count?(numerator) && valid_count?(denominator)
129
+ return unavailable_gate(criterion, threshold, numerator, denominator, "invalid_coverage_count")
130
+ end
131
+ return unavailable_gate(criterion, threshold, numerator, denominator, "zero_denominator") if denominator.zero?
132
+ if numerator > denominator
133
+ return unavailable_gate(criterion, threshold, numerator, denominator, "invalid_coverage_count")
134
+ end
135
+
136
+ status = numerator * 100 >= denominator * Rational(threshold.to_s) ? "passed" : "failed"
137
+ gate(criterion, threshold, numerator, denominator, status, nil)
138
+ end
139
+
140
+ def valid_count?(value)
141
+ value.is_a?(Integer) && value >= 0
142
+ end
143
+
144
+ def gate(criterion, threshold, numerator, denominator, status, reason)
145
+ { criterion: criterion, numerator: numerator, denominator: denominator, minimum: threshold,
146
+ status: status, reason: reason }
147
+ end
148
+
149
+ def unavailable_gate(criterion, threshold, numerator, denominator, reason)
150
+ gate(criterion, threshold, numerator, denominator, "unavailable", reason)
151
+ end
152
+
153
+ def value(hash, key)
154
+ return nil unless hash.respond_to?(:key?)
155
+ return hash[key] if hash.key?(key)
156
+
157
+ alternate = key.is_a?(Symbol) ? key.to_s : key.to_sym
158
+ return hash[alternate] if hash.key?(alternate)
159
+
160
+ nil
161
+ end
162
+
163
+ def key_present?(hash, key)
164
+ hash.key?(key) || hash.key?(key.to_s) || hash.key?(key.to_sym)
165
+ end
166
+ end
167
+ end
168
+
169
+ # rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists
@@ -50,7 +50,9 @@ module Branchproof
50
50
  current = @tests[id] || { id: id, adapter: "unknown", name: id, source: nil, class_name: nil,
51
51
  method_name: nil, status: "unknown", phase_counts: {} }
52
52
  merged = current.merge(value).merge(id: id)
53
- merged[:phase_counts] = (current[:phase_counts] || {}).merge(value[:phase_counts] || {})
53
+ merged[:phase_counts] = normalize_phase_counts(current[:phase_counts]).merge(
54
+ normalize_phase_counts(value[:phase_counts])
55
+ )
54
56
  @tests[id] = deep_dup(merged)
55
57
  status("registered", nil)
56
58
  end
@@ -120,6 +122,10 @@ module Branchproof
120
122
  nil
121
123
  end
122
124
 
125
+ def test_phase_counts
126
+ @tests.transform_values { |test| test[:phase_counts].dup.freeze }.freeze
127
+ end
128
+
123
129
  def snapshot
124
130
  deep_freeze(deep_dup({ schema_version: SCHEMA_VERSION, tool_version: TOOL_VERSION,
125
131
  criterion_version: CRITERION_VERSION,
@@ -168,7 +174,7 @@ module Branchproof
168
174
  @run_payloads[id] = Branchproof::Records.id(payload)
169
175
  end
170
176
  @run_ids |= incoming_runs
171
- incoming.fetch(:tests, []).each { register_test(test: _1) }
177
+ incoming.fetch(:tests, []).each { merge_test(_1) }
172
178
  incoming.fetch(:vectors, []).each { merge_vector(_1) }
173
179
  @diagnostics.concat(incoming.fetch(:diagnostics, []))
174
180
  @abort_counts.merge!(incoming.fetch(:abort_counts, {})) { |_k, a, b| a.to_i + b.to_i }
@@ -333,6 +339,39 @@ module Branchproof
333
339
  end
334
340
  end
335
341
 
342
+ def merge_test(raw)
343
+ value = symbolize(raw)
344
+ value[:phase_counts] = normalize_phase_counts(value[:phase_counts])
345
+ id = (value[:id] || test_id(value)).to_s
346
+ unless @tests.key?(id)
347
+ register_test(test: value)
348
+ return unless @tests.key?(id)
349
+
350
+ return
351
+ end
352
+
353
+ current = @tests.fetch(id)
354
+ merged = current.merge(value).merge(id: id)
355
+ merged[:phase_counts] = sum_phase_counts(current[:phase_counts], value[:phase_counts])
356
+ @tests[id] = deep_dup(merged)
357
+ end
358
+
359
+ def normalize_phase_counts(value)
360
+ (value || {}).each_with_object({}) do |(phase, count), result|
361
+ key = phase.to_s
362
+ result[key] = result.fetch(key, 0) + count
363
+ end
364
+ end
365
+
366
+ def sum_phase_counts(*counts)
367
+ counts.each_with_object({}) do |phase_counts, result|
368
+ normalize_phase_counts(phase_counts).each do |phase, count|
369
+ result[phase] = result.fetch(phase, 0) + count
370
+ end
371
+ result
372
+ end
373
+ end
374
+
336
375
  def merge_error(incoming)
337
376
  return "unsupported schema" unless incoming[:schema_version].to_s == SCHEMA_VERSION
338
377
  return "criterion mismatch" unless incoming[:criterion_version].to_s == CRITERION_VERSION
@@ -1,14 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
3
+ # rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
4
4
  require "pathname"
5
+ require_relative "coverage_index"
6
+ require_relative "report_selection"
5
7
 
6
8
  module Branchproof
7
9
  # Terminal renderings grouped around conditions or tests.
8
10
  class FocusedReport
9
11
  DECISION_TABLE_LABELS = { "true" => "T", "false" => "F", "dont_care" => "-" }.freeze
10
12
 
11
- def initialize(document:, view:, level:, missing_only: false, coordinator: nil)
13
+ def initialize(document:, view:, level:, missing_only: false, coordinator: nil, focus: nil, top: nil,
14
+ selection: nil)
12
15
  @document = document || {}
13
16
  @view = view.to_sym
14
17
  unless (Report::VIEWS - [:decisions]).include?(@view)
@@ -17,12 +20,14 @@ module Branchproof
17
20
 
18
21
  @level = level.to_i
19
22
  @missing_only = missing_only ? true : false
23
+ @selection = selection || ReportSelection.new(focus: focus, top: top)
20
24
  @index = CoverageIndex.new(document: @document)
21
25
  @tests = @index.tests.to_h { |test| [test[:id].to_s, test] }
22
26
  @test_name_counts = @index.tests.each_with_object(Hash.new(0)) do |test, counts|
23
27
  counts[[test[:name], test[:relative_path], test[:line]]] += 1
24
28
  end
25
29
  @coordinator = coordinator || Report.from_document(document: @document, level: @level)
30
+ @matching_decision_ids = @selection.focus_active? ? @selection.matching_decision_ids(@document) : nil
26
31
  end
27
32
 
28
33
  def render
@@ -45,20 +50,19 @@ module Branchproof
45
50
  end
46
51
  lines << "Empty groups mean no recorded completed observation."
47
52
  lines << ""
53
+ render_focus_notice(lines)
48
54
  lines.concat(@coordinator.coverage_ladder_lines)
55
+ lines.concat(@coordinator.coverage_policy_lines)
49
56
  case @view
50
57
  when :conditions
51
58
  render_conditions(lines)
52
- render_alternatives(lines)
53
59
  when :decision_tables
54
60
  render_decision_tables(lines)
55
61
  else
56
62
  render_tests(lines)
57
63
  end
58
- unless @view == :decision_tables
59
- render_unowned(lines)
60
- render_unsupported(lines)
61
- end
64
+ render_unowned(lines)
65
+ render_unsupported(lines)
62
66
  Array(fetch(@document, :diagnostics)).each do |diagnostic|
63
67
  lines << "Diagnostic: #{@coordinator.diagnostic_message(diagnostic)}"
64
68
  end
@@ -68,8 +72,8 @@ module Branchproof
68
72
  private
69
73
 
70
74
  def render_conditions(lines)
71
- rows = @index.conditions
72
- rows = rows.reject { |row| row[:status].to_s.upcase == "PROVEN" } if @missing_only && @level > 1
75
+ rows, alternatives, hidden = condition_rows_for_display
76
+ append_limit_summary(lines, hidden, "condition/alternative row")
73
77
  rows.each do |row|
74
78
  lines << "Condition: #{row[:expression]}"
75
79
  lines << "Location: #{location(row[:relative_path], row[:line], unavailable: "condition line unavailable")}"
@@ -108,7 +112,8 @@ module Branchproof
108
112
  end
109
113
  lines << ""
110
114
  end
111
- lines << "No missing conditions" if @missing_only && rows.empty?
115
+ lines << "No missing conditions" if @missing_only && rows.empty? && focus_match_exists?
116
+ render_alternatives(lines, alternatives)
112
117
  end
113
118
 
114
119
  def coverage_available?
@@ -133,11 +138,12 @@ module Branchproof
133
138
  # --missing-only only uncovered, non-impossible rules remain: a rule proven
134
139
  # impossible is not a missing obligation.
135
140
  def render_decision_tables(lines)
136
- rows = @index.decision_tables
141
+ all_rows = filtered_decision_tables
142
+ rows, hidden = limit_rows(all_rows)
143
+ append_limit_summary(lines, hidden, "decision")
137
144
  rendered = 0
138
- impossible = 0
145
+ impossible = @index.decision_tables.sum { |row| row[:impossible_rules].to_i }
139
146
  rows.each do |row|
140
- impossible += row[:impossible_rules].to_i
141
147
  rules = @missing_only ? row[:rules].select { |rule| rule[:coverage].to_s == "missing" } : row[:rules]
142
148
  next if @missing_only && rules.empty? && row[:status].to_s == "calculated"
143
149
 
@@ -187,9 +193,8 @@ module Branchproof
187
193
  lines << " Reason: #{Constraints.message(rule[:reachability_reason])}"
188
194
  end
189
195
 
190
- def render_alternatives(lines)
191
- rows = @index.alternatives
192
- rows = rows.select { |row| row[:missing] || row[:status].to_s != "covered" } if @missing_only && @level > 1
196
+ def render_alternatives(lines, rows = nil)
197
+ rows ||= filtered_alternatives
193
198
  rows.each do |row|
194
199
  lines << "Alternative #{row[:index]}: #{row[:expression]}"
195
200
  lines << "Location: #{location(row[:relative_path], row[:line],
@@ -208,7 +213,7 @@ module Branchproof
208
213
  end
209
214
  lines << ""
210
215
  end
211
- lines << "No missing alternatives" if @missing_only && rows.empty?
216
+ lines << "No missing alternatives" if @missing_only && rows.empty? && focus_match_exists?
212
217
  end
213
218
 
214
219
  def render_alternative_group(lines, heading, evidence)
@@ -219,25 +224,27 @@ module Branchproof
219
224
  end
220
225
 
221
226
  def render_tests(lines)
222
- rows = @index.tests
227
+ rows = filtered_tests
223
228
  missing_ids = @index.conditions.reject { |row| row[:status].to_s.upcase == "PROVEN" }.map { |row| row[:id] }
224
229
  missing_alternative_ids = @index.alternatives.select { |row| row[:missing] || row[:status].to_s != "covered" }
225
230
  .map { |row| row[:alternative_id] }
231
+ rows = rows.select do |row|
232
+ !@missing_only || test_observations(row, missing_ids, missing_alternative_ids).any?
233
+ end
234
+ rows, hidden = limit_rows(rows)
235
+ append_limit_summary(lines, hidden, "test")
226
236
  rows.each { |row| render_test_row(lines, row, missing_ids, missing_alternative_ids) }
227
237
  end
228
238
 
229
239
  def render_test_row(lines, row, missing_ids, missing_alternative_ids = [])
230
- observations = row[:observations]
231
- if @missing_only
232
- observations = observations.select do |observation|
233
- missing_ids.include?(observation[:condition_id]) ||
234
- missing_alternative_ids.include?(observation[:alternative_id])
235
- end
236
- return if observations.empty?
237
- end
240
+ observations = test_observations(row, missing_ids, missing_alternative_ids)
241
+ return if @missing_only && observations.empty?
242
+
238
243
  lines << "Test: #{row[:name]}"
239
244
  lines << "Location: #{location(row[:relative_path], row[:line], unavailable: "location unavailable")}"
240
245
  lines << "Status: #{row[:status] || "unknown"}"
246
+ command = @coordinator.rerun_command(row[:id])
247
+ lines << "Rerun: #{command}" if command
241
248
  lines << "Phases: #{row[:phases].join(", ")}" unless row[:phases].empty?
242
249
  render_test_observations(lines, observations)
243
250
  lines << ""
@@ -289,6 +296,11 @@ module Branchproof
289
296
  groups.each do |heading, conditions|
290
297
  next if conditions.empty?
291
298
 
299
+ if @selection.active?
300
+ lines << "#{heading}: #{conditions.length} (run-wide)"
301
+ next
302
+ end
303
+
292
304
  lines << "#{heading}:"
293
305
  conditions.each do |row|
294
306
  unavailable = row[:alternative_id] ? "alternative location unavailable" : "condition line unavailable"
@@ -305,6 +317,12 @@ module Branchproof
305
317
  end
306
318
  return if excluded.empty?
307
319
 
320
+ if @selection.active?
321
+ noun = excluded.length == 1 ? "decision" : "decisions"
322
+ lines << "Unsupported conditions (MC/DC unavailable): #{excluded.length} #{noun} (run-wide)"
323
+ return
324
+ end
325
+
308
326
  lines << "Unsupported conditions (MC/DC unavailable):"
309
327
  excluded.each do |decision|
310
328
  source = sources[fetch(decision, :source_id)] || {}
@@ -319,6 +337,107 @@ module Branchproof
319
337
  end
320
338
  end
321
339
 
340
+ def render_focus_notice(lines)
341
+ return unless @selection.focus_active?
342
+
343
+ lines << if @matching_decision_ids.empty?
344
+ "Focus: no matching decisions for #{@selection.focus_label}"
345
+ else
346
+ "Focus: #{@selection.focus_label}"
347
+ end
348
+ end
349
+
350
+ def append_limit_summary(lines, hidden, unit)
351
+ return unless @selection.top
352
+
353
+ noun = hidden == 1 ? unit : "#{unit}s"
354
+ hidden_noun = hidden == 1 ? unit : "#{unit}s"
355
+ lines << "Display limit: top #{@selection.top} #{noun}; hidden #{hidden} #{hidden_noun} " \
356
+ "(not risk-ranked)"
357
+ end
358
+
359
+ def focus_match_exists?
360
+ !@selection.focus_active? || !@matching_decision_ids.empty?
361
+ end
362
+
363
+ def filtered_conditions
364
+ rows = @index.conditions
365
+ rows = rows.select { |row| @matching_decision_ids.include?(row[:decision_id].to_s) } if @selection.focus_active?
366
+ rows = rows.reject { |row| row[:status].to_s.upcase == "PROVEN" } if @missing_only && @level > 1
367
+ rows.sort_by do |row|
368
+ [row[:relative_path].to_s, row[:line].to_i, row[:column].to_i,
369
+ row[:decision_id].to_s, row[:index].to_i]
370
+ end
371
+ end
372
+
373
+ def filtered_alternatives
374
+ rows = @index.alternatives
375
+ rows = rows.select { |row| @matching_decision_ids.include?(row[:decision_id].to_s) } if @selection.focus_active?
376
+ rows = rows.select { |row| row[:missing] || row[:status].to_s != "covered" } if @missing_only && @level > 1
377
+ rows.sort_by do |row|
378
+ [row[:relative_path].to_s, row[:line].to_i, row[:column].to_i,
379
+ row[:decision_id].to_s, row[:index].to_i]
380
+ end
381
+ end
382
+
383
+ def condition_rows_for_display
384
+ conditions = filtered_conditions
385
+ alternatives = filtered_alternatives
386
+ return [conditions, alternatives, 0] unless @selection.top
387
+
388
+ combined = (conditions.map { |row| [:condition, row] } + alternatives.map { |row| [:alternative, row] })
389
+ .sort_by do |kind, row|
390
+ [row[:relative_path].to_s, row[:line].to_i, row[:column].to_i, kind == :condition ? 0 : 1,
391
+ row[:decision_id].to_s, row[:index].to_i]
392
+ end
393
+ visible, hidden = @selection.limit(combined)
394
+ [visible.filter_map { |kind, row| row if kind == :condition },
395
+ visible.filter_map { |kind, row| row if kind == :alternative }, hidden]
396
+ end
397
+
398
+ def filtered_decision_tables
399
+ rows = @index.decision_tables
400
+ rows = rows.select { |row| @matching_decision_ids.include?(row[:decision_id].to_s) } if @selection.focus_active?
401
+ rows = rows.select do |row|
402
+ !@missing_only || row[:status].to_s != "calculated" || row[:rules].any? do |rule|
403
+ rule[:coverage].to_s == "missing"
404
+ end
405
+ end
406
+ rows.sort_by { |row| [row[:relative_path].to_s, row[:line].to_i, row[:decision_id].to_s] }
407
+ end
408
+
409
+ def filtered_tests
410
+ rows = @index.tests
411
+ return rows unless @selection.active?
412
+
413
+ if @selection.focus_active?
414
+ condition_ids = @index.conditions.select { |row| @matching_decision_ids.include?(row[:decision_id].to_s) }
415
+ .map { |row| row[:id].to_s }
416
+ alternative_ids = @index.alternatives.select { |row| @matching_decision_ids.include?(row[:decision_id].to_s) }
417
+ .map { |row| row[:alternative_id].to_s }
418
+ rows = rows.select do |row|
419
+ row[:observations].any? do |observation|
420
+ condition_ids.include?(observation[:condition_id].to_s) ||
421
+ alternative_ids.include?(observation[:alternative_id].to_s)
422
+ end
423
+ end
424
+ end
425
+ rows.sort_by { |row| [row[:relative_path].to_s, row[:line].to_i, row[:name].to_s, row[:id].to_s] }
426
+ end
427
+
428
+ def limit_rows(rows)
429
+ @selection.limit(rows)
430
+ end
431
+
432
+ def test_observations(row, missing_ids, missing_alternative_ids)
433
+ return row[:observations] unless @missing_only
434
+
435
+ row[:observations].select do |observation|
436
+ missing_ids.include?(observation[:condition_id]) ||
437
+ missing_alternative_ids.include?(observation[:alternative_id])
438
+ end
439
+ end
440
+
322
441
  def location(path, line, unavailable: "location unavailable")
323
442
  return "location unavailable" if path.to_s.empty?
324
443
  return "#{path}: #{unavailable}" if line.nil?
@@ -341,6 +460,9 @@ module Branchproof
341
460
  return id.to_s unless test
342
461
 
343
462
  label = "#{test[:name]} (#{location(test[:relative_path], test[:line])})"
463
+ command = @coordinator.rerun_command(id)
464
+ return "#{label} (rerun: #{command})" if command
465
+
344
466
  duplicates = @test_name_counts[[test[:name], test[:relative_path], test[:line]]]
345
467
  duplicates > 1 ? "#{label} [#{id}]" : label
346
468
  end
@@ -351,4 +473,4 @@ module Branchproof
351
473
  end
352
474
  end
353
475
 
354
- # rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
476
+ # rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
@@ -4,6 +4,7 @@ module Branchproof
4
4
  # Resolves the project policy used by the isolated analysis worker.
5
5
  class Project
6
6
  MODES = %w[auto ruby rails].freeze
7
+ FRAMEWORKS = %w[auto minitest rspec].freeze
7
8
  RAILS_ENVIRONMENT = {
8
9
  "RAILS_ENV" => "test",
9
10
  "RACK_ENV" => "test",
@@ -12,19 +13,24 @@ module Branchproof
12
13
  "DISABLE_SPRING" => "1"
13
14
  }.freeze
14
15
 
15
- def initialize(root:, mode: "auto")
16
+ def initialize(root:, mode: "auto", framework: "auto")
16
17
  @root = File.expand_path(root)
17
18
  @mode = mode.to_s
19
+ @framework = framework.to_s
18
20
  raise ArgumentError, "project must be auto, ruby, or rails" unless MODES.include?(@mode)
21
+ raise ArgumentError, "framework must be auto, minitest, or rspec" unless FRAMEWORKS.include?(@framework)
19
22
 
20
23
  validate_root!
24
+ resolve_framework!
21
25
  end
22
26
 
23
27
  def to_h
24
28
  {
25
29
  kind: kind,
26
30
  root: @root,
27
- load_paths: [File.join(@root, "lib"), File.join(@root, "test")],
31
+ framework: framework,
32
+ test_patterns: test_patterns,
33
+ load_paths: load_paths,
28
34
  environment: kind == "rails" ? RAILS_ENVIRONMENT.dup : {}
29
35
  }
30
36
  end
@@ -43,6 +49,47 @@ module Branchproof
43
49
  File.file?(File.join(@root, "config", "environment.rb"))
44
50
  end
45
51
 
52
+ def framework
53
+ @resolved_framework
54
+ end
55
+
56
+ def resolve_framework!
57
+ rspec = rspec_markers?
58
+ minitest = minitest_markers?
59
+ if @framework == "auto"
60
+ raise_both_frameworks if rspec && minitest
61
+ @resolved_framework = rspec ? "rspec" : "minitest"
62
+ else
63
+ @resolved_framework = @framework
64
+ end
65
+ end
66
+
67
+ def rspec_markers?
68
+ File.file?(File.join(@root, ".rspec")) || !Dir.glob(File.join(@root, "spec", "**", "*_spec.rb")).empty?
69
+ end
70
+
71
+ def raise_both_frameworks
72
+ raise ArgumentError,
73
+ "project contains both RSpec and Minitest markers; specify --framework rspec or --framework minitest"
74
+ end
75
+
76
+ def minitest_markers?
77
+ !Dir.glob(File.join(@root, "test", "**", "*_test.rb")).empty? ||
78
+ !Dir.glob(File.join(@root, "test", "**", "test_*.rb")).empty?
79
+ end
80
+
81
+ def test_patterns
82
+ if framework == "rspec"
83
+ %w[spec/**/*_spec.rb].freeze
84
+ else
85
+ %w[test/**/*_test.rb test/**/test_*.rb].freeze
86
+ end
87
+ end
88
+
89
+ def load_paths
90
+ [File.join(@root, "lib"), File.join(@root, framework == "rspec" ? "spec" : "test")]
91
+ end
92
+
46
93
  def validate_root!
47
94
  raise ArgumentError, "project root does not exist: #{@root}" unless File.directory?(@root)
48
95
  return unless @mode == "rails" && !rails_files?