mutaruby 0.1.1 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a7881b725c1ef86850ff3a13370a7f3217bfd3a943f152d26465ff62439559ef
4
- data.tar.gz: 55071854a05f0a10e85c749eb3a2984e04591ab6dcca7d69c5cc674675b01796
3
+ metadata.gz: 0fb14367f23f77c599ae74ba8f36f1d5e0e71c73ab8fa8267378569a3a359655
4
+ data.tar.gz: 036b55cb7754d44886e7056f0d63dda5725a0caf7037dc8eff50d49bf7df73fb
5
5
  SHA512:
6
- metadata.gz: ab4e2654379b04db8da0e6ef63fedcf918f9eb2fc8632d3632ea1412b86cd43e68e0a3f85ca998b424d48d8da4bd1bc0099d19af0369a124d1687ed49cfa2db1
7
- data.tar.gz: 2e26578a0581acde2d08c3ab7876a2d5868a05c91af2e1805f0609727582fdf32c2847be1571d42aa64158e0b5e5a0d4e4d5c78548e6b44a41f3513b388f453e
6
+ metadata.gz: 718044deb537125a73dfc0864aeea698594711196ecf12ba16655fac80e6fa23e6c5ef2ef478d360b270da7f3264e7fccc7d8f5bff2bf88e2863cbe61e937d8c
7
+ data.tar.gz: 7fb23d15489c6dae51343866c850e7e4a9ccf049f966e8521fa20fbe92a3eaca1f52bde4014a5387151d1aa36febbc44c92c20708e83fbf22e573dbddbd09f9e
data/CHANGELOG.md CHANGED
@@ -3,6 +3,28 @@
3
3
  All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [0.2.0] - 2026-10-12
7
+
8
+ ### Added
9
+
10
+ - Windows support. The test command runs under `cmd.exe` in a new process group; on timeout the whole process tree
11
+ is stopped with `taskkill /T` (graceful first, then `/F` after the grace period). Sandboxes get short names, writes
12
+ and deletes are retried while a file is locked, and symlinks fall back to copies where Windows does not allow
13
+ them. See "Windows notes" in the README.
14
+ - `Mutaruby::Platform`, which holds everything that differs between POSIX systems and Windows. The POSIX behaviour is
15
+ unchanged.
16
+ - CI on Linux, macOS and Windows (Ruby 3.1 to 3.4), including a fixture run whose infinite-loop mutant must be
17
+ stopped by the timeout.
18
+
19
+ ### Fixed
20
+
21
+ - Files with CRLF line endings aborted the run with a splice self-check error: `parser` reads `\r\n` as `\n`, so its
22
+ offsets did not match the file. Offsets are now mapped back to the file's own characters, and a mutant keeps the
23
+ file's line endings (mixed endings included).
24
+ - Sources are read as bytes and decoded as UTF-8, with no newline conversion on Windows; ignore files are read as
25
+ UTF-8 whatever the default external encoding.
26
+ - Sandbox removal no longer silently leaves files behind when one is locked.
27
+
6
28
  ## [0.1.1] - 2026-10-12
7
29
 
8
30
  mutaruby now scores 100% on its own code: every mutant is killed by a test or listed, with a checkable reason, in
@@ -49,5 +71,6 @@ First release.
49
71
  reason, ambiguity and staleness checks, `--strict-ignore` and `--suggest-ignore`.
50
72
  - Human, JSON and JUnit XML reports, a mutation score and `--fail-under` for CI.
51
73
 
74
+ [0.2.0]: https://github.com/Atlas-Authorization/mutaruby/releases/tag/v0.2.0
52
75
  [0.1.1]: https://github.com/Atlas-Authorization/mutaruby/releases/tag/v0.1.1
53
76
  [0.1.0]: https://github.com/Atlas-Authorization/mutaruby/releases/tag/v0.1.0
data/README.md CHANGED
@@ -5,8 +5,8 @@
5
5
 
6
6
  Free, MIT-licensed mutation testing for Ruby.
7
7
 
8
- mutaruby is mutation-tested with itself. In version 0.1.1, all 3,619 mutants of its own code are accounted for:
9
- 3,613 are killed by its tests (499 of them by timeout), and the other 6 are true equivalents, each listed in
8
+ mutaruby is mutation-tested with itself. In version 0.2.0, all 3,812 mutants of its own code are accounted for:
9
+ 3,806 are killed by its tests (521 of them by timeout), and the other 6 are true equivalents, each listed in
10
10
  [`.mutaruby-ignore.yml`](.mutaruby-ignore.yml) with a reason you can check. `bundle exec rake mutate` reproduces the
