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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +657 -0
- data/README.md +198 -1077
- data/lib/saboteur/cli/exit_status.rb +32 -1
- data/lib/saboteur/cli/help_options.rb +2 -1
- data/lib/saboteur/cli/history_command.rb +135 -0
- data/lib/saboteur/cli/presets.rb +6 -2
- data/lib/saboteur/cli/selection_options.rb +31 -0
- data/lib/saboteur/cli/shard_options.rb +17 -0
- data/lib/saboteur/cli/source_set.rb +53 -0
- data/lib/saboteur/cli/survivors_command.rb +157 -0
- data/lib/saboteur/cli.rb +71 -54
- data/lib/saboteur/config.rb +98 -5
- data/lib/saboteur/config_file.rb +8 -0
- data/lib/saboteur/constant_reads.rb +182 -0
- data/lib/saboteur/constant_subject.rb +70 -0
- data/lib/saboteur/constant_subject_finder.rb +281 -0
- data/lib/saboteur/coverage_reads.rb +29 -0
- data/lib/saboteur/dirty_tree_warning.rb +128 -0
- data/lib/saboteur/doctor.rb +9 -0
- data/lib/saboteur/engine/baseline_guard.rb +62 -14
- data/lib/saboteur/engine/boot.rb +4 -0
- data/lib/saboteur/engine/candidate_selection.rb +19 -0
- data/lib/saboteur/engine/candidates.rb +43 -3
- data/lib/saboteur/engine/coverage_store.rb +32 -10
- data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +18 -0
- data/lib/saboteur/engine/distribution.rb +10 -0
- data/lib/saboteur/engine/early_stop_recording.rb +3 -2
- data/lib/saboteur/engine/gated_run.rb +35 -0
- data/lib/saboteur/engine/load_coverage.rb +45 -0
- data/lib/saboteur/engine/out_of_scope_guard.rb +21 -0
- data/lib/saboteur/engine/pipelines.rb +107 -0
- data/lib/saboteur/engine/result_cache.rb +65 -8
- data/lib/saboteur/engine/run_budget.rb +27 -0
- data/lib/saboteur/engine/sandbox_policy.rb +38 -0
- data/lib/saboteur/engine/scheduler.rb +109 -3
- data/lib/saboteur/engine/selection_announcement.rb +54 -0
- data/lib/saboteur/engine/shard.rb +69 -0
- data/lib/saboteur/engine/subject_selection.rb +295 -20
- data/lib/saboteur/engine/test_guess.rb +315 -25
- data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
- data/lib/saboteur/engine.rb +100 -127
- data/lib/saboteur/execution/external_runner.rb +471 -0
- data/lib/saboteur/execution/marker_sweep.rb +79 -0
- data/lib/saboteur/execution/peak_memory.rb +28 -0
- data/lib/saboteur/execution/process_table.rb +12 -0
- data/lib/saboteur/execution/process_tree.rb +65 -5
- data/lib/saboteur/execution/reevaluator.rb +68 -12
- data/lib/saboteur/execution/runner.rb +46 -7
- data/lib/saboteur/execution/sandbox.rb +15 -2
- data/lib/saboteur/execution/seatbelt.rb +226 -0
- data/lib/saboteur/execution/supervisor.rb +27 -10
- data/lib/saboteur/execution/worker_pool.rb +23 -1
- data/lib/saboteur/frameworks/sqlite_memory_database.rb +49 -0
- data/lib/saboteur/gates.rb +43 -2
- data/lib/saboteur/help.rb +60 -9
- data/lib/saboteur/ignore_list.rb +29 -6
- data/lib/saboteur/integration/rspec.rb +135 -5
- data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
- data/lib/saboteur/mutation_source/builtin.rb +26 -5
- data/lib/saboteur/mutation_source/chain_removal.rb +1 -1
- data/lib/saboteur/mutation_source/condition_negation.rb +1 -1
- data/lib/saboteur/mutation_source/constant_literals.rb +66 -0
- data/lib/saboteur/mutation_source/element_removal.rb +73 -0
- data/lib/saboteur/mutation_source/endpoint_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/entry_edits.rb +44 -0
- data/lib/saboteur/mutation_source/filter_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/hazard_guard.rb +108 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/method_swap.rb +148 -0
- data/lib/saboteur/mutation_source/operator_assignment.rb +1 -1
- data/lib/saboteur/mutation_source/operator_examples.rb +3 -1
- data/lib/saboteur/mutation_source/operator_registry.rb +4 -2
- data/lib/saboteur/mutation_source/operator_visit.rb +1 -1
- data/lib/saboteur/mutation_source/pair_removal.rb +13 -31
- data/lib/saboteur/mutation_source/range_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/regex_replacement.rb +93 -0
- data/lib/saboteur/preview.rb +120 -12
- data/lib/saboteur/refinement_activations.rb +60 -0
- data/lib/saboteur/reporter/agent.rb +9 -1
- data/lib/saboteur/reporter/agent_commands.rb +10 -3
- data/lib/saboteur/reporter/agent_incomplete.rb +11 -0
- data/lib/saboteur/reporter/agent_summary.rb +4 -3
- data/lib/saboteur/reporter/findings.rb +5 -2
- data/lib/saboteur/reporter/history_policy.rb +11 -0
- data/lib/saboteur/reporter/history_reader.rb +47 -0
- data/lib/saboteur/reporter/html.rb +26 -4
- data/lib/saboteur/reporter/mutation_text.rb +6 -0
- data/lib/saboteur/reporter/stryker.rb +27 -3
- data/lib/saboteur/reporter/survivor_classification.rb +101 -0
- data/lib/saboteur/reporter/survivor_list.rb +15 -9
- data/lib/saboteur/reporter/terminal.rb +40 -7
- data/lib/saboteur/result.rb +19 -3
- data/lib/saboteur/selection/coverage_map.rb +105 -10
- data/lib/saboteur/selection/dependency_shield.rb +80 -0
- data/lib/saboteur/selection/external_test_map.rb +27 -0
- data/lib/saboteur/selection/git_diff.rb +93 -11
- data/lib/saboteur/selection/layout_only_lines.rb +80 -0
- data/lib/saboteur/selection/recorder.rb +22 -4
- data/lib/saboteur/selection/source_scope.rb +22 -0
- data/lib/saboteur/subject.rb +42 -7
- data/lib/saboteur/subject_finder.rb +173 -29
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +38 -15
- data/lib/saboteur.rb +13 -0
- metadata +35 -2
data/lib/saboteur/subject.rb
CHANGED
|
@@ -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
|
|
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(
|
|
114
|
+
pattern_lines = pattern_coverage_lines(body_node, edit)
|
|
92
115
|
return pattern_lines if pattern_lines
|
|
93
116
|
|
|
94
|
-
statement = innermost_statement(
|
|
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?(
|
|
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
|
-
|
|
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 (`
|
|
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
|
|
16
|
-
# `Class.new` or `Module.new`
|
|
17
|
-
# block
|
|
18
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: :
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
data/lib/saboteur/version.rb
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
return unless dirty
|
|
24
|
+
def label
|
|
25
|
+
return "unknown" if dirty.nil?
|
|
24
26
|
|
|
25
|
-
"
|
|
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.
|
|
40
|
-
|
|
41
|
-
return UNKNOWN unless head_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
|
-
|
|
44
|
-
|
|
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", "
|
|
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.
|
|
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-
|
|
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
|