mutaruby 0.1.0 → 0.1.1

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.
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'set'
4
- require_relative 'generator'
5
3
  require_relative 'ignore_file'
6
4
  require_relative 'sandbox'
7
5
  require_relative 'command'
@@ -31,82 +29,79 @@ module Mutaruby
31
29
 
32
30
  Options = Struct.new(:root, :command, :patterns, :since, :jobs, :timeout, :timeout_factor, :methods,
33
31
  :exclude_methods, :lines, :exclude_lines, :shard, :ignore_file, :operators, :copy_excludes,
34
- :progress, :keyword_init => true)
32
+ :progress, keyword_init: true) do
33
+ def self.build(**kw)
34
+ new(root: '.', patterns: [], jobs: 1, timeout_factor: 3.0, methods: [], exclude_methods: [], lines: [],
35
+ exclude_lines: [], copy_excludes: [], progress: false, **kw)
36
+ end
37
+ end
38
+
39
+ MARK = { killed: '.', timeout: 'T', survived: 'S', equivalent: 'e' }.freeze
35
40
 
36
41
  def initialize(opts, out: $stderr)
37
42
  @o = opts
38
43
  @out = out
39
- @root = File.expand_path(opts.root || Dir.pwd)
40
- @warnings = []
44
+ @root = File.expand_path(opts.root)
41
45
  end
42
46
 
43
47
  # Mutants of the selected files after every filter, plus what is needed to report them.
44
48
  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? }
49
+ sel = selection
50
+ files = sel.files
51
+ sources = files.to_h { |f| [f, File.read(File.join(@root, f), encoding: 'UTF-8')] }
52
+ warnings = []
53
+ all = files.flat_map do |f|
54
+ g = Generator.new(sources[f], path: File.join(@root, f), rel: f, operators: @o.operators)
55
+ ms = g.mutations
56
+ warnings.concat(g.errors)
57
+ ms
62
58
  end
59
+ all.each_with_index { |m, i| m.id = i }
60
+ ignore = load_ignore
61
+ errs = ignore ? ignore.validate(sources) : []
62
+ raise Error, "ignore file #{ignore.path}:\n #{errs.join("\n ")}" if errs.any?
63
+
64
+ { files: files, sources: sources, mutations: sel.filter(all), ignore: ignore, warnings: warnings,
65
+ complete: sel.complete? && @o.operators.nil? }
63
66
  end
64
67
 
65
68
  def run
66
- t0 = monotonic
67
- p = plan
68
69
  sandboxes = []
69
- sandboxes << Sandbox.new(@root, excludes: @o.copy_excludes || [])
70
70
  cmd = Command.new(@o.command, root: @root)
71
- base = cmd.run(sandboxes[0].dir, capture: true)
71
+ t0 = monotonic
72
+ p = plan
73
+ first = Sandbox.new(@root, excludes: @o.copy_excludes)
74
+ sandboxes << first
75
+ base = cmd.run(first.dir, capture: true)
72
76
  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}"
77
+ raise BaselineFailed, "the test command fails on the UNMUTATED code (exit #{base.exitstatus}); every mutant " \
78
+ "would count as killed, so nothing would be measured.\ncommand: #{@o.command}\n" \
79
+ "--- output (tail) ---\n#{base.output}"
76
80
  end
77
- timeout = @o.timeout || ((base.seconds * (@o.timeout_factor || 3.0)) + 5).ceil
81
+ timeout = @o.timeout || ((base.seconds * @o.timeout_factor) + 5).ceil
78
82
  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
83
+ matched = outcomes.select { |o| o.status == :equivalent }.map(&:ignore)
84
+ stale = p[:ignore] && p[:complete] ? p[:ignore].stale(matched, p[:files]) : []
84
85
  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
+ stale: stale, warnings: p[:warnings], baseline_seconds: base.seconds, timeout: timeout,
86
87
  jobs: jobs, seconds: monotonic - t0, complete: p[:complete])
87
- rescue Interrupt, SignalException
88
- cmd&.kill_all
88
+ rescue Interrupt
89
+ cmd.kill_all
89
90
  raise
90
91
  ensure
91
- sandboxes&.each(&:cleanup)
92
+ sandboxes.each(&:cleanup)
92
93
  end
93
94
 
94
95
  private
95
96
 
96
97
  def jobs
97
- [@o.jobs || 1, 1].max
98
+ [@o.jobs, 1].max
98
99
  end
99
100
 
100
101
  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
102
+ Selection.new(root: @root, patterns: @o.patterns, since: @o.since, methods: @o.methods,
103
+ exclude_methods: @o.exclude_methods, lines: @o.lines, exclude_lines: @o.exclude_lines,
104
+ shard: @o.shard)
110
105
  end
111
106
 
112
107
  def load_ignore
@@ -124,30 +119,13 @@ module Mutaruby
124
119
  queue = Queue.new
125
120
  plan[:mutations].each { |m| queue << m }
126
121
  n = [jobs, plan[:mutations].size].min
