saboteur 0.3.0 → 0.4.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +113 -1
  3. data/README.md +248 -63
  4. data/lib/generators/saboteur/install/install_generator.rb +21 -0
  5. data/lib/saboteur/agent_skill.rb +40 -0
  6. data/lib/saboteur/cli.rb +150 -22
  7. data/lib/saboteur/config.rb +27 -15
  8. data/lib/saboteur/config_file.rb +97 -0
  9. data/lib/saboteur/custom_map.rb +170 -0
  10. data/lib/saboteur/doctor.rb +17 -1
  11. data/lib/saboteur/engine/baseline_guard.rb +20 -3
  12. data/lib/saboteur/engine/boot.rb +9 -0
  13. data/lib/saboteur/engine/coverage_store.rb +31 -4
  14. data/lib/saboteur/engine/data_inputs.rb +43 -0
  15. data/lib/saboteur/engine/diagnostics.rb +28 -0
  16. data/lib/saboteur/engine/diff_scope.rb +8 -0
  17. data/lib/saboteur/engine/run_fingerprint.rb +34 -6
  18. data/lib/saboteur/engine/scheduler.rb +7 -16
  19. data/lib/saboteur/engine/suite_files.rb +8 -1
  20. data/lib/saboteur/engine.rb +74 -13
  21. data/lib/saboteur/execution/reevaluator.rb +7 -4
  22. data/lib/saboteur/execution/supervisor.rb +23 -11
  23. data/lib/saboteur/frameworks/rails.rb +329 -13
  24. data/lib/saboteur/help.rb +33 -17
  25. data/lib/saboteur/ignore_list.rb +71 -17
  26. data/lib/saboteur/install.rb +132 -0
  27. data/lib/saboteur/integration/rspec.rb +47 -3
  28. data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
  29. data/lib/saboteur/mutation_source/builtin.rb +8 -2
  30. data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
  31. data/lib/saboteur/replay.rb +1 -1
  32. data/lib/saboteur/reporter/agent.rb +87 -0
  33. data/lib/saboteur/reporter/agent_clipping.rb +29 -0
  34. data/lib/saboteur/reporter/agent_commands.rb +61 -0
  35. data/lib/saboteur/reporter/agent_incomplete.rb +37 -0
  36. data/lib/saboteur/reporter/agent_summary.rb +131 -0
  37. data/lib/saboteur/reporter/findings.rb +8 -0
  38. data/lib/saboteur/reporter/html.rb +49 -1
  39. data/lib/saboteur/reporter/stryker.rb +11 -4
  40. data/lib/saboteur/reporter/terminal.rb +65 -2
  41. data/lib/saboteur/result.rb +46 -5
  42. data/lib/saboteur/score.rb +89 -0
  43. data/lib/saboteur/version.rb +1 -1
  44. data/lib/saboteur/working_tree.rb +48 -0
  45. data/lib/saboteur.rb +4 -0
  46. metadata +17 -2
