saboteur 0.3.0 → 0.5.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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +280 -1
  3. data/README.md +635 -77
  4. data/exe/saboteur +6 -2
  5. data/lib/generators/saboteur/install/install_generator.rb +21 -0
  6. data/lib/saboteur/agent_skill.rb +46 -0
  7. data/lib/saboteur/baseline/acceptance.rb +218 -0
  8. data/lib/saboteur/baseline/command.rb +80 -0
  9. data/lib/saboteur/baseline/comparison.rb +105 -0
  10. data/lib/saboteur/baseline/completeness.rb +53 -0
  11. data/lib/saboteur/baseline/evidence.rb +114 -0
  12. data/lib/saboteur/baseline/identity.rb +78 -0
  13. data/lib/saboteur/baseline/manifest.rb +164 -0
  14. data/lib/saboteur/baseline/run_evidence.rb +117 -0
  15. data/lib/saboteur/baseline/workflow.rb +121 -0
  16. data/lib/saboteur/baseline_health.rb +1 -1
  17. data/lib/saboteur/cli/exit_status.rb +93 -0
  18. data/lib/saboteur/cli/run_reporting.rb +75 -0
  19. data/lib/saboteur/cli.rb +171 -63
  20. data/lib/saboteur/config.rb +101 -19
  21. data/lib/saboteur/config_file.rb +97 -0
  22. data/lib/saboteur/custom_map.rb +170 -0
  23. data/lib/saboteur/doctor.rb +57 -9
  24. data/lib/saboteur/engine/baseline_guard.rb +44 -5
  25. data/lib/saboteur/engine/boot.rb +9 -0
  26. data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
  27. data/lib/saboteur/engine/canary.rb +74 -0
  28. data/lib/saboteur/engine/candidate_selection.rb +18 -5
  29. data/lib/saboteur/engine/candidates.rb +59 -7
  30. data/lib/saboteur/engine/coverage_store.rb +53 -6
  31. data/lib/saboteur/engine/data_inputs.rb +43 -0
  32. data/lib/saboteur/engine/diagnostics.rb +176 -2
  33. data/lib/saboteur/engine/diff_scope.rb +18 -0
  34. data/lib/saboteur/engine/distribution.rb +54 -13
  35. data/lib/saboteur/engine/result_cache.rb +10 -5
  36. data/lib/saboteur/engine/run_fingerprint.rb +40 -10
  37. data/lib/saboteur/engine/sandbox_policy.rb +108 -0
  38. data/lib/saboteur/engine/scheduler.rb +99 -36
  39. data/lib/saboteur/engine/subject_selection.rb +24 -1
  40. data/lib/saboteur/engine/suite_files.rb +8 -1
  41. data/lib/saboteur/engine/test_guess.rb +52 -0
  42. data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
  43. data/lib/saboteur/engine/verdict_check.rb +7 -6
  44. data/lib/saboteur/engine.rb +257 -22
  45. data/lib/saboteur/execution/protocol.rb +2 -1
  46. data/lib/saboteur/execution/reevaluator.rb +79 -14
  47. data/lib/saboteur/execution/runner.rb +120 -17
  48. data/lib/saboteur/execution/sandbox.rb +271 -0
  49. data/lib/saboteur/execution/stale_captures.rb +43 -0
  50. data/lib/saboteur/execution/supervisor.rb +113 -9
  51. data/lib/saboteur/execution/worker_pool.rb +20 -4
  52. data/lib/saboteur/execution/wrapper.rb +45 -0
  53. data/lib/saboteur/frameworks/rails.rb +329 -13
  54. data/lib/saboteur/gates.rb +83 -0
  55. data/lib/saboteur/help.rb +63 -23
  56. data/lib/saboteur/ignore_list.rb +76 -17
  57. data/lib/saboteur/inline_suppression.rb +65 -0
  58. data/lib/saboteur/install.rb +132 -0
  59. data/lib/saboteur/integration/base.rb +1 -0
  60. data/lib/saboteur/integration/focus_guard.rb +83 -0
  61. data/lib/saboteur/integration/rspec.rb +68 -4
  62. data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
  63. data/lib/saboteur/mutation_source/builtin.rb +30 -5
  64. data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
  65. data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
  66. data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
  67. data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
  68. data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
  69. data/lib/saboteur/mutation_source/operator_registry.rb +57 -0
  70. data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
  71. data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
  72. data/lib/saboteur/preview.rb +106 -0
  73. data/lib/saboteur/replay.rb +1 -1
  74. data/lib/saboteur/reporter/agent.rb +87 -0
  75. data/lib/saboteur/reporter/agent_clipping.rb +29 -0
  76. data/lib/saboteur/reporter/agent_commands.rb +67 -0
  77. data/lib/saboteur/reporter/agent_incomplete.rb +60 -0
  78. data/lib/saboteur/reporter/agent_summary.rb +137 -0
  79. data/lib/saboteur/reporter/findings.rb +12 -19
  80. data/lib/saboteur/reporter/history_paths.rb +38 -0
  81. data/lib/saboteur/reporter/history_record.rb +132 -0
  82. data/lib/saboteur/reporter/html.rb +61 -2
  83. data/lib/saboteur/reporter/mutation_text.rb +36 -0
  84. data/lib/saboteur/reporter/next_steps.rb +30 -0
  85. data/lib/saboteur/reporter/palette.rb +37 -0
  86. data/lib/saboteur/reporter/score_history.rb +123 -0
  87. data/lib/saboteur/reporter/score_table.rb +57 -0
  88. data/lib/saboteur/reporter/stryker.rb +25 -5
  89. data/lib/saboteur/reporter/survivor_hints.rb +68 -0
  90. data/lib/saboteur/reporter/survivor_list.rb +126 -0
  91. data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
  92. data/lib/saboteur/reporter/terminal.rb +157 -42
  93. data/lib/saboteur/reporter/test_matrix.rb +57 -0
  94. data/lib/saboteur/result.rb +86 -10
  95. data/lib/saboteur/score.rb +89 -0
  96. data/lib/saboteur/selection/coverage_map.rb +119 -13
  97. data/lib/saboteur/selection/git_diff.rb +28 -1
  98. data/lib/saboteur/selection/recorder.rb +55 -13
  99. data/lib/saboteur/subject.rb +40 -4
  100. data/lib/saboteur/subject_finder.rb +62 -12
  101. data/lib/saboteur/version.rb +1 -1
  102. data/lib/saboteur/working_tree.rb +56 -0
  103. data/lib/saboteur.rb +10 -0
  104. metadata +58 -2
