branchproof 0.8.0 → 0.10.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +120 -28
  4. data/doc/Branchproof/DefaultInstrumentation.md +13 -0
  5. data/doc/Branchproof/DefaultRuntime.md +11 -0
  6. data/doc/Branchproof/DefaultSyntax.md +7 -0
  7. data/doc/Branchproof/Evidence.md +3 -0
  8. data/doc/Branchproof/ExceptionInstrumentation.md +9 -0
  9. data/doc/Branchproof/ExceptionRuntime.md +27 -0
  10. data/doc/Branchproof/ExceptionSyntax.md +9 -0
  11. data/doc/Branchproof/ExtendedAlternativeRuntime.md +19 -0
  12. data/doc/Branchproof/Instrumenter.md +1 -1
  13. data/doc/Branchproof/IterationInstrumentation.md +7 -0
  14. data/doc/Branchproof/IterationRuntime.md +21 -0
  15. data/doc/Branchproof/IterationSyntax.md +11 -0
  16. data/doc/Branchproof/Project.md +4 -1
  17. data/doc/Branchproof/RSpecAdapter/ClassRunnerGuard.md +11 -0
  18. data/doc/Branchproof/RSpecAdapter/ContextLifecycle.md +14 -0
  19. data/doc/Branchproof/RSpecAdapter/DefaultDiscovery.md +11 -0
  20. data/doc/Branchproof/RSpecAdapter/ExampleLifecycle.md +11 -0
  21. data/doc/Branchproof/RSpecAdapter/RunnerGuard.md +11 -0
  22. data/doc/Branchproof/RSpecAdapter/UnsupportedRunner.md +12 -0
  23. data/doc/Branchproof/RSpecAdapter.md +47 -0
  24. data/doc/Branchproof/RailsSupport.md +35 -2
  25. data/doc/Branchproof/Report.md +3 -0
  26. data/doc/Branchproof/Runtime.md +59 -1
  27. data/doc/Branchproof/Source.md +7 -1
  28. data/doc/Branchproof/ValueInstrumentation.md +7 -0
  29. data/doc/Branchproof/ValueRuntime.md +26 -0
  30. data/doc/Branchproof/ValueSyntax.md +26 -0
  31. data/doc/Branchproof/Worker.md +8 -1
  32. data/doc/Branchproof.md +24 -3
  33. data/doc/CHANGELOG.md +37 -0
  34. data/doc/README.md +120 -28
  35. data/lib/branchproof/cli.rb +74 -28
  36. data/lib/branchproof/comparison.rb +52 -6
  37. data/lib/branchproof/decision_syntax.rb +19 -9
  38. data/lib/branchproof/default_instrumentation.rb +140 -0
  39. data/lib/branchproof/default_runtime.rb +16 -0
  40. data/lib/branchproof/default_syntax.rb +94 -0
  41. data/lib/branchproof/evidence.rb +87 -1
  42. data/lib/branchproof/exception_instrumentation.rb +96 -0
  43. data/lib/branchproof/exception_runtime.rb +41 -0
  44. data/lib/branchproof/exception_syntax.rb +157 -0
  45. data/lib/branchproof/extended_alternative_runtime.rb +23 -0
  46. data/lib/branchproof/flow_instrumentation.rb +36 -7
  47. data/lib/branchproof/focused_report.rb +5 -0
  48. data/lib/branchproof/instrumenter.rb +50 -5
  49. data/lib/branchproof/iteration_instrumentation.rb +66 -0
  50. data/lib/branchproof/iteration_runtime.rb +79 -0
  51. data/lib/branchproof/iteration_syntax.rb +75 -0
  52. data/lib/branchproof/loader.rb +6 -1
  53. data/lib/branchproof/project.rb +49 -2
  54. data/lib/branchproof/rails_support.rb +101 -0
  55. data/lib/branchproof/report.rb +21 -1
  56. data/lib/branchproof/rspec_adapter.rb +423 -0
  57. data/lib/branchproof/runtime.rb +14 -0
  58. data/lib/branchproof/saved_report.rb +9 -2
  59. data/lib/branchproof/source.rb +59 -32
  60. data/lib/branchproof/value_instrumentation.rb +35 -0
  61. data/lib/branchproof/value_runtime.rb +73 -0
  62. data/lib/branchproof/value_syntax.rb +116 -0
  63. data/lib/branchproof/version.rb +1 -1
  64. data/lib/branchproof/worker.rb +75 -15
  65. data/lib/branchproof.rb +1 -0
  66. data/llms.txt +24 -3
  67. data/sig/branchproof.rbs +45 -1
  68. metadata +36 -2
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Branchproof
4
+ # Keep matching and iteration in the original lexical scope.
5
+ module IterationInstrumentation
6
+ private
7
+
8
+ # rubocop:disable Metrics/MethodLength
9
+ def render_flow(bytes, decision, nested, encloses)
10
+ metadata = decision.fetch(:instrumentation)
11
+ case metadata[:type]
12
+ when "iteration"
13
+ render_iteration(bytes, decision, nested, encloses)
14
+ when "required_pattern"
15
+ expression = render_children(bytes, decision[:byte_start], decision[:byte_length], nested, encloses)
16
+ runtime = self.class::RUNTIME
17
+ identifier = decision[:id].inspect
18
+ "(begin; #{runtime}.enter(#{identifier}); begin; #{expression}; " \
19
+ "#{runtime}.flow_select(#{identifier}, 0); nil; rescue ::NoMatchingPatternError; " \
20
+ "#{runtime}.flow_select(#{identifier}, 1); raise; ensure; #{runtime}.leave(#{identifier}); end; end)"
21
+ else
22
+ super
23
+ end
24
+ end
25
+
26
+ # rubocop:enable Metrics/MethodLength
27
+
28
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- nested exception callback seam
29
+ def render_iteration(bytes, decision, nested, encloses)
30
+ metadata = decision.fetch(:instrumentation)
31
+ identifier = decision[:id].inspect
32
+ runtime = self.class::RUNTIME
33
+ alternative_count = metadata[:lazy] ? 1 : 2
34
+ marker = "#{runtime}.flow_iteration_callback(#{identifier}, #{alternative_count})"
35
+ suffix = metadata[:empty] ? "nil; " : ""
36
+ callback = "; #{marker}; #{suffix}"
37
+ exception_child = nested.find do |child|
38
+ child[:instrumentation]&.fetch(:type, nil) == "exception" &&
39
+ child[:instrumentation].fetch(:implicit, false) &&
40
+ contains?(child[:byte_start], child[:byte_length], metadata[:insert_at], 0)
41
+ end
42
+ if exception_child
43
+ instrumentation = exception_child.fetch(:instrumentation)
44
+ callback_child = exception_child.merge(instrumentation: instrumentation.merge(
45
+ iteration_callback: { insert_at: metadata[:insert_at], text: callback }
46
+ ))
47
+ nested = nested.map { |child| child.equal?(exception_child) ? callback_child : child }
48
+ encloses = encloses.merge(callback_child => encloses[exception_child])
49
+ end
50
+ edits = exception_child ? [] : [{ start: metadata[:insert_at], length: 0, text: callback }]
51
+ if metadata[:receiver]
52
+ receiver = metadata.fetch(:receiver)
53
+ edits << flow_replacement(bytes, receiver, nested, encloses) do |expression|
54
+ "#{runtime}.flow_iteration_begin(#{identifier}, (#{expression}), #{alternative_count})"
55
+ end
56
+ end
57
+ expression = flow_fragments(bytes, decision, nested, edits, encloses)
58
+ if metadata[:receiver]
59
+ "(begin; begin; #{runtime}.flow_iteration_finish(#{identifier}, (#{expression}), 0); ensure; " \
60
+ "#{runtime}.flow_iteration_leave(#{identifier}); end; end)"
61
+ else
62
+ flow_frame(decision[:id], expression, default_path: 0)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Branchproof
4
+ # Runtime support for callback based iteration, including lazy receivers
5
+ # whose callbacks execute after the constructing call has returned.
6
+ module IterationRuntime
7
+ def flow_iteration_begin(decision_id, receiver, alternative_count = 2)
8
+ # Case equality bypasses an application's overridden `is_a?` method.
9
+ # rubocop:disable-next Style/CaseEquality
10
+ return receiver if alternative_count == 1 || ::Enumerator::Lazy === receiver
11
+
12
+ enter(decision_id)
13
+ receiver
14
+ end
15
+
16
+ # rubocop:disable-next Metrics/MethodLength
17
+ def flow_iteration_callback(decision_id, alternative_count = 2)
18
+ frame = iteration_frame(decision_id)
19
+ if frame
20
+ return nil if frame[:finished]
21
+
22
+ iteration_select(frame, alternative_count.to_i == 1 ? 0 : 1, alternative_count)
23
+ else
24
+ enter(decision_id)
25
+ begin
26
+ if alternative_count.to_i == 1
27
+ flow_select(decision_id, 0)
28
+ else
29
+ flow_path(decision_id, 1)
30
+ end
31
+ ensure
32
+ leave(decision_id)
33
+ end
34
+ end
35
+ nil
36
+ end
37
+
38
+ def flow_iteration_finish(decision_id, value, default_path = nil)
39
+ frame = iteration_frame(decision_id)
40
+ iteration_select(frame, default_path, 2) if frame && !frame[:finished] && !default_path.nil?
41
+ value
42
+ end
43
+
44
+ def flow_iteration_leave(decision_id)
45
+ frame = iteration_frame(decision_id)
46
+ frames = Thread.current[Branchproof::Runtime::FRAME_STATE_KEY]&.fetch(:frames, nil)
47
+ return nil unless frame && frames&.last.equal?(frame)
48
+
49
+ leave(decision_id)
50
+ end
51
+
52
+ private
53
+
54
+ # `current_frame` deliberately latches a diagnostic when a helper probes
55
+ # while another decision is active. Iteration callbacks may be deferred or
56
+ # nested, so an absent matching frame is an ordinary state here.
57
+ def iteration_frame(decision_id)
58
+ frames = Thread.current[Branchproof::Runtime::FRAME_STATE_KEY]&.fetch(:frames, nil)
59
+ index = frames ? frames.length - 1 : -1
60
+ while index >= 0
61
+ frame = frames[index]
62
+ return frame if frame[:decision_id] == decision_id
63
+
64
+ index -= 1
65
+ end
66
+ nil
67
+ end
68
+
69
+ def iteration_select(frame, index, alternative_count)
70
+ frame[:observations] = if alternative_count == 1
71
+ [[0, true]]
72
+ else
73
+ [[0, index.zero?], [1, index == 1]]
74
+ end
75
+ frame[:outcome] = true
76
+ frame[:finished] = true
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Branchproof
4
+ # Observe entry into source-visible callback bodies, not library internals.
5
+ module IterationSyntax
6
+ ITERATORS = %i[each each_with_index times upto downto step map collect select filter reject filter_map
7
+ find detect find_index take_while drop_while partition count all? any? none? one? fetch].freeze
8
+
9
+ private
10
+
11
+ def flow_decision_node?(node)
12
+ iteration_node?(node) || node.is_a?(Prism::MatchRequiredNode) || super
13
+ end
14
+
15
+ def iteration_node?(node)
16
+ node.is_a?(Prism::ForNode) ||
17
+ (node.is_a?(Prism::CallNode) && ITERATORS.include?(node.name) &&
18
+ node.block.is_a?(Prism::BlockNode) && !node.safe_navigation?)
19
+ end
20
+
21
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
22
+ def flow_details(node, bytes, encoding)
23
+ if node.is_a?(Prism::MatchRequiredNode)
24
+ alternatives = %w[matched mismatch].map { |label| iteration_alternative(node, label) }
25
+ return ["pattern", "required_pattern", alternatives,
26
+ { type: "required_pattern", range: byte_range(node.location) }, unsupported_reasons(node, bytes)]
27
+ end
28
+ return super unless iteration_node?(node)
29
+
30
+ body = node.is_a?(Prism::ForNode) ? node.statements : node.block.body
31
+ closing = node.is_a?(Prism::ForNode) ? node.end_keyword_loc : node.block.closing_loc
32
+ lazy = node.is_a?(Prism::CallNode) && lazy_receiver?(node.receiver)
33
+ fallback = node.is_a?(Prism::CallNode) && node.name == :fetch
34
+ context = if lazy
35
+ "lazy_callback"
36
+ else
37
+ (fallback ? "fetch_fallback" : "iteration")
38
+ end
39
+ labels = if lazy
40
+ ["callback entered"]
41
+ else
42
+ (fallback ? ["value present", "fallback entered"] : %w[empty entered])
43
+ end
44
+ metadata = { type: "iteration", range: byte_range(node.location), lazy: lazy,
45
+ insert_at: iteration_insert_at(body, closing),
46
+ empty: body.nil? }
47
+ metadata[:receiver] = byte_range(node.receiver.location) if node.is_a?(Prism::CallNode) && node.receiver
48
+ [lazy ? "multiway" : "implicit", context, labels.map { |label| iteration_alternative(node, label) },
49
+ metadata, unsupported_reasons(node, bytes)]
50
+ end
51
+
52
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
53
+
54
+ def lazy_receiver?(node)
55
+ node.is_a?(Prism::CallNode) && (node.name == :lazy || lazy_receiver?(node.receiver))
56
+ end
57
+
58
+ # Prism gives a block-level rescue/ensure BeginNode a location beginning at
59
+ # the `do` keyword. Inserting there would produce `call; callback; do`, so
60
+ # anchor the callback after the block parameters and before its statements.
61
+ def iteration_insert_at(body, closing)
62
+ return closing.start_offset unless body
63
+
64
+ if body.is_a?(Prism::BeginNode)
65
+ statements = body.statements
66
+ return statements.location.start_offset if statements&.location
67
+ end
68
+ body.location.start_offset
69
+ end
70
+
71
+ def iteration_alternative(node, label)
72
+ { expression: label, byte_start: node.location.start_offset, byte_length: node.location.length }
73
+ end
74
+ end
75
+ end
@@ -47,7 +47,12 @@ module Branchproof
47
47
  add_diagnostic("source_drift", "selected source changed after inventory", unit[:source_id])
