saboteur 0.5.0 → 0.7.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +399 -0
  3. data/README.md +202 -42
  4. data/exe/saboteur +34 -7
  5. data/lib/saboteur/baseline/comparison.rb +1 -0
  6. data/lib/saboteur/baseline/evidence.rb +11 -1
  7. data/lib/saboteur/baseline/run_evidence.rb +1 -1
  8. data/lib/saboteur/baseline/workflow.rb +2 -1
  9. data/lib/saboteur/baseline_health.rb +40 -6
  10. data/lib/saboteur/cli/exit_status.rb +49 -4
  11. data/lib/saboteur/cli/help_options.rb +16 -0
  12. data/lib/saboteur/cli/presets.rb +36 -0
  13. data/lib/saboteur/cli/run_budget_options.rb +37 -0
  14. data/lib/saboteur/cli/run_reporting.rb +75 -6
  15. data/lib/saboteur/cli.rb +80 -48
  16. data/lib/saboteur/config.rb +88 -13
  17. data/lib/saboteur/crash_report.rb +154 -0
  18. data/lib/saboteur/custom_map.rb +3 -1
  19. data/lib/saboteur/doctor.rb +64 -2
  20. data/lib/saboteur/engine/baseline_guard.rb +79 -5
  21. data/lib/saboteur/engine/boot.rb +12 -1
  22. data/lib/saboteur/engine/canary.rb +41 -10
  23. data/lib/saboteur/engine/candidate_selection.rb +9 -1
  24. data/lib/saboteur/engine/candidates.rb +4 -1
  25. data/lib/saboteur/engine/coverage_guard.rb +29 -0
  26. data/lib/saboteur/engine/coverage_store.rb +48 -6
  27. data/lib/saboteur/engine/diagnostics.rb +23 -2
  28. data/lib/saboteur/engine/diff_scope.rb +24 -5
  29. data/lib/saboteur/engine/distribution.rb +124 -8
  30. data/lib/saboteur/engine/early_stop_recording.rb +46 -0
  31. data/lib/saboteur/engine/result_cache.rb +2 -1
  32. data/lib/saboteur/engine/run_budget.rb +63 -0
  33. data/lib/saboteur/engine/run_fingerprint.rb +5 -1
  34. data/lib/saboteur/engine/sandbox_policy.rb +18 -11
  35. data/lib/saboteur/engine/scheduler.rb +114 -18
  36. data/lib/saboteur/engine/subject_selection.rb +84 -5
  37. data/lib/saboteur/engine/suite_files.rb +3 -1
  38. data/lib/saboteur/engine/test_guess.rb +183 -23
  39. data/lib/saboteur/engine/thread_warnings.rb +53 -0
  40. data/lib/saboteur/engine/unscoped_suite_warning.rb +41 -0
  41. data/lib/saboteur/engine/verdict_check.rb +54 -18
  42. data/lib/saboteur/engine.rb +175 -96
  43. data/lib/saboteur/execution/protocol.rb +2 -1
  44. data/lib/saboteur/execution/runner.rb +179 -17
  45. data/lib/saboteur/execution/sandbox.rb +6 -1
  46. data/lib/saboteur/execution/supervisor.rb +18 -3
  47. data/lib/saboteur/execution/worker_pool.rb +87 -7
  48. data/lib/saboteur/frameworks/database_hosts.rb +30 -0
  49. data/lib/saboteur/frameworks/rails.rb +8 -10
  50. data/lib/saboteur/help.rb +55 -10
  51. data/lib/saboteur/ignore_list.rb +11 -1
  52. data/lib/saboteur/install.rb +4 -3
  53. data/lib/saboteur/integration/base.rb +7 -2
  54. data/lib/saboteur/integration/rspec.rb +40 -4
  55. data/lib/saboteur/mutation_source/builtin.rb +22 -13
  56. data/lib/saboteur/mutation_source/hazard_guard.rb +21 -3
  57. data/lib/saboteur/mutation_source/interpolation_removal.rb +41 -0
  58. data/lib/saboteur/mutation_source/method_swap.rb +45 -0
  59. data/lib/saboteur/mutation_source/mutation.rb +5 -3
  60. data/lib/saboteur/mutation_source/operand_replacement.rb +54 -0
  61. data/lib/saboteur/mutation_source/operator_examples.rb +38 -0
  62. data/lib/saboteur/mutation_source/operator_registry.rb +36 -8
  63. data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
  64. data/lib/saboteur/preview.rb +12 -2
  65. data/lib/saboteur/reporter/agent_commands.rb +49 -16
  66. data/lib/saboteur/reporter/agent_incomplete.rb +27 -2
  67. data/lib/saboteur/reporter/agent_summary.rb +1 -1
  68. data/lib/saboteur/reporter/history_policy.rb +35 -0
  69. data/lib/saboteur/reporter/history_record.rb +4 -5
  70. data/lib/saboteur/reporter/next_steps.rb +14 -4
  71. data/lib/saboteur/reporter/score_history.rb +19 -3
  72. data/lib/saboteur/reporter/stryker.rb +19 -8
  73. data/lib/saboteur/reporter/survivor_hints.rb +3 -0
  74. data/lib/saboteur/reporter/terminal.rb +43 -10
  75. data/lib/saboteur/result.rb +25 -7
  76. data/lib/saboteur/selection/coverage_map.rb +2 -1
  77. data/lib/saboteur/selection/git_diff.rb +15 -5
  78. data/lib/saboteur/selection/untracked_changes.rb +126 -0
  79. data/lib/saboteur/subject.rb +11 -0
  80. data/lib/saboteur/text.rb +42 -0
  81. data/lib/saboteur/version.rb +1 -1
  82. data/lib/saboteur.rb +16 -1
  83. metadata +20 -2
