saboteur 0.7.0 → 0.8.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +657 -0
  3. data/README.md +198 -1077
  4. data/lib/saboteur/cli/exit_status.rb +32 -1
  5. data/lib/saboteur/cli/help_options.rb +2 -1
  6. data/lib/saboteur/cli/history_command.rb +135 -0
  7. data/lib/saboteur/cli/presets.rb +6 -2
  8. data/lib/saboteur/cli/selection_options.rb +31 -0
  9. data/lib/saboteur/cli/shard_options.rb +17 -0
  10. data/lib/saboteur/cli/source_set.rb +53 -0
  11. data/lib/saboteur/cli/survivors_command.rb +157 -0
  12. data/lib/saboteur/cli.rb +71 -54
  13. data/lib/saboteur/config.rb +98 -5
  14. data/lib/saboteur/config_file.rb +8 -0
  15. data/lib/saboteur/constant_reads.rb +182 -0
  16. data/lib/saboteur/constant_subject.rb +70 -0
  17. data/lib/saboteur/constant_subject_finder.rb +281 -0
  18. data/lib/saboteur/coverage_reads.rb +29 -0
  19. data/lib/saboteur/dirty_tree_warning.rb +128 -0
  20. data/lib/saboteur/doctor.rb +9 -0
  21. data/lib/saboteur/engine/baseline_guard.rb +62 -14
  22. data/lib/saboteur/engine/boot.rb +4 -0
  23. data/lib/saboteur/engine/candidate_selection.rb +19 -0
  24. data/lib/saboteur/engine/candidates.rb +43 -3
  25. data/lib/saboteur/engine/coverage_store.rb +32 -10
  26. data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
  27. data/lib/saboteur/engine/diagnostics.rb +18 -0
  28. data/lib/saboteur/engine/distribution.rb +10 -0
  29. data/lib/saboteur/engine/early_stop_recording.rb +3 -2
  30. data/lib/saboteur/engine/gated_run.rb +35 -0
  31. data/lib/saboteur/engine/load_coverage.rb +45 -0
  32. data/lib/saboteur/engine/out_of_scope_guard.rb +21 -0
  33. data/lib/saboteur/engine/pipelines.rb +107 -0
  34. data/lib/saboteur/engine/result_cache.rb +65 -8
  35. data/lib/saboteur/engine/run_budget.rb +27 -0
  36. data/lib/saboteur/engine/sandbox_policy.rb +38 -0
  37. data/lib/saboteur/engine/scheduler.rb +109 -3
  38. data/lib/saboteur/engine/selection_announcement.rb +54 -0
  39. data/lib/saboteur/engine/shard.rb +69 -0
  40. data/lib/saboteur/engine/subject_selection.rb +295 -20
  41. data/lib/saboteur/engine/test_guess.rb +315 -25
  42. data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
  43. data/lib/saboteur/engine.rb +100 -127
  44. data/lib/saboteur/execution/external_runner.rb +471 -0
  45. data/lib/saboteur/execution/marker_sweep.rb +79 -0
  46. data/lib/saboteur/execution/peak_memory.rb +28 -0
  47. data/lib/saboteur/execution/process_table.rb +12 -0
  48. data/lib/saboteur/execution/process_tree.rb +65 -5
  49. data/lib/saboteur/execution/reevaluator.rb +68 -12
  50. data/lib/saboteur/execution/runner.rb +46 -7
  51. data/lib/saboteur/execution/sandbox.rb +15 -2
  52. data/lib/saboteur/execution/seatbelt.rb +226 -0
  53. data/lib/saboteur/execution/supervisor.rb +27 -10
  54. data/lib/saboteur/execution/worker_pool.rb +23 -1
  55. data/lib/saboteur/frameworks/sqlite_memory_database.rb +49 -0
  56. data/lib/saboteur/gates.rb +43 -2
  57. data/lib/saboteur/help.rb +60 -9
  58. data/lib/saboteur/ignore_list.rb +29 -6
  59. data/lib/saboteur/integration/rspec.rb +135 -5
  60. data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
  61. data/lib/saboteur/mutation_source/builtin.rb +26 -5
  62. data/lib/saboteur/mutation_source/chain_removal.rb +1 -1
  63. data/lib/saboteur/mutation_source/condition_negation.rb +1 -1
  64. data/lib/saboteur/mutation_source/constant_literals.rb +66 -0
  65. data/lib/saboteur/mutation_source/element_removal.rb +73 -0
  66. data/lib/saboteur/mutation_source/endpoint_replacement.rb +1 -1
  67. data/lib/saboteur/mutation_source/entry_edits.rb +44 -0
  68. data/lib/saboteur/mutation_source/filter_replacement.rb +1 -1
  69. data/lib/saboteur/mutation_source/hazard_guard.rb +108 -0
  70. data/lib/saboteur/mutation_source/literal_replacement.rb +1 -1
  71. data/lib/saboteur/mutation_source/method_swap.rb +148 -0
  72. data/lib/saboteur/mutation_source/operator_assignment.rb +1 -1
  73. data/lib/saboteur/mutation_source/operator_examples.rb +3 -1
  74. data/lib/saboteur/mutation_source/operator_registry.rb +4 -2
  75. data/lib/saboteur/mutation_source/operator_visit.rb +1 -1
  76. data/lib/saboteur/mutation_source/pair_removal.rb +13 -31
  77. data/lib/saboteur/mutation_source/range_replacement.rb +1 -1
  78. data/lib/saboteur/mutation_source/regex_replacement.rb +93 -0
  79. data/lib/saboteur/preview.rb +120 -12
  80. data/lib/saboteur/refinement_activations.rb +60 -0
  81. data/lib/saboteur/reporter/agent.rb +9 -1
  82. data/lib/saboteur/reporter/agent_commands.rb +10 -3
  83. data/lib/saboteur/reporter/agent_incomplete.rb +11 -0
  84. data/lib/saboteur/reporter/agent_summary.rb +4 -3
  85. data/lib/saboteur/reporter/findings.rb +5 -2
  86. data/lib/saboteur/reporter/history_policy.rb +11 -0
  87. data/lib/saboteur/reporter/history_reader.rb +47 -0
  88. data/lib/saboteur/reporter/html.rb +26 -4
  89. data/lib/saboteur/reporter/mutation_text.rb +6 -0
  90. data/lib/saboteur/reporter/stryker.rb +27 -3
  91. data/lib/saboteur/reporter/survivor_classification.rb +101 -0
  92. data/lib/saboteur/reporter/survivor_list.rb +15 -9
  93. data/lib/saboteur/reporter/terminal.rb +40 -7
  94. data/lib/saboteur/result.rb +19 -3
  95. data/lib/saboteur/selection/coverage_map.rb +105 -10
  96. data/lib/saboteur/selection/dependency_shield.rb +80 -0
  97. data/lib/saboteur/selection/external_test_map.rb +27 -0
  98. data/lib/saboteur/selection/git_diff.rb +93 -11
  99. data/lib/saboteur/selection/layout_only_lines.rb +80 -0
  100. data/lib/saboteur/selection/recorder.rb +22 -4
  101. data/lib/saboteur/selection/source_scope.rb +22 -0
  102. data/lib/saboteur/subject.rb +42 -7
  103. data/lib/saboteur/subject_finder.rb +173 -29
  104. data/lib/saboteur/version.rb +1 -1
  105. data/lib/saboteur/working_tree.rb +38 -15
  106. data/lib/saboteur.rb +13 -0
  107. metadata +35 -2
