saboteur 0.3.0 → 0.4.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 +113 -1
- data/README.md +248 -63
- data/lib/generators/saboteur/install/install_generator.rb +21 -0
- data/lib/saboteur/agent_skill.rb +40 -0
- data/lib/saboteur/cli.rb +150 -22
- data/lib/saboteur/config.rb +27 -15
- data/lib/saboteur/config_file.rb +97 -0
- data/lib/saboteur/custom_map.rb +170 -0
- data/lib/saboteur/doctor.rb +17 -1
- data/lib/saboteur/engine/baseline_guard.rb +20 -3
- data/lib/saboteur/engine/boot.rb +9 -0
- data/lib/saboteur/engine/coverage_store.rb +31 -4
- data/lib/saboteur/engine/data_inputs.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +28 -0
- data/lib/saboteur/engine/diff_scope.rb +8 -0
- data/lib/saboteur/engine/run_fingerprint.rb +34 -6
- data/lib/saboteur/engine/scheduler.rb +7 -16
- data/lib/saboteur/engine/suite_files.rb +8 -1
- data/lib/saboteur/engine.rb +74 -13
- data/lib/saboteur/execution/reevaluator.rb +7 -4
- data/lib/saboteur/execution/supervisor.rb +23 -11
- data/lib/saboteur/frameworks/rails.rb +329 -13
- data/lib/saboteur/help.rb +33 -17
- data/lib/saboteur/ignore_list.rb +71 -17
- data/lib/saboteur/install.rb +132 -0
- data/lib/saboteur/integration/rspec.rb +47 -3
- data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
- data/lib/saboteur/mutation_source/builtin.rb +8 -2
- data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
- data/lib/saboteur/replay.rb +1 -1
- data/lib/saboteur/reporter/agent.rb +87 -0
- data/lib/saboteur/reporter/agent_clipping.rb +29 -0
- data/lib/saboteur/reporter/agent_commands.rb +61 -0
- data/lib/saboteur/reporter/agent_incomplete.rb +37 -0
- data/lib/saboteur/reporter/agent_summary.rb +131 -0
- data/lib/saboteur/reporter/findings.rb +8 -0
- data/lib/saboteur/reporter/html.rb +49 -1
- data/lib/saboteur/reporter/stryker.rb +11 -4
- data/lib/saboteur/reporter/terminal.rb +65 -2
- data/lib/saboteur/result.rb +46 -5
- data/lib/saboteur/score.rb +89 -0
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +48 -0
- data/lib/saboteur.rb +4 -0
- metadata +17 -2
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
# Writes the Rails starter files: config/saboteur.rb, bin/saboteur and a
|
|
8
|
+
# .gitignore entry. Plain Ruby so it runs and tests without Rails; the
|
|
9
|
+
# `rails generate saboteur:install` generator is a thin wrapper over it.
|
|
10
|
+
# Existing files are never overwritten unless force is set.
|
|
11
|
+
class Install
|
|
12
|
+
CONFIG = <<~RUBY
|
|
13
|
+
# saboteur configuration for a Rails + RSpec app. CLI flags override this file.
|
|
14
|
+
Saboteur.configure do |c|
|
|
15
|
+
c.include = ["app/**/*.rb"]
|
|
16
|
+
# Empty base classes: nothing useful to mutate.
|
|
17
|
+
c.exclude = [
|
|
18
|
+
"app/models/application_record.rb",
|
|
19
|
+
"app/controllers/application_controller.rb",
|
|
20
|
+
"app/jobs/application_job.rb",
|
|
21
|
+
"app/mailers/application_mailer.rb",
|
|
22
|
+
]
|
|
23
|
+
c.require = ["./config/environment"] # boots the app before specs load
|
|
24
|
+
c.rails = true # reconnect ActiveRecord after fork, per-worker databases
|
|
25
|
+
c.jobs = 4 # parallel workers; use 1 if your specs share external state
|
|
26
|
+
end
|
|
27
|
+
RUBY
|
|
28
|
+
|
|
29
|
+
BIN = <<~RUBY
|
|
30
|
+
#!/usr/bin/env ruby
|
|
31
|
+
# frozen_string_literal: true
|
|
32
|
+
|
|
33
|
+
ENV["RAILS_ENV"] ||= "test"
|
|
34
|
+
require "bundler/setup"
|
|
35
|
+
load Gem.bin_path("saboteur", "saboteur")
|
|
36
|
+
RUBY
|
|
37
|
+
|
|
38
|
+
IGNORE_ENTRY = ".saboteur/"
|
|
39
|
+
|
|
40
|
+
GEMFILE_HINT = <<~TEXT
|
|
41
|
+
Add saboteur to your Gemfile if it is not there yet:
|
|
42
|
+
|
|
43
|
+
group :test do
|
|
44
|
+
gem "saboteur", require: false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
Then run it with SimpleCov off for the run, for example:
|
|
48
|
+
|
|
49
|
+
bin/saboteur run --since origin/main
|
|
50
|
+
TEXT
|
|
51
|
+
|
|
52
|
+
Result = Data.define(:status, :path)
|
|
53
|
+
|
|
54
|
+
def initialize(root: Dir.pwd, force: false)
|
|
55
|
+
@root = File.expand_path(root)
|
|
56
|
+
@force = force
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns the list of Results (:created, :overwritten, :skipped, :appended, :unchanged).
|
|
60
|
+
def call
|
|
61
|
+
[write("config/saboteur.rb", CONFIG), write("bin/saboteur", BIN, mode: 0o755), gitignore]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Printed after install; the Gemfile is never edited.
|
|
65
|
+
def gemfile_hint
|
|
66
|
+
gemfile = File.join(@root, "Gemfile")
|
|
67
|
+
return nil if File.file?(gemfile) && File.read(gemfile).match?(/^\s*gem\s+["']saboteur["']/)
|
|
68
|
+
|
|
69
|
+
GEMFILE_HINT
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Never writes or chmods through a symlink: not the file itself, and not a
|
|
75
|
+
# symlinked directory above it (bin -> elsewhere). Without force the file is
|
|
76
|
+
# skipped; with force the link is removed and replaced by a real file or
|
|
77
|
+
# directory inside the project, and the old target is left alone.
|
|
78
|
+
def write(relative, content, mode: nil)
|
|
79
|
+
path = File.join(@root, relative)
|
|
80
|
+
link = symlink_component(relative)
|
|
81
|
+
existed = present?(path)
|
|
82
|
+
return Result.new(:skipped, relative) if (existed || link) && !@force
|
|
83
|
+
|
|
84
|
+
File.delete(link) if link
|
|
85
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
86
|
+
File.write(path, content)
|
|
87
|
+
File.chmod(mode, path) if mode
|
|
88
|
+
Result.new(existed ? :overwritten : :created, relative)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The first symlink on the way from the project root to the file, if any.
|
|
92
|
+
def symlink_component(relative)
|
|
93
|
+
current = @root
|
|
94
|
+
relative.split("/").each do |part|
|
|
95
|
+
current = File.join(current, part)
|
|
96
|
+
return current if File.symlink?(current)
|
|
97
|
+
end
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A dangling symlink does not "exist" but is not free to write either.
|
|
102
|
+
def present?(path)
|
|
103
|
+
File.exist?(path) || File.symlink?(path)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def gitignore
|
|
107
|
+
path = File.join(@root, ".gitignore")
|
|
108
|
+
link = symlink_component(".gitignore")
|
|
109
|
+
return Result.new(:skipped, ".gitignore") if link && !@force
|
|
110
|
+
|
|
111
|
+
current = File.file?(path) ? File.read(path) : ""
|
|
112
|
+
listed = listed?(current)
|
|
113
|
+
return Result.new(:unchanged, ".gitignore") if listed && !link
|
|
114
|
+
|
|
115
|
+
File.delete(link) if link
|
|
116
|
+
File.write(path, "#{current}#{missing_entry(current, listed)}")
|
|
117
|
+
Result.new(link ? :overwritten : :appended, ".gitignore")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def listed?(text)
|
|
121
|
+
text.lines.map(&:strip).include?(IGNORE_ENTRY)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# What to append so .saboteur/ is on its own line; nothing if already there.
|
|
125
|
+
def missing_entry(current, listed)
|
|
126
|
+
return "" if listed
|
|
127
|
+
|
|
128
|
+
separator = current.empty? || current.end_with?("\n") ? "" : "\n"
|
|
129
|
+
"#{separator}#{IGNORE_ENTRY}\n"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -59,14 +59,14 @@ module Saboteur
|
|
|
59
59
|
def run_all(recorder)
|
|
60
60
|
configuration.order = "rand:#{config.fetch('seed', 1)}"
|
|
61
61
|
configuration.fail_fast = false
|
|
62
|
-
execute(Listener.new(recorder))
|
|
62
|
+
execute(Listener.new(recorder), log_phase: true)
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def run_baseline(seed:)
|
|
66
66
|
configuration.order = "rand:#{seed}"
|
|
67
67
|
configuration.fail_fast = false
|
|
68
68
|
listener = Listener.new
|
|
69
|
-
passed = execute(listener)
|
|
69
|
+
passed = execute(listener, log_phase: true)
|
|
70
70
|
RunResult.new(passed: passed && listener.failed.empty?, failed_ids: listener.failed,
|
|
71
71
|
executed_ids: listener.executed, runtime: listener.runtime,
|
|
72
72
|
failure_classes: listener.failure_classes, failure_messages: listener.failure_messages)
|
|
@@ -104,11 +104,19 @@ module Saboteur
|
|
|
104
104
|
world.example_groups.flat_map(&:descendants).each(&:reset_memoized)
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
# log_phase is for the baseline and coverage children, whose $stdout is
|
|
108
|
+
# redirected to the phase's log: progress and failure detail land in
|
|
109
|
+
# that log, so a failed phase can be diagnosed from it. Mutant runs
|
|
110
|
+
# stay quiet.
|
|
111
|
+
def execute(listener, log_phase: false)
|
|
108
112
|
reporter = ::RSpec::Core::Reporter.new(configuration)
|
|
109
113
|
reporter.register_listener(listener, *Listener::EVENTS)
|
|
110
114
|
groups = world.ordered_example_groups.select { |group| group.descendant_filtered_examples.any? }
|
|
111
115
|
count = world.example_count(groups)
|
|
116
|
+
if log_phase
|
|
117
|
+
logger = PhaseLogger.new($stdout, total: count)
|
|
118
|
+
reporter.register_listener(logger, *PhaseLogger::EVENTS)
|
|
119
|
+
end
|
|
112
120
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
113
121
|
passed = reporter.report(count) do |r|
|
|
114
122
|
configuration.with_suite_hooks { groups.map { |group| group.run(r) }.all? }
|
|
@@ -117,6 +125,42 @@ module Saboteur
|
|
|
117
125
|
passed && !world.non_example_failure
|
|
118
126
|
end
|
|
119
127
|
|
|
128
|
+
# Progress and failure detail for a phase log. Every 25 finished
|
|
129
|
+
# examples is enough to see movement on a tail; failures print RSpec's
|
|
130
|
+
# full formatted detail, and the run ends with RSpec's totals line.
|
|
131
|
+
class PhaseLogger
|
|
132
|
+
EVENTS = %i[example_finished message dump_failures dump_summary].freeze
|
|
133
|
+
|
|
134
|
+
PROGRESS_EVERY = 25
|
|
135
|
+
|
|
136
|
+
def initialize(out, total:)
|
|
137
|
+
@out = out
|
|
138
|
+
@total = total
|
|
139
|
+
@executed = 0
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def example_finished(_notification)
|
|
143
|
+
@executed += 1
|
|
144
|
+
return unless @executed == @total || (@executed % PROGRESS_EVERY).zero?
|
|
145
|
+
|
|
146
|
+
@out.puts("#{@executed}/#{@total} examples finished")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# A failure outside any example (a suite hook, a load error) arrives
|
|
150
|
+
# as a message, never in dump_failures.
|
|
151
|
+
def message(notification)
|
|
152
|
+
@out.puts(notification.message)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def dump_failures(notification)
|
|
156
|
+
@out.puts(notification.fully_formatted_failed_examples)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def dump_summary(notification)
|
|
160
|
+
@out.puts(notification.totals_line)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
120
164
|
class Listener
|
|
121
165
|
EVENTS = %i[example_started example_finished example_failed].freeze
|
|
122
166
|
|
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
# Deletes one ordinary argument from a parenthesized method call.
|
|
10
|
+
class ArgumentRemoval < Base
|
|
11
|
+
BOUNDARIES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode,
|
|
12
|
+
Prism::ParametersNode, Prism::BlockParametersNode].freeze
|
|
13
|
+
EXCLUDED = [Prism::KeywordHashNode, Prism::SplatNode, Prism::ForwardingArgumentsNode].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(arid: []) # rubocop:disable Lint/MissingSuper
|
|
16
|
+
@arid = arid.map { |pattern| Regexp.new(pattern) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def name
|
|
20
|
+
"argument_removal"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def mutations_for(subject)
|
|
24
|
+
found = []
|
|
25
|
+
tokens = Prism.lex(subject.file_source).value.map(&:first)
|
|
26
|
+
visit(subject.node.body, subject, found, tokens)
|
|
27
|
+
found
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def visit(node, subject, found, tokens)
|
|
33
|
+
return if node.nil? || BOUNDARIES.any? { |type| node.is_a?(type) }
|
|
34
|
+
|
|
35
|
+
if node.is_a?(Prism::StatementsNode)
|
|
36
|
+
node.body.each do |child|
|
|
37
|
+
visit(child, subject, found, tokens) unless @arid.any? do |p|
|
|
38
|
+
p.match?(child.slice)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
return
|
|
42
|
+
end
|
|
43
|
+
remove_arguments(node, subject, found, tokens) if node.is_a?(Prism::CallNode)
|
|
44
|
+
node.compact_child_nodes.each { |child| visit(child, subject, found, tokens) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def remove_arguments(node, subject, found, tokens)
|
|
48
|
+
return unless node.opening_loc&.slice == "(" && node.arguments
|
|
49
|
+
|
|
50
|
+
arguments = node.arguments.arguments
|
|
51
|
+
arguments.each_with_index do |argument, index|
|
|
52
|
+
next if EXCLUDED.any? { |type| argument.is_a?(type) } || heredoc?(argument)
|
|
53
|
+
|
|
54
|
+
separator = separator_for(node, arguments, index, tokens)
|
|
55
|
+
edits = [delete(subject, argument.location)]
|
|
56
|
+
edits << delete(subject, separator) if separator
|
|
57
|
+
found << Mutation.new(subject_id: subject.id, operator: name, edits: edits, source: name)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def separator_for(node, arguments, index, tokens)
|
|
62
|
+
argument = arguments[index]
|
|
63
|
+
after = comma_between(argument.location.end_offset, node.closing_loc.start_offset, tokens)
|
|
64
|
+
return after if after
|
|
65
|
+
|
|
66
|
+
previous = arguments[0...index].last
|
|
67
|
+
comma_between(previous.location.end_offset, argument.location.start_offset, tokens) if previous
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def comma_between(first, last, tokens)
|
|
71
|
+
tokens.find do |token|
|
|
72
|
+
token.type == :COMMA && token.location.start_offset >= first && token.location.end_offset <= last
|
|
73
|
+
end&.location
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def heredoc?(node)
|
|
77
|
+
(node.respond_to?(:opening_loc) && node.opening_loc&.slice&.start_with?("<<")) ||
|
|
78
|
+
node.compact_child_nodes.any? { |child| heredoc?(child) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def delete(subject, location)
|
|
82
|
+
Edit.new(file: subject.file, start_byte: location.start_offset, end_byte: location.end_offset, replacement: "")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -5,6 +5,8 @@ require "prism"
|
|
|
5
5
|
require_relative "base"
|
|
6
6
|
require_relative "filter_replacement"
|
|
7
7
|
require_relative "literal_replacement"
|
|
8
|
+
require_relative "argument_removal"
|
|
9
|
+
require_relative "pair_removal"
|
|
8
10
|
|
|
9
11
|
module Saboteur
|
|
10
12
|
module MutationSource
|
|
@@ -50,6 +52,8 @@ module Saboteur
|
|
|
50
52
|
@arid = arid.map { |pattern| pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern) }
|
|
51
53
|
@filter_replacement = FilterReplacement.new(arid: arid)
|
|
52
54
|
@literal_replacement = LiteralReplacement.new(arid: arid)
|
|
55
|
+
@argument_removal = ArgumentRemoval.new(arid: arid)
|
|
56
|
+
@pair_removal = PairRemoval.new(arid: arid)
|
|
53
57
|
end
|
|
54
58
|
|
|
55
59
|
def name
|
|
@@ -64,6 +68,8 @@ module Saboteur
|
|
|
64
68
|
visit(body)
|
|
65
69
|
@found.concat(@filter_replacement.mutations_for(subject))
|
|
66
70
|
@found.concat(@literal_replacement.mutations_for(subject))
|
|
71
|
+
@found.concat(@argument_removal.mutations_for(subject))
|
|
72
|
+
@found.concat(@pair_removal.mutations_for(subject))
|
|
67
73
|
ensure
|
|
68
74
|
@subject = nil
|
|
69
75
|
end
|
|
@@ -91,8 +97,8 @@ module Saboteur
|
|
|
91
97
|
end
|
|
92
98
|
|
|
93
99
|
# A raise that the method's own rescue catches is absorbed by design. It
|
|
94
|
-
#
|
|
95
|
-
#
|
|
100
|
+
# is not generated: rejecting that exception is not the rescue contract.
|
|
101
|
+
# Fine mutants still run regardless of the coarse verdicts.
|
|
96
102
|
def rescues_everything?(body)
|
|
97
103
|
clause = body.is_a?(Prism::BeginNode) ? body.rescue_clause : nil
|
|
98
104
|
while clause
|
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
# Deletes one association from a hash literal or keyword argument list.
|
|
10
|
+
class PairRemoval < Base
|
|
11
|
+
BOUNDARIES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode,
|
|
12
|
+
Prism::ParametersNode, Prism::BlockParametersNode].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(arid: []) # rubocop:disable Lint/MissingSuper
|
|
15
|
+
@arid = arid.map { |pattern| Regexp.new(pattern) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def name
|
|
19
|
+
"pair_removal"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def mutations_for(subject)
|
|
23
|
+
found = []
|
|
24
|
+
tokens = Prism.lex(subject.file_source).value.map(&:first)
|
|
25
|
+
visit(subject.node.body, subject, found, tokens)
|
|
26
|
+
found
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def visit(node, subject, found, tokens)
|
|
32
|
+
return if node.nil? || BOUNDARIES.any? { |type| node.is_a?(type) }
|
|
33
|
+
|
|
34
|
+
if node.is_a?(Prism::StatementsNode)
|
|
35
|
+
node.body.each do |child|
|
|
36
|
+
visit(child, subject, found, tokens) unless @arid.any? { |p| p.match?(child.slice) }
|
|
37
|
+
end
|
|
38
|
+
return
|
|
39
|
+
end
|
|
40
|
+
remove_pairs(node, subject, found, tokens) if node.is_a?(Prism::HashNode)
|
|
41
|
+
remove_keywords(node, subject, found, tokens) if node.is_a?(Prism::CallNode)
|
|
42
|
+
node.compact_child_nodes.each { |child| visit(child, subject, found, tokens) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def remove_keywords(node, subject, found, tokens)
|
|
46
|
+
return unless node.arguments
|
|
47
|
+
|
|
48
|
+
node.arguments.arguments.grep(Prism::KeywordHashNode).each do |hash|
|
|
49
|
+
remove_pairs(hash, subject, found, tokens, call: node)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def remove_pairs(node, subject, found, tokens, call: nil)
|
|
54
|
+
pairs = node.elements
|
|
55
|
+
pairs.each_with_index do |pair, index|
|
|
56
|
+
next unless pair.is_a?(Prism::AssocNode)
|
|
57
|
+
next if heredoc?(pair)
|
|
58
|
+
|
|
59
|
+
edits = [delete(subject, pair.location)]
|
|
60
|
+
separator = separator_for(node, pairs, index, tokens, call)
|
|
61
|
+
edits << delete(subject, separator) if separator
|
|
62
|
+
found << Mutation.new(subject_id: subject.id, operator: name, edits: edits, source: name)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
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
|
+
def ending_for(node, call)
|
|
78
|
+
return node.closing_loc.start_offset if node.is_a?(Prism::HashNode)
|
|
79
|
+
return call.closing_loc.start_offset if call.closing_loc
|
|
80
|
+
return call.block.location.start_offset if call.block
|
|
81
|
+
|
|
82
|
+
node.location.end_offset
|
|
83
|
+
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
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
data/lib/saboteur/replay.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
module Saboteur
|
|
5
|
-
# Resolves one exact digest from current source, before caps or
|
|
5
|
+
# Resolves one exact digest from current source, before caps, ignore suppression or incremental reuse.
|
|
6
6
|
class Replay
|
|
7
7
|
def initialize(digest:)
|
|
8
8
|
unless digest.match?(/\A[0-9a-f]{16}\z/)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "agent_summary"
|
|
6
|
+
require_relative "agent_commands"
|
|
7
|
+
|
|
8
|
+
module Saboteur
|
|
9
|
+
module Reporter
|
|
10
|
+
# A small JSON summary for an AI agent that runs saboteur (`saboteur run --agent`).
|
|
11
|
+
#
|
|
12
|
+
# The payload stays under BYTE_BUDGET: it never embeds file sources or the full
|
|
13
|
+
# Stryker report. It names the verdict, says plainly what was not covered, shows
|
|
14
|
+
# the first few findings with short edit snippets, counts the rest, and points
|
|
15
|
+
# at the commands and files that hold the full detail.
|
|
16
|
+
#
|
|
17
|
+
# Exit codes in this mode are stricter than the classic ones: 0 means the run
|
|
18
|
+
# was complete and clean, nothing less.
|
|
19
|
+
module Agent
|
|
20
|
+
VERSION = 1
|
|
21
|
+
BYTE_BUDGET = 8 * 1024
|
|
22
|
+
FINDING_LIMIT = 3
|
|
23
|
+
SNIPPET_LIMITS = [200, 80, 0].freeze
|
|
24
|
+
MAX_NUMBER_DIGITS = 20
|
|
25
|
+
WARNING_LIMIT = 3
|
|
26
|
+
WARNING_LENGTH = 160
|
|
27
|
+
|
|
28
|
+
EXIT_CLEAN = 0
|
|
29
|
+
EXIT_REVIEW = 1
|
|
30
|
+
EXIT_INVOCATION = 2
|
|
31
|
+
EXIT_INCOMPLETE = 3
|
|
32
|
+
|
|
33
|
+
# Where a verdict comes from: the exact selection and the state of the working
|
|
34
|
+
# source before the run started. subjects, tests and sources are arrays.
|
|
35
|
+
Provenance = Data.define(:ref, :seed, :head, :working_tree, :changed_files, :mode, :json_report, :subjects,
|
|
36
|
+
:tests, :sources, :config_path, :digest, :cache, :incremental, :settings)
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
def call(report, provenance)
|
|
40
|
+
fit(report, provenance)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def render(payload)
|
|
44
|
+
"#{JSON.pretty_generate(payload)}\n"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def exit_code(payload)
|
|
48
|
+
payload.fetch(:exitCode)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The payload for a run that never started or could not finish.
|
|
52
|
+
def failure(message, exit_code)
|
|
53
|
+
verdict = exit_code == EXIT_INVOCATION ? "invalid_invocation" : "tool_failure"
|
|
54
|
+
{ version: VERSION, status: "error", verdict: verdict, exitCode: exit_code,
|
|
55
|
+
message: message.to_s[0, 500], next: { help: "saboteur help run" } }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def fit(report, provenance)
|
|
61
|
+
FINDING_LIMIT.downto(0) do |shown|
|
|
62
|
+
SNIPPET_LIMITS.each do |snippet_limit|
|
|
63
|
+
payload = Summary.build(report, provenance, shown, snippet_limit)
|
|
64
|
+
return payload if render(payload).bytesize <= BYTE_BUDGET
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
bounded_last_resort(report, provenance)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The minimal payload, or four fixed keys when even that is over the budget.
|
|
71
|
+
def bounded_last_resort(report, provenance)
|
|
72
|
+
small = minimal(report, provenance)
|
|
73
|
+
return small if render(small).bytesize <= BYTE_BUDGET
|
|
74
|
+
|
|
75
|
+
small.slice(:version, :status, :verdict, :exitCode).merge(truncated: true)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The last resort: fixed keys and short clipped values, so it always fits.
|
|
79
|
+
def minimal(report, provenance)
|
|
80
|
+
small = Summary.build(report, provenance, 0, SNIPPET_LIMITS.last)
|
|
81
|
+
small.merge(warnings: [], next: {}, provenance: small[:provenance].slice(:seed, :workingTree, :mode),
|
|
82
|
+
incomplete: small[:incomplete].map { |reason| reason.slice(:reason, :count) })
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Reporter
|
|
6
|
+
module Agent
|
|
7
|
+
# Keeps display values short so the agent payload stays inside its byte budget.
|
|
8
|
+
module Clipping
|
|
9
|
+
# A seed or setting stays a number while it is short; a huge value cannot take the budget.
|
|
10
|
+
def bounded_number(value)
|
|
11
|
+
value.to_s.length <= MAX_NUMBER_DIGITS ? value : clip_value(value, MAX_NUMBER_DIGITS)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def clip_value(value, limit)
|
|
15
|
+
return nil if value.nil?
|
|
16
|
+
|
|
17
|
+
text = value.to_s
|
|
18
|
+
text.length > limit ? "#{text[0, limit]}..." : text
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def clip(text, limit)
|
|
22
|
+
return "" if limit.zero?
|
|
23
|
+
|
|
24
|
+
text.length > limit ? "#{text[0, limit]}..." : text
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "shellwords"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
module Reporter
|
|
8
|
+
module Agent
|
|
9
|
+
# The shell commands an agent is told to run next. Every word is escaped with
|
|
10
|
+
# Shellwords, so a ref, path or subject holding shell syntax stays one literal
|
|
11
|
+
# argument. A command is never cut short: when it is longer than MAX_BYTES it
|
|
12
|
+
# is left out, because half a command could run something else.
|
|
13
|
+
module Commands
|
|
14
|
+
MAX_BYTES = 1024
|
|
15
|
+
|
|
16
|
+
# Options typed on the command line that change how a run behaves.
|
|
17
|
+
SETTING_FLAGS = { reeval: "--reeval", integration: "--integration", jobs: "--jobs",
|
|
18
|
+
timeout_factor: "--timeout-factor", json_report: "--json", html_report: "--html" }.freeze
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# Run the same selection again: ref, subjects, sources, tests, config,
|
|
22
|
+
# cache mode and replay digest, with the recorded seed.
|
|
23
|
+
def rerun(provenance)
|
|
24
|
+
words = ["saboteur", "run", "--agent", *selection(provenance), "--seed", provenance.seed.to_s]
|
|
25
|
+
finish(words, provenance)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Replay one survivor by digest in the same selection.
|
|
29
|
+
def replay(provenance, digest)
|
|
30
|
+
words = ["saboteur", "run", "--id", digest, *selection(provenance, replay: true), "--seed",
|
|
31
|
+
provenance.seed.to_s]
|
|
32
|
+
finish(words, provenance)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def finish(words, provenance)
|
|
38
|
+
words += ["--", *provenance.sources] unless provenance.sources.empty?
|
|
39
|
+
command = Shellwords.join(words)
|
|
40
|
+
command.bytesize <= MAX_BYTES ? command : nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def selection(provenance, replay: false)
|
|
44
|
+
words = []
|
|
45
|
+
words.push("--config", provenance.config_path) if provenance.config_path
|
|
46
|
+
words.push("--since", provenance.ref) if provenance.ref
|
|
47
|
+
provenance.subjects.each { |subject| words.push("--subject", subject) }
|
|
48
|
+
provenance.tests.each { |test| words.push("--test", test) }
|
|
49
|
+
SETTING_FLAGS.each do |key, flag|
|
|
50
|
+
words.push(flag, provenance.settings[key].to_s) if provenance.settings.key?(key)
|
|
51
|
+
end
|
|
52
|
+
words << "--no-cache" if provenance.cache == false
|
|
53
|
+
words << "--incremental" if provenance.incremental
|
|
54
|
+
words.push("--id", provenance.digest) if provenance.digest && !replay
|
|
55
|
+
words
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Reporter
|
|
6
|
+
module Agent
|
|
7
|
+
# What makes a verdict cover less than the whole selection. Each reason has a
|
|
8
|
+
# stable code, a count and a plain meaning.
|
|
9
|
+
module Incomplete
|
|
10
|
+
class << self
|
|
11
|
+
def reasons(report)
|
|
12
|
+
reasons = []
|
|
13
|
+
add(reasons, "no_coverage", skipped_for_coverage(report), "subjects or mutants had no covering test")
|
|
14
|
+
add(reasons, "mutant_cap", omitted_by_cap(report), "mutants omitted by max_mutants_per_method")
|
|
15
|
+
add(reasons, "errors", report.counts[:error], "mutants errored; their verdict did not hold up")
|
|
16
|
+
add(reasons, "resource_limit", report.counts[:resource_limit], "mutants hit the memory or process cap")
|
|
17
|
+
reasons
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def add(reasons, code, count, meaning)
|
|
23
|
+
reasons << { reason: code, count: count, meaning: meaning } if count.positive?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def skipped_for_coverage(report)
|
|
27
|
+
report.skipped_subjects.size + report.counts[:skipped]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def omitted_by_cap(report)
|
|
31
|
+
report.warnings.sum { |warning| warning[/omitted (\d+) mutant/, 1].to_i }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|