data/lib/saboteur/help.rb CHANGED
@@ -25,7 +25,10 @@ module Saboteur
25
25
  2 => "Usage or configuration error: fix the command or config/saboteur.rb. A replay --id that matches no " \
26
26
  "mutant is only found after the target has booted. A named source or test file that does not exist.",
27
27
  3 => "Tool error: saboteur could not finish, or more than half of the executed mutants errored (such " \
28
- "a run tested almost nothing). With --threshold, also any errored or resource-limited mutant."
28
+ "a run tested almost nothing). With --threshold, also any errored or resource-limited mutant.",
29
+ 4 => "Time budget spent: --max-duration stopped the run before the next subject, so the score is " \
30
+ "partial. --max-duration-ok leaves the exit to the plain verdict: 0, 1, or 3 when the budget " \
31
+ "stopped the run before any mutant was tested."
29
32
  }.freeze
30
33
 
31
34
  DOCTOR_EXIT_CODE_MEANINGS = {
@@ -40,15 +43,20 @@ module Saboteur
40
43
  summary: "Mutate your code and report the mutants your tests do not catch",
41
44
  description: "Builds a per-test coverage map, makes small changes to your methods, runs only the " \
42
45
  "tests that cover each change, and reports survivors (changes no test noticed). Source files or " \
43
- "directories after run replace include and exclude; --test limits the specs that run; " \
44
- "--threshold PERCENT replaces the survivor check: exit 0 when the score is at or above " \
45
- "PERCENT, exit 1 when it is below. Inline comment syntax: " \
46
+ "directories after run replace include and exclude. Without --test, the run infers the " \
47
+ "specs named after the mutated files and runs only those; a mutated file with no " \
48
+ "conventional spec stops the run with exit 2 before anything boots, and --all keeps " \
49
+ "the whole configured suite. --threshold PERCENT replaces the survivor check: exit 0 " \
50
+ "when the score is at or above PERCENT, exit 1 when it is below. All operators run " \
51
+ "unless you select a smaller set; exclusions always remove operators from that set. " \
52
+ "Inline comment syntax: " \
46
53
  '# saboteur:disable-line [operator ...] reason="...". A reason is required.',
47
54
  takes_options: true,
48
55
  exit_codes: EXIT_CODE_MEANINGS,
49
56
  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"],
57
+ ["Pull request: only methods changed since the target branch, with the inferred specs",
58
+ "saboteur run --since origin/main"],
59
+ ["Whole project and whole suite, four workers, repeatable seed", "saboteur run --all --jobs 4 --seed 42"],
52
60
  ["One class or method", "saboteur run --subject 'Person#adult?'"],
53
61
  ["Replay one mutant by the digest printed next to a survivor", "saboteur run --id 1a2b3c4d5e6f7a8b"],
54
62
  ["Mutate named files against named specs, skipping the whole-suite coverage run",
@@ -57,6 +65,35 @@ module Saboteur
57
65
  ["AI agent: bounded JSON summary and strict exit codes", "saboteur run --agent --since origin/main"]
58
66
  ]
59
67
  ),