@@ -2,12 +2,17 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Saboteur
5
- # One lexical scope enclosing a def: `module X`, `class X` or `class << self`.
5
+ # One lexical scope enclosing a def: `module X`, `class X`, a class-factory
6
+ # block (`X = Data.define(:x) do`) or `class << self`.
6
7
  Scope = Data.define(:kind, :path) do
8
+ # A factory scope wraps the def in `X.class_eval do`: reopening it as
9
+ # `class X` would fight the generated class (a Module.new X is not even
10
+ # a class), while class_eval adds the method to the class already there.
7
11
  def header
8
12
  case kind
9
13
  when :module then "module #{path}"
10
14
  when :class then "class #{path}"
15
+ when :factory then "#{path}.class_eval do"
11
16
  when :sclass then "class << self"
12
17
  end
13
18
  end
@@ -15,6 +20,13 @@ module Saboteur
15
20
  def sclass?
16
21
  kind == :sclass
17
22
  end
23
+
24
+ # A factory scope names a runtime owner (the generated class) but opens
25
+ # no lexical constant scope: `Outer = Class.new do Inner = ... end` binds
26
+ # Inner on the enclosing lexical scope, never on Outer (#339).
27
+ def factory?
28
+ kind == :factory
29
+ end
18
30
  end
19
31
 
20
32
  # A method defined with `def`: the unit saboteur mutates.
@@ -60,6 +72,17 @@ module Saboteur
60
72
  "#{owner}#{singleton ? '.' : '#'}#{name}"
61
73
  end
62
74
 
75
+ # The node whose children are the method's statements: the block body
76
+ # for `define_method(:m) { ... }` (#194), the def body otherwise.
77
+ def body_node
78
+ return node.body unless node.is_a?(Prism::CallNode)
79
+
80
+ # define_method carries its body in the block; scope and the lambda
81
+ # form of define_singleton_method carry it in the lambda argument.
82
+ lambda = node.arguments&.arguments&.find { |arg| arg.is_a?(Prism::LambdaNode) }
83
+ (lambda || node.block)&.body
84
+ end
85
+
63
86
  def source
64
87
  @source ||= file_source.byteslice(start_byte, end_byte - start_byte).force_encoding(Encoding::UTF_8)
65
88
  end
@@ -88,10 +111,10 @@ module Saboteur
88
111
  # An edit outside every statement (signature, around the def, whole
89
112
  # body) maps to the whole method.
90
113
  def coverage_lines_for(edit)
91
- pattern_lines = pattern_coverage_lines(node.body, edit)
114
+ pattern_lines = pattern_coverage_lines(body_node, edit)
92
115
  return pattern_lines if pattern_lines
93
116
 
