saboteur 0.6.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +367 -0
  3. data/README.md +166 -40
  4. data/exe/saboteur +34 -7
  5. data/lib/saboteur/baseline/workflow.rb +2 -1
  6. data/lib/saboteur/baseline_health.rb +40 -6
  7. data/lib/saboteur/cli/exit_status.rb +49 -4
  8. data/lib/saboteur/cli/help_options.rb +16 -0
  9. data/lib/saboteur/cli/presets.rb +36 -0
  10. data/lib/saboteur/cli/run_budget_options.rb +37 -0
  11. data/lib/saboteur/cli/run_reporting.rb +74 -5
  12. data/lib/saboteur/cli.rb +67 -46
  13. data/lib/saboteur/config.rb +66 -11
  14. data/lib/saboteur/crash_report.rb +154 -0
  15. data/lib/saboteur/custom_map.rb +3 -1
  16. data/lib/saboteur/doctor.rb +40 -1
  17. data/lib/saboteur/engine/baseline_guard.rb +79 -5
  18. data/lib/saboteur/engine/boot.rb +12 -1
  19. data/lib/saboteur/engine/canary.rb +41 -10
  20. data/lib/saboteur/engine/candidate_selection.rb +9 -1
  21. data/lib/saboteur/engine/coverage_guard.rb +29 -0
  22. data/lib/saboteur/engine/coverage_store.rb +48 -6
  23. data/lib/saboteur/engine/diagnostics.rb +23 -2
  24. data/lib/saboteur/engine/diff_scope.rb +24 -5
  25. data/lib/saboteur/engine/distribution.rb +124 -8
  26. data/lib/saboteur/engine/early_stop_recording.rb +46 -0
  27. data/lib/saboteur/engine/result_cache.rb +2 -1
  28. data/lib/saboteur/engine/run_budget.rb +63 -0
  29. data/lib/saboteur/engine/run_fingerprint.rb +4 -0
  30. data/lib/saboteur/engine/sandbox_policy.rb +18 -11
  31. data/lib/saboteur/engine/scheduler.rb +114 -18
  32. data/lib/saboteur/engine/subject_selection.rb +84 -5
  33. data/lib/saboteur/engine/suite_files.rb +3 -1
  34. data/lib/saboteur/engine/test_guess.rb +145 -22
  35. data/lib/saboteur/engine/thread_warnings.rb +53 -0
  36. data/lib/saboteur/engine/unscoped_suite_warning.rb +41 -0
  37. data/lib/saboteur/engine/verdict_check.rb +54 -18
  38. data/lib/saboteur/engine.rb +166 -95
  39. data/lib/saboteur/execution/protocol.rb +2 -1
  40. data/lib/saboteur/execution/runner.rb +179 -17
  41. data/lib/saboteur/execution/sandbox.rb +6 -1
  42. data/lib/saboteur/execution/supervisor.rb +18 -3
  43. data/lib/saboteur/execution/worker_pool.rb +87 -7
  44. data/lib/saboteur/help.rb +55 -11
  45. data/lib/saboteur/ignore_list.rb +11 -1
  46. data/lib/saboteur/install.rb +4 -3
  47. data/lib/saboteur/integration/base.rb +7 -2
  48. data/lib/saboteur/integration/rspec.rb +40 -4
  49. data/lib/saboteur/mutation_source/builtin.rb +20 -11
  50. data/lib/saboteur/mutation_source/hazard_guard.rb +21 -3
  51. data/lib/saboteur/mutation_source/interpolation_removal.rb +41 -0
  52. data/lib/saboteur/mutation_source/method_swap.rb +45 -0
  53. data/lib/saboteur/mutation_source/mutation.rb +5 -3
  54. data/lib/saboteur/mutation_source/operand_replacement.rb +54 -0
  55. data/lib/saboteur/mutation_source/operator_examples.rb +4 -1
  56. data/lib/saboteur/mutation_source/operator_registry.rb +4 -1
  57. data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
  58. data/lib/saboteur/preview.rb +2 -1
  59. data/lib/saboteur/reporter/agent_commands.rb +38 -13
  60. data/lib/saboteur/reporter/agent_incomplete.rb +27 -2
  61. data/lib/saboteur/reporter/next_steps.rb +14 -4
  62. data/lib/saboteur/reporter/score_history.rb +5 -1
  63. data/lib/saboteur/reporter/stryker.rb +13 -4
  64. data/lib/saboteur/reporter/survivor_hints.rb +3 -0
  65. data/lib/saboteur/reporter/terminal.rb +34 -10
  66. data/lib/saboteur/result.rb +22 -7
  67. data/lib/saboteur/selection/coverage_map.rb +2 -1
  68. data/lib/saboteur/selection/git_diff.rb +15 -5
  69. data/lib/saboteur/selection/untracked_changes.rb +126 -0
  70. data/lib/saboteur/subject.rb +11 -0
  71. data/lib/saboteur/text.rb +42 -0
  72. data/lib/saboteur/version.rb +1 -1
  73. data/lib/saboteur.rb +16 -1
  74. metadata +17 -2