68
+ Command.new(
69
+ name: "pr",
70
+ summary: "Preset: the fast pull-request loop over the lines changed since a ref",
71
+ description: "Expands to saboteur run --baseline-passes 1 --max-duration 10m --max-duration-ok " \
72
+ "--since REF, with REF the first operand (default main): one baseline check, a " \
73
+ "ten-minute wall-clock budget, and mutants limited to the changed methods. The " \
74
+ "preset opts out of the budget-specific exit 4; the normal and incomplete " \
75
+ "verdicts still fail the run (survivors exit 1, a budget stop before any mutant " \
76
+ "ran exits 3). Every run option still works; an explicit flag after the preset " \
77
+ "wins (#244).",
78
+ takes_options: true,
79
+ exit_codes: EXIT_CODE_MEANINGS,
80
+ examples: [["Changed lines since main", "saboteur pr"],
81
+ ["Changed lines since another target", "saboteur pr origin/develop"],
82
+ ["The same loop with two baseline checks", "saboteur pr --baseline-passes 2"]]
83
+ ),
84
+ Command.new(
85
+ name: "audit",
86
+ summary: "Preset: the whole project, all operators, both reports",
87
+ description: "Expands to saboteur run --all --operators all --baseline-passes 2 " \
88
+ "--json .saboteur/audit-report.json --html .saboteur/audit-report.html: every " \
89
+ "subject against the whole suite, all operators, both baseline checks, writing " \
90
+ "the Stryker JSON and HTML reports. Configured selection limits (since, " \
91
+ "subjects, exclude_operators, max_duration) still apply; an explicit flag after " \
92
+ "the preset wins (#244), and an explicit --test replaces the injected --all.",
93
+ takes_options: true,
94
+ exit_codes: EXIT_CODE_MEANINGS,
95
+ examples: [["Full project audit with JSON and HTML reports", "saboteur audit"]]
96
+ ),
60
97
  Command.new(
61
98
  name: "doctor",
62
99
  summary: "Check the environment, and with --suite run the baseline twice, without mutating anything",
@@ -121,7 +158,7 @@ module Saboteur
121
158
 
122
159
  AGENT_EXIT_CODES = " With --agent the codes are stricter: 0 only when the run was complete and clean; " \
123
160
  "1 survivors to review;\n 2 usage or config error; 3 incomplete (no coverage, caps, errors, " \
124
- "nothing selected) or tool failure."
161
+ "a budget stop before any mutant ran, nothing selected) or tool failure."
125
162
 
126
163
  FILES = [
127
164
  ["config/saboteur.rb", "Settings as Ruby (Saboteur.configure). Command line options override it."],
@@ -165,8 +202,16 @@ module Saboteur
165
202
  @commands.select(&:takes_options).map { |command| options_section(command) }
166
203
  end
167
204
 
205
+ # run, audit and pr share one exit-code table; the overview keeps every
206
+ # command's section but carries the agent note only on the first table
207
+ # that shares it, so the phrase appears exactly once.
168
208
  def exit_code_sections
169
- @commands.select(&:exit_codes).map { |command| exit_codes_section(command.exit_codes, command.name) }
209
+ noted = []
210
+ @commands.select(&:exit_codes).map do |command|
211
+ first = noted.none? { |codes| codes.equal?(command.exit_codes) }
212
+ noted << command.exit_codes
213
+ exit_codes_section(command.exit_codes, command.name, agent_note: first)
214
+ end
170
215
  end
171
216
 
172
217
  def title
@@ -203,10 +248,10 @@ module Saboteur
203
248
  "EXAMPLES\n#{examples.map { |note, line| " # #{note}\n #{line}" }.join("\n")}\n"
204
249
  end
205
250
 
206
- def exit_codes_section(codes, command_name = nil)
251
+ def exit_codes_section(codes, command_name = nil, agent_note: true)
207
252
  rows = codes.map { |code, meaning| " #{code} #{meaning}" }
208
253
  title = command_name ? "EXIT CODES (saboteur #{command_name})" : "EXIT CODES"
209
- note = codes.equal?(EXIT_CODE_MEANINGS) ? "#{AGENT_EXIT_CODES}\n" : ""
254
+ note = agent_note && codes.equal?(EXIT_CODE_MEANINGS) ? "#{AGENT_EXIT_CODES}\n" : ""
210
255
  "#{title}\n#{rows.join("\n")}\n#{note}"
211
256
  end
212
257
 
@@ -103,7 +103,10 @@ module Saboteur
103
103
 
104
104
  # Marks matching survivors as ignored. Returns warnings for stale entries:
105
105
  # entries whose subject was examined but whose mutation no longer exists.
106
- def apply(results)
106
+ # unscoped_digests (subject id => every digest generated before a diff
107
+ # scope cut) keeps a scoped run from calling a scoped-out entry stale
108
+ # (#284).
109
+ def apply(results, unscoped_digests: nil)
107
110
  seen = {}
108
111
  results.each do |result|
109
112
  key = [result.subject.id, result.digest]
@@ -113,8 +116,15 @@ module Saboteur
113
116
  seen[key] = true
114
117
  result.ignored = entry.reason if result.survived?
115
118
  end
119
+ stale_warnings(results, seen, unscoped_digests)
120
+ end
121
+
122
+ private
123
+
124
+ def stale_warnings(results, seen, unscoped_digests)
116
125
  examined = results.to_set { |result| result.subject.id }
117
126
  entries.select { |entry| examined.include?(entry.subject) && !seen[[entry.subject, entry.mutation]] }
127
+ .reject { |entry| unscoped_digests&.fetch(entry.subject, nil)&.include?(entry.mutation) }
118
128
  .map { |entry| "stale ignore entry #{entry.subject} #{entry.mutation}: no such mutation" }
119
129
  end
120
130
  end
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "fileutils"
5
+ require_relative "text"
5
6
 
6
7
  module Saboteur
7
8
  # Writes the Rails starter files: config/saboteur.rb, bin/saboteur and a
@@ -64,7 +65,7 @@ module Saboteur
64
65
  # Printed after install; the Gemfile is never edited.
65
66
  def gemfile_hint
66
67
  gemfile = File.join(@root, "Gemfile")
67
- return nil if File.file?(gemfile) && File.read(gemfile).match?(/^\s*gem\s+["']saboteur["']/)
68
+ return nil if File.file?(gemfile) && Text.utf8(File.read(gemfile)).match?(/^\s*gem\s+["']saboteur["']/)
68
69
 
69
70
  GEMFILE_HINT
70
71
  end
@@ -108,8 +109,8 @@ module Saboteur
108
109
  link = symlink_component(".gitignore")
109
110
  return Result.new(:skipped, ".gitignore") if link && !@force
110
111
 
111
- current = File.file?(path) ? File.read(path) : ""
112
- listed = listed?(current)
112
+ current = File.file?(path) ? Text.utf8_identity(File.read(path)) : ""
113
+ listed = listed?(Text.utf8(current))
113
114
  return Result.new(:unchanged, ".gitignore") if listed && !link
114
115
 
115
116
  File.delete(link) if link
@@ -10,8 +10,13 @@ module Saboteur
10
10
  # so a kill by a failed assertion can be told from a kill by a crash.
11
11
  # failure_messages: the first line of each failure, so a verdict that
12
12
  # does not hold up can say why the test failed.
13
- RunResult = Data.define(:passed, :failed_ids, :executed_ids, :runtime, :failure_classes, :failure_messages) do
14
- def initialize(passed:, failed_ids:, executed_ids:, runtime:, failure_classes: {}, failure_messages: {})
13
+ # failure_details: the full message and first backtrace frames of each
14
+ # failure, so a neutral re-run (the verdict check) can say exactly why
15
+ # the killer failed with the method unchanged (#296).
16
+ RunResult = Data.define(:passed, :failed_ids, :executed_ids, :runtime, :failure_classes, :failure_messages,
17
+ :failure_details) do
18
+ def initialize(passed:, failed_ids:, executed_ids:, runtime:, failure_classes: {}, failure_messages: {},
19
+ failure_details: {})
15
20
  super
16
21
  end
17
22
  end
@@ -55,7 +55,8 @@ module Saboteur
55
55
  passed = execute(listener)
56
56
  RunResult.new(passed: passed && listener.failed.empty?, failed_ids: listener.failed,
57
57
  executed_ids: listener.executed, runtime: listener.runtime,
58
- failure_classes: listener.failure_classes, failure_messages: listener.failure_messages)
58
+ failure_classes: listener.failure_classes, failure_messages: listener.failure_messages,
59
+ failure_details: listener.failure_details)
59
60
  end
60
61
 
61
62
  def run_all(recorder)
@@ -71,7 +72,8 @@ module Saboteur
71
72
  passed = execute(listener, log_phase: true)
72
73
  RunResult.new(passed: passed && listener.failed.empty?, failed_ids: listener.failed,
73
74
  executed_ids: listener.executed, runtime: listener.runtime,
74
- failure_classes: listener.failure_classes, failure_messages: listener.failure_messages)
75
+ failure_classes: listener.failure_classes, failure_messages: listener.failure_messages,
76
+ failure_details: listener.failure_details)
75
77
  end
76
78
 
77
79
  private
@@ -185,8 +187,10 @@ module Saboteur
185
187
  EVENTS = %i[example_started example_finished example_failed].freeze
186
188
 
187
189
  MESSAGE_LIMIT = 200
190
+ DETAIL_LIMIT = 2_000
191
+ BACKTRACE_LIMIT = 5
188
192
 
189
- attr_reader :executed, :failed, :failure_classes, :failure_messages
193
+ attr_reader :executed, :failed, :failure_classes, :failure_messages, :failure_details
190
194
  attr_accessor :runtime
191
195
 
192
196
  def initialize(recorder = nil)
@@ -195,6 +199,7 @@ module Saboteur
195
199
  @failed = []
196
200
  @failure_classes = {}
197
201
  @failure_messages = {}
202
+ @failure_details = {}
198
203
  @runtime = 0.0
199
204
  end
200
205
 
@@ -213,12 +218,43 @@ module Saboteur
213
218
  exception = notification.example.execution_result.exception
214
219
  @failure_classes[notification.example.id] = exception&.class&.name
215
220
  @failure_messages[notification.example.id] = first_line(exception&.message)
221
+ @failure_details[notification.example.id] = detail_for(exception)
216
222
  end
217
223
 
218
224
  private
219
225
 
226
+ # The full failure, class and first frames: a neutral re-run that
227
+ # fails needs enough context to explain why (the compact first line
228
+ # alone reads as just "expected: 0").
229
+ def detail_for(exception)
230
+ return nil unless exception
231
+
232
+ message = Text.utf8("#{exception.class.name}: #{exception.message}")
233
+ ([message] + relevant_frames(exception)).join("\n")[0, DETAIL_LIMIT]
234
+ rescue StandardError
235
+ exception.message.to_s.dump[0, DETAIL_LIMIT]
236
+ end
237
+
238
+ # An expectation failure leads with rspec's own frames (notify_failure,
239
+ # handle_failure); under the cap they would crowd out the spec line.
240
+ # Keep the project frames; fall back to the raw ones when rspec itself
241
+ # raised, so the detail never comes back empty.
242
+ def relevant_frames(exception)
243
+ frames = Array(exception.backtrace).map { |frame| Text.utf8(frame.to_s) }
244
+ project = frames.reject { |frame| frame.include?("/gems/rspec") }
245
+ (project.empty? ? frames : project).first(BACKTRACE_LIMIT)
246
+ end
247
+
220
248
  def first_line(message)
221
- message.to_s.lines.map(&:strip).find { |line| !line.empty? }.to_s[0, MESSAGE_LIMIT]
249
+ # Exception messages can carry bytes invalid under the locale encoding
250
+ # (e.g. a path with raw bytes); scrub before splitting, or .lines/.strip
251
+ # raises Encoding::CompatibilityError and the run reports error (CI #272).
252
+ Text.utf8(message.to_s).lines.map(&:strip).find { |line| !line.empty? }.to_s[0, MESSAGE_LIMIT]
253
+ rescue StandardError
254
+ # Text.utf8 is mutable code: when the mutant under test breaks it,
255
+ # the raise must not flip the run to error - the recorded failure
256
+ # is what classifies the kill. dump is ASCII-safe for any bytes.
257
+ message.to_s.dump[0, MESSAGE_LIMIT]
222
258
  end
223
259
  end
224
260
  end
@@ -13,6 +13,9 @@ require_relative "operator_registry"
13
13
  require_relative "range_replacement"
14
14
  require_relative "operator_assignment"
15
15
  require_relative "condition_negation"
16
+ require_relative "method_swap"
17
+ require_relative "operand_replacement"
18
+ require_relative "interpolation_removal"
16
19
 
17
20
  module Saboteur
18
21
  module MutationSource
@@ -53,9 +56,9 @@ module Saboteur
53
56
  CATCH_ALL = %w[StandardError ::StandardError Exception ::Exception].freeze
54
57
  SCOPE_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
55
58
 
56
- def initialize(arid: DEFAULT_ARID, operators: [])
59
+ def initialize(arid: DEFAULT_ARID, operators: [], exclude_operators: [])
57
60
  super()
58
- @operators = OperatorRegistry.resolve(operators)
61
+ @operators = OperatorRegistry.select(operators, exclude_operators)
59
62
  @arid = arid.map { |pattern| pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern) }
60
63
  @filter_replacement = FilterReplacement.new(arid: arid)
61
64
  @literal_replacement = LiteralReplacement.new(arid: arid)
@@ -66,6 +69,9 @@ module Saboteur
66
69
  @range_replacement = RangeReplacement.new(arid: arid)
67
70
  @operator_assignment = OperatorAssignment.new(arid: arid)
68
71
  @condition_negation = ConditionNegation.new(arid: arid)
72
+ @method_swap = MethodSwap.new(arid: arid)
73
+ @operand_replacement = OperandReplacement.new(arid: arid)
74
+ @interpolation_removal = InterpolationRemoval.new(arid: arid)
69
75
  end
70
76
 
71
77
  def name
@@ -78,20 +84,20 @@ module Saboteur
78
84
  @found = []
79
85
  coarse(body)
80
86
  visit(body)
81
- @found.concat(@filter_replacement.mutations_for(subject))
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))
87
+ @found.concat(delegated.flat_map { |source| source.mutations_for(subject) })
90
88
  @found.select { |mutation| @operators.include?(mutation.operator) }
91
89
  ensure
92
90
  @subject = nil
93
91
  end
94
92
 
93
+ # The single-purpose sources run after the shared walk, in registration
94
+ # order; each filters its own operator through the same policy below.
95
+ def delegated
96
+ [@filter_replacement, @literal_replacement, @argument_removal, @pair_removal, @chain_removal,
97
+ @endpoint_replacement, @range_replacement, @operator_assignment, @condition_negation,
98
+ @method_swap, @operand_replacement, @interpolation_removal]
99
+ end
100
+
95
101
  private
96
102
 
97
103
  # --- coarse ---------------------------------------------------------
@@ -185,9 +191,12 @@ module Saboteur
185
191
  add("safe_navigation", node.call_operator_loc, ".") if node.safe_navigation?
186
192
  end
187
193
 
194
+ # Only the infix spelling is binary: an operator call has no call
195
+ # operator, and its receiver, message, single argument and lack of a
196
+ # block all follow from that (a block binds to the last operand's own
197
+ # call, never to the operator).
188
198
  def binary?(node)
189
- node.receiver && node.message_loc && node.call_operator_loc.nil? &&
190
- node.arguments&.arguments&.size == 1 && node.block.nil?
199
+ node.call_operator_loc.nil?
191
200
  end
192
201
 
193
202
  def numeric_operands?(node)
@@ -31,6 +31,7 @@ module Saboteur
31
31
  ].freeze