127
- (sandboxes.size...n).each { sandboxes << Sandbox.new(@root, excludes: @o.copy_excludes || []) }
122
+ sandboxes << Sandbox.new(@root, excludes: @o.copy_excludes) while sandboxes.size < n
128
123
  results = []
129
124
  lock = Mutex.new
130
- threads = Array.new(n) do |w|
125
+ threads = sandboxes.map do |box|
131
126
  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)
127
+ while (m = next_mutation(queue))
128
+ o = evaluate(m, box, plan, cmd, timeout)
151
129
  lock.synchronize do
152
130
  results << o
153
131
  progress(o)
@@ -157,7 +135,7 @@ module Mutaruby
157
135
  end
158
136
  begin
159
137
  threads.each(&:join)
160
- rescue Exception # rubocop:disable Lint/RescueException -- stop the workers, then re-raise
138
+ rescue Interrupt
161
139
  threads.each(&:kill)
162
140
  raise
163
141
  end
@@ -165,7 +143,24 @@ module Mutaruby
165
143
  results.sort_by { |o| o.mutation.id }
166
144
  end
167
145
 
168
- MARK = { killed: '.', timeout: 'T', survived: 'S', equivalent: 'e' }.freeze
146
+ def next_mutation(queue)
147
+ queue.pop(true)
148
+ rescue ThreadError
149
+ nil
150
+ end
151
+
152
+ def evaluate(m, box, plan, cmd, timeout)
153
+ r = box.with_file(m.file, m.text, plan[:sources][m.file]) do
154
+ cmd.run(box.dir, timeout: timeout, env: { 'MUTARUBY_MUTANT_ID' => m.id.to_s, 'MUTARUBY_FILE' => m.file })
155
+ end
156
+ entry = plan[:ignore]&.match(m)
157
+ status = if r.timeout? then :timeout
158
+ elsif !r.passed? then :killed
159
+ elsif entry then :equivalent
160
+ else :survived
161
+ end
162
+ Outcome.new(mutation: m, status: status, seconds: r.seconds, ignore: entry)
163
+ end
169
164
 
170
165
  def progress(outcome)
171
166
  return unless @o.progress
@@ -12,14 +12,17 @@ module Mutaruby
12
12
  # is written back before the next mutant.
13
13
  class Sandbox
14
14
  DEFAULT_EXCLUDES = %w[.git coverage].freeze
15
+ PREFIX = 'mutaruby-'
15
16
 
16
17
  attr_reader :dir
17
18
 
18
- def initialize(root, excludes: [], prefix: 'mutaruby-')
19
- @root = File.expand_path(root)
19
+ def initialize(root, excludes: [])
20
+ # The real path: Find does not descend into a symlink, so a root reached through one (/tmp on macOS) would
21
+ # otherwise be copied as an empty directory.
22
+ @root = File.realpath(root)
20
23
  @excludes = DEFAULT_EXCLUDES + excludes
21
- @prefix = prefix
22
- @base = Dir.mktmpdir(prefix)
24
+ @base = Dir.mktmpdir(PREFIX)
25
+ @tmp_parent = File.realpath(File.dirname(@base))
23
26
  @dir = File.join(@base, File.basename(@root))
24
27
  copy_tree
25
28
  end
@@ -31,10 +34,12 @@ module Mutaruby
31
34
  # Writes `text` to `rel` inside the sandbox, yields, and restores the original bytes afterwards (also on error).
32
35
  def with_file(rel, text, original)
33
36
  target = path(rel)
34
- File.binwrite(target, text)
35
- yield
36
- ensure
37
- File.binwrite(target, original) if target
37
+ begin
38
+ File.binwrite(target, text)
39
+ yield
40
+ ensure
41
+ File.binwrite(target, original)
42
+ end
38
43
  end
39
44
 
40
45
  def cleanup
@@ -51,10 +56,10 @@ module Mutaruby
51
56
  end
52
57
 
53
58
  # 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.
59
+ # example TMPDIR=./tmp), copying it would copy the sandbox into itself. Paths found under the real root are real.
55
60
  def sandbox?(path)
56
- File.basename(path).start_with?(@prefix) && File.directory?(path) &&
57
- File.realpath(File.dirname(path)) == File.realpath(File.dirname(@base))
61
+ File.directory?(path) && File.basename(path).start_with?(PREFIX) &&
62
+ File.dirname(path) == @tmp_parent
58
63
  end
59
64
 
60
65
  def copy_tree
@@ -63,17 +68,14 @@ module Mutaruby
63
68
  next if src == @root
64
69
 
65
70
  rel = src.delete_prefix("#{@root}/")
66
- if excluded?(rel) || sandbox?(src)
67
- Find.prune
68
- next
69
- end
71
+ Find.prune if excluded?(rel) || sandbox?(src)
70
72
  dest = File.join(@dir, rel)
71
73
  st = File.lstat(src)
72
74
  if st.symlink?
73
75
  File.symlink(File.readlink(src), dest)
74
76
  elsif st.directory?
75
77
  FileUtils.mkdir_p(dest)
76
- elsif st.file?
78
+ elsif st.file? # not sockets, FIFOs or devices: copying a FIFO would block
77
79
  FileUtils.cp(src, dest, preserve: true)