11
11
  run; it fails on any surviving mutant or stale ignore entry.
12
12
 
@@ -48,6 +48,7 @@ The survivor above tells you that no test checks a total of exactly 100.
48
48
  - [Output formats](#output-formats)
49
49
  - [How it compares with mutant](#how-it-compares-with-mutant)
50
50
  - [Design notes](#design-notes)
51
+ - [Windows notes](#windows-notes)
51
52
  - [Limitations](#limitations)
52
53
  - [Contributing, security, licence](#contributing-security-licence)
53
54
 
@@ -84,7 +85,7 @@ end
84
85
  ```
85
86
 
86
87
  mutaruby needs Ruby 3.1 or newer and depends only on the [`parser`](https://github.com/whitequark/parser) gem. It runs
87
- on Linux and macOS (it uses process groups and `/bin/sh`).
88
+ on Linux, macOS and Windows (see [Windows notes](#windows-notes)); CI tests all three.
88
89
 
89
90
  ## Quickstart
90
91
 
@@ -134,7 +135,7 @@ Positional arguments are files or globs relative to the project root. The defaul
134
135
 
135
136
  | Option | Meaning |
136
137
  |---|---|
137
- | `-t`, `--test COMMAND` | **Required.** Shell command (run with `/bin/sh -c`) that runs your tests. Exit 0 means the mutant survived, non-zero means killed. |
138
+ | `-t`, `--test COMMAND` | **Required.** Shell command that runs your tests: run with `/bin/sh -c` on Linux and macOS, with `cmd.exe` on Windows. Exit 0 means the mutant survived, non-zero means killed. |
138
139
  | `-C`, `--root DIR` | Project root (default: the current directory). This is the directory copied into each sandbox, and the command runs there. |
139
140
  | `-j`, `--jobs N` | Parallel workers (default: the number of CPUs). Each worker has its own copy of the project. |
140
141
  | `--timeout SECONDS` | Per-mutant time limit. A mutant that exceeds it (an infinite loop, say) is killed together with every process it started and counted as **killed, by timeout**. The default is `baseline time x factor + 5` seconds. |
@@ -360,7 +361,7 @@ at any project with one command, mutaruby is for you.
360
361
 
361
362
  ## Design notes
362
363
 
363
- **A fresh process per mutant.** Every run, the baseline and each mutant, is a new `/bin/sh -c COMMAND` with its
364
+ **A fresh process per mutant.** Every run, the baseline and each mutant, is a new shell (`/bin/sh -c COMMAND`; `cmd.exe` on Windows) with its
364
365
  own Ruby. Nothing loaded by one run can leak into the next. This matters more than it looks. When mutants are
365
366
  evaluated inside one long-lived process, deleting a `require` cannot be detected because the library is already loaded,
366
367
  and a deleted method or constant can quietly keep its old definition. Those mutants then "survive" and report test
@@ -391,9 +392,33 @@ the next one.
391
392
 
392
393
  **Site keys instead of line numbers** for the ignore file; see [above](#equivalent-mutants-the-ignore-file).
393
394
 
395
+ ## Windows notes
396
+
397
+ mutaruby works the same way on Windows, with these differences:
398
+
399
+ - **The shell is `cmd.exe`.** Quote with double quotes, not single quotes: `mutaruby -t "bundle exec rspec"` works in
400
+ both shells, but `-t 'ruby -e "exit 1"'` is POSIX-only quoting. In `cmd.exe` the inner quotes have to be escaped
401
+ (or put the command in a `.bat` file, or in a Rake task, and call that). Variables are written `%NAME%`, not
402
+ `$NAME`.
403
+ - **Stopping a run.** Each run starts in a new process group. On timeout its whole process tree is stopped with
404
+ `taskkill /T` (first without `/F`, then, after the same two-second grace period, with `/F`). Unlike on POSIX
405
+ systems, processes that a run leaves behind after it exits normally are not stopped: Windows finds a tree through
406
+ the parent process, which is gone by then, and its id may already belong to an unrelated process. Make test
407
+ helpers that start servers stop them.
408
+ - **Sandboxes** are named `mr…` (shorter than `mutaruby-…`) under `%TEMP%`, because classic Windows paths are
409
+ limited to 260 characters. For a deeply nested project, enable long paths or point `TMP`/`TEMP` at a short
410
+ directory such as `C:\t`.
411
+ - **Locked files.** A process that is still exiting, or a virus scanner, can hold a file open for a moment. Writes
412
+ into the sandbox and its removal are retried (10 times, 0.1 s apart) before mutaruby gives up.
413
+ - **Symlinks** in the project are copied as symlinks when Windows allows it (Developer Mode or the symlink
414
+ privilege), and otherwise replaced by a copy of what they point to.
415
+ - **Line endings.** Files with CRLF (or mixed) line endings are mutated in place without changing their line endings.
416
+ Sources are read as UTF-8 regardless of the console code page.
417
+
394
418
  ## Limitations
395
419
 
396
- - Linux and macOS only (process groups, `/bin/sh`). Windows is not supported.
420
+ - On Windows, processes that a test run leaves running after it exits normally are not stopped (see
421
+ [Windows notes](#windows-notes)).
397
422
  - The test command is run as is for every mutant; mutaruby does not work out which tests cover which code.
398
423
  - The parser grammar is chosen to match the running Ruby, up to the newest grammar the installed `parser` gem knows.
399
424
  Files that `parser` cannot read (for example syntax newer than it supports) are skipped with a warning.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'tempfile'
4
+ require_relative 'platform'
4
5
 
5
6
  module Mutaruby
6
7
  # Runs the user's test command once, in a fresh process (a new shell and everything it starts), inside a sandbox.
@@ -16,18 +17,19 @@ module Mutaruby
16
17
  KILL_GRACE = 2
17
18
  POLL = 0.05
18
19
 
19
- def initialize(command, root:, kill_grace: KILL_GRACE)
20
+ def initialize(command, root:, kill_grace: KILL_GRACE, platform: Platform.current)
20
21
  @command = command
22
+ @platform = platform
21
23
  @kill_grace = kill_grace
22
24
  @root = File.expand_path(root)
23
- @live = []
25
+ @live = {} # pid => the thread reaping it
24
26
  @lock = Mutex.new
25
27
  end
26
28
 
27
29
  # Kills every run still in progress (used when mutaruby itself is interrupted: the runs live in their own process
28
30
  # groups, so a Ctrl-C in the terminal does not reach them).
29
31
  def kill_all
30
- @lock.synchronize { @live.each { |pid| kill_group(pid) } }
32
+ @lock.synchronize { @live.each { |pid, waiter| kill_group(pid, waiter) } }
31
33
  end
32
34
 
33
35
  # dir: sandbox project dir. timeout: seconds (nil = none). capture: keep an output tail (for the baseline report).
@@ -46,13 +48,13 @@ module Mutaruby
46
48
 
47
49
  def execute(dir, timeout, env, out)
48
50
  started = monotonic
49
- pid = Process.spawn(child_env(dir).merge(env), '/bin/sh', '-c', @command,
50
- chdir: dir, pgroup: true, in: File::NULL, out: out, err: out)
51
- @lock.synchronize { @live << pid }
51
+ pid = Process.spawn(child_env(dir).merge(env), *@platform.spawn_args(@command),
52
+ chdir: dir, in: File::NULL, out: out, err: out, **@platform.spawn_options)
52
53
  waiter = Process.detach(pid)
54
+ @lock.synchronize { @live[pid] = waiter }
53
55
  timed_out = waiter.join(timeout).nil?
54
- # A timed-out run, and anything a finished run left behind (a server, a forked worker), must not outlive it.
55
- kill_group(pid)
56
+ # A timed-out run, and (where the platform allows) anything a finished run left behind, must not outlive it.
57
+ kill_group(pid, waiter) if timed_out || @platform.stop_leftovers?
56
58
  st = waiter.value
57
59
  @lock.synchronize { @live.delete(pid) }
58
60
  status = if timed_out then :timeout
@@ -78,25 +80,12 @@ module Mutaruby
78
80
  env
79
81
  end
80
82
 
81
- # SIGTERM to the whole group, up to kill_grace seconds to exit, then SIGKILL.
82
- def kill_group(pid)
83
- signal_group('TERM', pid)
83
+ # Stop the whole tree gracefully, give it up to kill_grace seconds, then force it.
84
+ def kill_group(pid, waiter)
85
+ @platform.stop_tree(pid, graceful: true)
84
86
  deadline = monotonic + @kill_grace
85
- sleep POLL while monotonic < deadline && alive?(pid)
86
- signal_group('KILL', pid)
87
- end
88
-
89
- def signal_group(sig, pid)
90
- Process.kill(sig, -pid)
91
- rescue Errno::ESRCH, Errno::EPERM
92
- # the group is already gone
93
- end
94
-
95
- def alive?(pid)
96
- Process.kill(0, -pid)
97
- true
98
- rescue Errno::ESRCH, Errno::EPERM
99
- false
87
+ sleep POLL while monotonic < deadline && @platform.alive?(pid, waiter)
88
+ @platform.stop_tree(pid, graceful: false)
100
89
  end
101
90
 
102
91
  def tail(bytes)
@@ -97,7 +97,7 @@ module Mutaruby
97
97
  @errors << "#{@path}: cannot parse (not valid #{@src.encoding}); skipped"
98
98
  return []
99
99
  end
100
- ast = Parsing.parse(@path, @src)
100
+ ast = Parsing.parse(@path, normalize_newlines)
101
101
  @out = []
102
102
  @seen = {}
103
103
  @scope = [] # namespace names ("Foo", "Bar")
@@ -109,6 +109,28 @@ module Mutaruby
109
109
  []
110
110
  end
111
111
 
112
+ # `parser` reads every "\r\n" as "\n", so its offsets count one character per CRLF line break. The source is
113
+ # parsed in that form; @crlf records where (in parser offsets) a "\n" stands for "\r\n" in the file, so every
114
+ # splice is made at the file's own character offsets and the file keeps its line endings.
115
+ def normalize_newlines
116
+ @crlf = {}
117
+ norm = @src.gsub("\r\n") do
118
+ @crlf[$~.begin(0) - @crlf.size] = true
119
+ "\n"
120
+ end
121
+ @newline = @crlf.empty? ? "\n" : "\r\n"
122
+ norm
123
+ end
124
+
125
+ def original_offset(pos)
126
+ pos + @crlf.count { |q, _| q < pos }
127
+ end
128
+
129
+ # A slice of the parsed source as it reads in the file.
130
+ def with_crlf(slice, start)
131
+ slice.each_char.with_index.map { |c, i| @crlf[start + i] ? "\r\n" : c }.join
132
+ end
133
+
112
134
  # lineno => 1-based index of that line among the identical (stripped) lines of the file.
113
135
  def occurrence_index(lines)
114
136
  seen = Hash.new(0)
@@ -130,7 +152,9 @@ module Mutaruby
130
152
  line = @lines[lineno - 1]
131
153
  return if line.include?(DISABLE_MARK)
132
154
 
133
- text = Splicer.splice(@src, range.begin_pos, range.end_pos, orig, repl)
155
+ b = range.begin_pos
156
+ text = Splicer.splice(@src, original_offset(b), original_offset(range.end_pos), with_crlf(orig, b),
157
+ repl.gsub("\n", @newline))
134
158
  return if @seen[text]
135
159
 
136
160
  @seen[text] = true
@@ -38,7 +38,7 @@ module Mutaruby
38
38
  attr_reader :entries, :path
39
39
 
40
40
  def self.load(path)
41
- new(YAML.safe_load(File.read(path)), path: path)
41
+ new(YAML.safe_load(File.read(path, encoding: 'UTF-8')), path: path)
42
42
  rescue Psych::SyntaxError => e
43
43
  raise Invalid, "#{path}: not valid YAML (#{e})"
44
44
  end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rbconfig'
4
+
5
+ module Mutaruby
6
+ # Everything that differs between POSIX systems and Windows: how a test command is started, how its whole process
7
+ # tree is stopped, and how sandboxes are named.
8
+ module Platform
9
+ # Linux, macOS and the BSDs: the command runs under /bin/sh as the leader of its own process group, and the group
10
+ # is signalled as a whole.
11
+ class Posix
12
+ def name = 'posix'
13
+
14
+ def spawn_args(command) = ['/bin/sh', '-c', command]
15
+
16
+ def spawn_options = { pgroup: true }
17
+
18
+ def temp_prefix = 'mutaruby-'
19
+
20
+ # After a run ends, whatever is left of its process group is stopped too.
21
+ def stop_leftovers? = true
22
+
23
+ # graceful: SIGTERM, otherwise SIGKILL, to the process group led by pid.
24
+ def stop_tree(pid, graceful:)
25
+ Process.kill(graceful ? 'TERM' : 'KILL', -pid)
26
+ rescue Errno::ESRCH, Errno::EPERM
27
+ # the group is already gone
28
+ end
29
+
30
+ # waiter: the thread reaping the run's leader (Process.detach); unused here, the group decides.
31
+ def alive?(pid, _waiter)
32
+ Process.kill(0, -pid)
33
+ true
34
+ rescue Errno::ESRCH, Errno::EPERM
35
+ false
36
+ end
37
+ end
38
+
39
+ # Windows: the command string runs under cmd.exe (Process.spawn with a single string does that) in a new process
40
+ # group, and the tree below the command's process is stopped with taskkill /T (without /F first, then with /F).
41
+ class Windows
42
+ # system: how taskkill is run (replaceable in tests).
43
+ def initialize(system: ->(*argv) { Kernel.system(*argv, out: File::NULL, err: File::NULL) })
44
+ @system = system
45
+ end
46
+
47
+ def name = 'windows'
48
+
49
+ def spawn_args(command) = [command]
50
+
51
+ def spawn_options = { new_pgroup: true }
52
+
53
+ # Short: Windows paths are limited to 260 characters unless long paths are enabled.
54
+ def temp_prefix = 'mr'
55
+
56
+ # Not after a normal exit: taskkill /T finds the tree through the (now exited) parent process, and Windows may
57
+ # already have reused its id for an unrelated process.
58
+ def stop_leftovers? = false
59
+
60
+ def stop_tree(pid, graceful:)
61
+ @system.call('taskkill', '/T', *(graceful ? [] : ['/F']), '/PID', pid.to_s)
62
+ end
63
+
64
+ # While the thread reaping the command's process (Process.detach) runs, that process has not exited. (Signal 0
65
+ # is not a reliable liveness test on Windows: it can succeed for a process that has already exited.)
66
+ def alive?(_pid, waiter)
67
+ waiter.alive?
68
+ end
69
+ end
70
+
71
+ def self.windows?(host_os = RbConfig::CONFIG['host_os'])
72
+ host_os.match?(/mswin|mingw|cygwin/)
73
+ end
74
+
75
+ def self.for(host_os)
76
+ windows?(host_os) ? Windows.new : Posix.new
77
+ end
78
+
79
+ def self.current
80
+ @current ||= self.for(RbConfig::CONFIG['host_os'])
81
+ end
82
+ end
83
+ end
@@ -48,7 +48,8 @@ module Mutaruby
48
48
  def plan
49
49
  sel = selection
50
50
  files = sel.files
51
- sources = files.to_h { |f| [f, File.read(File.join(@root, f), encoding: 'UTF-8')] }
51
+ # Bytes as they are (no newline conversion on Windows), read as UTF-8 whatever the default encoding.
52
+ sources = files.to_h { |f| [f, File.binread(File.join(@root, f)).force_encoding(Encoding::UTF_8)] }
52
53
  warnings = []
53
54
  all = files.flat_map do |f|
54
55
  g = Generator.new(sources[f], path: File.join(@root, f), rel: f, operators: @o.operators)
@@ -3,6 +3,7 @@
3
3
  require 'fileutils'
4
4
  require 'find'
5
5
  require 'tmpdir'
6
+ require_relative 'platform'
6
7
 
7
8
  module Mutaruby
8
9
  # A private copy of the project. Every test-command run happens inside one, so the user's files are never written:
@@ -12,16 +13,35 @@ module Mutaruby
12
13
  # is written back before the next mutant.
13
14
  class Sandbox
14
15
  DEFAULT_EXCLUDES = %w[.git coverage].freeze
15
- PREFIX = 'mutaruby-'
16
+ # Writes and deletes are retried this many times, RETRY_DELAY seconds apart, while the file is locked (on Windows a
17
+ # process that is still exiting, or a virus scanner, can hold it for a moment).
18
+ RETRIES = 10
19
+ RETRY_DELAY = 0.1
20
+ LOCKED = [Errno::EACCES, Errno::EBUSY, Errno::EPERM].freeze
21
+
22
+ # Runs the block, retrying while it raises one of the LOCKED errors.
23
+ def self.retrying(tries: RETRIES, delay: RETRY_DELAY)
24
+ attempt = 1
25
+ begin
26
+ yield
27
+ rescue *LOCKED
28
+ raise if attempt >= tries
29
+
30
+ attempt += 1
31
+ sleep delay
32
+ retry
33
+ end
34
+ end
16
35
 
17
36
  attr_reader :dir
18
37
 
19
- def initialize(root, excludes: [])
38
+ def initialize(root, excludes: [], platform: Platform.current)
20
39
  # The real path: Find does not descend into a symlink, so a root reached through one (/tmp on macOS) would
21
40
  # otherwise be copied as an empty directory.
22
41
  @root = File.realpath(root)
23
42
  @excludes = DEFAULT_EXCLUDES + excludes
24
- @base = Dir.mktmpdir(PREFIX)
43
+ @prefix = platform.temp_prefix
44
+ @base = Dir.mktmpdir(@prefix)
25
45
  @tmp_parent = File.realpath(File.dirname(@base))
26
46
  @dir = File.join(@base, File.basename(@root))
27
47
  copy_tree
@@ -35,15 +55,16 @@ module Mutaruby
35
55
  def with_file(rel, text, original)
36
56
  target = path(rel)
37
57
  begin
38
- File.binwrite(target, text)
58
+ Sandbox.retrying { File.binwrite(target, text) }
39
59
  yield
40
60
  ensure
41
- File.binwrite(target, original)
61
+ Sandbox.retrying { File.binwrite(target, original) }
42
62
  end
43
63
  end
44
64
 
65
+ # Removes the sandbox. Unlike rm_rf, this does not silently leave a locked file behind: it retries, then raises.
45
66
  def cleanup
46
- FileUtils.rm_rf(@base)
67
+ Sandbox.retrying { FileUtils.rm_r(@base) if File.exist?(@base) }
47
68
  end
48
69
 
49
70
  private
@@ -58,10 +79,18 @@ module Mutaruby
58
79
  # A sandbox directory (this one or another worker's). When the temporary directory lies inside the project (for
59
80
  # example TMPDIR=./tmp), copying it would copy the sandbox into itself. Paths found under the real root are real.
60
81
  def sandbox?(path)
61
- File.directory?(path) && File.basename(path).start_with?(PREFIX) &&
82
+ File.directory?(path) && File.basename(path).start_with?(@prefix) &&
62
83
  File.dirname(path) == @tmp_parent
63
84
  end
64
85
 
86
+ # A symlink is copied as a symlink. Where that is not allowed (Windows without the symlink privilege), what it
87
+ # points to is copied instead; a dangling link is skipped.
88
+ def link(src, dest)
89
+ File.symlink(File.readlink(src), dest)
90
+ rescue NotImplementedError, Errno::EPERM, Errno::EACCES
91
+ FileUtils.cp_r(src, dest) if File.exist?(src) # cp_r follows a symlink given as its source
92
+ end
93
+
65
94
  def copy_tree
66
95
  FileUtils.mkdir_p(@dir)
67
96
  Find.find(@root) do |src|
@@ -72,7 +101,7 @@ module Mutaruby
72
101
  dest = File.join(@dir, rel)
73
102
  st = File.lstat(src)
74
103
  if st.symlink?
75
- File.symlink(File.readlink(src), dest)
104
+ link(src, dest)
76
105
  elsif st.directory?
77
106
  FileUtils.mkdir_p(dest)
78
107
  elsif st.file? # not sockets, FIFOs or devices: copying a FIFO would block
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mutaruby
4
- VERSION = '0.1.1'
4
+ VERSION = '0.2.0'
5
5
  end
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.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Atlas Authorization
@@ -51,6 +51,7 @@ files:
51
51
  - lib/mutaruby/ignore_file.rb
52
52
  - lib/mutaruby/mutation.rb
53
53
  - lib/mutaruby/parsing.rb
54
+ - lib/mutaruby/platform.rb
54
55
  - lib/mutaruby/reporters.rb
55
56
  - lib/mutaruby/runner.rb
56
57
  - lib/mutaruby/sandbox.rb