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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6007efb7a83e5669822a1622876457e9e654187acc923981e641835258c7637d
4
+ data.tar.gz: 373680f8e52d7cbd24f1eb3dde2322101eccf565c2752e10a174bd5ff9e6d8e2
5
+ SHA512:
6
+ metadata.gz: c0d37ea099ca68c5dc3608e2229446cb4e4494c623e63bb8033eb85a8a10bd7953e2cc84031678a15adda5da0b0da1f6bb1177badde04fa06dbd472f2d674534
7
+ data.tar.gz: 7484ed59697969071030c78ab81b8a716da93b09d4e95900b69b110f087809a6e1293153375104732457205af0e0cfb94f64eb34810242febd1e63be613fb97a
data/CHANGELOG.md ADDED
@@ -0,0 +1,47 @@
1
+ # Changelog
2
+
3
+ All notable changes are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.2.0] - 2026-10-10
10
+
11
+ ### Added
12
+ - `--seed N` (and `seed:` in `.saboteur.yml`, default 1): every test run seeds
13
+ Ruby's random numbers with it, so runs repeat; the report prints the seed.
14
+
15
+ ### Fixed
16
+ - Linux: killed orphans adopted by the supervisor are reaped reliably, so no
17
+ zombies are left behind (first run on Linux CI).
18
+ - The README status line describes what has actually been tested.
19
+
20
+ ## [0.1.0] - 2026-10-10
21
+
22
+ First release that does mutation testing. MIT licensed.
23
+
24
+ ### Added
25
+ - `saboteur run` with `--since`, `--subject`, `--timeout-factor`, `--jobs`,
26
+ `--reeval`, `--json`, `--no-cache`, `--incremental`, `--config`.
27
+ - Subjects found with Prism; mutations are byte-range edits: whole-method
28
+ `nil`/`true`/`false`/`raise` first, then comparison, boolean, arithmetic,
29
+ numeric literal, statement removal, condition, negation, safe navigation
30
+ and `||=`.
31
+ - Per-example test selection from a per-test coverage map, cached by content
32
+ hash; timeouts scaled from each test's recorded duration.
33
+ - Verdict check: every kill is re-run on the unchanged method, so a test that
34
+ fails anyway cannot count as a kill.
35
+ - Supervised children: whole process tree tracked and killed, deadline,
36
+ memory and process caps, CPU-limit backstop, recursion guard.
37
+ - Parallel workers (`--jobs N`), including Rails apps on Postgres and SQLite
38
+ with a database copy per worker.
39
+ - RSpec adapter and a Rails profile. Minitest is out of scope.
40
+ - Terminal report with unified diffs, Stryker JSON report, ignore list with
41
+ mandatory reasons, incremental result cache.
42
+ - Mutation-source extension point and lifecycle hooks.
43
+
44
+ ## [0.0.1] - unreleased
45
+
46
+ ### Added
47
+ - Gem skeleton to reserve the name. No mutation testing yet.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vamsi Pavan Mahesh Gunturu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # saboteur
2
+
3
+ Mutation testing for Ruby 3.3+.
4
+
5
+ saboteur makes small deliberate changes to your methods (mutants), runs only
6
+ the tests that execute each change, and reports the changes no test notices
7
+ (survivors). Each survivor points at a missing test or at code that does not
8
+ matter.
9
+
10
+ Status: an early release (0.2.0). Tested on the RSpec suites of plain gems
11
+ (dry-inflector, tty-color, hashie, addressable) and of two Rails 8.1 apps:
12
+ human-essentials on Postgres (4,080 mutants, 6 workers) and lobsters on
13
+ SQLite, both on macOS. Not yet tried on MySQL or older Rails versions. RSpec
14
+ only, by design.
15
+
16
+ ## What makes it different
17
+
18
+ - **Tests picked per mutant by line coverage.** Each mutant runs only the
19
+ tests that executed the changed statement. A change no test reaches is
20
+ reported as "no coverage", not as a survivor.
21
+ - **Likeliest killers first.** Tests that killed earlier mutants of the same
22
+ method run first, then the fastest; the first failure stops the run.
23
+ - **Coarse first.** Each method's body is first replaced with `nil` and with
24
+ `raise`. If a test suite does not notice that, saboteur reports it and
25
+ skips the fine-grained mutants for that method.
26
+ - **Diff mode.** `--since origin/main` mutates only methods changed on a branch.
27
+ - **Safe to run.** Every mutant runs in a supervised child; anything it
28
+ starts is tracked and killed, with deadline, memory and process caps.
29
+ - **Open report format.** JSON in the Stryker mutation-testing-report schema.
30
+ - **Pluggable mutation sources.** Add your own mutants by file and byte range.
31
+
32
+ ## Usage
33
+
34
+ ```sh
35
+ bundle exec saboteur run # whole project
36
+ bundle exec saboteur run --since origin/main # methods changed on this branch
37
+ bundle exec saboteur run --subject 'Billing::Invoice#total'
38
+ bundle exec saboteur run --subject Billing # a namespace
39
+ bundle exec saboteur run --incremental # reuse verdicts whose inputs did not change
40
+ bundle exec saboteur run --seed 1234 # repeat a run that used seed 1234
41
+ ```
42
+
43
+ Every test run starts from the same random seed (1 by default), so tests
44
+ that use `rand` see the same values in each mutant's run and in the check
45
+ that confirms a kill. The report prints the seed; pass it to `--seed` to
46
+ repeat a run.
47
+
48
+ `--incremental` reuses an earlier verdict only while the method, the mutant,
49
+ its covering tests and their files, the lockfile and the config are all
50
+ unchanged. It cannot see a change in a method this one calls, so run without
51
+ it now and then (and in CI on the main branch).
52
+
53
+ Output: counts per status, the score, timing, and a unified diff for every
54
+ survivor. The JSON report goes to `.saboteur/report.json`.
55
+
56
+ Exit codes: 0 no survivors (or all on the ignore list), 1 survivors,
57
+ 2 bad configuration, 3 the tool failed.
58
+
59
+ ## Configuration
60
+
61
+ `.saboteur.yml` in the project root; CLI flags override it.
62
+
63
+ ```yaml
64
+ include: [lib/**/*.rb]
65
+ exclude: [lib/my_gem/version.rb]
66
+ require: [] # boot files, e.g. ./config/environment
67
+ reeval: nesting # or class_eval; see docs/design.md 6.1
68
+ timeout_factor: 3 # per-mutant deadline = factor x its tests' recorded time
69
+ timeout_min: 5
70
+ max_memory_mb: 2048 # memory growth cap per mutant process tree
71
+ max_processes: 64
72
+ ignore_file: .saboteur-ignore.yml
73
+ seed: 1 # Ruby's random seed in every test run
74
+ ```
75
+
76
+ ### Ignoring a survivor
77
+
78
+ Every survivor is printed with a digest. Accept it with a reason:
79
+
80
+ ```yaml
81
+ # .saboteur-ignore.yml
82
+ - subject: "Billing::Invoice#total"
83
+ mutation: "1a2b3c4d5e6f7a8b"
84
+ reason: "equivalent: amounts are always whole cents"
85
+ ```
86
+
87
+ An entry without a reason is rejected.
88
+
89
+ ### SimpleCov
90
+
91
+ saboteur needs Ruby's `Coverage` for itself. Disable SimpleCov for the run
92
+ (most projects already guard it with an environment variable).
93
+
94
+ ## Extending
95
+
96
+ Public API, stable across minor versions:
97
+
98
+ - `Saboteur::MutationSource::Base#mutations_for(subject)` returning
99
+ `Saboteur::MutationSource::Mutation` objects made of `Edit`s (file, byte
100
+ range, replacement). Register with `sources: [builtin, path.rb:ClassName]`.
101
+ - `Saboteur::Integration::Base` for other test frameworks (RSpec is the
102
+ only one that ships).
103
+ - `Saboteur.hook(:before_boot | :after_boot | :before_fork | :after_fork |
104
+ :around_mutant)` in files listed under `hooks:`.
105
+
106
+ ## How it works
107
+
108
+ See [docs/design.md](docs/design.md). Validation results are in
109
+ [docs/validation](docs/validation), research in [docs/research](docs/research),
110
+ and the project checklist in [docs/status.md](docs/status.md).
111
+
112
+ ## Benchmark
113
+
114
+ A small, non-exhaustive comparison with [mutineer](https://github.com/davidteren/mutineer)
115
+ 1.6.0 on four RSpec projects (two libraries, two Rails apps). Each configuration
116
+ ran once on sampled files, so treat the numbers as indicative.
117
+
118
+ | Metric | saboteur | mutineer |
119
+ |---|---|---|
120
+ | Fake kills (lobsters) | 0 of 20 checked | 8 of 8 checked |
121
+ | Rails app with 6 workers | yes, same verdicts as 1 worker | 1 worker only |
122
+ | Runs on the Postgres app | yes | no |
123
+ | Time per mutant, hashie | 0.008 s | 0.092 s |
124
+ | Peak memory, hashie | 595 MB | 471 MB |
125
+ | Choice of operators, CI features | fixed set, fewer | 20 operators, baseline, HTML, GitHub Action |
126
+
127
+ Full results, method and limitations:
128
+ [benchmark page](https://vamsipavanmahesh.github.io/saboteur/benchmark/).
129
+ Scripts and raw data: [bench/vs-mutineer](bench/vs-mutineer).
130
+
131
+ ## Requirements
132
+
133
+ Ruby 3.3 or newer on Linux or macOS (it needs `fork`).
134
+
135
+ ## Contributing
136
+
137
+ See [CONTRIBUTING.md](CONTRIBUTING.md). Commits need a DCO `Signed-off-by` line.
138
+
139
+ ## Acknowledgements
140
+
141
+ Thanks to [Glomo](https://glomo.one) for providing the AI tokens used to build saboteur.
142
+
143
+ ## License
144
+
145
+ MIT. Copyright (c) 2026 Vamsi Pavan Mahesh Gunturu. See [LICENSE](LICENSE).
data/exe/saboteur ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+ # SPDX-License-Identifier: MIT
3
+ # frozen_string_literal: true
4
+
5
+ # Line coverage must be set up before any target code loads, so it happens
6
+ # first. It stays suspended (no counting) except while the coverage map is built.
7
+ require "coverage"
8
+ Coverage.setup(lines: true) if Coverage.state == :idle
9
+
10
+ require "saboteur/cli"
11
+
12
+ exit Saboteur::CLI.new(ARGV).call
@@ -0,0 +1,112 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require_relative "../saboteur"
6
+
7
+ module Saboteur
8
+ class CLI
9
+ USAGE = "Usage: saboteur run [--since REF] [--subject 'Klass#meth'] [--timeout-factor N] [--jobs N] [--seed N]"
10
+
11
+ EXIT_OK = 0
12
+ EXIT_SURVIVORS = 1
13
+ EXIT_USAGE = 2
14
+ EXIT_TOOL = 3
15
+
16
+ class UsageError < ConfigError; end
17
+
18
+ attr_reader :options
19
+
20
+ def initialize(argv, out: $stdout, err: $stderr)
21
+ @argv = argv.dup
22
+ @out = out
23
+ @err = err
24
+ @options = {}
25
+ end
26
+
27
+ def call
28
+ case @argv.shift
29
+ when "run" then run
30
+ when "version", "--version" then @out.puts(VERSION) || EXIT_OK
31
+ else usage_error
32
+ end
33
+ rescue OptionParser::ParseError => e
34
+ usage_error(e.message)
35
+ end
36
+
37
+ def config
38
+ @config ||= Config.load(root: Dir.pwd, path: options[:config], overrides: overrides)
39
+ end
40
+
41
+ private
42
+
43
+ def usage_error(message = nil)
44
+ @err.puts(message) if message
45
+ @err.puts(USAGE)
46
+ EXIT_USAGE
47
+ end
48
+
49
+ def run
50
+ parser.parse!(@argv)
51
+ raise UsageError, "unexpected arguments: #{@argv.join(' ')}" if @argv.any?
52
+
53
+ report = Engine.new(config, err: @err).call
54
+ @err.puts
55
+ Reporter::Terminal.new(@out).call(report)
56
+ write_json(report)
57
+ exit_code(report)
58
+ rescue ConfigError => e
59
+ @err.puts("saboteur: #{e.message}")
60
+ EXIT_USAGE
61
+ rescue Error => e
62
+ @err.puts("saboteur: #{e.message}")
63
+ EXIT_TOOL
64
+ end
65
+
66
+ def write_json(report)
67
+ path = config.path("json_report")
68
+ return unless path
69
+
70
+ Reporter::Stryker.new(path).call(report)
71
+ @out.puts "\nJSON report: #{path}"
72
+ end
73
+
74
+ def exit_code(report)
75
+ executed = report.executed
76
+ return EXIT_TOOL if executed.any? && executed.all? { |result| result.status == :error }
77
+
78
+ report.unignored_survivors.any? ? EXIT_SURVIVORS : EXIT_OK
79
+ end
80
+
81
+ def overrides
82
+ options.except(:config)
83
+ end
84
+
85
+ def parser
86
+ OptionParser.new do |opts|
87
+ opts.banner = USAGE
88
+ opts.on("--since REF", "Only mutate subjects changed since git REF") { |v| options[:since] = v }
89
+ opts.on("--subject EXPR", "Klass#meth, Klass.meth or a namespace; repeatable") { |v| add_subject(v) }
90
+ opts.on("--timeout-factor N", Float, "Multiple of baseline runtime (default 3)") { |v| set(:timeout_factor, v) }
91
+ opts.on("--jobs N", Integer, "Concurrent workers (default 1)") { |v| set(:jobs, v) }
92
+ opts.on("--seed N", Integer, "Seed for Ruby's random numbers in every test run (default 1)") do |v|
93
+ set(:seed, v)
94
+ end
95
+ opts.on("--config PATH", "Config file (default .saboteur.yml)") { |v| set(:config, v) }
96
+ opts.on("--integration NAME", "Test adapter (rspec)") { |v| set(:integration, v) }
97
+ opts.on("--reeval STRATEGY", Execution::Reevaluator::STRATEGIES, "class_eval or nesting") { |v| set(:reeval, v) }
98
+ opts.on("--json PATH", "Stryker JSON report path") { |v| set(:json_report, v) }
99
+ opts.on("--no-cache", "Rebuild the coverage map") { set(:cache, false) }
100
+ opts.on("--incremental", "Reuse earlier verdicts whose inputs did not change") { set(:incremental, true) }
101
+ end
102
+ end
103
+
104
+ def add_subject(expr)
105
+ (options[:subjects] ||= []) << expr
106
+ end
107
+
108
+ def set(key, value)
109
+ options[key] = value
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,133 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+ require_relative "mutation_source/builtin"
6
+
7
+ module Saboteur
8
+ # Settings from .saboteur.yml, overridden by CLI flags.
9
+ class Config
10
+ FILE = ".saboteur.yml"
11
+
12
+ DEFAULTS = {
13
+ "integration" => "rspec",
14
+ "require" => [],
15
+ "include" => ["lib/**/*.rb"],
16
+ "exclude" => [],
17
+ "arid" => MutationSource::Builtin::DEFAULT_ARID,
18
+ "timeout_factor" => 3.0,
19
+ "timeout_min" => 5.0,
20
+ "baseline_timeout" => 300.0,
21
+ "coverage_timeout" => 3600.0,
22
+ "max_memory_mb" => 2048,
23
+ "max_processes" => 64,
24
+ "jobs" => 1,
25
+ "ignore_file" => ".saboteur-ignore.yml",
26
+ "sources" => ["builtin"],
27
+ "hooks" => [],
28
+ "rails" => false,
29
+ "reeval" => "nesting",
30
+ "cache_dir" => ".saboteur",
31
+ "json_report" => ".saboteur/report.json",
32
+ "rspec_args" => [],
33
+ "subjects" => [],
34
+ "since" => nil,
35
+ "cache" => true,
36
+ "incremental" => false,
37
+ "seed" => 1
38
+ }.freeze
39
+
40
+ INTEGER_KEYS = %w[jobs max_memory_mb max_processes seed].freeze
41
+ FLOAT_KEYS = %w[timeout_factor timeout_min baseline_timeout coverage_timeout].freeze
42
+ LIST_KEYS = %w[require include exclude arid sources hooks rspec_args subjects].freeze
43
+
44
+ attr_reader :root
45
+
46
+ def self.load(root: Dir.pwd, path: nil, overrides: {})
47
+ path ||= File.join(root, FILE)
48
+ from_file = File.file?(path) ? parse(path) : {}
49
+ new(from_file.merge(stringify(overrides)), root: root)
50
+ end
51
+
52
+ def self.parse(path)
53
+ data = YAML.safe_load_file(path) || {}
54
+ raise ConfigError, "#{path}: expected a mapping at the top level" unless data.is_a?(Hash)
55
+
56
+ data
57
+ rescue Psych::SyntaxError => e
58
+ raise ConfigError, "#{path}: #{e.message}"
59
+ end
60
+
61
+ def self.stringify(hash)
62
+ hash.to_h { |key, value| [key.to_s, value] }.compact
63
+ end
64
+
65
+ def initialize(values = {}, root: Dir.pwd)
66
+ @root = File.expand_path(root)
67
+ unknown = values.keys - DEFAULTS.keys
68
+ raise ConfigError, "unknown config key(s): #{unknown.join(', ')}" if unknown.any?
69
+
70
+ @values = DEFAULTS.merge(values)
71
+ coerce!
72
+ validate!
73
+ end
74
+
75
+ def [](key)
76
+ @values.fetch(key.to_s)
77
+ end
78
+
79
+ def fetch(key, default = nil)
80
+ @values.fetch(key.to_s, default)
81
+ end
82
+
83
+ def to_h
84
+ @values.dup
85
+ end
86
+
87
+ # Ruby files to mutate, as absolute real paths.
88
+ def included_files
89
+ matched = self["include"].flat_map { |pattern| Dir.glob(pattern, base: root) }
90
+ excluded = self["exclude"].flat_map { |pattern| Dir.glob(pattern, base: root) }
91
+ (matched - excluded).select { |file| file.end_with?(".rb") }
92
+ .map { |file| File.realpath(File.join(root, file)) }.uniq.sort
93
+ end
94
+
95
+ # Patterns that match no file. A pattern goes stale when files move;
96
+ # an exclude that silently stops excluding is a hazard, so say so.
97
+ def unmatched_patterns
98
+ %w[include exclude].flat_map do |key|
99
+ self[key].select { |pattern| Dir.glob(pattern, base: root).empty? }.map { |pattern| "#{key}: #{pattern}" }
100
+ end
101
+ end
102
+
103
+ def path(key)
104
+ value = self[key]
105
+ value && File.expand_path(value, root)
106
+ end
107
+
108
+ private
109
+
110
+ def coerce!
111
+ INTEGER_KEYS.each { |key| @values[key] = Integer(@values[key]) }
112
+ FLOAT_KEYS.each { |key| @values[key] = Float(@values[key]) }
113
+ LIST_KEYS.each { |key| @values[key] = Array(@values[key]).map(&:to_s) }
114
+ rescue ArgumentError, TypeError => e
115
+ raise ConfigError, "invalid config value: #{e.message}"
116
+ end
117
+
118
+ def validate!
119
+ raise ConfigError, "jobs must be >= 1" if self["jobs"] < 1
120
+ raise ConfigError, "seed must be >= 0" if self["seed"].negative?
121
+ raise ConfigError, "max_memory_mb must be > 0" unless self["max_memory_mb"].positive?
122
+ raise ConfigError, "max_processes must be > 0" unless self["max_processes"].positive?
123
+ raise ConfigError, "timeout_factor must be > 0" unless self["timeout_factor"].positive?
124
+ raise ConfigError, "timeout_min must be > 0" unless self["timeout_min"].positive?
125
+ raise ConfigError, "reeval must be one of: #{Execution::Reevaluator::STRATEGIES.join(', ')}" unless
126
+ Execution::Reevaluator::STRATEGIES.include?(self["reeval"].to_s)
127
+
128
+ self["arid"].each { |pattern| Regexp.new(pattern) }
129
+ rescue RegexpError => e
130
+ raise ConfigError, "invalid arid pattern: #{e.message}"
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,83 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class Engine
6
+ # Loads the target once in the parent: hook files, the optional Rails
7
+ # profile, configured boot files, then the test adapter (which loads the
8
+ # test files without running any test). Returns the ready adapter.
9
+ class Boot
10
+ # A test suite that (perhaps only under a mutant) starts a saboteur run
11
+ # would otherwise run saboteur inside a saboteur child, recursively.
12
+ def self.refuse_recursion!
13
+ return unless ENV[Execution::Supervisor::CHILD_ENV]
14
+
15
+ raise Error, "refusing to start: this process is a saboteur child " \
16
+ "(#{Execution::Supervisor::CHILD_ENV} is set). A test is running saboteur itself; stub it."
17
+ end
18
+
19
+ # Prepended to SimpleCov's singleton: saboteur needs Ruby's Coverage for
20
+ # its own map, and Coverage can only be set up once per process.
21
+ module SimpleCovOff
22
+ def start(*, **)
23
+ nil
24
+ end
25
+ end
26
+
27
+ # Watches for the app's SimpleCov and turns its start into a no-op the
28
+ # moment the constant is defined, before any line of the app's
29
+ # SimpleCov setup runs. saboteur does not load SimpleCov itself:
30
+ # loading it runs the app's .simplecov at once.
31
+ module SimpleCovWatch
32
+ def const_added(name)
33
+ super
34
+ Boot.silence_simplecov if name == :SimpleCov && equal?(Object)
35
+ end
36
+ end
37
+
38
+ def self.watch_for_simplecov
39
+ Object.singleton_class.prepend(SimpleCovWatch) unless Object.singleton_class.include?(SimpleCovWatch)
40
+ silence_simplecov if defined?(::SimpleCov)
41
+ end
42
+
43
+ def self.silence_simplecov
44
+ return if ::SimpleCov.singleton_class.include?(SimpleCovOff)
45
+
46
+ ::SimpleCov.singleton_class.prepend(SimpleCovOff)
47
+ warn "saboteur: SimpleCov.start ignored for this run (saboteur needs Ruby's Coverage itself)"
48
+ end
49
+
50
+ # The Rails profile when rails: true, else nil (set by load_target).
51
+ attr_reader :rails
52
+
53
+ def initialize(config, hooks)
54
+ @config = config
55
+ @hooks = hooks
56
+ @root = File.realpath(config.root)
57
+ end
58
+
59
+ def load_target
60
+ self.class.watch_for_simplecov
61
+ load_hooks
62
+ @rails = rails = Frameworks::Rails.new(@config, @hooks) if @config["rails"]
63
+ rails&.before_boot
64
+ @hooks.run(:before_boot)
65
+ @config["require"].each { |file| require File.expand_path(file, @root) }
66
+ integration = Integration.fetch(@config["integration"]).new(@config)
67
+ integration.setup
68
+ rails&.after_boot
69
+ @hooks.run(:after_boot)
70
+ integration
71
+ end
72
+
73
+ private
74
+
75
+ def load_hooks
76
+ Saboteur.current_hooks = @hooks
77
+ @config["hooks"].each { |file| load File.expand_path(file, @root) }
78
+ ensure
79
+ Saboteur.current_hooks = nil
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,63 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "prism"
5
+
6
+ module Saboteur
7
+ class Engine
8
+ # Builds the mutation sources from config and turns their proposals for
9
+ # a subject into runnable candidates: applied, re-parsed, de-duplicated,
10
+ # coarse first. Returns [[mutation, mutated_text]].
11
+ class Candidates
12
+ def self.build_sources(config)
13
+ root = File.realpath(config.root)
14
+ config["sources"].map do |entry|
15
+ next MutationSource::Builtin.new(arid: config["arid"]) if entry == "builtin"
16
+
17
+ file, const = entry.split(":", 2)
18
+ raise ConfigError, "mutation source #{entry.inspect} must be builtin or path.rb:ClassName" unless const
19
+
20
+ require File.expand_path(file, root)
21
+ Object.const_get(const).new
22
+ end
23
+ end
24
+
25
+ def initialize(sources, diagnostics)
26
+ @sources = sources
27
+ @diagnostics = diagnostics
28
+ end
29
+
30
+ def for(subject)
31
+ return [] unless parses?(subject.source)
32
+
33
+ seen = Set[subject.source]
34
+ list = @sources.flat_map { |source| source.mutations_for(subject) }.filter_map do |mutation|
35
+ candidate(subject, mutation, seen)
36
+ end
37
+ list.sort_by.with_index { |(mutation, _), index| [mutation.coarse ? 0 : 1, index] }
38
+ end
39
+
40
+ private
41
+
42
+ def candidate(subject, mutation, seen)
43
+ text = subject.apply(mutation.edits)
44
+ return if seen.include?(text)
45
+
46
+ seen << text
47
+ unless parses?(text)
48
+ @diagnostics.count_invalid_mutation
49
+ return
50
+ end
51
+
52
+ [mutation, text]
53
+ rescue ArgumentError => e
54
+ @diagnostics.warn("#{subject.id}: dropped #{mutation.source} mutation: #{e.message}")
55
+ nil
56
+ end
57
+
58
+ def parses?(text)
59
+ Prism.parse(text).success?
60
+ end
61
+ end
62
+ end
63
+ end