rspec-signal 0.2.0 → 0.3.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -0
  3. data/README.md +326 -38
  4. data/exe/rspec-signal +9 -0
  5. data/exe/rspec-signal-parallel +12 -5
  6. data/lib/rspec/signal/causal/analysis.rb +292 -0
  7. data/lib/rspec/signal/causal/census.rb +86 -0
  8. data/lib/rspec/signal/causal/entities.rb +67 -0
  9. data/lib/rspec/signal/causal/evidence.rb +144 -0
  10. data/lib/rspec/signal/causal/phase.rb +98 -0
  11. data/lib/rspec/signal/causal/scope_analysis.rb +80 -0
  12. data/lib/rspec/signal/code_paths.rb +101 -0
  13. data/lib/rspec/signal/comparison.rb +101 -0
  14. data/lib/rspec/signal/configuration.rb +18 -2
  15. data/lib/rspec/signal/failure.rb +26 -3
  16. data/lib/rspec/signal/failure_builder.rb +17 -2
  17. data/lib/rspec/signal/fingerprint.rb +18 -2
  18. data/lib/rspec/signal/formatter.rb +121 -69
  19. data/lib/rspec/signal/group.rb +20 -3
  20. data/lib/rspec/signal/history.rb +102 -0
  21. data/lib/rspec/signal/message.rb +54 -1
  22. data/lib/rspec/signal/outside_example.rb +148 -0
  23. data/lib/rspec/signal/parallel_merger.rb +67 -7
  24. data/lib/rspec/signal/parallel_run.rb +4 -3
  25. data/lib/rspec/signal/progress_bar.rb +48 -0
  26. data/lib/rspec/signal/report.rb +119 -23
  27. data/lib/rspec/signal/reporters/json_report.rb +3 -2
  28. data/lib/rspec/signal/reporters/markdown.rb +29 -21
  29. data/lib/rspec/signal/reporters/outside_examples.rb +101 -0
  30. data/lib/rspec/signal/reporters/relationships.rb +228 -0
  31. data/lib/rspec/signal/reporters/shared_code_paths.rb +86 -0
  32. data/lib/rspec/signal/rerun.rb +39 -0
  33. data/lib/rspec/signal/run_status.rb +40 -0
  34. data/lib/rspec/signal/selection.rb +130 -0
  35. data/lib/rspec/signal/terminal_summary.rb +119 -0
  36. data/lib/rspec/signal/version.rb +1 -1
  37. data/lib/rspec/signal/writer.rb +55 -18
  38. data/lib/rspec/signal.rb +18 -0
  39. metadata +20 -2
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Signal
5
+ module Causal
6
+ # Failure concentration: where the failures that nothing structural
7
+ # related are unusually dense. Evidence about *where*, never *why*, so it
8
+ # is kept apart from the causal analysis and never carries a confidence:
9
+ # "9 of 11 examples in this file failed" is exactly what it claims.
10
+ #
11
+ # It sees only what the causal pass left unrelated, and only the census.
12
+ class ScopeAnalysis
13
+ # The only thresholds in the relationship analysis. Internal constants
14
+ # while the layer is experimental, never configuration.
15
+ MIN_FAILURES = 3
16
+ INSIDE = Rational(2, 3) # at least this share of the scope failed
17
+ OUTSIDE = Rational(1, 20) # and less than this share of the rest did
18
+ DIMENSIONS = %w[file type].freeze
19
+
20
+ # @param residual [Array<Unit>] units no causal relation claimed
21
+ # @param census [Census, nil]
22
+ # @return [Array(Array<Relation>, Array<Unit>)] scope groups, and what is left
23
+ def self.call(residual, census)
24
+ return [[], residual] if census.nil? || census.empty?
25
+
26
+ new(census).call(residual)
27
+ end
28
+
29
+ def initialize(census)
30
+ @census = census
31
+ end
32
+
33
+ # Most specific scope first, until none qualifies.
34
+ def call(residual)
35
+ scoped = []
36
+ loop do
37
+ best = candidates(residual).min_by { |candidate| candidate.values_at(:run, :order, :value) }
38
+ break unless best
39
+
40
+ scoped << Relation.new(kind: :scope, units: best[:units], scope: best[:stats])
41
+ residual -= best[:units]
42
+ end
43
+ [scoped, residual]
44
+ end
45
+
46
+ private
47
+
48
+ def candidates(residual)
49
+ examples = residual.reject(&:outside)
50
+ DIMENSIONS.each_with_index.flat_map do |dimension, order|
51
+ @census.values(dimension).filter_map do |value|
52
+ inside = examples.select { |unit| within?(unit, dimension, value) }
53
+ stats = concentration(dimension, value)
54
+ next unless stats && inside.size >= 2 && inside.sum(&:size) >= MIN_FAILURES
55
+
56
+ { units: inside, stats: stats, run: stats["examples"], order: order, value: value }
57
+ end
58
+ end
59
+ end
60
+
61
+ def within?(unit, dimension, value)
62
+ evidence = unit.evidence
63
+ evidence.size == unit.size && evidence.all? { |item| item.public_send(dimension).to_s == value }
64
+ end
65
+
66
+ def concentration(dimension, value)
67
+ run, failed = @census.count(dimension, value)
68
+ elsewhere_run = @census.total - run
69
+ elsewhere_failed = @census.failed - failed
70
+ return nil if run.zero? || elsewhere_run < run
71
+ return nil if Rational(failed, run) < INSIDE
72
+ return nil unless Rational(elsewhere_failed, elsewhere_run) < OUTSIDE
73
+
74
+ { "dimension" => dimension, "value" => value, "failed" => failed, "examples" => run,
75
+ "failed_elsewhere" => elsewhere_failed, "examples_elsewhere" => elsewhere_run }
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Signal
5
+ # One line of first-party code that several distinct signatures run through.
6
+ class CodePath
7
+ attr_reader :location, :first_seen, :failures
8
+
9
+ def initialize(location:, first_seen:)
10
+ @location = location
11
+ @first_seen = first_seen
12
+ @failures = []
13
+ # Insertion-ordered set. A location crossed by hundreds of failures
14
+ # would otherwise rescan the whole list on every one of them.
15
+ @seen = {}
16
+ end
17
+
18
+ def add(failure)
19
+ @failures << failure
20
+ @seen[failure.fingerprint.digest] = true
21
+ self
22
+ end
23
+
24
+ # The distinct signatures crossing this line, in the order first seen.
25
+ def signatures
26
+ @seen.keys
27
+ end
28
+
29
+ def size
30
+ @failures.size
31
+ end
32
+
33
+ def signature_count
34
+ @seen.size
35
+ end
36
+
37
+ def file
38
+ @file ||= location.sub(/:\d+\z/, "")
39
+ end
40
+
41
+ # The method names the frame was seen under, most common first. Useful
42
+ # when one file:line is reached through several call paths.
43
+ def labels
44
+ @labels ||= @failures.flat_map { |failure| failure.application_frames.select { |f| f.location == location } }
45
+ .map(&:label).compact.reject(&:empty?).uniq
46
+ end
47
+
48
+ def to_h
49
+ { location: location, signatures: signatures, signature_count: signature_count,
50
+ examples: size, labels: labels.first(3) }
51
+ end
52
+ end
53
+
54
+ # Finds the first-party lines that more than one signature passes through.
55
+ #
56
+ # Every other clustering signal in this gem reads the failure *message*.
57
+ # This one reads the *stack*, which is the only thing that can relate a
58
+ # `KeyError` to the `expect { }.not_to raise_error` that swallowed it. Both
59
+ # traces run through `app/pricing.rb:4`; nothing about their messages says
60
+ # so.
61
+ #
62
+ # It claims nothing about causation. It reports a measured fact -- these N
63
+ # signatures execute this line -- and the report says it in those words.
64
+ #
65
+ # Two rules keep it honest, and they are the ones the symptom clusterer
66
+ # already uses:
67
+ #
68
+ # * only first-party frames outside the spec suite are indexed, and only
69
+ # the innermost few, because the outer end of a stack is shared
70
+ # plumbing (`application_controller.rb`) while the inner end is the bug;
71
+ # * a location is reported only when two or more *distinct signatures*
72
+ # cross it. One signature crossing it is already reported as that
73
+ # signature's app context, and repeating it would be noise.
74
+ module CodePaths
75
+ MIN_SIGNATURES = 2
76
+
77
+ # How far in from the raise site to look. Deep enough to pass through a
78
+ # couple of application layers, shallow enough to stay off the framework
79
+ # boundary.
80
+ DEFAULT_DEPTH = 5
81
+
82
+ module_function
83
+
84
+ # @param failures [Array<Failure>]
85
+ # @return [Array<CodePath>] most signatures spanned first; deterministic
86
+ def call(failures, depth: DEFAULT_DEPTH, min_signatures: MIN_SIGNATURES)
87
+ paths = {}
88
+
89
+ failures.each_with_index do |failure, index|
90
+ failure.application_frames(depth).map(&:location).uniq.each do |location|
91
+ paths[location] ||= CodePath.new(location: location, first_seen: index)
92
+ paths[location].add(failure)
93
+ end
94
+ end
95
+
96
+ paths.values.select { |path| path.signature_count >= min_signatures }
97
+ .sort_by { |path| [-path.signature_count, -path.size, path.first_seen] }
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Signal
5
+ # What changed between the previous run and this one.
6
+ #
7
+ # Four buckets, from two keys per signature:
8
+ #
9
+ # digest the full fingerprint (exception, message, culprit, app context)
10
+ # loose exception and message only -- independent of line numbers
11
+ #
12
+ # persistent paired entries with the same digest
13
+ # resolved unpaired old entries after loose matching
14
+ # new unpaired new entries after loose matching
15
+ # changed remaining old/new entries paired by loose key
16
+ #
17
+ # The fourth bucket is what makes the other three trustworthy. Editing the
18
+ # file that raises shifts every culprit line, which without it turns every
19
+ # persistent failure into a spurious resolved/new pair -- and an agent that
20
+ # reads "28 resolved" when it resolved nothing is worse off than one told
21
+ # nothing at all.
22
+ class Comparison
23
+ Change = Struct.new(:digest, :loose, :exception, :count, keyword_init: true) do # rubocop:disable Lint/StructNewOverride
24
+ def to_h
25
+ { signature: digest, exception: exception, examples: count }.compact
26
+ end
27
+ end
28
+
29
+ attr_reader :previous_run_id, :previous_at, :previous_failures, :failure_count,
30
+ :persistent, :resolved, :new_signatures, :changed
31
+
32
+ def initialize(previous:, current:)
33
+ @previous_run_id = previous["run_id"]
34
+ @previous_at = previous["at"]
35
+ @previous_failures = previous.fetch("failures", 0)
36
+ @failure_count = current.fetch("failures", 0)
37
+ classify(entries(previous), entries(current))
38
+ end
39
+
40
+ def any?
41
+ [persistent, resolved, new_signatures, changed].any? { |bucket| !bucket.empty? }
42
+ end
43
+
44
+ # Reads as a sentence and fits on one terminal line.
45
+ def headline
46
+ parts = []
47
+ parts << "#{resolved.size} resolved" unless resolved.empty?
48
+ parts << "#{new_signatures.size} new" unless new_signatures.empty?
49
+ parts << "#{persistent.size} persistent" unless persistent.empty?
50
+ parts << "#{changed.size} changed" unless changed.empty?
51
+ return nil if parts.empty?
52
+
53
+ "Signatures: #{parts.join(", ")}; failures: #{previous_failures} -> #{failure_count}"
54
+ end
55
+
56
+ def to_h
57
+ {
58
+ previous_run_id: previous_run_id,
59
+ previous_at: previous_at,
60
+ previous_failures: previous_failures,
61
+ resolved: resolved.map(&:to_h),
62
+ new: new_signatures.map(&:to_h),
63
+ persistent: persistent.map(&:to_h),
64
+ changed: changed.map(&:to_h)
65
+ }.compact
66
+ end
67
+
68
+ private
69
+
70
+ def entries(run)
71
+ Array(run["signatures"]).map do |item|
72
+ Change.new(digest: item["digest"], loose: item["loose"],
73
+ exception: item["exception"], count: item["count"])
74
+ end
75
+ end
76
+
77
+ # Pairing is cardinality-aware so signatures that share a loose key do
78
+ # not hide resolved or new failures. Hash-backed queues keep the work
79
+ # linear even for suites with many signatures.
80
+ def classify(before, after)
81
+ @persistent, gone, arrived = pair(before, after, :digest)
82
+ @changed, @resolved, @new_signatures = pair(gone, arrived, :loose)
83
+ end
84
+
85
+ def pair(before, after, attribute)
86
+ available = before.group_by { |entry| entry.public_send(attribute) }
87
+ matched_before = {}.compare_by_identity
88
+
89
+ matched_after, unmatched_after = after.partition do |entry|
90
+ match = available[entry.public_send(attribute)]&.shift
91
+ matched_before[match] = true if match
92
+ match
93
+ end
94
+
95
+ unmatched_before = before.reject { |entry| matched_before.key?(entry) }
96
+
97
+ [matched_after, unmatched_before, unmatched_after]
98
+ end
99
+ end
100
+ end
101
+ end
@@ -23,11 +23,23 @@ module RSpec
23
23
  # the budgets cap how much of it reaches the Markdown report.