@@ -14,6 +14,8 @@ module Saboteur
14
14
  @runner.setup($stderr, $stdout)
15
15
  configuration.order = "rand:#{config.fetch('seed', 1)}"
16
16
  raise Error, "rspec quit while loading spec files" if world.wants_to_quit
17
+
18
+ refuse_unintended_focus
17
19
  end
18
20
 
19
21
  # RSpec only adds its default "spec" path when $0 is `rspec`.
@@ -48,7 +50,7 @@ module Saboteur
48
50
  wanted = Set.new(ids)
49
51
  narrow_to(wanted)
50
52
  order_by(ids)
51
- configuration.fail_fast = 1
53
+ configuration.fail_fast = config["matrix"] ? false : 1
52
54
  listener = Listener.new
53
55
  passed = execute(listener)
54
56
  RunResult.new(passed: passed && listener.failed.empty?, failed_ids: listener.failed,
@@ -59,14 +61,14 @@ module Saboteur
59
61
  def run_all(recorder)
60
62
  configuration.order = "rand:#{config.fetch('seed', 1)}"
61
63
  configuration.fail_fast = false
62
- execute(Listener.new(recorder))
64
+ execute(Listener.new(recorder), log_phase: true)
63
65
  end
64
66
 
65
67
  def run_baseline(seed:)
66
68
  configuration.order = "rand:#{seed}"
67
69
  configuration.fail_fast = false
68
70
  listener = Listener.new
69
- passed = execute(listener)
71
+ passed = execute(listener, log_phase: true)
70
72
  RunResult.new(passed: passed && listener.failed.empty?, failed_ids: listener.failed,
71
73
  executed_ids: listener.executed, runtime: listener.runtime,
72
74
  failure_classes: listener.failure_classes, failure_messages: listener.failure_messages)
@@ -74,6 +76,24 @@ module Saboteur
74
76
 
75
77
  private
76
78
 
79
+ def refuse_unintended_focus
80
+ return if config["allow_focus"]
81
+
82
+ FocusGuard.new(world:, explicit_filter_keys: explicit_inclusion_keys).check!
83
+ end
84
+
85
+ # Inclusion filters the user named in rspec_args or .rspec, such as
86
+ # `--tag slow`. Those narrow the run on purpose.
87
+ def explicit_inclusion_keys
88
+ options = configuration_options
89
+ filter_manager = ::RSpec::Core::FilterManager.new
90
+ options.configure_filter_manager(filter_manager)
91
+ keys = filter_manager.inclusions.rules.keys
92
+ # `--example TEXT` becomes a :full_description rule when the options
93
+ # reach the configuration, not in the filter manager.
94
+ options.options[:full_description] ? keys + [:full_description] : keys
95
+ end
96
+
77
97
  def world
78
98
  ::RSpec.world
79
99
  end
@@ -104,11 +124,19 @@ module Saboteur
104
124
  world.example_groups.flat_map(&:descendants).each(&:reset_memoized)
105
125
  end
106
126
 
107
- def execute(listener)
127
+ # log_phase is for the baseline and coverage children, whose $stdout is
128
+ # redirected to the phase's log: progress and failure detail land in
129
+ # that log, so a failed phase can be diagnosed from it. Mutant runs
130
+ # stay quiet.
131
+ def execute(listener, log_phase: false)
108
132
  reporter = ::RSpec::Core::Reporter.new(configuration)
109
133
  reporter.register_listener(listener, *Listener::EVENTS)
110
134
  groups = world.ordered_example_groups.select { |group| group.descendant_filtered_examples.any? }
111
135
  count = world.example_count(groups)
136
+ if log_phase
137
+ logger = PhaseLogger.new($stdout, total: count)
138
+ reporter.register_listener(logger, *PhaseLogger::EVENTS)
139
+ end
112
140
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
113
141
  passed = reporter.report(count) do |r|
114
142
  configuration.with_suite_hooks { groups.map { |group| group.run(r) }.all? }
@@ -117,6 +145,42 @@ module Saboteur
117
145
  passed && !world.non_example_failure
118
146
  end
119
147
 
148
+ # Progress and failure detail for a phase log. Every 25 finished
149
+ # examples is enough to see movement on a tail; failures print RSpec's
150
+ # full formatted detail, and the run ends with RSpec's totals line.
151
+ class PhaseLogger
152
+ EVENTS = %i[example_finished message dump_failures dump_summary].freeze
153
+
154
+ PROGRESS_EVERY = 25
155
+
156
+ def initialize(out, total:)
157
+ @out = out
158
+ @total = total
159
+ @executed = 0
160
+ end
161
+
162
+ def example_finished(_notification)
163
+ @executed += 1
164
+ return unless @executed == @total || (@executed % PROGRESS_EVERY).zero?
165
+
166
+ @out.puts("#{@executed}/#{@total} examples finished")
167
+ end
168
+
169
+ # A failure outside any example (a suite hook, a load error) arrives
170
+ # as a message, never in dump_failures.
171
+ def message(notification)
172
+ @out.puts(notification.message)
173
+ end
174
+
175
+ def dump_failures(notification)
176
+ @out.puts(notification.fully_formatted_failed_examples)
177
+ end
178
+
179
+ def dump_summary(notification)
180
+ @out.puts(notification.totals_line)
181
+ end
182
+ end
183
+
120
184
  class Listener
121
185
  EVENTS = %i[example_started example_finished example_failed].freeze
122
186
 
@@ -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
@@ -5,6 +5,14 @@ require "prism"
5
5
  require_relative "base"
6
6
  require_relative "filter_replacement"
7
7
  require_relative "literal_replacement"
8
+ require_relative "argument_removal"
9
+ require_relative "chain_removal"
10
+ require_relative "pair_removal"
11
+ require_relative "endpoint_replacement"
12
+ require_relative "operator_registry"
13
+ require_relative "range_replacement"
14
+ require_relative "operator_assignment"
15
+ require_relative "condition_negation"
8
16
 
9
17
  module Saboteur
10
18
  module MutationSource
@@ -13,7 +21,7 @@ module Saboteur
13
21
  class Builtin < Base
14
22
  DEFAULT_ARID = [
15
23
  '\A(puts|pp|p|print|warn)\b',
16
- '\A(::)?(Rails\.)?logger\b',
24
+ '\A(@|self\.|::)?(Rails\.)?logger\b',
17
25
  '\A(::)?(StatsD|Statsd|Datadog|NewRelic)\b',
18
26
  '\.instrument\b'
19
27
  ].freeze
@@ -45,11 +53,19 @@ module Saboteur
45
53
  CATCH_ALL = %w[StandardError ::StandardError Exception ::Exception].freeze
46
54
  SCOPE_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
47
55
 
48
- def initialize(arid: DEFAULT_ARID)
56
+ def initialize(arid: DEFAULT_ARID, operators: [])
49
57
  super()
58
+ @operators = OperatorRegistry.resolve(operators)
50
59
  @arid = arid.map { |pattern| pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern) }