94
- statement = innermost_statement(node.body, edit.start_byte, edit.end_byte)
117
+ statement = innermost_statement(body_node, edit.start_byte, edit.end_byte)
95
118
  return coverage_lines unless statement
96
119
 
97
120
  first = statement.location.start_line
@@ -102,7 +125,7 @@ module Saboteur
102
125
  # True when anything in the body can turn an error into a value: a
103
126
  # rescue or else clause, a modifier rescue, a nested begin/rescue.
104
127
  def recovers?
105
- rescuing?(node.body)
128
+ rescuing?(body_node)
106
129
  end
107
130
 
108
131
  # The 1-based line of the file that holds this byte offset.
@@ -140,6 +163,12 @@ module Saboteur
140
163
  text.force_encoding(Encoding::UTF_8)
141
164
  end
142
165
 
166
+ # Def subjects prefer surgical re-evaluation; a ConstantSubject always
167
+ # re-evaluates its whole file (there is no method to redefine).
168
+ def whole_file_only?
169
+ false
170
+ end
171
+
143
172
  def to_s
144
173
  id
145
174
  end
@@ -159,10 +188,15 @@ module Saboteur
159
188
  child.compact_child_nodes.any? { |grandchild| rescuing?(grandchild) }
160
189
  end
161
190
 
162
- def innermost_statement(node, start_byte, end_byte, found = nil)
191
+ # An interpolation's own statements (#{"..."}) are eager: they run on
192
+ # the enclosing statement's lines, so they never demote it (#498). A
193
+ # continuation line of a multi-line interpolated statement can lack a
194
+ # per-example line hit, so attribution must span from the statement's
195
+ # first line.
196
+ def innermost_statement(node, start_byte, end_byte, found = nil, embedded: false)
163
197
  return found if node.nil?
164
198
 
165
- if node.is_a?(Prism::StatementsNode)
199
+ if node.is_a?(Prism::StatementsNode) && !embedded
166
200
  statement = node.body.find do |child|
167
201
  child.location.start_offset <= start_byte && end_byte <= child.location.end_offset
168
202
  end
@@ -174,7 +208,8 @@ module Saboteur
174
208
  node.compact_child_nodes.each do |child|
175
209
  next unless child.location.start_offset <= start_byte && end_byte <= child.location.end_offset
176
210
 
177
- return innermost_statement(child, start_byte, end_byte, found)
211
+ return innermost_statement(child, start_byte, end_byte, found,
212
+ embedded: embedded || node.is_a?(Prism::EmbeddedStatementsNode))
178
213
  end
179
214
  found
180
215
  end
@@ -10,15 +10,17 @@ module Saboteur
10
10
  # Included: instance methods and `def self.x` inside a class or module,
11
11
  # defs inside `class << self`, and defs passed to a receiverless call such
12
12
  # as `private def x`. Skipped: top-level defs, defs nested in defs, and
13
- # `def obj.x` for obj != self. Defs in blocks (`Class.new do`, `included do`)
13
+ # `def obj.x` for obj != self. Defs in blocks (`included do`, builders)
14
14
  # are found but marked skipped ("defined inside a block"), so the report
15
- # names them instead of leaving them out. `X = Data.define`, `Struct.new`,
16
- # `Class.new` or `Module.new` with a block names X as the owner; any other
17
- # block, and a `A::X = ...` target, keeps the enclosing owner or the
18
- # placeholder.
15
+ # names them instead of leaving them out - except the four core class
16
+ # factories: `X = Data.define`, `Struct.new`, `Class.new` or `Module.new`
17
+ # with a block names X as the owner, and its defs are ordinary subjects
18
+ # that re-evaluate through X.class_eval (#290). Any other block, and a
19
+ # `A::X = ...` target, keeps the enclosing owner or the placeholder.
19
20
  class SubjectFinder
20
21
  Parsed = Data.define(:subjects, :errors)
21
22
  ANONYMOUS_OWNER = "(anonymous block)"
23
+ CALL_DEFINED_SINGLETONS = %i[scope define_singleton_method].freeze
22
24
  CLASS_FACTORIES = { Data: :define, Struct: :new, Class: :new, Module: :new }.freeze
23
25
 
24
26
  # source: the text to parse when it is not the file on disk (the file as
@@ -51,24 +53,142 @@ module Saboteur
51
53
  when Prism::ConstantWriteNode
52
54
  walk_constant_assignment(node, nesting, out, **ctx)
53
55
  when Prism::ModuleNode
54
- walk(node.body, nesting + [Scope.new(kind: :module, path: node.constant_path.slice)], out, **ctx)
56
+ scope = Scope.new(kind: :module, path: node.constant_path.slice)
57
+ walk(node.body, lexical_nesting(nesting) + [scope], out, **ctx)
55
58
  when Prism::ClassNode
56
- walk(node.body, nesting + [Scope.new(kind: :class, path: node.constant_path.slice)], out, **ctx)
59
+ scope = Scope.new(kind: :class, path: node.constant_path.slice)
60
+ walk(node.body, lexical_nesting(nesting) + [scope], out, **ctx)
57
61
  when Prism::SingletonClassNode