78
80
  end
79
81
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'open3'
4
+ require_relative 'error'
4
5
 
5
6
  module Mutaruby
6
7
  # Which files to mutate and which of their mutants to run.
@@ -21,16 +22,17 @@ module Mutaruby
21
22
  to = (m[3] || m[2]).to_i
22
23
  raise Error, "bad line spec #{spec.inspect} (FROM > TO)" if from > to
23
24
 
24
- LineSpec.new(m[1]&.sub(%r{\A\./}, ''), from, to)
25
+ LineSpec.new(m[1]&.delete_prefix('./'), from, to)
25
26
  end
26
27
 
27
28
  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)
29
+ i, n = spec.match(%r{\A(\d+)/(\d+)\z})&.captures&.map(&:to_i)
30
+ raise Error, "bad --shard #{spec.inspect} (expected I/N, 1 <= I <= N)" unless i&.between?(1, n)
30
31
 
31
- [m[1].to_i, m[2].to_i]
32
+ [i, n]
32
33
  end
33
34
 
35
+ # lines / exclude_lines: LineSpec objects (see parse_line_spec). shard: [i, n] or nil.
34
36
  def initialize(root:, patterns: [], since: nil, methods: [], exclude_methods: [], lines: [], exclude_lines: [],
35
37
  shard: nil)
36
38
  @root = File.expand_path(root)
@@ -38,8 +40,8 @@ module Mutaruby
38
40
  @since = since
39
41
  @methods = methods
40
42
  @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
+ @lines = lines
44
+ @exclude_lines = exclude_lines
43
45
  @shard = shard
44
46
  end
45
47
 
@@ -48,19 +50,16 @@ module Mutaruby
48
50
  @methods.empty? && @exclude_methods.empty? && @lines.empty? && @exclude_lines.empty? && @shard.nil?
49
51
  end
50
52
 
51
- # Sorted paths relative to the root.
53
+ # Sorted paths of the matching files, relative to the root.
52
54
  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
55
+ found = @patterns.flat_map { |pat| Dir.glob(pat, base: @root) }.map do |f|
56
+ full = File.expand_path(f, @root)
57
+ raise Error, "#{f} is outside the project root #{@root}" unless full.start_with?("#{@root}/")
58
+
59
+ full.delete_prefix("#{@root}/")
60
60
  end
61
- found = found.map { |f| f.sub(%r{\A\./}, '') }.uniq.sort
62
- found &= changed_files if @since
63
- found
61
+ found = found.select { |f| File.file?(File.join(@root, f)) }.uniq.sort
62
+ @since ? found & changed_files : found
64
63
  end
65
64
 
66
65
  def filter(mutations)
@@ -73,35 +72,27 @@ module Mutaruby
73
72
  return kept unless @shard
74
73
 
75
74
  i, n = @shard
76
- kept.each_with_index.select { |_, idx| idx % n == i - 1 }.map(&:first)
75
+ kept.select.with_index { |_, idx| idx % n == i - 1 }
77
76
  end
78
77
 
79
78
  private
80
79
 
81
- def relative(full)
82
- full.delete_prefix("#{@root}/")
83
- end
84
-
85
80
  # A pattern matches the qualified name ("Calc#add", "Calc.build", "Calc#*") or the bare method name ("add").
81
+ # Code outside any method matches no pattern.
86
82
  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)
83
+ name ? File.fnmatch?(pattern, name) || File.fnmatch?(pattern, name.split(/[#.]/).last) : false
91
84
  end
92
85
 
93
86
  # Files changed relative to a git ref: committed, staged and unstaged edits, plus new untracked files.
94
87
  def changed_files
95
- diff = git('diff', '--name-only', '--relative', @since, '--')
96
- untracked = git('ls-files', '--others', '--exclude-standard')
97
- (diff + untracked).uniq
88
+ git('diff', '--name-only', '--relative', @since, '--') + git('ls-files', '--others', '--exclude-standard')
98
89
  end
99
90
 
100
91
  def git(*args)
101
92
  out, err, st = Open3.capture3('git', '-C', @root, *args)
102
93
  raise Error, "git #{args.join(' ')} failed: #{err.strip}" unless st.success?
103
94
 
104
- out.split("\n").reject(&:empty?)
95
+ out.lines(chomp: true)
105
96
  end
106
97
  end
107
98
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mutaruby
4
- VERSION = '0.1.0'
4
+ VERSION = '0.1.1'
5
5
  end
data/lib/mutaruby.rb CHANGED
@@ -1,19 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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
4
  require_relative 'mutaruby/cli'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mutaruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Atlas Authorization
@@ -46,6 +46,7 @@ files:
46
46
  - lib/mutaruby.rb
47
47
  - lib/mutaruby/cli.rb
48
48
  - lib/mutaruby/command.rb
49
+ - lib/mutaruby/error.rb
49
50
  - lib/mutaruby/generator.rb
50
51
  - lib/mutaruby/ignore_file.rb
51
52
  - lib/mutaruby/mutation.rb