24
24
  attr_accessor :relate_failures, :max_clusters, :max_cluster_specs
25
25
 
26
+ # Shared code-path analysis. `code_path_depth` is how far in from the
27
+ # raise site first-party frames are indexed; `max_code_paths` caps how
28
+ # many reach the Markdown report.
29
+ attr_accessor :code_path_depth, :max_code_paths
30
+
31
+ # Experimental relationships between signatures (causal / scope /
32
+ # independent groups). Off by default while it is being dogfooded; its
33
+ # thresholds are internal constants, not configuration.
34
+ # See docs/design/causal-failure-intelligence.md.
35
+ attr_accessor :causal_analysis
36
+
26
37
  # Secret scrubbing.
27
38
  attr_accessor :redact, :redaction_patterns, :redaction_filter
28
39
 
29
- # Artifacts.
30
- attr_accessor :write_json, :write_full, :write_gitignore
40
+ # Artifacts. `track_history` keeps a small record of recent runs beside
41
+ # them so a run can say what changed since the last one.
42
+ attr_accessor :write_json, :write_full, :write_gitignore, :track_history
31
43
 
32
44
  # Behaviour.
33
45
  attr_accessor :enabled, :terminal_summary, :capture_capybara, :capture_page_html
@@ -115,17 +127,21 @@ module RSpec
115
127
  @max_groups = nil