32
32
 
33
33
  OPEN3 = /\A(::)?Open3\z/
34
+ SEND_NAMES = %w[send public_send __send__].freeze
34
35
  ALWAYS_NAMES = %w[rmtree].freeze
35
36
  KERNEL_RECEIVERS = %w[Kernel Process].freeze
36
37
 
@@ -81,6 +82,19 @@ module Saboteur
81
82
  name = node.name.to_s
82
83
  receiver = node.receiver&.slice&.sub(/\A::/, "")
83
84
  return true if OPEN3.match?(receiver.to_s)
85
+
86
+ args = node.arguments&.arguments || []
87
+ # obj.send(:rmtree, x) is obj.rmtree(x) with indirection (#240): the
88
+ # resolved selector faces the same direct-call semantics, with the
89
+ # arguments shifted past it (#294 review). A dynamic selector stays
90
+ # out of scope, as documented.
91
+ return true if SEND_NAMES.include?(name) && (selector = literal_selector(args.first)) &&
92
+ direct_hazard?(selector, receiver, args[1])
93
+
94
+ direct_hazard?(name, receiver, args.first)
95
+ end
96
+
97
+ def direct_hazard?(name, receiver, first_arg)
84
98
  return true if ALWAYS_NAMES.include?(name)
85
99
 