@@ -0,0 +1,131 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "findings"
5
+ require_relative "agent_incomplete"
6
+ require_relative "agent_clipping"
7
+
8
+ module Saboteur
9
+ module Reporter
10
+ module Agent
11
+ # Builds the payload hash for one run: status, verdict, exit code, counts,
12
+ # provenance, a few findings and the next commands.
13
+ module Summary
14
+ class << self
15
+ include Clipping
16
+
17
+ # Public entry first; the helpers below are private.
18
+ def build(report, provenance, shown, snippet_limit)
19
+ incomplete = Incomplete.reasons(report)
20
+ status = status_of(report, incomplete)
21
+ verdict = verdict_of(report, status)
22
+ {
23
+ version: VERSION, status: status, verdict: verdict, exitCode: exit_code_for(verdict),
24
+ provenance: provenance_hash(provenance),
25
+ counts: counts(report), score: score(report),
26
+ scoreDefinition: "(killed + timeout) / (killed + survived + timeout); null when nothing was tested",
27
+ incomplete: incomplete, timeoutCountedAsDetected: report.counts[:timeout],
28
+ findings: findings(report, shown, snippet_limit),
29
+ warnings: warnings(report),
30
+ next: next_steps(report, provenance)
31
+ }
32
+ end
33
+
34
+ private
35
+
36
+ def status_of(report, incomplete)
37
+ return "not_applicable" if report.results.empty? && report.skipped_subjects.empty?
38
+
39
+ incomplete.empty? ? "complete" : "partial"
40
+ end
41
+
42
+ def verdict_of(report, status)
43
+ return "survivors" if report.unignored_survivors.any?
44
+
45
+ { "complete" => "clean", "partial" => "incomplete", "not_applicable" => "not_applicable" }.fetch(status)
46
+ end
47
+
48
+ def exit_code_for(verdict)
49
+ case verdict
50
+ when "clean" then EXIT_CLEAN
51
+ when "survivors" then EXIT_REVIEW
52
+ else EXIT_INCOMPLETE
53
+ end
54
+ end
55
+
56
+ def counts(report)
57
+ report.counts.transform_keys { |status| camel(status) }.merge(executed: report.executed.size)
58
+ end
59
+
60
+ def camel(status)
61
+ status.to_s.gsub(/_(\w)/) { Regexp.last_match(1).upcase }.to_sym
62
+ end
63
+
64
+ def score(report)
65
+ report.score&.round(4)
66
+ end
67
+
68
+ # Display values are clipped; the commands under next use the exact values or are left out.
69
+ def provenance_hash(provenance)
70
+ { ref: clip_value(provenance.ref, 120), seed: bounded_number(provenance.seed),
71
+ head: clip_value(provenance.head, 64),
72
+ workingTree: provenance.working_tree, changedFiles: provenance.changed_files,
73
+ mode: provenance.mode, selection: selection_hash(provenance) }
74
+ end
75
+
76
+ def selection_hash(provenance)
77
+ %i[subjects tests sources].to_h do |key|
78
+ list = provenance.public_send(key)
79
+ [key, { count: list.size, first: list.first(3).map { |entry| clip_value(entry, 120) } }]
80
+ end.merge(config: clip_value(provenance.config_path, 120), digest: provenance.digest,
81
+ settings: provenance.settings.transform_values { |value| clip_value(value, 120) })
82
+ end
83
+
84
+ def findings(report, shown, snippet_limit)
85
+ all = Findings.build(report)
86
+ live = report.unignored_survivors.size
87
+ {
88
+ survivors: live, shown: shown_findings(all[:survivors], shown, snippet_limit),
89
+ omitted: live - [shown, all[:survivors].size].min,
90
+ accepted: report.survivors.size - live, ordering: all[:ordering]
91
+ }
92
+ end
93
+
94
+ def shown_findings(listed, shown, snippet_limit)
95
+ listed.first(shown).map do |finding|
96
+ finding.slice(:digest, :line, :endLine, :operator, :coarse)
97
+ .merge(path: clip_value(finding[:path], 200), subject: clip_value(finding[:subject], 200),
98
+ original: clip(finding[:original], snippet_limit),
99
+ replacement: clip(finding[:replacement], snippet_limit))
100
+ end
101
+ end
102
+
103
+ def warnings(report)
104
+ report.warnings.first(WARNING_LIMIT).map { |warning| warning.to_s[0, WARNING_LENGTH] }
105
+ end
106
+
107
+ def next_steps(report, provenance)
108
+ steps = {}
109
+ add_command(steps, :rerun, Commands.rerun(provenance))
110
+ first = Findings.build(report)[:survivors].first
111
+ add_command(steps, :replay, Commands.replay(provenance, first[:digest])) if first
112
+ steps[:fullReport] = provenance.json_report if short_path?(provenance.json_report)
113
+ steps
114
+ end
115
+
116
+ def short_path?(path)
117
+ path && path.bytesize <= 400
118
+ end
119
+
120
+ def add_command(steps, key, command)
121
+ steps[key] = command
122
+ return if command
123
+
124
+ steps[:"#{key}Unavailable"] =
125
+ "command longer than #{Commands::MAX_BYTES} bytes; rebuild it from provenance"
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -76,9 +76,17 @@ module Saboteur
76
76
  edits: entries,
77
77
  coarse: result.mutation.coarse
78
78
  }
79
+ entry = with_changed_lines(report, result, entry)
79
80
  result.ignored ? entry.merge(reason: result.ignored) : entry
80
81
  end
81
82
 
83
+ # In diff mode, the changed lines the mutant touches. line above is where
84
+ # the mutated statement starts, which can be above the changed line.
85
+ def with_changed_lines(report, result, entry)
86
+ lines = report.changed_lines_for(result)
87
+ lines.empty? ? entry : entry.merge(changedLines: lines)
88
+ end
89
+
82
90
  # Subject#apply applies edits in start-byte order; findings use the