@@ -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
@@ -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
@@ -28,7 +28,10 @@ module Saboteur
28
28
  "chain_removal" => "x.first.to_s -> x.to_s",
29
29
  "range" => "1..3 -> 1...3",
30
30
  "operator_assignment" => "x += 1 -> x -= 1",
31
- "condition_negation" => "x ? a : b -> !(x) ? a : b"
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 \""
32
35
  }.freeze
33
36
  end
34
37
  end
@@ -32,7 +32,10 @@ module Saboteur
32
32
  "argument_removal" => "Remove a call argument",
33
33
  "pair_removal" => "Remove a hash pair",
34
34
  "endpoint_replacement" => "Swap explicit no-argument first and last",
35
- "chain_removal" => "Remove one call from a method chain"
35
+ "chain_removal" => "Remove one call from a method chain",
36
+ "method_swap" => "Swap a method with its semantic opposite",
37
+ "operand_replacement" => "Replace a binary operation with one operand",
38
+ "interpolation_removal" => "Remove part of an interpolated string"
36
39
  }.freeze
37
40
  POLICY_VERSION = 2
38
41
  TIERS = { "all" => DESCRIPTIONS.keys.freeze }.freeze
@@ -0,0 +1,39 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "prism"
5
+
6
+ module Saboteur
7
+ module MutationSource
8
+ # The tree walk shared by the statement-driven operators: visit every
9
+ # node except nested definitions, skip arid statements, and hand each
10
+ # remaining node to the operator's mutate_node hook.
11
+ module OperatorVisit
12
+ SCOPE_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
13
+
14
+ def mutations_for(subject)
15
+ found = []
16
+ visit(subject.node.body, subject, found)
17
+ found
18
+ end
19
+
20
+ private
21
+
22
+ def visit(node, subject, found)
23
+ return if node.nil? || SCOPE_NODES.any? { |scope| node.is_a?(scope) }
24
+
25
+ if node.is_a?(Prism::StatementsNode)
26
+ node.body.each { |statement| visit(statement, subject, found) unless arid?(statement) }
27
+ return
28
+ end
29
+
30
+ mutate_node(node, subject, found)
31
+ node.compact_child_nodes.each { |child| visit(child, subject, found) }
32
+ end
33
+
34
+ def arid?(node)
35
+ @arid.any? { |pattern| pattern.match?(node.slice) }
36
+ end
37
+ end
38
+ end
39
+ end
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require_relative "engine"
5
+ require_relative "text"
5
6
 
6
7
  module Saboteur
7
8
  # Source-only planning. Never boots the host or executes its test suite.
@@ -92,7 +93,7 @@ module Saboteur
92
93
  # coverage map means "no estimate", never an exception.
93
94
  def cached_map
94
95
  path = File.join(@config.path("cache_dir"), "preview.json")