51
60
  @filter_replacement = FilterReplacement.new(arid: arid)
52
61
  @literal_replacement = LiteralReplacement.new(arid: arid)
62
+ @argument_removal = ArgumentRemoval.new(arid: arid)
63
+ @pair_removal = PairRemoval.new(arid: arid)
64
+ @chain_removal = ChainRemoval.new(arid: arid)
65
+ @endpoint_replacement = EndpointReplacement.new(arid: arid)
66
+ @range_replacement = RangeReplacement.new(arid: arid)
67
+ @operator_assignment = OperatorAssignment.new(arid: arid)
68
+ @condition_negation = ConditionNegation.new(arid: arid)
53
69
  end
54
70
 
55
71
  def name
@@ -64,6 +80,14 @@ module Saboteur
64
80
  visit(body)
65
81
  @found.concat(@filter_replacement.mutations_for(subject))
66
82
  @found.concat(@literal_replacement.mutations_for(subject))
83
+ @found.concat(@argument_removal.mutations_for(subject))
84
+ @found.concat(@pair_removal.mutations_for(subject))
85
+ @found.concat(@chain_removal.mutations_for(subject))
86
+ @found.concat(@endpoint_replacement.mutations_for(subject))
87
+ @found.concat(@range_replacement.mutations_for(subject))
88
+ @found.concat(@operator_assignment.mutations_for(subject))
89
+ @found.concat(@condition_negation.mutations_for(subject))
90
+ @found.select { |mutation| @operators.include?(mutation.operator) }
67
91
  ensure
