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
@@ -1,12 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rubygems/version"
4
+
5
+ # Rails policy validation intentionally stays in one adapter-neutral module.
6
+ # rubocop:disable Metrics/ModuleLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
7
+
3
8
  module Branchproof
4
9
  # Boots the selected Rails application after Branchproof's load hook is active.
5
10
  module RailsSupport
11
+ SUPPORTED_RAILS = Gem::Version.new("8.1").freeze
12
+ SUPPORTED_RSPEC_RAILS_MAJOR = 8
13
+ SUPPORTED_CAPYBARA_DRIVER = "rack_test"
14
+
6
15
  class Error < StandardError; end
7
16
 
8
17
  module_function
9
18
 
19
+ # Boot the Rails application for the native Minitest adapter.
10
20
  def boot(project:)
11
21
  environment = environment_path(project)
12
22
  raise Error, "Rails project is missing #{environment}" unless File.file?(environment)
@@ -26,6 +36,9 @@ module Branchproof
26
36
  File.join(root, "config", "environment.rb")
27
37
  end
28
38
 
39
+ # Require the application and validate the process-wide Rails policy.
40
+ # This method is public so adapter-specific helpers can own the remaining
41
+ # test framework setup while sharing the exact same Rails boot checks.
29
42
  def boot_environment(environment)
30
43
  require environment
31
44
  validate_application!
@@ -43,6 +56,90 @@ module Branchproof
43
56
  nil
44
57
  end
45
58
 
59
+ # Validate the seam after an RSpec Rails helper has loaded. RSpec owns
60
+ # requiring rspec/rails and configuring its example groups; this method
61
+ # only checks that that setup is attached to the same, already-validated
62
+ # Rails application.
63
+ def validate_rspec!(project:)
64
+ # A focused spec_helper-only run may live in a Rails project without
65
+ # loading Rails. Once Rails is loaded, all boot checks still apply.
66
+ return nil unless defined?(Rails)
67
+
68
+ kind = project[:kind] || project["kind"]
69
+ raise Error, "Rails was loaded for a non-Rails project" if kind.to_s != "rails"
70
+
71
+ validate_application!
72
+ raise Error, "RSpec Rails was not loaded; require rspec/rails from rails_helper" unless defined?(RSpec::Rails)
73
+
74
+ expected_root = File.expand_path(project.fetch(:root).to_s)
75
+ actual_root = Rails.root && File.expand_path(Rails.root.to_s)
76
+ if actual_root != expected_root
77
+ raise Error,
78
+ "Rails application root #{actual_root.inspect} does not match project root #{expected_root.inspect}"
79
+ end
80
+
81
+ rails_metadata = metadata.merge(rspec_rails_version: rspec_rails_version)
82
+ validate_version_tuple!(rails_metadata)
83
+ install_rspec_driver_guard!
84
+ rails_metadata
85
+ end
86
+
87
+ def install_rspec_driver_guard!
88
+ return unless defined?(Capybara::Session)
89
+ return if Capybara::Session.instance_variable_defined?(:@branchproof_driver_guard)
90
+
91
+ support = self
92
+ Capybara::Session.prepend(Module.new do
93
+ define_method(:initialize) do |driver, *args, &block|
94
+ begin
95
+ support.validate_rspec_driver!(driver: driver)
96
+ rescue Branchproof::RailsSupport::Error => e
97
+ adapter = (Branchproof::RSpecAdapter.active_adapter if defined?(Branchproof::RSpecAdapter))
98
+ adapter ? adapter.reject_execution!(e.message) : raise
99
+ end
100
+ super(driver, *args, &block)
101
+ end
102
+ end)
103
+ Capybara::Session.instance_variable_set(:@branchproof_driver_guard, true)
104
+ end
105
+
106
+ # RSpec Rails invokes this at example execution time, before the example
107
+ # can ask Capybara to launch a browser. The adapter converts this explicit
108
+ # rejection into an incomplete/error execution result.
109
+ def validate_rspec_driver!(example_metadata)
110
+ metadata = example_metadata.respond_to?(:to_h) ? example_metadata.to_h : {}
111
+ driver = metadata[:driver] || metadata["driver"]
112
+ threaded = metadata[:threaded] || metadata["threaded"] || metadata[:parallel] || metadata["parallel"]
113
+ unsupported = metadata[:js] == true || metadata["js"] == true || threaded ||
114
+ (driver && driver.to_s != SUPPORTED_CAPYBARA_DRIVER)
115
+ return true unless unsupported
116
+
117
+ selected = driver || (threaded ? "threaded" : "browser")
118
+ raise Error, "unsupported RSpec driver #{selected.inspect}; only in-process rack_test is supported"
119
+ end
120
+
121
+ def validate_version_tuple!(rails_metadata)
122
+ rails_version = Gem::Version.new(rails_metadata.fetch(:rails_version).to_s)
123
+ ruby_version = Gem::Version.new(rails_metadata.fetch(:ruby_version).to_s)
124
+ rspec_version = Gem::Version.new(rails_metadata.fetch(:rspec_rails_version).to_s)
125
+ return true if rails_version.segments.first(2) == SUPPORTED_RAILS.segments.first(2) &&
126
+ ruby_version.segments.first(2) == [3, 4] &&
127
+ rspec_version.segments.first == SUPPORTED_RSPEC_RAILS_MAJOR
128
+
129
+ raise Error,
130
+ "unsupported Rails/Ruby/RSpec Rails tuple: Rails #{rails_version}, Ruby #{ruby_version}, " \
131
+ "RSpec Rails #{rspec_version} (supported Rails 8.1, Ruby 3.4.x, RSpec Rails 8.x)"
132
+ end
133
+
134
+ def rspec_rails_version
135
+ require "rspec/rails/version" if defined?(RSpec::Rails) && !defined?(RSpec::Rails::Version::STRING)
136
+ return RSpec::Rails::Version::STRING if defined?(RSpec::Rails::Version::STRING)
137
+ return RSpec::Rails::VERSION if defined?(RSpec::Rails::VERSION)
138
+ return Gem.loaded_specs.fetch("rspec-rails").version.to_s if Gem.loaded_specs.key?("rspec-rails")
139
+
140
+ "unknown"
141
+ end
142
+
46
143
  def rails_application