83
91
  # same order, so an unsorted edit list from a custom mutation source
84
92
  # still gets a sane range and one entry per edit.
@@ -76,6 +76,7 @@ module Saboteur
76
76
  <p class="note">Covered mutation score = #{SCORE_FORMULA}. NoCoverage, errors, resource limits and skipped
77
77
  mutants are not counted. Survivors accepted on the ignore list stay in the denominator. It covers only the subjects of this run, not the whole project. A tool that reads the
78
78
  Stryker JSON may show a different score, because the schema counts NoCoverage as undetected.</p>
79
+ #{score_split(report)}
79
80
  <p class="note">Wide tables scroll sideways.</p>
80
81
  <p>#{counts.map { |status, count| "#{status_label(status)} #{count}" }.join(' ')}</p>
81
82
  <p>Survivors: #{report.unignored_survivors.size} to fix, #{report.survivors.size - report.unignored_survivors.size}
@@ -83,6 +84,37 @@ module Saboteur
83
84
  HTML
84
85
  end
85
86
 
87
+ # How the score splits into assertion and crash kills, and the score
88
+ # without the whole-body mutants, which almost any test that runs the
89
+ # method kills. Each part shows only when it has something to say.
90
+ def score_split(report)
91
+ breakdown = report.score_breakdown
92
+ lines = []
93
+ lines << kill_split_line(breakdown) if breakdown.split_known?
94
+ lines << whole_body_line(report)
95
+ lines.compact.join("\n")
96
+ end
97
+
98
+ def kill_split_line(breakdown)
99
+ "<p>Of the tested mutants: #{parts_text(breakdown)}. A crash shows the code ran and broke, " \
100
+ "not that a test checked a value.</p>"
101
+ end
102
+
103
+ def parts_text(breakdown)
104
+ breakdown.parts.map { |label, share| "#{score_text(share)} #{label}" }.join(", ")
105
+ end
106
+
107
+ def whole_body_line(report)
108
+ whole = report.coarse_score_breakdown
109
+ fine = report.fine_score_breakdown
110
+ return if whole.tested.zero? || fine.tested.zero?
111
+
112
+ split = fine.split_known? ? " (#{parts_text(fine)})" : ""
113
+ "<p>Without whole-body mutants (body_nil, body_true, body_false, body_raise): #{score_text(fine.value)}" \
114
+ "#{split} over #{fine.tested} mutants. Whole-body mutants alone: #{score_text(whole.value)} " \
115
+ "over #{whole.tested}.</p>"
116
+ end
117
+
86
118
  def score_text(score)
87
119
  score ? format("%.1f%%", score * 100) : "n/a"
88
120
  end
@@ -126,13 +158,29 @@ module Saboteur
126
158
  def mutant_row(mutant, names, result)
127
159
  start = mutant[:location][:start]
128
160
  tests = Array(mutant[:killedBy]).map { |id| names.fetch(id) }.join(", ")
129
- notes = [mutant[:statusReason], tests.empty? ? nil : "killed by #{tests}"].compact.join("; ")
161
+ notes = [mutant[:statusReason], tests.empty? ? nil : "killed by #{tests}",
162
+ passing_tests_note(mutant, names, result)].compact.join("; ")
130
163
  "<tr><td class=\"fit\">line #{start[:line]}, column #{start[:column]}</td><td>#{row_status(mutant,
131
164
  result)}</td>" \
132
165
  "<td>#{escape(mutant[:mutatorName])}</td><td class=\"wide\">#{change(mutant, result)}</td>" \
133
166
  "<td class=\"fit\"><code>#{escape(mutant[:id])}</code></td><td class=\"wide\">#{escape(notes)}</td></tr>"
134
167
  end
135
168
 
169
+ # A survivor's covering tests all passed with the mutant applied, or, for
170
+ # a reused verdict, in the earlier run it came from.
171
+ def passing_tests_note(mutant, names, result)
172
+ return unless mutant[:status] == "Survived"
173
+
174
+ passed = Array(mutant[:coveredBy]).map { |id| names.fetch(id) }
175
+ return if passed.empty?
176
+
177
+ shown = passed.first(Terminal::MAX_PASSING_TESTS).join(", ")
178
+ more = passed.size - Terminal::MAX_PASSING_TESTS
179
+ extra = more.positive? ? " (+#{more} more)" : ""
180
+ when_passed = result.reused? ? "passed in an earlier run" : "passed"
181
+ "covered by #{passed.size} #{passed.one? ? 'test' : 'tests'} that #{when_passed}: #{shown}#{extra}"
182
+ end
183
+
136
184
  def row_status(mutant, result)