86
100
  tag = receiver ? "#{receiver}.#{name}" : name
@@ -89,14 +103,18 @@ module Saboteur
89
103
  # spelling of a bare process spawn is the same hazard.
90
104
  return true if KERNEL_RECEIVERS.include?(receiver) && @tags.include?(name)
91
105
 
92
- piped_open?(node, name, receiver)
106
+ piped_open?(first_arg, name, receiver)
107
+ end
108
+
109
+ # A literal symbol or string selector names the real call.
110
+ def literal_selector(first)
111
+ first.unescaped if first.is_a?(Prism::SymbolNode) || first.is_a?(Prism::StringNode)
93
112
  end
94
113
 
95
114
  # Kernel#open with a "| command" argument spawns a process.
96
- def piped_open?(node, name, receiver)
115
+ def piped_open?(first, name, receiver)
97
116
  return false unless name == "open" && (receiver.nil? || receiver == "Kernel")
98
117
 
99
- first = node.arguments&.arguments&.first
100
118
  case first
101
119
  when Prism::StringNode then first.unescaped.start_with?("|")
102
120
  when Prism::InterpolatedStringNode
@@ -0,0 +1,41 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "prism"
5
+ require_relative "base"
6
+ require_relative "operator_visit"
7
+
8
+ module Saboteur
9
+ module MutationSource
10
+ # Removes one piece of an interpolated string at a time: each #{expr}
11
+ # and each literal segment. Plain strings stay with literal_replacement
12
+ # and backticks stay untouched (the hazard guard owns those).
13
+ class InterpolationRemoval < Base
14
+ include OperatorVisit
15
+
16
+ # Base inherits Object's empty initializer; there is no parent state to set.
17
+ def initialize(arid: []) # rubocop:disable Lint/MissingSuper
18
+ @arid = arid.map { |pattern| Regexp.new(pattern) }
19
+ end
20
+
21
+ def name
22
+ "interpolation_removal"
23
+ end
24
+
25
+ private
26
+
27
+ def mutate_node(node, subject, found)
28
+ remove_parts(node, subject, found) if node.is_a?(Prism::InterpolatedStringNode)
29
+ end
30
+
31
+ def remove_parts(node, subject, found)
32
+ node.parts.each do |part|
33
+ location = part.location
34
+ edit = Edit.new(file: subject.file, start_byte: location.start_offset,
35
+ end_byte: location.end_offset, replacement: "")
36
+ found << Mutation.new(subject_id: subject.id, operator: name, edits: [edit], source: name)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,45 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "prism"
5
+ require_relative "base"
6
+ require_relative "operator_visit"
7
+
8
+ module Saboteur
9
+ module MutationSource
10
+ # Swaps a method whose name has a semantic opposite: min with max,
11
+ # any? with all?, map with each. Only message bytes change; receiver,
12
+ # arguments and block stay intact. A receiver is required, so a bare
13
+ # max(a, b) Kernel call is left alone.
14
+ class MethodSwap < Base
15
+ include OperatorVisit
16
+
17
+ SWAPS = { min: "max", max: "min", any?: "all?", all?: "any?", map: "each", each: "map" }.freeze
18
+
19
+ # Base inherits Object's empty initializer; there is no parent state to set.
20
+ def initialize(arid: []) # rubocop:disable Lint/MissingSuper
21
+ @arid = arid.map { |pattern| Regexp.new(pattern) }
22
+ end
23
+
24
+ def name
25
+ "method_swap"
26
+ end
27
+
28
+ private
29
+
30
+ def mutate_node(node, subject, found)
31
+ swap(node, subject, found) if node.is_a?(Prism::CallNode)
32
+ end
33
+
34
+ def swap(node, subject, found)
35
+ replacement = SWAPS[node.name]
36
+ return unless replacement && node.receiver
37
+
38
+ location = node.message_loc
39
+ edit = Edit.new(file: subject.file, start_byte: location.start_offset,
40
+ end_byte: location.end_offset, replacement: replacement)
41
+ found << Mutation.new(subject_id: subject.id, operator: name, edits: [edit], source: name)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -15,13 +15,15 @@ module Saboteur
15
15
  end
