saboteur 0.2.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +153 -1
  3. data/README.md +526 -73
  4. data/lib/generators/saboteur/install/install_generator.rb +21 -0
  5. data/lib/saboteur/agent_skill.rb +40 -0
  6. data/lib/saboteur/baseline_health.rb +116 -0
  7. data/lib/saboteur/cli.rb +305 -30
  8. data/lib/saboteur/config.rb +65 -19
  9. data/lib/saboteur/config_file.rb +97 -0
  10. data/lib/saboteur/custom_map.rb +170 -0
  11. data/lib/saboteur/doctor.rb +88 -0
  12. data/lib/saboteur/engine/baseline_guard.rb +116 -0
  13. data/lib/saboteur/engine/boot.rb +9 -0
  14. data/lib/saboteur/engine/candidate_selection.rb +35 -0
  15. data/lib/saboteur/engine/coverage_store.rb +40 -6
  16. data/lib/saboteur/engine/data_inputs.rb +43 -0
  17. data/lib/saboteur/engine/diagnostics.rb +28 -0
  18. data/lib/saboteur/engine/diff_scope.rb +8 -0
  19. data/lib/saboteur/engine/distribution.rb +4 -2
  20. data/lib/saboteur/engine/run_fingerprint.rb +344 -0
  21. data/lib/saboteur/engine/scheduler.rb +7 -16
  22. data/lib/saboteur/engine/suite_files.rb +8 -1
  23. data/lib/saboteur/engine/verdict_check.rb +4 -2
  24. data/lib/saboteur/engine.rb +125 -25
  25. data/lib/saboteur/execution/fork_readiness.rb +46 -0
  26. data/lib/saboteur/execution/reevaluator.rb +7 -4
  27. data/lib/saboteur/execution/supervisor.rb +23 -11
  28. data/lib/saboteur/frameworks/rails.rb +329 -13
  29. data/lib/saboteur/help.rb +210 -0
  30. data/lib/saboteur/ignore_list.rb +71 -17
  31. data/lib/saboteur/install.rb +132 -0
  32. data/lib/saboteur/integration/base.rb +8 -0
  33. data/lib/saboteur/integration/rspec.rb +69 -4
  34. data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
  35. data/lib/saboteur/mutation_source/builtin.rb +14 -3
  36. data/lib/saboteur/mutation_source/filter_replacement.rb +60 -0
  37. data/lib/saboteur/mutation_source/literal_replacement.rb +74 -0
  38. data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
  39. data/lib/saboteur/replay.rb +33 -0
  40. data/lib/saboteur/reporter/agent.rb +87 -0
  41. data/lib/saboteur/reporter/agent_clipping.rb +29 -0
  42. data/lib/saboteur/reporter/agent_commands.rb +61 -0
  43. data/lib/saboteur/reporter/agent_incomplete.rb +37 -0
  44. data/lib/saboteur/reporter/agent_summary.rb +131 -0
  45. data/lib/saboteur/reporter/findings.rb +131 -0
  46. data/lib/saboteur/reporter/html.rb +213 -0
  47. data/lib/saboteur/reporter/replay.rb +23 -0
  48. data/lib/saboteur/reporter/stryker.rb +21 -4
  49. data/lib/saboteur/reporter/terminal.rb +76 -2
  50. data/lib/saboteur/result.rb +61 -9
  51. data/lib/saboteur/score.rb +89 -0
  52. data/lib/saboteur/version.rb +1 -1
  53. data/lib/saboteur/working_tree.rb +48 -0
  54. data/lib/saboteur.rb +7 -0
  55. metadata +30 -2
@@ -1,40 +1,94 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # frozen_string_literal: true
3
3
 
4
- require "yaml"
5
-
6
4
  module Saboteur
7
- # Survivors the team has accepted. YAML list of entries:
5
+ # Entry point used by config/saboteur_ignore.rb.
6
+ def self.ignore
7
+ collector = IgnoreList.current_collector
8
+ raise "Saboteur.ignore is only valid while config/saboteur_ignore.rb loads" unless collector
9
+
10
+ yield collector
11
+ collector
12
+ end
13
+
14
+ # Survivors the team has accepted. config/saboteur_ignore.rb declares them:
8
15
  #
9
- # - subject: "Person#adult?"
10
- # mutation: "1a2b3c4d5e6f7a8b" # digest shown next to each survivor
11
- # reason: "equivalent: age is always an integer"
16
+ # Saboteur.ignore do |i|
17
+ # i.mutation "1a2b3c4d5e6f7a8b",
18
+ # subject: "Person#adult?",
19
+ # reason: "equivalent: age is always an integer"
20
+ # end
12
21
  #