137
185
  badge = status_label(mutant[:status])
138
186
  # Only survivors are ever put on the ignore list.
@@ -16,14 +16,14 @@ module Saboteur
16
16
  @path = path
17
17
  end
18
18
 
19
- def call(report)
19
+ def call(report, exit_cause: nil)
20
20
  FileUtils.mkdir_p(File.dirname(@path))
21
- File.write(@path, JSON.generate(self.class.build(report)))
21
+ File.write(@path, JSON.generate(self.class.build(report, exit_cause: exit_cause)))
22
22
  end
23
23
 
24
24
  # Tests are listed once under testFiles with short ids; mutants refer to
25
25
  # them by id in killedBy/coveredBy instead of repeating full test names.
26
- def self.build(report)
26
+ def self.build(report, exit_cause: nil)
27
27
  test_ids = test_index(report.results)
28
28
  files = report.results.group_by { |result| result.subject.file }.to_h do |file, results|
29
29
  [relative(file, report.root), file_entry(results, test_ids)]
@@ -38,10 +38,17 @@ module Saboteur
38
38
  rejectedKills: report.rejected_kills,
39
39
  warnings: report.warnings,
40
40
  framework: { name: "saboteur", version: VERSION },
41
- saboteur: { findings: Findings.build(report) }
41
+ saboteur: { findings: Findings.build(report), scores: scores(report) }
42
+ .merge(exit_cause ? { exitCause: exit_cause } : {})
42
43
  }.merge(report.seed ? { config: { seed: report.seed } } : {})
43
44
  end
44
45
 
46
+ # Score with its assertion and crash shares, for all mutants and without
47
+ # the whole-body ones (body_nil, body_true, body_false, body_raise).
48
+ def self.scores(report)
49
+ { all: report.score_breakdown.to_h, withoutWholeBodyMutants: report.fine_score_breakdown.to_h }
50
+ end
51
+
45
52
  def self.skipped_subjects(report)
46
53
  report.skipped_subjects.map do |subject, reason|
47
54
  { subject: subject.id, file: relative(subject.file, report.root), line: subject.start_line, reason: reason }
@@ -7,6 +7,8 @@ module Saboteur
7
7
  # errors, then a unified diff for each survivor.
8
8
  class Terminal
9
9
  MAX_ERRORS = 10
10
+ MAX_PASSING_TESTS = 5
11
+ MAX_CHANGED_LINES = 3
10
12
 
11
13
  def initialize(out)
12
14
  @out = out
@@ -40,9 +42,10 @@ module Saboteur
40
42
  end
41
43
 
42
44
  def details(report)
43
- score = report.score
44
- @out.puts "score #{score ? format('%.1f %%', score * 100) : 'n/a'} " \
45
+ breakdown = report.score_breakdown
46
+ @out.puts "score #{percent(breakdown.value)}#{score_split(breakdown)} " \
45
47
  "(killed+timeout)/(killed+survived+timeout); errors, resource limits and skips excluded"
48
+ whole_body_score(report)
46
49
  mean = report.mean_runtime
47
50
  @out.puts "mean time per executed mutant #{format('%.3f', mean)} s" if mean
48
51
  @out.puts "invalid mutations dropped (did not parse): #{report.invalid_mutations}" if
@@ -55,6 +58,31 @@ module Saboteur
55
58
  kill_causes(report)
56
59
  end
57
60
 
61
+ # " (assertion 50.0 %, crash 50.0 %)": how much of the score is a failed
62
+ # check and how much is a crash. Timeouts and kills with no recorded
63
+ # cause are named only when there are some, so the parts add up.
64
+ def score_split(breakdown)
65
+ return "" unless breakdown.split_known?
66
+
67
+ " (#{breakdown.parts.map { |label, share| "#{label} #{percent(share)}" }.join(', ')})"
68
+ end
69
+
70
+ # Whole-body mutants (body_nil, body_true, body_false, body_raise) are
71
+ # killed by almost any test that runs the method, so they get their own
72
+ # line.
73
+ def whole_body_score(report)
74
+ whole = report.coarse_score_breakdown
75
+ fine = report.fine_score_breakdown
76
+ return if whole.tested.zero? || fine.tested.zero?
77
+
78
+ @out.puts "score without whole-body mutants #{percent(fine.value)}#{score_split(fine)} " \
79
+ "(#{fine.tested} mutants); whole-body mutants alone #{percent(whole.value)} (#{whole.tested})"
80
+ end
81
+
82
+ def percent(share)
83
+ share ? format("%.1f %%", share * 100) : "n/a"
84
+ end
85
+
58
86
  # How many kills came from a failed check and how many from a crash, and
