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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +113 -1
  3. data/README.md +248 -63
  4. data/lib/generators/saboteur/install/install_generator.rb +21 -0
  5. data/lib/saboteur/agent_skill.rb +40 -0
  6. data/lib/saboteur/cli.rb +150 -22
  7. data/lib/saboteur/config.rb +27 -15
  8. data/lib/saboteur/config_file.rb +97 -0
  9. data/lib/saboteur/custom_map.rb +170 -0
  10. data/lib/saboteur/doctor.rb +17 -1
  11. data/lib/saboteur/engine/baseline_guard.rb +20 -3
  12. data/lib/saboteur/engine/boot.rb +9 -0
  13. data/lib/saboteur/engine/coverage_store.rb +31 -4
  14. data/lib/saboteur/engine/data_inputs.rb +43 -0
  15. data/lib/saboteur/engine/diagnostics.rb +28 -0
  16. data/lib/saboteur/engine/diff_scope.rb +8 -0
  17. data/lib/saboteur/engine/run_fingerprint.rb +34 -6
  18. data/lib/saboteur/engine/scheduler.rb +7 -16
  19. data/lib/saboteur/engine/suite_files.rb +8 -1
  20. data/lib/saboteur/engine.rb +74 -13
  21. data/lib/saboteur/execution/reevaluator.rb +7 -4
  22. data/lib/saboteur/execution/supervisor.rb +23 -11
  23. data/lib/saboteur/frameworks/rails.rb +329 -13
  24. data/lib/saboteur/help.rb +33 -17
  25. data/lib/saboteur/ignore_list.rb +71 -17
  26. data/lib/saboteur/install.rb +132 -0
  27. data/lib/saboteur/integration/rspec.rb +47 -3
  28. data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
  29. data/lib/saboteur/mutation_source/builtin.rb +8 -2
  30. data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
  31. data/lib/saboteur/replay.rb +1 -1
  32. data/lib/saboteur/reporter/agent.rb +87 -0
  33. data/lib/saboteur/reporter/agent_clipping.rb +29 -0
  34. data/lib/saboteur/reporter/agent_commands.rb +61 -0
  35. data/lib/saboteur/reporter/agent_incomplete.rb +37 -0
  36. data/lib/saboteur/reporter/agent_summary.rb +131 -0
  37. data/lib/saboteur/reporter/findings.rb +8 -0
  38. data/lib/saboteur/reporter/html.rb +49 -1
  39. data/lib/saboteur/reporter/stryker.rb +11 -4
  40. data/lib/saboteur/reporter/terminal.rb +65 -2
  41. data/lib/saboteur/result.rb +46 -5
  42. data/lib/saboteur/score.rb +89 -0
  43. data/lib/saboteur/version.rb +1 -1
  44. data/lib/saboteur/working_tree.rb +48 -0
  45. data/lib/saboteur.rb +4 -0
  46. 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
- def execute(listener)
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
- # would survive, and a surviving coarse mutant stops every fine mutant
95
- # of the method, so it is not generated.
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
@@ -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 coarse gating.
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