116
128
  @max_clusters = 10
117
129
  @max_cluster_specs = 6
130
+ @code_path_depth = CodePaths::DEFAULT_DEPTH
131
+ @max_code_paths = 5
118
132
  end
119
133
 
120
134
  def default_artifacts
121
135
  @reduce_html = true
122
136
  @relate_failures = true
137
+ @causal_analysis = false
123
138
  @redact = true
124
139
  @redaction_patterns = []
125
140
  @redaction_filter = nil
126
141
  @write_json = true
127
142
  @write_full = false
128
143
  @write_gitignore = true
144
+ @track_history = true
129
145
  @capture_capybara = true
130
146
  @capture_page_html = false
131
147
  end
@@ -9,9 +9,13 @@ module RSpec
9
9
  :exception_class, :message, :reduced, :frames,
10
10
  :diagnostics, :shared_group_locations, :raw
11
11
 
12
+ # Structural facts for the experimental relationship analysis
13
+ # ({Causal::Evidence}); nil when they could not be captured.
14
+ attr_accessor :evidence
15
+
12
16
  def initialize(description:, spec_location:, exception_class:, message:, reduced:, frames:,
13
17
  rerun: nil, example_id: nil, diagnostics: {}, shared_group_locations: [],
14
- raw: nil, fingerprint: nil)
18
+ raw: nil, fingerprint: nil, evidence: nil)
15
19
  @description = description