47
144
  if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
48
145
  application = Rails.application
@@ -68,7 +165,11 @@ module Branchproof
68
165
 
69
166
  def metadata
70
167
  { rails_version: Rails::VERSION::STRING,
168
+ ruby_version: RUBY_VERSION,
169
+ environment: Rails.env.to_s,
170
+ reloading: reloading_enabled?(rails_application.config),
71
171
  serial_policy: { mode: "single_process", workers: 1, parallel_workers: 1 } }
72
172
  end
73
173
  end
74
174
  end
175
+ # rubocop:enable Metrics/ModuleLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
@@ -4,11 +4,13 @@
4
4
 
5
5
  require "json"
6
6
  require "pathname"
7
+ require_relative "coverage_policy"
8
+ require_relative "report_selection"
7
9
 
8
10
  module Branchproof
9
11
  # Renders versioned terminal and JSON analysis reports.
10
12
  class Report
11
- SCHEMA_VERSION = "1.3"
13
+ SCHEMA_VERSION = "1.4"
12
14
  CRITERION_VERSION = "masking_occurrence_v1"
13
15
  VIEWS = %i[decisions conditions tests decision_tables].freeze
14
16
  DECISION_TABLE_LABELS = { "true" => "T", "false" => "F", "dont_care" => "-" }.freeze
@@ -16,7 +18,7 @@ module Branchproof
16
18
  "excluded" => "EXCLUDED" }.freeze
17
19
 
18
20
  def initialize(inventory:, evidence:, analysis:, minima:, baseline:, diagnostics:, level: 3, missing_only: false,
19
- view: :decisions, run_metadata: {}, saved_document: nil)
21
+ view: :decisions, run_metadata: {}, saved_document: nil, focus: nil, top: nil, minimum: nil)
20
22
  raise ArgumentError, "level must be 1, 2, or 3" unless [1, 2, 3].include?(level.to_i)