59
87
  # which operators mostly kill by crashing (their score says less).
60
88
  def kill_causes(report)
@@ -114,12 +142,47 @@ module Saboteur
114
142
  shown(report).each do |result, hidden|
115
143
  location = "#{relative(result.subject.file, report.root)}:#{result.subject.start_line}"
116
144
  @out.puts "\n#{result.subject.id} #{result.mutation.operator} #{result.digest} #{location}"
145
+ changed_lines(report, result)
117
146
  @out.puts "ignored: #{result.ignored}" if result.ignored
118
147
  @out.puts result.diff(root: report.root)
148
+ passing_tests(result)
119
149
  @out.puts "#{hidden} more survivor#{'s' if hidden > 1} on this line in the JSON report" if hidden.positive?
120
150
  end
121
151
  end
122
152
 
153
+ # The tests that ran with the mutant applied and still passed: the
154
+ # missing assertion belongs in one of them. They are listed likeliest
155
+ # killers first, the order the run used. Line coverage can overstate which
156
+ # tests exercise the line, so the list is a lead, not proof.
157
+ def passing_tests(result)
158
+ tests = result.covered_by
159
+ return if tests.empty?
160
+
161
+ @out.puts "#{tests.size} #{tests.one? ? 'test' : 'tests'} covered this line and #{passed_phrase(result)} " \
162
+ "(the missing check may belong in one of them):"
163
+ tests.first(MAX_PASSING_TESTS).each { |name| @out.puts " #{name}" }
164
+ more = tests.size - MAX_PASSING_TESTS
165
+ @out.puts " ... #{more} more in the JSON report (coveredBy)" if more.positive?
166
+ end
167
+
168
+ # A reused verdict was not run again: its tests passed in the earlier run.
169
+ def passed_phrase(result)
170
+ result.reused? ? "passed in an earlier run (--incremental)" : "still passed"
171
+ end
172
+
173
+ # In diff mode: the changed lines this mutant touches. The location above
174
+ # is where the mutated statement starts, which can be a line or two above
175
+ # the line that changed.
176
+ def changed_lines(report, result)
177
+ lines = report.changed_lines_for(result)
178
+ return if lines.empty?
179
+
180
+ path = relative(result.subject.file, report.root)
181
+ shown = lines.first(MAX_CHANGED_LINES).map { |line| "#{path}:#{line}" }.join(", ")
182
+ more = lines.size - MAX_CHANGED_LINES
183
+ @out.puts "changed #{lines.one? ? 'line' : 'lines'}: #{shown}#{" (+#{more} more)" if more.positive?}"
184
+ end
185
+
123
186
  # [[result, how many more on the same line]]. In review mode survivors
124
187
  # on one line usually share a fate, so one is shown: live before
125
188
  # ignored, then coarse before fine, then the earliest edit.
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require_relative "reporter/diff"
5
+ require_relative "score"
5
6
 
6
7
  module Saboteur
7
8
  # resource_limit: the mutant's process tree hit the memory or process cap.
@@ -59,6 +60,11 @@ module Saboteur
59
60
  status == :killed && !kill_cause.nil? && kill_cause != "assertion"
60
61
  end
61
62
 
63
+ # Killed because a test's check failed: the test looked at a value.
64
+ def assertion_kill?
65
+ status == :killed && kill_cause == "assertion"
66
+ end
67
+
62
68
  # Taken from an earlier run (--incremental) instead of run again.
63
69
  def reused?
64
70
  @reused
@@ -84,12 +90,16 @@ module Saboteur
84
90
 
85
91
  # Everything one run produced.
86
92
  class Report
93
+ # More than half of the executed mutants errored: the run tested
94
+ # almost nothing and must not pass a gate.
95
+ MAX_ERROR_FRACTION = 0.5
96
+
87
97
  attr_reader :results, :subjects, :wall_time, :invalid_mutations, :warnings, :root, :seed, :skipped_subjects
88
98
 
89
99
  # REVIEW: diff mode; the terminal then shows one survivor per line.
90
100
  # seed: the random seed every test run started from (nil when unknown).
91
101
  def initialize(results:, subjects:, wall_time:, root:, invalid_mutations: 0, warnings: [], review: false,
92
- seed: nil, skipped_subjects: {})
102
+ seed: nil, skipped_subjects: {}, diff_scope: nil)
93
103
  @results = results
