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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -0
- data/README.md +230 -28
- data/doc/Branchproof/Configuration.md +28 -0
- data/doc/Branchproof/CoveragePolicy.md +27 -0
- data/doc/Branchproof/Evidence.md +3 -0
- data/doc/Branchproof/FocusedReport.md +1 -1
- data/doc/Branchproof/Project.md +4 -1
- data/doc/Branchproof/RSpecAdapter/ClassRunnerGuard.md +11 -0
- data/doc/Branchproof/RSpecAdapter/ContextLifecycle.md +14 -0
- data/doc/Branchproof/RSpecAdapter/DefaultDiscovery.md +11 -0
- data/doc/Branchproof/RSpecAdapter/ExampleLifecycle.md +11 -0
- data/doc/Branchproof/RSpecAdapter/RunnerGuard.md +11 -0
- data/doc/Branchproof/RSpecAdapter/UnsupportedRunner.md +12 -0
- data/doc/Branchproof/RSpecAdapter.md +47 -0
- data/doc/Branchproof/RailsSupport.md +35 -2
- data/doc/Branchproof/Report.md +11 -2
- data/doc/Branchproof/ReportSelection.md +42 -0
- data/doc/Branchproof/Runtime.md +3 -0
- data/doc/Branchproof/Worker.md +8 -1
- data/doc/Branchproof.md +11 -1
- data/doc/CHANGELOG.md +42 -0
- data/doc/README.md +230 -28
- data/lib/branchproof/cli.rb +191 -38
- data/lib/branchproof/comparison.rb +58 -7
- data/lib/branchproof/configuration.rb +111 -0
- data/lib/branchproof/coverage_policy.rb +169 -0
- data/lib/branchproof/evidence.rb +41 -2
- data/lib/branchproof/focused_report.rb +149 -27
- data/lib/branchproof/project.rb +49 -2
- data/lib/branchproof/rails_support.rb +101 -0
- data/lib/branchproof/report.rb +118 -10
- data/lib/branchproof/report_selection.rb +158 -0
- data/lib/branchproof/rspec_adapter.rb +423 -0
- data/lib/branchproof/runtime.rb +4 -0
- data/lib/branchproof/saved_report.rb +38 -5
- data/lib/branchproof/version.rb +1 -1
- data/lib/branchproof/worker.rb +75 -15
- data/lib/branchproof.rb +4 -0
- data/llms.txt +11 -1
- data/sig/branchproof.rbs +63 -4
- 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
|
data/lib/branchproof/report.rb
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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}
|
|
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, :
|
|
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
|