rspec-signal 0.1.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 (44) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +68 -0
  3. data/LICENSE +21 -0
  4. data/README.md +672 -0
  5. data/exe/rspec-signal +11 -0
  6. data/exe/rspec-signal-parallel +52 -0
  7. data/lib/rspec/signal/backtrace/classifier.rb +88 -0
  8. data/lib/rspec/signal/backtrace/frame.rb +31 -0
  9. data/lib/rspec/signal/backtrace/parser.rb +61 -0
  10. data/lib/rspec/signal/backtrace/reducer.rb +244 -0
  11. data/lib/rspec/signal/cluster.rb +68 -0
  12. data/lib/rspec/signal/clusterer.rb +49 -0
  13. data/lib/rspec/signal/configuration.rb +144 -0
  14. data/lib/rspec/signal/failure.rb +62 -0
  15. data/lib/rspec/signal/failure_builder.rb +224 -0
  16. data/lib/rspec/signal/fingerprint.rb +56 -0
  17. data/lib/rspec/signal/formatter.rb +207 -0
  18. data/lib/rspec/signal/group.rb +61 -0
  19. data/lib/rspec/signal/grouper.rb +28 -0
  20. data/lib/rspec/signal/html_summary.rb +224 -0
  21. data/lib/rspec/signal/integrations/capybara.rb +102 -0
  22. data/lib/rspec/signal/message.rb +161 -0
  23. data/lib/rspec/signal/parallel_merger.rb +125 -0
  24. data/lib/rspec/signal/parallel_run.rb +56 -0
  25. data/lib/rspec/signal/project.rb +158 -0
  26. data/lib/rspec/signal/redactor.rb +91 -0
  27. data/lib/rspec/signal/report.rb +88 -0
  28. data/lib/rspec/signal/reporters/full_output.rb +38 -0
  29. data/lib/rspec/signal/reporters/json_report.rb +20 -0
  30. data/lib/rspec/signal/reporters/markdown.rb +271 -0
  31. data/lib/rspec/signal/reporters/related_failures.rb +101 -0
  32. data/lib/rspec/signal/symptom.rb +22 -0
  33. data/lib/rspec/signal/symptoms/exception_class.rb +46 -0
  34. data/lib/rspec/signal/symptoms/http_status.rb +98 -0
  35. data/lib/rspec/signal/symptoms/record.rb +56 -0
  36. data/lib/rspec/signal/symptoms/route.rb +45 -0
  37. data/lib/rspec/signal/symptoms/ruby_error.rb +55 -0
  38. data/lib/rspec/signal/symptoms/selector.rb +72 -0
  39. data/lib/rspec/signal/symptoms.rb +42 -0
  40. data/lib/rspec/signal/version.rb +7 -0
  41. data/lib/rspec/signal/writer.rb +88 -0
  42. data/lib/rspec/signal.rb +156 -0
  43. data/lib/rspec-signal.rb +3 -0
  44. metadata +117 -0
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "English"
5
+ require "fileutils"
6
+ require "securerandom"
7
+ require "tmpdir"
8
+
9
+ begin
10
+ parallel_rspec = Gem.bin_path("parallel_tests", "parallel_rspec")
11
+ rescue Gem::GemNotFoundException
12
+ warn "rspec-signal: parallel_tests is required; add gem \"parallel_tests\" to your test bundle"
13
+ exit 2
14
+ end
15
+
16
+ run_id = "#{Time.now.utc.strftime("%Y%m%dT%H%M%S")}-#{SecureRandom.hex(6)}"
17
+ registry = Dir.mktmpdir("rspec-signal-#{run_id}")
18
+ lib = File.expand_path("../lib", __dir__)
19
+ ENV["RUBYLIB"] = [lib, ENV.fetch("RUBYLIB", nil)].compact.join(File::PATH_SEPARATOR)
20
+ ENV["RSPEC_SIGNAL_RUN_ID"] = run_id
21
+ ENV["RSPEC_SIGNAL_RUN_REGISTRY"] = registry
22
+ ENV["RSPEC_SIGNAL_QUIET"] = "1"
23
+ ENV["PARALLEL_TEST_FIRST_IS_1"] = "true"
24
+ signal_options = "--require rspec/signal --format RSpec::Signal::Formatter"
25
+ ENV["SPEC_OPTS"] = [ENV.fetch("SPEC_OPTS", nil), signal_options].compact.join(" ")
26
+
27
+ system(Gem.ruby, parallel_rspec, *ARGV)
28
+ test_status = $CHILD_STATUS.exitstatus || 1
29
+
30
+ begin
31
+ require "rspec/signal"
32
+ result = RSpec::Signal::ParallelMerger.new(registry: registry).call
33
+ report = result.report
34
+ puts
35
+ puts "#{report.example_count} examples, #{report.failure_count} failures across #{result.workers} workers"
36
+ puts "rspec-signal: #{report.group_count} exact signatures, #{report.cluster_count} related clusters, " \
37
+ "#{report.omitted_frames} backtrace frames omitted"
38
+ if result.write_result.summary_path
39
+ puts "Report: #{RSpec::Signal::Writer.new(RSpec::Signal.configuration).relative(result.write_result.summary_path)}"
40
+ end
41
+ if result.missing.any?
42
+ warn "rspec-signal: warning: #{result.missing.size} worker artifacts were missing; report is incomplete"
43
+ test_status = 1
44
+ end
45
+ rescue StandardError => e
46
+ warn "rspec-signal: parallel report aggregation failed (#{e.class}: #{e.message})"
47
+ test_status = 1 if test_status.zero?
48
+ ensure
49
+ FileUtils.rm_rf(registry)
50
+ end
51
+
52
+ exit test_status
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Signal
5
+ module Backtrace
6
+ # Decides whether a frame is project code, meaningful library code, or
7
+ # pure test-runner / CLI plumbing.
8
+ #
9
+ # The framework list is deliberately narrow: it contains only code that
10
+ # *runs* tests, loads files, or dispatches a CLI. Application libraries
11
+ # (ActiveRecord, Capybara, Rack, Net::HTTP, ...) are never framework, even
12
+ # though most of their frames still get collapsed by the reducer — they can
13
+ # legitimately explain what operation failed.
14
+ class Classifier
15
+ DEFAULT_FRAMEWORK_PATTERNS = [
16
+ # RSpec itself, however it is vendored
17
+ %r{/lib/rspec/(?:core|support|expectations|mocks|matchers|rails|its|retry)[./]},
18
+ %r{/rspec-(?:core|support|expectations|mocks|rails|its|retry)-[^/]+/},
19
+ %r{/lib/rspec/signal[./]},
20
+ %r{/lib/rspec_junit_formatter[./]},
21
+ # Bundler / RubyGems / CLI plumbing
22
+ %r{/lib/bundler/},
23
+ %r{/bundler-[^/]+/},
24
+ %r{/lib/rubygems/},
25
+ %r{/rubygems/core_ext/kernel_require\.rb},
26
+ %r{/bundled_gems\.rb},
27
+ %r{/thor-[^/]+/}, %r{/lib/thor/},
28
+ %r{/rake-[^/]+/}, %r{/lib/rake/},
29
+ # Ruby internals
30
+ /\A<internal:/,
31
+ %r{/lib/ruby/[^/]+/bundled_gems\.rb},
32
+ # Other test-runner plumbing
33
+ %r{/minitest[-/]}, %r{/lib/minitest/},
34
+ %r{/spring-[^/]+/}, %r{/simplecov[-/]}, %r{/simplecov-html[-/]},
35
+ %r{/parallel_tests-[^/]+/}, %r{/knapsack[-_]},
36
+ %r{/railties-[^/]+/lib/rails/(?:commands|test_unit|app_loader)},
37
+ %r{/lib/rails/(?:commands|test_unit|app_loader)}
38
+ ].freeze
39
+
40
+ # Checked *before* first-party detection. Binstubs live inside the
41
+ # project but are still pure plumbing, and anything a user adds here is
42
+ # taken as authoritative about their own repository.
43
+ DEFAULT_IGNORE_PATTERNS = [
44
+ %r{(?:\A|/)(?:bin|exe|\.bundle/bin)/(?:rspec|bundle|rake|spring)(?:\.\w+)?\z},
45
+ # Ruby pseudo-frames: the `-e` script, eval, irb, VM internals.
46
+ /\A-e\z/,
47
+ /\A\((?:eval|irb)[^)]*\)\z/,
48
+ /\A<internal:/
49
+ ].freeze
50
+
51
+ def initialize(project:, framework_patterns: DEFAULT_FRAMEWORK_PATTERNS,
52
+ ignore_patterns: DEFAULT_IGNORE_PATTERNS)
53
+ @project = project
54
+ @framework_patterns = framework_patterns
55
+ @ignore_patterns = ignore_patterns
56
+ end
57
+
58
+ # Mutates the frame in place with :kind, :display and :gem_name.
59
+ def call(frame)
60
+ absolute = @project.absolutize(frame.path)
61
+ frame.display_path = @project.display_path(frame.path)
62
+ frame.gem_name = @project.gem_name(frame.path)
63
+ frame.kind = classify(frame, absolute)
64
+ frame
65
+ end
66
+
67
+ private
68
+
69
+ # First-party beats the built-in framework list. Code inside the
70
+ # repository is always something the agent can open and fix, and a
71
+ # project directory can easily contain a substring that looks like a gem
72
+ # name -- a checkout at ~/src/rspec-signal-demo must not have every one
73
+ # of its own frames thrown away.
74
+ def classify(frame, absolute)
75
+ return :framework if matches?(@ignore_patterns, absolute, frame.path)
76
+ return :project if @project.first_party?(frame.path)
77
+ return :framework if matches?(@framework_patterns, absolute, frame.path)
78
+
79
+ :external
80
+ end
81
+
82
+ def matches?(patterns, absolute, path)
83
+ patterns.any? { |pattern| pattern.match?(absolute) || pattern.match?(path) }
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Signal
5
+ module Backtrace
6
+ # One parsed backtrace line.
7
+ #
8
+ # `kind` is one of:
9
+ # :project - first-party code the agent can open and edit
10
+ # :external - third-party library code (may explain the failing operation)
11
+ # :framework - test-runner / loader / CLI plumbing that never explains a bug
12
+ Frame = Struct.new(:raw, :path, :line, :label, :kind, :display_path, :gem_name, keyword_init: true) do
13
+ def project? = kind == :project
14
+ def external? = kind == :external
15
+ def framework? = kind == :framework
16
+
17
+ def location
18
+ line ? "#{display_path}:#{line}" : display_path
19
+ end
20
+
21
+ def to_s
22
+ label && !label.empty? ? "#{location} in `#{label}'" : location
23
+ end
24
+
25
+ def to_h
26
+ { location: location, label: label, kind: kind.to_s, gem: gem_name }.compact
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "frame"
4
+
5
+ module RSpec
6
+ module Signal
7
+ module Backtrace
8
+ # Turns raw backtrace strings into {Frame}s.
9
+ #
10
+ # Handles the two shapes Ruby emits:
11
+ # "/path/to/file.rb:12:in `method'"
12
+ # "/path/to/file.rb:12:in 'Klass#method'" (Ruby 3.4+ quoting)
13
+ # and the shapes RSpec emits after its own filtering:
14
+ # "./spec/models/user_spec.rb:12"
15
+ module Parser
16
+ LINE = /
17
+ \A
18
+ (?<path>.*?)
19
+ (?::(?<line>\d+))?
20
+ (?::in\s+[`'](?<label>.*)['`])?
21
+ \s*\z
22
+ /x
23
+
24
+ module_function
25
+
26
+ # @param backtrace [Array<String>, nil]
27
+ # @param classifier [#call] receives a Frame with :kind unset, returns the kind
28
+ # @return [Array<Frame>]
29
+ def parse(backtrace, classifier)
30
+ Array(backtrace).filter_map do |raw|
31
+ frame = parse_line(raw)
32
+ next unless frame
33
+
34
+ classifier.call(frame)
35
+ frame
36
+ end
37
+ end
38
+
39
+ def parse_line(raw)
40
+ text = raw.to_s.strip
41
+ return nil if text.empty?
42
+
43
+ match = LINE.match(text)
44
+ return nil unless match
45
+
46
+ path = match[:path].to_s.sub(%r{\A\./}, "")
47
+ return nil if path.empty?
48
+
49
+ Frame.new(
50
+ raw: text,
51
+ path: path,
52
+ line: match[:line]&.to_i,
53
+ label: match[:label],
54
+ kind: :external,
55
+ display_path: path
56
+ )
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "frame"
4
+
5
+ module RSpec
6
+ module Signal
7
+ module Backtrace
8
+ # A gap standing in for frames that were dropped.
9
+ Gap = Struct.new(:count, :kind, keyword_init: true) do # rubocop:disable Lint/StructNewOverride
10
+ def frame? = false
11
+
12
+ def to_s
13
+ noun = kind == :framework ? "framework/runtime" : "library"
14
+ "[#{count} #{noun} frame#{"s" unless count == 1} omitted]"
15
+ end
16
+
17
+ def to_h = { omitted: count, kind: kind.to_s }
18
+ end
19
+
20
+ # Frames and gaps are rendered side by side, so both answer `frame?`.
21
+ class Frame
22
+ def frame? = true
23
+ end
24
+
25
+ # The result of reducing one backtrace.
26
+ class Reduced
27
+ attr_reader :entries, :total, :omitted, :fallback
28
+
29
+ def initialize(entries:, total:, omitted:, fallback: false)
30
+ @entries = entries
31
+ @total = total
32
+ @omitted = omitted
33
+ @fallback = fallback
34
+ end
35
+
36
+ def frames = entries.select(&:frame?)
37
+ def project_frames = frames.select(&:project?)
38
+ def kept_count = frames.size
39
+ def fallback? = @fallback
40
+ def empty? = frames.empty?
41
+
42
+ def omitted_count = omitted.values.sum
43
+
44
+ # The frame that best identifies where this blew up: the innermost frame
45
+ # that is not test-runner plumbing.
46
+ def culprit = frames.first
47
+
48
+ # The innermost first-party frame — what the agent should open first.
49
+ def primary_location = project_frames.first
50
+
51
+ def to_a = entries.map(&:to_s)
52
+ end
53
+
54
+ # Reduces a full backtrace to the frames that carry diagnostic value.
55
+ #
56
+ # Rules, in order:
57
+ #
58
+ # 1. Consecutive repeats of the same file:line collapse into one.
59
+ # 2. Framework frames (test runner, loader, CLI) are always dropped.
60
+ # 3. Every first-party frame is kept.
61
+ # 4. A run of library frames that directly touches first-party code is
62
+ # partially kept: the library entry point the project called, the site
63
+ # that raised, and (budget permitting) their immediate neighbours.
64
+ # This is the "what operation failed" context.
65
+ # 5. Library frames that touch no first-party code at all are dropped.
66
+ # 6. If nothing survives, progressively fall back so that the report is
67
+ # never empty: innermost non-framework frames, then innermost frames.
68
+ class Reducer
69
+ # Relative value of each kind of frame. Anything scoring 0 is dropped.
70
+ SCORE_PROJECT = 100
71
+ SCORE_LIBRARY_ENTRY = 70 # last library frame before first-party code
72
+ SCORE_RAISE_SITE = 65 # innermost frame of an adjacent library run
73
+ SCORE_LIBRARY_NEAR = 40 # neighbours of the two above
74
+ SCORE_LIBRARY_CALLER = 60 # library frame that called into first-party code
75
+
76
+ # Labels that name no method: delegation shims and block frames.
77
+ ANONYMOUS_LABEL = /\A(?:block\s|<(?:top|module|class|main)\b|rescue in\b|ensure in\b)/
78
+
79
+ def initialize(max_frames: 12, max_external_context: 3, max_project_frames: 8, fallback_frames: 6)
80
+ @max_frames = max_frames
81
+ @max_external_context = max_external_context
82
+ @max_project_frames = max_project_frames
83
+ @fallback_frames = fallback_frames
84
+ end
85
+
86
+ # @param frames [Array<Frame>] innermost first, as Ruby emits them
87
+ # @return [Reduced]
88
+ def call(frames)
89
+ frames = collapse_repeats(Array(frames))
90
+ return Reduced.new(entries: [], total: 0, omitted: {}) if frames.empty?
91
+
92
+ scored = score(frames)
93
+ keep = select_keepers(scored)
94
+ return fallback(frames) if keep.empty?
95
+
96
+ build(frames, keep)
97
+ end
98
+
99
+ private
100
+
101
+ # Only *identical* frames collapse. A one-line method definition puts
102
+ # several different methods on the same file:line, and those are not
103
+ # repeats -- they are the call chain.
104
+ def collapse_repeats(frames)
105
+ frames.chunk_while { |a, b| a.path == b.path && a.line == b.line && a.label == b.label }.map do |chunk|
106
+ next chunk.first if chunk.size == 1
107
+
108
+ frame = chunk.first.dup
109
+ frame.label = "#{frame.label} (x#{chunk.size})"
110
+ frame
111
+ end
112
+ end
113
+
114
+ # @return [Hash<Integer, Integer>] frame index => score
115
+ #
116
+ # Adjacency is computed over the backtrace with framework frames removed,
117
+ # so a stray runner frame wedged between a library and the spec file does
118
+ # not hide the library context.
119
+ def score(frames)
120
+ scores = Hash.new(0)
121
+ significant = frames.each_index.reject { |i| frames[i].framework? }
122
+ project_indexes = significant.select { |i| frames[i].project? }
123
+
124
+ return scores if project_indexes.empty?
125
+
126
+ project_indexes.first(@max_project_frames).each { |i| scores[i] = SCORE_PROJECT }
127
+ scores[project_indexes.first] = SCORE_PROJECT + 5
128
+
129
+ positions = significant.each_with_index.to_h
130
+ external_runs(frames, significant).each do |run|
131
+ score_run(frames, run, scores, significant, positions)
132
+ end
133
+
134
+ scores
135
+ end
136
+
137
+ # Contiguous runs of :external frames within the significant subsequence,
138
+ # as arrays of original frame indexes.
139
+ def external_runs(frames, significant)
140
+ significant.chunk { |i| frames[i].external? }.filter_map { |external, run| run if external }
141
+ end
142
+
143
+ def score_run(frames, run, scores, significant, positions)
144
+ above, below = adjacency(frames, run, significant, positions)
145
+ return unless above || below
146
+
147
+ candidates(frames, run, above).each_with_index do |index, rank|
148
+ scores[index] = [scores[index], run_score(index, run, above, below, rank)].max
149
+ end
150
+ end
151
+
152
+ # Whether the run was called *by* first-party code (above) and/or calls
153
+ # *into* first-party code (below).
154
+ def adjacency(frames, run, significant, positions)
155
+ first_position = positions.fetch(run.first)
156
+ before = significant[first_position - 1] if first_position.positive?
157
+ after = significant[positions.fetch(run.last) + 1]
158
+
159
+ [after && frames[after].project?, before && frames[before].project?]
160
+ end
161
+
162
+ # The frames of this run worth keeping, best first.
163
+ def candidates(frames, run, above)
164
+ ranked = []
165
+ ranked << entry_point(frames, run) if above # library entry point
166
+ ranked << run.first # raise site, or caller of project code
167
+ ranked << run.last if above
168
+ ranked << run[1] if run.size > 1
169
+ ranked.compact.uniq.first(@max_external_context)
170
+ end
171
+
172
+ def run_score(index, run, above, below, rank)
173
+ return SCORE_LIBRARY_CALLER if below && index == run.first
174
+ return SCORE_RAISE_SITE if index == run.first
175
+ return SCORE_LIBRARY_ENTRY if above && rank.zero?
176
+
177
+ SCORE_LIBRARY_NEAR - rank
178
+ end
179
+
180
+ # The frame where project code entered the library. Scanning outwards in
181
+ # for the first *named* method skips delegation shims, whose labels are
182
+ # anonymous blocks and which say nothing about the failing operation:
183
+ # `capybara/dsl.rb:52 in block (2 levels) in <module:DSL>` is noise,
184
+ # `capybara/node/finders.rb:60 in find` is the answer.
185
+ def entry_point(frames, run)
186
+ run.reverse.find { |index| named_label?(frames[index].label) } || run.last
187
+ end
188
+
189
+ def named_label?(label)
190
+ text = label.to_s.strip
191
+ !text.empty? && !ANONYMOUS_LABEL.match?(text)
192
+ end
193
+
194
+ def select_keepers(scores)
195
+ positive = scores.select { |_, value| value.positive? }
196
+ positive.sort_by { |index, value| [-value, index] }.first(@max_frames).map(&:first).sort
197
+ end
198
+
199
+ def build(frames, keep)
200
+ entries = []
201
+ omitted = Hash.new(0)
202
+ pending = Hash.new(0)
203
+
204
+ frames.each_index do |i|
205
+ if keep.include?(i)
206
+ entries.concat(flush(pending))
207
+ entries << frames[i]
208
+ else
209
+ bucket = frames[i].framework? ? :framework : :external
210
+ pending[bucket] += 1
211
+ omitted[bucket] += 1
212
+ end
213
+ end
214
+ entries.concat(flush(pending))
215
+ # A gap before the first kept frame is always runner plumbing sitting
216
+ # above the real failure. The header already reports the totals.
217
+ entries.shift while entries.first.is_a?(Gap) && entries.first.kind == :framework
218
+
219
+ Reduced.new(entries: entries, total: frames.size, omitted: omitted)
220
+ end
221
+
222
+ # Gaps are emitted framework-last so the trailing note reads naturally.
223
+ def flush(pending)
224
+ %i[external framework].filter_map do |kind|
225
+ next if pending[kind].zero?
226
+
227
+ count = pending[kind]
228
+ pending[kind] = 0
229
+ Gap.new(count: count, kind: kind)
230
+ end
231
+ end
232
+
233
+ # Never return an empty trace: something is always better than nothing.
234
+ def fallback(frames)
235
+ keep = frames.each_index.reject { |i| frames[i].framework? }.first(@fallback_frames)
236
+ keep = frames.each_index.first(@fallback_frames) if keep.empty?
237
+
238
+ reduced = build(frames, keep)
239
+ Reduced.new(entries: reduced.entries, total: reduced.total, omitted: reduced.omitted, fallback: true)
240
+ end
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Signal
5
+ # A set of failures that share one diagnostic symptom.
6
+ #
7
+ # Weaker than a {Group} on purpose. A group asserts that its failures are
8
+ # the same failure; a cluster asserts only that they share a strong symptom
9
+ # and are worth looking at together. The report says so in those words, so
10
+ # nothing downstream mistakes one for the other.
11
+ class Cluster
12
+ Member = Struct.new(:failure, :detail)
13
+
14
+ attr_reader :symptom, :first_seen
15
+
16
+ def initialize(symptom:, first_seen:)
17
+ @symptom = symptom
18
+ @first_seen = first_seen
19
+ @members = []
20
+ end
21
+
22
+ def add(failure, symptom)
23
+ @members << Member.new(failure, symptom.detail)
24
+ self
25
+ end
26
+
27
+ def failures = @members.map(&:failure)
28
+ def size = @members.size
29
+ def kind = @symptom.kind
30
+ def key = @symptom.key
31
+ def label = @symptom.label
32
+
33
+ # The distinct wordings the symptom took, most common first, ties broken
34
+ # by the order they were seen so the list is stable.
35
+ def symptom_counts
36
+ @symptom_counts ||= begin
37
+ counts = @members.each_with_object(Hash.new(0)) { |member, tally| tally[member.detail] += 1 }
38
+ seen = counts.keys.each_with_index.to_h
39
+ counts.sort_by { |detail, count| [-count, seen[detail]] }.to_h
40
+ end
41
+ end
42
+
43
+ # The exact signatures this cluster spans. More than one is what makes a
44
+ # cluster worth printing at all.
45
+ def signatures
46
+ @signatures ||= failures.map { |failure| failure.fingerprint.digest }.uniq
47
+ end
48
+
49
+ def signature_count = signatures.size
50
+
51
+ def spec_files
52
+ @spec_files ||= failures.map { |failure| failure.spec_location.sub(/:\d+\z/, "") }.uniq
53
+ end
54
+
55
+ def to_h
56
+ {
57
+ kind: kind,
58
+ key: key,
59
+ label: label,
60
+ count: size,
61
+ signatures: signatures,
62
+ symptoms: symptom_counts,
63
+ specs: spec_files
64
+ }
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Signal
5
+ # Builds the related-failure clusters, and refuses to build most of them.
6
+ #
7
+ # Two rules do the work:
8
+ #
9
+ # * a cluster needs at least two failures, and
10
+ # * a cluster needs at least two *exact signatures*.
11
+ #
12
+ # The second is the important one. If every failure sharing a symptom is
13
+ # already the same signature, the signature section says it better and
14
+ # saying it twice is noise. A cluster therefore only ever appears when it
15
+ # tells the reader something the authoritative grouping could not: that
16
+ # failures RSpec, and rspec-signal, consider distinct nevertheless share a
17
+ # symptom.
18
+ #
19
+ # Ordering is deterministic -- biggest first, then most signatures spanned,
20
+ # then run order -- so two runs of the same suite produce the same report.
21
+ module Clusterer
22
+ MIN_FAILURES = 2
23
+ MIN_SIGNATURES = 2
24
+
25
+ module_function
26
+
27
+ # @param failures [Array<Failure>]
28
+ # @return [Array<Cluster>]
29
+ def call(failures)
30
+ clusters = {}
31
+
32
+ failures.each_with_index do |failure, index|
33
+ symptom = failure.symptom
34
+ next unless symptom
35
+
36
+ clusters[symptom.key] ||= Cluster.new(symptom: symptom, first_seen: index)
37
+ clusters[symptom.key].add(failure, symptom)
38
+ end
39
+
40
+ clusters.values.select { |cluster| worth_reporting?(cluster) }
41
+ .sort_by { |cluster| [-cluster.size, -cluster.signature_count, cluster.first_seen] }
42
+ end
43
+
44
+ def worth_reporting?(cluster)
45
+ cluster.size >= MIN_FAILURES && cluster.signature_count >= MIN_SIGNATURES
46
+ end
47
+ end
48
+ end
49
+ end