saboteur 0.4.0 → 0.6.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 +199 -0
- data/README.md +427 -20
- data/exe/saboteur +6 -2
- data/lib/saboteur/agent_skill.rb +6 -0
- data/lib/saboteur/baseline/acceptance.rb +218 -0
- data/lib/saboteur/baseline/command.rb +80 -0
- data/lib/saboteur/baseline/comparison.rb +106 -0
- data/lib/saboteur/baseline/completeness.rb +53 -0
- data/lib/saboteur/baseline/evidence.rb +124 -0
- data/lib/saboteur/baseline/identity.rb +78 -0
- data/lib/saboteur/baseline/manifest.rb +164 -0
- data/lib/saboteur/baseline/run_evidence.rb +117 -0
- data/lib/saboteur/baseline/workflow.rb +121 -0
- data/lib/saboteur/baseline_health.rb +1 -1
- data/lib/saboteur/cli/exit_status.rb +93 -0
- data/lib/saboteur/cli/run_reporting.rb +75 -0
- data/lib/saboteur/cli.rb +113 -122
- data/lib/saboteur/config.rb +96 -6
- data/lib/saboteur/doctor.rb +64 -9
- data/lib/saboteur/engine/baseline_guard.rb +33 -11
- data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
- data/lib/saboteur/engine/canary.rb +74 -0
- data/lib/saboteur/engine/candidate_selection.rb +18 -5
- data/lib/saboteur/engine/candidates.rb +62 -7
- data/lib/saboteur/engine/coverage_store.rb +34 -14
- data/lib/saboteur/engine/diagnostics.rb +150 -4
- data/lib/saboteur/engine/diff_scope.rb +10 -0
- data/lib/saboteur/engine/distribution.rb +54 -13
- data/lib/saboteur/engine/result_cache.rb +10 -5
- data/lib/saboteur/engine/run_fingerprint.rb +7 -5
- data/lib/saboteur/engine/sandbox_policy.rb +108 -0
- data/lib/saboteur/engine/scheduler.rb +92 -20
- data/lib/saboteur/engine/subject_selection.rb +24 -1
- data/lib/saboteur/engine/test_guess.rb +89 -0
- data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
- data/lib/saboteur/engine/verdict_check.rb +7 -6
- data/lib/saboteur/engine.rb +201 -19
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/reevaluator.rb +72 -10
- data/lib/saboteur/execution/runner.rb +120 -17
- data/lib/saboteur/execution/sandbox.rb +271 -0
- data/lib/saboteur/execution/stale_captures.rb +43 -0
- data/lib/saboteur/execution/supervisor.rb +101 -9
- data/lib/saboteur/execution/worker_pool.rb +20 -4
- data/lib/saboteur/execution/wrapper.rb +45 -0
- data/lib/saboteur/frameworks/database_hosts.rb +30 -0
- data/lib/saboteur/frameworks/rails.rb +8 -10
- data/lib/saboteur/gates.rb +83 -0
- data/lib/saboteur/help.rb +32 -7
- data/lib/saboteur/ignore_list.rb +5 -0
- data/lib/saboteur/inline_suppression.rb +65 -0
- data/lib/saboteur/integration/base.rb +1 -0
- data/lib/saboteur/integration/focus_guard.rb +83 -0
- data/lib/saboteur/integration/rspec.rb +21 -1
- data/lib/saboteur/mutation_source/builtin.rb +22 -3
- data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
- data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
- data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
- data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
- data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
- data/lib/saboteur/mutation_source/operator_examples.rb +35 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +82 -0
- data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
- data/lib/saboteur/preview.rb +115 -0
- data/lib/saboteur/reporter/agent_commands.rb +19 -5
- data/lib/saboteur/reporter/agent_incomplete.rb +24 -1
- data/lib/saboteur/reporter/agent_summary.rb +12 -6
- data/lib/saboteur/reporter/findings.rb +4 -19
- data/lib/saboteur/reporter/history_paths.rb +38 -0
- data/lib/saboteur/reporter/history_policy.rb +35 -0
- data/lib/saboteur/reporter/history_record.rb +131 -0
- data/lib/saboteur/reporter/html.rb +12 -1
- data/lib/saboteur/reporter/mutation_text.rb +36 -0
- data/lib/saboteur/reporter/next_steps.rb +30 -0
- data/lib/saboteur/reporter/palette.rb +37 -0
- data/lib/saboteur/reporter/score_history.rb +135 -0
- data/lib/saboteur/reporter/score_table.rb +57 -0
- data/lib/saboteur/reporter/stryker.rb +18 -3
- data/lib/saboteur/reporter/survivor_hints.rb +68 -0
- data/lib/saboteur/reporter/survivor_list.rb +126 -0
- data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
- data/lib/saboteur/reporter/terminal.rb +140 -79
- data/lib/saboteur/reporter/test_matrix.rb +57 -0
- data/lib/saboteur/result.rb +44 -6
- data/lib/saboteur/selection/coverage_map.rb +119 -13
- data/lib/saboteur/selection/git_diff.rb +28 -1
- data/lib/saboteur/selection/recorder.rb +55 -13
- data/lib/saboteur/subject.rb +40 -4
- data/lib/saboteur/subject_finder.rb +62 -12
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +8 -0
- data/lib/saboteur.rb +6 -0
- metadata +46 -2
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "prism"
|
|
5
|
+
require_relative "base"
|
|
6
|
+
|
|
7
|
+
module Saboteur
|
|
8
|
+
module MutationSource
|
|
9
|
+
# Drops one link from the middle of a method chain: a.b.c becomes a.c.
|
|
10
|
+
# The first receiver and the last call stay, so the result is still a call
|
|
11
|
+
# on the original object.
|
|
12
|
+
class ChainRemoval < Base
|
|
13
|
+
BOUNDARIES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
|
|
14
|
+
# Links this operator leaves alone by design. They hand back their receiver or a
|
|
15
|
+
# copy of it (tap after running a block), so dropping one changes mutability,
|
|
16
|
+
# copying or a block's side effects. Tests can observe those, but they are other
|
|
17
|
+
# contracts than "a step in the query chain went missing", and removing them
|
|
18
|
+
# mostly yields noise, so they are out of scope here.
|
|
19
|
+
IDENTITY_CALLS = %w[freeze dup clone itself tap].freeze
|
|
20
|
+
|
|
21
|
+
# Targets of a write: `a.b.c = 1`, `a.b.c += 1`, `a.b[0] ||= 1`. The whole receiver chain
|
|
22
|
+
# is the thing being assigned to, so no link of it is dropped.
|
|
23
|
+
WRITE_NODES = [Prism::CallOperatorWriteNode, Prism::CallOrWriteNode, Prism::CallAndWriteNode,
|
|
24
|
+
Prism::IndexOperatorWriteNode, Prism::IndexOrWriteNode, Prism::IndexAndWriteNode].freeze
|
|
25
|
+
|
|
26
|
+
def initialize(arid: []) # rubocop:disable Lint/MissingSuper
|
|
27
|
+
@arid = arid.map { |pattern| Regexp.new(pattern) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def name
|
|
31
|
+
"chain_removal"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def mutations_for(subject)
|
|
35
|
+
found = []
|
|
36
|
+
visit(subject.node.body, subject, found)
|
|
37
|
+
found
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def visit(node, subject, found)
|
|
43
|
+
return if node.nil? || BOUNDARIES.any? { |type| node.is_a?(type) }
|
|
44
|
+
|
|
45
|
+
if node.is_a?(Prism::StatementsNode)
|
|
46
|
+
node.body.each { |child| visit(child, subject, found) unless @arid.any? { |p| p.match?(child.slice) } }
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
remove_link(node, subject, found) if node.is_a?(Prism::CallNode)
|
|
50
|
+
children(node).each { |child| visit(child, subject, found) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Everything under the node except the receiver chain of a write target.
|
|
54
|
+
def children(node)
|
|
55
|
+
return node.compact_child_nodes unless write_target?(node)
|
|
56
|
+
|
|
57
|
+
node.compact_child_nodes.reject { |child| child.equal?(node.receiver) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def write_target?(node)
|
|
61
|
+
WRITE_NODES.any? { |type| node.is_a?(type) } || (node.is_a?(Prism::CallNode) && node.attribute_write?)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def remove_link(call, subject, found)
|
|
65
|
+
link = call.receiver
|
|
66
|
+
return unless call.call_operator_loc && !call.attribute_write? && removable?(link)
|
|
67
|
+
|
|
68
|
+
edit = Edit.new(file: subject.file, start_byte: link.receiver.end_offset,
|
|
69
|
+
end_byte: link.end_offset, replacement: "")
|
|
70
|
+
found << Mutation.new(subject_id: subject.id, operator: name, edits: [edit], source: name)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A dotted call with a method name (a dot always has a receiver).
|
|
74
|
+
def removable?(link)
|
|
75
|
+
link.is_a?(Prism::CallNode) && link.call_operator_loc && link.message_loc &&
|
|
76
|
+
!IDENTITY_CALLS.include?(link.name.to_s)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "base"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
module MutationSource
|
|
8
|
+
# Negates the predicate, retaining its evaluation and branch structure.
|
|
9
|
+
class ConditionNegation < Base
|
|
10
|
+
SCOPE_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(arid: []) # rubocop:disable Lint/MissingSuper
|
|
13
|
+
@arid = arid.map { |pattern| Regexp.new(pattern) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def name
|
|
17
|
+
"condition_negation"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def mutations_for(subject)
|
|
21
|
+
found = []
|
|
22
|
+
visit(subject.node.body, subject, found)
|
|
23
|
+
found
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def visit(node, subject, found)
|
|
29
|
+
return if node.nil? || SCOPE_NODES.any? { |scope| node.is_a?(scope) }
|
|
30
|
+
return if @arid.any? { |pattern| pattern.match?(node.slice) }
|
|
31
|
+
|
|
32
|
+
negate(node.predicate, subject, found) if node.is_a?(Prism::IfNode) || node.is_a?(Prism::UnlessNode)
|
|
33
|
+
node.compact_child_nodes.each { |child| visit(child, subject, found) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def negate(predicate, subject, found)
|
|
37
|
+
# Existing true/false and negation-removal operators own these cases.
|
|
38
|
+
if predicate.is_a?(Prism::TrueNode) || predicate.is_a?(Prism::FalseNode) || predicate.is_a?(Prism::NilNode)
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
return if predicate.is_a?(Prism::CallNode) && predicate.name == :!
|
|
42
|
+
|
|
43
|
+
location = predicate.location
|
|
44
|
+
edit = Edit.new(file: subject.file, start_byte: location.start_offset,
|
|
45
|
+
end_byte: location.end_offset, replacement: "!(#{predicate.slice})")
|
|
46
|
+
found << Mutation.new(subject_id: subject.id, operator: name, edits: [edit], source: name)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "prism"
|
|
5
|
+
require_relative "base"
|
|
6
|
+
|
|
7
|
+
module Saboteur
|
|
8
|
+
module MutationSource
|
|
9
|
+
# Swaps the selected end of an explicitly received collection.
|
|
10
|
+
# Only message bytes change; arguments, receiver and block stay intact.
|
|
11
|
+
class EndpointReplacement < Base
|
|
12
|
+
REPLACEMENTS = { first: "last", last: "first" }.freeze
|
|
13
|
+
SCOPE_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
|
|
14
|
+
|
|
15
|
+
# Base inherits Object's empty initializer; there is no parent state to set.
|
|
16
|
+
def initialize(arid: []) # rubocop:disable Lint/MissingSuper
|
|
17
|
+
@arid = arid.map { |pattern| Regexp.new(pattern) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def name
|
|
21
|
+
"endpoint_replacement"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def mutations_for(subject)
|
|
25
|
+
found = []
|
|
26
|
+
visit(subject.node.body, subject, found)
|
|
27
|
+
found
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def visit(node, subject, found)
|
|
33
|
+
return if node.nil? || SCOPE_NODES.any? { |scope| node.is_a?(scope) }
|
|
34
|
+
|
|
35
|
+
if node.is_a?(Prism::StatementsNode)
|
|
36
|
+
node.body.each { |statement| visit(statement, subject, found) unless arid?(statement) }
|
|
37
|
+
return
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
replace_endpoint(node, subject, found) if node.is_a?(Prism::CallNode)
|
|
41
|
+
node.compact_child_nodes.each { |child| visit(child, subject, found) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def replace_endpoint(node, subject, found)
|
|
45
|
+
replacement = REPLACEMENTS[node.name]
|
|
46
|
+
return unless replacement && node.receiver
|
|
47
|
+
return if node.block
|
|
48
|
+
return unless node.arguments.nil?
|
|
49
|
+
|
|
50
|
+
location = node.message_loc
|
|
51
|
+
edit = Edit.new(file: subject.file, start_byte: location.start_offset,
|
|
52
|
+
end_byte: location.end_offset, replacement: replacement)
|
|
53
|
+
found << Mutation.new(subject_id: subject.id, operator: name, edits: [edit], source: name)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def arid?(node)
|
|
57
|
+
@arid.any? { |pattern| pattern.match?(node.slice) }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module MutationSource
|
|
6
|
+
# Suppresses mutants that would run a destructive call with altered
|
|
7
|
+
# inputs. A mutant is only kept when every edit is provably safe: either
|
|
8
|
+
# the subject holds no hazardous call, or the edit removes every
|
|
9
|
+
# hazardous call in the method AND its replacement introduces no new one
|
|
10
|
+
# (checked by parsing the replacement, never by substring match). Edits
|
|
11
|
+
# strictly inside a call, edits elsewhere in a method that contains a
|
|
12
|
+
# call, and edits whose replacement re-includes a hazard (for example
|
|
13
|
+
# removing a negation in front of `system(...)`, or swapping
|
|
14
|
+
# `rm_rf("build")` for `rm_rf(".")`) all leave a destructive call
|
|
15
|
+
# running with altered inputs and are suppressed (issue #102).
|
|
16
|
+
class HazardGuard
|
|
17
|
+
# "<Receiver>.<name>" for calls with a receiver, the bare name for
|
|
18
|
+
# Kernel-style calls. Bare FileUtils operations are listed too, so an
|
|
19
|
+
# `include FileUtils` does not disarm the guard. Any method on Open3,
|
|
20
|
+
# any rmtree, and backticks/%x literals are always hazardous. Replace
|
|
21
|
+
# or extend via c.hazard; run them anyway with --allow-hazardous.
|
|
22
|
+
DEFAULT_HAZARD = [
|
|
23
|
+
"FileUtils.rm", "FileUtils.rm_r", "FileUtils.rm_rf", "FileUtils.rm_f",
|
|
24
|
+
"FileUtils.remove_dir", "FileUtils.remove_entry", "FileUtils.remove_entry_secure",
|
|
25
|
+
"rm", "rm_r", "rm_rf", "rm_f", "remove_dir", "remove_entry", "remove_entry_secure",
|
|
26
|
+
"File.delete", "File.unlink",
|
|
27
|
+
"Dir.rmdir", "Dir.delete", "Dir.unlink",
|
|
28
|
+
"IO.popen",
|
|
29
|
+
"Process.spawn", "Process.exec",
|
|
30
|
+
"system", "exec", "spawn", "`"
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
OPEN3 = /\A(::)?Open3\z/
|
|
34
|
+
ALWAYS_NAMES = %w[rmtree].freeze
|
|
35
|
+
KERNEL_RECEIVERS = %w[Kernel Process].freeze
|
|
36
|
+
|
|
37
|
+
def initialize(patterns = DEFAULT_HAZARD)
|
|
38
|
+
@tags = Set.new(patterns.map { |pattern| pattern.to_s.sub(/\A::/, "") })
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def name
|
|
42
|
+
"hazard_guard"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# candidates: [[mutation, mutated_text]]. Returns [kept, suppressed].
|
|
46
|
+
def partition(candidates, subject)
|
|
47
|
+
spans = hazard_spans(subject.node)
|
|
48
|
+
return [candidates, []] if spans.empty?
|
|
49
|
+
|
|
50
|
+
candidates.partition { |mutation, _| safe?(mutation, spans) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# Byte spans of hazardous calls in the node tree, with the source slice
|
|
56
|
+
# of each. Inner calls are covered by the outer span, so the walk does
|
|
57
|
+
# not descend into a hazardous call.
|
|
58
|
+
def hazard_spans(root)
|
|
59
|
+
spans = []
|
|
60
|
+
stack = [root]
|
|
61
|
+
until stack.empty?
|
|
62
|
+
node = stack.pop
|
|
63
|
+
if hazardous?(node)
|
|
64
|
+
spans << [node.location.start_offset, node.location.end_offset, node.slice]
|
|
65
|
+
next
|
|
66
|
+
end
|
|
67
|
+
stack.concat(node.compact_child_nodes)
|
|
68
|
+
end
|
|
69
|
+
spans
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def hazardous?(node)
|
|
73
|
+
case node
|
|
74
|
+
when Prism::XStringNode, Prism::InterpolatedXStringNode then true
|
|
75
|
+
when Prism::CallNode then hazardous_call?(node)
|
|
76
|
+
else false
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def hazardous_call?(node)
|
|
81
|
+
name = node.name.to_s
|
|
82
|
+
receiver = node.receiver&.slice&.sub(/\A::/, "")
|
|
83
|
+
return true if OPEN3.match?(receiver.to_s)
|
|
84
|
+
return true if ALWAYS_NAMES.include?(name)
|
|
85
|
+
|
|
86
|
+
tag = receiver ? "#{receiver}.#{name}" : name
|
|
87
|
+
return true if @tags.include?(tag)
|
|
88
|
+
# Kernel.system / Kernel.spawn / Process.spawn: the explicit-receiver
|
|
89
|
+
# spelling of a bare process spawn is the same hazard.
|
|
90
|
+
return true if KERNEL_RECEIVERS.include?(receiver) && @tags.include?(name)
|
|
91
|
+
|
|
92
|
+
piped_open?(node, name, receiver)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Kernel#open with a "| command" argument spawns a process.
|
|
96
|
+
def piped_open?(node, name, receiver)
|
|
97
|
+
return false unless name == "open" && (receiver.nil? || receiver == "Kernel")
|
|
98
|
+
|
|
99
|
+
first = node.arguments&.arguments&.first
|
|
100
|
+
case first
|
|
101
|
+
when Prism::StringNode then first.unescaped.start_with?("|")
|
|
102
|
+
when Prism::InterpolatedStringNode
|
|
103
|
+
part = first.parts.first
|
|
104
|
+
part.is_a?(Prism::StringNode) && part.unescaped.start_with?("|")
|
|
105
|
+
else false
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# A mutation is safe only while no hazardous call survives it: every
|
|
110
|
+
# edit must destroy every span (cover it wholly with a hazard-free
|
|
111
|
+
# replacement). Anything less - touching a call, or editing other
|
|
112
|
+
# code in a method that still holds a call - keeps the call running
|
|
113
|
+
# with altered inputs, so the mutant is suppressed.
|
|
114
|
+
def safe?(mutation, spans)
|
|
115
|
+
mutation.edits.all? { |edit| destroys_all?(edit, spans) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def destroys_all?(edit, spans)
|
|
119
|
+
spans.all? do |start_byte, end_byte, _slice|
|
|
120
|
+
edit.start_byte <= start_byte && edit.end_byte >= end_byte
|
|
121
|
+
end && replacement_clean?(edit.replacement)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The replacement itself, parsed as code. A replacement that parses
|
|
125
|
+
# and contains no hazardous call cannot run one; anything else -
|
|
126
|
+
# a hazard in the new text, or text that does not parse on its own -
|
|
127
|
+
# is treated as hazardous.
|
|
128
|
+
def replacement_clean?(replacement)
|
|
129
|
+
result = Prism.parse(replacement.to_s)
|
|
130
|
+
return false unless result.success?
|
|
131
|
+
|
|
132
|
+
hazard_spans(result.value).empty?
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "base"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
module MutationSource
|
|
8
|
+
# Changes the arithmetic operation, without changing the assignment target.
|
|
9
|
+
class OperatorAssignment < Base
|
|
10
|
+
REPLACEMENTS = { :+ => "-=", :- => "+=", :* => "/=", :/ => "*=" }.freeze
|
|
11
|
+
WRITE_NODES = [Prism::LocalVariableOperatorWriteNode, Prism::InstanceVariableOperatorWriteNode,
|
|
12
|
+
Prism::ClassVariableOperatorWriteNode, Prism::GlobalVariableOperatorWriteNode,
|
|
13
|
+
Prism::ConstantOperatorWriteNode, Prism::ConstantPathOperatorWriteNode,
|
|
14
|
+
Prism::CallOperatorWriteNode, Prism::IndexOperatorWriteNode].freeze
|
|
15
|
+
SCOPE_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(arid: []) # rubocop:disable Lint/MissingSuper
|
|
18
|
+
@arid = arid.map { |pattern| Regexp.new(pattern) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def name
|
|
22
|
+
"operator_assignment"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def mutations_for(subject)
|
|
26
|
+
found = []
|
|
27
|
+
visit(subject.node.body, subject, found)
|
|
28
|
+
found
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def visit(node, subject, found)
|
|
34
|
+
return if node.nil? || SCOPE_NODES.any? { |scope| node.is_a?(scope) }
|
|
35
|
+
return if @arid.any? { |pattern| pattern.match?(node.slice) }
|
|
36
|
+
|
|
37
|
+
replace(node, subject, found) if WRITE_NODES.any? { |type| node.is_a?(type) }
|
|
38
|
+
node.compact_child_nodes.each { |child| visit(child, subject, found) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def replace(node, subject, found)
|
|
42
|
+
replacement = REPLACEMENTS[node.binary_operator]
|
|
43
|
+
return unless replacement
|
|
44
|
+
|
|
45
|
+
location = node.binary_operator_loc
|
|
46
|
+
edit = Edit.new(file: subject.file, start_byte: location.start_offset,
|
|
47
|
+
end_byte: location.end_offset, replacement: replacement)
|
|
48
|
+
found << Mutation.new(subject_id: subject.id, operator: name, edits: [edit], source: name)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module MutationSource
|
|
6
|
+
module OperatorExamples
|
|
7
|
+
EXAMPLES = {
|
|
8
|
+
"body_nil" => "def value = 1 -> def value = nil",
|
|
9
|
+
"body_true" => "def ok? = false -> def ok? = true",
|
|
10
|
+
"body_false" => "def ok? = true -> def ok? = false",
|
|
11
|
+
"body_raise" => "def value = 1 -> def value; raise; end",
|
|
12
|
+
"comparison" => "a < b -> a <= b",
|
|
13
|
+
"arithmetic" => "a + b -> a - b",
|
|
14
|
+
"boolean_logic" => "a && b -> a",
|
|
15
|
+
"condition_true" => "if x -> if true",
|
|
16
|
+
"condition_false" => "if x -> if false",
|
|
17
|
+
"negation_removal" => "!x -> x",
|
|
18
|
+
"literal_number" => "1 -> 0",
|
|
19
|
+
"or_assign" => "x ||= 1 -> x = 1",
|
|
20
|
+
"safe_navigation" => "x&.name -> x.name",
|
|
21
|
+
"statement_removal" => "check; value -> nil; value",
|
|
22
|
+
"filter_replacement" => "xs.select { |x| x } -> xs.reject { |x| x }",
|
|
23
|
+
"literal_string" => "\"hello\" -> \"\"",
|
|
24
|
+
"literal_boolean" => "true -> false",
|
|
25
|
+
"argument_removal" => "send(a, b) -> send(a)",
|
|
26
|
+
"pair_removal" => "{a: 1, b: 2} -> {b: 2}",
|
|
27
|
+
"endpoint_replacement" => "xs.first -> xs.last",
|
|
28
|
+
"chain_removal" => "x.first.to_s -> x.to_s",
|
|
29
|
+
"range" => "1..3 -> 1...3",
|
|
30
|
+
"operator_assignment" => "x += 1 -> x -= 1",
|
|
31
|
+
"condition_negation" => "x ? a : b -> !(x) ? a : b"
|
|
32
|
+
}.freeze
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "did_you_mean"
|
|
5
|
+
require_relative "operator_examples"
|
|
6
|
+
|
|
7
|
+
module Saboteur
|
|
8
|
+
module MutationSource
|
|
9
|
+
# Public names used by selection, help and inline suppression validation.
|
|
10
|
+
module OperatorRegistry
|
|
11
|
+
DESCRIPTIONS = {
|
|
12
|
+
"operator_assignment" => "Swap arithmetic compound assignments",
|
|
13
|
+
"condition_negation" => "Negate if, unless and ternary predicates",
|
|
14
|
+
"range" => "Swap inclusive and exclusive bounded ranges",
|
|
15
|
+
"body_nil" => "Replace the method body with nil",
|
|
16
|
+
"body_true" => "Replace a predicate body with true",
|
|
17
|
+
"body_false" => "Replace a predicate body with false",
|
|
18
|
+
"body_raise" => "Replace the method body with raise",
|
|
19
|
+
"comparison" => "Change a comparison or replace its result",
|
|
20
|
+
"arithmetic" => "Swap an arithmetic operator",
|
|
21
|
+
"boolean_logic" => "Change a boolean connector",
|
|
22
|
+
"condition_true" => "Force a condition true",
|
|
23
|
+
"condition_false" => "Force a condition false",
|
|
24
|
+
"negation_removal" => "Remove a logical negation",
|
|
25
|
+
"literal_number" => "Change a numeric literal",
|
|
26
|
+
"or_assign" => "Replace ||= with assignment",
|
|
27
|
+
"safe_navigation" => "Replace safe navigation with a regular call",
|
|
28
|
+
"statement_removal" => "Replace a statement with nil",
|
|
29
|
+
"filter_replacement" => "Swap select and reject",
|
|
30
|
+
"literal_string" => "Empty a string literal",
|
|
31
|
+
"literal_boolean" => "Swap a boolean literal",
|
|
32
|
+
"argument_removal" => "Remove a call argument",
|
|
33
|
+
"pair_removal" => "Remove a hash pair",
|
|
34
|
+
"endpoint_replacement" => "Swap explicit no-argument first and last",
|
|
35
|
+
"chain_removal" => "Remove one call from a method chain"
|
|
36
|
+
}.freeze
|
|
37
|
+
POLICY_VERSION = 2
|
|
38
|
+
TIERS = { "all" => DESCRIPTIONS.keys.freeze }.freeze
|
|
39
|
+
|
|
40
|
+
def self.list(out, names: [], excluded: [])
|
|
41
|
+
active = select(names, excluded)
|
|
42
|
+
DESCRIPTIONS.each do |name, description|
|
|
43
|
+
out.puts("#{name} #{active.include?(name) ? 'on' : 'off'} #{description} #{OperatorExamples::EXAMPLES.fetch(name)}")
|
|
44
|
+
end
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.policy(names, excluded)
|
|
49
|
+
{ "version" => POLICY_VERSION, "selected" => names, "excluded" => excluded,
|
|
50
|
+
"effective" => select(names, excluded) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Exclusion wins over explicit selection; empty exclusions remove nothing.
|
|
54
|
+
def self.select(names, excluded)
|
|
55
|
+
selected = resolve(names)
|
|
56
|
+
excluded.empty? ? selected : selected - resolve(excluded)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.suggestion(name)
|
|
60
|
+
matches = DidYouMean::SpellChecker.new(dictionary: DESCRIPTIONS.keys).correct(name)
|
|
61
|
+
matches.empty? ? "" : "Did you mean #{matches.first}?"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.resolve(names)
|
|
65
|
+
return TIERS.fetch("all") if names.empty?
|
|
66
|
+
|
|
67
|
+
legacy = names & %w[core extended]
|
|
68
|
+
unless legacy.empty?
|
|
69
|
+
raise ConfigError, "operator tiers #{legacy.join(', ')} were removed: use concrete names or all; " \
|
|
70
|
+
"for the old core set exclude range,operator_assignment,condition_negation"
|
|
71
|
+
end
|
|
72
|
+
unknown = names - DESCRIPTIONS.keys - TIERS.keys
|
|
73
|
+
unless unknown.empty?
|
|
74
|
+
raise ConfigError, "unknown operators: #{unknown.join(', ')}. Valid names: " \
|
|
75
|
+
"#{(DESCRIPTIONS.keys + TIERS.keys).join(', ')}. " \
|
|
76
|
+
"#{suggestion(unknown.first)} See --list-operators."
|
|
77
|
+
end
|
|
78
|
+
names.flat_map { |name| TIERS.fetch(name) { [name] } }.uniq
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "base"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
module MutationSource
|
|
8
|
+
# Changes whether a range includes its upper endpoint.
|
|
9
|
+
class RangeReplacement < Base
|
|
10
|
+
SCOPE_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(arid: []) # rubocop:disable Lint/MissingSuper
|
|
13
|
+
@arid = arid.map { |pattern| Regexp.new(pattern) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def name
|
|
17
|
+
"range"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def mutations_for(subject)
|
|
21
|
+
found = []
|
|
22
|
+
visit(subject.node.body, subject, found)
|
|
23
|
+
found
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def visit(node, subject, found)
|
|
29
|
+
return if node.nil? || SCOPE_NODES.any? { |scope| node.is_a?(scope) }
|
|
30
|
+
return if @arid.any? { |pattern| pattern.match?(node.slice) }
|
|
31
|
+
|
|
32
|
+
if node.is_a?(Prism::RangeNode) && node.left && node.right
|
|
33
|
+
location = node.operator_loc
|
|
34
|
+
replacement = location.slice == ".." ? "..." : ".."
|
|
35
|
+
edit = Edit.new(file: subject.file, start_byte: location.start_offset,
|
|
36
|
+
end_byte: location.end_offset, replacement: replacement)
|
|
37
|
+
found << Mutation.new(subject_id: subject.id, operator: name, edits: [edit], source: name)
|
|
38
|
+
end
|
|
39
|
+
node.compact_child_nodes.each { |child| visit(child, subject, found) }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "engine"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
# Source-only planning. Never boots the host or executes its test suite.
|
|
8
|
+
class Preview
|
|
9
|
+
def initialize(config, out:, err:)
|
|
10
|
+
@config = config
|
|
11
|
+
@out = out
|
|
12
|
+
@diagnostics = Engine::Diagnostics.new(err)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
announce_operators
|
|
17
|
+
selection = Engine::SubjectSelection.new(@config, @diagnostics)
|
|
18
|
+
subjects = selection.select(@config.included_files)
|
|
19
|
+
scope = selection.changed_lines && Engine::DiffScope.new(selection.changed_lines)
|
|
20
|
+
guard = MutationSource::HazardGuard.new(@config["hazard"]) unless @config["allow_hazardous"]
|
|
21
|
+
candidates = Engine::Candidates.new(Engine::Candidates.build_sources(@config), @diagnostics, hazard_guard: guard)
|
|
22
|
+
lists = subjects.map { |subject| planned(subject, candidates, scope) }
|
|
23
|
+
inline_suppressions
|
|
24
|
+
incomplete = @diagnostics.discovery_failures.size
|
|
25
|
+
@out.puts("Total: #{lists.sum(&:size)} mutants; #{@diagnostics.omissions.sum(&:count)} " \
|
|
26
|
+
"dropped by max_mutants_per_method#{incomplete_note(incomplete)}")
|
|
27
|
+
incomplete.zero? ? estimate(subjects, lists) : @out.puts("no estimate: source discovery was incomplete")
|
|
28
|
+
print_warnings
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def print_warnings
|
|
35
|
+
@diagnostics.warnings.each { |warning| @out.puts("warning: #{warning}") }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def announce_operators
|
|
39
|
+
@out.puts("Operators: #{@config.operator_policy['effective'].join(', ')}")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Candidate filtering precedes diff restriction and the per-method cap.
|
|
43
|
+
# Name that scope rather than implying these rows were executed or scored.
|
|
44
|
+
def inline_suppressions
|
|
45
|
+
entries = @diagnostics.inline_suppressions
|
|
46
|
+
return if entries.empty?
|
|
47
|
+
|
|
48
|
+
@out.puts("Inline suppressions: #{entries.size} candidates in selected methods " \
|
|
49
|
+
"(before diff restriction and per-method cap; outside the planned total)")
|
|
50
|
+
entries.each do |entry|
|
|
51
|
+
path = entry[:file].delete_prefix("#{@config.root}/")
|
|
52
|
+
@out.puts(" #{path}:#{entry[:line]} #{entry[:subject]} #{entry[:operator]} " \
|
|
53
|
+
"#{entry[:digest]}: #{entry[:reason]}")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def incomplete_note(count)
|
|
58
|
+
return "" if count.zero?
|
|
59
|
+
|
|
60
|
+
" - INCOMPLETE: #{count} file(s) could not be parsed, so this total is not authoritative"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def estimate(subjects, lists)
|
|
64
|
+
map = cached_map
|
|
65
|
+
return @out.puts("no estimate: run once to build the coverage map") unless map
|
|
66
|
+
|
|
67
|
+
seconds = work_seconds(map, subjects, lists)
|
|
68
|
+
return @out.puts("no estimate: cached test durations are incomplete") unless seconds
|
|
69
|
+
|
|
70
|
+
@out.puts("Estimated baseline covering-test work: #{seconds.round(2)} s (cached original-test durations " \
|
|
71
|
+
"times planned mutants; not a bound on mutant runtime; excludes boot and forks)")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Cached data is untrusted input: every duration must be a non-negative
|
|
75
|
+
# number, the total must stay finite (JSON text such as 1e999 parses to
|
|
76
|
+
# Infinity), and anything that fails while reading it, such as a string or
|
|
77
|
+
# null, is no estimate. Source generation has already finished, so this
|
|
78
|
+
# cannot hide its errors.
|
|
79
|
+
def work_seconds(map, subjects, lists)
|
|
80
|
+
return unless map.durations.values.all? { |seconds| seconds >= 0 }
|
|
81
|
+
|
|
82
|
+
total = subjects.zip(lists).sum do |subject, list|
|
|
83
|
+
# nil when a covering test has no recorded duration: the rescue below.
|
|
84
|
+
map.recorded_runtime(map.tests_for(subject)) * list.size
|
|
85
|
+
end
|
|
86
|
+
total if total.finite?
|
|
87
|
+
rescue StandardError
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Any receipt that is not exactly a current, readable pointer to a
|
|
92
|
+
# coverage map means "no estimate", never an exception.
|
|
93
|
+
def cached_map
|
|
94
|
+
path = File.join(@config.path("cache_dir"), "preview.json")
|
|
95
|
+
receipt = JSON.parse(File.read(path))
|
|
96
|
+
return unless receipt["fingerprint"] == Engine::RunFingerprint.new(config: @config).snapshot.digest
|
|
97
|
+
|
|
98
|
+
Selection::CoverageMap.load(receipt["path"])
|
|
99
|
+
rescue StandardError
|
|
100
|
+
# An unreadable receipt or a map of the wrong shape (IndexError, TypeError, ...) is no estimate.
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def planned(subject, candidates, scope)
|
|
105
|
+
list = candidates.for(subject)
|
|
106
|
+
list = scope.restrict(subject, list) if scope
|
|
107
|
+
limit = @config["max_mutants_per_method"]
|
|
108
|
+
@diagnostics.record_omission(subject, count: list.size - limit, limit: limit) if list.size > limit
|
|
109
|
+
list = list.first(limit)
|
|
110
|
+
path = subject.file.delete_prefix("#{@config.root}/")
|
|
111
|
+
@out.puts("#{path}:#{subject.start_line} #{subject.id} #{list.size} mutants")
|
|
112
|
+
list
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|