21
23
  unless VIEWS.include?(view.to_sym)
22
24
  raise ArgumentError, "view must be :decisions, :conditions, :tests, or :decision_tables"
@@ -33,21 +35,29 @@ module Branchproof
33
35
  @view = view.to_sym
34
36
  @run_metadata = run_metadata || {}
35
37
  @saved_document = saved_document
38
+ raise ArgumentError, "minimum must be a hash" if !minimum.nil? && !minimum.is_a?(Hash)
39
+
40
+ @minimum_override = minimum.is_a?(Hash) && !minimum.empty? ? CoveragePolicy.normalize(minimum) : nil
41
+ @minimum = effective_minimum
42
+ @selection = ReportSelection.new(focus: focus, top: top)
36
43
  end
37
44
 
38
- def self.from_document(document:, level: nil, view: :decisions, missing_only: false)
45
+ def self.from_document(document:, level: nil, view: :decisions, missing_only: false, focus: nil, top: nil,
46
+ minimum: nil)
39
47
  data = document || {}
40
48
  new(inventory: data[:source_inventory] || data["source_inventory"],
41
49
  evidence: data[:observations] || data["observations"],
42
50
  analysis: data[:analysis] || data["analysis"], minima: data[:minima] || data["minima"],
43
51
  baseline: data[:baseline] || data["baseline"], diagnostics: data[:diagnostics] || data["diagnostics"],
44
52
  level: level || (data[:analysis] || data["analysis"] ? 3 : 1), view: view, missing_only: missing_only,
45
- run_metadata: data[:run_metadata] || data["run_metadata"], saved_document: data)
53
+ focus: focus, top: top,
54
+ run_metadata: data[:run_metadata] || data["run_metadata"], saved_document: data, minimum: minimum)
46
55
  end
47
56
 
48
57
  def write(io:, format:)
49
58
  format = format.to_sym
50
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?
51
61
 
52
62
  io.write(format == :json ? JSON.generate(json_document) : terminal_document)
53
63
  nil
@@ -105,6 +115,10 @@ module Branchproof
105
115
  def exit_code
106
116
  return 2 unless usage_valid?
107
117
 
118
+ policy_status = value(coverage_policy, :status).to_s
119
+ return 2 if policy_status == "unavailable"
120
+ return 1 if policy_status == "failed"
121
+
108
122
  status = value(@baseline, :status).to_s.upcase
109
123
  return 2 if %w[ERROR INCOMPLETE].include?(status)
110
124
  return 1 if status == "FAILED"
@@ -118,10 +132,24 @@ module Branchproof
118
132
  0
119
133
  end
120
134
 
135
+ def rerun_command(test_id)
136
+ test = test_records_by_id[test_id.to_s]
137
+ return unless test && value(test, :adapter).to_s == "rspec"
138
+
139
+ selector = value(test, :example_id).to_s
140
+ return if selector.empty?
141
+
142
+ "bundle exec rspec '#{selector.gsub("'", %q('"'"'))}'"
143
+ end
144
+
121
145
  private
122
146
 
123
147
  def json_document
124
- return normalize(@saved_document) if @saved_document
148
+ if @saved_document
149
+ document = normalize(@saved_document)
150
+ document["coverage_policy"] = normalize(coverage_policy) if @minimum_override
151
+ return document
152
+ end
125
153
 
126
154
  normalize(schema_version: SCHEMA_VERSION,
127
155
  tool_version: (defined?(Branchproof::VERSION) ? Branchproof::VERSION : "unknown"),
@@ -129,14 +157,15 @@ module Branchproof
129
157
  run_ids: Array(value(@evidence, :run_ids)),
130
158
  source_inventory: inventory_with_default_kinds, baseline: @baseline, observations: @evidence,
131
159
  analysis: @analysis, minima: @minima, metrics: metrics,
132
- diagnostics: @diagnostics, completeness: completeness,
160
+ diagnostics: @diagnostics, completeness: completeness, coverage_policy: coverage_policy,
133
161
  run_metadata: @run_metadata)
134
162
  end
135
163
 
136
164
  def terminal_document