58
62
  walk_singleton_class(node, nesting, out, **ctx)
59
63
  when Prism::StatementsNode
60
- previous_child = nil
61
- node.body.each do |child|
62
- walk(child, nesting, out, previous: previous_child, **ctx)
63
- previous_child = child
64
- end
64
+ walk_statements(node, nesting, out, **ctx)
65
65
  when Prism::DefNode
66
66
  add_subject(node, nesting, out, previous: previous, **ctx)
67
+ when Prism::CallNode
68
+ walk_call(node, nesting, out, **ctx)
67
69
  else
68
70
  node.compact_child_nodes.each { |child| walk(child, nesting, out, **ctx) }
69
71
  end
70
72
  end
71
73
 
74
+ # `define_method(:m) { ... }` is a method definition: the block body is
75
+ # the method body, and re-evaluating the whole call redefines the method
76
+ # on the enclosing owner (#194). Only a bare call with a literal name and
77
+ # a block counts; a computed name, an explicit receiver, or a lambda
78
+ # argument says nothing the finder can name.
79
+ # A define_method subject's body is its block; any other call is walked
80
+ # through its children.
81
+ def walk_call(node, nesting, out, **ctx)
82
+ return walk_heredoc_eval(node, nesting, out, **ctx) if heredoc_eval?(node)
83
+ return walk_children(node, nesting, out, **ctx) unless call_defined_method?(node)
84
+
85
+ in_sclass = nesting.last&.sclass? || false
86
+ owner = owner_path(nesting)
87
+ owner ||= ANONYMOUS_OWNER if ctx[:in_block]
88
+ return walk_children(node, nesting, out, **ctx) unless owner
89
+
90
+ name = node.arguments.arguments.first.unescaped.to_sym
91
+ # scope and define_singleton_method define on the class itself;
92
+ # define_method follows the surrounding nesting like a def (#194).
93
+ singleton = in_sclass || CALL_DEFINED_SINGLETONS.include?(node.name)
94
+ subject = Subject.new(name: name, owner: owner, singleton: singleton, sclass: in_sclass,
95
+ file: ctx[:file], node: node, file_source: ctx[:source], nesting: nesting,
96
+ frozen_literals: ctx[:frozen], sig_span: nil)
97
+ subject.skip_reason = "defined inside a block" if ctx[:in_block]
98
+ out << subject
99
+ end
100
+
101
+ # Statements carry `previous` so a def can see the sig comment above it.
102
+ def walk_statements(node, nesting, out, **ctx)
103
+ previous_child = nil
104
+ node.body.each do |child|
105
+ walk(child, nesting, out, previous: previous_child, **ctx)
106
+ previous_child = child
107
+ end
108
+ end
109
+
110
+ def walk_children(node, nesting, out, **ctx)
111
+ node.compact_child_nodes.each { |child| walk(child, nesting, out, **ctx) }
112
+ end
113
+
114
+ # `class_eval <<-RUBY ... RUBY` with a non-interpolated heredoc defines
115
+ # methods on the enclosing owner (#544). Only a bare call with one
116
+ # heredoc argument counts: an explicit receiver names another owner, and
117
+ # an interpolated heredoc does not map to file bytes 1:1, so it stays on
118
+ # the code-in-strings warning (#467).
119
+ def heredoc_eval?(node)
120
+ return false unless %i[class_eval module_eval].include?(node.name) && node.receiver.nil? && node.block.nil?
121
+
122
+ arguments = node.arguments&.arguments
123
+ return false unless arguments&.size == 1
124
+
125
+ heredoc = arguments.first
126
+ heredoc.is_a?(Prism::StringNode) && heredoc.opening_loc&.slice&.start_with?("<<")
127
+ end
128
+
129
+ # The heredoc body's bytes sit in the real file 1:1, so blanking every
130
+ # byte outside the body (keeping newlines) parses the defs at their
131
+ # real offsets and lines: mutants apply through the ordinary file-bytes
132
+ # path. The enclosing nesting supplies the owner. A body that does not
133
+ # parse defines nothing.
134
+ def walk_heredoc_eval(node, nesting, out, **ctx)
135
+ location = node.arguments.arguments.first.content_loc
136
+ virtual = blank_outside(ctx[:source], location.start_offset...location.end_offset)
137
+ result = Prism.parse(virtual, filepath: ctx[:file])
138
+ return if result.failure?
139
+
140
+ walk(result.value, nesting, out, **ctx)
141
+ end
142
+
143
+ # source with every byte outside range spaced over, except newlines:
144
+ # offsets and lines inside range stay exactly as in the real file.
145
+ def blank_outside(source, range)
146
+ source.bytes.each_with_index.map { |byte, index| range.cover?(index) || byte == 10 ? byte : 32 }.pack("C*")
147
+ .force_encoding(source.encoding)
148
+ end
149
+
150
+ def define_method_subject?(node)
151
+ return false unless node.name == :define_method && node.receiver.nil?
152
+ return false unless node.block.is_a?(Prism::BlockNode)
153
+
154
+ # With two arguments the second is the body and the block is dead code.
155
+ return false unless node.arguments&.arguments&.size == 1
156
+
157
+ name_arg = node.arguments.arguments.first
158
+ name_arg.is_a?(Prism::SymbolNode) || name_arg.is_a?(Prism::StringNode)
159
+ end
160
+
161
+ # Calls that define a method, all with a literal first argument for the
162
+ # name: define_method (instance, or singleton inside class << self) and
163
+ # the two class-level shapes the human-essentials dogfood exposed,
164
+ # `scope :name, -> { ... }` and define_singleton_method (#194). The
165
+ # whole call is the subject's source and re-evaluates on the owner.
166
+ def call_defined_method?(node)
167
+ return false unless node.receiver.nil?
168
+ return false unless literal_name?(node)
169
+
170
+ case node.name
171
+ when :define_method then define_method_subject?(node)
172
+ when :define_singleton_method then lambda_body?(node) || block_only?(node)
173
+ # Rails scope: exactly a literal name and a lambda body.
174
+ when :scope then lambda_body?(node) && node.arguments.arguments.size == 2
175
+ else false
176
+ end
177
+ end
178
+
179
+ def literal_name?(node)
180
+ name_arg = node.arguments&.arguments&.first
181
+ name_arg.is_a?(Prism::SymbolNode) || name_arg.is_a?(Prism::StringNode)
182
+ end
183
+
184
+ def lambda_body?(node)
185
+ node.block.nil? && node.arguments&.arguments&.last.is_a?(Prism::LambdaNode)
186
+ end
187
+
188
+ def block_only?(node)
189
+ node.block.is_a?(Prism::BlockNode) && node.arguments&.arguments&.size == 1
190
+ end
191
+
72
192
  # `Point = Data.define(:x) do ... end`: the defs belong to Point. Only the