94
104
  @subjects = subjects
95
105
  @wall_time = wall_time
@@ -99,12 +109,30 @@ module Saboteur
99
109
  @review = review
100
110
  @seed = seed
101
111
  @skipped_subjects = skipped_subjects
112
+ @diff_scope = diff_scope
113
+ end
114
+
115
+ # In diff mode, the changed lines (sorted) that a mutant touches; empty
116
+ # outside diff mode, or when the mutant touches none (a whole-body mutant
117
+ # of a method whose only change is its signature).
118
+ def changed_lines_for(result)
119
+ @diff_scope ? @diff_scope.changed_lines_for(result.subject, result.mutation) : []
102
120
  end
103
121
 
104
122
  def rejected_kills
105
123
  results.count(&:rejected_kill?)
106
124
  end
107
125
 
126
+ # Errored mutants (a crash, a neutral check that failed) are outside
127
+ # the score, so a run whose executed mutants mostly errored tested
128
+ # almost nothing. Skipped mutants never ran: they are neither the
129
+ # numerator nor the denominator.
130
+ def majority_errored?
131
+ return false if executed.empty?
132
+
133
+ executed.count { |result| result.status == :error }.fdiv(executed.size) > MAX_ERROR_FRACTION
134
+ end
135
+
108
136
  def review?
109
137
  @review
110
138
  end
@@ -115,10 +143,23 @@ module Saboteur
115
143
 
116
144
  # (killed + timeout) / (killed + survived + timeout); nil with nothing tested.
117
145
  def score
118
- c = counts
119
- detected = c[:killed] + c[:timeout]
120
- tested = detected + c[:survived]
121
- tested.zero? ? nil : detected.fdiv(tested)
146
+ score_breakdown.value
147
+ end
148
+
149
+ # The score with its split into assertion kills and crash kills.
150
+ def score_breakdown
151
+ @score_breakdown ||= Score.of(results)
152
+ end
153
+
154
+ # The same breakdown without the whole-body mutants (every
155
+ # mutation marked coarse: body_nil, body_true, body_false, body_raise). Almost any test that runs the method kills
156
+ # those, so they flatter the score.
157
+ def fine_score_breakdown
158
+ @fine_score_breakdown ||= Score.of(results.reject { |result| result.mutation.coarse })
159
+ end
160
+
161
+ def coarse_score_breakdown
162
+ @coarse_score_breakdown ||= Score.of(results.select { |result| result.mutation.coarse })
122
163
  end
123
164
 
124
165
  def survivors
