saboteur 0.2.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 (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +47 -0
  3. data/LICENSE +21 -0
  4. data/README.md +145 -0
  5. data/exe/saboteur +12 -0
  6. data/lib/saboteur/cli.rb +112 -0
  7. data/lib/saboteur/config.rb +133 -0
  8. data/lib/saboteur/engine/boot.rb +83 -0
  9. data/lib/saboteur/engine/candidates.rb +63 -0
  10. data/lib/saboteur/engine/coverage_store.rb +99 -0
  11. data/lib/saboteur/engine/diagnostics.rb +37 -0
  12. data/lib/saboteur/engine/diff_scope.rb +32 -0
  13. data/lib/saboteur/engine/distribution.rb +74 -0
  14. data/lib/saboteur/engine/result_cache.rb +93 -0
  15. data/lib/saboteur/engine/scheduler.rb +215 -0
  16. data/lib/saboteur/engine/subject_selection.rb +63 -0
  17. data/lib/saboteur/engine/suite_files.rb +82 -0
  18. data/lib/saboteur/engine/verdict_check.rb +78 -0
  19. data/lib/saboteur/engine.rb +133 -0
  20. data/lib/saboteur/execution/process_table.rb +177 -0
  21. data/lib/saboteur/execution/process_tree.rb +99 -0
  22. data/lib/saboteur/execution/protocol.rb +41 -0
  23. data/lib/saboteur/execution/reevaluator.rb +144 -0
  24. data/lib/saboteur/execution/runner.rb +191 -0
  25. data/lib/saboteur/execution/supervisor.rb +337 -0
  26. data/lib/saboteur/execution/worker_pool.rb +249 -0
  27. data/lib/saboteur/frameworks/rails.rb +166 -0
  28. data/lib/saboteur/hooks.rb +51 -0
  29. data/lib/saboteur/ignore_list.rb +62 -0
  30. data/lib/saboteur/integration/base.rb +72 -0
  31. data/lib/saboteur/integration/rspec.rb +143 -0
  32. data/lib/saboteur/mutation_source/base.rb +26 -0
  33. data/lib/saboteur/mutation_source/builtin.rb +232 -0
  34. data/lib/saboteur/mutation_source/edit.rb +18 -0
  35. data/lib/saboteur/mutation_source/mutation.rb +28 -0
  36. data/lib/saboteur/reporter/diff.rb +97 -0
  37. data/lib/saboteur/reporter/stryker.rb +116 -0
  38. data/lib/saboteur/reporter/terminal.rb +136 -0
  39. data/lib/saboteur/result.rb +134 -0
  40. data/lib/saboteur/selection/coverage_map.rb +113 -0
  41. data/lib/saboteur/selection/git_diff.rb +64 -0
  42. data/lib/saboteur/selection/recorder.rb +72 -0
  43. data/lib/saboteur/subject.rb +166 -0
  44. data/lib/saboteur/subject_finder.rb +98 -0
  45. data/lib/saboteur/version.rb +6 -0
  46. data/lib/saboteur.rb +51 -0
  47. metadata +130 -0
@@ -0,0 +1,99 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "coverage"
5
+ require "fileutils"
6
+ require "json"
7
+
8
+ module Saboteur
9
+ class Engine
10
+ # Returns the coverage map for a run: from the cache when every input is
11
+ # unchanged, otherwise built by running the whole suite once in a
12
+ # supervised child and saved.
13
+ class CoverageStore
14
+ CACHE_INPUTS = %w[integration require rspec_args rails].freeze
15
+ LOCKFILES = %w[Gemfile.lock gems.locked].freeze
16
+ LOG = "coverage-build.log"
17
+
18
+ def initialize(config:, integration:, hooks:, supervisor:, diagnostics:)
19
+ @config = config
20
+ @integration = integration
21
+ @hooks = hooks
22
+ @supervisor = supervisor
23
+ @diagnostics = diagnostics
24
+ @root = File.realpath(config.root)
25
+ end
26
+
27
+ def fetch(files)
28
+ path = File.join(@config.path("cache_dir"), "coverage-#{cache_key(files)}.json")
29
+ cached = @config["cache"] ? Selection::CoverageMap.load(path) : nil
30
+ return cached if cached
31
+
32
+ map = build(files)
33
+ map.save(path)
34
+ map
35
+ end
36
+
37
+ private
38
+
39
+ def cache_key(files)
40
+ inputs = JSON.generate(CACHE_INPUTS.to_h { |key| [key, @config[key]] })
41
+ all = files + @integration.test_files + SuiteFiles.new(@config).files + lockfiles
42
+ Selection::CoverageMap.cache_key(files: all.uniq, extra: inputs)
43
+ end
44
+
45
+ def lockfiles
46
+ LOCKFILES.map { |file| File.join(@root, file) }.select { |file| File.file?(file) }
47
+ end
48
+
49
+ def build(files)
50
+ unless Coverage.respond_to?(:state) && Coverage.state == :suspended
51
+ raise Error, "Coverage is #{Coverage.state}; saboteur must set it up before the app loads. " \
52
+ "Run through the saboteur executable and disable SimpleCov for the run."
53
+ end
54
+
55
+ @diagnostics.inform("building coverage map (runs the whole suite once)")
56
+ result = @supervisor.run(timeout: @config["coverage_timeout"]) { |writer| record(writer, files) }
57
+ raise Error, "coverage map build stopped: #{result.detail}" if result.stopped_by
58
+ raise Error, "coverage map build failed: #{died(result.exit_status)}" if result.payload.empty?
59
+
60
+ data = JSON.parse(result.payload)
61
+ raise Error, "coverage map build failed: #{data['error']}" if data["error"]
62
+
63
+ Selection::CoverageMap.from_json(result.payload)
64
+ end
65
+
66
+ def died(status)
67
+ how = if status&.signaled? then "was killed by signal #{Signal.signame(status.termsig)}"
68
+ else "exited with status #{status&.exitstatus}"
69
+ end
70
+ "the suite #{how} before reporting; its output is in #{log_path.delete_prefix("#{@root}/")}"
71
+ end
72
+
73
+ def log_path
74
+ File.join(@config.path("cache_dir"), LOG)
75
+ end
76
+
77
+ # Runs in the supervised child. The suite's output goes to a log, so a
78
+ # child that dies without reporting can still be diagnosed.
79
+ def record(writer, files)
80
+ FileUtils.mkdir_p(File.dirname(log_path))
81
+ $stdout.reopen(log_path, "w")
82
+ $stdout.sync = true
83
+ $stderr.reopen($stdout)
84
+ @hooks.run(:after_fork)
85
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
86
+ recorder = Selection::Recorder.new(files)
87
+ recorder.start
88
+ Kernel.srand(@config["seed"])
89
+ @integration.run_all(recorder)
90
+ build_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
91
+ writer.write(recorder.result(build_seconds: build_seconds).to_json)
92
+ writer.close
93
+ rescue Exception => e # rubocop:disable Lint/RescueException
94
+ writer.write(JSON.generate(error: "#{e.class}: #{e.message}"))
95
+ exit!(1)
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,37 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class Engine
6
+ # What a run has to say besides results: warnings for the report, the
7
+ # count of mutations dropped for not parsing, and progress marks.
8
+ class Diagnostics
9
+ MARKS = { killed: ".", survived: "S", timeout: "T", resource_limit: "L", error: "E" }.freeze
10
+
11
+ attr_reader :warnings, :invalid_mutations
12
+
13
+ def initialize(err)
14
+ @err = err
15
+ @warnings = []
16
+ @invalid_mutations = 0
17
+ end
18
+
19
+ def warn(message)
20
+ @warnings << message
21
+ end
22
+
23
+ def count_invalid_mutation
24
+ @invalid_mutations += 1
25
+ end
26
+
27
+ def inform(message)
28
+ @err.puts("saboteur: #{message}")
29
+ end
30
+
31
+ def progress(result)
32
+ @err.print(MARKS.fetch(result.status, "?"))
33
+ @err.flush
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,32 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class Engine
6
+ # Review-shaped diff mode (--since). A reviewer asks "do the tests check
7
+ # what this change did?", so only mutants on changed lines answer it.
8
+ # The coarse mutants of each changed method still run: they ask whether
9
+ # the method is tested at all.
10
+ class DiffScope
11
+ # changed_lines: { "/abs/file.rb" => Set[line numbers] }
12
+ def initialize(changed_lines)
13
+ @changed_lines = changed_lines
14
+ end
15
+
16
+ def restrict(subject, candidates)
17
+ changed = @changed_lines.fetch(subject.file, Set.new)
18
+ candidates.select do |mutation, _|
19
+ mutation.coarse || mutation.edits.any? { |edit| edited_lines(subject, edit).any? { changed.include?(_1) } }
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def edited_lines(subject, edit)
26
+ first = subject.line_of(edit.start_byte)
27
+ last = subject.line_of([edit.end_byte - 1, edit.start_byte].max)
28
+ (first..last)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,74 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class Engine
6
+ # Runs each subject's candidates through the scheduler, on one process or
7
+ # on a pool of workers (--jobs N). A worker sends its results back as
8
+ # plain data; the parent rebuilds them against its own copy of the same
9
+ # candidate list, so results look the same either way.
10
+ class Distribution
11
+ def initialize(jobs:, scheduler:, diagnostics:, cache:, on_start: nil)
12
+ @jobs = jobs
13
+ @on_start = on_start
14
+ @scheduler = scheduler
15
+ @diagnostics = diagnostics
16
+ @cache = cache
17
+ end
18
+
19
+ # lists: each subject's [[mutation, text]] candidates, in subject order.
20
+ def run(subjects, lists)
21
+ return subjects.zip(lists).flat_map { |subject, list| @scheduler.call(subject, list) } if @jobs <= 1
22
+
23
+ pool = Execution::WorkerPool.new(jobs: @jobs, on_start: @on_start)
24
+ rows = pool.map(subjects.each_index.to_a, weights: lists.map(&:size)) do |i|
25
+ work(subjects[i], lists[i])
26
+ end
27
+ rows.each_with_index.flat_map { |row, i| rebuild(row, subjects[i], lists[i]) }
28
+ end
29
+
30
+ private
31
+
32
+ # Runs in a worker.
33
+ def work(subject, list)
34
+ before = @diagnostics.warnings.size
35
+ results = @scheduler.call(subject, list)
36
+ { "warnings" => @diagnostics.warnings.drop(before),
37
+ "results" => results.map { |result| encode(result, list) } }
38
+ end
39
+
40
+ def encode(result, list)
41
+ {
42
+ "position" => list.index { |mutation, _| mutation.equal?(result.mutation) },
43
+ "status" => result.status.to_s, "reason" => result.reason, "runtime" => result.runtime,
44
+ "killed_by" => result.killed_by, "covered_by" => result.covered_by,
45
+ "kill_cause" => result.kill_cause, "reused" => result.reused?
46
+ }
47
+ end
48
+
49
+ def rebuild(row, subject, list)
50
+ return failed(row["error"], subject, list) if row["error"]
51
+
52
+ @diagnostics.warnings.concat(row["warnings"])
53
+ row["results"].map do |data|
54
+ mutation, text = list.fetch(data["position"])
55
+ result = MutantResult.new(subject: subject, mutation: mutation, status: data["status"].to_sym,
56
+ mutated_source: text, reason: data["reason"], runtime: data["runtime"],
57
+ killed_by: data["killed_by"], covered_by: data["covered_by"],
58
+ kill_cause: data["kill_cause"], reused: data["reused"])
59
+ @cache&.record(result) unless result.reused?
60
+ result
61
+ end
62
+ end
63
+
64
+ # The worker died (or raised) while running this subject.
65
+ def failed(message, subject, list)
66
+ @diagnostics.warn("#{subject.id}: #{message}")
67
+ list.map do |mutation, text|
68
+ MutantResult.new(subject: subject, mutation: mutation, status: :error, mutated_source: text,
69
+ reason: message)
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,93 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "digest"
5
+ require "fileutils"
6
+ require "json"
7
+
8
+ module Saboteur
9
+ class Engine
10
+ # Opt-in reuse of earlier verdicts (--incremental), like PIT's and
11
+ # Stryker's history files.
12
+ #
13
+ # A verdict is reused only while everything it was computed from is the
14
+ # same: the method's source, the mutant, the set of covering tests, the
15
+ # content of those tests' files, the lockfile, the run's inputs (config,
16
+ # saboteur version). Only killed, survived and timeout are stored;
17
+ # errors and skips always run again.
18
+ #
19
+ # Known hole, the reason this is opt-in: a change in a method this one
20
+ # calls is not seen, so a stored verdict may be stale after it.
21
+ class ResultCache
22
+ FORMAT = 1
23
+ STORED = %i[killed survived timeout].freeze
24
+ LOCKFILES = %w[Gemfile.lock gems.locked].freeze
25
+
26
+ # suite_digest: SuiteFiles#digest, so an edit to a spec helper or
27
+ # support file invalidates every verdict.
28
+ def initialize(path:, root:, inputs:, suite_digest: "")
29
+ @path = path
30
+ @root = root
31
+ @inputs = inputs
32
+ @suite_digest = suite_digest
33
+ @entries = load
34
+ @file_digests = {}
35
+ end
36
+
37
+ # The stored verdict as a hash (status, reason, killed_by, kill_cause,
38
+ # runtime), or nil when it may no longer hold.
39
+ def lookup(subject, mutation, tests)
40
+ entry = @entries[key(subject, mutation)]
41
+ return nil unless entry && entry["fingerprint"] == fingerprint(subject, tests)
42
+
43
+ { status: entry["status"].to_sym, reason: entry["reason"], killed_by: entry["killed_by"],
44
+ kill_cause: entry["kill_cause"], runtime: entry["runtime"] }
45
+ end
46
+
47
+ def record(result)
48
+ return unless STORED.include?(result.status)
49
+
50
+ @entries[key(result.subject, result.mutation)] = {
51
+ "fingerprint" => fingerprint(result.subject, result.covered_by), "status" => result.status.to_s,
52
+ "reason" => result.reason, "killed_by" => result.killed_by, "kill_cause" => result.kill_cause,
53
+ "runtime" => result.runtime
54
+ }
55
+ end
56
+
57
+ def save
58
+ FileUtils.mkdir_p(File.dirname(@path))
59
+ File.write(@path, JSON.generate(format: FORMAT, entries: @entries))
60
+ end
61
+
62
+ private
63
+
64
+ def load
65
+ data = JSON.parse(File.read(@path))
66
+ data["format"] == FORMAT ? data["entries"] : {}
67
+ rescue SystemCallError, JSON::ParserError
68
+ {}
69
+ end
70
+
71
+ def key(subject, mutation)
72
+ "#{subject.id} #{mutation.digest(subject_start: subject.start_byte, original_source: subject.file_source)}"
73
+ end
74
+
75
+ def fingerprint(subject, tests)
76
+ digest = Digest::SHA256.new
77
+ digest << VERSION << "\0" << @inputs << "\0" << @suite_digest << "\0" << subject.source << "\0"
78
+ tests.sort.each { |test| digest << test << "\0" << file_digest(test_file(test)) << "\0" }
79
+ LOCKFILES.each { |name| digest << file_digest(File.join(@root, name)) << "\0" }
80
+ digest.hexdigest
81
+ end
82
+
83
+ # RSpec ids name their file: "./spec/a_spec.rb[1:2]".
84
+ def test_file(test)
85
+ File.expand_path(test[/\A(.+?\.rb)(?:\[|:|\z)/, 1] || test, @root)
86
+ end
87
+
88
+ def file_digest(path)
89
+ @file_digests[path] ||= File.file?(path) ? Digest::SHA256.file(path).hexdigest : "missing"
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,215 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class Engine
6
+ # Runs one subject's candidates and returns their results. This is the
7
+ # unit of work parallel workers will share out.
8
+ #
9
+ # Order of business: skip what cannot be run (refinements, not loaded,
10
+ # shadowed, no covering test); coarse mutants; fine mutants only if no
11
+ # coarse mutant survived; then check every kill and timeout against the
12
+ # unchanged method (VerdictCheck). Each mutant runs only the tests that
13
+ # executed its edit, likeliest killers first, with a timeout from those
14
+ # tests' recorded durations.
15
+ class Scheduler
16
+ # A subject to run, with what its mutants need. killers counts the
17
+ # tests that killed earlier mutants of this subject; slowdown is how
18
+ # much slower than recorded its tests ran in a child (see calibrate).
19
+ Plan = Struct.new(:subject, :tests, :killers, :slowdown, keyword_init: true)
20
+
21
+ # Tests that killed more earlier mutants first; otherwise keep the order.
22
+ def self.prioritize(tests, killers)
23
+ tests.sort_by.with_index { |id, index| [-killers.fetch(id, 0), index] }
24
+ end
25
+
26
+ def initialize(config:, runner:, map:, reevaluator:, diagnostics:, cache: nil)
27
+ @config = config
28
+ @runner = runner
29
+ @map = map
30
+ @reevaluator = reevaluator
31
+ @diagnostics = diagnostics
32
+ @cache = cache
33
+ end
34
+
35
+ def call(subject, candidates)
36
+ return [] if candidates.empty?
37
+
38
+ reason = subject.skip_reason || load_problem(subject)
39
+ return skip_all(candidates, subject, reason) if reason
40
+
41
+ tests = @map.tests_for(subject)
42
+ return skip_all(candidates, subject, "no coverage") if tests.empty?
43
+
44
+ plan = Plan.new(subject:, tests:, killers: Hash.new(0), slowdown: 1.0)
45
+ reused = all_reused(plan, candidates)
46
+ return reused if reused
47
+
48
+ results = run_candidates(plan, candidates) do |mutation, text|
49
+ reuse(plan, mutation, text) || execute(plan, mutation, text)
50
+ end
51
+ checked(subject, results)
52
+ end
53
+
54
+ # The per-mutant timeout: timeout_factor times what the tests took
55
+ # while the map was built (times the subject's measured slowdown), and
56
+ # never less than timeout_min.
57
+ def timeout_for(tests, slowdown = 1.0)
58
+ recorded = @map.recorded_runtime(tests)
59
+ return @config["baseline_timeout"] unless recorded
60
+
61
+ deadline(recorded * slowdown)
62
+ end
63
+
64
+ private
65
+
66
+ # Verdicts that did not hold up are warned about once per subject;
67
+ # only checked verdicts are stored for --incremental.
68
+ def checked(subject, results)
69
+ run_unchanged = lambda do |tests, timeout|
70
+ @runner.run(subject: subject, text: subject.source, test_ids: tests, timeout: timeout)
71
+ .tap { |outcome| note_leftovers(subject, "verdict check", outcome) }
72
+ end
73
+ check = VerdictCheck.new(run_unchanged: run_unchanged, baseline_timeout: @config["baseline_timeout"])
74
+ final = check.call(results)
75
+ final.reject(&:reused?).each { |result| @cache&.record(result) }
76
+ overturned = final.zip(results).count { |after, before| !after.equal?(before) }
77
+ if overturned.positive?
78
+ @diagnostics.warn("#{subject.id}: #{overturned} verdict(s) failed with the method unchanged")
79
+ end
80
+ final
81
+ end
82
+
83
+ # When every verdict can be reused, nothing forks: not even the neutral
84
+ # check. Returns the results, or nil at the first one that must run.
85
+ def all_reused(plan, candidates)
86
+ return nil unless @cache
87
+
88
+ catch(:must_run) do
89
+ run_candidates(plan, candidates) { |mutation, text| reuse(plan, mutation, text) || throw(:must_run) }
90
+ end
91
+ end
92
+
93
+ def run_candidates(plan, candidates, &run_one)
94
+ coarse, fine = candidates.partition { |mutation, _| mutation.coarse }
95
+ results = coarse.map { |mutation, text| run_one.call(mutation, text) }
96
+ # A surviving coarse mutant means nothing checks the body, so the fine
97
+ # mutants would only add noise. Not so when a rescue or else can
98
+ # return a value of its own: fine mutants that make the body raise
99
+ # can still be caught.
100
+ if results.any?(&:survived?) && !plan.subject.recovers?
101
+ return results + skip_all(fine, plan.subject, "coarse mutant survived", covered_by: plan.tests)
102
+ end
103
+
104
+ results + fine.map { |mutation, text| run_one.call(mutation, text) }
105
+ end
106
+
107
+ # A stored verdict that still holds, as a result; nil when it must run.
108
+ def reuse(plan, mutation, text)
109
+ return nil unless @cache
110
+
111
+ tests = tests_for(plan, mutation)
112
+ return skip_all([[mutation, text]], plan.subject, "no coverage").first if tests.empty?
113
+
114
+ hit = @cache.lookup(plan.subject, mutation, tests)
115
+ return nil unless hit
116
+
117
+ MutantResult.new(subject: plan.subject, mutation: mutation, status: hit[:status], mutated_source: text,
118
+ reason: hit[:reason], runtime: hit[:runtime], killed_by: hit[:killed_by],
119
+ covered_by: tests, kill_cause: hit[:kill_cause], reused: true)
120
+ end
121
+
122
+ def execute(plan, mutation, text)
123
+ tests = tests_for(plan, mutation)
124
+ return skip_all([[mutation, text]], plan.subject, "no coverage").first if tests.empty?
125
+
126
+ timeout = timeout_for(tests, plan.slowdown)
127
+ outcome = @runner.run(subject: plan.subject, text: text, test_ids: tests, timeout: timeout, mutation: mutation)
128
+ outcome = calibrate(plan, mutation, text, tests, timeout) if outcome.status == :timeout
129
+ result = MutantResult.new(subject: plan.subject, mutation: mutation, status: outcome.status,
130
+ mutated_source: text, reason: outcome.message, runtime: outcome.runtime,
131
+ killed_by: outcome.failed_ids, covered_by: tests, kill_cause: outcome.kill_cause)
132
+ outcome.failed_ids.each { |id| plan.killers[id] += 1 }
133
+ note_leftovers(plan.subject, "#{mutation.operator} #{result.digest}", outcome)
134
+ @diagnostics.progress(result)
135
+ result
136
+ end
137
+
138
+ # The tests that ran the edited code (a coarse edit spans the whole
139
+ # body, so it gets every test of the method), prior killers first, then
140
+ # fastest-first.
141
+ def tests_for(plan, mutation)
142
+ line_numbers = mutation.edits.flat_map { |edit| plan.subject.coverage_lines_for(edit) }.uniq
143
+ tests = plan.tests & @map.tests_for_lines(plan.subject.file, line_numbers)
144
+ self.class.prioritize(tests, plan.killers)
145
+ end
146
+
147
+ def deadline(runtime)
148
+ [@config["timeout_factor"] * runtime, @config["timeout_min"]].max
149
+ end
150
+
151
+ # Recorded durations come from one warm process; a fresh child can be
152
+ # slower (lazy loading, first queries). So a timeout is checked at
153
+ # once: the unchanged method runs the same tests with
154
+ # baseline_timeout. If the deadline from that real runtime is longer,
155
+ # the mutant runs again with it, and later mutants of the subject get
156
+ # the measured slowdown. A timeout that remains is the mutant's own.
157
+ def calibrate(plan, mutation, text, tests, timeout)
158
+ unchanged = @runner.run(subject: plan.subject, text: plan.subject.source, test_ids: tests,
159
+ timeout: @config["baseline_timeout"])
160
+ note_leftovers(plan.subject, "timeout check", unchanged)
161
+ return unchanged_failed(unchanged) unless unchanged.status == :survived
162
+
163
+ recorded = @map.recorded_runtime(tests)
164
+ plan.slowdown = [plan.slowdown, unchanged.runtime / recorded].max if recorded&.positive?
165
+ longer = deadline(unchanged.runtime)
166
+ return Execution::Runner::Outcome.new(status: :timeout, runtime: timeout) if longer <= timeout
167
+
168
+ @runner.run(subject: plan.subject, text: text, test_ids: tests, timeout: longer, mutation: mutation)
169
+ end
170
+
171
+ def unchanged_failed(unchanged)
172
+ how = unchanged.status == :timeout ? "time out" : "fail"
173
+ hint = unchanged.status == :timeout ? " (raise baseline_timeout)" : ""
174
+ Execution::Runner::Outcome.new(status: :error, runtime: unchanged.runtime,
175
+ message: "tests also #{how} with the method unchanged#{hint}")
176
+ end
177
+
178
+ # The method saboteur would redefine must be the one Ruby is running.
179
+ def load_problem(subject)
180
+ owner = @reevaluator.resolve_owner(subject)
181
+ target = subject.singleton ? owner.singleton_class : owner
182
+ method = own_method(target, subject.name)
183
+ return "method not loaded" unless method
184
+
185
+ file, line = method.source_location
186
+ return nil if file && Selection::CoverageMap.canonical(file) == subject.file && line == subject.start_line
187
+
188
+ "shadowed definition (runtime method at #{file}:#{line})"
189
+ rescue Error => e
190
+ "owner not loaded: #{e.message}"
191
+ end
192
+
193
+ def own_method(target, name)
194
+ return nil unless target.method_defined?(name) || target.private_method_defined?(name)
195
+
196
+ method = target.instance_method(name)
197
+ method = method.super_method while method && method.owner != target
198
+ method
199
+ end
200
+
201
+ def note_leftovers(subject, what, outcome)
202
+ return if outcome.leftover_pids.empty?
203
+
204
+ @diagnostics.warn("#{subject.id} #{what}: killed #{outcome.leftover_pids.size} process(es) it left running")
205
+ end
206
+
207
+ def skip_all(candidates, subject, reason, covered_by: [])
208
+ candidates.map do |mutation, text|
209
+ MutantResult.new(subject: subject, mutation: mutation, status: :skipped, mutated_source: text,
210
+ reason: reason, covered_by: covered_by)
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,63 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class Engine
6
+ # The subjects to mutate: every def in the included files, narrowed by
7
+ # --subject patterns and by --since (methods with a changed line).
8
+ class SubjectSelection
9
+ # Lines changed since the --since ref, by file; nil without --since.
10
+ attr_reader :changed_lines
11
+
12
+ def initialize(config, diagnostics)
13
+ @config = config
14
+ @diagnostics = diagnostics
15
+ @root = File.realpath(config.root)
16
+ @changed_lines = nil
17
+ end
18
+
19
+ def select(files)
20
+ subjects = find(files)
21
+ subjects = by_pattern(subjects, @config["subjects"]) if @config["subjects"].any?
22
+ subjects = by_change(subjects, @config["since"]) if @config["since"]
23
+ subjects
24
+ end
25
+
26
+ # "Klass#meth", "Klass.meth", or a namespace prefix ("Klass", "A::B").
27
+ def self.match?(id, pattern)
28
+ id == pattern || %w[# . ::].any? { |separator| id.start_with?("#{pattern}#{separator}") }
29
+ end
30
+
31
+ private
32
+
33
+ def find(files)
34
+ finder = SubjectFinder.new
35
+ files.flat_map do |file|
36
+ parsed = finder.call(file)
37
+ parsed.errors.first(1).each do |message|
38
+ @diagnostics.warn("#{file.delete_prefix("#{@root}/")} does not parse: #{message}")
39
+ end
40
+ parsed.subjects
41
+ end
42
+ end
43
+
44
+ def by_pattern(subjects, patterns)
45
+ patterns.each do |pattern|
46
+ next if subjects.any? { |subject| self.class.match?(subject.id, pattern) }
47
+
48
+ @diagnostics.warn("--subject #{pattern} matches no subject (patterns are names like " \
49
+ "Klass, Klass#meth or Klass.meth, not globs)")
50
+ end
51
+ subjects.select { |subject| patterns.any? { |pattern| self.class.match?(subject.id, pattern) } }
52
+ end
53
+
54
+ def by_change(subjects, ref)
55
+ @changed_lines = Selection::GitDiff.changed_lines(ref, root: @root)
56
+ .transform_keys { |file| Selection::CoverageMap.canonical(file) }
57
+ subjects.select do |subject|
58
+ @changed_lines[subject.file]&.any? { |line| subject.contains_line?(line) }
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end