73
193
  # four core factories count, called on the bare constant; any other call
74
194
  # that takes a block says nothing about the class it returns.
@@ -77,9 +197,24 @@ module Saboteur
77
197
  return node.compact_child_nodes.each { |child| walk(child, nesting, out, **ctx) } unless class_factory?(call)
78
198
 
79
199
  # The receiver and arguments stay in the enclosing scope; only the
80
- # factory's own block belongs to the new class.
200
+ # factory's own block belongs to the new class. The block's body is
201
+ # walked directly, not as a block: the factory names its class, so the
202
+ # defs re-evaluate through X.class_eval and are ordinary subjects
203
+ # (#290), while a block nested deeper still marks its defs skipped.
81
204
  (call.compact_child_nodes - [call.block]).each { |child| walk(child, nesting, out, **ctx) }
82
- walk(call.block, nesting + [Scope.new(kind: :class, path: node.name.to_s)], out, **ctx)
205
+ walk(call.block.body, lexical_nesting(nesting) + [Scope.new(kind: :factory, path: node.name.to_s)], out, **ctx)
206
+ end
207
+
208
+ # Constant-introducing declarations (class/module keywords and factory
209
+ # assignments) bind on the enclosing lexical scope: factory blocks open
210
+ # none, so their scopes never join a new constant's path (#339) - unless
211
+ # an sclass follows the factory: `class << self` inside a factory block
212
+ # attaches to the factory's class at runtime (class_eval sets self), and
213
+ # the sclass needs the factory's path to name that attachment (#438).
214
+ def lexical_nesting(nesting)
215
+ nesting.each_with_index.reject do |scope, index|
216
+ scope.factory? && nesting[(index + 1)..].none?(&:sclass?)
217
+ end.map(&:first)
83
218
  end
84
219
 
85
220
  def class_factory?(call)
@@ -123,26 +258,35 @@ module Saboteur
123
258
  end
124
259
 
125
260
  # Lexical path of the enclosing class/module. `class ::Foo` restarts at the top.
261
+ # A bare `class << self` scope leaves the owner alone (its defs are the
262
+ # attached class's singleton methods), but a factory constant assigned
263
+ # through it binds on the singleton class (#352), so the sclass renders
264
+ # as a `singleton_class` path segment when a factory scope sits after it
265
+ # (#406): `Host.singleton_class::Widget`, mirroring the constant side
266
+ # (#380).
126
267
  def owner_path(nesting)
127
- parts = nesting.reject(&:sclass?).map(&:path)
128
- return nil if parts.empty?
268
+ segments = nesting.each_with_index.filter_map do |scope, index|
269
+ if scope.sclass?
270
+ :singleton_class if nesting[(index + 1)..].any?(&:factory?)
271
+ else
272
+ scope.path
273
+ end
274
+ end
275
+ return nil if segments.empty?
129
276
 
130
- parts.each_with_object([]) do |path, acc|
131
- acc.clear if path.start_with?("::")
132
- acc << path.delete_prefix("::")
133
- end.join("::")
277
+ segments.each_with_object(+"") do |segment, out|
278
+ if segment == :singleton_class
279
+ out << ".singleton_class"
280
+ else
281
+ out.clear if segment.start_with?("::")
282
+ out << "::" unless out.empty?
283
+ out << segment.delete_prefix("::")
284
+ end
285
+ end
134
286
  end
135
287
 