137
165
  unless @view == :decisions
138
166
  return FocusedReport.new(document: json_document, view: @view, level: @level,
139
- missing_only: @missing_only, coordinator: self).render
167
+ missing_only: @missing_only, coordinator: self,
168
+ selection: @selection).render
140
169
  end
141
170
 
142
171
  @terminal_ids = terminal_ids
@@ -151,10 +180,13 @@ module Branchproof
151
180
  "#{metrics[:unattributed]} unattributed",
152
181
  values_legend]
153
182
  lines.concat(coverage_ladder_lines)
183
+ lines.concat(coverage_policy_lines)
154
184
  lines << missing_summary_line if @missing_only
185
+ selected_decisions, hidden = selected_decisions_for_display
186
+ append_selection_lines(lines, hidden, "decision")
155
187
  lines << "Scope: supported decisions, conditions, and alternatives"
156
188
  lines << ""
157
- decisions_to_render.each { |decision| render_decision(lines, decision) }
189
+ selected_decisions.each { |decision| render_decision(lines, decision) }
158
190
  render_minima(lines) unless @missing_only
159
191
  unless @diagnostics.empty?
160
192
  lines << "Diagnostics:"
@@ -507,10 +539,17 @@ module Branchproof
507
539
  end
508
540
 
509
541
  def baseline_test_counts
542
+ noun = rspec_run? ? "examples" : "tests"
510
543
  executed = value(@baseline, :executed_tests)
511
544
  failed = value(@baseline, :failed_tests) || value(@baseline, :failures) || 0
512
545
  skipped = value(@baseline, :skipped_tests) || value(@baseline, :skips) || 0
513
- "#{executed || 0} tests, #{failed} failed, #{skipped} skipped"
546
+ "#{executed || 0} #{noun}, #{failed} failed, #{skipped} skipped"
547
+ end
548
+
549
+ def rspec_run?
550
+ framework = value(@run_metadata, :framework) || value(@run_metadata, :project_framework) ||
551
+ value(@baseline, :framework)
552
+ framework.to_s.downcase == "rspec"
514
553
  end
515
554
 
516
555
  def terminal_coverage_label
@@ -584,6 +623,9 @@ module Branchproof
584
623
  end
585
624
  return short_id(id) if name.empty? || name == id
586
625
 
626
+ command = rerun_command(id)
627
+ return "#{name} (rerun: #{command})" if command
628
+
587
629
  duplicates = test_records_by_name[name]
588
630
  return name if duplicates.length == 1
589
631
 
@@ -910,6 +952,33 @@ module Branchproof
910
952
  end
911
953
  end
912
954
 
955
+ def selected_decisions_for_display
956
+ decisions = decisions_to_render
957
+ return [decisions, 0] unless @selection.active?
958
+
959
+ inventory = value(@inventory, :source_units) ? @inventory : { source_units: [], decisions: inventory_decisions }
960
+ decisions = @selection.filter_decisions(decisions, inventory: inventory)
961
+ decisions = decisions.sort_by { |decision| @selection.sort_key(decision, inventory: inventory) }
962
+ @selection.limit(decisions)
963
+ end
964
+
965
+ def append_selection_lines(lines, hidden, unit)
966
+ if @selection.focus_active?
967
+ matching = @selection.matching_decision_ids(json_document)
968
+ lines << if matching.empty?
969
+ "Focus: no matching decisions for #{@selection.focus_label}"
970
+ else
971
+ "Focus: #{@selection.focus_label}"
972
+ end
973
+ end
974
+ return unless @selection.top
975
+
976
+ noun = hidden == 1 ? unit : "#{unit}s"
977
+ hidden_noun = hidden == 1 ? unit : "#{unit}s"
978
+ lines << "Display limit: top #{@selection.top} #{noun}; hidden #{hidden} #{hidden_noun} " \
979
+ "(not risk-ranked)"
980
+ end
981
+
913
982
  def alternatives_to_render(decision)
914
983
  alternatives = Array(value(decision, :alternatives))
915
984
  return alternatives unless @missing_only && analysis_available?
