saboteur 0.2.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 (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +47 -0
  3. data/LICENSE +21 -0
  4. data/README.md +145 -0
  5. data/exe/saboteur +12 -0
  6. data/lib/saboteur/cli.rb +112 -0
  7. data/lib/saboteur/config.rb +133 -0
  8. data/lib/saboteur/engine/boot.rb +83 -0
  9. data/lib/saboteur/engine/candidates.rb +63 -0
  10. data/lib/saboteur/engine/coverage_store.rb +99 -0
  11. data/lib/saboteur/engine/diagnostics.rb +37 -0
  12. data/lib/saboteur/engine/diff_scope.rb +32 -0
  13. data/lib/saboteur/engine/distribution.rb +74 -0
  14. data/lib/saboteur/engine/result_cache.rb +93 -0
  15. data/lib/saboteur/engine/scheduler.rb +215 -0
  16. data/lib/saboteur/engine/subject_selection.rb +63 -0
  17. data/lib/saboteur/engine/suite_files.rb +82 -0
  18. data/lib/saboteur/engine/verdict_check.rb +78 -0
  19. data/lib/saboteur/engine.rb +133 -0
  20. data/lib/saboteur/execution/process_table.rb +177 -0
  21. data/lib/saboteur/execution/process_tree.rb +99 -0
  22. data/lib/saboteur/execution/protocol.rb +41 -0
  23. data/lib/saboteur/execution/reevaluator.rb +144 -0
  24. data/lib/saboteur/execution/runner.rb +191 -0
  25. data/lib/saboteur/execution/supervisor.rb +337 -0
  26. data/lib/saboteur/execution/worker_pool.rb +249 -0
  27. data/lib/saboteur/frameworks/rails.rb +166 -0
  28. data/lib/saboteur/hooks.rb +51 -0
  29. data/lib/saboteur/ignore_list.rb +62 -0
  30. data/lib/saboteur/integration/base.rb +72 -0
  31. data/lib/saboteur/integration/rspec.rb +143 -0
  32. data/lib/saboteur/mutation_source/base.rb +26 -0
  33. data/lib/saboteur/mutation_source/builtin.rb +232 -0
  34. data/lib/saboteur/mutation_source/edit.rb +18 -0
  35. data/lib/saboteur/mutation_source/mutation.rb +28 -0
  36. data/lib/saboteur/reporter/diff.rb +97 -0
  37. data/lib/saboteur/reporter/stryker.rb +116 -0
  38. data/lib/saboteur/reporter/terminal.rb +136 -0
  39. data/lib/saboteur/result.rb +134 -0
  40. data/lib/saboteur/selection/coverage_map.rb +113 -0
  41. data/lib/saboteur/selection/git_diff.rb +64 -0
  42. data/lib/saboteur/selection/recorder.rb +72 -0
  43. data/lib/saboteur/subject.rb +166 -0
  44. data/lib/saboteur/subject_finder.rb +98 -0
  45. data/lib/saboteur/version.rb +6 -0
  46. data/lib/saboteur.rb +51 -0
  47. metadata +130 -0
@@ -0,0 +1,143 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Integration
6
+ # Loads spec files once in the parent. In a child, narrows the already
7
+ # loaded world to the requested example ids and runs it with a private
8
+ # reporter, so nothing is printed and no status file is written.
9
+ class RSpec < Base
10
+ def setup
11
+ require "rspec/core"
12
+ ::RSpec::Core::Runner.disable_autorun!
13
+ options = ::RSpec::Core::ConfigurationOptions.new(rspec_args)
14
+ @runner = ::RSpec::Core::Runner.new(options)
15
+ @runner.setup($stderr, $stdout)
16
+ raise Error, "rspec quit while loading spec files" if world.wants_to_quit
17
+ end
18
+
19
+ # RSpec only adds its default "spec" path when $0 is `rspec`.
20
+ def rspec_args
21
+ args = Array(config.fetch("rspec_args", []))
22
+ args.any? { |arg| !arg.start_with?("-") } ? args : args + ["spec"]
23
+ end
24
+
25
+ def all_tests
26
+ groups = world.ordered_example_groups.flat_map(&:descendants)
27
+ groups.flat_map { |group| world.filtered_examples[group] }
28
+ .map { |example| TestCase.new(id: example.id, file: example.metadata[:rerun_file_path]) }
29
+ end
30
+
31
+ def test_files
32
+ world.example_groups.map { |group| File.expand_path(group.metadata[:file_path]) }.uniq
33
+ end
34
+
35
+ # Runs the tests in the order given: groups by their earliest example,
36
+ # examples by position.
37
+ def run(ids)
38
+ wanted = Set.new(ids)
39
+ narrow_to(wanted)
40
+ order_by(ids)
41
+ configuration.fail_fast = 1
42
+ listener = Listener.new
43
+ passed = execute(listener)
44
+ RunResult.new(passed: passed && listener.failed.empty?, failed_ids: listener.failed,
45
+ executed_ids: listener.executed, runtime: listener.runtime,
46
+ failure_classes: listener.failure_classes, failure_messages: listener.failure_messages)
47
+ end
48
+
49
+ def run_all(recorder)
50
+ configuration.fail_fast = false
51
+ execute(Listener.new(recorder))
52
+ end
53
+
54
+ private
55
+
56
+ def world
57
+ ::RSpec.world
58
+ end
59
+
60
+ def configuration
61
+ ::RSpec.configuration
62
+ end
63
+
64
+ def order_by(ids)
65
+ rank = ids.each_with_index.to_h
66
+ example_rank = ->(example) { rank.fetch(example.id, Float::INFINITY) }
67
+ configuration.register_ordering(:global) do |items|
68
+ items.sort_by do |item|
69
+ if item.is_a?(Class)
70
+ item.descendant_filtered_examples.map(&example_rank).min || Float::INFINITY
71
+ else
72
+ example_rank.call(item)
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ def narrow_to(wanted)
79
+ world.filtered_examples.clear
80
+ world.filtered_examples.default_proc = proc do |hash, group|
81
+ hash[group] = group.examples.select { |example| wanted.include?(example.id) }
82
+ end
83
+ world.example_groups.flat_map(&:descendants).each(&:reset_memoized)
84
+ end
85
+
86
+ def execute(listener)
87
+ reporter = ::RSpec::Core::Reporter.new(configuration)
88
+ reporter.register_listener(listener, *Listener::EVENTS)
89
+ groups = world.ordered_example_groups.select { |group| group.descendant_filtered_examples.any? }
90
+ count = world.example_count(groups)
91
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
92
+ passed = reporter.report(count) do |r|
93
+ configuration.with_suite_hooks { groups.map { |group| group.run(r) }.all? }
94
+ end
95
+ listener.runtime = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
96
+ passed && !world.non_example_failure
97
+ end
98
+
99
+ class Listener
100
+ EVENTS = %i[example_started example_finished example_failed].freeze
101
+
102
+ MESSAGE_LIMIT = 200
103
+
104
+ attr_reader :executed, :failed, :failure_classes, :failure_messages
105
+ attr_accessor :runtime
106
+
107
+ def initialize(recorder = nil)
108
+ @recorder = recorder
109
+ @executed = []
110
+ @failed = []
111
+ @failure_classes = {}
112
+ @failure_messages = {}
113
+ @runtime = 0.0
114
+ end
115
+
116
+ def example_started(notification)
117
+ example = notification.example
118
+ @recorder&.test_started(example.id, File.expand_path(example.metadata[:rerun_file_path]))
119
+ end
120
+
121
+ def example_finished(notification)
122
+ @executed << notification.example.id
123
+ @recorder&.test_finished(notification.example.id)
124
+ end
125
+
126
+ def example_failed(notification)
127
+ @failed << notification.example.id
128
+ exception = notification.example.execution_result.exception
129
+ @failure_classes[notification.example.id] = exception&.class&.name
130
+ @failure_messages[notification.example.id] = first_line(exception&.message)
131
+ end
132
+
133
+ private
134
+
135
+ def first_line(message)
136
+ message.to_s.lines.map(&:strip).find { |line| !line.empty? }.to_s[0, MESSAGE_LIMIT]
137
+ end
138
+ end
139
+ end
140
+
141
+ register :rspec, RSpec
142
+ end
143
+ end
@@ -0,0 +1,26 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ # Public extension API: implement Base#mutations_for and yield Mutation
5
+ # objects built from Edit values. Stable across minor versions.
6
+ require_relative "edit"
7
+ require_relative "mutation"
8
+
9
+ module Saboteur
10
+ module MutationSource
11
+ # Extension point. A source yields Mutation objects for a subject.
12
+ # Built-in operators are one source; external sources (for example a
13
+ # service that proposes edits) implement the same method and are
14
+ # registered in config. Every edit must lie inside the subject's byte range.
15
+ class Base
16
+ def name
17
+ self.class.name
18
+ end
19
+
20
+ # subject responds to #id, #file, #start_byte, #end_byte, #source.
21
+ def mutations_for(_subject)
22
+ raise NotImplementedError, "#{self.class} must implement #mutations_for"
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,232 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "prism"
5
+ require_relative "base"
6
+
7
+ module Saboteur
8
+ module MutationSource
9
+ # The v1 operator set. Every mutation is a byte-range edit of the original
10
+ # file text; nothing is unparsed.
11
+ class Builtin < Base
12
+ DEFAULT_ARID = [
13
+ '\A(puts|pp|p|print|warn)\b',
14
+ '\A(::)?(Rails\.)?logger\b',
15
+ '\A(::)?(StatsD|Statsd|Datadog|NewRelic)\b',
16
+ '\.instrument\b'
17
+ ].freeze
18
+
19
+ # A comparison replacement: swap the operator, or put a literal in
20
+ # place of the whole comparison.
21
+ Swap = Data.define(:operator)
22
+ Literal = Data.define(:text)
23
+
24
+ # Sufficient relational replacements (Kaminski, Ammann, Offutt 2011):
25
+ # the others are subsumed by these. For == and != Ruby gets true/false:
26
+ # <= and >= raise on objects that are not Comparable, which only gives
27
+ # cheap crash kills.
28
+ COMPARISON = {
29
+ :< => [Swap["<="], Swap["!="], Literal["false"]], :<= => [Swap["<"], Swap["=="], Literal["true"]],
30
+ :> => [Swap[">="], Swap["!="], Literal["false"]], :>= => [Swap[">"], Swap["=="], Literal["true"]],
31
+ :== => [Literal["false"], Literal["true"]], :!= => [Literal["true"], Literal["false"]]
32
+ }.freeze
33
+ ARITHMETIC = { :+ => "-", :- => "+", :* => "/", :/ => "*" }.freeze
34
+ # Sufficient connector replacements (Just et al. 2012): each operand
35
+ # alone, then the constant the connector cannot produce from it.
36
+ ABSORBING = { "&&" => "false", "and" => "false", "||" => "true", "or" => "true" }.freeze
37
+ NON_NUMERIC = [Prism::StringNode, Prism::InterpolatedStringNode, Prism::XStringNode,
38
+ Prism::ArrayNode, Prism::HashNode, Prism::SymbolNode].freeze
39
+ OR_WRITE = [Prism::LocalVariableOrWriteNode, Prism::InstanceVariableOrWriteNode,
40
+ Prism::ClassVariableOrWriteNode, Prism::GlobalVariableOrWriteNode,
41
+ Prism::ConstantOrWriteNode, Prism::ConstantPathOrWriteNode,
42
+ Prism::CallOrWriteNode, Prism::IndexOrWriteNode].freeze
43
+ CATCH_ALL = %w[StandardError ::StandardError Exception ::Exception].freeze
44
+ SCOPE_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
45
+
46
+ def initialize(arid: DEFAULT_ARID)
47
+ super()
48
+ @arid = arid.map { |pattern| pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern) }
49
+ end
50
+
51
+ def name
52
+ "builtin"
53
+ end
54
+
55
+ def mutations_for(subject)
56
+ body = subject.node.body
57
+ @subject = subject
58
+ @found = []
59
+ coarse(body)
60
+ visit(body)
61
+ @found
62
+ ensure
63
+ @subject = nil
64
+ end
65
+
66
+ private
67
+
68
+ # --- coarse ---------------------------------------------------------
69
+
70
+ def coarse(body)
71
+ statements = body.is_a?(Prism::BeginNode) ? body.statements : body
72
+ return if statements.nil?
73
+
74
+ unless nothing_to_remove?(statements)
75
+ # A predicate's nil is just false; true is the value its tests must reject.
76
+ (@subject.name.end_with?("?") ? %w[true false] : %w[nil]).each do |value|
77
+ add("body_#{value}", statements, value, coarse: true)
78
+ end
79
+ end
80
+ add("body_raise", statements, "raise", coarse: true) unless rescues_everything?(body)
81
+ end
82
+
83
+ # Nothing but logging: replacing it changes nothing a test can see.
84
+ def nothing_to_remove?(statements)
85
+ statements.body.all? { |statement| arid?(statement) }
86
+ end
87
+
88
+ # A raise that the method's own rescue catches is absorbed by design. It
89
+ # would survive, and a surviving coarse mutant stops every fine mutant
90
+ # of the method, so it is not generated.
91
+ def rescues_everything?(body)
92
+ clause = body.is_a?(Prism::BeginNode) ? body.rescue_clause : nil
93
+ while clause
94
+ return true if clause.exceptions.empty? || clause.exceptions.any? { |e| CATCH_ALL.include?(e.slice) }
95
+
96
+ clause = clause.subsequent
97
+ end
98
+ false
99
+ end
100
+
101
+ # --- fine -----------------------------------------------------------
102
+
103
+ def visit(node)
104
+ return if node.nil?
105
+ return if SCOPE_NODES.any? { |klass| node.is_a?(klass) }
106
+
107
+ if node.is_a?(Prism::StatementsNode)
108
+ visit_statements(node)
109
+ return
110
+ end
111
+
112
+ mutate(node)
113
+ node.compact_child_nodes.each { |child| visit(child) }
114
+ end
115
+
116
+ # Statement removal applies only to sequences of two or more statements.
117
+ # A single statement is either the whole method body (body_nil covers
118
+ # it) or an expression slot: a ternary branch, an interpolation, the
119
+ # body of a modifier if. Replacing those with nil is not removing a
120
+ # statement and duplicates the condition and coarse operators.
121
+ def visit_statements(node)
122
+ statements = node.body
123
+ statements.each do |statement|
124
+ next if arid?(statement)
125
+
126
+ add("statement_removal", statement, "nil") if statements.size > 1 && removable?(statement)
127
+ visit(statement)
128
+ end
129
+ end
130
+
131
+ def mutate(node)
132
+ case node
133
+ when Prism::CallNode then mutate_call(node)
134
+ when Prism::AndNode, Prism::OrNode then mutate_boolean(node)
135
+ when Prism::IfNode, Prism::UnlessNode then mutate_condition(node.predicate, hang_on: nil)
136
+ when Prism::WhileNode then mutate_condition(node.predicate, hang_on: "true")
137
+ when Prism::UntilNode then mutate_condition(node.predicate, hang_on: "false")
138
+ when Prism::IntegerNode, Prism::FloatNode then mutate_number(node)
139
+ when *OR_WRITE then add("or_assign", node.operator_loc, "=")
140
+ end
141
+ end
142
+
143
+ def mutate_call(node)
144
+ if binary?(node)
145
+ COMPARISON.fetch(node.name, []).each do |replacement|
146
+ case replacement
147
+ when Swap then add("comparison", node.message_loc, replacement.operator)
148
+ when Literal then add("comparison", node, replacement.text)
149
+ end
150
+ end
151
+ arithmetic = ARITHMETIC[node.name]
152
+ add("arithmetic", node.message_loc, arithmetic) if arithmetic && numeric_operands?(node)
153
+ end
154
+ mutate_negation(node) if node.name == :! && node.receiver
155
+ add("safe_navigation", node.call_operator_loc, ".") if node.safe_navigation?
156
+ end
157
+
158
+ def binary?(node)
159
+ node.receiver && node.message_loc && node.call_operator_loc.nil? &&
160
+ node.arguments&.arguments&.size == 1 && node.block.nil?
161
+ end
162
+
163
+ def numeric_operands?(node)
164
+ [node.receiver, node.arguments.arguments.first].none? { |operand| NON_NUMERIC.any? { operand.is_a?(_1) } }
165
+ end
166
+
167
+ def mutate_negation(node)
168
+ add("negation_removal", node, node.receiver.slice)
169
+ end
170
+
171
+ # An operand of && / and binds at least as tightly as the expression it
172
+ # replaces, so it needs no parentheses (the re-parse check backs this).
173
+ def mutate_boolean(node)
174
+ [node.left, node.right].each { |operand| add("boolean_logic", node, operand.slice) }
175
+ add("boolean_logic", node, ABSORBING.fetch(node.operator_loc.slice))
176
+ end
177
+
178
+ # Replacing a loop condition with the value that never ends it would
179
+ # only produce timeouts, so that one is skipped.
180
+ def mutate_condition(predicate, hang_on:)
181
+ (%w[true false] - [hang_on]).each { |literal| add("condition_#{literal}", predicate, literal) }
182
+ end
183
+
184
+ def mutate_number(node)
185
+ value = node.value
186
+ candidates = [value + 1, value - 1, 0]
187
+ candidates.map! { |n| node.is_a?(Prism::FloatNode) ? n.to_f : n }
188
+ candidates.uniq.each do |n|
189
+ next if n == value
190
+
191
+ text = n.to_s
192
+ text = "(#{text})" if n.negative?
193
+ add("literal_number", node, text)
194
+ end
195
+ end
196
+
197
+ # --- helpers --------------------------------------------------------
198
+
199
+ # Removing nil changes nothing. Removing an if/unless without an else
200
+ # is the same mutant as forcing its condition (condition_false /
201
+ # condition_true), so it would only report the same gap twice.
202
+ def removable?(statement)
203
+ case statement
204
+ when Prism::NilNode then false
205
+ when Prism::IfNode then else_branch?(statement, :subsequent)
206
+ when Prism::UnlessNode then else_branch?(statement, :else_clause)
207
+ else true
208
+ end
209
+ end
210
+
211
+ def else_branch?(node, name)
212
+ !node.public_send(name).nil?
213
+ end
214
+
215
+ def arid?(node)
216
+ source = node.slice
217
+ @arid.any? { |pattern| pattern.match?(source) }
218
+ end
219
+
220
+ # node_or_location: a Prism node or location (both have offsets and slice).
221
+ def add(operator, node_or_location, replacement, coarse: false)
222
+ location = node_or_location
223
+ return if location.slice == replacement
224
+
225
+ edit = Edit.new(file: @subject.file, start_byte: location.start_offset,
226
+ end_byte: location.end_offset, replacement: replacement)
227
+ @found << Mutation.new(subject_id: @subject.id, operator: operator, edits: [edit],
228
+ coarse: coarse, source: name)
229
+ end
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,18 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module MutationSource
6
+ # A replacement of source bytes [start_byte, end_byte) in file with text.
7
+ # Offsets are byte offsets into the file as read from disk (UTF-8),
8
+ # matching Prism::Location#start_offset / #end_offset.
9
+ Edit = Data.define(:file, :start_byte, :end_byte, :replacement) do
10
+ def initialize(file:, start_byte:, end_byte:, replacement:)
11
+ raise ArgumentError, "start_byte must be >= 0" if start_byte.negative?
12
+ raise ArgumentError, "end_byte must be >= start_byte" if end_byte < start_byte
13
+
14
+ super
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,28 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "digest"
5
+
6
+ module Saboteur
7
+ module MutationSource
8
+ # One candidate change to one subject. subject_id is "Klass#meth" or "Klass.meth".
9
+ # coarse: true for whole-body replacements, which run before fine-grained ones.
10
+ Mutation = Data.define(:subject_id, :operator, :edits, :coarse, :source) do
11
+ def initialize(subject_id:, operator:, edits:, coarse: false, source: "builtin")
12
+ raise ArgumentError, "a mutation needs at least one edit" if edits.empty?
13
+
14
+ super(subject_id:, operator:, edits: edits.freeze, coarse:, source:)
15
+ end
16
+
17
+ # Stable id for the ignore list. Offsets are made relative to subject_start
18
+ # so edits elsewhere in the file do not change the id.
19
+ def digest(subject_start:, original_source:)
20
+ parts = edits.map do |edit|
21
+ original = original_source.byteslice(edit.start_byte, edit.end_byte - edit.start_byte)
22
+ [edit.start_byte - subject_start, original, edit.replacement].join("\0")
23
+ end
24
+ Digest::SHA256.hexdigest([subject_id, operator, *parts].join("\x1F"))[0, 16]
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,97 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Reporter
6
+ # Minimal unified diff between two method texts (LCS over lines). Methods
7
+ # are short, so the quadratic table is fine.
8
+ module Diff
9
+ CONTEXT = 3
10
+
11
+ module_function
12
+
13
+ def unified(original, mutated, path:, first_line: 1)
14
+ a = original.lines
15
+ b = mutated.lines
16
+ ops = operations(a, b)
17
+ hunks = hunks(ops)
18
+ return "" if hunks.empty?
19
+
20
+ out = "--- a/#{path}\n+++ b/#{path}\n"
21
+ hunks.each { |hunk| out << render(hunk, first_line) }
22
+ out
23
+ end
24
+
25
+ # [[:same|:del|:add, a_index, b_index, text]]
26
+ def operations(old_lines, new_lines)
27
+ a = old_lines
28
+ b = new_lines
29
+ table = lcs_table(a, b)
30
+ ops = []
31
+ i = 0
32
+ j = 0
33
+ while i < a.size || j < b.size
34
+ if i < a.size && j < b.size && a[i] == b[j]
35
+ ops << [:same, i, j, a[i]]
36
+ i += 1
37
+ j += 1
38
+ elsif i < a.size && (j >= b.size || table[i + 1][j] >= table[i][j + 1])
39
+ ops << [:del, i, j, a[i]]
40
+ i += 1
41
+ else
42
+ ops << [:add, i, j, b[j]]
43
+ j += 1
44
+ end
45
+ end
46
+ ops
47
+ end
48
+
49
+ def lcs_table(old_lines, new_lines)
50
+ a = old_lines
51
+ b = new_lines
52
+ table = Array.new(a.size + 1) { Array.new(b.size + 1, 0) }
53
+ (a.size - 1).downto(0) do |i|
54
+ (b.size - 1).downto(0) do |j|
55
+ table[i][j] = a[i] == b[j] ? table[i + 1][j + 1] + 1 : [table[i + 1][j], table[i][j + 1]].max
56
+ end
57
+ end
58
+ table
59
+ end
60
+
61
+ def hunks(ops)
62
+ changed = ops.each_index.reject { |index| ops[index][0] == :same }
63
+ return [] if changed.empty?
64
+
65
+ ranges = changed.map { |index| [[index - CONTEXT, 0].max, [index + CONTEXT, ops.size - 1].min] }
66
+ merged = ranges.each_with_object([]) do |(from, to), acc|
67
+ if acc.any? && from <= acc.last[1] + 1
68
+ acc.last[1] = [acc.last[1], to].max
69
+ else
70
+ acc << [from, to]
71
+ end
72
+ end
73
+ merged.map { |from, to| ops[from..to] }
74
+ end
75
+
76
+ def render(hunk, first_line)
77
+ a_start = hunk.first[1]
78
+ b_start = hunk.first[2]
79
+ a_count = hunk.count { |op| op[0] != :add }
80
+ b_count = hunk.count { |op| op[0] != :del }
81
+ out = "@@ -#{range(a_start + first_line, a_count)} +#{range(b_start + first_line, b_count)} @@\n"
82
+ hunk.each do |op, _, _, text|
83
+ prefix = { same: " ", del: "-", add: "+" }.fetch(op)
84
+ out << prefix << text
85
+ out << "\n" unless text.end_with?("\n")
86
+ end
87
+ out
88
+ end
89
+
90
+ def range(start, count)
91
+ return "#{start - 1},0" if count.zero?
92
+
93
+ count == 1 ? start.to_s : "#{start},#{count}"
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,116 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require "json"
6
+
7
+ module Saboteur
8
+ module Reporter
9
+ # JSON in the open mutation-testing-report-schema (schemaVersion 2), as
10
+ # read by the Stryker dashboard and mutation-testing-elements.
11
+ class Stryker
12
+ SCHEMA_VERSION = "2"
13
+ THRESHOLDS = { high: 80, low: 60 }.freeze
14
+
15
+ def initialize(path)
16
+ @path = path
17
+ end
18
+
19
+ def call(report)
20
+ FileUtils.mkdir_p(File.dirname(@path))
21
+ File.write(@path, JSON.generate(self.class.build(report)))
22
+ end
23
+
24
+ # Tests are listed once under testFiles with short ids; mutants refer to
25
+ # them by id in killedBy/coveredBy instead of repeating full test names.
26
+ def self.build(report)
27
+ test_ids = test_index(report.results)
28
+ files = report.results.group_by { |result| result.subject.file }.to_h do |file, results|
29
+ [relative(file, report.root), file_entry(results, test_ids)]
30
+ end
31
+ {
32
+ schemaVersion: SCHEMA_VERSION,
33
+ thresholds: THRESHOLDS,
34
+ projectRoot: report.root,
35
+ files: files,
36
+ testFiles: test_files(test_ids),
37
+ framework: { name: "saboteur", version: VERSION }
38
+ }.merge(report.seed ? { config: { seed: report.seed } } : {})
39
+ end
40
+
41
+ def self.test_index(results)
42
+ names = results.flat_map { |result| result.covered_by + result.killed_by }.uniq
43
+ names.each_with_index.to_h { |name, index| [name, index.to_s] }
44
+ end
45
+
46
+ # RSpec ids look like "./spec/a_spec.rb[1:2]"; anything else is grouped
47
+ # under an empty path.
48
+ def self.test_files(test_ids)
49
+ test_ids.group_by { |name, _| name[%r{\A\.?/?(.+?\.rb)\[}, 1].to_s }.transform_values do |pairs|
50
+ { tests: pairs.map { |name, id| { id: id, name: name } } }
51
+ end
52
+ end
53
+
54
+ def self.file_entry(results, test_ids)
55
+ source = results.first.subject.file_source.dup.force_encoding(Encoding::UTF_8)
56
+ { language: "ruby", source: source.scrub,
57
+ mutants: results.map { |result| mutant(result, source, test_ids) } }
58
+ end
59
+
60
+ def self.mutant(result, source, test_ids)
61
+ entry = {
62
+ id: result.digest,
63
+ mutatorName: result.mutation.operator,
64
+ replacement: result.mutation.edits.map(&:replacement).join(" | "),
65
+ location: location(result.mutation.edits.first, source),
66
+ status: status(result),
67
+ static: false
68
+ }
69
+ entry.merge(optional_fields(result, test_ids))
70
+ end
71
+
72
+ def self.optional_fields(result, test_ids)
73
+ {
74
+ statusReason: status_reason(result),
75
+ killedBy: result.killed_by.map { |name| test_ids.fetch(name) }.then { |ids| ids.empty? ? nil : ids },
76
+ coveredBy: result.covered_by.map { |name| test_ids.fetch(name) }.then { |ids| ids.empty? ? nil : ids },
77
+ duration: result.runtime && (result.runtime * 1000).round
78
+ }.compact
79
+ end
80
+
81
+ def self.status(result)
82
+ case result.status
83
+ when :killed then "Killed"
84
+ when :survived then "Survived"
85
+ when :timeout then "Timeout"
86
+ when :error, :resource_limit then "RuntimeError"
87
+ when :skipped then result.reason == "no coverage" ? "NoCoverage" : "Ignored"
88
+ end
89
+ end
90
+
91
+ def self.status_reason(result)
92
+ return "ignored: #{result.ignored}" if result.ignored
93
+ return "resource limit: #{result.reason}" if result.status == :resource_limit
94
+ return result.crash_kill? ? "crash: #{result.kill_cause}" : result.kill_cause if result.kill_cause
95
+
96
+ result.reason.to_s.lines.first&.strip
97
+ end
98
+
99
+ def self.location(edit, source)
100
+ { start: position(source, edit.start_byte), end: position(source, edit.end_byte) }
101
+ end
102
+
103
+ # 1-based line and column; column counts characters, not bytes.
104
+ def self.position(source, byte_offset)
105
+ before = source.byteslice(0, byte_offset).force_encoding(Encoding::UTF_8).scrub
106
+ line = before.count("\n") + 1
107
+ column = before.size - (before.rindex("\n") || -1)
108
+ { line: line, column: column }
109
+ end
110
+
111
+ def self.relative(file, root)
112
+ file.delete_prefix("#{root}/")
113
+ end
114
+ end
115
+ end
116
+ end