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,292 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Signal
5
+ module Causal
6
+ # One exact signature, or one set of identical errors outside examples.
7
+ # Signatures stay authoritative: this layer relates them, never re-cuts
8
+ # them.
9
+ Unit = Struct.new(:key, :failures, :outside, :position, keyword_init: true) do
10
+ def size
11
+ failures.size
12
+ end
13
+
14
+ def evidence
15
+ failures.map(&:evidence).compact
16
+ end
17
+
18
+ def representative
19
+ failures.first
20
+ end
21
+
22
+ def exception_class
23
+ representative.exception_class
24
+ end
25
+ end
26
+
27
+ # A classified set of units.
28
+ #
29
+ # causal structural evidence that the members share an origin.
30
+ # Always "high": nothing weaker is allowed to form one.
31
+ # scope failures unusually concentrated in one file or one
32
+ # `type:`. A fact about where, never a claim about why.
33
+ # independent no relationship found. Not "proven unrelated".
34
+ Relation = Struct.new(:kind, :units, :evidence, :scope, keyword_init: true) do
35
+ def initialize(**)
36
+ super
37
+ self.evidence ||= []
38
+ end
39
+
40
+ def failures
41
+ units.sum(&:size)
42
+ end
43
+
44
+ def confidence
45
+ kind == :causal ? "high" : nil
46
+ end
47
+ end
48
+
49
+ # Relates signatures by structural evidence and reports how much of the
50
+ # run it has classified. No weights, no similarity, no model: every
51
+ # relationship is an equality between facts read from the run.
52
+ class Analysis
53
+ # The three structural relations, in the order their evidence is shown.
54
+ LINKS = { identities: "shared_exception_object", link_key: "same_underlying_exception",
55
+ entities: "missing_entity" }.freeze
56
+
57
+ SETUP = [["setup", "before(:context) hook"], ["setup", "before hook"], %w[body let]].freeze
58
+
59
+ EVIDENCE_ORDER = %w[shared_exception_object missing_entity outside_examples same_underlying_exception
60
+ setup_failure setup_code failed_in_let teardown_failure].freeze
61
+
62
+ attr_reader :relations, :hints, :expected
63
+
64
+ def self.call(report)
65
+ outside = Grouper.call(report.outside_example_failures)
66
+ units = report.groups.map { |group| unit(group, false) } + outside.map { |group| unit(group, true) }
67
+ units.each_with_index { |unit, index| unit.position = index }
68
+ new(units, census: report.census,
69
+ expected: report.failure_count + report.errors_outside_examples)
70
+ end
71
+
72
+ def self.unit(group, outside)
73
+ key = outside ? "outside:#{group.fingerprint.digest}" : group.fingerprint.digest
74
+ Unit.new(key: key, failures: group.failures, outside: outside)
75
+ end
76
+
77
+ def initialize(units, census:, expected:)
78
+ @units = units
79
+ @census = census
80
+ @expected = expected
81
+ @hints = []
82
+ causal, residual = relate
83
+ scoped, independent = ScopeAnalysis.call(residual, @census)
84
+ @relations = causal + scoped + independent.map { |unit| Relation.new(kind: :independent, units: [unit]) }
85
+ @hints.concat(same_origin_hints)
86
+ @hints.concat(reused_instance_hints)
87
+ end
88
+
89
+ def accounted
90
+ @relations.sum(&:failures)
91
+ end
92
+
93
+ def count(kind)
94
+ @relations.select { |relation| relation.kind == kind }.sum(&:failures)
95
+ end
96
+
97
+ def not_captured
98
+ [@expected - accounted, 0].max
99
+ end
100
+
101
+ # Whether there is anything to say beyond the signatures themselves.
102
+ def informative?
103
+ @relations.any? { |relation| relation.kind != :independent }
104
+ end
105
+
106
+ private
107
+
108
+ # ---- causal ------------------------------------------------------
109
+
110
+ def relate
111
+ links = structural_links
112
+ parent = @units.map(&:position)
113
+ links.map(&:first).each { |units| units.each_cons(2) { |a, b| union(parent, a.position, b.position) } }
114
+
115
+ causal, residual = components(parent, links).partition { |members, evidence| causal?(members, evidence) }
116
+ relations = causal.map { |members, evidence| Relation.new(kind: :causal, units: members, evidence: evidence) }
117
+ [relations.sort_by { |relation| [-relation.failures, relation.units.first.position] },
118
+ residual.flat_map(&:first)]
119
+ end
120
+
121
+ # [units, evidence] for every value of a linking field that more than
122
+ # one unit carries.
123
+ def structural_links
124
+ LINKS.flat_map do |field, type|
125
+ shared(field).map { |value, units| [units, link_evidence(type, value, units)] }
126
+ end
127
+ end
128
+
129
+ def components(parent, links)
130
+ @units.group_by { |unit| find(parent, unit.position) }.values.map do |members|
131
+ [members, component_evidence(members, links)]
132
+ end
133
+ end
134
+
135
+ # Several related signatures always form a group. One signature forms
136
+ # one only when it carries a fact beyond its own fingerprint.
137
+ def causal?(members, evidence)
138
+ members.size > 1 || (members.first.size > 1 && !evidence.empty?)
139
+ end
140
+
141
+ # Values of one evidence field carried by more than one unit.
142
+ def shared(field)
143
+ index = Hash.new { |hash, key| hash[key] = [] }
144
+ @units.each { |unit| linkable(unit, field).each { |value| index[value] << unit } }
145
+ index.select { |_, units| units.size > 1 }
146
+ end
147
+
148
+ def linkable(unit, field)
149
+ evidence = field == :identities ? unit.evidence.select { |item| context_hook?(item) } : unit.evidence
150
+ evidence.flat_map { |item| Array(item.public_send(field)) }.uniq
151
+ end
152
+
153
+ def component_evidence(members, links)
154
+ linked = links.select { |units, _| members.include?(units.first) }.map(&:last)
155
+ dedupe(linked + members.flat_map { |unit| facts(unit) })
156
+ end
157
+
158
+ def link_evidence(type, value, units)
159
+ case type
160
+ when "missing_entity" then entity_evidence(value)
161
+ when "same_underlying_exception" then underlying_evidence(units)
162
+ else { "type" => type, "detail" => "one exception object reached #{units.sum(&:size)} examples" }
163
+ end
164
+ end
165
+
166
+ def underlying_evidence(units)
167
+ wrapped = units.flat_map(&:evidence).map(&:wrapped).compact.uniq
168
+ classes = units.map(&:exception_class).uniq
169
+ { "type" => "same_underlying_exception", "exception" => wrapped.first || classes.first,
170
+ "raised_as" => classes, "origin" => units.flat_map(&:evidence).map(&:origin).compact.first }.compact
171
+ end
172
+
173
+ def entity_evidence(value)
174
+ kind, name = value.split(":", 2)
175
+ { "type" => "missing_entity", "entity" => value, "kind" => kind, "name" => name }
176
+ end
177
+
178
+ # RSpec shares one exception object between examples in exactly one
179
+ # case: a before(:context) hook failed. Any other sharing is an
180
+ # exception *instance* being reused -- and Ruby keeps the first raise's
181
+ # backtrace when an instance is raised again, so nothing about it can
182
+ # be trusted as "raised once". That is a hint, never a link.
183
+ def context_hook?(evidence)
184
+ evidence.phase_detail == "before(:context) hook"
185
+ end
186
+
187
+ def reused_instance_hints
188
+ carriers = Hash.new { |hash, key| hash[key] = [] }
189
+ @units.reject(&:outside).each do |unit|
190
+ unit.evidence.reject { |item| context_hook?(item) }.each do |item|
191
+ item.identities.each { |token| carriers[token] << unit.key }
192
+ end
193
+ end
194
+ carriers.select { |_, owners| owners.size > 1 }.map do |_, owners|
195
+ { "type" => "reused_exception_instance", "signatures" => owners.uniq,
196
+ "detail" => "one exception instance was reported by #{owners.size} examples outside a " \
197
+ "before(:context) hook; Ruby keeps the first raise's backtrace, so its origin may be stale" }
198
+ end.uniq
199
+ end
200
+
201
+ # Facts a single unit carries beyond its fingerprint, when every one of
202
+ # its failures carries them.
203
+ def facts(unit)
204
+ return outside_facts(unit) if unit.outside
205
+
206
+ evidence = unit.evidence
207
+ return [] if evidence.empty? || evidence.size < unit.size
208
+
209
+ common = evidence.map(&:entities).reduce(:&) || []
210
+ shared_object_facts(unit, evidence) + phase_facts(evidence) + common.map { |value| entity_evidence(value) }
211
+ end
212
+
213
+ def shared_object_facts(unit, evidence)
214
+ tokens = evidence.select { |item| context_hook?(item) }.flat_map(&:identities)
215
+ return [] if tokens.uniq.size == tokens.size
216
+
217
+ [{ "type" => "shared_exception_object", "detail" => "one exception object reached #{unit.size} examples" }]
218
+ end
219
+
220
+ # Only when every member failed in a phase that says the same thing. A
221
+ # signature failing partly in setup and partly in the body gets none.
222
+ def phase_facts(evidence)
223
+ phases = evidence.map { |item| [item.phase, item.phase_detail] }.uniq
224
+ type = phase_fact(phases)
225
+ return [] unless type
226
+
227
+ reached = { "teardown_failure" => true, "failed_in_let" => true, "setup_failure" => false }[type]
228
+ [{ "type" => type, "body_reached" => reached, "phase_detail" => phases.map(&:last).join(", "),
229
+ "sites" => evidence.map(&:site).compact.uniq.first(3) }.compact]
230
+ end
231
+
232
+ def phase_fact(phases)
233
+ return "teardown_failure" if phases == [["teardown", "after hook"]]
234
+ return "failed_in_let" if phases == [%w[body let]]
235
+ return nil unless (phases - SETUP).empty?
236
+
237
+ phases.include?(%w[body let]) ? "setup_code" : "setup_failure"
238
+ end
239
+
240
+ def outside_facts(unit)
241
+ return [] if unit.size < 2
242
+
243
+ files = unit.failures.map(&:rerun).uniq
244
+ [{ "type" => "outside_examples", "files" => files.first(5), "file_count" => files.size,
245
+ "detail" => "the same error was raised outside any example #{unit.size} times" }]
246
+ end
247
+
248
+ def dedupe(evidence)
249
+ evidence.uniq { |item| [item["type"], item["entity"], item["phase_detail"]] }
250
+ .sort_by { |item| EVIDENCE_ORDER.index(item["type"]) || EVIDENCE_ORDER.size }
251
+ end
252
+
253
+ def find(parent, index)
254
+ parent[index] = find(parent, parent[index]) unless parent[index] == index
255
+ parent[index]
256
+ end
257
+
258
+ def union(parent, first, second)
259
+ a = find(parent, first)
260
+ b = find(parent, second)
261
+ parent[[a, b].max] = [a, b].min unless a == b
262
+ end
263
+
264
+ # ---- hints -------------------------------------------------------
265
+
266
+ # Two unrelated units raising the same class from the same first-party
267
+ # line, with different messages. Worth knowing; not worth merging --
268
+ # a chokepoint raises for many reasons.
269
+ def same_origin_hints
270
+ grouped = Hash.new { |hash, key| hash[key] = [] }
271
+ @units.reject(&:outside).each do |unit|
272
+ evidence = unit.evidence.first
273
+ next unless evidence&.origin && evidence.link_key
274
+
275
+ grouped[[unit.exception_class, evidence.origin]] << unit
276
+ end
277
+ grouped.filter_map do |(exception, origin), units|
278
+ next if units.size < 2 || same_relation?(units)
279
+
280
+ { "type" => "same_origin_different_message", "exception" => exception, "origin" => origin,
281
+ "signatures" => units.map(&:key) }
282
+ end
283
+ end
284
+
285
+ def same_relation?(units)
286
+ owner = units.map { |unit| @relations.index { |relation| relation.units.include?(unit) } }
287
+ owner.uniq.size == 1 && @relations[owner.first].kind == :causal
288
+ end
289
+ end
290
+ end
291
+ end
292
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Signal
5
+ module Causal
6
+ # How many examples ran, and failed, per spec file and per `type:`.
7
+ # Counters only -- no descriptions, no messages.
8
+ #
9
+ # Deliberately not per parallel worker: parallel_tests assigns files to
10
+ # workers by size, so "every failure ran on worker 2" usually means
11
+ # "every failing file was assigned to worker 2".
12
+ #
13
+ # This is the base rate every other signal lacks: "9 failures in one file"
14
+ # means something only next to "11 examples in that file, 0 of 493
15
+ # elsewhere failed".
16
+ class Census
17
+ DIMENSIONS = %w[file type].freeze
18
+
19
+ attr_reader :total, :failed
20
+
21
+ def initialize
22
+ @total = 0
23
+ @failed = 0
24
+ @counts = DIMENSIONS.to_h { |dimension| [dimension, Hash.new { |hash, key| hash[key] = [0, 0] }] }
25
+ end
26
+
27
+ # Called with the notification for every passed and failed example.
28
+ # Pending examples neither passed nor failed, and are left out of both
29
+ # sides. Never raises: counting must not cost a failure its report.
30
+ def record(notification, failed:)
31
+ example = notification.example
32
+ add({ "file" => Capture.file_of(example), "type" => Capture.type_of(example) }, failed: failed)
33
+ rescue StandardError
34
+ nil
35
+ end
36
+
37
+ def add(keys, failed:)
38
+ @total += 1
39
+ @failed += 1 if failed
40
+ keys.each { |dimension, value| bump(dimension, value, 1, failed ? 1 : 0) }
41
+ self
42
+ end
43
+
44
+ # @return [Array(Integer, Integer)] examples run and failed for one value
45
+ def count(dimension, value)
46
+ @counts.fetch(dimension, {}).fetch(value.to_s, [0, 0])
47
+ end
48
+
49
+ def values(dimension)
50
+ @counts.fetch(dimension, {}).keys.sort
51
+ end
52
+
53
+ def empty?
54
+ @total.zero?
55
+ end
56
+
57
+ def to_h
58
+ { "total" => @total, "failed" => @failed }.merge(@counts.reject { |_, values| values.empty? })
59
+ end
60
+
61
+ # Adds one parallel worker's census.
62
+ def merge(data)
63
+ return self unless data.is_a?(Hash)
64
+
65
+ run = data.fetch("total", 0).to_i
66
+ failures = data.fetch("failed", 0).to_i
67
+ @total += run
68
+ @failed += failures
69
+ %w[file type].each do |dimension|
70
+ data.fetch(dimension, {}).each { |value, (count, failed)| bump(dimension, value, count.to_i, failed.to_i) }
71
+ end
72
+ self
73
+ end
74
+
75
+ private
76
+
77
+ def bump(dimension, value, run, failures)
78
+ return if value.nil? || value.to_s.empty? || !@counts.key?(dimension)
79
+
80
+ @counts[dimension][value.to_s][0] += run
81
+ @counts[dimension][value.to_s][1] += failures
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Signal
5
+ module Causal
6
+ # Missing definitions a failure names, read from Ruby exception
7
+ # attributes -- never from message text. See {.call}.
8
+ module Entities
9
+ module_function
10
+
11
+ # Missing definitions, read from exception attributes rather than
12
+ # message text:
13
+ #
14
+ # env:KEY KeyError whose receiver is ENV itself
15
+ # const:A::B NameError for a constant, qualified by its namespace
16
+ # method:C#m NoMethodError/NameError on an instance of a class
17
+ # defined in this project (never nil, never a core
18
+ # class: two nils are not the same nil)
19
+ def call(error, config)
20
+ case error
21
+ when KeyError then env_entity(error)
22
+ when NoMethodError then method_entity(error, config)
23
+ when NameError then constant_entity(error) || method_entity(error, config)
24
+ else []
25
+ end
26
+ rescue StandardError
27
+ []
28
+ end
29
+
30
+ def env_entity(error)
31
+ error.receiver.equal?(ENV) ? ["env:#{error.key}"] : []
32
+ rescue ArgumentError
33
+ []
34
+ end
35
+
36
+ def constant_entity(error)
37
+ name = error.name.to_s
38
+ return nil unless /\A[A-Z]/.match?(name)
39
+
40
+ namespace = error.receiver
41
+ qualified = namespace.is_a?(Module) && namespace != Object && namespace.name
42
+ ["const:#{qualified ? "#{namespace.name}::#{name}" : name}"]
43
+ rescue ArgumentError
44
+ nil
45
+ end
46
+
47
+ def method_entity(error, config)
48
+ receiver = error.receiver
49
+ owner = receiver.is_a?(Module) ? receiver : receiver.class
50
+ return [] unless owner.name && first_party_class?(owner, config)
51
+
52
+ separator = receiver.is_a?(Module) ? "." : "#"
53
+ ["method:#{owner.name}#{separator}#{error.name}"]
54
+ rescue ArgumentError
55
+ []
56
+ end
57
+
58
+ def first_party_class?(owner, config)
59
+ file, = Object.const_source_location(owner.name)
60
+ !file.nil? && config.project.first_party?(file)
61
+ rescue StandardError
62
+ false
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module RSpec
6
+ module Signal
7
+ # Experimental causal-evidence layer. See docs/design/causal-failure-intelligence.md.
8
+ #
9
+ # Everything under this namespace is removable: delete the directory, the
10
+ # relationships reporter, and the few lines that call into it.
11
+ module Causal
12
+ # Structural facts about one failure, captured before backtrace reduction
13
+ # throws the evidence away. Plain data: it serializes into worker payloads
14
+ # so the parallel merger reasons over the same facts a serial run does.
15
+ #
16
+ # phase "setup" | "body" | "teardown" | "outside" | "unknown"
17
+ # phase_detail "before(:context) hook", "before hook", "after hook",
18
+ # "around hook", "let", or nil
19
+ # body_reached true / false, or nil when it cannot be known
20
+ # site the first-party line of the hook or `let` that failed
21
+ # origin innermost first-party frame, spec suite included
22
+ # origin_kind "app" | "support" | "spec"
23
+ # link_key identity of the *underlying* exception: innermost cause
24
+ # class, normalized message and origin. nil for assertion
25
+ # failures, which never link on origin.
26
+ # wrapped the innermost cause's class when the failure wraps one
27
+ # entities missing definitions read from exception attributes
28
+ # identities one token per exception object in the chain
29
+ Evidence = Struct.new(:phase, :phase_detail, :body_reached, :site, :origin, :origin_kind,
30
+ :link_key, :wrapped, :entities, :identities, :file, :type,
31
+ keyword_init: true) do
32
+ def to_h
33
+ super.transform_keys(&:to_s).reject { |_, value| value.nil? || value == [] }
34
+ end
35
+
36
+ def self.from_h(data)
37
+ return nil unless data.is_a?(Hash)
38
+
39
+ new(**members.to_h { |name| [name, data[name.to_s]] }).tap do |evidence|
40
+ evidence.entities = Array(evidence.entities)
41
+ evidence.identities = Array(evidence.identities)
42
+ end
43
+ end
44
+ end
45
+
46
+ # Reads {Evidence} out of an exception and its parsed frames.
47
+ #
48
+ # Nothing here guesses. Every field is either read from a Ruby exception
49
+ # attribute, from a frame RSpec itself put on the stack, or left nil.
50
+ module Capture
51
+ ASSERTION = /\ARSpec::(?:Expectations|Mocks)::|\ARSpec::Core::MultipleExceptionError/.freeze
52
+ SPEC_FILE = /_(?:spec|test)\.rb\z/.freeze
53
+ TEST_TREE = %r{\A(?:spec|test)/}.freeze
54
+ MAX_CAUSES = 3
55
+ MAX_CAUSE_FRAMES = 60
56
+
57
+ module_function
58
+
59
+ # @param exception [Exception]
60
+ # @param frames [Array<Backtrace::Frame>] the full parsed backtrace
61
+ # @param identities [Hash] compare_by_identity map shared across a run
62
+ def call(exception, frames:, config:, identities:, example: nil)
63
+ chain = chain(exception)
64
+ phase, detail, reached, site = Phase.call(frames)
65
+ origin = frames.find(&:project?)
66
+ Evidence.new(
67
+ phase: phase, phase_detail: detail, body_reached: reached, site: site&.location,
68
+ origin: origin&.location, origin_kind: origin && origin_kind(origin),
69
+ link_key: link_key(chain, origin, config),
70
+ wrapped: chain.size > 1 ? class_name(chain.last) : nil,
71
+ entities: chain.flat_map { |error| Entities.call(error, config) }.uniq,
72
+ identities: chain.map { |error| identities[error] ||= "e#{identities.size + 1}" },
73
+ **location_of(example)
74
+ )
75
+ end
76
+
77
+ def location_of(example)
78
+ example ? { file: file_of(example), type: type_of(example) } : {}
79
+ end
80
+
81
+ def chain(exception)
82
+ chain = [exception]
83
+ while (cause = chain.last.cause) && chain.none? { |seen| seen.equal?(cause) } && chain.size <= MAX_CAUSES
84
+ chain << cause
85
+ end
86
+ chain
87
+ end
88
+
89
+ def origin_kind(frame)
90
+ path = frame.display_path.to_s
91
+ return "spec" if SPEC_FILE.match?(path)
92
+ return "support" if TEST_TREE.match?(path)
93
+
94
+ "app"
95
+ end
96
+
97
+ # Failures link on origin only when the *underlying* exception is the
98
+ # same class, with the same normalized message, raised at the same
99
+ # first-party line. That relates a wrapped exception to its bare
100
+ # twin; it deliberately does not relate two different messages that
101
+ # happen to leave one chokepoint.
102
+ #
103
+ # The exception's own message is used, never RSpec's rendering of it:
104
+ # the rendering opens with the failing source line, which differs at
105
+ # every call site.
106
+ def link_key(chain, origin, config)
107
+ innermost = chain.last
108
+ name = class_name(innermost)
109
+ return nil if ASSERTION.match?(class_name(chain.first)) || ASSERTION.match?(name)
110
+
111
+ location = chain.size == 1 ? origin&.location : cause_origin(innermost, config)
112
+ return nil unless location
113
+
114
+ text = Message.new(innermost.message.to_s.split("\n").first(5), redactor: config.redactor,
115
+ project: config.project,
116
+ html_threshold: nil).normalized
117
+ Digest::SHA256.hexdigest([name, text, location].join("\0"))[0, 12]
118
+ end
119
+
120
+ def cause_origin(error, config)
121
+ frames = Backtrace::Parser.parse(Array(error.backtrace).first(MAX_CAUSE_FRAMES), config.classifier)
122
+ frames.find(&:project?)&.location
123
+ end
124
+
125
+ def file_of(example)
126
+ example.metadata[:rerun_file_path].to_s.sub(%r{\A\./}, "")
127
+ rescue StandardError
128
+ nil
129
+ end
130
+
131
+ def type_of(example)
132
+ example.metadata[:type]&.to_s
133
+ rescue StandardError
134
+ nil
135
+ end
136
+
137
+ def class_name(error)
138
+ name = error.class.name.to_s
139
+ name.empty? ? "(anonymous error class)" : name
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Signal
5
+ module Causal
6
+ # Which part of an example a failure happened in, read from the
7
+ # rspec-core frames just outside the failing code. See {.call}.
8
+ module Phase
9
+ CORE_FILE = %r{/lib/rspec/core/(\w+\.rb)\z}.freeze
10
+
11
+ # The hook methods RSpec runs a failing hook's block from. Resolved from
12
+ # the loaded rspec-core rather than hardcoded, so line numbers moving
13
+ # between releases cannot misclassify a frame.
14
+ HOOKS = {
15
+ "before hook" => %i[BeforeHook run],
16
+ "after hook" => %i[AfterHook run],
17
+ "around hook" => %i[AroundHook execute_with]
18
+ }.freeze
19
+
20
+ module_function
21
+
22
+ # Where the failure happened relative to the example, read from the
23
+ # rspec-core frames just outside the failing code:
24
+ #
25
+ # Example#instance_exec -> Example#run example body
26
+ # Example#instance_exec -> BeforeHook#run before(:each)
27
+ # Example#instance_exec -> AfterHook#run after(:each)
28
+ # BasicObject#instance_exec -> BeforeHook#run before(:context)
29
+ # (no Example involved)
30
+ #
31
+ # A `let` raising from inside the body is still the body: it began.
32
+ def call(frames)
33
+ core = frames.each_with_index.filter_map { |frame, index| [frame, index] if core_file(frame) }
34
+ anchor = core.index { |frame, _| %w[example.rb hooks.rb].include?(core_file(frame)) }
35
+ return ["unknown", nil, nil, nil] unless anchor
36
+
37
+ anchor_frame, anchor_index = core[anchor]
38
+ memoized = core.first(anchor).any? { |frame, _| core_file(frame) == "memoized_helpers.rb" }
39
+ caller = core.drop(anchor).map(&:first).find { |frame| !frame.label.to_s.end_with?("instance_exec") }
40
+ site = site(frames, anchor_index, memoized)
41
+ classify(caller, core_file(anchor_frame) == "hooks.rb", memoized) + [site]
42
+ end
43
+
44
+ def classify(caller, context, memoized)
45
+ return ["unknown", nil, nil] unless caller
46
+ return ["body", memoized ? "let" : nil, true] if body_frame?(caller)
47
+
48
+ case hook_kind(caller)
49
+ when "before hook" then context ? ["setup", "before(:context) hook", false] : ["setup", "before hook", false]
50
+ when "after hook" then ["teardown", "after hook", true]
51
+ when "around hook" then ["unknown", "around hook", nil]
52
+ else ["unknown", nil, nil]
53
+ end
54
+ end
55
+
56
+ def body_frame?(frame)
57
+ core_file(frame) == "example.rb" && /block in (?:\S+#)?run\z/.match?(frame.label.to_s)
58
+ end
59
+
60
+ def hook_kind(frame)
61
+ return nil unless core_file(frame) == "hooks.rb"
62
+
63
+ label = frame.label.to_s
64
+ by_label = HOOKS.find { |_, (klass, method)| label.include?("#{klass}##{method}") }
65
+ return by_label.first if by_label
66
+
67
+ path = File.expand_path(frame.path)
68
+ hook_sites.find { |_, file, line| file == path && frame.line.to_i.between?(line, line + 3) }&.first
69
+ end
70
+
71
+ def hook_sites
72
+ @hook_sites ||= HOOKS.filter_map do |name, (klass, method)|
73
+ file, line = ::RSpec::Core::Hooks.const_get(klass).instance_method(method).source_location
74
+ [name, File.expand_path(file), line]
75
+ rescue StandardError
76
+ nil
77
+ end
78
+ end
79
+
80
+ # The first-party line that *is* the failing hook or `let`: the frame
81
+ # just inside the memoized helper for a `let`, and the outermost
82
+ # first-party frame inside the runner otherwise.
83
+ def site(frames, anchor_index, memoized)
84
+ inner = frames.first(anchor_index)
85
+ if memoized
86
+ helper = inner.index { |frame| core_file(frame) == "memoized_helpers.rb" }
87
+ return inner.first(helper.to_i).reverse.find(&:project?)
88
+ end
89
+ inner.reverse.find(&:project?)
90
+ end
91
+
92
+ def core_file(frame)
93
+ frame.path.to_s[CORE_FILE, 1]
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end