@@ -942,6 +1011,44 @@ module Branchproof
942
1011
  !@analysis.nil?
943
1012
  end
944
1013
 
1014
+ def coverage_policy
1015
+ @coverage_policy ||= CoveragePolicy.new(minimum: @minimum).call(document: policy_document)
1016
+ end
1017
+
1018
+ def coverage_policy_lines
1019
+ policy = coverage_policy
1020
+ minimum = value(policy, :minimum) || {}
1021
+ return [] if minimum.empty?
1022
+
1023
+ lines = ["Coverage policy: #{value(policy, :status).to_s.upcase}"]
1024
+ Array(value(policy, :gates)).each do |gate|
1025
+ numerator = value(gate, :numerator)
1026
+ denominator = value(gate, :denominator)
1027
+ count = if denominator.nil? || (denominator.respond_to?(:zero?) && denominator.zero?)
1028
+ "N/A"
1029
+ else
1030
+ "#{numerator || "N/A"}/#{denominator}"
1031
+ end
1032
+ suffix = value(gate, :reason) ? "; reason: #{value(gate, :reason)}" : ""
1033
+ lines << " #{value(gate, :criterion)}: #{count}, threshold #{value(gate, :minimum)}, " \
1034
+ "#{value(gate, :status).to_s.upcase}#{suffix}"
1035
+ end
1036
+ lines << ""
1037
+ lines
1038
+ end
1039
+
1040
+ def policy_document
1041
+ { baseline: @baseline, completeness: value(@saved_document, :completeness) || completeness,
1042
+ observations: value(@saved_document, :observations) || @evidence,
1043
+ analysis: value(@saved_document, :analysis) || @analysis }
1044
+ end
1045
+
1046
+ def effective_minimum
1047
+ inherited = value(value(@saved_document, :coverage_policy), :minimum)
1048
+ base = inherited.is_a?(Hash) ? CoveragePolicy.normalize(inherited) : {}
1049
+ base.merge(@minimum_override || {})
1050
+ end
1051
+
945
1052
  def coverage_available?
946
1053
  analysis_available? && !value(@analysis, :coverage).nil?
947
1054
  end
@@ -1150,7 +1257,8 @@ module Branchproof
1150
1257
  def normalize_unknown(object)
1151
1258
  object.to_s
1152
1259
  end
1153
- public :condition_coverage_evidence, :coverage_ladder_lines, :coverage_status_label,
1260
+ public :condition_coverage_evidence, :coverage_ladder_lines, :coverage_policy_lines, :coverage_policy,
1261
+ :coverage_status_label,
1154
1262
  :decision_table_requirement, :decision_table_reachability, :decision_table_expected_heading
1155
1263
  end
1156
1264
  end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Branchproof