95
- receipt = JSON.parse(File.read(path))
96
+ receipt = JSON.parse(Text.utf8_strict(File.read(path)))
96
97
  return unless receipt["fingerprint"] == Engine::RunFingerprint.new(config: @config).snapshot.digest
97
98
 
98
99
  Selection::CoverageMap.load(receipt["path"])
@@ -8,15 +8,26 @@ module Saboteur
8
8
  module Agent
9
9
  # The shell commands an agent is told to run next. Every word is escaped with
10
10
  # Shellwords, so a ref, path or subject holding shell syntax stays one literal
11
- # argument. A command is never cut short: when it is longer than MAX_BYTES it
12
- # is left out, because half a command could run something else.
11
+ # argument. A command for the agent protocol is never cut short: when it is
12
+ # longer than MAX_BYTES it is left out, because half a command could run
13
+ # something else. The text footer passes capped: false instead - there the
14
+ # promise is the complete command, however long (#261).
13
15
  module Commands
14
16
  MAX_BYTES = 1024
15
17
 
16
18
  # Options typed on the command line that change how a run behaves.
17
19
  SETTING_FLAGS = { reeval: "--reeval", integration: "--integration", jobs: "--jobs",
18
20
  timeout_factor: "--timeout-factor", json_report: "--json", html_report: "--html",
19
- operators: "--operators", exclude_operators: "--exclude-operators" }.freeze
21
+ operators: "--operators", exclude_operators: "--exclude-operators",
22
+ baseline_passes: "--baseline-passes", max_duration: "--max-duration" }.freeze
23
+
24
+ # CLI-only boolean switches recorded in settings, mapped by value, so
25
+ # an explicit false (--no-max-duration-ok over a config true) survives
26
+ # into the generated commands instead of reverting to the config.
27
+ BOOLEAN_SETTING_FLAGS = { allow_hazardous: { true => "--allow-hazardous" },
28
+ allow_focus: { true => "--allow-focus" },
29
+ max_duration_ok: { true => "--max-duration-ok",
30
+ false => "--no-max-duration-ok" } }.freeze
20
31
 
21
32
  class << self
22
33
  # Run the same selection again: ref, subjects, sources, tests, config,
@@ -26,25 +37,37 @@ module Saboteur
26
37
  finish(words, provenance)
27
38
  end
28
39
 
29
- # Replay one survivor by digest in the same selection.
30
- def replay(provenance, digest)
40
+ # Replay one survivor by digest in the same selection. capped: false
41
+ # skips the protocol byte cap, for callers that print to a terminal.
42
+ def replay(provenance, digest, capped: true)
31
43
  words = ["saboteur", "run", "--id", digest, *selection(provenance, replay: true), "--seed",
32
44
  provenance.seed.to_s]
33
- finish(words, provenance)
45
+ finish(words, provenance, capped: capped)
34
46
  end
35
47
 
36
48
  private
37
49
 
38
- def finish(words, provenance)
50
+ def finish(words, provenance, capped: true)
39
51
  words += ["--", *provenance.sources] unless provenance.sources.empty?
40
52
  command = Shellwords.join(words)
41
- command.bytesize <= MAX_BYTES ? command : nil
53
+ command if !capped || command.bytesize <= MAX_BYTES
42
54
  end
43
55
 
44
- # An explicit scope is frozen: --no-guess-tests keeps a config-file
45
- # guess_tests from conflicting with the --test list.
56
+ def mode_words(provenance)
57
+ words = []
58
+ words << "--no-cache" if provenance.cache == false
59
+ words << "--incremental" if provenance.incremental
60
+ words.concat(boolean_words(provenance))
61
+ end
62
+
63
+ def boolean_words(provenance)
64
+ BOOLEAN_SETTING_FLAGS.filter_map { |key, words| words[provenance.settings[key]] }
65
+ end
66
+
67
+ # An explicit scope is frozen as --test, which wins over inferred
68
+ # selection by precedence, whatever the config file sets.
46
69
  def test_words(tests)
