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,194 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require_relative "config"
6
+
7
+ module Saboteur
8
+ # The text printed by `saboteur --help` and `saboteur help <command>`.
9
+ #
10
+ # It is a map of everything saboteur can do, for people and for AI agents that
11
+ # have no docs. The layout is fixed: section headers are upper case on their
12
+ # own line, command and key rows start with two spaces. Option lines come from
13
+ # the CLI's own option parser, so a new option shows up here without an edit.
14
+ class Help
15
+ Command = Data.define(:name, :summary, :description, :takes_options, :exit_codes, :examples)
16
+
17
+ PURPOSE = "mutation testing for Ruby and RSpec"
18
+ DOCUMENTATION = "https://github.com/vamsipavanmahesh/saboteur"
19
+
20
+ EXIT_CODE_MEANINGS = {
21
+ 0 => "Success. Without --threshold: no unignored survivors. With --threshold: the score is at or above " \
22
+ "it, even if mutants survived.",
23
+ 1 => "Without --threshold: at least one mutant survived and is not on the ignore list. With --threshold " \
24
+ "(survivors alone no longer fail the run): the score is below it, or no mutant was tested so " \
25
+ "there is no score.",
26
+ 2 => "Usage or configuration error: fix the command or .saboteur.yml. A replay --id that matches no " \
27
+ "mutant is only found after the target has booted. A named source or test file that does not exist.",
28
+ 3 => "Tool error: saboteur could not finish, or every executed mutant errored. With --threshold, " \
29
+ "also any errored or resource-limited mutant."
30
+ }.freeze
31
+
32
+ DOCTOR_EXIT_CODE_MEANINGS = {
33
+ 0 => "Healthy: the environment checks passed and the baseline passed twice in the same order.",
34
+ 2 => "Usage or configuration error: fix the command or .saboteur.yml.",
35
+ 3 => "A check failed: the target did not boot, or the baseline failed or changed between runs."
36
+ }.freeze
37
+
38
+ COMMANDS = [
39
+ Command.new(
40
+ name: "run",
41
+ summary: "Mutate your code and report the mutants your tests do not catch",
42
+ description: "Builds a per-test coverage map, makes small changes to your methods, runs only the " \
43
+ "tests that cover each change, and reports survivors (changes no test noticed). Source files or " \
44
+ "directories after run replace include and exclude; --test limits the specs that run; " \
45
+ "--threshold PERCENT replaces the survivor check: exit 0 when the score is at or above " \
46
+ "PERCENT, exit 1 when it is below.",
47
+ takes_options: true,
48
+ exit_codes: EXIT_CODE_MEANINGS,
49
+ examples: [
50
+ ["Pull request: only methods changed since the target branch", "saboteur run --since origin/main"],
51
+ ["Whole project, four workers, repeatable seed", "saboteur run --jobs 4 --seed 42"],
52
+ ["One class or method", "saboteur run --subject 'Person#adult?'"],
53
+ ["Replay one mutant by the digest printed next to a survivor", "saboteur run --id 1a2b3c4d5e6f7a8b"],
54
+ ["Mutate named files against named specs, skipping the whole-suite coverage run",
55
+ "saboteur run lib/a.rb lib/b.rb --test spec/a_spec.rb --test spec/b_spec.rb"],
56
+ ["CI gate: exit 1 when the score is below 90 percent", "saboteur run --threshold 90"]
57
+ ]
58
+ ),
59
+ Command.new(
60
+ name: "doctor",
61
+ summary: "Check the environment and run the baseline twice, without mutating anything",
62
+ description: "Boots the target like a run, checks runtime, coverage and boot files, then runs your " \
63
+ "suite twice in the same order with one seed and reports whether both runs passed. " \
64
+ "It never mutates anything.",
65
+ takes_options: true,
66
+ exit_codes: DOCTOR_EXIT_CODE_MEANINGS,
67
+ examples: [["Check the setup before the first run", "saboteur doctor"],
68
+ ["Use another config file and seed", "saboteur doctor --config ci.yml --seed 7"]]
69
+ ),
70
+ Command.new(
71
+ name: "version",
72
+ summary: "Print the installed version",
73
+ description: "Prints the version number and nothing else.",
74
+ takes_options: false,
75
+ exit_codes: nil,
76
+ examples: [["Print the version", "saboteur version"]]
77
+ ),
78
+ Command.new(
79
+ name: "help",
80
+ summary: "Show this map, or details for one command: saboteur help <command>",
81
+ description: "Without an argument, prints every command, option, config key and exit code. " \
82
+ "With a command name, prints that command's usage, options and examples.",
83
+ takes_options: false,
84
+ exit_codes: nil,
85
+ examples: [["Show everything", "saboteur help"], ["Show one command", "saboteur help run"]]
86
+ )
87
+ ].freeze
88
+
89
+ FILES = [
90
+ [".saboteur.yml", "Settings. Command line options override it."],
91
+ [".saboteur-ignore.yml", "Accepted survivors: entries with subject, mutation (digest) and a required reason."],
92
+ [".saboteur/report.json", "Stryker mutation-testing-report-schema JSON (--json changes the path)."],
93
+ [".saboteur/report.html", "Self-contained HTML report (--html changes the path)."],
94
+ [".saboteur/", "Cache: coverage map and incremental verdicts. Safe to delete."]
95
+ ].freeze
96
+
97
+ # option_lines: the option lines of each command's parser, keyed by command name.
98
+ def initialize(option_lines:, commands: COMMANDS)
99
+ @option_lines = option_lines
100
+ @commands = commands
101
+ end
102
+
103
+ def overview
104
+ [title, usage_section, commands_section, *option_sections, examples_section(all_examples),
105
+ *exit_code_sections, config_section, files_section, "Documentation: #{DOCUMENTATION}\n"].join("\n")
106
+ end
107
+
108
+ # Details for one command, or nil when there is no such command.
109
+ def command(name)
110
+ found = @commands.find { |candidate| candidate.name == name }
111
+ return unless found
112
+
113
+ sections = ["saboteur #{found.name} - #{found.summary}\n", "#{found.description}\n", command_usage(found)]
114
+ sections << options_section(found) if found.takes_options
115
+ sections << examples_section(found.examples)
116
+ sections << exit_codes_section(found.exit_codes) if found.exit_codes
117
+ sections.join("\n")
118
+ end
119
+
120
+ def command_names
121
+ @commands.map(&:name)
122
+ end
123
+
124
+ private
125
+
126
+ # One section for every command that takes options, so the overview needs no other page.
127
+ def option_sections
128
+ @commands.select(&:takes_options).map { |command| options_section(command) }
129
+ end
130
+
131
+ def exit_code_sections
132
+ @commands.select(&:exit_codes).map { |command| exit_codes_section(command.exit_codes, command.name) }
133
+ end
134
+
135
+ def title
136
+ "saboteur #{VERSION} - #{PURPOSE}\n"
137
+ end
138
+
139
+ def usage_section
140
+ "USAGE\n saboteur <command> [options]\n saboteur help [command]\n"
141
+ end
142
+
143
+ def command_usage(command)
144
+ suffix = command.takes_options ? " [options]" : ""
145
+ "USAGE\n saboteur #{command.name}#{suffix}\n"
146
+ end
147
+
148
+ def commands_section
149
+ rows = @commands.map { |command| " #{command.name.ljust(name_width)} #{command.summary}" }
150
+ "COMMANDS\n#{rows.join("\n")}\n"
151
+ end
152
+
153
+ def name_width
154
+ @commands.map { |command| command.name.length }.max
155
+ end
156
+
157
+ def options_section(command)
158
+ "OPTIONS (saboteur #{command.name})\n#{@option_lines.fetch(command.name).map(&:rstrip).join("\n")}\n"
159
+ end
160
+
161
+ def all_examples
162
+ @commands.flat_map(&:examples)
163
+ end
164
+
165
+ def examples_section(examples)
166
+ "EXAMPLES\n#{examples.map { |note, line| " # #{note}\n #{line}" }.join("\n")}\n"
167
+ end
168
+
169
+ def exit_codes_section(codes, command_name = nil)
170
+ rows = codes.map { |code, meaning| " #{code} #{meaning}" }
171
+ title = command_name ? "EXIT CODES (saboteur #{command_name})" : "EXIT CODES"
172
+ "#{title}\n#{rows.join("\n")}\n"
173
+ end
174
+
175
+ def config_section
176
+ rows = Config::DEFAULTS.map { |key, value| " #{key}: #{describe_default(value)}" }
177
+ intro = "Every key with its default value, as YAML. Unknown keys are an error."
178
+ "CONFIG FILE (.saboteur.yml)\n#{intro}\n#{rows.join("\n")}\n"
179
+ end
180
+
181
+ # Valid YAML (JSON flow style), so a copied line keeps the default's meaning.
182
+ def describe_default(value)
183
+ JSON.generate(value)
184
+ end
185
+
186
+ def files_section
187
+ "FILES\n#{FILES.map { |path, meaning| " #{path.ljust(files_width)} #{meaning}" }.join("\n")}\n"
188
+ end
189
+
190
+ def files_width
191
+ FILES.map { |path, _| path.length }.max
192
+ end
193
+ end
194
+ end
@@ -61,6 +61,14 @@ module Saboteur
61
61
  raise NotImplementedError