13
22
  # reason is mandatory; a file with a missing or blank reason does not load.
14
23
  class IgnoreList
15
24
  Entry = Data.define(:subject, :mutation, :reason)
16
25
 
26
+ # Collects the entries a saboteur_ignore.rb file declares.
27
+ class Collector
28
+ attr_reader :entries
29
+
30
+ def initialize
31
+ @entries = []
32
+ end
33
+
34
+ def mutation(digest = nil, subject: nil, reason: nil)
35
+ line = caller_locations(1, 1).first.lineno
36
+ values = { "subject" => subject, "mutation" => digest, "reason" => reason }
37
+ missing = values.select { |_, value| value.to_s.strip.empty? }.keys
38
+ raise ConfigError, "#{line}: entry is missing #{missing.join(', ')}" if missing.any?
39
+
40
+ @entries << Entry.new(
41
+ subject: subject.to_s.strip,
42
+ mutation: digest.to_s.strip,
43
+ reason: reason.to_s.strip
44
+ )
45
+ end
46
+ end
47
+
17
48
  attr_reader :entries
18
49
 
50
+ # The collector is process-global on purpose: the ignore file calls the
51
+ # top-level Saboteur.ignore, which must reach the load in progress.
52
+ # Loads are therefore serialized - never load two ignore files at once.
19
53
  def self.load(path)
20
54
  return new([]) unless path && File.file?(path)
21
55
 
22
- data = YAML.safe_load_file(path) || []
23
- raise ConfigError, "#{path}: expected a list of entries" unless data.is_a?(Array)
56
+ @collector = Collector.new
57
+ Kernel.load(path)
58
+ new(@collector.entries)
59
+ rescue ConfigError => e
60
+ raise ConfigError, "#{path}:#{e.message}"
61
+ rescue NoMethodError => e
62
+ raise ConfigError, unknown_directive_message(path, e) if e.receiver.is_a?(Collector)
24
63
 
25
- new(data.each_with_index.map { |raw, index| parse_entry(raw, path, index) })
26
- rescue Psych::SyntaxError => e
27
- raise ConfigError, "#{path}: #{e.message}"
64
+ # A typo'd call outside the collector: same treatment as a bare
65
+ # constant, or the raw NoMethodError points at our internals.
66
+ raise ConfigError, name_error_message(path, e)
67
+ rescue SyntaxError, LoadError => e
68
+ raise ConfigError, "#{path}: #{e.message.lines.first.to_s.chomp}"
69
+ rescue NameError => e
70
+ raise ConfigError, name_error_message(path, e)
71
+ ensure
72
+ @collector = nil
28
73
  end
29
74
 
30
- def self.parse_entry(raw, path, index)
31
- raise ConfigError, "#{path}: entry #{index + 1} is not a mapping" unless raw.is_a?(Hash)
75
+ def self.unknown_directive_message(path, error)
76
+ line = error.backtrace_locations.find { |loc| loc.path == path }&.lineno
77
+ "#{path}:#{line}: unknown directive #{error.name.to_s.inspect} - " \
78
+ 'declare entries with mutation "<digest>", subject:, reason:'
79
+ end
80
+
81
+ def self.name_error_message(path, error)
82
+ line = error.backtrace_locations&.find { |loc| loc.path == path }&.lineno
83
+ "#{path}:#{line}: #{error.message.lines.first.to_s.chomp} " \
84
+ "- this file loads before the app boots; use plain Ruby or ENV"
85
+ end
32
86
 
33
- values = %w[subject mutation reason].to_h { |key| [key, raw[key].to_s.strip] }
34
- missing = values.select { |_, value| value.empty? }.keys
35
- raise ConfigError, "#{path}: entry #{index + 1} is missing #{missing.join(', ')}" if missing.any?
87
+ private_class_method :unknown_directive_message, :name_error_message
36
88
 
37
- Entry.new(subject: values["subject"], mutation: values["mutation"], reason: values["reason"])
89
+ # The Collector Saboteur.ignore hands to the file being loaded.
90
+ def self.current_collector
91
+ @collector
38
92
  end
39
93
 
40
94
  def initialize(entries)
