mutante 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5644bbc1cfc15fe2e6fe859d2ce4ee547f4780ea1215d62cb29408f48bbca591
4
+ data.tar.gz: 451058c2475efd8c91a90575bd8100f35672eea29431fab6d8755429af4c58d5
5
+ SHA512:
6
+ metadata.gz: dda544bdea5c32ddd89486e4cb459b20a24a6cb65a7793ea1378bbeda74e83f17761b3603618d42e00b1327ed12f001cc4cafc8506797bdd9f0b335ca18c43a5
7
+ data.tar.gz: d961f33aaa7064aa9219fcc93d7bb98d6973a4192c19889d0232a25b84d2da9b6f7b82e2650ddd8f25805a4b9d7158c8c8535a73b150071636d3cf71e00381e3
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nazareno Moresco
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # Mutante
2
+
3
+ Find dead code by commenting lines out and running the tests. If the suite
4
+ still passes, that line is a strong candidate for removal.
5
+
6
+ Think of it as reverse mutation testing: instead of mutating code to see if
7
+ the suite catches the change, mutante *removes* code to see if anything
8
+ notices.
9
+
10
+ ## Installation
11
+
12
+ Add to your `Gemfile`:
13
+
14
+ ```ruby
15
+ group :development, :test do
16
+ gem "mutante"
17
+ end
18
+ ```
19
+
20
+ Then:
21
+
22
+ ```bash
23
+ bundle install
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ Run against every candidate file in a Rails project (models, controllers,
29
+ services, jobs, mailers, helpers, channels, serializers, and `lib/`):
30
+
31
+ ```bash
32
+ bundle exec mutante
33
+ ```
34
+
35
+ Limit to a single file or directory:
36
+
37
+ ```bash
38
+ bundle exec mutante app/models/user.rb
39
+ bundle exec mutante app/services/
40
+ ```
41
+
42
+ Stream test output while it runs:
43
+
44
+ ```bash
45
+ bundle exec mutante --verbose
46
+ ```
47
+
48
+ Mutante starts by running the whole suite. If the baseline is red, it bails
49
+ out — there's no signal in running mutated tests against an already-broken
50
+ suite.
51
+
52
+ For every line of real code it then:
53
+
54
+ 1. Rewrites the file with that one line commented out.
55
+ 2. Skips the line if the mutation produces a `SyntaxError` (e.g. `end`,
56
+ `def foo`, block headers).
57
+ 3. Runs the spec file that covers the source file. If no match is
58
+ configured and no mirrored spec exists, runs the entire suite.
59
+ 4. Restores the file, and flags the line if the suite stayed green.
60
+
61
+ At the end you get a list of flagged `path:line` locations to review.
62
+
63
+ ## Configuration
64
+
65
+ Drop a file at `config/initializers/mutante.rb` (or `.mutante.rb` in the
66
+ project root):
67
+
68
+ ```ruby
69
+ Mutante.configure do |config|
70
+ # Override the test command (default: bin/rspec or bundle exec rspec).
71
+ config.test_command = "bundle exec rspec --no-color"
72
+
73
+ # Narrow or widen which files are scanned.
74
+ config.include_globs = %w[app/services/**/*.rb app/models/**/*.rb]
75
+ config.exclude_globs << "app/services/legacy/**/*"
76
+
77
+ # Map a source-file glob to the spec(s) that cover it. Available
78
+ # placeholders in the target:
79
+ # {basename} - file name without extension
80
+ # {relative} - path below app/<layer>/ or lib/
81
+ # {relative_dir} - directory portion of {relative}
82
+ config.map "app/services/**/*.rb",
83
+ to: "spec/services/{relative}_spec.rb"
84
+
85
+ config.map "app/models/concerns/*.rb",
86
+ to: ["spec/models/concerns/{basename}_spec.rb",
87
+ "spec/shared/concerns/{basename}_spec.rb"]
88
+ end
89
+ ```
90
+
91
+ If no mapping matches, mutante falls back to the mirrored path under
92
+ `spec/` (so `app/models/user.rb` → `spec/models/user_spec.rb`). If that
93
+ doesn't exist either, it runs the full suite for that line.
94
+
95
+ ## Performance
96
+
97
+ This is slow. Commenting out a single line means a full test run, and
98
+ mutante does that once per eligible line. Use specific files or tight
99
+ mappings to keep runs tractable on large codebases.
100
+
101
+ ## License
102
+
103
+ MIT.
data/exe/mutante ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ require "mutante"
3
+
4
+ Mutante::CLI.start(ARGV)
@@ -0,0 +1,63 @@
1
+ require "optparse"
2
+
3
+ module Mutante
4
+ class CLI
5
+ DEFAULT_INITIALIZER_PATHS = [
6
+ "config/initializers/mutante.rb",
7
+ ".mutante.rb"
8
+ ].freeze
9
+
10
+ def self.start(argv)
11
+ new(argv).run
12
+ end
13
+
14
+ def initialize(argv)
15
+ @argv = argv.dup
16
+ @options = { verbose: false, config: nil }
17
+ end
18
+
19
+ def run
20
+ target = parse!
21
+ load_configuration
22
+ ok = Runner.new(verbose: @options[:verbose]).call(target)
23
+ exit(ok ? 0 : 1)
24
+ end
25
+
26
+ private
27
+
28
+ def parse!
29
+ parser = OptionParser.new do |o|
30
+ o.banner = "Usage: mutante [options] [FILE_OR_DIRECTORY]"
31
+
32
+ o.on("-c", "--config PATH", "Load configuration from PATH") do |v|
33
+ @options[:config] = v
34
+ end
35
+
36
+ o.on("-v", "--verbose", "Stream test output to the terminal") do
37
+ @options[:verbose] = true
38
+ end
39
+
40
+ o.on("--version", "Print version and exit") do
41
+ puts "mutante #{Mutante::VERSION}"
42
+ exit 0
43
+ end
44
+
45
+ o.on("-h", "--help", "Show this help message") do
46
+ puts o
47
+ exit 0
48
+ end
49
+ end
50
+
51
+ remainder = parser.parse(@argv)
52
+ remainder.first
53
+ end
54
+
55
+ def load_configuration
56
+ candidates = [@options[:config], *DEFAULT_INITIALIZER_PATHS].compact
57
+ path = candidates.find { |p| File.exist?(File.expand_path(p)) }
58
+ return unless path
59
+
60
+ load File.expand_path(path)
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,62 @@
1
+ module Mutante
2
+ class Configuration
3
+ DEFAULT_RAILS_GLOBS = %w[
4
+ app/models/**/*.rb
5
+ app/controllers/**/*.rb
6
+ app/services/**/*.rb
7
+ app/jobs/**/*.rb
8
+ app/mailers/**/*.rb
9
+ app/helpers/**/*.rb
10
+ app/channels/**/*.rb
11
+ app/serializers/**/*.rb
12
+ lib/**/*.rb
13
+ ].freeze
14
+
15
+ DEFAULT_EXCLUDE_GLOBS = %w[
16
+ spec/**/*
17
+ test/**/*
18
+ config/**/*
19
+ db/**/*
20
+ vendor/**/*
21
+ tmp/**/*
22
+ ].freeze
23
+
24
+ attr_accessor :include_globs,
25
+ :exclude_globs,
26
+ :test_command,
27
+ :test_mappings,
28
+ :root
29
+
30
+ def initialize
31
+ @include_globs = DEFAULT_RAILS_GLOBS.dup
32
+ @exclude_globs = DEFAULT_EXCLUDE_GLOBS.dup
33
+ @test_command = default_test_command
34
+ @test_mappings = {}
35
+ @root = Dir.pwd
36
+ end
37
+
38
+ # Map a source-file glob to the spec file(s) that cover it.
39
+ #
40
+ # config.map "app/services/**/*.rb", to: "spec/services/{basename}_spec.rb"
41
+ #
42
+ # Placeholders in the target:
43
+ # {basename} - file name without extension
44
+ # {relative} - path relative to app/ or lib/
45
+ # {relative_dir} - directory part of {relative}
46
+ def map(source_glob, to:)
47
+ test_mappings[source_glob] = to
48
+ end
49
+
50
+ private
51
+
52
+ def default_test_command
53
+ if File.exist?(File.join(Dir.pwd, "bin", "rspec"))
54
+ "bin/rspec"
55
+ elsif File.exist?(File.join(Dir.pwd, "Gemfile"))
56
+ "bundle exec rspec"
57
+ else
58
+ "rspec"
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,41 @@
1
+ module Mutante
2
+ class FileFinder
3
+ def initialize(configuration)
4
+ @configuration = configuration
5
+ end
6
+
7
+ # Given an optional explicit path (file or directory), returns an
8
+ # array of absolute file paths to analyze.
9
+ def call(target = nil)
10
+ files =
11
+ if target && File.file?(target)
12
+ [File.expand_path(target)]
13
+ elsif target && File.directory?(target)
14
+ glob_in(target, "**/*.rb")
15
+ else
16
+ from_include_globs
17
+ end
18
+
19
+ exclude!(files)
20
+ end
21
+
22
+ private
23
+
24
+ def from_include_globs
25
+ @configuration.include_globs.flat_map do |pattern|
26
+ glob_in(@configuration.root, pattern)
27
+ end
28
+ end
29
+
30
+ def glob_in(root, pattern)
31
+ Dir.glob(File.join(root, pattern)).map { |f| File.expand_path(f) }
32
+ end
33
+
34
+ def exclude!(files)
35
+ excludes = @configuration.exclude_globs.flat_map do |pattern|
36
+ Dir.glob(File.join(@configuration.root, pattern)).map { |f| File.expand_path(f) }
37
+ end
38
+ (files - excludes).uniq.sort
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,85 @@
1
+ module Mutante
2
+ # Walks a file line-by-line, yielding an opportunity to comment each
3
+ # eligible line out. The file is rewritten for each trial and restored
4
+ # afterwards — callers get a block that runs while the file is mutated.
5
+ class LineAnalyzer
6
+ # Lines that are pure structural keywords would break the syntax the
7
+ # moment they are commented out, so we skip them up front.
8
+ SKIP_EXACT = %w[
9
+ end else elsif ensure rescue begin do then
10
+ ].freeze
11
+
12
+ def initialize(path)
13
+ @path = path
14
+ @original_lines = File.readlines(path)
15
+ end
16
+
17
+ # Yields |line_number, line_text| for each line worth trying.
18
+ # Inside the block, the file on disk has that line commented out.
19
+ # After the block returns, the file is restored to its original form.
20
+ def each_candidate
21
+ @original_lines.each_with_index do |line, index|
22
+ next unless candidate?(line)
23
+
24
+ commented = commented_version(line)
25
+ next unless still_valid_ruby?(index, commented)
26
+
27
+ write_with_substitution(index, commented)
28
+ begin
29
+ yield(index + 1, line.chomp)
30
+ ensure
31
+ restore_original
32
+ end
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def candidate?(line)
39
+ stripped = line.strip
40
+ return false if stripped.empty?
41
+ return false if stripped.start_with?("#")
42
+ return false if SKIP_EXACT.include?(stripped)
43
+ return false if stripped =~ /\A(end|else|elsif|ensure|rescue|begin|do|then)\b.*\z/ && stripped !~ /[=.({]/
44
+ true
45
+ end
46
+
47
+ def commented_version(line)
48
+ leading = line[/\A\s*/]
49
+ rest = line[leading.length..] || ""
50
+ "#{leading}# #{rest}".sub(/(?<!\n)\z/, "\n")
51
+ end
52
+
53
+ def still_valid_ruby?(index, replacement_line)
54
+ candidate = @original_lines.dup
55
+ candidate[index] = replacement_line
56
+ source = candidate.join
57
+
58
+ silence_stderr do
59
+ RubyVM::InstructionSequence.compile(source)
60
+ end
61
+ true
62
+ rescue SyntaxError
63
+ false
64
+ end
65
+
66
+ def silence_stderr
67
+ original = $stderr
68
+ $stderr = File.open(File::NULL, "w")
69
+ yield
70
+ ensure
71
+ $stderr.close if $stderr && $stderr != original
72
+ $stderr = original
73
+ end
74
+
75
+ def write_with_substitution(index, replacement_line)
76
+ mutated = @original_lines.dup
77
+ mutated[index] = replacement_line
78
+ File.write(@path, mutated.join)
79
+ end
80
+
81
+ def restore_original
82
+ File.write(@path, @original_lines.join)
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,265 @@
1
+ module Mutante
2
+ class Reporter
3
+ SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
4
+
5
+ COLORS = {
6
+ reset: "\e[0m",
7
+ bold: "\e[1m",
8
+ dim: "\e[2m",
9
+ red: "\e[31m",
10
+ green: "\e[32m",
11
+ yellow: "\e[33m",
12
+ blue: "\e[34m",
13
+ magenta: "\e[35m",
14
+ cyan: "\e[36m",
15
+ gray: "\e[90m"
16
+ }.freeze
17
+
18
+ CLEAR_LINE = "\e[2K\r".freeze
19
+
20
+ def initialize(io: $stdout, force_plain: false)
21
+ @io = io
22
+ @flagged = []
23
+ @tty = !force_plain && io.respond_to?(:tty?) && io.tty?
24
+ @total_files = 0
25
+ @file_index = 0
26
+ @current_file = nil
27
+ @counts = nil
28
+ @file_started = nil
29
+ @started_at = nil
30
+ @spinner_index = 0
31
+ end
32
+
33
+ def starting(files)
34
+ @total_files = files.size
35
+ @started_at = monotonic_now
36
+ plural = files.size == 1 ? "file" : "files"
37
+ subtitle = "analyzing #{files.size} #{plural} · reverse mutation testing"
38
+
39
+ if @tty
40
+ draw_header("mutante v#{Mutante::VERSION}", subtitle)
41
+ else
42
+ @io.puts "mutante: #{subtitle}"
43
+ @io.puts
44
+ end
45
+ end
46
+
47
+ def baseline_starting
48
+ if @tty
49
+ @io.puts
50
+ @io.print " #{color(:cyan)}◆#{color(:reset)} running baseline test suite..."
51
+ @io.flush
52
+ else
53
+ @io.puts "mutante: running baseline test suite..."
54
+ end
55
+ end
56
+
57
+ def baseline_result(ok)
58
+ if @tty
59
+ @io.print CLEAR_LINE
60
+ if ok
61
+ @io.puts " #{color(:green)}●#{color(:reset)} baseline #{color(:green)}green#{color(:reset)}"
62
+ else
63
+ @io.puts " #{color(:red)}●#{color(:reset)} baseline #{color(:red)}RED#{color(:reset)} — fix the suite before running mutante."
64
+ end
65
+ else
66
+ @io.puts(ok ? " baseline green." : " baseline RED.")
67
+ @io.puts
68
+ end
69
+ end
70
+
71
+ def file_starting(path)
72
+ @file_index += 1
73
+ @current_file = path
74
+ @counts = { tested: 0, flagged: 0, skipped: 0 }
75
+ @file_started = monotonic_now
76
+
77
+ if @tty
78
+ @io.puts
79
+ @io.puts "#{color(:cyan)}▸#{color(:reset)} " \
80
+ "#{color(:bold)}#{path}#{color(:reset)} " \
81
+ "#{color(:gray)}[#{@file_index}/#{@total_files}]#{color(:reset)}"
82
+ redraw_status
83
+ else
84
+ @io.puts path
85
+ end
86
+ end
87
+
88
+ def line_flagged(path, line_number, line_text)
89
+ @flagged << { path: path, line: line_number, text: line_text }
90
+ @counts[:flagged] += 1 if @counts
91
+ @counts[:tested] += 1 if @counts
92
+
93
+ if @tty
94
+ @io.print CLEAR_LINE
95
+ @io.puts " #{color(:red)}⚑#{color(:reset)} " \
96
+ "#{color(:red)}#{path}:#{line_number}#{color(:reset)} " \
97
+ "#{color(:dim)}#{line_text.strip}#{color(:reset)}"
98
+ redraw_status
99
+ else
100
+ @io.puts " flagged #{path}:#{line_number} #{line_text.strip}"
101
+ end
102
+ end
103
+
104
+ def line_safe(_path, _line_number)
105
+ @counts[:tested] += 1 if @counts
106
+
107
+ if @tty
108
+ redraw_status
109
+ else
110
+ @io.print "."
111
+ @io.flush
112
+ end
113
+ end
114
+
115
+ def line_skipped(_path, _line_number, _reason)
116
+ @counts[:skipped] += 1 if @counts
117
+
118
+ if @tty
119
+ redraw_status
120
+ else
121
+ @io.print "-"
122
+ @io.flush
123
+ end
124
+ end
125
+
126
+ def file_finished
127
+ if @tty
128
+ @io.print CLEAR_LINE
129
+ elapsed = format_duration(monotonic_now - (@file_started || monotonic_now))
130
+ icon = @counts && @counts[:flagged].positive? ? "#{color(:yellow)}⚑#{color(:reset)}" : "#{color(:green)}✓#{color(:reset)}"
131
+ parts = []
132
+ parts << "#{@counts[:tested]} tested" if @counts
133
+ parts << "#{color(:red)}#{@counts[:flagged]} flagged#{color(:reset)}" if @counts && @counts[:flagged].positive?
134
+ parts << "#{@counts[:skipped]} skipped" if @counts && @counts[:skipped].positive?
135
+ parts << "#{color(:gray)}#{elapsed}#{color(:reset)}"
136
+ @io.puts " #{icon} #{parts.join(color(:gray) + ' · ' + color(:reset))}"
137
+ else
138
+ @io.puts
139
+ end
140
+ end
141
+
142
+ def finished
143
+ if @tty
144
+ draw_summary
145
+ else
146
+ @io.puts
147
+ @io.puts "=" * 60
148
+ if @flagged.empty?
149
+ @io.puts "mutante: no dead-code candidates found."
150
+ else
151
+ plural = @flagged.size == 1 ? "candidate line" : "candidate lines"
152
+ @io.puts "mutante: #{@flagged.size} #{plural} flagged:"
153
+ @flagged.each do |entry|
154
+ @io.puts " #{entry[:path]}:#{entry[:line]} #{entry[:text].strip}"
155
+ end
156
+ end
157
+ end
158
+ end
159
+
160
+ attr_reader :flagged
161
+
162
+ private
163
+
164
+ def color(name)
165
+ return "" unless @tty
166
+
167
+ COLORS.fetch(name, "")
168
+ end
169
+
170
+ def redraw_status
171
+ return unless @tty && @counts
172
+
173
+ @spinner_index = (@spinner_index + 1) % SPINNER_FRAMES.size
174
+ spinner = SPINNER_FRAMES[@spinner_index]
175
+
176
+ parts = []
177
+ parts << "#{@counts[:tested]} tested"
178
+ parts << "#{color(:red)}⚑ #{@counts[:flagged]}#{color(:reset)}" if @counts[:flagged].positive?
179
+ parts << "#{color(:gray)}#{@counts[:skipped]} skipped#{color(:reset)}" if @counts[:skipped].positive?
180
+
181
+ line = " #{color(:cyan)}#{spinner}#{color(:reset)} " \
182
+ "#{parts.join(color(:gray) + ' · ' + color(:reset))}"
183
+
184
+ @io.print CLEAR_LINE
185
+ @io.print line
186
+ @io.flush
187
+ end
188
+
189
+ def draw_header(title, subtitle)
190
+ width = [title.length, subtitle.length].max + 4
191
+ top = "╭#{"─" * width}╮"
192
+ middle1 = "│ #{color(:bold)}#{color(:magenta)}#{title}#{color(:reset)}#{" " * (width - title.length - 2)}│"
193
+ middle2 = "│ #{color(:gray)}#{subtitle}#{color(:reset)}#{" " * (width - subtitle.length - 2)}│"
194
+ bottom = "╰#{"─" * width}╯"
195
+
196
+ @io.puts top
197
+ @io.puts middle1
198
+ @io.puts middle2
199
+ @io.puts bottom
200
+ end
201
+
202
+ def draw_summary
203
+ elapsed = format_duration(monotonic_now - (@started_at || monotonic_now))
204
+
205
+ @io.puts
206
+ if @flagged.empty?
207
+ line1 = "#{color(:green)}✓#{color(:reset)} " \
208
+ "#{color(:bold)}no dead-code candidates found#{color(:reset)} " \
209
+ "#{color(:gray)}(#{elapsed})#{color(:reset)}"
210
+ @io.puts draw_box([line1], :green)
211
+ else
212
+ plural = @flagged.size == 1 ? "candidate line" : "candidate lines"
213
+ header = "#{color(:yellow)}⚑#{color(:reset)} " \
214
+ "#{color(:bold)}#{@flagged.size} #{plural} flagged#{color(:reset)} " \
215
+ "#{color(:gray)}(#{elapsed})#{color(:reset)}"
216
+
217
+ path_width = @flagged.map { |e| "#{e[:path]}:#{e[:line]}".length }.max
218
+ entries = @flagged.map do |entry|
219
+ locator = "#{entry[:path]}:#{entry[:line]}".ljust(path_width)
220
+ " #{color(:red)}#{locator}#{color(:reset)} " \
221
+ "#{color(:dim)}#{entry[:text].strip}#{color(:reset)}"
222
+ end
223
+
224
+ lines = [header, ""] + entries
225
+ @io.puts draw_box(lines, :yellow)
226
+ end
227
+ end
228
+
229
+ def draw_box(lines, border_color)
230
+ visible_widths = lines.map { |l| visible_length(l) }
231
+ width = visible_widths.max + 2
232
+
233
+ out = []
234
+ out << "#{color(border_color)}╭#{'─' * width}╮#{color(:reset)}"
235
+ lines.each_with_index do |line, i|
236
+ padding = " " * (width - visible_widths[i] - 1)
237
+ out << "#{color(border_color)}│#{color(:reset)} #{line}#{padding}#{color(border_color)}│#{color(:reset)}"
238
+ end
239
+ out << "#{color(border_color)}╰#{'─' * width}╯#{color(:reset)}"
240
+ out.join("\n")
241
+ end
242
+
243
+ def visible_length(str)
244
+ str.gsub(/\e\[[0-9;]*m/, "").length
245
+ end
246
+
247
+ def format_duration(seconds)
248
+ return "0s" if seconds.nil? || seconds.negative?
249
+
250
+ if seconds < 1
251
+ "#{(seconds * 1000).round}ms"
252
+ elsif seconds < 60
253
+ "#{seconds.round(1)}s"
254
+ else
255
+ minutes = (seconds / 60).to_i
256
+ rem = (seconds - minutes * 60).round
257
+ "#{minutes}m#{rem}s"
258
+ end
259
+ end
260
+
261
+ def monotonic_now
262
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
263
+ end
264
+ end
265
+ end
@@ -0,0 +1,59 @@
1
+ module Mutante
2
+ class Runner
3
+ def initialize(configuration: Mutante.configuration,
4
+ reporter: nil,
5
+ verbose: false,
6
+ test_runner: nil,
7
+ file_finder: nil,
8
+ spec_finder: nil)
9
+ @configuration = configuration
10
+ @reporter = reporter || Reporter.new(force_plain: verbose)
11
+ @verbose = verbose
12
+ @finder = file_finder || FileFinder.new(configuration)
13
+ @spec_finder = spec_finder || SpecFinder.new(configuration)
14
+ @test_runner = test_runner || TestRunner.new(configuration, verbose: verbose)
15
+ end
16
+
17
+ def call(target = nil)
18
+ files = @finder.call(target)
19
+ @reporter.starting(files)
20
+
21
+ return false unless baseline_passes?
22
+
23
+ files.each { |f| analyze_file(f) }
24
+ @reporter.finished
25
+ true
26
+ end
27
+
28
+ private
29
+
30
+ def baseline_passes?
31
+ @reporter.baseline_starting if @reporter.respond_to?(:baseline_starting)
32
+ result = @test_runner.call(nil)
33
+ @reporter.baseline_result(result) if @reporter.respond_to?(:baseline_result)
34
+ result
35
+ end
36
+
37
+ def analyze_file(path)
38
+ relative = relative_path(path)
39
+ @reporter.file_starting(relative)
40
+
41
+ spec_files = @spec_finder.call(path)
42
+
43
+ analyzer = LineAnalyzer.new(path)
44
+ analyzer.each_candidate do |line_number, line_text|
45
+ if @test_runner.call(spec_files)
46
+ @reporter.line_flagged(relative, line_number, line_text)
47
+ else
48
+ @reporter.line_safe(relative, line_number)
49
+ end
50
+ end
51
+
52
+ @reporter.file_finished
53
+ end
54
+
55
+ def relative_path(path)
56
+ path.sub(/\A#{Regexp.escape(@configuration.root)}\/?/, "")
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,75 @@
1
+ module Mutante
2
+ # Resolves which spec file(s) to run for a given source file.
3
+ #
4
+ # Priority:
5
+ # 1. Explicit mapping from Configuration#test_mappings.
6
+ # 2. Mirrored path under spec/ (app/foo/bar.rb -> spec/foo/bar_spec.rb).
7
+ # 3. Nil, meaning "fall back to the whole suite".
8
+ class SpecFinder
9
+ PLACEHOLDER_RE = /\{(basename|relative|relative_dir)\}/
10
+
11
+ def initialize(configuration)
12
+ @configuration = configuration
13
+ @root = configuration.root
14
+ end
15
+
16
+ # Returns an Array of spec file paths (relative to the project root),
17
+ # or nil to indicate the whole test suite should be used.
18
+ def call(source_file)
19
+ rel = relative(source_file)
20
+
21
+ mapping = matching_mapping(rel)
22
+ if mapping
23
+ paths = expand(mapping, rel)
24
+ existing = paths.select { |p| File.exist?(File.join(@root, p)) }
25
+ return existing unless existing.empty?
26
+ end
27
+
28
+ mirror = mirrored_spec(rel)
29
+ return [mirror] if mirror && File.exist?(File.join(@root, mirror))
30
+
31
+ nil
32
+ end
33
+
34
+ private
35
+
36
+ def relative(path)
37
+ abs = File.expand_path(path)
38
+ abs.sub(/\A#{Regexp.escape(@root)}\/?/, "")
39
+ end
40
+
41
+ def matching_mapping(rel)
42
+ @configuration.test_mappings.each do |glob, target|
43
+ return target if File.fnmatch?(glob, rel, File::FNM_PATHNAME | File::FNM_EXTGLOB)
44
+ end
45
+ nil
46
+ end
47
+
48
+ def expand(target, rel)
49
+ basename = File.basename(rel, ".rb")
50
+ stripped = rel.sub(%r{\A(app/[^/]+/|lib/)}, "")
51
+ relative_rb = stripped.sub(/\.rb\z/, "")
52
+ relative_dir = File.dirname(relative_rb)
53
+ relative_dir = "" if relative_dir == "."
54
+
55
+ Array(target).map do |t|
56
+ t.gsub(PLACEHOLDER_RE) do
57
+ case Regexp.last_match(1)
58
+ when "basename" then basename
59
+ when "relative" then relative_rb
60
+ when "relative_dir" then relative_dir
61
+ end
62
+ end
63
+ end
64
+ end
65
+
66
+ def mirrored_spec(rel)
67
+ case rel
68
+ when %r{\Aapp/(.+)\.rb\z}
69
+ "spec/#{Regexp.last_match(1)}_spec.rb"
70
+ when %r{\Alib/(.+)\.rb\z}
71
+ "spec/lib/#{Regexp.last_match(1)}_spec.rb"
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,34 @@
1
+ require "open3"
2
+ require "shellwords"
3
+
4
+ module Mutante
5
+ class TestRunner
6
+ def initialize(configuration, verbose: false)
7
+ @configuration = configuration
8
+ @verbose = verbose
9
+ end
10
+
11
+ # Runs the configured test command, scoped to `spec_files` when provided
12
+ # (otherwise runs the whole suite). Returns true if the suite passed.
13
+ def call(spec_files = nil)
14
+ command = build_command(spec_files)
15
+
16
+ if @verbose
17
+ puts " $ #{command}"
18
+ system(command, chdir: @configuration.root)
19
+ else
20
+ _out, _err, status = Open3.capture3(command, chdir: @configuration.root)
21
+ status.success?
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def build_command(spec_files)
28
+ base = @configuration.test_command
29
+ return base if spec_files.nil? || spec_files.empty?
30
+
31
+ "#{base} #{Array(spec_files).map { |f| Shellwords.escape(f) }.join(' ')}"
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,3 @@
1
+ module Mutante
2
+ VERSION = "0.1.0"
3
+ end
data/lib/mutante.rb ADDED
@@ -0,0 +1,25 @@
1
+ require "mutante/version"
2
+ require "mutante/configuration"
3
+ require "mutante/file_finder"
4
+ require "mutante/spec_finder"
5
+ require "mutante/line_analyzer"
6
+ require "mutante/test_runner"
7
+ require "mutante/reporter"
8
+ require "mutante/runner"
9
+ require "mutante/cli"
10
+
11
+ module Mutante
12
+ class Error < StandardError; end
13
+
14
+ def self.configuration
15
+ @configuration ||= Configuration.new
16
+ end
17
+
18
+ def self.configure
19
+ yield(configuration)
20
+ end
21
+
22
+ def self.reset_configuration!
23
+ @configuration = Configuration.new
24
+ end
25
+ end
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mutante
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Nazareno Moresco
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-04-24 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.12'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.12'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ description: Mutante iterates through lines of code, comments each one out, runs the
42
+ associated tests, and flags lines whose removal leaves the suite green — strong
43
+ candidates for dead code.
44
+ email:
45
+ executables:
46
+ - mutante
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - LICENSE.txt
51
+ - README.md
52
+ - exe/mutante
53
+ - lib/mutante.rb
54
+ - lib/mutante/cli.rb
55
+ - lib/mutante/configuration.rb
56
+ - lib/mutante/file_finder.rb
57
+ - lib/mutante/line_analyzer.rb
58
+ - lib/mutante/reporter.rb
59
+ - lib/mutante/runner.rb
60
+ - lib/mutante/spec_finder.rb
61
+ - lib/mutante/test_runner.rb
62
+ - lib/mutante/version.rb
63
+ homepage: https://github.com/alliants/mutante
64
+ licenses:
65
+ - MIT
66
+ metadata: {}
67
+ post_install_message:
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: 3.0.0
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubygems_version: 3.5.22
83
+ signing_key:
84
+ specification_version: 4
85
+ summary: Find dead code by commenting lines out and running tests.
86
+ test_files: []