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.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +199 -0
  3. data/README.md +427 -20
  4. data/exe/saboteur +6 -2
  5. data/lib/saboteur/agent_skill.rb +6 -0
  6. data/lib/saboteur/baseline/acceptance.rb +218 -0
  7. data/lib/saboteur/baseline/command.rb +80 -0
  8. data/lib/saboteur/baseline/comparison.rb +106 -0
  9. data/lib/saboteur/baseline/completeness.rb +53 -0
  10. data/lib/saboteur/baseline/evidence.rb +124 -0
  11. data/lib/saboteur/baseline/identity.rb +78 -0
  12. data/lib/saboteur/baseline/manifest.rb +164 -0
  13. data/lib/saboteur/baseline/run_evidence.rb +117 -0
  14. data/lib/saboteur/baseline/workflow.rb +121 -0
  15. data/lib/saboteur/baseline_health.rb +1 -1
  16. data/lib/saboteur/cli/exit_status.rb +93 -0
  17. data/lib/saboteur/cli/run_reporting.rb +75 -0
  18. data/lib/saboteur/cli.rb +113 -122
  19. data/lib/saboteur/config.rb +96 -6
  20. data/lib/saboteur/doctor.rb +64 -9
  21. data/lib/saboteur/engine/baseline_guard.rb +33 -11
  22. data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
  23. data/lib/saboteur/engine/canary.rb +74 -0
  24. data/lib/saboteur/engine/candidate_selection.rb +18 -5
  25. data/lib/saboteur/engine/candidates.rb +62 -7
  26. data/lib/saboteur/engine/coverage_store.rb +34 -14
  27. data/lib/saboteur/engine/diagnostics.rb +150 -4
  28. data/lib/saboteur/engine/diff_scope.rb +10 -0
  29. data/lib/saboteur/engine/distribution.rb +54 -13
  30. data/lib/saboteur/engine/result_cache.rb +10 -5
  31. data/lib/saboteur/engine/run_fingerprint.rb +7 -5
  32. data/lib/saboteur/engine/sandbox_policy.rb +108 -0
  33. data/lib/saboteur/engine/scheduler.rb +92 -20
  34. data/lib/saboteur/engine/subject_selection.rb +24 -1
  35. data/lib/saboteur/engine/test_guess.rb +89 -0
  36. data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
  37. data/lib/saboteur/engine/verdict_check.rb +7 -6
  38. data/lib/saboteur/engine.rb +201 -19
  39. data/lib/saboteur/execution/protocol.rb +2 -1
  40. data/lib/saboteur/execution/reevaluator.rb +72 -10
  41. data/lib/saboteur/execution/runner.rb +120 -17
  42. data/lib/saboteur/execution/sandbox.rb +271 -0
  43. data/lib/saboteur/execution/stale_captures.rb +43 -0
  44. data/lib/saboteur/execution/supervisor.rb +101 -9
  45. data/lib/saboteur/execution/worker_pool.rb +20 -4
  46. data/lib/saboteur/execution/wrapper.rb +45 -0
  47. data/lib/saboteur/frameworks/database_hosts.rb +30 -0
  48. data/lib/saboteur/frameworks/rails.rb +8 -10
  49. data/lib/saboteur/gates.rb +83 -0
  50. data/lib/saboteur/help.rb +32 -7
  51. data/lib/saboteur/ignore_list.rb +5 -0
  52. data/lib/saboteur/inline_suppression.rb +65 -0
  53. data/lib/saboteur/integration/base.rb +1 -0
  54. data/lib/saboteur/integration/focus_guard.rb +83 -0
  55. data/lib/saboteur/integration/rspec.rb +21 -1
  56. data/lib/saboteur/mutation_source/builtin.rb +22 -3
  57. data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
  58. data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
  59. data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
  60. data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
  61. data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
  62. data/lib/saboteur/mutation_source/operator_examples.rb +35 -0
  63. data/lib/saboteur/mutation_source/operator_registry.rb +82 -0
  64. data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
  65. data/lib/saboteur/preview.rb +115 -0
  66. data/lib/saboteur/reporter/agent_commands.rb +19 -5
  67. data/lib/saboteur/reporter/agent_incomplete.rb +24 -1
  68. data/lib/saboteur/reporter/agent_summary.rb +12 -6
  69. data/lib/saboteur/reporter/findings.rb +4 -19
  70. data/lib/saboteur/reporter/history_paths.rb +38 -0
  71. data/lib/saboteur/reporter/history_policy.rb +35 -0
  72. data/lib/saboteur/reporter/history_record.rb +131 -0
  73. data/lib/saboteur/reporter/html.rb +12 -1
  74. data/lib/saboteur/reporter/mutation_text.rb +36 -0
  75. data/lib/saboteur/reporter/next_steps.rb +30 -0
  76. data/lib/saboteur/reporter/palette.rb +37 -0
  77. data/lib/saboteur/reporter/score_history.rb +135 -0
  78. data/lib/saboteur/reporter/score_table.rb +57 -0
  79. data/lib/saboteur/reporter/stryker.rb +18 -3
  80. data/lib/saboteur/reporter/survivor_hints.rb +68 -0
  81. data/lib/saboteur/reporter/survivor_list.rb +126 -0
  82. data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
  83. data/lib/saboteur/reporter/terminal.rb +140 -79
  84. data/lib/saboteur/reporter/test_matrix.rb +57 -0
  85. data/lib/saboteur/result.rb +44 -6
  86. data/lib/saboteur/selection/coverage_map.rb +119 -13
  87. data/lib/saboteur/selection/git_diff.rb +28 -1
  88. data/lib/saboteur/selection/recorder.rb +55 -13
  89. data/lib/saboteur/subject.rb +40 -4
  90. data/lib/saboteur/subject_finder.rb +62 -12
  91. data/lib/saboteur/version.rb +1 -1
  92. data/lib/saboteur/working_tree.rb +8 -0
  93. data/lib/saboteur.rb +6 -0
  94. 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