16
16
 
17
17
  # Stable id for the ignore list. Offsets are made relative to subject_start
18
- # so edits elsewhere in the file do not change the id.
18
+ # so edits elsewhere in the file do not change the id. The digest hashes
19
+ # bytes, not encodings: a binary-read source and a UTF-8 replacement hold
20
+ # the same identity, and joining them without .b raises under LANG=C.
19
21
  def digest(subject_start:, original_source:)
20
22
  parts = edits.map do |edit|
21
23
  original = original_source.byteslice(edit.start_byte, edit.end_byte - edit.start_byte)
22
- [edit.start_byte - subject_start, original, edit.replacement].join("\0")
24
+ [edit.start_byte - subject_start, original.to_s.b, edit.replacement.b].join("\0".b)
23
25
  end
24
- Digest::SHA256.hexdigest([subject_id, operator, *parts].join("\x1F"))[0, 16]
26
+ Digest::SHA256.hexdigest([subject_id.to_s.b, operator.to_s.b, *parts].join("\x1F".b))[0, 16]
25
27
  end
26
28
  end
27
29
  end
@@ -0,0 +1,54 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "prism"
5
+ require_relative "base"
6
+ require_relative "operator_visit"
7
+
8
+ module Saboteur
9
+ module MutationSource
10
+ # Replaces a binary operation with each of its operands: a + b yields a
11
+ # and b. Arithmetic and comparison calls only; boolean connectors stay
12
+ # with boolean_logic, which already replaces a connector with each side.
13
+ # An operand binds at least as tightly as the operation it replaces, so
14
+ # it needs no parentheses (the re-parse check backs this).
15
+ class OperandReplacement < Base
16
+ include OperatorVisit
17
+
18
+ FAMILIES = %i[+ - * / % ** < <= > >= == !=].freeze
19
+
20
+ # Base inherits Object's empty initializer; there is no parent state to set.
21
+ def initialize(arid: []) # rubocop:disable Lint/MissingSuper
22
+ @arid = arid.map { |pattern| Regexp.new(pattern) }
23
+ end
24
+
25
+ def name
26
+ "operand_replacement"
27
+ end
28
+
29
+ private
30
+
31
+ def mutate_node(node, subject, found)
32
+ replace_operands(node, subject, found) if node.is_a?(Prism::CallNode)
33
+ end
34
+
35
+ def replace_operands(node, subject, found)
36
+ return unless FAMILIES.include?(node.name) && binary?(node)
37
+
38
+ [node.receiver, *node.arguments.arguments].each do |operand|
39
+ edit = Edit.new(file: subject.file, start_byte: node.start_offset,
40
+ end_byte: node.end_offset, replacement: operand.slice)
41
+ found << Mutation.new(subject_id: subject.id, operator: name, edits: [edit], source: name)
42
+ end
43
+ end
44
+
45
+ # Only the infix spelling is binary: an operator call has no call
46
+ # operator, and its receiver, message, single argument and lack of a
47
+ # block all follow from that (a block binds to the last operand's own
48
+ # call, never to the operator).
49
+ def binary?(node)
50
+ node.call_operator_loc.nil?
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,38 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module MutationSource
6
+ module OperatorExamples
7
+ EXAMPLES = {
8
+ "body_nil" => "def value = 1 -> def value = nil",
9
+ "body_true" => "def ok? = false -> def ok? = true",
10
+ "body_false" => "def ok? = true -> def ok? = false",
11
+ "body_raise" => "def value = 1 -> def value; raise; end",
12
+ "comparison" => "a < b -> a <= b",
13
+ "arithmetic" => "a + b -> a - b",
14
+ "boolean_logic" => "a && b -> a",
15
+ "condition_true" => "if x -> if true",
16
+ "condition_false" => "if x -> if false",
17
+ "negation_removal" => "!x -> x",
18
+ "literal_number" => "1 -> 0",
19
+ "or_assign" => "x ||= 1 -> x = 1",
20
+ "safe_navigation" => "x&.name -> x.name",
21
+ "statement_removal" => "check; value -> nil; value",
22
+ "filter_replacement" => "xs.select { |x| x } -> xs.reject { |x| x }",
23
+ "literal_string" => "\"hello\" -> \"\"",
24
+ "literal_boolean" => "true -> false",
25
+ "argument_removal" => "send(a, b) -> send(a)",
26
+ "pair_removal" => "{a: 1, b: 2} -> {b: 2}",
27
+ "endpoint_replacement" => "xs.first -> xs.last",
28
+ "chain_removal" => "x.first.to_s -> x.to_s",
29
+ "range" => "1..3 -> 1...3",
30
+ "operator_assignment" => "x += 1 -> x -= 1",
31
+ "condition_negation" => "x ? a : b -> !(x) ? a : b",
32
+ "method_swap" => "xs.min -> xs.max",
33
+ "operand_replacement" => "a + b -> a",
34
+ "interpolation_removal" => "\"hi \#{name}\" -> \"hi \""
35
+ }.freeze
36
+ end
37
+ end
38
+ end