136
288
  def mark_refinements(subjects, program)
137
- return unless uses_refinements?(program)
138
-
139
- subjects.each { |subject| subject.skip_reason = "refinements" }
140
- end
141
-
142
- def uses_refinements?(node)
143
- return true if node.is_a?(Prism::CallNode) && node.name == :using && node.receiver.nil?
144
-
145
- node.compact_child_nodes.any? { |child| uses_refinements?(child) }
289
+ RefinementActivations.mark(subjects, program)
146
290
  end
147
291
  end
148
292
  end
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Saboteur
5
- VERSION = "0.7.0"
5
+ VERSION = "0.8.0"
6
6
  end
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "open3"
5
+ require_relative "text"
5
6
 
6
7
  module Saboteur
7
8
  # The state of the working source a run looked at: the commit it was on and
@@ -11,23 +12,25 @@ module Saboteur
11
12
  # report from an earlier run does not make the tree look dirty. Without git, or
12
13
  # outside a repository, the state is unknown.
13
14
  class WorkingTree
14
- State = Data.define(:head, :dirty, :changed_files) do
15
- def label
16
- return "unknown" if dirty.nil?
15
+ # One uncommitted change: its path relative to the root (the new path
16
+ # of a rename, the old path of a deletion) and whether git tracks it.
17
+ Change = Data.define(:path, :tracked)
17
18
 
18
- dirty ? "dirty" : "clean"
19
+ State = Data.define(:head, :dirty, :changed_files, :changes) do
20
+ def initialize(head:, dirty:, changed_files:, changes: [])
21
+ super
19
22
  end
20
23
 
21
- # Shown before a run: mutants run your code and can change the tree.
22
- def warning
23
- return unless dirty
24
+ def label
25
+ return "unknown" if dirty.nil?
24
26
 
25
- "saboteur: warning: #{changed_files} uncommitted change(s) in the working tree. " \
26
- "Mutants run your code and could alter or delete files; run on a clean, committed tree or a CI checkout."
27
+ dirty ? "dirty" : "clean"
27
28
  end
28
29
  end
29
30
 
30
31
  UNKNOWN = State.new(head: nil, dirty: nil, changed_files: nil)
32
+ UNTRACKED = "??"
33
+ RENAMED_OR_COPIED = /[RC]/
31
34
 
32
35
  # ignored: paths relative to root that are not source (saboteur's cache and reports).
33
36
  def initialize(root, ignored: [])
@@ -35,22 +38,42 @@ module Saboteur
35
38
  @ignored = ignored
36
39
  end
37
40
 
41
+ # git status names paths from the top of the repository; the prefix
42
+ # turns them into paths relative to the root.
38
43
  def state
39
- head, head_status = Open3.capture2e("git", "rev-parse", "HEAD", chdir: @root)
40
- changes, changes_status = Open3.capture2e(*status_command, chdir: @root)
41
- return UNKNOWN unless head_status.success? && changes_status.success?
44
+ head, _, head_status = Open3.capture3("git", "rev-parse", "HEAD", "--show-prefix", chdir: @root)
45
+ status, _, status_status = Open3.capture3(*status_command, chdir: @root)
46
+ return UNKNOWN unless head_status.success? && status_status.success?
42
47
 
43
- count = changes.lines.size
44
- State.new(head: head.strip, dirty: count.positive?, changed_files: count)
48
+ sha, prefix = head.lines.map(&:chomp)
49
+ changes = changes_in(status, Text.utf8_identity(prefix.to_s))
50
+ State.new(head: sha, dirty: changes.any?, changed_files: changes.size, changes: changes)
45
51
  rescue SystemCallError
46
52
  UNKNOWN
47
53
  end
48
54
 
49
55
  private
50
56
 
57
+ # Porcelain v1 with -z: "XY path" entries separated by NUL, a rename or
58
+ # copy followed by one more entry holding its old path.
59
+ def changes_in(status, prefix)
60
+ entries = status.b.split("\0")
61
+ changes = []
62
+ until entries.empty?
63
+ entry = entries.shift
64
+ code = entry[0, 2]
65
+ entries.shift if code.match?(RENAMED_OR_COPIED)
66
+ path = Text.utf8_identity(entry[3..]).delete_prefix(prefix)
67
+ changes << Change.new(path: path, tracked: code != UNTRACKED)
68
+ end
69
+ changes
70
+ end
71
+
72
+ # -z implies --porcelain=v1, and status takes no revisions, so the
73
+ # pathspecs need no "--" in front.
51
74
  def status_command
52
75
  excluded = @ignored.map { |path| ":(exclude)#{path}" }
53
- ["git", "status", "--porcelain", "--untracked-files=all", "--", ".", *excluded]
76
+ ["git", "status", "-z", "--untracked-files=all", ".", *excluded]
54
77
  end
55
78
  end
56
79
  end
data/lib/saboteur.rb CHANGED
@@ -43,25 +43,36 @@ module Saboteur
43
43
  end
44
44
  end
45
45
 
46
+ require_relative "saboteur/coverage_reads"
46
47
  require_relative "saboteur/hooks"
47
48
  require_relative "saboteur/mutation_source/base"
