saboteur 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.
@@ -0,0 +1,165 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "cgi"
5
+ require "fileutils"
6
+
7
+ module Saboteur
8
+ module Reporter
9
+ # One self-contained HTML page: inline style, no script, no network. It is
10
+ # rendered from the same hash the JSON reporter writes (Stryker.build), so
11
+ # both reports list the same mutants with the same statuses. The scores can
12
+ # differ: the JSON schema counts NoCoverage as undetected, while this page
13
+ # shows Report#score, which leaves it out (see the "Covered mutation score"
14
+ # label on the page). The score note says what is and is not counted.
15
+ class Html
16
+ STYLE = <<~CSS
17
+ body{font:14px/1.5 system-ui,sans-serif;margin:1rem auto;max-width:72rem;padding:0 1rem;color:#1b1b1b}
18
+ h1,h2{margin:1.2em 0 .4em}table{border-collapse:collapse;width:100%;min-width:60rem}
19
+ th,td{border-bottom:1px solid #ddd;padding:.25rem .5rem;text-align:left;vertical-align:top;overflow-wrap:break-word}
20
+ td.fit{white-space:nowrap}td.wide{min-width:14rem}
21
+ th{background:#f4f4f4;font-size:12px;text-transform:uppercase}
22
+ .scroll{overflow-x:auto}.note{color:#555;font-size:12px}
23
+ .status{border-radius:3px;padding:0 .4rem;color:#fff;white-space:nowrap}
24
+ .Killed{background:#2e7d32}.Survived{background:#c62828}.Timeout{background:#b45309}
25
+ .RuntimeError{background:#6a1b9a}.NoCoverage{background:#455a64}.Ignored{background:#616161}
26
+ .Accepted{background:#8d6e63}
27
+ .warning{background:#fff8e1;border-left:4px solid #f9a825;margin:.3rem 0;padding:.3rem .6rem;overflow-wrap:anywhere}
28
+ code,pre{font-family:ui-monospace,monospace;font-size:13px}
29
+ pre{display:inline-block;vertical-align:top;margin:0;white-space:pre-wrap;overflow-wrap:anywhere}summary{cursor:pointer;font-weight:600;overflow-wrap:anywhere}
30
+ CSS
31
+
32
+ SCORE_FORMULA = "(killed + timeout) / (killed + survived + timeout)"
33
+
34
+ def initialize(path)
35
+ @path = path
36
+ end
37
+
38
+ def call(report)
39
+ FileUtils.mkdir_p(File.dirname(@path))
40
+ File.write(@path, self.class.build(report))
41
+ end
42
+
43
+ def self.build(report)
44
+ new(nil).build(report)
45
+ end
46
+
47
+ def build(report)
48
+ data = Stryker.build(report)
49
+ names = test_names(data)
50
+ results = report.results.to_h do |result|
51
+ [[Stryker.relative(result.subject.file, report.root), result.digest], result]
52
+ end
53
+ <<~HTML
54
+ <!DOCTYPE html>
55
+ <html lang="en"><head><meta charset="utf-8">
56
+ <meta name="viewport" content="width=device-width, initial-scale=1">
57
+ <title>saboteur report</title>
58
+ <style>#{STYLE}</style></head><body>
59
+ <h1>saboteur report</h1>
60
+ #{summary(report, data)}#{warnings(data[:warnings])}#{skipped_subjects(data[:skippedSubjects])}
61
+ <h2>Files</h2>
62
+ #{data[:files].map { |file, entry| file_section(file, entry, names, results) }.join("\n")}
63
+ </body></html>
64
+ HTML
65
+ end
66
+
67
+ private
68
+
69
+ def summary(report, data)
70
+ seed = data.dig(:config, :seed)
71
+ seed_note = seed ? ", seed #{seed}" : ""
72
+ counts = data[:files].values.flat_map { |entry| entry[:mutants] }.map { |mutant| mutant[:status] }.tally
73
+ <<~HTML
74
+ <p><strong>Covered mutation score: #{score_text(report.score)}</strong>
75
+ (#{report.subjects.size} subjects, #{report.results.size} mutants, #{format('%.1f', report.wall_time)}s#{seed_note})</p>
76
+ <p class="note">Covered mutation score = #{SCORE_FORMULA}. NoCoverage, errors, resource limits and skipped
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
+ Stryker JSON may show a different score, because the schema counts NoCoverage as undetected.</p>
79
+ <p class="note">Wide tables scroll sideways.</p>
80
+ <p>#{counts.map { |status, count| "#{status_label(status)} #{count}" }.join(' ')}</p>
81
+ <p>Survivors: #{report.unignored_survivors.size} to fix, #{report.survivors.size - report.unignored_survivors.size}
82
+ accepted on the ignore list (still shown as Survived, with the reason).</p>
83
+ HTML
84
+ end
85
+
86
+ def score_text(score)
87
+ score ? format("%.1f%%", score * 100) : "n/a"
88
+ end
89
+
90
+ def warnings(messages)
91
+ return "" if messages.empty?
92
+
93
+ items = messages.map { |message| %(<div class="warning">#{escape(message)}</div>) }
94
+ "<h2>Warnings</h2>\n#{items.join("\n")}"
95
+ end
96
+
97
+ def skipped_subjects(entries)
98
+ return "" if entries.empty?
99
+
100
+ rows = entries.map do |entry|
101
+ "<tr><td>#{escape(entry[:subject])}</td><td>#{escape(entry[:file])}:#{entry[:line]}</td>" \
102
+ "<td>#{escape(entry[:reason])}</td></tr>"
103
+ end
104
+ "<h2>Skipped subjects</h2>\n<div class=\"scroll\"><table><thead><tr><th>Subject</th><th>Location</th>" \
105
+ "<th>Reason</th></tr></thead>#{rows.join}</table></div>"
106
+ end
107
+
108
+ def test_names(data)
109
+ data[:testFiles].values.flat_map { |group| group[:tests] }.to_h { |test| [test[:id], test[:name]] }
110
+ end
111
+
112
+ def file_section(file, entry, names, results)
113
+ open = entry[:mutants].any? { |mutant| mutant[:status] == "Survived" } ? " open" : ""
114
+ ordered = entry[:mutants].sort_by { |mutant| position_key(mutant) }
115
+ rows = ordered.map { |mutant| mutant_row(mutant, names, results[[file, mutant[:id]]]) }
116
+ "<details#{open}><summary>#{escape(file)} (#{entry[:mutants].size} mutants)</summary>\n" \
117
+ "<div class=\"scroll\"><table><thead><tr><th>Location</th><th>Status</th><th>Operator</th><th>Change</th>" \
118
+ "<th>Id</th><th>Notes</th></tr></thead>\n#{rows.join("\n")}</table></div></details>"
119
+ end
120
+
121
+ def position_key(mutant)
122
+ start = mutant[:location][:start]
123
+ [start[:line], start[:column], mutant[:id]]
124
+ end
125
+
126
+ def mutant_row(mutant, names, result)
127
+ start = mutant[:location][:start]
128
+ tests = Array(mutant[:killedBy]).map { |id| names.fetch(id) }.join(", ")
129
+ notes = [mutant[:statusReason], tests.empty? ? nil : "killed by #{tests}"].compact.join("; ")
130
+ "<tr><td class=\"fit\">line #{start[:line]}, column #{start[:column]}</td><td>#{row_status(mutant,
131
+ result)}</td>" \
132
+ "<td>#{escape(mutant[:mutatorName])}</td><td class=\"wide\">#{change(mutant, result)}</td>" \
133
+ "<td class=\"fit\"><code>#{escape(mutant[:id])}</code></td><td class=\"wide\">#{escape(notes)}</td></tr>"
134
+ end
135
+
136
+ def row_status(mutant, result)
137
+ badge = status_label(mutant[:status])
138
+ # Only survivors are ever put on the ignore list.
139
+ result.ignored ? "#{badge} #{status_label('Accepted')}" : badge
140
+ end
141
+
142
+ # One edit shows the original text and its replacement. A mutant with
143
+ # several edits shows only the replacements, because one location cannot
144
+ # stand for all of them.
145
+ def change(mutant, result)
146
+ edits = result.mutation.edits
147
+ return "<pre>#{escape(mutant[:replacement])}</pre>" unless edits.size == 1
148
+
149
+ source = result.subject.file_source
150
+ edit = edits.first
151
+ original = source.byteslice(edit.start_byte, edit.end_byte - edit.start_byte).to_s
152
+ "<pre>#{escape(original.dup.force_encoding(Encoding::UTF_8).scrub)}</pre> becomes " \
153
+ "<pre>#{escape(edit.replacement)}</pre>"
154
+ end
155
+
156
+ def status_label(status)
157
+ %(<span class="status #{status}">#{status}</span>)
158
+ end
159
+
160
+ def escape(text)
161
+ CGI.escapeHTML(text.to_s)
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,23 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Reporter
6
+ # A replay is an inspection too, regardless of the resulting verdict.
7
+ class Replay
8
+ def initialize(out)
9
+ @out = out
10
+ end
11
+
12
+ def call(report)
13
+ report.results.each do |result|
14
+ @out.puts "\nReplay: #{result.subject.id} #{result.mutation.operator} #{result.digest} (#{result.status})"
15
+ @out.puts result.diff(root: report.root)
16
+ @out.puts "Selected tests:"
17
+ result.covered_by.each { |name| @out.puts " #{name}" }
18
+ @out.puts " none" if result.covered_by.empty?
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -34,10 +34,20 @@ module Saboteur
34
34
  projectRoot: report.root,
35
35
  files: files,
36
36
  testFiles: test_files(test_ids),
37
- framework: { name: "saboteur", version: VERSION }
37
+ skippedSubjects: skipped_subjects(report),
38
+ rejectedKills: report.rejected_kills,
39
+ warnings: report.warnings,
40
+ framework: { name: "saboteur", version: VERSION },
41
+ saboteur: { findings: Findings.build(report) }
38
42
  }.merge(report.seed ? { config: { seed: report.seed } } : {})
39
43
  end
40
44
 
45
+ def self.skipped_subjects(report)
46
+ report.skipped_subjects.map do |subject, reason|
47
+ { subject: subject.id, file: relative(subject.file, report.root), line: subject.start_line, reason: reason }
48
+ end
49
+ end
50
+
41
51
  def self.test_index(results)
42
52
  names = results.flat_map { |result| result.covered_by + result.killed_by }.uniq
43
53
  names.each_with_index.to_h { |name, index| [name, index.to_s] }
@@ -14,6 +14,7 @@ module Saboteur
14
14
 
15
15
  def call(report)
16
16
  summary(report)
17
+ skipped_subjects(report)
17
18
  warnings(report)
18
19
  errors(report)
19
20
  survivors(report)
@@ -50,6 +51,7 @@ module Saboteur
50
51
  reused = report.results.count(&:reused?)
51
52
  @out.puts "reused #{reused} of #{report.results.size} verdicts from earlier runs (--incremental)" if
52
53
  reused.positive?
54
+ @out.puts "kills rejected by unchanged-method check: #{report.rejected_kills}"
53
55
  kill_causes(report)
54
56
  end
55
57
 
@@ -80,6 +82,15 @@ module Saboteur
80
82
  parts.join(", ")
81
83
  end
82
84
 
85
+ def skipped_subjects(report)
86
+ report.skipped_subjects.values.tally.each do |reason, count|
87
+ @out.puts "skipped subjects: #{reason}: #{count}"
88
+ end
89
+ report.skipped_subjects.each do |subject, reason|
90
+ @out.puts " #{subject.id} #{relative(subject.file, report.root)}:#{subject.start_line}: #{reason}"
91
+ end
92
+ end
93
+
83
94
  def warnings(report)
84
95
  report.warnings.each { |warning| @out.puts "warning: #{warning}" }
85
96
  end
@@ -17,7 +17,7 @@ module Saboteur
17
17
  attr_accessor :ignored
18
18
 
19
19
  def initialize(subject:, mutation:, status:, mutated_source:, reason: nil, runtime: nil, killed_by: [],
20
- covered_by: [], kill_cause: nil, reused: false)
20
+ covered_by: [], kill_cause: nil, reused: false, rejected_kill: false)
21
21
  raise ArgumentError, "unknown status #{status.inspect}" unless STATUSES.include?(status)
22
22
 
23
23
  @subject = subject
@@ -29,14 +29,20 @@ module Saboteur
29
29
  @covered_by = covered_by
30
30
  @mutated_source = mutated_source
31
31
  @kill_cause = kill_cause
32
+ @rejected_kill = rejected_kill
32
33
  @reused = reused
33
34
  @ignored = nil
34
35
  end
35
36
 
36
37
  # The same mutant, marked as an error: its verdict did not hold up.
37
- def as_error(reason)
38
+ def as_error(reason, rejected_kill: false)
38
39
  MutantResult.new(subject: subject, mutation: mutation, status: :error, mutated_source: mutated_source,
39
- reason: reason, runtime: runtime, killed_by: killed_by, covered_by: covered_by)
40
+ reason: reason, runtime: runtime, killed_by: killed_by, covered_by: covered_by,
41
+ rejected_kill: rejected_kill)
42
+ end
43
+
44
+ def rejected_kill?
45
+ @rejected_kill
40
46
  end
41
47
 
42
48
  def digest
@@ -78,12 +84,12 @@ module Saboteur
78
84
 
79
85
  # Everything one run produced.
80
86
  class Report
81
- attr_reader :results, :subjects, :wall_time, :invalid_mutations, :warnings, :root, :seed
87
+ attr_reader :results, :subjects, :wall_time, :invalid_mutations, :warnings, :root, :seed, :skipped_subjects
82
88
 
83
89
  # REVIEW: diff mode; the terminal then shows one survivor per line.
84
90
  # seed: the random seed every test run started from (nil when unknown).
85
91
  def initialize(results:, subjects:, wall_time:, root:, invalid_mutations: 0, warnings: [], review: false,
86
- seed: nil)
92
+ seed: nil, skipped_subjects: {})
87
93
  @results = results
88
94
  @subjects = subjects
89
95
  @wall_time = wall_time
@@ -92,6 +98,11 @@ module Saboteur
92
98
  @warnings = warnings
93
99
  @review = review
94
100
  @seed = seed
101
+ @skipped_subjects = skipped_subjects
102
+ end
103
+
104
+ def rejected_kills
105
+ results.count(&:rejected_kill?)
95
106
  end
96
107
 
97
108
  def review?
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Saboteur
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
data/lib/saboteur.rb CHANGED
@@ -38,6 +38,7 @@ require_relative "saboteur/execution/supervisor"
38
38
  require_relative "saboteur/execution/reevaluator"
39
39
  require_relative "saboteur/execution/runner"
40
40
  require_relative "saboteur/execution/worker_pool"
41
+ require_relative "saboteur/execution/fork_readiness"
41
42
  require_relative "saboteur/selection/coverage_map"
42
43
  require_relative "saboteur/selection/recorder"
43
44
  require_relative "saboteur/selection/git_diff"
@@ -46,6 +47,8 @@ require_relative "saboteur/config"
46
47
  require_relative "saboteur/ignore_list"
47
48
  require_relative "saboteur/frameworks/rails"
48
49
  require_relative "saboteur/reporter/diff"
50
+ require_relative "saboteur/reporter/findings"
49
51
  require_relative "saboteur/reporter/terminal"
50
52
  require_relative "saboteur/reporter/stryker"
53
+ require_relative "saboteur/reporter/html"
51
54
  require_relative "saboteur/engine"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: saboteur
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vamsi Pavan Mahesh Gunturu
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-10 00:00:00.000000000 Z
11
+ date: 2026-10-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: fiddle
@@ -59,20 +59,26 @@ files:
59
59
  - README.md
60
60
  - exe/saboteur
61
61
  - lib/saboteur.rb
62
+ - lib/saboteur/baseline_health.rb
62
63
  - lib/saboteur/cli.rb
63
64
  - lib/saboteur/config.rb
65
+ - lib/saboteur/doctor.rb
64
66
  - lib/saboteur/engine.rb
67
+ - lib/saboteur/engine/baseline_guard.rb
65
68
  - lib/saboteur/engine/boot.rb
69
+ - lib/saboteur/engine/candidate_selection.rb
66
70
  - lib/saboteur/engine/candidates.rb
67
71
  - lib/saboteur/engine/coverage_store.rb
68
72
  - lib/saboteur/engine/diagnostics.rb
69
73
  - lib/saboteur/engine/diff_scope.rb
70
74
  - lib/saboteur/engine/distribution.rb
71
75
  - lib/saboteur/engine/result_cache.rb
76
+ - lib/saboteur/engine/run_fingerprint.rb
72
77
  - lib/saboteur/engine/scheduler.rb
73
78
  - lib/saboteur/engine/subject_selection.rb
74
79
  - lib/saboteur/engine/suite_files.rb
75
80
  - lib/saboteur/engine/verdict_check.rb
81
+ - lib/saboteur/execution/fork_readiness.rb
76
82
  - lib/saboteur/execution/process_table.rb
77
83
  - lib/saboteur/execution/process_tree.rb
78
84
  - lib/saboteur/execution/protocol.rb
@@ -81,6 +87,7 @@ files:
81
87
  - lib/saboteur/execution/supervisor.rb
82
88
  - lib/saboteur/execution/worker_pool.rb
83
89
  - lib/saboteur/frameworks/rails.rb
90
+ - lib/saboteur/help.rb
84
91
  - lib/saboteur/hooks.rb
85
92
  - lib/saboteur/ignore_list.rb
86
93
  - lib/saboteur/integration/base.rb
@@ -88,8 +95,14 @@ files:
88
95
  - lib/saboteur/mutation_source/base.rb
89
96
  - lib/saboteur/mutation_source/builtin.rb
90
97
  - lib/saboteur/mutation_source/edit.rb
98
+ - lib/saboteur/mutation_source/filter_replacement.rb
99
+ - lib/saboteur/mutation_source/literal_replacement.rb
91
100
  - lib/saboteur/mutation_source/mutation.rb
101
+ - lib/saboteur/replay.rb
92
102
  - lib/saboteur/reporter/diff.rb
103
+ - lib/saboteur/reporter/findings.rb
104
+ - lib/saboteur/reporter/html.rb
105
+ - lib/saboteur/reporter/replay.rb
93
106
  - lib/saboteur/reporter/stryker.rb
94
107
  - lib/saboteur/reporter/terminal.rb
95
108
  - lib/saboteur/result.rb