16
20
  @spec_location = spec_location
17
21
  @exception_class = exception_class
@@ -24,6 +28,22 @@ module RSpec
24
28
  @shared_group_locations = shared_group_locations
25
29
  @raw = raw
26
30
  @fingerprint = fingerprint
31
+ @evidence = evidence
32
+ end
33
+
34
+ # The argument that reruns exactly this example and nothing else.
35
+ # RSpec's example id when we have one; the location, which selects every
36
+ # example defined on that line, only as a fallback.
37
+ def rerun_argument
38
+ example_id || rerun
39
+ end
40
+
41
+ # The first-party frames worth attributing this failure to, innermost
42
+ # first and excluding the spec suite itself. This is what the
43
+ # cross-signature code-path analysis indexes on.
44
+ def application_frames(limit = nil)
45
+ frames = reduced.project_frames.reject { |frame| Fingerprint.spec_frame?(frame) }
46
+ limit ? frames.first(limit) : frames
27
47
  end
28
48
 
29
49
  def fingerprint
@@ -43,17 +63,20 @@ module RSpec
43
63
  @symptom = Symptoms.for(self)
44
64
  end
45
65
 
46
- def to_h
66
+ # @param max_message_lines [Integer] mirrors `config.max_message_lines`
67
+ # @param max_diff_lines [Integer] mirrors `config.max_diff_lines`
68
+ def to_h(max_message_lines: 30, max_diff_lines: 20)
47
69
  {
48
70
  description: description,
49
71
  location: spec_location,
50
72
  rerun: rerun,
51
73
  id: example_id,
52
74
  exception: exception_class,
53
- message: message.body,
75
+ message: message.body(max_lines: max_message_lines, max_diff_lines: max_diff_lines),
54
76
  trace: reduced.entries.map(&:to_h),
55
77
  omitted_frames: reduced.omitted_count,
56
78
  diagnostics: diagnostics,
79
+ shared_groups: shared_group_locations,
57
80
  symptom: symptom&.to_h
58
81
  }.reject { |_, value| value.nil? || (value.respond_to?(:empty?) && value.empty?) }