@@ -0,0 +1,132 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+
6
+ module Saboteur
7
+ # Writes the Rails starter files: config/saboteur.rb, bin/saboteur and a
8
+ # .gitignore entry. Plain Ruby so it runs and tests without Rails; the
9
+ # `rails generate saboteur:install` generator is a thin wrapper over it.
10
+ # Existing files are never overwritten unless force is set.
11
+ class Install
12
+ CONFIG = <<~RUBY
13
+ # saboteur configuration for a Rails + RSpec app. CLI flags override this file.
14
+ Saboteur.configure do |c|
15
+ c.include = ["app/**/*.rb"]
16
+ # Empty base classes: nothing useful to mutate.
17
+ c.exclude = [
18
+ "app/models/application_record.rb",
19
+ "app/controllers/application_controller.rb",
20
+ "app/jobs/application_job.rb",
21
+ "app/mailers/application_mailer.rb",
22
+ ]
23
+ c.require = ["./config/environment"] # boots the app before specs load
24
+ c.rails = true # reconnect ActiveRecord after fork, per-worker databases
25
+ c.jobs = 4 # parallel workers; use 1 if your specs share external state
26
+ end
27
+ RUBY
28
+
29
+ BIN = <<~RUBY
30
+ #!/usr/bin/env ruby
31
+ # frozen_string_literal: true
32
+
33
+ ENV["RAILS_ENV"] ||= "test"
34
+ require "bundler/setup"
35
+ load Gem.bin_path("saboteur", "saboteur")
36
+ RUBY
37
+
38
+ IGNORE_ENTRY = ".saboteur/"
39
+
40
+ GEMFILE_HINT = <<~TEXT
41
+ Add saboteur to your Gemfile if it is not there yet:
42
+
43
+ group :test do
44
+ gem "saboteur", require: false
45
+ end
46
+
47
+ Then run it with SimpleCov off for the run, for example:
48
+
49
+ bin/saboteur run --since origin/main
50
+ TEXT
51
+
52
+ Result = Data.define(:status, :path)
53
+
54
+ def initialize(root: Dir.pwd, force: false)
55
+ @root = File.expand_path(root)
56
+ @force = force
57
+ end
58
+
59
+ # Returns the list of Results (:created, :overwritten, :skipped, :appended, :unchanged).
60
+ def call
61
+ [write("config/saboteur.rb", CONFIG), write("bin/saboteur", BIN, mode: 0o755), gitignore]
62
+ end
63
+
64
+ # Printed after install; the Gemfile is never edited.
65
+ def gemfile_hint
66
+ gemfile = File.join(@root, "Gemfile")
67
+ return nil if File.file?(gemfile) && File.read(gemfile).match?(/^\s*gem\s+["']saboteur["']/)
68
+
69
+ GEMFILE_HINT
70
+ end
71
+
72
+ private
73
+
74
+ # Never writes or chmods through a symlink: not the file itself, and not a
75
+ # symlinked directory above it (bin -> elsewhere). Without force the file is
76
+ # skipped; with force the link is removed and replaced by a real file or
77
+ # directory inside the project, and the old target is left alone.
78
+ def write(relative, content, mode: nil)
79
+ path = File.join(@root, relative)
80
+ link = symlink_component(relative)
81
+ existed = present?(path)
82
+ return Result.new(:skipped, relative) if (existed || link) && !@force
83
+
84
+ File.delete(link) if link
85
+ FileUtils.mkdir_p(File.dirname(path))
86
+ File.write(path, content)
87
+ File.chmod(mode, path) if mode
88
+ Result.new(existed ? :overwritten : :created, relative)
89
+ end
90
+
91
+ # The first symlink on the way from the project root to the file, if any.
92
+ def symlink_component(relative)
93
+ current = @root
94
+ relative.split("/").each do |part|
95
+ current = File.join(current, part)
96
+ return current if File.symlink?(current)
97
+ end
98
+ nil
99
+ end
100
+
101
+ # A dangling symlink does not "exist" but is not free to write either.
102
+ def present?(path)
103
+ File.exist?(path) || File.symlink?(path)
104
+ end
105
+
106
+ def gitignore
107
+ path = File.join(@root, ".gitignore")
108
+ link = symlink_component(".gitignore")
109
+ return Result.new(:skipped, ".gitignore") if link && !@force
110
+
111
+ current = File.file?(path) ? File.read(path) : ""
112
+ listed = listed?(current)
113
+ return Result.new(:unchanged, ".gitignore") if listed && !link
114
+
115
+ File.delete(link) if link
116
+ File.write(path, "#{current}#{missing_entry(current, listed)}")
117
+ Result.new(link ? :overwritten : :appended, ".gitignore")
118
+ end
119
+
120
+ def listed?(text)
121
+ text.lines.map(&:strip).include?(IGNORE_ENTRY)
122
+ end
123
+
124
+ # What to append so .saboteur/ is on its own line; nothing if already there.
125
+ def missing_entry(current, listed)
126
+ return "" if listed
127
+
128
+ separator = current.empty? || current.end_with?("\n") ? "" : "\n"
129
+ "#{separator}#{IGNORE_ENTRY}\n"
130
+ end
131
+ end
132
+ 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,8 +57,19 @@ 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
- execute(Listener.new(recorder))
62
+ execute(Listener.new(recorder), log_phase: true)
63
+ end
64
+
65
+ def run_baseline(seed:)
66
+ configuration.order = "rand:#{seed}"
67
+ configuration.fail_fast = false
68
+ listener = Listener.new
69
+ passed = execute(listener, log_phase: true)
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)
52
73
  end
53
74
 
54
75
  private
@@ -83,11 +104,19 @@ module Saboteur
83
104
  world.example_groups.flat_map(&:descendants).each(&:reset_memoized)
84
105
  end
85
106
 
86
- def execute(listener)
107
+ # log_phase is for the baseline and coverage children, whose $stdout is
108
+ # redirected to the phase's log: progress and failure detail land in
109
+ # that log, so a failed phase can be diagnosed from it. Mutant runs
110
+ # stay quiet.
111
+ def execute(listener, log_phase: false)
87
112
  reporter = ::RSpec::Core::Reporter.new(configuration)
88
113
  reporter.register_listener(listener, *Listener::EVENTS)
89
114
  groups = world.ordered_example_groups.select { |group| group.descendant_filtered_examples.any? }
90
115
  count = world.example_count(groups)
116
+ if log_phase
117
+ logger = PhaseLogger.new($stdout, total: count)
118
+ reporter.register_listener(logger, *PhaseLogger::EVENTS)
119
+ end
91
120
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
92
121
  passed = reporter.report(count) do |r|
93
122
  configuration.with_suite_hooks { groups.map { |group| group.run(r) }.all? }
@@ -96,6 +125,42 @@ module Saboteur
96
125
  passed && !world.non_example_failure
97
126
  end
98
127
 
128
+ # Progress and failure detail for a phase log. Every 25 finished
129
+ # examples is enough to see movement on a tail; failures print RSpec's
130
+ # full formatted detail, and the run ends with RSpec's totals line.
131
+ class PhaseLogger
132
+ EVENTS = %i[example_finished message dump_failures dump_summary].freeze
133
+
134
+ PROGRESS_EVERY = 25
135
+
136
+ def initialize(out, total:)
137
+ @out = out
138
+ @total = total
139
+ @executed = 0
140
+ end
141
+
142
+ def example_finished(_notification)
143
+ @executed += 1
144
+ return unless @executed == @total || (@executed % PROGRESS_EVERY).zero?
145
+
146
+ @out.puts("#{@executed}/#{@total} examples finished")
147
+ end
148
+
149
+ # A failure outside any example (a suite hook, a load error) arrives
150
+ # as a message, never in dump_failures.
151
+ def message(notification)
152
+ @out.puts(notification.message)
153
+ end
154
+
155
+ def dump_failures(notification)
156
+ @out.puts(notification.fully_formatted_failed_examples)
157
+ end
158
+
159
+ def dump_summary(notification)
160
+ @out.puts(notification.totals_line)
161
+ end
162
+ end
163
+
99
164
  class Listener
100
165
  EVENTS = %i[example_started example_finished example_failed].freeze
101
166
 
@@ -0,0 +1,86 @@
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
+ # Deletes one ordinary argument from a parenthesized method call.
10
+ class ArgumentRemoval < Base
11
+ BOUNDARIES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode,
12
+ Prism::ParametersNode, Prism::BlockParametersNode].freeze
13
+ EXCLUDED = [Prism::KeywordHashNode, Prism::SplatNode, Prism::ForwardingArgumentsNode].freeze
14
+
15
+ def initialize(arid: []) # rubocop:disable Lint/MissingSuper
16
+ @arid = arid.map { |pattern| Regexp.new(pattern) }
17
+ end
18
+
19
+ def name
20
+ "argument_removal"
21
+ end
22
+
23
+ def mutations_for(subject)
24
+ found = []
25
+ tokens = Prism.lex(subject.file_source).value.map(&:first)
26
+ visit(subject.node.body, subject, found, tokens)
27
+ found
28
+ end
29
+
30
+ private
31
+
32
+ def visit(node, subject, found, tokens)
33
+ return if node.nil? || BOUNDARIES.any? { |type| node.is_a?(type) }
34
+
35
+ if node.is_a?(Prism::StatementsNode)
36
+ node.body.each do |child|
37
+ visit(child, subject, found, tokens) unless @arid.any? do |p|
38
+ p.match?(child.slice)
39
+ end
40
+ end
41
+ return
42
+ end
43
+ remove_arguments(node, subject, found, tokens) if node.is_a?(Prism::CallNode)
44
+ node.compact_child_nodes.each { |child| visit(child, subject, found, tokens) }
45
+ end
46
+
47
+ def remove_arguments(node, subject, found, tokens)
48
+ return unless node.opening_loc&.slice == "(" && node.arguments
49
+
50
+ arguments = node.arguments.arguments
51
+ arguments.each_with_index do |argument, index|
52
+ next if EXCLUDED.any? { |type| argument.is_a?(type) } || heredoc?(argument)
53
+
54
+ separator = separator_for(node, arguments, index, tokens)
55
+ edits = [delete(subject, argument.location)]
56
+ edits << delete(subject, separator) if separator
57
+ found << Mutation.new(subject_id: subject.id, operator: name, edits: edits, source: name)
58
+ end
59
+ end
60
+
61
+ def separator_for(node, arguments, index, tokens)
62
+ argument = arguments[index]
63
+ after = comma_between(argument.location.end_offset, node.closing_loc.start_offset, tokens)
64
+ return after if after
65
+
66
+ previous = arguments[0...index].last
67
+ comma_between(previous.location.end_offset, argument.location.start_offset, tokens) if previous
68
+ end
69
+
70
+ def comma_between(first, last, tokens)
71
+ tokens.find do |token|
72
+ token.type == :COMMA && token.location.start_offset >= first && token.location.end_offset <= last
73
+ end&.location
74
+ end
75
+
76
+ def heredoc?(node)
77
+ (node.respond_to?(:opening_loc) && node.opening_loc&.slice&.start_with?("<<")) ||
78
+ node.compact_child_nodes.any? { |child| heredoc?(child) }
79
+ end
80
+
81
+ def delete(subject, location)
82
+ Edit.new(file: subject.file, start_byte: location.start_offset, end_byte: location.end_offset, replacement: "")
83
+ end
84
+ end
85
+ end
86
+ end
@@ -3,6 +3,10 @@
3
3
 