48
49
  require_relative "saboteur/mutation_source/builtin"
49
50
  require_relative "saboteur/integration/base"
50
51
  require_relative "saboteur/subject"
52
+ require_relative "saboteur/refinement_activations"
51
53
  require_relative "saboteur/subject_finder"
54
+ require_relative "saboteur/constant_subject"
55
+ require_relative "saboteur/constant_subject_finder"
56
+ require_relative "saboteur/constant_reads"
57
+ require_relative "saboteur/mutation_source/constant_literals"
52
58
  require_relative "saboteur/execution/protocol"
53
59
  require_relative "saboteur/execution/process_table"
54
60
  require_relative "saboteur/execution/process_tree"
61
+ require_relative "saboteur/execution/marker_sweep"
55
62
  require_relative "saboteur/execution/supervisor"
56
63
  require_relative "saboteur/execution/reevaluator"
57
64
  require_relative "saboteur/execution/wrapper"
58
65
  require_relative "saboteur/execution/runner"
66
+ require_relative "saboteur/execution/external_runner"
59
67
  require_relative "saboteur/execution/worker_pool"
60
68
  require_relative "saboteur/execution/fork_readiness"
61
69
  require_relative "saboteur/execution/sandbox"
70
+ require_relative "saboteur/execution/seatbelt"
62
71
  require_relative "saboteur/selection/coverage_map"
72
+ require_relative "saboteur/selection/external_test_map"
63
73
  require_relative "saboteur/selection/recorder"
64
74
  require_relative "saboteur/selection/git_diff"
75
+ require_relative "saboteur/selection/source_scope"
65
76
  require_relative "saboteur/result"
66
77
  require_relative "saboteur/custom_map"
67
78
  require_relative "saboteur/gates"
@@ -72,8 +83,10 @@ require_relative "saboteur/reporter/diff"
72
83
  require_relative "saboteur/reporter/findings"
73
84
  require_relative "saboteur/reporter/agent"
74
85
  require_relative "saboteur/working_tree"
86
+ require_relative "saboteur/dirty_tree_warning"
75
87
  require_relative "saboteur/agent_skill"
76
88
  require_relative "saboteur/reporter/terminal"
77
89
  require_relative "saboteur/reporter/stryker"
90
+ require_relative "saboteur/reporter/survivor_classification"
78
91
  require_relative "saboteur/reporter/html"
79
92
  require_relative "saboteur/engine"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: saboteur
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vamsi Pavan Mahesh Gunturu
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-16 00:00:00.000000000 Z
11
+ date: 2026-10-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: fiddle
@@ -74,13 +74,23 @@ files:
74
74
  - lib/saboteur/cli.rb
75
75
  - lib/saboteur/cli/exit_status.rb
76
76
  - lib/saboteur/cli/help_options.rb
77
+ - lib/saboteur/cli/history_command.rb
77
78
  - lib/saboteur/cli/presets.rb
78
79
  - lib/saboteur/cli/run_budget_options.rb
79
80
  - lib/saboteur/cli/run_reporting.rb
81
+ - lib/saboteur/cli/selection_options.rb
82
+ - lib/saboteur/cli/shard_options.rb
83
+ - lib/saboteur/cli/source_set.rb
84
+ - lib/saboteur/cli/survivors_command.rb
80
85
  - lib/saboteur/config.rb
81
86
  - lib/saboteur/config_file.rb
87
+ - lib/saboteur/constant_reads.rb
88
+ - lib/saboteur/constant_subject.rb
89
+ - lib/saboteur/constant_subject_finder.rb
90
+ - lib/saboteur/coverage_reads.rb
82
91
  - lib/saboteur/crash_report.rb
83
92
  - lib/saboteur/custom_map.rb
93
+ - lib/saboteur/dirty_tree_warning.rb
84
94
  - lib/saboteur/doctor.rb
85
95
  - lib/saboteur/engine.rb
86
96
  - lib/saboteur/engine/baseline_guard.rb
@@ -92,15 +102,22 @@ files:
92
102
  - lib/saboteur/engine/coverage_guard.rb
93
103
  - lib/saboteur/engine/coverage_store.rb
94
104
  - lib/saboteur/engine/data_inputs.rb
105
+ - lib/saboteur/engine/defined_order_prefix.rb
95
106
  - lib/saboteur/engine/diagnostics.rb
96
107
  - lib/saboteur/engine/diff_scope.rb
97
108
  - lib/saboteur/engine/distribution.rb
98
109
  - lib/saboteur/engine/early_stop_recording.rb
110
+ - lib/saboteur/engine/gated_run.rb
111
+ - lib/saboteur/engine/load_coverage.rb
112
+ - lib/saboteur/engine/out_of_scope_guard.rb
113
+ - lib/saboteur/engine/pipelines.rb
99
114
  - lib/saboteur/engine/result_cache.rb
100
115
  - lib/saboteur/engine/run_budget.rb
101
116
  - lib/saboteur/engine/run_fingerprint.rb
102
117
  - lib/saboteur/engine/sandbox_policy.rb
103
118
  - lib/saboteur/engine/scheduler.rb