68
92
  @subject = nil
69
93
  end
@@ -91,8 +115,8 @@ module Saboteur
91
115
  end
92
116
 
93
117
  # A raise that the method's own rescue catches is absorbed by design. It
94
- # would survive, and a surviving coarse mutant stops every fine mutant
95
- # of the method, so it is not generated.
118
+ # is not generated: rejecting that exception is not the rescue contract.
119
+ # Fine mutants still run regardless of the coarse verdicts.
96
120
  def rescues_everything?(body)
97
121
  clause = body.is_a?(Prism::BeginNode) ? body.rescue_clause : nil
98
122
  while clause
@@ -128,7 +152,8 @@ module Saboteur
128
152
  statements.each do |statement|
129
153
  next if arid?(statement)
130
154
 
131
- add("statement_removal", statement, "nil") if statements.size > 1 && removable?(statement)
155
+ removable_here = statements.drop(1).any? && removable?(statement)
156
+ add("statement_removal", statement, "nil") if removable_here
132
157
  visit(statement)
133
158
  end
134
159
  end
@@ -0,0 +1,80 @@
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
+ # Drops one link from the middle of a method chain: a.b.c becomes a.c.
10
+ # The first receiver and the last call stay, so the result is still a call
11
+ # on the original object.
12
+ class ChainRemoval < Base
13
+ BOUNDARIES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
14
+ # Links this operator leaves alone by design. They hand back their receiver or a
15
+ # copy of it (tap after running a block), so dropping one changes mutability,
16
+ # copying or a block's side effects. Tests can observe those, but they are other
17
+ # contracts than "a step in the query chain went missing", and removing them
18
+ # mostly yields noise, so they are out of scope here.
19
+ IDENTITY_CALLS = %w[freeze dup clone itself tap].freeze
20
+
21
+ # Targets of a write: `a.b.c = 1`, `a.b.c += 1`, `a.b[0] ||= 1`. The whole receiver chain
22
+ # is the thing being assigned to, so no link of it is dropped.
23
+ WRITE_NODES = [Prism::CallOperatorWriteNode, Prism::CallOrWriteNode, Prism::CallAndWriteNode,
24
+ Prism::IndexOperatorWriteNode, Prism::IndexOrWriteNode, Prism::IndexAndWriteNode].freeze
25
+
26
+ def initialize(arid: []) # rubocop:disable Lint/MissingSuper
27
+ @arid = arid.map { |pattern| Regexp.new(pattern) }
28
+ end
29
+
30
+ def name
31
+ "chain_removal"
32
+ end
33
+
34
+ def mutations_for(subject)
35
+ found = []
36
+ visit(subject.node.body, subject, found)
37
+ found
38
+ end
39
+
40
+ private
41
+
42
+ def visit(node, subject, found)
43
+ return if node.nil? || BOUNDARIES.any? { |type| node.is_a?(type) }
44
+
45
+ if node.is_a?(Prism::StatementsNode)
46
+ node.body.each { |child| visit(child, subject, found) unless @arid.any? { |p| p.match?(child.slice) } }
47
+ return
48
+ end
49
+ remove_link(node, subject, found) if node.is_a?(Prism::CallNode)
50
+ children(node).each { |child| visit(child, subject, found) }
51
+ end
52
+
53
+ # Everything under the node except the receiver chain of a write target.
54
+ def children(node)
55
+ return node.compact_child_nodes unless write_target?(node)
56
+
57
+ node.compact_child_nodes.reject { |child| child.equal?(node.receiver) }
58
+ end
59
+
60
+ def write_target?(node)
61
+ WRITE_NODES.any? { |type| node.is_a?(type) } || (node.is_a?(Prism::CallNode) && node.attribute_write?)
62
+ end
63
+
64
+ def remove_link(call, subject, found)
65
+ link = call.receiver
66
+ return unless call.call_operator_loc && !call.attribute_write? && removable?(link)
67
+
68
+ edit = Edit.new(file: subject.file, start_byte: link.receiver.end_offset,
69
+ end_byte: link.end_offset, replacement: "")
70
+ found << Mutation.new(subject_id: subject.id, operator: name, edits: [edit], source: name)
71
+ end
72
+
73
+ # A dotted call with a method name (a dot always has a receiver).
74
+ def removable?(link)
75
+ link.is_a?(Prism::CallNode) && link.call_operator_loc && link.message_loc &&
76
+ !IDENTITY_CALLS.include?(link.name.to_s)
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,50 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "base"
5
+
6
+ module Saboteur
7
+ module MutationSource
8
+ # Negates the predicate, retaining its evaluation and branch structure.
9
+ class ConditionNegation < Base
10
+ SCOPE_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
11
+
12
+ def initialize(arid: []) # rubocop:disable Lint/MissingSuper
13
+ @arid = arid.map { |pattern| Regexp.new(pattern) }
14
+ end
15
+
16
+ def name
17
+ "condition_negation"
18
+ end
19
+
20
+ def mutations_for(subject)
21
+ found = []
22
+ visit(subject.node.body, subject, found)
23
+ found
24
+ end
25
+
26
+ private
27
+
28
+ def visit(node, subject, found)
29
+ return if node.nil? || SCOPE_NODES.any? { |scope| node.is_a?(scope) }
30
+ return if @arid.any? { |pattern| pattern.match?(node.slice) }
31
+
32
+ negate(node.predicate, subject, found) if node.is_a?(Prism::IfNode) || node.is_a?(Prism::UnlessNode)
33
+ node.compact_child_nodes.each { |child| visit(child, subject, found) }
34
+ end
35
+
36
+ def negate(predicate, subject, found)
37
+ # Existing true/false and negation-removal operators own these cases.
38
+ if predicate.is_a?(Prism::TrueNode) || predicate.is_a?(Prism::FalseNode) || predicate.is_a?(Prism::NilNode)
39
+ return
40
+ end
41
+ return if predicate.is_a?(Prism::CallNode) && predicate.name == :!
42
+
43
+ location = predicate.location
44
+ edit = Edit.new(file: subject.file, start_byte: location.start_offset,
45
+ end_byte: location.end_offset, replacement: "!(#{predicate.slice})")
46
+ found << Mutation.new(subject_id: subject.id, operator: name, edits: [edit], source: name)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,61 @@
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 selected end of an explicitly received collection.
10
+ # Only message bytes change; arguments, receiver and block stay intact.
11
+ class EndpointReplacement < Base
12
+ REPLACEMENTS = { first: "last", last: "first" }.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
+ "endpoint_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_endpoint(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_endpoint(node, subject, found)
45
+ replacement = REPLACEMENTS[node.name]
46
+ return unless replacement && node.receiver
47
+ return if node.block
48
+ return unless node.arguments.nil?
49
+
50
+ location = node.message_loc
51
+ edit = Edit.new(file: subject.file, start_byte: location.start_offset,
52
+ end_byte: location.end_offset, replacement: replacement)
53
+ found << Mutation.new(subject_id: subject.id, operator: name, edits: [edit], source: name)
54
+ end
55
+
56
+ def arid?(node)
57
+ @arid.any? { |pattern| pattern.match?(node.slice) }
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,136 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module MutationSource
6
+ # Suppresses mutants that would run a destructive call with altered
7
+ # inputs. A mutant is only kept when every edit is provably safe: either
8
+ # the subject holds no hazardous call, or the edit removes every
9
+ # hazardous call in the method AND its replacement introduces no new one
10
+ # (checked by parsing the replacement, never by substring match). Edits
11
+ # strictly inside a call, edits elsewhere in a method that contains a
12
+ # call, and edits whose replacement re-includes a hazard (for example
13
+ # removing a negation in front of `system(...)`, or swapping
14
+ # `rm_rf("build")` for `rm_rf(".")`) all leave a destructive call
15
+ # running with altered inputs and are suppressed (issue #102).
16
+ class HazardGuard
17
+ # "<Receiver>.<name>" for calls with a receiver, the bare name for
18
+ # Kernel-style calls. Bare FileUtils operations are listed too, so an
19
+ # `include FileUtils` does not disarm the guard. Any method on Open3,
20
+ # any rmtree, and backticks/%x literals are always hazardous. Replace
21
+ # or extend via c.hazard; run them anyway with --allow-hazardous.
22
+ DEFAULT_HAZARD = [
23
+ "FileUtils.rm", "FileUtils.rm_r", "FileUtils.rm_rf", "FileUtils.rm_f",
24
+ "FileUtils.remove_dir", "FileUtils.remove_entry", "FileUtils.remove_entry_secure",
25
+ "rm", "rm_r", "rm_rf", "rm_f", "remove_dir", "remove_entry", "remove_entry_secure",
26
+ "File.delete", "File.unlink",
27
+ "Dir.rmdir", "Dir.delete", "Dir.unlink",
28
+ "IO.popen",
29
+ "Process.spawn", "Process.exec",
30
+ "system", "exec", "spawn", "`"
31
+ ].freeze
32
+
33
+ OPEN3 = /\A(::)?Open3\z/
34
+ ALWAYS_NAMES = %w[rmtree].freeze
35
+ KERNEL_RECEIVERS = %w[Kernel Process].freeze
36
+
37
+ def initialize(patterns = DEFAULT_HAZARD)
38
+ @tags = Set.new(patterns.map { |pattern| pattern.to_s.sub(/\A::/, "") })
39
+ end
40
+
41
+ def name
42
+ "hazard_guard"
43
+ end
44
+
45
+ # candidates: [[mutation, mutated_text]]. Returns [kept, suppressed].
46
+ def partition(candidates, subject)
47
+ spans = hazard_spans(subject.node)
48
+ return [candidates, []] if spans.empty?
49
+
50
+ candidates.partition { |mutation, _| safe?(mutation, spans) }
51
+ end
52
+
53
+ private
54
+
55
+ # Byte spans of hazardous calls in the node tree, with the source slice
56
+ # of each. Inner calls are covered by the outer span, so the walk does
57
+ # not descend into a hazardous call.
58
+ def hazard_spans(root)
59
+ spans = []
60
+ stack = [root]
61
+ until stack.empty?
62
+ node = stack.pop
63
+ if hazardous?(node)
64
+ spans << [node.location.start_offset, node.location.end_offset, node.slice]
65
+ next
66
+ end
67
+ stack.concat(node.compact_child_nodes)
68
+ end
69
+ spans
70
+ end
71
+
72
+ def hazardous?(node)
73
+ case node
74
+ when Prism::XStringNode, Prism::InterpolatedXStringNode then true
75
+ when Prism::CallNode then hazardous_call?(node)
76
+ else false
77
+ end
78
+ end
79
+
80
+ def hazardous_call?(node)
81
+ name = node.name.to_s
82
+ receiver = node.receiver&.slice&.sub(/\A::/, "")
83
+ return true if OPEN3.match?(receiver.to_s)
84
+ return true if ALWAYS_NAMES.include?(name)
85
+
86
+ tag = receiver ? "#{receiver}.#{name}" : name
87
+ return true if @tags.include?(tag)
88
+ # Kernel.system / Kernel.spawn / Process.spawn: the explicit-receiver
89
+ # spelling of a bare process spawn is the same hazard.
90
+ return true if KERNEL_RECEIVERS.include?(receiver) && @tags.include?(name)
91
+
92
+ piped_open?(node, name, receiver)
93
+ end
94
+
95
+ # Kernel#open with a "| command" argument spawns a process.
96
+ def piped_open?(node, name, receiver)
97
+ return false unless name == "open" && (receiver.nil? || receiver == "Kernel")
98
+
99
+ first = node.arguments&.arguments&.first
100
+ case first
101
+ when Prism::StringNode then first.unescaped.start_with?("|")
102
+ when Prism::InterpolatedStringNode
103
+ part = first.parts.first
104
+ part.is_a?(Prism::StringNode) && part.unescaped.start_with?("|")
105
+ else false
106
+ end
107
+ end
108
+
109
+ # A mutation is safe only while no hazardous call survives it: every
110
+ # edit must destroy every span (cover it wholly with a hazard-free
111
+ # replacement). Anything less - touching a call, or editing other
112
+ # code in a method that still holds a call - keeps the call running
113
+ # with altered inputs, so the mutant is suppressed.
114
+ def safe?(mutation, spans)
115
+ mutation.edits.all? { |edit| destroys_all?(edit, spans) }
116
+ end
117
+
118
+ def destroys_all?(edit, spans)
119
+ spans.all? do |start_byte, end_byte, _slice|
120
+ edit.start_byte <= start_byte && edit.end_byte >= end_byte
121
+ end && replacement_clean?(edit.replacement)
122
+ end
123
+
124
+ # The replacement itself, parsed as code. A replacement that parses
125
+ # and contains no hazardous call cannot run one; anything else -
126
+ # a hazard in the new text, or text that does not parse on its own -
127
+ # is treated as hazardous.
128
+ def replacement_clean?(replacement)
129
+ result = Prism.parse(replacement.to_s)
130
+ return false unless result.success?
131
+
132
+ hazard_spans(result.value).empty?
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,52 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "base"
5
+
6
+ module Saboteur
7
+ module MutationSource
8
+ # Changes the arithmetic operation, without changing the assignment target.
9
+ class OperatorAssignment < Base
10
+ REPLACEMENTS = { :+ => "-=", :- => "+=", :* => "/=", :/ => "*=" }.freeze
11
+ WRITE_NODES = [Prism::LocalVariableOperatorWriteNode, Prism::InstanceVariableOperatorWriteNode,
12
+ Prism::ClassVariableOperatorWriteNode, Prism::GlobalVariableOperatorWriteNode,
13
+ Prism::ConstantOperatorWriteNode, Prism::ConstantPathOperatorWriteNode,
14
+ Prism::CallOperatorWriteNode, Prism::IndexOperatorWriteNode].freeze
15
+ SCOPE_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
16
+
17
+ def initialize(arid: []) # rubocop:disable Lint/MissingSuper
18
+ @arid = arid.map { |pattern| Regexp.new(pattern) }
19
+ end
20
+
21
+ def name
22
+ "operator_assignment"
23
+ end
24
+
25
+ def mutations_for(subject)
26
+ found = []
27
+ visit(subject.node.body, subject, found)
28
+ found
29
+ end
30
+
31
+ private
32
+
33
+ def visit(node, subject, found)
34
+ return if node.nil? || SCOPE_NODES.any? { |scope| node.is_a?(scope) }
35
+ return if @arid.any? { |pattern| pattern.match?(node.slice) }
36
+
37
+ replace(node, subject, found) if WRITE_NODES.any? { |type| node.is_a?(type) }
38
+ node.compact_child_nodes.each { |child| visit(child, subject, found) }
39
+ end
40
+
41
+ def replace(node, subject, found)
42
+ replacement = REPLACEMENTS[node.binary_operator]
43
+ return unless replacement
44
+
45
+ location = node.binary_operator_loc
46
+ edit = Edit.new(file: subject.file, start_byte: location.start_offset,
47
+ end_byte: location.end_offset, replacement: replacement)
48
+ found << Mutation.new(subject_id: subject.id, operator: name, edits: [edit], source: name)
49
+ end
50
+ end
51
+ end
52
+ end