4
4
  require "prism"
5
5
  require_relative "base"
6
+ require_relative "filter_replacement"
7
+ require_relative "literal_replacement"
8
+ require_relative "argument_removal"
9
+ require_relative "pair_removal"
6
10
 
7
11
  module Saboteur
8
12
  module MutationSource
@@ -46,6 +50,10 @@ module Saboteur
46
50
  def initialize(arid: DEFAULT_ARID)
47
51
  super()
48
52
  @arid = arid.map { |pattern| pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern) }
53
+ @filter_replacement = FilterReplacement.new(arid: arid)
54
+ @literal_replacement = LiteralReplacement.new(arid: arid)
55
+ @argument_removal = ArgumentRemoval.new(arid: arid)
56
+ @pair_removal = PairRemoval.new(arid: arid)
49
57
  end
50
58
 
51
59
  def name
@@ -58,7 +66,10 @@ module Saboteur
58
66
  @found = []
59
67
  coarse(body)
60
68
  visit(body)
61
- @found
69
+ @found.concat(@filter_replacement.mutations_for(subject))
70
+ @found.concat(@literal_replacement.mutations_for(subject))
71
+ @found.concat(@argument_removal.mutations_for(subject))
72
+ @found.concat(@pair_removal.mutations_for(subject))
62
73
  ensure
63
74
  @subject = nil
64
75
  end
@@ -86,8 +97,8 @@ module Saboteur
86
97
  end
87
98
 
88
99
  # 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.
100
+ # is not generated: rejecting that exception is not the rescue contract.
101
+ # Fine mutants still run regardless of the coarse verdicts.
91
102
  def rescues_everything?(body)
92
103
  clause = body.is_a?(Prism::BeginNode) ? body.rescue_clause : nil
93
104
  while clause
@@ -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