119
+ - lib/saboteur/engine/selection_announcement.rb
120
+ - lib/saboteur/engine/shard.rb
104
121
  - lib/saboteur/engine/subject_selection.rb
105
122
  - lib/saboteur/engine/suite_files.rb
106
123
  - lib/saboteur/engine/test_guess.rb
@@ -108,19 +125,24 @@ files:
108
125
  - lib/saboteur/engine/unchanged_syntax.rb
109
126
  - lib/saboteur/engine/unscoped_suite_warning.rb
110
127
  - lib/saboteur/engine/verdict_check.rb
128
+ - lib/saboteur/execution/external_runner.rb
111
129
  - lib/saboteur/execution/fork_readiness.rb
130
+ - lib/saboteur/execution/marker_sweep.rb
131
+ - lib/saboteur/execution/peak_memory.rb
112
132
  - lib/saboteur/execution/process_table.rb
113
133
  - lib/saboteur/execution/process_tree.rb
114
134
  - lib/saboteur/execution/protocol.rb
115
135
  - lib/saboteur/execution/reevaluator.rb
116
136
  - lib/saboteur/execution/runner.rb
117
137
  - lib/saboteur/execution/sandbox.rb
138
+ - lib/saboteur/execution/seatbelt.rb
118
139
  - lib/saboteur/execution/stale_captures.rb
119
140
  - lib/saboteur/execution/supervisor.rb
120
141
  - lib/saboteur/execution/worker_pool.rb
121
142
  - lib/saboteur/execution/wrapper.rb
122
143
  - lib/saboteur/frameworks/database_hosts.rb
123
144
  - lib/saboteur/frameworks/rails.rb
145
+ - lib/saboteur/frameworks/sqlite_memory_database.rb
124
146
  - lib/saboteur/gates.rb
125
147
  - lib/saboteur/help.rb
126
148
  - lib/saboteur/hooks.rb
@@ -135,8 +157,11 @@ files:
135
157
  - lib/saboteur/mutation_source/builtin.rb
136
158
  - lib/saboteur/mutation_source/chain_removal.rb
137
159
  - lib/saboteur/mutation_source/condition_negation.rb
160
+ - lib/saboteur/mutation_source/constant_literals.rb
138
161
  - lib/saboteur/mutation_source/edit.rb
162
+ - lib/saboteur/mutation_source/element_removal.rb
139
163
  - lib/saboteur/mutation_source/endpoint_replacement.rb
164
+ - lib/saboteur/mutation_source/entry_edits.rb
140
165
  - lib/saboteur/mutation_source/filter_replacement.rb
141
166
  - lib/saboteur/mutation_source/hazard_guard.rb
142
167
  - lib/saboteur/mutation_source/interpolation_removal.rb
@@ -150,7 +175,9 @@ files:
150
175
  - lib/saboteur/mutation_source/operator_visit.rb
151
176
  - lib/saboteur/mutation_source/pair_removal.rb
152
177
  - lib/saboteur/mutation_source/range_replacement.rb
178
+ - lib/saboteur/mutation_source/regex_replacement.rb
153
179
  - lib/saboteur/preview.rb
180
+ - lib/saboteur/refinement_activations.rb
154
181
  - lib/saboteur/replay.rb
155
182
  - lib/saboteur/reporter/agent.rb
156
183
  - lib/saboteur/reporter/agent_clipping.rb
@@ -161,6 +188,7 @@ files:
161
188
  - lib/saboteur/reporter/findings.rb
162
189
  - lib/saboteur/reporter/history_paths.rb
163
190
  - lib/saboteur/reporter/history_policy.rb
191
+ - lib/saboteur/reporter/history_reader.rb
164
192
  - lib/saboteur/reporter/history_record.rb
165
193
  - lib/saboteur/reporter/html.rb
166
194
  - lib/saboteur/reporter/mutation_text.rb
@@ -170,6 +198,7 @@ files:
170
198
  - lib/saboteur/reporter/score_history.rb
171
199
  - lib/saboteur/reporter/score_table.rb
172
200
  - lib/saboteur/reporter/stryker.rb
201
+ - lib/saboteur/reporter/survivor_classification.rb
173
202
  - lib/saboteur/reporter/survivor_hints.rb
174
203
  - lib/saboteur/reporter/survivor_list.rb
175
204
  - lib/saboteur/reporter/survivor_ranking.rb
@@ -178,8 +207,12 @@ files:
178
207
  - lib/saboteur/result.rb
179
208
  - lib/saboteur/score.rb
180
209
  - lib/saboteur/selection/coverage_map.rb
210
+ - lib/saboteur/selection/dependency_shield.rb
211
+ - lib/saboteur/selection/external_test_map.rb
181
212
  - lib/saboteur/selection/git_diff.rb
213
+ - lib/saboteur/selection/layout_only_lines.rb
182
214
  - lib/saboteur/selection/recorder.rb
215
+ - lib/saboteur/selection/source_scope.rb
183
216
  - lib/saboteur/selection/untracked_changes.rb
184
217
  - lib/saboteur/subject.rb
185
218
  - lib/saboteur/subject_finder.rb