@@ -0,0 +1,89 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ # The mutation score of a group of results, with the share of it that came
6
+ # from failed assertions and the share that came from crashes.
7
+ #
8
+ # value is (killed + timeout) / (killed + survived + timeout). A crash kill
9
+ # shows the code ran and broke, not that a test checked a value, so the
10
+ # split lets a reader see how much of the score is checked behavior.
11
+ # Errors, resource limits and skips are outside every number here.
12
+ class Score
13
+ def self.of(results)
14
+ new(results)
15
+ end
16
+
17
+ attr_reader :assertion_kills, :crash_kills, :unclassified_kills, :timeouts, :survivors
18
+
19
+ def initialize(results)
20
+ killed = results.select { |result| result.status == :killed }
21
+ @assertion_kills = killed.count(&:assertion_kill?)
22
+ @crash_kills = killed.count(&:crash_kill?)
23
+ @unclassified_kills = killed.size - assertion_kills - crash_kills
24
+ @timeouts = results.count { |result| result.status == :timeout }
25
+ @survivors = results.count(&:survived?)
26
+ end
27
+
28
+ def detected
29
+ assertion_kills + crash_kills + unclassified_kills + timeouts
30
+ end
31
+
32
+ def tested
33
+ detected + survivors
34
+ end
35
+
36
+ # nil with nothing tested.
37
+ def value
38
+ share(detected)
39
+ end
40
+
41
+ def assertion_share
42
+ share(assertion_kills)
43
+ end
44
+
45
+ def crash_share
46
+ share(crash_kills)
47
+ end
48
+
49
+ def unclassified_share
50
+ share(unclassified_kills)
51
+ end
52
+
53
+ def timeout_share
54
+ share(timeouts)
55
+ end
56
+
57
+ # False when no kill has a recorded cause: a split would then say
58
+ # nothing. A cause is missing for verdicts reused from a run made before
59
+ # causes were recorded, for a test that called exit, and for a failure
60
+ # outside any example (suite level).
61
+ def split_known?
62
+ assertion_kills.positive? || crash_kills.positive?
63
+ end
64
+
65
+ # [[label, share]]: assertion and crash always, then timeout and
66
+ # "cause not recorded" only when there are some, so the parts add up to
67
+ # value.
68
+ def parts
69
+ list = [["assertion", assertion_share], ["crash", crash_share]]
70
+ list << ["timeout", timeout_share] if timeouts.positive?
71
+ list << ["cause not recorded", unclassified_share] if unclassified_kills.positive?
72
+ list
73
+ end
74
+
75
+ def to_h
76
+ {
77
+ score: value, tested: tested, assertionKills: assertion_kills, crashKills: crash_kills,
78
+ unclassifiedKills: unclassified_kills, timeouts: timeouts, survivors: survivors,
79
+ assertionShare: assertion_share, crashShare: crash_share
80
+ }
81
+ end
82
+
83
+ private
84
+
85
+ def share(count)
86
+ tested.zero? ? nil : count.fdiv(tested)
87
+ end
88
+ end
89
+ end
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Saboteur
5
- VERSION = "0.3.0"
5
+ VERSION = "0.4.0"
6
6
  end
@@ -0,0 +1,48 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "open3"
5
+
6
+ module Saboteur
7
+ # The state of the working source a run looked at: the commit it was on and
8
+ # (the full SHA) and whether files differed from it, tracked changes and each
9
+ # untracked file alike.
10
+ # Saboteur's own output (cache and reports) is passed in as ignored paths, so a
11
+ # report from an earlier run does not make the tree look dirty. Without git, or
12
+ # outside a repository, the state is unknown.
13
+ class WorkingTree
14
+ State = Data.define(:head, :dirty, :changed_files) do
15
+ def label
16
+ return "unknown" if dirty.nil?
17
+
18
+ dirty ? "dirty" : "clean"
19
+ end
20
+ end
21
+
22
+ UNKNOWN = State.new(head: nil, dirty: nil, changed_files: nil)
23
+
24
+ # ignored: paths relative to root that are not source (saboteur's cache and reports).
25
+ def initialize(root, ignored: [])
26
+ @root = root
27
+ @ignored = ignored
28
+ end
29
+
30
+ def state
31
+ head, head_status = Open3.capture2e("git", "rev-parse", "HEAD", chdir: @root)
32
+ changes, changes_status = Open3.capture2e(*status_command, chdir: @root)
33
+ return UNKNOWN unless head_status.success? && changes_status.success?
34
+
35
+ count = changes.lines.size
36
+ State.new(head: head.strip, dirty: count.positive?, changed_files: count)
37
+ rescue SystemCallError
38
+ UNKNOWN
39
+ end
40
+
41
+ private
42
+
43
+ def status_command
44
+ excluded = @ignored.map { |path| ":(exclude)#{path}" }
45
+ ["git", "status", "--porcelain", "--untracked-files=all", "--", ".", *excluded]
46
+ end
47
+ end
48
+ end
data/lib/saboteur.rb CHANGED
@@ -43,11 +43,15 @@ require_relative "saboteur/selection/coverage_map"
43
43
  require_relative "saboteur/selection/recorder"
44
44
  require_relative "saboteur/selection/git_diff"
45
45
  require_relative "saboteur/result"
46
+ require_relative "saboteur/custom_map"
46
47
  require_relative "saboteur/config"
47
48
  require_relative "saboteur/ignore_list"
48
49
  require_relative "saboteur/frameworks/rails"
49
50
  require_relative "saboteur/reporter/diff"
50
51
  require_relative "saboteur/reporter/findings"
52
+ require_relative "saboteur/reporter/agent"
53
+ require_relative "saboteur/working_tree"
54
+ require_relative "saboteur/agent_skill"
51
55
  require_relative "saboteur/reporter/terminal"
52
56
  require_relative "saboteur/reporter/stryker"
53
57
  require_relative "saboteur/reporter/html"