62
62
  end
63
63
 
64
+ # Checks the unchanged suite in a fresh child. Existing adapters can
65
+ # use their selected-test runner; adapters may override to retain full
66
+ # suite ordering and report every executed test.
67
+ def run_baseline(seed:)
68
+ Kernel.srand(seed)
69
+ run(all_tests.map(&:id))
70
+ end
71
+
64
72
  # Files whose content decides which tests exist (for the cache key).
65
73
  def test_files
66
74
  []
@@ -10,9 +10,9 @@ module Saboteur
10
10
  def setup
11
11
  require "rspec/core"
12
12
  ::RSpec::Core::Runner.disable_autorun!
13
- options = ::RSpec::Core::ConfigurationOptions.new(rspec_args)
14
- @runner = ::RSpec::Core::Runner.new(options)
13
+ @runner = ::RSpec::Core::Runner.new(configuration_options)
15
14
  @runner.setup($stderr, $stdout)
15
+ configuration.order = "rand:#{config.fetch('seed', 1)}"
16
16
  raise Error, "rspec quit while loading spec files" if world.wants_to_quit
17
17
  end
18
18
 
@@ -22,6 +22,16 @@ module Saboteur
22
22
  args.any? { |arg| !arg.start_with?("-") } ? args : args + ["spec"]
