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,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module RSpec
7
+ module Signal
8
+ # Rehydrates worker JSON and performs grouping once, over the complete run.
9
+ class ParallelMerger
10
+ Result = Struct.new(:report, :workers, :missing, :write_result, keyword_init: true)
11
+
12
+ def initialize(registry:, config: RSpec::Signal.configuration)
13
+ @registry = registry
14
+ @config = config
15
+ end
16
+
17
+ def call
18
+ paths = Dir[File.join(@registry, "*.path")].map { |file| File.read(file).strip }
19
+ documents, missing = paths.partition { |path| File.file?(path) }
20
+ payloads = documents.map { |path| JSON.parse(File.read(path)) }
21
+ validate_configuration!(payloads)
22
+ apply_configuration(payloads.first&.fetch("configuration", {}) || {})
23
+ report = aggregate(payloads)
24
+ result = Result.new(
25
+ report: report,
26
+ workers: payloads.size,
27
+ missing: missing,
28
+ write_result: Writer.new(@config).write(report)
29
+ )
30
+ # Only once we have successfully produced a report: a worker JSON that
31
+ # failed to parse should stay on disk for a human to look at, not be
32
+ # deleted along with everything else.
33
+ cleanup_worker_artifacts(paths)
34
+ result
35
+ end
36
+
37
+ private
38
+
39
+ # Worker payloads are per-run (`workers/<run-id>/<worker-id>/signal.json`),
40
+ # so once merged into the top-level report they would otherwise sit in
41
+ # the project forever: a green run's own hygiene guarantee (stale
42
+ # artifacts never survive a passing suite) does not reach them, and each
43
+ # payload can carry the full unreduced output of every failure.
44
+ def cleanup_worker_artifacts(paths)
45
+ run_directories(paths).each { |directory| FileUtils.rm_rf(directory) }
46
+ end
47
+
48
+ def run_directories(paths)
49
+ paths.filter_map { |path| File.dirname(path, 2) if path.include?("/workers/") }.uniq
50
+ end
51
+
52
+ def aggregate(payloads)
53
+ summaries = payloads.map { |payload| payload.fetch("summary", {}) }
54
+ Report.new(
55
+ failures: load_failures(payloads),
56
+ example_count: sum(summaries, "examples"), failure_count: sum(summaries, "failures"),
57
+ pending_count: sum(summaries, "pending"),
58
+ duration: summaries.filter_map { |item| item["duration_seconds"] }.max,
59
+ environment: payloads.first&.fetch("environment", {}) || {},
60
+ errors_outside_examples: sum(summaries, "errors_outside_examples"), relate_failures: @config.relate_failures
61
+ )
62
+ end
63
+
64
+ def load_failures(payloads)
65
+ payloads.flat_map do |payload|
66
+ payload.fetch("failures", []).map { |failure| load_failure(failure) }
67
+ end
68
+ end
69
+
70
+ def load_failure(data)
71
+ entries = data.fetch("trace", []).map { |entry| load_entry(entry) }
72
+ frames = entries.select(&:frame?)
73
+ reduced = Backtrace::Reduced.new(entries: entries, total: frames.size + data.fetch("omitted_frames", 0),
74
+ omitted: { serialized: data.fetch("omitted_frames", 0) })
75
+ fingerprint = data["fingerprint"] || {}
76
+ Failure.new(**failure_attributes(data, reduced, frames), fingerprint: load_fingerprint(fingerprint))
77
+ end
78
+
79
+ def failure_attributes(data, reduced, frames)
80
+ { description: data.fetch("description"), spec_location: data.fetch("location"),
81
+ rerun: data["rerun"], example_id: data["id"], exception_class: data.fetch("exception"),
82
+ message: load_message(data.fetch("message", [])), reduced: reduced, frames: frames,
83
+ diagnostics: data.fetch("diagnostics", {}), raw: data["raw"] }
84
+ end
85
+
86
+ def load_fingerprint(data)
87
+ Fingerprint.new(exception_class: data["exception"], message: "worker", culprit: data["culprit"],
88
+ app_context: data["app_context"]).tap do |fingerprint|
89
+ fingerprint.instance_variable_set(:@digest, data["digest"])
90
+ end
91
+ end
92
+
93
+ def load_entry(entry)
94
+ return Backtrace::Gap.new(count: entry.fetch("omitted"), kind: entry.fetch("kind").to_sym) if entry["omitted"]
95
+
96
+ location = entry.fetch("location")
97
+ match = location.match(/\A(.+):(\d+)\z/)
98
+ path, line = match ? match.captures : [location, nil]
99
+ Backtrace::Frame.new(raw: location, path: path, display_path: path, line: line&.to_i,
100
+ label: entry["label"], kind: entry.fetch("kind").to_sym, gem_name: entry["gem"])
101
+ end
102
+
103
+ def load_message(lines)
104
+ Message.new(lines, redactor: @config.redactor, project: @config.project, html_threshold: nil)
105
+ end
106
+
107
+ def sum(items, key)
108
+ number = items.sum { |item| item.fetch(key, 0).to_f }
109
+ (number % 1).zero? ? number.to_i : number
110
+ end
111
+
112
+ def validate_configuration!(payloads)
113
+ configurations = payloads.map { |payload| payload.fetch("configuration", {}) }.uniq
114
+ return if configurations.size <= 1
115
+
116
+ raise ArgumentError, "workers reported inconsistent rspec-signal configuration"
117
+ end
118
+
119
+ def apply_configuration(values)
120
+ values.each { |name, value| @config.public_send("#{name}=", value) if @config.respond_to?("#{name}=") }
121
+ @config.reset_memoized!
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module RSpec
7
+ module Signal
8
+ # Filesystem protocol shared by parallel_tests workers and the parent merger.
9
+ module ParallelRun
10
+ RUN_ID = "RSPEC_SIGNAL_RUN_ID"
11
+ REGISTRY = "RSPEC_SIGNAL_RUN_REGISTRY"
12
+
13
+ module_function
14
+
15
+ def worker?
16
+ !ENV[RUN_ID].to_s.empty? && ENV.key?("TEST_ENV_NUMBER")
17
+ end
18
+
19
+ def worker_id
20
+ value = ENV.fetch("TEST_ENV_NUMBER", "").to_s
21
+ value.empty? ? "1" : value
22
+ end
23
+
24
+ def write_worker(report, config)
25
+ directory = File.join(config.output_path, "workers", ENV.fetch(RUN_ID), worker_id)
26
+ FileUtils.mkdir_p(directory)
27
+ path = File.join(directory, "signal.json")
28
+ payload = report.worker_h(write_full: config.write_full)
29
+ .merge(worker: worker_id, configuration: configuration_h(config))
30
+ atomic_write(path, "#{JSON.pretty_generate(payload)}\n")
31
+ register(path)
32
+ end
33
+
34
+ def configuration_h(config)
35
+ %i[output_dir project_root max_frames max_external_context max_project_frames fallback_frames
36
+ max_message_lines max_diff_lines reduce_html max_html_chars max_affected_examples max_groups relate_failures
37
+ max_clusters max_cluster_specs write_json write_full write_gitignore terminal_summary capture_capybara
38
+ capture_page_html].to_h { |name| [name, config.public_send(name)] }
39
+ end
40
+
41
+ def register(path)
42
+ registry = ENV.fetch(REGISTRY)
43
+ FileUtils.mkdir_p(registry)
44
+ atomic_write(File.join(registry, "#{worker_id}.path"), "#{path}\n")
45
+ end
46
+
47
+ def atomic_write(path, contents)
48
+ temporary = "#{path}.#{Process.pid}.tmp"
49
+ File.write(temporary, contents)
50
+ File.rename(temporary, path)
51
+ ensure
52
+ FileUtils.rm_f(temporary) if defined?(temporary)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Signal
5
+ # Knows which paths belong to the project under test ("first party") and how
6
+ # to render any path compactly.
7
+ #
8
+ # First-party means: the host application's own code, plus code from gems
9
+ # that are developed alongside it (Bundler `path:` sources, local Rails
10
+ # engines). Those are things the agent can actually open and edit.
11
+ class Project
12
+ # Directories that live inside the project root but are not project code.
13
+ VENDORED = %w[
14
+ vendor/bundle vendor/cache vendor/ruby .bundle node_modules
15
+ tmp/ .git/ coverage/
16
+ ].freeze
17
+
18
+ # Matches ".../gems/capybara-3.40.0/lib/capybara/node/finders.rb".
19
+ #
20
+ # The leading `.*/` is greedy on purpose: a RubyGems install path contains
21
+ # "/gems/" twice ("/lib/ruby/gems/3.3.0/gems/capybara-3.40.0/..."), and it
22
+ # is the last one that introduces the gem.
23
+ GEM_PATH = %r{
24
+ \A.*/(?:gems|bundler/gems)/
25
+ (?<name>[A-Za-z0-9_.-]+?)
26
+ # A release version, or the short SHA Bundler uses for git sources.
27
+ (?:-(?<version>\d[\w.]*(?:-[a-z0-9]+)?|[0-9a-f]{12,40}))?
28
+ /(?<rest>.*)\z
29
+ }x
30
+
31
+ # Matches ".../lib/ruby/3.3.0/json/common.rb" and ".../ruby/3.3.0/x86_64-linux/..."
32
+ STDLIB_PATH = %r{/lib/ruby/(?:\d+\.\d+\.\d+|site_ruby|vendor_ruby)/(?:[a-z0-9_]+-[a-z0-9_-]+/)?(?<rest>.*)\z}
33
+
34
+ attr_reader :root
35
+
36
+ # @param root [String] absolute path to the project root
37
+ # @param extra_first_party [Array<String>] additional absolute path prefixes
38
+ # to treat as first party (e.g. a sibling engine checkout)
39
+ def initialize(root: Dir.pwd, extra_first_party: [])
40
+ @root = File.expand_path(root)
41
+ @root_prefix = "#{@root}/"
42
+ @extra = extra_first_party.map { |p| "#{File.expand_path(p)}/" }
43
+ @cache = {}
44
+ end
45
+
46
+ # Absolute path prefixes of Bundler `path:` gems (local gems and engines).
47
+ # Discovered lazily and defensively: any Bundler problem simply means we
48
+ # fall back to plain root-relative detection.
49
+ def local_gem_prefixes
50
+ @local_gem_prefixes ||= discover_local_gem_prefixes
51
+ end
52
+
53
+ def first_party?(path)
54
+ return false if path.nil? || path.empty?
55
+
56
+ @cache[path] ||= compute_first_party(path)
57
+ end
58
+
59
+ # A short, stable, human/agent readable rendering of a path.
60
+ #
61
+ # /app/spec/models/user_spec.rb -> spec/models/user_spec.rb
62
+ # .../gems/capybara-3.40.0/lib/capybara/node/finders.rb -> capybara/node/finders.rb
63
+ # .../lib/ruby/3.3.0/json/common.rb -> ruby/json/common.rb
64
+ def display_path(path)
65
+ return path if path.nil? || path.empty?
66
+
67
+ absolute = absolutize(path)
68
+ return relative_to_root(absolute) if under?(absolute, @root_prefix) || first_party?(path)
69
+
70
+ if (m = GEM_PATH.match(absolute))
71
+ return "#{m[:name]}/#{strip_redundant_prefix(m[:name], m[:rest].sub(%r{\Alib/}, ""))}"
72
+ end
73
+
74
+ if (stdlib = STDLIB_PATH.match(absolute))
75
+ return "ruby/#{stdlib[:rest]}"
76
+ end
77
+
78
+ relative_to_root(absolute)
79
+ end
80
+
81
+ # The gem a path belongs to, or nil.
82
+ def gem_name(path)
83
+ m = GEM_PATH.match(absolutize(path))
84
+ m && m[:name]
85
+ end
86
+
87
+ def absolutize(path)
88
+ stripped = path.to_s.sub(%r{\A\./}, "")
89
+ # Ruby emits pseudo-frames that are not file paths at all: "<internal:...>",
90
+ # "-e", "(eval)", "(irb)". Joining those to the project root would wrongly
91
+ # make them look first party.
92
+ return stripped if stripped.start_with?("/", "<", "-", "(")
93
+
94
+ File.join(@root, stripped)
95
+ end
96
+
97
+ def relative_to_root(absolute)
98
+ return absolute[@root_prefix.length..] if under?(absolute, @root_prefix)
99
+
100
+ absolute
101
+ end
102
+
103
+ private
104
+
105
+ # "activerecord" + "active_record/validations.rb" -> "validations.rb", so the
106
+ # rendered frame reads `activerecord/validations.rb` rather than repeating
107
+ # the gem name in two spellings. Only leading *directory* segments that
108
+ # spell out the gem name are removed.
109
+ def strip_redundant_prefix(gem_name, rest)
110
+ segments = rest.split("/")
111
+ dirs = segments[0..-2] || []
112
+ target = normalize_gem_name(gem_name)
113
+
114
+ dirs.size.downto(1) do |count|
115
+ return segments[count..].join("/") if normalize_gem_name(dirs[0, count].join) == target
116
+ end
117
+
118
+ rest
119
+ end
120
+
121
+ def normalize_gem_name(name) = name.to_s.delete("-_")
122
+
123
+ def compute_first_party(path)
124
+ absolute = absolutize(path)
125
+
126
+ if under?(absolute, @root_prefix)
127
+ relative = absolute[@root_prefix.length..]
128
+ return false if VENDORED.any? { |d| relative.start_with?(d) }
129
+ # A gem unpacked anywhere under the root is still vendored code.
130
+ return false if GEM_PATH.match?(absolute)
131
+
132
+ return true
133
+ end
134
+
135
+ @extra.any? { |p| under?(absolute, p) } ||
136
+ local_gem_prefixes.any? { |p| under?(absolute, p) }
137
+ end
138
+
139
+ def under?(absolute, prefix)
140
+ absolute.start_with?(prefix)
141
+ end
142
+
143
+ def discover_local_gem_prefixes
144
+ return [] unless defined?(::Bundler)
145
+
146
+ ::Bundler.load.specs.filter_map do |gem_spec|
147
+ source = gem_spec.source
148
+ next unless source.class.name.to_s.include?("Source::Path")
149
+ next unless gem_spec.full_gem_path
150
+
151
+ "#{File.expand_path(gem_spec.full_gem_path)}/"
152
+ end.uniq
153
+ rescue StandardError, ::LoadError
154
+ []
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Signal
5
+ # Best-effort scrubbing of obvious credentials before a report leaves the
6
+ # machine.
7
+ #
8
+ # This is a safety net, not a guarantee. It targets shapes that are
9
+ # unambiguous (token prefixes, auth headers, credential-ish assignments) and
10
+ # deliberately does not try to guess at arbitrary secret values, because
11
+ # false positives destroy the diagnostic value of a report.
12
+ #
13
+ # Always review artifacts before sending them somewhere you do not control.
14
+ class Redactor
15
+ PLACEHOLDER = "[REDACTED]"
16
+
17
+ # Keys whose *values* are considered sensitive.
18
+ SENSITIVE_KEY = /
19
+ (?:api[_-]?key|secret[_-]?key|access[_-]?key|client[_-]?secret|private[_-]?key|
20
+ secret|password|passwd|pwd|token|auth[_-]?token|access[_-]?token|refresh[_-]?token|
21
+ session[_-]?id|csrf|cookie|authorization|credentials?)
22
+ /xi
23
+
24
+ DEFAULT_PATTERNS = [
25
+ # Authorization: Bearer xyz / Basic xyz
26
+ /\b(Authorization\s*[:=]\s*["']?\s*(?:Bearer|Basic|Token)\s+)[^\s"',;)\]}]+/i,
27
+ # key: "value" / key => 'value' / key=value / "key":"value"
28
+ /(["']?#{SENSITIVE_KEY.source}["']?\s*(?:=>|[:=])\s*)(["'])(?:(?!\2).){3,}\2/xi,
29
+ /(\b#{SENSITIVE_KEY.source}\s*=\s*)(?!["'])[^\s"',;&)\]}]{3,}/xi,
30
+ # URL query parameters
31
+ /([?&]#{SENSITIVE_KEY.source}=)[^&\s"'<>]+/xi,
32
+ # URL userinfo: https://user:pass@host
33
+ %r{(\b[a-z][a-z0-9+.-]*://[^/\s:@]+:)[^/\s@]+(@)}i,
34
+ # Well-known token shapes
35
+ /\bAKIA[0-9A-Z]{16}\b/,
36
+ /\bASIA[0-9A-Z]{16}\b/,
37
+ /\bgh[pousr]_[A-Za-z0-9]{20,}\b/,
38
+ /\bgithub_pat_[A-Za-z0-9_]{20,}\b/,
39
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/,
40
+ /\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{10,}\b/,
41
+ /\bglpat-[A-Za-z0-9_-]{16,}\b/,
42
+ /\bAIza[0-9A-Za-z_-]{30,}\b/,
43
+ # JWTs
44
+ /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*/,
45
+ # PEM blocks
46
+ /-----BEGIN[A-Z ]*PRIVATE KEY-----.*?-----END[A-Z ]*PRIVATE KEY-----/m
47
+ ].freeze
48
+
49
+ def initialize(enabled: true, patterns: DEFAULT_PATTERNS, extra_patterns: [], filter: nil)
50
+ @enabled = enabled
51
+ @patterns = patterns + Array(extra_patterns)
52
+ @filter = filter
53
+ end
54
+
55
+ def enabled? = @enabled
56
+
57
+ # @param text [String, nil]
58
+ # @return [String, nil]
59
+ def call(text)
60
+ return text if text.nil? || !@enabled
61
+
62
+ result = text.dup
63
+ @patterns.each do |pattern|
64
+ result = result.gsub(pattern) do |match|
65
+ replacement_for(pattern, match, Regexp.last_match)
66
+ end
67
+ end
68
+ result = @filter.call(result) if @filter
69
+ result
70
+ end
71
+ alias scrub call
72
+
73
+ private
74
+
75
+ # Patterns with capture groups keep the identifying prefix so the report
76
+ # still says *what* was redacted.
77
+ def replacement_for(_pattern, match, captures)
78
+ prefix = captures[1]
79
+ return PLACEHOLDER if prefix.nil?
80
+
81
+ suffix = captures[2] if captures[2] && captures[2] == "@"
82
+ quote = captures[2] if captures[2] && %w[" '].include?(captures[2])
83
+
84
+ return "#{prefix}#{quote}#{PLACEHOLDER}#{quote}" if quote
85
+ return "#{prefix}#{PLACEHOLDER}#{suffix}" if suffix
86
+
87
+ "#{prefix}#{PLACEHOLDER}" if match
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Signal
5
+ # The complete, renderer-independent result of a run.
6
+ class Report
7
+ attr_reader :failures, :groups, :clusters, :example_count, :failure_count, :pending_count,
8
+ :duration, :seed, :seed_used, :environment, :errors_outside_examples
9
+
10
+ def initialize(failures:, example_count: 0, failure_count: nil, pending_count: 0,
11
+ duration: nil, seed: nil, seed_used: false, environment: {},
12
+ errors_outside_examples: 0, relate_failures: true)
13
+ @failures = failures
14
+ @groups = Grouper.call(failures)
15
+ @clusters = relate_failures ? safely { Clusterer.call(failures) } : []
16
+ @example_count = example_count
17
+ @failure_count = failure_count || failures.size
18
+ @pending_count = pending_count
19
+ @duration = duration
20
+ @seed = seed
21
+ @seed_used = seed_used
22
+ @environment = environment
23
+ @errors_outside_examples = errors_outside_examples
24
+ end
25
+
26
+ # Clustering is the newest and least essential stage; a report without it
27
+ # is still worth having, so it is never allowed to take the run down.
28
+ def safely
29
+ yield
30
+ rescue StandardError
31
+ []
32
+ end
33
+
34
+ def group_count = groups.size
35
+ def cluster_count = clusters.size
36
+ def any_failures? = !failures.empty?
37
+ def seed_used? = !!@seed_used
38
+
39
+ # Backtrace frames dropped across the whole run. This is the number that
40
+ # makes the reduction visible.
41
+ def omitted_frames
42
+ @omitted_frames ||= failures.sum { |failure| failure.reduced.omitted_count }
43
+ end
44
+
45
+ def total_frames
46
+ @total_frames ||= failures.sum { |failure| failure.reduced.total }
47
+ end
48
+
49
+ def kept_frames = total_frames - omitted_frames
50
+
51
+ def to_h
52
+ {
53
+ schema: 1,
54
+ generated_by: "rspec-signal #{VERSION}",
55
+ summary: {
56
+ examples: example_count,
57
+ failures: failure_count,
58
+ pending: pending_count,
59
+ signatures: group_count,
60
+ related_clusters: cluster_count.positive? ? cluster_count : nil,
61
+ duration_seconds: duration&.round(3),
62
+ seed: seed_used? ? seed : nil,
63
+ errors_outside_examples: errors_outside_examples.positive? ? errors_outside_examples : nil
64
+ }.compact,
65
+ environment: environment,
66
+ backtrace_reduction: { total_frames: total_frames, kept_frames: kept_frames, omitted_frames: omitted_frames },
67
+ signatures: groups.map(&:to_h),
68
+ related: clusters.map(&:to_h)
69
+ }
70
+ end
71
+
72
+ # Worker interchange data. Unlike the public report summary this retains
73
+ # every reduced failure so grouping can be repeated across all workers.
74
+ #
75
+ # `raw` -- the unreduced formatter output -- is included only when
76
+ # `write_full` is on, since it is otherwise discarded on merge and would
77
+ # just bloat every worker payload for no reason.
78
+ def worker_h(write_full: false)
79
+ serialized = failures.map do |failure|
80
+ attributes = failure.to_h.merge(fingerprint: failure.fingerprint.to_h)
81
+ attributes[:raw] = failure.raw if write_full
82
+ attributes
83
+ end
84
+ to_h.merge(schema: 2, failures: serialized)
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Signal
5
+ module Reporters
6
+ # The unreduced failure output, preserved verbatim.
7
+ #
8
+ # This exists so reduction is never lossy in practice: if the compact
9
+ # report dropped the one frame that mattered, the original is one file
10
+ # away. It is not the artifact you hand to an agent.
11
+ class FullOutput
12
+ def initialize(report, config)
13
+ @report = report
14
+ @config = config
15
+ end
16
+
17
+ def render
18
+ out = ["rspec-signal #{VERSION} -- unreduced failure output", ""]
19
+ @report.failures.each_with_index do |failure, index|
20
+ out << (failure.raw || fallback(failure, index + 1))
21
+ out << ""
22
+ end
23
+ "#{out.join("\n").rstrip}\n"
24
+ end
25
+
26
+ private
27
+
28
+ def fallback(failure, position)
29
+ lines = [" #{position}) #{failure.description}",
30
+ " #{failure.exception_class}:",
31
+ *failure.message.lines.map { |line| " #{line}" }]
32
+ lines.concat(failure.frames.map { |frame| " # #{frame}" })
33
+ lines.join("\n")
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RSpec
6
+ module Signal
7
+ module Reporters
8
+ # Machine-readable twin of the Markdown report, for tooling and CI.
9
+ class JsonReport
10
+ def initialize(report, _config = nil)
11
+ @report = report
12
+ end
13
+
14
+ def render
15
+ "#{JSON.pretty_generate(@report.to_h)}\n"
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end