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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +103 -0
- data/exe/mutante +4 -0
- data/lib/mutante/cli.rb +63 -0
- data/lib/mutante/configuration.rb +62 -0
- data/lib/mutante/file_finder.rb +41 -0
- data/lib/mutante/line_analyzer.rb +85 -0
- data/lib/mutante/reporter.rb +265 -0
- data/lib/mutante/runner.rb +59 -0
- data/lib/mutante/spec_finder.rb +75 -0
- data/lib/mutante/test_runner.rb +34 -0
- data/lib/mutante/version.rb +3 -0
- data/lib/mutante.rb +25 -0
- metadata +86 -0
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
data/lib/mutante/cli.rb
ADDED
|
@@ -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
|
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: []
|