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
|
@@ -16,9 +16,29 @@ module Saboteur
|
|
|
16
16
|
|
|
17
17
|
SWAPS = { min: "max", max: "min", any?: "all?", all?: "any?", map: "each", each: "map" }.freeze
|
|
18
18
|
|
|
19
|
+
# Node types that mix used and discarded child positions get a named
|
|
20
|
+
# handler; anything not listed here is conservative: every child's
|
|
21
|
+
# value is treated as used (#440).
|
|
22
|
+
CONTAINER_HANDLERS = {
|
|
23
|
+
Prism::StatementsNode => :visit_statements,
|
|
24
|
+
Prism::IfNode => :visit_if,
|
|
25
|
+
Prism::UnlessNode => :visit_unless,
|
|
26
|
+
Prism::ElseNode => :visit_else,
|
|
27
|
+
Prism::EnsureNode => :visit_ensure,
|
|
28
|
+
Prism::CaseNode => :visit_case,
|
|
29
|
+
Prism::CaseMatchNode => :visit_case,
|
|
30
|
+
Prism::WhenNode => :visit_when,
|
|
31
|
+
Prism::InNode => :visit_in,
|
|
32
|
+
Prism::BeginNode => :visit_begin,
|
|
33
|
+
Prism::RescueNode => :visit_rescue,
|
|
34
|
+
Prism::WhileNode => :visit_loop,
|
|
35
|
+
Prism::UntilNode => :visit_loop
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
19
38
|
# Base inherits Object's empty initializer; there is no parent state to set.
|
|
20
39
|
def initialize(arid: []) # rubocop:disable Lint/MissingSuper
|
|
21
40
|
@arid = arid.map { |pattern| Regexp.new(pattern) }
|
|
41
|
+
@discard_depth = 0
|
|
22
42
|
end
|
|
23
43
|
|
|
24
44
|
def name
|
|
@@ -27,6 +47,133 @@ module Saboteur
|
|
|
27
47
|
|
|
28
48
|
private
|
|
29
49
|
|
|
50
|
+
# Overrides OperatorVisit#visit to track positions whose value is
|
|
51
|
+
# syntactically discarded: a non-last statement, and - recursing from a
|
|
52
|
+
# discarded statement - the tail expression of each branch of an
|
|
53
|
+
# if/elsif/else/unless/case/when, a begin/rescue body, or a
|
|
54
|
+
# while/until body (a loop's value never observes its body), or an
|
|
55
|
+
# ensure body (its value never contributes to the result). each
|
|
56
|
+
# and map differ only in their return value (both run the block the
|
|
57
|
+
# same way), so swapping them in a discarded-value position yields
|
|
58
|
+
# an equivalent mutant (#399, #440). A call whose value flows into
|
|
59
|
+
# a predicate, receiver, block result, or control-flow argument
|
|
60
|
+
# (return/break/next) is used and stays swappable, as is the whole
|
|
61
|
+
# body of a def or block. A lazy receiver breaks the equivalence:
|
|
62
|
+
# lazy.map defers the block, so discarding it runs nothing (#431).
|
|
63
|
+
# Only a `.lazy` in the receiver chain is detected; a Lazy held in a
|
|
64
|
+
# variable, parameter or ivar is not, and its swaps are suppressed.
|
|
65
|
+
def visit(node, subject, found)
|
|
66
|
+
return if node.nil? || OperatorVisit::SCOPE_NODES.any? { |scope| node.is_a?(scope) }
|
|
67
|
+
|
|
68
|
+
handler = CONTAINER_HANDLERS[node.class]
|
|
69
|
+
handler ? send(handler, node, subject, found) : visit_used_children(node, subject, found)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Non-last statements are discarded-value positions; the last
|
|
73
|
+
# statement inherits the surrounding context (#399).
|
|
74
|
+
def visit_statements(node, subject, found)
|
|
75
|
+
node.body.each_with_index do |statement, index|
|
|
76
|
+
next if arid?(statement)
|
|
77
|
+
|
|
78
|
+
if index < node.body.size - 1
|
|
79
|
+
discarded { visit(statement, subject, found) }
|
|
80
|
+
else
|
|
81
|
+
visit(statement, subject, found)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def visit_else(node, subject, found)
|
|
87
|
+
visit(node.statements, subject, found)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# An ensure body's value never contributes to the result, so it is
|
|
91
|
+
# always a discarded-value position (#440).
|
|
92
|
+
def visit_ensure(node, subject, found)
|
|
93
|
+
discarded { visit(node.statements, subject, found) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def visit_in(node, subject, found)
|
|
97
|
+
used { visit(node.pattern, subject, found) }
|
|
98
|
+
visit(node.statements, subject, found)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def visit_if(node, subject, found)
|
|
102
|
+
visit_branch(node, subject, found, :subsequent)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def visit_unless(node, subject, found)
|
|
106
|
+
visit_branch(node, subject, found, :else_clause)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Branch bodies inherit the discard context; the predicate's value
|
|
110
|
+
# selects the branch, so it is always used (#440).
|
|
111
|
+
def visit_branch(node, subject, found, else_reader)
|
|
112
|
+
used { visit(node.predicate, subject, found) }
|
|
113
|
+
visit(node.statements, subject, found)
|
|
114
|
+
visit(node.public_send(else_reader), subject, found)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def visit_case(node, subject, found)
|
|
118
|
+
used { visit(node.predicate, subject, found) }
|
|
119
|
+
node.conditions.each { |condition| visit(condition, subject, found) }
|
|
120
|
+
visit(node.else_clause, subject, found)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def visit_when(node, subject, found)
|
|
124
|
+
node.conditions.each { |condition| used { visit(condition, subject, found) } }
|
|
125
|
+
visit(node.statements, subject, found)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def visit_begin(node, subject, found)
|
|
129
|
+
visit(node.statements, subject, found)
|
|
130
|
+
visit(node.rescue_clause, subject, found)
|
|
131
|
+
visit(node.else_clause, subject, found)
|
|
132
|
+
discarded { visit(node.ensure_clause, subject, found) }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def visit_rescue(node, subject, found)
|
|
136
|
+
node.exceptions.each { |exception| used { visit(exception, subject, found) } }
|
|
137
|
+
visit(node.statements, subject, found)
|
|
138
|
+
visit(node.subsequent, subject, found)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# A loop's value never observes its body, so the body is always a
|
|
142
|
+
# discarded-value position (#440).
|
|
143
|
+
def visit_loop(node, subject, found)
|
|
144
|
+
used { visit(node.predicate, subject, found) }
|
|
145
|
+
discarded { visit(node.statements, subject, found) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Default: every child's value is consumed by its parent.
|
|
149
|
+
def visit_used_children(node, subject, found)
|
|
150
|
+
mutate_node(node, subject, found)
|
|
151
|
+
node.compact_child_nodes.each { |child| used { visit(child, subject, found) } }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Marks value-discarded positions during the walk. `used` resets the
|
|
155
|
+
# depth where a child's value is consumed by its parent.
|
|
156
|
+
def discarded
|
|
157
|
+
@discard_depth += 1
|
|
158
|
+
yield
|
|
159
|
+
ensure
|
|
160
|
+
@discard_depth -= 1
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def used
|
|
164
|
+
depth = @discard_depth
|
|
165
|
+
@discard_depth = 0
|
|
166
|
+
yield
|
|
167
|
+
ensure
|
|
168
|
+
@discard_depth = depth
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Any `.lazy` in the receiver chain, syntactically.
|
|
172
|
+
def lazy_receiver?(node)
|
|
173
|
+
node = node.receiver while node.is_a?(Prism::CallNode) && node.name != :lazy
|
|
174
|
+
node.is_a?(Prism::CallNode)
|
|
175
|
+
end
|
|
176
|
+
|
|
30
177
|
def mutate_node(node, subject, found)
|
|
31
178
|
swap(node, subject, found) if node.is_a?(Prism::CallNode)
|
|
32
179
|
end
|
|
@@ -34,6 +181,7 @@ module Saboteur
|
|
|
34
181
|
def swap(node, subject, found)
|
|
35
182
|
replacement = SWAPS[node.name]
|
|
36
183
|
return unless replacement && node.receiver
|
|
184
|
+
return if @discard_depth.positive? && %w[each map].include?(node.name.to_s) && !lazy_receiver?(node.receiver)
|
|
37
185
|
|
|
38
186
|
location = node.message_loc
|
|
39
187
|
edit = Edit.new(file: subject.file, start_byte: location.start_offset,
|
|
@@ -24,6 +24,7 @@ module Saboteur
|
|
|
24
24
|
"literal_boolean" => "true -> false",
|
|
25
25
|
"argument_removal" => "send(a, b) -> send(a)",
|
|
26
26
|
"pair_removal" => "{a: 1, b: 2} -> {b: 2}",
|
|
27
|
+
"element_removal" => "TAGS = [a, b] -> TAGS = [b]",
|
|
27
28
|
"endpoint_replacement" => "xs.first -> xs.last",
|
|
28
29
|
"chain_removal" => "x.first.to_s -> x.to_s",
|
|
29
30
|
"range" => "1..3 -> 1...3",
|
|
@@ -31,7 +32,8 @@ module Saboteur
|
|
|
31
32
|
"condition_negation" => "x ? a : b -> !(x) ? a : b",
|
|
32
33
|
"method_swap" => "xs.min -> xs.max",
|
|
33
34
|
"operand_replacement" => "a + b -> a",
|
|
34
|
-
"interpolation_removal" => "\"hi \#{name}\" -> \"hi \""
|
|
35
|
+
"interpolation_removal" => "\"hi \#{name}\" -> \"hi \"",
|
|
36
|
+
"regex_replacement" => "/\\A\\d+\\z/ -> /\\d+\\z/"
|
|
35
37
|
}.freeze
|
|
36
38
|
end
|
|
37
39
|
end
|
|
@@ -31,13 +31,15 @@ module Saboteur
|
|
|
31
31
|
"literal_boolean" => "Swap a boolean literal",
|
|
32
32
|
"argument_removal" => "Remove a call argument",
|
|
33
33
|
"pair_removal" => "Remove a hash pair",
|
|
34
|
+
"element_removal" => "Remove an element of a constant array literal",
|
|
34
35
|
"endpoint_replacement" => "Swap explicit no-argument first and last",
|
|
35
36
|
"chain_removal" => "Remove one call from a method chain",
|
|
36
37
|
"method_swap" => "Swap a method with its semantic opposite",
|
|
37
38
|
"operand_replacement" => "Replace a binary operation with one operand",
|
|
38
|
-
"interpolation_removal" => "Remove part of an interpolated string"
|
|
39
|
+
"interpolation_removal" => "Remove part of an interpolated string",
|
|
40
|
+
"regex_replacement" => "Drop a regex anchor or swap a regex quantifier"
|
|
39
41
|
}.freeze
|
|
40
|
-
POLICY_VERSION =
|
|
42
|
+
POLICY_VERSION = 3
|
|
41
43
|
TIERS = { "all" => DESCRIPTIONS.keys.freeze }.freeze
|
|
42
44
|
|
|
43
45
|
def self.list(out, names: [], excluded: [])
|
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
|
|
4
4
|
require "prism"
|
|
5
5
|
require_relative "base"
|
|
6
|
+
require_relative "entry_edits"
|
|
6
7
|
|
|
7
8
|
module Saboteur
|
|
8
9
|
module MutationSource
|
|
9
10
|
# Deletes one association from a hash literal or keyword argument list.
|
|
10
11
|
class PairRemoval < Base
|
|
12
|
+
include EntryEdits
|
|
13
|
+
|
|
11
14
|
BOUNDARIES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode,
|
|
12
15
|
Prism::ParametersNode, Prism::BlockParametersNode].freeze
|
|
13
16
|
|
|
@@ -19,10 +22,12 @@ module Saboteur
|
|
|
19
22
|
"pair_removal"
|
|
20
23
|
end
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
# root: the node to walk instead of the subject's method body (a
|
|
26
|
+
# constant's literal, see MutationSource::ConstantLiterals).
|
|
27
|
+
def mutations_for(subject, root: nil)
|
|
23
28
|
found = []
|
|
24
29
|
tokens = Prism.lex(subject.file_source).value.map(&:first)
|
|
25
|
-
visit(subject.
|
|
30
|
+
visit(root || subject.body_node, subject, found, tokens)
|
|
26
31
|
found
|
|
27
32
|
end
|
|
28
33
|
|
|
@@ -56,24 +61,16 @@ module Saboteur
|
|
|
56
61
|
next unless pair.is_a?(Prism::AssocNode)
|
|
57
62
|
next if heredoc?(pair)
|
|
58
63
|
|
|
59
|
-
edits = [
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
edits = [delete_entry(subject, pair.location)]
|
|
65
|
+
# Removing the first keyword argument may need the comma after the
|
|
66
|
+
# call's last positional argument.
|
|
67
|
+
before = call.arguments.arguments[0...-1].last if call
|
|
68
|
+
separator = entry_separator(pairs, index, ending_for(node, call), tokens, before: before)
|
|
69
|
+
edits << delete_entry(subject, separator) if separator
|
|
62
70
|
found << Mutation.new(subject_id: subject.id, operator: name, edits: edits, source: name)
|
|
63
71
|
end
|
|
64
72
|
end
|
|
65
73
|
|
|
66
|
-
def separator_for(node, pairs, index, tokens, call)
|
|
67
|
-
pair = pairs[index]
|
|
68
|
-
ending = ending_for(node, call)
|
|
69
|
-
after = comma_between(pair.location.end_offset, ending, tokens)
|
|
70
|
-
return after if after
|
|
71
|
-
|
|
72
|
-
previous = pairs[0...index].last
|
|
73
|
-
previous ||= call.arguments.arguments[0...-1].last if call
|
|
74
|
-
comma_between(previous.location.end_offset, pair.location.start_offset, tokens) if previous
|
|
75
|
-
end
|
|
76
|
-
|
|
77
74
|
def ending_for(node, call)
|
|
78
75
|
return node.closing_loc.start_offset if node.is_a?(Prism::HashNode)
|
|
79
76
|
return call.closing_loc.start_offset if call.closing_loc
|
|
@@ -81,21 +78,6 @@ module Saboteur
|
|
|
81
78
|
|
|
82
79
|
node.location.end_offset
|
|
83
80
|
end
|
|
84
|
-
|
|
85
|
-
def comma_between(first, last, tokens)
|
|
86
|
-
tokens.find do |token|
|
|
87
|
-
token.type == :COMMA && token.location.start_offset >= first && token.location.end_offset <= last
|
|
88
|
-
end&.location
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def heredoc?(node)
|
|
92
|
-
(node.respond_to?(:opening_loc) && node.opening_loc&.slice&.start_with?("<<")) ||
|
|
93
|
-
node.compact_child_nodes.any? { |child| heredoc?(child) }
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def delete(subject, location)
|
|
97
|
-
Edit.new(file: subject.file, start_byte: location.start_offset, end_byte: location.end_offset, replacement: "")
|
|
98
|
-
end
|
|
99
81
|
end
|
|
100
82
|
end
|
|
101
83
|
end
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
# Weakens a regex literal: drops one anchor (\A, \z, \Z, ^, $) at a time
|
|
11
|
+
# and swaps each + or * quantifier for the other (#135). Escaped
|
|
12
|
+
# characters, character classes, and lazy or possessive stacks stay
|
|
13
|
+
# untouched, and interpolated regexes stay out: their bytes are not a
|
|
14
|
+
# stable 1:1 map. Anchors make validations decisive, so a dropped anchor
|
|
15
|
+
# or a widened quantifier is exactly the mutant a weak test misses.
|
|
16
|
+
class RegexReplacement < Base
|
|
17
|
+
include OperatorVisit
|
|
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
|
+
"regex_replacement"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
BACKSLASH = 0x5C
|
|
29
|
+
ANCHOR_ESCAPES = %w[\\A \\z \\Z].freeze
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def mutate_node(node, subject, found)
|
|
34
|
+
return unless node.is_a?(Prism::RegularExpressionNode)
|
|
35
|
+
|
|
36
|
+
base = node.content_loc.start_offset
|
|
37
|
+
scan_anchors_and_quantifiers(node.content) do |offset, length, replacement|
|
|
38
|
+
edit = Edit.new(file: subject.file, start_byte: base + offset, end_byte: base + offset + length,
|
|
39
|
+
replacement: replacement)
|
|
40
|
+
found << Mutation.new(subject_id: subject.id, operator: name, edits: [edit], source: name)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Yields [offset, length, replacement] for each anchor drop and
|
|
45
|
+
# quantifier swap, scanning bytes so offsets line up with the file.
|
|
46
|
+
def scan_anchors_and_quantifiers(pattern)
|
|
47
|
+
bytes = pattern.bytes
|
|
48
|
+
depth = 0
|
|
49
|
+
index = 0
|
|
50
|
+
while index < bytes.size
|
|
51
|
+
if bytes[index] == BACKSLASH
|
|
52
|
+
yield index, 2, "" if depth.zero? && ANCHOR_ESCAPES.include?(pattern.byteslice(index, 2))
|
|
53
|
+
index += 2
|
|
54
|
+
else
|
|
55
|
+
depth = scan_plain_byte(bytes, index, depth) { |offset, replacement| yield offset, 1, replacement }
|
|
56
|
+
index += 1
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# One non-escape byte; returns the character-class depth after it.
|
|
62
|
+
def scan_plain_byte(bytes, index, depth)
|
|
63
|
+
case bytes[index]
|
|
64
|
+
# [ opens a class only at top level or as a POSIX [:class:];
|
|
65
|
+
# inside a class it is a literal member.
|
|
66
|
+
when 0x5B then depth.zero? || bytes[index + 1] == 0x3A ? depth + 1 : depth
|
|
67
|
+
when 0x5D then depth.positive? ? depth - 1 : 0 # ]
|
|
68
|
+
when 0x5E, 0x24 # ^ $
|
|
69
|
+
yield index, "" if depth.zero?
|
|
70
|
+
depth
|
|
71
|
+
when 0x2B, 0x2A # + *
|
|
72
|
+
yield index, (bytes[index] == 0x2B ? "*" : "+") if depth.zero? && plain_quantifier?(bytes, index)
|
|
73
|
+
depth
|
|
74
|
+
else
|
|
75
|
+
depth
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A bare quantifier: not the start of the pattern, not after an open
|
|
80
|
+
# group or alternation, and not stacked into a lazy (+?, ??) or
|
|
81
|
+
# possessive (*+, ++) form on either side.
|
|
82
|
+
def plain_quantifier?(bytes, index)
|
|
83
|
+
return false if index.zero?
|
|
84
|
+
|
|
85
|
+
previous = bytes[index - 1]
|
|
86
|
+
return false if [0x28, 0x7C, 0x3F, 0x2B, 0x2A].include?(previous) # ( | ? + *
|
|
87
|
+
|
|
88
|
+
nxt = bytes[index + 1]
|
|
89
|
+
nxt != 0x2B && nxt != 0x2A && nxt != 0x3F # + * ?
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
data/lib/saboteur/preview.rb
CHANGED
|
@@ -1,30 +1,40 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require "json"
|
|
4
5
|
require_relative "engine"
|
|
5
6
|
require_relative "text"
|
|
7
|
+
require_relative "version"
|
|
6
8
|
|
|
7
9
|
module Saboteur
|
|
8
10
|
# Source-only planning. Never boots the host or executes its test suite.
|
|
9
11
|
class Preview
|
|
10
|
-
|
|
12
|
+
# Version of the --format json inventory (#469). Consumers ignore fields
|
|
13
|
+
# they do not know; only a breaking change raises this number.
|
|
14
|
+
SCHEMA_VERSION = 1
|
|
15
|
+
|
|
16
|
+
def initialize(config, out:, err:, format: "text")
|
|
11
17
|
@config = config
|
|
12
18
|
@out = out
|
|
13
19
|
@diagnostics = Engine::Diagnostics.new(err)
|
|
20
|
+
@json = format == "json"
|
|
21
|
+
@inventory = []
|
|
22
|
+
@skipped = []
|
|
14
23
|
end
|
|
15
24
|
|
|
16
25
|
def call
|
|
17
26
|
announce_operators
|
|
18
27
|
selection = Engine::SubjectSelection.new(@config, @diagnostics)
|
|
19
28
|
subjects = selection.select(@config.included_files)
|
|
20
|
-
scope = selection.
|
|
29
|
+
scope = selection.behavior_lines && Engine::DiffScope.new(selection.behavior_lines)
|
|
21
30
|
guard = MutationSource::HazardGuard.new(@config["hazard"]) unless @config["allow_hazardous"]
|
|
22
31
|
candidates = Engine::Candidates.new(Engine::Candidates.build_sources(@config), @diagnostics, hazard_guard: guard)
|
|
23
|
-
lists = subjects.map { |subject| planned(subject, candidates, scope) }
|
|
32
|
+
lists = shard_lists(subjects, subjects.map { |subject| planned(subject, candidates, scope) })
|
|
24
33
|
inline_suppressions
|
|
25
34
|
incomplete = @diagnostics.discovery_failures.size
|
|
26
|
-
|
|
27
|
-
|
|
35
|
+
return print_inventory(subjects, lists, incomplete) if @json
|
|
36
|
+
|
|
37
|
+
print_totals(lists, incomplete)
|
|
28
38
|
incomplete.zero? ? estimate(subjects, lists) : @out.puts("no estimate: source discovery was incomplete")
|
|
29
39
|
print_warnings
|
|
30
40
|
nil
|
|
@@ -32,25 +42,37 @@ module Saboteur
|
|
|
32
42
|
|
|
33
43
|
private
|
|
34
44
|
|
|
45
|
+
def shard_lists(subjects, lists)
|
|
46
|
+
return lists unless @config["shard"]
|
|
47
|
+
|
|
48
|
+
@shard = Engine::Shard.parse(@config["shard"])
|
|
49
|
+
@shard.select(subjects, lists)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def print_totals(lists, incomplete)
|
|
53
|
+
@out.puts(@shard.line) if @shard
|
|
54
|
+
@out.puts("Total: #{lists.sum(&:size)} mutants; #{@diagnostics.omissions.sum(&:count)} " \
|
|
55
|
+
"dropped by max_mutants_per_method#{incomplete_note(incomplete)}")
|
|
56
|
+
end
|
|
57
|
+
|
|
35
58
|
def print_warnings
|
|
36
59
|
@diagnostics.warnings.each { |warning| @out.puts("warning: #{warning}") }
|
|
37
60
|
end
|
|
38
61
|
|
|
39
62
|
def announce_operators
|
|
40
|
-
@out.puts("Operators: #{@config.operator_policy['effective'].join(', ')}")
|
|
63
|
+
@out.puts("Operators: #{@config.operator_policy['effective'].join(', ')}") unless @json
|
|
41
64
|
end
|
|
42
65
|
|
|
43
66
|
# Candidate filtering precedes diff restriction and the per-method cap.
|
|
44
67
|
# Name that scope rather than implying these rows were executed or scored.
|
|
45
68
|
def inline_suppressions
|
|
46
69
|
entries = @diagnostics.inline_suppressions
|
|
47
|
-
return if entries.empty?
|
|
70
|
+
return if entries.empty? || @json
|
|
48
71
|
|
|
49
72
|
@out.puts("Inline suppressions: #{entries.size} candidates in selected methods " \
|
|
50
73
|
"(before diff restriction and per-method cap; outside the planned total)")
|
|
51
74
|
entries.each do |entry|
|
|
52
|
-
|
|
53
|
-
@out.puts(" #{path}:#{entry[:line]} #{entry[:subject]} #{entry[:operator]} " \
|
|
75
|
+
@out.puts(" #{relative_path(entry[:file])}:#{entry[:line]} #{entry[:subject]} #{entry[:operator]} " \
|
|
54
76
|
"#{entry[:digest]}: #{entry[:reason]}")
|
|
55
77
|
end
|
|
56
78
|
end
|
|
@@ -106,11 +128,97 @@ module Saboteur
|
|
|
106
128
|
list = candidates.for(subject)
|
|
107
129
|
list = scope.restrict(subject, list) if scope
|
|
108
130
|
limit = @config["max_mutants_per_method"]
|
|
109
|
-
|
|
131
|
+
if list.size > limit
|
|
132
|
+
@diagnostics.record_omission(subject, count: list.size - limit, limit: limit)
|
|
133
|
+
list.drop(limit).each { |(mutation, _)| record_skip(subject, mutation, "max_mutants_per_method") } if @json
|
|
134
|
+
end
|
|
110
135
|
list = list.first(limit)
|
|
111
|
-
|
|
112
|
-
|
|
136
|
+
if @json
|
|
137
|
+
list.each { |(mutation, _)| @inventory << mutant_entry(subject, mutation) }
|
|
138
|
+
else
|
|
139
|
+
@out.puts("#{relative_path(subject.file)}:#{subject.start_line} #{subject.id} #{list.size} mutants")
|
|
140
|
+
end
|
|
113
141
|
list
|
|
114
142
|
end
|
|
143
|
+
|
|
144
|
+
# The machine-readable inventory (#469): every planned mutant named by
|
|
145
|
+
# the stable digest a run or --id replay computes, plus the candidates
|
|
146
|
+
# that were planned but not runnable, each with its skip reason.
|
|
147
|
+
def print_inventory(subjects, lists, incomplete)
|
|
148
|
+
@out.puts(JSON.pretty_generate(
|
|
149
|
+
schema_version: SCHEMA_VERSION,
|
|
150
|
+
saboteur_version: VERSION,
|
|
151
|
+
mode: "dry-run",
|
|
152
|
+
operators: @config.operator_policy["effective"],
|
|
153
|
+
test_selection: @config["test_selection"],
|
|
154
|
+
tests: @config["tests"],
|
|
155
|
+
subjects: subjects.zip(lists).map { |subject, list| subject_entry(subject, list) },
|
|
156
|
+
mutants: @inventory,
|
|
157
|
+
skipped: @skipped + suppression_entries,
|
|
158
|
+
totals: {
|
|
159
|
+
mutants: lists.sum(&:size),
|
|
160
|
+
dropped_by_max_mutants_per_method: @diagnostics.omissions.sum(&:count),
|
|
161
|
+
incomplete_files: incomplete
|
|
162
|
+
},
|
|
163
|
+
estimated_seconds: incomplete.zero? ? estimated_seconds(subjects, lists) : nil,
|
|
164
|
+
warnings: @diagnostics.warnings
|
|
165
|
+
))
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def estimated_seconds(subjects, lists)
|
|
170
|
+
map = cached_map
|
|
171
|
+
seconds = map && work_seconds(map, subjects, lists)
|
|
172
|
+
seconds&.round(2)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def subject_entry(subject, list)
|
|
176
|
+
{ id: subject.id, file: relative_path(subject.file), start_line: subject.start_line,
|
|
177
|
+
end_line: subject.end_line, mutants: list.size }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Byte offsets index the file as read from disk, like the Stryker
|
|
181
|
+
# report; original and replacement text make each edit self-contained.
|
|
182
|
+
def mutant_entry(subject, mutation)
|
|
183
|
+
{
|
|
184
|
+
digest: mutation.digest(subject_start: subject.start_byte, original_source: subject.file_source),
|
|
185
|
+
subject: subject.id,
|
|
186
|
+
operator: mutation.operator.to_s,
|
|
187
|
+
file: relative_path(subject.file),
|
|
188
|
+
line: subject.line_of(mutation.edits.first.start_byte),
|
|
189
|
+
edits: mutation.edits.map do |edit|
|
|
190
|
+
{ start_byte: edit.start_byte, end_byte: edit.end_byte,
|
|
191
|
+
original: source_span(subject, edit), replacement: edit.replacement }
|
|
192
|
+
end
|
|
193
|
+
}
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def record_skip(subject, mutation, reason)
|
|
197
|
+
@skipped << mutant_entry(subject, mutation).merge(reason: reason)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Inline-suppressed and hazard-refused candidates with their reasons,
|
|
201
|
+
# recorded during candidate filtering, before diff restriction and the
|
|
202
|
+
# per-method cap (the same scope the text audit names).
|
|
203
|
+
def suppression_entries
|
|
204
|
+
inline = @diagnostics.inline_suppressions.map do |entry|
|
|
205
|
+
{ digest: entry[:digest], subject: entry[:subject], operator: entry[:operator].to_s,
|
|
206
|
+
file: relative_path(entry[:file]), line: entry[:line], reason: "inline_suppression: #{entry[:reason]}" }
|
|
207
|
+
end
|
|
208
|
+
hazards = @diagnostics.generation_events.select { |event| event.source == "hazard_guard" }.map do |event|
|
|
209
|
+
{ subject: event.subject, operator: event.operator.to_s, file: relative_path(event.file),
|
|
210
|
+
reason: "hazardous" }
|
|
211
|
+
end
|
|
212
|
+
inline + hazards
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def relative_path(file)
|
|
216
|
+
file.delete_prefix("#{@config.root}/")
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def source_span(subject, edit)
|
|
220
|
+
subject.file_source.byteslice(edit.start_byte, edit.end_byte - edit.start_byte).to_s
|
|
221
|
+
.force_encoding(Encoding::UTF_8)
|
|
222
|
+
end
|
|
115
223
|
end
|
|
116
224
|
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
# The lexical `using` sites of one parsed file (#634). A bare `using`
|
|
6
|
+
# activates its refinement from where it sits to the end of its own
|
|
7
|
+
# scope: a class or module body, or the whole file at the top level.
|
|
8
|
+
# Activation is lexical, not namespaced: a `class ::Q` nested inside
|
|
9
|
+
# the body still sees it, while a reopened body with the same constant
|
|
10
|
+
# path after the scope's `end` does not. Sites therefore compare byte
|
|
11
|
+
# ranges, not constant paths: a site covers a subject that starts after
|
|
12
|
+
# the `using` and before the scope's closing `end`. A `using` inside a
|
|
13
|
+
# method or block body cannot be scoped this way, so one anywhere keeps
|
|
14
|
+
# the old whole-file skip. Skipping only ever means "not mutated",
|
|
15
|
+
# never a wrong verdict.
|
|
16
|
+
class RefinementActivations
|
|
17
|
+
Site = Data.define(:start_byte, :scope_end, :dynamic_scope) do
|
|
18
|
+
def covers?(subject)
|
|
19
|
+
!dynamic_scope && subject.start_byte > start_byte && subject.start_byte < scope_end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.mark(subjects, program)
|
|
24
|
+
sites = []
|
|
25
|
+
collect(program, program.end_offset, false, sites)
|
|
26
|
+
return if sites.empty?
|
|
27
|
+
|
|
28
|
+
if sites.any?(&:dynamic_scope)
|
|
29
|
+
subjects.each { |subject| subject.skip_reason = "refinements" }
|
|
30
|
+
return
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
subjects.each do |subject|
|
|
34
|
+
subject.skip_reason = "refinements" if sites.any? { |site| site.covers?(subject) }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.collect(node, scope_end, dynamic, sites)
|
|
39
|
+
if node.is_a?(Prism::CallNode) && node.name == :using && node.receiver.nil?
|
|
40
|
+
sites << Site.new(start_byte: node.location.start_offset, scope_end: scope_end, dynamic_scope: dynamic)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
descend(node, scope_end, dynamic, sites)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.descend(node, scope_end, dynamic, sites)
|
|
47
|
+
case node
|
|
48
|
+
when Prism::ClassNode
|
|
49
|
+
collect(node.superclass, scope_end, dynamic, sites) if node.superclass
|
|
50
|
+
collect(node.body, node.end_offset, dynamic, sites) if node.body
|
|
51
|
+
when Prism::ModuleNode, Prism::SingletonClassNode
|
|
52
|
+
collect(node.body, node.end_offset, dynamic, sites) if node.body
|
|
53
|
+
when Prism::DefNode, Prism::BlockNode, Prism::LambdaNode
|
|
54
|
+
node.compact_child_nodes.each { |child| collect(child, scope_end, true, sites) }
|
|
55
|
+
else
|
|
56
|
+
node.compact_child_nodes.each { |child| collect(child, scope_end, dynamic, sites) }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|