6
+ # Validates and applies terminal-only report display filters.
7
+ # rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
8
+ class ReportSelection
9
+ attr_reader :focus, :top
10
+
11
+ def initialize(focus: nil, top: nil)
12
+ @focus = normalize_focus(focus)
13
+ @top = normalize_top(top)
14
+ end
15
+
16
+ def active?
17
+ !@focus.nil? || !@top.nil?
18
+ end
19
+
20
+ def focus_active?
21
+ !@focus.nil?
22
+ end
23
+
24
+ def focus_label
25
+ return unless @focus
26
+
27
+ @focus[:line] ? "#{@focus[:path]}:#{@focus[:line]}" : @focus[:path]
28
+ end
29
+
30
+ def matching_decision_ids(document)
31
+ inventory = fetch(document, :source_inventory) || fetch(document, :inventory) || {}
32
+ sources = source_map(inventory)
33
+ Array(fetch(inventory, :decisions)).filter_map do |decision|
34
+ next unless matches_decision?(decision, sources)
35
+
36
+ fetch(decision, :id).to_s
37
+ end
38
+ end
39
+
40
+ def filter_decisions(decisions, inventory: {})
41
+ return decisions unless focus_active?
42
+
43
+ sources = source_map(inventory)
44
+ decisions.select { |decision| matches_decision?(decision, sources) }
45
+ end
46
+
47
+ def limit(items)
48
+ return [items, 0] unless @top
49
+
50
+ [items.first(@top), [items.length - @top, 0].max]
51
+ end
52
+
53
+ def sort_key(decision, inventory: {})
54
+ source = source_for(decision, source_map(inventory))
55
+ [normalize_source_path(fetch(source, :relative_path)).to_s,
56
+ fetch(decision, :line).to_i, fetch(decision, :column).to_i, fetch(decision, :id).to_s]
57
+ end
58
+
59
+ private
60
+
61
+ def normalize_focus(focus)
62
+ return nil if focus.nil?
63
+ raise ArgumentError, "focus must be PATH or PATH:LINE" unless focus.is_a?(String)
64
+
65
+ raw = focus.strip
66
+ raise ArgumentError, "focus path must not be empty" if raw.empty?
67
+
68
+ path, line = if raw.match?(/:\d+\z/)
69
+ match = raw.match(/\A(.+):([0-9]+)\z/)
70
+ raise ArgumentError, "focus path must not be empty" unless match
71
+
72
+ [match[1], match[2].to_i]
73
+ else
74
+ raise ArgumentError, "focus line must be a positive integer" if raw.include?(":")
75
+
76
+ [raw, nil]
77
+ end
78
+ raise ArgumentError, "focus line must be a positive integer" if line && line <= 0
79
+
80
+ { path: normalize_path(path), line: line }.freeze
81
+ end
82
+
83
+ def normalize_top(top)
84
+ return nil if top.nil?
85
+
86
+ value = if top.is_a?(Integer)
87
+ top
88
+ elsif top.is_a?(String) && top.match?(/\A[1-9][0-9]*\z/)
89
+ top.to_i
90
+ end
91
+ raise ArgumentError, "top must be a positive integer" unless value&.positive?
92
+
93
+ value
94
+ end
95
+
96
+ def normalize_path(path)
97
+ text = path.to_s
98
+ raise ArgumentError, "focus path must not be empty" if text.empty? || text.include?("\0")
99
+
100
+ pathname = Pathname.new(text)
101
+ raise ArgumentError, "focus path must be project-relative" if pathname.absolute?
102
+
103
+ normalized = pathname.cleanpath.to_s
104
+ if normalized == "." || normalized == ".." || normalized.start_with?("../")
105
+ raise ArgumentError, "focus path must stay within the project"
106
+ end
107
+
108
+ normalized
109
+ rescue ArgumentError
110
+ raise
111
+ rescue StandardError => e
112
+ raise ArgumentError, "invalid focus path: #{e.message}"
113
+ end
114
+
115
+ def matches_decision?(decision, sources)
116
+ return false unless @focus
117
+
118
+ source = source_for(decision, sources)
119
+ return false unless normalize_source_path(fetch(source, :relative_path)) == @focus[:path]
120
+ return true unless @focus[:line]
121
+
122
+ start_line = fetch(decision, :line).to_i
123
+ return false unless start_line.positive?
124
+
125
+ end_line = start_line + fetch(decision, :expression).to_s.count("\n")
126
+ @focus[:line].between?(start_line, end_line)
127
+ end
128
+
129
+ def source_for(decision, sources)
130
+ sources[fetch(decision, :source_id).to_s] || decision
131
+ end
132
+
133
+ def source_map(inventory)
134
+ Array(fetch(inventory, :source_units)).to_h do |source|
135
+ [fetch(source, :source_id).to_s, source]
136
+ end
137
+ end
138
+
139
+ def normalize_source_path(path)
140
+ return nil if path.nil? || path.to_s.empty?
141
+ return nil if Pathname.new(path.to_s).absolute?
142
+
143
+ Pathname.new(path.to_s).cleanpath.to_s
144
+ rescue ArgumentError
145
+ nil
146
+ end
147
+
148
+ def fetch(hash, key)
149
+ return nil unless hash.respond_to?(:key?)
150
+ return hash[key] if hash.key?(key)
151
+ return hash[key.to_s] if hash.key?(key.to_s)
152
+
153
+ nil
154
+ end
155
+ end
156
+ end
157
+
158
+ # rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity