mutato 0.1.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 (70) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +13 -0
  3. data/LICENSE +21 -0
  4. data/README.md +218 -0
  5. data/exe/mutato +21 -0
  6. data/lib/mutato/abort.rb +12 -0
  7. data/lib/mutato/annotations.rb +65 -0
  8. data/lib/mutato/arid.rb +104 -0
  9. data/lib/mutato/baseline.rb +51 -0
  10. data/lib/mutato/boot.rb +99 -0
  11. data/lib/mutato/campaign.rb +52 -0
  12. data/lib/mutato/change.rb +49 -0
  13. data/lib/mutato/child.rb +135 -0
  14. data/lib/mutato/cli.rb +138 -0
  15. data/lib/mutato/clock.rb +21 -0
  16. data/lib/mutato/config.rb +39 -0
  17. data/lib/mutato/console.rb +33 -0
  18. data/lib/mutato/coverage_listener/tally.rb +26 -0
  19. data/lib/mutato/coverage_listener.rb +46 -0
  20. data/lib/mutato/covering.rb +45 -0
  21. data/lib/mutato/diff.rb +42 -0
  22. data/lib/mutato/emitter.rb +46 -0
  23. data/lib/mutato/equivalence.rb +30 -0
  24. data/lib/mutato/experience.rb +11 -0
  25. data/lib/mutato/flags.rb +58 -0
  26. data/lib/mutato/generation.rb +36 -0
  27. data/lib/mutato/generator.rb +10 -0
  28. data/lib/mutato/genres/conditions.rb +70 -0
  29. data/lib/mutato/genres/elements.rb +49 -0
  30. data/lib/mutato/genres/genre.rb +23 -0
  31. data/lib/mutato/genres/operators.rb +81 -0
  32. data/lib/mutato/genres/statements.rb +122 -0
  33. data/lib/mutato/genres/swaps.rb +58 -0
  34. data/lib/mutato/genres/values.rb +41 -0
  35. data/lib/mutato/genres.rb +53 -0
  36. data/lib/mutato/group_hooks.rb +45 -0
  37. data/lib/mutato/hits.rb +18 -0
  38. data/lib/mutato/installer/header.rb +40 -0
  39. data/lib/mutato/installer.rb +133 -0
  40. data/lib/mutato/measure.rb +37 -0
  41. data/lib/mutato/mode/control.rb +43 -0
  42. data/lib/mutato/mode/run.rb +33 -0
  43. data/lib/mutato/mode.rb +26 -0
  44. data/lib/mutato/mutation.rb +85 -0
  45. data/lib/mutato/nodes.rb +150 -0
  46. data/lib/mutato/options.rb +33 -0
  47. data/lib/mutato/output.rb +76 -0
  48. data/lib/mutato/privates.rb +46 -0
  49. data/lib/mutato/report.rb +36 -0
  50. data/lib/mutato/rspec_adapter/progress.rb +20 -0
  51. data/lib/mutato/rspec_adapter/result.rb +15 -0
  52. data/lib/mutato/rspec_adapter.rb +129 -0
  53. data/lib/mutato/run.rb +44 -0
  54. data/lib/mutato/selection.rb +100 -0
  55. data/lib/mutato/session.rb +53 -0
  56. data/lib/mutato/skips.rb +54 -0
  57. data/lib/mutato/soft_timeout.rb +8 -0
  58. data/lib/mutato/subject.rb +21 -0
  59. data/lib/mutato/subjects/scope.rb +44 -0
  60. data/lib/mutato/subjects.rb +67 -0
  61. data/lib/mutato/suite.rb +6 -0
  62. data/lib/mutato/trial.rb +93 -0
  63. data/lib/mutato/usage.rb +41 -0
  64. data/lib/mutato/version.rb +6 -0
  65. data/lib/mutato/visitor/cursor.rb +28 -0
  66. data/lib/mutato/visitor/walk.rb +17 -0
  67. data/lib/mutato/visitor.rb +88 -0
  68. data/lib/mutato/watchdog.rb +54 -0
  69. data/lib/mutato.rb +55 -0
  70. metadata +128 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2aafb54276c4ae2139a14b9422b16ffad91c363f501aa079f1a6579e74031509
4
+ data.tar.gz: 30bf198b8033421c66222b25653c8645d6a84fc6bc46cc02f7fb4929111af55e
5
+ SHA512:
6
+ metadata.gz: d01b47493ef62b0df820899c0f92de46d4f93eaee537dcdb232d21db995954b985e98099a3437bc2a0ea45387f7f126c88768a19dd60ff64392c0444e4e47f6a
7
+ data.tar.gz: fb3270a1f902dc9752670d5ecfe6a04c4879186ea0466c9e03cfdcdf2f767baeae4d85a7dcc72a97dee276baa78455083771618e6e5b3b5169b02c7a83b38dba
data/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ Notable changes for users of mutato. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-10-03
10
+
11
+ ### Added
12
+
13
+ - First release: mutation testing for RSpec, on Ruby 3.2 and later.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mathias Løkke Madsen
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,218 @@
1
+ <p align="center"><img src=".github/mutato.svg" width="150" alt=""></p>
2
+
3
+ # mutato
4
+
5
+ [![CI](https://github.com/LQkkeN/mutato/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/LQkkeN/mutato/actions/workflows/ci.yml)
6
+
7
+ Mutation testing for Ruby and RSpec. mutato changes your code one small step
8
+ at a time and runs the tests that cover each change. A change that no test
9
+ notices points at a missing test.
10
+
11
+ ## Install
12
+
13
+ ```ruby
14
+ # Gemfile
15
+ group :test do
16
+ gem 'mutato'
17
+ end
18
+ ```
19
+
20
+ Ruby 3.2 or later.
21
+
22
+ ## Find what your tests miss
23
+
24
+ Take this class, with specs for VAT on 100 and for a discount of 20:
25
+
26
+ ```ruby
27
+ # lib/shop/price.rb
28
+ module Shop
29
+ class Price
30
+ def initialize(amount, vat: 0.25)
31
+ @amount = amount
32
+ @vat = vat
33
+ end
34
+
35
+ def total(discount: 0)
36
+ net = [@amount - discount, 0].max
37
+ (net * (1 + @vat)).round(2)
38
+ end
39
+ end
40
+ end
41
+ ```
42
+
43
+ ```
44
+ $ bundle exec mutato run lib
45
+ 14 mutants over 1 files, 2 methods
46
+ ...
47
+ 7/14 caught 0.02s da2574057ce7 lib/shop/price.rb:9:14: [element] drop `@amount - discount`
48
+ 8/14 missed 0.01s 4c1d0b45c1fc lib/shop/price.rb:9:34: [element] drop `0`
49
+ ...
50
+ results: caught 11, missed 3
51
+
52
+ MISSED:
53
+ 4c1d0b45c1fc lib/shop/price.rb:9:34: [element] drop `0`
54
+ 5315afcd609d lib/shop/price.rb:10:26: [swap-method] replace `.round` with `.floor`
55
+ efc60b287f00 lib/shop/price.rb:10:26: [swap-method] replace `.round` with `.ceil`
56
+ ```
57
+
58
+ Each missed mutant is a change no test noticed: no spec gives a discount
59
+ larger than the amount, and none checks rounding up and down. Add those two
60
+ specs, then rerun just these mutants by id:
61
+
62
+ ```
63
+ $ bundle exec mutato run lib --only 4c1d0b45c1fc,5315afcd609d,efc60b287f00
64
+ ...
65
+ 1/3 caught 0.03s 4c1d0b45c1fc lib/shop/price.rb:9:34: [element] drop `0`
66
+ 2/3 caught 0.03s 5315afcd609d lib/shop/price.rb:10:26: [swap-method] replace `.round` with `.floor`
67
+ 3/3 caught 0.02s efc60b287f00 lib/shop/price.rb:10:26: [swap-method] replace `.round` with `.ceil`
68
+ results: caught 3
69
+ ```
70
+
71
+ The exit code is 2 while anything is missed. An id stays the same while its
72
+ method is unchanged and the path is written the same way (`lib` and `./lib`
73
+ differ).
74
+
75
+ ## See what would be tried
76
+
77
+ ```
78
+ $ bundle exec mutato list lib
79
+ 27f4e8ead8ca lib/shop/price.rb:4:7: [value] replace body of Shop::Price#initialize with nil (Shop::Price#initialize)
80
+ f7bfadab08e0 lib/shop/price.rb:4:7: [statement] delete statement `@amount = amount` (Shop::Price#initialize)
81
+ ...
82
+ 14 mutants over 1 files, 2 methods
83
+ ```
84
+
85
+ ## Only what a branch changed
86
+
87
+ ```sh
88
+ git diff -U0 origin/main | bundle exec mutato run lib --diff -
89
+ hg diff -U0 -r default | bundle exec mutato run lib --diff -
90
+ jj diff --git | bundle exec mutato run lib --diff -
91
+ bundle exec mutato run lib --diff changes.diff
92
+ ```
93
+
94
+ Any unified diff works; mutato never calls a version control system itself.
95
+ It reads the diff's paths from the current directory, so from a subdirectory
96
+ of the repository use `git diff --relative`.
97
+
98
+ ## On a GitHub pull request
99
+
100
+ ```yaml
101
+ - uses: actions/checkout@v7
102
+ with:
103
+ fetch-depth: 0 # git diff needs the base commit
104
+ - run: git diff -U0 ${{ github.event.pull_request.base.sha }} |
105
+ bundle exec mutato run lib --diff - --format github
106
+ shell: bash # pipefail: a failing git diff fails the step
107
+ ```
108
+
109
+ Survivors show as warnings on the changed lines, timeouts as notices, and a
110
+ table of survivors goes into the step summary. Elsewhere, `--format plain`
111
+ prints lines that editors and CI systems parse:
112
+
113
+ ```
114
+ lib/shop/price.rb:9:34: survived: drop `0`
115
+ ```
116
+
117
+ ## Choose the specs
118
+
119
+ ```sh
120
+ bundle exec mutato run lib --spec spec/unit # default: spec
121
+ bundle exec mutato run app lib --spec 'spec --tag ~slow' # any rspec arguments
122
+ bundle exec mutato run lib --spec "test --pattern '**/*_test.rb'" # files not named _spec.rb
123
+ ```
124
+
125
+ ## Start small on a big codebase
126
+
127
+ ```sh
128
+ bundle exec mutato run lib/billing # one directory or file
129
+ bundle exec mutato run lib --sample 50 # 50 at random, the same 50 every time
130
+ bundle exec mutato run lib --limit 20 # the first 20 in file order
131
+ bundle exec mutato run lib --genre arithmetic,relational # only some kinds of change
132
+ ```
133
+
134
+ ## Check the setup
135
+
136
+ ```
137
+ $ bundle exec mutato control lib --sample 40
138
+ ...
139
+ control OK: 2 of 2 reinstalled methods passed their tests
140
+ ```
141
+
142
+ `control` puts methods back unchanged and runs their tests. All must pass: a
143
+ method whose tests fail even then gives meaningless results in a real run.
144
+
145
+ ## Project hooks
146
+
147
+ `.mutato.rb` in the project root, or `--config FILE`:
148
+
149
+ ```ruby
150
+ # Tables cleaned in after hooks: a killed child leaves rows behind.
151
+ Mutato.before_mutant { truncate_tables }
152
+
153
+ # Rails loads lazily: load everything once, or untested files come out unloaded.
154
+ Mutato.after_boot { Rails.application.eager_load! }
155
+
156
+ # A socket must not be shared with the forked children.
157
+ Mutato.before_fork { SomeClient.disconnect }
158
+ Mutato.after_fork { SomeClient.connect }
159
+
160
+ # Accept a known survivor, with the reason on record; `mutato list` shows it.
161
+ Mutato.skip 'Accounts#obtain', 'lock re-check for a race no test reproduces'
162
+
163
+ # The project's own output calls, never worth mutating.
164
+ Mutato.arid :say, 'UI'
165
+ ```
166
+
167
+ Sequel and ActiveRecord connections are closed before every fork already.
168
+ Other options: `--out DIR` (default `mutato.out`), `--timeout-min SECONDS`
169
+ (default 10), `--version`, `--help`.
170
+
171
+ ## Reading the results
172
+
173
+ `mutato.out/missed.txt` is the list to act on. Every other outcome has a list
174
+ too, `outcomes.json` holds all of them, and `logs/` has one log per mutant
175
+ with whatever the tests printed, capped at 1 MB.
176
+
177
+ | outcome | meaning |
178
+ |---|---|
179
+ | caught | a test failed, and passes without the mutant |
180
+ | missed | no test failed: a test is missing |
181
+ | timeout | the tests ran past five times their usual time, often an infinite loop |
182
+ | unjudged | only flaky tests cover it |
183
+ | equivalent | provably no change: a removed `super` into an `initialize` that does nothing |
184
+ | uncovered | no test runs the line |
185
+ | load-time | the code runs only while the suite loads |
186
+ | unloaded | the suite never loads the file |
187
+ | overwritten | the tests redefined the method, as a code reloader does |
188
+ | unviable | the mutant did not load |
189
+
190
+ Exit codes: 2 when a mutant is missed or unjudged, 3 when there is nothing to
191
+ mutate (0 with `--diff`), 1 on errors, otherwise 0. `control` exits 1 when a
192
+ method fails and 3 when no test ran any.
193
+
194
+ ## What it changes
195
+
196
+ | genre | for example |
197
+ |---|---|
198
+ | statement | delete `@vat = vat` |
199
+ | condition | `if paid?` -> `if true`, `a && b` -> `a \|\| b`, `!x` -> `x` |
200
+ | relational | `a < b` -> `a <= b`, `a != b`, `false` |
201
+ | arithmetic | `a + b` -> `a - b`, `x += 1` -> `x -= 1`, `a - b` -> `b - a` |
202
+ | swap-method | `.round` -> `.floor`, `.max` -> `.min`, `.any?` -> `.all?` |
203
+ | value | a method's body -> `nil`, `[]`, `0`, `true` or `false` |
204
+ | element | `[net, 0]` -> `[net]` |
205
+
206
+ It leaves alone what no test could tell apart or should check: logging and
207
+ output, variables nothing reads, `return @x if @x`, and code outside methods.
208
+ `mutato list` names each method it skips, with the reason.
209
+
210
+ ## Limits
211
+
212
+ RSpec only, and one worker: a run takes as long as the tests it runs. An
213
+ endless `def x = ...` comes out uncovered, and native extensions are
214
+ invisible. A mutant can reach code the tests never did, such as the network.
215
+
216
+ ## Licence
217
+
218
+ MIT.
data/exe/mutato ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Before mutato's own files load, so that it can run on itself.
5
+ require "coverage"
6
+ Coverage.start(lines: true) unless Coverage.running?
7
+ require "bundler/setup" if File.exist?("Gemfile")
8
+ begin
9
+ require_relative "../lib/mutato"
10
+ rescue LoadError => error
11
+ hint = "Add mutato to the project's Gemfile; its dependency brings the prism it needs."
12
+ abort "mutato: #{error.message}. #{hint}"
13
+ end
14
+ if Gem::Version.new(Prism::VERSION) < Gem::Version.new("1.8")
15
+ abort "mutato: prism #{Prism::VERSION} is too old, 1.8 is needed"
16
+ end
17
+ require_relative "../lib/mutato/cli"
18
+
19
+ code = Mutato::CLI.call(ARGV)
20
+ # exit!, not exit: the project's at_exit handlers must not run.
21
+ exit! code
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ class Abort < StandardError
5
+ attr_reader :code
6
+
7
+ def initialize(message, code:)
8
+ super(message)
9
+ @code = code
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ class Annotations
5
+ FORMATS = { "github" => :github, "plain" => :plain }.freeze
6
+ SUMMARY = <<~MARKDOWN
7
+ ## mutato: %<count>d survivors
8
+
9
+ | where | mutant |
10
+ |---|---|
11
+ %<rows>s
12
+ MARKDOWN
13
+ private_constant :FORMATS, :SUMMARY
14
+
15
+ def self.command(level, verdict, outcome)
16
+ file, line, col, description = outcome.fetch_values(:file, :line, :col, :description)
17
+ "::#{level} file=#{file},line=#{line},col=#{col},title=mutato::#{verdict}: #{description}"
18
+ end
19
+
20
+ def self.line(outcome)
21
+ file, line, col, description = outcome.fetch_values(:file, :line, :col, :description)
22
+ "#{file}:#{line}:#{col}: survived: #{description}"
23
+ end
24
+
25
+ def self.step_summary(missed)
26
+ path = ENV.fetch("GITHUB_STEP_SUMMARY", nil)
27
+ return unless path && missed.any?
28
+
29
+ rows = missed.map { |outcome| row(*outcome.fetch_values(:file, :line, :description)) }
30
+ File.write(path, format(SUMMARY, count: missed.size, rows: rows.join("\n")), mode: "a")
31
+ end
32
+
33
+ # GitHub splits table cells on every pipe, inside backticks too, unless escaped.
34
+ def self.row(file, line, description)
35
+ "| `#{file}:#{line}` | #{description.gsub("|", "\\|")} |"
36
+ end
37
+
38
+ def initialize(console)
39
+ @console = console
40
+ end
41
+
42
+ def show(outcomes, format)
43
+ style = FORMATS[format]
44
+ public_send(style, outcomes) if style
45
+ end
46
+
47
+ def github(outcomes)
48
+ missed = Mode.missed(outcomes)
49
+ missed.each { |outcome| @console.out(Annotations.command("warning", "survived", outcome)) }
50
+ notices(outcomes)
51
+ Annotations.step_summary(missed)
52
+ end
53
+
54
+ def plain(outcomes)
55
+ Mode.missed(outcomes).each { |outcome| @console.out(Annotations.line(outcome)) }
56
+ end
57
+
58
+ private
59
+
60
+ def notices(outcomes)
61
+ timeouts = outcomes.select { |outcome| outcome.fetch(:outcome) == :timeout }
62
+ timeouts.each { |outcome| @console.out(Annotations.command("notice", "timeout", outcome)) }
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Mutato
6
+ # Code no test asserts on (output, logging): mutating it only makes survivors.
7
+ module Arid
8
+ METHODS = %i[puts warn p pp print require require_relative].freeze
9
+ LOGGING = /\Alog(?:ger)?\z/i
10
+ # `log_error(e)`, `logger_info`: a level after the underscore, so `log_in` is not one.
11
+ HELPER = /\Alog(?:ger)?(?:_(?:debug|info|warn|warning|error|fatal|exception|event|message))?\z/i
12
+ VARIABLES = [
13
+ Prism::InstanceVariableReadNode,
14
+ Prism::ClassVariableReadNode,
15
+ Prism::GlobalVariableReadNode,
16
+ Prism::LocalVariableReadNode
17
+ ].freeze
18
+ ITERATORS = %i[
19
+ each
20
+ each_with_index
21
+ each_with_object
22
+ each_pair
23
+ each_key
24
+ each_value
25
+ reverse_each
26
+ times
27
+ upto
28
+ downto
29
+ step
30
+ ].freeze
31
+ private_constant :METHODS, :LOGGING, :HELPER, :VARIABLES, :ITERATORS
32
+
33
+ module_function
34
+
35
+ def helper_name?(name)
36
+ name.to_s.match?(HELPER)
37
+ end
38
+
39
+ # A compound of arid parts is arid, and so is one with none: `if x; end`.
40
+ def arid_node?(node)
41
+ case node
42
+ when Prism::CallNode then arid_with_block?(node) || arid_loop?(node)
43
+ when Prism::IfNode, Prism::UnlessNode then all_arid?(branches_of(node))
44
+ when Prism::StatementsNode, Prism::ElseNode then all_arid?(node.compact_child_nodes)
45
+ else false
46
+ end
47
+ end
48
+
49
+ def all_arid?(parts)
50
+ parts.all? { |part| arid_node?(part) }
51
+ end
52
+
53
+ # A logging call with a block is arid only if its block is: `logger.tagged { work }` works.
54
+ def arid_with_block?(call)
55
+ block = call.block
56
+ body = block.body if block.is_a?(Prism::BlockNode)
57
+ arid_call?(call) && all_arid?([body].compact)
58
+ end
59
+
60
+ def arid_loop?(call)
61
+ block = call.block
62
+ ITERATORS.include?(call.name) && block.is_a?(Prism::BlockNode) && arid_node?(block.body)
63
+ end
64
+
65
+ def branches_of(conditional)
66
+ conditional.compact_child_nodes - [conditional.predicate]
67
+ end
68
+
69
+ def arid_call?(call)
70
+ name = call.name
71
+ receiver = call.receiver
72
+ return true if METHODS.include?(name) && console?(receiver)
73
+
74
+ Mutato.config.arid?(name) || (receiver.nil? && helper_name?(name)) || logging?(receiver)
75
+ end
76
+
77
+ # `puts` to the console, not to an IO the code was handed.
78
+ def console?(receiver)
79
+ receiver.nil? || receiver.slice.match?(/\A(?:\$std(?:out|err)|STD(?:OUT|ERR))\z/)
80
+ end
81
+
82
+ def logging?(receiver)
83
+ case receiver
84
+ when Prism::CallNode then logging_call?(receiver)
85
+ when Prism::ConstantReadNode, Prism::ConstantPathNode then logging_constant?(receiver.slice)
86
+ when *VARIABLES then receiver.name.to_s.sub(/\A(?:@@?|\$)/, "").match?(LOGGING)
87
+ else false
88
+ end
89
+ end
90
+
91
+ # `App.logger`, a reader without arguments (not `Math.log(x)`), or `opts[:logger]`.
92
+ def logging_call?(call)
93
+ arguments = Nodes.arguments_of(call)
94
+ name = call.name
95
+ return name.to_s.match?(LOGGING) && arguments.empty? unless name == :[]
96
+
97
+ (arguments in [key]) && key.slice.match?(/\A[:"']?log(?:ger)?["']?\z/i)
98
+ end
99
+
100
+ def logging_constant?(name)
101
+ name.split("::").last.match?(LOGGING) || Mutato.config.arid?(name)
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ # hooks: the seconds the suite and group hooks took, which every run repeats.
5
+ Baseline = Data.define(
6
+ :line_map,
7
+ :durations,
8
+ :order,
9
+ :hooks,
10
+ :locations,
11
+ :boot_lines,
12
+ :loaded_files
13
+ ) do
14
+ # `lib/calc/helpers.rb` is tested by `helpers_spec.rb` or `calc_helpers_spec.rb`.
15
+ def self.stems_of(file)
16
+ stems = [File.basename(file, ".rb"), file.delete_suffix(".rb").split("/").drop(1).join("_")]
17
+ stems.reject(&:empty?)
18
+ end
19
+
20
+ def covering(path, lines)
21
+ ids = lines.flat_map { |line| line_map.fetch("#{path}:#{line}", []) }
22
+ ids.uniq
23
+ end
24
+
25
+ def budget(ids, minimum)
26
+ [minimum, ids.sum { |id| durations.fetch(id) } * 5].max + hooks
27
+ end
28
+
29
+ def loaded?(path)
30
+ loaded_files.include?(path)
31
+ end
32
+
33
+ def booted?(path, lines)
34
+ lines.any? { |line| boot_lines.include?("#{path}:#{line}") }
35
+ end
36
+
37
+ # Named tests kill soonest; baseline order keeps order-dependent suites working.
38
+ def prioritize(ids, file)
39
+ stems = Baseline.stems_of(file)
40
+ ids.sort_by { |id| [named_after?(id, stems) ? 0 : 1, order.fetch(id, Float::INFINITY)] }
41
+ end
42
+
43
+ private
44
+
45
+ def named_after?(id, stems)
46
+ base = File.basename(locations.fetch(id))
47
+ stems.any? { |stem| base.start_with?("#{stem}_", "test_#{stem}") }
48
+ end
49
+ end
50
+ public_constant :Baseline
51
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "coverage"
4
+
5
+ module Mutato
6
+ class Boot
7
+ def initialize(selection, console)
8
+ @selection = selection
9
+ @console = console
10
+ @output = nil
11
+ end
12
+
13
+ def suite
14
+ adapter = booted
15
+ # What booting executed, before any test ran.
16
+ executed = Coverage.peek_result
17
+ started = Clock.now
18
+ measured = describe(measure(adapter), started)
19
+ Suite.new(adapter:, baseline: baseline(measured, executed), options:, output:)
20
+ end
21
+
22
+ private
23
+
24
+ def options
25
+ @selection.options
26
+ end
27
+
28
+ def output
29
+ @output ||= Output.new(options.out)
30
+ end
31
+
32
+ def booted
33
+ started = Clock.now
34
+ RSpecAdapter.boot(options.spec_args)
35
+ Mutato.config.run_hooks(:after_boot)
36
+ report_boot(started)
37
+ end
38
+
39
+ def report_boot(started)
40
+ count = RSpecAdapter.locations.size
41
+ @console.say(
42
+ format(
43
+ "boot: %<seconds>.2fs, %<count>d examples loaded",
44
+ seconds: Clock.since(started),
45
+ count:
46
+ )
47
+ )
48
+ @console.die("spec files failed to load, see the error above") if RSpecAdapter.load_failed?
49
+ @console.die(no_tests) if count.zero?
50
+ RSpecAdapter
51
+ end
52
+
53
+ def no_tests
54
+ return "the suite is Minitest; mutato is RSpec-only" if defined?(Minitest::Test)
55
+
56
+ "no tests loaded from #{options.spec_args.join(" ")}"
57
+ end
58
+
59
+ def measure(adapter)
60
+ log = output.log_path("baseline")
61
+ Mutato.config.run_hooks(:before_mutant)
62
+ check(Child.run(log:) { adapter.baseline(@selection.prefixes) }, log)
63
+ end
64
+
65
+ def check(measured, log)
66
+ return measured if measured.is_a?(Measure)
67
+
68
+ @console.die("baseline crashed: #{measured.inspect}, see #{log}")
69
+ end
70
+
71
+ def describe(measured, started)
72
+ @console.say(measured.summary(Clock.since(started)))
73
+ failed = measured.failed
74
+ if failed.any?
75
+ @console.say("baseline: #{failed.size} failing tests excluded: #{failed.first(5)}")
76
+ end
77
+ @console.die("baseline: nothing passed") if measured.all_failed?
78
+ measured
79
+ end
80
+
81
+ def baseline(measured, executed)
82
+ # With autoloading, a file may load first in a test.
83
+ loaded = executed.keys.to_set.merge(measured.seen)
84
+ measured.baseline(
85
+ locations: RSpecAdapter.locations,
86
+ boot_lines: boot_lines(executed),
87
+ loaded_files: loaded
88
+ )
89
+ end
90
+
91
+ def boot_lines(executed)
92
+ prefixes = @selection.prefixes
93
+ hits = executed.flat_map do |path, data|
94
+ path.start_with?(*prefixes) ? Hits.keys(path, data) : []
95
+ end
96
+ hits.to_set
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutato
4
+ # Outcomes are kept as they come, so an interrupt can still report them.
5
+ class Campaign
6
+ attr_reader :suite, :experience
7
+
8
+ def initialize(suite, console)
9
+ @suite = suite
10
+ @console = console
11
+ @experience = Experience.fresh
12
+ @outcomes = []
13
+ end
14
+
15
+ def try(todo, &)
16
+ watch(&)
17
+ @console.say("#{todo.size} mutants to try")
18
+ todo.each_with_index { |mutation, index| @outcomes << judge(mutation, index, todo) }
19
+ @outcomes
20
+ end
21
+
22
+ private
23
+
24
+ # Named, not anonymous: Ruby 3.3.0 rejects `&` forwarded from inside a block.
25
+ def watch(&report)
26
+ %w[INT TERM].each { |signal| trap(signal) { interrupted(signal, &report) } }
27
+ end
28
+
29
+ def interrupted(signal)
30
+ yield(@outcomes)
31
+ @console.die("interrupted by #{signal}")
32
+ end
33
+
34
+ def judge(mutation, index, todo)
35
+ outcome = Trial.new(mutation, @suite, @experience).judge
36
+ @suite.output.append(outcome)
37
+ @console.say(progress("#{index + 1}/#{todo.size}", outcome, mutation))
38
+ outcome
39
+ end
40
+
41
+ def progress(position, outcome, mutation)
42
+ format(
43
+ "%<position>8s %<outcome>-11s %<seconds>.2fs %<id>s %<label>s",
44
+ position:,
45
+ outcome: outcome.fetch(:outcome),
46
+ seconds: outcome.fetch(:seconds),
47
+ id: mutation.id,
48
+ label: mutation.label
49
+ )
50
+ end
51
+ end
52
+ end