23
23
  end
24
24
 
25
+ # RSpec's own parser reads rspec_args and .rspec, so option values such
26
+ # as `--require path` stay intact. Explicit tests then replace only the
27
+ # files and directories to run.
28
+ def configuration_options
29
+ ::RSpec::Core::ConfigurationOptions.new(rspec_args).tap do |options|
30
+ explicit = Array(config.fetch("tests", []))
31
+ options.options[:files_or_directories_to_run] = explicit if explicit.any?
32
+ end
33
+ end
34
+
25
35
  def all_tests
26
36
  groups = world.ordered_example_groups.flat_map(&:descendants)
27
37
  groups.flat_map { |group| world.filtered_examples[group] }
@@ -47,10 +57,21 @@ module Saboteur
47
57
  end
48
58
 
49
59
  def run_all(recorder)
60
+ configuration.order = "rand:#{config.fetch('seed', 1)}"
50
61
  configuration.fail_fast = false
51
62
  execute(Listener.new(recorder))
52
63
  end
53
64
 
65
+ def run_baseline(seed:)
66
+ configuration.order = "rand:#{seed}"
67
+ configuration.fail_fast = false
68
+ listener = Listener.new
69
+ passed = execute(listener)
70
+ RunResult.new(passed: passed && listener.failed.empty?, failed_ids: listener.failed,
71
+ executed_ids: listener.executed, runtime: listener.runtime,
72
+ failure_classes: listener.failure_classes, failure_messages: listener.failure_messages)
73
+ end
74
+
54
75
  private