59
82
  end
@@ -25,16 +25,18 @@ module RSpec
25
25
  exception = notification.exception
26
26
  extra = Array(example.metadata[:extra_failure_lines])
27
27
  frames = Backtrace::Parser.parse(backtrace_for(exception), @config.classifier)
28
+ message = message_for(notification, exception, extra)
28
29
 
29
30
  Failure.new(
30
31
  **identity(example),
31
32
  exception_class: exception_class_name(exception),
32
- message: message_for(notification, exception, extra),
33
+ message: message,
33
34
  reduced: @config.reducer.call(frames),
34
35
  frames: frames,
35
36
  diagnostics: diagnostics(example, extra),
36
37
  shared_group_locations: shared_group_locations(example),
37
- raw: raw_output(notification, position)
38
+ raw: raw_output(notification, position),
39
+ evidence: evidence_for(exception, frames, example)
38
40
  )
39
41
  end
40
42
 
@@ -49,6 +51,19 @@ module RSpec
49
51
  }
50
52
  end
51
53
 
54
+ # Read from the full backtrace, before reduction discards the runner
55
+ # frames that say which phase failed. Experimental, and never allowed to
56
+ # cost the report.
57
+ def evidence_for(exception, frames, example)
58
+ return nil unless @config.causal_analysis
59
+
60
+ primary = sub_exceptions(exception).first || exception
61
+ @identities ||= {}.compare_by_identity
62
+ Causal::Capture.call(primary, frames: frames, config: @config, identities: @identities, example: example)
63
+ rescue StandardError
64
+ nil
65
+ end
66
+
52
67
  def message_for(notification, exception, extra)
53
68
  appended = extra + shared_group_descriptions(notification.example)
54
69
  Message.new(message_lines_for(notification, appended),
@@ -31,7 +31,7 @@ module RSpec
31
31
  significant = frames.reject(&:framework?)
32
32
  culprit = significant.first
33
33
  app_context = significant.find do |frame|
34
- frame.project? && spec_patterns.none? { |pattern| pattern.match?(frame.display_path) }
34
+ frame.project? && !spec_frame?(frame, spec_patterns)
35
35
  end
36
36
 
37
37
  new(
@@ -42,6 +42,21 @@ module RSpec
42
42
  )
43
43
  end
44
44
 
45
+ # Whether a frame belongs to the spec suite rather than the application.
46
+ # Shared by fingerprinting and by the code-path analysis, which both need
47
+ # to look past the example that happened to trip over the bug.
48
+ def self.spec_frame?(frame, spec_patterns = DEFAULT_SPEC_PATTERNS)
49
+ spec_patterns.any? { |pattern| pattern.match?(frame.display_path) }
50
+ end
51
+
52
+ # A line-number-independent identity: the exception and its normalized
53
+ # message, without the frames. Two runs of the same broken code report
54
+ # the same digest even after an edit shifts the raise site, which is what
55
+ # lets a run comparison tell "moved" apart from "fixed, and a new one".
56
+ def loose_digest
57
+ @loose_digest ||= Digest::SHA256.hexdigest([exception_class, message].join("\0"))[0, 12]
58
+ end
59
+
45
60
  # Joined on NUL so that shifting text from one component into the next
46
61
  # cannot produce the same digest.
47
62
  def digest
@@ -49,7 +64,8 @@ module RSpec
49
64
  end
50
65
 
51
66
  def to_h
52
- { exception: exception_class, culprit: culprit, app_context: app_context, digest: digest }.compact
67
+ { exception: exception_class, culprit: culprit, app_context: app_context, digest: digest,
68
+ loose: loose_digest }.compact
53
69
  end
54
70
  end
55
71
  end