47
- tests.empty? ? [] : ["--no-guess-tests", *tests.flat_map { |test| ["--test", test] }]
70
+ tests.flat_map { |test| ["--test", test] }
48
71
  end
49
72
 
50
73
  def setting_words(provenance)
@@ -56,15 +79,17 @@ module Saboteur
56
79
  end
57
80
  end
58
81
 
82
+ # Whole-suite scope is a bare --all, only when no tests resolved;
83
+ # --all never combines with --test.
59
84
  def selection(provenance, replay: false)
60
85
  words = []
61
86
  words.push("--config", provenance.config_path) if provenance.config_path
62
87
  words.push("--since", provenance.ref) if provenance.ref
63
88
  provenance.subjects.each { |subject| words.push("--subject", subject) }
89
+ words << "--all" if provenance.settings[:all] && provenance.tests.empty?
64
90
  words.push(*test_words(provenance.tests))
65
91
  words.concat(setting_words(provenance))
66
- words << "--no-cache" if provenance.cache == false
67
- words << "--incremental" if provenance.incremental
92
+ words.concat(mode_words(provenance))
68
93
  words.push("--id", provenance.digest) if provenance.digest && !replay
69
94
  words
70
95
  end
@@ -8,6 +8,8 @@ module Saboteur
8
8
  # stable code, a count and a plain meaning.
9
9
  module Incomplete
10
10
  NO_COVERAGE = "no coverage"
11
+ SYNTAX_FAILURE = "invalid_file_syntax"
12
+ UNREADABLE_FAILURE = "unreadable"
11
13
 
12
14
  class << self
13
15
  def reasons(report)
@@ -23,14 +25,37 @@ module Saboteur
23
25
  add(reasons, "mutant_cap", omitted_by_cap(report), "mutants omitted by max_mutants_per_method")
24
26
  add(reasons, "errors", report.counts[:error], "mutants errored; their verdict did not hold up")
25
27
  add(reasons, "resource_limit", report.counts[:resource_limit], "mutants hit the memory or process cap")
26
- add(reasons, "fail_fast", report.early_stop&.unreached&.size.to_i,
27
- "selected subjects did not run because --fail-fast stopped the run")
28
+ add(reasons, "unverified", report.counts[:unverified],
29
+ "mutants the --max-duration budget left unverified; their kills are not confirmed")
30
+ discovery_failures(reasons, report)
31
+ early_stop_reason(reasons, report)
28
32
  inline_scope(reasons, report)
29
33
  reasons
30
34
  end
31
35
 
32
36
  private
33
37
 
38
+ # Source the selection could not read at all: its subjects never
39
+ # entered the run, so a clean or complete verdict would be wrong.
40
+ def discovery_failures(reasons, report)
41
+ failures = report.discovery_failures
42
+ add(reasons, SYNTAX_FAILURE, failures.count { |f| f.reason == SYNTAX_FAILURE },
43
+ "included files did not parse; their subjects were not discovered")
44
+ add(reasons, UNREADABLE_FAILURE, failures.count { |f| f.reason == UNREADABLE_FAILURE },
45
+ "included untracked files could not be read; their subjects were not covered")
46
+ end
47
+
48
+ def early_stop_reason(reasons, report)
49
+ stop = report.early_stop
50
+ return unless stop
51
+
52
+ label = stop.reason == "fail_fast" ? "--fail-fast stopped" : "the --max-duration budget stopped"
53
+ add(reasons, stop.reason, stop.unreached.size, "selected subjects did not run because #{label} the run")
54
+ omitted = stop.truncated.sum { |entry| entry[:omitted] }
55
+ add(reasons, "#{stop.reason}_truncated", omitted,
56
+ "mutants omitted inside subjects that stopped mid-way because #{label} the run")
57
+ end
58
+
34
59
  def inline_scope(reasons, report)
35
60
  add(reasons, "inline_suppressed", report.inline_suppressions.size,
36
61
  "candidates were suppressed by inline directives; see the full report audit")