mutaruby 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.
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'cgi'
5
+ require_relative 'version'
6
+
7
+ module Mutaruby
8
+ module Reporters
9
+ module_function
10
+
11
+ def score_text(report)
12
+ s = report.score
13
+ s.nil? ? 'n/a' : format('%.2f%%', s)
14
+ end
15
+
16
+ def human(report, io, verbose: false)
17
+ survivors = report.outcomes.select { |o| o.status == :survived }
18
+ if survivors.any?
19
+ io.puts 'Surviving mutants (not detected by the test command):'
20
+ survivors.each do |o|
21
+ m = o.mutation
22
+ io.puts
23
+ io.puts " #{m.location}#{m.method ? " in #{m.method}" : ''} [##{m.id}]"
24
+ io.puts " #{m.label}"
25
+ m.diff(report.sources[m.file]).each_line { |l| io.puts " #{l.chomp}" }
26
+ end
27
+ io.puts
28
+ end
29
+ if verbose
30
+ report.outcomes.select { |o| o.status == :timeout }.each do |o|
31
+ io.puts " timeout: #{o.mutation.location} #{o.mutation.label}"
32
+ end
33
+ report.outcomes.select { |o| o.status == :equivalent }.each do |o|
34
+ io.puts " equivalent: #{o.mutation.location} #{o.mutation.label} (#{o.ignore.reason})"
35
+ end
36
+ end
37
+ report.stale.each { |e| io.puts "warning: STALE ignore entry (no surviving mutant at this site; remove or re-review it): #{e.label}" }
38
+ report.warnings.each { |w| io.puts "warning: #{w}" }
39
+ io.puts 'mutaruby summary'
40
+ io.puts " files: #{report.files.size}"
41
+ io.puts " mutants: #{report.outcomes.size}"
42
+ io.puts " killed: #{report.killed} (#{report.count(:timeout)} by timeout)"
43
+ io.puts " survived: #{report.count(:survived)}"
44
+ io.puts " equivalent: #{report.count(:equivalent)} (ignore file)"
45
+ io.puts " score: #{score_text(report)} = killed / (mutants - equivalent)"
46
+ io.puts format(' time: %.1fs (baseline %.2fs, timeout %ss, jobs %d)', report.seconds,
47
+ report.baseline_seconds, report.timeout, report.jobs)
48
+ end
49
+
50
+ def json_data(report)
51
+ {
52
+ tool: 'mutaruby', version: VERSION, command: report.command,
53
+ score: report.score&.round(4), complete: report.complete,
54
+ counts: { mutants: report.outcomes.size, killed: report.killed, timeout: report.count(:timeout),
55
+ survived: report.count(:survived), equivalent: report.count(:equivalent) },
56
+ files: report.files, baseline_seconds: report.baseline_seconds.round(3), timeout: report.timeout,
57
+ mutants: report.outcomes.map do |o|
58
+ o.mutation.to_h.merge(status: o.status.to_s, seconds: o.seconds.round(3), ignore_reason: o.ignore&.reason)
59
+ end,
60
+ stale_ignores: report.stale.map(&:to_h).map { |h| h.compact.except(:index) },
61
+ warnings: report.warnings
62
+ }
63
+ end
64
+
65
+ def json(report, io)
66
+ io.puts JSON.pretty_generate(json_data(report))
67
+ end
68
+
69
+ # JUnit-style XML: one testcase per mutant. A survivor is a failure, an equivalent mutant is skipped.
70
+ def junit(report, io)
71
+ x = ->(s) { CGI.escapeHTML(s.to_s) }
72
+ failures = report.count(:survived)
73
+ io.puts '<?xml version="1.0" encoding="UTF-8"?>'
74
+ io.puts %(<testsuites name="mutaruby" tests="#{report.outcomes.size}" failures="#{failures}" time="#{report.seconds.round(3)}">)
75
+ io.puts %( <testsuite name="mutaruby" tests="#{report.outcomes.size}" failures="#{failures}" errors="0" ) +
76
+ %(skipped="#{report.count(:equivalent)}" time="#{report.seconds.round(3)}">)
77
+ io.puts %( <properties><property name="score" value="#{x.call(score_text(report))}"/></properties>)
78
+ report.outcomes.each do |o|
79
+ m = o.mutation
80
+ name = "##{m.id} #{m.location} #{m.label}"
81
+ io.print %( <testcase classname="#{x.call(m.file)}" name="#{x.call(name)}" time="#{o.seconds.round(3)}")
82
+ case o.status
83
+ when :survived
84
+ io.puts '>'
85
+ io.puts %( <failure message="#{x.call("survived: #{m.label}")}" type="survived">#{x.call(m.diff(report.sources[m.file]))}</failure>)
86
+ io.puts ' </testcase>'
87
+ when :equivalent
88
+ io.puts '>'
89
+ io.puts %( <skipped message="#{x.call("equivalent: #{o.ignore.reason}")}"/>)
90
+ io.puts ' </testcase>'
91
+ when :timeout
92
+ io.puts '>'
93
+ io.puts ' <system-out>killed by timeout</system-out>'
94
+ io.puts ' </testcase>'
95
+ else
96
+ io.puts '/>'
97
+ end
98
+ end
99
+ io.puts ' </testsuite>'
100
+ io.puts '</testsuites>'
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require_relative 'generator'
5
+ require_relative 'ignore_file'
6
+ require_relative 'sandbox'
7
+ require_relative 'command'
8
+ require_relative 'selection'
9
+
10
+ module Mutaruby
11
+ class BaselineFailed < Error; end
12
+
13
+ # Generates the mutants, checks the baseline, runs every mutant and collects the verdicts.
14
+ class Runner
15
+ Outcome = Struct.new(:mutation, :status, :seconds, :ignore, keyword_init: true) do
16
+ # :killed, :timeout (also a kill), :survived, :equivalent (survived, listed in the ignore file)
17
+ def killed? = %i[killed timeout].include?(status)
18
+ end
19
+
20
+ Report = Struct.new(:root, :command, :files, :outcomes, :sources, :stale, :warnings, :baseline_seconds, :timeout,
21
+ :jobs, :seconds, :complete, keyword_init: true) do
22
+ def count(status) = outcomes.count { |o| o.status == status }
23
+ def killed = count(:killed) + count(:timeout)
24
+
25
+ # killed / (killed + survived); equivalents are left out of both. nil when nothing was scored.
26
+ def score
27
+ scored = killed + count(:survived)
28
+ scored.zero? ? nil : (100.0 * killed / scored)
29
+ end
30
+ end
31
+
32
+ Options = Struct.new(:root, :command, :patterns, :since, :jobs, :timeout, :timeout_factor, :methods,
33
+ :exclude_methods, :lines, :exclude_lines, :shard, :ignore_file, :operators, :copy_excludes,
34
+ :progress, :keyword_init => true)
35
+
36
+ def initialize(opts, out: $stderr)
37
+ @o = opts
38
+ @out = out
39
+ @root = File.expand_path(opts.root || Dir.pwd)
40
+ @warnings = []
41
+ end
42
+
43
+ # Mutants of the selected files after every filter, plus what is needed to report them.
44
+ def plan
45
+ @plan ||= begin
46
+ sel = selection
47
+ files = sel.files
48
+ sources = files.to_h { |f| [f, File.read(File.join(@root, f), encoding: 'UTF-8')] }
49
+ all = files.flat_map do |f|
50
+ g = Generator.new(sources[f], path: File.join(@root, f), rel: f, operators: operator_set)
51
+ ms = g.mutations
52
+ @warnings.concat(g.errors)
53
+ ms
54
+ end
55
+ all.each_with_index { |m, i| m.id = i }
56
+ ignore = load_ignore
57
+ if ignore
58
+ errs = ignore.validate(sources)
59
+ raise Error, "ignore file #{ignore.path}:\n #{errs.join("\n ")}" if errs.any?
60
+ end
61
+ { files: files, sources: sources, mutations: sel.filter(all), ignore: ignore, complete: sel.complete? && @o.operators.nil? }
62
+ end
63
+ end
64
+
65
+ def run
66
+ t0 = monotonic
67
+ p = plan
68
+ sandboxes = []
69
+ sandboxes << Sandbox.new(@root, excludes: @o.copy_excludes || [])
70
+ cmd = Command.new(@o.command, root: @root)
71
+ base = cmd.run(sandboxes[0].dir, capture: true)
72
+ unless base.passed?
73
+ raise BaselineFailed, "the test command fails on the UNMUTATED code (#{base.timeout? ? 'timed out' : "exit #{base.exitstatus}"}); " \
74
+ "every mutant would count as killed, so nothing would be measured.\n" \
75
+ "command: #{@o.command}\n--- output (tail) ---\n#{base.output}"
76
+ end
77
+ timeout = @o.timeout || ((base.seconds * (@o.timeout_factor || 3.0)) + 5).ceil
78
+ outcomes = execute(p, cmd, sandboxes, timeout)
79
+ stale = []
80
+ if p[:ignore]
81
+ matched = outcomes.select { |o| o.status == :equivalent }.map(&:ignore).to_set
82
+ stale = p[:complete] ? p[:ignore].stale(matched, p[:files]) : []
83
+ end
84
+ Report.new(root: @root, command: @o.command, files: p[:files], outcomes: outcomes, sources: p[:sources],
85
+ stale: stale, warnings: @warnings, baseline_seconds: base.seconds, timeout: timeout,
86
+ jobs: jobs, seconds: monotonic - t0, complete: p[:complete])
87
+ rescue Interrupt, SignalException
88
+ cmd&.kill_all
89
+ raise
90
+ ensure
91
+ sandboxes&.each(&:cleanup)
92
+ end
93
+
94
+ private
95
+
96
+ def jobs
97
+ [@o.jobs || 1, 1].max
98
+ end
99
+
100
+ def selection
101
+ Selection.new(root: @root, patterns: @o.patterns || [], since: @o.since, methods: @o.methods || [],
102
+ exclude_methods: @o.exclude_methods || [], lines: @o.lines || [],
103
+ exclude_lines: @o.exclude_lines || [], shard: @o.shard)
104
+ end
105
+
106
+ def operator_set
107
+ return nil unless @o.operators
108
+
109
+ @o.operators.to_set
110
+ end
111
+
112
+ def load_ignore
113
+ path = @o.ignore_file
114
+ default = File.join(@root, '.mutaruby-ignore.yml')
115
+ path ||= default if File.exist?(default)
116
+ return nil unless path
117
+
118
+ raise Error, "ignore file not found: #{path}" unless File.exist?(path)
119
+
120
+ IgnoreFile.load(path)
121
+ end
122
+
123
+ def execute(plan, cmd, sandboxes, timeout)
124
+ queue = Queue.new
125
+ plan[:mutations].each { |m| queue << m }
126
+ n = [jobs, plan[:mutations].size].min
127
+ (sandboxes.size...n).each { sandboxes << Sandbox.new(@root, excludes: @o.copy_excludes || []) }
128
+ results = []
129
+ lock = Mutex.new
130
+ threads = Array.new(n) do |w|
131
+ Thread.new do
132
+ box = sandboxes[w]
133
+ loop do
134
+ m = begin
135
+ queue.pop(true)
136
+ rescue ThreadError
137
+ break
138
+ end
139
+ original = plan[:sources][m.file]
140
+ r = box.with_file(m.file, m.text, original) do
141
+ cmd.run(box.dir, timeout: timeout,
142
+ env: { 'MUTARUBY_MUTANT_ID' => m.id.to_s, 'MUTARUBY_FILE' => m.file })
143
+ end
144
+ entry = plan[:ignore]&.match(m)
145
+ status = if r.timeout? then :timeout
146
+ elsif !r.passed? then :killed
147
+ elsif entry then :equivalent
148
+ else :survived
149
+ end
150
+ o = Outcome.new(mutation: m, status: status, seconds: r.seconds, ignore: entry)
151
+ lock.synchronize do
152
+ results << o
153
+ progress(o)
154
+ end
155
+ end
156
+ end
157
+ end
158
+ begin
159
+ threads.each(&:join)
160
+ rescue Exception # rubocop:disable Lint/RescueException -- stop the workers, then re-raise
161
+ threads.each(&:kill)
162
+ raise
163
+ end
164
+ @out.puts if @o.progress && results.any?
165
+ results.sort_by { |o| o.mutation.id }
166
+ end
167
+
168
+ MARK = { killed: '.', timeout: 'T', survived: 'S', equivalent: 'e' }.freeze
169
+
170
+ def progress(outcome)
171
+ return unless @o.progress
172
+
173
+ @out.print(MARK[outcome.status])
174
+ @out.flush
175
+ end
176
+
177
+ def monotonic
178
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'find'
5
+ require 'tmpdir'
6
+
7
+ module Mutaruby
8
+ # A private copy of the project. Every test-command run happens inside one, so the user's files are never written:
9
+ # a crash, a timeout or a Ctrl-C in the middle of a run cannot leave a mutant behind in the working tree.
10
+ #
11
+ # Each worker owns one sandbox. A mutant is written into it, the test command runs, and the original file content
12
+ # is written back before the next mutant.
13
+ class Sandbox
14
+ DEFAULT_EXCLUDES = %w[.git coverage].freeze
15
+
16
+ attr_reader :dir
17
+
18
+ def initialize(root, excludes: [], prefix: 'mutaruby-')
19
+ @root = File.expand_path(root)
20
+ @excludes = DEFAULT_EXCLUDES + excludes
21
+ @prefix = prefix
22
+ @base = Dir.mktmpdir(prefix)
23
+ @dir = File.join(@base, File.basename(@root))
24
+ copy_tree
25
+ end
26
+
27
+ def path(rel)
28
+ File.join(@dir, rel)
29
+ end
30
+
31
+ # Writes `text` to `rel` inside the sandbox, yields, and restores the original bytes afterwards (also on error).
32
+ def with_file(rel, text, original)
33
+ target = path(rel)
34
+ File.binwrite(target, text)
35
+ yield
36
+ ensure
37
+ File.binwrite(target, original) if target
38
+ end
39
+
40
+ def cleanup
41
+ FileUtils.rm_rf(@base)
42
+ end
43
+
44
+ private
45
+
46
+ def excluded?(rel)
47
+ @excludes.any? do |pat|
48
+ File.fnmatch?(pat, rel, File::FNM_PATHNAME | File::FNM_DOTMATCH) ||
49
+ File.fnmatch?(pat, File.basename(rel), File::FNM_DOTMATCH)
50
+ end
51
+ end
52
+
53
+ # A sandbox directory (this one or another worker's). When the temporary directory lies inside the project (for
54
+ # example TMPDIR=./tmp), copying it would copy the sandbox into itself.
55
+ def sandbox?(path)
56
+ File.basename(path).start_with?(@prefix) && File.directory?(path) &&
57
+ File.realpath(File.dirname(path)) == File.realpath(File.dirname(@base))
58
+ end
59
+
60
+ def copy_tree
61
+ FileUtils.mkdir_p(@dir)
62
+ Find.find(@root) do |src|
63
+ next if src == @root
64
+
65
+ rel = src.delete_prefix("#{@root}/")
66
+ if excluded?(rel) || sandbox?(src)
67
+ Find.prune
68
+ next
69
+ end
70
+ dest = File.join(@dir, rel)
71
+ st = File.lstat(src)
72
+ if st.symlink?
73
+ File.symlink(File.readlink(src), dest)
74
+ elsif st.directory?
75
+ FileUtils.mkdir_p(dest)
76
+ elsif st.file?
77
+ FileUtils.cp(src, dest, preserve: true)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Mutaruby
6
+ # Which files to mutate and which of their mutants to run.
7
+ class Selection
8
+ DEFAULT_PATTERNS = ['lib/**/*.rb'].freeze
9
+
10
+ LineSpec = Struct.new(:file, :from, :to) do
11
+ def cover?(mutation)
12
+ (file.nil? || file == mutation.file) && mutation.lineno.between?(from, to)
13
+ end
14
+ end
15
+
16
+ def self.parse_line_spec(spec)
17
+ m = spec.match(/\A(?:(.+):)?(\d+)(?:-(\d+))?\z/)
18
+ raise Error, "bad line spec #{spec.inspect} (expected [FILE:]LINE or [FILE:]FROM-TO)" unless m
19
+
20
+ from = m[2].to_i
21
+ to = (m[3] || m[2]).to_i
22
+ raise Error, "bad line spec #{spec.inspect} (FROM > TO)" if from > to
23
+
24
+ LineSpec.new(m[1]&.sub(%r{\A\./}, ''), from, to)
25
+ end
26
+
27
+ def self.parse_shard(spec)
28
+ m = spec.to_s.match(%r{\A(\d+)/(\d+)\z})
29
+ raise Error, "bad --shard #{spec.inspect} (expected I/N, 1 <= I <= N)" unless m && m[2].to_i.positive? && m[1].to_i.between?(1, m[2].to_i)
30
+
31
+ [m[1].to_i, m[2].to_i]
32
+ end
33
+
34
+ def initialize(root:, patterns: [], since: nil, methods: [], exclude_methods: [], lines: [], exclude_lines: [],
35
+ shard: nil)
36
+ @root = File.expand_path(root)
37
+ @patterns = patterns.empty? ? DEFAULT_PATTERNS : patterns
38
+ @since = since
39
+ @methods = methods
40
+ @exclude_methods = exclude_methods
41
+ @lines = lines.map { |s| s.is_a?(LineSpec) ? s : self.class.parse_line_spec(s) }
42
+ @exclude_lines = exclude_lines.map { |s| s.is_a?(LineSpec) ? s : self.class.parse_line_spec(s) }
43
+ @shard = shard
44
+ end
45
+
46
+ # True when every mutant of each selected file is run (so stale ignore entries can be detected).
47
+ def complete?
48
+ @methods.empty? && @exclude_methods.empty? && @lines.empty? && @exclude_lines.empty? && @shard.nil?
49
+ end
50
+
51
+ # Sorted paths relative to the root.
52
+ def files
53
+ found = @patterns.flat_map do |pat|
54
+ full = File.expand_path(pat, @root)
55
+ if File.file?(full)
56
+ [relative(full)]
57
+ else
58
+ Dir.glob(pat, base: @root).select { |f| File.file?(File.join(@root, f)) }
59
+ end
60
+ end
61
+ found = found.map { |f| f.sub(%r{\A\./}, '') }.uniq.sort
62
+ found &= changed_files if @since
63
+ found
64
+ end
65
+
66
+ def filter(mutations)
67
+ kept = mutations.select do |m|
68
+ (@methods.empty? || @methods.any? { |p| method_match?(p, m.method) }) &&
69
+ @exclude_methods.none? { |p| method_match?(p, m.method) } &&
70
+ (@lines.empty? || @lines.any? { |s| s.cover?(m) }) &&
71
+ @exclude_lines.none? { |s| s.cover?(m) }
72
+ end
73
+ return kept unless @shard
74
+
75
+ i, n = @shard
76
+ kept.each_with_index.select { |_, idx| idx % n == i - 1 }.map(&:first)
77
+ end
78
+
79
+ private
80
+
81
+ def relative(full)
82
+ full.delete_prefix("#{@root}/")
83
+ end
84
+
85
+ # A pattern matches the qualified name ("Calc#add", "Calc.build", "Calc#*") or the bare method name ("add").
86
+ def method_match?(pattern, name)
87
+ return false if name.nil?
88
+
89
+ bare = name.split(/[#.]/).last
90
+ File.fnmatch?(pattern, name) || File.fnmatch?(pattern, bare)
91
+ end
92
+
93
+ # Files changed relative to a git ref: committed, staged and unstaged edits, plus new untracked files.
94
+ def changed_files
95
+ diff = git('diff', '--name-only', '--relative', @since, '--')
96
+ untracked = git('ls-files', '--others', '--exclude-standard')
97
+ (diff + untracked).uniq
98
+ end
99
+
100
+ def git(*args)
101
+ out, err, st = Open3.capture3('git', '-C', @root, *args)
102
+ raise Error, "git #{args.join(' ')} failed: #{err.strip}" unless st.success?
103
+
104
+ out.split("\n").reject(&:empty?)
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutaruby
4
+ # Raised when a splice would not replace exactly the node's own source. Aborts the whole run: a mutant placed at
5
+ # the wrong offset tests nothing, and if it "survives" it reports a test gap that does not exist.
6
+ class SpliceError < StandardError; end
7
+
8
+ # Replaces source[begin_pos...end_pos] with a replacement.
9
+ #
10
+ # The `parser` gem reports CHARACTER offsets, so the slice is taken by characters. Slicing by bytes would shift every
11
+ # mutant after the first multi-byte character in the file (an accented letter in a comment is enough) onto the wrong
12
+ # text. The self-check makes that class of bug (bytes vs characters, a stale buffer, a wrong range) fail loudly:
13
+ # the slice being replaced must be exactly the node's own source text, or nothing is spliced.
14
+ module Splicer
15
+ module_function
16
+
17
+ def splice(source, begin_pos, end_pos, expected, replacement)
18
+ actual = source[begin_pos...end_pos]
19
+ unless actual == expected
20
+ raise SpliceError, "splice self-check failed at offsets #{begin_pos}...#{end_pos}: " \
21
+ "the slice is #{actual.inspect}, the node's source is #{expected.inspect}"
22
+ end
23
+
24
+ source[0...begin_pos] + replacement + source[end_pos..]
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutaruby
4
+ VERSION = '0.1.0'
5
+ end
data/lib/mutaruby.rb ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # mutaruby: mutation testing for Ruby. See README.md.
4
+ module Mutaruby
5
+ class Error < StandardError; end
6
+ end
7
+
8
+ require_relative 'mutaruby/version'
9
+ require_relative 'mutaruby/parsing'
10
+ require_relative 'mutaruby/splicer'
11
+ require_relative 'mutaruby/mutation'
12
+ require_relative 'mutaruby/generator'
13
+ require_relative 'mutaruby/ignore_file'
14
+ require_relative 'mutaruby/sandbox'
15
+ require_relative 'mutaruby/command'
16
+ require_relative 'mutaruby/selection'
17
+ require_relative 'mutaruby/runner'
18
+ require_relative 'mutaruby/reporters'
19
+ require_relative 'mutaruby/cli'
metadata ADDED
@@ -0,0 +1,84 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mutaruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Atlas Authorization
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: parser
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '3.2'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '4'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '3.2'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '4'
32
+ description: mutaruby changes your code in small ways (mutants) and runs your test
33
+ command against each change. A mutant your tests do not notice is a gap in your
34
+ tests. Works with any test command (rspec, minitest, rake), runs every mutant in
35
+ a fresh process inside a private copy of the project, and reports a mutation score
36
+ as text, JSON or JUnit XML.
37
+ executables:
38
+ - mutaruby
39
+ extensions: []
40
+ extra_rdoc_files: []
41
+ files:
42
+ - CHANGELOG.md
43
+ - LICENSE
44
+ - README.md
45
+ - exe/mutaruby
46
+ - lib/mutaruby.rb
47
+ - lib/mutaruby/cli.rb
48
+ - lib/mutaruby/command.rb
49
+ - lib/mutaruby/generator.rb
50
+ - lib/mutaruby/ignore_file.rb
51
+ - lib/mutaruby/mutation.rb
52
+ - lib/mutaruby/parsing.rb
53
+ - lib/mutaruby/reporters.rb
54
+ - lib/mutaruby/runner.rb
55
+ - lib/mutaruby/sandbox.rb
56
+ - lib/mutaruby/selection.rb
57
+ - lib/mutaruby/splicer.rb
58
+ - lib/mutaruby/version.rb
59
+ homepage: https://github.com/Atlas-Authorization/mutaruby
60
+ licenses:
61
+ - MIT
62
+ metadata:
63
+ homepage_uri: https://github.com/Atlas-Authorization/mutaruby
64
+ source_code_uri: https://github.com/Atlas-Authorization/mutaruby
65
+ changelog_uri: https://github.com/Atlas-Authorization/mutaruby/blob/main/CHANGELOG.md
66
+ bug_tracker_uri: https://github.com/Atlas-Authorization/mutaruby/issues
67
+ rdoc_options: []
68
+ require_paths:
69
+ - lib
70
+ required_ruby_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '3.1'
75
+ required_rubygems_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '0'
80
+ requirements: []
81
+ rubygems_version: 4.0.16
82
+ specification_version: 4
83
+ summary: Free, MIT-licensed mutation testing for Ruby
84
+ test_files: []