55
76
 
56
77
  def world
@@ -3,6 +3,8 @@
3
3
 
4
4
  require "prism"
5
5
  require_relative "base"
6
+ require_relative "filter_replacement"
7
+ require_relative "literal_replacement"
6
8
 
7
9
  module Saboteur
8
10
  module MutationSource
@@ -46,6 +48,8 @@ module Saboteur
46
48
  def initialize(arid: DEFAULT_ARID)
47
49
  super()
48
50
  @arid = arid.map { |pattern| pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern) }
51
+ @filter_replacement = FilterReplacement.new(arid: arid)
52
+ @literal_replacement = LiteralReplacement.new(arid: arid)
49
53
  end
50
54
 
51
55
  def name
@@ -58,7 +62,8 @@ module Saboteur
58
62
  @found = []
59
63
  coarse(body)
60
64
  visit(body)
61
- @found
65
+ @found.concat(@filter_replacement.mutations_for(subject))
66
+ @found.concat(@literal_replacement.mutations_for(subject))
62
67
  ensure
63
68
  @subject = nil
64
69
  end
@@ -0,0 +1,60 @@
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
+ # Swaps the inclusion rule of a filter call with its exclusion rule.
10
+ # Only message bytes change; arguments, receiver and block stay intact.
11
+ class FilterReplacement < Base
12
+ REPLACEMENTS = { select: "reject", reject: "select", select!: "reject!", reject!: "select!" }.freeze
13
+ SCOPE_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
14
+
15
+ # Base inherits Object's empty initializer; there is no parent state to set.
16
+ def initialize(arid: []) # rubocop:disable Lint/MissingSuper
17
+ @arid = arid.map { |pattern| Regexp.new(pattern) }
18
+ end
19
+
20
+ def name
21
+ "filter_replacement"
22
+ end
23
+
24
+ def mutations_for(subject)
25
+ found = []
26
+ visit(subject.node.body, subject, found)
27
+ found
28
+ end
29
+
30
+ private
31
+
32
+ def visit(node, subject, found)
33
+ return if node.nil? || SCOPE_NODES.any? { |scope| node.is_a?(scope) }
34
+
35
+ if node.is_a?(Prism::StatementsNode)
36
+ node.body.each { |statement| visit(statement, subject, found) unless arid?(statement) }
37
+ return
38
+ end
39
+
40
+ replace_filter(node, subject, found) if node.is_a?(Prism::CallNode)
41
+ node.compact_child_nodes.each { |child| visit(child, subject, found) }
42
+ end
43
+
44
+ def replace_filter(node, subject, found)
45
+ replacement = REPLACEMENTS[node.name]
46
+ return unless replacement && node.receiver && node.block
47
+ return unless node.arguments.nil?
48
+
49
+ location = node.message_loc
50
+ edit = Edit.new(file: subject.file, start_byte: location.start_offset,
51
+ end_byte: location.end_offset, replacement: replacement)
52
+ found << Mutation.new(subject_id: subject.id, operator: name, edits: [edit], source: name)
53
+ end
54
+
55
+ def arid?(node)
56
+ @arid.any? { |pattern| pattern.match?(node.slice) }
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,74 @@
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
+ # Changes literal values without rebuilding the surrounding source.
10
+ class LiteralReplacement < Base
11
+ SCOPE_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
12
+ TEXT_NODES = [Prism::InterpolatedStringNode, Prism::SymbolNode, Prism::InterpolatedSymbolNode,
13
+ Prism::RegularExpressionNode, Prism::InterpolatedRegularExpressionNode,
14
+ Prism::XStringNode, Prism::InterpolatedXStringNode].freeze
15
+ PARAMETER_NODES = [Prism::ParametersNode, Prism::BlockParametersNode].freeze
16
+ BOUNDARIES = (SCOPE_NODES + TEXT_NODES + PARAMETER_NODES).freeze
17
+
18
+ # Base inherits Object's empty initializer; there is no parent state to set.
19
+ def initialize(arid: []) # rubocop:disable Lint/MissingSuper
20
+ @arid = arid.map { |pattern| Regexp.new(pattern) }
21
+ end
22
+
23
+ def name
24
+ "literal_replacement"
25
+ end
26
+
27
+ def mutations_for(subject)
28
+ found = []
29
+ visit(subject.node.body, subject, found)
30
+ found
31
+ end
32
+
33
+ private
34
+
35
+ def visit(node, subject, found)
36
+ return if node.nil? || boundary?(node)
37
+
38
+ if node.is_a?(Prism::StatementsNode)
39
+ node.body.each { |statement| visit(statement, subject, found) unless arid?(statement) }
40
+ return
41
+ end
42
+
43
+ case node
44
+ when Prism::TrueNode then add(subject, found, node, "literal_boolean", "false")
45
+ when Prism::FalseNode then add(subject, found, node, "literal_boolean", "true")
46
+ when Prism::StringNode
47
+ replace_string(node, subject, found)
48
+ end
49
+ node.compact_child_nodes.each { |child| visit(child, subject, found) }
50
+ end
51
+
52
+ def boundary?(node)
53
+ BOUNDARIES.any? { |type| node.is_a?(type) }
54
+ end
55
+
56
+ def replace_string(node, subject, found)
57
+ return if node.unescaped.empty?
58
+ return unless node.opening_loc && !node.opening_loc.slice.start_with?("<<")
59
+
60
+ add(subject, found, node, "literal_string", '""')
61
+ end
62
+
63
+ def add(subject, found, node, operator, replacement)
64
+ edit = Edit.new(file: subject.file, start_byte: node.location.start_offset,
65
+ end_byte: node.location.end_offset, replacement: replacement)
66
+ found << Mutation.new(subject_id: subject.id, operator: operator, edits: [edit], source: name)
67
+ end
68
+
69
+ def arid?(node)
70
+ @arid.any? { |pattern| pattern.match?(node.slice) }
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,33 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ # Resolves one exact digest from current source, before caps or coarse gating.
6
+ class Replay
7
+ def initialize(digest:)
8
+ unless digest.match?(/\A[0-9a-f]{16}\z/)
9
+ raise ConfigError, "--id needs a full 16 lowercase hexadecimal mutant digest"
10
+ end
11
+
12
+ @digest = digest
13
+ end
14
+
15
+ def select(subjects, candidates, scope: nil)
16
+ match = nil
17
+ subjects.each do |subject|
18
+ list = candidates.for(subject)
19
+ list = scope.restrict(subject, list) if scope
20
+ list.each do |mutation, text|
21
+ digest = mutation.digest(subject_start: subject.start_byte, original_source: subject.file_source)
22
+ next unless digest == @digest
23
+ raise ConfigError, "mutant #{@digest} is ambiguous; narrow --subject or include files" if match
24
+
25
+ match = [subject, [mutation, text]]
26
+ end
27
+ end
28
+ raise ConfigError, "mutant #{@digest} not found in the selected current source" unless match
29
+
30
+ match
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,123 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Reporter
6
+ # Findings for a code-review surface such as the saboteur GitHub Action:
7
+ # a bounded list of survivors with everything a note on the pull-request
8
+ # diff needs (file, line, digest, operator, original and replacement).
9
+ # Survivors accepted on the ignore list are listed apart, with their
10
+ # reason. The full, unbounded mutant data stays in the report's files
11
+ # section; survivorsTruncated says how many more findings live only there.
12
+ module Findings
13
+ VERSION = 1
14
+
15
+ # GitHub shows at most 10 annotations of one kind per step.
16
+ LIMIT = 10
17
+
18
+ # Published ordering, coarse survivors first: a surviving coarse mutant
19
+ # replaced the whole method body, which hints that no test pins the
20
+ # method's behavior - though it can also be a re-evaluation limit or an
21
+ # equivalent mutant, so this is a heuristic, not a guarantee. Ties break
22
+ # by file, line and digest, so one report always reads the same way.
23
+ ORDERING = "coarse survivors first, then file, line and digest"
24
+
25
+ class << self
26
+ def build(report)
27
+ live, accepted = report.survivors.partition { |result| result.ignored.nil? }
28
+ {
29
+ version: VERSION,
30
+ ordering: ORDERING,
31
+ limit: LIMIT,
32
+ survivors: listed(report, live),
33
+ survivorsTruncated: truncated(live),
34
+ accepted: listed(report, accepted),
35
+ acceptedTruncated: truncated(accepted)
36
+ }
37
+ end
38
+
39
+ private
40
+
41
+ def listed(report, results)
42
+ ordered(report, results).first(LIMIT).map { |result| finding(report, result) }
43
+ end
44
+
45
+ def truncated(results)
46
+ [results.size - LIMIT, 0].max
47
+ end
48
+
49
+ # partition keeps the coarse group ahead; inside a group the key is
50
+ # total (no two mutants share a digest), so the order is stable.
51
+ def ordered(report, results)
52
+ results.partition { |result| result.mutation.coarse }
53
+ .map { |group| group.sort_by { |result| tie_break(report, result) } }
54
+ .flatten
55
+ end
56
+
57
+ def tie_break(report, result)
58
+ [path(report, result), first_line(result), result.digest]
59
+ end
60
+
61
+ def first_line(result)
62
+ result.subject.line_of(result.mutation.edits.map(&:start_byte).min)
63
+ end
64
+
65
+ def finding(report, result)
66
+ entries = edit_entries(result)
67
+ entry = {
68
+ digest: result.digest,
69
+ path: path(report, result),
70
+ line: entries.map { |edit| edit[:line] }.min,
71
+ endLine: entries.map { |edit| edit[:endLine] }.max,
72
+ subject: result.subject.id,
73
+ operator: result.mutation.operator,
74
+ original: joined_texts(result) { |edit| original_text(result, edit) },
75
+ replacement: joined_texts(result, &:replacement),
76
+ edits: entries,
77
+ coarse: result.mutation.coarse
78
+ }
79
+ result.ignored ? entry.merge(reason: result.ignored) : entry
80
+ end
81
+
82
+ # Subject#apply applies edits in start-byte order; findings use the
83
+ # same order, so an unsorted edit list from a custom mutation source
84
+ # still gets a sane range and one entry per edit.
85
+ def sorted_edits(result)
86
+ result.mutation.edits.sort_by(&:start_byte)
87
+ end
88
+
89
+ # Stryker joins a multi-edit mutant's replacements the same way, so
90
+ # both reports describe one mutant with one text.
91
+ def joined_texts(result, &pick)
92
+ sorted_edits(result).map { |edit| safe_text(pick.call(edit)) }.join(" | ")
93
+ end
94
+
95
+ # One entry per edit, so a multi-edit finding can point each joined
96
+ # text at its own spot on the diff.
97
+ def edit_entries(result)
98
+ sorted_edits(result).map do |edit|
99
+ {
100
+ line: result.subject.line_of(edit.start_byte),
101
+ endLine: result.subject.line_of([edit.end_byte - 1, edit.start_byte].max),
102
+ original: safe_text(original_text(result, edit)),
103
+ replacement: safe_text(edit.replacement)
104
+ }
105
+ end
106
+ end
107
+
108
+ def original_text(result, edit)
109
+ result.subject.file_source.byteslice(edit.start_byte, edit.end_byte - edit.start_byte)
110
+ end
111
+
112
+ # A byte slice of source may be invalid UTF-8, which JSON cannot hold.
113
+ def safe_text(text)
114
+ text.dup.force_encoding(Encoding::UTF_8).scrub
115
+ end
116
+
117
+ def path(report, result)
118
+ result.subject.file.delete_prefix("#{report.root}/")
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end