48
48
  return nil
49
49
  end
50
- rewritten = @instrumenter.rewrite(unit: unit.merge(original_bytes: bytes))
50
+ begin
51
+ rewritten = @instrumenter.rewrite(unit: unit.merge(original_bytes: bytes))
52
+ rescue StandardError => e
53
+ add_diagnostic("rewrite_failure", "#{e.class}: #{e.message}", unit[:source_id], severity: "error")
54
+ return nil
55
+ end
51
56
  Array(rewritten[:diagnostics]).each do |diagnostic|
52
57
  add_diagnostic(diagnostic[:code], diagnostic[:message], unit[:source_id], severity: diagnostic[:severity])
53
58
  end
@@ -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?
@@ -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
@@ -118,6 +118,16 @@ module Branchproof
118
118
  0
119
119
  end
120
120
 
121
+ def rerun_command(test_id)
122
+ test = test_records_by_id[test_id.to_s]
123
+ return unless test && value(test, :adapter).to_s == "rspec"
124
+
125
+ selector = value(test, :example_id).to_s
126
+ return if selector.empty?
127
+
128
+ "bundle exec rspec '#{selector.gsub("'", %q('"'"'))}'"
129
+ end
130
+
121
131
  private
122
132
 
123
133
  def json_document
@@ -507,10 +517,17 @@ module Branchproof
507
517
  end
508
518
 
509
519
  def baseline_test_counts
520
+ noun = rspec_run? ? "examples" : "tests"
510
521
  executed = value(@baseline, :executed_tests)
511
522
  failed = value(@baseline, :failed_tests) || value(@baseline, :failures) || 0
512
523
  skipped = value(@baseline, :skipped_tests) || value(@baseline, :skips) || 0
513
- "#{executed || 0} tests, #{failed} failed, #{skipped} skipped"
524
+ "#{executed || 0} #{noun}, #{failed} failed, #{skipped} skipped"
525
+ end
526
+
527
+ def rspec_run?
528
+ framework = value(@run_metadata, :framework) || value(@run_metadata, :project_framework) ||
529
+ value(@baseline, :framework)
530
+ framework.to_s.downcase == "rspec"
514
531
  end
515
532
 
516
533
  def terminal_coverage_label
@@ -584,6 +601,9 @@ module Branchproof
584
601
  end
585
602
  return short_id(id) if name.empty? || name == id
586
603
 
604
+ command = rerun_command(id)
605
+ return "#{name} (rerun: #{command})" if command
606
+
587
607
  duplicates = test_records_by_name[name]
588
608
  return name if duplicates.length == 1
589
609